From 8174c0478d27221d84a34f74d827de1eb3017b5c Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 9 Nov 2026 11:59:33 +0800 Subject: [PATCH 1/2] test(vscode): wait for file watchers to arm before mutating fixtures --- packages/vscode/e2e/rstest/suite/helpers.ts | 54 ++++++ .../vscode/e2e/rstest/suite/workspace.test.ts | 14 ++ packages/vscode/src/detection.ts | 21 ++ packages/vscode/src/extension.ts | 1 + packages/vscode/src/types.ts | 7 + packages/vscode/tests/detection.test.ts | 182 +++++++++++++++++- 6 files changed, 275 insertions(+), 4 deletions(-) diff --git a/packages/vscode/e2e/rstest/suite/helpers.ts b/packages/vscode/e2e/rstest/suite/helpers.ts index 899f05f7..3bb2981a 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 staying + * deaf for hundreds of milliseconds and swallowing the whole event burst, + * which is exactly 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..da5ae93b 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -277,6 +277,17 @@ export class DetectionService implements vscode.Disposable { // rebuild itself does not need the event, it already has the fresh snapshot. #notifyUnchanged = false; #watchers: vscode.Disposable[] = []; + /** + * Watcher events received by the CURRENT watcher set, reset on every + * (re)install. E2E only (read through the extension's exports): the VS Code + * `FileSystemWatcher` has no "watcher armed" signal, so a suite that mutates + * watched fixture files right after a watcher (re)install can race its own + * backends — on Linux the arm window after `createFileSystemWatcher` has + * been observed long enough to drop the entire event burst. The suites + * instead rewrite an already-watched file until an event comes back through + * this counter; the rewrite never changes detection's signature. + */ + #watcherEventCount = 0; #debounce: ReturnType | undefined; #running: Promise | undefined; #rerun = false; @@ -305,6 +316,11 @@ export class DetectionService implements vscode.Disposable { return this.#snapshot; } + /** E2E only: events received since the last (re)install of the watchers. */ + get watcherEventCount(): number { + return this.#watcherEventCount; + } + /** Installs the watchers and runs the first detection pass. */ async initialize(): Promise { this.installWatchers(); @@ -396,9 +412,14 @@ export class DetectionService implements vscode.Disposable { watcher.dispose(); } this.#watchers = []; + // The window the implicit reset opens matters for the arm probe below: a + // count that increased under the old watcher set must not satisfy a suite + // currently waiting on the new one. + this.#watcherEventCount = 0; // 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..81385381 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -210,6 +210,13 @@ export interface RstackExtensionExports { whenStackActive(stack: StackId): Promise>; /** E2E only: shorten the shell's dependency-recovery polling interval. */ setDependencyPollIntervalForTest(intervalMs: number): void; + /** + * E2E only: how many watcher events DetectionService has received since its + * last watcher (re)install. There is no "watcher armed" API; suites that + * mutate watched fixture files after a folder or a watched-glob change wait + * for this counter to move first (see `e2e/rstest/suite/helpers.ts`). + */ + getDetectionWatcherEventCount(): number; } export type StackControllerFactory = () => StackController; diff --git a/packages/vscode/tests/detection.test.ts b/packages/vscode/tests/detection.test.ts index 27558be8..d1276d34 100644 --- a/packages/vscode/tests/detection.test.ts +++ b/packages/vscode/tests/detection.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it, rs } from '@rstest/core'; -import type vscode from 'vscode'; +import vscode from 'vscode'; import type { DetectionSnapshot } from '../src/types'; // `detection.ts` imports the `vscode` namespace for the watcher/`findFiles` // paths. `detectionWatchPatterns` is pure, but the module still has to load, so // the namespace is stubbed away: unit tests run in plain Node, with no // extension host (unit tests are Rstest, E2E is Electron). The stub carries -// exactly what `DetectionService` touches with no workspace folder open — its -// event plumbing — so the notification rules can be exercised here too. +// exactly what `DetectionService` touches — with no workspace folder open its +// event plumbing, and the single-fake-folder surface the watcher-event-counter +// test needs (fake watchers are captured on `workspace.watchers`, disposal +// removes them like a real dispose does, and folder-set listeners are +// captured). rs.mock('vscode', () => { class EventEmitter { readonly #listeners = new Set<(value: unknown) => void>(); @@ -29,14 +32,105 @@ rs.mock('vscode', () => { } } const disposable = { dispose: () => undefined }; + + type FakeHandler = (uri: unknown) => void; + type FakeWatcher = { + dispose(): void; + fire( + kind: 'onDidCreate' | 'onDidChange' | 'onDidDelete', + uri: unknown, + ): void; + }; + const watchers: FakeWatcher[] = []; + const folderChangeCbs: (() => void)[] = []; + const makeWatcher = (): FakeWatcher => { + const handlers: Record< + 'onDidCreate' | 'onDidChange' | 'onDidDelete', + FakeHandler[] + > = { + onDidCreate: [], + onDidChange: [], + onDidDelete: [], + }; + const watcher = { + onDidCreate: (cb: FakeHandler) => { + handlers.onDidCreate.push(cb); + return disposable; + }, + onDidChange: (cb: FakeHandler) => { + handlers.onDidChange.push(cb); + return disposable; + }, + onDidDelete: (cb: FakeHandler) => { + handlers.onDidDelete.push(cb); + return disposable; + }, + dispose: () => { + const index = watchers.indexOf(watcher); + if (index >= 0) { + watchers.splice(index, 1); + } + }, + fire: ( + kind: 'onDidCreate' | 'onDidChange' | 'onDidDelete', + uri: unknown, + ) => { + for (const cb of handlers[kind]) { + cb(uri); + } + }, + }; + watchers.push(watcher); + return watcher; + }; + class RelativePattern { + constructor( + readonly base: unknown, + readonly pattern: string, + ) {} + } + // Enough of `vscode.Uri` for `RelativePattern` bases in `installWatchers`. + class Uri { + readonly scheme = 'file'; + constructor(readonly fsPath: string) {} + get path(): string { + return this.fsPath; + } + toString(): string { + return `file://${this.fsPath}`; + } + static file(path: string): Uri { + return new Uri(path); + } + static joinPath(base: Uri, ...segments: string[]): Uri { + return new Uri([base.fsPath, ...segments].join('/')); + } + } const vscode = { EventEmitter, + RelativePattern, + Uri, workspace: { // No folder is open: every pass produces the empty snapshot, so the // detection signature is unchanged by construction. workspaceFolders: undefined, - onDidChangeWorkspaceFolders: () => disposable, + watchers, + folderChangeCbs, + onDidChangeWorkspaceFolders: (cb: () => void) => { + folderChangeCbs.push(cb); + return disposable; + }, onDidChangeConfiguration: () => disposable, + // One watcher per (re)install pattern; any listener fires its event. + createFileSystemWatcher: () => makeWatcher(), + // `detectFolder` scans: empty on every glob, so the snapshot can only + // report "nothing detected" for the fake folder. + findFiles: () => Promise.resolve([]), + getConfiguration: () => ({ get: () => undefined }), + fs: { + stat: () => + Promise.reject(new Error('the unit stub has no filesystem')), + }, }, }; return { ...vscode, default: vscode }; @@ -233,3 +327,83 @@ describe('DetectionService — notification rules', () => { expect(seen).toHaveLength(0); }); }); + +/** + * The E2E arm probe (`e2e/rstest/suite/helpers.ts`) waits for this counter to + * move before mutating watched fixture files, because the FileSystemWatcher + * API has no "watcher armed" signal and the Linux backends swallow an event + * burst issued inside the arm window. What the suites rely on is asserted + * here: events reach the counter only through the live watcher set, and every + * reinstall restarts the count at zero. + */ +describe('DetectionService — watcher event counter', () => { + type FakeWatcher = { + dispose(): void; + fire(kind: string, uri: unknown): void; + }; + + const workspaceStub = () => + ( + vscode as unknown as { + workspace: { + workspaceFolders: unknown; + watchers: FakeWatcher[]; + folderChangeCbs: (() => void)[]; + }; + } + ).workspace; + + const fakeOutput = () => + ({ + info: () => undefined, + warn: () => undefined, + error: () => undefined, + }) as unknown as vscode.LogOutputChannel; + + it('counts events on the live watcher set and restarts on each install', async () => { + const stub = workspaceStub(); + stub.folderChangeCbs.length = 0; + stub.workspaceFolders = [ + { + uri: { + scheme: 'file', + fsPath: '/tmp/rstack-detection-unit/folder', + toString: () => 'file:///tmp/rstack-detection-unit/folder', + }, + name: 'folder', + index: 0, + }, + ]; + const service = new DetectionService(fakeOutput()); + await service.initialize(); + expect(service.watcherEventCount).toBe(0); + expect(stub.watchers.length).toBeGreaterThan(0); + + // One event through every installed watcher: the counter is per event + // received, not per distinct path (the E2E probe only needs movement). + const eventUri = { + path: '/tmp/rstack-detection-unit/folder/rstest.config.ts', + }; + const fireEverywhere = () => { + for (const watcher of stub.watchers) { + watcher.fire('onDidChange', eventUri); + } + }; + fireEverywhere(); + expect(service.watcherEventCount).toBe(stub.watchers.length); + + // The folder-set change reinstall: old watchers dispose (removing them + // from the stub) and the count restarts — a stale count from the previous + // set must never satisfy a wait for the new one. + for (const cb of stub.folderChangeCbs) { + cb(); + } + expect(service.watcherEventCount).toBe(0); + expect(stub.watchers.length).toBeGreaterThan(0); + fireEverywhere(); + expect(service.watcherEventCount).toBe(stub.watchers.length); + + service.dispose(); + stub.workspaceFolders = undefined; + }); +}); From 70ed8069247976f2aff23c757b99264281622baa Mon Sep 17 00:00:00 2001 From: fi3ework Date: Mon, 9 Nov 2026 12:48:59 +0800 Subject: [PATCH 2/2] test(vscode): simplify watcher-armed probe (monotonic counter, lean unit test) --- packages/vscode/e2e/rstest/suite/helpers.ts | 8 +- packages/vscode/src/detection.ts | 19 +- packages/vscode/src/types.ts | 7 +- packages/vscode/tests/detection.test.ts | 182 +------------------- 4 files changed, 13 insertions(+), 203 deletions(-) diff --git a/packages/vscode/e2e/rstest/suite/helpers.ts b/packages/vscode/e2e/rstest/suite/helpers.ts index 3bb2981a..67b322fb 100644 --- a/packages/vscode/e2e/rstest/suite/helpers.ts +++ b/packages/vscode/e2e/rstest/suite/helpers.ts @@ -72,10 +72,10 @@ 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 staying - * deaf for hundreds of milliseconds and swallowing the whole event burst, - * which is exactly the "detection never re-ran" ecosystem-CI failure. The - * FileSystemWatcher API has no ready signal, so the suite instead rewrites + * 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 diff --git a/packages/vscode/src/detection.ts b/packages/vscode/src/detection.ts index da5ae93b..ee263d44 100644 --- a/packages/vscode/src/detection.ts +++ b/packages/vscode/src/detection.ts @@ -277,16 +277,9 @@ export class DetectionService implements vscode.Disposable { // rebuild itself does not need the event, it already has the fresh snapshot. #notifyUnchanged = false; #watchers: vscode.Disposable[] = []; - /** - * Watcher events received by the CURRENT watcher set, reset on every - * (re)install. E2E only (read through the extension's exports): the VS Code - * `FileSystemWatcher` has no "watcher armed" signal, so a suite that mutates - * watched fixture files right after a watcher (re)install can race its own - * backends — on Linux the arm window after `createFileSystemWatcher` has - * been observed long enough to drop the entire event burst. The suites - * instead rewrite an already-watched file until an event comes back through - * this counter; the rewrite never changes detection's signature. - */ + /** 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; @@ -316,7 +309,7 @@ export class DetectionService implements vscode.Disposable { return this.#snapshot; } - /** E2E only: events received since the last (re)install of the watchers. */ + /** E2E only — see `#watcherEventCount`. */ get watcherEventCount(): number { return this.#watcherEventCount; } @@ -412,10 +405,6 @@ export class DetectionService implements vscode.Disposable { watcher.dispose(); } this.#watchers = []; - // The window the implicit reset opens matters for the arm probe below: a - // count that increased under the old watcher set must not satisfy a suite - // currently waiting on the new one. - this.#watcherEventCount = 0; // 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) => { diff --git a/packages/vscode/src/types.ts b/packages/vscode/src/types.ts index 81385381..bbaa0c25 100644 --- a/packages/vscode/src/types.ts +++ b/packages/vscode/src/types.ts @@ -210,12 +210,7 @@ export interface RstackExtensionExports { whenStackActive(stack: StackId): Promise>; /** E2E only: shorten the shell's dependency-recovery polling interval. */ setDependencyPollIntervalForTest(intervalMs: number): void; - /** - * E2E only: how many watcher events DetectionService has received since its - * last watcher (re)install. There is no "watcher armed" API; suites that - * mutate watched fixture files after a folder or a watched-glob change wait - * for this counter to move first (see `e2e/rstest/suite/helpers.ts`). - */ + /** E2E only: see `watcherEventCount` (`src/detection.ts`). */ getDetectionWatcherEventCount(): number; } diff --git a/packages/vscode/tests/detection.test.ts b/packages/vscode/tests/detection.test.ts index d1276d34..27558be8 100644 --- a/packages/vscode/tests/detection.test.ts +++ b/packages/vscode/tests/detection.test.ts @@ -1,16 +1,13 @@ import { describe, expect, it, rs } from '@rstest/core'; -import vscode from 'vscode'; +import type vscode from 'vscode'; import type { DetectionSnapshot } from '../src/types'; // `detection.ts` imports the `vscode` namespace for the watcher/`findFiles` // paths. `detectionWatchPatterns` is pure, but the module still has to load, so // the namespace is stubbed away: unit tests run in plain Node, with no // extension host (unit tests are Rstest, E2E is Electron). The stub carries -// exactly what `DetectionService` touches — with no workspace folder open its -// event plumbing, and the single-fake-folder surface the watcher-event-counter -// test needs (fake watchers are captured on `workspace.watchers`, disposal -// removes them like a real dispose does, and folder-set listeners are -// captured). +// exactly what `DetectionService` touches with no workspace folder open — its +// event plumbing — so the notification rules can be exercised here too. rs.mock('vscode', () => { class EventEmitter { readonly #listeners = new Set<(value: unknown) => void>(); @@ -32,105 +29,14 @@ rs.mock('vscode', () => { } } const disposable = { dispose: () => undefined }; - - type FakeHandler = (uri: unknown) => void; - type FakeWatcher = { - dispose(): void; - fire( - kind: 'onDidCreate' | 'onDidChange' | 'onDidDelete', - uri: unknown, - ): void; - }; - const watchers: FakeWatcher[] = []; - const folderChangeCbs: (() => void)[] = []; - const makeWatcher = (): FakeWatcher => { - const handlers: Record< - 'onDidCreate' | 'onDidChange' | 'onDidDelete', - FakeHandler[] - > = { - onDidCreate: [], - onDidChange: [], - onDidDelete: [], - }; - const watcher = { - onDidCreate: (cb: FakeHandler) => { - handlers.onDidCreate.push(cb); - return disposable; - }, - onDidChange: (cb: FakeHandler) => { - handlers.onDidChange.push(cb); - return disposable; - }, - onDidDelete: (cb: FakeHandler) => { - handlers.onDidDelete.push(cb); - return disposable; - }, - dispose: () => { - const index = watchers.indexOf(watcher); - if (index >= 0) { - watchers.splice(index, 1); - } - }, - fire: ( - kind: 'onDidCreate' | 'onDidChange' | 'onDidDelete', - uri: unknown, - ) => { - for (const cb of handlers[kind]) { - cb(uri); - } - }, - }; - watchers.push(watcher); - return watcher; - }; - class RelativePattern { - constructor( - readonly base: unknown, - readonly pattern: string, - ) {} - } - // Enough of `vscode.Uri` for `RelativePattern` bases in `installWatchers`. - class Uri { - readonly scheme = 'file'; - constructor(readonly fsPath: string) {} - get path(): string { - return this.fsPath; - } - toString(): string { - return `file://${this.fsPath}`; - } - static file(path: string): Uri { - return new Uri(path); - } - static joinPath(base: Uri, ...segments: string[]): Uri { - return new Uri([base.fsPath, ...segments].join('/')); - } - } const vscode = { EventEmitter, - RelativePattern, - Uri, workspace: { // No folder is open: every pass produces the empty snapshot, so the // detection signature is unchanged by construction. workspaceFolders: undefined, - watchers, - folderChangeCbs, - onDidChangeWorkspaceFolders: (cb: () => void) => { - folderChangeCbs.push(cb); - return disposable; - }, + onDidChangeWorkspaceFolders: () => disposable, onDidChangeConfiguration: () => disposable, - // One watcher per (re)install pattern; any listener fires its event. - createFileSystemWatcher: () => makeWatcher(), - // `detectFolder` scans: empty on every glob, so the snapshot can only - // report "nothing detected" for the fake folder. - findFiles: () => Promise.resolve([]), - getConfiguration: () => ({ get: () => undefined }), - fs: { - stat: () => - Promise.reject(new Error('the unit stub has no filesystem')), - }, }, }; return { ...vscode, default: vscode }; @@ -327,83 +233,3 @@ describe('DetectionService — notification rules', () => { expect(seen).toHaveLength(0); }); }); - -/** - * The E2E arm probe (`e2e/rstest/suite/helpers.ts`) waits for this counter to - * move before mutating watched fixture files, because the FileSystemWatcher - * API has no "watcher armed" signal and the Linux backends swallow an event - * burst issued inside the arm window. What the suites rely on is asserted - * here: events reach the counter only through the live watcher set, and every - * reinstall restarts the count at zero. - */ -describe('DetectionService — watcher event counter', () => { - type FakeWatcher = { - dispose(): void; - fire(kind: string, uri: unknown): void; - }; - - const workspaceStub = () => - ( - vscode as unknown as { - workspace: { - workspaceFolders: unknown; - watchers: FakeWatcher[]; - folderChangeCbs: (() => void)[]; - }; - } - ).workspace; - - const fakeOutput = () => - ({ - info: () => undefined, - warn: () => undefined, - error: () => undefined, - }) as unknown as vscode.LogOutputChannel; - - it('counts events on the live watcher set and restarts on each install', async () => { - const stub = workspaceStub(); - stub.folderChangeCbs.length = 0; - stub.workspaceFolders = [ - { - uri: { - scheme: 'file', - fsPath: '/tmp/rstack-detection-unit/folder', - toString: () => 'file:///tmp/rstack-detection-unit/folder', - }, - name: 'folder', - index: 0, - }, - ]; - const service = new DetectionService(fakeOutput()); - await service.initialize(); - expect(service.watcherEventCount).toBe(0); - expect(stub.watchers.length).toBeGreaterThan(0); - - // One event through every installed watcher: the counter is per event - // received, not per distinct path (the E2E probe only needs movement). - const eventUri = { - path: '/tmp/rstack-detection-unit/folder/rstest.config.ts', - }; - const fireEverywhere = () => { - for (const watcher of stub.watchers) { - watcher.fire('onDidChange', eventUri); - } - }; - fireEverywhere(); - expect(service.watcherEventCount).toBe(stub.watchers.length); - - // The folder-set change reinstall: old watchers dispose (removing them - // from the stub) and the count restarts — a stale count from the previous - // set must never satisfy a wait for the new one. - for (const cb of stub.folderChangeCbs) { - cb(); - } - expect(service.watcherEventCount).toBe(0); - expect(stub.watchers.length).toBeGreaterThan(0); - fireEverywhere(); - expect(service.watcherEventCount).toBe(stub.watchers.length); - - service.dispose(); - stub.workspaceFolders = undefined; - }); -});