Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/serve-sim/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ https://github.com/user-attachments/assets/fbf890f4-c8c7-4684-82be-d677b8a188f8
- Swipe from the bottom to go home.
- gestures like pinch to zoom by holding the option key.
- Simulator logs are forwarded to the browser for browser-use MCP tools to read from.
- Recent simulator actions are available in the browser tools panel and `serve-sim event-log`.
- Drag and drop videos and images to add them to the simulator device.
- Keyboard commands and hot keys are forwarded to the simulator, including CMD+SHIFT+H to go home.
- Apple Watch, iPad, and iOS support.
Expand Down Expand Up @@ -51,6 +52,7 @@ serve-sim ca-debug <option> <on|off> [-d udid]
Toggle a CoreAnimation debug flag
(blended|copies|misaligned|offscreen|slow-animations)
serve-sim memory-warning [-d udid] Simulate a memory warning
serve-sim event-log [-d udid] Show recent simulator events

serve-sim camera <bundle-id> [-d udid] [source-options]
Inject a synthetic camera feed and (re)launch the app
Expand Down
61 changes: 61 additions & 0 deletions packages/serve-sim/src/__tests__/event-log-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";
import { formatEventLogLine } from "../event-log-format";
import type { EventLogEntry } from "../event-log";

function entry(overrides: Partial<EventLogEntry>): EventLogEntry {
const summary = overrides.summary ?? "Tap 0.214,0.585";
return {
id: 1,
timestamp: "2026-07-02T14:24:38.000Z",
source: "hid",
kind: "tap",
msg: overrides.msg ?? summary,
summary,
...overrides,
};
}

describe("formatEventLogLine", () => {
test("hides internal source/kind and formats tap coordinates as normalized values", () => {
expect(
formatEventLogLine(entry({
device: "AC78FEE5-C665-4295-889B-F6BCB1A618D5",
kind: "tap",
summary: "Tap 0.214,0.585",
details: { current: { x: 0.214, y: 0.585 } },
}), { deviceLabel: "iPhone 17" }),
).toContain("iPhone 17 Tap at 0.21, 0.59");
});

test("formats drags as a sentence", () => {
expect(
formatEventLogLine(entry({
kind: "drag",
summary: "Drag 0.854,0.542 -> 1,0.51",
details: {
start: { x: 0.854, y: 0.542 },
current: { x: 1, y: 0.51 },
moveCount: 74,
},
})),
).toContain("Drag from 0.85, 0.54 to 1.00, 0.51");
});

test("humanizes key and rotation labels", () => {
expect(
formatEventLogLine(entry({
kind: "key",
action: "up",
summary: "Key up MetaLeft",
details: { key: "MetaLeft" },
})),
).toContain("Key up Left Command");
expect(
formatEventLogLine(entry({
kind: "rotate",
action: "landscape_left",
summary: "Rotate landscape_left",
})),
).toContain("Rotate landscape left");
});
});
238 changes: 238 additions & 0 deletions packages/serve-sim/src/__tests__/event-log.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { describe, expect, test, beforeEach } from "bun:test";
import {
clearEventLogForTests,
EVENT_LOG_MAX_ENTRIES,
eventLogEventForCommand,
eventLogEventForHidMessage,
readEventLog,
recordEventLogEvent,
subscribeEventLog,
updateEventLogEvent,
} from "../event-log";

beforeEach(() => {
clearEventLogForTests();
});

describe("event log store", () => {
test("records entries in order and filters by device", () => {
recordEventLogEvent({
device: "DEVICE-A",
source: "hid",
kind: "button",
action: "home",
summary: "Home",
});
recordEventLogEvent({
device: "DEVICE-B",
source: "hid",
kind: "button",
action: "volume-up",
summary: "Button volume-up",
});

expect(readEventLog().map((event) => event.id)).toEqual([1, 2]);
expect(readEventLog().map((event) => event.msg)).toEqual(["Home", "Button volume-up"]);
expect(readEventLog({ device: "DEVICE-B" }).map((event) => event.summary)).toEqual([
"Button volume-up",
]);
});

test("keeps a bunyan-style msg field on recorded entries", () => {
recordEventLogEvent({
source: "exec",
kind: "button",
action: "home",
summary: "Home",
});
recordEventLogEvent({
source: "exec",
kind: "button",
action: "home",
summary: "Home",
msg: "Pressed Home",
});

expect(readEventLog()).toMatchObject([
{ summary: "Home", msg: "Home" },
{ summary: "Home", msg: "Pressed Home" },
]);
});

test("supports since and limit reads", () => {
for (let i = 0; i < 5; i++) {
recordEventLogEvent({
source: "exec",
kind: "button",
summary: `Event ${i}`,
});
}

expect(readEventLog({ sinceId: 2 }).map((event) => event.id)).toEqual([3, 4, 5]);
expect(readEventLog({ limit: 2 }).map((event) => event.id)).toEqual([4, 5]);
});

test("keeps only the newest entries when the store reaches its cap", () => {
for (let i = 0; i < EVENT_LOG_MAX_ENTRIES + 3; i++) {
recordEventLogEvent({
source: "exec",
kind: "button",
summary: `Event ${i}`,
});
}

const events = readEventLog();
expect(events).toHaveLength(EVENT_LOG_MAX_ENTRIES);
expect(events[0]).toMatchObject({ id: 4, summary: "Event 3" });
expect(events.at(-1)).toMatchObject({
id: EVENT_LOG_MAX_ENTRIES + 3,
summary: `Event ${EVENT_LOG_MAX_ENTRIES + 2}`,
});
});

test("notifies subscribers as entries are recorded", () => {
const seen: string[] = [];
const unsubscribe = subscribeEventLog((event) => seen.push(event.summary));
recordEventLogEvent({ source: "exec", kind: "button", summary: "Home" });
unsubscribe();
recordEventLogEvent({ source: "exec", kind: "button", summary: "Ignored" });
expect(seen).toEqual(["Home"]);
});

test("updates entries in place and notifies subscribers", () => {
const seen: string[] = [];
const unsubscribe = subscribeEventLog((event) => seen.push(event.summary));
const entry = recordEventLogEvent({
source: "hid",
kind: "touch",
action: "begin",
summary: "Touch begin 0.1,0.2",
});

updateEventLogEvent(entry.id, {
kind: "tap",
action: "tap",
summary: "Tap 0.1,0.2",
});
unsubscribe();

expect(readEventLog()).toMatchObject([
{ id: entry.id, kind: "tap", action: "tap", summary: "Tap 0.1,0.2", msg: "Tap 0.1,0.2" },
]);
expect(seen).toEqual(["Touch begin 0.1,0.2", "Tap 0.1,0.2"]);
});
});

describe("eventLogEventForHidMessage", () => {
test("maps button HID payloads", () => {
expect(
eventLogEventForHidMessage("UDID", 0x04, {
button: "volume-up",
page: 12,
usage: 233,
phase: "down",
}),
).toMatchObject({
device: "UDID",
source: "hid",
kind: "button",
action: "volume-up",
summary: "Button volume-up down",
});
});

test("maps touch payloads with screen details", () => {
expect(
eventLogEventForHidMessage("UDID", 0x03, { type: "begin", x: 0.5, y: 0.9 }, {
width: 390,
height: 844,
}),
).toMatchObject({
device: "UDID",
source: "hid",
kind: "touch",
action: "begin",
summary: "Touch begin 0.5,0.9",
details: { screen: { width: 390, height: 844 } },
});
});

test("redacts printable key HID usages", () => {
for (const usage of [23, 0x1e, 0x2d]) {
const event = eventLogEventForHidMessage("UDID", 0x06, { type: "up", usage });
expect(event).toMatchObject({
device: "UDID",
source: "hid",
kind: "key",
action: "up",
summary: "Key up character",
details: { key: "character", redacted: true },
});
expect("usage" in event!.details!).toBe(false);
}
});

test("maps non-printable key HID usages to readable labels", () => {
expect(
eventLogEventForHidMessage("UDID", 0x06, { type: "down", usage: 0x28 }),
).toMatchObject({
summary: "Key down Enter",
details: { usage: 0x28, key: "Enter" },
});
});
});

describe("eventLogEventForCommand", () => {
test("classifies app installs without logging upload chunks", () => {
expect(eventLogEventForCommand("bash -c 'echo abc= | base64 -d > /tmp/app.ipa'")).toBeNull();
expect(
eventLogEventForCommand("xcrun simctl install DEVICE-A /tmp/MyApp.ipa", { exitCode: 0 }),
).toMatchObject({
device: "DEVICE-A",
source: "exec",
kind: "app",
action: "install",
status: "ok",
summary: "Install app MyApp.ipa",
});
});

test("classifies toolbar home and screenshot commands", () => {
expect(
eventLogEventForCommand("xcrun simctl launch DEVICE-A com.apple.springboard", { exitCode: 0 }),
).toMatchObject({
device: "DEVICE-A",
kind: "button",
action: "home",
summary: "Home",
});
expect(
eventLogEventForCommand("xcrun simctl io DEVICE-A screenshot ~/Desktop/shot.png", { exitCode: 1 }),
).toMatchObject({
device: "DEVICE-A",
kind: "screenshot",
status: "error",
summary: "Screenshot",
});
});

test("classifies serve-sim commands invoked through node", () => {
expect(
eventLogEventForCommand("node /tmp/dist/serve-sim.js button volume-up -d DEVICE-A"),
).toMatchObject({
device: "DEVICE-A",
kind: "button",
action: "volume-up",
summary: "Button volume-up",
});
});

test("does not log read-only camera polling commands", () => {
expect(
eventLogEventForCommand("node /tmp/dist/serve-sim.js camera status -d DEVICE-A", { exitCode: 0 }),
).toBeNull();
expect(
eventLogEventForCommand("node /tmp/dist/serve-sim.js camera --list-webcams", { exitCode: 0 }),
).toBeNull();
});
});
Loading
Loading