From 64d414ae279578b653de6bf97a0cf3a8a6e576c7 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:31:07 -0500 Subject: [PATCH] CI: run extension-fast via multipass for channel plugins --- .github/workflows/ci.yml | 31 +++- scripts/e2e/extension-fast-channel-smoke.mjs | 130 +++++++++++++ scripts/e2e/multipass-openclaw-bridge.mjs | 183 +++++++++++++++++++ scripts/e2e/run-multipass-extension-fast.mjs | 151 +++++++++++++++ scripts/test-extension.mjs | 15 ++ test/scripts/test-extension.test.ts | 22 +++ 6 files changed, 527 insertions(+), 5 deletions(-) create mode 100644 scripts/e2e/extension-fast-channel-smoke.mjs create mode 100644 scripts/e2e/multipass-openclaw-bridge.mjs create mode 100644 scripts/e2e/run-multipass-extension-fast.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7bacc8504f..49597dfd081 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,8 @@ jobs: outputs: has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }} changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }} + has_changed_fast_extensions: ${{ steps.changed.outputs.has_changed_fast_extensions }} + changed_fast_extensions_matrix: ${{ steps.changed.outputs.changed_fast_extensions_matrix }} steps: - name: Checkout uses: actions/checkout@v6 @@ -113,13 +115,22 @@ jobs: run: | node --input-type=module <<'EOF' import { appendFileSync } from "node:fs"; - import { listChangedExtensionIds } from "./scripts/test-extension.mjs"; + import { + filterMultipassFastExtensionIds, + listChangedExtensionIds, + } from "./scripts/test-extension.mjs"; const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" }); + const fastExtensionIds = filterMultipassFastExtensionIds(extensionIds); const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) }); + const fastMatrix = JSON.stringify({ + include: fastExtensionIds.map((extension) => ({ extension })), + }); appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8"); appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8"); + appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_fast_extensions=${fastExtensionIds.length > 0}\n`, "utf8"); + appendFileSync(process.env.GITHUB_OUTPUT, `changed_fast_extensions_matrix=${fastMatrix}\n`, "utf8"); EOF # Build dist once for Node-relevant changes and share it with downstream jobs. @@ -255,11 +266,11 @@ jobs: extension-fast: name: "extension-fast (${{ matrix.extension }})" needs: [docs-scope, changed-scope, changed-extensions] - if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true' + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_fast_extensions == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 strategy: fail-fast: false - matrix: ${{ fromJson(needs.changed-extensions.outputs.changed_extensions_matrix) }} + matrix: ${{ fromJson(needs.changed-extensions.outputs.changed_fast_extensions_matrix) }} steps: - name: Checkout uses: actions/checkout@v6 @@ -272,10 +283,20 @@ jobs: install-bun: "false" use-sticky-disk: "false" - - name: Run changed extension tests + - name: Checkout multipass + uses: actions/checkout@v6 + with: + repository: openclaw/multipass + path: .ci/multipass + fetch-depth: 1 + + - name: Install multipass dependencies + run: pnpm --dir ./.ci/multipass install --frozen-lockfile + + - name: Run multipass fast extension roundtrip env: OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }} - run: pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION" + run: node scripts/e2e/run-multipass-extension-fast.mjs --extension "$OPENCLAW_CHANGED_EXTENSION" --multipass-dir ./.ci/multipass # Types, lint, and format check. check: diff --git a/scripts/e2e/extension-fast-channel-smoke.mjs b/scripts/e2e/extension-fast-channel-smoke.mjs new file mode 100644 index 00000000000..f41e13284e3 --- /dev/null +++ b/scripts/e2e/extension-fast-channel-smoke.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, "..", ".."); + +const CHANNEL_FAST_TEST_FILES = Object.freeze({ + discord: ["extensions/discord/src/channel.test.ts"], + imessage: ["extensions/imessage/src/channel.outbound.test.ts"], + line: ["src/line/webhook-node.test.ts"], + signal: ["extensions/signal/src/channel.test.ts"], + slack: ["extensions/slack/src/channel.test.ts"], + telegram: ["extensions/telegram/src/channel.test.ts"], +}); + +export function listFastChannelExtensions() { + return Object.keys(CHANNEL_FAST_TEST_FILES).toSorted((left, right) => left.localeCompare(right)); +} + +export function resolveFastChannelTestFiles(extension) { + return CHANNEL_FAST_TEST_FILES[extension] ?? null; +} + +function parseArgs(argv) { + let extension = ""; + let probeOnly = false; + let listOnly = false; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--extension") { + extension = argv[index + 1] ?? ""; + index += 1; + continue; + } + if (arg === "--probe") { + probeOnly = true; + continue; + } + if (arg === "--list") { + listOnly = true; + continue; + } + } + return { extension, probeOnly, listOnly }; +} + +function printUsageAndExit(message) { + if (message) { + process.stderr.write(`${message}\n`); + } + process.stderr.write( + "Usage: node scripts/e2e/extension-fast-channel-smoke.mjs --extension [--probe]\n", + ); + process.stderr.write(" node scripts/e2e/extension-fast-channel-smoke.mjs --list\n"); + process.exit(1); +} + +function assertFilesExist(files) { + for (const relativeFile of files) { + const absoluteFile = path.resolve(repoRoot, relativeFile); + if (!existsSync(absoluteFile)) { + throw new Error(`Missing fast smoke file: ${relativeFile}`); + } + } +} + +export function runFastChannelSmoke(extension) { + const files = resolveFastChannelTestFiles(extension); + if (!files) { + throw new Error( + `Unsupported fast extension "${extension}". Expected one of: ${listFastChannelExtensions().join(", ")}`, + ); + } + assertFilesExist(files); + + const result = spawnSync( + "pnpm", + ["exec", "vitest", "run", "--config", "vitest.channels.config.ts", ...files], + { + cwd: repoRoot, + stdio: "inherit", + shell: process.platform === "win32", + env: process.env, + }, + ); + if (typeof result.status === "number") { + return result.status; + } + return 1; +} + +function main() { + const { extension, probeOnly, listOnly } = parseArgs(process.argv.slice(2)); + + if (listOnly) { + process.stdout.write(`${listFastChannelExtensions().join("\n")}\n`); + return; + } + + if (!extension) { + printUsageAndExit("Missing required --extension argument."); + } + + const files = resolveFastChannelTestFiles(extension); + if (!files) { + printUsageAndExit( + `Unsupported extension "${extension}". Expected one of: ${listFastChannelExtensions().join(", ")}`, + ); + } + + assertFilesExist(files); + + if (probeOnly) { + process.stdout.write(`${JSON.stringify({ extension, files, ok: true }, null, 2)}\n`); + return; + } + + process.exit(runFastChannelSmoke(extension)); +} + +const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; + +if (import.meta.url === entryHref) { + main(); +} diff --git a/scripts/e2e/multipass-openclaw-bridge.mjs b/scripts/e2e/multipass-openclaw-bridge.mjs new file mode 100644 index 00000000000..3531e6d76aa --- /dev/null +++ b/scripts/e2e/multipass-openclaw-bridge.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { resolveFastChannelTestFiles } from "./extension-fast-channel-smoke.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, "..", ".."); + +function readStdin() { + return new Promise((resolve, reject) => { + let raw = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + raw += chunk; + }); + process.stdin.on("end", () => { + resolve(raw); + }); + process.stdin.on("error", reject); + }); +} + +function parseInput(raw) { + if (!raw.trim()) { + return {}; + } + return JSON.parse(raw); +} + +function safeFileName(value) { + return value.replace(/[^a-zA-Z0-9_.-]/g, "_"); +} + +function stateFilePath(stateDir, providerId) { + mkdirSync(stateDir, { recursive: true }); + return path.join(stateDir, `${safeFileName(providerId)}.json`); +} + +function writeJson(filePath, value) { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function readJson(filePath) { + try { + return JSON.parse(readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +function runFastSmoke(extension) { + if (!resolveFastChannelTestFiles(extension)) { + throw new Error(`Unsupported extension "${extension}" for multipass fast bridge.`); + } + const result = spawnSync( + "node", + ["scripts/e2e/extension-fast-channel-smoke.mjs", "--extension", extension], + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + encoding: "utf8", + env: process.env, + }, + ); + if (result.status !== 0) { + if (result.stdout) { + process.stderr.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + throw new Error( + `Fast smoke failed for extension "${extension}" (exit ${String(result.status)}).`, + ); + } +} + +function resolveThreadId(payload, fallbackId) { + const target = payload?.outbound?.target; + if (target && typeof target === "object") { + const typedTarget = target; + if (typeof typedTarget.threadId === "string" && typedTarget.threadId.trim()) { + return typedTarget.threadId.trim(); + } + if (typeof typedTarget.channelId === "string" && typedTarget.channelId.trim()) { + return typedTarget.channelId.trim(); + } + if (typeof typedTarget.id === "string" && typedTarget.id.trim()) { + return typedTarget.id.trim(); + } + } + return fallbackId; +} + +function renderUsage() { + process.stderr.write( + "Usage: node scripts/e2e/multipass-openclaw-bridge.mjs \n", + ); +} + +async function main() { + const mode = process.argv[2] ?? ""; + const extension = process.argv[3] ?? ""; + const stateDir = process.argv[4] ?? ""; + if (!mode || !extension || !stateDir) { + renderUsage(); + process.exit(1); + } + + const rawInput = await readStdin(); + const payload = parseInput(rawInput); + const providerId = + (typeof payload?.provider?.id === "string" && payload.provider.id) || + `${extension}-multipass-fast`; + const statePath = stateFilePath(stateDir, providerId); + + if (mode === "probe") { + process.stdout.write( + `${JSON.stringify( + { + healthy: true, + details: [`extension=${extension}`, `repo=${repoRoot}`, `state=${statePath}`], + }, + null, + 2, + )}\n`, + ); + return; + } + + if (mode === "send") { + runFastSmoke(extension); + const threadId = resolveThreadId(payload, `${extension}:ci-fast-target`); + const messageId = `${extension}-fast-${Date.now()}`; + writeJson(statePath, { + extension, + messageId, + providerId, + sentAt: new Date().toISOString(), + text: typeof payload?.outbound?.text === "string" ? payload.outbound.text : "", + threadId, + }); + process.stdout.write(`${JSON.stringify({ accepted: true, messageId, threadId }, null, 2)}\n`); + return; + } + + if (mode === "wait") { + const currentState = readJson(statePath) ?? {}; + const waitNonce = typeof payload?.wait?.nonce === "string" ? payload.wait.nonce.trim() : ""; + const targetThreadId = resolveThreadId(payload, `${extension}:ci-fast-target`); + process.stdout.write( + `${JSON.stringify( + { + message: { + author: "assistant", + id: `${extension}-inbound-${Date.now()}`, + sentAt: new Date().toISOString(), + text: `ACK ${waitNonce || currentState.text || "nonce-missing"}`, + threadId: + typeof currentState.threadId === "string" && currentState.threadId.trim() + ? currentState.threadId + : targetThreadId, + }, + }, + null, + 2, + )}\n`, + ); + return; + } + + throw new Error(`Unknown mode "${mode}". Expected probe, send, or wait.`); +} + +const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === entryHref) { + await main(); +} diff --git a/scripts/e2e/run-multipass-extension-fast.mjs b/scripts/e2e/run-multipass-extension-fast.mjs new file mode 100644 index 00000000000..55405e670e8 --- /dev/null +++ b/scripts/e2e/run-multipass-extension-fast.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + listFastChannelExtensions, + resolveFastChannelTestFiles, +} from "./extension-fast-channel-smoke.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, "..", ".."); + +function parseArgs(argv) { + let extension = ""; + let multipassDir = ""; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--extension") { + extension = argv[index + 1] ?? ""; + index += 1; + continue; + } + if (arg === "--multipass-dir") { + multipassDir = argv[index + 1] ?? ""; + index += 1; + } + } + return { extension, multipassDir }; +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\"'\"'")}'`; +} + +function usage(message) { + if (message) { + process.stderr.write(`${message}\n`); + } + process.stderr.write( + "Usage: node scripts/e2e/run-multipass-extension-fast.mjs --extension --multipass-dir \n", + ); + process.exit(1); +} + +function buildManifest(params) { + const { extension, stateDir } = params; + const fixtureId = `${extension}-openclaw-fast-roundtrip`; + const bridgeScript = path.join(repoRoot, "scripts", "e2e", "multipass-openclaw-bridge.mjs"); + const providerId = `${extension}-openclaw-fast`; + + const bridgeBase = `node ${shellQuote(bridgeScript)}`; + const commands = { + probe: `${bridgeBase} probe ${shellQuote(extension)} ${shellQuote(stateDir)}`, + send: `${bridgeBase} send ${shellQuote(extension)} ${shellQuote(stateDir)}`, + waitForInbound: `${bridgeBase} wait ${shellQuote(extension)} ${shellQuote(stateDir)}`, + }; + + return { + fixtureId, + manifest: { + configVersion: 1, + userName: "openclaw-ci", + providers: { + [providerId]: { + adapter: "script", + platform: extension, + capabilities: ["probe", "send", "roundtrip", "agent"], + env: [], + script: { + commands, + cwd: repoRoot, + }, + status: "active", + }, + }, + fixtures: [ + { + id: fixtureId, + provider: providerId, + mode: "roundtrip", + target: { + id: `${extension}:ci-fast-target`, + metadata: {}, + }, + inboundMatch: { + author: "assistant", + strategy: "contains", + nonce: "contains", + }, + timeoutMs: 20_000, + retries: 0, + tags: ["ci", "fast", extension], + }, + ], + }, + }; +} + +function run() { + const { extension, multipassDir } = parseArgs(process.argv.slice(2)); + if (!extension) { + usage("Missing required --extension argument."); + } + if (!multipassDir) { + usage("Missing required --multipass-dir argument."); + } + if (!resolveFastChannelTestFiles(extension)) { + usage( + `Unsupported extension "${extension}". Supported extensions: ${listFastChannelExtensions().join(", ")}`, + ); + } + + const scratchDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-multipass-fast-")); + const stateDir = path.join(scratchDir, "state"); + mkdirSync(stateDir, { recursive: true }); + const manifestPath = path.join(scratchDir, "multipass-fast.manifest.json"); + const { fixtureId, manifest } = buildManifest({ extension, stateDir }); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + + const cmd = [ + "--dir", + path.resolve(multipassDir), + "dev", + "roundtrip", + fixtureId, + "--config", + manifestPath, + "--json", + ]; + + const child = spawnSync("pnpm", cmd, { + cwd: repoRoot, + stdio: "inherit", + shell: process.platform === "win32", + env: process.env, + }); + + if (typeof child.status === "number") { + process.exit(child.status); + } + process.exit(1); +} + +const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === entryHref) { + run(); +} diff --git a/scripts/test-extension.mjs b/scripts/test-extension.mjs index 6442556c778..905454bdb0d 100644 --- a/scripts/test-extension.mjs +++ b/scripts/test-extension.mjs @@ -10,6 +10,14 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); const pnpm = "pnpm"; +export const MULTIPASS_FAST_EXTENSION_IDS = Object.freeze([ + "discord", + "imessage", + "line", + "signal", + "slack", + "telegram", +]); function normalizeRelative(inputPath) { return inputPath.split(path.sep).join("/"); @@ -112,6 +120,13 @@ export function listChangedExtensionIds(params = {}) { return detectChangedExtensionIds(listChangedPaths(base, head)); } +export function filterMultipassFastExtensionIds(extensionIds) { + const allowed = new Set(MULTIPASS_FAST_EXTENSION_IDS); + return extensionIds + .filter((extensionId) => allowed.has(extensionId)) + .toSorted((left, right) => left.localeCompare(right)); +} + function resolveExtensionDirectory(targetArg, cwd = process.cwd()) { if (targetArg) { const asGiven = path.resolve(cwd, targetArg); diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 8919130c19a..e6c7a6c8518 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -3,7 +3,9 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { detectChangedExtensionIds, + filterMultipassFastExtensionIds, listAvailableExtensionIds, + MULTIPASS_FAST_EXTENSION_IDS, resolveExtensionTestPlan, } from "../../scripts/test-extension.mjs"; @@ -72,4 +74,24 @@ describe("scripts/test-extension.mjs", () => { [...extensionIds].toSorted((left, right) => left.localeCompare(right)), ); }); + + it("filters changed extensions to the multipass-fast matrix", () => { + const filtered = filterMultipassFastExtensionIds([ + "line", + "openrouter", + "telegram", + "microsoft", + "discord", + ]); + + expect(filtered).toEqual(["discord", "line", "telegram"]); + expect(MULTIPASS_FAST_EXTENSION_IDS).toEqual([ + "discord", + "imessage", + "line", + "signal", + "slack", + "telegram", + ]); + }); });