From 1bee69f79bb1f254684c542c2abdebc94dff7781 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 02:34:07 +0900 Subject: [PATCH] refactor: route direct extension test targets --- docs/help/testing.md | 4 +- .../src/monitor/message-handler.preflight.ts | 76 +++++-- scripts/test-projects.mjs | 69 +++++-- scripts/test-projects.test-support.d.mts | 39 ++++ scripts/test-projects.test-support.mjs | 185 ++++++++++++++++-- src/scripts/test-projects.test.ts | 146 ++++++++++++++ test/scripts/test-projects.test.ts | 35 ---- 7 files changed, 473 insertions(+), 81 deletions(-) create mode 100644 scripts/test-projects.test-support.d.mts create mode 100644 src/scripts/test-projects.test.ts delete mode 100644 test/scripts/test-projects.test.ts diff --git a/docs/help/testing.md b/docs/help/testing.md index 1fe0be9a511..4034e9f80f7 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -25,6 +25,7 @@ Most days: - Full gate (expected before push): `pnpm build && pnpm check && pnpm test` - Faster local full-suite run on a roomy machine: `pnpm test:max` - Direct Vitest watch loop (modern projects config): `pnpm test:watch` +- Direct file targeting now routes extension/channel paths too: `pnpm test -- extensions/discord/src/monitor/message-handler.preflight.test.ts` When you touch tests or want extra confidence: @@ -57,7 +58,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Should be fast and stable - Projects note: - `pnpm test` and `pnpm test:watch` both use the same native Vitest `projects` config now. - - The tiny script wrapper only strips pnpm's passthrough separator; scheduling stays native Vitest. + - The tiny script wrapper still keeps scheduling native, but it now reroutes direct `extensions/...` and channel-surface test paths onto the matching Vitest lane automatically. + - If you target mixed suites in one command, the wrapper runs those lanes sequentially under the same local heavy-check lock. - Embedded runner note: - When you change message-tool discovery inputs or compaction runtime context, keep both levels of coverage. diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index db572b5615f..40886acb6e1 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -121,6 +121,50 @@ function isBoundThreadBotSystemMessage(params: { return DISCORD_BOUND_THREAD_SYSTEM_PREFIXES.some((prefix) => text.startsWith(prefix)); } +function resolveDiscordMentionState(params: { + authorIsBot: boolean; + botId?: string; + hasAnyMention: boolean; + isDirectMessage: boolean; + isExplicitlyMentioned: boolean; + mentionRegexes: RegExp[]; + mentionText: string; + mentionedEveryone: boolean; + referencedAuthorId?: string; + senderIsPluralKit: boolean; + transcript?: string; +}): { implicitMention: boolean; wasMentioned: boolean } { + if (params.isDirectMessage) { + return { + implicitMention: false, + wasMentioned: false, + }; + } + + const everyoneMentioned = + params.mentionedEveryone && (!params.authorIsBot || params.senderIsPluralKit); + const wasMentioned = + everyoneMentioned || + matchesMentionWithExplicit({ + text: params.mentionText, + mentionRegexes: params.mentionRegexes, + explicit: { + hasAnyMention: params.hasAnyMention, + isExplicitlyMentioned: params.isExplicitlyMentioned, + canResolveExplicit: Boolean(params.botId), + }, + transcript: params.transcript, + }); + const implicitMention = Boolean( + params.botId && params.referencedAuthorId && params.referencedAuthorId === params.botId, + ); + + return { + implicitMention, + wasMentioned, + }; +} + export function resolvePreflightMentionRequirement(params: { shouldRequireMention: boolean; bypassMentionRequirement: boolean; @@ -751,25 +795,19 @@ export async function preflightDiscordMessage( } const mentionText = hasTypedText ? baseText : ""; - const wasMentioned = - !isDirectMessage && - (((!author.bot || sender.isPluralKit) && Boolean(message.mentionedEveryone)) || - matchesMentionWithExplicit({ - text: mentionText, - mentionRegexes, - explicit: { - hasAnyMention, - isExplicitlyMentioned: explicitlyMentioned, - canResolveExplicit: Boolean(botId), - }, - transcript: preflightTranscript, - })); - const implicitMention = Boolean( - !isDirectMessage && - botId && - message.referencedMessage?.author?.id && - message.referencedMessage.author.id === botId, - ); + const { implicitMention, wasMentioned } = resolveDiscordMentionState({ + authorIsBot: Boolean(author.bot), + botId, + hasAnyMention, + isDirectMessage, + isExplicitlyMentioned: explicitlyMentioned, + mentionRegexes, + mentionText, + mentionedEveryone: Boolean(message.mentionedEveryone), + referencedAuthorId: message.referencedMessage?.author?.id, + senderIsPluralKit: sender.isPluralKit, + transcript: preflightTranscript, + }); if (shouldLogVerbose()) { logVerbose( `discord: inbound id=${message.id} guild=${params.data.guild_id ?? "dm"} channel=${messageChannelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`, diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index ce2571a6b25..af569deabff 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -1,8 +1,8 @@ +import fs from "node:fs"; import { acquireLocalHeavyCheckLockSync } from "./lib/local-heavy-check-runtime.mjs"; import { spawnPnpmRunner } from "./pnpm-runner.mjs"; -import { buildVitestArgs } from "./test-projects.test-support.mjs"; +import { createVitestRunSpecs, writeVitestIncludeFile } from "./test-projects.test-support.mjs"; -const vitestArgs = buildVitestArgs(process.argv.slice(2)); const releaseLock = acquireLocalHeavyCheckLockSync({ cwd: process.cwd(), env: process.env, @@ -18,21 +18,62 @@ const releaseLockOnce = () => { releaseLock(); }; -const child = spawnPnpmRunner({ - pnpmArgs: vitestArgs, - env: process.env, -}); - -child.on("exit", (code, signal) => { - releaseLockOnce(); - if (signal) { - process.kill(process.pid, signal); +function cleanupVitestRunSpec(spec) { + if (!spec.includeFilePath) { return; } - process.exit(code ?? 1); -}); + try { + fs.rmSync(spec.includeFilePath, { force: true }); + } catch { + // Best-effort cleanup for temp include lists. + } +} -child.on("error", (error) => { +function runVitestSpec(spec) { + if (spec.includeFilePath && spec.includePatterns) { + writeVitestIncludeFile(spec.includeFilePath, spec.includePatterns); + } + return new Promise((resolve, reject) => { + const child = spawnPnpmRunner({ + pnpmArgs: spec.pnpmArgs, + env: spec.env, + }); + + child.on("exit", (code, signal) => { + cleanupVitestRunSpec(spec); + resolve({ code: code ?? 1, signal }); + }); + + child.on("error", (error) => { + cleanupVitestRunSpec(spec); + reject(error); + }); + }); +} + +async function main() { + const runSpecs = createVitestRunSpecs(process.argv.slice(2), { + baseEnv: process.env, + cwd: process.cwd(), + }); + + for (const spec of runSpecs) { + const result = await runVitestSpec(spec); + if (result.signal) { + releaseLockOnce(); + process.kill(process.pid, result.signal); + return; + } + if (result.code !== 0) { + releaseLockOnce(); + process.exit(result.code); + } + } + + releaseLockOnce(); +} + +main().catch((error) => { releaseLockOnce(); console.error(error); process.exit(1); diff --git a/scripts/test-projects.test-support.d.mts b/scripts/test-projects.test-support.d.mts new file mode 100644 index 00000000000..b4a7a7a124c --- /dev/null +++ b/scripts/test-projects.test-support.d.mts @@ -0,0 +1,39 @@ +export type VitestRunPlan = { + config: string; + forwardedArgs: string[]; + includePatterns: string[] | null; + watchMode: boolean; +}; + +export type VitestRunSpec = { + config: string; + env: Record; + includeFilePath: string | null; + includePatterns: string[] | null; + pnpmArgs: string[]; + watchMode: boolean; +}; + +export function parseTestProjectsArgs( + args: string[], + cwd?: string, +): { + forwardedArgs: string[]; + targetArgs: string[]; + watchMode: boolean; +}; + +export function buildVitestRunPlans(args: string[], cwd?: string): VitestRunPlan[]; + +export function createVitestRunSpecs( + args: string[], + params?: { + baseEnv?: Record; + cwd?: string; + tempDir?: string; + }, +): VitestRunSpec[]; + +export function writeVitestIncludeFile(filePath: string, includePatterns: string[]): void; + +export function buildVitestArgs(args: string[], cwd?: string): string[]; diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index e9b9ec6f0b1..700093f5753 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1,5 +1,77 @@ -export function parseTestProjectsArgs(args) { +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { isChannelSurfaceTestFile } from "../vitest.channel-paths.mjs"; + +const DEFAULT_VITEST_CONFIG = "vitest.config.ts"; +const CHANNEL_VITEST_CONFIG = "vitest.channels.config.ts"; +const EXTENSIONS_VITEST_CONFIG = "vitest.extensions.config.ts"; +const INCLUDE_FILE_ENV_KEY = "OPENCLAW_VITEST_INCLUDE_FILE"; + +function normalizePathPattern(value) { + return value.replaceAll("\\", "/"); +} + +function isExistingPathTarget(arg, cwd) { + return fs.existsSync(path.resolve(cwd, arg)); +} + +function isGlobTarget(arg) { + return /[*?[\]{}]/u.test(arg); +} + +function isFileLikeTarget(arg) { + return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(arg); +} + +function isPathLikeTargetArg(arg, cwd) { + if (!arg || arg === "--" || arg.startsWith("-")) { + return false; + } + return isExistingPathTarget(arg, cwd) || isGlobTarget(arg) || isFileLikeTarget(arg); +} + +function toRepoRelativeTarget(arg, cwd) { + if (isGlobTarget(arg)) { + return normalizePathPattern(arg.replace(/^\.\//u, "")); + } + const absolute = path.resolve(cwd, arg); + return normalizePathPattern(path.relative(cwd, absolute)); +} + +function toScopedIncludePattern(arg, cwd) { + const relative = toRepoRelativeTarget(arg, cwd); + if (isGlobTarget(relative) || isFileLikeTarget(relative)) { + return relative; + } + return `${relative.replace(/\/+$/u, "")}/**/*.test.ts`; +} + +function classifyTarget(arg, cwd) { + const relative = toRepoRelativeTarget(arg, cwd); + if (relative.startsWith("extensions/")) { + return isChannelSurfaceTestFile(relative) ? "channel" : "extension"; + } + if (isChannelSurfaceTestFile(relative)) { + return "channel"; + } + return "default"; +} + +function createVitestArgs(params) { + return [ + "exec", + "vitest", + ...(params.watchMode ? [] : ["run"]), + "--config", + params.config, + ...params.forwardedArgs, + ]; +} + +export function parseTestProjectsArgs(args, cwd = process.cwd()) { const forwardedArgs = []; + const targetArgs = []; let watchMode = false; for (const arg of args) { @@ -10,20 +82,109 @@ export function parseTestProjectsArgs(args) { watchMode = true; continue; } + if (isPathLikeTargetArg(arg, cwd)) { + targetArgs.push(arg); + } forwardedArgs.push(arg); } - return { forwardedArgs, watchMode }; + return { forwardedArgs, targetArgs, watchMode }; } -export function buildVitestArgs(args) { - const { forwardedArgs, watchMode } = parseTestProjectsArgs(args); - return [ - "exec", - "vitest", - ...(watchMode ? [] : ["run"]), - "--config", - "vitest.config.ts", - ...forwardedArgs, - ]; +export function buildVitestRunPlans(args, cwd = process.cwd()) { + const { forwardedArgs, targetArgs, watchMode } = parseTestProjectsArgs(args, cwd); + if (targetArgs.length === 0) { + return [ + { + config: DEFAULT_VITEST_CONFIG, + forwardedArgs, + includePatterns: null, + watchMode, + }, + ]; + } + + const groupedTargets = new Map(); + for (const targetArg of targetArgs) { + const kind = classifyTarget(targetArg, cwd); + const current = groupedTargets.get(kind) ?? []; + current.push(targetArg); + groupedTargets.set(kind, current); + } + + if (watchMode && groupedTargets.size > 1) { + throw new Error( + "watch mode with mixed test suites is not supported; target one suite at a time or use a dedicated suite command", + ); + } + + const nonTargetArgs = forwardedArgs.filter((arg) => !targetArgs.includes(arg)); + const orderedKinds = ["default", "channel", "extension"]; + const plans = []; + for (const kind of orderedKinds) { + const grouped = groupedTargets.get(kind); + if (!grouped || grouped.length === 0) { + continue; + } + const config = + kind === "channel" + ? CHANNEL_VITEST_CONFIG + : kind === "extension" + ? EXTENSIONS_VITEST_CONFIG + : DEFAULT_VITEST_CONFIG; + const includePatterns = + kind === "default" + ? null + : grouped.map((targetArg) => toScopedIncludePattern(targetArg, cwd)); + const scopedTargetArgs = kind === "default" ? grouped : []; + plans.push({ + config, + forwardedArgs: [...nonTargetArgs, ...scopedTargetArgs], + includePatterns, + watchMode, + }); + } + return plans; +} + +export function createVitestRunSpecs(args, params = {}) { + const cwd = params.cwd ?? process.cwd(); + const plans = buildVitestRunPlans(args, cwd); + return plans.map((plan, index) => { + const includeFilePath = plan.includePatterns + ? path.join( + params.tempDir ?? os.tmpdir(), + `openclaw-vitest-include-${process.pid}-${Date.now()}-${index}.json`, + ) + : null; + return { + config: plan.config, + env: includeFilePath + ? { + ...(params.baseEnv ?? process.env), + [INCLUDE_FILE_ENV_KEY]: includeFilePath, + } + : (params.baseEnv ?? process.env), + includeFilePath, + includePatterns: plan.includePatterns, + pnpmArgs: createVitestArgs(plan), + watchMode: plan.watchMode, + }; + }); +} + +export function writeVitestIncludeFile(filePath, includePatterns) { + fs.writeFileSync(filePath, `${JSON.stringify(includePatterns, null, 2)}\n`); +} + +export function buildVitestArgs(args, cwd = process.cwd()) { + const [plan] = buildVitestRunPlans(args, cwd); + if (!plan) { + return createVitestArgs({ + config: DEFAULT_VITEST_CONFIG, + forwardedArgs: [], + watchMode: false, + }); + } + return createVitestArgs(plan); } diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts new file mode 100644 index 00000000000..6aa55d9f899 --- /dev/null +++ b/src/scripts/test-projects.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +const { buildVitestArgs, buildVitestRunPlans, createVitestRunSpecs, parseTestProjectsArgs } = + (await import("../../scripts/test-projects.test-support.mjs")) as unknown as { + buildVitestArgs: (args: string[], cwd?: string) => string[]; + buildVitestRunPlans: ( + args: string[], + cwd?: string, + ) => Array<{ + config: string; + forwardedArgs: string[]; + includePatterns: string[] | null; + watchMode: boolean; + }>; + createVitestRunSpecs: ( + args: string[], + params?: { + baseEnv?: NodeJS.ProcessEnv; + cwd?: string; + tempDir?: string; + }, + ) => Array<{ + config: string; + env: NodeJS.ProcessEnv; + includeFilePath: string | null; + includePatterns: string[] | null; + pnpmArgs: string[]; + watchMode: boolean; + }>; + parseTestProjectsArgs: ( + args: string[], + cwd?: string, + ) => { + forwardedArgs: string[]; + targetArgs: string[]; + watchMode: boolean; + }; + }; + +describe("test-projects args", () => { + it("drops a pnpm passthrough separator while preserving targeted filters", () => { + expect(parseTestProjectsArgs(["--", "src/foo.test.ts", "-t", "target"])).toEqual({ + forwardedArgs: ["src/foo.test.ts", "-t", "target"], + targetArgs: ["src/foo.test.ts"], + watchMode: false, + }); + }); + + it("keeps watch mode explicit without leaking the sentinel to Vitest", () => { + expect(buildVitestArgs(["--watch", "--", "src/foo.test.ts"])).toEqual([ + "exec", + "vitest", + "--config", + "vitest.config.ts", + "src/foo.test.ts", + ]); + }); + + it("uses run mode by default", () => { + expect(buildVitestArgs(["src/foo.test.ts"])).toEqual([ + "exec", + "vitest", + "run", + "--config", + "vitest.config.ts", + "src/foo.test.ts", + ]); + }); + + it("routes direct channel extension file targets to the channels config", () => { + expect( + buildVitestRunPlans(["extensions/discord/src/monitor/message-handler.preflight.test.ts"]), + ).toEqual([ + { + config: "vitest.channels.config.ts", + forwardedArgs: [], + includePatterns: ["extensions/discord/src/monitor/message-handler.preflight.test.ts"], + watchMode: false, + }, + ]); + }); + + it("routes direct provider extension file targets to the extensions config", () => { + expect(buildVitestRunPlans(["extensions/firecrawl/index.test.ts"])).toEqual([ + { + config: "vitest.extensions.config.ts", + forwardedArgs: [], + includePatterns: ["extensions/firecrawl/index.test.ts"], + watchMode: false, + }, + ]); + }); + + it("splits mixed core and extension targets into separate vitest runs", () => { + expect( + buildVitestRunPlans([ + "src/config/config-misc.test.ts", + "extensions/discord/src/monitor/message-handler.preflight.test.ts", + "-t", + "mention", + ]), + ).toEqual([ + { + config: "vitest.config.ts", + forwardedArgs: ["-t", "mention", "src/config/config-misc.test.ts"], + includePatterns: null, + watchMode: false, + }, + { + config: "vitest.channels.config.ts", + forwardedArgs: ["-t", "mention"], + includePatterns: ["extensions/discord/src/monitor/message-handler.preflight.test.ts"], + watchMode: false, + }, + ]); + }); + + it("writes scoped include files for routed extension runs", () => { + const [spec] = createVitestRunSpecs([ + "extensions/discord/src/monitor/message-handler.preflight.test.ts", + ]); + + expect(spec?.pnpmArgs).toEqual([ + "exec", + "vitest", + "run", + "--config", + "vitest.channels.config.ts", + ]); + expect(spec?.includePatterns).toEqual([ + "extensions/discord/src/monitor/message-handler.preflight.test.ts", + ]); + expect(spec?.includeFilePath).toContain("openclaw-vitest-include-"); + expect(spec?.env.OPENCLAW_VITEST_INCLUDE_FILE).toBe(spec?.includeFilePath); + }); + + it("rejects watch mode when a command spans multiple suites", () => { + expect(() => + buildVitestRunPlans([ + "--watch", + "src/config/config-misc.test.ts", + "extensions/discord/src/monitor/message-handler.preflight.test.ts", + ]), + ).toThrow("watch mode with mixed test suites is not supported"); + }); +}); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts deleted file mode 100644 index 207c7bdfe0b..00000000000 --- a/test/scripts/test-projects.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildVitestArgs, - parseTestProjectsArgs, -} from "../../scripts/test-projects.test-support.mjs"; - -describe("test-projects args", () => { - it("drops a pnpm passthrough separator while preserving targeted filters", () => { - expect(parseTestProjectsArgs(["--", "src/foo.test.ts", "-t", "target"])).toEqual({ - forwardedArgs: ["src/foo.test.ts", "-t", "target"], - watchMode: false, - }); - }); - - it("keeps watch mode explicit without leaking the sentinel to Vitest", () => { - expect(buildVitestArgs(["--watch", "--", "src/foo.test.ts"])).toEqual([ - "exec", - "vitest", - "--config", - "vitest.config.ts", - "src/foo.test.ts", - ]); - }); - - it("uses run mode by default", () => { - expect(buildVitestArgs(["src/foo.test.ts"])).toEqual([ - "exec", - "vitest", - "run", - "--config", - "vitest.config.ts", - "src/foo.test.ts", - ]); - }); -});