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
2 changes: 1 addition & 1 deletion packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,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 thirteen ways (the "adaptations" below). When syncing upstream, preserve them. A fourteenth 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`). (6) The lint E2E runner (`e2e/lint/runSuite.ts`) gives Mocha a 120s default timeout instead of 2000ms, so hooks doing real workspace I/O (the router suite's `suiteSetup`) do not time out on Windows CI runners.
- **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`). (6) The lint E2E runner (`e2e/lint/runSuite.ts`) gives Mocha a 120s default timeout instead of 2000ms, so hooks doing real workspace I/O (the router suite's `suiteSetup`) do not time out on Windows CI runners. (7) The test worker's `ProgressReporter` (`stacks/test/worker/reporter.ts`) defers suite-result deliveries into a per-testPath queue and flushes them (in emission order) just before the file result posts, because @rstest/core 0.12.6's sink paces case-result deliveries against the extension host's round-trip but fires suite delivery immediately — an `afterAll` failure can fire while a case emitted before it is still queued inside core, and on a loaded host it then reached `run.failed` ahead of that case (macOS E2E `progress.test.ts` flipped `failedMessages[2]`). Core fans the file result out after its case chain drained, so the flush point is the one moment every preceding case result is known-delivered; `onTestRunEnd` flushes leftovers and a new attempt's `onTestFileStart`/`onTestFileReady` drops the previous attempt's queue. Upstream 1.0 fixed this at the sink level (`runnerEventSink` joins all file-scoped deliveries before `onTestFileResult`, #1875); offer that back (or this) upstream for the 0.12 line when syncing. The ported `workerReporter` unit test drops its suite-result ack assertion accordingly.

## The thirteen adaptations

Expand Down
2 changes: 1 addition & 1 deletion packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@
"@rslint/core": "^0.10.0",
"@rstackjs/load-config": "^1.0.2",
"@rstest/core": "^0.12.6",
"@swc-next/parser": "^0.4.1",
"@swc-next/parser": "^0.5.0",
"@types/istanbul-lib-report": "^3.0.3",
"@types/mocha": "^10.0.10",
"@types/node": "^26.6.6",
Expand Down
3 changes: 3 additions & 0 deletions packages/vscode/src/stacks/test/parserTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,13 @@ export const parseTestFile = (
): (() => void) | void;
},
) => {
// errorRecovery keeps 0.4.x behavior: return diagnostics in `result`
// instead of throwing (the 0.5.0 default throws a plain diagnostic object).
const result = parseSync(code, {
lang: 'tsx',
preserveParens: false,
sourceType: 'module',
errorRecovery: true,
});
const error = result.diagnostics.find(
(diagnostic) => diagnostic.severity === 'error',
Expand Down
103 changes: 94 additions & 9 deletions packages/vscode/src/stacks/test/worker/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,105 @@ import { masterApi } from '.';
export class ProgressReporter implements Reporter {
readonly flushOutputStreams = false;

onTestRunStart = () => masterApi.onTestRunStart();
onTestRunEnd = () => masterApi.onTestRunEnd();
onTestFileStart = (test: TestFileInfo) => masterApi.onTestFileStart(test);
onTestFileReady = (test: TestFileInfo) => masterApi.onTestFileReady(test);
onTestFileResult = (test: TestFileResult) => masterApi.onTestFileResult(test);
// Suite results post through the same extension-host round-trip as case
// results, but @rstest/core 0.12.6 only paces case-result deliveries (its
// `createRunnerEventSink` chains each case's fanout behind the previous
// delivery) and fires a suite delivery as soon as the runner hook returns.
// A suite hook failure (an `afterAll` throw) therefore fires while a case
// emitted before it may still be queued inside core, not yet handed to us;
// on a slow extension host that suite failure can then post ahead of the
// pending case failures of the same file, and the editor publishes
// `run.failed` in arrival order — the exact macOS CI flip in
// `e2e/rstest/suite/progress.test.ts`.
//
// Core cannot tell us what is still queued, and its `onTestFileResult`
// waits for the case chain before fanning out, so every case result has
// been delivered by file-result time. So suites are deferred per testPath
// and flushed (in emission order) just before the file result posts: the
// master always sees [cases..., suites..., file] — runner emission order —
// at any extension-host speed. `onTestRunEnd` flushes leftovers so a torn-
// down attempt never loses queued results; a new attempt's
// `onTestFileStart`/`onTestFileReady` (core re-runs a file only after a
// worker SIGKILL retry) drops a previous attempt's queue. This mirrors
// upstream 1.0's `runnerEventSink` (`joinHooks` before
// `onTestFileResult`, #1875); our pinned 0.12.6 joins case deliveries
// only. Offer this back upstream for the 0.12 line.
private pendingSuiteResults = new Map<string, TestResult[]>();

private async flushSuiteResults(testPath: string): Promise<void> {
const queue = this.pendingSuiteResults.get(testPath);
if (!queue) return Promise.resolve();
// Drop the queue first: a suite result arriving during the flush belongs
// to a new emission sequence and starts a fresh queue instead of being
// re-flushed by this loop.
this.pendingSuiteResults.delete(testPath);
await sequentially(queue, (result) => masterApi.onTestCaseResult(result));
}

onTestRunStart = () => {
// Earlier runs flushed by now or were torn down mid-flight; a fresh run
// must not inherit their queues (the master's TestRunReporter resets its
// per-run caches the same way).
this.pendingSuiteResults.clear();
return masterApi.onTestRunStart();
};
onTestRunEnd = async () => {
// Fallback for a file result that never came: flush in queue-arrival
// order (a crashed attempt may hold suites) before the run closes, so
// nothing queued is silently dropped. Snapshot the keys so a queue
// re-added during a flush (a late suite arrival) starts its own pass.
for (const testPath of [...this.pendingSuiteResults.keys()]) {
await this.flushSuiteResults(testPath);
}
return masterApi.onTestRunEnd();
};
onTestFileStart = (test: TestFileInfo) => {
// New attempt boundary: the previous attempt's queued suite results
// belong to a discarded run and must not leak into this one.
this.pendingSuiteResults.delete(test.testPath);
return masterApi.onTestFileStart(test);
};
onTestFileReady = (test: TestFileInfo) => {
this.pendingSuiteResults.delete(test.testPath);
return masterApi.onTestFileReady(test);
};
onTestFileResult = async (test: TestFileResult) => {
// Core drained its case chain by now (for this testPath), so every case
// result precedes the flushed suites and the file result.
await this.flushSuiteResults(test.testPath);
return masterApi.onTestFileResult(test);
};
onTestSuiteStart = (test: TestSuiteInfo) => masterApi.onTestSuiteStart(test);
// TestRunReporter delegates suite results to this async method without
// returning its promise, so call it directly to preserve acknowledgement.
onTestSuiteResult = (result: TestResult) =>
masterApi.onTestCaseResult(result);
onTestSuiteResult = (result: TestResult) => {
// Queue instead of posting; delivery happens with the file result.
// Returns immediately, so the returned promise no longer acknowledges
// the extension host's handling of this result (upstream's ported unit
// test is adapted accordingly in
// `tests/stacks/test/workerReporter.test.ts`).
let queue = this.pendingSuiteResults.get(result.testPath);
if (!queue) {
queue = [];
this.pendingSuiteResults.set(result.testPath, queue);
}
queue.push(result);
};
onTestCaseStart = (test: TestCaseInfo) => masterApi.onTestCaseStart(test);
// Case results stay awaitable master requests: core's delivery chain (and
// the file-result join) settles per delivery, so the extension-host ack is
// what paces the next case's posting path.
onTestCaseResult = (result: TestResult) => masterApi.onTestCaseResult(result);
}

const sequentially = <T>(items: T[], step: (item: T) => Promise<unknown>) => {
let chain: Promise<unknown> = Promise.resolve();
for (const item of items) {
// A step's rejection (a torn-down transport) must not break the queue:
// the step's own caller already handles reporting; keep flushing.
chain = chain.then(() => step(item).catch(() => {}));
}
return chain;
};

export class ProgressLogger {
outputStream = new Writable({
decodeStrings: false,
Expand Down
Loading
Loading