Files
software-mansion__argent/packages/tool-server/test/flows/flow-launch-settle.test.ts
Filip131311 0b545c3c49 perf(flows): wait for the launched app to draw instead of sleeping 1.5s
Every `launch:` step slept a flat POST_LAUNCH_SETTLE_MS (1500ms) before the
run's first step, to give a cold start a head start. `restart-app` already
waits for the activity to be displayed (`am start -W`), so on any app that is
already drawing, that whole window was dead time.

The condition the sleep approximated is observable: the flow's own tree source
either shows the app's content or it does not. Poll it instead, with the same
1500ms as an upper bound — a warm app continues after one read, a genuinely
slow start still gets the full window.

The wait stays deliberately forgiving, so nothing that passed before can start
failing: a tree that never fills (a canvas or video first screen), or a read
that throws, spends the budget and carries on exactly as the sleep did. The
tree-source gate still runs first and is still what reports an unusable
source; this is a head start, not a gate.

Measured on an Android emulator (Pixel 9 API 35), 6-step flow against a fast
app, runs interleaved with the released build to cancel out host-load drift:

  baseline (sleep)   6.72s  6.79s  6.68s
  this change        5.27s  5.39s  5.07s

~1.45s saved per launch, i.e. the sleep minus the ~50-100ms the first tree
read costs. Same on iOS by construction; the win is per `launch:` step, so a
flow that relaunches several times saves a multiple of it.
2026-08-12 12:23:52 +02:00

239 lines
7.9 KiB
TypeScript

import { afterEach, 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 { DescribeTreeData } from "../../src/tools/describe/contract";
// The launch step's post-launch head start reads the flow tree to decide when
// the app has drawn, so these tests drive it through the tree source.
const fetchFlowTree = vi.fn();
vi.mock("../../src/tools/flows/flow-tree", () => ({
fetchFlowTree: (...args: unknown[]) => fetchFlowTree(...(args as [])),
}));
// Status-bar normalization shells out to `adb` for a real device id, which
// would put seconds of unrelated I/O inside the elapsed times asserted below.
vi.mock("../../src/utils/status-bar", () => ({
pinStatusBar: async () => true,
restoreStatusBar: async () => {},
}));
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
const PROJECT_ROOT = path.join(os.tmpdir(), `flow-launch-settle-${process.pid}`);
const ANDROID_DEVICE = "emulator-5554";
const LAUNCH_FLOW = `steps:
- launch: com.example.app
`;
/** A tree with real content — what a drawn app looks like to the runner. */
function drawnTree(): DescribeTreeData {
return {
tree: {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "Button",
label: "Sign in",
frame: { x: 0, y: 0, width: 1, height: 0.1 },
children: [],
},
{
role: "Text",
label: "Welcome",
frame: { x: 0, y: 0.2, width: 1, height: 0.1 },
children: [],
},
],
},
source: "android-devtools",
} as unknown as DescribeTreeData;
}
/** A tree with nothing in it — the app has not drawn its content yet. */
function blankTree(): DescribeTreeData {
return {
tree: { role: "Screen", frame: { x: 0, y: 0, width: 1, height: 1 }, children: [] },
source: "android-devtools",
} as unknown as DescribeTreeData;
}
/**
* What a freshly launched Android activity actually reports before it draws:
* the window's own content frame, identifier and nothing else. Captured from a
* cold start on a Pixel 9 emulator — it is the shape a "non-empty tree" check
* would wrongly call ready.
*/
function undrawnShellTree(): DescribeTreeData {
return {
tree: {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "FrameLayout",
identifier: "android:id/content",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [],
},
],
},
source: "android-devtools",
} as unknown as DescribeTreeData;
}
/** An app whose whole first screen is a single labelled view. */
function minimalDrawnTree(): DescribeTreeData {
return {
tree: {
role: "Screen",
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: "Text",
label: "Hello",
frame: { x: 0, y: 0, width: 1, height: 0.1 },
children: [],
},
],
},
source: "android-devtools",
} as unknown as DescribeTreeData;
}
function makeRegistry(): Registry {
return {
invokeTool: vi.fn(async (id: string) => {
if (id === "list-devices") {
return {
devices: [
{ platform: "android", id: ANDROID_DEVICE, udid: ANDROID_DEVICE, state: "device" },
],
};
}
return { ok: true, restarted: true };
}),
getTool: vi.fn(() => undefined),
resolveService: vi.fn(async () => ({ isReady: () => true })),
} as unknown as Registry;
}
async function writeFlow(name: string, yaml: string): Promise<string> {
const flowsDir = path.join(PROJECT_ROOT, ".argent", "flows");
await fs.mkdir(flowsDir, { recursive: true });
const file = path.join(flowsDir, `${name}.yaml`);
await fs.writeFile(file, yaml, "utf8");
return 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;
}
afterEach(async () => {
vi.restoreAllMocks();
fetchFlowTree.mockReset();
await fs.rm(PROJECT_ROOT, { recursive: true, force: true });
});
describe("post-launch settle", () => {
it("continues as soon as the app has drawn instead of sleeping the whole budget", async () => {
const flowFile = await writeFlow("launch", LAUNCH_FLOW);
fetchFlowTree.mockResolvedValue(drawnTree());
const registry = makeRegistry();
const started = Date.now();
const result = asRun(
await createRunFlowTool(registry).execute(
{},
{ name: "launch", project_root: PROJECT_ROOT, flow_file: flowFile, device: ANDROID_DEVICE }
)
);
const elapsed = Date.now() - started;
expect(result.ok).toBe(true);
// The old behaviour slept a flat 1500ms here regardless of the app. One
// read is the whole wait now — asserted as a poll count, which does not
// move with host load the way an elapsed time does.
expect(fetchFlowTree).toHaveBeenCalledTimes(1);
expect(elapsed).toBeLessThan(1400);
});
it("keeps waiting while the app is still blank, then carries on", async () => {
const flowFile = await writeFlow("launch", LAUNCH_FLOW);
// Blank forever: the budget is spent and the launch still succeeds, because
// a screen without accessible content is not a launch failure.
fetchFlowTree.mockResolvedValue(blankTree());
const registry = makeRegistry();
const started = Date.now();
const result = asRun(
await createRunFlowTool(registry).execute(
{},
{ name: "launch", project_root: PROJECT_ROOT, flow_file: flowFile, device: ANDROID_DEVICE }
)
);
const elapsed = Date.now() - started;
expect(result.ok).toBe(true);
expect(elapsed).toBeGreaterThanOrEqual(1400);
expect(fetchFlowTree.mock.calls.length).toBeGreaterThan(1);
});
it("keeps waiting on the undrawn content-frame shell a launching activity reports", async () => {
const flowFile = await writeFlow("launch", LAUNCH_FLOW);
// Non-empty, but the only view is the window's own content frame: an
// identifier with no label, no value, no children. The app has not drawn.
fetchFlowTree.mockResolvedValue(undrawnShellTree());
const registry = makeRegistry();
const started = Date.now();
await createRunFlowTool(registry).execute(
{},
{ name: "launch", project_root: PROJECT_ROOT, flow_file: flowFile, device: ANDROID_DEVICE }
);
expect(Date.now() - started).toBeGreaterThanOrEqual(1400);
});
it("continues early for an app whose whole first screen is one labelled view", async () => {
const flowFile = await writeFlow("launch", LAUNCH_FLOW);
fetchFlowTree.mockResolvedValue(minimalDrawnTree());
const registry = makeRegistry();
const started = Date.now();
const result = asRun(
await createRunFlowTool(registry).execute(
{},
{ name: "launch", project_root: PROJECT_ROOT, flow_file: flowFile, device: ANDROID_DEVICE }
)
);
expect(result.ok).toBe(true);
// One view is enough: a minimal screen must not be held for the full budget
// just for being small.
expect(fetchFlowTree).toHaveBeenCalledTimes(1);
expect(Date.now() - started).toBeLessThan(1400);
});
it("does not fail the launch when the tree source throws mid-settle", async () => {
const flowFile = await writeFlow("launch", LAUNCH_FLOW);
fetchFlowTree.mockRejectedValueOnce(new Error("helper busy")).mockResolvedValue(drawnTree());
const registry = makeRegistry();
const result = asRun(
await createRunFlowTool(registry).execute(
{},
{ name: "launch", project_root: PROJECT_ROOT, flow_file: flowFile, device: ANDROID_DEVICE }
)
);
expect(result.ok).toBe(true);
expect(result.steps[0]?.status).toBe("pass");
});
});