Files
software-mansion__argent/packages/tool-server/test/flows/flow-chromium-boot.test.ts
Jakub Piasecki 1a4f89cd5f feat(flow): allow to compose all flows via run: (#507)
## Summary

`run:` accepted **fragments only** — an e2e flow (one starting with
`launch:`)
was rejected as a `run:` target. This lifts that: an e2e flow can now be
nested
via `run:`, its `launch` running inline (a real terminate + relaunch on
native).
Composition becomes uniform — any flow can be a reusable building block.

## What changed

- **`flow-run.ts`**: dropped the `isE2eFlow` rejection in `execRunStep`;
cycle /
  `MAX_RUN_DEPTH` guards still bound recursion.
- **`flow-add-step.ts`**: the recorder now captures a recorded
`flow-execute` of
an e2e sibling as a portable `run: <name>` directive (was: raw `tool:`
step).
- **`flow-utils.ts`, `argent-create-flow/SKILL.md`**: updated docs/skill
that
  said e2e flows can't be `run:` targets.

## Known limitation — Chromium

Chromium `launch` boots an Electron app from a *path*, wired to the
top-level
flow only — so a nested **chromium** e2e flow doesn't boot its own app.
Native
nesting is correct; chromium nested e2e is unsupported.

## Testing

Rewrote the two affected tests (composition + recorder). Full flows
suite green
(349 tests); `tsc --noEmit` clean.
2026-07-17 14:18:32 +02:00

294 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { Registry } from "@argent/registry";
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
// The runner boots a Chromium e2e flow's app itself. Mock the Electron
// launcher so we can assert on how it's called + torn down without spawning a
// real process, and mock the port registry so teardown touches no on-disk state.
const bootElectronApp = vi.fn(async (opts: { appPath: string; extraArgs?: string[] }) => ({
platform: "chromium" as const,
id: "chromium-cdp-12345",
port: 12345,
pid: 4242,
appPath: opts.appPath,
booted: true as const,
}));
const killChromiumByPort = vi.fn();
vi.mock("../../src/tools/devices/boot-electron", () => ({
bootElectronApp: (...args: unknown[]) =>
(bootElectronApp as (...a: unknown[]) => unknown)(...args),
killChromiumByPort: (...args: unknown[]) =>
(killChromiumByPort as (...a: unknown[]) => unknown)(...args),
}));
vi.mock("../../src/utils/chromium-discovery", () => ({ untrackChromiumPort: vi.fn() }));
const PROJECT_ROOT = "/proj";
// Mock registry: invokeTool returns a canned result; the CDP resolveService
// throws by default — page-fronting swallows that, and a test that needs a
// reachable CDP api (the pinned-device attach) overrides it; getSnapshot is
// empty so teardown skips disposing a session; getTool is a stub.
function makeRegistry(invoke: (id: string, args: unknown) => Promise<unknown> = async () => ({})) {
return {
invokeTool: vi.fn(invoke),
getTool: vi.fn(() => undefined),
resolveService: vi.fn(async () => {
throw new Error("no cdp session in test");
}),
getSnapshot: vi.fn(() => ({ services: new Map() })),
disposeService: vi.fn(async () => {}),
} as unknown as Registry;
}
const writtenFiles: string[] = [];
async function writeFlow(yaml: string): Promise<string> {
const file = path.join(
os.tmpdir(),
`flow-chromium-boot-${writtenFiles.length}-${process.pid}.yaml`
);
await fs.writeFile(file, yaml, "utf8");
writtenFiles.push(file);
return file;
}
// A run: target must be a sibling named `<name>.yaml` (the runner resolves it
// against the parent flow's directory), so write it next to `parent` under a
// caller-chosen name.
async function writeSiblingFlow(parent: string, name: string, yaml: string): Promise<void> {
const file = path.join(path.dirname(parent), `${name}.yaml`);
await fs.writeFile(file, yaml, "utf8");
writtenFiles.push(file);
}
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
if (!("steps" in r)) throw new Error(`expected a FlowRunResult, got a notice: ${r.notice}`);
return r;
}
async function runFlow(
registry: Registry,
params: Record<string, unknown>
): Promise<FlowRunResult> {
// The flow file deliberately lives outside project_root (it pins the
// flow-relative app-path anchor), which the containment check only allows
// for a boundary-materialized upload — mark it as one, like a remote
// client's call would be.
const ctx = {
fileInputs: {
flow_file: { clientPath: String(params.flow_file), presentOnHost: false, viaUpload: true },
},
};
return asRun(await createRunFlowTool(registry).execute({}, params as never, ctx as never));
}
beforeEach(() => {
bootElectronApp.mockClear();
killChromiumByPort.mockClear();
});
afterEach(async () => {
await Promise.all(writtenFiles.splice(0).map((f) => fs.rm(f, { force: true })));
});
describe("flow-execute chromium boot", () => {
it("boots a fresh instance for a chromium-only e2e flow and tears it down", async () => {
const flowFile = await writeFlow("steps:\n - launch: { chromium: ./app }\n - echo: done\n");
const registry = makeRegistry();
const result = await runFlow(registry, {
name: "chromium-e2e",
project_root: PROJECT_ROOT,
flow_file: flowFile,
});
// Booted once, from the app path resolved against the flow file's directory —
// NOT project_root (they differ here: project_root is "/proj", the flow lives
// in os.tmpdir()), so this pins the flow-relative anchor.
expect(bootElectronApp).toHaveBeenCalledTimes(1);
expect(bootElectronApp.mock.calls[0][0]).toEqual({
appPath: path.join(path.dirname(flowFile), "app"),
extraArgs: undefined,
});
expect(bootElectronApp.mock.calls[0][0].appPath).not.toBe(path.join(PROJECT_ROOT, "app"));
// The run targets the freshly-booted device; the launch step passes without
// relaunching through a tool (it just settles the fresh window).
expect(result.device).toBe("chromium-cdp-12345");
expect(result.ok).toBe(true);
expect(result.steps[0]).toMatchObject({ kind: "launch", status: "pass" });
const invokedTools = (registry.invokeTool as any).mock.calls.map((c: unknown[]) => c[0]);
expect(invokedTools).not.toContain("launch-app");
expect(invokedTools).not.toContain("restart-app");
// Teardown kills the instance the runner booted — port first (the handle
// registry key), pid as the raw fallback.
expect(killChromiumByPort).toHaveBeenCalledWith(12345, 4242);
});
it("forwards extra CLI args and boots when --platform chromium disambiguates a multi-platform launch", async () => {
const flowFile = await writeFlow(
"steps:\n - launch: { ios: com.acme.app, chromium: { path: ./app, args: [--e2e] } }\n"
);
const registry = makeRegistry();
const result = await runFlow(registry, {
name: "multi",
project_root: PROJECT_ROOT,
flow_file: flowFile,
platform: "chromium",
});
expect(bootElectronApp).toHaveBeenCalledTimes(1);
expect(bootElectronApp.mock.calls[0][0]).toEqual({
appPath: path.join(path.dirname(flowFile), "app"),
extraArgs: ["--e2e"],
});
expect(result.ok).toBe(true);
expect(killChromiumByPort).toHaveBeenCalledWith(12345, 4242);
});
it("takes an absolute launch path as-is", async () => {
const flowFile = await writeFlow("steps:\n - launch: { chromium: /abs/app }\n");
const registry = makeRegistry();
await runFlow(registry, { name: "abs", project_root: PROJECT_ROOT, flow_file: flowFile });
expect(bootElectronApp.mock.calls[0][0]).toMatchObject({ appPath: "/abs/app" });
});
it("does not boot or tear down when an explicit --device pins an existing instance", async () => {
const flowFile = await writeFlow("steps:\n - launch: { chromium: ./app }\n");
const registry = makeRegistry();
const refreshViewport = vi.fn(async () => ({ width: 800, height: 600 }));
(registry.resolveService as any).mockImplementation(async () => ({
refreshViewport,
cdp: { send: vi.fn(async () => ({})) },
}));
const result = await runFlow(registry, {
name: "pinned",
project_root: PROJECT_ROOT,
flow_file: flowFile,
device: "chromium-cdp-9999",
});
// Explicit device: attach, never boot/teardown.
expect(bootElectronApp).not.toHaveBeenCalled();
expect(killChromiumByPort).not.toHaveBeenCalled();
expect(result.device).toBe("chromium-cdp-9999");
// The launch step attaches in place over CDP (viewport refresh). It must
// NOT route the launch value through launch-app: on chromium that value is
// an app *path*, which launch-app's bundleId grammar rejects under the real
// registry's input validation.
const invokedTools = (registry.invokeTool as any).mock.calls.map((c: unknown[]) => c[0]);
expect(invokedTools).not.toContain("launch-app");
expect(refreshViewport).toHaveBeenCalled();
expect(result.ok).toBe(true);
expect(result.steps[0]).toMatchObject({ kind: "launch", status: "pass" });
});
it("errors a nested e2e flow's launch (chromium boots only the top-level app) and keeps the first working", async () => {
// Parent chromium e2e flow that runs a nested chromium e2e flow. Chromium
// boots exactly one app for the run (the parent's), so the nested launch
// can't boot its own instance — it must fail loudly, not silently pass
// against the already-launched app. The parent's own launch still works.
const parent = await writeFlow(
"steps:\n - launch: { chromium: ./app-a }\n - run: nested-chromium\n"
);
await writeSiblingFlow(
parent,
"nested-chromium",
"steps:\n - launch: { chromium: ./app-b }\n - echo: should never run\n"
);
const registry = makeRegistry();
const result = await runFlow(registry, {
name: "parent-chromium",
project_root: PROJECT_ROOT,
flow_file: parent,
});
// Only the parent's app booted — the nested launch never spawned a second
// instance.
expect(bootElectronApp).toHaveBeenCalledTimes(1);
expect(result.ok).toBe(false);
// parent launch passes; run marker passes; nested launch errors; the rest skip.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"launch:pass",
"run:pass",
"launch:error",
"echo:skip",
]);
const nestedLaunch = result.steps[2];
expect(nestedLaunch.flow).toBe("nested-chromium");
expect(nestedLaunch.reason).toMatch(/chromium launches only the top-level flow's app/i);
// The one booted instance is still torn down cleanly on the failing run.
expect(killChromiumByPort).toHaveBeenCalledWith(12345, 4242);
});
it("still honors the first launch when a fragment run:s an e2e flow (the common composition)", async () => {
// Fragment B (no leading launch) that run:s e2e flow A (launch + setup).
// A's launch is the FIRST launch of the run, so the once-per-run guard does
// not fire — it attaches to the pinned instance and passes. Only a *second*
// launch is rejected. Uses a pinned device: a fragment top-level means the
// runner boots nothing itself, so an already-running instance is required.
const fragmentB = await writeFlow("steps:\n - run: setup-a\n - echo: B after A\n");
await writeSiblingFlow(
fragmentB,
"setup-a",
"steps:\n - launch: { chromium: ./app }\n - echo: A setup done\n"
);
const registry = makeRegistry();
const refreshViewport = vi.fn(async () => ({ width: 800, height: 600 }));
(registry.resolveService as any).mockImplementation(async () => ({
refreshViewport,
cdp: { send: vi.fn(async () => ({})) },
}));
const result = await runFlow(registry, {
name: "fragment-b",
project_root: PROJECT_ROOT,
flow_file: fragmentB,
device: "chromium-cdp-9999",
});
// Fragment top-level: the runner never boots (nor tears down) an instance;
// A's launch attaches to the pinned one instead.
expect(bootElectronApp).not.toHaveBeenCalled();
expect(killChromiumByPort).not.toHaveBeenCalled();
expect(refreshViewport).toHaveBeenCalledTimes(1);
expect(result.ok).toBe(true);
// run marker, then A's launch (honored) + echo, then B's trailing echo.
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
"run:pass",
"launch:pass",
"echo:pass",
"echo:pass",
]);
});
it("errors the launch step (and skips the rest) when the pinned instance is unreachable", async () => {
const flowFile = await writeFlow("steps:\n - launch: { chromium: ./app }\n - echo: after\n");
const registry = makeRegistry(); // resolveService throws: no CDP session behind the pinned id
const result = await runFlow(registry, {
name: "pinned-dead",
project_root: PROJECT_ROOT,
flow_file: flowFile,
device: "chromium-cdp-9999",
});
expect(result.ok).toBe(false);
expect(result.steps[0]).toMatchObject({ kind: "launch", status: "error" });
expect(result.steps[0].reason).toContain(
'could not attach to chromium instance "chromium-cdp-9999"'
);
expect(result.steps[1]).toMatchObject({ kind: "echo", status: "skip" });
});
});