Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
398
scripts/e2e/lib/release-user-journey/assertions.mjs
Normal file
398
scripts/e2e/lib/release-user-journey/assertions.mjs
Normal file
@@ -0,0 +1,398 @@
|
||||
// Assertions for release user-journey E2E scenarios.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
assertAgentReplyContainsMarker,
|
||||
assertOpenAiRequestLogUsed,
|
||||
} from "../agent-turn-output.mjs";
|
||||
import { readBoundedResponseText as readBoundedResponseTextWithLimit } from "../bounded-response-text.mjs";
|
||||
import {
|
||||
applyMockOpenAiModelConfig,
|
||||
parseMockOpenAiPort,
|
||||
} from "../fixtures/mock-openai-config.mjs";
|
||||
import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs";
|
||||
import {
|
||||
ERROR_DETAIL_TAIL_BYTES,
|
||||
fileContainsText,
|
||||
readJson,
|
||||
} from "../release-assertion-files.mjs";
|
||||
import { readTextFileTail } from "../text-file-utils.mjs";
|
||||
|
||||
function clickClackHttpTimeoutMs() {
|
||||
return readPositiveInt(
|
||||
process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS,
|
||||
5000,
|
||||
"OPENCLAW_RELEASE_USER_JOURNEY_HTTP_TIMEOUT_MS",
|
||||
);
|
||||
}
|
||||
|
||||
function clickClackHttpBodyMaxBytes() {
|
||||
return readPositiveInt(
|
||||
process.env.OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES,
|
||||
1024 * 1024,
|
||||
"OPENCLAW_RELEASE_USER_JOURNEY_HTTP_BODY_MAX_BYTES",
|
||||
);
|
||||
}
|
||||
|
||||
function readPositiveInt(raw, fallback, label) {
|
||||
const text = String(raw ?? "").trim();
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(text)}`);
|
||||
}
|
||||
const parsed = Number(text);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(text)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function withClickClackFixtureResponse(url, init, consume, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? clickClackHttpTimeoutMs();
|
||||
const controller = new AbortController();
|
||||
const timeoutError = new Error(`${url} timed out after ${timeoutMs}ms`);
|
||||
let timer;
|
||||
let response;
|
||||
const timeoutPromise = new Promise((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
response = await Promise.race([
|
||||
fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
return await consume(response, { timeoutPromise });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
await response?.body?.cancel?.().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponseText(
|
||||
response,
|
||||
label,
|
||||
byteLimit = clickClackHttpBodyMaxBytes(),
|
||||
options = {},
|
||||
) {
|
||||
return await readBoundedResponseTextWithLimit(response, label, byteLimit, options.timeoutPromise);
|
||||
}
|
||||
|
||||
async function readBoundedResponseJson(response, label, options = {}) {
|
||||
return JSON.parse(await readBoundedResponseText(response, label, undefined, options));
|
||||
}
|
||||
|
||||
function resolveHomePath(value) {
|
||||
if (value === "~") {
|
||||
return process.env.HOME;
|
||||
}
|
||||
if (value?.startsWith("~/") || value?.startsWith("~\\")) {
|
||||
return path.join(process.env.HOME ?? "", value.slice(2));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function comparablePath(value) {
|
||||
const resolved = path.resolve(resolveHomePath(value));
|
||||
try {
|
||||
return fs.realpathSync.native(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
function pathsEqual(left, right) {
|
||||
return comparablePath(left) === comparablePath(right);
|
||||
}
|
||||
|
||||
function configPath() {
|
||||
return (
|
||||
process.env.OPENCLAW_CONFIG_PATH ??
|
||||
path.join(process.env.HOME ?? "", ".openclaw", "openclaw.json")
|
||||
);
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(cfg) {
|
||||
fs.writeFileSync(configPath(), `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function installRecords() {
|
||||
return readPluginInstallRecords({ configPath: configPath() });
|
||||
}
|
||||
|
||||
function assertOnboard() {
|
||||
const home = process.argv[3];
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const authPath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
|
||||
assert(fs.existsSync(configPath()), "onboard did not write openclaw.json");
|
||||
const stateRaw =
|
||||
fs.readFileSync(configPath(), "utf8") +
|
||||
(fs.existsSync(authPath) ? fs.readFileSync(authPath, "utf8") : "");
|
||||
assert(
|
||||
!stateRaw.includes("sk-openclaw-release-user-journey"),
|
||||
"onboard persisted raw OpenAI key",
|
||||
);
|
||||
}
|
||||
|
||||
function configureMockModel() {
|
||||
const mockPort = parseMockOpenAiPort(process.argv[3]);
|
||||
const cfg = readJson(configPath());
|
||||
applyMockOpenAiModelConfig(cfg, { mockPort });
|
||||
writeConfig(cfg);
|
||||
}
|
||||
|
||||
function assertAgentTurn() {
|
||||
const marker = process.argv[3];
|
||||
const outputPath = process.argv[4];
|
||||
const requestLogPath = process.argv[5];
|
||||
assertAgentReplyContainsMarker(marker, outputPath);
|
||||
assertOpenAiRequestLogUsed(requestLogPath);
|
||||
}
|
||||
|
||||
function assertFileContains() {
|
||||
const file = process.argv[3];
|
||||
const needle = process.argv[4];
|
||||
assert(
|
||||
fileContainsText(file, needle),
|
||||
`${file} did not contain ${needle}. Output tail: ${readTextFileTail(file, ERROR_DETAIL_TAIL_BYTES)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function rememberPluginInstallPath() {
|
||||
const pluginId = process.argv[3];
|
||||
const installPathFile = process.argv[4];
|
||||
const sourcePathFile = process.argv[5];
|
||||
const expectedSourcePath = process.argv[6];
|
||||
assert(pluginId, "missing plugin id");
|
||||
assert(installPathFile, "missing install path file");
|
||||
const record = installRecords()[pluginId];
|
||||
assert(record, `missing install record for ${pluginId}`);
|
||||
const installPath = resolveHomePath(record.installPath);
|
||||
assert(installPath, `install path missing for ${pluginId}`);
|
||||
assert(
|
||||
fs.existsSync(installPath),
|
||||
`install path missing on disk for ${pluginId}: ${installPath}`,
|
||||
);
|
||||
if (expectedSourcePath && record.sourcePath) {
|
||||
assert(
|
||||
pathsEqual(record.sourcePath, expectedSourcePath),
|
||||
`unexpected source path for ${pluginId}: ${record.sourcePath}, expected ${expectedSourcePath}`,
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(installPathFile, installPath, "utf8");
|
||||
if (sourcePathFile && (expectedSourcePath || record.sourcePath)) {
|
||||
fs.writeFileSync(
|
||||
sourcePathFile,
|
||||
expectedSourcePath || resolveHomePath(record.sourcePath),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPluginUninstalled() {
|
||||
const pluginId = process.argv[3];
|
||||
const installPathFile = process.argv[4];
|
||||
const sourcePathFile = process.argv[5];
|
||||
const cfg = readJson(configPath());
|
||||
const records = installRecords();
|
||||
assert(!records[pluginId], `install record still present for ${pluginId}`);
|
||||
assert(!cfg.plugins?.entries?.[pluginId], `plugin config entry still present for ${pluginId}`);
|
||||
assert(!(cfg.plugins?.allow ?? []).includes(pluginId), `allowlist still contains ${pluginId}`);
|
||||
assert(!(cfg.plugins?.deny ?? []).includes(pluginId), `denylist still contains ${pluginId}`);
|
||||
if (!installPathFile) {
|
||||
return;
|
||||
}
|
||||
const installPath = fs.readFileSync(installPathFile, "utf8").trim();
|
||||
const sourcePath =
|
||||
sourcePathFile && fs.existsSync(sourcePathFile)
|
||||
? fs.readFileSync(sourcePathFile, "utf8").trim()
|
||||
: "";
|
||||
if (sourcePath) {
|
||||
assert(
|
||||
fs.existsSync(sourcePath),
|
||||
`source path was deleted during uninstall for ${pluginId}: ${sourcePath}`,
|
||||
);
|
||||
}
|
||||
const installPathIsSourcePath = sourcePath ? pathsEqual(installPath, sourcePath) : false;
|
||||
assert(
|
||||
installPathIsSourcePath || !fs.existsSync(installPath),
|
||||
`managed plugin directory still present: ${installPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
function configureClickClack() {
|
||||
const baseUrl = process.argv[3];
|
||||
const cfg = readJson(configPath());
|
||||
cfg.plugins = {
|
||||
...cfg.plugins,
|
||||
enabled: true,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
clickclack: {
|
||||
...cfg.plugins?.entries?.clickclack,
|
||||
enabled: true,
|
||||
llm: {
|
||||
...cfg.plugins?.entries?.clickclack?.llm,
|
||||
allowAgentIdOverride: true,
|
||||
allowModelOverride: true,
|
||||
allowedModels: ["openai/gpt-5.5"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
cfg.channels = {
|
||||
...cfg.channels,
|
||||
clickclack: {
|
||||
...cfg.channels?.clickclack,
|
||||
enabled: true,
|
||||
baseUrl,
|
||||
token: { source: "env", provider: "default", id: "CLICKCLACK_BOT_TOKEN" },
|
||||
workspace: "release",
|
||||
defaultTo: "channel:general",
|
||||
replyMode: "model",
|
||||
model: "openai/gpt-5.5",
|
||||
reconnectMs: 250,
|
||||
},
|
||||
};
|
||||
writeConfig(cfg);
|
||||
}
|
||||
|
||||
function assertChannelStatus() {
|
||||
const channel = process.argv[3];
|
||||
const statusPath = process.argv[4];
|
||||
const status = readJson(statusPath);
|
||||
const configured = Array.isArray(status.configuredChannels) ? status.configuredChannels : [];
|
||||
const liveStatus = status.channels?.[channel];
|
||||
assert(
|
||||
configured.includes(channel) || liveStatus?.ok === true,
|
||||
`${channel} missing from channels status: ${JSON.stringify(status)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function postClickClackInbound() {
|
||||
const baseUrl = process.argv[3];
|
||||
const body = process.argv[4];
|
||||
await withClickClackFixtureResponse(
|
||||
`${baseUrl}/fixture/inbound`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ body }),
|
||||
},
|
||||
async (response, options) => {
|
||||
const text = response.ok
|
||||
? ""
|
||||
: await readBoundedResponseText(response, "ClickClack inbound", undefined, options);
|
||||
assert(response.ok, `fixture inbound failed: ${response.status} ${text}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function waitClickClackSocket() {
|
||||
const baseUrl = process.argv[3];
|
||||
const timeoutSeconds = readPositiveInt(
|
||||
process.argv[4],
|
||||
30,
|
||||
"ClickClack websocket timeout seconds",
|
||||
);
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const remainingMs = Math.max(1, deadline - Date.now());
|
||||
const state = await withClickClackFixtureResponse(
|
||||
`${baseUrl}/fixture/state`,
|
||||
{},
|
||||
async (response, options) =>
|
||||
response.ok
|
||||
? await readBoundedResponseJson(response, "ClickClack fixture state", options)
|
||||
: undefined,
|
||||
{
|
||||
timeoutMs: Math.min(clickClackHttpTimeoutMs(), remainingMs),
|
||||
},
|
||||
).catch(() => undefined);
|
||||
if (state) {
|
||||
if (Number(state.socketCount ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
}
|
||||
throw new Error(`Timed out waiting for ClickClack websocket connection at ${baseUrl}`);
|
||||
}
|
||||
|
||||
function assertClickClackState() {
|
||||
const mode = process.argv[3];
|
||||
const statePath = process.argv[4];
|
||||
const needle = process.argv[5];
|
||||
const state = readJson(statePath);
|
||||
const haystack = JSON.stringify(mode === "outbound" ? state.outboundMessages : state);
|
||||
assert(haystack.includes(needle), `ClickClack state did not contain ${needle}: ${haystack}`);
|
||||
}
|
||||
|
||||
async function waitClickClackReply() {
|
||||
const statePath = process.argv[3];
|
||||
const marker = process.argv[4];
|
||||
const timeoutSeconds = readPositiveInt(process.argv[5], 30, "ClickClack reply timeout seconds");
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(statePath)) {
|
||||
const state = readJson(statePath);
|
||||
if (JSON.stringify(state.threadReplies ?? []).includes(marker)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
}
|
||||
const state = fs.existsSync(statePath) ? fs.readFileSync(statePath, "utf8") : "<missing>";
|
||||
throw new Error(`Timed out waiting for ClickClack reply marker ${marker}. State: ${state}`);
|
||||
}
|
||||
|
||||
const commands = {
|
||||
"assert-onboard": assertOnboard,
|
||||
"remember-plugin-install-path": rememberPluginInstallPath,
|
||||
"configure-mock-model": configureMockModel,
|
||||
"assert-agent-turn": assertAgentTurn,
|
||||
"assert-file-contains": assertFileContains,
|
||||
"assert-plugin-uninstalled": assertPluginUninstalled,
|
||||
"configure-clickclack": configureClickClack,
|
||||
"assert-channel-status": assertChannelStatus,
|
||||
"post-clickclack-inbound": postClickClackInbound,
|
||||
"wait-clickclack-socket": waitClickClackSocket,
|
||||
"assert-clickclack-state": assertClickClackState,
|
||||
"wait-clickclack-reply": waitClickClackReply,
|
||||
};
|
||||
|
||||
export async function runReleaseUserJourneyAssertion(command, args = []) {
|
||||
const fn = commands[command];
|
||||
if (!fn) {
|
||||
throw new Error(`unknown release-user-journey assertion command: ${command ?? "<missing>"}`);
|
||||
}
|
||||
const previousArgv = process.argv;
|
||||
process.argv = [previousArgv[0] ?? "node", fileURLToPath(import.meta.url), command, ...args];
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
process.argv = previousArgv;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await runReleaseUserJourneyAssertion(process.argv[2], process.argv.slice(3));
|
||||
}
|
||||
345
scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs
Normal file
345
scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs
Normal file
@@ -0,0 +1,345 @@
|
||||
// ClickClack fixture server for release user-journey E2E scenarios.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import { readPositiveIntEnv, readTcpPortEnv } from "../env-limits.mjs";
|
||||
|
||||
const port = readTcpPortEnv("CLICKCLACK_FIXTURE_PORT", 44181);
|
||||
const requestMaxBytes = readPositiveIntEnv("CLICKCLACK_FIXTURE_REQUEST_MAX_BYTES", 4 * 1024 * 1024);
|
||||
const token = process.env.CLICKCLACK_FIXTURE_TOKEN ?? "clickclack-release-token";
|
||||
const statePath = process.env.CLICKCLACK_FIXTURE_STATE ?? "/tmp/openclaw-clickclack-fixture.json";
|
||||
const workspace = {
|
||||
id: "ws_release",
|
||||
name: "Release Workspace",
|
||||
slug: "release",
|
||||
created_at: new Date(0).toISOString(),
|
||||
};
|
||||
const channel = {
|
||||
id: "ch_general",
|
||||
workspace_id: workspace.id,
|
||||
name: "general",
|
||||
kind: "text",
|
||||
created_at: new Date(0).toISOString(),
|
||||
};
|
||||
const botUser = {
|
||||
id: "usr_bot",
|
||||
kind: "bot",
|
||||
display_name: "OpenClaw Bot",
|
||||
handle: "openclaw",
|
||||
avatar_url: "",
|
||||
created_at: new Date(0).toISOString(),
|
||||
};
|
||||
const humanUser = {
|
||||
id: "usr_human",
|
||||
kind: "human",
|
||||
display_name: "Release User",
|
||||
handle: "release-user",
|
||||
avatar_url: "",
|
||||
created_at: new Date(0).toISOString(),
|
||||
};
|
||||
|
||||
let messageSeq = 0;
|
||||
let eventSeq = 0;
|
||||
const messages = [];
|
||||
const threadReplies = [];
|
||||
const outboundMessages = [];
|
||||
const sockets = new Set();
|
||||
|
||||
function persist() {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
messages,
|
||||
threadReplies,
|
||||
outboundMessages,
|
||||
socketCount: sockets.size,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function unauthorized(res) {
|
||||
json(res, 401, { error: "unauthorized" });
|
||||
}
|
||||
|
||||
function checkAuth(req, res) {
|
||||
if (req.url?.startsWith("/fixture/") || req.url === "/health") {
|
||||
return true;
|
||||
}
|
||||
if (req.headers.authorization !== `Bearer ${token}`) {
|
||||
unauthorized(res);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
let bytes = 0;
|
||||
let settled = false;
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
bytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (bytes > requestMaxBytes) {
|
||||
settled = true;
|
||||
body = "";
|
||||
req.resume();
|
||||
reject(requestBodyTooLargeError());
|
||||
return;
|
||||
}
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
req.on("error", (error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function requestBodyTooLargeError() {
|
||||
return Object.assign(
|
||||
new Error(`ClickClack fixture request body exceeded ${requestMaxBytes} bytes`),
|
||||
{
|
||||
code: "ETOOBIG",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isRequestBodyTooLargeError(error) {
|
||||
return error instanceof Error && error.code === "ETOOBIG";
|
||||
}
|
||||
|
||||
function handleRequestError(res, error) {
|
||||
if (res.headersSent) {
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
if (isRequestBodyTooLargeError(error)) {
|
||||
json(res, 413, { error: error.message });
|
||||
return;
|
||||
}
|
||||
json(res, 500, { error: String(error instanceof Error ? error.message : error) });
|
||||
}
|
||||
|
||||
function createMessage({ body, author = humanUser, parentMessageId }) {
|
||||
messageSeq += 1;
|
||||
const id = `msg_${messageSeq}`;
|
||||
const message = {
|
||||
id,
|
||||
workspace_id: workspace.id,
|
||||
channel_id: channel.id,
|
||||
author_id: author.id,
|
||||
...(parentMessageId ? { parent_message_id: parentMessageId } : {}),
|
||||
thread_root_id: parentMessageId ?? id,
|
||||
channel_seq: messageSeq,
|
||||
thread_seq: parentMessageId ? threadReplies.length + 1 : 0,
|
||||
body,
|
||||
body_format: "markdown",
|
||||
created_at: now(),
|
||||
author,
|
||||
};
|
||||
if (parentMessageId) {
|
||||
threadReplies.push(message);
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
persist();
|
||||
return message;
|
||||
}
|
||||
|
||||
function eventFor(message) {
|
||||
eventSeq += 1;
|
||||
return {
|
||||
id: `evt_${eventSeq}`,
|
||||
cursor: String(eventSeq),
|
||||
type: message.parent_message_id ? "thread.reply_created" : "message.created",
|
||||
workspace_id: workspace.id,
|
||||
channel_id: channel.id,
|
||||
seq: message.channel_seq,
|
||||
created_at: now(),
|
||||
payload: {
|
||||
message_id: message.id,
|
||||
author_id: message.author_id,
|
||||
...(message.parent_message_id ? { root_message_id: message.thread_root_id } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function frameText(text) {
|
||||
const payload = Buffer.from(text);
|
||||
if (payload.length < 126) {
|
||||
return Buffer.concat([Buffer.from([0x81, payload.length]), payload]);
|
||||
}
|
||||
if (payload.length < 65536) {
|
||||
const header = Buffer.alloc(4);
|
||||
header[0] = 0x81;
|
||||
header[1] = 126;
|
||||
header.writeUInt16BE(payload.length, 2);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
const header = Buffer.alloc(10);
|
||||
header[0] = 0x81;
|
||||
header[1] = 127;
|
||||
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
function broadcast(event) {
|
||||
const frame = frameText(JSON.stringify(event));
|
||||
for (const socket of sockets) {
|
||||
socket.write(frame);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
try {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (!checkAuth(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/health") {
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/me") {
|
||||
json(res, 200, { user: botUser });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/workspaces") {
|
||||
json(res, 200, { workspaces: [workspace] });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === `/api/workspaces/${workspace.id}/channels`) {
|
||||
json(res, 200, { channels: [channel] });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === `/api/channels/${channel.id}/messages`) {
|
||||
const afterSeq = Number(url.searchParams.get("after_seq") ?? 0);
|
||||
json(res, 200, {
|
||||
messages: messages.filter((message) => (message.channel_seq ?? 0) > afterSeq),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === `/api/channels/${channel.id}/messages`) {
|
||||
const body = await readBody(req);
|
||||
const message = createMessage({ body: String(body.body ?? ""), author: botUser });
|
||||
outboundMessages.push(message);
|
||||
persist();
|
||||
json(res, 200, { message });
|
||||
return;
|
||||
}
|
||||
const threadReplyMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread\/replies$/u);
|
||||
if (req.method === "POST" && threadReplyMatch) {
|
||||
const body = await readBody(req);
|
||||
const message = createMessage({
|
||||
body: String(body.body ?? ""),
|
||||
author: botUser,
|
||||
parentMessageId: decodeURIComponent(threadReplyMatch[1]),
|
||||
});
|
||||
json(res, 200, { message });
|
||||
return;
|
||||
}
|
||||
const threadMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread$/u);
|
||||
if (req.method === "GET" && threadMatch) {
|
||||
const rootId = decodeURIComponent(threadMatch[1]);
|
||||
json(res, 200, {
|
||||
root: messages.find((message) => message.id === rootId) ?? null,
|
||||
replies: threadReplies.filter((message) => message.thread_root_id === rootId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/api/realtime/events") {
|
||||
json(res, 200, { events: [] });
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/fixture/inbound") {
|
||||
const body = await readBody(req);
|
||||
const message = createMessage({ body: String(body.body ?? ""), author: humanUser });
|
||||
broadcast(eventFor(message));
|
||||
json(res, 200, { message });
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && url.pathname === "/fixture/state") {
|
||||
json(res, 200, { messages, threadReplies, outboundMessages, socketCount: sockets.size });
|
||||
return;
|
||||
}
|
||||
json(res, 404, { error: `unhandled ${req.method} ${url.pathname}` });
|
||||
} catch (error) {
|
||||
handleRequestError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
void handleRequest(req, res);
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname !== "/api/realtime/ws" || req.headers.authorization !== `Bearer ${token}`) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const key = req.headers["sec-websocket-key"];
|
||||
if (typeof key !== "string") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const accept = crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
socket.write(
|
||||
[
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${accept}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
sockets.add(socket);
|
||||
persist();
|
||||
socket.on("close", () => {
|
||||
sockets.delete(socket);
|
||||
persist();
|
||||
});
|
||||
socket.on("error", () => {
|
||||
sockets.delete(socket);
|
||||
persist();
|
||||
});
|
||||
});
|
||||
|
||||
persist();
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`clickclack fixture listening on ${port}`);
|
||||
});
|
||||
256
scripts/e2e/lib/release-user-journey/scenario.sh
Executable file
256
scripts/e2e/lib/release-user-journey/scenario.sh
Executable file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
export NO_COLOR=1
|
||||
|
||||
source scripts/lib/openclaw-e2e-instance.sh
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
openclaw_e2e_install_trash_shim
|
||||
|
||||
export NPM_CONFIG_PREFIX="$HOME/.npm-global"
|
||||
export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
|
||||
export npm_config_loglevel=error
|
||||
export npm_config_fund=false
|
||||
export npm_config_audit=false
|
||||
export OPENAI_API_KEY="sk-openclaw-release-user-journey"
|
||||
export OPENCLAW_GATEWAY_TOKEN="release-user-journey-token"
|
||||
export CLICKCLACK_BOT_TOKEN="clickclack-release-token"
|
||||
|
||||
PORT="18789"
|
||||
MOCK_PORT="44180"
|
||||
CLICKCLACK_PORT="44181"
|
||||
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_USER_JOURNEY"
|
||||
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-user-journey.XXXXXX")"
|
||||
LOG_DIR="$scenario_tmp/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
INSTALL_LOG="$LOG_DIR/install.log"
|
||||
ONBOARD_LOG="$LOG_DIR/onboard.log"
|
||||
OPENAI_LOG="$LOG_DIR/openai.log"
|
||||
AGENT_LOG="$LOG_DIR/agent.log"
|
||||
PLUGIN_A_INSTALL_LOG="$LOG_DIR/plugin-a-install.log"
|
||||
PLUGIN_A_CLI_LOG="$LOG_DIR/plugin-a-cli.log"
|
||||
PLUGIN_A_UNINSTALL_LOG="$LOG_DIR/plugin-a-uninstall.log"
|
||||
PLUGIN_B_INSTALL_LOG="$LOG_DIR/plugin-b-install.log"
|
||||
PLUGIN_B_CLI_LOG="$LOG_DIR/plugin-b-cli.log"
|
||||
PLUGIN_B_AFTER_RESTART_JSON="$LOG_DIR/plugin-b-after-restart.json"
|
||||
CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log"
|
||||
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
|
||||
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
|
||||
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
|
||||
GATEWAY_1_LOG="$LOG_DIR/gateway-1.log"
|
||||
GATEWAY_2_LOG="$LOG_DIR/gateway-2.log"
|
||||
STATUS_JSON="$LOG_DIR/status.json"
|
||||
STATUS_ERR="$LOG_DIR/status.err"
|
||||
STATUS_AFTER_RESTART_JSON="$LOG_DIR/status-after-restart.json"
|
||||
STATUS_AFTER_RESTART_ERR="$LOG_DIR/status-after-restart.err"
|
||||
DOCTOR_LOG="$LOG_DIR/doctor.log"
|
||||
PLUGIN_A_INSTALL_PATH_FILE="$scenario_tmp/plugin-a-install-path.txt"
|
||||
PLUGIN_A_SOURCE_PATH_FILE="$scenario_tmp/plugin-a-source-path.txt"
|
||||
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
|
||||
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
|
||||
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
|
||||
|
||||
mock_pid=""
|
||||
clickclack_pid=""
|
||||
gateway_pid=""
|
||||
|
||||
cleanup() {
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
openclaw_e2e_stop_process "${clickclack_pid:-}"
|
||||
openclaw_e2e_stop_process "${mock_pid:-}"
|
||||
rm -rf "$scenario_tmp"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "release user journey failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
"$INSTALL_LOG" \
|
||||
"$ONBOARD_LOG" \
|
||||
"$OPENAI_LOG" \
|
||||
"$MOCK_REQUEST_LOG" \
|
||||
"$AGENT_LOG" \
|
||||
"$PLUGIN_A_INSTALL_LOG" \
|
||||
"$PLUGIN_A_CLI_LOG" \
|
||||
"$PLUGIN_A_UNINSTALL_LOG" \
|
||||
"$PLUGIN_B_INSTALL_LOG" \
|
||||
"$PLUGIN_B_CLI_LOG" \
|
||||
"$CLICKCLACK_PLUGIN_INSTALL_LOG" \
|
||||
"$CLICKCLACK_SERVER_LOG" \
|
||||
"$CLICKCLACK_OUTBOUND_JSON" \
|
||||
"$GATEWAY_1_LOG" \
|
||||
"$GATEWAY_2_LOG" \
|
||||
"$STATUS_JSON" \
|
||||
"$STATUS_AFTER_RESTART_JSON" \
|
||||
"$DOCTOR_LOG" \
|
||||
"$CLICKCLACK_STATE"
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
|
||||
start_gateway() {
|
||||
local log_path="$1"
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$log_path")"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$log_path" 300 "$PORT"
|
||||
}
|
||||
|
||||
stop_gateway() {
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
gateway_pid=""
|
||||
}
|
||||
|
||||
write_journey_plugin() {
|
||||
local dir="$1"
|
||||
local id="$2"
|
||||
local version="$3"
|
||||
local method="$4"
|
||||
local name="$5"
|
||||
local cli_root="$6"
|
||||
local cli_output="$7"
|
||||
|
||||
mkdir -p "$dir"
|
||||
node - "$dir" "$id" "$version" "$method" "$name" "$cli_root" "$cli_output" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const [dir, id, version, method, name, cliRoot, cliOutput] = process.argv.slice(2);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: `@openclaw/${id}`,
|
||||
version,
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "index.js"),
|
||||
`module.exports = { id: ${JSON.stringify(id)}, name: ${JSON.stringify(name)}, register(api) { api.registerGatewayMethod(${JSON.stringify(method)}, async () => ({ ok: true })); api.registerCli(({ program }) => { const root = program.command(${JSON.stringify(cliRoot)}).description(${JSON.stringify(`${name} fixture command`)}); root.command("ping").description("Print fixture ping output").action(() => { console.log(${JSON.stringify(cliOutput)}); }); }, { descriptors: [{ name: ${JSON.stringify(cliRoot)}, description: ${JSON.stringify(`${name} fixture command`)}, hasSubcommands: true }] }); }, };\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify({ id, configSchema: { type: "object", properties: {} } }, null, 2)}\n`,
|
||||
);
|
||||
NODE
|
||||
}
|
||||
|
||||
openclaw_e2e_install_package "$INSTALL_LOG"
|
||||
command -v openclaw >/dev/null
|
||||
package_root="$(openclaw_e2e_package_root)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
|
||||
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
|
||||
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
|
||||
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
|
||||
clickclack_pid="$!"
|
||||
for _ in $(seq 1 100); do
|
||||
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200
|
||||
|
||||
echo "Running non-interactive onboarding..."
|
||||
openclaw onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--auth-choice skip \
|
||||
--gateway-port "$PORT" \
|
||||
--gateway-bind loopback \
|
||||
--skip-daemon \
|
||||
--skip-ui \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-health >"$ONBOARD_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-onboard "$HOME"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-mock-model "$MOCK_PORT"
|
||||
|
||||
echo "Running package-installed agent turn..."
|
||||
openclaw agent --local \
|
||||
--agent main \
|
||||
--session-id release-user-journey-agent \
|
||||
--message "Return marker $SUCCESS_MARKER" \
|
||||
--thinking off \
|
||||
--json >"$AGENT_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
|
||||
|
||||
echo "Installing first external plugin..."
|
||||
plugin_a_dir="$(mktemp -d "$scenario_tmp/plugin-a.XXXXXX")"
|
||||
plugin_a_install_path_file="$PLUGIN_A_INSTALL_PATH_FILE"
|
||||
plugin_a_source_path_file="$PLUGIN_A_SOURCE_PATH_FILE"
|
||||
write_journey_plugin "$plugin_a_dir" journey-plugin-a 0.0.1 journey.a "Journey Plugin A" journey-a "journey-plugin-a:pong"
|
||||
openclaw plugins install "$plugin_a_dir" >"$PLUGIN_A_INSTALL_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs \
|
||||
remember-plugin-install-path \
|
||||
journey-plugin-a \
|
||||
"$plugin_a_install_path_file" \
|
||||
"$plugin_a_source_path_file" \
|
||||
"$plugin_a_dir"
|
||||
openclaw journey-a ping >"$PLUGIN_A_CLI_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_A_CLI_LOG" "journey-plugin-a:pong"
|
||||
|
||||
echo "Uninstalling first external plugin..."
|
||||
openclaw plugins uninstall journey-plugin-a --force >"$PLUGIN_A_UNINSTALL_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs \
|
||||
assert-plugin-uninstalled \
|
||||
journey-plugin-a \
|
||||
"$plugin_a_install_path_file" \
|
||||
"$plugin_a_source_path_file"
|
||||
|
||||
echo "Installing replacement external plugin..."
|
||||
plugin_b_dir="$(mktemp -d "$scenario_tmp/plugin-b.XXXXXX")"
|
||||
write_journey_plugin "$plugin_b_dir" journey-plugin-b 0.0.1 journey.b "Journey Plugin B" journey-b "journey-plugin-b:pong"
|
||||
openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1
|
||||
openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong"
|
||||
|
||||
echo "Installing ClickClack fixture plugin..."
|
||||
clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")"
|
||||
node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir"
|
||||
openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1
|
||||
|
||||
echo "Configuring ClickClack..."
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
|
||||
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
|
||||
|
||||
echo "Sending ClickClack outbound message..."
|
||||
openclaw message send \
|
||||
--channel clickclack \
|
||||
--target channel:general \
|
||||
--message "release journey outbound" \
|
||||
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release journey outbound"
|
||||
|
||||
echo "Starting Gateway for ClickClack inbound..."
|
||||
start_gateway "$GATEWAY_1_LOG"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
|
||||
|
||||
echo "Restarting Gateway and checking state survival..."
|
||||
stop_gateway
|
||||
start_gateway "$GATEWAY_2_LOG"
|
||||
openclaw plugins inspect journey-plugin-b --runtime --json >"$PLUGIN_B_AFTER_RESTART_JSON" 2>&1
|
||||
openclaw channels status --json >"$STATUS_AFTER_RESTART_JSON" 2>"$STATUS_AFTER_RESTART_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_AFTER_RESTART_JSON"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_AFTER_RESTART_JSON" "journey-plugin-b"
|
||||
stop_gateway
|
||||
|
||||
echo "Running doctor at end of release journey..."
|
||||
openclaw doctor --repair --non-interactive >"$DOCTOR_LOG" 2>&1
|
||||
|
||||
echo "Release user journey scenario passed."
|
||||
427
scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs
Normal file
427
scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs
Normal file
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env node
|
||||
// Writes the external ClickClack channel fixture used by release journey E2Es.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const pluginDir = process.argv[2];
|
||||
if (!pluginDir) {
|
||||
console.error("usage: write-clickclack-plugin.mjs <plugin-dir>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "clickclack",
|
||||
version: "0.0.1",
|
||||
type: "module",
|
||||
openclaw: { extensions: ["./index.mjs"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: "clickclack",
|
||||
activation: { onStartup: false },
|
||||
channels: ["clickclack"],
|
||||
channelEnvVars: { clickclack: ["CLICKCLACK_BOT_TOKEN"] },
|
||||
channelConfigs: {
|
||||
clickclack: {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
enabled: { type: "boolean", default: true },
|
||||
baseUrl: { type: "string" },
|
||||
workspace: { type: "string" },
|
||||
defaultTo: { type: "string" },
|
||||
token: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.mjs"),
|
||||
`import crypto from "node:crypto";
|
||||
import net from "node:net";
|
||||
|
||||
const CHANNEL_ID = "clickclack";
|
||||
const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
function configFor(cfg) {
|
||||
return cfg?.channels?.clickclack ?? {};
|
||||
}
|
||||
|
||||
function readToken(raw) {
|
||||
if (typeof raw === "string") {
|
||||
return raw.trim();
|
||||
}
|
||||
if (raw && typeof raw === "object" && raw.source === "env" && typeof raw.id === "string") {
|
||||
return String(process.env[raw.id] ?? "").trim();
|
||||
}
|
||||
return String(process.env.CLICKCLACK_BOT_TOKEN ?? "").trim();
|
||||
}
|
||||
|
||||
function resolveAccount(cfg, accountId = DEFAULT_ACCOUNT_ID) {
|
||||
const config = configFor(cfg);
|
||||
const token = readToken(config.token);
|
||||
const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : "";
|
||||
return {
|
||||
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(baseUrl && token),
|
||||
baseUrl,
|
||||
token,
|
||||
workspace: typeof config.workspace === "string" && config.workspace ? config.workspace : "release",
|
||||
defaultTo: typeof config.defaultTo === "string" ? config.defaultTo : "channel:general",
|
||||
reconnectMs: Number.isFinite(config.reconnectMs) ? Math.max(50, Number(config.reconnectMs)) : 250,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson(account, method, pathname, body) {
|
||||
const response = await fetch(new URL(pathname, account.baseUrl), {
|
||||
method,
|
||||
headers: {
|
||||
authorization: \`Bearer \${account.token}\`,
|
||||
...(body == null ? {} : { "content-type": "application/json" }),
|
||||
},
|
||||
...(body == null ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(\`ClickClack fixture \${response.status}: \${await response.text()}\`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function resolveWorkspaceId(account) {
|
||||
const data = await requestJson(account, "GET", "/api/workspaces");
|
||||
const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
|
||||
const match = workspaces.find((workspace) =>
|
||||
workspace?.id === account.workspace ||
|
||||
workspace?.slug === account.workspace ||
|
||||
workspace?.name === account.workspace
|
||||
);
|
||||
if (!match?.id) {
|
||||
throw new Error(\`ClickClack workspace not found: \${account.workspace}\`);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async function resolveChannelId(account, workspaceId, rawTarget) {
|
||||
const target = String(rawTarget ?? "").trim();
|
||||
const channelName = target.startsWith("channel:") ? target.slice("channel:".length) : target;
|
||||
const data = await requestJson(account, "GET", \`/api/workspaces/\${encodeURIComponent(workspaceId)}/channels\`);
|
||||
const channels = Array.isArray(data.channels) ? data.channels : [];
|
||||
const match = channels.find((channel) => channel?.id === channelName || channel?.name === channelName);
|
||||
if (!match?.id) {
|
||||
throw new Error(\`ClickClack channel not found: \${channelName}\`);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async function sendText(cfg, to, text, accountId, threadId, replyToId) {
|
||||
const account = resolveAccount(cfg, accountId);
|
||||
if (!account.configured) {
|
||||
throw new Error("ClickClack is not configured");
|
||||
}
|
||||
const workspaceId = await resolveWorkspaceId(account);
|
||||
const rootId = threadId == null ? String(replyToId ?? "") : String(threadId);
|
||||
if (rootId) {
|
||||
const data = await requestJson(
|
||||
account,
|
||||
"POST",
|
||||
\`/api/messages/\${encodeURIComponent(rootId)}/thread/replies\`,
|
||||
{ body: text },
|
||||
);
|
||||
return data.message;
|
||||
}
|
||||
const channelId = await resolveChannelId(account, workspaceId, to);
|
||||
const data = await requestJson(account, "POST", \`/api/channels/\${encodeURIComponent(channelId)}/messages\`, {
|
||||
body: text,
|
||||
});
|
||||
return data.message;
|
||||
}
|
||||
|
||||
function decodeFrame(buffer) {
|
||||
if (buffer.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const opcode = buffer[0] & 0x0f;
|
||||
let length = buffer[1] & 0x7f;
|
||||
let offset = 2;
|
||||
if (length === 126) {
|
||||
if (buffer.length < 4) {
|
||||
return null;
|
||||
}
|
||||
length = buffer.readUInt16BE(2);
|
||||
offset = 4;
|
||||
} else if (length === 127) {
|
||||
if (buffer.length < 10) {
|
||||
return null;
|
||||
}
|
||||
length = Number(buffer.readBigUInt64BE(2));
|
||||
offset = 10;
|
||||
}
|
||||
if (buffer.length < offset + length) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
opcode,
|
||||
text: buffer.subarray(offset, offset + length).toString("utf8"),
|
||||
rest: buffer.subarray(offset + length),
|
||||
};
|
||||
}
|
||||
|
||||
function openEventSocket(account, workspaceId, afterCursor, onEvent, signal) {
|
||||
const base = new URL(account.baseUrl);
|
||||
const key = crypto.randomBytes(16).toString("base64");
|
||||
const socket = net.createConnection({
|
||||
host: base.hostname,
|
||||
port: Number(base.port || (base.protocol === "https:" ? 443 : 80)),
|
||||
});
|
||||
let buffer = Buffer.alloc(0);
|
||||
let upgraded = false;
|
||||
const close = () => socket.destroy();
|
||||
signal.addEventListener("abort", close, { once: true });
|
||||
socket.on("connect", () => {
|
||||
const query = new URLSearchParams({ workspace_id: workspaceId });
|
||||
if (afterCursor) {
|
||||
query.set("after_cursor", afterCursor);
|
||||
}
|
||||
socket.write(
|
||||
[
|
||||
\`GET /api/realtime/ws?\${query.toString()} HTTP/1.1\`,
|
||||
\`Host: \${base.host}\`,
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
\`Sec-WebSocket-Key: \${key}\`,
|
||||
"Sec-WebSocket-Version: 13",
|
||||
\`Authorization: Bearer \${account.token}\`,
|
||||
"",
|
||||
"",
|
||||
].join("\\r\\n"),
|
||||
);
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
if (!upgraded) {
|
||||
const headerEnd = buffer.indexOf("\\r\\n\\r\\n");
|
||||
if (headerEnd === -1) {
|
||||
return;
|
||||
}
|
||||
const headers = buffer.subarray(0, headerEnd).toString("utf8");
|
||||
if (!headers.startsWith("HTTP/1.1 101")) {
|
||||
socket.destroy(new Error(headers.split("\\r\\n")[0] || "websocket upgrade failed"));
|
||||
return;
|
||||
}
|
||||
upgraded = true;
|
||||
buffer = buffer.subarray(headerEnd + 4);
|
||||
}
|
||||
for (;;) {
|
||||
const frame = decodeFrame(buffer);
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
buffer = frame.rest;
|
||||
if (frame.opcode === 1) {
|
||||
onEvent(JSON.parse(frame.text));
|
||||
} else if (frame.opcode === 8) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on("close", () => signal.removeEventListener("abort", close));
|
||||
return socket;
|
||||
}
|
||||
|
||||
async function resolveEventMessage(account, event) {
|
||||
if (event?.type !== "message.created" || !event.channel_id || typeof event.seq !== "number") {
|
||||
return null;
|
||||
}
|
||||
const data = await requestJson(
|
||||
account,
|
||||
"GET",
|
||||
\`/api/channels/\${encodeURIComponent(event.channel_id)}/messages?after_seq=\${Math.max(0, event.seq - 1)}\`,
|
||||
);
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
return messages.find((message) => message?.id === event.payload?.message_id) ?? null;
|
||||
}
|
||||
|
||||
async function dispatchInbound(ctx, account, message) {
|
||||
const runtime = ctx.channelRuntime;
|
||||
if (!runtime) {
|
||||
throw new Error("ClickClack fixture requires channel runtime");
|
||||
}
|
||||
const target = \`channel:\${message.channel_id}\`;
|
||||
const route = runtime.routing.resolveAgentRoute({
|
||||
cfg: ctx.cfg,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
peer: { kind: "channel", id: target },
|
||||
});
|
||||
const storePath = runtime.session.resolveStorePath(ctx.cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const previousTimestamp = runtime.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const senderName = message.author?.display_name || message.author_id || "Release User";
|
||||
const body = runtime.reply.formatAgentEnvelope({
|
||||
channel: "ClickClack",
|
||||
from: senderName,
|
||||
timestamp: new Date(message.created_at),
|
||||
previousTimestamp,
|
||||
envelope: runtime.reply.resolveEnvelopeFormatOptions(ctx.cfg),
|
||||
body: message.body,
|
||||
});
|
||||
const ctxPayload = runtime.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
BodyForAgent: message.body,
|
||||
RawBody: message.body,
|
||||
CommandBody: message.body,
|
||||
From: target,
|
||||
To: target,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId ?? account.accountId,
|
||||
ChatType: "group",
|
||||
WasMentioned: true,
|
||||
ConversationLabel: message.channel_id,
|
||||
GroupChannel: message.channel_id,
|
||||
NativeChannelId: message.channel_id,
|
||||
MessageSid: message.id,
|
||||
MessageSidFull: message.id,
|
||||
ReplyToId: message.id,
|
||||
Timestamp: message.created_at,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: target,
|
||||
CommandAuthorized: true,
|
||||
});
|
||||
await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: ctx.cfg,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
const text = payload && typeof payload === "object" ? String(payload.text ?? "") : "";
|
||||
if (text.trim()) {
|
||||
await sendText(ctx.cfg, target, text, account.accountId, message.id, message.id);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const clickclackPlugin = {
|
||||
id: CHANNEL_ID,
|
||||
meta: {
|
||||
id: CHANNEL_ID,
|
||||
label: "ClickClack",
|
||||
selectionLabel: "ClickClack",
|
||||
docsPath: "/channels/clickclack",
|
||||
blurb: "Release journey ClickClack fixture.",
|
||||
},
|
||||
capabilities: { chatTypes: ["group"], threads: true },
|
||||
config: {
|
||||
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
resolveAccount,
|
||||
isConfigured: (account) => account.configured,
|
||||
isEnabled: (account) => account.enabled,
|
||||
resolveDefaultTo: ({ cfg }) => resolveAccount(cfg).defaultTo,
|
||||
},
|
||||
status: {
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
ok: snapshot.configured === true,
|
||||
label: snapshot.configured ? "configured" : "missing config",
|
||||
detail: snapshot.baseUrl ?? "",
|
||||
}),
|
||||
buildAccountSnapshot: ({ account }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
baseUrl: account.baseUrl,
|
||||
}),
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: async (ctx) => {
|
||||
const message = await sendText(ctx.cfg, ctx.to, ctx.text, ctx.accountId, ctx.threadId, ctx.replyToId);
|
||||
return { channel: CHANNEL_ID, messageId: message.id };
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = resolveAccount(ctx.cfg, ctx.account.accountId);
|
||||
if (!account.configured) {
|
||||
throw new Error("ClickClack is not configured");
|
||||
}
|
||||
const workspaceId = await resolveWorkspaceId(account);
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
running: true,
|
||||
configured: true,
|
||||
enabled: account.enabled,
|
||||
baseUrl: account.baseUrl,
|
||||
});
|
||||
try {
|
||||
while (!ctx.abortSignal.aborted) {
|
||||
const socket = openEventSocket(
|
||||
account,
|
||||
workspaceId,
|
||||
"",
|
||||
(event) => {
|
||||
void (async () => {
|
||||
const message = await resolveEventMessage(account, event);
|
||||
if (message && message.author?.kind !== "bot") {
|
||||
await dispatchInbound(ctx, account, message);
|
||||
}
|
||||
})().catch((error) => {
|
||||
ctx.log?.error?.(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
},
|
||||
ctx.abortSignal,
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
socket.once("close", resolve);
|
||||
socket.once("error", resolve);
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, account.reconnectMs));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
ctx.setStatus({ accountId: account.accountId, running: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
id: CHANNEL_ID,
|
||||
register(api) {
|
||||
api.registerChannel({ plugin: clickclackPlugin });
|
||||
},
|
||||
};
|
||||
`,
|
||||
);
|
||||
Reference in New Issue
Block a user