Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/vscode/e2e/rstest/suite/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
const api =
vscode.extensions.getExtension<RstackExtensionExports>(
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));
}
Expand Down
14 changes: 14 additions & 0 deletions packages/vscode/e2e/rstest/suite/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getTestItemByLabels,
toLabelTree,
waitFor,
waitForWatcherArmed,
} from './helpers';

// The unwrapped single-folder tree of workspace-1 (the standard layout: one
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down
10 changes: 10 additions & 0 deletions packages/vscode/src/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | undefined;
#running: Promise<DetectionSnapshot> | undefined;
#rerun = false;
Expand Down Expand Up @@ -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<DetectionSnapshot> {
this.installWatchers();
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ class ExtensionShell {
}
this.syncDependencyPoll();
},
getDetectionWatcherEventCount: () => this.#detection.watcherEventCount,
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/vscode/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export interface RstackExtensionExports {
whenStackActive(stack: StackId): Promise<Record<string, unknown>>;
/** 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;
Loading