diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 3ac38af..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. -- **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. +- 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 1d94b4a..f3750d8 100644 --- a/packages/vscode/src/stacks/test/parserTest.ts +++ b/packages/vscode/src/stacks/test/parserTest.ts @@ -65,8 +65,219 @@ export const parseTestFile = ( .join(''); }; + // 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', + 'StaticBlock', + 'ClassDeclaration', + 'ClassExpression', + 'ForStatement', + 'ForInStatement', + 'ForOfStatement', + 'SwitchStatement', + 'CatchClause', + ]); + + const scopeOf = (name: string) => + scopes.findLast((scope) => scope.names.has(name))?.names; + + 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 bind = (pattern: Node, name: string | null, isVar = false): void => { + if (pattern.type === 'Identifier') { + 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)) { + bind(pattern.argument, null, isVar); + } + } else if (pattern.type === 'AssignmentPattern') { + if (isNode(pattern.left)) { + bind(pattern.left, null, isVar); + } + } else if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) { + if (isNode(element)) { + bind(element, null, isVar); + } + } + } else if (pattern.type === 'ObjectPattern') { + for (const property of pattern.properties) { + if (!isNode(property)) { + continue; + } + const value = + property.type === 'RestElement' ? property.argument : property.value; + if (isNode(value)) { + bind(value, null, isVar); + } + } + } + }; + + const collectBindings = (node: Node): void => { + if (node.type === 'VariableDeclaration') { + for (const declaration of node.declarations) { + if (!isNode(declaration) || !isNode(declaration.id)) { + 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) + : null; + bind(declaration.id, name, node.kind === 'var'); + } + } else if ( + node.type === 'AssignmentExpression' && + node.operator === '=' && + node.left.type === 'Identifier' + ) { + // 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) || + (scope.isFunction && scopeOf(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' && + Array.isArray(node.specifiers) + ) { + for (const specifier of node.specifiers) { + if (!isNode(specifier) || !isNode(specifier.local)) { + continue; + } + if (specifier.type === 'ImportNamespaceSpecifier') { + bind(specifier.local, null); + } else if ( + specifier.type === 'ImportSpecifier' && + specifier.importKind !== 'type' && + isNode(specifier.imported) && + specifier.imported.type === 'Identifier' + ) { + bind(specifier.local, specifier.imported.name); + } + } + } else if ( + (node.type === 'FunctionDeclaration' || + node.type === 'ClassDeclaration') && + isNode(node.id) && + node.id.type === 'Identifier' + ) { + bind(node.id, node.id.name); + } + }; + 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() }); + 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] + : isFunction && 'params' in node && Array.isArray(node.params) + ? node.params + : []; + for (const param of params) { + if (isNode(param)) { + bind(param, null); + } + } + } + + // 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 === 'SwitchStatement' + ? node.cases.flatMap((branch) => branch.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; if (node.type === 'CallExpression') { let functionName: string | undefined; @@ -86,9 +297,20 @@ 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]) || + 'unnamed test', functionName, ); } @@ -97,7 +319,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); } } @@ -107,6 +329,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 c346516..a54f7c0 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', () => { @@ -413,4 +531,131 @@ 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, () => {});`, + '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();`, + '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', () => {}); }); });`, + '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 }) => { + 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) => { + const name = register(title); + const parent = suite; + pending.push(() => { + suite = [...parent, name]; + body(); + suite = parent; + }); + }, + register, + ); + // Rstest defers suite bodies until the current level has registered. + for (const body of pending) { + body(); + } + + const names: string[] = []; + const parents: string[] = []; + parseTestFile(code, { + 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); + // Discovery is depth-first; deferred collection differs in order, not ownership. + expect({ label, names: names.sort() }).toEqual({ + label, + names: runtime.sort(), + }); + } + }); + + 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', + ]); + }); });