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
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ extension RunnerTests {
}

func executeUptime() -> Response {
// Placeholder value: the transport layer (jsonResponse) overwrites currentUptimeMs with a
// fresher send-time stamp on every ok response; kept so direct callers still get a value.
Response(
ok: true,
data: DataPayload(currentUptimeMs: currentUptimeMs())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,46 @@ extension RunnerTests {
XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.notAccepted.rawValue)
}

func testStampingCurrentUptimePreservesPayload() {
let stamped = Response(ok: true, data: DataPayload(message: "recording started"))
.stampingCurrentUptimeMs(123.5)

XCTAssertEqual(stamped.ok, true)
XCTAssertEqual(stamped.data?.message, "recording started")
XCTAssertEqual(stamped.data?.currentUptimeMs, 123.5)
}

func testStampingCurrentUptimeCreatesPayloadWhenNil() {
let stamped = Response(ok: true).stampingCurrentUptimeMs(456.0)

XCTAssertEqual(stamped.ok, true)
XCTAssertEqual(stamped.data?.currentUptimeMs, 456.0)
}

func testStampingCurrentUptimeSkipsErrorResponses() {
let response = Response(ok: false, error: ErrorPayload(message: "boom"))
let stamped = response.stampingCurrentUptimeMs(789.0)

XCTAssertEqual(stamped.ok, false)
XCTAssertNil(stamped.data)
XCTAssertEqual(stamped.error?.message, "boom")
}

func testJournalStoredResponseStaysUnstamped() throws {
let journal = RunnerCommandJournal()
let recordStart = runnerJournalCommand("recordStart", id: "record-start-anchor")

journal.accept(command: recordStart)
journal.finish(
command: recordStart,
response: Response(ok: true, data: DataPayload(message: "recording started"))
)

let status = journal.status(commandId: "record-start-anchor")
let responseJson = try XCTUnwrap(status.lifecycleResponseJson)
XCTAssertFalse(responseJson.contains("currentUptimeMs"))
}

func testCommandJournalRetentionPolicy() throws {
let journal = RunnerCommandJournal()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,18 @@ struct Response: Codable {
}
}

extension Response {
// The daemon pairs this gesture-clock anchor with its own receipt time to map
// gesture uptimes onto wall-clock for the recording touch overlay. Error responses
// carry no anchor so the daemon falls back instead of pairing a stale value.
func stampingCurrentUptimeMs(_ value: Double) -> Response {
guard ok else { return self }
var payload = data ?? DataPayload()
payload.currentUptimeMs = value
return Response(ok: ok, data: payload, error: error)
}
}

struct DataPayload: Codable {
let message: String?
let text: String?
Expand All @@ -177,7 +189,7 @@ struct DataPayload: Codable {
let y2: Double?
let referenceWidth: Double?
let referenceHeight: Double?
let currentUptimeMs: Double?
var currentUptimeMs: Double?
let commandId: String?
let lifecycleState: String?
let lifecycleCommand: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,17 @@ extension RunnerTests {
// MARK: - Response Encoding

private func jsonResponse(status: Int, response: Response) -> Data {
// Stamp the gesture-clock uptime at the END of command handling, just before the HTTP
// write, so the warm snapshot and recordStart responses carry the anchor for free. This
// runs AFTER commandJournal.finish, so journal-stored lifecycleResponseJson stays
// unstamped — recovered/status-replayed results carry no anchor and the daemon falls back
// rather than pairing a stale uptime with a much-later receipt time.
let stamped =
response.ok
? response.stampingCurrentUptimeMs(ProcessInfo.processInfo.systemUptime * 1000)
: response
let encoder = JSONEncoder()
let body = (try? encoder.encode(response)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
let body = (try? encoder.encode(stamped)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
return httpResponse(status: status, body: body)
}

Expand Down
184 changes: 182 additions & 2 deletions src/daemon/handlers/__tests__/record-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1144,8 +1144,13 @@ test('record start does not fail when iOS simulator runner warm-up fails', async
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
};
});
mockRunIosRunnerCommand.mockImplementation(async () => {
throw new Error('runner warm-up unavailable');
const runnerCalls: RunnerCall[] = [];
mockRunIosRunnerCommand.mockImplementation(async (_device, command) => {
runnerCalls.push({ command: command.command });
if (command.command === 'snapshot') {
throw new Error('runner warm-up unavailable');
}
return { currentUptimeMs: 30_000 };
});

const response = await runRecordCommand({
Expand All @@ -1156,6 +1161,181 @@ test('record start does not fail when iOS simulator runner warm-up fails', async

expect(response?.ok).toBe(true);
expect(started).toBe(true);
// Warm-up failure falls back to the standalone uptime command for the anchor.
expect(runnerCalls.map((call) => call.command)).toEqual(['snapshot', 'uptime']);
const recording = sessionStore.get(sessionName)?.recording;
expect(recording?.platform).toBe('ios');
if (recording?.platform === 'ios') {
expect(recording.gestureClockOriginUptimeMs).toBe(30_000);
}
});

test('record start anchors gesture clock from simulator warm-up and skips standalone uptime', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-sim-warm-anchor';
const session = makeSession(sessionName, {
platform: 'ios',
id: 'sim-1',
name: 'Simulator',
kind: 'simulator',
booted: true,
});
session.appBundleId = 'com.apple.Preferences';
sessionStore.set(sessionName, session);

mockRunCmdBackground.mockImplementation(() => ({
child: { kill: () => {} } as any,
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
}));
const runnerCalls: RunnerCall[] = [];
mockRunIosRunnerCommand.mockImplementation(async (_device, command) => {
runnerCalls.push({ command: command.command });
if (command.command === 'snapshot') {
return { currentUptimeMs: 20_000 };
}
return {};
});

const beforeMs = Date.now();
const response = await runRecordCommand({
sessionStore,
sessionName,
positionals: ['start', './sim-warm-anchor.mp4'],
});
const afterMs = Date.now();

expect(response?.ok).toBe(true);
expect(runnerCalls.map((call) => call.command)).toEqual(['snapshot']);
const recording = sessionStore.get(sessionName)?.recording;
expect(recording?.platform).toBe('ios');
if (recording?.platform === 'ios') {
expect(recording.gestureClockOriginUptimeMs).toBe(20_000);
expect(recording.gestureClockOriginAtMs).toBeGreaterThanOrEqual(beforeMs);
expect(recording.gestureClockOriginAtMs).toBeLessThanOrEqual(afterMs);
}
});

test('record start falls back to standalone uptime when warm response lacks currentUptimeMs', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-sim-warm-missing';
const session = makeSession(sessionName, {
platform: 'ios',
id: 'sim-1',
name: 'Simulator',
kind: 'simulator',
booted: true,
});
session.appBundleId = 'com.apple.Preferences';
sessionStore.set(sessionName, session);

mockRunCmdBackground.mockImplementation(() => ({
child: { kill: () => {} } as any,
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
}));
const runnerCalls: RunnerCall[] = [];
mockRunIosRunnerCommand.mockImplementation(async (_device, command) => {
runnerCalls.push({ command: command.command });
if (command.command === 'uptime') {
return { currentUptimeMs: 30_000 };
}
return {};
});

const response = await runRecordCommand({
sessionStore,
sessionName,
positionals: ['start', './sim-warm-missing.mp4'],
});

expect(response?.ok).toBe(true);
expect(runnerCalls.map((call) => call.command)).toEqual(['snapshot', 'uptime']);
const recording = sessionStore.get(sessionName)?.recording;
expect(recording?.platform).toBe('ios');
if (recording?.platform === 'ios') {
expect(recording.gestureClockOriginUptimeMs).toBe(30_000);
}
});

test('record start rejects non-finite or non-positive warm anchors', async () => {
for (const badValue of [Number.NaN, -1]) {
const sessionStore = makeSessionStore();
const sessionName = `ios-sim-warm-bad-${badValue}`;
const session = makeSession(sessionName, {
platform: 'ios',
id: 'sim-1',
name: 'Simulator',
kind: 'simulator',
booted: true,
});
session.appBundleId = 'com.apple.Preferences';
sessionStore.set(sessionName, session);

mockRunCmdBackground.mockImplementation(() => ({
child: { kill: () => {} } as any,
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
}));
const runnerCalls: RunnerCall[] = [];
mockRunIosRunnerCommand.mockImplementation(async (_device, command) => {
runnerCalls.push({ command: command.command });
if (command.command === 'snapshot') {
return { currentUptimeMs: badValue };
}
return { currentUptimeMs: 30_000 };
});

const response = await runRecordCommand({
sessionStore,
sessionName,
positionals: ['start', `./sim-warm-bad-${badValue}.mp4`],
});

expect(response?.ok).toBe(true);
expect(runnerCalls.map((call) => call.command)).toEqual(['snapshot', 'uptime']);
const recording = sessionStore.get(sessionName)?.recording;
expect(recording?.platform).toBe('ios');
if (recording?.platform === 'ios') {
expect(recording.gestureClockOriginUptimeMs).toBe(30_000);
}
}
});

test('record start degrades to wall-clock when warm anchor missing and uptime fails', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-sim-anchor-degraded';
const session = makeSession(sessionName, {
platform: 'ios',
id: 'sim-1',
name: 'Simulator',
kind: 'simulator',
booted: true,
});
session.appBundleId = 'com.apple.Preferences';
sessionStore.set(sessionName, session);

mockRunCmdBackground.mockImplementation(() => ({
child: { kill: () => {} } as any,
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
}));
mockRunIosRunnerCommand.mockImplementation(async (_device, command) => {
if (command.command === 'uptime') {
throw new Error('uptime unavailable');
}
return {};
});

const response = await runRecordCommand({
sessionStore,
sessionName,
positionals: ['start', './sim-anchor-degraded.mp4'],
});

expect(response?.ok).toBe(true);
const recording = sessionStore.get(sessionName)?.recording;
expect(recording?.platform).toBe('ios');
if (recording?.platform === 'ios') {
expect(recording.gestureClockOriginAtMs).toBeUndefined();
expect(recording.gestureClockOriginUptimeMs).toBeUndefined();
}
});

test('record start skips iOS simulator runner warm-up when touch overlays are hidden', async () => {
Expand Down
33 changes: 30 additions & 3 deletions src/daemon/handlers/record-trace-ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,24 @@ async function stopRunnerRecordingBestEffort(params: {
}
}

type RunnerGestureClockAnchor = {
gestureClockOriginAtMs: number;
gestureClockOriginUptimeMs: number;
};

export async function warmIosSimulatorRunner(params: {
req: DaemonRequest;
activeSession: SessionState;
device: SessionState['device'];
logPath?: string;
deps: RecordTraceDeps;
}): Promise<void> {
}): Promise<RunnerGestureClockAnchor | undefined> {
const { req, activeSession, device, logPath, deps } = params;
const appBundleId = normalizeAppBundleId(activeSession);
if (!appBundleId) return;
if (!appBundleId) return undefined;

try {
await deps.runIosRunnerCommand(
const result = await deps.runIosRunnerCommand(
device,
{
command: 'snapshot',
Expand All @@ -112,6 +117,27 @@ export async function warmIosSimulatorRunner(params: {
},
getIosRunnerOptions(req, logPath, activeSession),
);
// Pair the runner-stamped uptime with daemon receipt time. The runner stamps
// currentUptimeMs just before sending the response, so receive time is the closest
// wall-clock pair; the request midpoint would be wrong because this warm request can
// include cold runner build/launch (10s+).
const receivedAtMs = Date.now();
if (
typeof result.currentUptimeMs === 'number' &&
Number.isFinite(result.currentUptimeMs) &&
result.currentUptimeMs > 0
) {
emitDiagnostic({
level: 'debug',
phase: 'record_start_gesture_clock_anchor',
data: { source: 'warm_snapshot' },
});
return {
gestureClockOriginAtMs: receivedAtMs,
gestureClockOriginUptimeMs: result.currentUptimeMs,
};
}
return undefined;
} catch (error) {
emitDiagnostic({
level: 'warn',
Expand All @@ -123,6 +149,7 @@ export async function warmIosSimulatorRunner(params: {
error: formatRecordTraceError(error),
},
});
return undefined;
}
}

Expand Down
23 changes: 13 additions & 10 deletions src/daemon/handlers/record-trace-recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,20 +128,23 @@ async function startIosSimulatorRecording(params: {
}): Promise<DaemonResponse | NonNullable<SessionState['recording']>> {
const { req, activeSession, device, logPath, deps, recordingBase, resolvedOut } = params;

if (recordingBase.showTouches) {
await warmIosSimulatorRunner({
req,
activeSession,
device,
logPath,
deps,
});
}
// The warm-up carries the gesture-clock anchor on its snapshot response when the runner
// stamps it, letting us skip a standalone uptime command. The anchor is a pure clock pair
// (origin uptime + daemon receipt time), so capturing it before the recorder spawn/settle
// window is equivalent to capturing it after: recordingStartedAt stays readyAt below.
const warmAnchor = recordingBase.showTouches
? await warmIosSimulatorRunner({ req, activeSession, device, logPath, deps })
: undefined;
const { child, wait } = deps.startIosSimulatorRecording({ device, outPath: resolvedOut });
const readyAt = await waitForLocalRecordingSettleWindow(resolvedOut);
let gestureClockOriginAtMs: number | undefined;
let gestureClockOriginUptimeMs: number | undefined;
if (recordingBase.showTouches) {
if (warmAnchor) {
gestureClockOriginAtMs = warmAnchor.gestureClockOriginAtMs;
gestureClockOriginUptimeMs = warmAnchor.gestureClockOriginUptimeMs;
} else if (recordingBase.showTouches) {
// Fallback for older runner builds (or a failed/unavailable warm anchor): issue a
// standalone uptime command and pair it at the request midpoint.
try {
const uptimeRequestStartedAtMs = Date.now();
const uptimeResult = await deps.runIosRunnerCommand(
Expand Down
Loading