diff --git a/.gitignore b/.gitignore index 0c3f7bf..b2f67e3 100644 --- a/.gitignore +++ b/.gitignore @@ -155,6 +155,7 @@ packages/vscode/.playground/ packages/vscode/e2e/fixtures/*/node_modules/ packages/vscode/e2e/fixtures/*/pnpm-lock.yaml packages/vscode/e2e/lint/fixtures/pnpm-lock.yaml +packages/vscode/e2e/lint/fixtures/*/pnpm-lock.yaml packages/vscode/e2e/rstest/fixtures/*/pnpm-lock.yaml # Build-time copy of the workspace root LICENSE (see rslib.config.mts) diff --git a/docs/adr/0002-fmt-lsp-on-user-node-runtime.md b/docs/adr/0002-fmt-lsp-on-user-node-runtime.md index 04d1704..cb42a91 100644 --- a/docs/adr/0002-fmt-lsp-on-user-node-runtime.md +++ b/docs/adr/0002-fmt-lsp-on-user-node-runtime.md @@ -37,6 +37,6 @@ Falling back to the VS Code Node runtime stays rejected — ADR 0001's load-bear - There is no cold path any more, so formatting is briefly unavailable after activation, after a restart and after a config change, while that folder's server starts. A format requested before the client has registered the server's capability finds no formatter for the document; nothing falls back to a fresh process. - The server advertises document formatting only: no range or selection formatting, no format-on-type, no diagnostics. It also ignores the editor's `FormattingOptions` (tab size, spaces) and the client's language id — the file path picks the parser and the project's config decides the style. An editor setting that disagrees with the project config loses, which is the same answer `rs fmt` gives in a terminal. - Failures stay per folder and stay statuses: no `rstack` installed is `disabled`, an `rstack` below `0.5.2` is `version mismatch`, no Node clearing the floor is `version mismatch` with fmt's own consequence appended to the shared preflight message ("until then rs fmt will not format"), and only a server that fails to launch or stops on its own is `crashed`. One folder in any of those states does not affect another folder's server, and the stack's single status report is the folder set folded by severity (`crashed` > `version mismatch`, which is also where a running folder's pin advisory ranks > `disabled` > `starting` > `running`; the pure fold lives in `stacks/fmt/status.ts`), so a healthy sibling starting or recovering never overwrites another folder's failure — and the status says `starting`, not `running`, until a server actually formats. -- An `rstack.config.*` create, change or delete restarts the server of the folder that contains it, and only that one; a detection change reconciles the folder set, leaves healthy servers alone — a healthy server's cached config is worth keeping — and restarts a folder whose runtime already failed (`disabled`, `version mismatch`, `crashed`) in place, on the same path a config change uses, which re-runs package resolution and the version check. Detection notifies on lockfile events even when the folder set is unchanged precisely so that the install or upgrade that fixes a failed resolution is picked up without a manual restart. The remaining blind spot is an install that changes no lockfile (a fresh clone whose lockfile is already current): no file event fires, so the `disabled` status names the restart command as the way out. Watching `node_modules` for that case was rejected (unreliable under pnpm's layout and excluded by VS Code's default watcher excludes), and a bundled fallback formatter — the usual way editor extensions mask this blind spot — is ruled out by resolve-from-project. +- An `rstack.config.*` create, change or delete restarts the server of the folder that contains it, and only that one; a detection change reconciles the folder set, leaves healthy servers alone — a healthy server's cached config is worth keeping — and restarts a folder whose runtime already failed (`disabled`, `version mismatch`, `crashed`) in place, on the same path a config change uses, which re-runs package resolution and the version check. Dependency installation recovery, including the unchanged-lockfile blind spot and the rejected watcher alternatives, is governed by ADR 0005. - "A subproject becomes its own workspace folder" means a _sibling_ folder (or opening only the subproject). Keeping the parent **and** the nested subdirectory as workspace folders with fmt detected in both is a documented limitation: the parent's selector also matches the nested files, and which of the two servers VS Code asks is not defined. Per-document routing to the deepest folder was considered (lint carries a `WorkspaceDocumentRouter` for exactly this) and deferred — complexity the scenario does not yet justify. - The extension now holds one long-lived Node process per detected folder for fmt. Each is owned by the same process owner the lint client uses, so a stop is bounded (SIGTERM, then SIGKILL) and the automatic restart vscode-languageclient performs cannot leave an orphan behind. diff --git a/docs/adr/0003-lint-through-editor-worker.md b/docs/adr/0003-lint-through-editor-worker.md index 98f88b8..27a6033 100644 --- a/docs/adr/0003-lint-through-editor-worker.md +++ b/docs/adr/0003-lint-through-editor-worker.md @@ -27,5 +27,5 @@ Rslint's language server is two halves: the Go process (`rslint --lsp`) lints na - **One override, and it names a core, not a binary.** `rstack.rslint.binPath` / `customBinPath` are removed in favour of `rstack.rslint.corePath` — the setting upstream introduced in rslint #1617: a path to an `@rslint/core` package directory, resource-scoped, from which the binary, config host, protocol version and plugin host all derive. In a bridged folder it overrides the rstack → `@rslint/core` hop only; the shim stays rstack's. A binary chosen independently of its core cannot be supported: the two must speak the same protocol. The rest of #1617 — per-document core resolution, one runtime per physical installation — has since been synced (issue #13): a **Lint runtime** is now one Rslint core inside one workspace folder, resolved per open document and refcounted by it, so a folder runs as many workers as its files have distinct cores (a bridged folder always exactly one, rstack's) and none at all while nothing is open. The worker never noticed: it still takes explicit `--core` / `--config` paths, which is precisely why that change did not touch it. - **Ownership is per folder, native wins.** One server holds one config choice for its lifetime (the supported config protocols lock `configPath` per process), and explicit and automatic modes cannot mix, so a folder is bridged only when no `rslint.config.*` exists anywhere in it and a `rstack.config.*` sits at its root; a subdirectory `rstack.config.*` lights nothing (`rs lint` in a terminal reads its cwd only — the same reason ADR 0002 rejected deepest-config-wins for fmt). Detection lights a bridged folder on the file's presence and never reads it: a `rstack.config.*` without `define.lint()` runs an empty config, as `rs lint` does. - **Config changes refresh, mode changes restart.** Rslint has a live refresh (`rslint/configRefresh` with the same `configPath`), unlike `rs fmt --lsp`, so the extension keeps its watcher-driven refresh — extended, for a bridged folder, with the root `rstack.config.*` — and the worker re-stamps `protocolVersion` and its `configPath` on every refresh (the extension does not know either). Only a native ↔ bridged flip, or a dependency change the refresh cannot absorb, restarts the server. This is the "diverge only when the tool forces it" rule: rslint can refresh, fmt cannot. -- **Failure states mirror fmt.** Bridged folder: no `rstack` → `disabled`; `rstack` or the chained `@rslint/core` below floor, or no Node clearing the floor → `version mismatch`; worker or Go dying → `crashed`. Native folder missing `@rslint/core` stays `crashed` — the user asked for Rslint by name. +- **Failure states mirror fmt.** No `rstack`, no `@rslint/core`, or a config importing an absent package → `disabled`; `rstack` or the chained `@rslint/core` below floor, or no Node clearing the floor → `version mismatch`; worker or Go dying → `crashed`. Config-import failures are classified where the worker still has the loader's structured error and carried to the editor as data; the live Go server remains available for a later refresh. - The lint copy diverges further from upstream: the reverse-request adapter and plugin pool move into the worker unchanged in logic, and the extension-side `Rslint.ts` keeps only the language-client half. Recorded as an adaptation in `packages/vscode/AGENTS.md`. diff --git a/docs/adr/0005-not-installed-recovery-by-polling.md b/docs/adr/0005-not-installed-recovery-by-polling.md new file mode 100644 index 0000000..3df2c70 --- /dev/null +++ b/docs/adr/0005-not-installed-recovery-by-polling.md @@ -0,0 +1,39 @@ +--- +status: accepted +--- + +# Recover not-installed stacks by polling only while recovery is needed + +Installing an already-locked project can populate `node_modules` without changing any config or lockfile. Detection's config and lockfile watchers then have no event to send, even though every stack deliberately resolves its toolchain from the project and needs another resolution pass. The status names the restart command as a fallback, but a fresh clone should recover without requiring it. + +The lint stack previously added a direct watcher for `**/node_modules/@rslint/core/package.json`. A controlled VS Code Extension Host experiment opened a project with no `node_modules`, waited for `disabled`, installed with a frozen lockfile, and observed for 90 seconds without invoking a restart command. Lockfile bytes and nanosecond mtime were verified unchanged in every run: + +| Install layout | Watcher event | Automatic recovery | +| -------------- | ------------- | ------------------ | +| npm flat | 2/2 | 2/2 | +| pnpm isolated | 0/2 | 0/2 | +| pnpm hoisted | 0/2 | 0/2 | + +The result is not explained by pnpm symlinks: the hoisted core was an ordinary directory and still produced no matching event. Versions were VS Code 1.136.1, pnpm 11.20.0, and npm 11.17.0. + +The watcher's original rationale was also factually wrong. At Microsoft VS Code commit [`008427a`](https://github.com/microsoft/vscode/commit/008427a901bf4aa79b47f175ccc8da1731750f78), the default `files.watcherExclude` contains only `.git/objects`, `.git/subtree-cache`, and `.hg/store`, each at the root and one directory below; it does not exclude `node_modules` ([`files.contribution.ts:294-310`](https://github.com/microsoft/vscode/blob/008427a901bf4aa79b47f175ccc8da1731750f78/src/vs/workbench/contrib/files/browser/files.contribution.ts#L294-L310)). The failure is the absent pnpm per-file event observed above, not a VS Code default exclude. + +**Decision.** The extension shell owns one recursive 60-second timer. It exists while any live controller's raw folder/project/runtime state is disabled, crashed or version-mismatched, enters the shell's existing serialized queue, and forces the same detection notification as a lockfile event even when the detection signature is unchanged. The three stacks reuse their existing dependency-change paths: lint reconciles open documents and refreshes failed configs, fmt restarts failed folder runtimes in place, and Rstest re-resolves shims and retries failed config evaluation. The timer stops when no failed state remains (running, starting or idle). Lockfile watchers stay as the lower-latency path. + +The aggregate status is deliberately not the predicate: every owned raw failure needs recovery. A retry landing mid-install can read half-written `node_modules` and fail with a syntax error instead of a missing dependency. Continuing every minute through that real error makes the transient harmless without a provisional-error heuristic. Real errors still replace not-installed in status and Output; persistent error messages and not-installed warnings are deduplicated so retries do not log every minute. The restart hint remains in the status as an explicit fallback. + +fmt has one tool-forced limitation. Restarting `rs fmt --lsp` re-runs package resolution, but the server loads project config lazily on the next formatting request. After a missing dependency, a poll can therefore move the folder to `running` before config loading has been proved; the next format either succeeds or reports the same config failure and returns the folder to `disabled`, which restarts polling. A known real config error instead remains `crashed` across restarts until a format produces edits. + +## Considered options + +- **Direct `node_modules` watchers** — rejected by the experiment: they recovered npm but missed both pnpm layouts. +- **Package-manager marker files** such as `node_modules/.modules.yaml`, `node_modules/.package-lock.json`, or `.yarn-integrity` — rejected because each covers one installer/layout and makes recovery depend on private install artifacts rather than the state being recovered. +- **Retry on window focus** — rejected because an install can finish while focus never leaves VS Code, and unrelated focus changes would cause unbounded retries. +- **Bundled tool fallbacks** — rejected by the resolve-from-project contract: editor and CLI must run the same installed versions. + +## Consequences + +- Healthy workspaces incur no polling work. An unresolved workspace retries at most once per timer interval, through the existing serialized shell queue. +- Recovery no longer depends on installer-specific file events; lockfile watchers remain the faster path when they do fire. +- A real config error replaces not-installed without stopping recovery. Config events and the explicit restart command remain available alongside the minute poll. +- fmt cannot prove config recovery at initialize time. Only a later format producing edits ends its warning episode; empty edits are ambiguous because the server uses them for both no-op formatting and failures whose showMessage may already have been sent. diff --git a/packages/vscode/AGENTS.md b/packages/vscode/AGENTS.md index 0df73ad..a42f958 100644 --- a/packages/vscode/AGENTS.md +++ b/packages/vscode/AGENTS.md @@ -7,7 +7,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 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`). 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. -- **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`). +- **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 nine adaptations @@ -17,7 +19,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten 4. **Status aggregation** — stacks own no UI chrome; they report to the shell's single status bar item, which always exists. In CI the test stack's `MasterLogger` also mirrors every entry to stderr (`RSTACK_E2E_MIRROR_LOGS=1`, set by `e2e/rstest/runTest.ts`) — the output channel is unreadable there; rationale in `stacks/test/logger.ts`. 5. **Worker-cwd decoupling** (test) — a project's cwd is explicit, not derived from the config file path; for native configs behavior stays byte-identical to upstream. 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 every supported config protocol 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`. +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 every supported config protocol 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`. The worker also sends the editor-only `rstack/rslintConfigDependency` notification (`stacks/lint/worker/configDependencyProtocol.ts`) when config loading finds a missing package. `ConfigTransactionAdapter` rewrites only that classified `rslint/loadConfigs` candidate's error message to its first line, so Go cannot echo a require stack beside the single warning. An initialized client whose initial configRefresh rejects with that verdict stays available for retry, rather than propagating a generic startup crash through RuntimeManager. 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. @@ -25,7 +27,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten - **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. No settings migration exists either — not for earlier states of this extension, and not for the two retired standalone extensions (removed in #15; users re-enter their settings under `rstack.*`). Testing and fixtures track only the latest published releases, pinned exactly and bumped by Renovate; a green E2E run speaks only for those releases. `SUPPORT_MATRIX` floors are the minimum versions the extension accepts: each entry is the lowest release evidence shows works with the current code, and its comment records that evidence. Move a floor only when a change makes older releases stop working, never because a devDependency or fixture moved. Raising a floor needs no transition story; the status names the required version. - **The three tools are treated uniformly by default.** Detection, dependency-change retry, restart semantics, version gating and status reporting follow one shared pattern across the lint/test/fmt stacks; a stack diverges only when its tool forces it, and the divergence is recorded here as a gotcha. When adding behavior to one stack, first ask whether it belongs to all three. This is about behavior, not code — the upstream copies still must not be deduplicated. -- **Not installed is a state, not an error — uniformly.** A folder or project whose dependencies are not installed (no `rstack`, no `@rstest/core`, no `@rslint/core`, a config importing a package that is not there) is the normal state of a fresh clone and of scaffolded templates beside their generator (`create-rstack`'s `template-*`, which declare their own dependencies and are never installed). Every stack reports it the same way: a `disabled` status whose reason names the restart command as the way out (ADR 0002: an install that changes no lockfile fires no detection pass), one `warn` line in the output channel without a stack trace, never a `crashed` status and never a notification. The words come from one place, `shared/notInstalled.ts` (the `formatVersionMismatch` precedent) — each stack keeps its own status machinery, none its own wording; the restart hint is derived from `stackCommandTitle`, which `tests/extension.test.ts` checks against the manifest. Lint's report lives in the `onDocumentFailure` hook (`stacks/lint/index.ts`), which owns the log line too, so the upstream-tracked `RuntimeManager` only defers to it. Rstest classifies the config-import case in the worker (`missingDependencyCauseOf`: Node's `code`, a bare — package-name — specifier, and for a subpath a walk-up proving the package really is absent, so a typo'd relative import or a missing subpath of an installed package stays a real error) because the IPC channel drops the `code` — `NormalizedConfigResult` carries the verdict as data end to end, and `Project` branches on it. The config-import case is implemented for Rstest only today — lint and fmt load configs inside their own servers and cannot classify there yet (#30). +- **Not installed is a state, not an error — uniformly.** A folder or project whose dependencies are not installed (no `rstack`, no `@rstest/core`, no `@rslint/core`, a config importing a package that is not there) is the normal state of a fresh clone and of scaffolded templates beside their generator (`create-rstack`'s `template-*`, which declare their own dependencies and are never installed). Every stack reports it the same way: a `disabled` status whose reason keeps the restart command as an explicit fallback, one `warn` line per unresolved episode in the output channel without a stack trace, never a `crashed` status and never a notification. The shell owns one 60-second recursive poll while any controller's raw folder/project/runtime state is disabled, crashed or version-mismatched; it enters the existing serialized queue, forces the same detection notification as a lockfile event, and stops when no failed state remains (ADR 0005). A mid-install retry can read half-written `node_modules` and produce a real syntax error; continuing through failed states makes that transient harmless without a provisional-error heuristic. Real errors remain visible in status and Output, deduplicated by message rather than logged every minute. The words come from one place, `shared/notInstalled.ts` (the `formatVersionMismatch` precedent) — each stack keeps its own status machinery, none its own wording; the restart hint is derived from `stackCommandTitle`, which `tests/extension.test.ts` checks against the manifest. Rstest classifies config-import failures in its worker (`missingDependencyCauseOf`: Node's `code`, a bare package specifier, and for a subpath a walk-up proving the package really is absent) because IPC drops the `code`; Rslint makes the same code-gated decision where its worker still has structured loader results and sends a dedicated verdict to the editor; fmt intercepts only the exact `rs fmt cannot format this workspace:` Error notification and applies the shared message classifier. A typo'd relative import or a missing subpath of an installed package stays a real error in all three. - One stack failing to register or crashing must never take another stack (or the shell) down. - The shell always activates; per-folder config detection decides which stacks start, and re-runs on config/lockfile changes without a window reload. The per-stack enable settings are coarse kill switches only. - Reconciles and restarts share one serialized queue (`enqueue`); a reconcile leaves a live stack alone, so the restart path — the commands, and the full pass any relevant settings change triggers — is the only thing that rebuilds one. Do not add a second queue. @@ -39,9 +41,10 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten ## Gotchas — decisions that look wrong but aren't +- fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage. - The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves. - **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one. -- **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). Triggers: the shell's detection pass (which already covers lockfiles) plus one lint-owned watcher on `node_modules/@rslint/core/package.json` — upstream's glob minus the lockfiles detection owns. 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. +- **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. - 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`. diff --git a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/.nvmrc b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json new file mode 100644 index 0000000..46d49ae --- /dev/null +++ b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/package.json @@ -0,0 +1,10 @@ +{ + "name": "rstack-editor-fixture-fmt-missing-config-dependency", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture: rs fmt config imports a package that is not installed.", + "dependencies": { + "rstack": "0.7.2" + } +} diff --git a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/rstack.config.ts b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/rstack.config.ts new file mode 100644 index 0000000..ffdc3df --- /dev/null +++ b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/rstack.config.ts @@ -0,0 +1 @@ +import 'missing-fmt-config-dependency'; diff --git a/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/src/needs-format.ts b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/src/needs-format.ts new file mode 100644 index 0000000..01bb788 --- /dev/null +++ b/packages/vscode/e2e/fixtures/fmt-missing-config-dependency/src/needs-format.ts @@ -0,0 +1 @@ +const answer={value:'42'}; diff --git a/packages/vscode/e2e/lint/fixtures/dependency-recovery/.nvmrc b/packages/vscode/e2e/lint/fixtures/dependency-recovery/.nvmrc new file mode 100644 index 0000000..6f4247a --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/dependency-recovery/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/packages/vscode/e2e/lint/fixtures/dependency-recovery/package.json b/packages/vscode/e2e/lint/fixtures/dependency-recovery/package.json new file mode 100644 index 0000000..b3516ec --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/dependency-recovery/package.json @@ -0,0 +1,10 @@ +{ + "name": "rstack-editor-fixture-lint-dependency-recovery", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "E2E fixture: Rslint dependencies are installed while VS Code stays open.", + "dependencies": { + "@rslint/core": "0.9.0" + } +} diff --git a/packages/vscode/e2e/lint/fixtures/dependency-recovery/rslint.config.mjs b/packages/vscode/e2e/lint/fixtures/dependency-recovery/rslint.config.mjs new file mode 100644 index 0000000..2f07926 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/dependency-recovery/rslint.config.mjs @@ -0,0 +1,8 @@ +export default [ + { + files: ['src/**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]; diff --git a/packages/vscode/e2e/lint/fixtures/dependency-recovery/src/index.ts b/packages/vscode/e2e/lint/fixtures/dependency-recovery/src/index.ts new file mode 100644 index 0000000..eab7469 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/dependency-recovery/src/index.ts @@ -0,0 +1 @@ +debugger; diff --git a/packages/vscode/e2e/lint/fixtures/missing-config-dependency/rslint.config.mjs b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/rslint.config.mjs new file mode 100644 index 0000000..83b50bc --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/rslint.config.mjs @@ -0,0 +1,10 @@ +import 'missing-rslint-config-dependency'; + +export default [ + { + files: ['src/**/*.ts'], + rules: { + 'no-debugger': 'error', + }, + }, +]; diff --git a/packages/vscode/e2e/lint/fixtures/missing-config-dependency/src/index.ts b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/src/index.ts new file mode 100644 index 0000000..eab7469 --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/src/index.ts @@ -0,0 +1 @@ +debugger; diff --git a/packages/vscode/e2e/lint/fixtures/missing-config-dependency/tsconfig.json b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/tsconfig.json new file mode 100644 index 0000000..f798b5c --- /dev/null +++ b/packages/vscode/e2e/lint/fixtures/missing-config-dependency/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/vscode/e2e/lint/runTest.ts b/packages/vscode/e2e/lint/runTest.ts index 7ca02b5..443bcd6 100644 --- a/packages/vscode/e2e/lint/runTest.ts +++ b/packages/vscode/e2e/lint/runTest.ts @@ -39,6 +39,7 @@ interface TestSuite { tests: string; workspaceEntry?: string; workspaceFolders?: string[]; + inheritDependencies?: boolean; } const workspaceMarkerFile = '.rstack-vscode-test-sandbox.json'; @@ -118,19 +119,22 @@ async function runIsolatedSuite( { encoding: 'utf8', flag: 'wx', mode: 0o600 }, ); - // Preserve the fixture install root's package boundary and dependency - // lookup (the project-resolved `@rslint/core`) without - // placing a writable node_modules link inside the test workspace. - const packageRoot = await findPackageRoot(suite.workspace); - await fs.promises.copyFile( - path.join(packageRoot, 'package.json'), - path.join(profileRoot, 'package.json'), - ); - await fs.promises.symlink( - path.join(packageRoot, 'node_modules'), - path.join(profileRoot, 'node_modules'), - process.platform === 'win32' ? 'junction' : 'dir', - ); + if (suite.inheritDependencies !== false) { + // Preserve the fixture install root's package boundary and dependency + // lookup (the project-resolved `@rslint/core`) without placing a + // writable node_modules link inside the test workspace. The dependency + // recovery suite opts out: absence at startup is what it tests. + const packageRoot = await findPackageRoot(suite.workspace); + await fs.promises.copyFile( + path.join(packageRoot, 'package.json'), + path.join(profileRoot, 'package.json'), + ); + await fs.promises.symlink( + path.join(packageRoot, 'node_modules'), + path.join(profileRoot, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } await runTests({ extensionDevelopmentPath, @@ -309,6 +313,17 @@ async function main(): Promise { workspace: sharedFixture('rstack'), tests: suiteDir('suite-bridge'), }, + { + name: 'Missing config dependency tests', + workspace: fixture('missing-config-dependency'), + tests: suiteDir('suite-missing-config-dependency'), + }, + { + name: 'Dependency polling recovery tests', + workspace: fixture('dependency-recovery'), + tests: suiteDir('suite-dependency-recovery'), + inheritDependencies: false, + }, ]; // Optional development filter: `RSTACK_LINT_E2E_SUITES="No config,Monorepo"` diff --git a/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts new file mode 100644 index 0000000..d410c0b --- /dev/null +++ b/packages/vscode/e2e/lint/suite-dependency-recovery/dependency-recovery.test.ts @@ -0,0 +1,95 @@ +import * as assert from 'node:assert'; +import { execFile as execFileCallback } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import * as vscode from 'vscode'; +import type { StackState } from '../../../src/types'; +import { waitForRslintDiagnostics } from '../utils/diagnostics'; +import { extensionExports } from '../utils/extension'; + +const execFile = promisify(execFileCallback); + +function lintExports(): { + getFolderStates(): ReadonlyMap; +} { + const exports = extensionExports().getStackExports('rslint'); + assert.ok(exports, 'lint stack exports are unavailable'); + return exports as ReturnType; +} + +async function waitForFolderKind( + kind: StackState['kind'], + timeoutMs = 90_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const states = [...lintExports().getFolderStates().values()]; + const crashed = states.find((state) => state.kind === 'crashed'); + assert.equal( + crashed, + undefined, + `Rslint became crashed while waiting for ${kind}: ${crashed?.detail}`, + ); + if (states.some((state) => state.kind === kind)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for the Rslint folder to become ${kind}`); +} + +suite('Rslint dependency polling recovery', function () { + this.timeout(180_000); + + test('recovers after pnpm install without a restart command', async () => { + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + assert.ok(root, 'VS Code test workspace is unavailable'); + const api = extensionExports(); + api.setDependencyPollIntervalForTest(250); + + const document = await vscode.workspace.openTextDocument( + path.join(root, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + await waitForFolderKind('disabled'); + const warnings = api.getRecordedWarnings('rslint'); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /@rslint\/core is not installed/); + + const lockfile = path.join(root, 'pnpm-lock.yaml'); + const beforeContents = fs.readFileSync(lockfile); + const beforeMtime = fs.statSync(lockfile).mtimeMs; + + await execFile( + 'pnpm', + ['install', '--frozen-lockfile', '--ignore-scripts'], + { + cwd: root, + timeout: 90_000, + // Match setupFixtures.mjs/run.mjs: Windows needs a shell for pnpm's + // .cmd shim. All arguments are fixed safe tokens; cwd is not interpolated. + shell: process.platform === 'win32', + }, + ); + + assert.deepStrictEqual( + fs.readFileSync(lockfile), + beforeContents, + 'pnpm install changed the lockfile contents', + ); + assert.strictEqual( + fs.statSync(lockfile).mtimeMs, + beforeMtime, + 'pnpm install changed the lockfile mtime', + ); + + await waitForRslintDiagnostics(document, undefined, 90_000); + await waitForFolderKind('running'); + assert.strictEqual( + api.getRecordedWarnings('rslint').length, + 1, + 'poll retries must not repeat the unresolved episode warning', + ); + }); +}); diff --git a/packages/vscode/e2e/lint/suite-dependency-recovery/index.ts b/packages/vscode/e2e/lint/suite-dependency-recovery/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-dependency-recovery/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts index 1b50b25..a17e9dd 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/config-transaction.test.ts @@ -24,10 +24,10 @@ import { CONFIG_REFRESH_WATCH_GLOB, configRefreshReasonForPath, createLanguageClientOptions, - isConfigSourceChangeDuringTransaction, recoverConfigDiscoveryOnServerState, retryConfigRefreshOnSourceChange, } from '../../../src/stacks/lint/Rslint'; +import { isConfigSourceChangeDuringTransaction } from '../../../src/stacks/lint/worker/configDependencyProtocol'; import { LspConfigTransactionAdapter } from '../../../src/stacks/lint/worker/ConfigTransactionAdapter'; import { State } from 'vscode-languageclient/node'; import { @@ -282,6 +282,11 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-1', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), + }, ); const loaded = await adapter.loadConfigs(loadRequest()); @@ -327,6 +332,11 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-1', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), + }, ); await adapter.loadConfigs(loadRequest('tx-abort')); @@ -389,6 +399,11 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-degraded', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), + }, ); await adapter.loadConfigs(loadRequest('tx-degraded')); @@ -424,6 +439,11 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-before-prepare', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), + }, ); await adapter.loadConfigs(loadRequest('tx-prepare-race')); @@ -448,6 +468,11 @@ suite('LSP config discovery transactions', () => { pool, () => 'fingerprint-1', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), + }, ); await adapter.loadConfigs(loadRequest('tx-response-lost')); @@ -485,6 +510,11 @@ suite('LSP config discovery transactions', () => { new TestPluginPool(), () => 'fingerprint-1', CONFIG_DISCOVERY_PROTOCOL_VERSION, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: () => assert.fail('unexpected missing dependency'), + reportError: () => assert.fail('unexpected config error'), + }, ); await assert.rejects(adapter.loadConfigs(loadRequest()), /load failed/); diff --git a/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts b/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts index ca071fa..a72b34c 100644 --- a/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts +++ b/packages/vscode/e2e/lint/suite-jsconfig/runtime-manager.test.ts @@ -78,6 +78,10 @@ class FakeRuntime implements ManagedRslintRuntime { startCalls = 0; closeCalls = 0; + isStopped(): boolean { + return this.closeCalls > 0; + } + constructor( readonly rootKey: string, readonly workspaceFolder: WorkspaceFolder, diff --git a/packages/vscode/e2e/lint/suite-missing-config-dependency/index.ts b/packages/vscode/e2e/lint/suite-missing-config-dependency/index.ts new file mode 100644 index 0000000..e8a41f6 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-missing-config-dependency/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(); diff --git a/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts new file mode 100644 index 0000000..810cd19 --- /dev/null +++ b/packages/vscode/e2e/lint/suite-missing-config-dependency/missing-config-dependency.test.ts @@ -0,0 +1,89 @@ +import * as assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as vscode from 'vscode'; +import type { StackState } from '../../../src/types'; +import { + getRslintDiagnostics, + waitForRslintDiagnostics, +} from '../utils/diagnostics'; +import { extensionExports } from '../utils/extension'; + +function lintExports(): { + getFolderStates(): ReadonlyMap; + getRuntimeStates(): ReadonlyMap; +} { + const exports = extensionExports().getStackExports('rslint'); + assert.ok(exports, 'lint stack exports are unavailable'); + return exports as ReturnType; +} + +async function waitForRuntimeKind( + kind: StackState['kind'], + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const exports = lintExports(); + const states = [ + ...exports.getFolderStates().values(), + ...exports.getRuntimeStates().values(), + ]; + const crashed = states.find((state) => state.kind === 'crashed'); + assert.equal( + crashed, + undefined, + `Rslint became crashed while waiting for ${kind}: ${crashed?.detail}`, + ); + if (states.some((state) => state.kind === kind)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for the Rslint runtime to become ${kind}`); +} + +suite('Rslint missing config dependency', function () { + this.timeout(120_000); + + test('reports not installed across initial and live config refreshes', async () => { + const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + assert.ok(root, 'VS Code test workspace is unavailable'); + const document = await vscode.workspace.openTextDocument( + path.join(root, 'src', 'index.ts'), + ); + await vscode.window.showTextDocument(document); + + await waitForRuntimeKind('disabled'); + + const folderStates = [...lintExports().getFolderStates().values()]; + const runtimeStates = [...lintExports().getRuntimeStates().values()]; + assert.ok(folderStates.every((state) => state.kind === 'disabled')); + assert.ok(runtimeStates.every((state) => state.kind === 'disabled')); + assert.deepStrictEqual(getRslintDiagnostics(document), []); + + const warnings = extensionExports().getRecordedWarnings('rslint'); + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /missing-rslint-config-dependency/); + assert.ok(!warnings[0].includes('\n'), 'warning must remain one line'); + + const configPath = path.join(root, 'rslint.config.mjs'); + fs.writeFileSync( + configPath, + "export default [{ files: ['src/**/*.ts'], rules: { 'no-debugger': 'error' } }];\n", + ); + await waitForRslintDiagnostics(document); + await waitForRuntimeKind('running'); + + fs.writeFileSync( + configPath, + "import 'missing-rslint-config-dependency';\nexport default [];\n", + ); + await waitForRuntimeKind('disabled'); + assert.strictEqual( + extensionExports().getRecordedWarnings('rslint').length, + warnings.length + 1, + 'the new missing-dependency episode must add exactly one warning', + ); + }); +}); diff --git a/packages/vscode/e2e/rstest/runTest.ts b/packages/vscode/e2e/rstest/runTest.ts index 9c19941..7ce4bb8 100644 --- a/packages/vscode/e2e/rstest/runTest.ts +++ b/packages/vscode/e2e/rstest/runTest.ts @@ -18,7 +18,13 @@ * the shell probe never runs. */ import { createHash } from 'node:crypto'; -import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { + cpSync, + existsSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { runTests } from '@vscode/test-electron'; @@ -80,7 +86,7 @@ async function main() { )}\n`, ); - await runTests({ + const launchOptions = { // Pinnable for CI; `stable` locally. `runTests` forwards the whole options // object to the downloader, so `version`/`timeout`/`vscodeExecutablePath` // all apply to it. A cached download under `.vscode-test/` is reused. @@ -114,7 +120,39 @@ async function main() { '--user-data-dir', scratchDir, ], - }); + }; + await runTests(launchOptions); + + // Reuse setupFixtures.mjs's exact published pin and generated lockfile, + // but start this isolated workspace with no inherited node_modules. + const recoveryRoot = mkdtempSync(path.join(tmpdir(), 'rst-recovery-')); + const recoveryWorkspace = path.join(recoveryRoot, 'workspace'); + try { + cpSync(path.join(fixturesRoot, 'workspace-1'), recoveryWorkspace, { + recursive: true, + filter: (source) => path.basename(source) !== 'node_modules', + }); + await runTests({ + ...launchOptions, + extensionTestsPath: path.resolve( + __dirname, + './suite-dependency-recovery/index', + ), + launchArgs: [ + recoveryWorkspace, + ...launchOptions.launchArgs.slice(1, -2), + '--user-data-dir', + path.join(recoveryRoot, 'profile'), + ], + }); + } finally { + rmSync(recoveryRoot, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 200, + }); + } } main().catch((error) => { diff --git a/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts b/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts new file mode 100644 index 0000000..d3e5704 --- /dev/null +++ b/packages/vscode/e2e/rstest/suite-dependency-recovery/dependency-recovery.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { execFile as execFileCallback } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import vscode from 'vscode'; +import type { RstackExtensionExports } from '../../../src/types'; +import { getProjectItems, getRstestExports, waitFor } from '../suite/helpers'; + +const execFile = promisify(execFileCallback); + +suite('Rstest dependency polling recovery', function () { + this.timeout(180_000); + + test('recovers after pnpm install without a restart command', async () => { + const folder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(folder); + const root = folder.uri.fsPath; + assert.equal(fs.existsSync(path.join(root, 'node_modules')), false); + const extension = + vscode.extensions.getExtension('rstack.rstack'); + assert.ok(extension); + const api = await extension.activate(); + api.setDependencyPollIntervalForTest(250); + const rstest = await getRstestExports(); + const hasNotInstalled = api.getStackExports('rstest') + ?.hasNotInstalledState as () => boolean; + await waitFor(() => assert.equal(hasNotInstalled(), true)); + + const lockfile = path.join(root, 'pnpm-lock.yaml'); + const contents = fs.readFileSync(lockfile); + const mtime = fs.statSync(lockfile).mtimeMs; + await execFile( + 'pnpm', + ['install', '--frozen-lockfile', '--ignore-scripts'], + { + cwd: root, + timeout: 90_000, + // Windows needs a shell for pnpm.cmd; arguments are fixed safe tokens. + shell: process.platform === 'win32', + }, + ); + assert.deepEqual( + fs.readFileSync(lockfile), + contents, + 'lockfile contents changed', + ); + assert.equal( + fs.statSync(lockfile).mtimeMs, + mtime, + 'lockfile mtime changed', + ); + + await waitFor( + () => { + assert.equal(hasNotInstalled(), false); + const items = getProjectItems(rstest.testController); + assert.ok(items.some((item) => item.id.endsWith('/test/foo.test.ts'))); + }, + { timeoutMs: 90_000, pollMs: 100 }, + ); + }); +}); diff --git a/packages/vscode/e2e/rstest/suite-dependency-recovery/index.ts b/packages/vscode/e2e/rstest/suite-dependency-recovery/index.ts new file mode 100644 index 0000000..ab80101 --- /dev/null +++ b/packages/vscode/e2e/rstest/suite-dependency-recovery/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../../runSuite'; + +export const run = createRun(__dirname); diff --git a/packages/vscode/e2e/run.mjs b/packages/vscode/e2e/run.mjs index 7b4d36c..4b69b6f 100644 --- a/packages/vscode/e2e/run.mjs +++ b/packages/vscode/e2e/run.mjs @@ -26,7 +26,7 @@ const SLICES = [ // The shell/detection/fmt suites (`e2e/suite/`) over the multi-root // workspace of the three shared fixtures. name: 'vscode', - fixtures: ['rslint', 'rstest', 'rstack'], + fixtures: ['rslint', 'rstest', 'rstack', 'fmt-missing-config-dependency'], entry: 'tests-dist/e2e/runTest.js', compile: true, }, @@ -43,7 +43,7 @@ const SLICES = [ // shared `rstack` fixture. `RSTACK_LINT_E2E_SUITES=` filters // which suites run. name: 'lint', - fixtures: ['lint', 'rstack'], + fixtures: ['lint', 'lint-dependency-recovery', 'rstack'], entry: 'tests-dist/e2e/lint/runTest.js', compile: true, }, @@ -63,7 +63,7 @@ const run = (command, args, opts = {}) => { const result = spawnSync(command, args, { cwd: packageRoot, stdio: 'inherit', - env: process.env, + env: { ...process.env, RSTACK_E2E_RECORD_WARNINGS: '1' }, // With `shell: true` Node concatenates command and args UNESCAPED, so a // path containing spaces (the checkout, `process.execPath`) would fall // apart into several arguments — callers opt in only where the command diff --git a/packages/vscode/e2e/runSuite.ts b/packages/vscode/e2e/runSuite.ts new file mode 100644 index 0000000..969004b --- /dev/null +++ b/packages/vscode/e2e/runSuite.ts @@ -0,0 +1,53 @@ +import { readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import Mocha from 'mocha'; + +const collectTests = (dir: string): string[] => + readdirSync(dir).flatMap((entry) => { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + return collectTests(full); + } + return full.endsWith('.test.js') ? [full] : []; + }); + +/** + * The extension host's entry point into a VS Code slice suite. VS Code calls + * the returned function once the window has started, so tests observe the real + * `onStartupFinished` activation instead of forcing it. + */ +export const createRun = (testPath: string): (() => Promise) => { + return () => { + const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 120_000 }); + for (const file of collectTests(testPath)) { + mocha.addFile(file); + } + + return new Promise((resolve, reject) => { + try { + // Mocha's reporter writes to the extension host's stdout, which never + // reaches the harness log — the rejection message is the only channel + // that does, so it must name the failures itself. + const failed: string[] = []; + const runner = mocha.run((failures) => { + if (failures > 0) { + reject( + new Error( + `${failures} E2E test(s) failed:\n${failed.join('\n')}`, + ), + ); + } else { + resolve(); + } + }); + runner.on('fail', (test, error) => { + failed.push( + `- ${test.fullTitle()}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + }; +}; diff --git a/packages/vscode/e2e/runTest.ts b/packages/vscode/e2e/runTest.ts index 8c473c7..dad4d53 100644 --- a/packages/vscode/e2e/runTest.ts +++ b/packages/vscode/e2e/runTest.ts @@ -11,33 +11,29 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { runTests } from '@vscode/test-electron'; -const FIXTURE_NAMES = ['rslint', 'rstest', 'rstack'] as const; +const FIXTURE_NAMES = [ + 'rslint', + 'rstest', + 'rstack', + 'fmt-missing-config-dependency', +] as const; -async function main() { - // `__dirname` is `/tests-dist/e2e` (see tsconfig.e2e.json). - const extensionDevelopmentPath = path.resolve(__dirname, '../..'); - const extensionTestsPath = path.resolve(__dirname, './suite/index'); - const fixturesDir = path.join(extensionDevelopmentPath, 'e2e/fixtures'); - const workspaceFile = path.join(fixturesDir, 'e2e.code-workspace'); - - // The extension host loads `main` from `package.json`; an unbuilt repo would - // otherwise fail deep inside VS Code with an unhelpful activation error. - if (!existsSync(path.join(extensionDevelopmentPath, 'dist/extension.js'))) { - throw new Error( - 'dist/extension.js is missing — run `pnpm build` before `pnpm test:e2e`.', - ); - } - for (const name of FIXTURE_NAMES) { - if (!existsSync(path.join(fixturesDir, name, 'node_modules'))) { - throw new Error( - `the ${name} E2E fixture is not installed — run \`pnpm test:e2e:fixtures\`.`, - ); - } - } +interface LaunchOptions { + readonly extensionDevelopmentPath: string; + readonly extensionTestsPath: string; + readonly workspace: string; + readonly profileSuffix: string; +} +async function launch({ + extensionDevelopmentPath, + extensionTestsPath, + workspace, + profileSuffix, +}: LaunchOptions): Promise { // A short user-data dir keeps the Unix socket paths below the macOS limit. const hash = createHash('sha1') - .update(extensionDevelopmentPath) + .update(`${extensionDevelopmentPath}\0${profileSuffix}`) .digest('hex') .slice(0, 8); const userDataDir = mkdtempSync(path.join(tmpdir(), `rstack-${hash}-`)); @@ -57,7 +53,7 @@ async function main() { extensionDevelopmentPath, extensionTestsPath, launchArgs: [ - workspaceFile, + workspace, // Keep VS Code's CI-only extension inventory and AgentHost info logs out // of test output while preserving an opt-in for verbose diagnosis. `--log=${process.env.VSCODE_TEST_LOG_LEVEL ?? 'warn'}`, @@ -78,6 +74,44 @@ async function main() { }); } +async function main() { + // `__dirname` is `/tests-dist/e2e` (see tsconfig.e2e.json). + const extensionDevelopmentPath = path.resolve(__dirname, '../..'); + const fixturesDir = path.join(extensionDevelopmentPath, 'e2e/fixtures'); + const workspaceFile = path.join(fixturesDir, 'e2e.code-workspace'); + + // The extension host loads `main` from `package.json`; an unbuilt repo would + // otherwise fail deep inside VS Code with an unhelpful activation error. + if (!existsSync(path.join(extensionDevelopmentPath, 'dist/extension.js'))) { + throw new Error( + 'dist/extension.js is missing — run `pnpm build` before `pnpm test:e2e`.', + ); + } + for (const name of FIXTURE_NAMES) { + if (!existsSync(path.join(fixturesDir, name, 'node_modules'))) { + throw new Error( + `the ${name} E2E fixture is not installed — run \`pnpm test:e2e:fixtures\`.`, + ); + } + } + + await launch({ + extensionDevelopmentPath, + extensionTestsPath: path.resolve(__dirname, './suite/index'), + workspace: workspaceFile, + profileSuffix: 'main', + }); + await launch({ + extensionDevelopmentPath, + extensionTestsPath: path.resolve( + __dirname, + './suite-fmt-missing-config-dependency/index', + ), + workspace: path.join(fixturesDir, 'fmt-missing-config-dependency'), + profileSuffix: 'fmt-missing-config-dependency', + }); +} + main().catch((error) => { console.error('Failed to run E2E tests'); console.error(error); diff --git a/packages/vscode/e2e/setupFixtures.mjs b/packages/vscode/e2e/setupFixtures.mjs index 8a95cef..fb4cd6d 100644 --- a/packages/vscode/e2e/setupFixtures.mjs +++ b/packages/vscode/e2e/setupFixtures.mjs @@ -32,9 +32,19 @@ export const FIXTURES = { rslint: path.join(FIXTURES_DIR, 'rslint'), rstest: path.join(FIXTURES_DIR, 'rstest'), rstack: path.join(FIXTURES_DIR, 'rstack'), + 'fmt-missing-config-dependency': path.join( + FIXTURES_DIR, + 'fmt-missing-config-dependency', + ), 'rstest-workspace-1': path.join(here, 'rstest', 'fixtures', 'workspace-1'), 'rstest-workspace-2': path.join(here, 'rstest', 'fixtures', 'workspace-2'), lint: path.join(here, 'lint', 'fixtures'), + 'lint-dependency-recovery': path.join( + here, + 'lint', + 'fixtures', + 'dependency-recovery', + ), }; export const FIXTURE_NAMES = Object.keys(FIXTURES); diff --git a/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts new file mode 100644 index 0000000..3187633 --- /dev/null +++ b/packages/vscode/e2e/suite-fmt-missing-config-dependency/fmt-missing-config-dependency.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import type { RstackExtensionExports } from '../../src/types'; +import { eventually } from '../suite/helpers'; + +suite('fmt missing config dependency', () => { + test('suppresses the server toast and reports disabled', async () => { + const extension = + vscode.extensions.getExtension('rstack.rstack'); + assert.ok(extension, 'rstack.rstack is not installed in the test host'); + const api = await extension.activate(); + const exports = await api.whenStackActive('fmt'); + const folderStates = exports.folderStates as () => Record; + const suppressedConfigDependencyMessages = + exports.suppressedConfigDependencyMessages as () => number; + const folder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(folder, 'fmt fixture workspace is unavailable'); + const observedStates: string[] = []; + const sampleState = (): string => { + const state = folderStates()[folder.uri.fsPath]; + observedStates.push(state); + return state; + }; + + await eventually(() => { + const state = sampleState(); + assert.notEqual( + state, + 'crashed', + 'rs fmt became crashed while waiting for running', + ); + assert.equal(state, 'running'); + }, 'the rs fmt server to start'); + + const uri = vscode.Uri.joinPath(folder.uri, 'src', 'needs-format.ts'); + await vscode.workspace.openTextDocument(uri); + await vscode.commands.executeCommand( + 'vscode.executeFormatDocumentProvider', + uri, + { tabSize: 2, insertSpaces: true }, + ); + + await eventually(() => { + const state = sampleState(); + assert.notEqual( + state, + 'crashed', + 'rs fmt became crashed while waiting for disabled', + ); + assert.equal(state, 'disabled'); + }, 'the fmt config dependency failure to become disabled'); + // eventually retries thrown assertions, so retain every sample and check + // outside it: a transient crash must not disappear behind later recovery. + assert.ok(!observedStates.includes('crashed'), observedStates.join(' -> ')); + assert.equal(suppressedConfigDependencyMessages(), 1); + const warnings = api.getRecordedWarnings('fmt'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /missing-fmt-config-dependency/); + assert.ok(!warnings[0].includes('\n'), 'warning must remain one line'); + + // The server deduplicates identical showMessage errors, but still returns + // empty edits. A silent second response must not clear the warning latch. + await vscode.commands.executeCommand( + 'vscode.executeFormatDocumentProvider', + uri, + { tabSize: 2, insertSpaces: true }, + ); + assert.equal(sampleState(), 'disabled'); + assert.equal(api.getRecordedWarnings('fmt').length, 1); + }); +}); diff --git a/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts b/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts new file mode 100644 index 0000000..e86c367 --- /dev/null +++ b/packages/vscode/e2e/suite-fmt-missing-config-dependency/index.ts @@ -0,0 +1,3 @@ +import { createRun } from '../runSuite'; + +export const run = createRun(__dirname); diff --git a/packages/vscode/e2e/suite/index.ts b/packages/vscode/e2e/suite/index.ts index dfd8ada..e86c367 100644 --- a/packages/vscode/e2e/suite/index.ts +++ b/packages/vscode/e2e/suite/index.ts @@ -1,49 +1,3 @@ -import { readdirSync, statSync } from 'node:fs'; -import path from 'node:path'; -import Mocha from 'mocha'; +import { createRun } from '../runSuite'; -const collectTests = (dir: string): string[] => - readdirSync(dir).flatMap((entry) => { - const full = path.join(dir, entry); - if (statSync(full).isDirectory()) { - return collectTests(full); - } - return full.endsWith('.test.js') ? [full] : []; - }); - -/** - * The extension host's entry point into the suite. VS Code calls `run()` once - * the window has started, so the tests observe the real `onStartupFinished` - * activation instead of forcing it. - */ -export function run(): Promise { - const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 120_000 }); - for (const file of collectTests(__dirname)) { - mocha.addFile(file); - } - - return new Promise((resolve, reject) => { - try { - // Mocha's reporter writes to the extension host's stdout, which never - // reaches the harness log — the rejection message is the only channel - // that does, so it must name the failures itself. - const failed: string[] = []; - const runner = mocha.run((failures) => { - if (failures > 0) { - reject( - new Error(`${failures} E2E test(s) failed:\n${failed.join('\n')}`), - ); - } else { - resolve(); - } - }); - runner.on('fail', (test, error) => { - failed.push( - `- ${test.fullTitle()}: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - } catch (error) { - reject(error instanceof Error ? error : new Error(String(error))); - } - }); -} +export const run = createRun(__dirname); diff --git a/packages/vscode/src/channels.ts b/packages/vscode/src/channels.ts index 852b4ab..944296d 100644 --- a/packages/vscode/src/channels.ts +++ b/packages/vscode/src/channels.ts @@ -19,6 +19,7 @@ export class Channels implements vscode.Disposable { readonly shell: vscode.LogOutputChannel; readonly #stacks: Record; + readonly #recordedWarnings = new Map(); constructor() { this.shell = vscode.window.createOutputChannel(CHANNEL_NAMES.shell, { @@ -30,6 +31,22 @@ export class Channels implements vscode.Disposable { vscode.window.createOutputChannel(CHANNEL_NAMES[stack], { log: true }), ]), ) as Record; + if (process.env.RSTACK_E2E_RECORD_WARNINGS === '1') { + for (const stack of STACK_IDS) { + const channel = this.#stacks[stack]; + const warnings: string[] = []; + this.#recordedWarnings.set(stack, warnings); + const warn = channel.warn.bind(channel); + channel.warn = (message, ...args) => { + warnings.push(message); + warn(message, ...args); + }; + } + } + } + + getRecordedWarnings(stack: StackId): readonly string[] { + return [...(this.#recordedWarnings.get(stack) ?? [])]; } forStack(stack: StackId): vscode.LogOutputChannel { diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index dd906cc..d0ffd90 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -34,8 +34,9 @@ export const DEFAULT_RSTEST_CONFIG_GLOBS = [ /** * Lockfiles are watched as a proxy for dependency changes — the pattern Rslint - * already uses. Watching `node_modules` directly is unreliable (pnpm symlinks) - * and is not attempted. + * already uses — and remain the low-latency path. A direct `node_modules` + * watcher is not attempted: pnpm installs produced no matching per-file event + * in either isolated or hoisted layout (ADR 0005). */ export const LOCKFILE_NAMES = [ 'package-lock.json', @@ -181,11 +182,11 @@ export const detectFolder = async ( ), ] as const); - const rootRstackConfigPath = rstackConfigFiles.find((uri) => - RSTACK_CONFIG_NAMES.some( - (name) => - vscode.Uri.joinPath(folder.uri, name).toString() === uri.toString(), - ), + // Match the shim's loader precedence, not findFiles discovery order. + const rootRstackConfigPath = RSTACK_CONFIG_NAMES.map((name) => + vscode.Uri.joinPath(folder.uri, name), + ).find((candidate) => + rstackConfigFiles.some((uri) => uri.toString() === candidate.toString()), )?.fsPath; const rslintMode = decideRslintMode({ nativeConfigPaths: rslintConfigFiles.map((uri) => uri.fsPath), @@ -212,7 +213,7 @@ export const detectFolder = async ( }, }; - return { folder, stacks }; + return { folder, rootRstackConfigPath, stacks }; }; const signatureOf = (snapshot: DetectionSnapshot): string => @@ -244,9 +245,9 @@ export class DetectionService implements vscode.Disposable { // out identical while every project-resolved package (Rslint binary, Rstest // core, the rstack shim) may now resolve differently. Such a pass must // notify subscribers even when the signature is unchanged, or failed - // resolutions are never retried until a window reload. Set by the lockfile - // watcher only — a caller that drives the rebuild itself does not need the - // event, it already has the fresh snapshot. + // resolutions would wait for the polling fallback. Set by the lockfile + // watcher and by `refreshForDependencyChange`; a caller that drives the + // rebuild itself does not need the event, it already has the fresh snapshot. #notifyUnchanged = false; #watchers: vscode.Disposable[] = []; #debounce: ReturnType | undefined; @@ -283,6 +284,16 @@ export class DetectionService implements vscode.Disposable { return this.refresh(); } + /** + * Re-runs package probes and notifies live stacks even when detection's file + * signature stays unchanged. This is the same signal a lockfile event sends: + * dependencies may now resolve from a newly populated `node_modules`. + */ + refreshForDependencyChange(): Promise { + this.#notifyUnchanged = true; + return this.refresh(); + } + async refresh(): Promise { if (this.#running) { // Coalesce concurrent refreshes: one extra pass covers every caller that diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index e250577..eac8eb7 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -27,6 +27,8 @@ const STACK_FACTORIES: Readonly> = { /** Stacks that run project-loading children on the shared User Node runtime. */ const USER_NODE_STACKS: readonly StackId[] = ['rslint', 'rstest', 'fmt']; +const DEFAULT_DEPENDENCY_POLL_INTERVAL_MS = 60_000; + const errorMessage = (error: unknown): string => error instanceof Error ? (error.stack ?? error.message) : String(error); @@ -57,6 +59,9 @@ class ExtensionShell { >(); #reconciling: Promise = Promise.resolve(); + #dependencyPollIntervalMs = DEFAULT_DEPENDENCY_POLL_INTERVAL_MS; + #dependencyPollTimer: ReturnType | undefined; + #dependencyPollInFlight = false; #disposed = false; constructor(private readonly context: vscode.ExtensionContext) { @@ -219,6 +224,55 @@ class ExtensionShell { void this.reconcile(); } + private get dependencyPollNeeded(): boolean { + if (this.#disposed) return false; + for (const controller of this.#controllers.values()) { + if (controller.hasFailedState()) return true; + } + return false; + } + + /** + * Starts one recursive timer only while a live controller owns a + * failed state. The timer enters the same shell queue as every + * reconcile/restart, then sends the same forced detection event as a + * lockfile change; each stack therefore reuses its existing retry path. + */ + private syncDependencyPoll(): void { + if (this.#dependencyPollInFlight) return; + if (!this.dependencyPollNeeded) { + if (this.#dependencyPollTimer !== undefined) { + clearTimeout(this.#dependencyPollTimer); + this.#dependencyPollTimer = undefined; + } + return; + } + if (this.#dependencyPollTimer !== undefined) { + return; + } + this.#dependencyPollTimer = setTimeout(() => { + this.#dependencyPollTimer = undefined; + this.#dependencyPollInFlight = true; + void this.enqueue(async () => { + if (!this.dependencyPollNeeded) { + return; + } + try { + await this.#detection.refreshForDependencyChange(); + } catch (error) { + if (!this.#disposed) { + this.#channels.shell.error( + `Dependency recovery detection failed: ${errorMessage(error)}`, + ); + } + } + }).finally(() => { + this.#dependencyPollInFlight = false; + this.syncDependencyPoll(); + }); + }, this.#dependencyPollIntervalMs); + } + /** * `rstack.restart` (every stack) and `rstack..restart` (one) — a full * reset, not a "retry whatever looks broken". @@ -324,6 +378,7 @@ class ExtensionShell { next?: StackState, ): Promise { this.#controllers.delete(stack); + this.syncDependencyPoll(); await this.disposeController(stack, controller); if (!next || this.#disposed) { return; @@ -387,7 +442,9 @@ class ExtensionShell { stack, extensionContext: this.context, output: this.#channels.forStack(stack), - status: this.#statusBar.reporterFor(stack), + status: this.#statusBar.reporterFor(stack, () => + this.syncDependencyPoll(), + ), detection: snapshot, onDidChangeDetection: this.#detectionEmitter.event, }); @@ -403,6 +460,7 @@ class ExtensionShell { } await this.setContextKey(`rstack.${stack}.active`, true); this.#statusBar.setActive(stack, true); + this.syncDependencyPoll(); this.#channels.shell.info(`${STACK_LABELS[stack]} registered`); } catch (error) { await this.retire(stack, controller, { @@ -456,6 +514,7 @@ class ExtensionShell { buildExports(): RstackExtensionExports { return { + getRecordedWarnings: (stack) => this.#channels.getRecordedWarnings(stack), getStackExports: (stack) => this.#stackExports.get(stack), whenStackActive: (stack) => { const current = this.#stackExports.get(stack); @@ -468,6 +527,17 @@ class ExtensionShell { this.#stackExportWaiters.set(stack, waiters); }); }, + setDependencyPollIntervalForTest: (intervalMs) => { + if (!Number.isFinite(intervalMs) || intervalMs < 1) { + throw new Error('dependency poll interval must be a positive number'); + } + this.#dependencyPollIntervalMs = intervalMs; + if (this.#dependencyPollTimer !== undefined) { + clearTimeout(this.#dependencyPollTimer); + this.#dependencyPollTimer = undefined; + } + this.syncDependencyPoll(); + }, }; } @@ -483,6 +553,10 @@ class ExtensionShell { async dispose(): Promise { this.#disposed = true; + if (this.#dependencyPollTimer !== undefined) { + clearTimeout(this.#dependencyPollTimer); + this.#dependencyPollTimer = undefined; + } // Before the wait below, not after it: the service holds a debounce timer // and its own watchers, so leaving it live means a file touched during // shutdown can start a fresh detection pass behind us. diff --git a/packages/vscode/src/shared/displayPath.ts b/packages/vscode/src/shared/displayPath.ts new file mode 100644 index 0000000..1c75c34 --- /dev/null +++ b/packages/vscode/src/shared/displayPath.ts @@ -0,0 +1,9 @@ +import path from 'node:path'; + +/** A folder-relative status label, without exposing paths outside the folder. */ +export const displayPath = (folderPath: string, filePath: string): string => { + const relative = path.relative(folderPath, filePath); + return relative.length > 0 && !relative.startsWith('..') + ? relative + : path.basename(filePath); +}; diff --git a/packages/vscode/src/shared/messageLatch.ts b/packages/vscode/src/shared/messageLatch.ts new file mode 100644 index 0000000..228a4df --- /dev/null +++ b/packages/vscode/src/shared/messageLatch.ts @@ -0,0 +1,18 @@ +/** Suppresses consecutive identical messages until the owning operation recovers. */ +export class MessageLatch { + #message: string | undefined; + + get current(): string | undefined { + return this.#message; + } + + changed(message: string): boolean { + if (this.#message === message) return false; + this.#message = message; + return true; + } + + clear(): void { + this.#message = undefined; + } +} diff --git a/packages/vscode/src/shared/missingDependency.ts b/packages/vscode/src/shared/missingDependency.ts index 141314c..675d7e0 100644 --- a/packages/vscode/src/shared/missingDependency.ts +++ b/packages/vscode/src/shared/missingDependency.ts @@ -1,6 +1,12 @@ import path from 'node:path'; import { findPackageJsonUncached } from './packageResolve'; +export function isMissingDependencyCode( + code: unknown, +): code is 'ERR_MODULE_NOT_FOUND' | 'MODULE_NOT_FOUND' { + return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND'; +} + /** * 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 @@ -9,29 +15,17 @@ import { findPackageJsonUncached } from './packageResolve'; * `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. + * that is not installed, or `undefined` for a real error. 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. 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, +export function classifyMissingDependencyMessage( + message: string, 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 [firstLine] = message.split('\n', 1); const specifier = /^Cannot find (?:package|module) '([^']+)'/.exec( firstLine, )?.[1]; @@ -58,3 +52,20 @@ export function missingDependencyCauseOf( } return firstLine; } + +/** + * Error-object entry point used where Node's loader code survives. The code is + * still required there: arbitrary user errors may contain loader-like prose. + * Worker/protocol boundaries that already carry a separately checked code use + * `classifyMissingDependencyMessage` directly because serialization can drop + * custom Error fields. + */ +export function missingDependencyCauseOf( + error: unknown, + resolveFrom: string, +): string | undefined { + if (!(error instanceof Error)) return undefined; + const { code } = error as NodeJS.ErrnoException; + if (!isMissingDependencyCode(code)) return undefined; + return classifyMissingDependencyMessage(error.message, resolveFrom); +} diff --git a/packages/vscode/src/shared/notInstalled.ts b/packages/vscode/src/shared/notInstalled.ts index 42f0bea..7ebd976 100644 --- a/packages/vscode/src/shared/notInstalled.ts +++ b/packages/vscode/src/shared/notInstalled.ts @@ -1,3 +1,4 @@ +import { MessageLatch } from './messageLatch'; import { COMMAND_CATEGORY, STACK_LABELS, @@ -13,10 +14,9 @@ import { * `formatVersionMismatch` — each keeps its own status machinery, but what * the user reads is one sentence, not three near-copies. * - * The trailing hint covers the recovery no watcher sees: an install that - * changes no lockfile (a fresh clone whose lockfile is already current) fires - * no detection pass, so the restart command is the way out and the status is - * where it has to be named (ADR 0002). + * A shell-owned poll covers installs that change no lockfile. The trailing + * restart hint remains the explicit fallback when recovery is delayed or the + * project stays broken for another reason (ADR 0005). */ const restartHint = (stack: StackId): string => `then run "${COMMAND_CATEGORY}: ${stackCommandTitle(stack)}" if this status stays`; @@ -51,6 +51,40 @@ export const formatConfigDependencyMissingLog = ( ): string => `Cannot load ${configPath}: ${cause}. Install the project dependencies to enable ${STACK_LABELS[stack]} for this config.`; +export interface ConfigDependencyFailure { + readonly configPath: string; + readonly cause: string; +} + +/** + * Deduplicates one not-installed warning until a successful load ends the + * episode. Stacks receive their failures over different protocols, but + * the latch semantics and the user-facing words are the same. + */ +export class NotInstalledEpisode { + readonly #message = new MessageLatch(); + + get active(): boolean { + return this.#message.current !== undefined; + } + + observe(stack: StackId, configPath: string, cause: string) { + const warning = this.#message.changed(`${configPath}\0${cause}`) + ? formatConfigDependencyMissingLog(stack, configPath, cause) + : undefined; + return { + reason: formatConfigDependencyMissingStatus(stack, configPath), + warning, + }; + } + + clear(): boolean { + const wasActive = this.active; + this.#message.clear(); + return wasActive; + } +} + /** * The output-channel line: where the stack looked, plus the stack's own * consequence — the same shape as the shared Node preflight message diff --git a/packages/vscode/src/stacks/fmt/index.ts b/packages/vscode/src/stacks/fmt/index.ts index f05e46c..20c7089 100644 --- a/packages/vscode/src/stacks/fmt/index.ts +++ b/packages/vscode/src/stacks/fmt/index.ts @@ -1,17 +1,24 @@ import path from 'node:path'; import vscode from 'vscode'; +import type { ShowMessageParams } from 'vscode-languageclient'; import { CloseAction, ErrorAction, LanguageClient, + MessageType, + ShowMessageNotification, State, type ErrorHandler, type LanguageClientOptions, type ServerOptions, } from 'vscode-languageclient/node'; import { RSTACK_CONFIG_GLOB } from '../../detection'; +import { MessageLatch } from '../../shared/messageLatch'; +import { displayPath } from '../../shared/displayPath'; +import { classifyMissingDependencyMessage } from '../../shared/missingDependency'; import { getConfiguredNodeExecutable } from '../../shared/nodeExecutableSetting'; import { + NotInstalledEpisode, formatNotInstalledLog, formatNotInstalledStatus, } from '../../shared/notInstalled'; @@ -48,6 +55,8 @@ import { isFailedFmtState, } from './status'; +const FMT_SESSION_ERROR_PREFIX = 'rs fmt cannot format this workspace: '; + // prettier 3.9.6 getSupportInfo() vscodeLanguageIds snapshot (rs fmt's pinned // prettier). Revisit when the pinned prettier changes. const LANGUAGE_IDS = [ @@ -154,6 +163,13 @@ class FmtFolderRuntime { #client: LanguageClient | undefined; #defaultErrorHandler: ErrorHandler | undefined; #stateWatcher: vscode.Disposable | undefined; + #configPath: string | undefined; + readonly #packageEpisode = new MessageLatch(); + readonly #configDependencyEpisode = new NotInstalledEpisode(); + readonly #startError = new MessageLatch(); + readonly #sessionError = new MessageLatch(); + #failureSeq = 0; + suppressedShowMessages = 0; #closing = false; #disposed = false; /** True only across `startImpl`'s `client.start()` await — the window `interruptInFlightStart` exists for. */ @@ -193,6 +209,10 @@ class FmtFolderRuntime { return this.folder.uri.fsPath; } + setConfigPath(configPath: string | undefined): void { + this.#configPath = configPath; + } + private setState(state: FmtRuntimeState, detail = ''): void { this.#state = state; this.#detail = detail; @@ -204,6 +224,53 @@ class FmtFolderRuntime { this.onDidChangeStatus(); } + private handleShowMessage(message: ShowMessageParams): void { + switch (message.type) { + case MessageType.Error: { + if (!message.message.startsWith(FMT_SESSION_ERROR_PREFIX)) { + void vscode.window.showErrorMessage(message.message); + break; + } + const firstLine = message.message + .slice(FMT_SESSION_ERROR_PREFIX.length) + .split('\n', 1)[0]; + const configPath = this.#configPath; + this.#failureSeq++; + if (configPath !== undefined) { + const cause = classifyMissingDependencyMessage( + firstLine.replace(/^Error(?: \[[A-Z_]+\])?: /, ''), + this.folderPath, + ); + if (cause !== undefined) { + this.#sessionError.clear(); + const report = this.#configDependencyEpisode.observe( + 'fmt', + displayPath(this.folderPath, configPath), + cause, + ); + if (report.warning !== undefined) + this.context.output.warn(report.warning); + this.suppressedShowMessages++; + this.setState('disabled', report.reason); + return; + } + } + this.#configDependencyEpisode.clear(); + if (this.#sessionError.changed(firstLine)) + this.context.output.error(firstLine); + this.setState('crashed', firstLine); + void vscode.window.showErrorMessage(message.message); + break; + } + case MessageType.Warning: + void vscode.window.showWarningMessage(message.message); + break; + default: + void vscode.window.showInformationMessage(message.message); + break; + } + } + /** * `waitFor` is the previous runtime's retirement (see the controller's * `#retiring`): awaited *inside* the queue, so a config-event `restart()` @@ -299,16 +366,17 @@ class FmtFolderRuntime { const pkgJsonPath = findPackageJsonUncached('rstack', folderRoot); if (!pkgJsonPath) { - // The trailing hint covers the one recovery path no watcher sees: an - // install that changes no lockfile (a fresh clone whose lockfile is - // already current) fires no file event, so nothing rebuilds this - // runtime — the status message is where the way out has to live. + // The shell polls while this state remains disabled. The trailing restart + // hint stays as the explicit fallback if recovery is delayed. this.setState('disabled', formatNotInstalledStatus('fmt', 'rstack')); - context.output.warn( - formatNotInstalledLog('rstack', this.folder.name, folderRoot), - ); + if (this.#packageEpisode.changed(folderRoot)) { + context.output.warn( + formatNotInstalledLog('rstack', this.folder.name, folderRoot), + ); + } return; } + this.#packageEpisode.clear(); // One read for the version and the bin entry; `readPackageJson` re-reads // from disk by design, so a reinstall is picked up on the next start. @@ -349,6 +417,13 @@ class FmtFolderRuntime { serverOptions, this.createClientOptions(), ); + // vscode-languageclient installs pending handlers after initialize with + // method-keyed replacement semantics. Registering before start therefore + // replaces its default toast handler while leaving unrelated messages on + // the same Error/Warning/Info UI path below. + client.onNotification(ShowMessageNotification.type, (message) => { + this.handleShowMessage(message); + }); // Created once per client, not per callback: the default handler carries // the restart budget (N crashes in K minutes), and it can only be created // from the client the options were built for. @@ -363,12 +438,12 @@ class FmtFolderRuntime { // owner's call; either way this folder is currently not formatting. this.setState('crashed', 'the rs fmt language server stopped'); } else if (event.newState === State.Running) { - // The one writer for `running`. It fires on the first start - // (synchronously, before `client.start()` resolves) and again when - // vscode-languageclient's error handler restarts a crashed server — - // the way back out of `crashed`, the same transition the lint stack's - // state watcher makes. - this.setState('running'); + // Initialize does not load config. Keep a known real config error + // polling across restarts until formatting actually produces edits. + this.setState( + this.#sessionError.current === undefined ? 'running' : 'crashed', + this.#sessionError.current, + ); } }); @@ -396,15 +471,20 @@ class FmtFolderRuntime { if (interrupted || this.#disposed) { return; } + const message = error instanceof Error ? error.message : String(error); this.setState( 'crashed', - `the rs fmt language server failed to start: ${ - error instanceof Error ? error.message : String(error) - }`, + `the rs fmt language server failed to start: ${message}`, ); - context.output.error('Failed to start the rs fmt language server', error); + if (this.#startError.changed(message)) { + context.output.error( + 'Failed to start the rs fmt language server', + error, + ); + } return; } + this.#startError.clear(); context.output.info(`rs fmt language server started for ${folderRoot}`); } @@ -490,6 +570,35 @@ class FmtFolderRuntime { // instead of a separate "... Trace" channel per folder. traceOutputChannel: this.context.output, errorHandler, + middleware: { + provideDocumentFormattingEdits: async ( + document, + options, + token, + next, + ) => { + const failuresBeforeRequest = this.#failureSeq; + const edits = await next(document, options, token); + const hadConfigDependency = this.#configDependencyEpisode.active; + // Empty edits also signal failure; notifications during this request + // must not be cleared by its edits. + if ( + (edits?.length ?? 0) > 0 && + failuresBeforeRequest === this.#failureSeq + ) { + this.#configDependencyEpisode.clear(); + const hadSessionError = this.#sessionError.current !== undefined; + this.#sessionError.clear(); + if ( + (hadConfigDependency && this.#state === 'disabled') || + (hadSessionError && this.#state === 'crashed') + ) { + this.setState('running'); + } + } + return edits; + }, + }, }; } @@ -632,6 +741,12 @@ class FmtController implements StackController { runtime.state, ]), ), + /** E2E only: classified config failures suppressed from showMessage. */ + suppressedConfigDependencyMessages: (): number => + [...this.#runtimes.values()].reduce( + (count, runtime) => count + runtime.suppressedShowMessages, + 0, + ), }); } @@ -649,9 +764,11 @@ class FmtController implements StackController { return; } const detected = new Map( - snapshot - .foldersFor('fmt') - .map((entry) => [entry.folder.uri.fsPath, entry.folder] as const), + snapshot.foldersFor('fmt').map((entry) => { + const folderPath = entry.folder.uri.fsPath; + const configPath = entry.rootRstackConfigPath; + return [folderPath, { folder: entry.folder, configPath }] as const; + }), ); for (const [folderPath, runtime] of [...this.#runtimes]) { if (!detected.has(folderPath)) { @@ -672,9 +789,10 @@ class FmtController implements StackController { this.#retiring.set(folderPath, retirement); } } - for (const [folderPath, folder] of detected) { + for (const [folderPath, { folder, configPath }] of detected) { const existing = this.#runtimes.get(folderPath); if (existing) { + existing.setConfigPath(configPath); if (isFailedFmtState(existing.state)) { // A failed runtime is retried in place, on the same path a config // change uses: restart re-runs package resolution, the version @@ -689,6 +807,7 @@ class FmtController implements StackController { const runtime = new FmtFolderRuntime(folder, context, () => this.reportStatus(), ); + runtime.setConfigPath(configPath); this.#runtimes.set(folderPath, runtime); void runtime.start(this.#retiring.get(folderPath)); } @@ -722,6 +841,13 @@ class FmtController implements StackController { ); } + hasFailedState(): boolean { + for (const runtime of this.#runtimes.values()) { + if (isFailedFmtState(runtime.state)) return true; + } + return false; + } + /** * Covers `rstack.fmt.restart` (the shell rebuilds the controller), a folder * losing detection and a workspace losing its trust: none of them may leave a diff --git a/packages/vscode/src/stacks/fmt/status.ts b/packages/vscode/src/stacks/fmt/status.ts index 09109a4..3d54d0c 100644 --- a/packages/vscode/src/stacks/fmt/status.ts +++ b/packages/vscode/src/stacks/fmt/status.ts @@ -1,4 +1,4 @@ -import type { StackState } from '../../types'; +import { isFailedStackState, type StackState } from '../../types'; /** * A folder runtime's lifecycle state, as the E2E exports report it. @@ -69,7 +69,7 @@ const STATE_RANK: Readonly> = { * retried by the next pass. */ export const isFailedFmtState = (state: FmtRuntimeState): boolean => - state === 'disabled' || state === 'version-mismatch' || state === 'crashed'; + isFailedStackState(state); /** * Folds every folder runtime's state into the one report the shell shows for diff --git a/packages/vscode/src/stacks/lint/Rslint.ts b/packages/vscode/src/stacks/lint/Rslint.ts index a41ad9e..a924fd9 100644 --- a/packages/vscode/src/stacks/lint/Rslint.ts +++ b/packages/vscode/src/stacks/lint/Rslint.ts @@ -26,6 +26,9 @@ import { type ServerOptions, State, } from 'vscode-languageclient/node'; +import { MessageLatch } from '../../shared/messageLatch'; +import { displayPath } from '../../shared/displayPath'; +import { NotInstalledEpisode } from '../../shared/notInstalled'; import { configuredNodeBelowFloor, NodePreflightError, @@ -37,6 +40,11 @@ import type { CoreInstallation } from './CoreResolver'; import { LanguageServerProcessOwner } from './LanguageServerProcessOwner'; import type { Logger } from './logger'; import type { RslintMode } from './resolution'; +import { + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + isConfigSourceChangeDuringTransaction, + type ConfigDependencyStatusNotification, +} from './worker/configDependencyProtocol'; import { RslintVersionMismatchError, runningRslintStatus, @@ -127,19 +135,6 @@ export function configRefreshReasonForPath( : 'config-change'; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { - if (!isRecord(error)) return false; - return ( - error.code === 'CONFIG_CHANGED_DURING_LOAD' || - (typeof error.message === 'string' && - error.message.includes('config changed while')) - ); -} - export async function retryConfigRefreshOnSourceChange( initial: () => Promise, retry: () => Promise, @@ -314,6 +309,7 @@ export class Rslint implements Disposable { public readonly workspaceFolder: WorkspaceFolder; private readonly router: WorkspaceDocumentRouter; private readonly reportStatus: RslintStatusSink; + private bridgeConfigPath: string | undefined; private readonly installation: CoreInstallation; private readonly lspOutputChannel: OutputChannel; private readonly outputChannel: OutputChannel; @@ -326,6 +322,10 @@ export class Rslint implements Disposable { private stateWatcher: Disposable | undefined; private lifecycleEpoch = 0; private advisory: string | undefined; + private readonly configDependencyEpisode = new NotInstalledEpisode(); + private configDependencyRetryPending = false; + private configRefreshFailed = false; + private readonly configError = new MessageLatch(); private startPromise: Promise | undefined; private startOperation: Promise | undefined; private clientStartPromise: Promise | undefined; @@ -344,14 +344,61 @@ export class Rslint implements Disposable { this.onClosed = options.onClosed; } + public setBridgeConfigPath(configPath: string | undefined): void { + if (this.installation.mode === 'bridged') + this.bridgeConfigPath = configPath; + } + private report(state: StackState): void { this.reportStatus(state); } private reportRunning(): void { + if (this.configRefreshFailed || this.hasConfigDependencyFailure()) return; this.report(runningRslintStatus(this.advisory)); } + private handleConfigDependencyStatus( + notification: ConfigDependencyStatusNotification, + ): void { + if (notification.kind === 'error') { + this.configRefreshFailed = true; + this.report({ kind: 'crashed', detail: notification.message }); + this.configDependencyEpisode.clear(); + if (this.configError.changed(notification.message)) { + this.logger.error( + `Failed to refresh config discovery: ${notification.message}`, + ); + } + return; + } + this.configError.clear(); + const wasFailed = this.configRefreshFailed; + this.configRefreshFailed = false; + if (notification.kind === 'ok') { + const wasMissing = this.configDependencyEpisode.clear(); + if ((wasMissing || wasFailed) && this.isRunning()) this.reportRunning(); + return; + } + const failure = notification.failure; + const physicalPath = + failure.configPath === this.installation.shimPath && this.bridgeConfigPath + ? this.bridgeConfigPath + : failure.configPath; + const report = this.configDependencyEpisode.observe( + 'rslint', + displayPath(this.workspaceFolder.uri.fsPath, physicalPath), + failure.cause, + ); + if (report.warning !== undefined) { + this.logger.warn(report.warning); + } + this.report({ + kind: 'disabled', + reason: report.reason, + }); + } + public async start(signal: AbortSignal): Promise { if (this.startPromise) { await this.startPromise; @@ -374,7 +421,9 @@ export class Rslint implements Disposable { } private reportStartFailure(error: unknown): void { - if (this.isPlannedStartAbort(error)) return; + if (this.isPlannedStartAbort(error) || this.hasConfigDependencyFailure()) { + return; + } this.report(statusForRslintStartFailure(error)); } @@ -441,6 +490,12 @@ export class Rslint implements Disposable { serverOptions, clientOptions, ); + client.onNotification( + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + (notification: ConfigDependencyStatusNotification) => { + this.handleConfigDependencyStatus(notification); + }, + ); errorHandlerHolder.current = client.createDefaultErrorHandler(); this.client = client; this.stateWatcher = client.onDidChangeState((event) => { @@ -492,7 +547,12 @@ export class Rslint implements Disposable { ); }, (error: unknown) => { - this.logger.error('Failed to recover after server restart', error); + if (!this.hasConfigDependencyFailure()) { + this.logger.error( + 'Failed to recover after server restart', + error, + ); + } }, ); }); @@ -511,9 +571,22 @@ export class Rslint implements Disposable { this.logger.info('Rslint language client started successfully'); this.reportRunning(); } catch (error: unknown) { + // Keep the initialized runtime available for configRefresh retries. + // Rethrowing this classified rejection would make RuntimeManager close + // it and onDocumentFailure replace disabled with a generic crash. + if ( + !this.isPlannedStartAbort(error) && + this.hasConfigDependencyFailure() && + client.state === State.Running + ) { + return; + } // A close or supersede during start is a planned abort, not a failure; // logging it as an error made every teardown race look like a crash. - if (!this.isPlannedStartAbort(error)) { + if ( + !this.isPlannedStartAbort(error) && + !this.hasConfigDependencyFailure() + ) { this.logger.error('Failed to start Rslint language client', error); } throw error; @@ -575,7 +648,9 @@ export class Rslint implements Disposable { this.configReloadTimer = setTimeout(() => { this.configReloadTimer = undefined; void this.requestConfigRefresh(reason).catch((error: unknown) => { - this.logger.error('Failed to refresh config discovery', error); + if (!this.hasConfigDependencyFailure()) { + this.logger.error('Failed to refresh config discovery', error); + } }); }, 300); }; @@ -598,12 +673,47 @@ export class Rslint implements Disposable { if (!client) return; const refresh = this.configReloadChain.then(async () => { if (!this.isLifecycleCurrent(epoch, client)) return; - await client.sendRequest('rslint/configRefresh', { reason }); + const wasFailed = this.configRefreshFailed; + this.configRefreshFailed = false; + try { + await client.sendRequest('rslint/configRefresh', { reason }); + if (wasFailed && this.isRunning()) this.reportRunning(); + } catch (error) { + // The worker verdict already surfaced this rejection as a real config + // error. Keep the live runtime for config edits without duplicate logs + // or a generic startup failure replacing its precise status. + // Source-change races must still reach the existing startup retry. + if ( + isConfigSourceChangeDuringTransaction(error) || + !this.configRefreshFailed + ) { + this.configRefreshFailed = wasFailed; + throw error; + } + } }); this.configReloadChain = refresh.catch(() => undefined); await refresh; } + public hasConfigDependencyFailure(): boolean { + return this.configDependencyEpisode.active; + } + + public retryConfigDependency(): Promise | undefined { + if ( + this.configDependencyRetryPending || + (!this.hasConfigDependencyFailure() && !this.configRefreshFailed) + ) + return undefined; + // Polls must not queue more requests behind a user config that never + // settles. The first caller already observes this retry's outcome. + this.configDependencyRetryPending = true; + return this.requestConfigRefresh('dependency-change').finally(() => { + this.configDependencyRetryPending = false; + }); + } + private isLifecycleCurrent(epoch: number, client: LanguageClient): boolean { return ( epoch === this.lifecycleEpoch && client === this.client && !this.closing @@ -729,6 +839,10 @@ export class Rslint implements Disposable { return this.client?.state === State.Running; } + public isStopped(): boolean { + return this.client?.state === State.Stopped; + } + public serverAdvertisesHover(): boolean { return Boolean(this.client?.initializeResult?.capabilities.hoverProvider); } diff --git a/packages/vscode/src/stacks/lint/RuntimeManager.ts b/packages/vscode/src/stacks/lint/RuntimeManager.ts index 5b94fd8..58aa441 100644 --- a/packages/vscode/src/stacks/lint/RuntimeManager.ts +++ b/packages/vscode/src/stacks/lint/RuntimeManager.ts @@ -16,8 +16,8 @@ // - The extra hooks (`onDocumentFailure` / `onDocumentSettled` / // `onRuntimeClosed`) exist only so the controller can keep its per-folder // status fold in step; they carry no lifecycle decisions. -// - One ahead-of-upstream fix: `reconcile` resolves before sweeping pending -// uses (`planDocumentCore`) — see AGENTS.md ("Ahead of upstream"). +// - Ahead-of-upstream fixes: resolve before sweeping pending uses, and retire +// stopped same-key clients on reconcile — see AGENTS.md ("Ahead of upstream"). import { workspace, type TextDocument, type WorkspaceFolder } from 'vscode'; import type { @@ -34,6 +34,8 @@ import { export interface ManagedRslintRuntime extends DocumentRoutingRuntime { start(signal: AbortSignal): Promise; close(): Promise; + /** A stopped client is unusable; a pending/automatic start is not. */ + isStopped(): boolean; } export type ManagedRslintRuntimeFactory = ( @@ -266,7 +268,11 @@ export class RuntimeManager { return; } const { workspaceFolder, resolved } = plan; - if (existing?.resolved.key === resolved.key) { + if ( + existing?.resolved.key === resolved.key && + !existing.closePromise && + !existing.runtime.isStopped() + ) { this.options.onDocumentSettled?.(document); return; } @@ -274,6 +280,14 @@ export class RuntimeManager { let replacement: RuntimeEntry | undefined; let switched = false; try { + const entry = this.entries.get(resolved.key); + if (entry?.active && entry.runtime.isStopped()) { + // Retire the dead same-key owner before activating its replacement. + // closeRuntime removes it immediately and installs the shared closing + // barrier; concurrent documents then acquire/adopt one pending start. + await this.closeRuntime(entry); + if (!this.isCurrentDocument(document, epoch)) return; + } replacement = this.acquireRuntime(resolved, key); await replacement.startPromise; if (!this.isCurrentDocument(document, epoch)) { diff --git a/packages/vscode/src/stacks/lint/index.ts b/packages/vscode/src/stacks/lint/index.ts index 74b4862..4639551 100644 --- a/packages/vscode/src/stacks/lint/index.ts +++ b/packages/vscode/src/stacks/lint/index.ts @@ -1,4 +1,5 @@ import vscode from 'vscode'; +import { isFailedStackState } from '../../types'; import type { DetectionSnapshot, StackContext, @@ -39,16 +40,6 @@ import { WorkspaceDocumentRouter } from './WorkspaceDocumentRouter'; * the per-folder status fold the shell requires. */ -/** - * The one core-topology signal the shell's detection watcher does not carry: - * a core swapped in place. Lockfiles — upstream's other half of this glob — - * are already detection's business, and a detection pass notifies this stack - * even when the folder set is unchanged. `files.watcherExclude` hides - * `node_modules` by default, so in practice the lockfile path is the one that - * fires; this watcher costs nothing and covers the rest. - */ -const CORE_TOPOLOGY_GLOB = '**/node_modules/@rslint/core/package.json'; - /** Everything one detected folder contributes to its status fold. */ interface FolderStates { /** One entry per live Lint runtime, keyed by its runtime key. */ @@ -105,9 +96,21 @@ class RslintController implements StackController { context.onDidChangeDetection((snapshot) => { this.#snapshot = snapshot; this.pruneDepartedFolders(); - // A detection pass fires on config topology and lockfile changes — - // exactly the moments a document's core may have appeared, moved or - // changed ownership. This replaces the coordinator's `retryFailedRoots`. + for (const runtime of this.#runtimes.values()) { + runtime.setBridgeConfigPath( + snapshot.forFolder(runtime.workspaceFolder)?.rootRstackConfigPath, + ); + void runtime.retryConfigDependency()?.catch((error: unknown) => { + if (!runtime.hasConfigDependencyFailure()) { + this.#logger?.error( + 'Failed to retry Rslint config dependency discovery', + error, + ); + } + }); + } + // A user config can hang indefinitely. Other documents must still + // re-resolve their cores on this pass; refreshes run independently. this.reconcileOpenDocuments('detection change'); }), vscode.workspace.onDidChangeWorkspaceFolders(() => { @@ -129,18 +132,6 @@ class RslintController implements StackController { }), ); - const topologyWatcher = - vscode.workspace.createFileSystemWatcher(CORE_TOPOLOGY_GLOB); - const onTopologyChange = () => { - this.reconcileOpenDocuments('dependency change'); - }; - this.#subscriptions.push( - topologyWatcher, - topologyWatcher.onDidCreate(onTopologyChange), - topologyWatcher.onDidChange(onTopologyChange), - topologyWatcher.onDidDelete(onTopologyChange), - ); - this.publishStatus(); // Adaptation #1: activation must not wait for a language server. Documents // already open are reconciled in the background; failures surface per @@ -209,33 +200,38 @@ class RslintController implements StackController { // upstream's error. A document with a last-good runtime still // lints, so its consequence says what it keeps, not "will not". const missing = missingPackageOf(error); - if (missing !== undefined) { - logger.warn( - formatNotInstalledLog( + const status = statusForRslintStartFailure(error); + const attributed = resolved + ? attributeToCore(status, resolved.installation.packageDirectory) + : status; + const previous = this.#folderStates + .get(folderKeyOf(workspaceFolder)) + ?.failures.get(document.uri.toString()); + if (JSON.stringify(previous) !== JSON.stringify(attributed)) { + if (missing !== undefined) { + const warning = formatNotInstalledLog( missing, workspaceFolder.name, workspaceFolder.uri.fsPath, `${document.uri} ${keeping ? `keeps ${keeping}` : 'will not lint'} until it is installed`, - ), - ); - } else { - logger.error( - formatCoreSelectionFailure(document.uri.toString(), keeping), - error, - ); + ); + logger.warn(warning); + } else { + logger.error( + formatCoreSelectionFailure(document.uri.toString(), keeping), + error, + ); + } } // Last-good semantics: the document keeps whatever runtime it had. // The failure is still the folder's worst news, so it is folded in // beside the runtimes rather than shown as a toast. A start failure // outlives its (already closed) runtime here, so it names the core. - const status = statusForRslintStartFailure(error); this.setState( folderKeyOf(workspaceFolder), 'failures', document.uri.toString(), - resolved - ? attributeToCore(status, resolved.installation.packageDirectory) - : status, + attributed, ); }, onDocumentSettled: (document) => { @@ -283,6 +279,9 @@ class RslintController implements StackController { } }, }); + runtime.setBridgeConfigPath( + this.#snapshot?.forFolder(workspaceFolder)?.rootRstackConfigPath, + ); this.#runtimes.set(resolved.key, runtime); return runtime; } @@ -382,6 +381,17 @@ class RslintController implements StackController { ); } + hasFailedState(): boolean { + for (const states of this.#folderStates.values()) { + for (const bucket of [states.runtimes, states.failures]) { + for (const state of bucket.values()) { + if (isFailedStackState(state.kind)) return true; + } + } + } + return false; + } + private async closeRuntimeManager(): Promise { const manager = this.#runtimeManager; this.#runtimeManager = undefined; diff --git a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts index 4e456c3..4576c31 100644 --- a/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts +++ b/packages/vscode/src/stacks/lint/worker/ConfigTransactionAdapter.ts @@ -2,11 +2,23 @@ import type { ActivateConfigsRequest, ActivateConfigsResponse, ConfigModuleActivationPlan, + ConfigModuleCandidate, ConfigModuleEslintPluginEntry, ConfigModulePluginDescriptor, LoadConfigsRequest, LoadConfigsResponse, } from '@rslint/core/config-loader'; +import { + classifyMissingDependencyMessage, + isMissingDependencyCode, +} from '../../../shared/missingDependency'; +import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; + +interface ConfigDependencyObserver { + resolveFrom(candidate: ConfigModuleCandidate): string; + report(failure: ConfigDependencyFailure): void; + reportError(message: string): void; +} interface ConfigActivationWireResponse { transactionId: string; @@ -88,6 +100,7 @@ export class LspConfigTransactionAdapter { private readonly pluginLintPool: PluginLintPoolAdapter, private readonly fingerprint: (plan: ConfigModuleActivationPlan) => string, private readonly protocolVersion: number, + private readonly configDependencyObserver: ConfigDependencyObserver, ) {} async loadConfigs( @@ -106,7 +119,48 @@ export class LspConfigTransactionAdapter { ); this.assertActive(); throwIfAborted(signal); - return response; + if (!response.results.some((result) => result.status === 'failed')) { + return response; + } + let classified = false; + return { + ...response, + results: response.results.map((result, index) => { + if (result.status !== 'failed') return result; + const candidate = request.candidates[index]; + const cause = + candidate !== undefined && + isMissingDependencyCode(result.error.code) + ? classifyMissingDependencyMessage( + result.error.message, + this.configDependencyObserver.resolveFrom(candidate), + ) + : undefined; + // Scan every failure: a later real error must not be hidden by the + // first missing dependency, even though only that result is rewritten. + if (cause === undefined || candidate === undefined) { + this.configDependencyObserver.reportError( + result.error.message.split('\n', 1)[0], + ); + return result; + } + if (classified) return result; + classified = true; + this.configDependencyObserver.report({ + configPath: candidate.configPath, + cause, + }); + return { + ...result, + error: { + ...result.error, + // Keep the classified result to one line so Go cannot echo a + // CJS require stack beside the policy's one-warn-line report. + message: cause, + }, + }; + }), + }; } catch (error) { this.cleanup(transactionId); throw error; diff --git a/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts new file mode 100644 index 0000000..4d9dc49 --- /dev/null +++ b/packages/vscode/src/stacks/lint/worker/configDependencyProtocol.ts @@ -0,0 +1,20 @@ +import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; +import { isRecord } from './core'; + +export const CONFIG_DEPENDENCY_STATUS_NOTIFICATION = + 'rstack/rslintConfigDependency'; + +export type ConfigDependencyStatusNotification = + | { readonly kind: 'ok' } + | { readonly kind: 'missing'; readonly failure: ConfigDependencyFailure } + | { readonly kind: 'error'; readonly message: string }; + +/** Shared with the editor's startup retry; this module stays vscode-free. */ +export function isConfigSourceChangeDuringTransaction(error: unknown): boolean { + if (!isRecord(error)) return false; + return ( + error.code === 'CONFIG_CHANGED_DURING_LOAD' || + (typeof error.message === 'string' && + error.message.includes('config changed while')) + ); +} diff --git a/packages/vscode/src/stacks/lint/worker/core.ts b/packages/vscode/src/stacks/lint/worker/core.ts index 82e1e96..80c69b1 100644 --- a/packages/vscode/src/stacks/lint/worker/core.ts +++ b/packages/vscode/src/stacks/lint/worker/core.ts @@ -37,7 +37,7 @@ export interface CoreInstallation { createPluginLintHost: typeof createPluginLintHost; } -function isRecord(value: unknown): value is Record { +export function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/vscode/src/stacks/lint/worker/index.ts b/packages/vscode/src/stacks/lint/worker/index.ts index 830278d..772fd22 100644 --- a/packages/vscode/src/stacks/lint/worker/index.ts +++ b/packages/vscode/src/stacks/lint/worker/index.ts @@ -22,6 +22,12 @@ import { import { loadCoreInstallation } from './core'; import { ActivationFingerprinter } from './fingerprint'; import { logger } from './logger'; +import { + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + isConfigSourceChangeDuringTransaction, + type ConfigDependencyStatusNotification, +} from './configDependencyProtocol'; +import type { ConfigDependencyFailure } from '../../../shared/notInstalled'; const GRACEFUL_EXIT_TIMEOUT_MS = 500; const FORCED_EXIT_TIMEOUT_MS = 1_500; @@ -136,6 +142,7 @@ function forwardRequest( interface EditorProxyOptions { readonly protocolVersion: number; readonly configPath?: string; + takeConfigStatus(): ConfigDependencyStatusNotification; observeRefresh(reason: unknown): void; requestStop(request: StopRequest): void; } @@ -149,15 +156,41 @@ export function registerEditorProxy( if (method === 'rslint/configRefresh') { const refresh = params as ConfigRefreshParams; options.observeRefresh(refresh?.reason); - return goConnection.sendRequest( - method, - stampConfigRefresh( - refresh, - options.protocolVersion, - options.configPath, - ), - token, - ); + try { + const result = await goConnection.sendRequest( + method, + stampConfigRefresh( + refresh, + options.protocolVersion, + options.configPath, + ), + token, + ); + await editorConnection.sendNotification( + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + options.takeConfigStatus(), + ); + return result; + } catch (error) { + const status = options.takeConfigStatus(); + // The editor already retries this transaction race during startup. + // Leave its rejection untouched and send no premature failure (or + // success) verdict; the startup catch reports once if retries exhaust. + if (isConfigSourceChangeDuringTransaction(error)) throw error; + await editorConnection.sendNotification( + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + status.kind === 'ok' + ? { + kind: 'error', + message: (error instanceof Error + ? error.message + : String(error) + ).split('\n', 1)[0], + } + : status, + ); + throw error; + } } return forwardRequest(goConnection, method, params, token); }); @@ -193,11 +226,25 @@ export async function runLintWorker( logger, installation.createPluginLintHost, ); + let configDependencyFailure: ConfigDependencyFailure | undefined; + let configError: string | undefined; const adapter = new LspConfigTransactionAdapter( installation.createConfigModuleHost(), pluginLintPool, (activation) => fingerprinter.compute(activation), installation.protocolVersion, + { + resolveFrom: (candidate) => + candidate.configPath === options.configPath + ? process.cwd() + : candidate.configDirectory, + report: (failure) => { + configDependencyFailure ??= failure; + }, + reportError: (message) => { + configError ??= message; + }, + }, ); const stop = deferred(); @@ -212,6 +259,15 @@ export async function runLintWorker( registerEditorProxy(editorConnection, goConnection, { protocolVersion: installation.protocolVersion, configPath: options.configPath, + takeConfigStatus: () => { + const failure = configDependencyFailure; + const message = configError; + configDependencyFailure = undefined; + configError = undefined; + if (message !== undefined) return { kind: 'error', message }; + if (failure !== undefined) return { kind: 'missing', failure }; + return { kind: 'ok' }; + }, observeRefresh: (reason) => fingerprinter.observeRefresh(reason), requestStop, }); diff --git a/packages/vscode/src/stacks/test/index.ts b/packages/vscode/src/stacks/test/index.ts index 831fdab..10f36ef 100644 --- a/packages/vscode/src/stacks/test/index.ts +++ b/packages/vscode/src/stacks/test/index.ts @@ -71,6 +71,15 @@ class Rstest implements vscode.Disposable { return this.ctrl; } + hasFailedState(): boolean { + for (const workspace of this.workspaces.values()) { + for (const project of workspace.projects.values()) { + if (project.hasFailedState) return true; + } + } + return false; + } + /** * What upstream's `activate()` effectively exported (the `Rstest` instance): * the E2E suites (`e2e/rstest/`) consume `testController`, `runProfile` @@ -82,6 +91,7 @@ class Rstest implements vscode.Disposable { */ buildExports(): Record { return { + hasNotInstalledState: () => status.hasNotInstalled(), testController: this.ctrl, runProfile: this.runProfile, startTestRun: this.startTestRun, @@ -555,6 +565,10 @@ class RstestController implements StackController { return this.#rstest.buildExports(); } + hasFailedState(): boolean { + return status.hasFailed() || (this.#rstest?.hasFailedState() ?? false); + } + dispose(): void { this.#rstest?.dispose(); this.#rstest = undefined; diff --git a/packages/vscode/src/stacks/test/master.ts b/packages/vscode/src/stacks/test/master.ts index 7ec58e4..00a98af 100644 --- a/packages/vscode/src/stacks/test/master.ts +++ b/packages/vscode/src/stacks/test/master.ts @@ -18,6 +18,7 @@ import { getConfiguredNodeExecutable, } from '../../shared/nodeExecutableSetting'; import { CONFIG_SECTION, getConfigValue } from './config'; +import { MessageLatch } from '../../shared/messageLatch'; import { formatNotInstalledLog, formatNotInstalledStatus, @@ -133,6 +134,9 @@ export class RstestApi { // `createChildProcess`. private disposed = false; private lastResolvedRstestPath?: string; + private readonly coreMissingEpisode = new MessageLatch(); + private readonly unsupportedCoreMessage = new MessageLatch(); + private readonly resolutionErrorMessage = new MessageLatch(); constructor( private workspace: vscode.WorkspaceFolder, @@ -336,17 +340,25 @@ export class RstestApi { // out plus one warn line — the normal state of a repository whose // dependencies are not installed yet, never a notification. private reportCoreNotInstalled(searchedFrom: string): void { - logger.warn( - formatNotInstalledLog( - '@rstest/core', - this.workspace.name, - searchedFrom, - CORE_NOT_INSTALLED_CONSEQUENCE, - ), - ); + if (this.coreMissingEpisode.changed(searchedFrom)) { + logger.warn( + formatNotInstalledLog( + '@rstest/core', + this.workspace.name, + searchedFrom, + CORE_NOT_INSTALLED_CONSEQUENCE, + ), + ); + } status.notInstalled(CORE_NOT_INSTALLED_STATUS, this.statusSource); } + private reportResolutionError(message: string): boolean { + if (!this.resolutionErrorMessage.changed(message)) return false; + vscode.window.showErrorMessage(message); + return true; + } + // Returns '' 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. @@ -368,10 +380,13 @@ export class RstestApi { paths: [this.cwd], }); } catch (e) { - vscode.window.showErrorMessage( - 'Failed to resolve @rstest/core/package.json. Please upgrade @rstest/core to the latest version.', - ); - logger.error('Failed to resolve @rstest/core/package.json', e); + if ( + this.reportResolutionError( + 'Failed to resolve @rstest/core/package.json. Please upgrade @rstest/core to the latest version.', + ) + ) { + logger.error('Failed to resolve @rstest/core/package.json', e); + } return ''; } } else { @@ -397,6 +412,8 @@ export class RstestApi { if (!nodeExport) return ''; } + this.coreMissingEpisode.clear(); + const coreVersion = readPackageVersion(corePackageJsonPath); // Upstream also compared the core version against the extension's own @@ -419,18 +436,21 @@ export class RstestApi { this.statusSource, ) ) { - logger.error( - `Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`, - ); + const message = `Unsupported @rstest/core version ${coreVersion ?? 'unknown'} resolved from ${this.cwd}`; + if (this.unsupportedCoreMessage.changed(message)) { + logger.error(message); + } } else { + this.unsupportedCoreMessage.clear(); status.versionOk(this.statusSource); } } this.lastResolvedRstestPath = nodeExport; + this.resolutionErrorMessage.clear(); return nodeExport; } catch (e) { - vscode.window.showErrorMessage(toErrorMessage(e)); + this.reportResolutionError(toErrorMessage(e)); throw e; } } @@ -466,12 +486,14 @@ export class RstestApi { public async getNormalizedConfig() { const { worker, rstestPath } = await this.createChildProcess(); - const result = await worker.getNormalizedConfig({ - rstestPath, - configFilePath: this.configFilePath, - }); - worker.$close(); - return result; + try { + return await worker.getNormalizedConfig({ + rstestPath, + configFilePath: this.configFilePath, + }); + } finally { + worker.$close(); + } } public async listTests(include?: string[]) { diff --git a/packages/vscode/src/stacks/test/project.ts b/packages/vscode/src/stacks/test/project.ts index 9e3e981..bebb5bc 100644 --- a/packages/vscode/src/stacks/test/project.ts +++ b/packages/vscode/src/stacks/test/project.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; import type { TestInfo } from '@rstest/core'; import picomatch from 'picomatch'; import { glob } from 'tinyglobby'; @@ -7,10 +8,13 @@ import { RSTACK_CONFIG_NAMES } from '../../detection'; import { resolveRstackShim } from './bridge'; import { watchConfigValue } from './config'; import { - formatConfigDependencyMissingLog, + NotInstalledEpisode, formatConfigDependencyMissingStatus, } from '../../shared/notInstalled'; -import { logUnlessReported } from './coreResolution'; +import { + logUnlessReported, + ReportedRstestResolutionError, +} from './coreResolution'; import { logger } from './logger'; import { RstestApi } from './master'; import { type ChildProjectRef, computeCoveredConfigs } from './projectCoverage'; @@ -235,17 +239,14 @@ export class WorkspaceManager implements vscode.Disposable { ); } /** - * Recreates projects whose one-shot config evaluation failed — dependencies - * may have been installed since (observed as a lockfile-driven detection - * pass). Only ever called from a detection event, never from a project - * callback, so a persistently failing config cannot recreate itself in a - * loop; it is simply re-attempted once per detection pass. + * Retries failed projects after a dependency change. Installs and upgrades + * can recover missing packages, version mismatches, worker failures and real + * config errors alike. The project keeps its identity and the retry is + * single-flight. */ public retryFailedProjects() { - for (const [key, project] of [...this.projects]) { - if (!project.configLoadFailed) continue; - project.dispose(); - this.projects.set(key, this.createProject(project.source)); + for (const project of this.projects.values()) { + void project.retryFailedConfig(); } } @@ -550,10 +551,9 @@ export class Project implements vscode.Disposable { // the same tests are not shown twice. suppressed = false; // The one-shot config evaluation in the constructor rejected (typically: - // dependencies not installed yet). `retryFailedProjects` recreates such - // projects on the next detection pass. + // dependencies not installed yet). A dependency-change pass retries it. configLoadFailed = false; - /** What this project was built from; `retryFailedProjects` rebuilds from it. */ + /** What this project was built from. */ readonly source: ProjectSource; // See `ProjectSource`. readonly sourceUri: vscode.Uri; @@ -562,6 +562,10 @@ export class Project implements vscode.Disposable { readonly rstestResolutionDir: string; readonly isBridge: boolean; #watch?: vscode.Disposable; + #collectionFailed = false; + #configLoad: Promise | undefined; + readonly #configDependencyEpisode = new NotInstalledEpisode(); + readonly #reportedConfigErrors = new Set(); constructor( private workspaceFolder: vscode.WorkspaceFolder, source: ProjectSource, @@ -587,7 +591,12 @@ export class Project implements vscode.Disposable { ); this.cancellationSource = new vscode.CancellationTokenSource(); - void this.api + void this.loadConfig(); + } + + private loadConfig(): Promise { + if (this.#configLoad !== undefined) return this.#configLoad; + const pending = this.api .getNormalizedConfig() .then((result) => { if (this.cancellationSource.token.isCancellationRequested) return; @@ -595,7 +604,21 @@ export class Project implements vscode.Disposable { this.reportMissingDependency(result.message); return; } - status.installed(this.configDependencyStatusSource); + this.configLoadFailed = false; + this.#configDependencyEpisode.clear(); + this.#reportedConfigErrors.clear(); + status.forget(this.configDependencyStatusSource); + if ( + this.#collectionFailed || + this.root.fsPath !== result.root || + !isDeepStrictEqual(this.include, result.include) || + !isDeepStrictEqual(this.exclude, result.exclude) + ) { + // The watcher captures the root and matchers. Cancel its pending + // collection before discovering files with the recovered config. + this.#watch?.dispose(); + this.#watch = undefined; + } this.root = vscode.Uri.file(result.root); this.include = result.include; this.exclude = result.exclude; @@ -606,10 +629,56 @@ export class Project implements vscode.Disposable { .catch((error) => { if (this.cancellationSource.token.isCancellationRequested) return; this.configLoadFailed = true; - logUnlessReported('Failed to initialize project config', error); + this.#configDependencyEpisode.clear(); + // A reported setup error can have only a toast/log, not a status. + // Publish that raw failure so the shell schedules recovery, without + // replacing an already-reported missing-core or version verdict. + if ( + !(error instanceof ReportedRstestResolutionError) || + !status.hasFailed(this.sourceUri.toString()) + ) { + const cause = + error instanceof Error + ? error.message.split('\n', 1)[0] + : String(error); + // Crash outranks disabled: replacing the missing-dependency verdict + // must not paint a healthy intermediate state. + status.crashed( + `Cannot load ${relativeTo(this.workspaceFolder, this.sourceUri)}: ${cause}`, + this.configDependencyStatusSource, + ); + } + status.installed(this.configDependencyStatusSource); + const errorKey = + error instanceof Error + ? `${error.name}:${error.message}` + : String(error); + if (!this.#reportedConfigErrors.has(errorKey)) { + this.#reportedConfigErrors.add(errorKey); + logUnlessReported('Failed to initialize project config', error); + } // Let the manager settle its tree even when a config fails to load. this.onConfigResolved?.(); + }) + .finally(() => { + if (this.#configLoad === pending) this.#configLoad = undefined; }); + this.#configLoad = pending; + return pending; + } + + get hasFailedState(): boolean { + return ( + this.configLoadFailed || + status.hasFailed(this.sourceUri.toString()) || + status.hasFailed(this.configDependencyStatusSource) + ); + } + + /** Re-evaluates a failed config or a core lost after loading, in place. */ + public retryFailedConfig(): Promise | undefined { + if (!this.hasFailedState) return undefined; + return this.loadConfig(); } /** @@ -629,9 +698,12 @@ export class Project implements vscode.Disposable { // Latched under this project's key, which `dispose` forgets. private reportMissingDependency(cause: string): void { this.configLoadFailed = true; - logger.warn( - formatConfigDependencyMissingLog('rstest', this.sourceUri.fsPath, cause), + const report = this.#configDependencyEpisode.observe( + 'rstest', + this.sourceUri.fsPath, + cause, ); + if (report.warning !== undefined) logger.warn(report.warning); status.notInstalled( formatConfigDependencyMissingStatus( 'rstest', @@ -750,6 +822,7 @@ export class Project implements vscode.Disposable { if (token.isCancellationRequested) return; + this.#collectionFailed = false; const visited = new Set(); for (const { uri, tests } of files) { this.updateOrCreateFile(uri, tests); @@ -786,6 +859,7 @@ export class Project implements vscode.Disposable { }) .catch((error) => { if (!token.isCancellationRequested) { + this.#collectionFailed = true; logUnlessReported( 'Failed to update runtime test list', error, @@ -822,6 +896,7 @@ export class Project implements vscode.Disposable { }); } catch (error) { if (!token.isCancellationRequested) { + this.#collectionFailed = true; logUnlessReported('Failed to collect test files', error); } } finally { diff --git a/packages/vscode/src/stacks/test/status.ts b/packages/vscode/src/stacks/test/status.ts index 731c514..5d54db5 100644 --- a/packages/vscode/src/stacks/test/status.ts +++ b/packages/vscode/src/stacks/test/status.ts @@ -70,6 +70,19 @@ class StatusHolder implements StatusReporter { this.#reporter = undefined; } + public hasFailed(source?: string): boolean { + if (source === undefined) return this.#latched; + return ( + this.#crashes.has(source) || + this.#mismatches.has(source) || + this.#notInstalled.has(source) + ); + } + + public hasNotInstalled(): boolean { + return this.#notInstalled.size > 0; + } + get #latched(): boolean { return ( this.#crashes.size > 0 || diff --git a/packages/vscode/src/statusBar.ts b/packages/vscode/src/statusBar.ts index f66bd99..c6ee72d 100644 --- a/packages/vscode/src/statusBar.ts +++ b/packages/vscode/src/statusBar.ts @@ -276,15 +276,18 @@ export class StatusBar implements vscode.Disposable { this.#item.show(); } - reporterFor(stack: StackId): StatusReporter { + reporterFor(stack: StackId, onReport?: () => void): StatusReporter { + const report = (state: StackState): void => { + this.setState(stack, state); + onReport?.(); + }; return { stack, - report: (state) => this.setState(stack, state), - starting: (detail) => this.setState(stack, { kind: 'starting', detail }), - running: (detail) => this.setState(stack, { kind: 'running', detail }), - crashed: (detail) => this.setState(stack, { kind: 'crashed', detail }), - versionMismatch: (detail) => - this.setState(stack, { kind: 'version-mismatch', detail }), + report, + starting: (detail) => report({ kind: 'starting', detail }), + running: (detail) => report({ kind: 'running', detail }), + crashed: (detail) => report({ kind: 'crashed', detail }), + versionMismatch: (detail) => report({ kind: 'version-mismatch', detail }), }; } diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index 639e3cc..baacaa3 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -56,6 +56,12 @@ export type StackState = | { readonly kind: 'crashed'; readonly detail: string } | { readonly kind: 'version-mismatch'; readonly detail: string }; +/** Raw runtime failures that need dependency recovery, not shell gate states. */ +export const isFailedStackState = ( + kind: StackState['kind'] | 'stopped', +): boolean => + kind === 'disabled' || kind === 'crashed' || kind === 'version-mismatch'; + /** * The seam every stack reports through instead of owning a status bar item * (the status-aggregation adaptation). The shell aggregates all three @@ -89,6 +95,7 @@ export interface StackDetection { export interface FolderDetection { readonly folder: vscode.WorkspaceFolder; + readonly rootRstackConfigPath?: string; readonly stacks: Readonly>; } @@ -157,6 +164,8 @@ export interface StackController { */ readonly restartOnSettings?: readonly string[]; register(context: StackContext): Promise | void>; + /** True while an owned folder/project is disabled, crashed or version-mismatched. */ + hasFailedState(): boolean; /** Teardown may be asynchronous (stopping a language server, workers). */ dispose(): void | Promise; } @@ -166,6 +175,8 @@ export interface StackController { * tests; not a stable API for other extensions. */ export interface RstackExtensionExports { + /** E2E only: warnings captured when RSTACK_E2E_RECORD_WARNINGS is enabled. */ + getRecordedWarnings(stack: StackId): readonly string[]; /** Live exports the stack published at registration; undefined when inactive. */ getStackExports(stack: StackId): Record | undefined; /** @@ -173,6 +184,8 @@ export interface RstackExtensionExports { * already did). Rejects nothing: a stack that never activates never settles. */ whenStackActive(stack: StackId): Promise>; + /** E2E only: shorten the shell's dependency-recovery polling interval. */ + setDependencyPollIntervalForTest(intervalMs: number): void; } export type StackControllerFactory = () => StackController; diff --git a/packages/vscode/tests/extension.test.ts b/packages/vscode/tests/extension.test.ts index d5b0c26..e5f56be 100644 --- a/packages/vscode/tests/extension.test.ts +++ b/packages/vscode/tests/extension.test.ts @@ -13,11 +13,15 @@ import { STACK_IDS, stackCommand, stackCommandTitle, + type StatusReporter, } from '../src/types'; interface FakeController { readonly restartOnSettings?: readonly string[]; - register(): Promise>; + register(context: { + status: StatusReporter; + }): Promise>; + hasFailedState(): boolean; dispose(): Promise; } @@ -56,6 +60,8 @@ const harness = rs.hoisted(() => { events: [] as string[], /** One entry per detection pass the shell asked for. */ refreshes: 0, + /** Forced unchanged passes issued by the dependency-recovery timer. */ + dependencyRefreshes: 0, /** Everything the shell wrote to its own output channel. */ shellLog: [] as string[], commands: new Map unknown>(), @@ -70,10 +76,16 @@ const harness = rs.hoisted(() => { settings: new Map(), /** How often `runRestart` reset the host-scoped User Node memo. */ nodeResets: 0, + /** Stacks with a raw failed state, independent of the aggregate report. */ + failed: new Set(), + /** Shell-wrapped reporters handed to the fake controllers. */ + reporters: new Map(), /** Every configuration listener the shell installed. */ configListeners: [] as ((event: { affectsConfiguration(section: string): boolean; }) => void)[], + /** Detection change callbacks, wrapped so tests can publish a fresh snapshot. */ + detectionListeners: [] as Array<() => void>, }); const state = { ...defaults(), @@ -83,8 +95,9 @@ const harness = rs.hoisted(() => { controller(stack: string): FakeController { return { restartOnSettings: state.restartOnSettings.get(stack), - register: async () => { + register: async ({ status }) => { state.events.push(`register:${stack}`); + state.reporters.set(stack, status); const block = state.blockRegister.get(stack); if (block) { state.registering.add(stack); @@ -96,8 +109,10 @@ const harness = rs.hoisted(() => { } return { stack }; }, + hasFailedState: () => state.failed.has(stack), dispose: async () => { state.events.push(`dispose:${stack}`); + state.reporters.delete(stack); if (state.registering.has(stack)) { state.overlaps.push(stack); } @@ -231,7 +246,18 @@ rs.mock('../src/detection', () => { forFolder: () => undefined, }); class DetectionService { - readonly onDidChange = () => ({ dispose: () => undefined }); + readonly onDidChange = ( + listener: (value: ReturnType) => void, + ) => { + const emit = () => listener(snapshot()); + harness.detectionListeners.push(emit); + return { + dispose: () => { + const index = harness.detectionListeners.indexOf(emit); + if (index >= 0) harness.detectionListeners.splice(index, 1); + }, + }; + }; get snapshot() { return snapshot(); } @@ -242,6 +268,10 @@ rs.mock('../src/detection', () => { harness.refreshes += 1; return this.snapshot; } + async refreshForDependencyChange() { + harness.dependencyRefreshes += 1; + return this.snapshot; + } dispose() {} } return { DetectionService }; @@ -305,6 +335,15 @@ const changeSetting = (...sections: string[]): void => { const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); +const waitFor = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error('timed out waiting for the shell condition'); +}; + /** * Restart is a shell concern, so *which settings trigger one* is too: a stack * declares `restartOnSettings` as data and the shell owns the listener and the @@ -435,6 +474,60 @@ describe('restart-triggering settings', () => { }); }); +describe('dependency recovery polling', () => { + beforeEach(() => { + harness.reset(); + harness.detected = new Set(['rslint']); + }); + + afterEach(async () => { + await deactivate(); + }); + + it.each(['crashed', 'version-mismatch'] as const)( + 'polls through the forced detection path while %s', + async (kind) => { + const exports = await activate(context); + exports.setDependencyPollIntervalForTest(5); + harness.failed.add('rslint'); + harness.reporters.get('rslint')?.report({ kind, detail: 'retry needed' }); + + await waitFor(() => harness.dependencyRefreshes > 0); + expect(harness.refreshes).toBe(0); + + const beforeRealError = harness.dependencyRefreshes; + harness.reporters.get('rslint')?.crashed('half-written package'); + await waitFor(() => harness.dependencyRefreshes > beforeRealError); + + harness.failed.delete('rslint'); + harness.reporters.get('rslint')?.running(); + const completed = harness.dependencyRefreshes; + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(harness.dependencyRefreshes).toBe(completed); + }, + ); + + it('queues a poll tick behind an in-flight reconcile', async () => { + const exports = await activate(context); + exports.setDependencyPollIntervalForTest(5); + + const blockedRegister = Promise.withResolvers(); + harness.blockRegister.set('fmt', blockedRegister.promise); + harness.detected.add('fmt'); + for (const emit of harness.detectionListeners) emit(); + await waitFor(() => harness.registering.has('fmt')); + + harness.failed.add('rslint'); + harness.reporters.get('rslint')?.report({ kind: 'disabled' }); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(harness.dependencyRefreshes).toBe(0); + + blockedRegister.resolve(); + await waitFor(() => harness.dependencyRefreshes > 0); + expect(harness.overlaps).toEqual([]); + }); +}); + describe('the extension manifest', () => { it('scopes language-client tracing to the window', () => { const manifest = require('../package.json') as { diff --git a/packages/vscode/tests/lintDetection.test.ts b/packages/vscode/tests/lintDetection.test.ts index 5abaf34..d0da7f3 100644 --- a/packages/vscode/tests/lintDetection.test.ts +++ b/packages/vscode/tests/lintDetection.test.ts @@ -1,7 +1,83 @@ -import { describe, expect, it } from '@rstest/core'; +import path from 'node:path'; +import { describe, expect, it, rs } from '@rstest/core'; +import vscode from 'vscode'; import { decideRslintMode } from '../src/stacks/lint/resolution'; +import { detectFolder, RSTACK_CONFIG_GLOB } from '../src/detection'; + +let configPaths: string[] = []; +rs.mock('vscode', () => { + const file = (fsPath: string) => ({ fsPath, toString: () => fsPath }); + const api = { + Uri: { + file, + joinPath: (uri: { fsPath: string }, ...parts: string[]) => + file(path.join(uri.fsPath, ...parts)), + }, + RelativePattern: class { + constructor( + readonly folder: unknown, + readonly pattern: string, + ) {} + }, + workspace: { + getConfiguration: () => ({ get: () => undefined }), + findFiles: async ({ pattern }: { pattern: string }) => + pattern === RSTACK_CONFIG_GLOB ? configPaths.map(file) : [], + fs: { + stat: async () => { + throw new Error('not found'); + }, + }, + }, + }; + return { ...api, default: api }; +}); describe('Rslint folder ownership', () => { + it('selects the root config by loader precedence rather than discovery order', async () => { + const folder = path.resolve('/workspace'); + const ts = path.join(folder, 'rstack.config.ts'); + const js = path.join(folder, 'rstack.config.js'); + const workspaceFolder = { + uri: vscode.Uri.file(folder), + name: 'workspace', + index: 0, + }; + for (const ordered of [ + [js, ts], + [ts, js], + ]) { + configPaths = ordered; + expect((await detectFolder(workspaceFolder)).rootRstackConfigPath).toBe( + ts, + ); + } + }); + + it('attributes bridge failures to the root config regardless of discovery order', async () => { + const folder = path.resolve('/workspace'); + const root = path.join(folder, 'rstack.config.ts'); + const nested = path.join(folder, 'packages', 'app', 'rstack.config.ts'); + const workspaceFolder = { + uri: vscode.Uri.file(folder), + name: 'workspace', + index: 0, + }; + for (const ordered of [ + [nested, root], + [root, nested], + ]) { + configPaths = ordered; + const snapshot = await detectFolder(workspaceFolder); + expect(snapshot.rootRstackConfigPath).toBe(root); + expect(snapshot.stacks.rslint.mode).toBe('bridged'); + } + configPaths = [nested]; + expect( + (await detectFolder(workspaceFolder)).rootRstackConfigPath, + ).toBeUndefined(); + }); + it('gives native config presence precedence anywhere in the folder', () => { expect( decideRslintMode({ diff --git a/packages/vscode/tests/shared/missingDependency.test.ts b/packages/vscode/tests/shared/missingDependency.test.ts index 51e4ba7..7036aac 100644 --- a/packages/vscode/tests/shared/missingDependency.test.ts +++ b/packages/vscode/tests/shared/missingDependency.test.ts @@ -2,7 +2,10 @@ 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'; +import { + classifyMissingDependencyMessage, + 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 @@ -101,3 +104,31 @@ describe('missingDependencyCauseOf', () => { expect(classify(undefined)).toBe(undefined); }); }); + +describe('classifyMissingDependencyMessage', () => { + it('classifies loader messages without requiring an Error code', () => { + expect( + classifyMissingDependencyMessage( + "Cannot find package '@scope/missing' imported from /project/config.mjs", + __dirname, + ), + ).toBe( + "Cannot find package '@scope/missing' imported from /project/config.mjs", + ); + expect( + classifyMissingDependencyMessage( + "Cannot find module 'missing-package'\nRequire stack:\n- /project/config.cjs", + __dirname, + ), + ).toBe("Cannot find module 'missing-package'"); + }); + + it('rejects non-loader messages even without the Error-code gate', () => { + expect( + classifyMissingDependencyMessage( + "Configuration says Cannot find package 'missing'", + __dirname, + ), + ).toBe(undefined); + }); +}); diff --git a/packages/vscode/tests/stacks/fmt/runtime.test.ts b/packages/vscode/tests/stacks/fmt/runtime.test.ts new file mode 100644 index 0000000..682db04 --- /dev/null +++ b/packages/vscode/tests/stacks/fmt/runtime.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, expect, it, rs } from '@rstest/core'; +import type { + LanguageClientOptions, + ShowMessageParams, +} from 'vscode-languageclient'; +import type { StackContext, StackState } from '../../../src/types'; + +const FMT_SESSION_ERROR_PREFIX = 'rs fmt cannot format this workspace: '; + +const clients: Array<{ + notify(message: ShowMessageParams): void; + options: LanguageClientOptions; +}> = []; +const toasts: string[] = []; +rs.mock('vscode', () => ({ + default: { + RelativePattern: class {}, + env: {}, + window: { + showErrorMessage: (message: string) => toasts.push(message), + showWarningMessage() {}, + showInformationMessage() {}, + }, + workspace: { + createFileSystemWatcher: () => ({ + onDidCreate: () => ({ dispose() {} }), + onDidChange: () => ({ dispose() {} }), + onDidDelete: () => ({ dispose() {} }), + dispose() {}, + }), + }, + }, +})); +rs.mock('../../../src/detection', () => ({ + RSTACK_CONFIG_GLOB: '**/rstack.config.*', +})); +rs.mock('../../../src/shared/nodeExecutableSetting', () => ({ + getConfiguredNodeExecutable: () => undefined, +})); +rs.mock('../../../src/shared/nodeResolution', () => ({ + resolveUserNodeOnce: async () => ({ executable: 'node' }), +})); +rs.mock('../../../src/shared/packageResolve', () => ({ + findPackageJsonUncached: () => '/project/node_modules/rstack/package.json', + readPackageJson: () => ({ version: '0.7.2', bin: 'bin/rs.js' }), +})); +rs.mock('../../../src/stacks/lint/LanguageServerProcessOwner', () => ({ + LanguageServerProcessOwner: class { + beginClose() {} + async close() {} + }, +})); +rs.mock('vscode-languageclient/node', () => ({ + MessageType: { Error: 1, Warning: 2, Info: 3 }, + State: { Running: 2, Stopped: 1 }, + ShowMessageNotification: { type: 'window/showMessage' }, + LanguageClient: class { + state = 2; + notify!: (message: ShowMessageParams) => void; + change!: (event: { newState: number }) => void; + constructor( + _id: string, + _name: string, + _server: unknown, + readonly options: LanguageClientOptions, + ) { + clients.push(this); + } + onNotification(_method: unknown, callback: typeof this.notify) { + this.notify = callback; + } + onDidChangeState(callback: typeof this.change) { + this.change = callback; + return { dispose() {} }; + } + createDefaultErrorHandler() { + return {}; + } + async start() { + this.change({ newState: 2 }); + } + async dispose() {} + async stop() {} + }, +})); + +import { createFmtController } from '../../../src/stacks/fmt'; + +let controller: ReturnType; +let states: StackState[]; +let errors: string[]; +let warnings: string[]; +let redetect: () => void; +beforeEach(async () => { + clients.length = 0; + toasts.length = 0; + states = []; + errors = []; + warnings = []; + const detection = { + foldersFor: () => [ + { + folder: { name: 'project', uri: { fsPath: '/project' } }, + rootRstackConfigPath: '/project/rstack.config.ts', + }, + ], + }; + controller = createFmtController(); + await controller.register({ + detection, + onDidChangeDetection: (listener: (snapshot: typeof detection) => void) => { + redetect = () => listener(detection); + return { dispose() {} }; + }, + status: { report: (state: StackState) => states.push(state) }, + output: { + info() {}, + debug() {}, + warn: (message: string) => warnings.push(message), + error: (message: string) => errors.push(message), + }, + } as unknown as StackContext); + await rs.waitUntil(() => states.at(-1)?.kind === 'running'); +}); +afterEach(async () => { + await controller.dispose(); +}); + +async function format(editCount: number, duringRequest?: () => void) { + const provide = + clients.at(-1)!.options.middleware!.provideDocumentFormattingEdits!; + return provide({} as never, {} as never, {} as never, async () => { + duringRequest?.(); + return Array.from({ length: editCount }, () => ({}) as never); + }); +} + +it('reports real session errors, deduplicates logs across restarts, and preserves protocol toasts', async () => { + const message = { + type: 1 as const, + message: `${FMT_SESSION_ERROR_PREFIX}SyntaxError: Unexpected token\nstack trace`, + }; + clients[0].notify(message); + expect(states.at(-1)).toMatchObject({ + kind: 'crashed', + detail: 'SyntaxError: Unexpected token', + }); + expect(controller.hasFailedState()).toBe(true); + expect(errors).toEqual(['SyntaxError: Unexpected token']); + expect(toasts).toEqual([message.message]); + clients[0].notify(message); + expect(errors).toHaveLength(1); + expect(toasts).toHaveLength(2); + + redetect(); + await rs.waitUntil(() => clients.length === 2); + expect(states.at(-1)?.kind).toBe('crashed'); + clients[1].notify(message); + expect(errors).toHaveLength(1); + await format(0); + expect(controller.hasFailedState()).toBe(true); + await format(1, () => clients[1].notify(message)); + expect(controller.hasFailedState()).toBe(true); + expect(errors).toHaveLength(1); + await format(1); + expect(states.at(-1)?.kind).toBe('running'); + expect(controller.hasFailedState()).toBe(false); + clients[1].notify(message); + expect(errors).toHaveLength(2); + clients[1].notify({ + type: 1, + message: `${FMT_SESSION_ERROR_PREFIX}Error: Different failure`, + }); + expect(errors.at(-1)).toBe('Error: Different failure'); + expect(errors).toHaveLength(3); +}); + +it('keeps classified missing dependencies disabled with one warning and no toast', async () => { + const message = { + type: 1 as const, + message: `${FMT_SESSION_ERROR_PREFIX}Error: Cannot find package 'missing'`, + }; + clients[0].notify(message); + clients[0].notify(message); + expect(states.at(-1)?.kind).toBe('disabled'); + expect(warnings).toHaveLength(1); + expect(errors).toEqual([]); + expect(toasts).toEqual([]); + await format(1); + expect(states.at(-1)?.kind).toBe('running'); + clients[0].notify(message); + expect(states.at(-1)?.kind).toBe('disabled'); + expect(warnings).toHaveLength(2); +}); diff --git a/packages/vscode/tests/stacks/lint/runtimeManager.test.ts b/packages/vscode/tests/stacks/lint/runtimeManager.test.ts index 233b80c..a7f5ddc 100644 --- a/packages/vscode/tests/stacks/lint/runtimeManager.test.ts +++ b/packages/vscode/tests/stacks/lint/runtimeManager.test.ts @@ -65,6 +65,7 @@ interface FakeRuntime { releaseStart(): void; closes: number; aborted: boolean; + stopped: boolean; } function fakeRuntime(): FakeRuntime { @@ -76,9 +77,11 @@ function fakeRuntime(): FakeRuntime { releaseStart, closes: 0, aborted: false, + stopped: false, runtime: { rootKey: 'core-key', workspaceFolder: folder, + isStopped: () => fake.stopped, sendDocumentOpen: async () => undefined, sendDocumentClose: async () => undefined, clearDocumentDiagnostics: () => undefined, @@ -173,6 +176,53 @@ async function settleWithStartsReleased( } describe('RuntimeManager reconcile-during-start', () => { + it.each([false, true])( + 'replaces a same-key runtime only when stopped=%s', + async (stopped) => { + const harness = createHarness(); + const document = documentOf('/project/src/index.ts'); + await settleWithStartsReleased( + harness, + harness.manager.reconcile(document), + ); + harness.runtimes[0].stopped = stopped; + + await settleWithStartsReleased( + harness, + harness.manager.reconcile(document), + ); + expect(harness.runtimes).toHaveLength(stopped ? 2 : 1); + expect(harness.runtimes[0].closes).toBe(stopped ? 1 : 0); + expect(harness.failures).toEqual([]); + await harness.manager.close(); + }, + ); + + it('shares one replacement across stopped-runtime users and adopts its pending start', async () => { + const harness = createHarness(); + const a = documentOf('/project/src/a.ts'); + const b = documentOf('/project/src/b.ts'); + await settleWithStartsReleased( + harness, + Promise.all([harness.manager.reconcile(a), harness.manager.reconcile(b)]), + ); + harness.runtimes[0].stopped = true; + const first = harness.manager.reconcile(a); + const second = harness.manager.reconcile(b); + await rs.waitUntil(() => harness.runtimes.length === 2, WAIT); + const successor = harness.manager.reconcile(a); + await settleWithStartsReleased( + harness, + Promise.all([first, second, successor]), + ); + expect(harness.runtimes).toHaveLength(2); + expect(harness.runtimes[0].closes).toBe(1); + expect(harness.runtimes[1].closes).toBe(0); + expect(harness.runtimes[1].aborted).toBe(false); + expect(harness.failures).toEqual([]); + await harness.manager.close(); + }); + it('keeps the pending runtime when a second reconcile resolves to the same key', async () => { const harness = createHarness(); const document = documentOf('/project/src/index.ts'); diff --git a/packages/vscode/tests/stacks/lint/start.test.ts b/packages/vscode/tests/stacks/lint/start.test.ts new file mode 100644 index 0000000..de9c19e --- /dev/null +++ b/packages/vscode/tests/stacks/lint/start.test.ts @@ -0,0 +1,343 @@ +import { expect, it, rs } from '@rstest/core'; +import type { + DetectionSnapshot, + StackContext, + StackState, +} from '../../../src/types'; +import type { RslintOptions } from '../../../src/stacks/lint/Rslint'; +import type { ResolvedCoreRuntime } from '../../../src/stacks/lint/CoreResolver'; +import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; + +let refreshOutcome: + 'missing' | 'broken' | 'fixed' | 'changed' | 'changed-once' = 'missing'; +let pendingRefresh: Promise | undefined; +let refreshCalls = 0; +let reconciles = 0; + +rs.mock('vscode', () => { + const api = { + RelativePattern: class {}, + workspace: { + textDocuments: [], + onDidChangeWorkspaceFolders: () => ({ dispose() {} }), + onDidOpenTextDocument: () => ({ dispose() {} }), + onDidCloseTextDocument: () => ({ dispose() {} }), + createFileSystemWatcher: () => ({ + onDidCreate() {}, + onDidChange() {}, + onDidDelete() {}, + }), + }, + env: {}, + }; + return { ...api, default: api }; +}); +let runtimeFactory: (resolved: ResolvedCoreRuntime) => Rslint; +rs.mock('../../../src/stacks/lint/RuntimeManager', () => ({ + RuntimeManager: class { + constructor( + _router: unknown, + _resolver: unknown, + create: typeof runtimeFactory, + ) { + runtimeFactory = create; + } + initialize() {} + clearResolutionCache() {} + async reconcileOpenDocuments() { + reconciles++; + } + }, +})); +rs.mock('../../../src/stacks/lint/CoreResolver', () => ({ + CoreResolver: class {}, +})); +rs.mock('../../../src/stacks/lint/ruleDocumentationProviders', () => ({ + registerRuleDocumentationProviders: () => [], +})); +rs.mock('../../../src/shared/nodeExecutableSetting', () => ({ + getConfiguredNodeExecutable: () => undefined, +})); +rs.mock('../../../src/shared/nodeResolution', () => ({ + resolveUserNodeOnce: async () => ({ executable: 'node' }), +})); +rs.mock('vscode-languageclient/node', () => ({ + State: { Running: 2, Stopped: 1 }, + LanguageClient: class { + state = 2; + private notification: ((value: unknown) => void) | undefined; + onNotification(_method: unknown, callback: (value: unknown) => void) { + this.notification = callback; + } + onDidChangeState() { + return { dispose() {} }; + } + createDefaultErrorHandler() { + return {}; + } + async start() {} + async sendRequest() { + refreshCalls++; + if (pendingRefresh) return pendingRefresh; + if (refreshOutcome === 'changed' || refreshOutcome === 'changed-once') { + if (refreshOutcome === 'changed-once') refreshOutcome = 'fixed'; + let request!: (method: string, params: unknown) => Promise; + // Use the real worker proxy so the test observes its notification + // ordering, not a mock of the behavior being fixed. + registerEditorProxy( + { + onRequest: (handler: typeof request) => { + request = handler; + }, + onNotification() {}, + sendNotification: (_method: string, params: unknown) => + this.notification?.(params), + } as never, + { + sendRequest: async () => { + throw new Error('config changed while loading'); + }, + } as never, + { + protocolVersion: 2, + takeConfigStatus: () => ({ kind: 'ok' }), + observeRefresh() {}, + requestStop() {}, + }, + ); + return request('rslint/configRefresh', { reason: 'initial' }); + } + if (refreshOutcome !== 'missing') { + this.notification?.( + refreshOutcome === 'broken' + ? { kind: 'error', message: 'Invalid config' } + : { kind: 'ok' }, + ); + if (refreshOutcome === 'broken') throw new Error('Invalid config'); + return; + } + this.notification?.({ + kind: 'missing', + failure: { + configPath: '/project/rslint.config.mjs', + cause: "Cannot find package 'missing'", + }, + }); + throw new Error('configRefresh rejected'); + } + }, +})); + +import { Rslint } from '../../../src/stacks/lint/Rslint'; +import { createRslintController } from '../../../src/stacks/lint'; + +it('reconciles documents on every detection pass even while a config refresh is hung', async () => { + const folder = { + name: 'project', + uri: { fsPath: '/project', toString: () => 'file:///project' }, + }; + const entry = { folder, stacks: { rslint: { mode: 'native' } } }; + const snapshot = { + forFolder: () => entry, + foldersFor: () => [entry], + } as unknown as DetectionSnapshot; + let onDetection!: (snapshot: DetectionSnapshot) => void; + const controller = createRslintController(); + await controller.register({ + detection: snapshot, + onDidChangeDetection: (listener: typeof onDetection) => { + onDetection = listener; + return { dispose() {} }; + }, + output: { debug() {}, info() {}, warn() {}, error() {} }, + status: { report() {} }, + } as unknown as StackContext); + const runtime = runtimeFactory({ + key: 'core', + workspaceFolder: folder, + installation: { mode: 'native', packageDirectory: '/project/core' }, + } as unknown as ResolvedCoreRuntime); + const hung = Promise.withResolvers(); + const retry = rs + .spyOn(runtime, 'retryConfigDependency') + .mockReturnValue(hung.promise); + const before = reconciles; + try { + onDetection(snapshot); + await Promise.resolve(); + expect(reconciles).toBe(before + 1); + onDetection(snapshot); + await Promise.resolve(); + expect(reconciles).toBe(before + 2); + } finally { + hung.resolve(); + retry.mockRestore(); + } +}); + +it('updates a surviving bridge runtime attribution before the next config failure', async () => { + const folder = { + name: 'project', + uri: { fsPath: '/project', toString: () => 'file:///project' }, + }; + const snapshot = (configPath: string): DetectionSnapshot => { + const entry = { + folder, + rootRstackConfigPath: configPath, + stacks: { rslint: { mode: 'bridged' } }, + }; + return { + forFolder: () => entry, + foldersFor: () => [entry], + } as unknown as DetectionSnapshot; + }; + let onDetection!: (snapshot: DetectionSnapshot) => void; + const warnings: string[] = []; + const states: StackState[] = []; + const controller = createRslintController(); + await controller.register({ + detection: snapshot('/project/rstack.config.js'), + onDidChangeDetection: (listener: typeof onDetection) => { + onDetection = listener; + return { dispose() {} }; + }, + output: { warn: (message: string) => warnings.push(message) }, + status: { report: (state: StackState) => states.push(state) }, + } as unknown as StackContext); + const shimPath = '/project/node_modules/rstack/dist/rslintConfig.js'; + const runtime = runtimeFactory({ + key: 'bridge', + workspaceFolder: folder, + installation: { + mode: 'bridged', + packageDirectory: '/project/core', + shimPath, + }, + } as unknown as ResolvedCoreRuntime); + + onDetection(snapshot('/project/rstack.config.ts')); + // Deliver the next worker verdict to the same runtime, not a replacement. + ( + runtime as unknown as { handleConfigDependencyStatus(value: unknown): void } + ).handleConfigDependencyStatus({ + kind: 'missing', + failure: { configPath: shimPath, cause: "Cannot find package 'missing'" }, + }); + expect(states.at(-1)).toMatchObject({ + kind: 'disabled', + reason: expect.stringContaining('rstack.config.ts'), + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('Cannot load rstack.config.ts:'); + expect(warnings[0]).not.toContain('rstack.config.js'); +}); + +function createRuntime() { + const states: StackState[] = []; + const warnings: string[] = []; + const errors: unknown[] = []; + const runtime = new Rslint({ + rootKey: '/project/core', + workspaceFolder: { name: 'project', uri: { fsPath: '/project' } }, + installation: { mode: 'native', packageDirectory: '/project/core' }, + router: { createMiddleware: () => ({}) }, + logger: { + info() {}, + debug() {}, + warn: (message: string) => warnings.push(message), + error: (...args: unknown[]) => errors.push(args), + }, + reportStatus: (state: StackState) => states.push(state), + } as unknown as RslintOptions); + + return { runtime, states, warnings, errors }; +} + +it('keeps dependency retries single-flight until a hung refresh settles', async () => { + refreshOutcome = 'missing'; + const { runtime, states } = createRuntime(); + await runtime.start(new AbortController().signal); + const gate = Promise.withResolvers(); + pendingRefresh = gate.promise; + const before = refreshCalls; + const first = runtime.retryConfigDependency(); + try { + await rs.waitUntil(() => refreshCalls === before + 1); + for (let tick = 0; tick < 5; tick++) { + expect(runtime.retryConfigDependency()).toBeUndefined(); + } + expect(refreshCalls).toBe(before + 1); + } finally { + pendingRefresh = undefined; + refreshOutcome = 'fixed'; + gate.resolve(); + await first; + } + await runtime.retryConfigDependency(); + expect(refreshCalls).toBe(before + 2); + expect(states.at(-1)?.kind).toBe('running'); +}); + +it('keeps an initialized runtime disabled when initial configRefresh rejects', async () => { + refreshOutcome = 'missing'; + const { runtime, states, warnings, errors } = createRuntime(); + + await runtime.start(new AbortController().signal); + expect(states.at(-1)?.kind).toBe('disabled'); + expect(states.some((state) => state.kind === 'crashed')).toBe(false); + expect(warnings).toHaveLength(1); + expect(errors).toEqual([]); + await expect(runtime.retryConfigDependency()).rejects.toThrow( + 'configRefresh rejected', + ); + expect(warnings).toHaveLength(1); + + refreshOutcome = 'broken'; + const beforeBroken = states.length; + await runtime.retryConfigDependency(); + expect(states.slice(beforeBroken).map((state) => state.kind)).toEqual([ + 'crashed', + ]); + expect(runtime.hasConfigDependencyFailure()).toBe(false); + expect(errors).toEqual([ + ['Failed to refresh config discovery: Invalid config'], + ]); + + await runtime.retryConfigDependency(); + expect(errors).toEqual([ + ['Failed to refresh config discovery: Invalid config'], + ]); + + refreshOutcome = 'fixed'; + await runtime.retryConfigDependency(); + expect(states.at(-1)?.kind).toBe('running'); + expect(errors).toHaveLength(1); + + refreshOutcome = 'changed'; + await expect( + ( + runtime as unknown as { + requestConfigRefresh(reason: string): Promise; + } + ).requestConfigRefresh('initial'), + ).rejects.toThrow('config changed while loading'); +}); + +it('recovers a startup config source race without a crash or error log', async () => { + refreshOutcome = 'changed-once'; + const { runtime, states, errors } = createRuntime(); + await runtime.start(new AbortController().signal); + expect(states.some((state) => state.kind === 'crashed')).toBe(false); + expect(states.at(-1)?.kind).toBe('running'); + expect(errors).toEqual([]); +}); + +it('reports one startup crash when the config source retry is exhausted', async () => { + refreshOutcome = 'changed'; + const { runtime, states, errors } = createRuntime(); + await expect(runtime.start(new AbortController().signal)).rejects.toThrow( + 'config changed while loading', + ); + expect(states.filter((state) => state.kind === 'crashed')).toHaveLength(1); + expect(errors).toHaveLength(1); +}); diff --git a/packages/vscode/tests/stacks/lint/worker.test.ts b/packages/vscode/tests/stacks/lint/worker.test.ts index 85128ac..228ab00 100644 --- a/packages/vscode/tests/stacks/lint/worker.test.ts +++ b/packages/vscode/tests/stacks/lint/worker.test.ts @@ -4,12 +4,22 @@ import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; import { describe, expect, it } from '@rstest/core'; +import type { + ConfigModuleActivationPlan, + LoadConfigsRequest, + LoadConfigsResponse, +} from '@rslint/core/config-loader'; import { createMessageConnection, NullLogger } from 'vscode-jsonrpc/node'; import { LINT_WORKER_USAGE, parseWorkerArgs, stampConfigRefresh, } from '../../../src/stacks/lint/worker/cli'; +import { LspConfigTransactionAdapter } from '../../../src/stacks/lint/worker/ConfigTransactionAdapter'; +import { + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + type ConfigDependencyStatusNotification, +} from '../../../src/stacks/lint/worker/configDependencyProtocol'; import { registerEditorProxy } from '../../../src/stacks/lint/worker/index'; const fakeGoSource = String.raw` @@ -28,6 +38,18 @@ function handle(message) { } if (message.id === undefined) return; const hasParams = Object.prototype.hasOwnProperty.call(message, 'params'); + if ( + message.method === 'rslint/configRefresh' && + ['reject', 'changed'].includes(message.params?.reason) + ) { + send({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32603, message: message.params.reason === 'changed' + ? 'config changed while loading' : 'refresh rejected' }, + }); + return; + } send({ jsonrpc: '2.0', id: message.id, @@ -135,14 +157,35 @@ describe('lint worker config refresh', () => { ); const configPath = path.resolve('/project/rslintConfig.js'); const observedReasons: unknown[] = []; + const notificationFailure = { + configPath: '/project/rslint.config.mjs', + cause: "Cannot find package 'missing'", + }; + const notifications: ConfigDependencyStatusNotification[] = []; + let activeFailure: + { readonly configPath: string; readonly cause: string } | undefined = + notificationFailure; try { registerEditorProxy(workerConnection, goConnection, { protocolVersion: 2, configPath, + takeConfigStatus: () => { + const failure = activeFailure; + activeFailure = undefined; + if (failure !== undefined) + return { kind: 'missing' as const, failure }; + return { kind: 'ok' as const }; + }, observeRefresh: (reason) => observedReasons.push(reason), requestStop: () => undefined, }); + editorConnection.onNotification( + CONFIG_DEPENDENCY_STATUS_NOTIFICATION, + (notification: ConfigDependencyStatusNotification) => { + notifications.push(notification); + }, + ); goConnection.listen(); workerConnection.listen(); editorConnection.listen(); @@ -162,6 +205,33 @@ describe('lint worker config refresh', () => { }, }); expect(observedReasons).toEqual(['config-change']); + expect(notifications).toEqual([ + { kind: 'missing', failure: notificationFailure }, + ]); + + await expect( + editorConnection.sendRequest('rslint/configRefresh', { + reason: 'reject', + }), + ).rejects.toThrow('refresh rejected'); + expect(observedReasons).toEqual(['config-change', 'reject']); + expect(notifications).toEqual([ + { kind: 'missing', failure: notificationFailure }, + { kind: 'error', message: 'refresh rejected' }, + ]); + + activeFailure = notificationFailure; + await expect( + editorConnection.sendRequest('rslint/configRefresh', { + reason: 'changed', + }), + ).rejects.toThrow('config changed while loading'); + expect(notifications).toHaveLength(2); + + await editorConnection.sendRequest('rslint/configRefresh', { + reason: 'initial', + }); + expect(notifications.at(-1)).toEqual({ kind: 'ok' }); const shutdown = await editorConnection.sendRequest<{ readonly method: string; @@ -185,3 +255,245 @@ describe('lint worker config refresh', () => { } }); }); + +describe('lint worker config dependency classification', () => { + it('prefers a real candidate error over a missing dependency in the same refresh', async () => { + let missing: { configPath: string; cause: string } | undefined; + let configError: string | undefined; + const observer = { + resolveFrom: () => '/project', + report: (failure: NonNullable) => { + missing = failure; + }, + reportError: (message: string) => { + configError ??= message; + }, + }; + const adapter = new LspConfigTransactionAdapter( + { + loadConfigs: async () => ({ + transactionId: 'mixed', + results: [ + { + id: 'missing', + status: 'failed' as const, + error: { + code: 'ERR_MODULE_NOT_FOUND', + message: "Cannot find package 'absent'", + }, + }, + { + id: 'broken', + status: 'failed' as const, + error: { + code: 'SyntaxError', + message: 'SyntaxError: Unexpected token\n at config.ts:1', + }, + }, + ], + }), + activateConfigs: async () => { + throw new Error('unused'); + }, + deleteSession: () => true, + }, + { + prepare: async () => true, + commit: async () => true, + abort: async () => {}, + }, + () => 'fingerprint', + 3, + observer, + ); + const notifications: unknown[] = []; + let refresh!: (method: string, params: unknown) => Promise; + const options = { + protocolVersion: 3, + takeConfigStatus: () => + configError !== undefined + ? { kind: 'error' as const, message: configError } + : missing !== undefined + ? { kind: 'missing' as const, failure: missing } + : { kind: 'ok' as const }, + observeRefresh() {}, + requestStop() {}, + }; + registerEditorProxy( + { + onRequest: (handler: typeof refresh) => { + refresh = handler; + }, + onNotification() {}, + sendNotification: async (_method: string, value: unknown) => { + notifications.push(value); + }, + } as never, + { + sendRequest: async () => { + await adapter.loadConfigs({ + protocolVersion: 3, + transactionId: 'mixed', + loadMode: 'fresh', + candidates: ['missing', 'broken'].map((id) => ({ + id, + configPath: `/project/${id}.config.ts`, + configDirectory: '/project', + })), + }); + throw new Error('config refresh failed'); + }, + } as never, + options, + ); + await expect( + refresh('rslint/configRefresh', { reason: 'initial' }), + ).rejects.toThrow('config refresh failed'); + expect(notifications).toEqual([ + { kind: 'error', message: 'SyntaxError: Unexpected token' }, + ]); + }); + + it('reports and truncates only the first classified failed candidate', async () => { + const firstMessage = + "Cannot find module 'first-missing'\nRequire stack:\n- /project/first.config.cjs"; + const secondMessage = + "Cannot find package 'second-missing' imported from /project/second.config.mjs"; + const host = { + loadConfigs: async (): Promise => ({ + transactionId: 'transaction', + results: [ + { + id: 'first', + status: 'failed', + error: { code: 'MODULE_NOT_FOUND', message: firstMessage }, + }, + { + id: 'second', + status: 'failed', + error: { code: 'ERR_MODULE_NOT_FOUND', message: secondMessage }, + }, + ], + }), + activateConfigs: async () => { + throw new Error('not used'); + }, + deleteSession: () => true, + }; + const pluginLintPool = { + prepare: async () => true, + commit: async () => true, + abort: async () => undefined, + }; + const failures: Array<{ configPath: string; cause: string }> = []; + const adapter = new LspConfigTransactionAdapter( + host, + pluginLintPool, + (_plan: ConfigModuleActivationPlan) => 'fingerprint', + 3, + { + resolveFrom: (candidate) => candidate.configDirectory, + report: (failure) => failures.push(failure), + reportError: () => { + throw new Error('unexpected config error'); + }, + }, + ); + const request: LoadConfigsRequest = { + protocolVersion: 3, + transactionId: 'transaction', + loadMode: 'cached', + candidates: [ + { + id: 'first', + configPath: '/project/first.config.cjs', + configDirectory: '/project', + }, + { + id: 'second', + configPath: '/project/second.config.mjs', + configDirectory: '/project', + }, + ], + }; + + const response = await adapter.loadConfigs(request); + + expect(failures).toEqual([ + { + configPath: '/project/first.config.cjs', + cause: "Cannot find module 'first-missing'", + }, + ]); + expect(response.results).toEqual([ + { + id: 'first', + status: 'failed', + error: { + code: 'MODULE_NOT_FOUND', + message: "Cannot find module 'first-missing'", + }, + }, + { + id: 'second', + status: 'failed', + error: { code: 'ERR_MODULE_NOT_FOUND', message: secondMessage }, + }, + ]); + }); + + it('leaves an unclassified failed result untouched', async () => { + const response: LoadConfigsResponse = { + transactionId: 'transaction', + results: [ + { + id: 'config', + status: 'failed', + error: { + code: 'ERR_MODULE_NOT_FOUND', + message: "Cannot find package './relative.js'", + }, + }, + ], + }; + const failures: Array<{ configPath: string; cause: string }> = []; + const adapter = new LspConfigTransactionAdapter( + { + loadConfigs: async () => response, + activateConfigs: async () => { + throw new Error('not used'); + }, + deleteSession: () => true, + }, + { + prepare: async () => true, + commit: async () => true, + abort: async () => undefined, + }, + () => 'fingerprint', + 3, + { + resolveFrom: () => '/project', + report: (failure) => failures.push(failure), + reportError: (message) => + expect(message).toBe("Cannot find package './relative.js'"), + }, + ); + + const result = await adapter.loadConfigs({ + protocolVersion: 3, + transactionId: 'transaction', + loadMode: 'cached', + candidates: [ + { + id: 'config', + configPath: '/project/rslint.config.mjs', + configDirectory: '/project', + }, + ], + }); + + expect(result).toEqual(response); + expect(failures).toEqual([]); + }); +}); diff --git a/packages/vscode/tests/stacks/test/master.test.ts b/packages/vscode/tests/stacks/test/master.test.ts index 3c65594..802e0e4 100644 --- a/packages/vscode/tests/stacks/test/master.test.ts +++ b/packages/vscode/tests/stacks/test/master.test.ts @@ -6,6 +6,7 @@ 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 { nodeRequire } from '../../../src/stacks/test/nodeRequire'; import { type NodeProbe, configuredNodeBelowFloor, @@ -150,7 +151,7 @@ const createApi = (cwd = noCoreDir, rstestResolutionDir = cwd) => { ); }; -const writeCoreInstall = (root: string) => { +const writeCoreInstall = (root: string, version = '0.11.8') => { const packageDir = path.join(root, 'node_modules', '@rstest', 'core'); const entry = path.join(packageDir, 'index.js'); const bin = path.join(packageDir, 'bin', 'rstest.js'); @@ -159,7 +160,7 @@ const writeCoreInstall = (root: string) => { path.join(packageDir, 'package.json'), JSON.stringify({ name: '@rstest/core', - version: '0.11.8', + version, main: 'index.js', bin: { rstest: 'bin/rstest.js' }, }), @@ -189,6 +190,7 @@ describe('RstestApi package-resolution anchor', () => { storeEntry = path.join(cwd, 'node_modules', '.pnpm', 'rstack@0.6.1'); rstackDir = path.join(storeEntry, 'node_modules', 'rstack'); fs.mkdirSync(rstackDir, { recursive: true }); + loggedErrors.length = 0; }); afterEach(() => { @@ -228,6 +230,32 @@ describe('RstestApi package-resolution anchor', () => { bin: configured.bin, }); }); + + it('deduplicates each unsupported-version message until a supported version resolves', () => { + writeCoreInstall(cwd, '0.5.0'); + const api = createApi(cwd); + + resolveRstestPaths(api); + resolveRstestPaths(api); + expect(loggedErrors).toEqual([ + `Unsupported @rstest/core version 0.5.0 resolved from ${cwd}`, + ]); + + writeCoreInstall(cwd, '0.4.0'); + resolveRstestPaths(api); + expect(loggedErrors.at(-1)).toBe( + `Unsupported @rstest/core version 0.4.0 resolved from ${cwd}`, + ); + + writeCoreInstall(cwd); + resolveRstestPaths(api); + writeCoreInstall(cwd, '0.4.0'); + resolveRstestPaths(api); + expect(loggedErrors).toHaveLength(3); + + resolveRstestPaths(createApi(cwd)); + expect(loggedErrors).toHaveLength(4); + }); }); describe('RstestApi with a missing @rstest/core', () => { @@ -338,6 +366,69 @@ describe('RstestApi with an unresolvable rstestPackagePath', () => { expect(shownMessages[0]).toContain(configured); }); + it('deduplicates a resolution error until resolution succeeds', () => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-vscode-')), + ); + const installed = writeCoreInstall(root); + const api = createApi(root); + const resolve = () => (api as any).resolveRstestPath() as string; + + try { + expect(resolve).toThrow(); + expect(resolve).toThrow(); + expect(shownMessages).toHaveLength(1); + + settings.rstestPackagePath = path.join( + installed.packageDir, + 'package.json', + ); + expect(resolve()).toBe(installed.entry); + + settings.rstestPackagePath = configured; + expect(resolve).toThrow(); + expect(shownMessages).toHaveLength(2); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('deduplicates package metadata errors and toasts together until recovery', () => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'rstest-log-')), + ); + const installed = writeCoreInstall(root); + const metadata = path.join(installed.packageDir, 'package.json'); + settings.rstestPackagePath = metadata; + loggedErrors.length = 0; + const original = nodeRequire.resolve; + let broken = true; + const spy = rs + .spyOn(nodeRequire, 'resolve') + .mockImplementation((specifier, options) => { + if (broken && specifier === metadata) + throw new Error('incomplete package metadata'); + return original(specifier, options); + }); + const api = createApi(root); + const resolve = () => (api as any).resolveRstestPath() as string; + try { + expect(resolve()).toBe(''); + expect(resolve()).toBe(''); + expect(shownMessages).toHaveLength(1); + expect(loggedErrors).toHaveLength(1); + broken = false; + expect(resolve()).toBe(installed.entry); + broken = true; + expect(resolve()).toBe(''); + expect(shownMessages).toHaveLength(2); + expect(loggedErrors).toHaveLength(2); + } finally { + spy.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('should notify for a terminal run', () => { createApi().runInTerminal({}); expect(shownMessages).toHaveLength(1); @@ -545,3 +636,23 @@ describe('RstestApi worker spawn failures', () => { expect(crashes()).toEqual([]); }); }); + +it('closes the config worker when config evaluation rejects', async () => { + const api = createApi(); + const close = rs.fn(); + rs.spyOn(api, 'createChildProcess').mockResolvedValue({ + rstestPath: '/project/rstest', + worker: { + getNormalizedConfig: async () => { + throw new SyntaxError('Invalid config'); + }, + $close: close, + }, + } as never); + try { + await expect(api.getNormalizedConfig()).rejects.toThrow('Invalid config'); + expect(close).toHaveBeenCalledTimes(1); + } finally { + api.dispose(); + } +}); diff --git a/packages/vscode/tests/stacks/test/project.test.ts b/packages/vscode/tests/stacks/test/project.test.ts index 01c39f4..a03c40e 100644 --- a/packages/vscode/tests/stacks/test/project.test.ts +++ b/packages/vscode/tests/stacks/test/project.test.ts @@ -20,6 +20,17 @@ const apiCalls: { }[] = []; let normalizedConfigFailure: unknown; let normalizedConfigResult: NormalizedConfigResult | undefined; +let normalizedConfigCalls = 0; +let pendingConfig: Promise | undefined; +let runtimeCollection = false; +let collectionFailure: unknown; +let listedFiles: string[] = []; +const renderedFiles = new Set(); +const fileWatchers: { + root: string; + active: boolean; + create?: (uri: any) => void; +}[] = []; rs.mock('../../../src/stacks/test/master', () => { class RstestApi { @@ -27,7 +38,7 @@ rs.mock('../../../src/stacks/test/master', () => { _workspace: unknown, cwd: string, configFilePath: string, - _project: unknown, + private project: { sourceUri: { toString(): string } }, rstestResolutionDir: string, ) { apiCalls.push({ cwd, configFilePath, rstestResolutionDir }); @@ -35,6 +46,8 @@ rs.mock('../../../src/stacks/test/master', () => { // Never settles: the constructor's config-resolution continuation would // otherwise start watchers this test has no filesystem for. getNormalizedConfig() { + normalizedConfigCalls += 1; + if (pendingConfig) return pendingConfig; if (normalizedConfigFailure) { return Promise.reject(normalizedConfigFailure); } @@ -43,6 +56,19 @@ rs.mock('../../../src/stacks/test/master', () => { } return new Promise(() => {}); } + async listTests(include?: string[]) { + if (collectionFailure) { + status.notInstalled( + 'core disappeared', + this.project.sourceUri.toString(), + ); + throw collectionFailure; + } + return (include ?? listedFiles).map((testPath) => ({ + testPath, + tests: [], + })); + } dispose() {} } return { RstestApi, runningWorkers: new Set() }; @@ -64,6 +90,7 @@ const channel = { rs.mock('vscode', () => { const vscode = { Uri: { + parse: (value: string) => uri(value.slice('file://'.length)), file: (fsPath: string) => ({ scheme: 'file', fsPath, @@ -72,9 +99,17 @@ rs.mock('vscode', () => { }), }, CancellationTokenSource: class { - token = { isCancellationRequested: false }; + listeners: (() => void)[] = []; + token = { + isCancellationRequested: false, + onCancellationRequested: (listener: () => void) => { + this.listeners.push(listener); + return { dispose() {} }; + }, + }; cancel() { this.token.isCancellationRequested = true; + this.listeners.forEach((listener) => listener()); } dispose() {} }, @@ -89,14 +124,31 @@ rs.mock('vscode', () => { }, workspace: { fs: {}, - getConfiguration: () => ({ get: () => undefined }), - onDidChangeConfiguration: () => ({ dispose: () => {} }), - createFileSystemWatcher: () => ({ - onDidCreate: () => ({ dispose: () => {} }), - onDidChange: () => ({ dispose: () => {} }), - onDidDelete: () => ({ dispose: () => {} }), - dispose: () => {}, + getConfiguration: () => ({ + get: (key: string) => + runtimeCollection && key === 'testCaseCollectMethod' + ? 'runtime' + : undefined, }), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + createFileSystemWatcher: (pattern: { base: { fsPath: string } }) => { + const watcher: (typeof fileWatchers)[number] = { + root: pattern.base.fsPath, + active: true, + }; + fileWatchers.push(watcher); + return { + onDidCreate: (listener: (uri: any) => void) => { + watcher.create = listener; + return { dispose() {} }; + }, + onDidChange: () => ({ dispose() {} }), + onDidDelete: () => ({ dispose() {} }), + dispose: () => { + watcher.active = false; + }, + }; + }, }, }; return { ...vscode, default: vscode }; @@ -125,14 +177,25 @@ const controller = { } as any; const collection = { - replace: () => {}, - add: () => {}, + replace: () => { + renderedFiles.clear(); + }, + add: (item: { id: string }) => { + renderedFiles.add(item.id); + }, forEach: () => {}, } as any; beforeEach(() => { normalizedConfigFailure = undefined; normalizedConfigResult = undefined; + normalizedConfigCalls = 0; + pendingConfig = undefined; + runtimeCollection = false; + collectionFailure = undefined; + listedFiles = []; + renderedFiles.clear(); + fileWatchers.length = 0; loggedErrors.length = 0; loggedWarnings.length = 0; logger.bind(channel as never); @@ -150,6 +213,247 @@ const createProject = async (source: any) => { }; describe('Project config/cwd/package-resolution decoupling', () => { + it('collects files missed during an outage after unchanged-config recovery', async () => { + runtimeCollection = true; + collectionFailure = new ReportedRstestResolutionError('core disappeared'); + normalizedConfigResult = { + ok: true, + root: '/repo', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + const { reporter } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ + sourceUri: uri('/repo/rstest.config.ts'), + }); + try { + await rs.waitUntil(() => status.hasFailed(project.sourceUri.toString())); + expect(renderedFiles.size).toBe(0); + collectionFailure = undefined; + listedFiles = ['/repo/outage.test.ts']; + await project.retryFailedConfig(); + await rs.waitUntil(() => + renderedFiles.has(uri(listedFiles[0]!).toString()), + ); + } finally { + project.dispose(); + status.unbind(); + } + }); + + it('reports a late resolution failure to the shell and clears it after recovery', async () => { + const gate = Promise.withResolvers(); + pendingConfig = gate.promise; + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ + sourceUri: uri('/repo/rstest.config.ts'), + }); + try { + expect(reported).toEqual([]); + gate.reject(new ReportedRstestResolutionError()); + await rs.waitUntil(() => project.configLoadFailed); + expect(reported.at(-1)).toEqual({ + kind: 'crashed', + detail: 'Cannot load rstest.config.ts: Failed to resolve rstest path', + }); + expect(loggedErrors).toEqual([]); + pendingConfig = undefined; + normalizedConfigResult = { + ok: true, + root: '/repo', + include: [], + exclude: [], + childProjects: [], + }; + await project.retryFailedConfig(); + expect(reported.at(-1)?.kind).toBe('running'); + expect(project.hasFailedState).toBe(false); + } finally { + project.dispose(); + status.unbind(); + } + }); + + it('replaces stale test files and watches the recovered root and globs only when changed', async () => { + const oldRoot = path.join('/repo', 'old'); + const newRoot = path.join('/repo', 'new'); + const oldFile = path.join(oldRoot, 'old.test.ts'); + const currentFile = path.join(newRoot, 'current.spec.ts'); + const addedFile = path.join(newRoot, 'added.spec.ts'); + runtimeCollection = true; + listedFiles = [oldFile]; + normalizedConfigResult = { + ok: true, + root: oldRoot, + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + const { reporter } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ + sourceUri: uri('/repo/rstest.config.ts'), + }); + const files = () => [...renderedFiles].sort(); + const createFile = (file: string) => { + for (const watcher of fileWatchers) { + if (watcher.active && file.startsWith(`${watcher.root}${path.sep}`)) + watcher.create?.(uri(file)); + } + }; + try { + await rs.waitUntil(() => files().includes(uri(oldFile).toString())); + normalizedConfigFailure = new SyntaxError('half-written dependency'); + status.crashed('worker stopped', project.sourceUri.toString()); + await project.retryFailedConfig(); + normalizedConfigFailure = undefined; + listedFiles = [currentFile]; + normalizedConfigResult = { + ok: true, + root: newRoot, + include: ['**/*.spec.ts'], + exclude: ['**/ignored.spec.ts'], + childProjects: [], + }; + await project.retryFailedConfig(); + await rs.waitUntil(() => files().includes(uri(currentFile).toString())); + expect(files()).toEqual([uri(currentFile).toString()]); + createFile(path.join(oldRoot, 'stale.test.ts')); + createFile(path.join(newRoot, 'ignored.spec.ts')); + createFile(path.join(newRoot, 'wrong.test.ts')); + createFile(addedFile); + await rs.waitUntil(() => files().includes(uri(addedFile).toString())); + expect(files()).toEqual([ + uri(addedFile).toString(), + uri(currentFile).toString(), + ]); + + // Unchanged normalization must preserve the collected items rather than + // re-listing and removing the file delivered through the watcher. + normalizedConfigResult = { + ...normalizedConfigResult, + include: [...normalizedConfigResult.include], + exclude: [...normalizedConfigResult.exclude], + }; + await project.retryFailedConfig(); + expect(files()).toEqual([ + uri(addedFile).toString(), + uri(currentFile).toString(), + ]); + } finally { + project.dispose(); + status.unbind(); + } + }); + + it('re-resolves a core lost after successful config loading on a dependency pass', async () => { + const config = uri('/repo/pkg/rstest.config.ts'); + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const loaded: NormalizedConfigResult = { + ok: true, + root: '/repo/pkg', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + normalizedConfigResult = loaded; + const { project } = await createProject({ sourceUri: config }); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(project.configLoadFailed).toBe(false); + + // listTests -> createChildProcess -> resolveRstestPath reports this core + // source, without going through Project.loadConfig's failure handler. + project.api.listTests = rs.fn(async () => { + status.notInstalled('@rstest/core is not installed', config.toString()); + throw new ReportedRstestResolutionError(); + }); + await expect(project.api.listTests()).rejects.toThrow( + 'Failed to resolve rstest path', + ); + expect(project.configLoadFailed).toBe(false); + expect(project.hasFailedState).toBe(true); + + // A new config request resolves the core before its worker RPC. Model + // successful resolution's versionOk, which clears the core-source latch. + const reResolve = rs + .spyOn(project.api, 'getNormalizedConfig') + .mockImplementation(async () => { + status.versionOk(config.toString()); + return loaded; + }); + const { WorkspaceManager } = + await import('../../../src/stacks/test/project'); + WorkspaceManager.prototype.retryFailedProjects.call({ + projects: new Map([['config', project]]), + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(reResolve).toHaveBeenCalledTimes(1); + expect(project.hasFailedState).toBe(false); + expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); + expect(loggedErrors).toEqual([]); + } finally { + project.dispose(); + status.unbind(); + } + }); + + it('retries a worker crash after successful config loading only until it recovers', async () => { + const config = uri('/repo/pkg/rstest.config.ts'); + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const loaded: NormalizedConfigResult = { + ok: true, + root: '/repo/pkg', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + normalizedConfigResult = loaded; + const { project } = await createProject({ sourceUri: config }); + try { + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(project.configLoadFailed).toBe(false); + + // createChildProcess reports unexpected worker errors and exits against + // the project source, independently of the successful config load. + status.crashed('worker process exited unexpectedly', config.toString()); + expect(project.configLoadFailed).toBe(false); + expect(project.hasFailedState).toBe(true); + + // A config retry creates a fresh worker. Model its spawn notification, + // which retires the crash recorded for this project source. + const retry = rs + .spyOn(project.api, 'getNormalizedConfig') + .mockImplementation(async () => { + status.workerSpawned(config.toString()); + return loaded; + }); + const { WorkspaceManager } = + await import('../../../src/stacks/test/project'); + const manager = { + projects: new Map([['config', project]]), + } as never; + + WorkspaceManager.prototype.retryFailedProjects.call(manager); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(retry).toHaveBeenCalledTimes(1); + expect(project.hasFailedState).toBe(false); + expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); + + WorkspaceManager.prototype.retryFailedProjects.call(manager); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(retry).toHaveBeenCalledTimes(1); + } finally { + project.dispose(); + status.unbind(); + } + }); + it('keeps the upstream derivation for a native rstest config', async () => { const configFile = uri(path.join('/repo', 'pkg', 'rstest.config.ts')); @@ -263,4 +567,103 @@ describe('Project config/cwd/package-resolution decoupling', () => { expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); status.unbind(); }); + + it('retries a missing config dependency in place with one flight and one warning', async () => { + const rstackConfig = uri('/repo/templates/app/rstack.config.ts'); + normalizedConfigResult = { + ok: false, + message: "Cannot find package '@rsbuild/plugin-react'", + }; + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ sourceUri: rstackConfig }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const firstRetry = project.retryFailedConfig(); + const sameRetry = project.retryFailedConfig(); + expect(firstRetry).toBe(sameRetry); + await firstRetry; + expect(normalizedConfigCalls).toBe(2); + expect(loggedWarnings).toHaveLength(1); + expect(project.configLoadFailed).toBe(true); + + normalizedConfigResult = { + ok: true, + root: '/repo/templates/app', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + await project.retryFailedConfig(); + expect(normalizedConfigCalls).toBe(3); + expect(project.configLoadFailed).toBe(false); + expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); + + project.dispose(); + status.unbind(); + }); + + it('keeps retrying a real config error on dependency passes and deduplicates it', async () => { + const config = uri('/repo/templates/app/rstest.config.ts'); + normalizedConfigResult = { + ok: false, + message: "Cannot find package '@rstest/plugin-missing'", + }; + const { reporter, reported } = createStatusRecorder(); + status.bind(reporter); + const { project } = await createProject({ sourceUri: config }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(status.hasFailed()).toBe(true); + normalizedConfigResult = undefined; + normalizedConfigFailure = new SyntaxError('Unexpected token export'); + await project.retryFailedConfig(); + + expect(project.configLoadFailed).toBe(true); + expect(status.hasFailed()).toBe(true); + expect(loggedWarnings).toHaveLength(1); + expect(loggedErrors).toHaveLength(1); + expect(loggedErrors[0]).toContain('Failed to initialize project config'); + expect(reported.at(-1)).toEqual({ + kind: 'crashed', + detail: + 'Cannot load templates/app/rstest.config.ts: Unexpected token export', + }); + + const { WorkspaceManager } = + await import('../../../src/stacks/test/project'); + WorkspaceManager.prototype.retryFailedProjects.call({ + projects: new Map([['config', project]]), + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(normalizedConfigCalls).toBe(3); + expect(loggedErrors).toHaveLength(1); + + normalizedConfigFailure = new SyntaxError('Unexpected token import'); + WorkspaceManager.prototype.retryFailedProjects.call({ + projects: new Map([['config', project]]), + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(loggedErrors).toHaveLength(2); + + normalizedConfigFailure = undefined; + normalizedConfigResult = { + ok: true, + root: '/repo/templates/app', + include: ['**/*.test.ts'], + exclude: [], + childProjects: [], + }; + await project.retryFailedConfig(); + expect(reported.at(-1)).toEqual({ kind: 'running', detail: undefined }); + + normalizedConfigResult = undefined; + normalizedConfigFailure = new SyntaxError('Unexpected token export'); + status.crashed('retry this recovered project', config.toString()); + await project.retryFailedConfig(); + expect(loggedErrors).toHaveLength(3); + + project.dispose(); + status.unbind(); + }); });