diff --git a/packages/vscode/e2e/rstest/suite/helpers.ts b/packages/vscode/e2e/rstest/suite/helpers.ts index 899f05f7..67b322fb 100644 --- a/packages/vscode/e2e/rstest/suite/helpers.ts +++ b/packages/vscode/e2e/rstest/suite/helpers.ts @@ -3,6 +3,7 @@ // the `Rstest` instance directly, while here the shell republishes the stack's // exports through `RstackExtensionExports` (see `src/types.ts`). import assert from 'node:assert'; +import fs from 'node:fs/promises'; import path from 'node:path'; import vscode from 'vscode'; import type { RstackExtensionExports } from '../../../src/types'; @@ -62,6 +63,59 @@ export function currentRstestExports(): RstestExports { return stackExports as unknown as RstestExports; } +/** How long one identical-content rewrite waits for the event to come back. */ +const WATCHER_ARM_PROBE_WAIT_MS = 500; +/** Overall budget for proving the watcher armed, rewriting between probes. */ +const WATCHER_ARM_TIMEOUT_MS = 15_000; + +/** + * Proves the detection file watcher delivers events for `file` before the + * suite mutates watched fixture files. `fs.rename`/`write` issued right after + * the watchers changed — a folder added or `configFileGlobPattern` updated — + * race the watcher's arming: on Linux the backend has been observed taking + * hundreds of milliseconds to arm and dropping the whole event burst written + * in that window, which is the "detection never re-ran" ecosystem-CI failure. + * The FileSystemWatcher API has no ready signal, so the suite instead rewrites + * `file` with its identical content and waits for a watcher event through + * `getDetectionWatcherEventCount()`: if the write is not observed, the + * upcoming mutations would not be either. The rewrite never changes + * detection's output, so this only costs a no-op detection pass. + * + * `file` must be a file the currently-configured watch patterns match (e.g. a + * live `rstest.config.*` after the fixture creates it), or the probe never + * completes. + */ +export async function waitForWatcherArmed(file: string): Promise { + const api = + vscode.extensions.getExtension( + EXTENSION_ID, + )?.exports; + assert.ok(api, `extension ${EXTENSION_ID} should be activated`); + const eventCount = () => api.getDetectionWatcherEventCount(); + const content = await fs.readFile(file, 'utf8'); + let attempts = 0; + const deadline = Date.now() + WATCHER_ARM_TIMEOUT_MS; + while (Date.now() < deadline) { + attempts += 1; + const baseline = eventCount(); + await fs.writeFile(file, content); + const probeDeadline = Math.min( + Date.now() + WATCHER_ARM_PROBE_WAIT_MS, + deadline, + ); + while (Date.now() < probeDeadline) { + await delay(25); + if (eventCount() !== baseline) { + return; + } + } + } + throw new Error( + `the file watcher never delivered an event within ${WATCHER_ARM_TIMEOUT_MS} ms: rewrote ${file} ` + + `${attempts} times with identical content`, + ); +} + export async function delay(ms: number) { await new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/packages/vscode/e2e/rstest/suite/workspace.test.ts b/packages/vscode/e2e/rstest/suite/workspace.test.ts index c1b211bd..30b87100 100644 --- a/packages/vscode/e2e/rstest/suite/workspace.test.ts +++ b/packages/vscode/e2e/rstest/suite/workspace.test.ts @@ -28,6 +28,7 @@ import { getTestItemByLabels, toLabelTree, waitFor, + waitForWatcherArmed, } from './helpers'; // The unwrapped single-folder tree of workspace-1 (the standard layout: one @@ -122,6 +123,12 @@ suite('Workspace discover suite', () => { // (detection scopes per folder): with no matching config the folder is undetected and // dropped entirely, and workspace-1 — the only detected folder left — // is shown unwrapped again. + // The renames below must be seen by the freshly installed workspace-2 + // watcher (nothing else re-runs detection): on Linux the backend arms + // late, so probe it first (see `waitForWatcherArmed`). + await waitForWatcherArmed( + path.resolve(fixturesRoot, 'workspace-2/project-1/rstest.config.ts'), + ); await fs.rename( path.resolve(fixturesRoot, 'workspace-2/project-1/rstest.config.ts'), path.resolve(fixturesRoot, 'workspace-2/project-1/foo.config.ts'), @@ -167,6 +174,13 @@ suite('Workspace discover suite', () => { // add config file. // Adapted like the step above: workspace-2 is still the only detected // folder, so its two projects are shown unwrapped. + // The rename's create event under the new glob is the only signal: probe + // the watcher reinstalled by `config.update` above before renaming + // (`project-2` still holds a `bar.config.ts`, which the `foo.config.*` + // glob does not watch, so the probe targets `project-1`). + await waitForWatcherArmed( + path.resolve(fixturesRoot, 'workspace-2/project-1/foo.config.ts'), + ); await fs.rename( path.resolve(fixturesRoot, 'workspace-2/folder/project-2/bar.config.ts'), path.resolve(fixturesRoot, 'workspace-2/folder/project-2/foo.config.ts'), diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index 088d167e..ee263d44 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -277,6 +277,10 @@ export class DetectionService implements vscode.Disposable { // rebuild itself does not need the event, it already has the fresh snapshot. #notifyUnchanged = false; #watchers: vscode.Disposable[] = []; + /** E2E only: monotonic count of watcher events received — the arm probe in + * `e2e/rstest/suite/helpers.ts` compares against its own baseline, so the + * counter is never reset. */ + #watcherEventCount = 0; #debounce: ReturnType | undefined; #running: Promise | undefined; #rerun = false; @@ -305,6 +309,11 @@ export class DetectionService implements vscode.Disposable { return this.#snapshot; } + /** E2E only — see `#watcherEventCount`. */ + get watcherEventCount(): number { + return this.#watcherEventCount; + } + /** Installs the watchers and runs the first detection pass. */ async initialize(): Promise { this.installWatchers(); @@ -399,6 +408,7 @@ export class DetectionService implements vscode.Disposable { // A lockfile event is a dependency change wherever it is watched: a // monorepo opened at a subdirectory installs at an ancestor. const onEvent = (uri: vscode.Uri) => { + this.#watcherEventCount += 1; if (isLockfile(uri)) { this.#notifyUnchanged = true; } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index eac8eb74..70be0341 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -538,6 +538,7 @@ class ExtensionShell { } this.syncDependencyPoll(); }, + getDetectionWatcherEventCount: () => this.#detection.watcherEventCount, }; } diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index e2e82772..bbaa0c25 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -210,6 +210,8 @@ export interface RstackExtensionExports { whenStackActive(stack: StackId): Promise>; /** E2E only: shorten the shell's dependency-recovery polling interval. */ setDependencyPollIntervalForTest(intervalMs: number): void; + /** E2E only: see `watcherEventCount` (`src/detection.ts`). */ + getDetectionWatcherEventCount(): number; } export type StackControllerFactory = () => StackController;