CI: run extension-fast via multipass for channel plugins

This commit is contained in:
joshavant 2026-03-17 22:31:07 -05:00
parent a89cb3e10e
commit 64d414ae27
No known key found for this signature in database
GPG Key ID: 4463B60B0DD49BC4
6 changed files with 527 additions and 5 deletions

View File

@ -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:

View File

@ -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 <discord|imessage|line|signal|slack|telegram> [--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();
}

View File

@ -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 <probe|send|wait> <extension> <state-dir>\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();
}

View File

@ -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 <discord|imessage|line|signal|slack|telegram> --multipass-dir <path>\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();
}

View File

@ -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);

View File

@ -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",
]);
});
});