diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index a8f081e..dfeda2c 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -6,11 +6,9 @@ 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 ten ways (the "adaptations" below). When syncing upstream, preserve them. An eleventh divergence is either a bug or must be added to this list. -- **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. +- **Tracked upstream state.** `stacks/lint` is synced to web-infra-dev/rslint `packages/vscode-extension` at **39536fd6** (#1617 — per-document core resolution, `CoreResolver` + `RuntimeManager`, `corePath`, PnP removed) and **892482e0** (#1630 — `configPath` on `rslint/configRefresh`). Targeted later ports are **84f9c9b5** (#1967 — languageclient-owned live LSP tracing) and **b7176723** (#1951 — remove legacy JSON config watching); the Unicode BOM E2E comes from **5fc197a5** (#1560), with its native-config fixture shape from **b7176723**. `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. `stacks/test` tracks web-infra-dev/rstest `packages/vscode` through **8f945491** (#1729 — public programmatic API), **d2812754** (#1804 — quoted exact file filters), **99be33e8** (#1805 — public test listing), **88cd5f6d** (#1806 — browser projects in watch), and **994b77e0** (#1807 — public watch API). The earlier targeted lifecycle fix from **d82db4fc31a61ee74b2a74917f14a458e1bca419** is subsumed by this sync; our failed-project retry and worker-cleanup behavior remains 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`). -- **Targeted Rstest lifecycle port:** `RstestApi.getNormalizedConfig()` closes its worker in `finally`, including rejected config evaluation, matching web-infra-dev/rstest `packages/vscode/src/master.ts` at `d82db4fc31a61ee74b2a74917f14a458e1bca419`. This fixes a leak in our older copy; it is already fixed upstream. Dependency passes retry failed projects, including real config errors, while preserving single-flight loading and worker cleanup. - ## The ten adaptations 1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker. @@ -50,11 +48,12 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics. - The lint worker is deliberately vscode-free so it can move upstream whole. It takes explicit `--core` / `--config` native paths, writes logs only to stderr because stdout is LSP, and owns the Go child plus config/plugin lifecycles. Config edits use `rslint/configRefresh` with the same pinned path; a native ↔ bridged ownership change replaces the whole folder runtime because the supported config protocols lock that choice for the process lifetime. - The test × `rstack.config.*` bridge stays thin on purpose: it points the upstream machinery at rstack's shipped shim and lets the shim interpret the config inside the worker, same as the CLI. Bridged projects resolve `@rstest/core` from the resolved rstack package directory, mirroring lint, so rstack's dependency remains visible under isolated installs. Never re-implement rstack config semantics in the extension. +- Rstest's upstream VS Code extension deep-imports `quoteFilter` from core to mark exact file filters. The published package does not export that helper, so our copy lives in `stacks/test/vendored/coreInternals.ts` beside the other core internals; keep it byte-identical when syncing filter behavior. - The fmt stack is an LSP client: one `rs fmt --lsp` server per detected workspace folder, spawned at the **folder root** even when a deeper `rstack.config.*` exists. Deepest-config-wins was removed deliberately — `rs fmt` loads one config from its cwd with no upward walk, so anchoring deeper made the editor disagree with `rs fmt` in a terminal; a subproject that needs its own fmt config becomes its own workspace folder. The stack registers **no** `DocumentFormattingEditProvider`: the client registers the provider from the server's `documentFormattingProvider` capability, and adding one by hand would double-register. A config create/change/delete **restarts** the owning folder's server (the server caches its config for its process lifetime and has no config-change message), which is also why the stack watches `RSTACK_CONFIG_GLOB` itself instead of relying on detection — a detection signature records which config files exist, not their contents. A detection pass keeps healthy servers and restarts failed ones in place (`isFailedFmtState`) — lockfile events notify even when the folder set is unchanged, precisely so a completed install or upgrade is retried without a manual restart. There is no stdin fallback below `SUPPORT_MATRIX.rstack`; that is a version gate, not an omission. **Nested workspace folders are a documented limitation, by decision**: when a folder and its subdirectory are both workspace folders and both detect fmt, the parent's per-folder selector also matches the nested folder's files, and which server VS Code hands the request to is not defined — the supported shape is subprojects as _sibling_ workspace folders (or only the subproject opened), not parent-plus-child. Routing (lint's `WorkspaceDocumentRouter` shape) was considered and deferred. Why all of it: `docs/adr/0002-fmt-lsp-on-user-node-runtime.md`. - fmt importing `stacks/lint/LanguageServerProcessOwner.ts` is not a refactor across the copies: that file has no lint imports and no lint behaviour, it only owns the native children of one language client — including the ones vscode-languageclient's automatic restart creates, which is exactly the leak an ad-hoc copy would reintroduce. Lint's `ManagedLanguageClient` is _restated_ in `stacks/fmt/index.ts` instead, because importing it from `Rslint.ts` would couple fmt to the lint stack's runtime graph. Keep that line where it is: shared process ownership yes, shared stack runtime no. - The VSIX is platform-targeted for exactly one reason: the test stack's AST collection loads a native parser binding. Do not add another native dependency — it multiplies the release matrix. - `shared/nodeResolution.ts` takes its shell, its cwd and its notify callback as options instead of importing `vscode` and a stack's `logger` singleton, unlike its neighbours. That is not stylistic: it keeps `resolveUserNode` a pure decision table over its inputs, which is what makes the case-by-case unit tests possible without a `vscode` stub. It sat in `stacks/test/` until fmt became the second stack running project code on a User Node runtime — the exact condition its old note named — and moved on that trigger, not before. Its host-scoped preflight memo is reset by the shell's restart pass only when **no** consumer stack (`USER_NODE_STACKS`) survives the pass — a single-stack `rstack.fmt.restart` beside a live Rstest controller deliberately keeps the memo, since the survivor's existing workers were built on that decision; the full `rstack.restart` always clears it. `stacks/fmt/binEntry.ts` and `stacks/fmt/status.ts` are separate pure modules for the same testability reason — `stacks/fmt/index.ts` evaluates `vscode`, and `status.ts` pins the fold's invariant (a healthy sibling folder never masks another folder's failure) in a unit test the single-folder E2E fixtures cannot. -- The uniform Node floor deliberately exceeds `@rstest/core`'s own `engines` (`^20.19.0 || >=22.12.0`) and also mirrors the supported rstack line's `engines.node` (`rstack >= 0.7.0`). Do not specialise the floor per project — that was considered and rejected. Why, and what else was rejected: `docs/adr/0001-node-runtime-selection.md`. +- The uniform Node floor deliberately exceeds `@rstest/core`'s own `engines` (`^20.19.0 || >=22.12.0`) and also mirrors the supported rstack line's `engines.node` (`rstack >= 0.7.6`). Do not specialise the floor per project — that was considered and rejected. Why, and what else was rejected: `docs/adr/0001-node-runtime-selection.md`. - Bun is not a supported worker runtime (it segfaults running `@rstest/core`). If that is ever revisited, gate it on an explicit setting — never on `bun.lock`, since bun-as-package-manager still runs the `rs` bin through its `#!/usr/bin/env node` shebang. ## Testing diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 28a99e0..4c46b9c 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -36,11 +36,11 @@ A restart re-resolves every binary and package version and respawns every tool p The project-resolved packages are checked against a support matrix at runtime; a mismatch shows up as the `version mismatch` status bar state. -| Package | Required | -| -------------- | --------- | -| `@rslint/core` | `>=0.8.0` | -| `@rstest/core` | `>=0.6.0` | -| `rstack` | `>=0.7.0` | +| Package | Required | +| -------------- | ---------- | +| `@rslint/core` | `>=0.8.0` | +| `@rstest/core` | `>=0.12.0` | +| `rstack` | `>=0.7.6` | ## Auto-fix on save (Rslint) diff --git a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json index 1bd442c..5d9eb7e 100644 --- a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json +++ b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json @@ -5,6 +5,6 @@ "type": "module", "description": "E2E fixture: rs fmt config imports a package that is not installed.", "dependencies": { - "rstack": "0.7.4" + "rstack": "0.7.6" } } diff --git a/packages/vscode/e2e/fixtures/rstack/package.json b/packages/vscode/e2e/fixtures/rstack/package.json index df9ef16..f998217 100644 --- a/packages/vscode/e2e/fixtures/rstack/package.json +++ b/packages/vscode/e2e/fixtures/rstack/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "E2E fixture: an rstack-cli project whose only config is `rstack.config.ts`, which lights all three stacks.", "dependencies": { - "rstack": "0.7.4" + "rstack": "0.7.6" }, "devDependencies": { "jiti": "^2.0.0" diff --git a/packages/vscode/e2e/fixtures/rstest-ownership/package.json b/packages/vscode/e2e/fixtures/rstest-ownership/package.json index c87839d..d5cb395 100644 --- a/packages/vscode/e2e/fixtures/rstest-ownership/package.json +++ b/packages/vscode/e2e/fixtures/rstest-ownership/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "dependencies": { - "@rstest/core": "0.11.12", - "rstack": "0.7.4" + "@rstest/core": "0.12.0", + "rstack": "0.7.6" } } diff --git a/packages/vscode/e2e/fixtures/rstest/package.json b/packages/vscode/e2e/fixtures/rstest/package.json index a83a2af..1acd01d 100644 --- a/packages/vscode/e2e/fixtures/rstest/package.json +++ b/packages/vscode/e2e/fixtures/rstest/package.json @@ -5,6 +5,6 @@ "type": "module", "description": "E2E fixture: a project detected as Rstest only, installed from the npm registry.", "dependencies": { - "@rstest/core": "0.11.12" + "@rstest/core": "0.12.0" } } diff --git a/packages/vscode/e2e/rstest/fixtures/workspace-1/package.json b/packages/vscode/e2e/rstest/fixtures/workspace-1/package.json index f0ca18f..72c7172 100644 --- a/packages/vscode/e2e/rstest/fixtures/workspace-1/package.json +++ b/packages/vscode/e2e/rstest/fixtures/workspace-1/package.json @@ -4,6 +4,6 @@ "private": true, "description": "E2E fixture for the ported Rstest suites: upstream `tests/fixtures/workspace-1`, made self-contained on the published `@rstest/core`.", "dependencies": { - "@rstest/core": "0.11.12" + "@rstest/core": "0.12.0" } } diff --git a/packages/vscode/e2e/rstest/fixtures/workspace-2/package.json b/packages/vscode/e2e/rstest/fixtures/workspace-2/package.json index 28a75da..de32330 100644 --- a/packages/vscode/e2e/rstest/fixtures/workspace-2/package.json +++ b/packages/vscode/e2e/rstest/fixtures/workspace-2/package.json @@ -4,6 +4,6 @@ "private": true, "description": "E2E fixture for the ported Rstest suites: upstream `tests/fixtures/workspace-2`. One install at the root serves both nested projects via the normal node_modules walk-up.", "dependencies": { - "@rstest/core": "0.11.12" + "@rstest/core": "0.12.0" } } diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 6fa4ffd..9e9b86e 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -434,7 +434,7 @@ "@rslib/core": "^1.0.0", "@rslint/core": "^0.9.2", "@rstackjs/load-config": "^1.0.0", - "@rstest/core": "^0.11.12", + "@rstest/core": "^0.12.0", "@types/istanbul-lib-report": "^3.0.3", "@types/mocha": "^10.0.10", "@types/node": "^22.20.2", diff --git a/packages/vscode/src/shared/versionCheck.ts b/packages/vscode/src/shared/versionCheck.ts index 39063ed..201db7b 100644 --- a/packages/vscode/src/shared/versionCheck.ts +++ b/packages/vscode/src/shared/versionCheck.ts @@ -9,10 +9,12 @@ import { readPackageJson } from './packageResolve'; * * Launch floors (verified against npm): * - `@rslint/core >= 0.8.0` — explicit protocol-2 config selection. - * - `@rstest/core >= 0.6.0` — the existing `MIN_CORE_VERSION` upstream. - * - `rstack >= 0.7.0` — the first release whose lint shim sets `basePath` and - * itself pins `@rslint/core` 0.9.0, preserving project-relative config - * paths (#431). + * - `@rstest/core >= 0.12.0` — the first release exporting the public + * `@rstest/core/api` createRstest instance API; older cores lack it. + * - `rstack >= 0.7.6` — the first release pinning `@rstest/core ~0.12.0` + * (https://github.com/rstackjs/rstack-cli/releases/tag/v0.7.6). + * Bridged `rstack.config.*` projects resolve core from the rstack package + * directory, so earlier releases carry a core below the `@rstest/core` floor. * * The rstack floor is **uniform across consumers by decision**: lint, Rstest * and fmt all check the same entry, so "which rstack does the extension @@ -21,8 +23,8 @@ import { readPackageJson } from './packageResolve'; */ export const SUPPORT_MATRIX = { '@rslint/core': '>=0.8.0', - '@rstest/core': '>=0.6.0', - rstack: '>=0.7.0', + '@rstest/core': '>=0.12.0', + rstack: '>=0.7.6', } as const; export type SupportedPackage = keyof typeof SUPPORT_MATRIX; diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index c8abe8b..e07d5ac 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -8,7 +8,12 @@ import { logUnlessReported } from './coreResolution'; import { RstestDiagnostics } from './diagnostics'; import { TestErrorStore, testMessageText } from './errorStore'; import { logger } from './logger'; -import { runningWorkers, warmWorkerNodePreflight } from './master'; +import { + closeWorkerGracefully, + runningWorkers, + warmWorkerNodePreflight, +} from './master'; +import { quoteFilter } from './vendored/coreInternals'; import { NODE_EXECUTABLE_SETTING } from '../../shared/nodeResolution'; import { Project, WorkspaceManager } from './project'; import { routeToOwners } from './runRouting'; @@ -484,20 +489,18 @@ class Rstest implements vscode.Disposable { } else if (data instanceof ProjectFolder) { // grouping folder spans multiple projects; recurse into children await discoverTests(gatherTestItems(test.children, false)); - } else if (data instanceof TestFolder) { - await data.api.runTest({ - ...commonOptions, - fileFilter: data.uri.fsPath, - }); - } else if (data instanceof TestFile) { + } else if (data instanceof TestFile || data instanceof TestFolder) { await data.api.runTest({ ...commonOptions, - fileFilter: data.uri.fsPath, + fileFilter: + data instanceof TestFolder + ? data.uri.fsPath + : quoteFilter(data.uri.fsPath), }); } else if (data instanceof TestCase) { await data.api.runTest({ ...commonOptions, - fileFilter: data.uri.fsPath, + fileFilter: quoteFilter(data.uri.fsPath), testCaseNamePath: data.parentNames.concat(test.label), isSuite: data.type === 'suite', }); @@ -526,13 +529,12 @@ class Rstest implements vscode.Disposable { } }; - dispose() { + dispose(): void { this.disposed = true; // Upstream's `deactivate()`. A worker is a child process, so it outlives a // plain `TestController.dispose()` and has to be closed explicitly. - for (const worker of runningWorkers) { - worker.$close(); - } + // Start teardown without awaiting it so the shell's serialized restart queue stays responsive. + for (const worker of runningWorkers) void closeWorkerGracefully(worker); disposeTerminal(); for (const workspace of this.workspaces.values()) { workspace.dispose(); diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 3a2ea01..f41710e 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -1,5 +1,6 @@ -import { type ChildProcess, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { statSync } from 'node:fs'; +import { createRequire } from 'node:module'; import net from 'node:net'; import path, { dirname } from 'node:path'; import { type BirpcReturn, createBirpc } from 'birpc'; @@ -43,6 +44,8 @@ import { injectForceColor } from './shared/colorEnv'; import { NODE_RUNTIME_STATUS_SOURCE, status } from './status'; import { runInTerminal as sendToTerminal, shellQuote } from './terminal'; import { TestRunReporter } from './testRunReporter'; +import type { WorkerInitOptions } from './types'; +import { quoteFilter } from './vendored/coreInternals'; import { toErrorMessage } from './utils'; import type { Worker } from './worker'; @@ -55,7 +58,49 @@ const CORE_NOT_INSTALLED_STATUS = formatNotInstalledStatus( ); const CORE_NOT_INSTALLED_CONSEQUENCE = `install the project dependencies, or set "${CONFIG_SECTION}.rstestPackagePath" to an installed @rstest/core package.json`; -export const runningWorkers = new Set>(); +type WorkerRpc = BirpcReturn; +type RstestPaths = Pick; +export const runningWorkers = new Set(); +export const WATCHER_CLOSE_TIMEOUT_MS = 30_000; +const forceKilledWorkers = new WeakSet(); +const workerClosePromises = new WeakMap>(); + +export const closeWorkerGracefully = (worker: WorkerRpc): Promise => { + if (worker.$closed) return Promise.resolve(); + const pendingClose = workerClosePromises.get(worker); + if (pendingClose) return pendingClose; + const closePromise = (async () => { + let timer: NodeJS.Timeout | undefined; + let closeTimedOut = false; + try { + await Promise.race([ + Promise.resolve() + .then(() => worker.closeWatcher()) + .catch((error) => { + logger.warn('Failed to close the continuous test watcher', error); + }), + new Promise((resolve) => { + timer = setTimeout(() => { + closeTimedOut = true; + resolve(); + }, WATCHER_CLOSE_TIMEOUT_MS); + timer.unref(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + if (closeTimedOut) { + forceKilledWorkers.add(worker); + logger.warn( + 'Timed out waiting for the continuous test watcher to close; terminating the worker. Watcher teardown was skipped.', + ); + } + if (!worker.$closed) worker.$close(); + } + })(); + workerClosePromises.set(worker, closePromise); + return closePromise; +}; /** * The host-level inputs to the worker-node preflight. `notify` must not close @@ -125,10 +170,8 @@ const isPortAvailable = (port: number, host?: string): Promise => }); export class RstestApi { - private childProcesses = new Set(); - // Processes killed on purpose outside the `$close` → `off` path (dispose, - // failed debugger attach). Their `exit` events are not crashes. - private readonly expectedExits = new WeakSet(); + private workers = new Set(); + private disposePromise?: Promise; // Flipped by `dispose()` and checked wherever an await can outlive a // restart — see `reportNodeRuntimeIssue` and the spawn abort in // `createChildProcess`. @@ -359,10 +402,10 @@ export class RstestApi { return true; } - // Returns '' when resolution failed. Every such branch has already reported + // Returns undefined when resolution failed. Every such branch has already reported // itself — silently for a missing core, with a notification otherwise — so // callers must fail quietly rather than report again. - private resolveRstestPath(): string { + private resolveRstestPaths(): RstestPaths | undefined { try { const configured = this.resolveConfiguredPackageJson(); @@ -374,7 +417,7 @@ export class RstestApi { // fixed path, so cache staleness is moot. // `dirname` turns the package.json specifier into its package entry. nodeExport = this.resolveFromCwd(dirname(configured), configured); - if (!nodeExport) return ''; + if (!nodeExport) return undefined; try { corePackageJsonPath = nodeRequire.resolve(configured, { paths: [this.cwd], @@ -387,7 +430,7 @@ export class RstestApi { ) { logger.error('Failed to resolve @rstest/core/package.json', e); } - return ''; + return undefined; } } else { // The uncached walk-up runs first (see `findPackageJsonUncached`); @@ -401,7 +444,7 @@ export class RstestApi { ); if (!found) { this.reportCoreNotInstalled(this.rstestResolutionDir); - return ''; + return undefined; } corePackageJsonPath = found; nodeExport = this.resolveFromCwd( @@ -409,7 +452,7 @@ export class RstestApi { undefined, dirname(corePackageJsonPath), ); - if (!nodeExport) return ''; + if (!nodeExport) return undefined; } this.coreMissingEpisode.clear(); @@ -440,15 +483,21 @@ export class RstestApi { if (this.unsupportedCoreMessage.changed(message)) { logger.error(message); } - } else { - this.unsupportedCoreMessage.clear(); - status.versionOk(this.statusSource); + return undefined; } } + // Resolve through the core package's own exports, including configured + // copies outside node_modules that require package self-reference. + const apiPath = + createRequire(corePackageJsonPath).resolve('@rstest/core/api'); + if (!this.disposed) { + this.unsupportedCoreMessage.clear(); + status.versionOk(this.statusSource); + } this.lastResolvedRstestPath = nodeExport; this.resolutionErrorMessage.clear(); - return nodeExport; + return { rstestPath: nodeExport, apiPath }; } catch (e) { this.reportResolutionError(toErrorMessage(e)); throw e; @@ -485,10 +534,10 @@ export class RstestApi { } public async getNormalizedConfig() { - const { worker, rstestPath } = await this.createChildProcess(); + const { worker, ...paths } = await this.createChildProcess(); try { return await worker.getNormalizedConfig({ - rstestPath, + ...paths, configFilePath: this.configFilePath, }); } finally { @@ -496,16 +545,18 @@ export class RstestApi { } } - public async listTests(include?: string[]) { - const { worker, rstestPath } = await this.createChildProcess(); - const tests = await worker.listTests({ - rstestPath, - configFilePath: this.configFilePath, - include, - includeTaskLocation: true, - }); - worker.$close(); - return tests; + public async listTests(fileFilters?: string[]) { + const { worker, ...paths } = await this.createChildProcess(); + try { + return await worker.listTests({ + ...paths, + configFilePath: this.configFilePath, + fileFilters: fileFilters?.map(quoteFilter), + includeTaskLocation: true, + }); + } finally { + worker.$close(); + } } public async runTest({ @@ -553,21 +604,23 @@ export class RstestApi { this.project, testCaseNamePath, coverageEnabled, - onFinish, + // The worker RPC settles after post-report checks for one-shot runs and + // after the initial watch session is established for continuous runs. It + // also settles on startup failures that emit no reporter end event. + undefined, createTestRun, this.configFilePath, applyDiagnostic ? diagnostics : undefined, errorStore, ); - const { worker, rstestPath } = await this.createChildProcess( + const { worker, ...paths } = await this.createChildProcess( testRunReporter, kind === vscode.TestRunProfileKind.Debug, run, ); token.onCancellationRequested(() => { - worker.$close(); - onFinish(); + void closeWorkerGracefully(worker).finally(onFinish); }); void worker @@ -579,10 +632,11 @@ export class RstestApi { : undefined, update: updateSnapshot, configFilePath: this.configFilePath, - rstestPath, + ...paths, coverage: coverageEnabled ? { enabled: true } : undefined, includeTaskLocation: true, }) + .then(onFinish) .catch((error) => { if (!token.isCancellationRequested) { const message = toErrorMessage(error); @@ -712,8 +766,8 @@ export class RstestApi { // Resolved once per spawn and handed back to the caller: the callers' // worker requests need the same path, and re-resolving would repeat the // uncached `node_modules` walk (and its status reporting). - const rstestPath = this.resolveRstestPath(); - if (!rstestPath) { + const paths = this.resolveRstestPaths(); + if (!paths) { throw new ReportedRstestResolutionError(); } const debuggerPort = getConfigValue('debuggerPort', this.workspace); @@ -780,7 +834,6 @@ export class RstestApi { env: workerEnv, }, ); - this.childProcesses.add(rstestProcess); rstestProcess.stdout?.on('data', (d) => { const content = d.toString(); @@ -810,17 +863,20 @@ export class RstestApi { bind: 'functions', timeout: 600_000, off: () => { - rstestProcess.kill(); - this.childProcesses.delete(rstestProcess); + rstestProcess.kill( + forceKilledWorkers.has(worker) ? 'SIGKILL' : 'SIGTERM', + ); + this.workers.delete(worker); runningWorkers.delete(worker); }, }); + this.workers.add(worker); runningWorkers.add(worker); logger.debug('Sent init payload to worker', { root: this.cwd, - rstestPath, + ...paths, configFilePath: this.configFilePath, }); @@ -870,19 +926,12 @@ export class RstestApi { rstestProcess.on('exit', (code, signal) => { logger.debug('Worker process exited', { code, signal }); if (worker.$closed) return; - if (!this.expectedExits.has(rstestProcess)) { - // An exit nobody asked for: every deliberate teardown either runs - // `$close` first (its `off` handler kills after `$closed` flips) or - // marks the process in `expectedExits` before killing. The process - // *did* spawn — which cleared the crash latch — and nothing else - // will report; e.g. an invalid `nodeExecArgs` option makes Node exit - // right after a successful spawn, and without this the status keeps - // saying running over an empty Test Explorer. - status.crashed( - `worker process exited unexpectedly (code: ${String(code)}, signal: ${String(signal)})`, - this.statusSource, - ); - } + // Every deliberate teardown closes the RPC before killing the process. + // An exit reaching here must clear the otherwise-stale running status. + status.crashed( + `worker process exited unexpectedly (code: ${String(code)}, signal: ${String(signal)})`, + this.statusSource, + ); // Always unblock pending calls (and drop the worker from the tracking // set via `off`) when the worker exits before we closed it — expected // or not. @@ -894,42 +943,49 @@ export class RstestApi { // handled instead of throwing uncaught in the extension host. if (startDebugging) { const debugOutFiles = getConfigValue('debugOutFiles', this.workspace); - const startedDebugging = await vscode.debug.startDebugging( - this.workspace, - { - type: 'node', - name: 'Rstest Debug', - request: 'attach', - skipFiles: getConfigValue('debugExclude', this.workspace), - ...(debugOutFiles.length ? { outFiles: debugOutFiles } : {}), - ...(debuggerPort - ? { - port: debuggerPort, - address: debuggerAddress ?? DEFAULT_DEBUG_HOST, - } - : { processId: rstestProcess.pid }), - }, - { testRun }, - ); - if (!startedDebugging) { - this.expectedExits.add(rstestProcess); - rstestProcess.kill(); - throw new Error( - `Failed to attach debugger to test worker process (PID: ${rstestProcess.pid})`, + try { + const startedDebugging = await vscode.debug.startDebugging( + this.workspace, + { + type: 'node', + name: 'Rstest Debug', + request: 'attach', + skipFiles: getConfigValue('debugExclude', this.workspace), + ...(debugOutFiles.length ? { outFiles: debugOutFiles } : {}), + ...(debuggerPort + ? { + port: debuggerPort, + address: debuggerAddress ?? DEFAULT_DEBUG_HOST, + } + : { processId: rstestProcess.pid }), + }, + { testRun }, ); + if (this.disposed) { + throw new Error( + 'worker spawn aborted: this master was disposed while the debugger was attaching', + ); + } + if (!startedDebugging) { + throw new Error( + `Failed to attach debugger to test worker process (PID: ${rstestProcess.pid})`, + ); + } + } catch (error) { + if (!worker.$closed) worker.$close(); + throw error; } } - return { worker, rstestPath }; + return { worker, ...paths }; } - public dispose() { + public dispose(): Promise { this.disposed = true; status.forget(this.nodeRuntimeStatusSource); - for (const child of this.childProcesses) { - this.expectedExits.add(child); - child.kill(); - } - this.childProcesses.clear(); + this.disposePromise ??= Promise.all( + Array.from(this.workers, closeWorkerGracefully), + ).then(() => undefined); + return this.disposePromise; } } diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 0618685..00c70dc 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { isDeepStrictEqual } from 'node:util'; -import type { TestInfo } from '@rstest/core'; +import type { ListedTest } from '@rstest/core/api'; import picomatch from 'picomatch'; import { glob } from 'tinyglobby'; import vscode from 'vscode'; @@ -19,7 +19,13 @@ import { logger } from './logger'; import { RstestApi } from './master'; import { type ChildProjectRef, computeCoveredConfigs } from './projectCoverage'; import { status } from './status'; -import { ProjectFolder, TestFile, TestFolder, testData } from './testTree'; +import { + groupListedTestsByFile, + ProjectFolder, + TestFile, + TestFolder, + testData, +} from './testTree'; // The default config file name at the workspace root. A lone project using it // is shown without a project node (its test files sit directly under the root). @@ -49,7 +55,7 @@ export type ProjectSource = { */ readonly sourceUri: vscode.Uri; /** - * The config file handed to Rstest (`-c` / `initCli({ config })`). Defaults + * The config file handed to Rstest (`-c` / `loadConfig({ path })`). Defaults * to `sourceUri`; differs only for the rstack bridge, where it is * `/dist/rstestConfig.js`. */ @@ -772,7 +778,7 @@ export class Project implements vscode.Disposable { } dispose() { this.#watch?.dispose(); - this.api.dispose(); + void this.api.dispose(); this.cancellationSource.cancel(); // This project's failures must not outlive it (config removed, folder // closed, bridge rebuilt). The master latches under the source URI — @@ -801,7 +807,7 @@ export class Project implements vscode.Disposable { this.testItem.busy = true; } try { - const files: { uri: vscode.Uri; tests?: TestInfo[] }[] = + const files: { uri: vscode.Uri; tests?: ListedTest[] }[] = method === 'ast' ? // ast await glob(this.include, { @@ -814,12 +820,7 @@ export class Project implements vscode.Disposable { files.map((file) => ({ uri: vscode.Uri.file(file) })), ) : // runtime - await this.api.listTests().then((files) => - files.map((file) => ({ - uri: vscode.Uri.file(file.testPath), - tests: file.tests, - })), - ); + await this.api.listTests().then(groupListedTestsByFile); if (token.isCancellationRequested) return; @@ -850,11 +851,13 @@ export class Project implements vscode.Disposable { const updateOrCreateByRuntime = (uri: vscode.Uri) => { void this.api .listTests([uri.fsPath]) - .then((files) => { + .then((listedTests) => { if (token.isCancellationRequested) return; - for (const { testPath, tests } of files) { - const uri = vscode.Uri.file(testPath); - this.updateOrCreateFile(uri, tests); + for (const { uri: listedUri, tests } of groupListedTestsByFile( + listedTests, + [uri.fsPath], + )) { + this.updateOrCreateFile(listedUri, tests); } this.buildTree(); }) @@ -910,14 +913,14 @@ export class Project implements vscode.Disposable { return watcher; } // TODO pass cancellation token to updateFromDisk - private updateOrCreateFile(uri: vscode.Uri, tests?: TestInfo[]) { + private updateOrCreateFile(uri: vscode.Uri, tests?: ListedTest[]) { let data = this.testFiles.get(uri.toString()); if (!data) { data = new TestFile(this.api, uri, this.testController); this.testFiles.set(uri.toString(), data); } if (tests) { - data.updateFromList(tests); + data.updateFromListedTests(tests); } else { data.updateFromDisk(); } diff --git a/packages/vscode/src/stacks/test/projectCoverage.ts b/packages/vscode/src/stacks/test/projectCoverage.ts index 308df70..fe1baf7 100644 --- a/packages/vscode/src/stacks/test/projectCoverage.ts +++ b/packages/vscode/src/stacks/test/projectCoverage.ts @@ -45,7 +45,7 @@ type Node = { include: Set; }; -// Whether `parent` aggregates a nested intermediate config `child`. `initCli` +// Whether `parent` aggregates a nested intermediate config `child`. `resolveRunnerInputs` // flattens such a child to its leaf projects, so the child's own config file // never appears in `parent`'s footprint — but its leaves do. So the child is // covered when its own footprint is a subset of the parent's. When the two @@ -75,7 +75,7 @@ const aggregatesNestedConfig = (child: Node, parent: Node): boolean => { // config as a leaf — exact identity, so a directory holding several // configs is disambiguated for free); // - aggregates this config's own leaves (a nested intermediate config, whose -// own file `initCli` flattens away; see `aggregatesNestedConfig`). +// own file `resolveRunnerInputs` flattens away; see `aggregatesNestedConfig`). // In both cases the parent must also be able to *display* the child's files: // in AST mode a project only globs its own `include`, so a child whose include // patterns the parent does not also match is kept visible (its tests would diff --git a/packages/vscode/src/stacks/test/testTree.ts b/packages/vscode/src/stacks/test/testTree.ts index d65330a..3a27819 100644 --- a/packages/vscode/src/stacks/test/testTree.ts +++ b/packages/vscode/src/stacks/test/testTree.ts @@ -1,5 +1,6 @@ import { TextDecoder } from 'node:util'; import type { TestInfo } from '@rstest/core'; +import type { ListedTest } from '@rstest/core/api'; import vscode from 'vscode'; import { logger } from './logger'; import type { RstestApi } from './master'; @@ -55,6 +56,48 @@ export function gatherTestItems( return items; } +const createPreviousRangeLookup = (items: vscode.TestItem[]) => { + const previousRanges = new Map(); + const snapshot = (item: vscode.TestItem, idPath: string[]) => { + if (item.range) previousRanges.set(idPath.join('\x00'), item.range); + item.children.forEach((child) => snapshot(child, [...idPath, child.id])); + }; + items.forEach((item) => snapshot(item, [item.id])); + return (idPath: string[]) => previousRanges.get(idPath.join('\x00')); +}; + +const toVscodeRange = ( + location: { line: number; column: number } | undefined, +): vscode.Range | undefined => { + if (!location) return undefined; + const line = location.line - 1; + const column = location.column - 1; + return new vscode.Range(line, column, line, column); +}; + +export const groupListedTestsByFile = ( + tests: ListedTest[], + requestedFiles: string[] = [], +): Array<{ uri: vscode.Uri; tests: ListedTest[] }> => { + // Seed filtered refreshes so deleted or newly excluded files clear the tree. + const byFile = new Map( + requestedFiles.map((file) => [file, []]), + ); + // A file has one VS Code URI; render the first project's declaration tree. + const projects = new Map(); + for (const test of tests) { + if (!projects.has(test.testPath)) projects.set(test.testPath, test.project); + if (projects.get(test.testPath) !== test.project) continue; + const entries = byFile.get(test.testPath) ?? []; + byFile.set(test.testPath, entries); + if (test.type !== 'file') entries.push(test); + } + return Array.from(byFile, ([file, entries]) => ({ + uri: vscode.Uri.file(file), + tests: entries, + })); +}; + export class TestFolder { constructor( public api: RstestApi, @@ -155,13 +198,7 @@ export class TestFile { // collapsing to line 1, which would move every gutter icon to the imports. // Keys are the path of duplicate-aware item ids so that duplicate sibling // names each keep their own range. - const previousRanges = new Map(); - const rangeKey = (idPath: string[]) => idPath.join('\x00'); - const snapshot = (item: vscode.TestItem, idPath: string[]) => { - if (item.range) previousRanges.set(rangeKey(idPath), item.range); - item.children.forEach((child) => snapshot(child, [...idPath, child.id])); - }; - this.children.forEach((item) => snapshot(item, [item.id])); + const getPreviousRange = createPreviousRangeLookup(this.children); const handleChild = ( test: TestInfo, @@ -174,15 +211,7 @@ export class TestFile { ...parentIds, getTestItemId(test.name, siblingIndexOf(parent, test.name)), ]; - let range: vscode.Range | undefined; - if (test.location) { - // vscode location is zero based - const line = test.location.line - 1; - const column = test.location.column - 1; - range = new vscode.Range(line, column, line, column); - } else { - range = previousRanges.get(rangeKey(ids)); - } + const range = toVscodeRange(test.location) ?? getPreviousRange(ids); const testItem = this.onTest( range, test.name, @@ -210,6 +239,60 @@ export class TestFile { this.testItem?.children.replace(this.children); } + public updateFromListedTests(tests: ListedTest[]): void { + const getPreviousRange = createPreviousRangeLookup(this.children); + type Parent = { + names: string[]; + ids: string[]; + children: vscode.TestItem[]; + item?: vscode.TestItem; + }; + const root: Parent = { names: [], ids: [], children: [] }; + const parents: Parent[] = [root]; + const finalizeParent = (): void => { + const parent = parents.pop()!; + parent.item?.children.replace(parent.children); + }; + + for (const test of tests) { + const parentNames = test.parentNames ?? []; + const parentKey = parentNames.join('\x00'); + while ( + parents.length > 1 && + parents.at(-1)!.names.join('\x00') !== parentKey + ) { + finalizeParent(); + } + const parent = parents.at(-1)!; + // Empty-string names are valid declarations; only file rows lack a name. + if (test.name === undefined) continue; + const id = getTestItemId( + test.name, + siblingIndexOf(parent.children, test.name), + ); + const ids = [...parent.ids, id]; + const testItem = this.onTest( + toVscodeRange(test.location) ?? getPreviousRange(ids), + test.name, + test.type === 'suite' ? 'suite' : 'test', + parent.children, + parentNames, + ); + testItem.description = test.runMode; + if (test.type === 'suite') { + parents.push({ + names: [...parentNames, test.name], + ids, + children: [], + item: testItem, + }); + } + } + while (parents.length > 1) finalizeParent(); + this.children = root.children; + this.testItem?.children.replace(this.children); + } + private onTest( range: vscode.Range | undefined, name: string, diff --git a/packages/vscode/src/stacks/test/types.ts b/packages/vscode/src/stacks/test/types.ts index 9725d5a..df330ab 100644 --- a/packages/vscode/src/stacks/test/types.ts +++ b/packages/vscode/src/stacks/test/types.ts @@ -2,6 +2,7 @@ import type { RstestConfig } from '@rstest/core'; //#region master -> worker export type WorkerInitOptions = RstestConfig & { + apiPath: string; configFilePath: string; fileFilters?: string[]; rstestPath: string; diff --git a/packages/vscode/src/stacks/test/vendored/coreInternals.ts b/packages/vscode/src/stacks/test/vendored/coreInternals.ts index aeaadfb..bd71c07 100644 --- a/packages/vscode/src/stacks/test/vendored/coreInternals.ts +++ b/packages/vscode/src/stacks/test/vendored/coreInternals.ts @@ -2,9 +2,10 @@ * Vendored from `web-infra-dev/rstest` @ origin/main: * - `packages/core/src/utils/constants.ts` (`ROOT_SUITE_NAME`) * - `packages/core/src/utils/error.ts` (`parseErrorStacktrace`) + * - `packages/core/src/utils/helper.ts` (`quoteFilter`) * - * Upstream's VS Code extension lives in the same monorepo and deep-imports both - * from `../../core/src/...`. Neither is reachable from the published package: + * Upstream's VS Code extension lives in the same monorepo and deep-imports these + * from `../../core/src/...`. None is reachable from the published package: * `@rstest/core`'s exports map is `.`, `./api`, `./internal/adapter`, * `./internal/browser`, `./internal/browser-runtime`, `./package.json`, * `./globals`, `./importMeta` — so a standalone repo has to vendor them. @@ -26,6 +27,8 @@ import { parse as stackTraceParse, type StackFrame } from 'stacktrace-parser'; export const ROOT_SUITE_NAME = 'Rstest:_internal_root_suite'; +export const quoteFilter = (path: string): string => `"${path}"`; + const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file); const stackIgnores: (RegExp | string)[] = [ diff --git a/packages/vscode/src/stacks/test/worker/index.ts b/packages/vscode/src/stacks/test/worker/index.ts index fed7381..b912821 100644 --- a/packages/vscode/src/stacks/test/worker/index.ts +++ b/packages/vscode/src/stacks/test/worker/index.ts @@ -1,64 +1,67 @@ import { pathToFileURL } from 'node:url'; import { createBirpc } from 'birpc'; -import type { TestRunReporter } from '../testRunReporter'; import { missingDependencyCauseOf } from '../../../shared/missingDependency'; +import { SUPPORT_MATRIX } from '../../../shared/versionCheck'; +import type { TestRunReporter } from '../testRunReporter'; import type { NormalizedConfigResult, WorkerInitOptions } from '../types'; import { retractForceColorIfDisabled } from '../shared/colorEnv'; import { logger } from './logger'; import { CoverageReporter, ProgressLogger, ProgressReporter } from './reporter'; +type ActiveWatcher = { close(): Promise }; + // fix ESM import path issue on windows // Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. -const normalizeImportPath = (path: string) => { - return pathToFileURL(path).toString(); -}; +const normalizeImportPath = (path: string) => pathToFileURL(path).toString(); export class Worker { + private activeOneShotRun?: Promise; + private watcher?: ActiveWatcher; + private watcherClosePromise?: Promise; + private watcherStartupPromise?: Promise; + private async init({ + apiPath, configFilePath, fileFilters, rstestPath, command = 'run', ...overrideConfig }: WorkerInitOptions) { - const rstestModule = (await import( + const coreModule = (await import( normalizeImportPath(rstestPath) )) as typeof import('@rstest/core'); + const apiModule: Partial = await import( + normalizeImportPath(apiPath) + ); + if (typeof apiModule.createRstest !== 'function') { + throw new Error( + `@rstest/core at ${apiPath} does not export createRstest from "./api"; this extension requires @rstest/core ${SUPPORT_MATRIX['@rstest/core']}`, + ); + } logger.debug('Loaded Rstest module'); - const { createRstest, initCli } = rstestModule; + const { loadConfig, mergeRstestConfig } = coreModule; + const { createRstest } = apiModule; - const initializedOptions = await initCli({ - config: configFilePath, - }); - const { projects, config: initializedConfig } = initializedOptions; + const loaded = await loadConfig({ path: configFilePath }); // The config may have set NO_COLOR just now — the CLI's own decision // point is also right after config load (adaptation #9, colorEnv.ts). retractForceColorIfDisabled(process.env); - logger.debug('initializedOptions', initializedOptions); - - const rstest = createRstest( - { - config: { - ...initializedConfig, - ...overrideConfig, - reporters: [ - // place default reporter first to ensure output is flushed - ['default', { logger: new ProgressLogger() }], - new ProgressReporter(), - ], - coverage: { - ...initializedConfig.coverage, - ...overrideConfig.coverage, - }, - }, - configFilePath, - projects, + const config = mergeRstestConfig(loaded.content, { + ...overrideConfig, + reporters: [ + ['default', { logger: new ProgressLogger() }], + new ProgressReporter(), + ], + coverage: { + ...loaded.content.coverage, + ...overrideConfig.coverage, }, - command, - fileFilters ?? [], - ); - - return { rstest, projects }; + }); + const rstest = await createRstest({ + config: { content: config, filePath: loaded.filePath }, + }); + return { rstest, fileFilters, command }; } public async getNormalizedConfig( @@ -70,22 +73,22 @@ export class Worker { // own import of the same path is served from the module cache. await import(normalizeImportPath(options.rstestPath)); try { - const { rstest, projects } = await this.init(options); + const { rstest } = await this.init(options); + const { config } = rstest.context; return { ok: true, - root: rstest.context.normalizedConfig.root, - include: rstest.context.normalizedConfig.include, - exclude: rstest.context.normalizedConfig.exclude.patterns, - // Sub-projects this config aggregates via `projects`. Empty for a - // leaf config. The extension uses these to avoid registering a child - // config as its own top-level project when a parent already covers - // it (otherwise the same test files show up twice). A file-based - // child is identified by its config file; inline children only have - // a root. `null` (not `undefined`) so the fields survive the IPC - // JSON round-trip. - childProjects: projects.map((project) => ({ + root: rstest.context.rootPath, + include: config.include, + exclude: config.exclude.patterns, + // Sub-projects this config aggregates via `projects`. Empty for a leaf + // config. The extension uses these to avoid registering a child config + // as its own top-level project when a parent already covers it + // (otherwise the same test files show up twice). A file-based child is + // identified by its config file; inline children only have a root. + // `null` (not `undefined`) so the fields survive the IPC JSON round-trip. + childProjects: rstest.context.projects.map((project) => ({ configFilePath: project.configFilePath ?? null, - root: project.config.root ?? null, + root: project.rootPath, })), }; } catch (error) { @@ -93,39 +96,135 @@ export class Worker { // IPC round-trip. Only this unprompted, per-config evaluation gets the // treatment — a run or list the user asked for reports its failure. const cause = missingDependencyCauseOf(error); - if (cause !== undefined) { - return { ok: false, message: cause }; - } + if (cause !== undefined) return { ok: false, message: cause }; throw error; } } - public async runTest(data: WorkerInitOptions) { + public runTest(data: WorkerInitOptions): Promise { + const operation = this.executeTestRun(data); + if (data.command === 'watch') return operation; + this.activeOneShotRun = operation; + const clear = () => { + if (this.activeOneShotRun === operation) + this.activeOneShotRun = undefined; + }; + void operation.then(clear, clear); + return operation; + } + + private async executeTestRun(data: WorkerInitOptions): Promise { logger.debug('Received runTest request', JSON.stringify(data, null, 2)); try { - const { rstest } = await this.init(data); - if (data.coverage?.enabled) { - rstest.context.normalizedConfig.coverage.reporters.push( - new CoverageReporter(), + const { rstest, fileFilters, command } = await this.init({ + ...data, + coverage: data.coverage?.enabled + ? { ...data.coverage, reporters: [new CoverageReporter()] } + : data.coverage, + }); + const runOptions = { filters: fileFilters } satisfies NonNullable< + Parameters[0] + >; + if (command === 'watch') { + const startup = rstest.watch(runOptions); + this.watcherStartupPromise = startup; + try { + this.watcher = await startup; + this.watcherClosePromise = undefined; + logger.debug('Test run completed', { result: this.watcher }); + return; + } finally { + if (this.watcherStartupPromise === startup) { + this.watcherStartupPromise = undefined; + } + } + } + const result = await rstest.run(runOptions); + if (result.status === 'error') { + throw new Error( + result.unhandledErrors.map((error) => error.message).join('\n\n'), + ); + } + if ( + result.status === 'fail' && + result.summary.tests.failed === 0 && + result.summary.files.failed === 0 + ) { + throw new Error( + 'Rstest run failed without test-level failures. Check for operation-level failures such as coverage report errors or unmet coverage thresholds.', ); } - const res = await rstest.runTests(); - logger.debug('Test run completed', { result: res }); + logger.debug('Test run completed', { result }); } catch (error) { logger.error('Test run failed', error); throw error; } } + public async closeWatcher(): Promise { + if (this.activeOneShotRun) { + try { + await this.activeOneShotRun; + } catch { + // The runTest RPC owns reporting operation failures. Graceful shutdown + // only waits for the run's executor and teardown to settle. + } + } + let watcher = this.watcher; + if (!watcher && this.watcherStartupPromise) { + try { + watcher = await this.watcherStartupPromise; + } catch { + return; + } + } + if (!watcher) return; + this.watcherClosePromise ??= watcher.close().finally(() => { + this.watcher = undefined; + }); + await this.watcherClosePromise; + } + public async listTests(data: WorkerInitOptions) { - const { rstest } = await this.init({ ...data, command: 'list' }); - const res = await rstest.listTests({}); - return res; + const { rstest, fileFilters } = await this.init({ + ...data, + command: 'list', + }); + const filterOptions = { filters: fileFilters }; + const declarations = await rstest.listTests({ + ...filterOptions, + includeSuites: true, + includeTaskLocation: true, + }); + // A second call rebuilds the list engine, including planner/config/glob work. + // Filtered refreshes are covered by the caller's requestedFiles seed. + if (fileFilters) return declarations; + const files = await rstest.listTests({ ...filterOptions, filesOnly: true }); + return [...files, ...declarations]; } } -export const masterApi = createBirpc(new Worker(), { +const worker = new Worker(); +export const masterApi = createBirpc(worker, { post: (data) => process.send?.(data), on: (fn) => process.on('message', fn), bind: 'functions', }); + +if (process.argv[1] === __filename) { + let shutdownPromise: Promise | undefined; + const shutdown = () => { + // The master owns the 30-second grace period and uses SIGKILL if it expires. + // Once SIGTERM arrives, wait for the same idempotent close instead of + // truncating an in-flight teardown with a second one-second deadline. + shutdownPromise ??= worker + .closeWatcher() + .catch((error) => + logger.error('Failed to close the active watcher', error), + ) + .finally(() => process.exit()); + }; + process.once('disconnect', shutdown); + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); +} diff --git a/packages/vscode/src/stacks/test/worker/reporter.ts b/packages/vscode/src/stacks/test/worker/reporter.ts index 2e757fc..b3ed719 100644 --- a/packages/vscode/src/stacks/test/worker/reporter.ts +++ b/packages/vscode/src/stacks/test/worker/reporter.ts @@ -1,5 +1,12 @@ import { Writable } from 'node:stream'; -import type { Reporter } from '@rstest/core'; +import type { + Reporter, + TestCaseInfo, + TestFileInfo, + TestFileResult, + TestResult, + TestSuiteInfo, +} from '@rstest/core'; import type { Context, ReportBase, @@ -12,15 +19,18 @@ import { masterApi } from '.'; export class ProgressReporter implements Reporter { readonly flushOutputStreams = false; - onTestRunStart = masterApi.onTestRunStart.asEvent; - onTestRunEnd = () => masterApi.onTestRunEnd.asEvent(); - onTestFileStart = masterApi.onTestFileStart.asEvent; - onTestFileReady = masterApi.onTestFileReady.asEvent; - onTestFileResult = masterApi.onTestFileResult.asEvent; - onTestSuiteStart = masterApi.onTestSuiteStart.asEvent; - onTestSuiteResult = masterApi.onTestSuiteResult.asEvent; - onTestCaseStart = masterApi.onTestCaseStart.asEvent; - onTestCaseResult = masterApi.onTestCaseResult.asEvent; + onTestRunStart = () => masterApi.onTestRunStart(); + onTestRunEnd = () => masterApi.onTestRunEnd(); + onTestFileStart = (test: TestFileInfo) => masterApi.onTestFileStart(test); + onTestFileReady = (test: TestFileInfo) => masterApi.onTestFileReady(test); + onTestFileResult = (test: TestFileResult) => 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); + onTestCaseStart = (test: TestCaseInfo) => masterApi.onTestCaseStart(test); + onTestCaseResult = (result: TestResult) => masterApi.onTestCaseResult(result); } export class ProgressLogger { diff --git a/packages/vscode/tests/stacks/fmt/runtime.test.ts b/packages/vscode/tests/stacks/fmt/runtime.test.ts index 682db04..faf1b58 100644 --- a/packages/vscode/tests/stacks/fmt/runtime.test.ts +++ b/packages/vscode/tests/stacks/fmt/runtime.test.ts @@ -42,7 +42,7 @@ rs.mock('../../../src/shared/nodeResolution', () => ({ })); rs.mock('../../../src/shared/packageResolve', () => ({ findPackageJsonUncached: () => '/project/node_modules/rstack/package.json', - readPackageJson: () => ({ version: '0.7.2', bin: 'bin/rs.js' }), + readPackageJson: () => ({ version: '0.7.6', bin: 'bin/rs.js' }), })); rs.mock('../../../src/stacks/lint/LanguageServerProcessOwner', () => ({ LanguageServerProcessOwner: class { diff --git a/packages/vscode/tests/stacks/lint/coreResolver.test.ts b/packages/vscode/tests/stacks/lint/coreResolver.test.ts index 2434316..a012333 100644 --- a/packages/vscode/tests/stacks/lint/coreResolver.test.ts +++ b/packages/vscode/tests/stacks/lint/coreResolver.test.ts @@ -90,7 +90,7 @@ describe('CoreResolver', () => { // the core alone. const root = temporaryDirectory(); installPackage(root, '@rslint/core', '0.9.0'); - const rstack = installPackage(root, 'rstack', '0.7.2'); + const rstack = installPackage(root, 'rstack', '0.7.6'); const shimPath = installShim(rstack); const folder = folderOf(root); const resolver = new CoreResolver(); diff --git a/packages/vscode/tests/stacks/test/bridge.test.ts b/packages/vscode/tests/stacks/test/bridge.test.ts index b5ac5f2..ce7c666 100644 --- a/packages/vscode/tests/stacks/test/bridge.test.ts +++ b/packages/vscode/tests/stacks/test/bridge.test.ts @@ -43,7 +43,7 @@ const makeTmpDir = (): string => { * `dist/rstestConfig.js`. */ const createWorkspace = ({ - version = '0.7.2', + version = '0.7.6', shim = true, }: { version?: string | null; shim?: boolean } = {}): string => { const root = makeTmpDir(); @@ -101,7 +101,7 @@ describe('resolveRstackShim', () => { const shim = resolveRstackShim(configDir); expect(shim).toBeDefined(); - expect(shim?.version).toBe('0.7.2'); + expect(shim?.version).toBe('0.7.6'); // The same file `rs test` injects with `--config`. expect(shim?.configFilePath).toBe( path.join(configDir, 'node_modules', 'rstack', 'dist', 'rstestConfig.js'), @@ -197,14 +197,14 @@ describe('resolveRstackShim', () => { }); it('refuses an rstack older than the support matrix floor', () => { - const configDir = createWorkspace({ version: '0.6.5' }); + const configDir = createWorkspace({ version: '0.7.5' }); expect(resolveRstackShim(configDir)).toBeUndefined(); expect(reported).toEqual([ { kind: 'version-mismatch', detail: - 'rstack 0.6.5 is not supported, this extension requires >=0.7.0', + 'rstack 0.7.5 is not supported, this extension requires >=0.7.6', }, ]); }); diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index 802e0e4..5fb210b 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -1,11 +1,16 @@ -import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import fs from 'node:fs'; import { createRequire } from 'node:module'; +import { spawn } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, rs } from '@rstest/core'; import { logger } from '../../../src/stacks/test/logger'; -import { RstestApi } from '../../../src/stacks/test/master'; +import { + RstestApi, + runningWorkers, + WATCHER_CLOSE_TIMEOUT_MS, +} from '../../../src/stacks/test/master'; import { nodeRequire } from '../../../src/stacks/test/nodeRequire'; import { type NodeProbe, @@ -13,9 +18,19 @@ import { resetUserNodeCaches, } from '../../../src/shared/nodeResolution'; import { status } from '../../../src/stacks/test/status'; +import type { TestRunReporter } from '../../../src/stacks/test/testRunReporter'; +import type { WorkerInitOptions } from '../../../src/stacks/test/types'; +import { Worker } from '../../../src/stacks/test/worker'; import type { StackState, StatusReporter } from '../../../src/types'; import { createStatusRecorder } from './statusRecorder'; +rs.mock('node:child_process', () => { + const original = createRequire(__filename)( + 'node:child_process', + ) as typeof import('node:child_process'); + return { ...original, spawn: rs.fn(original.spawn) }; +}); + // The Rstest runner injects its own `@rstest/core` into every resolution path so // that test files can import it, which makes "the project has no @rstest/core" // impossible to stage in-process. `nodeRequire` is therefore wrapped: a lookup @@ -65,6 +80,7 @@ const loggedErrors: string[] = []; const loggedWarnings: string[] = []; const createdTerminals: string[] = []; const settings: Record = {}; +let startDebugging = async (): Promise => true; const channel = { debug: () => {}, @@ -84,6 +100,8 @@ logger.bind(channel as never); rs.mock('vscode', () => { const vscode = { TestRunProfileKind: { Run: 1, Debug: 2, Coverage: 3 }, + debug: { startDebugging: () => startDebugging() }, + env: { shell: '/bin/sh' }, FileCoverage: class {}, Position: class {}, Range: class {}, @@ -151,7 +169,7 @@ const createApi = (cwd = noCoreDir, rstestResolutionDir = cwd) => { ); }; -const writeCoreInstall = (root: string, version = '0.11.8') => { +const writeCoreInstall = (root: string, version = '0.12.0') => { const packageDir = path.join(root, 'node_modules', '@rstest', 'core'); const entry = path.join(packageDir, 'index.js'); const bin = path.join(packageDir, 'bin', 'rstest.js'); @@ -161,17 +179,25 @@ const writeCoreInstall = (root: string, version = '0.11.8') => { JSON.stringify({ name: '@rstest/core', version, - main: 'index.js', + exports: { + '.': './index.js', + './api': './api.js', + './package.json': './package.json', + }, bin: { rstest: 'bin/rstest.js' }, }), ); fs.writeFileSync(entry, 'module.exports = {};\n'); + fs.writeFileSync(path.join(packageDir, 'api.js'), 'module.exports = {};\n'); fs.writeFileSync(bin, '#!/usr/bin/env node\n'); return { packageDir, entry, bin }; }; const resolveRstestPaths = (api: RstestApi) => ({ - entry: (api as any).resolveRstestPath() as string, + paths: (api as any).resolveRstestPaths() as { + apiPath: string; + rstestPath: string; + }, bin: (api as any).resolveRstestBin() as string, }); @@ -202,7 +228,10 @@ describe('RstestApi package-resolution anchor', () => { writeCoreInstall(storeEntry); expect(resolveRstestPaths(createApi(cwd))).toEqual({ - entry: native.entry, + paths: { + apiPath: path.join(native.packageDir, 'api.js'), + rstestPath: native.entry, + }, bin: native.bin, }); }); @@ -212,7 +241,10 @@ describe('RstestApi package-resolution anchor', () => { const bridged = writeCoreInstall(storeEntry); expect(resolveRstestPaths(createApi(cwd, rstackDir))).toEqual({ - entry: bridged.entry, + paths: { + apiPath: path.join(bridged.packageDir, 'api.js'), + rstestPath: bridged.entry, + }, bin: bridged.bin, }); }); @@ -226,11 +258,30 @@ describe('RstestApi package-resolution anchor', () => { ); expect(resolveRstestPaths(createApi(cwd, rstackDir))).toEqual({ - entry: configured.entry, + paths: { + apiPath: path.join(configured.packageDir, 'api.js'), + rstestPath: configured.entry, + }, bin: configured.bin, }); }); + it('resolves the configured core API outside node_modules by self-reference', () => { + const installed = writeCoreInstall(root); + const packageDir = path.join(root, 'vendor', 'rstest-core'); + fs.mkdirSync(path.dirname(packageDir), { recursive: true }); + fs.renameSync(installed.packageDir, packageDir); + settings.rstestPackagePath = path.join(packageDir, 'package.json'); + + expect(resolveRstestPaths(createApi(cwd))).toEqual({ + paths: { + apiPath: path.join(packageDir, 'api.js'), + rstestPath: path.join(packageDir, 'index.js'), + }, + bin: path.join(packageDir, 'bin', 'rstest.js'), + }); + }); + it('deduplicates each unsupported-version message until a supported version resolves', () => { writeCoreInstall(cwd, '0.5.0'); const api = createApi(cwd); @@ -256,6 +307,28 @@ describe('RstestApi package-resolution anchor', () => { resolveRstestPaths(createApi(cwd)); expect(loggedErrors).toHaveLength(4); }); + + it('rejects 0.11 before spawning and reports only a version mismatch', async () => { + writeCoreInstall(cwd, '0.11.12'); + const recorder = createStatusRecorder(); + status.bind(recorder.reporter); + shownMessages.length = 0; + rs.mocked(spawn).mockClear(); + try { + await expect(createApi(cwd).createChildProcess()).rejects.toMatchObject({ + name: 'ReportedRstestResolutionError', + }); + expect(recorder.reported.at(-1)).toEqual({ + kind: 'version-mismatch', + detail: + '@rstest/core 0.11.12 is not supported, this extension requires >=0.12.0', + }); + expect(spawn).not.toHaveBeenCalled(); + expect(shownMessages).toEqual([]); + } finally { + status.unbind(); + } + }); }); describe('RstestApi with a missing @rstest/core', () => { @@ -372,7 +445,7 @@ describe('RstestApi with an unresolvable rstestPackagePath', () => { ); const installed = writeCoreInstall(root); const api = createApi(root); - const resolve = () => (api as any).resolveRstestPath() as string; + const resolve = () => (api as any).resolveRstestPaths(); try { expect(resolve).toThrow(); @@ -383,7 +456,7 @@ describe('RstestApi with an unresolvable rstestPackagePath', () => { installed.packageDir, 'package.json', ); - expect(resolve()).toBe(installed.entry); + expect(resolve().rstestPath).toBe(installed.entry); settings.rstestPackagePath = configured; expect(resolve).toThrow(); @@ -411,16 +484,16 @@ describe('RstestApi with an unresolvable rstestPackagePath', () => { return original(specifier, options); }); const api = createApi(root); - const resolve = () => (api as any).resolveRstestPath() as string; + const resolve = () => (api as any).resolveRstestPaths(); try { - expect(resolve()).toBe(''); - expect(resolve()).toBe(''); + expect(resolve()).toBeUndefined(); + expect(resolve()).toBeUndefined(); expect(shownMessages).toHaveLength(1); expect(loggedErrors).toHaveLength(1); broken = false; - expect(resolve()).toBe(installed.entry); + expect(resolve().rstestPath).toBe(installed.entry); broken = true; - expect(resolve()).toBe(''); + expect(resolve()).toBeUndefined(); expect(shownMessages).toHaveLength(2); expect(loggedErrors).toHaveLength(2); } finally { @@ -540,9 +613,12 @@ describe('RstestApi with a configured nodeExecutable', () => { // resolution failure. await seedProbe({ kind: 'ok', version: '24.3.0' }); const api = createApi(packageDir); + const spawnMock = rs.mocked(spawn); + spawnMock.mockClear(); const spawning = api.createChildProcess(); api.dispose(); await expect(spawning).rejects.toThrow('disposed'); + expect(spawnMock).not.toHaveBeenCalled(); }); }); @@ -618,22 +694,26 @@ describe('RstestApi worker spawn failures', () => { 'console.log("worker-up"); setInterval(() => {}, 1000)', ]; api = createApi(packageDir); - - await api.createChildProcess(); - const child = [...((api as any).childProcesses as Set)][0]!; - // Await the child's first stdout chunk, not the 'spawn' event: Node gives - // no timing guarantee for 'spawn' relative to this continuation, while - // stream data is buffered until a listener attaches — and 'spawn' (which - // precedes all other events, setting the handler's latch) is guaranteed - // delivered by the time data flows. - await new Promise((resolve) => { - child.stdout?.on('data', () => resolve()); - }); - - child.emit('error', new Error('write EPIPE')); - - expect(shownMessages).toEqual([]); - expect(crashes()).toEqual([]); + const spawnMock = rs.mocked(spawn); + spawnMock.mockClear(); + try { + const { worker } = await api.createChildProcess(); + const child = spawnMock.mock.results[0].value!; + // Await the child's first stdout chunk, not the 'spawn' event: Node gives + // no timing guarantee for 'spawn' relative to this continuation, while + // stream data is buffered until a listener attaches — and 'spawn' (which + // precedes all other events, setting the handler's latch) is guaranteed + // delivered by the time data flows. + await new Promise((resolve) => { + child.stdout?.on('data', () => resolve()); + }); + child.emit('error', new Error('write EPIPE')); + expect(shownMessages).toEqual([]); + expect(crashes()).toEqual([]); + worker.$close(); + } finally { + spawnMock.mockClear(); + } }); }); @@ -641,6 +721,7 @@ it('closes the config worker when config evaluation rejects', async () => { const api = createApi(); const close = rs.fn(); rs.spyOn(api, 'createChildProcess').mockResolvedValue({ + apiPath: '/project/rstest/api', rstestPath: '/project/rstest', worker: { getNormalizedConfig: async () => { @@ -653,6 +734,555 @@ it('closes the config worker when config evaluation rejects', async () => { await expect(api.getNormalizedConfig()).rejects.toThrow('Invalid config'); expect(close).toHaveBeenCalledTimes(1); } finally { - api.dispose(); + await api.dispose(); } }); + +describe('RstestApi graceful disposal', () => { + afterEach(() => { + rs.useRealTimers(); + }); + + it('waits for watcher teardown before terminating the worker', async () => { + const api = createApi(); + const order: string[] = []; + const teardown = Promise.withResolvers(); + const worker = { + closeWatcher: rs.fn(async () => { + await teardown.promise; + order.push('teardown'); + }), + $close: rs.fn(() => order.push('kill')), + }; + (api as any).workers = new Set([worker]); + + const disposal = api.dispose(); + await Promise.resolve(); + expect(order).toEqual([]); + + teardown.resolve(); + await disposal; + + expect(worker.closeWatcher).toHaveBeenCalledTimes(1); + expect(order).toEqual(['teardown', 'kill']); + }); +}); + +class MockRstestProcess extends EventEmitter { + static nextPid = 10_000; + connected = true; + respondToClose = true; + killSignals: (NodeJS.Signals | number | undefined)[] = []; + pid = MockRstestProcess.nextPid++; + stderr = new EventEmitter(); + stdout = new EventEmitter(); + + send(data: unknown): boolean { + const request = data as { i?: string; m?: string; t?: string }; + if ( + this.respondToClose && + request.t === 'q' && + request.i && + request.m === 'closeWatcher' + ) { + this.emit('message', { t: 's', i: request.i, r: undefined }); + } + return true; + } + + kill(signal?: NodeJS.Signals | number): boolean { + this.killSignals.push(signal); + this.connected = false; + queueMicrotask(() => this.emit('exit', 0, signal)); + return true; + } +} + +const spawnedProcesses: MockRstestProcess[] = []; +const realSpawn = createRequire(__filename)('node:child_process') + .spawn as typeof spawn; + +const mockWorker = ( + api: RstestApi, + runTest: (data: WorkerInitOptions) => Promise = async () => {}, +) => { + const worker = { + $close: rs.fn(), + closeWatcher: rs.fn(async () => {}), + listTests: rs.fn(async () => []), + runTest: rs.fn(runTest), + }; + rs.spyOn(api, 'createChildProcess').mockResolvedValue({ + worker, + apiPath: '/rstest/api.js', + rstestPath: '/rstest/index.js', + } as any); + return worker; +}; + +const createInFlightOneShotWorker = (shouldReject = false) => { + const order: string[] = []; + const runStarted = Promise.withResolvers(); + const runFinished = Promise.withResolvers(); + const worker = new Worker(); + rs.spyOn(worker as any, 'init').mockResolvedValue({ + command: 'run', + fileFilters: undefined, + rstest: { + run: async () => { + runStarted.resolve(); + await runFinished.promise; + order.push('teardown'); + if (shouldReject) throw new Error('test run failed'); + return { status: 'pass', unhandledErrors: [] }; + }, + }, + }); + return { + worker, + order, + started: runStarted.promise, + finish: runFinished.resolve, + }; +}; + +const createRunContext = () => { + const output: string[] = []; + let cancellationHandler: (() => void) | undefined; + const token = { + isCancellationRequested: false, + onCancellationRequested: (handler: () => void) => { + cancellationHandler = handler; + return { dispose: () => {} }; + }, + }; + return { + output, + run: { appendOutput: (message: string) => output.push(message) } as any, + token: token as any, + cancel() { + token.isCancellationRequested = true; + cancellationHandler?.(); + }, + }; +}; + +describe('Rstest public API', () => { + beforeEach(async () => { + spawnedProcesses.length = 0; + shownMessages.length = 0; + loggedWarnings.length = 0; + startDebugging = async () => true; + resetUserNodeCaches(); + settings['rstack.nodeExecutable'] = process.execPath; + await configuredNodeBelowFloor(process.execPath, { + probe: async () => ({ kind: 'ok', version: '24.0.0' }), + }); + rs.mocked(spawn).mockImplementation(() => { + const child = new MockRstestProcess(); + spawnedProcesses.push(child); + return child as never; + }); + }); + + afterEach(() => { + status.unbind(); + resetUserNodeCaches(); + rs.useRealTimers(); + rs.restoreAllMocks(); + rs.mocked(spawn).mockImplementation(realSpawn); + for (const key of Object.keys(settings)) delete settings[key]; + }); + + describe('Rstest public test listing', () => { + it('quotes file paths for targeted runtime discovery', async () => { + const api = createApi(); + const worker = mockWorker(api); + await api.listTests(['/x/file.test.ts']); + expect(worker.listTests).toHaveBeenCalledWith( + expect.objectContaining({ fileFilters: ['"/x/file.test.ts"'] }), + ); + }); + + it('returns file rows together with declarations for full discovery', async () => { + const testPath = '/x/empty.test.ts'; + const declaration = { + fullName: 'case', + name: 'case', + parentNames: [], + project: 'rstest', + testPath, + type: 'case', + } as const; + const file = { project: 'rstest', testPath, type: 'file' } as const; + const listTests = rs.fn(async ({ filesOnly }: { filesOnly?: boolean }) => + filesOnly ? [file] : [declaration], + ); + const worker = new Worker(); + rs.spyOn(worker as any, 'init').mockResolvedValue({ + fileFilters: undefined, + rstest: { listTests }, + }); + await expect(worker.listTests({} as WorkerInitOptions)).resolves.toEqual([ + file, + declaration, + ]); + expect(listTests).toHaveBeenCalledWith({ + filesOnly: true, + filters: undefined, + }); + }); + + it('collects declarations only for filtered refreshes', async () => { + const testPath = '/x/example.test.ts'; + const declaration = { + fullName: 'case', + name: 'case', + parentNames: [], + project: 'rstest', + testPath, + type: 'case', + } as const; + const listTests = rs.fn(async () => [declaration]); + const worker = new Worker(); + rs.spyOn(worker as any, 'init').mockResolvedValue({ + fileFilters: [`"${testPath}"`], + rstest: { listTests }, + }); + await expect(worker.listTests({} as WorkerInitOptions)).resolves.toEqual([ + declaration, + ]); + expect(listTests).toHaveBeenCalledTimes(1); + expect(listTests).toHaveBeenCalledWith({ + filters: [`"${testPath}"`], + includeTaskLocation: true, + includeSuites: true, + }); + }); + + it('closes the worker when test collection rejects', async () => { + const api = createApi(); + const worker = { + $close: rs.fn(() => runningWorkers.delete(worker as any)), + listTests: rs.fn(async () => { + throw new Error('Test collection failed.'); + }), + }; + runningWorkers.add(worker as any); + rs.spyOn(api, 'createChildProcess').mockResolvedValue({ + worker, + apiPath: '/rstest/api.js', + rstestPath: '/rstest/index.js', + } as any); + await expect(api.listTests()).rejects.toThrow('Test collection failed.'); + expect(worker.$close).toHaveBeenCalledTimes(1); + }); + }); + + describe('Rstest public run lifecycle', () => { + it.each([ + { filter: '/x/tests', kind: 'folder' }, + { filter: '"/x/tests/file.test.ts"', kind: 'file' }, + ])('forwards the $kind path without a filter mode', async ({ filter }) => { + const api = createApi(); + const engineRun = rs.fn(async () => ({ + status: 'pass', + unhandledErrors: [], + })); + const coreWorker = new Worker(); + rs.spyOn(coreWorker as any, 'init').mockResolvedValue({ + command: 'run', + fileFilters: [filter], + rstest: { run: engineRun }, + }); + const worker = mockWorker(api, (data) => coreWorker.runTest(data)); + const { run, token } = createRunContext(); + await api.runTest({ fileFilter: filter, run, token }); + expect(worker.runTest).toHaveBeenCalledWith( + expect.objectContaining({ fileFilters: [filter] }), + ); + expect(engineRun).toHaveBeenCalledWith({ filters: [filter] }); + }); + + it('finishes when a worker resolves without a reporter end event', async () => { + const api = createApi(); + const worker = mockWorker(api); + const { run, token } = createRunContext(); + await expect(api.runTest({ run, token })).resolves.toBeUndefined(); + expect(worker.$close).toHaveBeenCalledTimes(1); + }); + + it('surfaces every unhandled error from a one-shot run', async () => { + const api = createApi(); + const coreWorker = new Worker(); + rs.spyOn(coreWorker as any, 'init').mockResolvedValue({ + command: 'run', + fileFilters: undefined, + rstest: { + run: async () => ({ + status: 'error', + unhandledErrors: [ + { message: 'Build failed' }, + { message: 'Invalid config' }, + ], + }), + }, + }); + const worker = mockWorker(api, (data) => coreWorker.runTest(data)); + const { output, run, token } = createRunContext(); + await api.runTest({ run, token }); + expect(worker.$close).toHaveBeenCalledTimes(1); + expect(output.join('')).toContain('Build failed\r\n\r\nInvalid config'); + expect(shownMessages).toContain( + 'Rstest test run failed: Build failed\n\nInvalid config', + ); + }); + + it('does not surface ordinary test failures as a global run error', async () => { + const api = createApi(); + const coreWorker = new Worker(); + rs.spyOn(coreWorker as any, 'init').mockResolvedValue({ + command: 'run', + fileFilters: undefined, + rstest: { + run: async () => ({ + status: 'fail', + summary: { tests: { failed: 1 }, files: { failed: 1 } }, + unhandledErrors: [], + }), + }, + }); + mockWorker(api, (data) => coreWorker.runTest(data)); + const { output, run, token } = createRunContext(); + await api.runTest({ run, token }); + expect(output).toEqual([]); + expect(shownMessages).toEqual([]); + }); + + it('waits for an active one-shot run when cancellation closes the worker', async () => { + const api = createApi(); + const { + worker: coreWorker, + order, + started, + finish, + } = createInFlightOneShotWorker(); + const worker = mockWorker(api, (data) => coreWorker.runTest(data)); + worker.closeWatcher.mockImplementation(() => coreWorker.closeWatcher()); + worker.$close.mockImplementation(() => { + if ((worker as any).$closed) return; + (worker as any).$closed = true; + order.push('kill'); + }); + const { run, token, cancel } = createRunContext(); + const running = api.runTest({ run, token }); + await started; + cancel(); + await new Promise((resolve) => setTimeout(resolve)); + expect(worker.$close).not.toHaveBeenCalled(); + finish(); + await running; + await expect.poll(() => worker.$close.mock.calls.length).toBe(1); + expect(order).toEqual(['teardown', 'kill']); + }); + + it('surfaces coverage failures without test-level failures', async () => { + const api = createApi(); + const coreWorker = new Worker(); + rs.spyOn(coreWorker as any, 'init').mockResolvedValue({ + command: 'run', + fileFilters: undefined, + rstest: { + run: async () => ({ + status: 'fail', + summary: { tests: { failed: 0 }, files: { failed: 0 } }, + unhandledErrors: [], + }), + }, + }); + mockWorker(api, (data) => coreWorker.runTest(data)); + const { output, run, token } = createRunContext(); + await api.runTest({ run, token }); + expect(output.join('')).toContain( + 'Rstest run failed without test-level failures', + ); + expect(shownMessages[0]).toContain( + 'coverage report errors or unmet coverage thresholds', + ); + }); + + it('finishes a rejected continuous run and surfaces its error', async () => { + const api = createApi(); + const worker = mockWorker(api, async () => { + throw new Error('Browser launch failed'); + }); + const { output, run, token } = createRunContext(); + await api.runTest({ run, token, continuous: true }); + expect(worker.$close).toHaveBeenCalledTimes(1); + expect(output.join('')).toContain('Browser launch failed'); + }); + + it('waits for continuous worker startup after the first reporter cycle', async () => { + const api = createApi(); + const startup = Promise.withResolvers(); + let reporter: TestRunReporter | undefined; + const worker = { + $close: rs.fn(), + closeWatcher: rs.fn(async () => {}), + runTest: rs.fn(() => startup.promise), + }; + rs.spyOn(api, 'createChildProcess').mockImplementation(async (value) => { + reporter = value; + return { + worker, + apiPath: '/rstest/api.js', + rstestPath: '/rstest/index.js', + } as any; + }); + const { run, token } = createRunContext(); + const order: string[] = []; + run.appendOutput = () => order.push('output'); + run.end = () => order.push('end'); + const hostRun = api + .runTest({ run, token, continuous: true }) + .finally(() => run.end()); + await Promise.resolve(); + await reporter!.onTestRunEnd(); + reporter!.onOutput('Waiting for file changes...'); + await Promise.resolve(); + expect(order).toEqual(['output']); + startup.resolve(); + await hostRun; + expect(order).toEqual(['output', 'end']); + }); + + it('waits for watcher startup before terminating a canceled continuous run', async () => { + const api = createApi(); + const order: string[] = []; + const watcherStartup = Promise.withResolvers<{ + close(): Promise; + }>(); + const watcherStarted = Promise.withResolvers(); + const coreWorker = new Worker(); + rs.spyOn(coreWorker as any, 'init').mockResolvedValue({ + command: 'watch', + fileFilters: undefined, + rstest: { + watch: () => { + watcherStarted.resolve(); + return watcherStartup.promise; + }, + }, + }); + const worker = mockWorker(api, (data) => coreWorker.runTest(data)); + worker.closeWatcher.mockImplementation(() => coreWorker.closeWatcher()); + worker.$close.mockImplementation(() => { + if ((worker as any).$closed) return; + (worker as any).$closed = true; + order.push('kill'); + }); + const { run, token, cancel } = createRunContext(); + const running = api.runTest({ run, token, continuous: true }); + await watcherStarted.promise; + cancel(); + expect(worker.$close).not.toHaveBeenCalled(); + watcherStartup.resolve({ + close: async () => { + order.push('teardown'); + }, + }); + await running; + await expect.poll(() => worker.$close.mock.calls.length).toBe(1); + expect(order).toEqual(['teardown', 'kill']); + }); + }); + + describe('Rstest public disposal and debug startup', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-public-api-')); + writeCoreInstall(root); + }); + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + + it('waits for an active one-shot run before disposing the worker', async () => { + const api = createApi(); + const { + worker: coreWorker, + order, + started, + finish, + } = createInFlightOneShotWorker(true); + const operation = coreWorker.runTest({} as WorkerInitOptions); + const result = operation.then( + () => 'resolved', + () => 'rejected', + ); + await started; + const worker = { + closeWatcher: rs.fn(() => coreWorker.closeWatcher()), + $close: rs.fn(() => order.push('kill')), + }; + (api as any).workers = new Set([worker]); + const disposal = api.dispose(); + await new Promise((resolve) => setTimeout(resolve)); + expect(worker.$close).not.toHaveBeenCalled(); + finish(); + await expect(result).resolves.toBe('rejected'); + await disposal; + expect(order).toEqual(['teardown', 'kill']); + }); + + it('uses SIGKILL when graceful watcher teardown times out', async () => { + rs.useFakeTimers(); + loggedWarnings.length = 0; + const api = createApi(root); + await api.createChildProcess(); + spawnedProcesses[0].respondToClose = false; + const disposal = api.dispose(); + await rs.advanceTimersByTimeAsync(WATCHER_CLOSE_TIMEOUT_MS); + await disposal; + expect(spawnedProcesses[0].killSignals).toEqual(['SIGKILL']); + expect(loggedWarnings).toContain( + 'Timed out waiting for the continuous test watcher to close; terminating the worker. Watcher teardown was skipped.', + ); + }); + + it('closes a spawned worker when debugger attachment rejects', async () => { + startDebugging = async () => { + throw new Error('Debugger attachment failed.'); + }; + const api = createApi(root); + await expect(api.createChildProcess(undefined, true)).rejects.toThrow( + 'Debugger attachment failed.', + ); + expect(spawnedProcesses[0].killSignals).toEqual(['SIGTERM']); + expect((api as any).workers.size).toBe(0); + expect(runningWorkers.size).toBe(0); + }); + + it('closes a worker when disposal starts during debugger attachment', async () => { + const attachment = Promise.withResolvers(); + const started = Promise.withResolvers(); + startDebugging = () => { + started.resolve(); + return attachment.promise; + }; + const api = createApi(root); + const starting = api.createChildProcess(undefined, true); + await started.promise; + // dispose() sets this flag synchronously before closing its current worker + // snapshot; isolate the post-attach guard from the close RPC exercised by + // the disposal tests above. + (api as any).disposed = true; + attachment.resolve(true); + await expect(starting).rejects.toThrow( + 'worker spawn aborted: this master was disposed while the debugger was attaching', + ); + expect(spawnedProcesses[0].killSignals).toEqual(['SIGTERM']); + expect(runningWorkers.size).toBe(0); + }); + }); +}); diff --git a/packages/vscode/tests/stacks/test/projectCoverage.test.ts b/packages/vscode/tests/stacks/test/projectCoverage.test.ts index 6504a51..eb42d03 100644 --- a/packages/vscode/tests/stacks/test/projectCoverage.test.ts +++ b/packages/vscode/tests/stacks/test/projectCoverage.test.ts @@ -87,7 +87,7 @@ describe('computeCoveredConfigs', () => { it('suppresses a nested intermediate config the root also aggregates', () => { // A root aggregates `sub` (which itself has `projects`) plus another - // project. `initCli` flattens `sub` to its leaf projects, so `sub`'s own + // project. `resolveRunnerInputs` flattens `sub` to its leaf projects, so `sub`'s own // config file never appears in the root's child list — but its leaves do. const covered = computeCoveredConfigs([ p('/repo/rstest.config.ts', '/repo', [ diff --git a/packages/vscode/tests/stacks/test/testTree.test.ts b/packages/vscode/tests/stacks/test/testTree.test.ts index 8e2c350..61e3d1d 100644 --- a/packages/vscode/tests/stacks/test/testTree.test.ts +++ b/packages/vscode/tests/stacks/test/testTree.test.ts @@ -63,6 +63,7 @@ const location = (line: number) => ({ line, column: 3 }); // suite "outer" @ line 7, cases "a" @ 12 and "b" @ 16 (1-based, like core) const withLocations = [ { + testId: 'outer', type: 'suite', name: 'outer', location: location(7), @@ -75,6 +76,7 @@ const withLocations = [ const withoutLocations = [ { + testId: 'outer', type: 'suite', name: 'outer', location: undefined, @@ -120,6 +122,7 @@ describe('TestFile.updateFromList', () => { file.updateFromList(withLocations); file.updateFromList([ { + testId: 'outer', type: 'suite', name: 'outer', location: location(9), @@ -143,8 +146,20 @@ describe('TestFile.updateFromList', () => { file.setTestItem(root); const dup = [ - { type: 'case', name: 'renders', location: location(4), tests: [] }, - { type: 'case', name: 'renders', location: location(9), tests: [] }, + { + testId: 'renders-1', + type: 'case', + name: 'renders', + location: location(4), + tests: [], + }, + { + testId: 'renders-2', + type: 'case', + name: 'renders', + location: location(9), + tests: [], + }, ] as any; file.updateFromList(dup); // duplicate siblings get distinct ids by occurrence index @@ -158,8 +173,20 @@ describe('TestFile.updateFromList', () => { // location-less rebuild must keep each occurrence's own range, not collapse // both onto the last one's. file.updateFromList([ - { type: 'case', name: 'renders', location: undefined, tests: [] }, - { type: 'case', name: 'renders', location: undefined, tests: [] }, + { + testId: 'renders-1', + type: 'case', + name: 'renders', + location: undefined, + tests: [], + }, + { + testId: 'renders-2', + type: 'case', + name: 'renders', + location: undefined, + tests: [], + }, ] as any); expect(root.children.get(getTestItemId('renders', 0)).range.startLine).toBe( 3, @@ -168,4 +195,173 @@ describe('TestFile.updateFromList', () => { 8, ); }); + + it('builds a hierarchy from flat listed tests', async () => { + const { TestFile } = await import('../../../src/stacks/test/testTree'); + const controller = createController(); + const uri = { fsPath: '/x/flat.test.ts', toString: () => 'file:///x' }; + const file = new TestFile({} as any, uri as any, controller); + const root = controller.createTestItem('root', 'flat.test.ts', uri); + file.setTestItem(root); + file.updateFromListedTests([ + { + testPath: uri.fsPath, + name: 'outer', + fullName: 'outer', + parentNames: [], + project: 'rstest', + type: 'suite', + }, + { + testPath: uri.fsPath, + name: 'case', + fullName: 'outer > case', + parentNames: ['outer'], + project: 'rstest', + type: 'case', + }, + ] as any); + expect(root.children.get('outer').children.get('case').label).toBe('case'); + }); + + it('keeps empty-named cases under an empty-named listed suite', async () => { + const { TestFile } = await import('../../../src/stacks/test/testTree'); + const controller = createController(); + const uri = { + fsPath: '/x/empty-names.test.ts', + toString: () => 'file:///x', + }; + const file = new TestFile({} as any, uri as any, controller); + const root = controller.createTestItem('root', 'empty-names.test.ts', uri); + file.setTestItem(root); + file.updateFromListedTests([ + { + testPath: uri.fsPath, + name: '', + fullName: '', + parentNames: [], + project: 'rstest', + type: 'suite', + }, + { + testPath: uri.fsPath, + name: '', + fullName: ' > ', + parentNames: [''], + project: 'rstest', + type: 'case', + }, + { + testPath: uri.fsPath, + name: 'normal', + fullName: ' > normal', + parentNames: [''], + project: 'rstest', + type: 'case', + }, + ]); + const suite = root.children.get(''); + expect(suite).toBeDefined(); + expect(root.children.size).toBe(1); + expect(suite.children.size).toBe(2); + expect(suite.children.get('').label).toBe(''); + expect(suite.children.get('normal').label).toBe('normal'); + }); + + it('groups files and seeds empty filtered refreshes', async () => { + const { TestFile, groupListedTestsByFile } = + await import('../../../src/stacks/test/testTree'); + const testPath = '/x/empty.test.ts'; + expect( + groupListedTestsByFile([ + { project: 'rstest', testPath, type: 'file' }, + ] as any)[0]?.tests, + ).toEqual([]); + const removed = '/x/removed.test.ts'; + const [group] = groupListedTestsByFile([], [removed]); + expect(group.uri.fsPath).toBe(removed); + const controller = createController(); + const file = new TestFile({} as any, group.uri, controller); + const root = controller.createTestItem( + 'root', + 'removed.test.ts', + group.uri, + ); + file.setTestItem(root); + file.updateFromList(withLocations); + expect(root.children.size).toBe(1); + file.updateFromListedTests(group.tests); + expect(root.children.size).toBe(0); + }); + + it('renders skipped and todo tests from a structured list', async () => { + const { TestFile } = await import('../../../src/stacks/test/testTree'); + const controller = createController(); + const uri = { fsPath: '/x/modes.test.ts', toString: () => 'file:///x' }; + const file = new TestFile({} as any, uri as any, controller); + const root = controller.createTestItem('root', 'modes.test.ts', uri); + file.setTestItem(root); + file.updateFromListedTests( + ['skip', 'todo'].map((runMode) => ({ + testPath: uri.fsPath, + name: runMode, + fullName: runMode, + parentNames: [], + project: 'rstest', + type: 'case', + runMode, + })) as any, + ); + expect(root.children.get('skip').description).toBe('skip'); + expect(root.children.get('todo').description).toBe('todo'); + }); + + it('renders only the first project hierarchy for a shared file', async () => { + const { TestFile, groupListedTestsByFile } = + await import('../../../src/stacks/test/testTree'); + const controller = createController(); + const uri = { fsPath: '/x/shared.test.ts', toString: () => 'file:///x' }; + const file = new TestFile({} as any, uri as any, controller); + const root = controller.createTestItem('root', 'shared.test.ts', uri); + file.setTestItem(root); + const [group] = groupListedTestsByFile([ + { + testPath: uri.fsPath, + name: 'suite', + fullName: 'suite', + parentNames: [], + project: 'alpha', + type: 'suite', + }, + { + testPath: uri.fsPath, + name: 'alpha', + fullName: 'suite > alpha', + parentNames: ['suite'], + project: 'alpha', + type: 'case', + }, + { + testPath: uri.fsPath, + name: 'suite', + fullName: 'suite', + parentNames: [], + project: 'beta', + type: 'suite', + }, + { + testPath: uri.fsPath, + name: 'beta', + fullName: 'suite > beta', + parentNames: ['suite'], + project: 'beta', + type: 'case', + }, + ] as any); + file.updateFromListedTests(group.tests); + expect(root.children.get('suite').children.get('alpha').label).toBe( + 'alpha', + ); + expect(root.children.get('suite').children.get('beta')).toBeUndefined(); + }); }); diff --git a/packages/vscode/tests/stacks/test/workerReporter.test.ts b/packages/vscode/tests/stacks/test/workerReporter.test.ts new file mode 100644 index 0000000..4b2c99e --- /dev/null +++ b/packages/vscode/tests/stacks/test/workerReporter.test.ts @@ -0,0 +1,43 @@ +import { expect, it, rs } from '@rstest/core'; +import { ProgressReporter } from '../../../src/stacks/test/worker/reporter'; + +let resultAcknowledgement = Promise.withResolvers(); + +rs.mock('../../../src/stacks/test/worker', () => { + const acknowledged = () => resultAcknowledgement.promise; + const event = Object.assign(acknowledged, { asEvent: async () => {} }); + const immediate = Object.assign(async () => {}, { asEvent: async () => {} }); + return { + masterApi: { + onTestRunStart: immediate, + onTestRunEnd: immediate, + onTestFileStart: immediate, + onTestFileReady: immediate, + onTestFileResult: event, + onTestSuiteStart: immediate, + onTestSuiteResult: immediate, + onTestCaseStart: immediate, + onTestCaseResult: event, + }, + }; +}); + +it('waits for result callbacks to finish in the extension host', async () => { + const reporter = new ProgressReporter(); + for (const report of [ + () => reporter.onTestCaseResult({} as any), + () => reporter.onTestSuiteResult({} as any), + () => reporter.onTestFileResult({} as any), + ]) { + resultAcknowledgement = Promise.withResolvers(); + let settled = false; + const reporting = report().then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + resultAcknowledgement.resolve(); + await reporting; + expect(settled).toBe(true); + } +}); diff --git a/packages/vscode/tests/versionCheck.test.ts b/packages/vscode/tests/versionCheck.test.ts index 1a44685..acd973f 100644 --- a/packages/vscode/tests/versionCheck.test.ts +++ b/packages/vscode/tests/versionCheck.test.ts @@ -15,8 +15,8 @@ describe('support matrix', () => { it('pins the launch support floors', () => { expect(SUPPORT_MATRIX).toEqual({ '@rslint/core': '>=0.8.0', - '@rstest/core': '>=0.6.0', - rstack: '>=0.7.0', + '@rstest/core': '>=0.12.0', + rstack: '>=0.7.6', }); }); }); @@ -25,8 +25,8 @@ describe('checkPackageVersion', () => { it('accepts versions at and above the floor', () => { expect(checkPackageVersion('@rslint/core', '0.8.0').kind).toBe('ok'); expect(checkPackageVersion('@rslint/core', '1.2.3').kind).toBe('ok'); - expect(checkPackageVersion('@rstest/core', '0.11.5').kind).toBe('ok'); - expect(checkPackageVersion('rstack', '0.7.0').kind).toBe('ok'); + expect(checkPackageVersion('@rstest/core', '0.12.0').kind).toBe('ok'); + expect(checkPackageVersion('rstack', '0.7.6').kind).toBe('ok'); }); it('accepts prereleases of a supported range', () => { @@ -34,11 +34,16 @@ describe('checkPackageVersion', () => { }); it('rejects versions below the floor', () => { - const result = checkPackageVersion('@rstest/core', '0.5.9'); + expect(checkPackageVersion('rstack', '0.7.5')).toEqual({ + kind: 'mismatch', + version: '0.7.5', + required: '>=0.7.6', + }); + const result = checkPackageVersion('@rstest/core', '0.11.12'); expect(result.kind).toBe('mismatch'); if (result.kind === 'mismatch') { expect(formatVersionMismatch('@rstest/core', result)).toContain( - '>=0.6.0', + '>=0.12.0', ); } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f40c71..90f9d44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^1.0.0 version: 1.0.0(jiti@2.7.0) '@rstest/core': - specifier: ^0.11.12 - version: 0.11.12 + specifier: ^0.12.0 + version: 0.12.0 '@types/istanbul-lib-report': specifier: ^3.0.3 version: 3.0.3 @@ -585,6 +585,16 @@ packages: core-js: optional: true + '@rsbuild/core@2.2.8': + resolution: {integrity: sha512-/htjYpZiqZFB/fELguE8nkqKDT/DqHXeXUgdf+X4UlP5BJc1EeolZ9ksDpCw9WNTlcn3zsPOj7UZ0dUUB4FAFA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + '@rslib/core@1.0.0': resolution: {integrity: sha512-eZNXCWU1v5oti4+DKCunc76DkNnYqMgRtks9UyZRRFlmaK4pKAxhejeCUS+XkbMurGIAa+aDyVynQD7DXEgK1w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -661,6 +671,11 @@ packages: cpu: [arm64] os: [darwin] + '@rspack/binding-darwin-arm64@2.2.6': + resolution: {integrity: sha512-y8I8MAeKAOYuLqEKYQSmNFcW4dI1u5O+xYQcjT1L3Gby02KEt0YxlmSPvzElSQJUjsJ8PkKqU3U1suDjA6xvkg==} + cpu: [arm64] + os: [darwin] + '@rspack/binding-darwin-x64@2.2.2': resolution: {integrity: sha512-uFIcUPUXiPxM6ljenLafp5TemT8eLZm1riRn8fJYmpqNCK+aCcTaud18XHZpI5SjzzcY+xUHfVShgvNzKXuf2g==} cpu: [x64] @@ -671,6 +686,11 @@ packages: cpu: [x64] os: [darwin] + '@rspack/binding-darwin-x64@2.2.6': + resolution: {integrity: sha512-+zhtUAP+nZON1lSlqOFJWW2VpIXRZ4/NQskYe0xohELI4Fr6xun/Oh8dIMt+Nviamvo7lbO9XURrFio1+Fziog==} + cpu: [x64] + os: [darwin] + '@rspack/binding-linux-arm64-gnu@2.2.2': resolution: {integrity: sha512-Pjby4pDSMNJQK2VBzpgCj6lb+DGuenS1fEDb6xi5/apbJb9v5WE+e43Mz7i+XqgXS8e846pjaONV3KM5WKB1LQ==} cpu: [arm64] @@ -683,6 +703,12 @@ packages: os: [linux] libc: [glibc] + '@rspack/binding-linux-arm64-gnu@2.2.6': + resolution: {integrity: sha512-7GI9xlkzVMAp9dbNvynvAreMjQQNw01b+khYYfmoQOgWXTUwrtzR7/JhJ3tijVQTXkyiYAKJ0RPvz57yD+p1vQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-arm64-musl@2.2.2': resolution: {integrity: sha512-0u9O7tTVT2z+F6o/eEY6f6+My8Jn9U56QiBwkfy90ZaoAfZyQSSTxqaK/zA7W43oFxQefeCpiPas27VUzrSakw==} cpu: [arm64] @@ -695,6 +721,12 @@ packages: os: [linux] libc: [musl] + '@rspack/binding-linux-arm64-musl@2.2.6': + resolution: {integrity: sha512-klkjpGdeROzRiGSk2amfeZBAh0vWu0GjX5xKKQfRR4IqKQxURGCQafbvl3VX5XrHEUnKFzag89Lyv+ZVx5eTCw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rspack/binding-linux-ppc64-gnu@2.2.2': resolution: {integrity: sha512-N3lVnhq5qOvpmP5n386JzR1GcE8HzAqq4/r440Z6yle9m7PhVFJ6oXVWxgaDTYV0mtv9YLJmaG+YrjYfUa1vuA==} cpu: [ppc64] @@ -707,6 +739,12 @@ packages: os: [linux] libc: [glibc] + '@rspack/binding-linux-ppc64-gnu@2.2.6': + resolution: {integrity: sha512-TpLZr49oU31EuEqVDczpbfLZ6c976zu8HspBuUWYj1Ra4SNeOHhINKE1i42OVw2DCEMpsz8Tv3tYyLVACGC8JA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-riscv64-gnu@2.2.2': resolution: {integrity: sha512-ReClZyp32/rJkUDV/oGDU0X6BCFyEkTR6r4stFwm/qOOaG6mUMRzZe4gur8Yh979p4Hiz9EdkmfEHY02GBXcaQ==} cpu: [riscv64] @@ -719,6 +757,12 @@ packages: os: [linux] libc: [glibc] + '@rspack/binding-linux-riscv64-gnu@2.2.6': + resolution: {integrity: sha512-gU3Ki8Lt6cE4qRM62959/28GG3XQUZsnvstgDULJpkHR5QFGYXUhOoPe2fpyWYrLw6+t9lWXBuI/gUez1zc8Sg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-riscv64-musl@2.2.2': resolution: {integrity: sha512-mqsorvTNerr3r8zI35NFTPYATPDlhepdiUhZjKT6ylqpHlP7vLU9fp4aEMK/CuTv2f+IVsa0+iZqtMxHs2tSjw==} cpu: [riscv64] @@ -731,6 +775,12 @@ packages: os: [linux] libc: [musl] + '@rspack/binding-linux-riscv64-musl@2.2.6': + resolution: {integrity: sha512-OQRvU8HT9rGmtgdpieO722IrGViDlR0D6yjqbt7S51b+saBPwJ8Nf3fwMtpZnBo1djV2BGXK00L34jYypeChrw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@rspack/binding-linux-s390x-gnu@2.2.2': resolution: {integrity: sha512-ql2Jub8QYWSugBppmj2u0SjcIj6fOQuAaLCYQOqU7zbvz/uuWTfoqmdWKExwrxeCU9Tzt7emVM0ETuVEpoca+A==} cpu: [s390x] @@ -743,6 +793,12 @@ packages: os: [linux] libc: [glibc] + '@rspack/binding-linux-s390x-gnu@2.2.6': + resolution: {integrity: sha512-pus4Z76+J1iRO5Gutc6pq1LkyVhXqi0Sw6o5Dxwtl3sSbVy+F5PJkxtAT+DEg07pQy5m3dCymz2CIpiFlwYLTQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-x64-gnu@2.2.2': resolution: {integrity: sha512-MNYKYEHrtIVEno2q5rgpou/JVffRwn109xPK3kxct95EojHsniNa8jwy6eEHeOazP4EN60Si7NElE3aQ6JssHw==} cpu: [x64] @@ -755,6 +811,12 @@ packages: os: [linux] libc: [glibc] + '@rspack/binding-linux-x64-gnu@2.2.6': + resolution: {integrity: sha512-M0Efi9ny2cJ6VdhHxsZbcwdRCQ0HqBAsnPxPB8Ulknj+VtRlWnTlX/DlqMaHFuCb0MOWS6DXGGEAZ8veEil0BA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rspack/binding-linux-x64-musl@2.2.2': resolution: {integrity: sha512-y9/9CmE8lrECaF17GAhacibTL+SvlEPEotQnUuBFC/WFtXh8hU2E7a7bAkpYGSLH6kM0nrJZHO3hTvM1wdWOJw==} cpu: [x64] @@ -767,6 +829,12 @@ packages: os: [linux] libc: [musl] + '@rspack/binding-linux-x64-musl@2.2.6': + resolution: {integrity: sha512-614vRMr6VAqUXQm9TY7FCamcB08XJOv51WAwIlfEpoKTYxzNiQvrTYBYPgStUH20ywLwVoN6EJ++WCmHaebGWw==} + cpu: [x64] + os: [linux] + libc: [musl] + '@rspack/binding-wasm32-wasi@2.2.2': resolution: {integrity: sha512-VbDIjjeFwZvMSKAOGY5IbU6lLzt6AHHHncTdMMkZ94Xk7O2BOHe9BXDV32Ln29TIW2C8m1fdxfPZWDiecVghUQ==} cpu: [wasm32] @@ -775,6 +843,10 @@ packages: resolution: {integrity: sha512-gici3jWJi0GDy9kbYh57LoA57H8A2EAD9cV2jFKp1YoJ2aX2U9lANh4L+xoKPTvVQYpRvleoA5jkAvyfeLQ5bw==} cpu: [wasm32] + '@rspack/binding-wasm32-wasi@2.2.6': + resolution: {integrity: sha512-p995VmOEcHWPKw/Eek0CYUjmXWhfyWOPoKtE51Dy0/jqHcs7uRy53igDd277yHTuKCHibmwU+cjSKUe6b/aBZg==} + cpu: [wasm32] + '@rspack/binding-win32-arm64-msvc@2.2.2': resolution: {integrity: sha512-rfcNg0W3ZPZvXma1gTyEt9/Z8FxASIaQr+sMWTSaTPPaeU3xY1+0hYcrD0kUFNs3/5L4u63myI4R8qRXiuW3pA==} cpu: [arm64] @@ -785,6 +857,11 @@ packages: cpu: [arm64] os: [win32] + '@rspack/binding-win32-arm64-msvc@2.2.6': + resolution: {integrity: sha512-xC8nHMqVzQWqVW95HOcf9qTsM445ui7QNfUTDui6oTiHcBtsyfK1CEdXsc50bdi/FrJ2ueOps3M5gxbM8Ttl/A==} + cpu: [arm64] + os: [win32] + '@rspack/binding-win32-ia32-msvc@2.2.2': resolution: {integrity: sha512-TFPvr9RZw9oHIhooDhXHzWjKcHpGPTxkznSeM2poIWU0CdEuua2rVUfsrriTF1Dmx+9kMly61DQmyOHCbb+b2g==} cpu: [ia32] @@ -795,6 +872,11 @@ packages: cpu: [ia32] os: [win32] + '@rspack/binding-win32-ia32-msvc@2.2.6': + resolution: {integrity: sha512-sfqP/LSnMwAHpTA+Cfbkhvx/IvUr23Z7S+miOkaMT/j2XJYCBrPZK0ZYm8+LUNDQ2ij8/XCJT4pVpQbtc/SMPw==} + cpu: [ia32] + os: [win32] + '@rspack/binding-win32-x64-msvc@2.2.2': resolution: {integrity: sha512-GvEGyL594dtWN9SoVnKWh0exrM8WLInaUjwcuA2JKbCi1ak/9iHxip/U1dY3DuVqBYBhmvYq96JPbl91xnzFjg==} cpu: [x64] @@ -805,12 +887,20 @@ packages: cpu: [x64] os: [win32] + '@rspack/binding-win32-x64-msvc@2.2.6': + resolution: {integrity: sha512-b1ERY5QydZWJKA13AYsoMPZycG5AGoUc3sOMqLszO0gs84aMnLbK7lBiyeZCvEcHEa7gSFQlLQr9p+Xk7HPO4Q==} + cpu: [x64] + os: [win32] + '@rspack/binding@2.2.2': resolution: {integrity: sha512-gWjKDQfVQJSBh/I+y9WTlyERsiShSJ7eI6Yl0SJs/6gjx8t4ixuwWCsaEFFjwSL4nSn6ML5hNQ7UFY3gj72BWA==} '@rspack/binding@2.2.3': resolution: {integrity: sha512-532+T5N6yIdukChiL85H4NFN2vn9oi8wlLa1ByPNtpNYriE9s24fUhn0RiK7w/xACqnNpYmXCqLYCvjOzyCy+w==} + '@rspack/binding@2.2.6': + resolution: {integrity: sha512-bCB4a7KKaQpPNlkExf27AmLJabeGjpjXlhnCdvmSIHHlT3YfpYz2CSKOLLLI/BcBez8zUBNDVmvswm3ggCCRqA==} + '@rspack/core@2.2.2': resolution: {integrity: sha512-/yztfDZR5syIPBrUpzBpL+6fhhl0IHBPcXlNr4tOMBULbocFIz7Z4/cqvf1ix0DBbKpIGx99v6N1IDbk2gi8hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -835,6 +925,18 @@ packages: '@swc/helpers': optional: true + '@rspack/core@2.2.6': + resolution: {integrity: sha512-sqN75Fgf6v3tCmt8GAG/rdcYHOUDv/YZbQf24YCGDoMLmdNlXCosCHkBYWI8qdLs9IqSnGt7XgbcA39Zilg6UQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 + '@swc/helpers': ^0.5.23 + peerDependenciesMeta: + '@module-federation/runtime-tools': + optional: true + '@swc/helpers': + optional: true + '@rstackjs/cli-darwin-arm64@0.7.4': resolution: {integrity: sha512-5uGgWG+SOCyfh8UkOKakh0Ftf9bQlVE1qOSOxeHeSmnFVc8+gF9sgo/nJyP3jHJM4XapL5KhL7DTF6Vy8z+8Fg==} engines: {node: ^22.18.0 || >=24.3.0} @@ -942,6 +1044,19 @@ packages: jsdom: optional: true + '@rstest/core@0.12.0': + resolution: {integrity: sha512-jawxfkm97gUroI/KNgNwvEOQ7Ty6gIdWK8U7pbs9MSSDQQxR8I8tMs3SoIRKtaxVLy5n8eDJpWWvffMPF43YZg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + happy-dom: ^20.8.3 + jsdom: '>=15.0.0' + peerDependenciesMeta: + happy-dom: + optional: true + jsdom: + optional: true + '@secretlint/config-creator@10.2.2': resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} engines: {node: '>=20.0.0'} @@ -1394,6 +1509,9 @@ packages: ci-info@2.0.0: resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -3097,6 +3215,13 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' + '@rsbuild/core@2.2.8': + dependencies: + '@rspack/core': 2.2.6(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + '@rslib/core@1.0.0(typescript@5.9.3)': dependencies: '@rsbuild/core': 2.2.3 @@ -3151,60 +3276,90 @@ snapshots: '@rspack/binding-darwin-arm64@2.2.3': optional: true + '@rspack/binding-darwin-arm64@2.2.6': + optional: true + '@rspack/binding-darwin-x64@2.2.2': optional: true '@rspack/binding-darwin-x64@2.2.3': optional: true + '@rspack/binding-darwin-x64@2.2.6': + optional: true + '@rspack/binding-linux-arm64-gnu@2.2.2': optional: true '@rspack/binding-linux-arm64-gnu@2.2.3': optional: true + '@rspack/binding-linux-arm64-gnu@2.2.6': + optional: true + '@rspack/binding-linux-arm64-musl@2.2.2': optional: true '@rspack/binding-linux-arm64-musl@2.2.3': optional: true + '@rspack/binding-linux-arm64-musl@2.2.6': + optional: true + '@rspack/binding-linux-ppc64-gnu@2.2.2': optional: true '@rspack/binding-linux-ppc64-gnu@2.2.3': optional: true + '@rspack/binding-linux-ppc64-gnu@2.2.6': + optional: true + '@rspack/binding-linux-riscv64-gnu@2.2.2': optional: true '@rspack/binding-linux-riscv64-gnu@2.2.3': optional: true + '@rspack/binding-linux-riscv64-gnu@2.2.6': + optional: true + '@rspack/binding-linux-riscv64-musl@2.2.2': optional: true '@rspack/binding-linux-riscv64-musl@2.2.3': optional: true + '@rspack/binding-linux-riscv64-musl@2.2.6': + optional: true + '@rspack/binding-linux-s390x-gnu@2.2.2': optional: true '@rspack/binding-linux-s390x-gnu@2.2.3': optional: true + '@rspack/binding-linux-s390x-gnu@2.2.6': + optional: true + '@rspack/binding-linux-x64-gnu@2.2.2': optional: true '@rspack/binding-linux-x64-gnu@2.2.3': optional: true + '@rspack/binding-linux-x64-gnu@2.2.6': + optional: true + '@rspack/binding-linux-x64-musl@2.2.2': optional: true '@rspack/binding-linux-x64-musl@2.2.3': optional: true + '@rspack/binding-linux-x64-musl@2.2.6': + optional: true + '@rspack/binding-wasm32-wasi@2.2.2': dependencies: '@emnapi/core': 1.11.3 @@ -3219,24 +3374,40 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optional: true + '@rspack/binding-wasm32-wasi@2.2.6': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + '@rspack/binding-win32-arm64-msvc@2.2.2': optional: true '@rspack/binding-win32-arm64-msvc@2.2.3': optional: true + '@rspack/binding-win32-arm64-msvc@2.2.6': + optional: true + '@rspack/binding-win32-ia32-msvc@2.2.2': optional: true '@rspack/binding-win32-ia32-msvc@2.2.3': optional: true + '@rspack/binding-win32-ia32-msvc@2.2.6': + optional: true + '@rspack/binding-win32-x64-msvc@2.2.2': optional: true '@rspack/binding-win32-x64-msvc@2.2.3': optional: true + '@rspack/binding-win32-x64-msvc@2.2.6': + optional: true + '@rspack/binding@2.2.2': optionalDependencies: '@rspack/binding-darwin-arm64': 2.2.2 @@ -3271,6 +3442,23 @@ snapshots: '@rspack/binding-win32-ia32-msvc': 2.2.3 '@rspack/binding-win32-x64-msvc': 2.2.3 + '@rspack/binding@2.2.6': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.2.6 + '@rspack/binding-darwin-x64': 2.2.6 + '@rspack/binding-linux-arm64-gnu': 2.2.6 + '@rspack/binding-linux-arm64-musl': 2.2.6 + '@rspack/binding-linux-ppc64-gnu': 2.2.6 + '@rspack/binding-linux-riscv64-gnu': 2.2.6 + '@rspack/binding-linux-riscv64-musl': 2.2.6 + '@rspack/binding-linux-s390x-gnu': 2.2.6 + '@rspack/binding-linux-x64-gnu': 2.2.6 + '@rspack/binding-linux-x64-musl': 2.2.6 + '@rspack/binding-wasm32-wasi': 2.2.6 + '@rspack/binding-win32-arm64-msvc': 2.2.6 + '@rspack/binding-win32-ia32-msvc': 2.2.6 + '@rspack/binding-win32-x64-msvc': 2.2.6 + '@rspack/core@2.2.2(@swc/helpers@0.5.23)': dependencies: '@rspack/binding': 2.2.2 @@ -3283,6 +3471,12 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.23 + '@rspack/core@2.2.6(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.2.6 + optionalDependencies: + '@swc/helpers': 0.5.23 + '@rstackjs/cli-darwin-arm64@0.7.4': optional: true @@ -3334,6 +3528,15 @@ snapshots: - '@module-federation/runtime-tools' - core-js + '@rstest/core@0.12.0': + dependencies: + '@rsbuild/core': 2.2.8 + '@types/chai': 5.2.3 + cjs-module-lexer: 2.2.1 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - core-js + '@secretlint/config-creator@10.2.2': dependencies: '@secretlint/types': 10.2.2 @@ -3802,6 +4005,8 @@ snapshots: ci-info@2.0.0: {} + cjs-module-lexer@2.2.1: {} + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0