Skip to content

Commit 82894d3

Browse files
authored
feat: Take mocha configuration from package.json (#230)
Resolves #171
1 parent 1a63ba8 commit 82894d3

16 files changed

+323
-83
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
},
113113
"activationEvents": [
114114
"workspaceContains:**/.mocharc.{js,cjs,yaml,yml,json,jsonc}",
115+
"workspaceContains:**/package.json",
115116
"onCommand:mocha-vscode.getControllersForTest"
116117
],
117118
"repository": {

src/configurationFile.ts

+46
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ export class ConfigurationFile implements vscode.Disposable {
3434
private readonly ds = new DisposableStore();
3535
private readonly didDeleteEmitter = this.ds.add(new vscode.EventEmitter<void>());
3636
private readonly didChangeEmitter = this.ds.add(new vscode.EventEmitter<void>());
37+
private readonly activateEmitter = this.ds.add(new vscode.EventEmitter<void>());
3738

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

54+
/**
55+
* Fired the config file becomes active for actually handling tests
56+
* (e.g. not fired on package.json without mocha section).
57+
*/
58+
public readonly onActivate = this.activateEmitter.event;
59+
5260
constructor(
5361
private readonly logChannel: vscode.LogOutputChannel,
5462
public readonly uri: vscode.Uri,
@@ -65,6 +73,7 @@ export class ConfigurationFile implements vscode.Disposable {
6573
changeDebounce = undefined;
6674
this.readPromise = undefined;
6775
this.didChangeEmitter.fire();
76+
this.tryActivate();
6877
}, 300);
6978
}),
7079
);
@@ -77,6 +86,43 @@ export class ConfigurationFile implements vscode.Disposable {
7786
);
7887
}
7988

89+
public get isActive() {
90+
return this._activateFired;
91+
}
92+
93+
public async tryActivate(): Promise<boolean> {
94+
if (this._activateFired) {
95+
return true;
96+
}
97+
98+
const configFile = path.basename(this.uri.fsPath).toLowerCase();
99+
if (configFile === 'package.json') {
100+
try {
101+
const packageJson = JSON.parse(await fs.promises.readFile(this.uri.fsPath, 'utf-8'));
102+
if ('mocha' in packageJson && typeof packageJson.mocha !== 'undefined') {
103+
this.logChannel.trace('Found mocha section in package.config, skipping activation');
104+
this.activateEmitter.fire();
105+
this._activateFired = true;
106+
return true;
107+
} else {
108+
this.logChannel.trace('No mocha section in package.config, skipping activation');
109+
}
110+
} catch (e) {
111+
this.logChannel.warn(
112+
'Error while reading mocha options from package.config, skipping activation',
113+
e,
114+
);
115+
}
116+
} else {
117+
// for normal mocharc files directly activate
118+
this.activateEmitter.fire();
119+
this._activateFired = true;
120+
return true;
121+
}
122+
123+
return false;
124+
}
125+
80126
/**
81127
* Reads the config file from disk.
82128
* @throws {HumanError} if anything goes wrong

src/constants.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import path from 'path';
1111
import type { IExtensionSettings } from './discoverer/types';
1212

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

1616
export const defaultTestSymbols: IExtensionSettings = {
1717
suite: ['describe', 'suite'],
@@ -23,6 +23,7 @@ export const defaultTestSymbols: IExtensionSettings = {
2323

2424
export const showConfigErrorCommand = 'mocha-vscode.showConfigError';
2525
export const getControllersForTestCommand = 'mocha-vscode.getControllersForTest';
26+
export const recreateControllersForTestCommand = 'mocha-vscode.recreateControllersForTestCommand';
2627

2728
function equalsIgnoreCase(a: string, b: string) {
2829
return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0;

src/controller.ts

+62-31
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export class Controller {
7777
*/
7878
private currentConfig?: ConfigurationList;
7979

80-
private discoverer!: SettingsBasedFallbackTestDiscoverer;
80+
private discoverer?: SettingsBasedFallbackTestDiscoverer;
8181

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

110-
public ctrl: vscode.TestController;
110+
public ctrl?: vscode.TestController;
111111

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

117+
public tryActivate() {
118+
return this.configFile.tryActivate();
119+
}
120+
117121
constructor(
118122
private readonly logChannel: vscode.LogOutputChannel,
119123
private readonly wf: vscode.WorkspaceFolder,
@@ -126,34 +130,49 @@ export class Controller {
126130
wf.uri.fsPath,
127131
configFileUri.fsPath,
128132
);
129-
const ctrl = (this.ctrl = vscode.tests.createTestController(
130-
configFileUri.toString(),
131-
configFileUri.fsPath,
132-
));
133-
this.disposables.add(ctrl);
134133
this.configFile = this.disposables.add(new ConfigurationFile(logChannel, configFileUri, wf));
135134

136-
this.recreateDiscoverer();
135+
this.disposables.add(
136+
this.configFile.onActivate(() => {
137+
try {
138+
const ctrl = (this.ctrl = vscode.tests.createTestController(
139+
configFileUri.toString(),
140+
configFileUri.fsPath,
141+
));
142+
this.disposables.add(ctrl);
143+
144+
this.recreateDiscoverer();
145+
const rescan = async (reason: string) => {
146+
try {
147+
logChannel.info(`Rescan of tests triggered (${reason}) - ${this.configFile.uri}}`);
148+
this.recreateDiscoverer();
149+
await this.scanFiles();
150+
} catch (e) {
151+
this.logChannel.error(e as Error, 'Failed to rescan tests');
152+
}
153+
};
154+
this.disposables.add(this.configFile.onDidChange(() => rescan('mocharc changed')));
155+
this.disposables.add(this.settings.onDidChange(() => rescan('settings changed')));
156+
ctrl.refreshHandler = () => {
157+
this.configFile.forget();
158+
rescan('user');
159+
};
160+
this.scanFiles();
161+
} catch (e) {
162+
this.logChannel.error(e as Error);
163+
}
164+
}),
165+
);
137166

138-
const rescan = async (reason: string) => {
139-
try {
140-
logChannel.info(`Rescan of tests triggered (${reason}) - ${this.configFile.uri}}`);
141-
this.recreateDiscoverer();
142-
await this.scanFiles();
143-
} catch (e) {
144-
this.logChannel.error(e as Error, 'Failed to rescan tests');
145-
}
146-
};
147-
this.disposables.add(this.configFile.onDidChange(() => rescan('mocharc changed')));
148-
this.disposables.add(this.settings.onDidChange(() => rescan('settings changed')));
149-
ctrl.refreshHandler = () => {
150-
this.configFile.forget();
151-
rescan('user');
152-
};
153-
this.scanFiles();
167+
this.configFile.tryActivate();
154168
}
155169

156170
recreateDiscoverer(newTsConfig: boolean = true) {
171+
if (!this.ctrl) {
172+
this.logChannel.trace('Skipping discoverer recreation, mocha is not active in this project.');
173+
return;
174+
}
175+
157176
if (!this.tsconfigStore) {
158177
newTsConfig = true;
159178
}
@@ -209,7 +228,7 @@ export class Controller {
209228

210229
let tree: IParsedNode[];
211230
try {
212-
tree = await this.discoverer.discover(uri.fsPath, contents);
231+
tree = await this.discoverer!.discover(uri.fsPath, contents);
213232
} catch (e) {
214233
this.logChannel.error(
215234
'Error while test extracting ',
@@ -242,7 +261,7 @@ export class Controller {
242261
): vscode.TestItem => {
243262
let item = parent.children.get(node.name);
244263
if (!item) {
245-
item = this.ctrl.createTestItem(node.name, node.name, start.uri);
264+
item = this.ctrl!.createTestItem(node.name, node.name, start.uri);
246265
counter.add(node.kind);
247266
testMetadata.set(item, {
248267
type: node.kind === NodeKind.Suite ? ItemType.Suite : ItemType.Test,
@@ -305,7 +324,7 @@ export class Controller {
305324
for (const [id, test] of previous.items) {
306325
if (!newTestsInFile.has(id)) {
307326
const meta = testMetadata.get(test);
308-
(test.parent?.children ?? this.ctrl.items).delete(id);
327+
(test.parent?.children ?? this.ctrl!.items).delete(id);
309328
if (meta?.type === ItemType.Test) {
310329
counter.remove(NodeKind.Test);
311330
} else if (meta?.type === ItemType.Suite) {
@@ -337,7 +356,7 @@ export class Controller {
337356
let last: vscode.TestItemCollection | undefined;
338357
for (const { children, item } of itemsIt) {
339358
if (item && children.size === 1) {
340-
deleteFrom ??= { items: last || this.ctrl.items, id: item.id };
359+
deleteFrom ??= { items: last || this.ctrl!.items, id: item.id };
341360
} else {
342361
deleteFrom = undefined;
343362
}
@@ -352,7 +371,7 @@ export class Controller {
352371
if (deleteFrom) {
353372
deleteFrom.items.delete(deleteFrom.id);
354373
} else {
355-
last!.delete(id);
374+
last?.delete(id);
356375
}
357376
}
358377

@@ -384,18 +403,22 @@ export class Controller {
384403
for (const key of this.testsInFiles.keys()) {
385404
this.deleteFileTests(key);
386405
}
387-
const item = (this.errorItem = this.ctrl.createTestItem('error', 'Extension Test Error'));
406+
const item = (this.errorItem = this.ctrl!.createTestItem('error', 'Extension Test Error'));
388407
item.error = new vscode.MarkdownString(
389408
`[View details](command:${showConfigErrorCommand}?${encodeURIComponent(
390409
JSON.stringify([this.configFile.uri.toString()]),
391410
)})`,
392411
);
393412
item.error.isTrusted = true;
394-
this.ctrl.items.add(item);
413+
this.ctrl!.items.add(item);
395414
}
396415

397416
/** Creates run profiles for each configuration in the extension tests */
398417
private applyRunHandlers() {
418+
if (!this.ctrl) {
419+
return;
420+
}
421+
399422
const oldRunHandlers = this.runProfiles;
400423
this.runProfiles = new Map();
401424
const originalName = 'Mocha Config';
@@ -446,6 +469,11 @@ export class Controller {
446469
}
447470

448471
public async scanFiles() {
472+
if (!this.ctrl) {
473+
this.logChannel.trace('Skipping file scan, mocha is not active in this project.');
474+
return;
475+
}
476+
449477
if (this.errorItem) {
450478
this.ctrl.items.delete(this.errorItem.id);
451479
this.errorItem = undefined;
@@ -502,6 +530,9 @@ export class Controller {
502530

503531
/** Gets the test collection for a file of the given URI, descending from the root. */
504532
private getContainingItemsForFile(uri: vscode.Uri, createOpts?: ICreateOpts) {
533+
if (!this.ctrl) {
534+
return [];
535+
}
505536
return getContainingItemsForFile(this.configFile.uri, this.ctrl, uri, createOpts);
506537
}
507538
}

src/extension.ts

+23-9
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as timers from 'timers/promises';
1111
import * as vscode from 'vscode';
1212
import { ConfigValue } from './configValue';
1313
import { ConsoleOuputChannel } from './consoleLogChannel';
14-
import { getControllersForTestCommand } from './constants';
14+
import { getControllersForTestCommand, recreateControllersForTestCommand } from './constants';
1515
import { initESBuild } from './esbuild';
1616
import { TestRunner } from './runner';
1717
import { SourceMapStore } from './source-map-store';
@@ -103,9 +103,7 @@ export function activate(context: vscode.ExtensionContext) {
103103
}
104104
};
105105

106-
const initialSync = (async () => {
107-
await initESBuild(context, logChannel);
108-
106+
async function syncWorkspaceFoldersWithRetry() {
109107
// Workaround for vscode#179203 where findFiles doesn't work on startup.
110108
// This extension is only activated on workspaceContains, so we have pretty
111109
// high confidence that we should find something.
@@ -118,15 +116,31 @@ export function activate(context: vscode.ExtensionContext) {
118116

119117
await timers.setTimeout(1000);
120118
}
119+
}
120+
121+
const initialSync = (async () => {
122+
await initESBuild(context, logChannel);
123+
await syncWorkspaceFoldersWithRetry();
121124
})();
122125

123126
context.subscriptions.push(
124127
vscode.workspace.onDidChangeWorkspaceFolders(syncWorkspaceFolders),
125-
vscode.commands.registerCommand(getControllersForTestCommand, () =>
126-
initialSync.then(() =>
127-
Array.from(watchers.values()).flatMap((w) => Array.from(w.controllers.values())),
128-
),
129-
),
128+
vscode.commands.registerCommand(getControllersForTestCommand, async () => {
129+
await initialSync;
130+
return Array.from(watchers.values()).flatMap((w) => Array.from(w.controllers.values()));
131+
}),
132+
vscode.commands.registerCommand(recreateControllersForTestCommand, async () => {
133+
logChannel.debug('Destroying all watchers and test controllers');
134+
for (const [, watcher] of watchers) {
135+
watcher.dispose();
136+
}
137+
watchers.clear();
138+
resyncState = FolderSyncState.Idle;
139+
140+
logChannel.debug('Destroyed controllers, recreating');
141+
await syncWorkspaceFoldersWithRetry();
142+
return Array.from(watchers.values()).flatMap((w) => Array.from(w.controllers.values()));
143+
}),
130144
new vscode.Disposable(() => watchers.forEach((c) => c.dispose())),
131145
logChannel,
132146
);

src/test/integration/config-file-change.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ describe('config-file-change', () => {
6161

6262
// scan and test results
6363
expect(c).to.not.be.undefined;
64-
c!.scanFiles();
64+
await c!.scanFiles();
6565
expectTestTree(c!, [
6666
['folder', [['nested.test.js', [['is nested']]]]],
6767
['hello.test.js', [['math', [['addition'], ['subtraction']]]]],
@@ -100,7 +100,7 @@ describe('config-file-change', () => {
100100

101101
// scan and test results
102102
expect(c).to.not.be.undefined;
103-
c!.scanFiles();
103+
await c!.scanFiles();
104104
expectTestTree(c!, [['hello.test.js', [['math', [['addition'], ['subtraction']]]]]]);
105105
});
106106
});

src/test/integration/overlapping-tests.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ describe('overlapping tests', () => {
147147
const profiles = c.profiles;
148148
expect(profiles).to.have.lengthOf(2);
149149

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

152152
const run = await captureTestRun(
153153
c,

0 commit comments

Comments
 (0)