From b40ece230a9117d6294f9ca9b5fc893e77946317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 13:53:03 +0200 Subject: [PATCH 1/4] feat: polish replay test progress reporter --- examples/test-app/app.json | 5 +- examples/test-app/maestro/checkout-form.yaml | 1 + .../__tests__/session-test-suite.test.ts | 4 + src/daemon/handlers/session-replay-runtime.ts | 56 ++++++++- src/daemon/request-progress.ts | 12 +- src/replay/test/__tests__/progress.test.ts | 91 +++++++++++---- .../test/__tests__/reporters-default.test.ts | 70 +++++++++++ src/replay/test/progress.ts | 109 ++++++++++++++++-- src/replay/test/reporters/default.ts | 53 ++++++++- src/replay/test/reporters/progress.ts | 2 + src/replay/test/reporters/types.ts | 2 + 11 files changed, 365 insertions(+), 40 deletions(-) create mode 100644 src/replay/test/__tests__/reporters-default.test.ts diff --git a/examples/test-app/app.json b/examples/test-app/app.json index 743ad9951..ee83f2bcb 100644 --- a/examples/test-app/app.json +++ b/examples/test-app/app.json @@ -6,7 +6,10 @@ "orientation": "default", "userInterfaceStyle": "automatic", "buildCacheProvider": { - "plugin": "expo-build-disk-cache" + "plugin": "expo-build-disk-cache", + "options": { + "cacheDir": "~/Developer/agent-device/.expo/build-run-cache" + } }, "plugins": ["expo-router"], "ios": { diff --git a/examples/test-app/maestro/checkout-form.yaml b/examples/test-app/maestro/checkout-form.yaml index 8a22a349b..125a3ea88 100644 --- a/examples/test-app/maestro/checkout-form.yaml +++ b/examples/test-app/maestro/checkout-form.yaml @@ -1,4 +1,5 @@ appId: com.callstack.agentdevicelab +name: Checkout form env: CHECKOUT_NAME: Ada Lovelace CHECKOUT_EMAIL: ada@example.com diff --git a/src/daemon/handlers/__tests__/session-test-suite.test.ts b/src/daemon/handlers/__tests__/session-test-suite.test.ts index e39c28a4a..509ccc8a7 100644 --- a/src/daemon/handlers/__tests__/session-test-suite.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite.test.ts @@ -188,6 +188,8 @@ test('test emits progress when attempts retry and pass', async () => { maxAttempts: 2, stepIndex: 1, stepTotal: 1, + stepCommand: 'open', + stepValue: 'Demo', }); expect(testEvents[2]).toMatchObject({ type: 'replay-test', @@ -211,6 +213,8 @@ test('test emits progress when attempts retry and pass', async () => { maxAttempts: 2, stepIndex: 1, stepTotal: 1, + stepCommand: 'open', + stepValue: 'Demo', }); expect(testEvents[4]).toMatchObject({ type: 'replay-test', diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 474d9d0c7..9cd6afd04 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -4,13 +4,18 @@ import { type CommandFlags } from '../../core/dispatch.ts'; import { parseReplayInput } from '../../compat/replay-input.ts'; import { asAppError } from '../../kernel/errors.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionAction } from '../types.ts'; -import { emitRequestProgress, readReplayTestActionProgress } from '../request-progress.ts'; +import { + emitRequestProgress, + readReplayTestActionProgress, + type ReplayTestProgressEvent, +} from '../request-progress.ts'; import { SessionStore } from '../session-store.ts'; import { type ReplayScriptMetadata, writeReplayScript } from '../../replay/script.ts'; import { healReplayAction } from './session-replay-heal.ts'; import { formatScriptActionSummary } from '../../replay/script-utils.ts'; import { errorResponse } from './response.ts'; import { invokeReplayAction } from './session-replay-action-runtime.ts'; +import { tryParseSelectorChain } from '../selectors.ts'; import { buildReplayVarScope, collectReplayShellEnv, @@ -93,7 +98,7 @@ export async function runReplayScriptFile(params: { for (let index = 0; index < actions.length; index += 1) { const action = actions[index]; if (!action || action.command === 'replay') continue; - emitReplayTestActionProgress(resolved, index, actions.length); + emitReplayTestActionProgress(resolved, index, actions.length, action); const sampleStart = readSessionSnapshotSampleCount(sessionStore, sessionName); let response = await invokeReplayAction({ @@ -239,6 +244,7 @@ function emitReplayTestActionProgress( file: string, actionIndex: number, actionTotal: number, + action: SessionAction, ): void { const progress = readReplayTestActionProgress(); if (!progress) return; @@ -249,9 +255,55 @@ function emitReplayTestActionProgress( status: 'progress', stepIndex: actionIndex + 1, stepTotal: actionTotal, + ...formatReplayTestActionProgress(action), }); } +function formatReplayTestActionProgress( + action: SessionAction, +): Pick { + return { + stepCommand: formatReplayTestProgressCommand(action.command), + ...formatReplayTestProgressValue(action), + }; +} + +function formatReplayTestProgressCommand(command: string): string { + if (!command.startsWith('__maestro')) return command; + const name = command.slice('__maestro'.length); + return name.length > 0 ? name[0]!.toLowerCase() + name.slice(1) : command; +} + +function formatReplayTestProgressValue( + action: SessionAction, +): Pick { + const positionals = action.positionals ?? []; + const selectorValue = readSelectorDisplayValue(positionals[0]); + if (selectorValue) return { stepValue: selectorValue }; + if (action.command === '__maestroTapPointPercent' && positionals.length >= 2) { + return { stepValue: `${positionals[0]},${positionals[1]}%` }; + } + if (positionals.length === 0) return {}; + return { stepValue: positionals.join(' ') }; +} + +function readSelectorDisplayValue(selector: string | undefined): string | undefined { + if (!selector) return undefined; + const parsed = tryParseSelectorChain(selector); + if (!parsed) return undefined; + const values = parsed.selectors.flatMap((entry) => + entry.terms.flatMap((term) => + (term.key === 'label' || term.key === 'text' || term.key === 'id') && + typeof term.value === 'string' + ? [term.value] + : [], + ), + ); + if (values.length === 0) return undefined; + const first = values[0]; + return first && values.every((value) => value === first) ? first : undefined; +} + function buildReplayMetadataFlags( flags: CommandFlags | undefined, metadata: ReplayScriptMetadata, diff --git a/src/daemon/request-progress.ts b/src/daemon/request-progress.ts index 1b013933d..d6fdd0bd8 100644 --- a/src/daemon/request-progress.ts +++ b/src/daemon/request-progress.ts @@ -20,6 +20,8 @@ export type ReplayTestProgressEvent = { total: number; stepIndex?: number; stepTotal?: number; + stepCommand?: string; + stepValue?: string; attempt?: number; maxAttempts?: number; durationMs?: number; @@ -47,7 +49,15 @@ export type RequestProgressEvent = export type RequestProgressSink = (event: RequestProgressEvent) => void; export type ReplayTestActionProgressContext = Omit< ReplayTestProgressEvent, - 'type' | 'status' | 'stepIndex' | 'stepTotal' | 'durationMs' | 'retrying' | 'message' + | 'type' + | 'status' + | 'stepIndex' + | 'stepTotal' + | 'stepCommand' + | 'stepValue' + | 'durationMs' + | 'retrying' + | 'message' >; const requestProgress = new AsyncLocalStorage(); diff --git a/src/replay/test/__tests__/progress.test.ts b/src/replay/test/__tests__/progress.test.ts index aaa62d327..c3af0d458 100644 --- a/src/replay/test/__tests__/progress.test.ts +++ b/src/replay/test/__tests__/progress.test.ts @@ -23,6 +23,24 @@ function renderTestResult( ?.text; } +function withForcedColor(run: () => T): T { + const originalForceColor = process.env.FORCE_COLOR; + const originalNoColor = process.env.NO_COLOR; + process.env.FORCE_COLOR = '1'; + delete process.env.NO_COLOR; + try { + return run(); + } finally { + restoreEnvValue('FORCE_COLOR', originalForceColor); + restoreEnvValue('NO_COLOR', originalNoColor); + } +} + +function restoreEnvValue(name: 'FORCE_COLOR' | 'NO_COLOR', value: string | undefined): void { + if (typeof value === 'string') process.env[name] = value; + else delete process.env[name]; +} + test('createReplayTestProgressRenderer renders pass, retry, fail, and skip cases', () => { const cases: Array<{ event: ReplayTestResult; expected: RegExp }> = [ { @@ -130,12 +148,8 @@ test('createReplayTestProgressRenderer colors stderr progress rows when stdout i } }); -test('createReplayTestProgressRenderer dims live step progress when color is enabled', () => { - const originalForceColor = process.env.FORCE_COLOR; - const originalNoColor = process.env.NO_COLOR; - process.env.FORCE_COLOR = '1'; - delete process.env.NO_COLOR; - try { +test('createReplayTestProgressRenderer renders live step progress with spinner and action detail', () => { + withForcedColor(() => { const renderer = createReplayTestProgressRenderer({ liveProgress: true }); const rendered = renderer.render({ type: 'test-step', @@ -146,27 +160,61 @@ test('createReplayTestProgressRenderer dims live step progress when color is ena total: 1, stepIndex: 3, stepTotal: 20, + stepCommand: 'tapOn', + stepValue: 'Sign in', }, }); assert.deepEqual(rendered, { - text: '\r\u001B[2K⊙ Checkout flow\u001B[2m [3/20]\u001B[22m', + text: '\r\u001B[2K\u001B[34m⠋\u001B[39m Checkout flow \u001B[2m[\u001B[22m\u001B[2m3/20\u001B[22m \u001B[35mtapOn\u001B[39m \u001B[32mSign in\u001B[39m\u001B[2m]\u001B[22m', newline: false, }); - } finally { - if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor; - else delete process.env.FORCE_COLOR; - if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor; - else delete process.env.NO_COLOR; - } + }); +}); + +test('createReplayTestProgressRenderer trims live step progress by visible columns', () => { + withForcedColor(() => { + const renderer = createReplayTestProgressRenderer({ liveProgress: true, columns: 56 }); + const rendered = renderer.render({ + type: 'test-step', + test: { + file: '/tmp/checkout-form.yaml', + index: 1, + total: 1, + stepIndex: 2, + stepTotal: 20, + stepCommand: 'assertVisible', + stepValue: 'Agent', + }, + }); + + assert.deepEqual(rendered, { + text: '\r\u001B[2K\u001B[34m⠋\u001B[39m checkout-form.yaml \u001B[2m[\u001B[22m\u001B[2m2/20\u001B[22m \u001B[35massertVisible\u001B[39m \u001B[32mAgent\u001B[39m\u001B[2m]\u001B[22m', + newline: false, + }); + + const truncatingRenderer = createReplayTestProgressRenderer({ + liveProgress: true, + columns: 36, + }); + const truncated = truncatingRenderer.render({ + type: 'test-step', + test: { + file: '/tmp/checkout-form.yaml', + index: 1, + total: 1, + stepIndex: 2, + stepTotal: 20, + stepCommand: 'assertVisible', + stepValue: 'Agent Login', + }, + }); + assert.ok(truncated?.text.endsWith('...\u001B[0m')); + }); }); test('createReplayTestProgressRenderer colors completed result markers when color is enabled', () => { - const originalForceColor = process.env.FORCE_COLOR; - const originalNoColor = process.env.NO_COLOR; - process.env.FORCE_COLOR = '1'; - delete process.env.NO_COLOR; - try { + withForcedColor(() => { assert.equal( renderTestResult({ file: '/tmp/01-pass.ad', @@ -201,10 +249,5 @@ test('createReplayTestProgressRenderer colors completed result markers when colo message: 'boom', }); assert.ok(failedLine?.startsWith('\u001B[31m⨯\u001B[39m Checkout failure')); - } finally { - if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor; - else delete process.env.FORCE_COLOR; - if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor; - else delete process.env.NO_COLOR; - } + }); }); diff --git a/src/replay/test/__tests__/reporters-default.test.ts b/src/replay/test/__tests__/reporters-default.test.ts new file mode 100644 index 000000000..2d4ca64bf --- /dev/null +++ b/src/replay/test/__tests__/reporters-default.test.ts @@ -0,0 +1,70 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import type { ReplaySuiteResult } from '../../../daemon/types.ts'; +import { createDefaultReplayTestReporter } from '../reporters/default.ts'; +import type { ReplayTestReporterContext } from '../reporters/types.ts'; + +function createReporterContext(options: { stderrIsTty: boolean }): { + context: ReplayTestReporterContext; + stderr: string[]; + stdout: string[]; +} { + const stderr: string[] = []; + const stdout: string[] = []; + return { + context: { + stdout: { + isTTY: false, + write: (text) => stdout.push(text), + }, + stderr: { + isTTY: options.stderrIsTty, + write: (text) => stderr.push(text), + }, + }, + stderr, + stdout, + }; +} + +function emptySuite(): ReplaySuiteResult { + return { + total: 0, + executed: 0, + passed: 0, + failed: 0, + skipped: 0, + notRun: 0, + durationMs: 0, + failures: [], + tests: [], + }; +} + +test('default replay test reporter hides and restores cursor for tty progress', () => { + const reporter = createDefaultReplayTestReporter(); + const { context, stderr, stdout } = createReporterContext({ stderrIsTty: true }); + + reporter.onSuiteStart?.( + { total: 0, runnable: 0, skipped: 0, artifactsDir: '/tmp/replay' }, + context, + ); + reporter.onSuiteEnd?.(emptySuite(), context); + + assert.equal(stderr[0], '\u001B[?25l'); + assert.equal(stderr.at(-1), '\u001B[?25h'); + assert.deepEqual(stdout, ['Test summary: 0 passed (0) in 0s\n']); +}); + +test('default replay test reporter leaves cursor alone for non-tty streams', () => { + const reporter = createDefaultReplayTestReporter(); + const { context, stderr } = createReporterContext({ stderrIsTty: false }); + + reporter.onSuiteStart?.( + { total: 0, runnable: 0, skipped: 0, artifactsDir: '/tmp/replay' }, + context, + ); + reporter.onSuiteEnd?.(emptySuite(), context); + + assert.deepEqual(stderr, []); +}); diff --git a/src/replay/test/progress.ts b/src/replay/test/progress.ts index ea934d802..14b7c255b 100644 --- a/src/replay/test/progress.ts +++ b/src/replay/test/progress.ts @@ -24,11 +24,21 @@ export type ReplayTestProgressRenderer = { render(event: ReplayTestReporterProgressEvent): ReplayTestProgressRender | undefined; }; +const REPLAY_TEST_PROGRESS_SPINNER = { + interval: 80, + frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], +}; +const ANSI_ESCAPE_PREFIX = `${String.fromCharCode(27)}[`; +const ANSI_RESET = `${ANSI_ESCAPE_PREFIX}0m`; + +export const REPLAY_TEST_PROGRESS_SPINNER_INTERVAL_MS = REPLAY_TEST_PROGRESS_SPINNER.interval; + export function createReplayTestProgressRenderer( options: ReplayTestProgressFormatOptions = {}, ): ReplayTestProgressRenderer { const completedKeys = new Set(); let hasLiveProgressLine = false; + let spinnerFrameIndex = 0; return { render(event) { if (event.type === 'suite-start') { @@ -39,8 +49,12 @@ export function createReplayTestProgressRenderer( if (event.type === 'test-step') { if (!options.liveProgress) return undefined; hasLiveProgressLine = true; + const spinnerFrame = nextReplayTestProgressSpinnerFrame(spinnerFrameIndex); + spinnerFrameIndex += 1; return { - text: clearLinePrefix(formatReplayTestLiveProgressLine(event.test, options)), + text: clearLinePrefix( + formatReplayTestLiveProgressLine(event.test, options, spinnerFrame), + ), newline: false, }; } @@ -59,6 +73,13 @@ export function createReplayTestProgressRenderer( }; } +function nextReplayTestProgressSpinnerFrame(index: number): string { + return ( + REPLAY_TEST_PROGRESS_SPINNER.frames[index % REPLAY_TEST_PROGRESS_SPINNER.frames.length] ?? + REPLAY_TEST_PROGRESS_SPINNER.frames[0]! + ); +} + function formatReplayTestProgressEvent( event: ReplayTestResult, options: ReplayTestProgressFormatOptions = {}, @@ -81,24 +102,31 @@ function replayTestStatusIcon(status: ReplayTestResult['status']): string { function formatReplayTestLiveProgressLine( event: ReplayTestStep, options: ReplayTestProgressFormatOptions, + spinnerFrame: string, ): string { const title = event.title?.trim(); const file = path.basename(event.file); const useColor = supportsColor(process.stderr); + const spinner = formatReplayTestProgressSpinner(spinnerFrame, { useColor }); const shardSuffix = formatReplayTestProgressShardSuffix(event, { useColor }); const stepSuffix = formatReplayTestLiveProgressStepSuffix(event, { useColor }); const suffix = `${shardSuffix}${stepSuffix}`; - const prefix = '⊙ '; + const prefix = `${spinner} `; if (!title) return trimToColumns(`${prefix}${file}${suffix}`, options.columns); - const titlePrefix = prefix; - const titleSuffix = suffix; const availableTitleColumns = Math.max( 0, - resolveColumns(options.columns) - titlePrefix.length - titleSuffix.length, + resolveColumns(options.columns) - visibleLength(prefix) - visibleLength(suffix), ); const formattedTitle = trimToColumns(title, availableTitleColumns); - return trimToColumns(`${titlePrefix}${formattedTitle}${titleSuffix}`, options.columns); + return trimToColumns(`${prefix}${formattedTitle}${suffix}`, options.columns); +} + +function formatReplayTestProgressSpinner( + frame: string, + options: { useColor?: boolean } = {}, +): string { + return options.useColor ? colorizeProgressMarker(frame, 'blue') : frame; } function formatReplayTestLiveProgressStepSuffix( @@ -107,8 +135,21 @@ function formatReplayTestLiveProgressStepSuffix( ): string { const stepIndex = event.stepIndex ?? 0; const stepTotal = event.stepTotal ?? 0; - const suffix = ` [${stepIndex}/${stepTotal}]`; - return options.useColor ? colorizeProgressMarker(suffix, 'dim') : suffix; + const stepMarker = `${stepIndex}/${stepTotal}`; + const command = event.stepCommand?.trim(); + const value = event.stepValue?.trim(); + if (!options.useColor) { + const details = [stepMarker, command, value].filter(Boolean).join(' '); + return ` [${details}]`; + } + const openBracket = colorizeProgressMarker('[', 'dim'); + const closeBracket = colorizeProgressMarker(']', 'dim'); + const details = [ + colorizeProgressMarker(stepMarker, 'dim'), + command ? colorizeProgressMarker(command, 'magenta') : '', + value ? colorizeProgressMarker(value, 'green') : '', + ].filter(Boolean); + return ` ${openBracket}${details.join(' ')}${closeBracket}`; } function addReplayTestCaseDetailLines( @@ -240,10 +281,58 @@ function resolveColumns(columns: number | undefined): number { function trimToColumns(value: string, columns: number | undefined): string { const limit = resolveColumns(columns); - if (value.length <= limit) return value; + if (visibleLength(value) <= limit) return value; if (limit <= 0) return ''; if (limit <= 3) return '.'.repeat(limit); - return `${value.slice(0, limit - 3)}...`; + return `${sliceVisibleColumns(value, limit - 3)}...${hasAnsi(value) ? ANSI_RESET : ''}`; +} + +function visibleLength(value: string): number { + let length = 0; + for (let index = 0; index < value.length; ) { + const ansi = readAnsiEscapeAt(value, index); + if (ansi) { + index += ansi.length; + continue; + } + length += 1; + index += 1; + } + return length; +} + +function hasAnsi(value: string): boolean { + return value.includes(ANSI_ESCAPE_PREFIX); +} + +function sliceVisibleColumns(value: string, columns: number): string { + if (columns <= 0) return ''; + let visibleColumns = 0; + let output = ''; + for (let index = 0; index < value.length && visibleColumns < columns; ) { + const ansi = readAnsiEscapeAt(value, index); + if (ansi) { + output += ansi; + index += ansi.length; + continue; + } + output += value[index]; + index += 1; + visibleColumns += 1; + } + return output; +} + +function readAnsiEscapeAt(value: string, index: number): string | null { + if (!value.startsWith(ANSI_ESCAPE_PREFIX, index)) return null; + for (let cursor = index + ANSI_ESCAPE_PREFIX.length; cursor < value.length; cursor += 1) { + if (isAnsiFinalByte(value.charCodeAt(cursor))) return value.slice(index, cursor + 1); + } + return null; +} + +function isAnsiFinalByte(code: number): boolean { + return code >= 0x40 && code <= 0x7e; } function replayTestProgressStepLines(event: ReplayTestResult): string[] { diff --git a/src/replay/test/reporters/default.ts b/src/replay/test/reporters/default.ts index 905c200d5..2aba646b6 100644 --- a/src/replay/test/reporters/default.ts +++ b/src/replay/test/reporters/default.ts @@ -1,6 +1,9 @@ import type { ReplaySuiteResult } from '../../../daemon/types.ts'; import { replayTestFailureStepLines } from '../trace.ts'; -import { createReplayTestProgressRenderer } from '../progress.ts'; +import { + createReplayTestProgressRenderer, + REPLAY_TEST_PROGRESS_SPINNER_INTERVAL_MS, +} from '../progress.ts'; import { formatDurationSeconds } from '../../../utils/duration-format.ts'; import { colorize, supportsColor } from '../../../utils/output.ts'; import type { @@ -23,12 +26,21 @@ import { type PassedReplayTestResult, } from './format.ts'; +const ANSI_ESCAPE_PREFIX = `${String.fromCharCode(27)}[`; +const HIDE_CURSOR = `${ANSI_ESCAPE_PREFIX}?25l`; +const SHOW_CURSOR = `${ANSI_ESCAPE_PREFIX}?25h`; + export function createDefaultReplayTestReporter(): ReplayTestReporter { let progressRenderer: ReturnType | undefined; + let latestLiveProgressEvent: ReplayTestReporterProgressEvent | undefined; + let progressInterval: ReturnType | undefined; + let cursorHidden = false; const renderProgress = ( event: ReplayTestReporterProgressEvent, context: ReplayTestReporterContext, ) => { + stopLiveProgressInterval(); + latestLiveProgressEvent = event.type === 'test-step' ? event : undefined; progressRenderer ??= createReplayTestProgressRenderer({ verbose: context.verbose, liveProgress: context.stderr.isTTY && !process.env.CI, @@ -37,10 +49,38 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter { const output = progressRenderer.render(event); if (!output) return; context.stderr.write(output.newline ? `${output.text}\n` : output.text); + if (event.type === 'test-step' && context.stderr.isTTY && !process.env.CI) { + startLiveProgressInterval(context); + } + }; + const startLiveProgressInterval = (context: ReplayTestReporterContext) => { + if (progressInterval || !latestLiveProgressEvent) return; + progressInterval = setInterval(() => { + if (!latestLiveProgressEvent) return; + const output = progressRenderer?.render(latestLiveProgressEvent); + if (output) context.stderr.write(output.newline ? `${output.text}\n` : output.text); + }, REPLAY_TEST_PROGRESS_SPINNER_INTERVAL_MS); + progressInterval.unref?.(); + }; + const stopLiveProgressInterval = () => { + if (!progressInterval) return; + clearInterval(progressInterval); + progressInterval = undefined; + }; + const hideCursor = (context: ReplayTestReporterContext) => { + if (cursorHidden || !shouldControlCursor(context)) return; + context.stderr.write(HIDE_CURSOR); + cursorHidden = true; + }; + const showCursor = (context: ReplayTestReporterContext) => { + if (!cursorHidden) return; + context.stderr.write(SHOW_CURSOR); + cursorHidden = false; }; return { name: 'default', onSuiteStart: (suite, context) => { + hideCursor(context); renderProgress({ type: 'suite-start', suite }, context); }, onTestStep: (test, context) => { @@ -49,11 +89,20 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter { onTestResult: (test, context) => { renderProgress({ type: 'test-result', test }, context); }, - onSuiteEnd: (suite, context) => renderReplayTestSummary(suite, context), + onSuiteEnd: (suite, context) => { + stopLiveProgressInterval(); + latestLiveProgressEvent = undefined; + showCursor(context); + renderReplayTestSummary(suite, context); + }, getExitCode: getReplayTestExitCode, }; } +function shouldControlCursor(context: ReplayTestReporterContext): boolean { + return context.stderr.isTTY && !process.env.CI; +} + function renderReplayTestSummary( data: ReplaySuiteResult, context: ReplayTestReporterContext, diff --git a/src/replay/test/reporters/progress.ts b/src/replay/test/reporters/progress.ts index ce82836d8..527c3cfad 100644 --- a/src/replay/test/reporters/progress.ts +++ b/src/replay/test/reporters/progress.ts @@ -40,6 +40,8 @@ export function toReplayTestReporterProgressEvent( ...test, stepIndex: event.stepIndex, stepTotal: event.stepTotal, + stepCommand: event.stepCommand, + stepValue: event.stepValue, }, }; } diff --git a/src/replay/test/reporters/types.ts b/src/replay/test/reporters/types.ts index 1254e0c58..54cfbd62a 100644 --- a/src/replay/test/reporters/types.ts +++ b/src/replay/test/reporters/types.ts @@ -45,6 +45,8 @@ export type ReplayTestCase = { export type ReplayTestStep = ReplayTestCase & { stepIndex?: number; stepTotal?: number; + stepCommand?: string; + stepValue?: string; }; export type ReplayTestResult = ReplayTestCase & { From c5082392e956b04c7e5c721e178ebce3e9ec0f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 14:27:45 +0200 Subject: [PATCH 2/4] test: stabilize replay reporter cursor test in CI --- .../test/__tests__/reporters-default.test.ts | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/replay/test/__tests__/reporters-default.test.ts b/src/replay/test/__tests__/reporters-default.test.ts index 2d4ca64bf..70a15d2cb 100644 --- a/src/replay/test/__tests__/reporters-default.test.ts +++ b/src/replay/test/__tests__/reporters-default.test.ts @@ -41,19 +41,33 @@ function emptySuite(): ReplaySuiteResult { }; } +function withCiEnv(value: string | undefined, run: () => T): T { + const original = process.env.CI; + if (value === undefined) delete process.env.CI; + else process.env.CI = value; + try { + return run(); + } finally { + if (original === undefined) delete process.env.CI; + else process.env.CI = original; + } +} + test('default replay test reporter hides and restores cursor for tty progress', () => { - const reporter = createDefaultReplayTestReporter(); - const { context, stderr, stdout } = createReporterContext({ stderrIsTty: true }); + withCiEnv(undefined, () => { + const reporter = createDefaultReplayTestReporter(); + const { context, stderr, stdout } = createReporterContext({ stderrIsTty: true }); - reporter.onSuiteStart?.( - { total: 0, runnable: 0, skipped: 0, artifactsDir: '/tmp/replay' }, - context, - ); - reporter.onSuiteEnd?.(emptySuite(), context); + reporter.onSuiteStart?.( + { total: 0, runnable: 0, skipped: 0, artifactsDir: '/tmp/replay' }, + context, + ); + reporter.onSuiteEnd?.(emptySuite(), context); - assert.equal(stderr[0], '\u001B[?25l'); - assert.equal(stderr.at(-1), '\u001B[?25h'); - assert.deepEqual(stdout, ['Test summary: 0 passed (0) in 0s\n']); + assert.equal(stderr[0], '\u001B[?25l'); + assert.equal(stderr.at(-1), '\u001B[?25h'); + assert.deepEqual(stdout, ['Test summary: 0 passed (0) in 0s\n']); + }); }); test('default replay test reporter leaves cursor alone for non-tty streams', () => { From eb78330c8f61050a393f03d4849c160f9924c7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 14:37:25 +0200 Subject: [PATCH 3/4] refactor: dedupe replay reporter live progress checks --- src/replay/test/reporters/default.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/replay/test/reporters/default.ts b/src/replay/test/reporters/default.ts index 2aba646b6..cb162bccd 100644 --- a/src/replay/test/reporters/default.ts +++ b/src/replay/test/reporters/default.ts @@ -43,13 +43,13 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter { latestLiveProgressEvent = event.type === 'test-step' ? event : undefined; progressRenderer ??= createReplayTestProgressRenderer({ verbose: context.verbose, - liveProgress: context.stderr.isTTY && !process.env.CI, + liveProgress: shouldUseLiveProgress(context), columns: context.stderr.columns, }); const output = progressRenderer.render(event); if (!output) return; context.stderr.write(output.newline ? `${output.text}\n` : output.text); - if (event.type === 'test-step' && context.stderr.isTTY && !process.env.CI) { + if (event.type === 'test-step' && shouldUseLiveProgress(context)) { startLiveProgressInterval(context); } }; @@ -68,7 +68,7 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter { progressInterval = undefined; }; const hideCursor = (context: ReplayTestReporterContext) => { - if (cursorHidden || !shouldControlCursor(context)) return; + if (cursorHidden || !shouldUseLiveProgress(context)) return; context.stderr.write(HIDE_CURSOR); cursorHidden = true; }; @@ -99,7 +99,7 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter { }; } -function shouldControlCursor(context: ReplayTestReporterContext): boolean { +function shouldUseLiveProgress(context: ReplayTestReporterContext): boolean { return context.stderr.isTTY && !process.env.CI; } From 3ae88119d6b2055268f99ccf45ff47780affcf2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 15:45:54 +0200 Subject: [PATCH 4/4] fix: make Expo build cache path configurable --- .gitignore | 1 + examples/test-app/app.config.js | 27 +++++++++++++++++++++++++++ examples/test-app/app.json | 24 ------------------------ 3 files changed, 28 insertions(+), 24 deletions(-) create mode 100644 examples/test-app/app.config.js delete mode 100644 examples/test-app/app.json diff --git a/.gitignore b/.gitignore index 722c2297f..581c9ade7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +examples/test-app/.env.local scripts/perf/.results/ .pnpm-store/ .fallow/ diff --git a/examples/test-app/app.config.js b/examples/test-app/app.config.js new file mode 100644 index 000000000..1113b78e7 --- /dev/null +++ b/examples/test-app/app.config.js @@ -0,0 +1,27 @@ +const buildRunCacheDir = + process.env.AGENT_DEVICE_EXPO_BUILD_CACHE_DIR?.trim() || './.expo/build-run-cache'; + +module.exports = { + expo: { + name: 'Agent Device Tester', + slug: 'agent-device-test-app', + version: '1.0.0', + orientation: 'default', + userInterfaceStyle: 'automatic', + buildCacheProvider: { + plugin: 'expo-build-disk-cache', + options: { + cacheDir: buildRunCacheDir, + }, + }, + plugins: ['expo-router'], + ios: { + supportsTablet: true, + bundleIdentifier: 'com.callstack.agentdevicelab', + }, + android: { + package: 'com.callstack.agentdevicelab', + predictiveBackGestureEnabled: false, + }, + }, +}; diff --git a/examples/test-app/app.json b/examples/test-app/app.json deleted file mode 100644 index ee83f2bcb..000000000 --- a/examples/test-app/app.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "expo": { - "name": "Agent Device Tester", - "slug": "agent-device-test-app", - "version": "1.0.0", - "orientation": "default", - "userInterfaceStyle": "automatic", - "buildCacheProvider": { - "plugin": "expo-build-disk-cache", - "options": { - "cacheDir": "~/Developer/agent-device/.expo/build-run-cache" - } - }, - "plugins": ["expo-router"], - "ios": { - "supportsTablet": true, - "bundleIdentifier": "com.callstack.agentdevicelab" - }, - "android": { - "package": "com.callstack.agentdevicelab", - "predictiveBackGestureEnabled": false - } - } -}