Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,13 +710,21 @@ const modelsSub = defineCommand({

const inspectSub = defineCommand({
meta: { name: 'inspect', description: 'The screen as compact JSON: each element with text, an id or a label' },
args: { ...commonArgs, id: idArg },
args: {
...commonArgs,
id: idArg,
all: {
type: 'boolean',
default: false,
description: 'Also list the keyboard keys and the System UI (status and navigation bars)',
},
},
async run({ args }) {
const json = Boolean(args.json);
await runDeviceCommand(json, async () => {
const ctx = await context(args);
const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined);
const { screen, session } = await DeviceGateway.inspect(ctx.apiUrl, ctx.auth, id);
const { screen, session } = await DeviceGateway.inspect(ctx.apiUrl, ctx.auth, id, Boolean(args.all));
return {
session,
data: { screen },
Expand All @@ -728,6 +736,15 @@ const inspectSub = defineCommand({
),
);
logger.log(ui.branch(screen.elements.map((el) => formatElement(el))));
const notes: string[] = [];
if (screen.keyboard_open) notes.push('A keyboard is open.');
if (screen.hidden && (screen.hidden.keyboard > 0 || screen.hidden.system > 0)) {
notes.push(
`Left out ${screen.hidden.keyboard} keyboard and ${screen.hidden.system} System UI elements (--all shows them).`,
);
}

if (notes.length > 0) logger.log(ui.note(notes.join(' ')));
},
};
});
Expand All @@ -741,6 +758,11 @@ export function formatElement(el: DeviceScreenElement): string {
if (el.a11y && el.a11y !== el.txt) parts.push(`a11y:"${el.a11y}"`);
if (el.rid) parts.push(`id:${el.rid}`);
if (el.hint) parts.push(`hint:"${el.hint}"`);
for (const state of ['checked', 'selected', 'focused'] as const) {
if (el[state]) parts.push(state);
}

if (el.enabled === false) parts.push('disabled');
return `${parts.join(' ')} ${colors.dim(el.b)}`;
}

Expand Down
3 changes: 2 additions & 1 deletion src/gateways/device-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,9 @@ export const DeviceGateway = {
baseUrl: string,
auth: AuthContext,
id: string,
all = false,
): Promise<{ screen: DeviceScreen; session: DeviceSession }> {
return request(baseUrl, auth, 'GET', `${sessionPath(id)}/inspect`);
return request(baseUrl, auth, 'GET', `${sessionPath(id)}/inspect${all ? '?all=true' : ''}`);
},

install(
Expand Down
11 changes: 11 additions & 0 deletions src/types/domain/device-session.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export interface DeviceCommandResult {
/** Each flow of a run. Absent from APIs older than this field. */
flows?: DeviceFlowResult[];
output?: string;
/** A failed run: the app's visible text just after it failed. */
screen_text?: string[];
success: boolean;
}

Expand All @@ -131,8 +133,13 @@ export interface DeviceScreenElement {
/** Bounds, "[left,top][right,bottom]". */
b: string;
a11y?: string;
checked?: true;
/** False when disabled; absent when enabled. */
enabled?: false;
focused?: true;
hint?: string;
rid?: string;
selected?: true;
txt?: string;
}

Expand All @@ -142,6 +149,10 @@ export interface DeviceScreen {
/** False when no new frame came in time; this is then the latest. */
fresh: boolean;
height: number;
/** Keyboard keys and System UI left out (only when some were); --all shows them. */
hidden?: { keyboard: number; system: number };
/** An on-screen keyboard is showing (Android). */
keyboard_open?: true;
keys: Record<string, string>;
width: number;
}
Expand Down
14 changes: 14 additions & 0 deletions src/utils/device-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ export function printRunResult(
passed: boolean,
result: DeviceCommandResult,
label: string,
): void {
printOutcome(log, passed, result, label);
// What the app showed after the failure: why an assertion missed.
if (!passed && result.screen_text?.length) {
log(ui.section('On screen after the failure'));
log(ui.branch(result.screen_text.map((t) => `"${t}"`)));
}
}

function printOutcome(
log: (line: string) => void,
passed: boolean,
result: DeviceCommandResult,
label: string,
): void {
const report = parseRunReport(result);
if (!report || report.flows.length === 0) {
Expand Down
20 changes: 20 additions & 0 deletions test/unit/device-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { expect } from 'chai';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { stripVTControlCharacters } from 'node:util';

import { runCommand } from 'citty';

import {
collectFlagValues,
decodeDataUrl,
deviceCommand,
formatElement,
newLogLines,
parseEnvFlags,
startNextSteps,
Expand Down Expand Up @@ -451,6 +453,17 @@ describe('dcd device', () => {
expect(out.data).to.deep.equal({ screen });
});

it('inspect --all asks for the keyboard and System UI too', async () => {
const screen = { width: 390, height: 844, keys: {}, elements: [], fresh: true };
const calls = stubFetch(() => ({ body: { screen, session: session('a-b-c') } }));
await runJson('inspect', { id: 'a-b-c' });
await runJson('inspect', { id: 'a-b-c', all: true });
expect(calls.map((c) => c.url)).to.deep.equal([
`${API}/device/sessions/a-b-c/inspect`,
`${API}/device/sessions/a-b-c/inspect?all=true`,
]);
});

it('screenshot saves the image and reports its path', async () => {
const file = path.join(tempDir, 'shot.jpg');
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]);
Expand Down Expand Up @@ -731,6 +744,13 @@ describe('dcd device', () => {
});

describe('helpers', () => {
it('formatElement shows state that is not the default', () => {
const line = stripVTControlCharacters(
formatElement({ b: '[0,0][1,1]', txt: 'English', selected: true, checked: true, enabled: false }),
);
expect(line).to.equal('"English" checked selected disabled [0,0][1,1]');
});

it('startNextSteps fits the session: check readiness first, install only with no app', () => {
const cmds = (s: DeviceSession, hasApp: boolean) => startNextSteps(s, hasApp).map(([, cmd]) => cmd);

Expand Down
11 changes: 11 additions & 0 deletions test/unit/device-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ describe('dcd device run output', () => {
expect(out).not.to.include('total_commands_executed');
});

it("shows a failed run's screen text, so a near miss is plain", () => {
const out = printed(false, {
success: false,
error: 'Assertion is false',
screen_text: ['Star at the centre'],
});
expect(out).to.include('On screen after the failure');
expect(out).to.include('"Star at the centre"');
expect(printed(true, { success: true, screen_text: ['x'] })).not.to.include('On screen');
});

it('counts the failures in a run of several flows', () => {
const out = printed(false, {
success: false,
Expand Down
Loading