From 418dd85e66aabd512c403f30f372c8ac687e5252 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 04:38:05 -0700 Subject: [PATCH] improve: speed up focused test execution (#106307) * test: speed up focused test execution * style: format vitest config test * test: preserve targeted watch routing --- extensions/active-memory/index.test.ts | 11 +- .../client-fetch.error-body-boundary.test.ts | 15 +- ...core.interactions.navigation-guard.test.ts | 146 +++++++++++------- scripts/test-projects.mjs | 4 +- scripts/test-projects.test-support.d.mts | 6 +- scripts/test-projects.test-support.mjs | 26 +++- test/scripts/test-projects.test.ts | 45 ++++++ test/vitest-scoped-config.test.ts | 105 +++++++++++++ test/vitest/vitest.contracts-shared.ts | 28 +--- test/vitest/vitest.gateway-client.config.ts | 4 +- test/vitest/vitest.gateway-core.config.ts | 4 +- test/vitest/vitest.gateway-methods.config.ts | 4 +- test/vitest/vitest.gateway-server.config.ts | 4 +- test/vitest/vitest.pattern-file.ts | 111 ++++++++++++- test/vitest/vitest.scoped-config.ts | 22 ++- ui/src/pages/chat/chat-send.test.ts | 23 ++- 16 files changed, 451 insertions(+), 107 deletions(-) diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index efaa13ca0b1d..44a66a70f8de 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -4201,6 +4201,7 @@ describe("active-memory plugin", () => { }); it("uses a late verbose summary after a successful result and later unavailable trace", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const CONFIGURED_TIMEOUT_MS = 1_000; testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); @@ -4219,6 +4220,10 @@ describe("active-memory plugin", () => { }; const verboseSummary = "This memory says the user usually orders tonkotsu ramen, keeps chili crisp nearby, and prefers short dinner suggestions without menu preamble."; + let markDelayScheduled: (() => void) | undefined; + const delayScheduled = new Promise((resolve) => { + markDelayScheduled = resolve; + }); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { @@ -4259,6 +4264,7 @@ describe("active-memory plugin", () => { }, }, ]); + markDelayScheduled?.(); await new Promise((resolve) => { setTimeout(resolve, 550); }); @@ -4281,10 +4287,13 @@ describe("active-memory plugin", () => { }; }); - const result = await requireHook("before_prompt_build")( + const resultPromise = requireHook("before_prompt_build")( { prompt: "what food do i usually order? unavailable then summary", messages: [] }, { agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" }, ); + await delayScheduled; + await vi.advanceTimersByTimeAsync(550); + const result = await resultPromise; expect(requirePrependContext(result)).toContain( "This memory says the user usually orders tonkotsu ramen", diff --git a/extensions/browser/src/browser/client-fetch.error-body-boundary.test.ts b/extensions/browser/src/browser/client-fetch.error-body-boundary.test.ts index f748d51739f8..9c65a0840827 100644 --- a/extensions/browser/src/browser/client-fetch.error-body-boundary.test.ts +++ b/extensions/browser/src/browser/client-fetch.error-body-boundary.test.ts @@ -26,6 +26,11 @@ const SUCCESS_STREAM_CHUNK = Buffer.alloc(64 * 1024, "x"); const SUCCESS_STREAM_BODY_BYTES = 33 * 1024 * 1024; const BROWSER_SUCCESS_BODY_LIMIT_BYTES = 32 * 1024 * 1024; +function scheduleStreamChunk(writeNext: () => void): void { + // Separate event-loop turns preserve streaming and backpressure without a wall-clock sleep. + setImmediate(writeNext); +} + describe("fetchHttpJson error body boundary", () => { let server: http.Server; let baseUrl: string; @@ -91,11 +96,10 @@ describe("fetchHttpJson error body boundary", () => { ? SUCCESS_STREAM_CHUNK : SUCCESS_STREAM_CHUNK.subarray(0, remaining); written += chunk.byteLength; - const scheduleNext = () => setTimeout(writeNext, 2); if (res.write(chunk)) { - scheduleNext(); + scheduleStreamChunk(writeNext); } else { - res.once("drain", scheduleNext); + res.once("drain", () => scheduleStreamChunk(writeNext)); } }; writeNext(); @@ -143,11 +147,10 @@ describe("fetchHttpJson error body boundary", () => { return; } written += STREAM_CHUNK.byteLength; - const writeMore = () => setTimeout(writeNext, 2); if (res.write(STREAM_CHUNK)) { - writeMore(); + scheduleStreamChunk(writeNext); } else { - res.once("drain", writeMore); + res.once("drain", () => scheduleStreamChunk(writeNext)); } }; writeNext(); diff --git a/extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts b/extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts index 39c1461d17d4..49097c0636aa 100644 --- a/extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.interactions.navigation-guard.test.ts @@ -28,6 +28,26 @@ function createMutableFrame(initialUrl: string) { }; } +async function runWithVirtualNavigationGrace(run: () => Promise): Promise { + vi.useFakeTimers(); + try { + // Observe rejection before advancing the production grace timer to avoid a transient + // unhandled rejection; timing-specific cases below still advance exact durations. + const settled = run().then( + (value) => ({ status: "fulfilled" as const, value }), + (reason: unknown) => ({ status: "rejected" as const, reason }), + ); + await vi.runAllTimersAsync(); + const result = await settled; + if (result.status === "rejected") { + throw result.reason; + } + return result.value; + } finally { + vi.useRealTimers(); + } +} + describe("pw-tools-core interaction navigation guard", () => { it("waits for the grace window before completing a successful non-navigating click", async () => { vi.useFakeTimers(); @@ -845,12 +865,14 @@ describe("pw-tools-core interaction navigation guard", () => { getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely.mockRejectedValueOnce(blocked); await expect( - mod.clickViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - targetId: "T1", - ref: "1", - ssrfPolicy: { allowPrivateNetwork: false }, - }), + runWithVirtualNavigationGrace(() => + mod.clickViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + ref: "1", + ssrfPolicy: { allowPrivateNetwork: false }, + }), + ), ).rejects.toThrow("blocked interaction navigation"); expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({ @@ -918,12 +940,14 @@ describe("pw-tools-core interaction navigation guard", () => { }; setPwToolsCoreCurrentPage(page); - const result = await mod.evaluateViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - targetId: "T1", - fn: "() => location.href = 'http://127.0.0.1:9222/json/version'", - ssrfPolicy: { allowPrivateNetwork: false }, - }); + const result = await runWithVirtualNavigationGrace(() => + mod.evaluateViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + fn: "() => location.href = 'http://127.0.0.1:9222/json/version'", + ssrfPolicy: { allowPrivateNetwork: false }, + }), + ); expect(result).toBe("ok"); expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({ @@ -1125,12 +1149,14 @@ describe("pw-tools-core interaction navigation guard", () => { setPwToolsCoreCurrentRefLocator({ click }); setPwToolsCoreCurrentPage(page); - await mod.clickViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - targetId: "T1", - ref: "1", - ssrfPolicy: { allowPrivateNetwork: false }, - }); + await runWithVirtualNavigationGrace(() => + mod.clickViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + ref: "1", + ssrfPolicy: { allowPrivateNetwork: false }, + }), + ); expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({ cdpUrl: "http://127.0.0.1:18792", @@ -1152,12 +1178,14 @@ describe("pw-tools-core interaction navigation guard", () => { setPwToolsCoreCurrentRefLocator({ click }); setPwToolsCoreCurrentPage(page); - await mod.clickViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - targetId: "T1", - ref: "1", - ssrfPolicy: { allowPrivateNetwork: false }, - }); + await runWithVirtualNavigationGrace(() => + mod.clickViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + ref: "1", + ssrfPolicy: { allowPrivateNetwork: false }, + }), + ); expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({ cdpUrl: "http://127.0.0.1:18792", @@ -1196,12 +1224,14 @@ describe("pw-tools-core interaction navigation guard", () => { setPwToolsCoreCurrentRefLocator({ click }); setPwToolsCoreCurrentPage(page); - await mod.clickViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - targetId: "T1", - ref: "1", - ssrfPolicy: { allowPrivateNetwork: false }, - }); + await runWithVirtualNavigationGrace(() => + mod.clickViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + ref: "1", + ssrfPolicy: { allowPrivateNetwork: false }, + }), + ); expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({ cdpUrl: "http://127.0.0.1:18792", @@ -1219,12 +1249,14 @@ describe("pw-tools-core interaction navigation guard", () => { }; setPwToolsCoreCurrentPage(page); - const result = await mod.evaluateViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - targetId: "T1", - fn: "() => 1", - ssrfPolicy: { allowPrivateNetwork: false }, - }); + const result = await runWithVirtualNavigationGrace(() => + mod.evaluateViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + fn: "() => 1", + ssrfPolicy: { allowPrivateNetwork: false }, + }), + ); expect(result).toBe("ok"); expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({ @@ -1250,12 +1282,14 @@ describe("pw-tools-core interaction navigation guard", () => { setPwToolsCoreCurrentRefLocator({ click }); setPwToolsCoreCurrentPage(page); - await mod.batchViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - targetId: "T1", - ssrfPolicy: { allowPrivateNetwork: false }, - actions: [{ kind: "click", ref: "1" }], - }); + await runWithVirtualNavigationGrace(() => + mod.batchViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + ssrfPolicy: { allowPrivateNetwork: false }, + actions: [{ kind: "click", ref: "1" }], + }), + ); expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({ cdpUrl: "http://127.0.0.1:18792", @@ -1343,12 +1377,14 @@ describe("pw-tools-core interaction navigation guard", () => { setPwToolsCoreCurrentPage(page); setPwToolsCoreCurrentRefLocator({ click }); - const result = await mod.executeActViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - targetId: "T1", - action: { kind: "click", ref: "1" }, - ssrfPolicy: { allowPrivateNetwork: false }, - }); + const result = await runWithVirtualNavigationGrace(() => + mod.executeActViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + action: { kind: "click", ref: "1" }, + ssrfPolicy: { allowPrivateNetwork: false }, + }), + ); expect(result.downloads).toEqual([ { @@ -1395,12 +1431,14 @@ describe("pw-tools-core interaction navigation guard", () => { setPwToolsCoreCurrentPage(page); setPwToolsCoreCurrentRefLocator(locator); - await mod.executeActViaPlaywright({ - cdpUrl: "http://127.0.0.1:18792", - targetId: "T1", - action, - ssrfPolicy: { allowPrivateNetwork: false }, - }); + await runWithVirtualNavigationGrace(() => + mod.executeActViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "T1", + action, + ssrfPolicy: { allowPrivateNetwork: false }, + }), + ); expect(drain).toHaveBeenCalledWith({ firstEventGraceMs: 0, diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index 39fbb6595134..f271c78c1699 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -122,7 +122,9 @@ function runPnpmSpecCommand(spec, pnpmArgs, label) { async function runVitestSpec(spec) { if (spec.includeFilePath && spec.includePatterns) { - writeVitestIncludeFile(spec.includeFilePath, spec.includePatterns); + writeVitestIncludeFile(spec.includeFilePath, spec.includePatterns, { + expandGlobs: !spec.watchMode, + }); } try { if (spec.preflightPnpmArgs) { diff --git a/scripts/test-projects.test-support.d.mts b/scripts/test-projects.test-support.d.mts index 17adc6285c9d..acebd44f6952 100644 --- a/scripts/test-projects.test-support.d.mts +++ b/scripts/test-projects.test-support.d.mts @@ -156,7 +156,11 @@ export function shouldAcquireLocalHeavyCheckLock( env?: Record, ): boolean; -export function writeVitestIncludeFile(filePath: string, includePatterns: string[]): void; +export function writeVitestIncludeFile( + filePath: string, + includePatterns: string[], + options?: { cwd?: string; expandGlobs?: boolean }, +): void; export function formatFailedShardDigest( failures: FailedVitestShard[], diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index ddc721126f79..d7b3e0f9718a 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -2618,7 +2618,7 @@ function isExistingDirectoryTarget(arg, cwd) { } function isGlobTarget(arg) { - return /[*?[\]{}]/u.test(arg); + return /[*?[\]{}]|[@+!]\(/u.test(arg); } function isFileLikeTarget(arg) { @@ -4554,8 +4554,28 @@ export function shouldAcquireLocalHeavyCheckLock(runSpecs, env = process.env) { ); } -export function writeVitestIncludeFile(filePath, includePatterns) { - fs.writeFileSync(filePath, `${JSON.stringify(includePatterns, null, 2)}\n`); +function expandVitestIncludePatterns(includePatterns, cwd) { + const candidateFiles = includePatterns.some(isGlobTarget) + ? listExplicitTestTargetFilesForCwd(cwd) + : []; + return uniqueOrdered( + includePatterns.flatMap((pattern) => { + if (!isGlobTarget(pattern)) { + return [pattern]; + } + return candidateFiles.filter((file) => path.matchesGlob(file, pattern)); + }), + ); +} + +export function writeVitestIncludeFile(filePath, includePatterns, options = {}) { + // Shared Vitest projects intersect this file with their ownership globs. + // One-shot runs emit concrete paths; watch runs retain globs for new files. + const expandedPatterns = + options.expandGlobs === false + ? includePatterns + : expandVitestIncludePatterns(includePatterns, options.cwd ?? process.cwd()); + fs.writeFileSync(filePath, `${JSON.stringify(expandedPatterns, null, 2)}\n`); } function shellQuote(value) { diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index c8043290a932..4b32c0c4940a 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -27,6 +27,7 @@ import { resolveChangedTargetArgs, resolveParallelFullSuiteConcurrency, shouldRetryVitestNoOutputTimeout, + writeVitestIncludeFile, } from "../../scripts/test-projects.test-support.mjs"; import { captureReaddirSyncCallsDuring } from "../../src/test-utils/fs-scan-assertions.js"; import { toRepoPath } from "../../src/test-utils/repo-files.js"; @@ -3570,6 +3571,50 @@ describe("scripts/test-projects changed-target routing", () => { expect(spec?.includeFilePath).not.toMatch(new RegExp(`${process.pid}-\\d+-0\\.json$`, "u")); }); + it("expands routed glob targets to literal include-file paths", () => { + withTinyGitRepo( + { + "src/gateway/core.test.ts": "", + "src/gateway/server-methods/ping.test.ts": "", + "src/gateway/server-startup.test.ts": "", + }, + (cwd) => { + const includeFile = path.join(cwd, "include.json"); + writeVitestIncludeFile( + includeFile, + [ + "src/gateway/**/*.test.ts", + "src/gateway/server-*.test.ts", + "src/gateway/@(core|server-startup).test.ts", + ], + { cwd }, + ); + + expect(JSON.parse(fs.readFileSync(includeFile, "utf8"))).toEqual([ + "src/gateway/core.test.ts", + "src/gateway/server-methods/ping.test.ts", + "src/gateway/server-startup.test.ts", + ]); + }, + ); + }); + + it("retains routed glob targets in watch-mode include files", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-projects-watch-")); + try { + const includeFile = path.join(tempDir, "include.json"); + writeVitestIncludeFile(includeFile, ["src/gateway/**/*.test.ts"], { + expandGlobs: false, + }); + + expect(JSON.parse(fs.readFileSync(includeFile, "utf8"))).toEqual([ + "src/gateway/**/*.test.ts", + ]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("preflights targeted UI E2E specs with Playwright browser assets", () => { const [spec] = createVitestRunSpecs(["ui/src/pages/tasks/tasks.e2e.test.ts"], { baseEnv: {}, diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 8b7ff91d71bd..9e2f4abeb342 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -45,6 +45,10 @@ import { createExtensionVoiceCallVitestConfig } from "./vitest/vitest.extension- import { createExtensionWhatsAppVitestConfig } from "./vitest/vitest.extension-whatsapp.config.ts"; import { createExtensionZaloVitestConfig } from "./vitest/vitest.extension-zalo.config.ts"; import { createExtensionsVitestConfig } from "./vitest/vitest.extensions.config.ts"; +import { createGatewayClientVitestConfig } from "./vitest/vitest.gateway-client.config.ts"; +import { createGatewayCoreVitestConfig } from "./vitest/vitest.gateway-core.config.ts"; +import { createGatewayMethodsVitestConfig } from "./vitest/vitest.gateway-methods.config.ts"; +import { createGatewayServerVitestConfig } from "./vitest/vitest.gateway-server.config.ts"; import { createGatewayVitestConfig } from "./vitest/vitest.gateway.config.ts"; import { createHooksVitestConfig } from "./vitest/vitest.hooks.config.ts"; import { createInfraVitestConfig } from "./vitest/vitest.infra.config.ts"; @@ -386,6 +390,107 @@ describe("createScopedVitestConfig", () => { } }); + it("keeps include-file targets inside the scoped project's ownership", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-scoped-")); + try { + const includeFile = path.join(tempDir, "include.json"); + fs.writeFileSync( + includeFile, + JSON.stringify(["src/gateway/server.node-pairing-ssh-verify.test.ts"]), + "utf8", + ); + + const config = createScopedVitestConfig(["src/gateway/server-methods/**/*.test.ts"], { + dir: "src/gateway", + env: { + OPENCLAW_VITEST_INCLUDE_FILE: includeFile, + }, + intersectIncludeFile: true, + }); + const testConfig = requireTestConfig(config); + + expect(testConfig.include).toEqual([]); + expect(testConfig.passWithNoTests).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it.each([ + "src/gateway/**/*{server,client}*.test.ts", + "src/gateway/@(server|core).test.ts", + "src/gateway/nested/**/*.test.ts", + ])( + "rejects ambiguous watch-mode include-file target %s at an ownership boundary", + (candidate) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-scoped-")); + try { + const includeFile = path.join(tempDir, "include.json"); + fs.writeFileSync(includeFile, JSON.stringify([candidate]), "utf8"); + + expect(() => + createScopedVitestConfig(["src/gateway/**/*server*.test.ts"], { + dir: "src/gateway", + env: { + OPENCLAW_VITEST_INCLUDE_FILE: includeFile, + }, + intersectIncludeFile: true, + }), + ).toThrow(`cannot safely intersect non-literal include path: ${candidate}`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }, + ); + + it("intersects a watch-mode directory target with project ownership", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-scoped-")); + try { + const includeFile = path.join(tempDir, "include.json"); + fs.writeFileSync(includeFile, JSON.stringify(["src/gateway/**/*.test.ts"]), "utf8"); + + const config = createScopedVitestConfig(["src/gateway/**/*server*.test.ts"], { + dir: "src/gateway", + env: { + OPENCLAW_VITEST_INCLUDE_FILE: includeFile, + }, + intersectIncludeFile: true, + }); + + expect(requireTestConfig(config).include).toEqual(["**/*server*.test.ts"]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("keeps shared gateway include files inside their actual child projects", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-scoped-")); + try { + const includeFile = path.join(tempDir, "include.json"); + fs.writeFileSync( + includeFile, + JSON.stringify(["src/gateway/server.node-pairing-ssh-verify.test.ts"]), + "utf8", + ); + const env = { OPENCLAW_VITEST_INCLUDE_FILE: includeFile }; + + expect(requireTestConfig(createGatewayServerVitestConfig(env)).include).toEqual([ + "server.node-pairing-ssh-verify.test.ts", + ]); + const coreConfig = requireTestConfig(createGatewayCoreVitestConfig(env)); + expect(coreConfig.include).toEqual(["server.node-pairing-ssh-verify.test.ts"]); + expect(coreConfig.passWithNoTests).toBe(true); + const clientConfig = requireTestConfig(createGatewayClientVitestConfig(env)); + expect(clientConfig.include).toEqual([]); + expect(clientConfig.passWithNoTests).toBe(true); + const methodsConfig = requireTestConfig(createGatewayMethodsVitestConfig(env)); + expect(methodsConfig.include).toEqual([]); + expect(methodsConfig.passWithNoTests).toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("overrides setup files when a scoped config requests them", () => { const config = createScopedVitestConfig(["src/example.test.ts"], { env: {}, diff --git a/test/vitest/vitest.contracts-shared.ts b/test/vitest/vitest.contracts-shared.ts index a96910ca2472..80d7d8a97897 100644 --- a/test/vitest/vitest.contracts-shared.ts +++ b/test/vitest/vitest.contracts-shared.ts @@ -1,7 +1,10 @@ // Vitest contract shared helpers build contract test project configuration. -import path from "node:path"; import { defineConfig } from "vitest/config"; -import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts"; +import { + intersectIncludePatterns, + loadPatternListFromEnv, + narrowIncludePatternsForCli, +} from "./vitest.pattern-file.ts"; import { nonIsolatedRunnerPath, sharedVitestConfig } from "./vitest.shared.config.ts"; const base = sharedVitestConfig as Record; @@ -53,25 +56,6 @@ function loadContractsIncludePatternsFromEnv( return loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); } -function narrowContractIncludePatterns( - includePatterns: string[], - candidatePatterns: string[] | null, -): string[] | null { - if (!candidatePatterns) { - return null; - } - - return [ - ...new Set( - candidatePatterns.filter((candidate) => - includePatterns.some( - (pattern) => path.matchesGlob(candidate, pattern) || path.matchesGlob(pattern, candidate), - ), - ), - ), - ]; -} - export function createContractsVitestConfig( includePatterns: string[], env: Record = process.env, @@ -79,7 +63,7 @@ export function createContractsVitestConfig( options: { name?: string } = {}, ) { const cliIncludePatterns = narrowIncludePatternsForCli(includePatterns, argv); - const envIncludePatterns = narrowContractIncludePatterns( + const envIncludePatterns = intersectIncludePatterns( includePatterns, loadContractsIncludePatternsFromEnv(env), ); diff --git a/test/vitest/vitest.gateway-client.config.ts b/test/vitest/vitest.gateway-client.config.ts index 1aa23e00c36a..d031313a262b 100644 --- a/test/vitest/vitest.gateway-client.config.ts +++ b/test/vitest/vitest.gateway-client.config.ts @@ -1,7 +1,7 @@ // Vitest gateway client config wires the gateway client test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -function createGatewayClientVitestConfig(env?: Record) { +export function createGatewayClientVitestConfig(env?: Record) { return createScopedVitestConfig( [ "packages/gateway-client/src/**/*.test.ts", @@ -14,6 +14,8 @@ function createGatewayClientVitestConfig(env?: Record) { +export function createGatewayCoreVitestConfig(env?: Record) { return createScopedVitestConfig(["src/gateway/**/*.test.ts"], { dir: "src/gateway", env, exclude: nonCoreGatewayTestExclude, + // Gateway child projects share one include file; preserve this project's ownership. + intersectIncludeFile: true, name: "gateway-core", }); } diff --git a/test/vitest/vitest.gateway-methods.config.ts b/test/vitest/vitest.gateway-methods.config.ts index f12222b58fff..84799de5bb83 100644 --- a/test/vitest/vitest.gateway-methods.config.ts +++ b/test/vitest/vitest.gateway-methods.config.ts @@ -1,10 +1,12 @@ // Vitest gateway methods config wires the gateway methods test shard. import { createScopedVitestConfig } from "./vitest.scoped-config.ts"; -function createGatewayMethodsVitestConfig(env?: Record) { +export function createGatewayMethodsVitestConfig(env?: Record) { return createScopedVitestConfig(["src/gateway/server-methods/**/*.test.ts"], { dir: "src/gateway", env, + // Gateway child projects share one include file; preserve this project's ownership. + intersectIncludeFile: true, name: "gateway-methods", }); } diff --git a/test/vitest/vitest.gateway-server.config.ts b/test/vitest/vitest.gateway-server.config.ts index 9b6b44a6fc9e..bb4cbaec4947 100644 --- a/test/vitest/vitest.gateway-server.config.ts +++ b/test/vitest/vitest.gateway-server.config.ts @@ -9,7 +9,7 @@ const gatewayServerBackedHttpTests = [ "src/gateway/probe.auth.integration.test.ts", ]; -function createGatewayServerVitestConfig(env?: Record) { +export function createGatewayServerVitestConfig(env?: Record) { return createScopedVitestConfig( ["src/gateway/**/*server*.test.ts", ...gatewayServerBackedHttpTests], { @@ -22,6 +22,8 @@ function createGatewayServerVitestConfig(env?: Record + includePatterns.some((pattern) => patternsCouldOverlap(value, pattern)), + ), + ), + ]; +} + +function isPlainRepoRelativePath(value: string): boolean { + if (!/^[A-Za-z0-9_./-]+$/u.test(value) || path.isAbsolute(value)) { + return false; + } + return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."); +} + +function directoryTestPatternRoot(value: string): string | null { + const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\//u, ""); + if (normalized === "**/*.test.ts") { + return ""; + } + const suffix = "/**/*.test.ts"; + if (!normalized.endsWith(suffix)) { + return null; + } + const root = normalized.slice(0, -suffix.length); + return isPlainRepoRelativePath(root) ? root : null; +} + +function isAtOrUnder(value: string, root: string): boolean { + return root === "" || value === root || value.startsWith(`${root}/`); +} + +function patternIsFullyUnderDirectory(pattern: string, root: string): boolean { + const normalized = pattern.trim().replaceAll("\\", "/").replace(/^\.\//u, ""); + if (!normalized.endsWith(".test.ts")) { + return false; + } + const literalPrefix = literalPrefixForGlobPattern(normalized).replace(/\/+$/u, ""); + return isAtOrUnder(literalPrefix, root); +} + +function intersectDirectoryTestPattern( + includePatterns: string[], + candidatePattern: string, +): string[] | null { + const candidateRoot = directoryTestPatternRoot(candidatePattern); + if (candidateRoot === null) { + return null; + } + + const result: string[] = []; + let hasAmbiguousOverlap = false; + for (const includePattern of includePatterns) { + const includeRoot = directoryTestPatternRoot(includePattern); + if (includeRoot !== null && isAtOrUnder(candidateRoot, includeRoot)) { + return [candidatePattern]; + } else if (patternIsFullyUnderDirectory(includePattern, candidateRoot)) { + result.push(includePattern); + } else if (patternsCouldOverlap(candidatePattern, includePattern)) { + hasAmbiguousOverlap = true; + } + } + if (hasAmbiguousOverlap) { + return null; + } + return [...new Set(result)]; +} + +export function intersectIncludePatterns( + includePatterns: string[], + candidatePatterns: string[] | null, +): string[] | null { + if (!candidatePatterns) { + return null; + } + + const result: string[] = []; + for (const candidate of candidatePatterns) { + if (!isPlainRepoRelativePath(candidate)) { + // Watch directory targets retain their glob so newly added tests appear. + // Only generated directory globs have a provable ownership intersection. + const intersection = intersectDirectoryTestPattern(includePatterns, candidate); + if (!intersection) { + throw new Error(`cannot safely intersect non-literal include path: ${candidate}`); + } + result.push(...intersection); + continue; + } + if (includePatterns.some((include) => path.matchesGlob(candidate, include))) { + result.push(candidate); + } + } + + return [...new Set(result)]; +} + function loadPatternListFile(filePath: string, label: string): string[] { const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; if (!Array.isArray(parsed)) { @@ -186,9 +291,5 @@ export function narrowIncludePatternsForCli( return null; } - const matched = cliPatterns.filter((value) => - includePatterns.some((pattern) => patternsCouldOverlap(value, pattern)), - ); - - return [...new Set(matched)]; + return narrowIncludePatterns(includePatterns, cliPatterns); } diff --git a/test/vitest/vitest.scoped-config.ts b/test/vitest/vitest.scoped-config.ts index 2df71d04ffe2..8a5d3eb7dc86 100644 --- a/test/vitest/vitest.scoped-config.ts +++ b/test/vitest/vitest.scoped-config.ts @@ -1,7 +1,11 @@ // Vitest scoped config helper builds test configs for scoped file patterns. import path from "node:path"; import { defineConfig } from "vitest/config"; -import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts"; +import { + intersectIncludePatterns, + loadPatternListFromEnv, + narrowIncludePatternsForCli, +} from "./vitest.pattern-file.ts"; import { nonIsolatedRunnerPath, repoRoot, @@ -203,6 +207,7 @@ export function createScopedVitestConfig( isolate?: boolean; name?: string; fileParallelism?: boolean; + intersectIncludeFile?: boolean; pool?: "forks" | "threads"; passWithNoTests?: boolean; excludeUnitFastTests?: boolean; @@ -216,7 +221,10 @@ export function createScopedVitestConfig( const scopedDir = options?.dir; const resolvedScopedDir = scopedDir ? path.join(repoRoot, scopedDir) : undefined; const env = options?.env; - const includeFromEnv = loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); + const externalIncludePatterns = loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); + const includeFromEnv = options?.intersectIncludeFile + ? intersectIncludePatterns(include, externalIncludePatterns) + : externalIncludePatterns; const cliInclude = narrowIncludePatternsForCli(include, options?.argv, { scopedDir, }); @@ -230,6 +238,9 @@ export function createScopedVitestConfig( [...(baseTest.exclude ?? []), ...unitFastExcludePatterns, ...(options?.exclude ?? [])], scopedDir, ); + const scopedEnvInclude = includeFromEnv + ? relativizeScopedPatterns(includeFromEnv, scopedDir) + : includeFromEnv; const scopedCliInclude = cliInclude ? relativizeScopedPatterns(cliInclude, scopedDir) : null; const isolate = options?.isolate ?? resolveVitestIsolation(options?.env); const setupFiles = [ @@ -270,9 +281,12 @@ export function createScopedVitestConfig( }), ...(options?.passWithNoTests !== undefined ? { passWithNoTests: options.passWithNoTests } - : shouldPassWithNoTestsForCliIncludes(scopedCliInclude, exclude) + : externalIncludePatterns !== null && + shouldPassWithNoTestsForCliIncludes(scopedEnvInclude, exclude) ? { passWithNoTests: true } - : {}), + : shouldPassWithNoTestsForCliIncludes(scopedCliInclude, exclude) + ? { passWithNoTests: true } + : {}), }, }); } diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index 21cb8a56a01f..2980a924b73c 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -370,6 +370,22 @@ async function raceWithMacrotask(promise: Promise): Promise<"resolved" ]); } +async function completesWithin(promise: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + describe("refreshChat", () => { beforeAll(async () => { await loadChatHelpers(); @@ -4259,12 +4275,7 @@ describe("handleSendChat", () => { }); admitHostQueueItems(host); - const completed = await Promise.race([ - retryReconnectableQueuedChatSends(host).then(() => true), - new Promise((resolve) => { - setTimeout(() => resolve(false), 500); - }), - ]); + const completed = await completesWithin(retryReconnectableQueuedChatSends(host), 500); expect(completed).toBe(true); expect(executeSlashCommandMock).toHaveBeenCalledTimes(1);