Skip to content

Commit cd44582

Browse files
authored
fix: tighten dogfood snapshot and session UX (#350)
* fix: tighten dogfood snapshot and session UX * fix: improve iOS simulator network dump recovery * fix: honor simulator device set in iOS log recovery
1 parent b4cfe3b commit cd44582

15 files changed

Lines changed: 528 additions & 28 deletions

skills/agent-device/references/debugging.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ Logging is off by default. Enable it only when you need a debugging window.
3636
- Default app logs live under `~/.agent-device/sessions/<session>/app.log`.
3737
- `logs clear --restart` is the fastest clean repro loop.
3838
- `network dump [limit] [summary|headers|body|all]` parses recent HTTP(s) entries from the same session app log.
39+
- On iOS simulators, `network dump` can recover recent app log history with `simctl log show` when the live session stream is sparse, so check the returned notes before assuming the repro window was empty.
40+
- On iOS, `network dump` is still limited to what Unified Logging exposes for the app process. If the app does not emit request metadata there, `network dump` can legitimately return no HTTP entries even during a real repro.
3941
- Summary output already shows timestamp, status, and duration when the log backend exposes them.
4042
- Prefer the explicit flag form `network dump 25 --include headers|body|all` when you need more than the default summary view.
43+
- If iOS simulator notes say app logs were recovered but none looked like HTTP traffic, treat that as an app instrumentation gap rather than a missing repro and inspect `logs path` for the non-network diagnostics that were captured.
4144
- `logs doctor` checks backend and runtime readiness for the current session and device.
4245
- `logs mark "before tap"` inserts a timestamped marker into the app log.
4346
- Android `network dump` surfaces timestamps from logcat-style prefixes and can backfill status and request/response duration from adjacent GIBSDK packet lines, so check it before dumping raw log windows.
@@ -84,6 +87,7 @@ agent-device alert accept
8487

8588
- `alert` is only supported on iOS simulators.
8689
- `alert accept` and `alert dismiss` retry internally for a short window, so you usually do not need manual sleeps.
90+
- If a permission sheet is visible in `snapshot` or `screenshot` but `alert accept` says no alert was found, treat it as normal tappable UI for that run: take a scoped `snapshot -i -s "<visible label>"` and `press @ref` instead of looping on `alert`.
8791
- iOS 16+ "Allow Paste" prompts are suppressed under XCUITest. Use `xcrun simctl pbcopy booted` when you need to seed simulator clipboard content directly.
8892

8993
## Setup problems worth recognizing early

skills/agent-device/references/exploration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ agent-device close
9090
- On iOS and Android, default snapshot output is visible-first. Off-screen interactive content is surfaced as discovery hints (including inline scroll/list hidden-content hints when known), not shown as directly tappable refs.
9191
- Treat large text-surface lines in `snapshot -i` as discovery output. If a node shows preview or truncation metadata, use `get text @ref` only after you have already decided that `snapshot -i` is needed for that surface.
9292
- Use `snapshot -i -s "Camera"` or `snapshot -i -s @e3` when you want a smaller, scoped result.
93+
- If `snapshot -i -s "<query>"` returns 0 nodes, the scope did not match the current screen. Widen the query or re-check the screen state instead of assuming the command silently fell back to the full tree.
9394
- If `snapshot -i` returns 0 nodes but the screen is visibly populated, treat `screenshot` as visual truth, wait briefly, then re-run `snapshot -i` once before escalating.
9495
- If `snapshot -i -d <n>` says the interactive output is empty at that depth, retry without `-d` instead of taking more shallow snapshots.
9596

src/daemon/__tests__/app-log.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
assertAndroidPackageArgSafe,
1010
buildAppleLogPredicate,
1111
buildIosDeviceLogStreamArgs,
12+
buildIosSimulatorLogStreamArgs,
1213
clearAppLogFiles,
1314
cleanupStaleAppLogProcesses,
1415
getAppLogPathMetadata,
@@ -22,7 +23,7 @@ test('buildAppleLogPredicate includes bundle-aware filters', () => {
2223
assert.match(predicate, /subsystem == "com\.example\.app"/);
2324
assert.match(predicate, /processImagePath ENDSWITH\[c\] "\/com\.example\.app"/);
2425
assert.match(predicate, /senderImagePath ENDSWITH\[c\] "\/com\.example\.app"/);
25-
assert.match(predicate, /eventMessage CONTAINS\[c\] "com\.example\.app"/);
26+
assert.doesNotMatch(predicate, /eventMessage CONTAINS\[c\] "com\.example\.app"/);
2627
});
2728

2829
test('assertAndroidPackageArgSafe rejects unsafe values', () => {
@@ -92,6 +93,50 @@ test('buildIosDeviceLogStreamArgs builds expected devicectl command args', () =>
9293
]);
9394
});
9495

96+
test('buildIosSimulatorLogStreamArgs streams logs inside the simulator at info level', () => {
97+
assert.deepEqual(
98+
buildIosSimulatorLogStreamArgs({ deviceId: 'sim-1', appBundleId: 'com.example.app' }),
99+
[
100+
'simctl',
101+
'spawn',
102+
'sim-1',
103+
'log',
104+
'stream',
105+
'--style',
106+
'compact',
107+
'--level',
108+
'info',
109+
'--predicate',
110+
buildAppleLogPredicate('com.example.app'),
111+
],
112+
);
113+
});
114+
115+
test('buildIosSimulatorLogStreamArgs respects simulator device set scoping', () => {
116+
assert.deepEqual(
117+
buildIosSimulatorLogStreamArgs({
118+
deviceId: 'sim-1',
119+
appBundleId: 'com.example.app',
120+
simulatorSetPath: '/tmp/tenant-a/simulators',
121+
}),
122+
[
123+
'simctl',
124+
'--set',
125+
'/tmp/tenant-a/simulators',
126+
'spawn',
127+
'sim-1',
128+
'log',
129+
'stream',
130+
'--style',
131+
'compact',
132+
'--level',
133+
'info',
134+
'--predicate',
135+
buildAppleLogPredicate('com.example.app'),
136+
],
137+
);
138+
});
139+
95140
test('cleanupStaleAppLogProcesses removes legacy plain pid files safely', () => {
96141
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-clean-legacy-'));
97142
const sessionDir = path.join(root, 'default');

src/daemon/__tests__/network-log.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,26 @@ test('readRecentNetworkTraffic keeps Android packet enrichment disabled for Appl
129129
assert.equal(dump.entries[0]?.durationMs, undefined);
130130
});
131131

132+
test('readRecentNetworkTraffic ignores plain documentation URLs in non-network log messages', () => {
133+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-network-log-'));
134+
const logPath = path.join(tempDir, 'app.log');
135+
fs.writeFileSync(
136+
logPath,
137+
'2026-04-02 08:14:44.371 E New Expensify Dev[32193:8c7e18d] Airship config warning. See https://docs.airship.com/platform/mobile/setup/sdk/ios/#url-allow-list for more information.\n',
138+
'utf8',
139+
);
140+
141+
const dump = readRecentNetworkTraffic(logPath, {
142+
backend: 'ios-simulator',
143+
maxEntries: 5,
144+
include: 'summary',
145+
maxPayloadChars: 2048,
146+
maxScanLines: 100,
147+
});
148+
149+
assert.equal(dump.entries.length, 0);
150+
});
151+
132152
test('readRecentNetworkTraffic returns empty result when log file is missing', () => {
133153
const logPath = path.join(os.tmpdir(), 'agent-device-network-log-missing', 'app.log');
134154
const dump = readRecentNetworkTraffic(logPath, {

src/daemon/__tests__/request-lock-policy.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,28 @@ test('rejects fresh-session selector conflicts under request lock policy', () =>
5252
);
5353
});
5454

55+
test('allows open to choose a fresh-session target under request lock policy', () => {
56+
const req = applyRequestLockPolicy({
57+
token: 'token',
58+
session: 'qa-ios',
59+
command: 'open',
60+
positionals: ['Settings'],
61+
flags: {
62+
platform: 'ios',
63+
device: 'iPhone 16',
64+
udid: 'SIM-001',
65+
},
66+
meta: {
67+
lockPolicy: 'reject',
68+
lockPlatform: 'ios',
69+
},
70+
});
71+
72+
assert.equal(req.flags?.platform, 'ios');
73+
assert.equal(req.flags?.device, 'iPhone 16');
74+
assert.equal(req.flags?.udid, 'SIM-001');
75+
});
76+
5577
test('strips fresh-session selector conflicts and restores lock platform', () => {
5678
const req = applyRequestLockPolicy({
5779
token: 'token',

src/daemon/app-log-ios.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { spawn } from 'node:child_process';
22
import fs from 'node:fs';
3+
import { buildSimctlArgs } from '../platforms/ios/simctl.ts';
4+
import { runCmd } from '../utils/exec.ts';
35
import { clearPidFile, writePidFile, type AppLogResult } from './app-log-process.ts';
46
import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts';
57

@@ -8,24 +10,96 @@ export function buildAppleLogPredicate(appBundleId: string): string {
810
`subsystem == "${appBundleId}"`,
911
`processImagePath ENDSWITH[c] "/${appBundleId}"`,
1012
`senderImagePath ENDSWITH[c] "/${appBundleId}"`,
11-
`eventMessage CONTAINS[c] "${appBundleId}"`,
1213
].join(' OR ');
1314
}
1415

16+
export function buildIosSimulatorLogStreamArgs(params: {
17+
deviceId: string;
18+
appBundleId: string;
19+
simulatorSetPath?: string;
20+
}): string[] {
21+
const { deviceId, appBundleId, simulatorSetPath } = params;
22+
return buildSimctlArgs(
23+
[
24+
'spawn',
25+
deviceId,
26+
'log',
27+
'stream',
28+
'--style',
29+
'compact',
30+
'--level',
31+
'info',
32+
'--predicate',
33+
buildAppleLogPredicate(appBundleId),
34+
],
35+
{ simulatorSetPath },
36+
);
37+
}
38+
1539
export function buildIosDeviceLogStreamArgs(deviceId: string): string[] {
1640
return ['devicectl', 'device', 'log', 'stream', '--device', deviceId];
1741
}
1842

43+
export async function readRecentIosSimulatorLogShowForBundle(params: {
44+
deviceId: string;
45+
appBundleId: string;
46+
startedAt?: number;
47+
simulatorSetPath?: string;
48+
}): Promise<{ text: string; recoveredLineCount: number } | null> {
49+
const { deviceId, appBundleId, startedAt, simulatorSetPath } = params;
50+
const args = buildSimctlArgs(
51+
[
52+
'spawn',
53+
deviceId,
54+
'log',
55+
'show',
56+
'--style',
57+
'compact',
58+
'--info',
59+
'--predicate',
60+
buildAppleLogPredicate(appBundleId),
61+
],
62+
{ simulatorSetPath },
63+
);
64+
if (typeof startedAt === 'number' && Number.isFinite(startedAt) && startedAt > 0) {
65+
args.push('--start', `@${Math.floor(startedAt / 1000)}`);
66+
} else {
67+
args.push('--last', '5m');
68+
}
69+
const result = await runCmd('xcrun', args, { allowFailure: true, timeoutMs: 4_000 });
70+
if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
71+
return null;
72+
}
73+
const lines = result.stdout
74+
.split('\n')
75+
.map((line) => line.trimEnd())
76+
.filter((line) => {
77+
const trimmed = line.trim();
78+
return (
79+
trimmed.length > 0 && !trimmed.startsWith('Timestamp Ty Process[PID:TID]')
80+
);
81+
});
82+
if (lines.length === 0) {
83+
return null;
84+
}
85+
return {
86+
text: `${lines.join('\n')}\n`,
87+
recoveredLineCount: lines.length,
88+
};
89+
}
90+
1991
export async function startIosSimulatorAppLog(
92+
deviceId: string,
2093
appBundleId: string,
2194
stream: fs.WriteStream,
2295
redactionPatterns: RegExp[],
96+
simulatorSetPath?: string,
2397
pidPath?: string,
2498
): Promise<AppLogResult> {
2599
let state: 'active' | 'failed' = 'active';
26100
const child = spawn(
27-
'log',
28-
['stream', '--style', 'compact', '--predicate', buildAppleLogPredicate(appBundleId)],
101+
'xcrun',
102+
buildIosSimulatorLogStreamArgs({ deviceId, appBundleId, simulatorSetPath }),
29103
{
30104
stdio: ['ignore', 'pipe', 'pipe'],
31105
},

0 commit comments

Comments
 (0)