From b9002c9971453fabefe7f3f93c850140570403de Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 29 Sep 2026 20:15:43 +0800 Subject: [PATCH 1/5] feat(vscode): support function names as test titles Port the VS Code extension changes from web-infra-dev/rstest#1908 (e1607861ac055c6f9b687f0fea3a7829f46ec14b). Preserve local parser test paths and track post-0.12.2 main without changing dependencies. --- packages/vscode/AGENTS.md | 2 +- packages/vscode/src/stacks/test/parserTest.ts | 324 +++++++++++++++++- .../vscode/tests/stacks/test/parse.test.ts | 124 ++++++- 3 files changed, 444 insertions(+), 6 deletions(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 3ac38af..605a883 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -6,7 +6,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase. - The copies diverge from upstream in exactly eleven ways (the "adaptations" below). When syncing upstream, preserve them. A twelfth divergence is either a bug or must be added to this list. -- **Tracked upstream state.** `stacks/lint` tracks web-infra-dev/rslint `packages/vscode-extension` at **e5d76242** (0.9.3); `stacks/test` tracks web-infra-dev/rstest `packages/vscode` at **41daaa1b** (0.12.2). Additional ports: Rslint **84f9c9b5** trace wording and **72cd2b1f** fixAll waits; Rstest **988f8e1d** per-bundle license notices, with explicit native-binding inclusion and no network license-text replenishment. We replace upstream's plugin-host failure toast with status (adaptations 4/7). Diff `CoreResolver.ts`, `RuntimeManager.ts`, `WorkspaceDocumentRouter.ts`, and `Rslint.ts` on future syncs. The rstest#1888 IPC port uses default JSON instead of advanced serialization: values must survive a JSON round-trip, and both `createBirpc` calls spread `rpcErrorCodec` (`stacks/test/shared/rpc.ts`) to preserve thrown errors. Failed-project retry and worker cleanup remain ahead of upstream. +- **Tracked upstream state.** `stacks/lint` tracks web-infra-dev/rslint `packages/vscode-extension` at **e5d76242** (0.9.3); `stacks/test` tracks web-infra-dev/rstest `packages/vscode` at **e1607861** (post-0.12.2 main). Additional ports: Rslint **84f9c9b5** trace wording and **72cd2b1f** fixAll waits; Rstest **988f8e1d** per-bundle license notices, with explicit native-binding inclusion and no network license-text replenishment. We replace upstream's plugin-host failure toast with status (adaptations 4/7). Diff `CoreResolver.ts`, `RuntimeManager.ts`, `WorkspaceDocumentRouter.ts`, and `Rslint.ts` on future syncs. The rstest#1888 IPC port uses default JSON instead of advanced serialization: values must survive a JSON round-trip, and both `createBirpc` calls spread `rpcErrorCodec` (`stacks/test/shared/rpc.ts`) to preserve thrown errors. Failed-project retry and worker cleanup remain ahead of upstream. - **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. (5) `RuntimeManager` retires a stopped client even when its resolved key is unchanged. The existing closing barrier and pending-use adoption share one replacement across documents; running and starting clients remain untouched (`tests/stacks/lint/runtimeManager.test.ts`). ## The eleven adaptations diff --git a/packages/vscode/src/stacks/test/parserTest.ts b/packages/vscode/src/stacks/test/parserTest.ts index 1d94b4a..854b1ef 100644 --- a/packages/vscode/src/stacks/test/parserTest.ts +++ b/packages/vscode/src/stacks/test/parserTest.ts @@ -65,8 +65,313 @@ export const parseTestFile = ( .join(''); }; + type NameScope = { + parent: NameScope | null; + isFunctionScope: boolean; + bindings: Map; + }; + type NameValue = + | { type: 'name'; value: string } + | { type: 'alias'; name: string; scope: NameScope; position: number } + | null; + type NameBinding = { + kind: 'function' | 'import' | 'lexical' | 'parameter' | 'variable'; + position: number; + initialized: boolean; + value: NameValue; + }; + + const scopesByNode = new WeakMap(); + const createScope = ( + parent: NameScope | null, + isFunctionScope = false, + ): NameScope => ({ parent, isFunctionScope, bindings: new Map() }); + + const addBinding = ( + name: string, + scope: NameScope, + binding: NameBinding, + ): void => { + const bindings = scope.bindings.get(name) ?? []; + bindings.push(binding); + scope.bindings.set(name, bindings); + }; + + const addPatternBindings = ( + pattern: Node, + scope: NameScope, + binding: NameBinding, + ): void => { + if (pattern.type === 'Identifier') { + addBinding(pattern.name, scope, binding); + } else if (pattern.type === 'RestElement') { + if (isNode(pattern.argument)) { + addPatternBindings(pattern.argument, scope, { + ...binding, + value: null, + }); + } + } else if (pattern.type === 'AssignmentPattern') { + if (isNode(pattern.left)) { + addPatternBindings(pattern.left, scope, { ...binding, value: null }); + } + } else if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) { + if (isNode(element)) { + addPatternBindings(element, scope, { ...binding, value: null }); + } + } + } else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) { + if (!isNode(property)) { + continue; + } + const nestedPattern = + property.type === 'RestElement' ? property.argument : property.value; + if (isNode(nestedPattern)) { + addPatternBindings(nestedPattern, scope, { + ...binding, + value: null, + }); + } + } + } + }; + + const findFunctionScope = (scope: NameScope): NameScope => { + let current = scope; + while (!current.isFunctionScope && current.parent) { + current = current.parent; + } + return current; + }; + + const collectNameScopes = (node: Node, parentScope: NameScope): void => { + const createsFunctionScope = + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression'; + const createsBlockScope = + node.type === 'BlockStatement' || + node.type === 'ClassDeclaration' || + node.type === 'ClassExpression' || + node.type === 'ForStatement' || + node.type === 'ForInStatement' || + node.type === 'ForOfStatement' || + node.type === 'SwitchStatement' || + node.type === 'CatchClause'; + const scope = + createsFunctionScope || createsBlockScope + ? createScope(parentScope, createsFunctionScope) + : parentScope; + + if ( + (node.type === 'FunctionDeclaration' || + node.type === 'ClassDeclaration') && + isNode(node.id) && + node.id.type === 'Identifier' + ) { + const kind = node.type === 'FunctionDeclaration' ? 'function' : 'lexical'; + const binding: NameBinding = { + kind, + position: node.type === 'FunctionDeclaration' ? node.start : node.end, + initialized: true, + value: { type: 'name', value: node.id.name }, + }; + addBinding(node.id.name, parentScope, binding); + addBinding(node.id.name, scope, binding); + } else if ( + (node.type === 'FunctionExpression' || node.type === 'ClassExpression') && + isNode(node.id) && + node.id.type === 'Identifier' + ) { + addBinding(node.id.name, scope, { + kind: 'lexical', + position: node.start, + initialized: true, + value: { type: 'name', value: node.id.name }, + }); + } + + if (node.type === 'VariableDeclaration') { + const declarationScope = + node.kind === 'var' ? findFunctionScope(scope) : scope; + for (const declaration of node.declarations) { + if (!isNode(declaration) || !isNode(declaration.id)) { + continue; + } + + const initializer = isNode(declaration.init) + ? declaration.init + : undefined; + let value: NameValue = null; + if (initializer?.type === 'Identifier') { + value = { + type: 'alias', + name: initializer.name, + scope: declarationScope, + position: initializer.start, + }; + } else if ( + declaration.id.type === 'Identifier' && + (initializer?.type === 'ArrowFunctionExpression' || + initializer?.type === 'FunctionExpression' || + initializer?.type === 'ClassExpression') + ) { + const name = + (initializer.type !== 'ArrowFunctionExpression' && + isNode(initializer.id) && + initializer.id.type === 'Identifier' + ? initializer.id.name + : undefined) ?? declaration.id.name; + value = { type: 'name', value: name }; + } + + addPatternBindings(declaration.id, declarationScope, { + kind: node.kind === 'var' ? 'variable' : 'lexical', + position: initializer?.end ?? declaration.start, + initialized: Boolean(initializer), + value, + }); + } + } else if ( + (node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' || + node.type === 'ArrowFunctionExpression') && + Array.isArray(node.params) + ) { + for (const param of node.params) { + if (isNode(param)) { + addPatternBindings(param, scope, { + kind: 'parameter', + position: node.start, + initialized: true, + value: null, + }); + } + } + } else if (node.type === 'CatchClause' && isNode(node.param)) { + addPatternBindings(node.param, scope, { + kind: 'parameter', + position: node.start, + initialized: true, + value: null, + }); + } else if ( + node.type === 'ImportDeclaration' && + Array.isArray(node.specifiers) + ) { + for (const specifier of node.specifiers) { + if (!isNode(specifier) || !isNode(specifier.local)) { + continue; + } + + const name = + node.importKind !== 'type' && + specifier.type === 'ImportSpecifier' && + specifier.importKind !== 'type' && + isNode(specifier.imported) && + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : null; + addPatternBindings(specifier.local, scope, { + kind: 'import', + position: node.start, + initialized: true, + value: name === null ? null : { type: 'name', value: name }, + }); + } + } + + scopesByNode.set(node, scope); + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const child of value) { + if (isNode(child)) { + collectNameScopes(child, scope); + } + } + } else if (isNode(value)) { + collectNameScopes(value, scope); + } + } + }; + + collectNameScopes(result.program, createScope(null, true)); + + const resolveName = ( + name: string, + scope: NameScope | undefined, + position: number, + seen: Set, + ): string | null => { + let current = scope; + while (current) { + const bindings = current.bindings.get(name); + if (bindings?.length) { + const initialized = bindings.filter( + (binding) => binding.initialized && binding.position <= position, + ); + const variable = initialized + .filter((binding) => binding.kind === 'variable') + .sort((a, b) => b.position - a.position)[0]; + const lexical = initialized + .filter((binding) => binding.kind === 'lexical') + .sort((a, b) => b.position - a.position)[0]; + const imported = initialized.find( + (binding) => binding.kind === 'import', + ); + const functionDeclaration = bindings + .filter((binding) => binding.kind === 'function') + .sort((a, b) => b.position - a.position)[0]; + const binding = variable ?? lexical ?? imported ?? functionDeclaration; + + if (!binding || !binding.value || seen.has(binding)) { + return null; + } + if (binding.value.type === 'name') { + return binding.value.value; + } + + seen.add(binding); + return resolveName( + binding.value.name, + binding.value.scope, + binding.value.position, + seen, + ); + } + current = current.parent ?? undefined; + } + return null; + }; + + const getFunctionName = ( + node: Node | undefined, + scope: NameScope | undefined, + position: number, + ): string | null => { + if ( + node?.type === 'FunctionExpression' || + node?.type === 'ClassExpression' + ) { + return isNode(node.id) && node.id.type === 'Identifier' + ? node.id.name + : ''; + } + if (node?.type === 'ArrowFunctionExpression') { + return ''; + } + if (node?.type !== 'Identifier') { + return null; + } + return resolveName(node.name, scope, position, new Set()); + }; + const walkNode = (node: Node): void => { let exit: (() => void) | void | undefined; + let functionTitleNode: Node | undefined; if (node.type === 'CallExpression') { let functionName: string | undefined; @@ -86,9 +391,24 @@ export const parseTestFile = ( functionName === 'describe' || functionName === 'suite' ) { + const title = node.arguments[0]; + if ( + isNode(title) && + (title.type === 'FunctionExpression' || + title.type === 'ClassExpression' || + title.type === 'ArrowFunctionExpression') + ) { + functionTitleNode = title; + } exit = events.onTest( offsetToRange(node.start, node.end), - getStringLiteralValue(node.arguments[0]) || 'unnamed test', + getStringLiteralValue(node.arguments[0]) || + getFunctionName( + node.arguments[0], + scopesByNode.get(node), + node.start, + ) || + 'unnamed test', functionName, ); } @@ -97,7 +417,7 @@ export const parseTestFile = ( for (const value of Object.values(node)) { if (Array.isArray(value)) { for (const child of value) { - if (isNode(child)) { + if (isNode(child) && child !== functionTitleNode) { walkNode(child); } } diff --git a/packages/vscode/tests/stacks/test/parse.test.ts b/packages/vscode/tests/stacks/test/parse.test.ts index c346516..b0d62f1 100644 --- a/packages/vscode/tests/stacks/test/parse.test.ts +++ b/packages/vscode/tests/stacks/test/parse.test.ts @@ -133,7 +133,7 @@ describe('parseTestFile', () => { expect(tests.map((t) => t.type)).toEqual(['test', 'suite']); }); - it('should mark non-literal or missing names as "unnamed test"', () => { + it('should label anonymous function names and mark other dynamic names as "unnamed test"', () => { const code = ` const title = getTitle(); function getTitle() { return 'x'; } @@ -155,8 +155,126 @@ describe('parseTestFile', () => { }, }); - expect(tests.length).toBe(5); - expect(tests.every((t) => t.name === 'unnamed test')).toBe(true); + expect(tests.map((test) => test.name).sort()).toEqual([ + '', + '', + 'unnamed test', + 'unnamed test', + 'unnamed test', + ]); + }); + + it('should use function and class names from expressions and identifiers', () => { + const code = ` + function Component() {} + const ArrowComponent = () => {}; + class Widget {} + const NamedWidget = class InternalWidget {}; + test(Component, () => {}); + it(ArrowComponent, () => {}); + describe(Widget, () => {}); + suite(NamedWidget, () => {}); + test(function DirectFunction() {}, () => {}); + describe(class DirectClass {}, () => {}); + `; + + const tests: { name: string; type: string }[] = []; + parseTestFile(code, { + onTest: ( + _range: Range, + name: string, + testType: 'test' | 'it' | 'describe' | 'suite', + ) => { + tests.push({ name, type: testType }); + }, + }); + + tests.sort((a, b) => a.name.localeCompare(b.name)); + + expect(tests.map((test) => test.name)).toEqual([ + 'ArrowComponent', + 'Component', + 'DirectClass', + 'DirectFunction', + 'InternalWidget', + 'Widget', + ]); + }); + + it('should resolve function names in the nearest lexical scope', () => { + const code = ` + function Component() {} + test(Component, () => {}); + { + const Component = function InnerComponent() {}; + test(Component, () => {}); + } + test(Component, () => {}); + `; + + const names: string[] = []; + parseTestFile(code, { + onTest: (_range, name) => { + names.push(name); + }, + }); + + expect(names.sort()).toEqual(['Component', 'Component', 'InnerComponent']); + }); + + it('should use imported names for named function bindings', () => { + const code = ` + import { Component, Original as Local } from './component'; + test(Component, () => {}); + it(Local, () => {}); + `; + + const names: string[] = []; + parseTestFile(code, { + onTest: (_range, name) => { + names.push(name); + }, + }); + + expect(names.sort()).toEqual(['Component', 'Original']); + }); + + it('should resolve identifier aliases at each call site', () => { + const code = ` + function Component() {} + const Alias = Component; + var Name = function First() {}; + test(Alias, () => {}); + test(Name, () => {}); + var Name = function Second() {}; + test(Name, () => {}); + `; + + const names: string[] = []; + parseTestFile(code, { + onTest: (_range, name) => { + names.push(name); + }, + }); + + expect(names).toEqual(['Component', 'First', 'Second']); + }); + + it('should not collect tests inside a function-valued title', () => { + const code = ` + test(function Title() { + test('phantom', () => {}); + }, () => {}); + `; + + const names: string[] = []; + parseTestFile(code, { + onTest: (_range, name) => { + names.push(name); + }, + }); + + expect(names).toEqual(['Title']); }); it('should handle complex template literals with multiple expressions', () => { From 78240b05f7e254528a93e54c1e1a5897e88fb2c8 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 29 Sep 2026 20:31:30 +0800 Subject: [PATCH 2/5] refactor(vscode): resolve function titles in the discovery walk --- packages/vscode/AGENTS.md | 6 +- packages/vscode/src/stacks/test/parserTest.ts | 363 ++++++------------ .../vscode/tests/stacks/test/parse.test.ts | 91 +++++ 3 files changed, 203 insertions(+), 257 deletions(-) diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 605a883..8c3a959 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -5,11 +5,11 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## The copies are intentional - `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase. -- The copies diverge from upstream in exactly eleven ways (the "adaptations" below). When syncing upstream, preserve them. A twelfth divergence is either a bug or must be added to this list. +- The copies diverge from upstream in exactly twelve ways (the "adaptations" below). When syncing upstream, preserve them. A thirteenth divergence is either a bug or must be added to this list. - **Tracked upstream state.** `stacks/lint` tracks web-infra-dev/rslint `packages/vscode-extension` at **e5d76242** (0.9.3); `stacks/test` tracks web-infra-dev/rstest `packages/vscode` at **e1607861** (post-0.12.2 main). Additional ports: Rslint **84f9c9b5** trace wording and **72cd2b1f** fixAll waits; Rstest **988f8e1d** per-bundle license notices, with explicit native-binding inclusion and no network license-text replenishment. We replace upstream's plugin-host failure toast with status (adaptations 4/7). Diff `CoreResolver.ts`, `RuntimeManager.ts`, `WorkspaceDocumentRouter.ts`, and `Rslint.ts` on future syncs. The rstest#1888 IPC port uses default JSON instead of advanced serialization: values must survive a JSON round-trip, and both `createBirpc` calls spread `rpcErrorCodec` (`stacks/test/shared/rpc.ts`) to preserve thrown errors. Failed-project retry and worker cleanup remain ahead of upstream. - **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. (5) `RuntimeManager` retires a stopped client even when its resolved key is unchanged. The existing closing barrier and pending-use adoption share one replacement across documents; running and starting clients remain untouched (`tests/stacks/lint/runtimeManager.test.ts`). -## The eleven adaptations +## The twelve adaptations 1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker. 2. **Namespace** — everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` settings and command ids are not read, aliased or migrated (breaking old settings and keybindings was an accepted cost). @@ -25,6 +25,8 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 11. **Debugger-owned test processes** (test) — debug runs use js-debug `launch` with child-process auto-attach instead of upstream's `--inspect-wait` plus `attach`. js-debug owns inspector endpoints and process teardown; `debuggerPort` / `debuggerAddress` are removed without migration. `debugWorker.ts` owns the session and a debug-only local socket carrying JSON birpc messages (`shared/socketRpc.ts`) after a first-line per-launch secret authenticates the worker; normal runs retain Node IPC. Preserve the 9229-occupied breakpoint and continue/cancel/stop cleanup regression in `e2e/rstest/suite/debug.test.ts` when syncing. +12. **Function-title discovery** (test) — `stacks/test/parserTest.ts` replaces upstream's scope pre-pass with walk-order bindings and assignment tracking for runtime-name parity. Unbound identifiers (including default imports) and non-computed members use best-effort identifier/property names rather than giving up with `unnamed test`; known unknown bindings still stay unnamed. Re-apply this adaptation when syncing `parserTest.ts`, preserving the upstream tests and local runtime-parity cases. + ## Rules - **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. No settings migration exists either — not for earlier states of this extension, and not for the two retired standalone extensions (removed in #15; users re-enter their settings under `rstack.*`). Testing and fixtures track only the latest published releases, pinned exactly and bumped by Renovate; a green E2E run speaks only for those releases. `SUPPORT_MATRIX` floors are the minimum versions the extension accepts: each entry is the lowest release evidence shows works with the current code, and its comment records that evidence. Move a floor only when a change makes older releases stop working, never because a devDependency or fixture moved. Raising a floor needs no transition story; the status names the required version. diff --git a/packages/vscode/src/stacks/test/parserTest.ts b/packages/vscode/src/stacks/test/parserTest.ts index 854b1ef..0d948a5 100644 --- a/packages/vscode/src/stacks/test/parserTest.ts +++ b/packages/vscode/src/stacks/test/parserTest.ts @@ -65,60 +65,78 @@ export const parseTestFile = ( .join(''); }; - type NameScope = { - parent: NameScope | null; - isFunctionScope: boolean; - bindings: Map; - }; - type NameValue = - | { type: 'name'; value: string } - | { type: 'alias'; name: string; scope: NameScope; position: number } - | null; - type NameBinding = { - kind: 'function' | 'import' | 'lexical' | 'parameter' | 'variable'; - position: number; - initialized: boolean; - value: NameValue; - }; + // Test item names must match runtime `name || ''`. Record bindings + // in walk order so later calls see assignments without a separate scope pass. + type Scope = { isFunction: boolean; names: Map }; + const scopes: Scope[] = [{ isFunction: true, names: new Map() }]; + const functionTypes = new Set([ + 'FunctionDeclaration', + 'FunctionExpression', + 'ArrowFunctionExpression', + ]); + const blockTypes = new Set([ + 'BlockStatement', + 'ClassDeclaration', + 'ClassExpression', + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + 'SwitchStatement', + 'CatchClause', + ]); + + const scopeOf = (name: string) => + scopes.findLast((scope) => scope.names.has(name))?.names; - const scopesByNode = new WeakMap(); - const createScope = ( - parent: NameScope | null, - isFunctionScope = false, - ): NameScope => ({ parent, isFunctionScope, bindings: new Map() }); - - const addBinding = ( - name: string, - scope: NameScope, - binding: NameBinding, - ): void => { - const bindings = scope.bindings.get(name) ?? []; - bindings.push(binding); - scope.bindings.set(name, bindings); + const getFunctionName = ( + node: Node | undefined, + inferred?: string, + ): string | null => { + if ( + node?.type === 'FunctionExpression' || + node?.type === 'ClassExpression' + ) { + return isNode(node.id) && node.id.type === 'Identifier' + ? node.id.name + : (inferred ?? ''); + } + if (node?.type === 'ArrowFunctionExpression') { + return inferred ?? ''; + } + if (node?.type === 'Identifier') { + const names = scopeOf(node.name); + // Unbound identifiers (including default imports) use a best-effort name; + // known unknown values must not fall back through a shadowing binding. + return names ? (names.get(node.name) ?? null) : node.name; + } + if ( + node?.type === 'MemberExpression' && + !node.computed && + node.property.type === 'Identifier' + ) { + return node.property.name; + } + return null; }; - const addPatternBindings = ( - pattern: Node, - scope: NameScope, - binding: NameBinding, - ): void => { + const bind = (pattern: Node, name: string | null, isVar = false): void => { if (pattern.type === 'Identifier') { - addBinding(pattern.name, scope, binding); + const scope = isVar + ? scopes.findLast((candidate) => candidate.isFunction) + : scopes.at(-1); + scope?.names.set(pattern.name, name); } else if (pattern.type === 'RestElement') { if (isNode(pattern.argument)) { - addPatternBindings(pattern.argument, scope, { - ...binding, - value: null, - }); + bind(pattern.argument, null, isVar); } } else if (pattern.type === 'AssignmentPattern') { if (isNode(pattern.left)) { - addPatternBindings(pattern.left, scope, { ...binding, value: null }); + bind(pattern.left, null, isVar); } } else if (pattern.type === 'ArrayPattern') { for (const element of pattern.elements) { if (isNode(element)) { - addPatternBindings(element, scope, { ...binding, value: null }); + bind(element, null, isVar); } } } else if (pattern.type === 'ObjectPattern') { @@ -126,250 +144,86 @@ export const parseTestFile = ( if (!isNode(property)) { continue; } - const nestedPattern = + const value = property.type === 'RestElement' ? property.argument : property.value; - if (isNode(nestedPattern)) { - addPatternBindings(nestedPattern, scope, { - ...binding, - value: null, - }); + if (isNode(value)) { + bind(value, null, isVar); } } } }; - const findFunctionScope = (scope: NameScope): NameScope => { - let current = scope; - while (!current.isFunctionScope && current.parent) { - current = current.parent; - } - return current; - }; - - const collectNameScopes = (node: Node, parentScope: NameScope): void => { - const createsFunctionScope = - node.type === 'FunctionDeclaration' || - node.type === 'FunctionExpression' || - node.type === 'ArrowFunctionExpression'; - const createsBlockScope = - node.type === 'BlockStatement' || - node.type === 'ClassDeclaration' || - node.type === 'ClassExpression' || - node.type === 'ForStatement' || - node.type === 'ForInStatement' || - node.type === 'ForOfStatement' || - node.type === 'SwitchStatement' || - node.type === 'CatchClause'; - const scope = - createsFunctionScope || createsBlockScope - ? createScope(parentScope, createsFunctionScope) - : parentScope; - - if ( - (node.type === 'FunctionDeclaration' || - node.type === 'ClassDeclaration') && - isNode(node.id) && - node.id.type === 'Identifier' - ) { - const kind = node.type === 'FunctionDeclaration' ? 'function' : 'lexical'; - const binding: NameBinding = { - kind, - position: node.type === 'FunctionDeclaration' ? node.start : node.end, - initialized: true, - value: { type: 'name', value: node.id.name }, - }; - addBinding(node.id.name, parentScope, binding); - addBinding(node.id.name, scope, binding); - } else if ( - (node.type === 'FunctionExpression' || node.type === 'ClassExpression') && - isNode(node.id) && - node.id.type === 'Identifier' - ) { - addBinding(node.id.name, scope, { - kind: 'lexical', - position: node.start, - initialized: true, - value: { type: 'name', value: node.id.name }, - }); - } - + const collectBindings = (node: Node): void => { if (node.type === 'VariableDeclaration') { - const declarationScope = - node.kind === 'var' ? findFunctionScope(scope) : scope; for (const declaration of node.declarations) { if (!isNode(declaration) || !isNode(declaration.id)) { continue; } - - const initializer = isNode(declaration.init) - ? declaration.init - : undefined; - let value: NameValue = null; - if (initializer?.type === 'Identifier') { - value = { - type: 'alias', - name: initializer.name, - scope: declarationScope, - position: initializer.start, - }; - } else if ( - declaration.id.type === 'Identifier' && - (initializer?.type === 'ArrowFunctionExpression' || - initializer?.type === 'FunctionExpression' || - initializer?.type === 'ClassExpression') - ) { - const name = - (initializer.type !== 'ArrowFunctionExpression' && - isNode(initializer.id) && - initializer.id.type === 'Identifier' - ? initializer.id.name - : undefined) ?? declaration.id.name; - value = { type: 'name', value: name }; - } - - addPatternBindings(declaration.id, declarationScope, { - kind: node.kind === 'var' ? 'variable' : 'lexical', - position: initializer?.end ?? declaration.start, - initialized: Boolean(initializer), - value, - }); + const init = isNode(declaration.init) ? declaration.init : undefined; + const name = + init && declaration.id.type === 'Identifier' + ? getFunctionName(init, declaration.id.name) + : null; + bind(declaration.id, name, node.kind === 'var'); } } else if ( - (node.type === 'FunctionDeclaration' || - node.type === 'FunctionExpression' || - node.type === 'ArrowFunctionExpression') && - Array.isArray(node.params) + node.type === 'AssignmentExpression' && + node.operator === '=' && + node.left.type === 'Identifier' ) { - for (const param of node.params) { - if (isNode(param)) { - addPatternBindings(param, scope, { - kind: 'parameter', - position: node.start, - initialized: true, - value: null, - }); - } - } - } else if (node.type === 'CatchClause' && isNode(node.param)) { - addPatternBindings(node.param, scope, { - kind: 'parameter', - position: node.start, - initialized: true, - value: null, - }); + scopeOf(node.left.name)?.set( + node.left.name, + getFunctionName(node.right, node.left.name), + ); } else if ( node.type === 'ImportDeclaration' && + node.importKind !== 'type' && Array.isArray(node.specifiers) ) { for (const specifier of node.specifiers) { if (!isNode(specifier) || !isNode(specifier.local)) { continue; } - - const name = - node.importKind !== 'type' && + if (specifier.type === 'ImportNamespaceSpecifier') { + bind(specifier.local, null); + } else if ( specifier.type === 'ImportSpecifier' && specifier.importKind !== 'type' && isNode(specifier.imported) && specifier.imported.type === 'Identifier' - ? specifier.imported.name - : null; - addPatternBindings(specifier.local, scope, { - kind: 'import', - position: node.start, - initialized: true, - value: name === null ? null : { type: 'name', value: name }, - }); - } - } - - scopesByNode.set(node, scope); - - for (const value of Object.values(node)) { - if (Array.isArray(value)) { - for (const child of value) { - if (isNode(child)) { - collectNameScopes(child, scope); - } + ) { + bind(specifier.local, specifier.imported.name); } - } else if (isNode(value)) { - collectNameScopes(value, scope); } + } else if ( + (node.type === 'FunctionDeclaration' || + node.type === 'ClassDeclaration') && + isNode(node.id) && + node.id.type === 'Identifier' + ) { + bind(node.id, node.id.name); } }; - collectNameScopes(result.program, createScope(null, true)); - - const resolveName = ( - name: string, - scope: NameScope | undefined, - position: number, - seen: Set, - ): string | null => { - let current = scope; - while (current) { - const bindings = current.bindings.get(name); - if (bindings?.length) { - const initialized = bindings.filter( - (binding) => binding.initialized && binding.position <= position, - ); - const variable = initialized - .filter((binding) => binding.kind === 'variable') - .sort((a, b) => b.position - a.position)[0]; - const lexical = initialized - .filter((binding) => binding.kind === 'lexical') - .sort((a, b) => b.position - a.position)[0]; - const imported = initialized.find( - (binding) => binding.kind === 'import', - ); - const functionDeclaration = bindings - .filter((binding) => binding.kind === 'function') - .sort((a, b) => b.position - a.position)[0]; - const binding = variable ?? lexical ?? imported ?? functionDeclaration; - - if (!binding || !binding.value || seen.has(binding)) { - return null; - } - if (binding.value.type === 'name') { - return binding.value.value; + const walkNode = (node: Node): void => { + collectBindings(node); + const isFunction = functionTypes.has(node.type); + const opensScope = isFunction || blockTypes.has(node.type); + if (opensScope) { + scopes.push({ isFunction, names: new Map() }); + const params = + node.type === 'CatchClause' + ? [node.param] + : isFunction && 'params' in node && Array.isArray(node.params) + ? node.params + : []; + for (const param of params) { + if (isNode(param)) { + bind(param, null); } - - seen.add(binding); - return resolveName( - binding.value.name, - binding.value.scope, - binding.value.position, - seen, - ); } - current = current.parent ?? undefined; - } - return null; - }; - - const getFunctionName = ( - node: Node | undefined, - scope: NameScope | undefined, - position: number, - ): string | null => { - if ( - node?.type === 'FunctionExpression' || - node?.type === 'ClassExpression' - ) { - return isNode(node.id) && node.id.type === 'Identifier' - ? node.id.name - : ''; - } - if (node?.type === 'ArrowFunctionExpression') { - return ''; - } - if (node?.type !== 'Identifier') { - return null; } - return resolveName(node.name, scope, position, new Set()); - }; - const walkNode = (node: Node): void => { let exit: (() => void) | void | undefined; let functionTitleNode: Node | undefined; @@ -403,11 +257,7 @@ export const parseTestFile = ( exit = events.onTest( offsetToRange(node.start, node.end), getStringLiteralValue(node.arguments[0]) || - getFunctionName( - node.arguments[0], - scopesByNode.get(node), - node.start, - ) || + getFunctionName(node.arguments[0]) || 'unnamed test', functionName, ); @@ -427,6 +277,9 @@ export const parseTestFile = ( } exit?.(); + if (opensScope) { + scopes.pop(); + } }; walkNode(result.program); diff --git a/packages/vscode/tests/stacks/test/parse.test.ts b/packages/vscode/tests/stacks/test/parse.test.ts index b0d62f1..a157419 100644 --- a/packages/vscode/tests/stacks/test/parse.test.ts +++ b/packages/vscode/tests/stacks/test/parse.test.ts @@ -531,4 +531,95 @@ describe('outer', () => { }), ).toThrow(SyntaxError); }); + + it('should infer default import and namespace member titles', () => { + const names: string[] = []; + parseTestFile( + `import Foo from './foo'; + import * as mod from './foo'; + describe(Foo, () => {}); + test(mod.Bar, () => {}); + test(mod, () => {});`, + { + onTest: (_range, name) => { + names.push(name); + }, + }, + ); + expect(names).toEqual(['Foo', 'Bar', 'unnamed test']); + }); + + it('should match runtime function titles across declarations and assignments', () => { + const cases: Record = { + 'function declaration': `function Foo() {} describe(Foo, () => {});`, + 'hoisted function': `describe(Foo, () => {}); function Foo() {}`, + 'class declaration': `class Foo {} describe(Foo, () => {});`, + 'const arrow': `const foo = () => {}; describe(foo, () => {});`, + 'named expression': `const a = function b() {}; describe(a, () => {});`, + 'alias chain': `function Foo() {} const A = Foo; const B = A; describe(B, () => {});`, + 'inline arrow': `describe(() => {}, () => {});`, + 'inline named function': `describe(function Foo() {}, () => {});`, + 'inline anonymous class': `describe(class {}, () => {});`, + 'let assigned later': `let foo; foo = () => {}; describe(foo, () => {});`, + 'var reassigned': `var Foo = function A() {}; test(Foo, () => {}); Foo = function B() {}; describe(Foo, () => {});`, + 'block shadow': `function Foo() {} { const Foo = function Bar() {}; describe(Foo, () => {}); } describe(Foo, () => {});`, + 'object method': `const o = { m() {} }; describe(o.m, () => {});`, + 'nested title': `class Foo {} describe(Foo, () => { test(Foo, () => {}); });`, + 'class static block': `class Foo { static { describe(Foo, () => {}); } }`, + 'deferred class reference': `const go = () => describe(Foo, () => {}); class Foo {} go();`, + 'inferred expression names': `let Foo; Foo = function() {}; test(Foo, () => {}); Foo = class {}; test(Foo, () => {});`, + 'alias before reassignment': `let Foo = function A() {}; const Alias = Foo; Foo = function B() {}; test(Alias, () => {}); test(Foo, () => {});`, + 'var in block': `{ var Foo = function Bar() {}; } test(Foo, () => {});`, + 'assignment scope': `let Foo = function Outer() {}; { let Foo = function Inner() {}; Foo = function Changed() {}; test(Foo, () => {}); } test(Foo, () => {}); { Foo = function Updated() {}; } test(Foo, () => {});`, + }; + + for (const [label, code] of Object.entries(cases)) { + const runtime: string[] = []; + const register = (title: string | { name: string }) => { + runtime.push( + typeof title === 'string' ? title : title.name || '', + ); + }; + new Function('describe', 'test', code)( + (title: string | { name: string }, body: () => void) => { + register(title); + body(); + }, + register, + ); + + const names: string[] = []; + parseTestFile(code, { + onTest: (_range, name) => { + names.push(name); + }, + }); + expect(runtime.length).toBeGreaterThan(0); + expect({ label, names }).toEqual({ label, names: runtime }); + } + }); + + it('should keep unknown bindings from falling back to outer names', () => { + const names: string[] = []; + parseTestFile( + `function Foo() {} + { let Foo; test(Foo, () => {}); } + { const { value: Foo } = source; test(Foo, () => {}); } + const run = (Foo) => test(Foo, () => {}); + try {} catch (Foo) { test(Foo, () => {}); } + test(Foo, () => {});`, + { + onTest: (_range, name) => { + names.push(name); + }, + }, + ); + expect(names).toEqual([ + 'unnamed test', + 'unnamed test', + 'unnamed test', + 'unnamed test', + 'Foo', + ]); + }); }); From a5c430ae6e527ed9d0c6b0202b89ebce677cd472 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 29 Sep 2026 21:18:37 +0800 Subject: [PATCH 3/5] fix(vscode): scope function-title assignments and self-bindings --- packages/vscode/src/stacks/test/parserTest.ts | 36 ++++++++++++++++--- .../vscode/tests/stacks/test/parse.test.ts | 5 +++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/vscode/src/stacks/test/parserTest.ts b/packages/vscode/src/stacks/test/parserTest.ts index 0d948a5..157b63e 100644 --- a/packages/vscode/src/stacks/test/parserTest.ts +++ b/packages/vscode/src/stacks/test/parserTest.ts @@ -160,6 +160,16 @@ export const parseTestFile = ( continue; } const init = isNode(declaration.init) ? declaration.init : undefined; + if ( + node.kind === 'var' && + !init && + declaration.id.type === 'Identifier' && + scopes + .findLast((scope) => scope.isFunction) + ?.names.has(declaration.id.name) + ) { + continue; + } const name = init && declaration.id.type === 'Identifier' ? getFunctionName(init, declaration.id.name) @@ -171,10 +181,20 @@ export const parseTestFile = ( node.operator === '=' && node.left.type === 'Identifier' ) { - scopeOf(node.left.name)?.set( - node.left.name, - getFunctionName(node.right, node.left.name), - ); + // Walking a deferred body must not mutate its enclosing function's names. + for (let index = scopes.length - 1; index >= 0; index--) { + const scope = scopes[index]; + if (scope.names.has(node.left.name)) { + scope.names.set( + node.left.name, + getFunctionName(node.right, node.left.name), + ); + break; + } + if (scope.isFunction) { + break; + } + } } else if ( node.type === 'ImportDeclaration' && node.importKind !== 'type' && @@ -211,6 +231,14 @@ export const parseTestFile = ( const opensScope = isFunction || blockTypes.has(node.type); if (opensScope) { scopes.push({ isFunction, names: new Map() }); + if ( + (node.type === 'FunctionExpression' || + node.type === 'ClassExpression') && + isNode(node.id) && + node.id.type === 'Identifier' + ) { + bind(node.id, node.id.name); + } const params = node.type === 'CatchClause' ? [node.param] diff --git a/packages/vscode/tests/stacks/test/parse.test.ts b/packages/vscode/tests/stacks/test/parse.test.ts index a157419..d60e20b 100644 --- a/packages/vscode/tests/stacks/test/parse.test.ts +++ b/packages/vscode/tests/stacks/test/parse.test.ts @@ -571,6 +571,11 @@ describe('outer', () => { 'alias before reassignment': `let Foo = function A() {}; const Alias = Foo; Foo = function B() {}; test(Alias, () => {}); test(Foo, () => {});`, 'var in block': `{ var Foo = function Bar() {}; } test(Foo, () => {});`, 'assignment scope': `let Foo = function Outer() {}; { let Foo = function Inner() {}; Foo = function Changed() {}; test(Foo, () => {}); } test(Foo, () => {}); { Foo = function Updated() {}; } test(Foo, () => {});`, + 'deferred callback assignment': `let Title = function Before() {}; test('mutator', () => { Title = function After() {}; }); test(Title, () => {});`, + 'bare var redeclaration': `var Title = function Real() {}; test(Title, () => {}); var Title; test(Title, () => {});`, + 'function expression self binding': `let Inner; const setup = function Inner() { test(Inner, () => {}); }; setup();`, + 'class expression self binding': `let Named; const C = class Named { static { test(Named, () => {}); } };`, + 'function local assignment': `function setup() { let Title = function Before() {}; { Title = function After() {}; } test(Title, () => {}); } setup();`, }; for (const [label, code] of Object.entries(cases)) { From b843c9d150003f77e06e749701eab5f81758c411 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 29 Sep 2026 21:28:48 +0800 Subject: [PATCH 4/5] fix(vscode): scope static blocks and hoist function titles --- packages/vscode/src/stacks/test/parserTest.ts | 21 +++++++++++++++++++ .../vscode/tests/stacks/test/parse.test.ts | 13 +++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/stacks/test/parserTest.ts b/packages/vscode/src/stacks/test/parserTest.ts index 157b63e..36c126f 100644 --- a/packages/vscode/src/stacks/test/parserTest.ts +++ b/packages/vscode/src/stacks/test/parserTest.ts @@ -76,6 +76,7 @@ export const parseTestFile = ( ]); const blockTypes = new Set([ 'BlockStatement', + 'StaticBlock', 'ClassDeclaration', 'ClassExpression', 'ForStatement', @@ -252,6 +253,26 @@ export const parseTestFile = ( } } + // Hoisted declarations shadow outer names even before their walk position. + const statements = + node.type === 'Program' || + node.type === 'BlockStatement' || + node.type === 'StaticBlock' + ? node.body + : node.type === 'SwitchCase' + ? node.consequent + : []; + for (const statement of statements) { + const declaration = + statement.type === 'ExportNamedDeclaration' || + statement.type === 'ExportDefaultDeclaration' + ? statement.declaration + : statement; + if (declaration?.type === 'FunctionDeclaration') { + collectBindings(declaration); + } + } + let exit: (() => void) | void | undefined; let functionTitleNode: Node | undefined; diff --git a/packages/vscode/tests/stacks/test/parse.test.ts b/packages/vscode/tests/stacks/test/parse.test.ts index d60e20b..0555c19 100644 --- a/packages/vscode/tests/stacks/test/parse.test.ts +++ b/packages/vscode/tests/stacks/test/parse.test.ts @@ -576,10 +576,17 @@ describe('outer', () => { 'function expression self binding': `let Inner; const setup = function Inner() { test(Inner, () => {}); }; setup();`, 'class expression self binding': `let Named; const C = class Named { static { test(Named, () => {}); } };`, 'function local assignment': `function setup() { let Title = function Before() {}; { Title = function After() {}; } test(Title, () => {}); } setup();`, + 'deferred describe assignment': `let T = function Before() {}; describe('g', () => { T = function After() {}; }); test(T, () => {});`, + 'separate static blocks': `function Title() {} class C { static { let Title = function Inner() {}; test(Title, () => {}); } static { test(Title, () => {}); } }`, + 'hoisted local function': `const F = function Outer() {}; function setup() { test(F, () => {}); function F() {} } setup();`, + 'hoisted static block function': `const F = function Outer() {}; class C { static { test(F, () => {}); function F() {} } }`, + 'hoisted switch case function': `const F = function Outer() {}; switch (1) { case 1: test(F, () => {}); function F() {} }`, + 'nested deferred suites': `describe('outer', () => { describe('inner', () => { test('leaf', () => {}); }); });`, }; for (const [label, code] of Object.entries(cases)) { const runtime: string[] = []; + const pending: (() => void)[] = []; const register = (title: string | { name: string }) => { runtime.push( typeof title === 'string' ? title : title.name || '', @@ -588,10 +595,14 @@ describe('outer', () => { new Function('describe', 'test', code)( (title: string | { name: string }, body: () => void) => { register(title); - body(); + pending.push(body); }, register, ); + // Rstest defers suite bodies until the current level has registered. + for (const body of pending) { + body(); + } const names: string[] = []; parseTestFile(code, { From 592390030051fd851ca1fb8940f0dc078cd354e5 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 29 Sep 2026 21:40:40 +0800 Subject: [PATCH 5/5] fix(vscode): keep callback-local title assignments and hoist across switch cases --- packages/vscode/src/stacks/test/parserTest.ts | 11 +++--- .../vscode/tests/stacks/test/parse.test.ts | 36 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/vscode/src/stacks/test/parserTest.ts b/packages/vscode/src/stacks/test/parserTest.ts index 36c126f..f3750d8 100644 --- a/packages/vscode/src/stacks/test/parserTest.ts +++ b/packages/vscode/src/stacks/test/parserTest.ts @@ -182,10 +182,13 @@ export const parseTestFile = ( node.operator === '=' && node.left.type === 'Identifier' ) { - // Walking a deferred body must not mutate its enclosing function's names. + // Keep deferred writes to outer bindings in a function-local overlay. for (let index = scopes.length - 1; index >= 0; index--) { const scope = scopes[index]; - if (scope.names.has(node.left.name)) { + if ( + scope.names.has(node.left.name) || + (scope.isFunction && scopeOf(node.left.name)) + ) { scope.names.set( node.left.name, getFunctionName(node.right, node.left.name), @@ -259,8 +262,8 @@ export const parseTestFile = ( node.type === 'BlockStatement' || node.type === 'StaticBlock' ? node.body - : node.type === 'SwitchCase' - ? node.consequent + : node.type === 'SwitchStatement' + ? node.cases.flatMap((branch) => branch.consequent) : []; for (const statement of statements) { const declaration = diff --git a/packages/vscode/tests/stacks/test/parse.test.ts b/packages/vscode/tests/stacks/test/parse.test.ts index 0555c19..a54f7c0 100644 --- a/packages/vscode/tests/stacks/test/parse.test.ts +++ b/packages/vscode/tests/stacks/test/parse.test.ts @@ -582,20 +582,29 @@ describe('outer', () => { 'hoisted static block function': `const F = function Outer() {}; class C { static { test(F, () => {}); function F() {} } }`, 'hoisted switch case function': `const F = function Outer() {}; switch (1) { case 1: test(F, () => {}); function F() {} }`, 'nested deferred suites': `describe('outer', () => { describe('inner', () => { test('leaf', () => {}); }); });`, + 'callback-local assignment overlay': `let T = function Before() {}; describe('g', () => { T = function After() {}; test(T, () => {}); }); test(T, () => {});`, + 'hoisting across switch cases': `const F = function Outer() {}; switch (1) { case 1: test(F, () => {}); break; case 2: function F() {} }`, }; for (const [label, code] of Object.entries(cases)) { const runtime: string[] = []; + let suite: string[] = []; const pending: (() => void)[] = []; const register = (title: string | { name: string }) => { - runtime.push( - typeof title === 'string' ? title : title.name || '', - ); + const name = + typeof title === 'string' ? title : title.name || ''; + runtime.push([...suite, name].join(' > ')); + return name; }; new Function('describe', 'test', code)( (title: string | { name: string }, body: () => void) => { - register(title); - pending.push(body); + const name = register(title); + const parent = suite; + pending.push(() => { + suite = [...parent, name]; + body(); + suite = parent; + }); }, register, ); @@ -605,13 +614,24 @@ describe('outer', () => { } const names: string[] = []; + const parents: string[] = []; parseTestFile(code, { - onTest: (_range, name) => { - names.push(name); + onTest: (_range, name, type) => { + names.push([...parents, name].join(' > ')); + if (type === 'describe' || type === 'suite') { + parents.push(name); + return () => { + parents.pop(); + }; + } }, }); expect(runtime.length).toBeGreaterThan(0); - expect({ label, names }).toEqual({ label, names: runtime }); + // Discovery is depth-first; deferred collection differs in order, not ownership. + expect({ label, names: names.sort() }).toEqual({ + label, + names: runtime.sort(), + }); } });