diff --git a/package.json b/package.json index d785708..fb49f59 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,52 @@ } } }, + "nodejs-testing.testSpecifiers": { + "type": "array", + "markdownDescription": "_Advanced_: A list of specifiers that indicate test function to search for:\nIt defaults to:\n\n```json\n[\n {\n \"import\": \"node:test\",\n \"name\": [\"default\", \"it\", \"test\", \"describe\", \"suite\"]\n }\n]\n```\n\nBut in case your test function is wrapped, you can specify it with a relative import:\nNOTE: relative imports must be prefixed with ./\nNOTE: A `name` of \"default\" is special and means the default export of that module is a test function\n\n```json\n[\n {\n \"import\": \"./test/utils.js\",\n \"name\": \"test\"\n }\n]\n```\n", + "default": [ + { + "import": "node:test", + "name": [ + "default", + "it", + "test", + "describe", + "suite" + ] + } + ], + "items": { + "type": "object", + "default": { + "import": "node:test", + "name": [ + "default", + "it", + "test", + "describe", + "suite" + ] + }, + "required": [ + "import", + "name" + ], + "properties": { + "import": { + "type": "string", + "markdownDescription": "A package specifier (i.e. node:test) or workspace-relative path beginning with ./ (like ./test/utils.js) that indicates where the 'test' function can be imported from in your codebase" + }, + "name": { + "type": "array", + "markdownDescription": "A list of functions that are imported from `import` that should be treated as test functions, the special name 'default' refers to the default export of a module", + "items": { + "type": "string" + } + } + } + } + }, "nodejs-testing.envFile": { "type": "string", "markdownDescription": "Absolute path to a file containing environment variable definitions.\n\nNote: template parameters like ${workspaceFolder} will be resolved.", diff --git a/src/__snapshots__/parsing.test.ts.snap b/src/__snapshots__/parsing.test.ts.snap index 1a56985..2537b78 100644 --- a/src/__snapshots__/parsing.test.ts.snap +++ b/src/__snapshots__/parsing.test.ts.snap @@ -669,3 +669,309 @@ exports[`extract > works with string literals 1`] = ` }, ] `; + +exports[`extract tests with custom specifiers > extracts default import tests 1`] = ` +[ + { + "children": [ + { + "children": [], + "fn": "test", + "location": SourceLocation { + "end": Position { + "column": 4, + "line": 8, + }, + "start": Position { + "column": 2, + "line": 6, + }, + }, + "name": "nested test", + }, + ], + "fn": "test", + "location": SourceLocation { + "end": Position { + "column": 2, + "line": 9, + }, + "start": Position { + "column": 0, + "line": 4, + }, + }, + "name": "default import test", + }, +] +`; + +exports[`extract tests with custom specifiers > extracts named import tests 1`] = ` +[ + { + "children": [], + "fn": "wrappedTest", + "location": SourceLocation { + "end": Position { + "column": 6, + "line": 4, + }, + "start": Position { + "column": 4, + "line": 2, + }, + }, + "name": "addition", + }, +] +`; + +exports[`extract tests with custom specifiers > extracts renamed named import test 1`] = ` +[ + { + "children": [], + "fn": "wrappedTest", + "location": SourceLocation { + "end": Position { + "column": 6, + "line": 4, + }, + "start": Position { + "column": 4, + "line": 2, + }, + }, + "name": "addition", + }, +] +`; + +exports[`extract tests with custom specifiers > extracts renamed named import tests 1`] = ` +[ + { + "children": [], + "fn": "wrappedTest", + "location": SourceLocation { + "end": Position { + "column": 6, + "line": 4, + }, + "start": Position { + "column": 4, + "line": 2, + }, + }, + "name": "addition", + }, +] +`; + +exports[`extract tests with custom specifiers > extracts star import tests 1`] = ` +[ + { + "children": [ + { + "children": [], + "fn": "specialTest", + "location": SourceLocation { + "end": Position { + "column": 8, + "line": 10, + }, + "start": Position { + "column": 6, + "line": 8, + }, + }, + "name": "subtest", + }, + ], + "fn": "specialTest", + "location": SourceLocation { + "end": Position { + "column": 6, + "line": 11, + }, + "start": Position { + "column": 4, + "line": 2, + }, + }, + "name": "addition", + }, +] +`; + +exports[`extract with custom test specifiers in commonjs code > extracts aliased require 1`] = ` +[ + { + "children": [ + { + "children": [], + "fn": "it", + "location": SourceLocation { + "end": Position { + "column": 4, + "line": 7, + }, + "start": Position { + "column": 2, + "line": 5, + }, + }, + "name": "nested test", + }, + ], + "fn": "describe", + "location": SourceLocation { + "end": Position { + "column": 2, + "line": 8, + }, + "start": Position { + "column": 0, + "line": 3, + }, + }, + "name": "destructured test", + }, +] +`; + +exports[`extract with custom test specifiers in commonjs code > extracts default require tests 1`] = ` +[ + { + "children": [ + { + "children": [], + "fn": "test", + "location": SourceLocation { + "end": Position { + "column": 4, + "line": 7, + }, + "start": Position { + "column": 2, + "line": 5, + }, + }, + "name": "nested test", + }, + ], + "fn": "test", + "location": SourceLocation { + "end": Position { + "column": 2, + "line": 8, + }, + "start": Position { + "column": 0, + "line": 3, + }, + }, + "name": "default import test", + }, +] +`; + +exports[`extract with custom test specifiers in commonjs code > extracts destructed require 1`] = ` +[ + { + "children": [ + { + "children": [], + "fn": "it", + "location": SourceLocation { + "end": Position { + "column": 4, + "line": 7, + }, + "start": Position { + "column": 2, + "line": 5, + }, + }, + "name": "nested test", + }, + ], + "fn": "describe", + "location": SourceLocation { + "end": Position { + "column": 2, + "line": 8, + }, + "start": Position { + "column": 0, + "line": 3, + }, + }, + "name": "destructured test", + }, +] +`; + +exports[`extract with custom test specifiers in commonjs code > extracts ts import mangled 1`] = ` +[ + { + "children": [ + { + "children": [], + "fn": "it", + "location": SourceLocation { + "end": Position { + "column": 6, + "line": 8, + }, + "start": Position { + "column": 4, + "line": 6, + }, + }, + "name": "addition", + }, + { + "children": [], + "fn": "it", + "location": SourceLocation { + "end": Position { + "column": 6, + "line": 11, + }, + "start": Position { + "column": 4, + "line": 9, + }, + }, + "name": "addition", + }, + { + "children": [], + "fn": "it", + "location": SourceLocation { + "end": Position { + "column": 6, + "line": 14, + }, + "start": Position { + "column": 4, + "line": 12, + }, + }, + "name": "subtraction", + }, + ], + "fn": "describe", + "location": SourceLocation { + "end": Position { + "column": 2, + "line": 15, + }, + "start": Position { + "column": 0, + "line": 5, + }, + }, + "name": "math", + }, +] +`; diff --git a/src/controller.ts b/src/controller.ts index b369a0b..831cfe8 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -5,12 +5,16 @@ import picomatch from "picomatch"; import * as vscode from "vscode"; import { coverageContext } from "./coverage"; import { DisposableStore, MutableDisposable } from "./disposable"; +import { ExtensionConfig } from "./extension-config"; import { last } from "./iterable"; import { ICreateOpts, ItemType, getContainingItemsForFile, testMetadata } from "./metadata"; import { IParsedNode, parseSource } from "./parsing"; import { RunHandler, TestRunner } from "./runner"; import { ISourceMapMaintainer, SourceMapStore } from "./source-map-store"; -import { ExtensionConfig } from './extension-config'; +import { + fileMightHaveTests, + type TestFunctionSpecifierConfig, +} from "./test-function-specifier-config"; const diagnosticCollection = vscode.languages.createDiagnosticCollection("nodejs-testing-dupes"); @@ -18,7 +22,7 @@ function jsExtensions(extensions: string[]) { let jsExtensions = ""; if (extensions == null || extensions.length == 0) { - throw "this case never accurs"; + throw "this case never occurs"; } else if (extensions.length == 1) { jsExtensions = `.${extensions[0]}`; } else { @@ -61,7 +65,7 @@ export class Controller { } >(); - /** Change emtiter used for testing, to pick up when the file watcher detects a chagne */ + /** Change emitter used for testing, to pick up when the file watcher detects a change */ public readonly onDidChange = this.didChangeEmitter.event; /** Handler for a normal test run */ public readonly runHandler: RunHandler; @@ -78,6 +82,10 @@ export class Controller { include: string[], exclude: string[], extensionConfigs: ExtensionConfig[], + /** + * The configuration which defines which functions should be treated as tests + */ + private readonly testSpecifiers: TestFunctionSpecifierConfig[], ) { this.disposable.add(ctrl); this.disposable.add(runner); @@ -157,11 +165,18 @@ export class Controller { } } + /** + * Re-check this file for tests, and add them to the UI. + * Assumes that the URI has already passed `this.includeTest` + * + * @param uri the URI of the file in question to reparse and check for tests + * @param contents the file contents of uri - to be used as an optimization + */ private async _syncFile(uri: vscode.Uri, contents?: string) { contents ??= await fs.readFile(uri.fsPath, "utf8"); - // cheap test for relevancy: - if (!contents.includes("node:test")) { + // If this file definitely doesn't have any tests, we can skip any expensive processing on it + if (!fileMightHaveTests(this.testSpecifiers, contents)) { this.deleteFileTests(uri.toString()); return; } @@ -174,7 +189,7 @@ export class Controller { return; } - const tree = parseSource(contents); + const tree = parseSource(contents, this.wf.uri.path, uri.path, this.testSpecifiers); if (!tree.length) { this.deleteFileTests(uri.toString()); return; @@ -224,7 +239,7 @@ export class Controller { return item; }; - // We assume that all tests inside a top-leve describe/test are from the same + // We assume that all tests inside a top-level describe/test are from the same // source file. This is probably a good assumption. Likewise we assume that a single // a single describe/test is not split between different files. const newTestsInFile = new Map(); @@ -295,8 +310,8 @@ export class Controller { new vscode.RelativePattern(this.wf, `**/*`), )); - watcher.onDidCreate((uri) => this.includeTest(uri.fsPath) && this._syncFile(uri)); - watcher.onDidChange((uri) => this.includeTest(uri.fsPath) && this._syncFile(uri)); + watcher.onDidCreate((uri) => this.syncFile(uri)); + watcher.onDidChange((uri) => this.syncFile(uri)); watcher.onDidDelete((uri) => { const prefix = uri.toString(); for (const key of this.testsInFiles.keys()) { diff --git a/src/extension.ts b/src/extension.ts index 6ae67c6..e2dfb8d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ import { ConfigValue } from "./configValue"; import { Controller } from "./controller"; import { TestRunner } from "./runner"; import { SourceMapStore } from "./source-map-store"; +import { defaultTestFunctionSpecifiers } from "./test-function-specifier-config"; export async function activate(context: vscode.ExtensionContext) { const smStore = new SourceMapStore(); @@ -15,6 +16,8 @@ export async function activate(context: vscode.ExtensionContext) { }, ]); + const testSpecifiers = new ConfigValue("testSpecifiers", defaultTestFunctionSpecifiers); + const ctrls = new Map(); const refreshFolders = () => { for (const ctrl of ctrls.values()) { @@ -48,6 +51,7 @@ export async function activate(context: vscode.ExtensionContext) { includePattern.value, excludePatterns.value, extensions.value, + testSpecifiers.value, ), ); } @@ -102,6 +106,7 @@ export async function activate(context: vscode.ExtensionContext) { includePattern.onChange(refreshFolders), excludePatterns.onChange(refreshFolders), extensions.onChange(refreshFolders), + testSpecifiers.onChange(refreshFolders), new vscode.Disposable(() => ctrls.forEach((c) => c.dispose())), ); diff --git a/src/parsing.test.ts b/src/parsing.test.ts index e18d76b..cb8a1a1 100644 --- a/src/parsing.test.ts +++ b/src/parsing.test.ts @@ -1,5 +1,35 @@ import { describe, expect, it } from "vitest"; import { parseSource } from "./parsing"; +import { + defaultTestFunctionSpecifiers, + type TestFunctionSpecifierConfig, +} from "./test-function-specifier-config"; + +function parseSourceSimple(contents: string) { + return parseSource( + contents, + "/workspace/", + "/workspace/test.test.js", + defaultTestFunctionSpecifiers, + ); +} + +type ParseCustomOptions = { + testNames?: string[]; + testImport?: string; + workspace?: string; + path?: string; +}; + +// parseSourceCustom is a ergonomic version of parseSource that has reasonable defaults for the parameters +const parseSourceCustom = (contents: string, options?: ParseCustomOptions) => { + const testNames = options?.testNames ?? ["test"]; + const testImport = options?.testImport ?? "./test/utils"; + const workspace = options?.workspace ?? "/workspace/"; + const path = options?.path ?? "/workspace/test/addition.test.js"; + const testFunctions: TestFunctionSpecifierConfig[] = [{ import: testImport, name: testNames }]; + return parseSource(contents, workspace, path, testFunctions); +}; const testCases = (prefix = "") => `${prefix}describe("math", () => { ${prefix}it("addition", () => { @@ -20,7 +50,7 @@ describe("extract", () => { ${testCases("nt.")}`; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("extracts star import", () => { @@ -28,7 +58,7 @@ describe("extract", () => { ${testCases("nt.")}`; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("extracts named imports", () => { @@ -36,7 +66,7 @@ describe("extract", () => { ${testCases()}`; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("extracts aliased imports", () => { @@ -44,7 +74,7 @@ describe("extract", () => { ${testCases("x")}`; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("extracts default require", () => { @@ -52,7 +82,7 @@ describe("extract", () => { ${testCases("nt.")}`; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("extracts destructed require", () => { @@ -60,7 +90,7 @@ describe("extract", () => { ${testCases()}`; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("extracts aliased require", () => { @@ -68,7 +98,7 @@ describe("extract", () => { ${testCases("x")}`; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("extracts ts import mangled", () => { @@ -89,7 +119,7 @@ const node_test_1 = require("node:test"); }); //# sourceMappingURL=example.js.map`; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("does not break on empty call expression (#3)", () => { @@ -99,7 +129,7 @@ const node_test_1 = require("node:test"); ${testCases()}`; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("works with string literals", () => { @@ -111,7 +141,7 @@ const node_test_1 = require("node:test"); }); `; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("works with default cjs import", () => { @@ -120,7 +150,7 @@ const node_test_1 = require("node:test"); nt(\`addition\`, () => {}); `; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); }); it("works with default esm import", () => { @@ -129,6 +159,183 @@ const node_test_1 = require("node:test"); nt(\`addition\`, () => {}); `; - expect(parseSource(src)).toMatchSnapshot(); + expect(parseSourceSimple(src)).toMatchSnapshot(); + }); +}); + +describe("extract tests with custom specifiers", () => { + it("extracts default import tests", () => { + const contents = ` +import nt from "./utils"; + +nt("default import test", () => { + strictEqual(1 + 1, 2); + nt("nested test", () => { + strictEqual(1 + 1, 2); + }); +}); +`; + + const result = parseSourceCustom(contents, { + testNames: ["default"], + }); + + // One test, and one subtest + expect(result.length).toEqual(1); + expect(result[0].children.length).toEqual(1); + expect(result).toMatchSnapshot(); + }); + + it("extracts star import tests", () => { + const contents = `import * as Utils from "./utils"; + Utils.specialTest("addition", () => { + strictEqual(1 + 1, 2); + + // this should not be identified as test + Utils.log("something") + + Utils.specialTest("subtest", () => { + strictEqual(1 + 1, 2); + }); + }); + `; + + const result = parseSourceCustom(contents, { + testNames: ["specialTest"], + }); + + expect(result.length).toEqual(1); + expect(result[0].children.length).toEqual(1); + expect(result).toMatchSnapshot(); + }); + + it("extracts named import tests", () => { + const contents = `import { wrappedTest } from "./utils"; + wrappedTest("addition", () => { + strictEqual(1 + 1, 2); + });`; + + const result = parseSourceCustom(contents, { + testNames: ["wrappedTest"], + }); + expect(result.length).toEqual(1); + expect(result).toMatchSnapshot(); + }); + + it("extracts renamed named import tests", () => { + const contents = `import { wrappedTest as renamedTest } from "./utils"; + renamedTest("addition", () => { + strictEqual(1 + 1, 2); + });`; + + const result = parseSourceCustom(contents, { + testNames: ["wrappedTest"], + }); + expect(result.length).toEqual(1); + expect(result).toMatchSnapshot(); + }); + + it("extracts renamed named import test", () => { + const contents = `import { wrappedTest as renamedTest } from "./utils"; + renamedTest("addition", () => { + strictEqual(1 + 1, 2); + });`; + + const result = parseSourceCustom(contents, { + testNames: ["wrappedTest"], + }); + expect(result.length).toEqual(1); + expect(result).toMatchSnapshot(); + }); +}); + +describe("extract with custom test specifiers in commonjs code", () => { + it("extracts default require tests", () => { + const contents = `const nt = require("./utils"); + +nt("default import test", () => { + strictEqual(1 + 1, 2); + nt("nested test", () => { + strictEqual(1 + 1, 2); + }); +}); +`; + const result = parseSourceCustom(contents, { + testNames: ["default"], + }); + + // One test, and one subtest + expect(result.length).toEqual(1); + expect(result[0].children.length).toEqual(1); + expect(result).toMatchSnapshot(); + }); + + it("extracts destructed require", () => { + const contents = `const { describe, it } = require("./utils"); + +describe("destructured test", () => { + strictEqual(1 + 1, 2); + it("nested test", () => { + strictEqual(1 + 1, 2); + }); +}); +`; + const result = parseSourceCustom(contents, { + testNames: ["describe", "it"], + }); + + // One test, and one subtest + expect(result.length).toEqual(1); + expect(result[0].children.length).toEqual(1); + expect(result).toMatchSnapshot(); + }); + + it("extracts aliased require", () => { + const contents = `const { describe: xdescribe, it: xit } = require("./utils"); + +xdescribe("destructured test", () => { + strictEqual(1 + 1, 2); + xit("nested test", () => { + strictEqual(1 + 1, 2); + }); +}); +`; + + const result = parseSourceCustom(contents, { + testNames: ["describe", "it"], + }); + + // One test, and one subtest + expect(result.length).toEqual(1); + expect(result[0].children.length).toEqual(1); + expect(result).toMatchSnapshot(); + }); + + it("extracts ts import mangled", () => { + const contents = `"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const assert_1 = require("assert"); +const node_test_1 = require("./utils"); +(0, node_test_1.describe)("math", () => { + (0, node_test_1.it)("addition", () => { + (0, assert_1.strictEqual)(1 + 1, 2); + }); + (0, node_test_1.it)("addition", () => { + (0, assert_1.strictEqual)(1 + 1, 2); + }); + (0, node_test_1.it)("subtraction", () => { + (0, assert_1.strictEqual)(1 - 1, 0); + }); +}); +//# sourceMappingURL=example.js.map`; + + const result = parseSourceCustom(contents, { + testNames: ["describe", "it"], + }); + + // 1 test, 3 subtests + expect(result.length).toEqual(1); + expect(result[0].children.length).toEqual(3); + expect(result).toMatchSnapshot(); }); }); diff --git a/src/parsing.ts b/src/parsing.ts index ba19fd1..42c0355 100644 --- a/src/parsing.ts +++ b/src/parsing.ts @@ -1,7 +1,21 @@ import type { Options } from "acorn"; import { parse } from "acorn-loose"; import * as evk from "eslint-visitor-keys"; -import { CallExpression, Expression, Node, SourceLocation, Super } from "estree"; +import { + CallExpression, + Expression, + Node, + SourceLocation, + Super, + type ImportDeclaration, + type VariableDeclarator, +} from "estree"; +import * as PosixPath from "node:path/posix"; +import { + defaultTestFunctionSpecifiers, + type TestFunctionSpecifierConfig, +} from "./test-function-specifier-config"; +import { assertUnreachable } from "./utils"; const enum C { ImportDeclaration = "ImportDeclaration", @@ -45,19 +59,26 @@ const matchIdentified = }; const matchNamespaced = - (name: string): ExtractTest => + (objectName: string, validTestNames: Set): ExtractTest => (n) => { const callee = unpackCalleeExpression(n); - if (callee.type === C.Identifier && callee.name === name) { + if (callee.type === C.Identifier && callee.name === objectName) { return "test"; // default export, #42 } - return callee.type === C.MemberExpression && - callee.object.type === C.Identifier && - callee.object.name === name && - callee.property.type === C.Identifier - ? callee.property.name - : undefined; + if ( + !( + callee.type === C.MemberExpression && + callee.object.type === C.Identifier && + callee.object.name === objectName && + callee.property.type === C.Identifier + ) + ) { + return undefined; + } + + const calleeName = callee.property.name; + return validTestNames.has(calleeName) ? calleeName : undefined; }; const getStringish = (nameArg: Node | undefined): string | undefined => { @@ -76,52 +97,209 @@ export interface IParsedNode { children: IParsedNode[]; } -export const parseSource = (text: string) => { +/** + * Resolve the import of `importString` from the file at `fileUriPath` relative to the workspace root. + * + * If the `importString` refers a package it remains unchanged. + */ +function importRelativeToWorkspaceRoot( + workspaceFolderUriPath: string, + fileUriPath: string, + importString: string, +) { + let importValue = importString; + if (importString.startsWith("./") || importString.startsWith("../")) { + // This is a relative import, we need to adjust the import value for matching purposes + const importRelativeToRoot = PosixPath.relative( + workspaceFolderUriPath, + PosixPath.resolve(PosixPath.dirname(fileUriPath), importString), + ); + + importValue = `./${importRelativeToRoot}`; + } + + return importValue; +} + +/** + * Look for test ESM imports in this AST node + * + * @param workspaceFolderUriPath The path component of the workspace folder URI this file belongs to, used for relative path references to a custom test function + * @param fileUriPath The path component of the URI of the file we are extracting tests from + * @param testFunctions the tests function imports to check for + * @param node the ImportDeclaration to look for test imports + * @returns + */ +function importDeclarationExtractTests( + workspaceFolderUriPath: string, + fileUriPath: string, + testFunctions: TestFunctionSpecifierConfig[], + node: ImportDeclaration, +): ExtractTest[] { + const idTests: ExtractTest[] = []; + if (typeof node.source.value !== "string") { + return idTests; + } + + const fromRootImport = importRelativeToWorkspaceRoot( + workspaceFolderUriPath, + fileUriPath, + node.source.value, + ); + + for (const specifier of testFunctions) { + if (specifier.import !== fromRootImport) { + continue; + } + + // Next check to see if the functions imported are tests functions + const validNames = new Set( + typeof specifier.name === "string" ? [specifier.name] : specifier.name, + ); + + for (const spec of node.specifiers) { + const specType = spec.type; + if (specType === C.ImportDefaultSpecifier) { + // The name "default" is special, it is used when you are trying to + // target a default export from a file in your workspace + if (validNames.has("default")) { + idTests.push(matchNamespaced(spec.local.name, validNames)); + } + } else if (specType === C.ImportNamespaceSpecifier) { + idTests.push(matchNamespaced(spec.local.name, validNames)); + } else if (specType === C.ImportSpecifier) { + if (spec.imported.type === C.Identifier) { + if (validNames.has(spec.imported.name)) { + idTests.push(matchIdentified(spec.imported.name, spec.local.name)); + } + } + } else { + assertUnreachable(specType, `${specType} was unhandled`); + } + } + } + + return idTests; +} + +/** + * Look for test imports in a `require` call + * For example: + * ``` + * const test = require("node:test"); + * const { test } = require("node:test"); + * const { test: renamedTest } = require("node:test"); + * ``` + * + * @param workspaceFolderUriPath The path component of the workspace folder URI this file belongs to, used for relative path references to a custom test function + * @param fileUriPath The path component of the URI of the file we are extracting tests from + * @param testFunctions the tests function imports to check for + * @param node the VariableDeclarator to look for + * @returns + */ +function requireCallExtractTests( + workspaceFolderUriPath: string, + fileUriPath: string, + testFunctions: TestFunctionSpecifierConfig[], + node: VariableDeclarator, +) { + const idTests: ExtractTest[] = []; + if (node.init?.type !== C.CallExpression) { + return idTests; + } + + const firstArg = getStringish(node.init.arguments[0]); + if (!firstArg) { + return idTests; + } + + const fromRootImport = importRelativeToWorkspaceRoot( + workspaceFolderUriPath, + fileUriPath, + firstArg, + ); + + for (const specifier of testFunctions) { + if (specifier.import !== fromRootImport) { + continue; + } + + // Next check to see if the functions imported are tests functions + const validNames = new Set( + typeof specifier.name === "string" ? [specifier.name] : specifier.name, + ); + + if (node.id.type === C.Identifier) { + idTests.push(matchNamespaced(node.id.name, validNames)); + continue; + } + + if (node.id.type === C.ObjectPattern) { + for (const prop of node.id.properties) { + if ( + prop.type === C.Property && + prop.key.type === C.Identifier && + prop.value.type === C.Identifier && + validNames.has(prop.key.name) + ) { + idTests.push(matchIdentified(prop.key.name, prop.value.name)); + } + } + } + } + + return idTests; +} + +/** + * Parse the source code in `text` to identify any tests that match `testFunctions` + * + * @param text the contents of the file we want to parse + * @param workspaceFolderUriPath the path component of the URI of the workspace this file exists. + * If you have a `const folder: vscode.WorkspaceFolder`, then use folder.uri.path + * @param fileUriPath the path component of the URI of the file we are checking for tests + * @param testFunctions the configured test specifiers for the kinds of tests we are looking for + * @returns A hierarchical tree of tests that exist in this file + */ +export const parseSource = ( + text: string, + workspaceFolderUriPath: string, + fileUriPath: string, + testFunctions: TestFunctionSpecifierConfig[], +): IParsedNode[] => { const ast = parse(text, acornOptions); + const testMatchers = testFunctions ?? defaultTestFunctionSpecifiers; const idTests: ExtractTest[] = []; + // Since tests can be nested inside of each other, for example a test suite. + // We keep track of the test declarations in a tree. const stack: { node: Node; r: IParsedNode }[] = []; stack.push({ node: undefined, r: { children: [] } } as any); traverse(ast as Node, { enter(node) { - if (node.type === C.ImportDeclaration && node.source.value === C.NodeTest) { - for (const spec of node.specifiers) { - switch (spec.type) { - case C.ImportNamespaceSpecifier: - case C.ImportDefaultSpecifier: - idTests.push(matchNamespaced(spec.local.name)); - break; - case C.ImportSpecifier: - if (spec.imported.type === C.Identifier) { - idTests.push(matchIdentified(spec.imported.name, spec.local.name)); - } - break; - } - } + if (node.type === C.ImportDeclaration) { + const matchers = importDeclarationExtractTests( + workspaceFolderUriPath, + fileUriPath, + testMatchers, + node, + ); + idTests.push(...matchers); } else if ( node.type === C.VariableDeclarator && node.init?.type === C.CallExpression && node.init.callee.type === C.Identifier && node.init.callee.name === "require" ) { - const firstArg = getStringish(node.init.arguments[0]); - if (firstArg === C.NodeTest) { - if (node.id.type === C.ObjectPattern) { - for (const prop of node.id.properties) { - if ( - prop.type === C.Property && - prop.key.type === C.Identifier && - prop.value.type === C.Identifier - ) { - idTests.push(matchIdentified(prop.key.name, prop.value.name)); - } - } - } else if (node.id.type === C.Identifier) { - idTests.push(matchNamespaced(node.id.name)); - } - } + const matchers = requireCallExtractTests( + workspaceFolderUriPath, + fileUriPath, + testMatchers, + node, + ); + idTests.push(...matchers); } else if (node.type === C.CallExpression) { const name = getStringish(node.arguments[0]); if (name === undefined) { @@ -137,7 +315,13 @@ export const parseSource = (text: string) => { fn, name, }; + + // We have encountered a test function, record it in the tree. stack[stack.length - 1].r.children.push(child); + + // This test function is potentially a "parent" for subtests, so + // keep it as the "current leaf" of the stack, so future sub-tests + // can be associated with it stack.push({ node, r: child }); break; } @@ -145,6 +329,9 @@ export const parseSource = (text: string) => { } }, leave(node) { + // We are exiting a node that was potentially a test function. If it was, + // we need to pop it of the stack, since there are no more subtests to be + // associated with it. if (stack[stack.length - 1].node === node) { stack.pop(); } diff --git a/src/test-function-specifier-config.ts b/src/test-function-specifier-config.ts new file mode 100644 index 0000000..010902d --- /dev/null +++ b/src/test-function-specifier-config.ts @@ -0,0 +1,59 @@ +import * as PathPosix from "node:path/posix"; + +/** + * A declarative way to target a function call either imported from a package, + * like node:test or from another file in the project + */ +export interface TestFunctionSpecifierConfig { + /** The names of the functions that should be included in the test runner view */ + name: string[] | string; + + /** + * The import location where those functions were imported from. If the import + * starts with `./` it will be treated as a file import relative to the root + * of the workspace, otherwise it refers to a package, like node:test or + * vitest + */ + import: string; +} + +export const defaultTestFunctionSpecifiers: TestFunctionSpecifierConfig[] = [ + { import: "node:test", name: ["default", "it", "test", "describe", "suite"] }, +]; + +function singleFileMightHaveTests( + testSpec: TestFunctionSpecifierConfig, + contents: string, +): boolean { + if (testSpec.import.startsWith("./")) { + // If this test specifier is a relative import, like + // './my/test/functions/utils.ts' it is a little harder to do an easy check + // for tests, since it could be anything like: + // ./utils + // ./utils.js + // ./utils.ts + // ../utils.ts + // ./functions/utils.ts + // ../functions/utils.ts + // etc. + // We look for the extension-less basename of the test-defining file in the test- + return contents.includes(PathPosix.parse(testSpec.import).name); + } + + // This is a test function imported from a package + return contents.includes(testSpec.import); +} + +/** + * Cheaply check if this file _might_ include any tests matched by the given specifications + * + * @param testSpecs the test specifiers to cheaply check the file contents + * @param contents the contents of the file we are checking for tests + * @returns true if this file requires further processing to check for tests + */ +export function fileMightHaveTests( + testSpecs: TestFunctionSpecifierConfig[], + contents: string, +): boolean { + return testSpecs.some((spec) => singleFileMightHaveTests(spec, contents)); +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..c2981c4 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,11 @@ +/** + * A utility function to ensure that typescript unions are exhaustively checked. + * This function will fail at compile time if a previously exhaustive check is + * no longer exhaustive (in case a new value is added) + * + * @param _ the value that should have already been exhaustivly checked + * @param message The error message to throw in case this code is reached during runtime + */ +export function assertUnreachable(_: never, message: string): never { + throw new Error(message); +} diff --git a/testCases/simple/workspace/inRootFolderWrapped.test.mjs b/testCases/simple/workspace/inRootFolderWrapped.test.mjs new file mode 100644 index 0000000..f5f740e --- /dev/null +++ b/testCases/simple/workspace/inRootFolderWrapped.test.mjs @@ -0,0 +1,6 @@ +import { test } from "./test/utils.js"; +import { strictEqual } from "node:assert"; + +test("using wrapped test function from the workspace folder", () => { + strictEqual(1 + 1, 2); +}); diff --git a/testCases/simple/workspace/otherFolder/otherFolderWrapped.test.mjs b/testCases/simple/workspace/otherFolder/otherFolderWrapped.test.mjs new file mode 100644 index 0000000..84ae0f5 --- /dev/null +++ b/testCases/simple/workspace/otherFolder/otherFolderWrapped.test.mjs @@ -0,0 +1,6 @@ +import { test } from "../test/utils.js"; +import { strictEqual } from "node:assert"; + +test("using wrapped test function from the workspace/otherFolder folder", () => { + strictEqual(1 + 1, 2); +}); diff --git a/testCases/simple/workspace/test/inTestFolderWrapped.mjs b/testCases/simple/workspace/test/inTestFolderWrapped.mjs new file mode 100644 index 0000000..6f3d395 --- /dev/null +++ b/testCases/simple/workspace/test/inTestFolderWrapped.mjs @@ -0,0 +1,6 @@ +import { test } from "./utils.js"; +import { strictEqual } from "node:assert"; + +test("using wrapped test function from the workspace/test folder", () => { + strictEqual(1 + 1, 2); +}); diff --git a/testCases/simple/workspace/test/utils.js b/testCases/simple/workspace/test/utils.js new file mode 100644 index 0000000..822297c --- /dev/null +++ b/testCases/simple/workspace/test/utils.js @@ -0,0 +1,5 @@ +const { test: nodeTest } = require("node:test"); + +exports.test = function test(name, fn) { + return nodeTest(name, fn); +};