Skip to content

Commit

Permalink
feat: Take mocha configuration from package.json
Browse files Browse the repository at this point in the history
  • Loading branch information
Danielku15 committed Jan 19, 2025
1 parent 1a63ba8 commit f2a6432
Show file tree
Hide file tree
Showing 14 changed files with 271 additions and 61 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
},
"activationEvents": [
"workspaceContains:**/.mocharc.{js,cjs,yaml,yml,json,jsonc}",
"workspaceContains:**/package.json",
"onCommand:mocha-vscode.getControllersForTest"
],
"repository": {
Expand Down
46 changes: 46 additions & 0 deletions src/configurationFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export class ConfigurationFile implements vscode.Disposable {
private readonly ds = new DisposableStore();
private readonly didDeleteEmitter = this.ds.add(new vscode.EventEmitter<void>());
private readonly didChangeEmitter = this.ds.add(new vscode.EventEmitter<void>());
private readonly activateEmitter = this.ds.add(new vscode.EventEmitter<void>());

private _activateFired: boolean = false;
private _resolver?: resolveModule.Resolver;
private _optionsModule?: OptionsModule;
private _configModule?: ConfigModule;
Expand All @@ -49,6 +51,12 @@ export class ConfigurationFile implements vscode.Disposable {
/** Fired when the file changes. */
public readonly onDidChange = this.didChangeEmitter.event;

/**
* Fired the config file becomes active for actually handling tests
* (e.g. not fired on package.json without mocha section).
*/
public readonly onActivate = this.activateEmitter.event;

constructor(
private readonly logChannel: vscode.LogOutputChannel,
public readonly uri: vscode.Uri,
Expand All @@ -65,6 +73,7 @@ export class ConfigurationFile implements vscode.Disposable {
changeDebounce = undefined;
this.readPromise = undefined;
this.didChangeEmitter.fire();
this.tryActivate();
}, 300);
}),
);
Expand All @@ -77,6 +86,43 @@ export class ConfigurationFile implements vscode.Disposable {
);
}

public get isActive() {
return this._activateFired;
}

public async tryActivate(): Promise<boolean> {
if (this._activateFired) {
return true;
}

const configFile = path.basename(this.uri.fsPath).toLowerCase();
if (configFile === 'package.json') {
try {
const packageJson = JSON.parse(await fs.promises.readFile(this.uri.fsPath, 'utf-8'));
if ('mocha' in packageJson && typeof packageJson.mocha !== 'undefined') {
this.logChannel.trace('Found mocha section in package.config, skipping activation');
this.activateEmitter.fire();
this._activateFired = true;
return true;
} else {
this.logChannel.trace('No mocha section in package.config, skipping activation');
}
} catch (e) {
this.logChannel.warn(
'Error while reading mocha options from package.config, skipping activation',
e,
);
}
} else {
// for normal mocharc files directly activate
this.activateEmitter.fire();
this._activateFired = true;
return true;
}

return false;
}

/**
* Reads the config file from disk.
* @throws {HumanError} if anything goes wrong
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import path from 'path';
import type { IExtensionSettings } from './discoverer/types';

/** Pattern of files the CLI looks for */
export const configFilePattern = '**/.mocharc.{js,cjs,yaml,yml,json,jsonc}';
export const configFilePatterns = ['**/.mocharc.{js,cjs,yaml,yml,json,jsonc}', '**/package.json'];

export const defaultTestSymbols: IExtensionSettings = {
suite: ['describe', 'suite'],
Expand Down
87 changes: 57 additions & 30 deletions src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class Controller {
*/
private currentConfig?: ConfigurationList;

private discoverer!: SettingsBasedFallbackTestDiscoverer;
private discoverer?: SettingsBasedFallbackTestDiscoverer;

public readonly settings = this.disposables.add(
new ConfigValue('extractSettings', defaultTestSymbols),
Expand Down Expand Up @@ -107,13 +107,17 @@ export class Controller {
public readonly onDidDispose = this.disposeEmitter.event;
private tsconfigStore?: TsConfigStore;

public ctrl: vscode.TestController;
public ctrl?: vscode.TestController;

/** Gets run profiles the controller has registerd. */
public get profiles() {
return [...this.runProfiles.values()].flat();
}

public tryActivate() {
return this.configFile.tryActivate();
}

constructor(
private readonly logChannel: vscode.LogOutputChannel,
private readonly wf: vscode.WorkspaceFolder,
Expand All @@ -126,34 +130,45 @@ export class Controller {
wf.uri.fsPath,
configFileUri.fsPath,
);
const ctrl = (this.ctrl = vscode.tests.createTestController(
configFileUri.toString(),
configFileUri.fsPath,
));
this.disposables.add(ctrl);
this.configFile = this.disposables.add(new ConfigurationFile(logChannel, configFileUri, wf));

this.recreateDiscoverer();
this.disposables.add(
this.configFile.onActivate(() => {
const ctrl = (this.ctrl = vscode.tests.createTestController(
configFileUri.toString(),
configFileUri.fsPath,
));
this.disposables.add(ctrl);

const rescan = async (reason: string) => {
try {
logChannel.info(`Rescan of tests triggered (${reason}) - ${this.configFile.uri}}`);
this.recreateDiscoverer();
await this.scanFiles();
} catch (e) {
this.logChannel.error(e as Error, 'Failed to rescan tests');
}
};
this.disposables.add(this.configFile.onDidChange(() => rescan('mocharc changed')));
this.disposables.add(this.settings.onDidChange(() => rescan('settings changed')));
ctrl.refreshHandler = () => {
this.configFile.forget();
rescan('user');
};
this.scanFiles();
const rescan = async (reason: string) => {
try {
logChannel.info(`Rescan of tests triggered (${reason}) - ${this.configFile.uri}}`);
this.recreateDiscoverer();
await this.scanFiles();
} catch (e) {
this.logChannel.error(e as Error, 'Failed to rescan tests');
}
};
this.disposables.add(this.configFile.onDidChange(() => rescan('mocharc changed')));
this.disposables.add(this.settings.onDidChange(() => rescan('settings changed')));
ctrl.refreshHandler = () => {
this.configFile.forget();
rescan('user');
};
this.scanFiles();
}),
);

this.configFile.tryActivate();
}

recreateDiscoverer(newTsConfig: boolean = true) {
if (!this.ctrl) {
this.logChannel.trace('Skipping discoverer recreation, mocha is not active in this project.');
return;
}

if (!this.tsconfigStore) {
newTsConfig = true;
}
Expand Down Expand Up @@ -209,7 +224,7 @@ export class Controller {

let tree: IParsedNode[];
try {
tree = await this.discoverer.discover(uri.fsPath, contents);
tree = await this.discoverer!.discover(uri.fsPath, contents);
} catch (e) {
this.logChannel.error(
'Error while test extracting ',
Expand Down Expand Up @@ -242,7 +257,7 @@ export class Controller {
): vscode.TestItem => {
let item = parent.children.get(node.name);
if (!item) {
item = this.ctrl.createTestItem(node.name, node.name, start.uri);
item = this.ctrl!.createTestItem(node.name, node.name, start.uri);
counter.add(node.kind);
testMetadata.set(item, {
type: node.kind === NodeKind.Suite ? ItemType.Suite : ItemType.Test,
Expand Down Expand Up @@ -305,7 +320,7 @@ export class Controller {
for (const [id, test] of previous.items) {
if (!newTestsInFile.has(id)) {
const meta = testMetadata.get(test);
(test.parent?.children ?? this.ctrl.items).delete(id);
(test.parent?.children ?? this.ctrl!.items).delete(id);
if (meta?.type === ItemType.Test) {
counter.remove(NodeKind.Test);
} else if (meta?.type === ItemType.Suite) {
Expand Down Expand Up @@ -337,7 +352,7 @@ export class Controller {
let last: vscode.TestItemCollection | undefined;
for (const { children, item } of itemsIt) {
if (item && children.size === 1) {
deleteFrom ??= { items: last || this.ctrl.items, id: item.id };
deleteFrom ??= { items: last || this.ctrl!.items, id: item.id };
} else {
deleteFrom = undefined;
}
Expand All @@ -352,7 +367,7 @@ export class Controller {
if (deleteFrom) {
deleteFrom.items.delete(deleteFrom.id);
} else {
last!.delete(id);
last?.delete(id);
}
}

Expand Down Expand Up @@ -384,18 +399,22 @@ export class Controller {
for (const key of this.testsInFiles.keys()) {
this.deleteFileTests(key);
}
const item = (this.errorItem = this.ctrl.createTestItem('error', 'Extension Test Error'));
const item = (this.errorItem = this.ctrl!.createTestItem('error', 'Extension Test Error'));
item.error = new vscode.MarkdownString(
`[View details](command:${showConfigErrorCommand}?${encodeURIComponent(
JSON.stringify([this.configFile.uri.toString()]),
)})`,
);
item.error.isTrusted = true;
this.ctrl.items.add(item);
this.ctrl!.items.add(item);
}

/** Creates run profiles for each configuration in the extension tests */
private applyRunHandlers() {
if (!this.ctrl) {
return;
}

const oldRunHandlers = this.runProfiles;
this.runProfiles = new Map();
const originalName = 'Mocha Config';
Expand Down Expand Up @@ -446,6 +465,11 @@ export class Controller {
}

public async scanFiles() {
if (!this.ctrl) {
this.logChannel.trace('Skipping file scan, mocha is not active in this project.');
return;
}

if (this.errorItem) {
this.ctrl.items.delete(this.errorItem.id);
this.errorItem = undefined;
Expand Down Expand Up @@ -502,6 +526,9 @@ export class Controller {

/** Gets the test collection for a file of the given URI, descending from the root. */
private getContainingItemsForFile(uri: vscode.Uri, createOpts?: ICreateOpts) {
if (!this.ctrl) {
return [];
}
return getContainingItemsForFile(this.configFile.uri, this.ctrl, uri, createOpts);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/test/integration/overlapping-tests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ describe('overlapping tests', () => {
const profiles = c.profiles;
expect(profiles).to.have.lengthOf(2);

const testItems = include.map((i) => findTestItem(c.ctrl.items, i)!);
const testItems = include.map((i) => findTestItem(c.ctrl!.items, i)!);

const run = await captureTestRun(
c,
Expand Down
96 changes: 96 additions & 0 deletions src/test/integration/package-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Copyright (C) Daniel Kuschny (Danielku15) and contributors.
* Copyright (C) Microsoft Corporation. All rights reserved.
*
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/

import { promises as fs } from 'fs';
import * as path from 'path';
import { setTimeout } from 'timers/promises';
import * as vscode from 'vscode';
import {
captureTestRun,
expectTestTree,
getController,
integrationTestPrepare,
onceScanComplete,
} from '../util';

describe('package-json', () => {
const workspaceFolder = integrationTestPrepare('package-json');

it('discovers tests', async () => {
const c = await getController();

expectTestTree(c, [
['folder', [['nested.test.js', [['is nested']]]]],
['hello.test.js', [['math', [['addition'], ['failing'], ['subtraction']]]]],
]);
});

it('runs tests in a file', async () => {
const c = await getController();
const run = await captureTestRun(
c,
new vscode.TestRunRequest(
[c.ctrl!.items.get('hello.test.js')!],
undefined,
c.profiles.find((p) => p.kind === vscode.TestRunProfileKind.Run),
),
);

run.expectStates({
'hello.test.js/math/addition': ['enqueued', 'started', 'passed'],
'hello.test.js/math/subtraction': ['enqueued', 'started', 'passed'],
'hello.test.js/math/failing': ['enqueued', 'started', 'failed'],
});
});

it('debugs tests in a file', async () => {
const c = await getController();
const run = await captureTestRun(
c,
new vscode.TestRunRequest(
[c.ctrl!.items.get('hello.test.js')!],
undefined,
c.profiles.find((p) => p.kind === vscode.TestRunProfileKind.Debug),
),
);

run.expectStates({
'hello.test.js/math/addition': ['enqueued', 'started', 'passed'],
'hello.test.js/math/subtraction': ['enqueued', 'started', 'passed'],
'hello.test.js/math/failing': ['enqueued', 'started', 'failed'],
});
});

it('handles changes to package.json', async () => {
const c = await getController();

expectTestTree(c, [
['folder', [['nested.test.js', [['is nested']]]]],
['hello.test.js', [['math', [['addition'], ['failing'], ['subtraction']]]]],
]);

const onChange = onceScanComplete(c);

const configPath = path.join(workspaceFolder, 'package.json');
const original = await fs.readFile(configPath, 'utf-8');
let updated = original.replace('**/*.test.js', '*.test.js');

// the vscode file watcher is set up async and does not always catch the change, keep changing the file
let ok: boolean | void = false;
while (!ok) {
updated += '\n';
await fs.writeFile(configPath, updated);
ok = await Promise.race([onChange.then(() => true), setTimeout(1000)]);
}

expectTestTree(c, [
['hello.test.js', [['math', [['addition'], ['failing'], ['subtraction']]]]],
]);
});
});
Loading

0 comments on commit f2a6432

Please sign in to comment.