From a350489bd5f0b9d48329546455b68a97965dbfa5 Mon Sep 17 00:00:00 2001 From: fi3ework Date: Tue, 25 Aug 2026 13:22:06 +0800 Subject: [PATCH 1/4] fix(vscode): stop forcing color when the project config disables it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The master hard-coded FORCE_COLOR=1 into the rstest worker env (as the upstream extension does), so a project whose config sets process.env.NO_COLOR = '1' at load time hit Node's "'NO_COLOR' env is ignored" warning in every pool process. Mirror the CLI's getForceColorEnv semantics instead (adaptation #9): the master injects FORCE_COLOR=1 into the composed spawn env only when the user set neither color standard (marking the injection), and the worker retracts the marked injection right after config load — the CLI's own decision point — when the config turned color off. A user-set FORCE_COLOR beside a config-set NO_COLOR still warns, exactly as the bare CLI does. Also apply the review cleanups from the same pass: versionOk now retires a root's not-installed latch too (dropping the paired installed() calls), versionMismatch gains the same re-raise dedupe as notInstalled, the missing-package verdict rides on RslintResolutionError instead of a partial code table, missingDependencyCauseOf moves to shared/ for the lint/fmt config loaders (#30), stackCommandTitle loses its single-value verb parameter and now also feeds the status-bar hover, the unused NormalizedConfigResult.reason discriminant is dropped, the lint core-selection failure wording is spelled once, and the worker spawn reuses one resolved rstest path and one IPC send callback. --- packages/vscode/AGENTS.md | 5 +- .../vscode/src/shared/missingDependency.ts | 60 ++++++++++ packages/vscode/src/shared/notInstalled.ts | 2 +- .../vscode/src/stacks/lint/RuntimeManager.ts | 16 ++- packages/vscode/src/stacks/lint/index.ts | 5 +- packages/vscode/src/stacks/lint/resolution.ts | 15 ++- packages/vscode/src/stacks/lint/status.ts | 27 +---- packages/vscode/src/stacks/test/bridge.ts | 20 ++-- .../vscode/src/stacks/test/coreResolution.ts | 62 +---------- packages/vscode/src/stacks/test/master.ts | 56 ++++++---- .../vscode/src/stacks/test/shared/colorEnv.ts | 52 +++++++++ packages/vscode/src/stacks/test/status.ts | 18 ++- packages/vscode/src/stacks/test/types.ts | 2 +- .../vscode/src/stacks/test/worker/index.ts | 8 +- packages/vscode/src/statusBar.ts | 3 +- packages/vscode/src/types.ts | 16 +-- packages/vscode/tests/extension.test.ts | 2 +- .../tests/shared/missingDependency.test.ts | 103 ++++++++++++++++++ .../vscode/tests/stacks/lint/status.test.ts | 22 +++- .../vscode/tests/stacks/test/colorEnv.test.ts | 66 +++++++++++ .../tests/stacks/test/coreResolution.test.ts | 89 +-------------- .../vscode/tests/stacks/test/project.test.ts | 1 - 22 files changed, 420 insertions(+), 230 deletions(-) create mode 100644 packages/vscode/src/shared/missingDependency.ts create mode 100644 packages/vscode/src/stacks/test/shared/colorEnv.ts create mode 100644 packages/vscode/tests/shared/missingDependency.test.ts create mode 100644 packages/vscode/tests/stacks/test/colorEnv.test.ts diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 960cf7f..a31e0b0 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -5,10 +5,10 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## The copies are intentional - `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase. -- The copies diverge from upstream in exactly eight ways (the "adaptations" below). When syncing upstream, preserve them. A ninth divergence is either a bug or must be added to this list. +- The copies diverge from upstream in exactly nine ways (the "adaptations" below). When syncing upstream, preserve them. A tenth 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`). `CoreResolver.ts` / `RuntimeManager.ts` / `WorkspaceDocumentRouter.ts` / `Rslint.ts` are the files to diff when syncing further; record the new commits here when you do. -## The eight adaptations +## The nine adaptations 1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker. 2. **Namespace** — everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` settings and command ids are not read, aliased or migrated (breaking old settings and keybindings was an accepted cost). @@ -18,6 +18,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 6. **Node runtime selection** (lint, test, fmt) — the Node a project-loading child process runs on is a **User Node runtime** chosen by the extension against one uniform floor, never assumed from PATH; the recovery path is the user's own shell, and the dividing line is the **load bound** (terms in CONTEXT.md; the full rule and rationale in `docs/adr/0001-node-runtime-selection.md`). All three callers — the lint worker, the rstest worker and the `rs fmt --lsp` server — take the decision from the one shared module (`shared/nodeResolution.ts`) and share one escape hatch, the resource-scoped `rstack.nodeExecutable` (`shared/nodeExecutableSetting.ts`); each appends its own consequence to the shared preflight message. 7. **Lint worker and Rstack bridge** — the extension host is only Rslint's language client. One vscode-free, editor-shipped lint worker per **Lint runtime** (one Rslint core inside one workspace folder — CONTEXT.md) runs on the User Node runtime, owns the Go LSP plus all five reverse requests, and derives the binary/config/plugin pieces from one explicit `@rslint/core` directory. Upstream's `CoreResolver` loads that core in the extension host; ours only walks to the directory (`fs.stat` + `package.json` + semver) and hands the path to the worker, and its `CoreInstallation` therefore carries paths, not module factories; upstream's installation cache goes with the module loading it memoized (`clear()` is a no-op kept for the `RuntimeManager` contract). A bridged folder passes only rstack's published `dist/rslintConfig.js` shim; neither the extension nor the worker re-implements Rstack config semantics. Because protocol 2 locks `configPath` per process, the shim is part of the runtime key (`folder + core identity + shim`), which upstream — having no bridge — keys on the core alone. Why: `docs/adr/0003-lint-through-editor-worker.md`. 8. **Self-documenting Rslint diagnostics** — client-side providers parse Inline directives into per-rule hover, DocumentLink and underline-decoration affordances (the hover renders `Rslint(rule-id)`, the shape VS Code gives the published diagnostics), and the router enriches today's `[rule-id] message` diagnostics with a derived Rule docs link. No rule metadata or network lookup is bundled (ADR 0004). The hover provider yields whenever the owning language client's resolved capabilities advertise `hoverProvider`; an optional `Rslint.onClosed` hook identity-safely prunes the controller's capability mirror; the diagnostic synthesis is removed once upstream publishes `code` / `codeDescription` natively. +9. **Color env parity with the CLI** (test) — upstream hard-codes `FORCE_COLOR: '1'` into the worker's spawn env; ours mirrors the CLI's `getForceColorEnv` (rstest `packages/core/src/utils/logger.ts`) instead (`stacks/test/shared/colorEnv.ts`): the master injects `FORCE_COLOR=1` into the composed spawn env only when neither `FORCE_COLOR` nor `NO_COLOR` is already set (marking the injection with `RSTACK_FORCE_COLOR_INJECTED`), and the worker retracts the marked injection right after config load if the config set `NO_COLOR` — the CLI's own decision point. Otherwise a project whose config sets `process.env.NO_COLOR` (rstack-cli does) hits Node's "'NO_COLOR' env is ignored" warning in every pool process. A user-set `FORCE_COLOR` beside a config-set `NO_COLOR` still warns, exactly as the bare CLI does. ## Rules diff --git a/packages/vscode/src/shared/missingDependency.ts b/packages/vscode/src/shared/missingDependency.ts new file mode 100644 index 0000000..141314c --- /dev/null +++ b/packages/vscode/src/shared/missingDependency.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; +import { findPackageJsonUncached } from './packageResolve'; + +/** + * The classifier behind the "config imports a package that is not installed" + * verdict of the uniform not-installed policy (AGENTS.md). Nothing in it is + * Rstest-specific — it reads Node's loader errors — and lint/fmt will need + * the same verdict where their configs load (#30), which is why it lives in + * `shared/` beside the walk-up it uses rather than in one stack. + * + * Returns the one-line cause when a config evaluation failed on a package + * that is not installed, or `undefined` for a real error. Gated on the + * error's `code` — Node's own classification (`ERR_MODULE_NOT_FOUND` for + * ESM, `MODULE_NOT_FOUND` for CJS) — but the code alone is too broad: a + * typo'd relative import fails with the same codes, and installing + * dependencies cannot fix it, so only a bare specifier — a package name, + * read from the message since CJS carries no structured one — counts, and + * anything unrecognized fails towards the full error report. The check has + * to run in the process where the error is thrown: an IPC channel back to + * the extension host (`serialization: 'advanced'`) drops the `code`, so the + * verdict travels as data (e.g. `NormalizedConfigResult`). Only the first + * line comes back: the rest of a CJS message is the require stack, and the + * not-installed state is one warn line without one. + */ +export function missingDependencyCauseOf( + error: unknown, + resolveFrom: string, +): string | undefined { + if (!(error instanceof Error)) return undefined; + const { code } = error as NodeJS.ErrnoException; + if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { + return undefined; + } + const [firstLine] = error.message.split('\n', 1); + const specifier = /^Cannot find (?:package|module) '([^']+)'/.exec( + firstLine, + )?.[1]; + if ( + specifier === undefined || + specifier.startsWith('.') || + specifier.startsWith('file:') || + path.isAbsolute(specifier) + ) { + return undefined; + } + // `installed-package/missing-subpath` wears the same bare shape, but the + // package itself is there — installing dependencies cannot fix it either, + // so a subpath is checked against the physical `node_modules` with the + // same uncached walk-up every stack resolves packages with. + const packageName = specifier.startsWith('@') + ? specifier.split('/').slice(0, 2).join('/') + : specifier.split('/', 1)[0]; + if ( + packageName !== specifier && + findPackageJsonUncached(packageName, resolveFrom) !== undefined + ) { + return undefined; + } + return firstLine; +} diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index b5d3753..42f0bea 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -19,7 +19,7 @@ import { * where it has to be named (ADR 0002). */ const restartHint = (stack: StackId): string => - `then run "${COMMAND_CATEGORY}: ${stackCommandTitle(stack, 'restart')}" if this status stays`; + `then run "${COMMAND_CATEGORY}: ${stackCommandTitle(stack)}" if this status stays`; /** The `disabled` reason for a package the stack needs and cannot find. */ export const formatNotInstalledStatus = ( diff --git a/packages/vscode/src/stacks/lint/RuntimeManager.ts b/packages/vscode/src/stacks/lint/RuntimeManager.ts index a7f27b0..ebaff49 100644 --- a/packages/vscode/src/stacks/lint/RuntimeManager.ts +++ b/packages/vscode/src/stacks/lint/RuntimeManager.ts @@ -91,6 +91,19 @@ function documentKey(document: TextDocument): string { return document.uri.toString(); } +/** + * The one wording for a document whose core selection failed, shared with + * the `onDocumentFailure` hook (`index.ts`), which owns the report but must + * not drift from the hook-less fallback below. + */ +export function formatCoreSelectionFailure( + documentUri: string, + keeping?: string, +): string { + const suffix = keeping ? ` (keeping ${keeping} active)` : ''; + return `Could not select an Rslint core for ${documentUri}${suffix}`; +} + function cancellationError(key: string): Error { const error = new Error(`Rslint runtime ${JSON.stringify(key)} was released`); error.name = 'AbortError'; @@ -418,9 +431,8 @@ export class RuntimeManager { }); return; } - const suffix = keeping ? ` (keeping ${keeping} active)` : ''; this.logger.error( - `Could not select an Rslint core for ${document.uri}${suffix}`, + formatCoreSelectionFailure(document.uri.toString(), keeping), error, ); } diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 5b601c5..cc29e1b 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -12,7 +12,7 @@ import { Logger } from './logger'; import { Rslint } from './Rslint'; import type { RslintMode } from './resolution'; import { registerRuleDocumentationProviders } from './ruleDocumentationProviders'; -import { RuntimeManager } from './RuntimeManager'; +import { formatCoreSelectionFailure, RuntimeManager } from './RuntimeManager'; import { aggregateFolderStates, attributeToCore, @@ -223,9 +223,8 @@ class RslintController implements StackController { ), ); } else { - const suffix = keeping ? ` (keeping ${keeping} active)` : ''; logger.error( - `Could not select an Rslint core for ${document.uri}${suffix}`, + formatCoreSelectionFailure(document.uri.toString(), keeping), error, ); } diff --git a/packages/vscode/src/stacks/lint/resolution.ts b/packages/vscode/src/stacks/lint/resolution.ts index d8527ff..e5860a1 100644 --- a/packages/vscode/src/stacks/lint/resolution.ts +++ b/packages/vscode/src/stacks/lint/resolution.ts @@ -20,13 +20,23 @@ export type RslintResolutionErrorCode = 'missing-rstack' | 'missing-core' | 'invalid-package' | 'missing-shim'; export class RslintResolutionError extends Error { + /** + * The package whose absence caused this failure — set only by the + * not-installed throws (`resolveInstalledPackage`), the state the three + * stacks report uniformly (AGENTS.md). `missing-shim` and + * `invalid-package` leave it unset on purpose: there the package is + * present and the fix is an upgrade or the setting, not an install. + */ + readonly missingPackage?: 'rstack' | '@rslint/core'; + constructor( readonly code: RslintResolutionErrorCode, message: string, - options?: { cause?: unknown }, + options?: { cause?: unknown; missingPackage?: 'rstack' | '@rslint/core' }, ) { - super(message, options); + super(message, { cause: options?.cause }); this.name = 'RslintResolutionError'; + this.missingPackage = options?.missingPackage; } } @@ -101,6 +111,7 @@ function resolveInstalledPackage( throw new RslintResolutionError( code, `Could not resolve ${packageName} from ${searchRoot}`, + { missingPackage: packageName }, ); } return readPackageLocation(packageName, packageJsonPath); diff --git a/packages/vscode/src/stacks/lint/status.ts b/packages/vscode/src/stacks/lint/status.ts index 5765bdd..cb11602 100644 --- a/packages/vscode/src/stacks/lint/status.ts +++ b/packages/vscode/src/stacks/lint/status.ts @@ -1,10 +1,7 @@ import type { StackState } from '../../types'; import { formatNotInstalledStatus } from '../../shared/notInstalled'; import type { SupportedPackage } from '../../shared/versionCheck'; -import { - RslintResolutionError, - type RslintResolutionErrorCode, -} from './resolution'; +import { RslintResolutionError } from './resolution'; export class RslintVersionMismatchError extends Error { constructor(message: string) { @@ -16,27 +13,15 @@ export class RslintVersionMismatchError extends Error { /** * The package whose absence makes this failure the not-installed state the * three stacks report uniformly (AGENTS.md) — a `disabled` status and a - * one-line warning, never a crash or a stack trace. `missing-rstack` is a - * bridged folder without `rstack`; `missing-core` is a native folder on a - * fresh clone (a bridged folder reaches it only when rstack's own dependency - * is gone — an interrupted install, where installing is still the fix). - * `missing-shim` stays an error: the package is there, its version is wrong. - * A misconfigured `corePath` is `invalid-package`, also an error: the fix is - * the setting, not an install. + * one-line warning, never a crash or a stack trace. The verdict is the throw + * site's, carried on the error (`RslintResolutionError.missingPackage`), so + * a new resolution failure cannot silently fall through to `crashed` here by + * missing a mapping. */ -const NOT_INSTALLED_PACKAGE: Partial< - Record -> = { - 'missing-rstack': 'rstack', - 'missing-core': '@rslint/core', -}; - export const missingPackageOf = ( error: unknown, ): SupportedPackage | undefined => - error instanceof RslintResolutionError - ? NOT_INSTALLED_PACKAGE[error.code] - : undefined; + error instanceof RslintResolutionError ? error.missingPackage : undefined; export const statusForRslintStartFailure = (error: unknown): StackState => { if (error instanceof RslintVersionMismatchError) { diff --git a/packages/vscode/src/stacks/test/bridge.ts b/packages/vscode/src/stacks/test/bridge.ts index c80f55e..66e457e 100644 --- a/packages/vscode/src/stacks/test/bridge.ts +++ b/packages/vscode/src/stacks/test/bridge.ts @@ -43,6 +43,14 @@ import { status } from './status'; /** Relative to the `rstack` package root. Same file `rs test` injects. */ const SHIM_RELATIVE_PATH = path.join('dist', 'rstestConfig.js'); +// Both arguments are literals (`CORE_NOT_INSTALLED_STATUS` in master.ts is +// the same shape), so the status is one string instead of one per refresh +// pass. +const RSTACK_NOT_INSTALLED_STATUS = formatNotInstalledStatus( + 'rstest', + 'rstack', +); + export type RstackShim = { /** Absolute path of `/dist/rstestConfig.js`. */ readonly configFilePath: string; @@ -89,10 +97,7 @@ export function resolveRstackShim( // status stays until this directory resolves or stops being a candidate. // A stale mismatch from a previously-present install is retired by the // latch itself (a package-state observation restates its root). - status.notInstalled( - formatNotInstalledStatus('rstest', 'rstack'), - configDir, - ); + status.notInstalled(RSTACK_NOT_INSTALLED_STATUS, configDir); return undefined; } @@ -130,10 +135,9 @@ export function resolveRstackShim( packageDirectory, version, }); - // At most one of the two latches can be live (a package-state observation - // restates its root), so this repaints once, whichever failure the - // previous pass observed. + // One recovery observation: a passed version check proves the package is + // installed, so `versionOk` retires whichever of the two latches the + // previous pass left. status.versionOk(configDir); - status.installed(configDir); return { configFilePath, packageDirectory, version }; } diff --git a/packages/vscode/src/stacks/test/coreResolution.ts b/packages/vscode/src/stacks/test/coreResolution.ts index 7a63b41..d3c826f 100644 --- a/packages/vscode/src/stacks/test/coreResolution.ts +++ b/packages/vscode/src/stacks/test/coreResolution.ts @@ -1,22 +1,16 @@ /** * Classifying and reporting failed Rstest resolutions — the host-side helpers - * for a `@rstest/core` that cannot be resolved, and the worker-side - * classifier for a config whose own import failed - * (`missingDependencyCauseOf`). + * for a `@rstest/core` that cannot be resolved. (The worker-side classifier + * for a config whose own import failed is `shared/missingDependency.ts`.) * * Every message here replaces Node's own `MODULE_NOT_FOUND` text, which - * embeds a multi-line require stack and says nothing about what to do; the - * classifier also touches the filesystem once, to check whether a failing - * subpath's package really is absent. An + * embeds a multi-line require stack and says nothing about what to do. An * uninstalled core is the normal state of a freshly cloned repository and is * resolved for every config file without the user asking, so it is only * logged; a `rstestPackagePath` that does not resolve is a setting the user * has to fix, so it is notified. */ -import path from 'node:path'; -import { findPackageJsonUncached } from '../../shared/packageResolve'; - /** * Resolution failed after the actionable error was already logged or shown. * Callers still reject so project initialization stops, but must not report the @@ -52,53 +46,3 @@ export function formatConfiguredCoreNotFoundMessage( ): string { return `Cannot find "@rstest/core" at the configured "rstack.rstest.rstestPackagePath": ${configuredPackagePath}. Update the setting to point at an installed "@rstest/core" package.json.`; } - -// The one-line cause when a config evaluation failed on a package that is -// not installed, or `undefined` for a real error. Gated on the error's -// `code` — Node's own classification (`ERR_MODULE_NOT_FOUND` for ESM, -// `MODULE_NOT_FOUND` for CJS) — but the code alone is too broad: a typo'd -// relative import fails with the same codes, and installing dependencies -// cannot fix it, so only a bare specifier — a package name, read from the -// message since CJS carries no structured one — counts, and anything -// unrecognized fails towards the full error report. The check has to run in -// the worker, where the error is thrown: the IPC channel back to the -// extension host (`serialization: 'advanced'`) drops the `code`, so the -// verdict travels as data (`NormalizedConfigResult`). Only the first line -// comes back: the rest of a CJS message is the require stack, and the -// not-installed state is one warn line without one. -export function missingDependencyCauseOf( - error: unknown, - resolveFrom: string, -): string | undefined { - if (!(error instanceof Error)) return undefined; - const { code } = error as NodeJS.ErrnoException; - if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { - return undefined; - } - const [firstLine] = error.message.split('\n', 1); - const specifier = /^Cannot find (?:package|module) '([^']+)'/.exec( - firstLine, - )?.[1]; - if ( - specifier === undefined || - specifier.startsWith('.') || - specifier.startsWith('file:') || - path.isAbsolute(specifier) - ) { - return undefined; - } - // `installed-package/missing-subpath` wears the same bare shape, but the - // package itself is there — installing dependencies cannot fix it either, - // so a subpath is checked against the physical `node_modules` with the - // same uncached walk-up every stack resolves packages with. - const packageName = specifier.startsWith('@') - ? specifier.split('/').slice(0, 2).join('/') - : specifier.split('/', 1)[0]; - if ( - packageName !== specifier && - findPackageJsonUncached(packageName, resolveFrom) !== undefined - ) { - return undefined; - } - return firstLine; -} diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 19816bf..eedd4af 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -38,6 +38,7 @@ import { resolveUserNodeOnce, } from '../../shared/nodeResolution'; import type { Project } from './project'; +import { injectForceColor } from './shared/colorEnv'; import { NODE_RUNTIME_STATUS_SOURCE, status } from './status'; import { runInTerminal as sendToTerminal, shellQuote } from './terminal'; import { TestRunReporter } from './testRunReporter'; @@ -410,8 +411,6 @@ export class RstestApi { // and a mismatch re-latched now — for a root that may never come back — // would have nothing left to clear it. if (!this.disposed) { - // The core resolved: this root's missing install, if any, is over. - status.installed(this.statusSource); if ( !reportVersionCheck( status, @@ -466,9 +465,9 @@ export class RstestApi { } public async getNormalizedConfig() { - const worker = await this.createChildProcess(); + const { worker, rstestPath } = await this.createChildProcess(); const result = await worker.getNormalizedConfig({ - rstestPath: this.resolveRstestPath(), + rstestPath, configFilePath: this.configFilePath, }); worker.$close(); @@ -476,9 +475,9 @@ export class RstestApi { } public async listTests(include?: string[]) { - const worker = await this.createChildProcess(); + const { worker, rstestPath } = await this.createChildProcess(); const tests = await worker.listTests({ - rstestPath: this.resolveRstestPath(), + rstestPath, configFilePath: this.configFilePath, include, includeTaskLocation: true, @@ -539,7 +538,7 @@ export class RstestApi { errorStore, ); - const worker = await this.createChildProcess( + const { worker, rstestPath } = await this.createChildProcess( testRunReporter, kind === vscode.TestRunProfileKind.Debug, run, @@ -558,7 +557,7 @@ export class RstestApi { : undefined, update: updateSnapshot, configFilePath: this.configFilePath, - rstestPath: this.resolveRstestPath(), + rstestPath, coverage: coverageEnabled ? { enabled: true } : undefined, includeTaskLocation: true, }) @@ -688,6 +687,9 @@ export class RstestApi { logger.warn(message); throw new Error(message); } + // 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) { throw new ReportedRstestResolutionError(); @@ -732,6 +734,20 @@ export class RstestApi { nodeExecutable, nodeExecArgs, }); + const workerEnv: NodeJS.ProcessEnv = { + // same as packages/core/src/cli/prepare.ts + // if (!process.env.NODE_ENV) process.env.NODE_ENV = 'test' + NODE_ENV: 'test', + ...process.env, + ...nodeEnv, + ...debugNodeEnv, + // process.env.RSTEST = 'true'; + RSTEST: 'true', + }; + // Upstream sets FORCE_COLOR: '1' unconditionally; ours is conditional and + // the worker retracts it when the config disables color (adaptation #9, + // shared/colorEnv.ts). + injectForceColor(workerEnv); const rstestProcess = spawn( nodeExecutable, [...nodeExecArgs, ...execArgv, workerPath], @@ -739,17 +755,7 @@ export class RstestApi { cwd: this.cwd, stdio: ['pipe', 'pipe', 'pipe', 'ipc'], serialization: 'advanced', - env: { - // same as packages/core/src/cli/prepare.ts - // if (!process.env.NODE_ENV) process.env.NODE_ENV = 'test' - NODE_ENV: 'test', - ...process.env, - ...nodeEnv, - ...debugNodeEnv, - // process.env.RSTEST = 'true'; - RSTEST: 'true', - FORCE_COLOR: '1', - }, + env: workerEnv, }, ); this.childProcesses.add(rstestProcess); @@ -764,6 +770,11 @@ export class RstestApi { logger.error('[worker stderr]', content.trimEnd()); }); + // One shared send callback rather than a fresh closure per message: birpc + // answers every reporter call, so a large run sends thousands of times. + const onSendError = (error: Error | null) => { + if (error) logger.debug('IPC send to worker failed', error); + }; const worker = createBirpc(testRunReporter, { // Target the local process rather than the shared field, which is // reassigned on every spawn; skip once the IPC channel is gone. The @@ -771,10 +782,7 @@ export class RstestApi { // message losing the race against the worker's death — as a process // 'error' event instead. post: (data) => { - if (rstestProcess.connected) - rstestProcess.send(data, (error) => { - if (error) logger.debug('IPC send to worker failed', error); - }); + if (rstestProcess.connected) rstestProcess.send(data, onSendError); }, on: (fn) => rstestProcess.on('message', fn), bind: 'functions', @@ -884,7 +892,7 @@ export class RstestApi { } } - return worker; + return { worker, rstestPath }; } public dispose() { diff --git a/packages/vscode/src/stacks/test/shared/colorEnv.ts b/packages/vscode/src/stacks/test/shared/colorEnv.ts new file mode 100644 index 0000000..fcdd421 --- /dev/null +++ b/packages/vscode/src/stacks/test/shared/colorEnv.ts @@ -0,0 +1,52 @@ +/** + * Color env handling for the worker spawn, mirroring the CLI's + * `getForceColorEnv` (rstest `packages/core/src/utils/logger.ts`). + * + * The CLI decides which color env vars to inject into its pool processes + * lazily, at spawn time — after the config has loaded — and injects nothing + * when `FORCE_COLOR` or `NO_COLOR` is already set, whether by the user's + * shell or by the config itself (`process.env.NO_COLOR = '1'` in an + * `rstack.config.ts` is a supported way to turn colors off). + * + * The extension has the CLI's problem one level earlier: the worker's stdio + * is piped, so color detection in the loaded core concludes "no color", yet + * the output is rendered in VS Code's ANSI-capable test-run terminal. The + * master therefore plays the terminal's role on the spawn env — injection + * lands before any worker code (or its imports) can snapshot the env — but + * only when the user expressed no preference, and the worker retracts the + * injection at the CLI's own decision point — after config load — if the + * config turned color off. Without the retraction, pool processes inherit + * the injected `FORCE_COLOR` alongside the config-set `NO_COLOR`, and Node + * warns ("The 'NO_COLOR' env is ignored...") in every one of them. + */ + +/** + * Marks an injected `FORCE_COLOR` so the retraction can tell it from a + * user-set one, which is never touched: that combination warns in the bare + * CLI too, and silencing it here would hide the user's own conflict. + */ +const INJECTED_MARKER = 'RSTACK_FORCE_COLOR_INJECTED'; + +/** + * Force-enable color on the worker's spawn env unless the user already set + * either standard. Call on the fully composed env (after `nodeEnv` and + * friends), so a user preference expressed through settings is respected too. + */ +export function injectForceColor(env: NodeJS.ProcessEnv): void { + if (env.FORCE_COLOR !== undefined || env.NO_COLOR !== undefined) { + return; + } + env.FORCE_COLOR = '1'; + env[INJECTED_MARKER] = '1'; +} + +/** + * Retract an earlier injection if the loaded config set `NO_COLOR`. Call in + * the worker, right after the config has been evaluated. + */ +export function retractForceColorIfDisabled(env: NodeJS.ProcessEnv): void { + if (env[INJECTED_MARKER] === '1' && env.NO_COLOR !== undefined) { + delete env.FORCE_COLOR; + delete env[INJECTED_MARKER]; + } +} diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 69f84ce..5efa4a7 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -138,7 +138,10 @@ class StatusHolder implements StatusReporter { } } + // Like `notInstalled`, re-raised on every refresh pass with the same words + // (the bridge re-resolves per pass), so an unchanged entry is not restated. versionMismatch(detail: string, source = ''): void { + if (this.#mismatches.get(source) === detail) return; this.#restatePackageState(this.#mismatches, source); this.#mismatches.set(source, detail); this.#paintOrRun(); @@ -150,10 +153,19 @@ class StatusHolder implements StatusReporter { this.#paintOrRun(); } - /** A package version check passed: that root's previous mismatch is resolved. */ + /** + * A package version check passed. A version was read, so the package is + * necessarily installed: this one observation ends both a previous + * mismatch and a previous missing install — the recovery-side restatement + * mirroring `#restatePackageState`, so a success site cannot forget half + * the clearing (`crashed` stays: it is a fact about the worker, not the + * package). `installed` below survives for the `config-deps:` namespace, + * which has no version verdict. + */ versionOk(source = ''): void { - if (!this.#mismatches.delete(source)) return; - this.#paintOrRun(); + const hadMismatch = this.#mismatches.delete(source); + const hadNotInstalled = this.#notInstalled.delete(source); + if (hadMismatch || hadNotInstalled) this.#paintOrRun(); } /** diff --git a/packages/vscode/src/stacks/test/types.ts b/packages/vscode/src/stacks/test/types.ts index 6795120..660a49a 100644 --- a/packages/vscode/src/stacks/test/types.ts +++ b/packages/vscode/src/stacks/test/types.ts @@ -23,4 +23,4 @@ export type NormalizedConfigResult = exclude: string[]; childProjects: { configFilePath: string | null; root: string | null }[]; } - | { ok: false; reason: 'missing-dependency'; message: string }; + | { ok: false; message: string }; diff --git a/packages/vscode/src/stacks/test/worker/index.ts b/packages/vscode/src/stacks/test/worker/index.ts index 7d310c5..430f978 100644 --- a/packages/vscode/src/stacks/test/worker/index.ts +++ b/packages/vscode/src/stacks/test/worker/index.ts @@ -1,8 +1,9 @@ import { pathToFileURL } from 'node:url'; import { createBirpc } from 'birpc'; import type { TestRunReporter } from '../testRunReporter'; -import { missingDependencyCauseOf } from '../coreResolution'; +import { missingDependencyCauseOf } from '../../../shared/missingDependency'; import type { NormalizedConfigResult, WorkerInitOptions } from '../types'; +import { retractForceColorIfDisabled } from '../shared/colorEnv'; import { logger } from './logger'; import { CoverageReporter, ProgressLogger, ProgressReporter } from './reporter'; @@ -30,6 +31,9 @@ export class Worker { config: configFilePath, }); const { projects, config: initializedConfig } = initializedOptions; + // 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( @@ -92,7 +96,7 @@ export class Worker { // where the config's dependencies are installed. const cause = missingDependencyCauseOf(error, process.cwd()); if (cause !== undefined) { - return { ok: false, reason: 'missing-dependency', message: cause }; + return { ok: false, message: cause }; } throw error; } diff --git a/packages/vscode/src/statusBar.ts b/packages/vscode/src/statusBar.ts index 5fb5391..f66bd99 100644 --- a/packages/vscode/src/statusBar.ts +++ b/packages/vscode/src/statusBar.ts @@ -6,6 +6,7 @@ import { STACK_IDS, STACK_LABELS, stackCommand, + stackCommandTitle, } from './types'; /** The item's resting look: no stack is in a state worth colouring for. */ @@ -373,7 +374,7 @@ export class StatusBar implements vscode.Disposable { anchor( stackCommand(stack, 'restart'), '$(refresh)', - `Restart ${label}`, + stackCommandTitle(stack), ), ); } diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index a71bfd5..bdf97ac 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -30,14 +30,16 @@ export const stackCommand = ( export const COMMAND_CATEGORY = 'Rstack'; /** - * The title side of `stackCommand`, spelled once for the same reason: a - * status that tells the user which command to run has to say what the - * Command Palette shows (`: `), and a status naming a - * command that was renamed is not a type error. `tests/extension.test.ts` - * checks the manifest against this. + * The title side of `stackCommand(stack, 'restart')`, spelled once for the + * same reason: a status that tells the user which command to run has to say + * what the Command Palette shows (`<category>: <title>`), and a status + * naming a command that was renamed is not a type error. + * `tests/extension.test.ts` checks the manifest against this. Restart is the + * only command a status ever points at, so there is no verb parameter until + * a second one exists. */ -export const stackCommandTitle = (stack: StackId, verb: 'restart'): string => - `${verb === 'restart' ? 'Restart' : verb} ${STACK_LABELS[stack]}`; +export const stackCommandTitle = (stack: StackId): string => + `Restart ${STACK_LABELS[stack]}`; /** * Per-stack state machine surfaced by the status bar hover. diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index 5f8515d..c1e094d 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -482,7 +482,7 @@ describe('the shell restart command', () => { (entry) => entry.command === command, ), ).toMatchObject({ - title: stackCommandTitle(stack, 'restart'), + title: stackCommandTitle(stack), category: COMMAND_CATEGORY, }); } diff --git a/packages/vscode/tests/shared/missingDependency.test.ts b/packages/vscode/tests/shared/missingDependency.test.ts new file mode 100644 index 0000000..51e4ba7 --- /dev/null +++ b/packages/vscode/tests/shared/missingDependency.test.ts @@ -0,0 +1,103 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { missingDependencyCauseOf } from '../../src/shared/missingDependency'; + +// Resolve for real rather than hand-building an error object: the classifier +// reads a code and a message Node owns, so a fake error would only assert +// itself. +const resolveError = (specifier: string, from: string): unknown => { + try { + require.resolve(specifier, { paths: [from] }); + } catch (e) { + return e; + } + throw new Error(`expected "${specifier}" not to resolve`); +}; + +describe('missingDependencyCauseOf', () => { + // The classifier resolves from the worker's cwd — the project root; the + // test directory stands in for it. + const classify = (error: unknown, from = __dirname) => + missingDependencyCauseOf(error, from); + + // Same reasoning as `resolveError`: the errors come from Node's own + // loaders. + const importError = async (specifier: string): Promise<unknown> => { + try { + await import(specifier); + } catch (e) { + return e; + } + throw new Error(`expected "${specifier}" not to import`); + }; + + it('should name a package an ESM config failed to import', async () => { + expect( + classify(await importError('@rstest/definitely-not-installed')), + ).toContain("'@rstest/definitely-not-installed'"); + }); + + it('should keep a CJS failure to one line, without the require stack', () => { + const cause = classify( + resolveError('@rstest/definitely-not-installed', __dirname), + ); + expect(cause).toContain("'@rstest/definitely-not-installed'"); + // The not-installed warn is one line, no stack (AGENTS.md); Node's + // MODULE_NOT_FOUND message embeds a multi-line `Require stack:`. + expect(cause).not.toContain('\n'); + expect(cause).not.toContain('Require stack'); + }); + + it('should leave a missing relative or absolute import to the error report', () => { + // A typo'd `./helper` or a missing generated file is a source problem — + // installing dependencies cannot fix it, so it must not be classified as + // the not-installed state. The ESM loader reports relative imports as + // absolute paths, which the absolute case stands in for. + expect(classify(resolveError('./definitely-missing', __dirname))).toBe( + undefined, + ); + expect( + classify( + resolveError( + path.join(os.tmpdir(), 'definitely-missing.js'), + os.tmpdir(), + ), + ), + ).toBe(undefined); + }); + + it('should tell a missing subpath of an installed package from a missing one', () => { + // `require('installed-package/missing')` fails with the same code and a + // bare-looking specifier, but the package is there — that is a source + // error, not the not-installed state. The same subpath under a package + // that is really absent still is. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-vscode-')); + try { + const pkgDir = path.join(root, 'node_modules', 'installed-package'); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + '{"name":"installed-package","version":"1.0.0","main":"./index.js"}', + ); + fs.writeFileSync(path.join(pkgDir, 'index.js'), 'module.exports = {};\n'); + + expect( + classify(resolveError('installed-package/missing', root), root), + ).toBe(undefined); + expect( + classify(resolveError('not-installed-package/missing', root), root), + ).toContain("'not-installed-package/missing'"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('should leave every other failure to the full error report', () => { + expect(classify(new SyntaxError('Unexpected token'))).toBe(undefined); + expect(classify(new Error("Cannot find package 'x'"))).toBe(undefined); + expect(classify("Cannot find package 'x'")).toBe(undefined); + expect(classify(undefined)).toBe(undefined); + }); +}); diff --git a/packages/vscode/tests/stacks/lint/status.test.ts b/packages/vscode/tests/stacks/lint/status.test.ts index 55de03f..6c54021 100644 --- a/packages/vscode/tests/stacks/lint/status.test.ts +++ b/packages/vscode/tests/stacks/lint/status.test.ts @@ -13,30 +13,40 @@ import { describe('Rslint status classification', () => { it('disables a folder whose package is not installed', () => { // Both not-installed shapes take the uniform disabled state (AGENTS.md): - // a bridged folder without rstack, a fresh clone without the core. + // a bridged folder without rstack, a fresh clone without the core. The + // verdict rides on the error (`missingPackage`), set by the throw site. expect( statusForRslintStartFailure( - new RslintResolutionError('missing-rstack', 'missing rstack'), + new RslintResolutionError('missing-rstack', 'missing rstack', { + missingPackage: 'rstack', + }), ), ).toMatchObject({ kind: 'disabled' }); expect( statusForRslintStartFailure( - new RslintResolutionError('missing-core', 'missing core'), + new RslintResolutionError('missing-core', 'missing core', { + missingPackage: '@rslint/core', + }), ), ).toMatchObject({ kind: 'disabled' }); }); it('names the package a not-installed failure is missing', () => { // The same boundary the status and the warn line use: a missing package - // is the disabled state; a present-but-broken install is an error. + // is the disabled state; a present-but-broken install is an error, so + // the not-installed throws carry `missingPackage` and the rest do not. expect( missingPackageOf( - new RslintResolutionError('missing-rstack', 'missing rstack'), + new RslintResolutionError('missing-rstack', 'missing rstack', { + missingPackage: 'rstack', + }), ), ).toBe('rstack'); expect( missingPackageOf( - new RslintResolutionError('missing-core', 'missing core'), + new RslintResolutionError('missing-core', 'missing core', { + missingPackage: '@rslint/core', + }), ), ).toBe('@rslint/core'); expect( diff --git a/packages/vscode/tests/stacks/test/colorEnv.test.ts b/packages/vscode/tests/stacks/test/colorEnv.test.ts new file mode 100644 index 0000000..0d84f26 --- /dev/null +++ b/packages/vscode/tests/stacks/test/colorEnv.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from '@rstest/core'; +import { + injectForceColor, + retractForceColorIfDisabled, +} from '../../../src/stacks/test/shared/colorEnv'; + +// The pair mirrors the CLI's getForceColorEnv semantics (adaptation #9): +// inject only when the user expressed no preference, retract only what was +// injected, and only when the config turned color off. + +describe('injectForceColor', () => { + it('injects when neither standard is set', () => { + const env: NodeJS.ProcessEnv = {}; + injectForceColor(env); + expect(env.FORCE_COLOR).toBe('1'); + }); + + it('respects a user-set NO_COLOR', () => { + const env: NodeJS.ProcessEnv = { NO_COLOR: '1' }; + injectForceColor(env); + expect(env.FORCE_COLOR).toBeUndefined(); + }); + + it('respects a user-set FORCE_COLOR, including an explicit off', () => { + const env: NodeJS.ProcessEnv = { FORCE_COLOR: '0' }; + injectForceColor(env); + expect(env.FORCE_COLOR).toBe('0'); + }); +}); + +describe('retractForceColorIfDisabled', () => { + it('retracts the injection when the config set NO_COLOR', () => { + const env: NodeJS.ProcessEnv = {}; + injectForceColor(env); + env.NO_COLOR = '1'; // config load + retractForceColorIfDisabled(env); + expect(env.FORCE_COLOR).toBeUndefined(); + expect(env.NO_COLOR).toBe('1'); + }); + + it('leaves the injection alone when the config set nothing', () => { + const env: NodeJS.ProcessEnv = {}; + injectForceColor(env); + retractForceColorIfDisabled(env); + expect(env.FORCE_COLOR).toBe('1'); + }); + + it('never touches a user-set FORCE_COLOR (that conflict warns in the bare CLI too)', () => { + const env: NodeJS.ProcessEnv = { FORCE_COLOR: '1' }; + injectForceColor(env); + env.NO_COLOR = '1'; // config load + retractForceColorIfDisabled(env); + expect(env.FORCE_COLOR).toBe('1'); + }); + + it('survives repeated config evaluations in one worker process', () => { + // `init()` runs once per RPC call; a second retraction after the first + // must stay a no-op. + const env: NodeJS.ProcessEnv = {}; + injectForceColor(env); + env.NO_COLOR = '1'; + retractForceColorIfDisabled(env); + retractForceColorIfDisabled(env); + expect(env.FORCE_COLOR).toBeUndefined(); + }); +}); diff --git a/packages/vscode/tests/stacks/test/coreResolution.test.ts b/packages/vscode/tests/stacks/test/coreResolution.test.ts index 8d654bb..594cf7d 100644 --- a/packages/vscode/tests/stacks/test/coreResolution.test.ts +++ b/packages/vscode/tests/stacks/test/coreResolution.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from '@rstest/core'; import { formatConfiguredCoreNotFoundMessage, isModuleNotFoundError, - missingDependencyCauseOf, } from '../../../src/stacks/test/coreResolution'; // Resolve for real rather than hand-building an error object: the predicate @@ -65,88 +64,6 @@ describe('core-not-found messages', () => { }); }); -describe('missingDependencyCauseOf', () => { - // The classifier resolves from the worker's cwd — the project root; the - // test directory stands in for it. - const classify = (error: unknown, from = __dirname) => - missingDependencyCauseOf(error, from); - - // Same reasoning as `resolveError`: the classifier reads a code and a - // message Node owns, so the errors come from Node's own loaders. - const importError = async (specifier: string): Promise<unknown> => { - try { - await import(specifier); - } catch (e) { - return e; - } - throw new Error(`expected "${specifier}" not to import`); - }; - - it('should name a package an ESM config failed to import', async () => { - expect( - classify(await importError('@rstest/definitely-not-installed')), - ).toContain("'@rstest/definitely-not-installed'"); - }); - - it('should keep a CJS failure to one line, without the require stack', () => { - const cause = classify( - resolveError('@rstest/definitely-not-installed', __dirname), - ); - expect(cause).toContain("'@rstest/definitely-not-installed'"); - // The not-installed warn is one line, no stack (AGENTS.md); Node's - // MODULE_NOT_FOUND message embeds a multi-line `Require stack:`. - expect(cause).not.toContain('\n'); - expect(cause).not.toContain('Require stack'); - }); - - it('should leave a missing relative or absolute import to the error report', () => { - // A typo'd `./helper` or a missing generated file is a source problem — - // installing dependencies cannot fix it, so it must not be classified as - // the not-installed state. The ESM loader reports relative imports as - // absolute paths, which the absolute case stands in for. - expect(classify(resolveError('./definitely-missing', __dirname))).toBe( - undefined, - ); - expect( - classify( - resolveError( - path.join(os.tmpdir(), 'definitely-missing.js'), - os.tmpdir(), - ), - ), - ).toBe(undefined); - }); - - it('should tell a missing subpath of an installed package from a missing one', () => { - // `require('installed-package/missing')` fails with the same code and a - // bare-looking specifier, but the package is there — that is a source - // error, not the not-installed state. The same subpath under a package - // that is really absent still is. - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-vscode-')); - try { - const pkgDir = path.join(root, 'node_modules', 'installed-package'); - fs.mkdirSync(pkgDir, { recursive: true }); - fs.writeFileSync( - path.join(pkgDir, 'package.json'), - '{"name":"installed-package","version":"1.0.0","main":"./index.js"}', - ); - fs.writeFileSync(path.join(pkgDir, 'index.js'), 'module.exports = {};\n'); - - expect( - classify(resolveError('installed-package/missing', root), root), - ).toBe(undefined); - expect( - classify(resolveError('not-installed-package/missing', root), root), - ).toContain("'not-installed-package/missing'"); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } - }); - - it('should leave every other failure to the full error report', () => { - expect(classify(new SyntaxError('Unexpected token'))).toBe(undefined); - expect(classify(new Error("Cannot find package 'x'"))).toBe(undefined); - expect(classify("Cannot find package 'x'")).toBe(undefined); - expect(classify(undefined)).toBe(undefined); - }); -}); +// `missingDependencyCauseOf` moved to `shared/` (#30 wants the same verdict +// in the lint/fmt config loaders); its tests live in +// `tests/shared/missingDependency.test.ts`. diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index 940b530..01c39f4 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -226,7 +226,6 @@ describe('Project config/cwd/package-resolution decoupling', () => { // error's `code` still existed. normalizedConfigResult = { ok: false, - reason: 'missing-dependency', message: `Cannot find package '@rsbuild/plugin-react' imported from ${rstackConfig.fsPath}`, }; const { reporter, reported } = createStatusRecorder(); From 942ac519cf6a6089219e8b37512b5cf83761dfb6 Mon Sep 17 00:00:00 2001 From: fi3ework <fi3ework@gmail.com> Date: Tue, 25 Aug 2026 13:45:50 +0800 Subject: [PATCH 2/4] fix(vscode): address review findings on quiet classification, latches, color marker Three review findings, each verified before fixing: - The missing-cwd spawn refusal threw a plain Error, so callers re-logged the already-warned stale-project state as an error with a stack. The guard now throws ReportedRstestResolutionError (which gained an optional message), and the four catch sites above RstestApi share one logUnlessReported helper next to the class instead of re-deciding. - The re-raise dedupe in versionMismatch/notInstalled short-circuited before the package-state restatement, so a crash latched between two identical verdicts survived a retry that aborted before spawning. Both observations now fold into one #observePackageState that restates first and skips only the repaint. - retractForceColorIfDisabled left RSTACK_FORCE_COLOR_INJECTED in the env on the no-NO_COLOR path; the marker is now removed once the decision is complete, so pool processes and user test code never observe it. --- .../vscode/src/stacks/test/coreResolution.ts | 28 +++++++++---- packages/vscode/src/stacks/test/index.ts | 3 +- packages/vscode/src/stacks/test/master.ts | 2 +- packages/vscode/src/stacks/test/project.ts | 13 +++--- .../vscode/src/stacks/test/shared/colorEnv.ts | 7 +++- packages/vscode/src/stacks/test/status.ts | 41 ++++++++++--------- .../vscode/tests/stacks/test/colorEnv.test.ts | 5 +++ .../vscode/tests/stacks/test/master.test.ts | 7 +++- .../vscode/tests/stacks/test/status.test.ts | 25 +++++++++++ 9 files changed, 92 insertions(+), 39 deletions(-) diff --git a/packages/vscode/src/stacks/test/coreResolution.ts b/packages/vscode/src/stacks/test/coreResolution.ts index d3c826f..2530981 100644 --- a/packages/vscode/src/stacks/test/coreResolution.ts +++ b/packages/vscode/src/stacks/test/coreResolution.ts @@ -1,7 +1,9 @@ /** - * Classifying and reporting failed Rstest resolutions — the host-side helpers - * for a `@rstest/core` that cannot be resolved. (The worker-side classifier - * for a config whose own import failed is `shared/missingDependency.ts`.) + * Classifying and reporting failed Rstest worker setups — the host-side + * helpers for a `@rstest/core` that cannot be resolved, and the + * already-reported marker for any setup failure whose actionable state was + * logged where it was observed. (The worker-side classifier for a config + * whose own import failed is `shared/missingDependency.ts`.) * * Every message here replaces Node's own `MODULE_NOT_FOUND` text, which * embeds a multi-line require stack and says nothing about what to do. An @@ -11,18 +13,28 @@ * has to fix, so it is notified. */ +import { logger } from './logger'; + /** - * Resolution failed after the actionable error was already logged or shown. - * Callers still reject so project initialization stops, but must not report the - * same failure again. + * The worker could not be set up, and the actionable state was already logged + * or shown — a core that did not resolve, or a spawn refused because the + * project directory is gone. Callers still reject so the operation stops, but + * must not report the same failure again: catch sites log through + * `logUnlessReported` below instead of re-deciding. */ export class ReportedRstestResolutionError extends Error { - constructor() { - super('Failed to resolve rstest path'); + constructor(message = 'Failed to resolve rstest path') { + super(message); this.name = 'ReportedRstestResolutionError'; } } +/** The catch-site half of the contract above. */ +export function logUnlessReported(message: string, error: unknown): void { + if (error instanceof ReportedRstestResolutionError) return; + logger.error(message, error); +} + // Whether `specifier` itself is what could not be found. `MODULE_NOT_FOUND` // alone is too broad: a package that is installed but whose entry file is gone // (an interrupted install, or a workspace link that has not been built) throws diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index 42d0ff6..831fdab 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -4,6 +4,7 @@ import type { StackContext, StackController, } from '../../types'; +import { logUnlessReported } from './coreResolution'; import { RstestDiagnostics } from './diagnostics'; import { TestErrorStore, testMessageText } from './errorStore'; import { logger } from './logger'; @@ -486,7 +487,7 @@ class Rstest implements vscode.Disposable { request.include ?? gatherTestItems(this.ctrl.items, false), ); } catch (error) { - logger.error('Error running tests:', error); + logUnlessReported('Error running tests:', error); } finally { run.end(); } diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index eedd4af..69b4ff1 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -685,7 +685,7 @@ export class RstestApi { if (this.cwdIsGone()) { const message = this.missingCwdMessage(); logger.warn(message); - throw new Error(message); + throw new ReportedRstestResolutionError(message); } // Resolved once per spawn and handed back to the caller: the callers' // worker requests need the same path, and re-resolving would repeat the diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 1189c8f..9e3e981 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -10,7 +10,7 @@ import { formatConfigDependencyMissingLog, formatConfigDependencyMissingStatus, } from '../../shared/notInstalled'; -import { ReportedRstestResolutionError } from './coreResolution'; +import { logUnlessReported } from './coreResolution'; import { logger } from './logger'; import { RstestApi } from './master'; import { type ChildProjectRef, computeCoveredConfigs } from './projectCoverage'; @@ -606,9 +606,7 @@ export class Project implements vscode.Disposable { .catch((error) => { if (this.cancellationSource.token.isCancellationRequested) return; this.configLoadFailed = true; - if (!(error instanceof ReportedRstestResolutionError)) { - logger.error('Failed to initialize project config', error); - } + logUnlessReported('Failed to initialize project config', error); // Let the manager settle its tree even when a config fails to load. this.onConfigResolved?.(); }); @@ -788,7 +786,10 @@ export class Project implements vscode.Disposable { }) .catch((error) => { if (!token.isCancellationRequested) { - logger.error('Failed to update runtime test list', error); + logUnlessReported( + 'Failed to update runtime test list', + error, + ); } }); }; @@ -821,7 +822,7 @@ export class Project implements vscode.Disposable { }); } catch (error) { if (!token.isCancellationRequested) { - logger.error('Failed to collect test files', error); + logUnlessReported('Failed to collect test files', error); } } finally { if (this.testItem) { diff --git a/packages/vscode/src/stacks/test/shared/colorEnv.ts b/packages/vscode/src/stacks/test/shared/colorEnv.ts index fcdd421..5129c9e 100644 --- a/packages/vscode/src/stacks/test/shared/colorEnv.ts +++ b/packages/vscode/src/stacks/test/shared/colorEnv.ts @@ -42,11 +42,14 @@ export function injectForceColor(env: NodeJS.ProcessEnv): void { /** * Retract an earlier injection if the loaded config set `NO_COLOR`. Call in - * the worker, right after the config has been evaluated. + * the worker, right after the config has been evaluated. The marker is + * removed either way: the decision is complete, and pool processes, user + * test code and their children must not observe an extension-only variable + * the bare CLI never supplies. */ export function retractForceColorIfDisabled(env: NodeJS.ProcessEnv): void { if (env[INJECTED_MARKER] === '1' && env.NO_COLOR !== undefined) { delete env.FORCE_COLOR; - delete env[INJECTED_MARKER]; } + delete env[INJECTED_MARKER]; } diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 5efa4a7..731c514 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -131,20 +131,29 @@ class StatusHolder implements StatusReporter { * exit, `workerSpawned`, cannot happen while the package is unusable. * `crashed` above supersedes nothing: it is a fact about the worker, * arriving while the package state stays whatever it was. + * + * Both observations are re-raised on every refresh pass with the same + * words (the bridge re-resolves per pass), so an unchanged observation is + * not repainted. The restatement still runs first: a re-raise means a + * fresh resolution pass ran, so a crash latched in between — whose worker + * is gone — is retired even when the verdict itself did not change. */ - #restatePackageState(keep: Map<string, string>, source: string): void { - for (const latch of [this.#crashes, this.#mismatches, this.#notInstalled]) { - if (latch !== keep) latch.delete(source); + #observePackageState( + latch: Map<string, string>, + detail: string, + source: string, + ): void { + let retired = false; + for (const other of [this.#crashes, this.#mismatches, this.#notInstalled]) { + if (other !== latch && other.delete(source)) retired = true; } + if (!retired && latch.get(source) === detail) return; + latch.set(source, detail); + this.#paintOrRun(); } - // Like `notInstalled`, re-raised on every refresh pass with the same words - // (the bridge re-resolves per pass), so an unchanged entry is not restated. versionMismatch(detail: string, source = ''): void { - if (this.#mismatches.get(source) === detail) return; - this.#restatePackageState(this.#mismatches, source); - this.#mismatches.set(source, detail); - this.#paintOrRun(); + this.#observePackageState(this.#mismatches, detail, source); } /** A worker process came up: that root's previous spawn failure is over. */ @@ -157,7 +166,7 @@ class StatusHolder implements StatusReporter { * A package version check passed. A version was read, so the package is * necessarily installed: this one observation ends both a previous * mismatch and a previous missing install — the recovery-side restatement - * mirroring `#restatePackageState`, so a success site cannot forget half + * mirroring `#observePackageState`, so a success site cannot forget half * the clearing (`crashed` stays: it is a fact about the worker, not the * package). `installed` below survives for the `config-deps:` namespace, * which has no version verdict. @@ -168,17 +177,9 @@ class StatusHolder implements StatusReporter { if (hadMismatch || hadNotInstalled) this.#paintOrRun(); } - /** - * A root's dependencies are not installed: `disabled`, with the way out. - * Unlike the crash and mismatch latches this one is re-raised on every - * refresh pass and worker spawn with the same words, so an unchanged entry - * is not repainted. - */ + /** A root's dependencies are not installed: `disabled`, with the way out. */ notInstalled(reason: string, source = ''): void { - if (this.#notInstalled.get(source) === reason) return; - this.#restatePackageState(this.#notInstalled, source); - this.#notInstalled.set(source, reason); - this.#paintOrRun(); + this.#observePackageState(this.#notInstalled, reason, source); } /** A resolution under that root succeeded: its missing install is over. */ diff --git a/packages/vscode/tests/stacks/test/colorEnv.test.ts b/packages/vscode/tests/stacks/test/colorEnv.test.ts index 0d84f26..ae9dbf7 100644 --- a/packages/vscode/tests/stacks/test/colorEnv.test.ts +++ b/packages/vscode/tests/stacks/test/colorEnv.test.ts @@ -29,6 +29,9 @@ describe('injectForceColor', () => { }); describe('retractForceColorIfDisabled', () => { + // Both paths also assert the internal marker is gone: pool processes and + // user test code must not observe an extension-only variable the bare CLI + // never supplies. it('retracts the injection when the config set NO_COLOR', () => { const env: NodeJS.ProcessEnv = {}; injectForceColor(env); @@ -36,6 +39,7 @@ describe('retractForceColorIfDisabled', () => { retractForceColorIfDisabled(env); expect(env.FORCE_COLOR).toBeUndefined(); expect(env.NO_COLOR).toBe('1'); + expect(env.RSTACK_FORCE_COLOR_INJECTED).toBeUndefined(); }); it('leaves the injection alone when the config set nothing', () => { @@ -43,6 +47,7 @@ describe('retractForceColorIfDisabled', () => { injectForceColor(env); retractForceColorIfDisabled(env); expect(env.FORCE_COLOR).toBe('1'); + expect(env.RSTACK_FORCE_COLOR_INJECTED).toBeUndefined(); }); it('never touches a user-set FORCE_COLOR (that conflict warns in the bare CLI too)', () => { diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index ba91a11..03bd7e8 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -494,7 +494,12 @@ describe('RstestApi worker spawn failures', () => { api = createApi(cwd); fs.rmSync(cwd, { recursive: true, force: true }); - await expect(api.createChildProcess()).rejects.toThrow('no longer exists'); + // The reported-error class keeps the quiet classification through the + // callers — `logUnlessReported` must not re-log this as a failure. + await expect(api.createChildProcess()).rejects.toMatchObject({ + name: 'ReportedRstestResolutionError', + message: expect.stringContaining('no longer exists'), + }); expect(shownMessages).toEqual([]); expect(reported).toEqual([]); diff --git a/packages/vscode/tests/stacks/test/status.test.ts b/packages/vscode/tests/stacks/test/status.test.ts index b1d8d00..85aa55f 100644 --- a/packages/vscode/tests/stacks/test/status.test.ts +++ b/packages/vscode/tests/stacks/test/status.test.ts @@ -63,6 +63,31 @@ describe('StatusHolder failure latches', () => { expect(calls.slice(2)).toEqual(['mismatch:rstack too old']); }); + it('retires a stale crash even when the re-raised verdict is unchanged', () => { + // A re-raise means a fresh resolution pass ran, so the crashed worker is + // gone — e.g. a retry that aborts before spawning (occupied debug port) + // never reaches `workerSpawned`, and only the restatement can clear the + // old spawn failure. The dedupe must not short-circuit past it. + const calls = bindRecorder(); + status.versionMismatch('core too old', '/a'); + status.crashed('spawn ENOENT', '/a'); + status.versionMismatch('core too old', '/a'); + expect(calls).toEqual([ + 'mismatch:core too old', + 'crashed:spawn ENOENT', + 'mismatch:core too old', + ]); + }); + + it('stays silent on an identical re-raise with nothing else latched', () => { + const calls = bindRecorder(); + status.notInstalled('core missing', '/a'); + status.notInstalled('core missing', '/a'); + status.versionMismatch('core too old', '/b'); + status.versionMismatch('core too old', '/b'); + expect(calls).toEqual(['report:disabled', 'mismatch:core too old']); + }); + it('lets a package-state observation retire a stale crash', () => { // The crash's only other exit is `workerSpawned`, which cannot happen // while the package is unusable — a fresh resolution verdict restates From 6f91ac30cb43830ccedde8c25cda09698966ff3a Mon Sep 17 00:00:00 2001 From: fi3ework <fi3ework@gmail.com> Date: Tue, 25 Aug 2026 13:55:06 +0800 Subject: [PATCH 3/4] fix(vscode): keep the spawn-race cwd classification quiet through pending RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete-after-check handler logged the stale-project warning but closed the worker with birpc's default error, so pending RPCs rejected with a bare '[birpc] rpc is closed' and the callers' catches re-logged the failure. birpc's $close(customError) rejects pending calls with the given error; the cwd-gone branch now passes a ReportedRstestResolutionError carrying the same message, so logUnlessReported stays quiet — the same contract the pre-spawn guard already follows. The branch's race window (cwd deleted between the guard and spawn, with no 'spawn' event timing guarantee) has no deterministic unit test; the classification is covered by types and the existing spawn-failure suite. --- packages/vscode/src/stacks/test/master.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 69b4ff1..462891a 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -809,6 +809,7 @@ export class RstestApi { }); rstestProcess.on('error', (error) => { + let closeError: Error | undefined; if (spawned) { // Post-spawn errors (a failed kill(), an IPC write losing the race // against the worker's death) are teardown noise with nothing for @@ -818,8 +819,13 @@ export class RstestApi { } else if (this.cwdIsGone()) { // The cwd was deleted between the pre-spawn guard and the spawn — // Node blames the executable ("spawn node ENOENT") when it is the - // cwd that is gone. Same stale-project state, same quiet report. - logger.warn(this.missingCwdMessage()); + // cwd that is gone. Same stale-project state, same quiet report — + // including the pending RPCs: without the reported-error class they + // would reject with a bare birpc error, which the callers' catches + // (`logUnlessReported`) re-log as a real failure. + const message = this.missingCwdMessage(); + logger.warn(message); + closeError = new ReportedRstestResolutionError(message); } else { logger.error('Worker process error', error); // The status-aggregation adaptation: a worker that never came up is the @@ -836,7 +842,7 @@ export class RstestApi { } // Reject any in-flight birpc calls instead of letting them hang; $close // runs the `off` handler, which removes the process from the Set. - if (!worker.$closed) worker.$close(); + if (!worker.$closed) worker.$close(closeError); }); rstestProcess.on('exit', (code, signal) => { From 9b9c988cb135258b8e20c531d7df344a80557be5 Mon Sep 17 00:00:00 2001 From: fi3ework <fi3ework@gmail.com> Date: Tue, 25 Aug 2026 14:02:52 +0800 Subject: [PATCH 4/4] fix(vscode): retract only the injected FORCE_COLOR value A config that assigns both FORCE_COLOR and NO_COLOR at load time owns the FORCE_COLOR value; the bare CLI, deciding after config load, leaves both intact. The retraction now removes FORCE_COLOR only while it still holds the injected '1', so pools keep a config-set force-color preference. --- .../vscode/src/stacks/test/shared/colorEnv.ts | 16 +++++++++++----- .../vscode/tests/stacks/test/colorEnv.test.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/vscode/src/stacks/test/shared/colorEnv.ts b/packages/vscode/src/stacks/test/shared/colorEnv.ts index 5129c9e..13ef416 100644 --- a/packages/vscode/src/stacks/test/shared/colorEnv.ts +++ b/packages/vscode/src/stacks/test/shared/colorEnv.ts @@ -42,13 +42,19 @@ export function injectForceColor(env: NodeJS.ProcessEnv): void { /** * Retract an earlier injection if the loaded config set `NO_COLOR`. Call in - * the worker, right after the config has been evaluated. The marker is - * removed either way: the decision is complete, and pool processes, user - * test code and their children must not observe an extension-only variable - * the bare CLI never supplies. + * the worker, right after the config has been evaluated. Only the injected + * value is retracted: a config that overwrote `FORCE_COLOR` owns it now, and + * the bare CLI — deciding after config load — would leave such a value + * intact. The marker is removed either way: the decision is complete, and + * pool processes, user test code and their children must not observe an + * extension-only variable the bare CLI never supplies. */ export function retractForceColorIfDisabled(env: NodeJS.ProcessEnv): void { - if (env[INJECTED_MARKER] === '1' && env.NO_COLOR !== undefined) { + if ( + env[INJECTED_MARKER] === '1' && + env.NO_COLOR !== undefined && + env.FORCE_COLOR === '1' + ) { delete env.FORCE_COLOR; } delete env[INJECTED_MARKER]; diff --git a/packages/vscode/tests/stacks/test/colorEnv.test.ts b/packages/vscode/tests/stacks/test/colorEnv.test.ts index ae9dbf7..37bef2b 100644 --- a/packages/vscode/tests/stacks/test/colorEnv.test.ts +++ b/packages/vscode/tests/stacks/test/colorEnv.test.ts @@ -50,6 +50,18 @@ describe('retractForceColorIfDisabled', () => { expect(env.RSTACK_FORCE_COLOR_INJECTED).toBeUndefined(); }); + it('preserves a FORCE_COLOR the config overwrote, even beside its NO_COLOR', () => { + // The config owns the value now; the bare CLI — deciding after config + // load — would leave both intact. Only the injected '1' may be removed. + const env: NodeJS.ProcessEnv = {}; + injectForceColor(env); + env.FORCE_COLOR = '3'; // config load + env.NO_COLOR = '1'; // config load + retractForceColorIfDisabled(env); + expect(env.FORCE_COLOR).toBe('3'); + expect(env.RSTACK_FORCE_COLOR_INJECTED).toBeUndefined(); + }); + it('never touches a user-set FORCE_COLOR (that conflict warns in the bare CLI too)', () => { const env: NodeJS.ProcessEnv = { FORCE_COLOR: '1' }; injectForceColor(env);