mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
0b545c3c49
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.
239 lines
7.9 KiB
TypeScript
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");
|
|
});
|
|
});
|