Skip to content

Commit 6b9bf56

Browse files
authored
feat(vscode): support function names as test titles (#78)
* 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. * refactor(vscode): resolve function titles in the discovery walk * fix(vscode): scope function-title assignments and self-bindings * fix(vscode): scope static blocks and hoist function titles * fix(vscode): keep callback-local title assignments and hoist across switch cases
1 parent 01c6ccf commit 6b9bf56

3 files changed

Lines changed: 480 additions & 8 deletions

File tree

‎packages/vscode/AGENTS.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
55
## The copies are intentional
66

77
- `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.
8-
- 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.
9-
- **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.
8+
- 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.
9+
- **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.
1010
- **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`).
1111

12-
## The eleven adaptations
12+
## The twelve adaptations
1313

1414
1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker.
1515
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). Both `rstack.rslint.corePath` and `rstack.rstest.corePath` accept a package directory or its `package.json`, resolving relative paths from the workspace folder; only Rstest expands `${workspaceFolder}`. Preserve these unified override names without legacy aliases when syncing.
@@ -25,6 +25,8 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
2525

2626
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.
2727

28+
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.
29+
2830
## Rules
2931

3032
- **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.

‎packages/vscode/src/stacks/test/parserTest.ts‎

Lines changed: 227 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,219 @@ export const parseTestFile = (
6565
.join('');
6666
};
6767

68+
// Test item names must match runtime `name || '<anonymous>'`. Record bindings
69+
// in walk order so later calls see assignments without a separate scope pass.
70+
type Scope = { isFunction: boolean; names: Map<string, string | null> };
71+
const scopes: Scope[] = [{ isFunction: true, names: new Map() }];
72+
const functionTypes = new Set([
73+
'FunctionDeclaration',
74+
'FunctionExpression',
75+
'ArrowFunctionExpression',
76+
]);
77+
const blockTypes = new Set([
78+
'BlockStatement',
79+
'StaticBlock',
80+
'ClassDeclaration',
81+
'ClassExpression',
82+
'ForStatement',
83+
'ForInStatement',
84+
'ForOfStatement',
85+
'SwitchStatement',
86+
'CatchClause',
87+
]);
88+
89+
const scopeOf = (name: string) =>
90+
scopes.findLast((scope) => scope.names.has(name))?.names;
91+
92+
const getFunctionName = (
93+
node: Node | undefined,
94+
inferred?: string,
95+
): string | null => {
96+
if (
97+
node?.type === 'FunctionExpression' ||
98+
node?.type === 'ClassExpression'
99+
) {
100+
return isNode(node.id) && node.id.type === 'Identifier'
101+
? node.id.name
102+
: (inferred ?? '<anonymous>');
103+
}
104+
if (node?.type === 'ArrowFunctionExpression') {
105+
return inferred ?? '<anonymous>';
106+
}
107+
if (node?.type === 'Identifier') {
108+
const names = scopeOf(node.name);
109+
// Unbound identifiers (including default imports) use a best-effort name;
110+
// known unknown values must not fall back through a shadowing binding.
111+
return names ? (names.get(node.name) ?? null) : node.name;
112+
}
113+
if (
114+
node?.type === 'MemberExpression' &&
115+
!node.computed &&
116+
node.property.type === 'Identifier'
117+
) {
118+
return node.property.name;
119+
}
120+
return null;
121+
};
122+
123+
const bind = (pattern: Node, name: string | null, isVar = false): void => {
124+
if (pattern.type === 'Identifier') {
125+
const scope = isVar
126+
? scopes.findLast((candidate) => candidate.isFunction)
127+
: scopes.at(-1);
128+
scope?.names.set(pattern.name, name);
129+
} else if (pattern.type === 'RestElement') {
130+
if (isNode(pattern.argument)) {
131+
bind(pattern.argument, null, isVar);
132+
}
133+
} else if (pattern.type === 'AssignmentPattern') {
134+
if (isNode(pattern.left)) {
135+
bind(pattern.left, null, isVar);
136+
}
137+
} else if (pattern.type === 'ArrayPattern') {
138+
for (const element of pattern.elements) {
139+
if (isNode(element)) {
140+
bind(element, null, isVar);
141+
}
142+
}
143+
} else if (pattern.type === 'ObjectPattern') {
144+
for (const property of pattern.properties) {
145+
if (!isNode(property)) {
146+
continue;
147+
}
148+
const value =
149+
property.type === 'RestElement' ? property.argument : property.value;
150+
if (isNode(value)) {
151+
bind(value, null, isVar);
152+
}
153+
}
154+
}
155+
};
156+
157+
const collectBindings = (node: Node): void => {
158+
if (node.type === 'VariableDeclaration') {
159+
for (const declaration of node.declarations) {
160+
if (!isNode(declaration) || !isNode(declaration.id)) {
161+
continue;
162+
}
163+
const init = isNode(declaration.init) ? declaration.init : undefined;
164+
if (
165+
node.kind === 'var' &&
166+
!init &&
167+
declaration.id.type === 'Identifier' &&
168+
scopes
169+
.findLast((scope) => scope.isFunction)
170+
?.names.has(declaration.id.name)
171+
) {
172+
continue;
173+
}
174+
const name =
175+
init && declaration.id.type === 'Identifier'
176+
? getFunctionName(init, declaration.id.name)
177+
: null;
178+
bind(declaration.id, name, node.kind === 'var');
179+
}
180+
} else if (
181+
node.type === 'AssignmentExpression' &&
182+
node.operator === '=' &&
183+
node.left.type === 'Identifier'
184+
) {
185+
// Keep deferred writes to outer bindings in a function-local overlay.
186+
for (let index = scopes.length - 1; index >= 0; index--) {
187+
const scope = scopes[index];
188+
if (
189+
scope.names.has(node.left.name) ||
190+
(scope.isFunction && scopeOf(node.left.name))
191+
) {
192+
scope.names.set(
193+
node.left.name,
194+
getFunctionName(node.right, node.left.name),
195+
);
196+
break;
197+
}
198+
if (scope.isFunction) {
199+
break;
200+
}
201+
}
202+
} else if (
203+
node.type === 'ImportDeclaration' &&
204+
node.importKind !== 'type' &&
205+
Array.isArray(node.specifiers)
206+
) {
207+
for (const specifier of node.specifiers) {
208+
if (!isNode(specifier) || !isNode(specifier.local)) {
209+
continue;
210+
}
211+
if (specifier.type === 'ImportNamespaceSpecifier') {
212+
bind(specifier.local, null);
213+
} else if (
214+
specifier.type === 'ImportSpecifier' &&
215+
specifier.importKind !== 'type' &&
216+
isNode(specifier.imported) &&
217+
specifier.imported.type === 'Identifier'
218+
) {
219+
bind(specifier.local, specifier.imported.name);
220+
}
221+
}
222+
} else if (
223+
(node.type === 'FunctionDeclaration' ||
224+
node.type === 'ClassDeclaration') &&
225+
isNode(node.id) &&
226+
node.id.type === 'Identifier'
227+
) {
228+
bind(node.id, node.id.name);
229+
}
230+
};
231+
68232
const walkNode = (node: Node): void => {
233+
collectBindings(node);
234+
const isFunction = functionTypes.has(node.type);
235+
const opensScope = isFunction || blockTypes.has(node.type);
236+
if (opensScope) {
237+
scopes.push({ isFunction, names: new Map() });
238+
if (
239+
(node.type === 'FunctionExpression' ||
240+
node.type === 'ClassExpression') &&
241+
isNode(node.id) &&
242+
node.id.type === 'Identifier'
243+
) {
244+
bind(node.id, node.id.name);
245+
}
246+
const params =
247+
node.type === 'CatchClause'
248+
? [node.param]
249+
: isFunction && 'params' in node && Array.isArray(node.params)
250+
? node.params
251+
: [];
252+
for (const param of params) {
253+
if (isNode(param)) {
254+
bind(param, null);
255+
}
256+
}
257+
}
258+
259+
// Hoisted declarations shadow outer names even before their walk position.
260+
const statements =
261+
node.type === 'Program' ||
262+
node.type === 'BlockStatement' ||
263+
node.type === 'StaticBlock'
264+
? node.body
265+
: node.type === 'SwitchStatement'
266+
? node.cases.flatMap((branch) => branch.consequent)
267+
: [];
268+
for (const statement of statements) {
269+
const declaration =
270+
statement.type === 'ExportNamedDeclaration' ||
271+
statement.type === 'ExportDefaultDeclaration'
272+
? statement.declaration
273+
: statement;
274+
if (declaration?.type === 'FunctionDeclaration') {
275+
collectBindings(declaration);
276+
}
277+
}
278+
69279
let exit: (() => void) | void | undefined;
280+
let functionTitleNode: Node | undefined;
70281

71282
if (node.type === 'CallExpression') {
72283
let functionName: string | undefined;
@@ -86,9 +297,20 @@ export const parseTestFile = (
86297
functionName === 'describe' ||
87298
functionName === 'suite'
88299
) {
300+
const title = node.arguments[0];
301+
if (
302+
isNode(title) &&
303+
(title.type === 'FunctionExpression' ||
304+
title.type === 'ClassExpression' ||
305+
title.type === 'ArrowFunctionExpression')
306+
) {
307+
functionTitleNode = title;
308+
}
89309
exit = events.onTest(
90310
offsetToRange(node.start, node.end),
91-
getStringLiteralValue(node.arguments[0]) || 'unnamed test',
311+
getStringLiteralValue(node.arguments[0]) ||
312+
getFunctionName(node.arguments[0]) ||
313+
'unnamed test',
92314
functionName,
93315
);
94316
}
@@ -97,7 +319,7 @@ export const parseTestFile = (
97319
for (const value of Object.values(node)) {
98320
if (Array.isArray(value)) {
99321
for (const child of value) {
100-
if (isNode(child)) {
322+
if (isNode(child) && child !== functionTitleNode) {
101323
walkNode(child);
102324
}
103325
}
@@ -107,6 +329,9 @@ export const parseTestFile = (
107329
}
108330

109331
exit?.();
332+
if (opensScope) {
333+
scopes.pop();
334+
}
110335
};
111336

112337
walkNode(result.program);

0 commit comments

Comments
 (0)