mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
3e4828d68d
* feat: add scale-only screenshot sizing
* fix: refuse retired --max-size inputs on every released surface
Released sizing inputs must fail closed with migration guidance instead of
silently producing native-size artifacts:
- contracts: RETIRED_SCREENSHOT_MAX_SIZE declaration + SCREENSHOT_SCALE_LIMITS
as the single source for the scale bounds and migration messages
- .ad parser: released 'screenshot ... --max-size N' and 'record start ...
--max-size N' lines now refuse at parse time (frozen replay-compat witnesses)
- daemon: screenshot rejects old-client screenshotMaxSize like recording does;
the recording guard now shares the same contract data
- Node client: screenshot/record daemon writers refuse the removed { maxSize }
option before transport
- CLI: --max-size unknown-flag error carries the migration guidance
- config/env: stale screenshotMaxSize config keys and the retired
AGENT_DEVICE_SCREENSHOT_MAX_SIZE env var are refused for sizing commands
(other commands keep working)
Quality: numberField now reuses the canonical readOptionalNumber contract
helper (AppError bounds instead of plain Error); png-resize inlines one-use
wrappers and restores the worker-thread rationale; docs typo fixed.
* test: drop retired maxSize entries from the MCP undocumented-input allowlist
* fix: refuse retired maxSize at the MCP field-projection seam + release-provenance corpus witnesses
- readFieldInput silently dropped undeclared keys before the daemon writers
could refuse them, so an MCP call carrying { maxSize } reached transport and
returned native-size success. New retiredField() combinator declares the
removed key in the field map: the projection seam refuses it with the
canonical migration message and the JSON schema no longer advertises it.
Real-route MCP executor regressions cover screenshot and record.
- replay-compat corpus: derived v0.20.5 witnesses for the released screenshot
and record --max-size forms (SHA-256 pinned, new retired-capture-size
coverage surface) so check:replay-compat proves the shipped syntax refuses
with migration guidance instead of degrading silently.
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
213 lines
8.9 KiB
TypeScript
213 lines
8.9 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { PUBLIC_COMMANDS } from '../../../src/command-catalog.ts';
|
|
import { readDaemonInfo } from '../../../src/daemon/client/daemon-client-metadata.ts';
|
|
import { resolveDaemonPaths } from '../../../src/daemon/config.ts';
|
|
import {
|
|
collectPagedEventTimeline,
|
|
type EventTimelinePage,
|
|
} from '../live-device-e2e/event-timeline.ts';
|
|
import { assertMp4File, assertNonEmptyFile, assertWaitText } from './live-assertions.ts';
|
|
import { type LiveContext, runStep, verifyCommand } from './live-harness.ts';
|
|
|
|
const C = PUBLIC_COMMANDS;
|
|
|
|
export async function assertObservabilityAndArtifacts(context: LiveContext): Promise<void> {
|
|
await runStep(context, 'open Android fixture for observability', [
|
|
'open',
|
|
context.appId,
|
|
'--relaunch',
|
|
]);
|
|
await assertWaitText(context, 'Agent Device Tester');
|
|
await assertMetrics(context);
|
|
await assertLogs(context);
|
|
await assertTraceAndRecording(context);
|
|
await assertBatchAndEvents(context);
|
|
await assertArtifactInventory(context);
|
|
}
|
|
|
|
async function assertMetrics(context: LiveContext): Promise<void> {
|
|
const perf = await runStep(context, 'read Android fixture performance metrics', [
|
|
'perf',
|
|
'metrics',
|
|
]);
|
|
const metrics = perf.json?.data?.metrics;
|
|
assert.equal(metrics?.startup?.available, true, JSON.stringify(perf.json));
|
|
assert.ok(Number(metrics?.startup?.lastDurationMs) > 0, JSON.stringify(perf.json));
|
|
assert.equal(metrics?.memory?.available, true, JSON.stringify(perf.json));
|
|
assert.ok(Number(metrics?.memory?.totalPssKb) > 0, JSON.stringify(perf.json));
|
|
assert.equal(metrics?.cpu?.available, true, JSON.stringify(perf.json));
|
|
assert.ok(Number.isFinite(Number(metrics?.cpu?.usagePercent)), JSON.stringify(perf.json));
|
|
verifyCommand(
|
|
context,
|
|
C.perf,
|
|
'Android startup, PSS memory, and CPU metrics are typed and numeric',
|
|
);
|
|
}
|
|
|
|
async function assertLogs(context: LiveContext): Promise<void> {
|
|
const started = await runStep(context, 'start Android fixture logcat stream', ['logs', 'start']);
|
|
assert.equal(started.json?.data?.started, true, JSON.stringify(started.json));
|
|
assert.ok(fs.existsSync(String(started.json?.data?.path)), JSON.stringify(started.json));
|
|
const inspected = await runStep(context, 'inspect Android fixture logcat stream', [
|
|
'logs',
|
|
'path',
|
|
]);
|
|
assert.equal(inspected.json?.data?.active, true, JSON.stringify(inspected.json));
|
|
assert.equal(inspected.json?.data?.backend, 'android', JSON.stringify(inspected.json));
|
|
assert.ok(fs.existsSync(String(inspected.json?.data?.path)), JSON.stringify(inspected.json));
|
|
const stopped = await runStep(context, 'stop Android fixture logcat stream', ['logs', 'stop']);
|
|
assert.equal(stopped.json?.data?.stopped, true, JSON.stringify(stopped.json));
|
|
verifyCommand(context, C.logs, 'Android logcat starts, exposes its path, and stops');
|
|
}
|
|
|
|
async function assertTraceAndRecording(context: LiveContext): Promise<void> {
|
|
const tracePath = path.join(context.artifactDir, 'fixture.adtrace');
|
|
await runStep(context, 'reveal Android quick actions before trace', ['scroll', 'down', '0.7']);
|
|
await runStep(context, 'start Android interaction trace', ['trace', 'start', tracePath]);
|
|
await runStep(context, 'trace Android visible mutation', ['press', 'id="home-open-catalog"']);
|
|
await runStep(context, 'stop Android interaction trace', ['trace', 'stop', tracePath]);
|
|
assertNonEmptyFile(tracePath, 'trace');
|
|
verifyCommand(
|
|
context,
|
|
C.trace,
|
|
'traced Android press writes non-empty diagnostics at requested path',
|
|
);
|
|
|
|
const recordingPath = path.join(context.artifactDir, 'fixture.mp4');
|
|
await runStep(context, 'start short Android screen recording', [
|
|
'record',
|
|
'start',
|
|
recordingPath,
|
|
'--hide-touches',
|
|
]);
|
|
await runStep(context, 'return to Android fixture home before recording', [
|
|
'click',
|
|
'label="Home"',
|
|
]);
|
|
await runStep(context, 'restore Android fixture home scroll top', ['scroll', 'top']);
|
|
await runStep(context, 'reveal Android quick actions before recording', [
|
|
'scroll',
|
|
'down',
|
|
'0.7',
|
|
]);
|
|
await runStep(context, 'record Android visible mutation', ['press', 'id="home-open-settings"']);
|
|
await assertWaitText(context, 'Settings');
|
|
await runStep(context, 'stop short Android screen recording', ['record', 'stop']);
|
|
await assertMp4File(recordingPath);
|
|
verifyCommand(context, C.record, 'short Android recording is a finalized playable MP4');
|
|
}
|
|
|
|
async function assertBatchAndEvents(context: LiveContext): Promise<void> {
|
|
await runStep(context, 'return to Android fixture home before batch', ['click', 'label="Home"']);
|
|
await runStep(context, 'restore Android fixture home title before batch', ['scroll', 'top']);
|
|
await assertWaitText(context, 'Agent Device Tester');
|
|
const batch = await runStep(context, 'run Android nested semantic read batch', [
|
|
'batch',
|
|
'--steps',
|
|
JSON.stringify([
|
|
{
|
|
command: 'get',
|
|
input: { format: 'text', target: { kind: 'selector', selector: 'id="dismiss-notice"' } },
|
|
},
|
|
{
|
|
command: 'is',
|
|
input: { predicate: 'visible', selector: 'id="home-title"' },
|
|
},
|
|
]),
|
|
]);
|
|
const results = batch.json?.data?.results;
|
|
assert.ok(
|
|
Array.isArray(results),
|
|
`batch must retain nested results: ${JSON.stringify(batch.json)}`,
|
|
);
|
|
assert.equal(results.length, 2, JSON.stringify(batch.json));
|
|
assert.equal(results[0]?.command, 'get', JSON.stringify(batch.json));
|
|
assert.equal(results[0]?.data?.text, 'Dismiss notice', JSON.stringify(batch.json));
|
|
assert.equal(results[1]?.command, 'is', JSON.stringify(batch.json));
|
|
assert.equal(results[1]?.data?.pass, true, JSON.stringify(batch.json));
|
|
verifyCommand(context, C.batch, 'Android batch retains nested get and is evidence');
|
|
|
|
await runStep(context, 'capture Android snapshot for the event timeline', ['snapshot']);
|
|
const timeline = await collectPagedEventTimeline(async (cursor) => {
|
|
const result = await runStep(
|
|
context,
|
|
cursor === undefined ? 'read Android event timeline' : `read Android events from ${cursor}`,
|
|
cursor === undefined ? ['events', '4'] : ['events', '4', cursor],
|
|
);
|
|
return (result.json?.data ?? {}) as EventTimelinePage;
|
|
});
|
|
assert.ok(timeline.pages.length > 1, JSON.stringify(timeline.pages));
|
|
for (const command of [C.open, C.press, C.snapshot]) {
|
|
assert.ok(
|
|
timeline.commands.includes(command),
|
|
`events missing ${command}: ${JSON.stringify(timeline)}`,
|
|
);
|
|
}
|
|
verifyCommand(
|
|
context,
|
|
C.events,
|
|
'paged Android event timeline includes open, press, and snapshot',
|
|
);
|
|
}
|
|
|
|
async function assertArtifactInventory(context: LiveContext): Promise<void> {
|
|
const inventory = await runStep(context, 'list Android daemon artifacts', ['artifacts']);
|
|
const artifacts = inventory.json?.data?.artifacts;
|
|
assert.ok(Array.isArray(artifacts), JSON.stringify(inventory.json));
|
|
// Recordings are absent by design for local clients: record stop hands the MP4
|
|
// to the caller's path directly (asserted above), and only remote clients get a
|
|
// downloadable inventory entry. The trace is tracked unconditionally.
|
|
const trace = artifacts.find(
|
|
(artifact: { artifactType?: unknown; id?: unknown; sizeBytes?: unknown }) =>
|
|
artifact.artifactType === 'trace-log' &&
|
|
typeof artifact.id === 'string' &&
|
|
Number(artifact.sizeBytes) > 0,
|
|
) as { id: string; sizeBytes: number } | undefined;
|
|
assert.ok(trace, `trace artifact is not downloadable: ${JSON.stringify(artifacts)}`);
|
|
const downloaded = await downloadDaemonArtifact(context, trace.id);
|
|
assert.equal(
|
|
downloaded.byteLength,
|
|
trace.sizeBytes,
|
|
'artifact download size must match inventory',
|
|
);
|
|
|
|
const afterDownload = await runStep(context, 'confirm downloaded artifact leaves inventory', [
|
|
'artifacts',
|
|
]);
|
|
const remaining = afterDownload.json?.data?.artifacts;
|
|
assert.ok(Array.isArray(remaining), JSON.stringify(afterDownload.json));
|
|
assert.equal(
|
|
remaining.some((artifact: { id?: unknown }) => artifact.id === trace.id),
|
|
false,
|
|
'artifact download must consume its inventory entry',
|
|
);
|
|
verifyCommand(
|
|
context,
|
|
C.artifacts,
|
|
'daemon inventory lists a non-empty trace artifact and its download consumes the entry',
|
|
);
|
|
}
|
|
|
|
async function downloadDaemonArtifact(
|
|
context: LiveContext,
|
|
artifactId: string,
|
|
): Promise<ArrayBuffer> {
|
|
const daemonPaths = resolveDaemonPaths(context.env.AGENT_DEVICE_STATE_DIR, {
|
|
env: context.env,
|
|
projectRoot: process.cwd(),
|
|
});
|
|
const daemon = readDaemonInfo(daemonPaths.infoPath);
|
|
assert.ok(daemon?.httpPort, `daemon HTTP metadata unavailable at ${daemonPaths.infoPath}`);
|
|
const response = await fetch(
|
|
`http://127.0.0.1:${daemon.httpPort}/artifacts/${encodeURIComponent(artifactId)}`,
|
|
{ headers: { authorization: `Bearer ${daemon.token}` } },
|
|
);
|
|
if (response.status !== 200) {
|
|
assert.fail(`artifact download failed: ${response.status} ${await response.text()}`);
|
|
}
|
|
return response.arrayBuffer();
|
|
}
|