mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
d4a712c401
`settle: true` collided with the runner's other `settle` — waiting for the tree to quiesce — which is the very thing a swipe step does after every dispatch, whichever way the flag is set. One sentence of the flow skill used both senses at once. This names the physics instead: `momentum: false` is the momentum-free gesture, and the natural fling is the default the prose already described. ### Renamed | surface | before | after | | --- | --- | --- | | `swipe` flow directive | `settle: true` | `momentum: false` | | `gesture-swipe` param | `settle` | `momentum` | | `gesture-drag` param | `settle` | `momentum` | The directive passes the flag straight through to the tools, so all three move together. `momentum: true` is the default and normalizes to absent, mirroring what `settle: false` did — parse/serialize stay exact inverses. ### Also - Recording summary label `(settle)` → `(momentum-free)` - Internal constants → `MOMENTUM_FREE_EASE_EXPONENT`, `MOMENTUM_FREE_MIN_STEPS` - `flow-run` tool description and `argent-create-flow` skill updated ### Testing `tsc -b`, eslint (`--max-warnings 0`) and prettier clean; `test/flows` + `test/tools` pass 671/671, including a new case covering the legacy-spelling rejection.
562 lines
21 KiB
TypeScript
562 lines
21 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 type { DescribeNode, DescribeTreeData } from "../../src/tools/describe/contract";
|
|
|
|
// Cancel the run mid-directive by tripping an AbortController from inside the
|
|
// tree fetch itself: the mock counts reads and aborts on a scripted one, which
|
|
// lands the abort deterministically inside a directive's auto-wait / focus-wait
|
|
// poll (no timer races).
|
|
let currentFetch: () => DescribeTreeData;
|
|
vi.mock("../../src/tools/flows/flow-tree", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../../src/tools/flows/flow-tree")>()),
|
|
fetchFlowTree: vi.fn(async (): Promise<DescribeTreeData> => currentFetch()),
|
|
}));
|
|
|
|
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
|
|
import { serializeFlow } from "../../src/tools/flows/flow-utils";
|
|
|
|
const DEVICE = "00000000-0000-0000-0000-0000000000ab"; // iOS UDID shape
|
|
let tmpDir: string;
|
|
|
|
function n(partial: Partial<DescribeNode> & { frame: DescribeNode["frame"] }): DescribeNode {
|
|
return { role: "AXOther", children: [], ...partial };
|
|
}
|
|
|
|
function screen(children: DescribeNode[]): DescribeNode {
|
|
return n({ role: "AXWindow", frame: { x: 0, y: 0, width: 1, height: 1 }, children });
|
|
}
|
|
|
|
function mockRegistry(calls: string[]): Registry {
|
|
return {
|
|
invokeTool: vi.fn(async (id: string) => {
|
|
calls.push(id);
|
|
if (id === "list-devices") return { devices: [] };
|
|
return { ok: true };
|
|
}),
|
|
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
async function writeFlow(name: string, yaml: Parameters<typeof serializeFlow>[0]): Promise<void> {
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(path.join(dir, `${name}.yaml`), serializeFlow(yaml), "utf8");
|
|
}
|
|
|
|
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
|
|
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
|
|
return r;
|
|
}
|
|
|
|
async function run(
|
|
name: string,
|
|
registry: Registry,
|
|
signal: AbortSignal,
|
|
device = DEVICE
|
|
): Promise<FlowRunResult> {
|
|
return asRun(
|
|
await createRunFlowTool(registry).execute({}, { name, project_root: tmpDir, device }, {
|
|
signal,
|
|
} as never)
|
|
);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-abort-"));
|
|
});
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("run cancellation mid-directive", () => {
|
|
it("fails the verdict when an already-aborted run only contains echo narration", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
currentFetch = () => ({ tree: screen([]), source: "native-devtools" });
|
|
|
|
await writeFlow("cancelled-echo", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "echo", message: "never narrated" }],
|
|
});
|
|
|
|
const result = await run("cancelled-echo", mockRegistry([]), controller.signal);
|
|
|
|
expect(result.steps).toMatchObject([{ kind: "echo", status: "skip", reason: "run aborted" }]);
|
|
// Echo remains excluded from displayed counters, but cancellation still
|
|
// makes the run incomplete rather than a zero-step pass.
|
|
expect(result.skipped).toBe(0);
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("reports a tap cancelled during its auto-wait as a skip, not an offscreen failure", async () => {
|
|
const controller = new AbortController();
|
|
// The target never appears; the run is cancelled on the third tree read
|
|
// (i.e. while the tap's auto-wait is still polling).
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
reads++;
|
|
if (reads >= 3) controller.abort();
|
|
return {
|
|
tree: screen([n({ label: "Other", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-tap", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tap", selector: { text: "Checkout", loose: true } }],
|
|
});
|
|
|
|
const result = await run("cancelled-tap", mockRegistry(calls), controller.signal);
|
|
|
|
// A skip with the uniform abort reason — NOT a fail with the misleading
|
|
// "no visible element matched … add a scroll-to step" hint.
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(result.ok).toBe(false);
|
|
expect(calls).not.toContain("gesture-tap");
|
|
});
|
|
|
|
it("dispatches no tap when the run is cancelled during the settle-completing tree read", async () => {
|
|
const controller = new AbortController();
|
|
// The target is visible and the tree is stable, so read 2 is the read that
|
|
// completes the settle (two identical fingerprints). The abort lands inside
|
|
// that read — settleTree must NOT hand the tree back to waitForFrame, or the
|
|
// tap would be dispatched post-cancellation and recorded as a pass.
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
reads++;
|
|
if (reads >= 2) controller.abort();
|
|
return {
|
|
tree: screen([
|
|
n({ label: "Checkout", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } }),
|
|
]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-tap-settle", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tap", selector: { text: "Checkout", loose: true } }],
|
|
});
|
|
|
|
const result = await run("cancelled-tap-settle", mockRegistry(calls), controller.signal);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(result.ok).toBe(false);
|
|
expect(calls).not.toContain("gesture-tap");
|
|
});
|
|
|
|
it("dispatches no tap when the run is cancelled one read before the settle completes", async () => {
|
|
// Control for the settle-completing case above: aborting during the FIRST
|
|
// read (not yet a fingerprint match) already skipped correctly — keep it so.
|
|
const controller = new AbortController();
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
reads++;
|
|
if (reads >= 1) controller.abort();
|
|
return {
|
|
tree: screen([
|
|
n({ label: "Checkout", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } }),
|
|
]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-tap-first-read", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tap", selector: { text: "Checkout", loose: true } }],
|
|
});
|
|
|
|
const result = await run("cancelled-tap-first-read", mockRegistry(calls), controller.signal);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tap:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(calls).not.toContain("gesture-tap");
|
|
});
|
|
|
|
it("dispatches no scroll increment when the run is cancelled during a mid-scroll settle read", async () => {
|
|
const controller = new AbortController();
|
|
// The target never appears and the tree is stable — after read 2 settles,
|
|
// scroll-to would dispatch its first swipe increment. The abort lands inside
|
|
// that settle-completing read, so no gesture may follow it.
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
reads++;
|
|
if (reads >= 2) controller.abort();
|
|
return {
|
|
tree: screen([n({ label: "Row 1", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-scroll-settle", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "scroll-to", target: { text: "Order #1234" }, direction: "down" }],
|
|
});
|
|
|
|
const result = await run("cancelled-scroll-settle", mockRegistry(calls), controller.signal);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["scroll-to:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(calls).not.toContain("gesture-swipe");
|
|
expect(calls).not.toContain("gesture-scroll");
|
|
});
|
|
|
|
// gesture-swipe consults the abort signal per 16ms frame and rejects instead of
|
|
// holding a finger down for the rest of the duration. That rejection reaches the
|
|
// flow through invokeOnDevice and invokeSubTool, so every dispatch site has to
|
|
// map it back onto the uniform abort skip - otherwise a cancelled run reads as
|
|
// broken rather than cancelled.
|
|
function abortingGestureRegistry(
|
|
calls: string[],
|
|
controller: AbortController,
|
|
gestureTool = "gesture-swipe"
|
|
): Registry {
|
|
return {
|
|
invokeTool: vi.fn(async (id: string) => {
|
|
calls.push(id);
|
|
if (id === "list-devices") return { devices: [] };
|
|
if (id === gestureTool) {
|
|
controller.abort();
|
|
const err = new Error(
|
|
`${gestureTool} aborted - cancelled mid-gesture after 3 of 301 frames`
|
|
);
|
|
err.name = "AbortError";
|
|
throw err;
|
|
}
|
|
return { ok: true };
|
|
}),
|
|
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
it("reports a swipe cancelled inside the gesture as a skip, not the tool's error", async () => {
|
|
const controller = new AbortController();
|
|
currentFetch = () => ({
|
|
tree: screen([n({ label: "Row 1", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
});
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-mid-swipe", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "swipe", direction: "left" }],
|
|
});
|
|
|
|
const result = await run(
|
|
"cancelled-mid-swipe",
|
|
abortingGestureRegistry(calls, controller),
|
|
controller.signal
|
|
);
|
|
|
|
expect(calls).toContain("gesture-swipe");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["swipe:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(result.aborted).toBe(true);
|
|
});
|
|
|
|
it("reports a scroll-to cancelled inside its increment as a skip, not the tool's error", async () => {
|
|
const controller = new AbortController();
|
|
// The target never appears and the tree is stable, so the settle completes
|
|
// and scroll-to dispatches its first swipe increment - which is where the
|
|
// cancellation lands here, one layer deeper than the settle-read case above.
|
|
currentFetch = () => ({
|
|
tree: screen([n({ label: "Row 1", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
});
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-mid-increment", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "scroll-to", target: { text: "Order #1234" }, direction: "down" }],
|
|
});
|
|
|
|
const result = await run(
|
|
"cancelled-mid-increment",
|
|
abortingGestureRegistry(calls, controller),
|
|
controller.signal
|
|
);
|
|
|
|
expect(calls).toContain("gesture-swipe");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["scroll-to:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(result.aborted).toBe(true);
|
|
});
|
|
|
|
it("reports a raw tool step cancelled inside the gesture as a skip, not the tool's error", async () => {
|
|
const controller = new AbortController();
|
|
// The third dispatch site, and the one flow-yaml.md sends authors to for a
|
|
// flick faster than the swipe directive's floor: a raw `tool: gesture-swipe`
|
|
// step, whose signal invokeSubTool forwards to the tool itself.
|
|
currentFetch = () => ({ tree: screen([]), source: "native-devtools" });
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-raw-swipe", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{
|
|
kind: "tool",
|
|
name: "gesture-swipe",
|
|
args: { fromX: 0.5, fromY: 0.8, toX: 0.5, toY: 0.2, durationMs: 16 },
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await run(
|
|
"cancelled-raw-swipe",
|
|
abortingGestureRegistry(calls, controller),
|
|
controller.signal
|
|
);
|
|
|
|
expect(calls).toContain("gesture-swipe");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["tool:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
// Not the tool's own "aborted - cancelled mid-gesture after N of M frames".
|
|
expect(result.steps[0].reason).not.toMatch(/frames/);
|
|
expect(result.aborted).toBe(true);
|
|
});
|
|
|
|
it("reports a chromium long-press cancelled inside the hold as a skip, not the tool's error", async () => {
|
|
const controller = new AbortController();
|
|
// On chromium a long-press IS a gesture-drag with from == to, so the tool's
|
|
// per-frame abort reaches this dispatch site as well.
|
|
currentFetch = () => ({
|
|
tree: screen([n({ label: "Row 3", frame: { x: 0.1, y: 0.4, width: 0.8, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
});
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-mid-press", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "long-press", selector: { text: "Row 3", loose: true } }],
|
|
});
|
|
|
|
const result = await run(
|
|
"cancelled-mid-press",
|
|
abortingGestureRegistry(calls, controller, "gesture-drag"),
|
|
controller.signal,
|
|
"chromium-cdp-9222"
|
|
);
|
|
|
|
expect(calls).toContain("gesture-drag");
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["long-press:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(result.aborted).toBe(true);
|
|
});
|
|
|
|
it("dispatches no focus tap when a type step is cancelled during the settle-completing read", async () => {
|
|
const controller = new AbortController();
|
|
// Same timing as the tap case, but for `type`: the leak would be the focus
|
|
// tap (the keyboard dispatches are separately guarded already).
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
reads++;
|
|
if (reads >= 2) controller.abort();
|
|
return {
|
|
tree: screen([
|
|
n({ identifier: "email", frame: { x: 0.1, y: 0.2, width: 0.8, height: 0.06 } }),
|
|
]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-type-settle", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "type", into: { identifier: "email" }, text: "a@b.com" }],
|
|
});
|
|
|
|
const result = await run("cancelled-type-settle", mockRegistry(calls), controller.signal);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["type:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(calls).not.toContain("gesture-tap");
|
|
expect(calls).not.toContain("keyboard");
|
|
});
|
|
|
|
it("injects no keyboard input when the run is cancelled during the focus wait", async () => {
|
|
const controller = new AbortController();
|
|
// Reads 1-2 are the pre-tap settle (field resolves immediately); read 3 is
|
|
// the focus poll's first look — the field never reports focus, and the run
|
|
// is cancelled there.
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
reads++;
|
|
if (reads >= 3) controller.abort();
|
|
return {
|
|
tree: screen([
|
|
n({ identifier: "email", frame: { x: 0.1, y: 0.2, width: 0.8, height: 0.06 } }),
|
|
]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-type", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "type", into: { identifier: "email" }, text: "a@b.com" }],
|
|
});
|
|
|
|
const result = await run("cancelled-type", mockRegistry(calls), controller.signal);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["type:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
// The focus tap fired before the cancel, but neither the text nor the
|
|
// submitting Enter may reach the app afterwards.
|
|
expect(calls).toContain("gesture-tap");
|
|
expect(calls).not.toContain("keyboard");
|
|
});
|
|
|
|
it("attributes abort skips inside a fragment to the fragment, not the root", async () => {
|
|
const controller = new AbortController();
|
|
// The fragment's tap polls for a target that never appears; the run is
|
|
// cancelled on the third tree read, mid-auto-wait. The steps after the tap
|
|
// then hit execSteps' abort-skip branch (the run: line) and the hard-stop
|
|
// branch (the trailing echo) — with the fragment still on the run stack.
|
|
// Every skip line must carry the fragment's attribution, and the run: line
|
|
// its own target stem, identical to the executed and hard-stop paths.
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
reads++;
|
|
if (reads >= 3) controller.abort();
|
|
return {
|
|
tree: screen([n({ label: "Other", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
|
|
await writeFlow("other", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "echo", message: "never loaded" }],
|
|
});
|
|
await writeFlow("frag", {
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "tap", selector: { text: "Checkout", loose: true } },
|
|
{ kind: "run", flow: "other.yaml" },
|
|
{ kind: "echo", message: "fragment tail" },
|
|
],
|
|
});
|
|
await writeFlow("main", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "run", flow: "frag.yaml" }],
|
|
});
|
|
|
|
const result = await run("main", mockRegistry([]), controller.signal);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual([
|
|
"run:pass",
|
|
"tap:skip",
|
|
"run:skip",
|
|
"echo:skip",
|
|
]);
|
|
// The cancelled tap and the abort-skipped run: line report the uniform
|
|
// abort reason; the echo after them is a plain hard-stop skip.
|
|
expect(result.steps[1]).toMatchObject({ flow: "frag", reason: "run aborted" });
|
|
expect(result.steps[2]).toMatchObject({
|
|
flow: "other",
|
|
target: "other.yaml",
|
|
reason: "run aborted",
|
|
});
|
|
expect(result.steps[3]).toMatchObject({ flow: "frag", message: "fragment tail" });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("reports an await cancelled mid-poll as a skip with the uniform abort reason", async () => {
|
|
const controller = new AbortController();
|
|
let reads = 0;
|
|
currentFetch = () => {
|
|
reads++;
|
|
if (reads >= 3) controller.abort();
|
|
return {
|
|
tree: screen([n({ label: "Other", frame: { x: 0.1, y: 0.1, width: 0.8, height: 0.1 } })]),
|
|
source: "native-devtools",
|
|
};
|
|
};
|
|
const calls: string[] = [];
|
|
|
|
await writeFlow("cancelled-await", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "await", condition: "visible", selector: { identifier: "spinner" } }],
|
|
});
|
|
|
|
const result = await run("cancelled-await", mockRegistry(calls), controller.signal);
|
|
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["await:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
});
|
|
});
|
|
|
|
describe("run cancellation mid-launch", () => {
|
|
// Like mockRegistry, but the restart-app call runs a scripted hook first —
|
|
// tripping the abort deterministically inside the launch step.
|
|
function launchRegistry(calls: string[], onRestartApp: () => unknown): Registry {
|
|
return {
|
|
invokeTool: vi.fn(async (id: string) => {
|
|
calls.push(id);
|
|
if (id === "list-devices") return { devices: [] };
|
|
if (id === "restart-app") return onRestartApp();
|
|
return { ok: true };
|
|
}),
|
|
getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
it("reports a launch cancelled during the post-launch settle as a skip", async () => {
|
|
const controller = new AbortController();
|
|
const calls: string[] = [];
|
|
// restart-app succeeds, but the run is cancelled right after — the abort
|
|
// lands in the post-launch settle / tree-source gate.
|
|
const registry = launchRegistry(calls, () => {
|
|
controller.abort();
|
|
return { ok: true };
|
|
});
|
|
|
|
await writeFlow("cancelled-launch-settle", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "launch", app: "com.acme.app" }],
|
|
});
|
|
|
|
const result = await run("cancelled-launch-settle", registry, controller.signal);
|
|
|
|
// A skip with the uniform abort reason — NOT a pass: the settle and the
|
|
// tree-source gate were cut short, so the launch verified nothing.
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["launch:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(result.ok).toBe(false);
|
|
expect(calls).toContain("restart-app");
|
|
});
|
|
|
|
it("reports a launch cancelled during restart-app as a skip, not an error", async () => {
|
|
const controller = new AbortController();
|
|
const calls: string[] = [];
|
|
// The cancellation makes the restart-app sub-tool itself reject: that
|
|
// rejection is the abort, not an app failure.
|
|
const registry = launchRegistry(calls, () => {
|
|
controller.abort();
|
|
throw new Error("This operation was aborted");
|
|
});
|
|
|
|
await writeFlow("cancelled-launch-restart", {
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "launch", app: "com.acme.app" }],
|
|
});
|
|
|
|
const result = await run("cancelled-launch-restart", registry, controller.signal);
|
|
|
|
// A skip with the uniform abort reason — NOT an error blaming restart-app.
|
|
expect(result.steps.map((s) => `${s.kind}:${s.status}`)).toEqual(["launch:skip"]);
|
|
expect(result.steps[0].reason).toBe("run aborted");
|
|
expect(calls).toContain("restart-app");
|
|
});
|
|
});
|