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
8 changes: 4 additions & 4 deletions src/__tests__/cli-network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ test('test command prints suite summary and exits non-zero on failures', async (
assert.equal(result.code, 1);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.meta?.requestProgress, 'replay-test');
assert.match(result.stderr, /Running replay suite\.\.\./);
assert.doesNotMatch(result.stderr, /Running replay suite\.\.\./);
assert.match(result.stdout, /PASS 01-pass\.ad \(0\.01s\)/);
assert.match(
result.stdout,
Expand All @@ -124,7 +124,7 @@ test('test command --verbose prints all test statuses', async () => {

assert.equal(result.code, 1);
assert.equal(result.calls[0]?.meta?.debug, false);
assert.match(result.stderr, /Running replay suite\.\.\./);
assert.doesNotMatch(result.stderr, /Running replay suite\.\.\./);
assert.match(result.stdout, /PASS 01-pass\.ad \(0\.01s\)/);
assert.match(result.stdout, /SKIP 03-skip\.ad/);
});
Expand Down Expand Up @@ -352,7 +352,7 @@ test('test command reports flaky passed-on-retry cases in the default summary',
}));

assert.equal(result.code, null);
assert.match(result.stderr, /Running replay suite\.\.\./);
assert.doesNotMatch(result.stderr, /Running replay suite\.\.\./);
assert.doesNotMatch(result.stdout, /FLAKY/);
assert.match(
result.stdout,
Expand Down Expand Up @@ -489,7 +489,7 @@ test('test --maestro forwards Maestro backend and platform for directory suites'
assert.equal(result.calls[0]?.flags?.replayBackend, 'maestro');
assert.equal(result.calls[0]?.flags?.platform, 'android');
assert.equal(result.calls[0]?.meta?.requestProgress, 'replay-test');
assert.match(result.stderr, /Running replay suite\.\.\./);
assert.doesNotMatch(result.stderr, /Running replay suite\.\.\./);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
Expand Down
125 changes: 125 additions & 0 deletions src/__tests__/cli-test-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { formatReplayTestProgressEvent } from '../cli-test-progress.ts';
import type { RequestProgressEvent } from '../daemon/request-progress.ts';

test('formatReplayTestProgressEvent renders replay suite start context', () => {
const line = formatReplayTestProgressEvent({
type: 'replay-test-suite',
status: 'start',
total: 4,
runnable: 3,
skipped: 1,
artifactsDir: '/tmp/replay-suite',
shardMode: 'split',
shardCount: 2,
});

assert.equal(
line,
[
'Running replay suite: 4 files',
' sharding: split across 2 devices',
' artifacts: /tmp/replay-suite',
].join('\n'),
);
});

test('formatReplayTestProgressEvent renders replay test start context with shard metadata', () => {
const line = formatReplayTestProgressEvent({
type: 'replay-test',
file: '/tmp/auth-flow.yml',
title: 'Authentication flow',
status: 'start',
index: 2,
total: 5,
session: 'maestro-test:test:suite:2:attempt-1',
artifactsDir: '/tmp/replay-suite/auth-flow',
shardIndex: 1,
shardCount: 2,
deviceId: 'E140A942-965C-4A92-AC63-F3B23756BE02',
});

assert.equal(
line,
[
'[2/5] START "Authentication flow" in auth-flow.yml [shard 2/2 E140A942-965C-4A92-AC63-F3B23756BE02]',
' session: maestro-test:test:suite:2:attempt-1',
' artifacts: /tmp/replay-suite/auth-flow',
].join('\n'),
);
});

test('formatReplayTestProgressEvent ignores unknown progress event types', () => {
const line = formatReplayTestProgressEvent({
type: 'future-progress-event',
status: 'start',
} as unknown as RequestProgressEvent);

assert.equal(line, undefined);
});

test('formatReplayTestProgressEvent renders pass, retry, fail, and skip cases', () => {
const cases: Array<{ event: RequestProgressEvent; expected: RegExp }> = [
{
event: {
type: 'replay-test',
file: '/tmp/01-login.ad',
status: 'pass',
index: 1,
total: 3,
attempt: 2,
maxAttempts: 2,
durationMs: 12_345,
},
expected: /^\[1\/3] PASS 01-login\.ad after 2 attempts \(total 12\.3s\)$/,
},
{
event: {
type: 'replay-test',
file: '/tmp/02-checkout.ad',
status: 'fail',
index: 2,
total: 3,
attempt: 1,
maxAttempts: 2,
durationMs: 1_234,
retrying: true,
message: 'first attempt failed',
},
expected: /^\[2\/3] RETRY 02-checkout\.ad attempt 1\/2 \(1\.23s\)\n first attempt failed$/,
},
{
event: {
type: 'replay-test',
file: '/tmp/03-payment.ad',
status: 'fail',
index: 3,
total: 3,
attempt: 2,
maxAttempts: 2,
durationMs: 9_876,
message: 'assertVisible failed',
session: 'maestro-test:test:suite:3:attempt-2',
artifactsDir: '/tmp/replay-suite/payment',
},
expected:
/^\[3\/3] FAIL 03-payment\.ad after 2 attempts \(total 9\.88s\)\n assertVisible failed\n session: maestro-test:test:suite:3:attempt-2\n artifacts: \/tmp\/replay-suite\/payment$/,
},
{
event: {
type: 'replay-test',
file: '/tmp/04-skip.ad',
status: 'skip',
index: 4,
total: 5,
message: 'missing platform metadata for --platform ios',
},
expected: /^\[4\/5] SKIP 04-skip\.ad\n missing platform metadata for --platform ios$/,
},
];

for (const { event, expected } of cases) {
assert.match(formatReplayTestProgressEvent(event) ?? '', expected);
}
});
2 changes: 1 addition & 1 deletion src/__tests__/daemon-client-progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ test('readDaemonSocketProgressResponse parses split progress lines before respon
assert.deepEqual(await responsePromise, { ok: true, data: { via: 'socket-progress' } });
assert.equal(socket.encoding, 'utf8');
assert.equal(socket.ended, true);
assert.match(stderr, /FAIL "Login flow" attempt 1\/2 retrying \(1\.23s\)/);
assert.match(stderr, /\[1\/2] RETRY "Login flow" in 01-login\.ad attempt 1\/2 \(1\.23s\)/);
assert.match(stderr, / first attempt failed/);
} finally {
process.stderr.write = originalStderrWrite;
Expand Down
96 changes: 74 additions & 22 deletions src/cli-test-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,98 @@ import path from 'node:path';
import type { RequestProgressEvent } from './daemon/request-progress.ts';
import { formatDurationSeconds } from './utils/duration-format.ts';

type ReplayTestCaseProgressEvent = Extract<RequestProgressEvent, { type: 'replay-test' }>;
type ReplayTestCaseStatus = ReplayTestCaseProgressEvent['status'];

const REPLAY_TEST_STATUS_LABELS: Record<ReplayTestCaseStatus, string> = {
start: 'START',
pass: 'PASS',
fail: 'FAIL',
skip: 'SKIP',
};

export function formatReplayTestProgressEvent(event: RequestProgressEvent): string | undefined {
if (event.type !== 'replay-test') return undefined;
if (event.status === 'pass') return undefined;
if (event.status === 'fail' && !event.retrying) return undefined;
if (event.type === 'replay-test-suite') {
return formatReplayTestSuiteProgressEvent(event);
}
const eventType = (event as { type?: string }).type;
if (eventType !== 'replay-test') {
return undefined;
}
return formatReplayTestCaseProgressEvent(event);
}

function formatReplayTestSuiteProgressEvent(
event: Extract<RequestProgressEvent, { type: 'replay-test-suite' }>,
): string {
const lines = [`Running replay suite: ${event.total} ${event.total === 1 ? 'file' : 'files'}`];
if (event.shardMode && event.shardCount && event.shardCount > 1) {
lines.push(` sharding: ${event.shardMode} across ${event.shardCount} devices`);
}
lines.push(` artifacts: ${event.artifactsDir}`);
return lines.join('\n');
}

function formatReplayTestCaseProgressEvent(event: ReplayTestCaseProgressEvent): string {
const lines = [formatReplayTestCaseSummaryLine(event)];
addReplayTestCaseDetailLines(lines, event);
return lines.join('\n');
}

function addReplayTestCaseDetailLines(lines: string[], event: ReplayTestCaseProgressEvent): void {
if (event.status === 'start') {
if (event.session) lines.push(` session: ${event.session}`);
if (event.artifactsDir) lines.push(` artifacts: ${event.artifactsDir}`);
return;
}

const message = event.message?.replace(/\s+/g, ' ').trim();
if (message) lines.push(` ${message}`);
if (event.status === 'fail' && !event.retrying) {
if (event.session) lines.push(` session: ${event.session}`);
if (event.artifactsDir) lines.push(` artifacts: ${event.artifactsDir}`);
}
}

function formatReplayTestCaseSummaryLine(event: ReplayTestCaseProgressEvent): string {
const indexPrefix = `[${event.index}/${event.total}]`;
const statusLabel = formatReplayTestProgressStatusLabel(event);
const name = formatReplayTestProgressName(event);
const shardSuffix = formatReplayTestProgressShardSuffix(event);
const attemptSuffix = formatReplayProgressAttemptSuffix(event);
const durationSuffix =
event.durationMs !== undefined ? ` (${formatReplayProgressDuration(event)})` : '';
const attemptSuffix = formatReplayProgressAttemptSuffix(event);
const message = event.message?.replace(/\s+/g, ' ').trim();
return `${indexPrefix} ${statusLabel} ${name}${shardSuffix}${attemptSuffix}${durationSuffix}`;
}

if (event.status === 'skip') {
return [`SKIP ${name}`, message ? ` ${message}` : ''].filter(Boolean).join('\n');
}
function formatReplayTestProgressName(event: ReplayTestCaseProgressEvent): string {
const title = event.title?.trim();
const file = path.basename(event.file);
return title ? `${JSON.stringify(title)} in ${file}` : file;
}

return [
`FAIL ${name}${attemptSuffix}${durationSuffix}`,
message ? ` ${message}` : '',
event.artifactsDir ? ` artifacts: ${event.artifactsDir}` : '',
]
.filter(Boolean)
.join('\n');
function formatReplayTestProgressStatusLabel(event: ReplayTestCaseProgressEvent): string {
return event.retrying ? 'RETRY' : REPLAY_TEST_STATUS_LABELS[event.status];
}

function formatReplayTestProgressName(event: RequestProgressEvent): string {
const title = event.title?.trim();
if (title) return JSON.stringify(title);
return path.basename(event.file);
function formatReplayTestProgressShardSuffix(event: ReplayTestCaseProgressEvent): string {
if (typeof event.shardIndex !== 'number') return '';
const shardCount = typeof event.shardCount === 'number' ? event.shardCount : '?';
const device = typeof event.deviceId === 'string' ? ` ${event.deviceId}` : '';
return ` [shard ${event.shardIndex + 1}/${shardCount}${device}]`;
}

function formatReplayProgressAttemptSuffix(event: RequestProgressEvent): string {
function formatReplayProgressAttemptSuffix(event: ReplayTestCaseProgressEvent): string {
if (event.attempt === undefined) return '';
if (event.status === 'start') return '';
if (event.status === 'fail' && event.retrying && event.maxAttempts !== undefined) {
return ` attempt ${event.attempt}/${event.maxAttempts} retrying`;
return ` attempt ${event.attempt}/${event.maxAttempts}`;
}
if (event.attempt > 1) return ` after ${event.attempt} attempts`;
return '';
}

function formatReplayProgressDuration(event: RequestProgressEvent): string {
function formatReplayProgressDuration(event: ReplayTestCaseProgressEvent): string {
const duration = formatDurationSeconds(event.durationMs ?? 0);
return event.attempt && event.attempt > 1 && !event.retrying ? `total ${duration}` : duration;
}
6 changes: 0 additions & 6 deletions src/cli-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ type PassedReplayTestResult = Extract<ReplaySuiteTestResult, { status: 'passed'
type FailedReplayTestResult = Extract<ReplaySuiteTestResult, { status: 'failed' }>;
type ReplayTestError = FailedReplayTestResult['error'];

export function announceReplayTestRun(options: { json?: boolean }): void {
if (!options.json) {
process.stderr.write('Running replay suite...\n');
}
}

export function renderReplayTestResponse(options: {
suite: ReplaySuiteResult;
json?: boolean;
Expand Down
5 changes: 1 addition & 4 deletions src/cli/commands/generic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CommandRequestResult } from '../../client.ts';
import { announceReplayTestRun, renderReplayTestResponse } from '../../cli-test.ts';
import { renderReplayTestResponse } from '../../cli-test.ts';
import { runCliCommandWithOutput } from '../../commands/cli-runner.ts';
import type { CommandName } from '../../commands/command-metadata.ts';
import type { CliOutput } from '../../commands/command-contract.ts';
Expand All @@ -16,9 +16,6 @@ export async function runGenericClientBackedCommand({
flags,
client,
}: ClientCommandParams & { command: ClientBackedCliCommandName }): Promise<boolean> {
if (command === 'test') {
announceReplayTestRun({ json: flags.json });
}
const { result, cliOutput } = await runCliCommandWithOutput({
client,
command: command as CommandName,
Expand Down
25 changes: 25 additions & 0 deletions src/compat/maestro/__tests__/runtime-interactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,31 @@ test('invokeMaestroSwipeScreen preserves vertical percentage endpoints', async (
expect(swipeFlags[0]?.postGestureStabilization).toBeUndefined();
});

test('invokeMaestroSwipeScreen keeps iOS horizontal percentage swipes away from screen edges', async () => {
const swipes: string[][] = [];
const response = await invokeMaestroSwipeScreen({
baseReq: {
token: 'test',
session: 'ios-pager',
flags: { platform: 'ios' },
},
positionals: ['percent', '10', '50', '90', '50', '300'],
invoke: async (req: DaemonRequest): Promise<DaemonResponse> => {
if (req.command === 'snapshot') {
return { ok: true, data: fullScreenSnapshot(400, 800) };
}
if (req.command === 'swipe') {
swipes.push(req.positionals ?? []);
return { ok: true, data: {} };
}
return { ok: false, error: { code: 'UNEXPECTED_COMMAND', message: req.command } };
},
});

expect(response.ok).toBe(true);
expect(swipes).toEqual([['60', '400', '340', '400', '300']]);
});

test('invokeMaestroSwipeScreen keeps Android horizontal percentage swipes on the content lane', async () => {
const swipes: string[][] = [];
const response = await invokeMaestroSwipeScreen({
Expand Down
18 changes: 16 additions & 2 deletions src/compat/maestro/runtime-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,14 +341,28 @@ function resolvePercentScreenSwipe(
}
const [x1, y1, x2, y2] = values as [number, number, number, number];
const lane = maestroHorizontalContentSwipeLanePercent(platform, x1, y1, x2, y2);
const marginPx = maestroPercentSwipeMarginPx(platform, frame, x1, y1, x2, y2);
return {
ok: true,
start: pointFromPercent(frame, x1, lane.startY, { marginPx: 1 }),
end: pointFromPercent(frame, x2, lane.endY, { marginPx: 1 }),
start: pointFromPercent(frame, x1, lane.startY, { marginPx }),
end: pointFromPercent(frame, x2, lane.endY, { marginPx }),
durationMs,
};
}

function maestroPercentSwipeMarginPx(
platform: string,
frame: GestureReferenceFrame,
x1: number,
y1: number,
x2: number,
y2: number,
): number {
if (platform !== 'ios') return 1;
if (y1 !== y2 || Math.abs(x2 - x1) < 30) return 1;
return Math.max(1, Math.round(frame.referenceWidth * 0.15));
}

function maestroHorizontalContentSwipeLanePercent(
platform: string,
x1: number,
Expand Down
2 changes: 1 addition & 1 deletion src/daemon/handlers/__tests__/session-replay-vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1560,7 +1560,7 @@ test('runReplayScriptFile resolves Maestro screen swipes from the snapshot frame
[
['gesture', ['swipe', 'left', '300']],
['snapshot', []],
['swipe', ['360', '400', '40', '400', '300']],
['swipe', ['340', '400', '60', '400', '300']],
],
);
});
Expand Down
Loading
Loading