Skip to content

Commit bf8d952

Browse files
authored
fix: speed up web snapshots (#842)
* fix: speed up web snapshots * ci: stabilize iOS simulator smoke boot
1 parent b3b8c90 commit bf8d952

24 files changed

Lines changed: 284 additions & 23 deletions

.github/actions/boot-ios-test-simulator/action.yml

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,25 @@ inputs:
99
description: "Preferred simulator device name"
1010
required: false
1111
default: "iPhone 17 Pro"
12+
boot-timeout-seconds:
13+
description: "Maximum time to wait for simulator bootstatus"
14+
required: false
15+
default: "300"
16+
17+
outputs:
18+
simulator-udid:
19+
description: "UDID of the booted iOS simulator"
20+
value: ${{ steps.boot.outputs.simulator-udid }}
1221

1322
runs:
1423
using: "composite"
1524
steps:
1625
- name: Resolve and boot iOS test simulator
26+
id: boot
1727
run: |
1828
set -euo pipefail
19-
RUNTIME_TOKEN="SimRuntime.iOS-${{ inputs.runtime-version }}"
29+
RUNTIME_VERSION="${{ inputs.runtime-version }}"
30+
RUNTIME_TOKEN="SimRuntime.iOS-${RUNTIME_VERSION//./-}"
2031
export RUNTIME_TOKEN
2132
export PREFERRED_DEVICE_NAME="${{ inputs.preferred-device-name }}"
2233
UDID="$(
@@ -40,10 +51,37 @@ runs:
4051
available.find((device) => device.state === "Booted") ??
4152
available[0];
4253
if (!preferred?.udid) process.exit(1);
54+
console.error(`Selected ${preferred.name} (${preferred.udid}) from ${preferred.runtime}`);
4355
process.stdout.write(preferred.udid);
4456
'
4557
)"
58+
echo "simulator-udid=$UDID" >> "$GITHUB_OUTPUT"
4659
xcrun simctl shutdown all || true
4760
xcrun simctl boot "$UDID" || true
48-
xcrun simctl bootstatus "$UDID" -b
61+
xcrun simctl bootstatus "$UDID" -b &
62+
BOOTSTATUS_PID="$!"
63+
TIMEOUT_SECONDS="${{ inputs.boot-timeout-seconds }}"
64+
BOOT_TIMEOUT_FILE="$(mktemp)"
65+
(
66+
sleep "$TIMEOUT_SECONDS"
67+
if kill -0 "$BOOTSTATUS_PID" 2>/dev/null; then
68+
echo "Timed out waiting ${TIMEOUT_SECONDS}s for simulator $UDID to boot" >&2
69+
touch "$BOOT_TIMEOUT_FILE"
70+
kill "$BOOTSTATUS_PID" 2>/dev/null || true
71+
fi
72+
) &
73+
BOOT_WATCHDOG_PID="$!"
74+
set +e
75+
wait "$BOOTSTATUS_PID"
76+
BOOTSTATUS_EXIT="$?"
77+
set -e
78+
kill "$BOOT_WATCHDOG_PID" 2>/dev/null || true
79+
wait "$BOOT_WATCHDOG_PID" 2>/dev/null || true
80+
if [ -f "$BOOT_TIMEOUT_FILE" ]; then
81+
rm -f "$BOOT_TIMEOUT_FILE"
82+
xcrun simctl list devices || true
83+
exit 1
84+
fi
85+
rm -f "$BOOT_TIMEOUT_FILE"
86+
exit "$BOOTSTATUS_EXIT"
4987
shell: bash

.github/workflows/ios.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ jobs:
5050
xcuitest-destination: generic/platform=iOS Simulator
5151

5252
- name: Boot iOS test simulator
53+
id: ios-simulator
5354
uses: ./.github/actions/boot-ios-test-simulator
5455
with:
5556
runtime-version: ${{ env.IOS_RUNTIME_VERSION }}
@@ -58,12 +59,12 @@ jobs:
5859
- name: Prepare iOS runner
5960
run: |
6061
pnpm clean:daemon
61-
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json
62+
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --timeout "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --json
6263
pnpm clean:daemon
6364
6465
- name: Run iOS simulator smoke replay
6566
run: |
66-
node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator/01-settings.ad --retries 2 --artifacts-dir test/artifacts/replays-ios-simulator-smoke --report-junit test/artifacts/replays-ios-simulator-smoke.junit.xml
67+
node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator/01-settings.ad --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --retries 2 --artifacts-dir test/artifacts/replays-ios-simulator-smoke --report-junit test/artifacts/replays-ios-simulator-smoke.junit.xml
6768
6869
- name: Run iOS physical device smoke replay
6970
if: env.IOS_UDID != ''

src/backend.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export type BackendSnapshotResult = {
5555
} & SnapshotCaptureAnnotations;
5656

5757
export type BackendSnapshotOptions = SnapshotOptions & {
58+
includeRects?: boolean;
5859
outPath?: string;
5960
};
6061

src/cli/commands/web.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ export async function runWebCommand(
1313
const action = positionals[0];
1414
switch (action) {
1515
case 'setup': {
16+
printWebSetupStart(options.flags.json);
1617
const status = await setupManagedAgentBrowser({
1718
stateDir: options.stateDir,
1819
});
19-
printWebResult(options.flags.json, 'Managed web backend installed.', { status });
20+
printWebSetupResult(options.flags.json, status);
2021
return 0;
2122
}
2223
case 'doctor': {
@@ -35,6 +36,24 @@ export async function runWebCommand(
3536
}
3637
}
3738

39+
function printWebSetupStart(json: boolean | undefined): void {
40+
if (json) return;
41+
process.stdout.write('Setting up managed agent-browser backend (downloads if needed)...\n');
42+
}
43+
44+
function printWebSetupResult(
45+
json: boolean | undefined,
46+
status: Awaited<ReturnType<typeof setupManagedAgentBrowser>>,
47+
): void {
48+
if (json) {
49+
printJson({ success: true, data: { status } });
50+
return;
51+
}
52+
process.stdout.write(
53+
`Managed web backend installed.\nagent-browser available at: ${status.binaryPath}\n`,
54+
);
55+
}
56+
3857
function printWebResult(json: boolean | undefined, message: string, data: Record<string, unknown>) {
3958
if (json) {
4059
printJson({ success: true, data });

src/commands/interaction/runtime/interactions.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,10 @@ test('runtime selector interactions fall back to a full snapshot when interactiv
106106
assert.equal(result.kind, 'selector');
107107
assert.equal(result.node?.label, 'General');
108108
assert.deepEqual(calls, [{ x: 160, y: 122 }]);
109-
assert.deepEqual(captureOptions, [{ interactiveOnly: true }, { interactiveOnly: false }]);
109+
assert.deepEqual(captureOptions, [
110+
{ interactiveOnly: true, includeRects: true },
111+
{ interactiveOnly: false, includeRects: true },
112+
]);
110113
});
111114

112115
test('runtime click keeps distinct tab button centers when iOS reports the tab bar as hittable', async () => {

src/commands/interaction/runtime/resolution.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export async function captureInteractionSnapshot(
238238
if (!session) throw new AppError('SESSION_NOT_FOUND', 'No active session. Run open first.');
239239
const result = await runtime.backend.captureSnapshot(toBackendContext(runtime, options), {
240240
interactiveOnly,
241+
includeRects: true,
241242
});
242243
const snapshot =
243244
result.snapshot ??

src/commands/interaction/runtime/selector-read-shared.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ export async function requireSnapshotSession(
3636
export async function captureSelectorSnapshot(
3737
runtime: AgentDeviceRuntime,
3838
options: CommandContext & SelectorSnapshotOptions,
39-
captureOptions: { updateSession: boolean; scope?: string } = { updateSession: true },
39+
captureOptions: { updateSession: boolean; scope?: string; includeRects?: boolean } = {
40+
updateSession: true,
41+
},
4042
): Promise<CapturedSnapshot> {
4143
const captureSnapshot = runtime.backend.captureSnapshot;
4244
if (!captureSnapshot) {
@@ -49,6 +51,7 @@ export async function captureSelectorSnapshot(
4951
depth: options.depth,
5052
scope: captureOptions.scope ?? options.scope,
5153
raw: options.raw,
54+
includeRects: captureOptions.includeRects,
5255
});
5356
const snapshot =
5457
result.snapshot ??

src/commands/interaction/runtime/selector-read.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,33 @@ test('runtime selectors forward public snapshot options to backend capture', asy
145145
depth: 2,
146146
scope: 'Login',
147147
raw: true,
148+
includeRects: false,
148149
});
149150
});
150151

152+
test('runtime visibility predicates request snapshot rects', async () => {
153+
const snapshot = selectorSnapshot();
154+
let captureOptions: BackendSnapshotOptions | undefined;
155+
const device = createAgentDevice({
156+
backend: {
157+
platform: 'web',
158+
captureSnapshot: async (_context, options) => {
159+
captureOptions = options;
160+
return { snapshot };
161+
},
162+
} satisfies AgentDeviceBackend,
163+
artifacts: createLocalArtifactAdapter(),
164+
sessions: createMemorySessionStore([{ name: 'default', snapshot }]),
165+
policy: localCommandPolicy(),
166+
});
167+
168+
await device.selectors.isVisible(selector('label=Continue'), {
169+
session: 'default',
170+
});
171+
172+
assert.equal(captureOptions?.includeRects, true);
173+
});
174+
151175
test('runtime is validates selector predicates', async () => {
152176
const device = createSelectorDevice(selectorSnapshot());
153177

src/commands/interaction/runtime/selector-read.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { buildSelectorChainForNode } from '../../../utils/selector-build.ts';
1818
import {
1919
evaluateIsPredicate,
2020
isSupportedPredicate,
21+
type IsPredicate,
2122
} from '../../../utils/selector-is-predicates.ts';
2223
import type {
2324
ElementTarget,
@@ -256,7 +257,11 @@ export const isCommand: RuntimeCommand<IsCommandOptions, IsCommandResult> = asyn
256257
if (options.predicate === 'text' && !options.expectedText) {
257258
throw new AppError('INVALID_ARGS', 'is text requires expected text value');
258259
}
259-
const capture = await captureSelectorSnapshot(runtime, options, { updateSession: true });
260+
const includeRects = predicateNeedsRects(options.predicate);
261+
const capture = await captureSelectorSnapshot(runtime, options, {
262+
updateSession: true,
263+
includeRects,
264+
});
260265
const chain = parseSelectorChain(options.selector);
261266

262267
if (options.predicate === 'exists') {
@@ -423,6 +428,10 @@ function sparseSelectorSnapshotError(verdict: SnapshotQualityVerdict): AppError
423428
});
424429
}
425430

431+
function predicateNeedsRects(predicate: IsPredicate): boolean {
432+
return predicate === 'visible' || predicate === 'hidden';
433+
}
434+
426435
async function waitForSelector(
427436
runtime: AgentDeviceRuntime,
428437
options: WaitCommandOptions,

src/core/dispatch-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type DispatchContext = ScreenshotDispatchFlags & {
4747
snapshotDepth?: number;
4848
snapshotScope?: string;
4949
snapshotRaw?: boolean;
50+
snapshotIncludeRects?: boolean;
5051
count?: number;
5152
intervalMs?: number;
5253
delayMs?: number;

0 commit comments

Comments
 (0)