From f2a64324a154a2e6ed41e787319dc1b56c368db9 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sun, 19 Jan 2025 19:26:24 +0100 Subject: [PATCH] feat: Take mocha configuration from package.json --- package.json | 1 + src/configurationFile.ts | 46 +++++++++ src/constants.ts | 2 +- src/controller.ts | 87 +++++++++++------ .../integration/overlapping-tests.test.ts | 2 +- src/test/integration/package-json.test.ts | 96 +++++++++++++++++++ src/test/integration/simple.test.ts | 14 +-- src/test/integration/source-mapped.test.ts | 2 +- src/test/integration/typescript.test.ts | 2 +- src/test/util.ts | 36 +++++-- src/workspaceWatcher.ts | 24 +++-- .../package-json/folder/nested.test.js | 1 + test-workspaces/package-json/hello.test.js | 14 +++ test-workspaces/package-json/package.json | 5 + 14 files changed, 271 insertions(+), 61 deletions(-) create mode 100644 src/test/integration/package-json.test.ts create mode 100644 test-workspaces/package-json/folder/nested.test.js create mode 100644 test-workspaces/package-json/hello.test.js create mode 100644 test-workspaces/package-json/package.json diff --git a/package.json b/package.json index 236fc56..c4301ae 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ }, "activationEvents": [ "workspaceContains:**/.mocharc.{js,cjs,yaml,yml,json,jsonc}", + "workspaceContains:**/package.json", "onCommand:mocha-vscode.getControllersForTest" ], "repository": { diff --git a/src/configurationFile.ts b/src/configurationFile.ts index b196dba..26318c5 100644 --- a/src/configurationFile.ts +++ b/src/configurationFile.ts @@ -34,7 +34,9 @@ export class ConfigurationFile implements vscode.Disposable { private readonly ds = new DisposableStore(); private readonly didDeleteEmitter = this.ds.add(new vscode.EventEmitter()); private readonly didChangeEmitter = this.ds.add(new vscode.EventEmitter()); + private readonly activateEmitter = this.ds.add(new vscode.EventEmitter()); + private _activateFired: boolean = false; private _resolver?: resolveModule.Resolver; private _optionsModule?: OptionsModule; private _configModule?: ConfigModule; @@ -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, @@ -65,6 +73,7 @@ export class ConfigurationFile implements vscode.Disposable { changeDebounce = undefined; this.readPromise = undefined; this.didChangeEmitter.fire(); + this.tryActivate(); }, 300); }), ); @@ -77,6 +86,43 @@ export class ConfigurationFile implements vscode.Disposable { ); } + public get isActive() { + return this._activateFired; + } + + public async tryActivate(): Promise { + 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 diff --git a/src/constants.ts b/src/constants.ts index b35c7f7..9d561f0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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'], diff --git a/src/controller.ts b/src/controller.ts index c1f12cb..264002c 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -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), @@ -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, @@ -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; } @@ -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 ', @@ -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, @@ -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) { @@ -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; } @@ -352,7 +367,7 @@ export class Controller { if (deleteFrom) { deleteFrom.items.delete(deleteFrom.id); } else { - last!.delete(id); + last?.delete(id); } } @@ -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'; @@ -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; @@ -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); } } diff --git a/src/test/integration/overlapping-tests.test.ts b/src/test/integration/overlapping-tests.test.ts index ba3756e..8714fef 100644 --- a/src/test/integration/overlapping-tests.test.ts +++ b/src/test/integration/overlapping-tests.test.ts @@ -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, diff --git a/src/test/integration/package-json.test.ts b/src/test/integration/package-json.test.ts new file mode 100644 index 0000000..cf56ffd --- /dev/null +++ b/src/test/integration/package-json.test.ts @@ -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']]]]], + ]); + }); +}); diff --git a/src/test/integration/simple.test.ts b/src/test/integration/simple.test.ts index 0be3996..ed8c692 100644 --- a/src/test/integration/simple.test.ts +++ b/src/test/integration/simple.test.ts @@ -151,7 +151,7 @@ describe('simple', () => { const run = await captureTestRun( c, new vscode.TestRunRequest( - [c.ctrl.items.get('folder')!], + [c.ctrl!.items.get('folder')!], undefined, c.profiles.find((p) => p.kind === vscode.TestRunProfileKind.Run), ), @@ -167,7 +167,7 @@ describe('simple', () => { const run = await captureTestRun( c, new vscode.TestRunRequest( - [c.ctrl.items.get('hello.test.js')!], + [c.ctrl!.items.get('hello.test.js')!], undefined, c.profiles.find((p) => p.kind === vscode.TestRunProfileKind.Run), ), @@ -185,7 +185,7 @@ describe('simple', () => { const run = await captureTestRun( c, new vscode.TestRunRequest( - [c.ctrl.items.get('hello.test.js')!], + [c.ctrl!.items.get('hello.test.js')!], undefined, c.profiles.find((p) => p.kind === vscode.TestRunProfileKind.Debug), ), @@ -203,7 +203,7 @@ describe('simple', () => { const run = await captureTestRun( c, new vscode.TestRunRequest( - [c.ctrl.items.get('hello.test.js')!.children.get('math')!.children.get('addition')!], + [c.ctrl!.items.get('hello.test.js')!.children.get('math')!.children.get('addition')!], undefined, c.profiles.find((p) => p.kind === vscode.TestRunProfileKind.Run), ), @@ -219,7 +219,7 @@ describe('simple', () => { const run = await captureTestRun( c, new vscode.TestRunRequest( - [c.ctrl.items.get('hello.test.js')!.children.get('math')!.children.get('failing')!], + [c.ctrl!.items.get('hello.test.js')!.children.get('math')!.children.get('failing')!], undefined, c.profiles.find((p) => p.kind === vscode.TestRunProfileKind.Run), ), @@ -245,7 +245,7 @@ describe('simple', () => { c, new vscode.TestRunRequest( undefined, - [c.ctrl.items.get('hello.test.js')!, c.ctrl.items.get('folder')!], + [c.ctrl!.items.get('hello.test.js')!, c.ctrl!.items.get('folder')!], c.profiles.find((p) => p.kind === vscode.TestRunProfileKind.Run), ), ); @@ -299,7 +299,7 @@ describe('simple', () => { const profiles = c.profiles; expect(profiles).to.have.lengthOf(2); - const item = c.ctrl.items.get('stacktrace.test.js')!; + const item = c.ctrl!.items.get('stacktrace.test.js')!; const run = await captureTestRun( c, new vscode.TestRunRequest( diff --git a/src/test/integration/source-mapped.test.ts b/src/test/integration/source-mapped.test.ts index 26954e5..3c767bd 100644 --- a/src/test/integration/source-mapped.test.ts +++ b/src/test/integration/source-mapped.test.ts @@ -22,7 +22,7 @@ describe('source mapped', () => { it('has correct test locations', async () => { const c = await getController(); - const src = extractParsedNodes(c.ctrl.items); + const src = extractParsedNodes(c.ctrl!.items); expect(src).to.deep.equal([ { name: 'hello.test.ts', diff --git a/src/test/integration/typescript.test.ts b/src/test/integration/typescript.test.ts index 4990512..75aef7d 100644 --- a/src/test/integration/typescript.test.ts +++ b/src/test/integration/typescript.test.ts @@ -68,7 +68,7 @@ describe('typescript', () => { const run = await captureTestRun( c, new vscode.TestRunRequest( - [c.ctrl.items.get('hello.test.ts')!.children.get('math')!.children.get('failing')!], + [c.ctrl!.items.get('hello.test.ts')!.children.get('math')!.children.get('failing')!], undefined, c.profiles.find((p) => p.kind === vscode.TestRunProfileKind.Run), ), diff --git a/src/test/util.ts b/src/test/util.ts index d48964f..1d5118a 100644 --- a/src/test/util.ts +++ b/src/test/util.ts @@ -97,23 +97,39 @@ async function backupWorkspace(source: string) { } export async function getController(scan: boolean = true) { - const c = await tryGetController(scan); - if (!c) { - throw new Error('no controllers registered'); + for (let retry = 0; retry < 3; retry++) { + const c = await tryGetController(scan); + if (c) { + return c; + } + + await setTimeout(1000); } - return c; + + throw new Error('no controllers registered'); } export async function tryGetController(scan: boolean = true) { - const c = await vscode.commands.executeCommand(getControllersForTestCommand); + const controllers = await vscode.commands.executeCommand( + getControllersForTestCommand, + ); - if (!c || !c.length) { + if (!controllers || !controllers.length) { return undefined; } - const controller = c[0]; - if (scan) { + let controller: Controller | undefined = undefined; + + for (const c of controllers) { + if (await c.tryActivate()) { + controller = c; + break; + } + } + + if (controller && scan) { await controller.scanFiles(); } + return controller; } @@ -199,7 +215,7 @@ export function onceScanComplete(controller: Controller, timeout: number = 10000 export function buildTestTreeExpectation({ ctrl }: Controller) { const e = ['root', []] satisfies TestTreeExpectation; - buildTreeExpectation(e, ctrl.items); + buildTreeExpectation(e, ctrl!.items); return e[1]; } @@ -309,7 +325,7 @@ export class FakeTestRun implements vscode.TestRun { export async function captureTestRun(ctrl: Controller, req: vscode.TestRunRequest) { const fake = new FakeTestRun(); - const createTestRun = sinon.stub(ctrl.ctrl, 'createTestRun').returns(fake); + const createTestRun = sinon.stub(ctrl.ctrl!, 'createTestRun').returns(fake); try { await req.profile!.runHandler(req, new vscode.CancellationTokenSource().token); return fake; diff --git a/src/workspaceWatcher.ts b/src/workspaceWatcher.ts index e013cdf..db98d5c 100644 --- a/src/workspaceWatcher.ts +++ b/src/workspaceWatcher.ts @@ -9,7 +9,7 @@ import { minimatch } from 'minimatch'; import * as vscode from 'vscode'; -import { configFilePattern } from './constants'; +import { configFilePatterns } from './constants'; import { Controller } from './controller'; import { DisposableStore } from './disposable'; import { TestRunner } from './runner'; @@ -37,23 +37,27 @@ export class WorkspaceFolderWatcher { this.disposables.add(watcher); watcher.onDidCreate((uri) => { - if (minimatch(uri.fsPath.replace(/\\/g, '/'), configFilePattern)) { - this.addConfigFile(uri); + for (const pattern of configFilePatterns) { + if (minimatch(uri.fsPath.replace(/\\/g, '/'), pattern)) { + this.addConfigFile(uri); + return; + } } }); watcher.onDidDelete((uri) => { this.removeConfigFile(uri); }); - const files = await vscode.workspace.findFiles( - new vscode.RelativePattern(this.folder, configFilePattern), - '**/node_modules/**', - ); - this.logChannel.debug('Checking workspace folder for config files', this.folder); + for (const configFilePattern of configFilePatterns) { + const files = await vscode.workspace.findFiles( + new vscode.RelativePattern(this.folder, configFilePattern), + '**/node_modules/**', + ); - for (const file of files) { - this.addConfigFile(file); + for (const file of files) { + this.addConfigFile(file); + } } } diff --git a/test-workspaces/package-json/folder/nested.test.js b/test-workspaces/package-json/folder/nested.test.js new file mode 100644 index 0000000..4bca548 --- /dev/null +++ b/test-workspaces/package-json/folder/nested.test.js @@ -0,0 +1 @@ +it('is nested', async () => {}); diff --git a/test-workspaces/package-json/hello.test.js b/test-workspaces/package-json/hello.test.js new file mode 100644 index 0000000..95ae058 --- /dev/null +++ b/test-workspaces/package-json/hello.test.js @@ -0,0 +1,14 @@ +const { strictEqual } = require('node:assert'); + +describe('math', () => { + it('addition', async () => { + strictEqual(1 + 1, 2); + }); + + it('subtraction', async () => { + strictEqual(1 - 1, 0); + }); + it('failing', async () => { + strictEqual(1 * 1, 0); + }); +}); diff --git a/test-workspaces/package-json/package.json b/test-workspaces/package-json/package.json new file mode 100644 index 0000000..42ad452 --- /dev/null +++ b/test-workspaces/package-json/package.json @@ -0,0 +1,5 @@ +{ + "mocha": { + "spec": "**/*.test.js" + } +} \ No newline at end of file