From cb313d5378cbc9f0e0f7fc7ef0c7f172d258f5f9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 16 May 2026 19:18:52 +0800 Subject: [PATCH] test: share fs scan assertions --- .../plugins/bundled.shape-guard.test.ts | 13 +-- .../channel-import-guardrails.test.ts | 11 +- .../turn/message-turn-guardrails.test.ts | 11 +- .../legacy-config-write-ownership.test.ts | 11 +- src/docs/channel-config-examples.test.ts | 11 +- src/docs/plugin-doc-examples.test.ts | 11 +- src/infra/fs-safe-import-boundary.test.ts | 11 +- .../outbound/cfg-threading.guard.test.ts | 15 +-- .../bundled-capability-metadata.test.ts | 11 +- src/plugins/bundled-plugin-metadata.test.ts | 20 ++-- src/plugins/bundled-plugin-naming.test.ts | 69 ++++++------ .../contracts/boundary-invariants.test.ts | 22 ++-- .../core-extension-facade-boundary.test.ts | 11 +- ...tension-package-project-boundaries.test.ts | 11 +- ...sion-runtime-dependencies.contract.test.ts | 11 +- ...in-sdk-package-contract-guardrails.test.ts | 18 ++- .../contracts/plugin-sdk-subpaths.test.ts | 24 ++-- .../contracts/plugin-tool-contracts.test.ts | 11 +- ...vider-catalog-deprecation.contract.test.ts | 11 +- .../provider-family-plugin-tests.test.ts | 11 +- .../npm-install-security-scan.release.test.ts | 15 ++- src/plugins/runtime-registry-boundary.test.ts | 14 +-- .../test-helpers/archive-fixtures.test.ts | 11 +- src/test-utils/fs-scan-assertions.ts | 103 +++++++++++++++++ src/tools/boundary.test.ts | 15 +-- test/extension-test-boundary.test.ts | 11 +- .../bundled-plugin-build-entries.test.ts | 49 +++----- .../bundled-plugin-source-utils.test.ts | 58 +++------- .../channel-contract-test-plan.test.ts | 51 ++------- test/scripts/ci-node-test-plan.test.ts | 54 +++------ test/scripts/lint-suppressions.test.ts | 11 +- .../scripts/plugin-contract-test-plan.test.ts | 51 ++------- test/scripts/prompt-snapshots.test.ts | 11 +- test/scripts/runtime-postbuild.test.ts | 47 ++------ .../test-built-status-message-runtime.test.ts | 11 +- test/scripts/test-extension.test.ts | 106 +++++------------- test/scripts/test-live-shard.test.ts | 11 +- test/scripts/test-projects.test.ts | 19 ++-- 38 files changed, 384 insertions(+), 588 deletions(-) create mode 100644 src/test-utils/fs-scan-assertions.ts diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 31637bebde1..f8353d7fd29 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; vi.mock("../../plugins/bundled-dir.js", async (importOriginal) => { const actual = await importOriginal(); @@ -194,18 +195,12 @@ describe("bundled channel entry shape guards", () => { const bundledPluginRoots = listSourceBundledPluginRoots(); it("lists source bundled plugin roots without in-process directory scans", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const roots = listSourceBundledPluginRoots(); expect(roots.length).toBeGreaterThan(0); - expect(roots.every((root) => path.dirname(root) === path.resolve("extensions"))).toBe( - true, - ); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + expect(roots.every((root) => path.dirname(root) === path.resolve("extensions"))).toBe(true); + }); }); it("treats missing bundled discovery results as empty", async () => { diff --git a/src/channels/plugins/contracts/channel-import-guardrails.test.ts b/src/channels/plugins/contracts/channel-import-guardrails.test.ts index 9978064bdb5..b260ef10b15 100644 --- a/src/channels/plugins/contracts/channel-import-guardrails.test.ts +++ b/src/channels/plugins/contracts/channel-import-guardrails.test.ts @@ -2,10 +2,11 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { classifyBundledExtensionSourcePath } from "../../../../scripts/lib/extension-source-classifier.mjs"; import { GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES } from "../../../plugin-sdk/test-helpers/public-artifacts.js"; import { loadPluginManifestRegistry } from "../../../plugins/manifest-registry.js"; +import { expectNoReaddirSyncDuring } from "../../../test-utils/fs-scan-assertions.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const REPO_ROOT = resolve(ROOT_DIR, ".."); @@ -589,8 +590,7 @@ function expectCoreSourceStaysOffPluginSpecificSdkFacades(file: string, imports: describe("channel import guardrails", () => { it("lists channel import guardrail sources from git without walking roots", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const extensionSources = collectExtensionSourceFiles(); const coreSources = collectCoreSourceFiles(); const telegramSources = collectExtensionFiles("telegram"); @@ -598,10 +598,7 @@ describe("channel import guardrails", () => { expect(extensionSources.length).toBeGreaterThan(0); expect(coreSources.length).toBeGreaterThan(0); expect(telegramSources.length).toBeGreaterThan(0); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps channel helper modules off their own SDK barrels", () => { diff --git a/src/channels/turn/message-turn-guardrails.test.ts b/src/channels/turn/message-turn-guardrails.test.ts index ca876ba7ba4..bf10b19ed6a 100644 --- a/src/channels/turn/message-turn-guardrails.test.ts +++ b/src/channels/turn/message-turn-guardrails.test.ts @@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -135,17 +136,13 @@ function collectReplyHistoryBindings(source: string): Set { describe("message turn migration guardrails", () => { it("lists plugin TypeScript files from git without walking extension roots", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = listTsFiles("extensions"); expect(files.length).toBeGreaterThan(0); expect(files.every((file) => file.startsWith("extensions/"))).toBe(true); expect(files.some((file) => file.endsWith(".d.ts"))).toBe(false); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps migrated message paths off low-level reply-history helpers", () => { diff --git a/src/commands/doctor/shared/legacy-config-write-ownership.test.ts b/src/commands/doctor/shared/legacy-config-write-ownership.test.ts index b992711e113..d77d031e01d 100644 --- a/src/commands/doctor/shared/legacy-config-write-ownership.test.ts +++ b/src/commands/doctor/shared/legacy-config-write-ownership.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../../test-utils/fs-scan-assertions.js"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../../.."); const SRC_ROOT = path.join(REPO_ROOT, "src"); @@ -140,16 +141,12 @@ function collectViolations(files: string[]): string[] { describe("legacy config write ownership", () => { it("lists ownership scan files without scanning source directories in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = collectSourceFiles(SRC_ROOT); expect(files.length).toBeGreaterThan(0); expect(files.every(isOwnedSourceFile)).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps legacy config repair flags and migration modules under doctor", () => { diff --git a/src/docs/channel-config-examples.test.ts b/src/docs/channel-config-examples.test.ts index 9448bdfbf67..9ee8e895bc4 100644 --- a/src/docs/channel-config-examples.test.ts +++ b/src/docs/channel-config-examples.test.ts @@ -2,8 +2,9 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import JSON5 from "json5"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { OpenClawSchema } from "../config/zod-schema.js"; +import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; const CHANNEL_DOCS_DIR = path.join(process.cwd(), "docs", "channels"); @@ -68,16 +69,12 @@ function listFindChannelDocFiles(): string[] | null { describe("channel docs config examples", () => { it("lists channel docs without scanning the docs directory in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = listChannelDocFiles(); expect(files.length).toBeGreaterThan(0); expect(files.every((filePath) => filePath.endsWith(".md"))).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps channel docs JSON fences parseable", () => { diff --git a/src/docs/plugin-doc-examples.test.ts b/src/docs/plugin-doc-examples.test.ts index 6dd119ddf2c..de81a44d42a 100644 --- a/src/docs/plugin-doc-examples.test.ts +++ b/src/docs/plugin-doc-examples.test.ts @@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import JSON5 from "json5"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; const PLUGIN_DOCS_DIR = path.join(process.cwd(), "docs", "plugins"); @@ -73,16 +74,12 @@ function walkMarkdownFiles(dir: string): string[] { describe("plugin docs examples", () => { it("lists plugin docs without scanning directories in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = listMarkdownFiles(PLUGIN_DOCS_DIR); expect(files.length).toBeGreaterThan(0); expect(files.every((filePath) => filePath.endsWith(".md"))).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps plugin docs JSON fences parseable", () => { diff --git a/src/infra/fs-safe-import-boundary.test.ts b/src/infra/fs-safe-import-boundary.test.ts index 3bdfe96df02..1ca32ae20f5 100644 --- a/src/infra/fs-safe-import-boundary.test.ts +++ b/src/infra/fs-safe-import-boundary.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); const SCAN_ROOTS = ["src", "packages", "extensions"] as const; @@ -87,16 +88,12 @@ function toRepoPath(filePath: string): string { describe("fs-safe import boundary", () => { it("lists source files without scanning boundary roots in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = SCAN_ROOTS.flatMap((root) => listSourceFiles(path.join(REPO_ROOT, root))); expect(files.length).toBeGreaterThan(0); expect(files.every(isSourceFile)).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps direct fs-safe imports behind OpenClaw policy wrappers", () => { diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index 82120ac0353..c87a32cf76c 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -3,7 +3,8 @@ import fs, { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; const thisFilePath = fileURLToPath(import.meta.url); const thisDir = path.dirname(thisFilePath); @@ -262,22 +263,18 @@ function extractOutboundBlock(source: string, file: string): string { describe("outbound cfg-threading guard", () => { it("lists outbound entrypoints without scanning directories in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const coreAdapterFiles = listCoreOutboundEntryFiles(); const extensionFiles = listExtensionFiles(); expect(coreAdapterFiles.length).toBeGreaterThan(0); expect(extensionFiles.adapterEntrypoints.length).toBeGreaterThan(0); expect( - coreAdapterFiles.every((file) => - file.startsWith("src/channels/plugins/outbound/") && file.endsWith(".ts"), + coreAdapterFiles.every( + (file) => file.startsWith("src/channels/plugins/outbound/") && file.endsWith(".ts"), ), ).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps outbound adapter entrypoints free of getRuntimeConfig calls", () => { diff --git a/src/plugins/bundled-capability-metadata.test.ts b/src/plugins/bundled-capability-metadata.test.ts index 56605312b67..a847043fe5d 100644 --- a/src/plugins/bundled-capability-metadata.test.ts +++ b/src/plugins/bundled-capability-metadata.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; import { normalizeBundledPluginStringList } from "./bundled-plugin-scan.js"; import { BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS, @@ -69,16 +70,12 @@ function readManifestRecords(): PluginManifest[] { describe("bundled capability metadata", () => { it("lists bundled extension packages from git without scanning extension dirs", () => { const extensionsDir = path.join(repoRoot, "extensions"); - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const packagePaths = listExtensionPackagePaths(extensionsDir); expect(packagePaths.length).toBeGreaterThan(0); expect(packagePaths.every((file) => file.endsWith("package.json"))).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps contract snapshots aligned with bundled plugin manifests", () => { diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index b7e43f067f3..3cfbb4cfe75 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; import { collectBundledChannelConfigs } from "./bundled-channel-config-metadata.js"; import { type BundledPluginMetadata, @@ -132,11 +133,10 @@ function listRepoBundledPluginMetadata(): readonly BundledPluginMetadata[] { function listRepoBundledPluginManifestsUncached() { const bundledPluginsDir = path.join(repoRoot, "extensions"); - return listRepoBundledPluginManifestDirs() - .flatMap((dirName) => { - const result = loadPluginManifest(path.join(bundledPluginsDir, dirName), false); - return result.ok ? [{ dirName, manifest: result.manifest }] : []; - }); + return listRepoBundledPluginManifestDirs().flatMap((dirName) => { + const result = loadPluginManifest(path.join(bundledPluginsDir, dirName), false); + return result.ok ? [{ dirName, manifest: result.manifest }] : []; + }); } function listRepoBundledPluginManifestDirs(): string[] { @@ -335,16 +335,12 @@ function createInstalledPluginIndexForManifests( describe("bundled plugin metadata", () => { it("lists bundled plugin manifests without scanning extension directories in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const manifests = listRepoBundledPluginManifestsUncached(); expect(manifests.length).toBeGreaterThan(0); expect(manifests.every((entry) => entry.dirName.length > 0)).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it( diff --git a/src/plugins/bundled-plugin-naming.test.ts b/src/plugins/bundled-plugin-naming.test.ts index de5be7b9b5c..915f9d82d01 100644 --- a/src/plugins/bundled-plugin-naming.test.ts +++ b/src/plugins/bundled-plugin-naming.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; type PluginManifestShape = { id?: unknown; @@ -64,9 +65,7 @@ function listBundledPluginDirs(): string[] { } function listExternalBundledPluginDirs(): string[] | null { - const files = - listGitPluginMetadataFiles() ?? - listFindPluginMetadataFiles(); + const files = listGitPluginMetadataFiles() ?? listFindPluginMetadataFiles(); if (!files) { return null; } @@ -84,8 +83,9 @@ function listExternalBundledPluginDirs(): string[] | null { } return [...metadataByDir.entries()] - .filter(([, metadataFiles]) => - metadataFiles.has("package.json") && metadataFiles.has("openclaw.plugin.json"), + .filter( + ([, metadataFiles]) => + metadataFiles.has("package.json") && metadataFiles.has("openclaw.plugin.json"), ) .map(([dirName]) => dirName) .toSorted(); @@ -148,33 +148,32 @@ function listFindPluginMetadataFiles(): string[] | null { } function readBundledPluginRecords(): BundledPluginRecord[] { - return listBundledPluginDirs() - .flatMap((dirName) => { - const rootDir = path.join(EXTENSIONS_ROOT, dirName); - const packagePath = path.join(rootDir, "package.json"); - const manifestPath = path.join(rootDir, "openclaw.plugin.json"); - if (!fs.existsSync(packagePath) || !fs.existsSync(manifestPath)) { - return []; - } + return listBundledPluginDirs().flatMap((dirName) => { + const rootDir = path.join(EXTENSIONS_ROOT, dirName); + const packagePath = path.join(rootDir, "package.json"); + const manifestPath = path.join(rootDir, "openclaw.plugin.json"); + if (!fs.existsSync(packagePath) || !fs.existsSync(manifestPath)) { + return []; + } - const manifest = readJsonFile(manifestPath); - const pkg = readJsonFile(packagePath); - const manifestId = normalizeText(manifest.id); - const packageName = normalizeText(pkg.name); - if (!manifestId || !packageName) { - return []; - } + const manifest = readJsonFile(manifestPath); + const pkg = readJsonFile(packagePath); + const manifestId = normalizeText(manifest.id); + const packageName = normalizeText(pkg.name); + if (!manifestId || !packageName) { + return []; + } - return [ - { - dirName, - packageName, - manifestId, - installNpmSpec: normalizeText(pkg.openclaw?.install?.npmSpec), - channelId: normalizeText(pkg.openclaw?.channel?.id), - }, - ]; - }); + return [ + { + dirName, + packageName, + manifestId, + installNpmSpec: normalizeText(pkg.openclaw?.install?.npmSpec), + channelId: normalizeText(pkg.openclaw?.channel?.id), + }, + ]; + }); } function resolveAllowedPackageNamesForId(pluginId: string): string[] { @@ -200,16 +199,12 @@ function expectNoBundledPluginNamingMismatches(params: { describe("bundled plugin naming guardrails", () => { it("lists bundled plugin metadata without scanning extension directories in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const records = readBundledPluginRecords(); expect(records.length).toBeGreaterThan(0); expect(records.every((record) => record.dirName.length > 0)).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it.each([ diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 04689654ae1..27dc4e17165 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -3,7 +3,8 @@ import fs, { readFileSync } from "node:fs"; import { dirname, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(SRC_ROOT, ".."); @@ -164,7 +165,9 @@ function listTsFiles(rootRelativePath: string, filter: FileFilter = {}): string[ } function listExternalTsFiles(rootRelativePath: string, filter: FileFilter): string[] | null { - return listGitTrackedTsFiles(rootRelativePath, filter) ?? listFindTsFiles(rootRelativePath, filter); + return ( + listGitTrackedTsFiles(rootRelativePath, filter) ?? listFindTsFiles(rootRelativePath, filter) + ); } function listGitTrackedTsFiles(rootRelativePath: string, filter: FileFilter): string[] | null { @@ -296,17 +299,16 @@ function collectTypedHookNames(source: string): string[] { describe("plugin contract boundary invariants", () => { it("lists boundary invariant source files without walking roots in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); try { - tsFilesCache.clear(); - const files = listTsFiles("src", { excludeTests: true }); + expectNoReaddirSyncDuring(() => { + tsFilesCache.clear(); + const files = listTsFiles("src", { excludeTests: true }); - expect(files.length).toBeGreaterThan(0); - expect(files.every((file) => file.startsWith("src/") && file.endsWith(".ts"))).toBe(true); - expect(files.some((file) => file.endsWith(".test.ts"))).toBe(false); - expect(readDir).not.toHaveBeenCalled(); + expect(files.length).toBeGreaterThan(0); + expect(files.every((file) => file.startsWith("src/") && file.endsWith(".ts"))).toBe(true); + expect(files.some((file) => file.endsWith(".test.ts"))).toBe(false); + }); } finally { - readDir.mockRestore(); tsFilesCache.clear(); } }); diff --git a/src/plugins/contracts/core-extension-facade-boundary.test.ts b/src/plugins/contracts/core-extension-facade-boundary.test.ts index cfb2686831b..54a9624a0f8 100644 --- a/src/plugins/contracts/core-extension-facade-boundary.test.ts +++ b/src/plugins/contracts/core-extension-facade-boundary.test.ts @@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; const repoRoot = fileURLToPath(new URL("../../..", import.meta.url)); const srcRoot = path.join(repoRoot, "src"); @@ -74,16 +75,12 @@ function toRepoRelative(filePath: string): string { describe("core extension facade boundary", () => { it("lists core facade boundary sources from git without walking src", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = collectSourceFiles(srcRoot); expect(files.length).toBeGreaterThan(0); expect(files.some((file) => file.includes("/plugin-sdk/"))).toBe(false); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("does not expose Ollama plugin facades from core plugin-sdk", () => { diff --git a/src/plugins/contracts/extension-package-project-boundaries.test.ts b/src/plugins/contracts/extension-package-project-boundaries.test.ts index d64d561606f..a28ab6570ae 100644 --- a/src/plugins/contracts/extension-package-project-boundaries.test.ts +++ b/src/plugins/contracts/extension-package-project-boundaries.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { relative, resolve } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { collectExtensionsWithTsconfig, collectOptInExtensionPackageBoundaries, @@ -13,6 +13,7 @@ import { readExtensionPackageBoundaryPackageJson, readExtensionPackageBoundaryTsconfig, } from "../../../scripts/lib/extension-package-boundary.ts"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; const REPO_ROOT = resolve(import.meta.dirname, "../../.."); const EXTENSION_PACKAGE_BOUNDARY_PATHS_CONFIG = @@ -136,17 +137,13 @@ function collectOpenClawRuntimeDirectImportFiles(relativeDir: string): string[] describe("opt-in extension package boundaries", () => { it("lists package boundary code files from git without walking package roots", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const memoryHostFiles = collectCodeFiles("packages/memory-host-sdk/src"); const packageContractFiles = collectCodeFiles("packages/plugin-package-contract/src"); expect(memoryHostFiles.length).toBeGreaterThan(0); expect(packageContractFiles.length).toBeGreaterThan(0); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps path aliases in a dedicated shared config", () => { diff --git a/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts b/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts index a8439434227..bbb9679b62d 100644 --- a/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts +++ b/src/plugins/contracts/extension-runtime-dependencies.contract.test.ts @@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { builtinModules } from "node:module"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; const EXTENSION_ROOT = "extensions"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); @@ -276,17 +277,13 @@ describe("Discord dependency ownership", () => { describe("extension runtime dependency manifests", () => { it("lists extension dependency inputs from git without walking extension dirs", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const manifests = listPackageManifests(EXTENSION_ROOT); const runtimeFiles = listRuntimeFiles("extensions/discord"); expect(manifests.length).toBeGreaterThan(0); expect(runtimeFiles.length).toBeGreaterThan(0); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps json5 in memory-core for packaged runtime config parsing", () => { diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index b45c091c6f0..f23f2ae9f11 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { deprecatedBarrelPluginSdkEntrypoints, deprecatedPublicPluginSdkEntrypoints, @@ -13,6 +13,7 @@ import { reservedBundledPluginSdkEntrypoints, supportedBundledFacadeSdkEntrypoints, } from "../../plugin-sdk/entrypoints.js"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(ROOT_DIR, ".."); @@ -452,10 +453,9 @@ function collectDeprecatedTestBarrelImports(): Array<{ file: string; specifier: } function collectDeprecatedPackageTestingBridgeDrift(): string[] { - const source = fs.readFileSync( - resolve(REPO_ROOT, "packages/plugin-sdk/src/testing.ts"), - "utf8", - ).trim(); + const source = fs + .readFileSync(resolve(REPO_ROOT, "packages/plugin-sdk/src/testing.ts"), "utf8") + .trim(); return source === 'export * from "../../../src/plugin-sdk/testing.js";' ? [] : ["packages/plugin-sdk/src/testing.ts"]; @@ -665,8 +665,7 @@ function collectExtensionProductionSdkSubpathImports(subpaths: ReadonlySet { it("lists package guardrail scan inputs from git without walking roots", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const pluginIds = collectBundledPluginIds(); const extensionFiles = collectExtensionFiles(resolve(REPO_ROOT, "extensions")); const workspaceFiles = collectWorkspaceCodeFiles(); @@ -674,10 +673,7 @@ describe("plugin-sdk package contract guardrails", () => { expect(pluginIds.length).toBeGreaterThan(0); expect(extensionFiles.length).toBeGreaterThan(0); expect(workspaceFiles.length).toBeGreaterThan(extensionFiles.length); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps plugin-sdk entrypoint metadata unique", () => { diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index fe9372b1d14..d4453810fec 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -23,7 +23,7 @@ import type { } from "openclaw/plugin-sdk/core"; import * as providerEntrySdk from "openclaw/plugin-sdk/provider-entry"; import ts from "typescript"; -import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../../channels/plugins/types.js"; import type { BaseProbeResult, @@ -50,6 +50,7 @@ import * as coreDirectSdk from "../../plugin-sdk/core.js"; import { publicPluginSdkSubpaths as pluginSdkSubpaths } from "../../plugin-sdk/entrypoints.js"; import * as globalSingletonDirectSdk from "../../plugin-sdk/global-singleton.js"; import * as providerEntryDirectSdk from "../../plugin-sdk/provider-entry.js"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; import type { PluginRuntime } from "../runtime/types.js"; import type { OpenClawPluginApi } from "../types.js"; @@ -327,7 +328,9 @@ function expectNamedExportParity(params: BrowserHelperExportParityContract) { } function listTrackedRepoTsFiles(dir: string): string[] | null { - const relativeDir = relative(REPO_ROOT, dir).split(/[\\/]+/u).join("/"); + const relativeDir = relative(REPO_ROOT, dir) + .split(/[\\/]+/u) + .join("/"); if (!relativeDir || relativeDir.startsWith("..")) { return null; } @@ -344,9 +347,7 @@ function listTrackedRepoTsFiles(dir: string): string[] | null { .map((line) => line.trim().replaceAll("\\", "/")) .filter( (line) => - line.endsWith(".ts") && - !line.includes("/dist/") && - !line.includes("/node_modules/"), + line.endsWith(".ts") && !line.includes("/dist/") && !line.includes("/node_modules/"), ) .map((line) => resolve(REPO_ROOT, ...line.split("/"))) .toSorted(); @@ -825,16 +826,15 @@ describe("plugin-sdk subpath exports", () => { }); it("lists repo source candidates from git without walking SDK boundary roots", () => { - const readDir = vi.spyOn(fs, "readdirSync"); try { - repoTsFilesCache.clear(); - const files = listRepoTsFiles(resolve(REPO_ROOT, "src")); + expectNoReaddirSyncDuring(() => { + repoTsFilesCache.clear(); + const files = listRepoTsFiles(resolve(REPO_ROOT, "src")); - expect(files.length).toBeGreaterThan(0); - expect(files.every((file) => file.endsWith(".ts"))).toBe(true); - expect(readDir).not.toHaveBeenCalled(); + expect(files.length).toBeGreaterThan(0); + expect(files.every((file) => file.endsWith(".ts"))).toBe(true); + }); } finally { - readDir.mockRestore(); repoTsFilesCache.clear(); } }); diff --git a/src/plugins/contracts/plugin-tool-contracts.test.ts b/src/plugins/contracts/plugin-tool-contracts.test.ts index 293849705e8..ef5a1824583 100644 --- a/src/plugins/contracts/plugin-tool-contracts.test.ts +++ b/src/plugins/contracts/plugin-tool-contracts.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; type PluginManifestFile = { id?: unknown; @@ -275,8 +276,7 @@ function normalizeManifestTools(value: unknown): string[] { describe("bundled plugin tool manifest contracts", () => { it("lists plugin tool contract inputs from git without walking extension roots", () => { const extensionsDir = path.join(process.cwd(), "extensions"); - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const manifestPaths = listPluginManifestPaths(extensionsDir); const sourceFiles = manifestPaths.flatMap((manifestPath) => walkFiles(path.dirname(manifestPath)).filter(isProductionSource), @@ -284,10 +284,7 @@ describe("bundled plugin tool manifest contracts", () => { expect(manifestPaths.length).toBeGreaterThan(0); expect(sourceFiles.length).toBeGreaterThan(0); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("declares every production registerTool owner in contracts.tools", () => { diff --git a/src/plugins/contracts/provider-catalog-deprecation.contract.test.ts b/src/plugins/contracts/provider-catalog-deprecation.contract.test.ts index 1129f9b1d74..6a68011596c 100644 --- a/src/plugins/contracts/provider-catalog-deprecation.contract.test.ts +++ b/src/plugins/contracts/provider-catalog-deprecation.contract.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; const repoRoot = path.resolve(import.meta.dirname, "../../.."); const extensionsRoot = path.join(repoRoot, "extensions"); @@ -143,8 +144,7 @@ function lineNumberFor(source: string, offset: number): number { describe("bundled provider catalog deprecation guard", () => { it("lists production extension sources from git without walking extension roots", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = walkProductionSourceFiles(extensionsRoot); expect(files.length).toBeGreaterThan(0); @@ -152,10 +152,7 @@ describe("bundled provider catalog deprecation guard", () => { true, ); expect(files.some((file) => file.endsWith(".test.ts"))).toBe(false); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps bundled provider plugins off the deprecated discovery hook", () => { diff --git a/src/plugins/contracts/provider-family-plugin-tests.test.ts b/src/plugins/contracts/provider-family-plugin-tests.test.ts index abf64b2ddfd..9e594b2300a 100644 --- a/src/plugins/contracts/provider-family-plugin-tests.test.ts +++ b/src/plugins/contracts/provider-family-plugin-tests.test.ts @@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { basename, dirname, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; type SharedFamilyHookKind = "replay" | "stream" | "tool-compat"; @@ -217,16 +218,12 @@ function collectSharedFamilyAssignments(): Map { it("lists bundled plugin files from git without walking plugin roots", () => { const bundledRoots = listBundledPluginRoots(); - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = bundledRoots.flatMap((plugin) => listFiles(plugin.rootDir)); expect(files.length).toBeGreaterThan(0); expect(files.some((file) => toRepoRelative(file).startsWith("extensions/"))).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps shared-family provider hooks covered by at least one plugin-boundary test", () => { diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index 56fccf2041d..53f82f70682 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -3,8 +3,9 @@ import fs, { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from " import { tmpdir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { promisify } from "node:util"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { isScannable, scanDirectoryWithSummary } from "../security/skill-scanner.js"; +import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; type NpmPackFile = { path?: unknown; @@ -294,18 +295,16 @@ async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): P describe("publishable plugin npm package install security scan", () => { it("lists publishable plugin packages without scanning extension directories in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const packages = collectPublishablePluginPackages(); expect(packages.length).toBeGreaterThan(0); expect( - packages.every((plugin) => plugin.packageDir.split(sep).join("/").startsWith("extensions/")), + packages.every((plugin) => + plugin.packageDir.split(sep).join("/").startsWith("extensions/"), + ), ).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("keeps npm-published plugin files clear of unexpected critical hits", async () => { diff --git a/src/plugins/runtime-registry-boundary.test.ts b/src/plugins/runtime-registry-boundary.test.ts index 8715ccee9b6..60f3805de38 100644 --- a/src/plugins/runtime-registry-boundary.test.ts +++ b/src/plugins/runtime-registry-boundary.test.ts @@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process"; import fs, { readFileSync } from "node:fs"; import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoFsSyncDuring } from "../test-utils/fs-scan-assertions.js"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const allowedRuntimeResolverRefs = new Set([ @@ -93,19 +94,12 @@ function toPosix(value: string): string { describe("runtime plugin registry boundary", () => { it("lists source files without scanning src in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - const stat = vi.spyOn(fs, "statSync"); - try { + expectNoFsSyncDuring(() => { const files = listSourceFiles(resolve(repoRoot, "src")); expect(files.length).toBeGreaterThan(0); expect(files.every(isProductionTypeScriptFile)).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - expect(stat).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - stat.mockRestore(); - } + }, ["readdirSync", "statSync"]); }); it("keeps runtime registry resolution behind the loader boundary", () => { diff --git a/src/plugins/test-helpers/archive-fixtures.test.ts b/src/plugins/test-helpers/archive-fixtures.test.ts index 12c1fb328ac..c6ba76dbe55 100644 --- a/src/plugins/test-helpers/archive-fixtures.test.ts +++ b/src/plugins/test-helpers/archive-fixtures.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { afterAll, describe, expect, it, vi } from "vitest"; +import { afterAll, describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js"; import { listFlatRootArchiveEntries } from "./archive-fixtures.js"; import { createSuiteTempRootTracker } from "./fs-fixtures.js"; @@ -19,13 +20,9 @@ describe("archive fixture helpers", () => { fs.mkdirSync(path.join(pkgDir, "dist")); fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {}\n"); - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { expect(listFlatRootArchiveEntries(pkgDir)).toEqual(["dist", "package.json"]); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }, ); }); diff --git a/src/test-utils/fs-scan-assertions.ts b/src/test-utils/fs-scan-assertions.ts new file mode 100644 index 00000000000..25e5df15ec5 --- /dev/null +++ b/src/test-utils/fs-scan-assertions.ts @@ -0,0 +1,103 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import { expect, vi } from "vitest"; + +type FsScanCounter = "existsSync" | "readdirSync" | "statSync"; + +type NodeFsScanResult = { + counts: Partial>; + result: T; +}; + +export function expectNoReaddirSyncDuring(run: () => T): T { + return expectNoFsSyncDuring(run, ["readdirSync"]); +} + +export function expectNoFsSyncDuring(run: () => T, counters: FsScanCounter[]): T { + const spies = counters.map((counter) => { + switch (counter) { + case "existsSync": + return vi.spyOn(fs, "existsSync"); + case "readdirSync": + return vi.spyOn(fs, "readdirSync"); + case "statSync": + return vi.spyOn(fs, "statSync"); + default: { + counter satisfies never; + throw new Error("Unsupported fs scan counter"); + } + } + }); + try { + const result = run(); + for (const spy of spies) { + expect(spy).not.toHaveBeenCalled(); + } + return result; + } finally { + for (const spy of spies) { + spy.mockRestore(); + } + } +} + +export function captureReaddirSyncCallsDuring(run: () => T): { + calls: unknown[][]; + result: T; +} { + const readDir = vi.spyOn(fs, "readdirSync"); + try { + const before = readDir.mock.calls.length; + const result = run(); + return { calls: readDir.mock.calls.slice(before), result }; + } finally { + readDir.mockRestore(); + } +} + +export function expectNoNodeFsScans( + body: string, + options?: { + counters?: FsScanCounter[]; + parseResult?: (result: unknown) => T; + }, +): T { + const counters = options?.counters ?? ["existsSync", "readdirSync"]; + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + ` + import fs from "node:fs"; + import { syncBuiltinESMExports } from "node:module"; + const counts = ${JSON.stringify(Object.fromEntries(counters.map((name) => [name, 0])))}; + ${counters + .map( + (name) => ` + const ${name}Original = fs.${name}; + fs.${name} = (...args) => { + counts.${name} += 1; + return ${name}Original(...args); + };`, + ) + .join("\n")} + syncBuiltinESMExports(); + const result = await (async () => { + ${body} + })(); + console.log(JSON.stringify({ counts, result })); + `, + ], + { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + expect(result.status, result.stderr).toBe(0); + const payload = JSON.parse(result.stdout) as NodeFsScanResult; + expect(payload.counts).toEqual(Object.fromEntries(counters.map((name) => [name, 0]))); + return options?.parseResult ? options.parseResult(payload.result) : (payload.result as T); +} diff --git a/src/tools/boundary.test.ts b/src/tools/boundary.test.ts index 45469f7968a..898c006ec09 100644 --- a/src/tools/boundary.test.ts +++ b/src/tools/boundary.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import fs, { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; const toolsDir = new URL("./", import.meta.url); const toolsDirPath = fileURLToPath(toolsDir); @@ -92,18 +93,12 @@ function listFindProductionToolModuleFiles(): string[] | null { describe("tool system boundary", () => { it("lists production tool modules without scanning the tools directory in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = listProductionToolModuleFiles(); expect(files.length).toBeGreaterThan(0); - expect(files.every((file) => file.endsWith(".ts") && !file.endsWith(".test.ts"))).toBe( - true, - ); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + expect(files.every((file) => file.endsWith(".ts") && !file.endsWith(".test.ts"))).toBe(true); + }); }); it("keeps production tool modules independent from OpenClaw subsystems", () => { diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts index 44e417f9346..92f29d7fb39 100644 --- a/test/extension-test-boundary.test.ts +++ b/test/extension-test-boundary.test.ts @@ -2,8 +2,9 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { BUNDLED_PLUGIN_PATH_PREFIX } from "openclaw/plugin-sdk/test-fixtures"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES } from "../src/plugin-sdk/test-helpers/public-artifacts.js"; +import { expectNoReaddirSyncDuring } from "../src/test-utils/fs-scan-assertions.js"; const repoRoot = path.resolve(import.meta.dirname, ".."); const ALLOWED_EXTENSION_PUBLIC_SURFACE_BASENAMES = new Set( @@ -197,8 +198,7 @@ function isAllowedCoreContractSuite(file: string, imports: readonly string[]): b describe("non-extension test boundaries", () => { it("lists boundary scan files from git without walking repo roots", () => { - const readdirSync = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const srcTests = walk(path.join(repoRoot, "src")); const srcCode = walkCode(path.join(repoRoot, "src")); const pluginIds = collectBundledPluginIds(); @@ -206,10 +206,7 @@ describe("non-extension test boundaries", () => { expect(srcTests.length).toBeGreaterThan(0); expect(srcCode.length).toBeGreaterThan(0); expect(pluginIds.size).toBeGreaterThan(0); - expect(readdirSync).not.toHaveBeenCalled(); - } finally { - readdirSync.mockRestore(); - } + }); }); it("keeps plugin-owned behavior suites under the bundled plugin tree", () => { diff --git a/test/scripts/bundled-plugin-build-entries.test.ts b/test/scripts/bundled-plugin-build-entries.test.ts index f15432a8f90..9def93f4565 100644 --- a/test/scripts/bundled-plugin-build-entries.test.ts +++ b/test/scripts/bundled-plugin-build-entries.test.ts @@ -1,4 +1,3 @@ -import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -7,6 +6,7 @@ import { listBundledPluginBuildEntries, listBundledPluginPackArtifacts, } from "../../scripts/lib/bundled-plugin-build-entries.mjs"; +import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js"; function expectNoPrefixMatches(values: string[], prefix: string) { expect(values.filter((value) => value.startsWith(prefix))).toEqual([]); @@ -76,45 +76,24 @@ describe("bundled plugin build entries", () => { }); it("discovers repo plugin build entries without directory scans", () => { - const output = execFileSync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import fs from "node:fs"; - import { syncBuiltinESMExports } from "node:module"; - const counts = { readdirSync: 0 }; - const originalReaddirSync = fs.readdirSync; - fs.readdirSync = (...args) => { - counts.readdirSync += 1; - return originalReaddirSync(...args); - }; - syncBuiltinESMExports(); - const build = await import("./scripts/lib/bundled-plugin-build-entries.mjs"); - const entries = build.listBundledPluginBuildEntries(); - const artifacts = build.listBundledPluginPackArtifacts(); - console.log(JSON.stringify({ - artifacts: artifacts.length, - counts, - entries: Object.keys(entries).length, - })); - `, - ], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); - const payload = JSON.parse(output) as { + const payload = expectNoNodeFsScans<{ artifacts: number; - counts: { readdirSync: number }; entries: number; - }; + }>( + ` + const build = await import("./scripts/lib/bundled-plugin-build-entries.mjs"); + const entries = build.listBundledPluginBuildEntries(); + const artifacts = build.listBundledPluginPackArtifacts(); + return { + artifacts: artifacts.length, + entries: Object.keys(entries).length, + }; + `, + { counters: ["readdirSync"] }, + ); expect(payload.entries).toBeGreaterThan(0); expect(payload.artifacts).toBeGreaterThan(0); - expect(payload.counts.readdirSync).toBe(0); }); it("packs runtime core support packages without requiring plugin manifests", () => { diff --git a/test/scripts/bundled-plugin-source-utils.test.ts b/test/scripts/bundled-plugin-source-utils.test.ts index 008fee422b4..1e22b9991c0 100644 --- a/test/scripts/bundled-plugin-source-utils.test.ts +++ b/test/scripts/bundled-plugin-source-utils.test.ts @@ -1,6 +1,6 @@ -import { execFileSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { collectBundledPluginSources } from "../../scripts/lib/bundled-plugin-source-utils.mjs"; +import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js"; describe("scripts/lib/bundled-plugin-source-utils.mjs", () => { it("collects bundled plugin sources with package metadata", () => { @@ -17,51 +17,23 @@ describe("scripts/lib/bundled-plugin-source-utils.mjs", () => { }); it("discovers repo bundled plugin sources without scanning extension directories", () => { - const output = execFileSync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import fs from "node:fs"; - import { syncBuiltinESMExports } from "node:module"; - const counts = { existsSync: 0, readdirSync: 0 }; - const originalExistsSync = fs.existsSync; - const originalReaddirSync = fs.readdirSync; - fs.existsSync = (...args) => { - counts.existsSync += 1; - return originalExistsSync(...args); - }; - fs.readdirSync = (...args) => { - counts.readdirSync += 1; - return originalReaddirSync(...args); - }; - syncBuiltinESMExports(); - const utils = await import("./scripts/lib/bundled-plugin-source-utils.mjs"); - const sources = utils.collectBundledPluginSources({ - repoRoot: process.cwd(), - requirePackageJson: true, - }); - console.log(JSON.stringify({ - channels: sources.filter((source) => Array.isArray(source.manifest?.channels) && source.manifest.channels.length > 0).length, - counts, - sources: sources.length, - })); - `, - ], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); - - const payload = JSON.parse(output) as { + const payload = expectNoNodeFsScans<{ channels: number; - counts: { existsSync: number; readdirSync: number }; sources: number; - }; + }>(` + const utils = await import("./scripts/lib/bundled-plugin-source-utils.mjs"); + const sources = utils.collectBundledPluginSources({ + repoRoot: process.cwd(), + requirePackageJson: true, + }); + return { + channels: sources.filter( + (source) => Array.isArray(source.manifest?.channels) && source.manifest.channels.length > 0, + ).length, + sources: sources.length, + }; + `); expect(payload.sources).toBeGreaterThan(0); expect(payload.channels).toBeGreaterThan(0); - expect(payload.counts).toEqual({ existsSync: 0, readdirSync: 0 }); }); }); diff --git a/test/scripts/channel-contract-test-plan.test.ts b/test/scripts/channel-contract-test-plan.test.ts index cbe8c8f2172..56aa23fed65 100644 --- a/test/scripts/channel-contract-test-plan.test.ts +++ b/test/scripts/channel-contract-test-plan.test.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { createChannelContractTestShards } from "../../scripts/lib/channel-contract-test-plan.mjs"; +import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js"; function listContractTests(rootDir = "src/channels/plugins/contracts"): string[] { const result = spawnSync("git", ["ls-files", "--", rootDir], { @@ -45,51 +46,19 @@ describe("scripts/lib/channel-contract-test-plan.mjs", () => { }); it("uses git-tracked files without walking contract directories", () => { - const result = spawnSync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import fs from "node:fs"; - import { syncBuiltinESMExports } from "node:module"; - const counts = { existsSync: 0, readdirSync: 0 }; - const originalExistsSync = fs.existsSync; - const originalReaddirSync = fs.readdirSync; - fs.existsSync = (...args) => { - counts.existsSync += 1; - return originalExistsSync(...args); - }; - fs.readdirSync = (...args) => { - counts.readdirSync += 1; - return originalReaddirSync(...args); - }; - syncBuiltinESMExports(); - const { createChannelContractTestShards } = await import("./scripts/lib/channel-contract-test-plan.mjs"); - const shards = createChannelContractTestShards(); - console.log(JSON.stringify({ - counts, - files: shards.reduce((total, shard) => total + shard.includePatterns.length, 0), - shards: shards.length, - })); - `, - ], - { - cwd: process.cwd(), - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }, - ); - - expect(result.status, result.stderr).toBe(0); - const payload = JSON.parse(result.stdout) as { - counts: { existsSync: number; readdirSync: number }; + const payload = expectNoNodeFsScans<{ files: number; shards: number; - }; + }>(` + const { createChannelContractTestShards } = await import("./scripts/lib/channel-contract-test-plan.mjs"); + const shards = createChannelContractTestShards(); + return { + files: shards.reduce((total, shard) => total + shard.includePatterns.length, 0), + shards: shards.length, + }; + `); expect(payload.shards).toBe(3); expect(payload.files).toBeGreaterThan(0); - expect(payload.counts).toEqual({ existsSync: 0, readdirSync: 0 }); }); it("keeps registry-backed surface shards spread across checks", () => { diff --git a/test/scripts/ci-node-test-plan.test.ts b/test/scripts/ci-node-test-plan.test.ts index aed31bfa5bc..321f17e98c1 100644 --- a/test/scripts/ci-node-test-plan.test.ts +++ b/test/scripts/ci-node-test-plan.test.ts @@ -4,6 +4,7 @@ import { join, relative, resolve } from "node:path"; import fg from "fast-glob"; import { describe, expect, it } from "vitest"; import { createNodeTestShards } from "../../scripts/lib/ci-node-test-plan.mjs"; +import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js"; import { commandsLightTestFiles } from "../vitest/vitest.commands-light-paths.mjs"; import { createPluginsVitestConfig } from "../vitest/vitest.plugins.config.ts"; @@ -94,51 +95,22 @@ function isGatewayServerTestFile(file: string): boolean { describe("scripts/lib/ci-node-test-plan.mjs", () => { it("creates split shards without walking test roots", () => { - const result = spawnSync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import fs from "node:fs"; - import { syncBuiltinESMExports } from "node:module"; - const counts = { existsSync: 0, readdirSync: 0 }; - const originalExistsSync = fs.existsSync; - const originalReaddirSync = fs.readdirSync; - fs.existsSync = (...args) => { - counts.existsSync += 1; - return originalExistsSync(...args); - }; - fs.readdirSync = (...args) => { - counts.readdirSync += 1; - return originalReaddirSync(...args); - }; - syncBuiltinESMExports(); - const { createNodeTestShards } = await import("./scripts/lib/ci-node-test-plan.mjs"); - const shards = createNodeTestShards(); - console.log(JSON.stringify({ - counts, - includePatterns: shards.reduce((total, shard) => total + (shard.includePatterns?.length ?? 0), 0), - shards: shards.length, - })); - `, - ], - { - cwd: process.cwd(), - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }, - ); - - expect(result.status, result.stderr).toBe(0); - const payload = JSON.parse(result.stdout) as { - counts: { existsSync: number; readdirSync: number }; + const payload = expectNoNodeFsScans<{ includePatterns: number; shards: number; - }; + }>(` + const { createNodeTestShards } = await import("./scripts/lib/ci-node-test-plan.mjs"); + const shards = createNodeTestShards(); + return { + includePatterns: shards.reduce( + (total, shard) => total + (shard.includePatterns?.length ?? 0), + 0, + ), + shards: shards.length, + }; + `); expect(payload.shards).toBeGreaterThan(0); expect(payload.includePatterns).toBeGreaterThan(0); - expect(payload.counts).toEqual({ existsSync: 0, readdirSync: 0 }); }); it("splits the slow core unit shards while keeping paired source/security coverage", () => { diff --git a/test/scripts/lint-suppressions.test.ts b/test/scripts/lint-suppressions.test.ts index 02023a33ed0..1169c232778 100644 --- a/test/scripts/lint-suppressions.test.ts +++ b/test/scripts/lint-suppressions.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; +import { expectNoReaddirSyncDuring } from "../../src/test-utils/fs-scan-assertions.js"; const repoRoot = path.resolve(import.meta.dirname, "../.."); const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]); @@ -107,16 +108,12 @@ function summarizeSuppressions(entries: readonly SuppressionEntry[]): string[] { describe("production lint suppressions", () => { it("lists production files from git without walking source roots", () => { - const readdirSync = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = ROOTS.flatMap((root) => walkCodeFiles(path.join(repoRoot, root))).toSorted(); expect(files.length).toBeGreaterThan(0); expect(files.some((file) => file.endsWith(".test.ts"))).toBe(false); - expect(readdirSync).not.toHaveBeenCalled(); - } finally { - readdirSync.mockRestore(); - } + }); }); it("keeps the intentional production suppression tail on an explicit allowlist", () => { diff --git a/test/scripts/plugin-contract-test-plan.test.ts b/test/scripts/plugin-contract-test-plan.test.ts index 8447e12e1af..5653ee33ce3 100644 --- a/test/scripts/plugin-contract-test-plan.test.ts +++ b/test/scripts/plugin-contract-test-plan.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { createPluginContractTestShards } from "../../scripts/lib/plugin-contract-test-plan.mjs"; +import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js"; function listContractTests(rootDir = "src/plugins/contracts"): string[] { const result = spawnSync("git", ["ls-files", "--", rootDir], { @@ -58,51 +59,19 @@ describe("scripts/lib/plugin-contract-test-plan.mjs", () => { }); it("uses git-tracked files without walking contract directories", () => { - const result = spawnSync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import fs from "node:fs"; - import { syncBuiltinESMExports } from "node:module"; - const counts = { existsSync: 0, readdirSync: 0 }; - const originalExistsSync = fs.existsSync; - const originalReaddirSync = fs.readdirSync; - fs.existsSync = (...args) => { - counts.existsSync += 1; - return originalExistsSync(...args); - }; - fs.readdirSync = (...args) => { - counts.readdirSync += 1; - return originalReaddirSync(...args); - }; - syncBuiltinESMExports(); - const { createPluginContractTestShards } = await import("./scripts/lib/plugin-contract-test-plan.mjs"); - const shards = createPluginContractTestShards(); - console.log(JSON.stringify({ - counts, - files: shards.reduce((total, shard) => total + shard.includePatterns.length, 0), - shards: shards.length, - })); - `, - ], - { - cwd: process.cwd(), - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }, - ); - - expect(result.status, result.stderr).toBe(0); - const payload = JSON.parse(result.stdout) as { - counts: { existsSync: number; readdirSync: number }; + const payload = expectNoNodeFsScans<{ files: number; shards: number; - }; + }>(` + const { createPluginContractTestShards } = await import("./scripts/lib/plugin-contract-test-plan.mjs"); + const shards = createPluginContractTestShards(); + return { + files: shards.reduce((total, shard) => total + shard.includePatterns.length, 0), + shards: shards.length, + }; + `); expect(payload.shards).toBe(4); expect(payload.files).toBeGreaterThan(0); - expect(payload.counts).toEqual({ existsSync: 0, readdirSync: 0 }); }); it("keeps plugin registration contract files spread across checks", () => { diff --git a/test/scripts/prompt-snapshots.test.ts b/test/scripts/prompt-snapshots.test.ts index f139187a7fd..074cffe9730 100644 --- a/test/scripts/prompt-snapshots.test.ts +++ b/test/scripts/prompt-snapshots.test.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { createFormattedPromptSnapshotFiles, deleteStalePromptSnapshotFiles, @@ -13,6 +13,7 @@ import { renderCodexModelInstructions, runCodexModelPromptFixtureSync, } from "../../scripts/sync-codex-model-prompt-fixture.js"; +import { expectNoReaddirSyncDuring } from "../../src/test-utils/fs-scan-assertions.js"; import { CODEX_MODEL_PROMPT_FIXTURE_DIR, CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR, @@ -118,16 +119,12 @@ describe("happy path prompt snapshots", () => { }, 300_000); it("lists committed Codex prompt snapshot artifacts without scanning directories in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const committed = listCommittedPromptSnapshotFiles(); expect(committed.length).toBeGreaterThan(0); expect(committed.every((file) => file.endsWith(".md") || file.endsWith(".json"))).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("matches the committed Codex prompt snapshot artifacts", async () => { diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index 4bf5f32b3f1..9a189087d7f 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -1,4 +1,3 @@ -import { execFileSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -15,6 +14,7 @@ import { writeLegacyRootRuntimeCompatAliases, writeStableRootRuntimeAliases, } from "../../scripts/runtime-postbuild.mjs"; +import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js"; import { createScriptTestHarness } from "./test-helpers.js"; const { createTempDir } = createScriptTestHarness(); @@ -44,44 +44,16 @@ describe("runtime postbuild static assets", () => { }); it("discovers repo static asset metadata without scanning extension directories", () => { - const output = execFileSync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import fs from "node:fs"; - import { syncBuiltinESMExports } from "node:module"; - const counts = { existsSync: 0, readdirSync: 0 }; - const originalExistsSync = fs.existsSync; - const originalReaddirSync = fs.readdirSync; - fs.existsSync = (...args) => { - counts.existsSync += 1; - return originalExistsSync(...args); - }; - fs.readdirSync = (...args) => { - counts.readdirSync += 1; - return originalReaddirSync(...args); - }; - syncBuiltinESMExports(); - const assets = await import("./scripts/lib/static-extension-assets.mjs"); - console.log(JSON.stringify({ - counts, - outputs: assets.listStaticExtensionAssetOutputs(), - sources: assets.listStaticExtensionAssetSources(), - })); - `, - ], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); - const payload = JSON.parse(output) as { - counts: { existsSync: number; readdirSync: number }; + const payload = expectNoNodeFsScans<{ outputs: string[]; sources: string[]; - }; + }>(` + const assets = await import("./scripts/lib/static-extension-assets.mjs"); + return { + outputs: assets.listStaticExtensionAssetOutputs(), + sources: assets.listStaticExtensionAssetSources(), + }; + `); expect(payload.outputs).toEqual([ "dist/extensions/acpx/error-format.mjs", @@ -90,7 +62,6 @@ describe("runtime postbuild static assets", () => { "dist/extensions/diffs/assets/viewer-runtime.js", ]); expect(payload.sources).toContain("extensions/diffs/assets/viewer-runtime.js"); - expect(payload.counts).toEqual({ existsSync: 0, readdirSync: 0 }); }); it("discovers static assets from plugin package metadata", async () => { diff --git a/test/scripts/test-built-status-message-runtime.test.ts b/test/scripts/test-built-status-message-runtime.test.ts index 8f7a4a7d272..558732e1bf5 100644 --- a/test/scripts/test-built-status-message-runtime.test.ts +++ b/test/scripts/test-built-status-message-runtime.test.ts @@ -1,8 +1,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { findBuiltStatusMessageRuntimePath } from "../../scripts/test-built-status-message-runtime.mjs"; +import { expectNoReaddirSyncDuring } from "../../src/test-utils/fs-scan-assertions.js"; const tempDirs: string[] = []; @@ -27,14 +28,10 @@ describe("test-built-status-message-runtime", () => { fs.writeFileSync(path.join(distDir, "status-message.runtime-abc123.js"), "export {}\n"); fs.writeFileSync(path.join(distDir, "other.js"), "export {}\n"); - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { expect(findBuiltStatusMessageRuntimePath(distDir)).toBe( path.join(distDir, "status-message.runtime-abc123.js"), ); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); }); diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 36fd80d75d9..beccc8098c3 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -17,6 +17,7 @@ import { resolveExtensionBatchParallelism, runExtensionBatchPlan, } from "../../scripts/test-extension-batch.mjs"; +import { expectNoNodeFsScans } from "../../src/test-utils/fs-scan-assertions.js"; const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs"); @@ -253,50 +254,22 @@ describe("scripts/test-extension.mjs", () => { }); it("lists available extension ids from git without reading extension directories", () => { - const output = execFileSync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import fs from "node:fs"; - import { syncBuiltinESMExports } from "node:module"; - const counts = { existsSync: 0, readdirSync: 0 }; - const originalExistsSync = fs.existsSync; - const originalReaddirSync = fs.readdirSync; - fs.existsSync = (...args) => { - counts.existsSync += 1; - return originalExistsSync(...args); - }; - fs.readdirSync = (...args) => { - counts.readdirSync += 1; - return originalReaddirSync(...args); - }; - syncBuiltinESMExports(); - const { detectChangedExtensionIds, listAvailableExtensionIds } = await import("./scripts/lib/changed-extensions.mjs"); - const ids = listAvailableExtensionIds(); - const changed = detectChangedExtensionIds([ - "extensions/slack/src/channel.ts", - "src/line/message.test.ts", - "extensions/not-real/package.json", - ]); - console.log(JSON.stringify({ changed, counts, ids: ids.length })); - `, - ], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); - - const payload = JSON.parse(output) as { + const payload = expectNoNodeFsScans<{ changed: string[]; - counts: { existsSync: number; readdirSync: number }; ids: number; - }; + }>(` + const { detectChangedExtensionIds, listAvailableExtensionIds } = + await import("./scripts/lib/changed-extensions.mjs"); + const ids = listAvailableExtensionIds(); + const changed = detectChangedExtensionIds([ + "extensions/slack/src/channel.ts", + "src/line/message.test.ts", + "extensions/not-real/package.json", + ]); + return { changed, ids: ids.length }; + `); expect(payload.changed).toEqual(["line", "slack"]); expect(payload.ids).toBeGreaterThan(0); - expect(payload.counts).toEqual({ existsSync: 0, readdirSync: 0 }); }); it("can fail safe to all extensions when the base revision is unavailable", () => { @@ -472,49 +445,28 @@ describe("scripts/test-extension.mjs", () => { }); it("counts tracked extension tests without walking extension directories", () => { - const output = execFileSync( - process.execPath, - [ - "--input-type=module", - "--eval", - ` - import fs from "node:fs"; - import { syncBuiltinESMExports } from "node:module"; - const counts = { readdirSync: 0 }; - const originalReaddirSync = fs.readdirSync; - fs.readdirSync = (...args) => { - counts.readdirSync += 1; - return originalReaddirSync(...args); - }; - syncBuiltinESMExports(); - const { createExtensionTestShards, resolveExtensionBatchPlan } = await import("./scripts/lib/extension-test-plan.mjs"); - const extensionIds = ["matrix", "openai", "slack", "telegram"]; - const batch = resolveExtensionBatchPlan({ cwd: process.cwd(), extensionIds }); - const shards = createExtensionTestShards({ cwd: process.cwd(), extensionIds, shardCount: 2 }); - console.log(JSON.stringify({ - batchTests: batch.testFileCount, - counts, - shards: shards.length, - shardTests: shards.reduce((total, shard) => total + shard.testFileCount, 0), - })); - `, - ], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); - - const payload = JSON.parse(output) as { + const payload = expectNoNodeFsScans<{ batchTests: number; - counts: { readdirSync: number }; shards: number; shardTests: number; - }; + }>( + ` + const { createExtensionTestShards, resolveExtensionBatchPlan } = + await import("./scripts/lib/extension-test-plan.mjs"); + const extensionIds = ["matrix", "openai", "slack", "telegram"]; + const batch = resolveExtensionBatchPlan({ cwd: process.cwd(), extensionIds }); + const shards = createExtensionTestShards({ cwd: process.cwd(), extensionIds, shardCount: 2 }); + return { + batchTests: batch.testFileCount, + shards: shards.length, + shardTests: shards.reduce((total, shard) => total + shard.testFileCount, 0), + }; + `, + { counters: ["readdirSync"] }, + ); expect(payload.batchTests).toBeGreaterThan(0); expect(payload.shards).toBe(2); expect(payload.shardTests).toBe(payload.batchTests); - expect(payload.counts.readdirSync).toBe(0); }); it("balances extension test shards by estimated CI cost", () => { diff --git a/test/scripts/test-live-shard.test.ts b/test/scripts/test-live-shard.test.ts index 90fc71349c2..2417539ed6e 100644 --- a/test/scripts/test-live-shard.test.ts +++ b/test/scripts/test-live-shard.test.ts @@ -1,26 +1,23 @@ import fs, { readFileSync } from "node:fs"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { LIVE_TEST_SHARDS, RELEASE_LIVE_TEST_SHARDS, collectAllLiveTestFiles, selectLiveShardFiles, } from "../../scripts/test-live-shard.mjs"; +import { expectNoReaddirSyncDuring } from "../../src/test-utils/fs-scan-assertions.js"; describe("scripts/test-live-shard", () => { const allFiles = collectAllLiveTestFiles(); it("discovers live tests without scanning source roots in-process", () => { - const readDir = vi.spyOn(fs, "readdirSync"); - try { + expectNoReaddirSyncDuring(() => { const files = collectAllLiveTestFiles(); expect(files.length).toBeGreaterThan(0); expect(files.every((file) => file.endsWith(".live.test.ts"))).toBe(true); - expect(readDir).not.toHaveBeenCalled(); - } finally { - readDir.mockRestore(); - } + }); }); it("covers every native live test and tracks provider-filtered release fanout", () => { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 02b55a20635..f5a959f95ca 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -17,6 +17,7 @@ import { resolveParallelFullSuiteConcurrency, shouldRetryVitestNoOutputTimeout, } from "../../scripts/test-projects.test-support.mjs"; +import { captureReaddirSyncCallsDuring } from "../../src/test-utils/fs-scan-assertions.js"; import { fullSuiteVitestShards } from "../vitest/vitest.test-shards.mjs"; const normalizeRepoPath = (value: string) => value.replaceAll("\\", "/"); @@ -1172,20 +1173,16 @@ describe("scripts/test-projects full-suite sharding", () => { const gatewayServerConfig = "test/vitest/vitest.gateway-server.config.ts"; process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = "1"; let plans: ReturnType; - const readdirSync = vi.spyOn(fs, "readdirSync"); - const before = readdirSync.mock.calls.length; let gatewayTreeReads: unknown[][] = []; try { - plans = buildFullSuiteVitestRunPlans([], process.cwd()); - gatewayTreeReads = readdirSync.mock.calls - .slice(before) - .filter(([target]) => - typeof target === "string" - ? normalizeRepoPath(target).includes("src/gateway") - : false, - ); + const captured = captureReaddirSyncCallsDuring(() => + buildFullSuiteVitestRunPlans([], process.cwd()), + ); + plans = captured.result; + gatewayTreeReads = captured.calls.filter(([target]) => + typeof target === "string" ? normalizeRepoPath(target).includes("src/gateway") : false, + ); } finally { - readdirSync.mockRestore(); if (previous === undefined) { delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS; } else {