@alignfirst/openclaw-test 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/Dockerfile.base +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +132 -0
  4. package/bin/cli.mjs +7 -0
  5. package/dist/bus.d.ts +1 -0
  6. package/dist/bus.js +20 -0
  7. package/dist/cell-result.d.ts +25 -0
  8. package/dist/cell-result.js +32 -0
  9. package/dist/cli.d.ts +5 -0
  10. package/dist/cli.js +110 -0
  11. package/dist/context.d.ts +220 -0
  12. package/dist/context.js +479 -0
  13. package/dist/cost.d.ts +2 -0
  14. package/dist/cost.js +19 -0
  15. package/dist/env-cli.d.ts +5 -0
  16. package/dist/env-cli.js +645 -0
  17. package/dist/exec-rpc.d.ts +13 -0
  18. package/dist/exec-rpc.js +46 -0
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.js +1 -0
  21. package/dist/judge.d.ts +47 -0
  22. package/dist/judge.js +183 -0
  23. package/dist/loop.d.ts +62 -0
  24. package/dist/loop.js +316 -0
  25. package/dist/mock-cli-server.d.ts +40 -0
  26. package/dist/mock-cli-server.js +194 -0
  27. package/dist/mock-cli-shim.d.ts +7 -0
  28. package/dist/mock-cli-shim.js +89 -0
  29. package/dist/models.d.ts +23 -0
  30. package/dist/models.js +78 -0
  31. package/dist/parse-tagged-json.d.ts +1 -0
  32. package/dist/parse-tagged-json.js +16 -0
  33. package/dist/report.d.ts +215 -0
  34. package/dist/report.js +19 -0
  35. package/dist/runner-args.d.ts +11 -0
  36. package/dist/runner-args.js +84 -0
  37. package/dist/runner.d.ts +2 -0
  38. package/dist/runner.js +371 -0
  39. package/dist/summary.d.ts +3 -0
  40. package/dist/summary.js +64 -0
  41. package/dist/transcript-dump.d.ts +1 -0
  42. package/dist/transcript-dump.js +50 -0
  43. package/dist/transcript-log.d.ts +67 -0
  44. package/dist/transcript-log.js +199 -0
  45. package/dist/transcript-store.d.ts +6 -0
  46. package/dist/transcript-store.js +69 -0
  47. package/docker-compose.yml +83 -0
  48. package/exec-watcher.mjs +155 -0
  49. package/package.json +60 -0
  50. package/templates/.env.local.example +28 -0
  51. package/templates/Dockerfile +47 -0
  52. package/templates/docker-compose.yml +10 -0
  53. package/templates/openclaw.json +40 -0
@@ -0,0 +1,645 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, isAbsolute, join, resolve } from "node:path";
5
+ import { discoverScenarios, expandChannelSelection, runMatrix, workerSpawnEnv, } from "./loop.js";
6
+ import { resolveSelectedModels } from "./models.js";
7
+ // Path-shaped vars from .env.local: resolved against the consumer's project dir so users
8
+ // can write natural relative paths. Compose `include:` would otherwise resolve them
9
+ // against the package's compose file in node_modules/.
10
+ const PATH_VARS = [
11
+ "OPENCLAW_WORKSPACE_DIR",
12
+ "OPENCLAW_CONFIG_PATH",
13
+ "OPENCLAW_TEST_SCENARIOS_DIR",
14
+ "OPENCLAW_TEST_ARTIFACTS_DIR",
15
+ "OPENCLAW_TEST_GATEWAY_LOGS_DIR",
16
+ ];
17
+ const RUN_USAGE = `usage: openclaw-test run --channel <id|id,id,…|all> [<scenario> ...] [--all]
18
+ [--model <id|id,id,…|all>] [--iterations N] [--max-failures N]
19
+ [--parallel K] [--stop-on-fail] [--reuse-stack]
20
+
21
+ Scenario selection is required: either a positional list or --all (mutually exclusive).
22
+ --model <id|id,id,…|all>
23
+ select the agent model(s): a bare id (e.g. claude-sonnet-4-6), a
24
+ comma list of bare ids, or "all". Defaults to OPENCLAW_DEFAULT_TEST_MODEL.
25
+ The catalog is OPENCLAW_TEST_MODELS (.env.local), a comma list of full
26
+ provider/model refs; the bare id is the suffix after the last "/".
27
+ "all" needs credentials for every selected provider.
28
+ --iterations N run each (scenario, channel) pair N times (default 1).
29
+ --max-failures N abort a pair once failures > N (default 1). Best-effort under
30
+ --parallel: in-flight cells of a bailed pair still finish.
31
+ --parallel K run up to K cells concurrently, each on its own worker stack
32
+ (Compose project <base>-w<i>). Defaults to OPENCLAW_TEST_PARALLEL
33
+ (.env.local), fallback 1. With K > 1, per-cell output is captured
34
+ to <artifacts>/<runStamp>/cells/<leaf>.log.
35
+ --stop-on-fail stop dispatching at the first failing cell (in-flight cells
36
+ drain) — pairs with --iterations to triage one bug at a time.
37
+ --reuse-stack skip the per-cell bus+gateway recreation (fastest path; but
38
+ scenarios leak state into each other with this option).
39
+
40
+ The host owns the matrix: cells are dispatched to a pool of K worker stacks.
41
+ Before a cell, a worker's bus and gateway are recreated (docker compose up -d
42
+ --force-recreate --wait bus gateway) for fresh state; this also lazily creates
43
+ a worker that isn't running yet. Each cell is one 'docker compose run --rm
44
+ runner' invocation.
45
+
46
+ Workers this run created are torn down at the end; already-running workers stay
47
+ up. Run 'openclaw-test env up [--parallel K]' beforehand to keep workers warm
48
+ across iterative runs.
49
+
50
+ If the base image needs (re)building, every running <base>-w* worker stack is
51
+ torn down first so the new image is picked up.`;
52
+ const ENV_USAGE = `usage: openclaw-test env <build|up|down>
53
+
54
+ build build the base and consumer images.
55
+ up [--parallel K] bring up worker stacks w1…wK warm (K defaults to
56
+ OPENCLAW_TEST_PARALLEL, fallback 1).
57
+ down tear down every <base>-w* worker stack, whatever K was.`;
58
+ export async function envCommand(packageDir, argv) {
59
+ const [sub, ...rest] = argv;
60
+ if (sub !== "build" && sub !== "up" && sub !== "down")
61
+ failEnv();
62
+ const parallelFlag = parseEnvArgs(sub, rest);
63
+ setupHostEnv(packageDir);
64
+ setBaseTag(packageDir);
65
+ if (sub === "down")
66
+ process.exit(sweepWorkerStacks());
67
+ if (sub === "build") {
68
+ ensureHostOutputDirs([]);
69
+ ensureBaseImage(packageDir, { force: true });
70
+ process.exit(execComposeSync(buildConsumerImageArgs(workerComposeArgs(1))));
71
+ }
72
+ const parallel = resolveParallel(parallelFlag, readEnvVar("OPENCLAW_TEST_PARALLEL"));
73
+ const workers = buildWorkerContexts(parallel);
74
+ ensureHostOutputDirs(workers);
75
+ ensureBaseImage(packageDir, { force: false });
76
+ ensureConsumerImage(workerComposeArgs(1));
77
+ const configPath = renderRuntimeConfig(canonicalConfigPath());
78
+ for (const worker of workers)
79
+ refreshWorkerWorkspace(worker);
80
+ const codes = await Promise.all(workers.map((worker) => execCompose([...worker.composeArgs, "up", "-d", "--wait", "--remove-orphans", "bus", "gateway"], workerSpawnEnv(worker, configPath))));
81
+ process.exit(codes.find((code) => code !== 0) ?? 0);
82
+ }
83
+ export function buildConsumerImageArgs(composeArgs) {
84
+ return [...composeArgs, "build", "bus"];
85
+ }
86
+ export async function runCommand(packageDir, argv) {
87
+ const args = parseRunArgs(argv);
88
+ setupHostEnv(packageDir);
89
+ const parallel = resolveParallel(args.parallel, readEnvVar("OPENCLAW_TEST_PARALLEL"));
90
+ const models = resolveSelectedModels({
91
+ selection: args.model,
92
+ modelsEnv: readEnvVar("OPENCLAW_TEST_MODELS"),
93
+ defaultEnv: readEnvVar("OPENCLAW_DEFAULT_TEST_MODEL"),
94
+ });
95
+ const canonicalPath = canonicalConfigPath();
96
+ // Each model's config is rendered once (its own temp file holding the expanded
97
+ // secret); worker loops hand the path to every spawn via per-spawn env.
98
+ const renderedConfigs = new Map();
99
+ const renderConfigPath = (m) => {
100
+ const cached = renderedConfigs.get(m.id);
101
+ if (cached)
102
+ return cached;
103
+ const path = renderRuntimeConfig(canonicalPath, m);
104
+ renderedConfigs.set(m.id, path);
105
+ return path;
106
+ };
107
+ setBaseTag(packageDir);
108
+ const didBuild = ensureBaseImage(packageDir, { force: false });
109
+ // A fresh base image means any running worker stack is on a stale image. Sweep
110
+ // them all so the lazy per-cell recreate boots them on the new image — and so
111
+ // the auto-down at the end fires (wasRunningBefore is computed after the sweep).
112
+ if (didBuild) {
113
+ const sweepCode = sweepWorkerStacks();
114
+ if (sweepCode !== 0)
115
+ process.exit(sweepCode);
116
+ }
117
+ const scenariosDir = process.env.OPENCLAW_TEST_SCENARIOS_DIR;
118
+ const artifactsDir = process.env.OPENCLAW_TEST_ARTIFACTS_DIR;
119
+ // `--all` is alphabetical (discoverScenarios sorts); an explicit list keeps CLI
120
+ // order, deduped.
121
+ const scenarios = args.all ? discoverScenarios(scenariosDir) : [...new Set(args.positionals)];
122
+ const channels = expandChannelSelection(args.channel, canonicalPath);
123
+ const cellCount = models.length * scenarios.length * channels.length * args.iterations;
124
+ const workers = buildWorkerContexts(Math.min(parallel, cellCount));
125
+ ensureHostOutputDirs(workers);
126
+ ensureConsumerImage(workerComposeArgs(1));
127
+ const baseStamp = new Date().toISOString().replace(/[:.]/g, "-");
128
+ const resultsDir = resolve(artifactsDir, baseStamp, "cells");
129
+ // The runner sees the artifacts dir bind-mounted at /opt/openclaw-test/artifacts (see docker-compose.yml).
130
+ const runnerResultsDir = `/opt/openclaw-test/artifacts/${baseStamp}/cells`;
131
+ mkdirSync(resultsDir, { recursive: true });
132
+ const matrixExit = await runMatrix({
133
+ scenarios,
134
+ channels,
135
+ models,
136
+ renderConfigPath,
137
+ iterations: args.iterations,
138
+ maxFailures: args.maxFailures,
139
+ stopOnFail: args.stopOnFail,
140
+ reuseStack: args.reuseStack,
141
+ parallel: workers.length,
142
+ workers,
143
+ refreshWorkspace: refreshWorkerWorkspace,
144
+ artifactsDir: resolve(artifactsDir, baseStamp),
145
+ resultsDir,
146
+ runnerResultsDir,
147
+ baseStamp,
148
+ });
149
+ const booted = workers.filter((worker) => !worker.wasRunningBefore);
150
+ await Promise.all(booted.map((worker) => execCompose([...worker.composeArgs, "down"])));
151
+ process.exit(matrixExit);
152
+ }
153
+ /** Flag wins over the env var; fallback 1. Both must parse to an integer ≥ 1. */
154
+ export function resolveParallel(flag, envValue) {
155
+ if (flag !== undefined)
156
+ return parseParallelValue("--parallel", flag);
157
+ if (envValue !== undefined)
158
+ return parseParallelValue("OPENCLAW_TEST_PARALLEL", envValue);
159
+ return 1;
160
+ }
161
+ function parseParallelValue(source, raw) {
162
+ const n = Number(raw);
163
+ if (!Number.isInteger(n) || n < 1) {
164
+ throw new Error(`openclaw-test: ${source} expects an integer >= 1, got ${JSON.stringify(raw)}`);
165
+ }
166
+ return n;
167
+ }
168
+ function canonicalConfigPath() {
169
+ const path = process.env.OPENCLAW_CONFIG_PATH;
170
+ if (!path)
171
+ throw new Error("openclaw-test: OPENCLAW_CONFIG_PATH is unset");
172
+ return path;
173
+ }
174
+ function buildWorkerContexts(count) {
175
+ const projectDir = process.env.OPENCLAW_TEST_PROJECT_DIR ?? process.cwd();
176
+ const logsRoot = process.env.OPENCLAW_TEST_GATEWAY_LOGS_DIR;
177
+ const contexts = [];
178
+ for (let i = 1; i <= count; ++i) {
179
+ const composeArgs = workerComposeArgs(i);
180
+ contexts.push({
181
+ index: i,
182
+ composeArgs,
183
+ gatewayLogsDir: join(logsRoot, `w${i}`),
184
+ workspaceDir: join(projectDir, ".workers", `w${i}`, "workspace"),
185
+ wasRunningBefore: areBusAndGatewayRunning(composeArgs),
186
+ });
187
+ }
188
+ return contexts;
189
+ }
190
+ function workerComposeArgs(index) {
191
+ return [...composeBaseArgs(), "-p", `${projectNameBase()}-w${index}`];
192
+ }
193
+ function projectNameBase() {
194
+ const projectDir = process.env.OPENCLAW_TEST_PROJECT_DIR ?? process.cwd();
195
+ return sanitizeProjectName(basename(projectDir));
196
+ }
197
+ // Compose project-name rules: lowercase, [a-z0-9_-], must start alphanumeric.
198
+ function sanitizeProjectName(name) {
199
+ const sanitized = name
200
+ .toLowerCase()
201
+ .replace(/[^a-z0-9_-]/g, "-")
202
+ .replace(/^[^a-z0-9]+/, "");
203
+ return sanitized === "" ? "openclaw-test" : sanitized;
204
+ }
205
+ function refreshWorkerWorkspace(worker) {
206
+ const src = process.env.OPENCLAW_WORKSPACE_DIR;
207
+ if (!src)
208
+ throw new Error("openclaw-test: OPENCLAW_WORKSPACE_DIR is unset");
209
+ rmSync(worker.workspaceDir, { recursive: true, force: true });
210
+ cpSync(src, worker.workspaceDir, { recursive: true });
211
+ }
212
+ /** Tear down every `<base>-w<N>` Compose project, whatever K was. Idempotent. */
213
+ function sweepWorkerStacks() {
214
+ let exitCode = 0;
215
+ for (const name of listWorkerStackNames()) {
216
+ const code = execComposeSync([...composeBaseArgs(), "-p", name, "down"]);
217
+ if (code !== 0)
218
+ exitCode = code;
219
+ }
220
+ return exitCode;
221
+ }
222
+ function listWorkerStackNames() {
223
+ const r = spawnSync("docker", ["compose", "ls", "--all", "--format", "json"], {
224
+ encoding: "utf8",
225
+ });
226
+ if (r.status !== 0)
227
+ return [];
228
+ let projects;
229
+ try {
230
+ projects = JSON.parse(r.stdout);
231
+ }
232
+ catch {
233
+ return [];
234
+ }
235
+ // The sanitized base contains no regex metacharacters (only [a-z0-9_-]).
236
+ const pattern = new RegExp(`^${projectNameBase()}-w\\d+$`);
237
+ return projects
238
+ .map((p) => p.Name)
239
+ .filter((name) => typeof name === "string" && pattern.test(name));
240
+ }
241
+ // Every service declares the same build and image. Target one representative service so Compose
242
+ // does not build bus, gateway and runner concurrently onto the shared tag.
243
+ function ensureConsumerImage(composeArgs) {
244
+ const image = process.env.OPENCLAW_TEST_CONSUMER_IMAGE;
245
+ const inspect = spawnSync("docker", ["image", "inspect", image], { stdio: "ignore" });
246
+ if (inspect.status === 0)
247
+ return;
248
+ const code = execComposeSync(buildConsumerImageArgs(composeArgs));
249
+ if (code !== 0)
250
+ process.exit(code);
251
+ }
252
+ /**
253
+ * OpenClaw has no native env interpolation in its config, so the harness expands
254
+ * `${VAR}` itself. Walk the canonical config, replace any string value of the
255
+ * exact form `${VAR}` with the value of `VAR` (process.env wins over `.env.local`),
256
+ * and write the rendered JSON to a run-scoped temp dir so the expanded secret is
257
+ * never persisted to a committed or artifact path. Returns the rendered path; the
258
+ * canonical file is never mutated and `process.env` is never repointed — callers
259
+ * pass the path to worker spawns via per-spawn env.
260
+ *
261
+ * When a `model` is given, `agents.entries.main.model` is set to its full ref so
262
+ * the gateway boots on the selected model. Rendered per model (own temp file).
263
+ *
264
+ * A `${VAR}` resolving to empty drops its enclosing `models.providers.*` entry so
265
+ * a defined-but-unused provider with no key can't trip OpenClaw boot validation.
266
+ */
267
+ function renderRuntimeConfig(canonicalPath, model) {
268
+ const projectDir = process.env.OPENCLAW_TEST_PROJECT_DIR ?? process.cwd();
269
+ const dotenv = readDotenvFile(projectDir);
270
+ const config = JSON.parse(readFileSync(canonicalPath, "utf8"));
271
+ const rendered = expandEnvRefs(config, dotenv);
272
+ const droppedProviders = dropProvidersMissingKey(rendered);
273
+ if (model) {
274
+ assertModelProviderPresent(model, droppedProviders);
275
+ setMainAgentModel(rendered, model.ref);
276
+ }
277
+ const dir = mkdtempSync(join(tmpdir(), "openclaw-test-config-"));
278
+ const renderedPath = join(dir, "openclaw.json");
279
+ writeFileSync(renderedPath, JSON.stringify(rendered, null, 2));
280
+ return renderedPath;
281
+ }
282
+ // A model's provider is the ref's first segment (`provider/model`); built-in
283
+ // providers (e.g. `anthropic`) aren't declared in config and never get dropped.
284
+ // Fail fast only when the selected model's provider was declared but dropped for
285
+ // an empty key — otherwise `setMainAgentModel` would point `main` at a missing
286
+ // provider and the gateway would boot-fail with an opaque error.
287
+ function assertModelProviderPresent(model, droppedProviders) {
288
+ const provider = model.ref.split("/")[0];
289
+ if (droppedProviders.has(provider)) {
290
+ throw new Error(`openclaw-test: selected model "${model.ref}" needs provider "${provider}", but its API key expanded empty. Set the provider's API key in .env.local.`);
291
+ }
292
+ }
293
+ function setMainAgentModel(config, ref) {
294
+ const agents = config.agents;
295
+ const agentsObj = agents && typeof agents === "object"
296
+ ? agents
297
+ : undefined;
298
+ if (Array.isArray(agentsObj?.list)) {
299
+ throw new Error("openclaw-test: config uses the retired agents.list array; migrate openclaw.json to agents.entries keyed by agent id (agents.entries.main)");
300
+ }
301
+ const entries = agentsObj?.entries;
302
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
303
+ throw new Error("openclaw-test: config has no agents.entries object");
304
+ }
305
+ const main = entries.main;
306
+ if (!main || typeof main !== "object" || Array.isArray(main)) {
307
+ throw new Error("openclaw-test: config has no agents.entries.main object");
308
+ }
309
+ main.model = ref;
310
+ }
311
+ const ENV_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
312
+ function expandEnvRefs(value, dotenv) {
313
+ if (typeof value === "string") {
314
+ const m = value.match(ENV_REF);
315
+ if (!m)
316
+ return value;
317
+ const name = m[1];
318
+ const resolved = process.env[name] ?? dotenv[name];
319
+ if (resolved === undefined) {
320
+ console.warn(`openclaw-test: config references unset env var ${name}; expanding to ""`);
321
+ return "";
322
+ }
323
+ return resolved;
324
+ }
325
+ if (Array.isArray(value))
326
+ return value.map((v) => expandEnvRefs(v, dotenv));
327
+ if (value && typeof value === "object") {
328
+ const out = {};
329
+ for (const [k, v] of Object.entries(value))
330
+ out[k] = expandEnvRefs(v, dotenv);
331
+ return out;
332
+ }
333
+ return value;
334
+ }
335
+ // On the rendered config, an empty `apiKey` means its `${VAR}` reference expanded
336
+ // to an unset value. Drop the whole provider so boot validation for a
337
+ // defined-but-unused provider can't fail on the empty key.
338
+ function dropProvidersMissingKey(config) {
339
+ const dropped = new Set();
340
+ const models = config.models;
341
+ if (!models || typeof models !== "object")
342
+ return dropped;
343
+ const providers = models.providers;
344
+ if (!providers || typeof providers !== "object")
345
+ return dropped;
346
+ for (const [id, provider] of Object.entries(providers)) {
347
+ if (provider &&
348
+ typeof provider === "object" &&
349
+ provider.apiKey === "") {
350
+ delete providers[id];
351
+ dropped.add(id);
352
+ }
353
+ }
354
+ return dropped;
355
+ }
356
+ const BASE_IMAGE_NAME = "paleo/openclaw-test-base";
357
+ function setBaseTag(packageDir) {
358
+ process.env.OPENCLAW_TEST_BASE_TAG = readPackageVersion(packageDir);
359
+ }
360
+ // Build (or reuse) the consumer-agnostic base image. Tagged with the openclaw-test
361
+ // package version so consumer Dockerfiles can pin via the OPENCLAW_TEST_BASE_TAG
362
+ // build arg. `force` always rebuilds — Docker's layer cache makes no-op
363
+ // rebuilds near-free, so we skip the inspect dance on `env build`.
364
+ function ensureBaseImage(packageDir, opts) {
365
+ const tag = `${BASE_IMAGE_NAME}:${process.env.OPENCLAW_TEST_BASE_TAG}`;
366
+ if (!opts.force) {
367
+ const inspect = spawnSync("docker", ["image", "inspect", tag], { stdio: "ignore" });
368
+ if (inspect.status === 0)
369
+ return false;
370
+ }
371
+ const dockerfile = resolve(packageDir, "Dockerfile.base");
372
+ const args = [
373
+ "build",
374
+ "-f",
375
+ dockerfile,
376
+ "-t",
377
+ tag,
378
+ "--build-arg",
379
+ `ASSISTANT_UID=${process.env.ASSISTANT_UID}`,
380
+ "--build-arg",
381
+ `ASSISTANT_GID=${process.env.ASSISTANT_GID}`,
382
+ packageDir,
383
+ ];
384
+ const r = spawnSync("docker", args, { stdio: "inherit" });
385
+ if (r.status !== 0) {
386
+ console.error(`base image build failed (tag ${tag})`);
387
+ process.exit(r.status ?? 1);
388
+ }
389
+ return true;
390
+ }
391
+ function readPackageVersion(packageDir) {
392
+ const pkg = JSON.parse(readFileSync(resolve(packageDir, "package.json"), "utf8"));
393
+ if (!pkg.version) {
394
+ console.error("openclaw-test: package.json is missing 'version'");
395
+ process.exit(1);
396
+ }
397
+ return pkg.version;
398
+ }
399
+ function setupHostEnv(packageDir) {
400
+ const projectDir = process.env.OPENCLAW_TEST_PROJECT_DIR ?? process.cwd();
401
+ process.env.OPENCLAW_TEST_PROJECT_DIR = projectDir;
402
+ process.env.OPENCLAW_TEST_PACKAGE_DIR ??= packageDir;
403
+ process.env.OPENCLAW_TEST_CONSUMER_IMAGE ??= `${sanitizeProjectName(basename(projectDir))}-openclaw-test:latest`;
404
+ if (!process.env.ASSISTANT_UID)
405
+ process.env.ASSISTANT_UID = String(process.getuid?.() ?? 1000);
406
+ if (!process.env.ASSISTANT_GID)
407
+ process.env.ASSISTANT_GID = String(process.getgid?.() ?? 1000);
408
+ absolutizePathVarsFromEnvFile(projectDir);
409
+ applyPathDefaults(projectDir);
410
+ }
411
+ // Defaults relative to the consumer's project dir, applied after `.env.local` so
412
+ // explicit values win. Doing this in the CLI (not via `${VAR:-default}` in
413
+ // docker-compose.yml) avoids nested Compose interpolation, which is fragile
414
+ // across versions and not portable across Compose implementations.
415
+ const PATH_DEFAULTS = {
416
+ OPENCLAW_CONFIG_PATH: "openclaw.json",
417
+ OPENCLAW_TEST_SCENARIOS_DIR: "scenarios",
418
+ OPENCLAW_TEST_ARTIFACTS_DIR: "artifacts",
419
+ OPENCLAW_TEST_GATEWAY_LOGS_DIR: ".gateway-logs",
420
+ };
421
+ function applyPathDefaults(projectDir) {
422
+ for (const [key, rel] of Object.entries(PATH_DEFAULTS)) {
423
+ if (!process.env[key])
424
+ process.env[key] = resolve(projectDir, rel);
425
+ }
426
+ }
427
+ // Pre-create the host-side output dirs (including each worker's gateway logs dir)
428
+ // as the current user, before any `docker compose up`. Otherwise the Docker
429
+ // daemon auto-creates a missing bind-mount source as root, which the container —
430
+ // running as the host UID — can then neither write nor let the user delete
431
+ // without sudo. Idempotent; safe to call on every command that brings a stack up.
432
+ function ensureHostOutputDirs(workers) {
433
+ for (const key of ["OPENCLAW_TEST_ARTIFACTS_DIR", "OPENCLAW_TEST_GATEWAY_LOGS_DIR"]) {
434
+ const dir = process.env[key];
435
+ if (dir)
436
+ mkdirSync(dir, { recursive: true });
437
+ }
438
+ for (const worker of workers)
439
+ mkdirSync(worker.gatewayLogsDir, { recursive: true });
440
+ }
441
+ /**
442
+ * Absolutize path vars from `.env.local` against the consumer's project dir.
443
+ *
444
+ * Compose `include:` resolves relative bind-mount paths against the declaring
445
+ * file (here, `node_modules/@alignfirst/openclaw-test/`), not the consumer's
446
+ * project dir. Exporting absolute paths via `process.env` sidesteps that.
447
+ */
448
+ function absolutizePathVarsFromEnvFile(projectDir) {
449
+ const parsed = readDotenvFile(projectDir);
450
+ for (const key of PATH_VARS) {
451
+ // Shell env already wins over --env-file; only act when .env.local supplies a relative path.
452
+ if (process.env[key])
453
+ continue;
454
+ const raw = parsed[key];
455
+ if (!raw)
456
+ continue;
457
+ process.env[key] = isAbsolute(raw) ? raw : resolve(projectDir, raw);
458
+ }
459
+ }
460
+ // The non-path vars (models, parallel) are never exported by setupHostEnv.
461
+ // Read the shell env first (wins over --env-file), then fall back to .env.local.
462
+ function readEnvVar(key) {
463
+ const fromShell = process.env[key];
464
+ if (fromShell !== undefined && fromShell !== "")
465
+ return fromShell;
466
+ const projectDir = process.env.OPENCLAW_TEST_PROJECT_DIR ?? process.cwd();
467
+ const fromFile = readDotenvFile(projectDir)[key];
468
+ return fromFile !== undefined && fromFile !== "" ? fromFile : undefined;
469
+ }
470
+ function readDotenvFile(projectDir) {
471
+ const envFile = resolve(projectDir, ".env.local");
472
+ if (!existsSync(envFile))
473
+ return {};
474
+ return parseDotenv(readFileSync(envFile, "utf8"));
475
+ }
476
+ function parseDotenv(text) {
477
+ const out = {};
478
+ for (const line of text.split("\n")) {
479
+ const trimmed = line.trim();
480
+ if (!trimmed || trimmed.startsWith("#"))
481
+ continue;
482
+ const eq = trimmed.indexOf("=");
483
+ if (eq < 1)
484
+ continue;
485
+ const key = trimmed.slice(0, eq).trim();
486
+ let val = trimmed.slice(eq + 1).trim();
487
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
488
+ val = val.slice(1, -1);
489
+ }
490
+ out[key] = val;
491
+ }
492
+ return out;
493
+ }
494
+ function composeBaseArgs() {
495
+ const projectDir = process.env.OPENCLAW_TEST_PROJECT_DIR ?? process.cwd();
496
+ const composeFile = resolve(projectDir, "docker-compose.yml");
497
+ const envFile = resolve(projectDir, ".env.local");
498
+ const args = ["compose"];
499
+ if (existsSync(envFile))
500
+ args.push("--env-file", envFile);
501
+ args.push("-f", composeFile);
502
+ return args;
503
+ }
504
+ function areBusAndGatewayRunning(composeArgs) {
505
+ const r = spawnSync("docker", [...composeArgs, "ps", "--services", "--filter", "status=running"], {
506
+ encoding: "utf8",
507
+ });
508
+ if (r.status !== 0)
509
+ return false;
510
+ const services = new Set(r.stdout
511
+ .split("\n")
512
+ .map((s) => s.trim())
513
+ .filter(Boolean));
514
+ return services.has("bus") && services.has("gateway");
515
+ }
516
+ function execComposeSync(args) {
517
+ const result = spawnSync("docker", args, { stdio: "inherit" });
518
+ if (result.error) {
519
+ console.error(result.error.message);
520
+ return 1;
521
+ }
522
+ return result.status ?? 1;
523
+ }
524
+ function execCompose(args, env) {
525
+ return new Promise((resolveExit) => {
526
+ const child = spawn("docker", args, {
527
+ stdio: "inherit",
528
+ env: env ? { ...process.env, ...env } : process.env,
529
+ });
530
+ child.on("exit", (code) => resolveExit(code ?? 1));
531
+ child.on("error", (err) => {
532
+ console.error(err.message);
533
+ resolveExit(1);
534
+ });
535
+ });
536
+ }
537
+ function requireFlagValue(flag, raw) {
538
+ if (raw === undefined || raw.startsWith("--")) {
539
+ failRun(`error: ${flag} expects a value`);
540
+ }
541
+ return raw;
542
+ }
543
+ function parseIntFlag(flag, raw, min) {
544
+ const n = Number(raw);
545
+ if (!Number.isInteger(n) || n < min) {
546
+ failRun(`error: ${flag} expects an integer >= ${min}, got ${JSON.stringify(raw)}`);
547
+ }
548
+ return n;
549
+ }
550
+ function parseRunArgs(argv) {
551
+ let channel;
552
+ let model;
553
+ let iterations = 1;
554
+ let maxFailures = 1;
555
+ let parallel;
556
+ let stopOnFail = false;
557
+ let reuseStack = false;
558
+ let all = false;
559
+ const positionals = [];
560
+ for (let i = 0; i < argv.length; i++) {
561
+ const a = argv[i];
562
+ if (a === "-h" || a === "--help")
563
+ failRun();
564
+ else if (a === "--all")
565
+ all = true;
566
+ else if (a === "--reuse-stack")
567
+ reuseStack = true;
568
+ else if (a === "--stop-on-fail")
569
+ stopOnFail = true;
570
+ else if (a === "--channel")
571
+ channel = requireFlagValue("--channel", argv[++i]);
572
+ else if (a?.startsWith("--channel="))
573
+ channel = a.slice("--channel=".length);
574
+ else if (a === "--model")
575
+ model = requireFlagValue("--model", argv[++i]);
576
+ else if (a?.startsWith("--model="))
577
+ model = a.slice("--model=".length);
578
+ else if (a === "--iterations")
579
+ iterations = parseIntFlag("--iterations", argv[++i] ?? "", 1);
580
+ else if (a?.startsWith("--iterations="))
581
+ iterations = parseIntFlag("--iterations", a.slice("--iterations=".length), 1);
582
+ else if (a === "--max-failures")
583
+ maxFailures = parseIntFlag("--max-failures", argv[++i] ?? "", 0);
584
+ else if (a?.startsWith("--max-failures="))
585
+ maxFailures = parseIntFlag("--max-failures", a.slice("--max-failures=".length), 0);
586
+ else if (a === "--parallel")
587
+ parallel = requireFlagValue("--parallel", argv[++i]);
588
+ else if (a?.startsWith("--parallel="))
589
+ parallel = a.slice("--parallel=".length);
590
+ else if (a?.startsWith("--"))
591
+ failRun(`error: unknown flag ${a}`);
592
+ else if (a)
593
+ positionals.push(a);
594
+ }
595
+ if (!channel || channel.length === 0)
596
+ failRun("error: --channel is required");
597
+ if (all && positionals.length > 0)
598
+ failRun("error: pass either --all or a positional scenario list, not both");
599
+ if (!all && positionals.length === 0)
600
+ failRun("error: must pass --all or one or more scenario names");
601
+ return {
602
+ channel: channel,
603
+ model,
604
+ iterations,
605
+ maxFailures,
606
+ parallel,
607
+ stopOnFail,
608
+ reuseStack,
609
+ all,
610
+ positionals,
611
+ };
612
+ }
613
+ function failRun(msg) {
614
+ if (msg)
615
+ console.error(msg);
616
+ console.error(RUN_USAGE);
617
+ process.exit(1);
618
+ }
619
+ /** Returns the raw `--parallel` value; only `env up` accepts it. */
620
+ function parseEnvArgs(sub, rest) {
621
+ let parallel;
622
+ for (let i = 0; i < rest.length; i++) {
623
+ const a = rest[i];
624
+ if (a === "--parallel") {
625
+ const raw = rest[++i];
626
+ if (raw === undefined || raw.startsWith("--"))
627
+ failEnv("error: --parallel expects a value");
628
+ parallel = raw;
629
+ }
630
+ else if (a.startsWith("--parallel="))
631
+ parallel = a.slice("--parallel=".length);
632
+ else
633
+ failEnv(`error: unknown argument ${a}`);
634
+ }
635
+ if (parallel !== undefined && sub !== "up") {
636
+ failEnv("error: --parallel is only valid on 'env up'");
637
+ }
638
+ return parallel;
639
+ }
640
+ function failEnv(msg) {
641
+ if (msg)
642
+ console.error(msg);
643
+ console.error(ENV_USAGE);
644
+ process.exit(1);
645
+ }
@@ -0,0 +1,13 @@
1
+ export declare const IPC_DIR = "/var/run/openclaw-test-ipc";
2
+ export interface ExecInGatewayOptions {
3
+ cwd?: string;
4
+ env?: Record<string, string>;
5
+ stdin?: string;
6
+ timeoutMs?: number;
7
+ }
8
+ export interface ExecInGatewayResult {
9
+ exitCode: number;
10
+ stdout: string;
11
+ stderr: string;
12
+ }
13
+ export declare function execInGateway(argv: string[], opts?: ExecInGatewayOptions): Promise<ExecInGatewayResult>;