From 29cc7f431f8af332cddded57759cd8ce9d6c4a3f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 12:44:24 +0100 Subject: [PATCH] test: share runtime scan filters and cached test scans --- ...subagents.sessions-spawn.lifecycle.test.ts | 84 +++---------------- ...s.subagents.sessions-spawn.test-harness.ts | 3 +- src/security/temp-path-guard.test.ts | 27 ++---- src/security/weak-random-patterns.test.ts | 9 +- src/test-utils/repo-scan.ts | 60 +++++++++++++ 5 files changed, 80 insertions(+), 103 deletions(-) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index d10be4b42..d12303b61 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -289,40 +289,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }); it("sessions_spawn reports timed out when agent.wait returns timeout", async () => { - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - return { - runId: `run-${agentCallCount}`, - status: "accepted", - acceptedAt: 5000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string } | undefined; - return { - runId: params?.runId ?? "run-1", - status: "timeout", - startedAt: 6000, - endedAt: 7000, - }; - } - if (request.method === "chat.history") { - return { - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "still working" }], - }, - ], - }; - } - return {}; + const ctx = setupSessionsSpawnGatewayMock({ + includeChatHistory: true, + chatHistoryText: "still working", + agentWaitResult: { status: "timeout", startedAt: 6000, endedAt: 7000 }, }); const tool = await getSessionsSpawnTool({ @@ -340,9 +310,9 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - await waitFor(() => calls.filter((call) => call.method === "agent").length >= 2); + await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); - const mainAgentCall = calls + const mainAgentCall = ctx.calls .filter((call) => call.method === "agent") .find((call) => { const params = call.params as { lane?: string } | undefined; @@ -355,40 +325,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { }); it("sessions_spawn announces with requester accountId", async () => { - const calls: Array<{ method?: string; params?: unknown }> = []; - let agentCallCount = 0; - let childRunId: string | undefined; - - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: unknown }; - calls.push(request); - if (request.method === "agent") { - agentCallCount += 1; - const runId = `run-${agentCallCount}`; - const params = request.params as { lane?: string; sessionKey?: string } | undefined; - if (params?.lane === "subagent") { - childRunId = runId; - } - return { - runId, - status: "accepted", - acceptedAt: 4000 + agentCallCount, - }; - } - if (request.method === "agent.wait") { - const params = request.params as { runId?: string; timeoutMs?: number } | undefined; - return { - runId: params?.runId ?? "run-1", - status: "ok", - startedAt: 1000, - endedAt: 2000, - }; - } - if (request.method === "sessions.delete" || request.method === "sessions.patch") { - return { ok: true }; - } - return {}; - }); + const ctx = setupSessionsSpawnGatewayMock({}); const tool = await getSessionsSpawnTool({ agentSessionKey: "main", @@ -406,13 +343,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { runId: "run-1", }); - if (!childRunId) { + const child = ctx.getChild(); + if (!child.runId) { throw new Error("missing child runId"); } vi.useFakeTimers(); try { emitAgentEvent({ - runId: childRunId, + runId: child.runId, stream: "lifecycle", data: { phase: "end", @@ -426,7 +364,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { vi.useRealTimers(); } - const agentCalls = calls.filter((call) => call.method === "agent"); + const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); const announceParams = agentCalls[1]?.params as | { accountId?: string; channel?: string; deliver?: boolean } diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index 6a50517eb..129e15b9f 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -8,6 +8,7 @@ export type AgentWaitCall = { runId?: string; timeoutMs?: number }; type SessionsSpawnGatewayMockOptions = { includeSessionsList?: boolean; includeChatHistory?: boolean; + chatHistoryText?: string; onAgentSubagentSpawn?: (params: unknown) => void; onSessionsPatch?: (params: unknown) => void; onSessionsDelete?: (params: unknown) => void; @@ -137,7 +138,7 @@ export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMoc messages: [ { role: "assistant", - content: [{ type: "text", text: "done" }], + content: [{ type: "text", text: setupOpts.chatHistoryText ?? "done" }], }, ], }; diff --git a/src/security/temp-path-guard.test.ts b/src/security/temp-path-guard.test.ts index 05dfb9d9d..5cdaa05ac 100644 --- a/src/security/temp-path-guard.test.ts +++ b/src/security/temp-path-guard.test.ts @@ -2,24 +2,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import ts from "typescript"; import { describe, expect, it } from "vitest"; -import { listRepoFiles } from "../test-utils/repo-scan.js"; +import { listRuntimeSourceFiles, shouldSkipRuntimeSourcePath } from "../test-utils/repo-scan.js"; const RUNTIME_ROOTS = ["src", "extensions"] as const; -const SKIP_PATTERNS = [ - /\.test\.tsx?$/, - /\.test-helpers\.tsx?$/, - /\.test-utils\.tsx?$/, - /\.e2e\.tsx?$/, - /\.d\.ts$/, - /[\\/](?:__tests__|tests)[\\/]/, - /[\\/][^\\/]*test-helpers(?:\.[^\\/]+)?\.ts$/, -]; const QUICK_TMPDIR_JOIN_PATTERN = /\bpath\.join\s*\(\s*os\.tmpdir\s*\(\s*\)/; -function shouldSkip(relativePath: string): boolean { - return SKIP_PATTERNS.some((pattern) => pattern.test(relativePath)); -} - function isIdentifierNamed(node: ts.Node, name: string): node is ts.Identifier { return ts.isIdentifier(node) && node.text === name; } @@ -86,9 +73,9 @@ function hasDynamicTmpdirJoin(source: string, filePath = "fixture.ts"): boolean describe("temp path guard", () => { it("skips test helper filename variants", () => { - expect(shouldSkip("src/commands/test-helpers.ts")).toBe(true); - expect(shouldSkip("src/commands/sessions.test-helpers.ts")).toBe(true); - expect(shouldSkip("src\\commands\\sessions.test-helpers.ts")).toBe(true); + expect(shouldSkipRuntimeSourcePath("src/commands/test-helpers.ts")).toBe(true); + expect(shouldSkipRuntimeSourcePath("src/commands/sessions.test-helpers.ts")).toBe(true); + expect(shouldSkipRuntimeSourcePath("src\\commands\\sessions.test-helpers.ts")).toBe(true); }); it("detects dynamic and ignores static fixtures", () => { @@ -117,16 +104,12 @@ describe("temp path guard", () => { const repoRoot = process.cwd(); const offenders: string[] = []; - const files = await listRepoFiles(repoRoot, { + const files = await listRuntimeSourceFiles(repoRoot, { roots: RUNTIME_ROOTS, extensions: [".ts", ".tsx"], - skipHiddenDirectories: true, }); for (const file of files) { const relativePath = path.relative(repoRoot, file); - if (shouldSkip(relativePath)) { - continue; - } const source = await fs.readFile(file, "utf-8"); if (!QUICK_TMPDIR_JOIN_PATTERN.test(source)) { continue; diff --git a/src/security/weak-random-patterns.test.ts b/src/security/weak-random-patterns.test.ts index fca78a76a..0bc17d46e 100644 --- a/src/security/weak-random-patterns.test.ts +++ b/src/security/weak-random-patterns.test.ts @@ -1,20 +1,15 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { listRepoFiles } from "../test-utils/repo-scan.js"; +import { listRuntimeSourceFiles } from "../test-utils/repo-scan.js"; const SCAN_ROOTS = ["src", "extensions"] as const; -function isRuntimeTypeScriptFile(relativePath: string): boolean { - return !relativePath.endsWith(".test.ts") && !relativePath.endsWith(".d.ts"); -} - async function findWeakRandomPatternMatches(repoRoot: string): Promise { const matches: string[] = []; - const files = await listRepoFiles(repoRoot, { + const files = await listRuntimeSourceFiles(repoRoot, { roots: SCAN_ROOTS, extensions: [".ts"], - shouldIncludeFile: isRuntimeTypeScriptFile, }); for (const filePath of files) { const lines = (await fs.readFile(filePath, "utf8")).split(/\r?\n/); diff --git a/src/test-utils/repo-scan.ts b/src/test-utils/repo-scan.ts index c01509ea6..9dbf67fed 100644 --- a/src/test-utils/repo-scan.ts +++ b/src/test-utils/repo-scan.ts @@ -2,6 +2,18 @@ import fs from "node:fs/promises"; import path from "node:path"; export const DEFAULT_REPO_SCAN_SKIP_DIR_NAMES = new Set([".git", "dist", "node_modules"]); +export const DEFAULT_RUNTIME_SOURCE_ROOTS = ["src", "extensions"] as const; +export const DEFAULT_RUNTIME_SOURCE_EXTENSIONS = [".ts", ".tsx"] as const; +export const RUNTIME_SOURCE_SKIP_PATTERNS = [ + /\.test\.tsx?$/, + /\.test-helpers\.tsx?$/, + /\.test-utils\.tsx?$/, + /\.e2e\.tsx?$/, + /\.d\.ts$/, + /\/(?:__tests__|tests)\//, + /\/[^/]*test-helpers(?:\.[^/]+)?\.tsx?$/, + /\/[^/]*test-utils(?:\.[^/]+)?\.tsx?$/, +] as const; export type RepoFileScanOptions = { roots: readonly string[]; @@ -10,10 +22,15 @@ export type RepoFileScanOptions = { skipHiddenDirectories?: boolean; shouldIncludeFile?: (relativePath: string) => boolean; }; +export type RuntimeSourceScanOptions = { + roots?: readonly string[]; + extensions?: readonly string[]; +}; type PendingDir = { absolutePath: string; }; +const runtimeSourceScanCache = new Map>>(); function shouldSkipDirectory( name: string, @@ -29,6 +46,18 @@ function hasAllowedExtension(fileName: string, extensions: readonly string[]): b return extensions.some((extension) => fileName.endsWith(extension)); } +function normalizeRelativePath(relativePath: string): string { + return relativePath.replaceAll("\\", "/"); +} + +function toSortedUnique(values: readonly string[]): Array { + return [...new Set(values)].toSorted(); +} + +function getRuntimeScanCacheKey(repoRoot: string, roots: readonly string[]): string { + return `${repoRoot}::${toSortedUnique(roots).join(",")}`; +} + export async function listRepoFiles( repoRoot: string, options: RepoFileScanOptions, @@ -76,3 +105,34 @@ export async function listRepoFiles( files.sort((a, b) => a.localeCompare(b)); return files; } + +export function shouldSkipRuntimeSourcePath(relativePath: string): boolean { + const normalizedPath = normalizeRelativePath(relativePath); + return RUNTIME_SOURCE_SKIP_PATTERNS.some((pattern) => pattern.test(normalizedPath)); +} + +export async function listRuntimeSourceFiles( + repoRoot: string, + options: RuntimeSourceScanOptions = {}, +): Promise> { + const roots = options.roots ?? DEFAULT_RUNTIME_SOURCE_ROOTS; + const requestedExtensions = toSortedUnique( + options.extensions ?? DEFAULT_RUNTIME_SOURCE_EXTENSIONS, + ); + const cacheKey = getRuntimeScanCacheKey(repoRoot, roots); + + let pending = runtimeSourceScanCache.get(cacheKey); + if (!pending) { + pending = listRepoFiles(repoRoot, { + roots, + extensions: DEFAULT_RUNTIME_SOURCE_EXTENSIONS, + skipHiddenDirectories: true, + shouldIncludeFile: (relativePath) => !shouldSkipRuntimeSourcePath(relativePath), + }); + runtimeSourceScanCache.set(cacheKey, pending); + } + const files = await pending; + return files.filter((filePath) => + requestedExtensions.some((extension) => filePath.endsWith(extension)), + ); +}