diff --git a/src/__tests__/cli-network.test.ts b/src/__tests__/cli-network.test.ts index 3b5a1157d..045f4e4d5 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -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, @@ -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/); }); @@ -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, @@ -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 }); } diff --git a/src/__tests__/cli-test-progress.test.ts b/src/__tests__/cli-test-progress.test.ts new file mode 100644 index 000000000..d1ff4089f --- /dev/null +++ b/src/__tests__/cli-test-progress.test.ts @@ -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); + } +}); diff --git a/src/__tests__/daemon-client-progress.test.ts b/src/__tests__/daemon-client-progress.test.ts index 19db7b3ab..eaf4b3c69 100644 --- a/src/__tests__/daemon-client-progress.test.ts +++ b/src/__tests__/daemon-client-progress.test.ts @@ -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; diff --git a/src/cli-test-progress.ts b/src/cli-test-progress.ts index 0d914d8d5..b199404a5 100644 --- a/src/cli-test-progress.ts +++ b/src/cli-test-progress.ts @@ -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; +type ReplayTestCaseStatus = ReplayTestCaseProgressEvent['status']; + +const REPLAY_TEST_STATUS_LABELS: Record = { + 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, +): 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; } diff --git a/src/cli-test.ts b/src/cli-test.ts index 242a4d58d..f3f5f67da 100644 --- a/src/cli-test.ts +++ b/src/cli-test.ts @@ -10,12 +10,6 @@ type PassedReplayTestResult = Extract; 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; diff --git a/src/cli/commands/generic.ts b/src/cli/commands/generic.ts index 7094a54d9..db8fed2e1 100644 --- a/src/cli/commands/generic.ts +++ b/src/cli/commands/generic.ts @@ -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'; @@ -16,9 +16,6 @@ export async function runGenericClientBackedCommand({ flags, client, }: ClientCommandParams & { command: ClientBackedCliCommandName }): Promise { - if (command === 'test') { - announceReplayTestRun({ json: flags.json }); - } const { result, cliOutput } = await runCliCommandWithOutput({ client, command: command as CommandName, diff --git a/src/compat/maestro/__tests__/runtime-interactions.test.ts b/src/compat/maestro/__tests__/runtime-interactions.test.ts index 0a8d96199..efbc3f775 100644 --- a/src/compat/maestro/__tests__/runtime-interactions.test.ts +++ b/src/compat/maestro/__tests__/runtime-interactions.test.ts @@ -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 => { + 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({ diff --git a/src/compat/maestro/runtime-interactions.ts b/src/compat/maestro/runtime-interactions.ts index 3dee47f1c..c8533431f 100644 --- a/src/compat/maestro/runtime-interactions.ts +++ b/src/compat/maestro/runtime-interactions.ts @@ -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, diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index 9802b574a..643de7c03 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -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']], ], ); }); diff --git a/src/daemon/handlers/__tests__/session-test-suite.test.ts b/src/daemon/handlers/__tests__/session-test-suite.test.ts index 8b3cbe5fa..9eef59ed4 100644 --- a/src/daemon/handlers/__tests__/session-test-suite.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite.test.ts @@ -6,6 +6,12 @@ import { handleSessionCommands } from '../session.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData } from '../../types.ts'; import { type RequestProgressEvent, withRequestProgressSink } from '../../request-progress.ts'; +import { + clearRequestCanceled, + getRequestSignal, + markRequestCanceled, + registerRequestAbort, +} from '../../request-cancel.ts'; import { withDeviceInventoryProvider } from '../../../core/dispatch-resolve.ts'; import type { DeviceInfo } from '../../../utils/device.ts'; @@ -156,8 +162,16 @@ test('test emits progress when attempts retry and pass', async () => { durationMs: expect.any(Number), }, ]); - expect(events.map((event) => event.status)).toEqual(['fail', 'pass']); expect(events[0]).toMatchObject({ + type: 'replay-test-suite', + status: 'start', + total: 1, + runnable: 1, + skipped: 0, + }); + const testEvents = events.filter((event) => event.type === 'replay-test'); + expect(testEvents.map((event) => event.status)).toEqual(['start', 'fail', 'pass']); + expect(testEvents[1]).toMatchObject({ type: 'replay-test', title: undefined, status: 'fail', @@ -169,7 +183,7 @@ test('test emits progress when attempts retry and pass', async () => { retrying: true, message: 'Replay failed at step 1 (open "Demo"): first attempt failed', }); - expect(events[1]).toMatchObject({ + expect(testEvents[2]).toMatchObject({ type: 'replay-test', title: undefined, status: 'pass', @@ -208,15 +222,91 @@ test('test emits skip progress without synthetic duration', async () => { const data = expectOkData(response); expect(data.skipped).toBe(1); - expect(events.map((event) => event.status)).toEqual(['skip', 'pass']); expect(events[0]).toMatchObject({ + type: 'replay-test-suite', + status: 'start', + total: 2, + runnable: 1, + skipped: 1, + }); + const testEvents = events.filter((event) => event.type === 'replay-test'); + expect(testEvents.map((event) => event.status)).toEqual(['skip', 'start', 'pass']); + expect(testEvents[0]).toMatchObject({ type: 'replay-test', status: 'skip', index: 1, total: 2, message: 'missing platform metadata for --platform android', }); - expect(events[0]?.durationMs).toBeUndefined(); + expect(testEvents[0]?.durationMs).toBeUndefined(); +}); + +test('test stops the suite when the parent request is canceled during an active replay attempt', async () => { + const sessionStore = makeSessionStore(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-test-suite-parent-cancel-')); + fs.writeFileSync(path.join(root, '01-first.ad'), 'context platform=ios\nopen "Demo"\n'); + fs.writeFileSync(path.join(root, '02-second.ad'), 'context platform=ios\nopen "Demo"\n'); + + const parentRequestId = 'suite-parent-cancel'; + const invokedRequestIds: string[] = []; + const events: RequestProgressEvent[] = []; + registerRequestAbort(parentRequestId); + + try { + const response = await withRequestProgressSink( + (event) => events.push(event), + async () => + await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'test', + positionals: [root], + meta: { cwd: root, requestId: parentRequestId }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async (req) => { + const nestedRequestId = req.meta?.requestId; + expect(nestedRequestId).toBeTypeOf('string'); + invokedRequestIds.push(String(nestedRequestId)); + const signal = getRequestSignal(nestedRequestId); + expect(signal).toBeDefined(); + queueMicrotask(() => { + markRequestCanceled(parentRequestId); + }); + await new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + signal?.addEventListener('abort', () => resolve(), { once: true }); + }); + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'request canceled', + details: { reason: 'request_canceled' }, + }, + }; + }, + }), + ); + + const data = expectOkData(response); + expect(invokedRequestIds).toHaveLength(1); + expect( + events.some( + (event) => event.type === 'replay-test' && event.status === 'fail' && event.retrying, + ), + ).toBe(false); + expect(data.failed).toBe(1); + expect(data.notRun).toBe(1); + } finally { + clearRequestCanceled(parentRequestId); + } }); test('test --shard-all runs each runnable entry on each selected device', async () => { diff --git a/src/daemon/handlers/session-test-attempt.ts b/src/daemon/handlers/session-test-attempt.ts index 66de64e10..b734abfb9 100644 --- a/src/daemon/handlers/session-test-attempt.ts +++ b/src/daemon/handlers/session-test-attempt.ts @@ -15,6 +15,7 @@ import { isReplayInfrastructureFailure } from './session-test-infrastructure.ts' import { runReplayTestAttempt } from './session-test-runtime.ts'; import type { ReplayTestRuntimeDependencies } from './session-test-types.ts'; import type { ReplayTestShardContext } from './session-test-sharding.ts'; +import { isRequestCanceled } from '../request-cancel.ts'; type ReplayTestCaseResult = Extract; type ReplayTestAttemptFailure = NonNullable< @@ -82,6 +83,7 @@ async function runReplayTestCaseAttempts( params: ReplayTestCaseParams, context: ReplayTestCaseContext, ): Promise { + emitReplayTestStartProgress(params, context); const outcome: ReplayTestCaseOutcome = { finalSessionName: '', attempts: 0, @@ -90,9 +92,10 @@ async function runReplayTestCaseAttempts( }; for (let attemptIndex = 0; attemptIndex <= params.retries; attemptIndex += 1) { + if (isRequestCanceled(params.requestId)) break; const attempt = await runSingleReplayTestAttempt(params, context, attemptIndex); updateReplayTestCaseOutcome(outcome, attempt); - if (shouldStopReplayTestAttempts(attempt.response, attemptIndex, params.retries)) break; + if (shouldStopReplayTestAttempts(params, attempt.response, attemptIndex)) break; emitReplayTestRetryProgress(params, context, attempt); } @@ -116,18 +119,20 @@ async function runSingleReplayTestAttempt( ); const attemptArtifactsDir = path.join(context.testArtifactsDir, `attempt-${attempt}`); prepareReplayTestAttemptArtifacts(entry.path, attemptArtifactsDir); + const attemptRequestId = buildReplayTestAttemptRequestId({ + requestId, + suiteInvocationId, + filePath: entry.path, + caseIndex, + attemptIndex, + shardIndex: shard?.shardIndex, + }); const response = await runReplayTestAttempt({ filePath: entry.path, sessionName: testSessionName, - requestId: buildReplayTestAttemptRequestId({ - requestId, - suiteInvocationId, - filePath: entry.path, - caseIndex, - attemptIndex, - shardIndex: shard?.shardIndex, - }), + requestId: attemptRequestId, + parentRequestId: requestId, timeoutMs, platform: entry.metadata.platform, target: entry.metadata.target, @@ -149,6 +154,26 @@ async function runSingleReplayTestAttempt( return { response, sessionName: testSessionName, attempt, durationMs }; } +function emitReplayTestStartProgress( + params: ReplayTestCaseParams, + context: ReplayTestCaseContext, +): void { + const { entry, sessionName, suiteInvocationId, caseIndex, suiteIndex, suiteTotal, shard } = + params; + emitRequestProgress({ + type: 'replay-test', + file: entry.path, + title: entry.title, + status: 'start', + index: suiteIndex, + total: suiteTotal, + maxAttempts: context.maxAttempts, + session: buildReplayTestSessionName(sessionName, suiteInvocationId, entry.path, caseIndex), + artifactsDir: context.testArtifactsDir, + ...replayTestProgressShardMetadata(shard), + }); +} + function updateReplayTestCaseOutcome( outcome: ReplayTestCaseOutcome, attempt: ReplayTestAttemptResult, @@ -166,11 +191,16 @@ function updateReplayTestCaseOutcome( } function shouldStopReplayTestAttempts( + params: ReplayTestCaseParams, response: DaemonResponse, attemptIndex: number, - retries: number, ): boolean { - return response.ok || isReplayInfrastructureFailure(response) || attemptIndex >= retries; + return ( + response.ok || + isRequestCanceled(params.requestId) || + isReplayInfrastructureFailure(response) || + attemptIndex >= params.retries + ); } function emitReplayTestRetryProgress( @@ -191,6 +221,9 @@ function emitReplayTestRetryProgress( durationMs: attempt.durationMs, retrying: true, message: attempt.response.error.message, + session: attempt.sessionName, + artifactsDir: context.testArtifactsDir, + ...replayTestProgressShardMetadata(params.shard), }); } @@ -225,7 +258,9 @@ function buildReplayTestPassedResult( attempt: outcome.attempts, maxAttempts: context.maxAttempts, durationMs, + session: outcome.finalSessionName, artifactsDir: context.testArtifactsDir, + ...replayTestProgressShardMetadata(shard), }); return { file: entry.path, @@ -262,8 +297,10 @@ function buildReplayTestFailedResult( attempt: outcome.attempts, maxAttempts: context.maxAttempts, durationMs, + session: outcome.finalSessionName, artifactsDir: context.testArtifactsDir, message: error.message, + ...replayTestProgressShardMetadata(shard), }); return { file: entry.path, @@ -295,6 +332,12 @@ function replayTestWarningsResultMetadata( function replayTestShardResultMetadata( shard: ReplayTestShardContext | undefined, +): Pick { + return replayTestProgressShardMetadata(shard); +} + +function replayTestProgressShardMetadata( + shard: ReplayTestShardContext | undefined, ): Pick { return shard ? { diff --git a/src/daemon/handlers/session-test-runtime.ts b/src/daemon/handlers/session-test-runtime.ts index 6fbd48381..a03d1066f 100644 --- a/src/daemon/handlers/session-test-runtime.ts +++ b/src/daemon/handlers/session-test-runtime.ts @@ -5,6 +5,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { normalizeError } from '../../utils/errors.ts'; import { clearRequestCanceled, + getRequestSignal, markRequestCanceled, registerRequestAbort, } from '../request-cancel.ts'; @@ -25,6 +26,7 @@ export async function runReplayTestAttempt( filePath: string; sessionName: string; requestId: string; + parentRequestId?: string; timeoutMs?: number; platform?: ReplayScriptMetadata['platform']; target?: ReplayScriptMetadata['target']; @@ -36,6 +38,7 @@ export async function runReplayTestAttempt( filePath, sessionName, requestId, + parentRequestId, timeoutMs, platform, target, @@ -46,6 +49,7 @@ export async function runReplayTestAttempt( finalizeAttempt, } = params; registerRequestAbort(requestId); + const clearParentAbortRelay = relayReplayTestAbortFromParent(requestId, parentRequestId); const artifactPaths = new Set(); let timeoutHandle: ReturnType | undefined; let timedOut = false; @@ -80,6 +84,7 @@ export async function runReplayTestAttempt( } satisfies DaemonResponse; }) .finally(() => { + clearParentAbortRelay(); clearRequestCanceled(requestId); }); @@ -188,6 +193,27 @@ export async function runReplayTestAttempt( ); } +function relayReplayTestAbortFromParent( + requestId: string, + parentRequestId: string | undefined, +): () => void { + if (!parentRequestId || parentRequestId === requestId) return () => {}; + const parentSignal = getRequestSignal(parentRequestId); + if (!parentSignal) return () => {}; + + const cancelRequest = () => { + markRequestCanceled(requestId); + }; + if (parentSignal.aborted) { + cancelRequest(); + return () => {}; + } + parentSignal.addEventListener('abort', cancelRequest, { once: true }); + return () => { + parentSignal.removeEventListener('abort', cancelRequest); + }; +} + async function waitForReplayAfterTimeout(replayPromise: Promise): Promise { return await Promise.race([ replayPromise.then(() => true), diff --git a/src/daemon/handlers/session-test.ts b/src/daemon/handlers/session-test.ts index 6c92d7170..86bc25105 100644 --- a/src/daemon/handlers/session-test.ts +++ b/src/daemon/handlers/session-test.ts @@ -19,9 +19,27 @@ import { import { isReplayInfrastructureFailure } from './session-test-infrastructure.ts'; import { runReplayTestCase } from './session-test-attempt.ts'; import type { ReplayTestRuntimeDependencies } from './session-test-types.ts'; -import { buildReplayTestShardPlan, type ReplayTestShardContext } from './session-test-sharding.ts'; +import { + buildReplayTestShardPlan, + type ReplayTestShardContext, + type ReplayTestShardPlan, +} from './session-test-sharding.ts'; +import { isRequestCanceled } from '../request-cancel.ts'; type ReplayTestEntry = ReturnType[number]; +type ReplayTestQueuedEntry = { + entry: ReplayTestRunEntry; + suiteIndex: number; +}; + +type ReplayTestSuitePlan = { + entries: ReplayTestEntry[]; + runnable: ReplayTestQueuedEntry[]; + shardPlan: ReplayTestShardPlan | undefined; + suiteArtifactsDir: string; + suiteInvocationId: string; + total: number; +}; export async function runReplayTestSuite( params: { @@ -35,42 +53,27 @@ export async function runReplayTestSuite( } try { - const entries = discoverReplayTestEntries({ - inputs: req.positionals, - cwd: req.meta?.cwd, - platformFilter: req.flags?.platform, - replayBackend: req.flags?.replayBackend, - }); - const suiteInvocationId = buildReplayTestInvocationId(req.meta?.requestId); - const suiteArtifactsDir = resolveReplayTestArtifactsDir({ - artifactsDir: - typeof req.flags?.artifactsDir === 'string' ? req.flags.artifactsDir : undefined, - cwd: req.meta?.cwd, - suiteInvocationId, - }); - const suiteStartedAt = Date.now(); - const skipped = entries.filter((entry) => entry.kind === 'skip'); - const runnable = entries.filter((entry): entry is ReplayTestRunEntry => entry.kind === 'run'); - const shardPlan = await buildReplayTestShardPlan(req.flags, runnable, skipped.length); - const results: ReplaySuiteTestResult[] = shardPlan + const plan = await prepareReplayTestSuitePlan(req); + emitReplayTestSuiteStart(plan); + const results: ReplaySuiteTestResult[] = plan.shardPlan ? emitSkippedReplayTestResults({ - entries, - total: shardPlan.total, + entries: plan.entries, + total: plan.total, }) : []; - if (shardPlan) { + if (plan.shardPlan) { results.push( ...(await runReplayTestShards({ - shards: shardPlan.shards, + shards: plan.shardPlan.shards, sessionName, - suiteInvocationId, + suiteInvocationId: plan.suiteInvocationId, cwd: req.meta?.cwd, requestId: req.meta?.requestId, flags: req.flags, - suiteArtifactsDir, - suiteTotal: shardPlan.total, + suiteArtifactsDir: plan.suiteArtifactsDir, + suiteTotal: plan.total, runReplay, cleanupSession, finalizeAttempt, @@ -79,14 +82,14 @@ export async function runReplayTestSuite( } else { results.push( ...(await runReplayTestEntriesInDiscoveryOrder({ - discoveryEntries: entries, + discoveryEntries: plan.entries, sessionName, - suiteInvocationId, + suiteInvocationId: plan.suiteInvocationId, cwd: req.meta?.cwd, requestId: req.meta?.requestId, flags: req.flags, - suiteArtifactsDir, - suiteTotal: entries.length, + suiteArtifactsDir: plan.suiteArtifactsDir, + suiteTotal: plan.total, runReplay, cleanupSession, finalizeAttempt, @@ -94,11 +97,7 @@ export async function runReplayTestSuite( ); } - const data = summarizeReplayTestResults( - shardPlan?.total ?? entries.length, - results, - Date.now() - suiteStartedAt, - ); + const data = summarizeReplayTestResults(plan.total, results, Date.now() - suiteStartedAt); return { ok: true, data }; } catch (err) { const appErr = asAppError(err); @@ -106,6 +105,58 @@ export async function runReplayTestSuite( } } +async function prepareReplayTestSuitePlan(req: DaemonRequest): Promise { + const entries = discoverReplayTestSuiteEntries(req); + const runnable = runnableReplayTestEntries(entries); + const skippedCount = entries.length - runnable.length; + const suiteInvocationId = buildReplayTestInvocationId(req.meta?.requestId); + const shardPlan = await buildReplayTestShardPlan(req.flags, runnable, skippedCount); + return { + entries, + runnable, + shardPlan, + suiteInvocationId, + suiteArtifactsDir: replayTestSuiteArtifactsDir(req, suiteInvocationId), + total: shardPlan?.total ?? entries.length, + }; +} + +function discoverReplayTestSuiteEntries(req: DaemonRequest): ReplayTestEntry[] { + return discoverReplayTestEntries({ + inputs: req.positionals ?? [], + cwd: req.meta?.cwd, + platformFilter: req.flags?.platform, + replayBackend: req.flags?.replayBackend, + }); +} + +function runnableReplayTestEntries(entries: ReplayTestEntry[]): ReplayTestQueuedEntry[] { + return entries.flatMap((entry, entryIndex) => + entry.kind === 'run' ? [{ entry, suiteIndex: entryIndex + 1 }] : [], + ); +} + +function replayTestSuiteArtifactsDir(req: DaemonRequest, suiteInvocationId: string): string { + return resolveReplayTestArtifactsDir({ + artifactsDir: typeof req.flags?.artifactsDir === 'string' ? req.flags.artifactsDir : undefined, + cwd: req.meta?.cwd, + suiteInvocationId, + }); +} + +function emitReplayTestSuiteStart(plan: ReplayTestSuitePlan): void { + emitRequestProgress({ + type: 'replay-test-suite', + status: 'start', + total: plan.total, + runnable: plan.runnable.length, + skipped: plan.entries.length - plan.runnable.length, + artifactsDir: plan.suiteArtifactsDir, + shardMode: plan.shardPlan?.mode, + shardCount: plan.shardPlan?.shardCount, + }); +} + function emitSkippedReplayTestResults(params: { entries: ReplayTestEntry[]; total: number; @@ -135,7 +186,7 @@ function emitSkippedReplayTestResults(params: { async function runReplayTestShards( params: { - shards: Array; + shards: Array; sessionName: string; suiteInvocationId: string; cwd?: string; @@ -156,13 +207,13 @@ async function runReplayTestShards( } function buildUnexpectedShardFailure( - shard: ReplayTestShardContext & { entries: ReplayTestRunEntry[] }, + shard: ReplayTestShardContext & { entries: ReplayTestQueuedEntry[] }, sessionName: string, reason: unknown, ): ReplaySuiteTestFailed { const appErr = normalizeError(reason); return { - file: shard.entries[0]?.path ?? `shard-${shard.shardIndex + 1}`, + file: shard.entries[0]?.entry.path ?? `shard-${shard.shardIndex + 1}`, session: formatReplayTestShardSessionName(sessionName, shard), status: 'failed', durationMs: 0, @@ -183,7 +234,7 @@ function buildUnexpectedShardFailure( async function runReplayTestShard( params: { - shard: ReplayTestShardContext & { entries: ReplayTestRunEntry[] }; + shard: ReplayTestShardContext & { entries: ReplayTestQueuedEntry[] }; sessionName: string; suiteInvocationId: string; cwd?: string; @@ -237,6 +288,7 @@ async function runReplayTestEntriesInDiscoveryOrder( const results: ReplaySuiteTestResult[] = []; let executed = 0; for (const [entryIndex, entry] of discoveryEntries.entries()) { + if (isRequestCanceled(requestId)) break; if (entry.kind === 'skip') { emitRequestProgress({ type: 'replay-test', @@ -273,14 +325,14 @@ async function runReplayTestEntriesInDiscoveryOrder( finalizeAttempt, }); results.push(result); - if (flags?.failFast === true || isReplayInfrastructureFailure(result)) break; + if (shouldStopReplayTestExecution(result, flags, requestId)) break; } return results; } async function runReplayTestEntries( params: { - entries: ReplayTestRunEntry[]; + entries: ReplayTestQueuedEntry[]; sessionName: string; suiteInvocationId: string; cwd?: string; @@ -306,7 +358,9 @@ async function runReplayTestEntries( finalizeAttempt, } = params; const results: ReplaySuiteTestResult[] = []; - for (const [entryIndex, entry] of entries.entries()) { + for (const [entryIndex, queued] of entries.entries()) { + if (isRequestCanceled(requestId)) break; + const { entry, suiteIndex } = queued; const result = await runReplayTestCase({ entry, sessionName, @@ -317,7 +371,7 @@ async function runReplayTestEntries( retries: resolveReplayTestRetries(flags?.retries, entry.metadata.retries), timeoutMs: resolveReplayTestTimeout(flags?.timeoutMs, entry.metadata.timeoutMs), suiteArtifactsDir, - suiteIndex: entryIndex + 1, + suiteIndex, suiteTotal, shard, runReplay, @@ -325,11 +379,23 @@ async function runReplayTestEntries( finalizeAttempt, }); results.push(result); - if (flags?.failFast === true || isReplayInfrastructureFailure(result)) break; + if (shouldStopReplayTestExecution(result, flags, requestId)) break; } return results; } +function shouldStopReplayTestExecution( + result: ReplaySuiteTestResult, + flags: DaemonRequest['flags'], + requestId: string | undefined, +): boolean { + return ( + isRequestCanceled(requestId) || + flags?.failFast === true || + isReplayInfrastructureFailure(result) + ); +} + function summarizeReplayTestResults( total: number, results: ReplaySuiteTestResult[], diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index 114b11a35..1d65175a7 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -1,18 +1,21 @@ import http, { type IncomingHttpHeaders } from 'node:http'; import fs from 'node:fs'; import { AppError, normalizeError, toAppErrorCode } from '../utils/errors.ts'; +import { emitDiagnostic } from '../utils/diagnostics.ts'; import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; import type { JsonRpcId, JsonRpcRequestEnvelope, LeaseBackend } from '../contracts.ts'; import type { DaemonInstallSource, DaemonInvokeFn, DaemonRequest } from './types.ts'; import { normalizeTenantId } from './config.ts'; import { clearRequestCanceled, + isRequestCanceled, markRequestCanceled, registerRequestAbort, resolveRequestTrackingId, } from './request-cancel.ts'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { sleep } from '../utils/timeouts.ts'; import { cleanupDownloadableArtifact, prepareDownloadableArtifact, @@ -65,6 +68,9 @@ type HttpAuthDecision = | { ok: false; statusCode: number; response: JsonRpcResponse }; const MAX_HTTP_RPC_BODY_BYTES = 1024 * 1024; +const CLIENT_DISCONNECT_ABORT_POLL_INTERVAL_MS = 200; +const CLIENT_DISCONNECT_ABORT_MAX_WINDOW_MS = 15_000; +const IOS_RUNNER_ABORT_REPLAY_COMMANDS = new Set(['replay', 'test']); const COMMAND_RPC_METHODS = new Set(['agent_device.command', 'agent-device.command']); const INSTALL_FROM_SOURCE_RPC_METHODS = new Set([ 'agent_device.install_from_source', @@ -554,6 +560,7 @@ export async function createDaemonHttpServer(options: { } let requestIdForCleanup: string | undefined; + let handlerCompleted = false; try { const params = rpcRequest.params as Record; const daemonRequest = methodToDaemonRequest(rpcRequest.method, params, req.headers); @@ -578,13 +585,6 @@ export async function createDaemonHttpServer(options: { requestId: requestIdForCleanup, }; registerRequestAbort(requestIdForCleanup); - const markCanceledIfResponseIncomplete = () => { - if (!res.writableFinished) { - markRequestCanceled(requestIdForCleanup); - } - }; - req.on('aborted', markCanceledIfResponseIncomplete); - res.on('close', markCanceledIfResponseIncomplete); const authResult = await runHttpAuthHook(authHook, { headers: req.headers, @@ -606,6 +606,32 @@ export async function createDaemonHttpServer(options: { }; } + const abortIosRunnerOnDisconnect = shouldAbortIosRunnerSessionsOnDisconnect(daemonRequest); + let canceledInFlight = false; + const markCanceledIfResponseIncomplete = () => { + if (handlerCompleted || res.writableFinished || canceledInFlight) return; + canceledInFlight = true; + markRequestCanceled(requestIdForCleanup); + emitDiagnostic({ + level: 'warn', + phase: 'request_client_disconnected', + data: { + requestId: requestIdForCleanup, + abortIosRunnerSessions: abortIosRunnerOnDisconnect, + }, + }); + if (abortIosRunnerOnDisconnect) { + void abortInFlightIosRunnerSessionsWhileDisconnected(requestIdForCleanup); + } + }; + req.on('aborted', markCanceledIfResponseIncomplete); + res.on('close', () => { + if (res.headersSent) markCanceledIfResponseIncomplete(); + }); + if (req.aborted || (res.destroyed && res.headersSent)) { + markCanceledIfResponseIncomplete(); + } + const streamProgress = shouldStreamRequestProgress(daemonRequest); if (streamProgress) { res.statusCode = 200; @@ -614,6 +640,7 @@ export async function createDaemonHttpServer(options: { (event) => writeProgressEnvelope(res, event), async () => await handleRequest(daemonRequest), ); + handlerCompleted = true; const rpcResponse = daemonResponse.ok ? ({ jsonrpc: '2.0', @@ -631,6 +658,7 @@ export async function createDaemonHttpServer(options: { } const daemonResponse = await handleRequest(daemonRequest); + handlerCompleted = true; if (daemonResponse.ok) { sendJson(res, { jsonrpc: '2.0', id: rpcRequest.id ?? null, result: daemonResponse }); return; @@ -646,6 +674,7 @@ export async function createDaemonHttpServer(options: { statusCodeForNormalizedError(daemonResponse.error.code), ); } catch (error) { + handlerCompleted = true; const normalized = normalizeError(error); if (res.headersSent) { writeRpcResponseEnvelope( @@ -757,6 +786,34 @@ async function handleArtifactDownload( } } +async function abortInFlightIosRunnerSessionsWhileDisconnected( + requestId: string | undefined, +): Promise { + try { + const deadline = Date.now() + CLIENT_DISCONNECT_ABORT_MAX_WINDOW_MS; + while (isRequestCanceled(requestId) && Date.now() < deadline) { + const { abortAllIosRunnerSessions } = await import('../platforms/ios/runner-client.ts'); + await abortAllIosRunnerSessions(); + if (!isRequestCanceled(requestId)) break; + await sleep(CLIENT_DISCONNECT_ABORT_POLL_INTERVAL_MS); + } + } catch (error) { + emitDiagnostic({ + level: 'error', + phase: 'request_client_disconnect_abort_failed', + data: { + message: error instanceof Error ? error.message : String(error), + }, + }); + } +} + +function shouldAbortIosRunnerSessionsOnDisconnect(req: DaemonRequest): boolean { + if (req.flags?.platform === 'android') return false; + if (req.flags?.platform === 'ios') return true; + return IOS_RUNNER_ABORT_REPLAY_COMMANDS.has(req.command); +} + async function authorizeAuxiliaryHttpRequest(params: { req: http.IncomingMessage; res: http.ServerResponse; diff --git a/src/daemon/request-progress.ts b/src/daemon/request-progress.ts index 8a47b09b3..bd5a5fad1 100644 --- a/src/daemon/request-progress.ts +++ b/src/daemon/request-progress.ts @@ -1,10 +1,21 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +export type ReplayTestSuiteProgressEvent = { + type: 'replay-test-suite'; + status: 'start'; + total: number; + runnable: number; + skipped: number; + artifactsDir: string; + shardMode?: 'all' | 'split'; + shardCount?: number; +}; + export type ReplayTestProgressEvent = { type: 'replay-test'; file: string; title?: string; - status: 'pass' | 'fail' | 'skip'; + status: 'start' | 'pass' | 'fail' | 'skip'; index: number; total: number; attempt?: number; @@ -12,10 +23,14 @@ export type ReplayTestProgressEvent = { durationMs?: number; retrying?: boolean; message?: string; + session?: string; artifactsDir?: string; + shardIndex?: number; + shardCount?: number; + deviceId?: string; }; -export type RequestProgressEvent = ReplayTestProgressEvent; +export type RequestProgressEvent = ReplayTestSuiteProgressEvent | ReplayTestProgressEvent; export type RequestProgressSink = (event: RequestProgressEvent) => void; const requestProgress = new AsyncLocalStorage(); diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index b087b9b8c..d749c01ea 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -1294,7 +1294,24 @@ test('openIosApp appends launchArgs alongside --payload-url for iOS device deep } }); -test('openIosApp launches iOS simulator app before opening URL', async () => { +test('openIosApp opens custom-scheme iOS simulator URLs directly when launch args are absent', async () => { + mockEnsureBootedSimulator.mockResolvedValue(); + mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + + await openIosApp(IOS_TEST_SIMULATOR, 'MyApp', { + appBundleId: 'com.example.app', + url: 'myapp://item/42', + }); + + assert.equal(mockRunCmd.mock.calls.length, 1); + assert.deepEqual(mockRunCmd.mock.calls[0], [ + 'xcrun', + ['simctl', 'openurl', 'sim-1', 'myapp://item/42'], + undefined, + ]); +}); + +test('openIosApp launches iOS simulator app before opening custom-scheme URL with launchArgs', async () => { mockEnsureBootedSimulator.mockResolvedValue(); mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index d0d456cbf..379594735 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -13,7 +13,11 @@ import { import { requireLocationCoordinates } from '../../utils/location-coordinates.ts'; import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts'; import { Deadline, retryWithPolicy } from '../../utils/retry.ts'; -import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts'; +import { + isDeepLinkTarget, + isWebUrl, + resolveIosDeviceDeepLinkBundleId, +} from '../../core/open-target.ts'; import { getUnsupportedMacOsSettingMessage } from '../../core/settings-contract.ts'; import { parsePermissionAction, @@ -200,10 +204,12 @@ export async function openIosApp( throw new AppError('INVALID_ARGS', 'open requires a valid URL target'); } if (device.kind === 'simulator') { - const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); - await launchIosSimulatorApp(device, bundleId, { - ...(launchArgs ? { launchArgs } : {}), - }); + if (launchArgs || isWebUrl(explicitUrl)) { + const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app)); + await launchIosSimulatorApp(device, bundleId, { + ...(launchArgs ? { launchArgs } : {}), + }); + } await openIosSimulatorUrl(device, explicitUrl, undefined); return; } diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index e6f5a87de..e550f6382 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -496,7 +496,7 @@ test('sendToDaemon prints replay test progress before the socket response', asyn }); assert.deepEqual(response, { ok: true, data: { via: 'socket' } }); - 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 { (net as unknown as { createConnection: typeof net.createConnection }).createConnection = @@ -506,7 +506,7 @@ test('sendToDaemon prints replay test progress before the socket response', asyn } }); -test('sendToDaemon ignores terminal replay test progress before the HTTP NDJSON response', async () => { +test('sendToDaemon prints replay test progress before the HTTP NDJSON response', async () => { let stderr = ''; const seenPaths: string[] = []; const originalStderrWrite = process.stderr.write.bind(process.stderr); @@ -598,7 +598,7 @@ test('sendToDaemon ignores terminal replay test progress before the HTTP NDJSON assert.deepEqual(response, { ok: true, data: { via: 'http-progress' } }); }); assert.deepEqual(seenPaths, ['GET /agent-device/health', 'POST /agent-device/rpc']); - assert.doesNotMatch(stderr, /PASS "Payments flow" \(2\.50s\)/); + assert.match(stderr, /\[2\/3] PASS "Payments flow" in 02-payments\.ad \(2\.50s\)/); } finally { (http as unknown as { request: typeof http.request }).request = originalHttpRequest; process.stderr.write = originalStderrWrite;