From d572188f61ba6bf304c59fec19a1f165e742bd6d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 16 Mar 2026 01:47:52 -0700 Subject: [PATCH] Tests: add extension test runner --- package.json | 1 + scripts/test-extension.mjs | 185 ++++++++++++++++++++++++++++ test/scripts/test-extension.test.ts | 50 ++++++++ 3 files changed, 236 insertions(+) create mode 100644 scripts/test-extension.mjs create mode 100644 test/scripts/test-extension.test.ts diff --git a/package.json b/package.json index d9fd801eae1..00412359bf5 100644 --- a/package.json +++ b/package.json @@ -324,6 +324,7 @@ "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts", + "test:extension": "node scripts/test-extension.mjs", "test:extensions": "vitest run --config vitest.extensions.config.ts", "test:fast": "vitest run --config vitest.unit.config.ts", "test:force": "node --import tsx scripts/test-force.ts", diff --git a/scripts/test-extension.mjs b/scripts/test-extension.mjs new file mode 100644 index 00000000000..bcc6aa30200 --- /dev/null +++ b/scripts/test-extension.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { channelTestRoots } from "../vitest.channel-paths.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const pnpm = "pnpm"; + +function normalizeRelative(inputPath) { + return inputPath.split(path.sep).join("/"); +} + +function isTestFile(filePath) { + return filePath.endsWith(".test.ts") || filePath.endsWith(".test.tsx"); +} + +function collectTestFiles(rootPath) { + const results = []; + const stack = [rootPath]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current || !fs.existsSync(current)) { + continue; + } + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") { + continue; + } + stack.push(fullPath); + continue; + } + if (entry.isFile() && isTestFile(fullPath)) { + results.push(fullPath); + } + } + } + + return results.toSorted((left, right) => left.localeCompare(right)); +} + +function resolveExtensionDirectory(targetArg, cwd = process.cwd()) { + if (targetArg) { + const asGiven = path.resolve(cwd, targetArg); + if (fs.existsSync(path.join(asGiven, "package.json"))) { + return asGiven; + } + + const byName = path.join(repoRoot, "extensions", targetArg); + if (fs.existsSync(path.join(byName, "package.json"))) { + return byName; + } + + throw new Error( + `Unknown extension target "${targetArg}". Use an extension name like "slack" or a path under extensions/.`, + ); + } + + let current = cwd; + while (true) { + if ( + normalizeRelative(path.relative(repoRoot, current)).startsWith("extensions/") && + fs.existsSync(path.join(current, "package.json")) + ) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + throw new Error( + "No extension target provided, and current working directory is not inside extensions/.", + ); +} + +export function resolveExtensionTestPlan(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const targetArg = params.targetArg; + const extensionDir = resolveExtensionDirectory(targetArg, cwd); + const extensionId = path.basename(extensionDir); + const relativeExtensionDir = normalizeRelative(path.relative(repoRoot, extensionDir)); + + const roots = [relativeExtensionDir]; + const pairedCoreRoot = path.join(repoRoot, "src", extensionId); + if (fs.existsSync(pairedCoreRoot)) { + const pairedRelativeRoot = normalizeRelative(path.relative(repoRoot, pairedCoreRoot)); + if (collectTestFiles(pairedCoreRoot).length > 0) { + roots.push(pairedRelativeRoot); + } + } + + const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root)); + const config = usesChannelConfig ? "vitest.channels.config.ts" : "vitest.extensions.config.ts"; + const testFiles = roots.flatMap((root) => collectTestFiles(path.join(repoRoot, root))); + + return { + config, + extensionDir: relativeExtensionDir, + extensionId, + roots, + testFiles: testFiles.map((filePath) => normalizeRelative(path.relative(repoRoot, filePath))), + }; +} + +function printUsage() { + console.error("Usage: pnpm test:extension [vitest args...]"); + console.error(" node scripts/test-extension.mjs [extension-name|path] [vitest args...]"); +} + +async function run() { + const rawArgs = process.argv.slice(2); + const dryRun = rawArgs.includes("--dry-run"); + const json = rawArgs.includes("--json"); + const args = rawArgs.filter((arg) => arg !== "--" && arg !== "--dry-run" && arg !== "--json"); + + let targetArg; + if (args[0] && !args[0].startsWith("-")) { + targetArg = args.shift(); + } + + let plan; + try { + plan = resolveExtensionTestPlan({ cwd: process.cwd(), targetArg }); + } catch (error) { + printUsage(); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + if (plan.testFiles.length === 0) { + console.error(`No tests found for ${plan.extensionDir}.`); + process.exit(1); + } + + if (dryRun) { + if (json) { + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + } else { + console.log(`[test-extension] ${plan.extensionId}`); + console.log(`config: ${plan.config}`); + console.log(`roots: ${plan.roots.join(", ")}`); + console.log(`tests: ${plan.testFiles.length}`); + } + return; + } + + console.log( + `[test-extension] Running ${plan.testFiles.length} test files for ${plan.extensionId} with ${plan.config}`, + ); + + const child = spawn( + pnpm, + ["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...args], + { + cwd: repoRoot, + stdio: "inherit", + shell: process.platform === "win32", + env: process.env, + }, + ); + + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 1); + }); +} + +const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; + +if (import.meta.url === entryHref) { + await run(); +} diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts new file mode 100644 index 00000000000..1ab4a68deb8 --- /dev/null +++ b/test/scripts/test-extension.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveExtensionTestPlan } from "../../scripts/test-extension.mjs"; + +const scriptPath = path.join(process.cwd(), "scripts", "test-extension.mjs"); + +function readPlan(args: string[], cwd = process.cwd()) { + const stdout = execFileSync(process.execPath, [scriptPath, ...args, "--dry-run", "--json"], { + cwd, + encoding: "utf8", + }); + return JSON.parse(stdout) as ReturnType; +} + +describe("scripts/test-extension.mjs", () => { + it("resolves channel-root extensions onto the channel vitest config", () => { + const plan = resolveExtensionTestPlan({ targetArg: "slack", cwd: process.cwd() }); + + expect(plan.extensionId).toBe("slack"); + expect(plan.extensionDir).toBe("extensions/slack"); + expect(plan.config).toBe("vitest.channels.config.ts"); + expect(plan.testFiles.some((file) => file.startsWith("extensions/slack/"))).toBe(true); + }); + + it("resolves provider extensions onto the extensions vitest config", () => { + const plan = resolveExtensionTestPlan({ targetArg: "firecrawl", cwd: process.cwd() }); + + expect(plan.extensionId).toBe("firecrawl"); + expect(plan.config).toBe("vitest.extensions.config.ts"); + expect(plan.testFiles.some((file) => file.startsWith("extensions/firecrawl/"))).toBe(true); + }); + + it("includes paired src roots when they contain tests", () => { + const plan = resolveExtensionTestPlan({ targetArg: "line", cwd: process.cwd() }); + + expect(plan.roots).toContain("extensions/line"); + expect(plan.roots).toContain("src/line"); + expect(plan.config).toBe("vitest.channels.config.ts"); + expect(plan.testFiles.some((file) => file.startsWith("src/line/"))).toBe(true); + }); + + it("infers the extension from the current working directory", () => { + const cwd = path.join(process.cwd(), "extensions", "slack"); + const plan = readPlan([], cwd); + + expect(plan.extensionId).toBe("slack"); + expect(plan.extensionDir).toBe("extensions/slack"); + }); +});