Skip to content

Commit 3f12aa3

Browse files
committed
fix: harden runtime boundary follow-ups
1 parent 5fa95fd commit 3f12aa3

8 files changed

Lines changed: 76 additions & 7 deletions

src/__tests__/runtime-diff-screenshot.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ test('runtime diff screenshot captures live current image and cleans temporary c
2222
const baseline = path.join(dir, 'baseline.png');
2323
const diffOut = path.join(dir, 'diff.png');
2424
let capturedCurrentPath: string | undefined;
25+
let capturedOptions: BackendScreenshotOptions | undefined;
2526

2627
fs.writeFileSync(baseline, solidPngBuffer(10, 10, { r: 0, g: 0, b: 0 }));
2728

2829
try {
2930
const device = createAgentDevice({
30-
backend: createScreenshotBackend((outPath) => {
31+
backend: createScreenshotBackend((outPath, options) => {
3132
capturedCurrentPath = outPath;
33+
capturedOptions = options;
3234
fs.writeFileSync(outPath, solidPngBuffer(10, 10, { r: 255, g: 255, b: 255 }));
3335
return { path: outPath };
3436
}),
@@ -42,6 +44,7 @@ test('runtime diff screenshot captures live current image and cleans temporary c
4244
current: { kind: 'live' },
4345
out: { kind: 'path', path: diffOut },
4446
threshold: 0,
47+
surface: 'menubar',
4548
});
4649

4750
assert.equal(result.match, false);
@@ -50,6 +53,7 @@ test('runtime diff screenshot captures live current image and cleans temporary c
5053
assert.equal(fs.existsSync(diffOut), true);
5154
assert.equal(typeof capturedCurrentPath, 'string');
5255
assert.equal(fs.existsSync(capturedCurrentPath!), false);
56+
assert.equal(capturedOptions?.surface, 'menubar');
5357
} finally {
5458
fs.rmSync(dir, { recursive: true, force: true });
5559
}

src/__tests__/runtime-interactions.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ test('runtime interactions reject unsupported macOS desktop and menubar surfaces
102102
() => desktop.interactions.click({ kind: 'point', x: 1, y: 2 }, { session: 'default' }),
103103
/click is not supported on macOS desktop sessions yet/,
104104
);
105+
await assert.rejects(
106+
() =>
107+
desktop.interactions.click(
108+
{ kind: 'point', x: 1, y: 2 },
109+
{ session: 'default', metadata: { surface: 'app' } },
110+
),
111+
/click is not supported on macOS desktop sessions yet/,
112+
);
105113

106114
const menubar = createInteractionDevice(fillableSnapshot(), {
107115
platform: 'macos',

src/__tests__/runtime-snapshot.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,47 @@ test('runtime snapshot emits filtered Android guidance from backend analysis', a
116116
]);
117117
});
118118

119+
test('runtime snapshot stale-drop warning uses the runtime clock', async () => {
120+
const session = {
121+
name: 'default',
122+
snapshot: makeSnapshotState(
123+
Array.from({ length: 20 }, (_, index) => ({
124+
index,
125+
depth: 0,
126+
type: 'Text',
127+
label: `Before ${index}`,
128+
})),
129+
{ backend: 'android' },
130+
),
131+
};
132+
session.snapshot.createdAt = 1_000;
133+
const device = createAgentDevice({
134+
backend: createSnapshotBackend(() => ({
135+
snapshot: makeSnapshotState([{ index: 0, depth: 0, type: 'Text', label: 'After' }], {
136+
backend: 'android',
137+
}),
138+
})),
139+
artifacts: createLocalArtifactAdapter(),
140+
sessions: {
141+
get: () => session,
142+
set: (record) => {
143+
session.snapshot = record.snapshot!;
144+
},
145+
},
146+
policy: localCommandPolicy(),
147+
clock: {
148+
now: () => 1_500,
149+
sleep: async () => {},
150+
},
151+
});
152+
153+
const result = await device.capture.snapshot({ session: 'default' });
154+
155+
assert.deepEqual(result.warnings, [
156+
'Recent snapshots dropped sharply in node count, which suggests stale or mid-transition UI. Use screenshot as visual truth, wait briefly, then re-snapshot once.',
157+
]);
158+
});
159+
119160
function createSnapshotBackend(
120161
captureSnapshot: () => BackendSnapshotResult | Promise<BackendSnapshotResult>,
121162
): AgentDeviceBackend {

src/cli/commands/screenshot.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ function createClientScreenshotBackend(
9595
session: context.session,
9696
overlayRefs: options?.overlayRefs,
9797
fullscreen: options?.fullscreen,
98+
surface: options?.surface,
9899
});
99100
return {
100101
path: result.path,

src/client-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ export type CaptureScreenshotOptions = AgentDeviceRequestOverrides & {
311311
path?: string;
312312
overlayRefs?: boolean;
313313
fullscreen?: boolean;
314+
surface?: 'app' | 'frontmost-app' | 'desktop' | 'menubar';
314315
};
315316

316317
export type CaptureScreenshotResult = {

src/commands/capture-diff-screenshot.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type DiffScreenshotCommandOptions = CommandContext & {
2626
currentOverlayOut?: FileOutputRef;
2727
threshold?: number;
2828
overlayRefs?: boolean;
29+
surface?: BackendScreenshotOptions['surface'];
2930
};
3031

3132
export type DiffScreenshotCommandResult = ScreenshotDiffResult & {
@@ -119,14 +120,14 @@ function normalizeThreshold(threshold: unknown): number {
119120

120121
async function captureLiveCurrentScreenshot(
121122
runtime: AgentDeviceRuntime,
122-
options: CommandContext,
123+
options: DiffScreenshotCommandOptions,
123124
): Promise<ResolvedInputFile> {
124125
const temp = await createCommandTempFile(runtime, {
125126
prefix: 'agent-device-diff-current',
126127
ext: '.png',
127128
});
128129
try {
129-
await captureScreenshot(runtime, options, temp.path);
130+
await captureScreenshot(runtime, options, temp.path, screenshotSurfaceOptions(options));
130131
} catch (error) {
131132
await temp.cleanup();
132133
throw error;
@@ -156,6 +157,7 @@ async function maybeAttachCurrentOverlay(
156157
try {
157158
const overlayResult = await captureScreenshot(runtime, options, overlayOutput.path, {
158159
overlayRefs: true,
160+
...screenshotSurfaceOptions(options),
159161
});
160162
const overlayArtifact = await overlayOutput.publish();
161163
if (overlayArtifact) artifacts.push(overlayArtifact);
@@ -201,6 +203,12 @@ async function captureScreenshot(
201203
);
202204
}
203205

206+
function screenshotSurfaceOptions(
207+
options: Pick<DiffScreenshotCommandOptions, 'surface'>,
208+
): BackendScreenshotOptions {
209+
return options.surface ? { surface: options.surface } : {};
210+
}
211+
204212
function resolveCurrentOverlayOutputRef(
205213
options: DiffScreenshotCommandOptions,
206214
diffOutputPath: string | undefined,

src/commands/capture-snapshot.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ async function captureRuntimeSnapshot(
132132
},
133133
);
134134
const snapshot = normalizeBackendSnapshot(result, runtime);
135+
const warningTime = now(runtime);
135136
return {
136137
snapshot,
137138
result,
@@ -141,6 +142,7 @@ async function captureRuntimeSnapshot(
141142
snapshot,
142143
options,
143144
session,
145+
now: warningTime,
144146
}),
145147
};
146148
}
@@ -154,7 +156,7 @@ function normalizeBackendSnapshot(
154156
nodes: result.nodes ?? [],
155157
truncated: result.truncated,
156158
backend: result.backend as SnapshotState['backend'],
157-
createdAt: runtime.clock?.now() ?? Date.now(),
159+
createdAt: now(runtime),
158160
};
159161
}
160162

@@ -189,6 +191,7 @@ function buildSnapshotWarnings(params: {
189191
snapshot: SnapshotState;
190192
options: SnapshotCommandOptions;
191193
session: CommandSessionRecord | undefined;
194+
now: number;
192195
}): string[] {
193196
const warnings = [...(params.result.warnings ?? [])];
194197
const interactiveOnly = params.options.interactiveOnly === true;
@@ -219,7 +222,7 @@ function buildSnapshotWarnings(params: {
219222
if (
220223
!params.result.freshness &&
221224
previousSnapshot &&
222-
Date.now() - previousSnapshot.createdAt <= 2_000 &&
225+
params.now - previousSnapshot.createdAt <= 2_000 &&
223226
isLikelyStaleSnapshotDrop(previousSnapshot.nodes.length, params.snapshot.nodes.length)
224227
) {
225228
warnings.push(
@@ -248,6 +251,10 @@ function isLikelyStaleSnapshotDrop(previousCount: number, currentCount: number):
248251
return currentCount <= Math.floor(previousCount * 0.2);
249252
}
250253

254+
function now(runtime: AgentDeviceRuntime): number {
255+
return runtime.clock?.now() ?? Date.now();
256+
}
257+
251258
function uniqueStrings(values: string[]): string[] {
252259
return Array.from(new Set(values));
253260
}

src/commands/interactions.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ async function assertSupportedInteractionSurface(
279279
if (runtime.backend.platform !== 'macos') return;
280280
const surface = await resolveInteractionSurface(runtime, options);
281281
if (surface !== 'desktop' && surface !== 'menubar') return;
282+
// Menu bar button activation is supported by the existing daemon path; text entry is not.
282283
if (surface === 'menubar' && (action === 'click' || action === 'press')) return;
283284
throw new AppError(
284285
'UNSUPPORTED_OPERATION',
@@ -290,8 +291,6 @@ async function resolveInteractionSurface(
290291
runtime: AgentDeviceRuntime,
291292
options: CommandContext,
292293
): Promise<unknown> {
293-
const metadataSurface = options.metadata?.surface;
294-
if (metadataSurface) return metadataSurface;
295294
const session = await runtime.sessions.get(options.session ?? 'default');
296295
return session?.metadata?.surface;
297296
}

0 commit comments

Comments
 (0)