Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ten ways (the "adaptations" below). When syncing upstream, preserve them. An eleventh divergence is either a bug or must be added to this list.
- 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.
- **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 ten adaptations
## The eleven 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).
Expand All @@ -23,6 +23,8 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

10. **Test file ownership** (test) — publication mirrors the CLI per project. When a request carries the same file or the same case from several projects (merged gutter, run-at-cursor, run-current-file; all three profiles reach `startTestRun`), only items of the deepest project root run and the others are reported skipped to clear stale merged gutter state — implemented in `runRouting.ts`, `index.ts` (`startTestRun`), and `master.ts` (`RstestApi.project` made public). Explicit single-project selections, project/folder/Run All, and `rstack.rstest.runInTerminal` (VS Code supplies one explicitly picked item) keep CLI scope.

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.

## 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.
Expand Down
2 changes: 0 additions & 2 deletions packages/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ All settings live under the unified `rstack.*` namespace. There are no `rslint.*
| `rstack.rstest.debugNodeEnv` | `null` | Extra env when debugging tests. |
| `rstack.rstest.debugExclude` | `["<node_internals>/**"]` | Debug `skipFiles`. |
| `rstack.rstest.debugOutFiles` | `[]` | Debug `outFiles`. |
| `rstack.rstest.debuggerPort` | — | Debugger port. |
| `rstack.rstest.debuggerAddress` | — | Debugger address. |
| `rstack.rstest.terminalShellPath` | — | Shell used by **Run in Terminal**. |
| `rstack.rstest.terminalShellArgs` | `[]` | Shell args for **Run in Terminal**. |
| `rstack.fmt.enable` | `true` | Enable/disable the formatter integration. |
Expand Down
166 changes: 166 additions & 0 deletions packages/vscode/e2e/rstest/suite/debug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import assert from 'node:assert';
import net from 'node:net';
import path from 'node:path';
import vscode from 'vscode';
import {
FIXTURES_ROOT,
getRstestExports,
getTestItemByLabels,
waitFor,
} from './helpers';

suite('Rstest debug launch', () => {
for (const finish of ['continue', 'cancel', 'stop'] as const) {
test(`hits a breakpoint with port 9229 occupied and cleans up after ${finish}`, async () => {
const occupied = net.createServer();
await new Promise<void>((resolve, reject) => {
occupied.once('error', reject);
occupied.listen(9229, '127.0.0.1', resolve);
});
const cancellation = new vscode.CancellationTokenSource();
const sessions = new Map<string, vscode.DebugSession>();
const processIds = new Set<number>();
const disposables: vscode.Disposable[] = [];
const file = path.join(FIXTURES_ROOT, 'workspace-1/test/index.test.ts');
const breakpoint = new vscode.SourceBreakpoint(
new vscode.Location(vscode.Uri.file(file), new vscode.Position(5, 0)),
);
let stopped:
{ session: vscode.DebugSession; threadId: number } | undefined;
const passed: vscode.TestItem[] = [];
let ended = false;
let running: Promise<void> | undefined;
try {
disposables.push(
vscode.debug.onDidStartDebugSession((session) => {
sessions.set(session.id, session);
}),
vscode.debug.onDidTerminateDebugSession((session) =>
sessions.delete(session.id),
),
vscode.debug.registerDebugAdapterTrackerFactory('*', {
// js-debug resolves the public 'node' type to 'pwa-node'.
createDebugAdapterTracker: (session) => ({
onDidSendMessage: (message) => {
if (
message.type === 'event' &&
message.event === 'stopped' &&
message.body.reason === 'breakpoint'
) {
stopped = { session, threadId: message.body.threadId };
}
},
}),
}),
);
vscode.debug.addBreakpoints([breakpoint]);
const api = await getRstestExports();
const item = await waitFor(() =>
getTestItemByLabels(api.testController.items, [
'test',
'index.test.ts',
]),
);
const request = new vscode.TestRunRequest(
[item],
undefined,
api.debugProfile,
);
// A real TestRun is required for startDebugging's testRun association.
running = api.startTestRun(
request,
cancellation.token,
false,
(runRequest) => {
const run = api.testController.createTestRun(runRequest);
const originalPassed = run.passed.bind(run);
const originalEnd = run.end.bind(run);
run.passed = (test, duration) => {
passed.push(test);
originalPassed(test, duration);
};
run.end = () => {
ended = true;
originalEnd();
};
return run;
},
);
await waitFor(
() => assert.ok(stopped, 'debuggee should stop at the breakpoint'),
{ timeoutMs: 60_000 },
);
assert.ok(stopped);
const stack = await stopped.session.customRequest('stackTrace', {
threadId: stopped.threadId,
});
assert.equal(
path.normalize(stack.stackFrames[0].source.path),
path.normalize(file),
);
assert.equal(stack.stackFrames[0].line, 6);
const root = [...sessions.values()].find(
(session) => session.name === 'Rstest Debug',
);
assert.ok(root);
for (const session of sessions.values()) {
if (session.id === root.id) continue;
const evaluation = await session.customRequest('evaluate', {
expression: 'process.pid',
context: 'repl',
});
const pid = Number(evaluation.result);
assert.ok(Number.isInteger(pid) && pid > 0, evaluation.result);
processIds.add(pid);
}
assert.ok(
processIds.size >= 2,
'worker and pool child must be debugged',
);
if (finish === 'continue') {
await stopped.session.customRequest('continue', {
threadId: stopped.threadId,
});
} else if (finish === 'cancel') {
cancellation.cancel();
} else {
await vscode.debug.stopDebugging(root);
}
await waitFor(
() => {
assert.equal(ended, true, 'TestRun must end');
if (finish === 'continue') {
assert.deepEqual(passed.map((test) => test.label).sort(), [
'Index',
'index.test.ts',
'should add two numbers correctly',
'should test source code correctly',
]);
}
assert.equal(sessions.size, 0, 'all debug sessions must end');
for (const pid of processIds) {
assert.throws(
() => process.kill(pid, 0),
{ code: 'ESRCH' },
`process ${pid} must exit`,
);
}
},
{ timeoutMs: 60_000 },
);
await running;
} finally {
cancellation.cancel();
vscode.debug.removeBreakpoints([breakpoint]);
await Promise.all(
[...sessions.values()].map((session) =>
vscode.debug.stopDebugging(session),
),
);
for (const disposable of disposables) disposable.dispose();
cancellation.dispose();
await new Promise<void>((resolve) => occupied.close(() => resolve()));
}
});
}
});
1 change: 1 addition & 0 deletions packages/vscode/e2e/rstest/suite/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { RstackExtensionExports } from '../../../src/types';
export interface RstestExports {
testController: vscode.TestController;
runProfile: vscode.TestRunProfile;
debugProfile: vscode.TestRunProfile;
getResolvedRstestPath: (sourceUri: string) => string | undefined;
startTestRun: (
request: vscode.TestRunRequest,
Expand Down
12 changes: 0 additions & 12 deletions packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -274,18 +274,6 @@
"scope": "resource",
"markdownDescription": "When source maps are enabled, glob patterns locating the generated JavaScript files (maps to the debug session's `outFiles`)."
},
"rstack.rstest.debuggerPort": {
"order": 11,
"type": "number",
"scope": "resource",
"description": "Port the debugger attaches to. Defaults to Node's inspector behavior (9229, or a free port if taken)."
},
"rstack.rstest.debuggerAddress": {
"order": 12,
"type": "string",
"scope": "resource",
"description": "TCP/IP address the debugger attaches to. Defaults to localhost."
},
"rstack.rstest.terminalShellPath": {
"order": 13,
"type": "string",
Expand Down
3 changes: 0 additions & 3 deletions packages/vscode/src/stacks/test/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
fallback,
type InferOutput,
literal,
number,
object,
optional,
parse,
Expand All @@ -30,8 +29,6 @@ const configSchema = object({
nodeExecArgs: fallback(array(string()), []),
nodeEnv: fallback(optional(record(string(), string())), undefined),
debugNodeEnv: fallback(optional(record(string(), string())), undefined),
debuggerPort: fallback(optional(number()), undefined),
debuggerAddress: fallback(optional(string()), undefined),
debugExclude: fallback(array(string()), ['<node_internals>/**']),
debugOutFiles: fallback(array(string()), []),
configFileGlobPattern: fallback(array(string()), [
Expand Down
Loading
Loading