mobile-debug-mcp 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursor-plugin/marketplace.json +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/README.md +96 -24
- package/agent-plugin/.antigravity-plugin/README.md +48 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/README.md +37 -8
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/cursor/LICENSE +201 -0
- package/agent-plugin/cursor/README.md +9 -0
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +261 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +13 -0
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +46 -18
- package/dist/contracts/device-readiness.js +1 -0
- package/dist/debugger/guard.js +4 -4
- package/dist/debugger/ios-worker.js +62 -0
- package/dist/debugger/ios.js +57 -0
- package/dist/debugger/sessions.js +33 -16
- package/dist/debugger/worker.js +6 -4
- package/dist/interact/index.js +12 -2
- package/dist/interact/waits/ui-change-wait.js +9 -2
- package/dist/interact/waits/ui-wait.js +7 -3
- package/dist/manage/index.js +3 -0
- package/dist/manage/ios.js +2 -0
- package/dist/observe/device-readiness.js +116 -0
- package/dist/observe/evidence.js +31 -0
- package/dist/observe/ios-trace/artifact.js +63 -0
- package/dist/observe/ios-trace/capabilities.js +86 -0
- package/dist/observe/ios-trace/native.js +93 -0
- package/dist/observe/ios-trace/notification.js +35 -0
- package/dist/observe/ios-trace/process.js +98 -0
- package/dist/observe/ios-trace/sessions.js +246 -0
- package/dist/observe/readiness-service.js +22 -10
- package/dist/observe/readiness.js +34 -4
- package/dist/observe/screenshot-encoding.js +4 -0
- package/dist/observe/snapshot-collection.js +7 -2
- package/dist/observe/system-trace-error.js +7 -0
- package/dist/observe/system-trace-router.js +81 -0
- package/dist/observe/system-trace.js +4 -8
- package/dist/server/definitions/manage.js +3 -2
- package/dist/server/handlers/action-execution.js +23 -14
- package/dist/server/handlers/compact-interaction.js +106 -0
- package/dist/server/handlers/compact-state.js +47 -0
- package/dist/server/handlers/debugger-handlers.js +27 -8
- package/dist/server/handlers/diagnostic.js +12 -4
- package/dist/server/handlers/input.js +2 -0
- package/dist/server/handlers/interact.js +2 -0
- package/dist/server/handlers/interaction-diagnostics.js +62 -0
- package/dist/server/handlers/journey.js +18 -20
- package/dist/server/handlers/observe.js +2 -1
- package/dist/server/handlers/tool-handlers.js +28 -14
- package/dist/server/schemas/debugger-schema.js +10 -7
- package/dist/server/schemas/interaction-diagnostics.js +9 -0
- package/dist/server/schemas/system-trace-schema.js +14 -9
- package/dist/server/tool-definitions.js +9 -4
- package/dist/server-core.js +5 -2
- package/dist/server.js +5 -5
- package/dist/system/android.js +19 -15
- package/dist/system/index.js +2 -1
- package/dist/utils/android/utils.js +7 -16
- package/dist/utils/app-installation.js +77 -0
- package/dist/utils/benchmark-metrics.js +43 -0
- package/dist/utils/exec.js +4 -0
- package/dist/utils/ios/utils.js +7 -11
- package/dist/utils/resolve-device.js +3 -2
- package/dist/utils/timing.js +3 -3
- package/docs/CHANGELOG.md +48 -1
- package/docs/agent-plugin-installation-verification.md +39 -0
- package/docs/benchmarks/README.md +1 -0
- package/docs/benchmarks/end-to-end-latency/README.md +98 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.json +6532 -0
- package/docs/benchmarks/end-to-end-latency/android-snapshot-pair.md +30 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.json +15901 -0
- package/docs/benchmarks/end-to-end-latency/baseline-attempt-1.md +14 -0
- package/docs/benchmarks/end-to-end-latency/baseline.json +233843 -0
- package/docs/benchmarks/end-to-end-latency/baseline.md +21 -0
- package/docs/benchmarks/end-to-end-latency/investigation.md +128 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.json +84 -0
- package/docs/benchmarks/end-to-end-latency/ios-smoke.md +9 -0
- package/docs/benchmarks/end-to-end-latency/smoke.json +19601 -0
- package/docs/benchmarks/end-to-end-latency/smoke.md +21 -0
- package/docs/code-structure.md +3 -1
- package/docs/specs/031-ios-system-traces/intent.md +38 -0
- package/docs/specs/031-ios-system-traces/investigation.md +92 -0
- package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
- package/docs/specs/031-ios-system-traces/plan.md +166 -0
- package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
- package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
- package/docs/specs/031-ios-system-traces/spec.md +135 -0
- package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
- package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
- package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
- package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
- package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
- package/docs/specs/033-device-readiness-blockers/android-evidence.json +437 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-evidence.json +28 -0
- package/docs/specs/033-device-readiness-blockers/android-physical-power-evidence.json +29 -0
- package/docs/specs/033-device-readiness-blockers/intent.md +41 -0
- package/docs/specs/033-device-readiness-blockers/ios-investigation.md +55 -0
- package/docs/specs/033-device-readiness-blockers/ios-probe-evidence.json +24 -0
- package/docs/specs/033-device-readiness-blockers/ios-tool-evidence.json +73 -0
- package/docs/specs/033-device-readiness-blockers/plan.md +249 -0
- package/docs/specs/033-device-readiness-blockers/spec.md +134 -0
- package/docs/specs/034-app-installation-status/intent.md +41 -0
- package/docs/specs/034-app-installation-status/native-evidence.json +654 -0
- package/docs/specs/034-app-installation-status/plan.md +73 -0
- package/docs/specs/034-app-installation-status/spec.md +114 -0
- package/docs/specs/035-compact-interaction-results/baseline.md +45 -0
- package/docs/specs/035-compact-interaction-results/intent.md +38 -0
- package/docs/specs/035-compact-interaction-results/plan.md +43 -0
- package/docs/specs/035-compact-interaction-results/spec.md +118 -0
- package/docs/specs/035-compact-interaction-results/verification.md +111 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-amendment.md +60 -0
- package/docs/specs/036-end-to-end-interaction-latency/android-snapshot-plan.md +32 -0
- package/docs/specs/036-end-to-end-interaction-latency/intent.md +37 -0
- package/docs/specs/036-end-to-end-interaction-latency/plan.md +59 -0
- package/docs/specs/036-end-to-end-interaction-latency/spec.md +126 -0
- package/docs/specs/036-end-to-end-interaction-latency/verification.md +87 -0
- package/docs/tools/TOOLS.md +3 -2
- package/docs/tools/compact-interactions.md +56 -0
- package/docs/tools/correlated-evidence.md +6 -0
- package/docs/tools/debugger.md +45 -5
- package/docs/tools/interact.md +17 -8
- package/docs/tools/manage.md +10 -2
- package/docs/tools/observe.md +42 -2
- package/docs/tools/system-traces.md +81 -14
- package/docs/tools/system.md +6 -0
- package/package.json +2 -1
- package/scripts/agent-plugin.mjs +55 -5
- package/src/contracts/device-readiness.ts +8 -0
- package/src/debugger/android.ts +1 -1
- package/src/debugger/guard.ts +5 -5
- package/src/debugger/ios-worker.ts +49 -0
- package/src/debugger/ios.ts +45 -0
- package/src/debugger/lldb/breakpoints.py +51 -0
- package/src/debugger/lldb/inspection.py +109 -0
- package/src/debugger/lldb/worker.py +203 -0
- package/src/debugger/sessions.ts +24 -12
- package/src/debugger/worker.ts +7 -4
- package/src/interact/index.ts +13 -2
- package/src/interact/waits/ui-change-wait.ts +7 -2
- package/src/interact/waits/ui-wait.ts +6 -3
- package/src/manage/index.ts +2 -0
- package/src/manage/ios.ts +2 -0
- package/src/observe/device-readiness.ts +96 -0
- package/src/observe/evidence.ts +30 -1
- package/src/observe/ios-trace/artifact.ts +55 -0
- package/src/observe/ios-trace/capabilities.ts +74 -0
- package/src/observe/ios-trace/native.ts +72 -0
- package/src/observe/ios-trace/notification.ts +30 -0
- package/src/observe/ios-trace/process.ts +82 -0
- package/src/observe/ios-trace/sessions.ts +206 -0
- package/src/observe/readiness-service.ts +22 -10
- package/src/observe/readiness.ts +37 -4
- package/src/observe/screenshot-encoding.ts +4 -0
- package/src/observe/snapshot-collection.ts +7 -2
- package/src/observe/system-trace-error.ts +3 -0
- package/src/observe/system-trace-router.ts +61 -0
- package/src/observe/system-trace.ts +4 -4
- package/src/server/common.ts +2 -2
- package/src/server/definitions/manage.ts +3 -2
- package/src/server/handlers/action-execution.ts +22 -14
- package/src/server/handlers/compact-interaction.ts +91 -0
- package/src/server/handlers/compact-state.ts +43 -0
- package/src/server/handlers/debugger-handlers.ts +28 -9
- package/src/server/handlers/diagnostic.ts +11 -4
- package/src/server/handlers/input.ts +2 -0
- package/src/server/handlers/interact.ts +2 -0
- package/src/server/handlers/interaction-diagnostics.ts +50 -0
- package/src/server/handlers/journey.ts +15 -17
- package/src/server/handlers/observe.ts +2 -1
- package/src/server/handlers/payload.ts +2 -1
- package/src/server/handlers/tool-handlers.ts +29 -14
- package/src/server/schemas/debugger-schema.ts +10 -7
- package/src/server/schemas/interaction-diagnostics.ts +10 -0
- package/src/server/schemas/system-trace-schema.ts +15 -9
- package/src/server/tool-definitions.ts +8 -4
- package/src/server-core.ts +5 -2
- package/src/server.ts +4 -4
- package/src/system/android.ts +18 -11
- package/src/system/index.ts +2 -1
- package/src/types.ts +2 -0
- package/src/utils/android/utils.ts +8 -10
- package/src/utils/app-installation.ts +73 -0
- package/src/utils/benchmark-metrics.ts +42 -0
- package/src/utils/exec.ts +5 -0
- package/src/utils/ios/utils.ts +7 -10
- package/src/utils/resolve-device.ts +4 -2
- package/src/utils/timing.ts +3 -2
- package/test/device/automated/debugger/runtime.android.test.ts +5 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +227 -0
- package/test/device/automated/interact/compact-results.smoke.ts +58 -0
- package/test/device/automated/latency/end-to-end-environment.ts +61 -0
- package/test/device/automated/latency/end-to-end-report.ts +90 -0
- package/test/device/automated/latency/end-to-end-session.ts +98 -0
- package/test/device/automated/latency/end-to-end.ts +149 -0
- package/test/device/automated/latency/owned-transport.ts +81 -0
- package/test/device/automated/latency/snapshot-pair.ts +143 -0
- package/test/device/automated/latency-ios/benchmark.ts +126 -0
- package/test/device/automated/observe/correlated-evidence.smoke.ts +7 -5
- package/test/device/automated/observe/device-readiness-cleanup.ts +14 -0
- package/test/device/automated/observe/device-readiness.android.smoke.ts +78 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
- package/test/device/automated/observe/system-trace.smoke.ts +2 -2
- package/test/fixtures/contracts/tool-definitions.json +243 -47
- package/test/fixtures/ios-debugger-app/Main.swift +95 -0
- package/test/fixtures/ios-debugger-app/README.md +27 -0
- package/test/fixtures/ios-debugger-app/build.ts +21 -0
- package/test/fixtures/latency-journey-app/build.ts +3 -3
- package/test/helpers/ios-trace-shutdown.ts +29 -0
- package/test/unit/debugger/handle-guards.test.ts +89 -0
- package/test/unit/debugger/ios-preparation.test.ts +57 -0
- package/test/unit/debugger/ios.test.ts +104 -0
- package/test/unit/debugger/lldb-worker.py +166 -0
- package/test/unit/debugger/lldb-worker.test.ts +10 -0
- package/test/unit/interact/ui-change-readiness-interruption.test.ts +37 -0
- package/test/unit/observe/capture_debug_snapshot.test.ts +66 -12
- package/test/unit/observe/device-readiness-cleanup.test.ts +24 -0
- package/test/unit/observe/device-readiness.test.ts +97 -0
- package/test/unit/observe/ios-trace-native.test.ts +90 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +154 -0
- package/test/unit/observe/readiness-retained-blockers.test.ts +40 -0
- package/test/unit/observe/readiness.test.ts +1 -1
- package/test/unit/observe/system-trace-router.test.ts +49 -0
- package/test/unit/quality/agent-plugin-export.test.ts +38 -0
- package/test/unit/server/compact-interaction.test.ts +87 -0
- package/test/unit/server/compact-journey-evidence.test.ts +40 -0
- package/test/unit/server/compact-review-regressions.test.ts +78 -0
- package/test/unit/server/conditional_actions.test.ts +7 -3
- package/test/unit/server/correlated-evidence.test.ts +18 -6
- package/test/unit/server/end-to-end-report.test.ts +46 -0
- package/test/unit/server/input-device-readiness.test.ts +55 -0
- package/test/unit/server/interaction-diagnostics.test.ts +45 -0
- package/test/unit/server/owned-benchmark-transport.test.ts +32 -0
- package/test/unit/server/readiness-retained-deadline.test.ts +32 -0
- package/test/unit/server/readiness.test.ts +24 -1
- package/test/unit/server/run_journey.test.ts +2 -1
- package/test/unit/server/selector_tap_target.test.ts +2 -1
- package/test/unit/server/system-trace.test.ts +18 -7
- package/test/unit/utils/app-installation.test.ts +78 -0
- package/test/unit/utils/benchmark-metrics.test.ts +36 -0
- package/test/unit/utils/resolve_device.test.ts +11 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getXcrunCmd } from '../../utils/ios/utils.js';
|
|
5
|
+
import { iosCapabilities, resolveIosApp } from './capabilities.js';
|
|
6
|
+
import { prepareNotification, listenNotification } from './notification.js';
|
|
7
|
+
import { spawnTrace, stopTraceChild, classifyTraceFailure } from './process.js';
|
|
8
|
+
import { traceSize, validateArtifact, IOS_TRACE_MAX_BYTES } from './artifact.js';
|
|
9
|
+
export class IosTraceNative {
|
|
10
|
+
spawn;
|
|
11
|
+
children = new Set();
|
|
12
|
+
recorders = new Set();
|
|
13
|
+
closing = false;
|
|
14
|
+
constructor(spawn = spawnTrace) {
|
|
15
|
+
this.spawn = (command, args, timeout) => {
|
|
16
|
+
if (this.closing)
|
|
17
|
+
throw new Error('TRACE_BUSY');
|
|
18
|
+
const child = spawn(command, args, timeout);
|
|
19
|
+
this.children.add(child);
|
|
20
|
+
if (args.includes('xctrace') && args.includes('record'))
|
|
21
|
+
this.recorders.add(child);
|
|
22
|
+
void child.done.then(() => { this.children.delete(child); this.recorders.delete(child); });
|
|
23
|
+
return child;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
shutdown() {
|
|
27
|
+
this.closing = true;
|
|
28
|
+
for (const child of this.children)
|
|
29
|
+
child.signal('SIGINT');
|
|
30
|
+
const helpers = setTimeout(() => { for (const child of this.children)
|
|
31
|
+
if (!this.recorders.has(child))
|
|
32
|
+
child.signal('SIGKILL'); }, 1000);
|
|
33
|
+
const recorders = setTimeout(() => { for (const child of this.recorders)
|
|
34
|
+
child.signal('SIGKILL'); }, 8000);
|
|
35
|
+
helpers.unref();
|
|
36
|
+
recorders.unref();
|
|
37
|
+
}
|
|
38
|
+
capabilities(device, appId, template) { return iosCapabilities(device, appId, template, this.spawn); }
|
|
39
|
+
resolve(device, app) { return resolveIosApp(device, app, this.spawn); }
|
|
40
|
+
async storage(directory) {
|
|
41
|
+
const stat = await fs.statfs(directory);
|
|
42
|
+
if (stat.bavail * stat.bsize < 1024 * 1024 * 1024)
|
|
43
|
+
throw new Error('STORAGE_LIMIT');
|
|
44
|
+
}
|
|
45
|
+
async start(device, app, directory, duration, own) {
|
|
46
|
+
await this.storage(directory);
|
|
47
|
+
const binary = await prepareNotification(directory, this.spawn);
|
|
48
|
+
return this.startRecorder(device, app, directory, duration, binary, own);
|
|
49
|
+
}
|
|
50
|
+
async startRecorder(device, app, directory, duration, binary, own) {
|
|
51
|
+
const name = 'tools.mobiledebug.trace.' + randomUUID();
|
|
52
|
+
const listener = await listenNotification(binary, name, this.spawn);
|
|
53
|
+
let recording;
|
|
54
|
+
try {
|
|
55
|
+
const current = await this.resolve(device, app.app_id);
|
|
56
|
+
if (current.pid !== app.pid || current.executable !== app.executable || current.started_at !== app.started_at)
|
|
57
|
+
throw new Error('APP_IDENTITY_CHANGED');
|
|
58
|
+
const file = path.join(directory, 'capture.trace');
|
|
59
|
+
const recorder = this.spawn(getXcrunCmd(), ['xctrace', 'record', '--template', 'Time Profiler', '--device', device,
|
|
60
|
+
'--attach', String(app.pid), '--time-limit', `${duration}ms`, '--output', file, '--notify-tracing-started', name], duration + 65000);
|
|
61
|
+
recording = { recorder, file, ready_at_ms: 0 };
|
|
62
|
+
own(recording);
|
|
63
|
+
await Promise.race([listener.line('READY', 35000), recorder.done.then(result => { throw new Error(classifyTraceFailure(result)); })]);
|
|
64
|
+
if (!recorder.alive())
|
|
65
|
+
throw new Error('RECORDER_EXITED');
|
|
66
|
+
recording.ready_at_ms = Date.now();
|
|
67
|
+
return recording;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (recording)
|
|
71
|
+
await stopTraceChild(recording.recorder).catch(() => { });
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await stopTraceChild(listener, 1000).catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
stop(recording, timeout) { return stopTraceChild(recording.recorder, timeout); }
|
|
79
|
+
async monitor(device, app, recording) {
|
|
80
|
+
const current = await this.resolve(device, app.app_id);
|
|
81
|
+
if (current.pid !== app.pid || current.executable !== app.executable || current.started_at !== app.started_at)
|
|
82
|
+
throw new Error('APP_IDENTITY_CHANGED');
|
|
83
|
+
try {
|
|
84
|
+
if (await traceSize(recording.file) > IOS_TRACE_MAX_BYTES)
|
|
85
|
+
throw new Error('STORAGE_LIMIT');
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
validate(recording, device, app) { return validateArtifact(recording.file, device, app, this.spawn); }
|
|
93
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getXcrunCmd } from '../../utils/ios/utils.js';
|
|
4
|
+
import { traceCommand, spawnTrace, stopTraceChild } from './process.js';
|
|
5
|
+
// A private per-session listener avoids requiring a third-party notification utility.
|
|
6
|
+
const source = `#include <notify.h>
|
|
7
|
+
#include <dispatch/dispatch.h>
|
|
8
|
+
#include <stdio.h>
|
|
9
|
+
#include <stdlib.h>
|
|
10
|
+
int main(int argc, char **argv) {
|
|
11
|
+
if (argc != 2) return 2;
|
|
12
|
+
int token;
|
|
13
|
+
if (notify_register_dispatch(argv[1], &token, dispatch_get_main_queue(), ^(int t) {
|
|
14
|
+
puts("READY"); fflush(stdout); notify_cancel(t); exit(0);
|
|
15
|
+
}) != NOTIFY_STATUS_OK) return 3;
|
|
16
|
+
puts("ARMED"); fflush(stdout); dispatch_main();
|
|
17
|
+
}`;
|
|
18
|
+
export async function prepareNotification(directory, spawn = spawnTrace) {
|
|
19
|
+
const file = path.join(directory, 'notification.c'), binary = path.join(directory, 'notification');
|
|
20
|
+
await fs.writeFile(file, source, { mode: 0o600 });
|
|
21
|
+
await traceCommand(getXcrunCmd(), ['clang', '-fblocks', file, '-o', binary], 30000, spawn);
|
|
22
|
+
await fs.chmod(binary, 0o700);
|
|
23
|
+
return binary;
|
|
24
|
+
}
|
|
25
|
+
export async function listenNotification(binary, name, spawn = spawnTrace) {
|
|
26
|
+
const listener = spawn(binary, [name], 40000);
|
|
27
|
+
try {
|
|
28
|
+
await listener.line('ARMED', 5000);
|
|
29
|
+
return listener;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
await stopTraceChild(listener, 1000).catch(() => { });
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
3
|
+
export const spawnTrace = (command, args, timeout) => {
|
|
4
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
5
|
+
let output = '', settled = false, timedOut = false;
|
|
6
|
+
const waiters = new Set();
|
|
7
|
+
let force;
|
|
8
|
+
const terminate = () => {
|
|
9
|
+
if (settled)
|
|
10
|
+
return;
|
|
11
|
+
child.kill('SIGTERM');
|
|
12
|
+
force ??= setTimeout(() => { if (!settled)
|
|
13
|
+
child.kill('SIGKILL'); }, 1000);
|
|
14
|
+
force.unref();
|
|
15
|
+
};
|
|
16
|
+
const timer = setTimeout(() => { timedOut = true; terminate(); }, timeout);
|
|
17
|
+
timer.unref();
|
|
18
|
+
const done = new Promise(resolve => {
|
|
19
|
+
const finish = (code) => {
|
|
20
|
+
if (settled)
|
|
21
|
+
return;
|
|
22
|
+
settled = true;
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
if (force)
|
|
25
|
+
clearTimeout(force);
|
|
26
|
+
for (const wake of waiters)
|
|
27
|
+
wake();
|
|
28
|
+
resolve({ code, output, timedOut });
|
|
29
|
+
};
|
|
30
|
+
child.on('error', () => finish(null));
|
|
31
|
+
child.on('close', finish);
|
|
32
|
+
const data = (chunk) => {
|
|
33
|
+
output = (output + chunk.toString()).slice(-MAX_OUTPUT);
|
|
34
|
+
for (const wake of waiters)
|
|
35
|
+
wake();
|
|
36
|
+
};
|
|
37
|
+
child.stdout?.on('data', data);
|
|
38
|
+
child.stderr?.on('data', data);
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
done, alive: () => !settled && child.exitCode === null && child.signalCode === null,
|
|
42
|
+
signal: signal => { if (!settled)
|
|
43
|
+
child.kill(signal); },
|
|
44
|
+
line: (text, ms) => new Promise((resolve, reject) => {
|
|
45
|
+
const deadline = setTimeout(() => { waiters.delete(check); reject(new Error('READINESS_TIMEOUT')); }, ms);
|
|
46
|
+
const check = () => {
|
|
47
|
+
if (output.split(/\r?\n/).includes(text)) {
|
|
48
|
+
clearTimeout(deadline);
|
|
49
|
+
waiters.delete(check);
|
|
50
|
+
resolve();
|
|
51
|
+
}
|
|
52
|
+
else if (settled) {
|
|
53
|
+
clearTimeout(deadline);
|
|
54
|
+
waiters.delete(check);
|
|
55
|
+
reject(new Error('RECORDER_EXITED'));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
waiters.add(check);
|
|
59
|
+
check();
|
|
60
|
+
})
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
export async function traceCommand(command, args, timeout = 10000, spawn = spawnTrace) {
|
|
64
|
+
const result = await spawn(command, args, timeout).done;
|
|
65
|
+
if (result.code !== 0 || result.timedOut)
|
|
66
|
+
throw new Error(result.timedOut ? 'ACTION_TIMEOUT' : 'COMMAND_FAILED');
|
|
67
|
+
return result.output.trim();
|
|
68
|
+
}
|
|
69
|
+
export async function stopTraceChild(child, timeout = 30000) {
|
|
70
|
+
if (child.alive())
|
|
71
|
+
child.signal('SIGINT');
|
|
72
|
+
let timer;
|
|
73
|
+
const result = await Promise.race([
|
|
74
|
+
child.done,
|
|
75
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(null), timeout); })
|
|
76
|
+
]);
|
|
77
|
+
if (timer)
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
if (result)
|
|
80
|
+
return result;
|
|
81
|
+
child.signal('SIGKILL');
|
|
82
|
+
let forceTimer;
|
|
83
|
+
await Promise.race([child.done, new Promise(resolve => { forceTimer = setTimeout(resolve, 1000); })]);
|
|
84
|
+
if (forceTimer)
|
|
85
|
+
clearTimeout(forceTimer);
|
|
86
|
+
throw new Error('ACTION_TIMEOUT');
|
|
87
|
+
}
|
|
88
|
+
export function classifyTraceFailure(result) {
|
|
89
|
+
if (result.timedOut)
|
|
90
|
+
return 'ACTION_TIMEOUT';
|
|
91
|
+
if (/permission|not permitted|authoriz|privacy|access denied/i.test(result.output))
|
|
92
|
+
return 'PROFILING_PERMISSION_REQUIRED';
|
|
93
|
+
if (/cannot find process|process.*not.*found/i.test(result.output))
|
|
94
|
+
return 'APP_NOT_RUNNING';
|
|
95
|
+
if (/disconnect|device.*unavailable/i.test(result.output))
|
|
96
|
+
return 'TARGET_DISCONNECTED';
|
|
97
|
+
return 'RECORDER_EXITED';
|
|
98
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { classifyTraceFailure } from './process.js';
|
|
2
|
+
import { TraceError } from '../system-trace-error.js';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { evidenceSessionId } from '../evidence.js';
|
|
8
|
+
import { IosTraceNative } from './native.js';
|
|
9
|
+
const reasonFor = (error, fallback) => {
|
|
10
|
+
const code = error instanceof Error ? error.message : '';
|
|
11
|
+
return ['STORAGE_LIMIT', 'APP_IDENTITY_CHANGED', 'ACTION_TIMEOUT', 'READINESS_TIMEOUT', 'RECORDER_EXITED', 'PROFILING_PERMISSION_REQUIRED', 'APP_NOT_RUNNING', 'TARGET_DISCONNECTED'].includes(code) ? code : fallback;
|
|
12
|
+
};
|
|
13
|
+
export class IosTraceSessions {
|
|
14
|
+
native;
|
|
15
|
+
time;
|
|
16
|
+
sessions = new Map();
|
|
17
|
+
closing = false;
|
|
18
|
+
constructor(native = new IosTraceNative(), time = {
|
|
19
|
+
wall: Date.now, mono: () => performance.now(), sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
20
|
+
schedule: (fn, ms) => { const timer = setTimeout(fn, ms); timer.unref(); return timer; }, clear: clearTimeout
|
|
21
|
+
}) {
|
|
22
|
+
this.native = native;
|
|
23
|
+
this.time = time;
|
|
24
|
+
}
|
|
25
|
+
get(id) {
|
|
26
|
+
this.prune();
|
|
27
|
+
const s = this.sessions.get(id);
|
|
28
|
+
if (!s)
|
|
29
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
prune() {
|
|
33
|
+
const terminal = [...this.sessions.values()].filter(s => s.terminal !== undefined && s.result.cleanup.status === 'complete');
|
|
34
|
+
for (const [index, s] of terminal.entries())
|
|
35
|
+
if (this.time.mono() - s.terminal >= 600000 || index < terminal.length - 100)
|
|
36
|
+
this.sessions.delete(s.result.trace_id);
|
|
37
|
+
}
|
|
38
|
+
serial(s, fn) {
|
|
39
|
+
const promise = s.queue.then(fn);
|
|
40
|
+
s.queue = promise.catch(() => { });
|
|
41
|
+
return promise;
|
|
42
|
+
}
|
|
43
|
+
snapshot(s) { return structuredClone(s.result); }
|
|
44
|
+
fail(s, reason) {
|
|
45
|
+
s.result.state = 'failed';
|
|
46
|
+
s.result.reason = reason;
|
|
47
|
+
s.result.completion_reason = 'error';
|
|
48
|
+
s.result.recovery = reason === 'PROFILING_PERMISSION_REQUIRED' ? 'Grant the required profiling permission through the macOS or Xcode UI, then explicitly start a new capture.' : reason === 'TRACE_ARTIFACT_FAILED' ? 'Retry stop to validate the finalised owned artifact, or cancel to discard.' : 'Check target, app and profiling permissions; cancel the owned session before retrying start.';
|
|
49
|
+
for (const id of s.linked)
|
|
50
|
+
s.partial.add(id);
|
|
51
|
+
}
|
|
52
|
+
async start(device, appId, duration = 30000) {
|
|
53
|
+
this.prune();
|
|
54
|
+
const active = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete');
|
|
55
|
+
if (this.closing || active.length >= 4 || active.some(s => s.result.device.id === device.id))
|
|
56
|
+
throw new TraceError('TRACE_BUSY');
|
|
57
|
+
if (!Number.isInteger(duration) || duration < 1000 || duration > 120000)
|
|
58
|
+
throw new TraceError('INVALID_ARGUMENT');
|
|
59
|
+
const result = {
|
|
60
|
+
trace_id: randomUUID(), session_id: evidenceSessionId(), device: structuredClone(device), backend: 'xctrace', state: 'starting',
|
|
61
|
+
reason: null, recovery: null, completion_reason: null, capture_started_at_ms: null, capture_completed_at_ms: null,
|
|
62
|
+
recording_ready_at_ms: null, app_id: appId, pid: null, executable: null, template: 'Time Profiler', artifact: null,
|
|
63
|
+
cleanup: { status: 'pending', remaining_resources: [] }, warnings: [], correlation_ids: [], clock_alignment: 'unknown'
|
|
64
|
+
};
|
|
65
|
+
const s = { result, deadline: Infinity, discard: false, queue: Promise.resolve(), linked: new Set(), partial: new Set() };
|
|
66
|
+
this.sessions.set(result.trace_id, s);
|
|
67
|
+
return this.serial(s, async () => {
|
|
68
|
+
try {
|
|
69
|
+
s.directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mobile-debug-ios-trace-'));
|
|
70
|
+
await fs.chmod(s.directory, 0o700);
|
|
71
|
+
s.result.cleanup.remaining_resources = [s.directory];
|
|
72
|
+
s.app = await this.native.resolve(device.id, appId);
|
|
73
|
+
s.result.pid = s.app.pid;
|
|
74
|
+
s.result.executable = s.app.executable;
|
|
75
|
+
if (this.closing)
|
|
76
|
+
throw new TraceError('TRACE_BUSY');
|
|
77
|
+
s.result.capture_started_at_ms = this.time.wall();
|
|
78
|
+
s.deadline = Infinity;
|
|
79
|
+
s.recording = await this.native.start(device.id, s.app, s.directory, duration, recording => { s.recording = recording; if (this.closing)
|
|
80
|
+
recording.recorder.signal('SIGINT'); });
|
|
81
|
+
if (this.closing)
|
|
82
|
+
throw new Error('READINESS_TIMEOUT');
|
|
83
|
+
s.deadline = this.time.mono() + duration;
|
|
84
|
+
s.result.recording_ready_at_ms = s.recording.ready_at_ms;
|
|
85
|
+
s.result.state = 'recording';
|
|
86
|
+
this.schedule(s);
|
|
87
|
+
void s.recording.recorder.done.then(() => this.control(result.trace_id, 'status')).catch(() => { });
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.fail(s, reasonFor(error, 'TRACE_START_FAILED'));
|
|
91
|
+
s.discard = true;
|
|
92
|
+
await this.finalise(s, 'cancel', true);
|
|
93
|
+
}
|
|
94
|
+
return this.snapshot(s);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
schedule(s) {
|
|
98
|
+
if (s.result.state !== 'recording')
|
|
99
|
+
return;
|
|
100
|
+
s.timer = this.time.schedule(() => {
|
|
101
|
+
void this.serial(s, () => this.poll(s)).finally(() => this.schedule(s)).catch(() => { });
|
|
102
|
+
}, 1000);
|
|
103
|
+
}
|
|
104
|
+
async poll(s) {
|
|
105
|
+
if (s.result.state !== 'recording' || !s.recording || !s.app)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
await this.native.monitor(s.result.device.id, s.app, s.recording);
|
|
109
|
+
s.monitorFailures = 0;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const reason = error instanceof Error && error.message === 'COMMAND_FAILED'
|
|
113
|
+
? 'COMMAND_FAILED' : reasonFor(error, 'TRACE_MONITOR_FAILED');
|
|
114
|
+
const definitive = ['STORAGE_LIMIT', 'APP_IDENTITY_CHANGED', 'APP_NOT_RUNNING'].includes(reason);
|
|
115
|
+
s.monitorFailures = (s.monitorFailures ?? 0) + 1;
|
|
116
|
+
if (definitive || s.monitorFailures >= 3) {
|
|
117
|
+
this.fail(s, reason);
|
|
118
|
+
s.discard = true;
|
|
119
|
+
await this.finalise(s, 'cancel', true);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (this.time.mono() >= s.deadline || !s.recording.recorder.alive())
|
|
124
|
+
await this.finalise(s, 'duration');
|
|
125
|
+
}
|
|
126
|
+
async control(id, op, timeout) {
|
|
127
|
+
const s = this.get(id);
|
|
128
|
+
return this.serial(s, async () => {
|
|
129
|
+
if (s.result.cleanup.status === 'complete')
|
|
130
|
+
return this.snapshot(s);
|
|
131
|
+
if (op === 'status' && s.result.state === 'recording' && s.recording?.recorder.alive() && this.time.mono() < s.deadline) {
|
|
132
|
+
await this.poll(s);
|
|
133
|
+
return this.snapshot(s);
|
|
134
|
+
}
|
|
135
|
+
await this.finalise(s, op === 'status' ? 'duration' : op, false, timeout);
|
|
136
|
+
return this.snapshot(s);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async finalise(s, op, preserveFailure = false, timeout) {
|
|
140
|
+
if (s.timer) {
|
|
141
|
+
this.time.clear(s.timer);
|
|
142
|
+
s.timer = undefined;
|
|
143
|
+
}
|
|
144
|
+
if (op === 'cancel')
|
|
145
|
+
s.discard = true;
|
|
146
|
+
if (!preserveFailure)
|
|
147
|
+
s.result.state = 'stopping';
|
|
148
|
+
for (const id of s.linked)
|
|
149
|
+
s.partial.add(id);
|
|
150
|
+
try {
|
|
151
|
+
await this.finishRecorder(s, op, timeout);
|
|
152
|
+
s.result.capture_completed_at_ms ??= this.time.wall();
|
|
153
|
+
await this.finishArtifact(s);
|
|
154
|
+
s.result.cleanup = { status: 'complete', remaining_resources: [] };
|
|
155
|
+
s.terminal = this.time.mono();
|
|
156
|
+
if (!preserveFailure) {
|
|
157
|
+
s.result.state = s.discard ? 'cancelled' : 'completed';
|
|
158
|
+
s.result.reason = s.discard ? 'TRACE_CANCELLED' : null;
|
|
159
|
+
s.result.recovery = null;
|
|
160
|
+
s.result.completion_reason = s.discard ? 'cancelled' : op === 'duration' ? 'duration_limit' : 'requested';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
this.fail(s, reasonFor(error, s.recording?.recorder.alive() ? 'TRACE_STOP_FAILED' : 'TRACE_ARTIFACT_FAILED'));
|
|
165
|
+
s.result.cleanup.status = 'failed';
|
|
166
|
+
// Stop timeouts can throw after SIGKILL has already closed the recorder.
|
|
167
|
+
// Discarding is safe only after exit; keep the failure but release owned files.
|
|
168
|
+
if ((s.discard || s.result.reason === 'STORAGE_LIMIT') && !s.recording?.recorder.alive())
|
|
169
|
+
await this.discardStoppedFiles(s);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async discardStoppedFiles(s) {
|
|
173
|
+
try {
|
|
174
|
+
if (s.directory)
|
|
175
|
+
await fs.rm(s.directory, { recursive: true, force: true });
|
|
176
|
+
s.result.cleanup = { status: 'complete', remaining_resources: [] };
|
|
177
|
+
s.terminal = this.time.mono();
|
|
178
|
+
}
|
|
179
|
+
catch { /* retain failed cleanup and owned path for cancel retry */ }
|
|
180
|
+
}
|
|
181
|
+
async finishRecorder(s, op, timeout) {
|
|
182
|
+
if (!s.recording || s.recorderOk !== undefined)
|
|
183
|
+
return;
|
|
184
|
+
const ended = await this.native.stop(s.recording, timeout);
|
|
185
|
+
s.recorderOk = ended.code === 0 && !ended.timedOut;
|
|
186
|
+
if (ended.output.includes('[Warning]'))
|
|
187
|
+
s.result.warnings = ['INSTRUMENTS_RUN_WARNING: one or more Instruments warnings; coverage may be incomplete.'];
|
|
188
|
+
if (!s.recorderOk && !s.discard)
|
|
189
|
+
throw new Error(classifyTraceFailure(ended));
|
|
190
|
+
if (op === 'duration' && !ended.output.includes('Reached specified time limit') && this.time.mono() < s.deadline && !s.discard)
|
|
191
|
+
throw new Error('RECORDER_EXITED');
|
|
192
|
+
}
|
|
193
|
+
async finishArtifact(s) {
|
|
194
|
+
if (!s.discard && !s.result.artifact) {
|
|
195
|
+
if (!s.recording || !s.app || !s.recorderOk)
|
|
196
|
+
throw new Error('RECORDER_EXITED');
|
|
197
|
+
s.result.artifact = await this.native.validate(s.recording, s.result.device.id, s.app);
|
|
198
|
+
}
|
|
199
|
+
if (s.discard && s.directory)
|
|
200
|
+
await fs.rm(s.directory, { recursive: true, force: true });
|
|
201
|
+
else if (s.directory)
|
|
202
|
+
await Promise.all(['notification', 'notification.c'].map(p => fs.rm(path.join(s.directory, p), { force: true })));
|
|
203
|
+
}
|
|
204
|
+
async link(id, device, correlation) {
|
|
205
|
+
const s = this.get(id);
|
|
206
|
+
if (device.platform !== 'ios' || device.id !== s.result.device.id)
|
|
207
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
208
|
+
const status = await this.control(id, 'status');
|
|
209
|
+
if (status.state !== 'recording')
|
|
210
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
211
|
+
return this.serial(s, async () => {
|
|
212
|
+
if (s.result.state !== 'recording' || !s.recording?.recorder.alive() || this.time.mono() >= s.deadline)
|
|
213
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
214
|
+
if (!s.result.correlation_ids.includes(correlation)) {
|
|
215
|
+
if (s.result.correlation_ids.length >= 1000)
|
|
216
|
+
throw new TraceError('TRACE_BUSY');
|
|
217
|
+
s.result.correlation_ids.push(correlation);
|
|
218
|
+
}
|
|
219
|
+
s.linked.add(correlation);
|
|
220
|
+
let finished;
|
|
221
|
+
return () => finished ??= (async () => {
|
|
222
|
+
let active = false;
|
|
223
|
+
try {
|
|
224
|
+
active = (await this.control(id, 'status')).state === 'recording';
|
|
225
|
+
}
|
|
226
|
+
catch { /* unknown is partial */ }
|
|
227
|
+
s.linked.delete(correlation);
|
|
228
|
+
return { trace_id: id, coverage: !active || s.partial.has(correlation) ? 'partial' : 'covered', clock_alignment: 'unknown' };
|
|
229
|
+
})();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async shutdown() {
|
|
233
|
+
this.closing = true;
|
|
234
|
+
this.native.shutdown();
|
|
235
|
+
const active = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete');
|
|
236
|
+
// Signal immediately even when an in-flight start/validation owns the serial queue.
|
|
237
|
+
for (const s of active)
|
|
238
|
+
s.recording?.recorder.signal('SIGINT');
|
|
239
|
+
let timer;
|
|
240
|
+
await Promise.race([Promise.allSettled(active.map(s => this.control(s.result.trace_id, 'cancel', 8000))),
|
|
241
|
+
new Promise(resolve => { timer = setTimeout(resolve, 9000); })]);
|
|
242
|
+
if (timer)
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
return active.filter(s => s.result.cleanup.status !== 'complete').map(s => this.snapshot(s));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unknownDeviceReadiness, observeDeviceReadiness } from './device-readiness.js';
|
|
1
2
|
import { pausedReadiness } from '../debugger/guard.js';
|
|
2
3
|
import { resolveTargetDevice, withResolvedDevice } from '../utils/resolve-device.js';
|
|
3
4
|
import { remainingBudget, runWithBudget } from '../utils/operation-budget.js';
|
|
@@ -28,10 +29,14 @@ export async function readinessDelay(ms) {
|
|
|
28
29
|
await new Promise(resolve => setTimeout(resolve, Math.min(ms, remainingBudget(ms))));
|
|
29
30
|
remainingBudget();
|
|
30
31
|
}
|
|
32
|
+
function observationComplete(tracker, wait, count) {
|
|
33
|
+
return tracker.ready || (!wait && (tracker.summary.state === 'loading' || count === 2));
|
|
34
|
+
}
|
|
31
35
|
export async function getAppReadiness(args, wait = false) {
|
|
32
36
|
const started = performance.now();
|
|
33
37
|
let device = null;
|
|
34
38
|
let tracker = new ReadinessTracker(args.platform ?? 'android', args.deviceId ?? '');
|
|
39
|
+
tracker.deviceReadiness = unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE');
|
|
35
40
|
let failureCode;
|
|
36
41
|
let debugPause = null;
|
|
37
42
|
await runWithBudget(args.timeoutMs ?? (wait ? 30000 : 15000), async () => {
|
|
@@ -43,11 +48,19 @@ export async function getAppReadiness(args, wait = false) {
|
|
|
43
48
|
let count = 0;
|
|
44
49
|
while (wait || count < 2) {
|
|
45
50
|
remainingBudget();
|
|
46
|
-
debugPause = device.
|
|
51
|
+
debugPause = pausedReadiness(device.id, device.platform);
|
|
47
52
|
if (debugPause) {
|
|
53
|
+
tracker.deviceReadiness = await observeDeviceReadiness(device);
|
|
48
54
|
failureCode = wait ? 'DEBUGGER_PAUSED' : undefined;
|
|
49
55
|
return;
|
|
50
56
|
}
|
|
57
|
+
await tracker.observeDevice();
|
|
58
|
+
if (tracker.deviceReadiness.input_ready === false) {
|
|
59
|
+
if (!wait)
|
|
60
|
+
return;
|
|
61
|
+
await readinessDelay(args.pollIntervalMs ?? 500);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
51
64
|
const captureStarted = Date.now();
|
|
52
65
|
try {
|
|
53
66
|
const tree = await ToolsObserve.getUITreeHandler({ platform: device.platform, deviceId: device.id });
|
|
@@ -68,9 +81,7 @@ export async function getAppReadiness(args, wait = false) {
|
|
|
68
81
|
}
|
|
69
82
|
}
|
|
70
83
|
count++;
|
|
71
|
-
if (tracker
|
|
72
|
-
return;
|
|
73
|
-
if (!wait && count === 2)
|
|
84
|
+
if (observationComplete(tracker, wait, count))
|
|
74
85
|
return;
|
|
75
86
|
await readinessDelay(wait ? args.pollIntervalMs ?? 500 : args.stabilityMs ?? 500);
|
|
76
87
|
}
|
|
@@ -86,17 +97,18 @@ export async function getAppReadiness(args, wait = false) {
|
|
|
86
97
|
}
|
|
87
98
|
});
|
|
88
99
|
if (debugPause)
|
|
89
|
-
return { success: !wait, device, readiness: debugPause, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) };
|
|
100
|
+
return { success: !wait, device, readiness: debugPause, device_readiness: tracker.deviceReadiness, elapsed_ms: performance.now() - started, ...(wait ? { failure_code: 'DEBUGGER_PAUSED' } : {}) };
|
|
90
101
|
const state = tracker.summary.state;
|
|
91
|
-
const success = !failureCode && (wait ?
|
|
102
|
+
const success = !failureCode && (wait ? tracker.ready : state !== 'unknown' || tracker.deviceReadiness.input_ready === false);
|
|
92
103
|
return {
|
|
93
|
-
success, device, readiness: tracker.summary, elapsed_ms: performance.now() - started,
|
|
104
|
+
success, device, readiness: tracker.summary, device_readiness: tracker.deviceReadiness, elapsed_ms: performance.now() - started,
|
|
94
105
|
...(!success ? { failure_code: failureCode ?? (wait ? 'READINESS_TIMEOUT' : 'READINESS_UNAVAILABLE') } : {})
|
|
95
106
|
};
|
|
96
107
|
}
|
|
97
108
|
export async function withReadyWait(target, timeoutMs, operation, failure) {
|
|
98
109
|
const started = performance.now();
|
|
99
110
|
let tracker = new ReadinessTracker(target.platform ?? 'android', target.deviceId ?? '');
|
|
111
|
+
tracker.deviceReadiness = unknownDeviceReadiness('DEVICE_TARGET_UNAVAILABLE');
|
|
100
112
|
let resolved = false;
|
|
101
113
|
return runWithBudget(timeoutMs, async () => {
|
|
102
114
|
try {
|
|
@@ -110,9 +122,9 @@ export async function withReadyWait(target, timeoutMs, operation, failure) {
|
|
|
110
122
|
}
|
|
111
123
|
catch {
|
|
112
124
|
const alreadyFailed = result && (('status' in result && result.status !== 'success') || ('success' in result && result.success === false));
|
|
113
|
-
return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline() };
|
|
125
|
+
return { ...(alreadyFailed ? result : failure('TIMEOUT', performance.now() - started, tracker.summary.sample_count)), readiness: tracker.deadline(), device_readiness: tracker.deviceReadiness };
|
|
114
126
|
}
|
|
115
|
-
return { ...result, readiness: tracker.summary };
|
|
127
|
+
return { ...result, readiness: tracker.summary, device_readiness: tracker.deviceReadiness };
|
|
116
128
|
}
|
|
117
129
|
catch (error) {
|
|
118
130
|
const expired = budgetExpired(error);
|
|
@@ -120,7 +132,7 @@ export async function withReadyWait(target, timeoutMs, operation, failure) {
|
|
|
120
132
|
tracker.deadline();
|
|
121
133
|
else
|
|
122
134
|
tracker.unavailable();
|
|
123
|
-
return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary };
|
|
135
|
+
return { ...failure(!expired && (!resolved || isDeviceUnavailable(error)) ? 'DEVICE_UNAVAILABLE' : 'TIMEOUT', performance.now() - started, tracker.summary.sample_count), readiness: tracker.summary, device_readiness: tracker.deviceReadiness };
|
|
124
136
|
}
|
|
125
137
|
});
|
|
126
138
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unknownDeviceReadiness, observeDeviceReadiness, finishDeviceReadiness } from './device-readiness.js';
|
|
1
2
|
import { pausedReadiness } from '../debugger/guard.js';
|
|
2
3
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
4
|
import { remainingBudget } from '../utils/operation-budget.js';
|
|
@@ -7,10 +8,38 @@ const capabilities = { loading: true, transitioning: true, settled: true, unresp
|
|
|
7
8
|
export function isDeviceUnavailable(error) {
|
|
8
9
|
return /device.*(?:not found|offline|disconnected)|no devices found|READINESS_DEVICE_UNAVAILABLE/i.test(String(error));
|
|
9
10
|
}
|
|
11
|
+
function retainBlockedEvidence(previous, next) {
|
|
12
|
+
const retained = [];
|
|
13
|
+
if (['unknown', 'unsupported'].includes(next.wakefulness) && ['asleep', 'transitioning'].includes(previous.wakefulness)) {
|
|
14
|
+
next.wakefulness = previous.wakefulness;
|
|
15
|
+
retained.push('DEVICE_ASLEEP', 'DEVICE_TRANSITIONING');
|
|
16
|
+
}
|
|
17
|
+
if (['unknown', 'unsupported'].includes(next.lock_state) && previous.lock_state === 'locked') {
|
|
18
|
+
next.lock_state = previous.lock_state;
|
|
19
|
+
retained.push('DEVICE_LOCKED');
|
|
20
|
+
}
|
|
21
|
+
if (retained.length) {
|
|
22
|
+
next.evidence = [...previous.evidence.filter(item => retained.includes(item.code)), ...next.evidence].slice(-8);
|
|
23
|
+
next.limitations = [...new Set([...next.limitations, 'DEVICE_BLOCKER_EVIDENCE_RETAINED'])];
|
|
24
|
+
}
|
|
25
|
+
return finishDeviceReadiness(next);
|
|
26
|
+
}
|
|
10
27
|
export class ReadinessTracker {
|
|
11
28
|
platform;
|
|
12
29
|
deviceId;
|
|
13
30
|
stabilityMs;
|
|
31
|
+
deviceReadiness = unknownDeviceReadiness();
|
|
32
|
+
get ready() { return this.summary.state === 'settled' && this.deviceReadiness.input_ready !== false; }
|
|
33
|
+
async observeDevice() {
|
|
34
|
+
const next = await observeDeviceReadiness({ platform: this.platform, id: this.deviceId });
|
|
35
|
+
this.deviceReadiness = retainBlockedEvidence(this.deviceReadiness, next);
|
|
36
|
+
if (this.deviceReadiness.input_ready === false) {
|
|
37
|
+
this.signature = null;
|
|
38
|
+
this.stableSince = 0;
|
|
39
|
+
this.summary = { ...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
|
|
40
|
+
limitations: [...new Set([...this.summary.limitations, 'DEVICE_BLOCKED'])] };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
14
43
|
signature = null;
|
|
15
44
|
stableSince = 0;
|
|
16
45
|
summary;
|
|
@@ -21,14 +50,14 @@ export class ReadinessTracker {
|
|
|
21
50
|
this.summary = {
|
|
22
51
|
state: 'unknown', scope: 'device_ui', basis: 'insufficient', captured_at_ms: null,
|
|
23
52
|
sample_count: 0, stable_for_ms: 0, snapshot_session_id: null, snapshot_revision: null,
|
|
24
|
-
evidence: [], limitations: [...baselineLimitations, this.platform
|
|
53
|
+
evidence: [], limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', 'INSUFFICIENT_SAMPLES'], capabilities: { ...capabilities, debugger_paused: ['android', 'ios'].includes(this.platform) }
|
|
25
54
|
};
|
|
26
55
|
}
|
|
27
56
|
unavailable(code = 'OBSERVATION_FAILED') {
|
|
28
57
|
this.signature = null;
|
|
29
58
|
this.summary = {
|
|
30
59
|
...this.summary, state: 'unknown', basis: 'insufficient', stable_for_ms: 0,
|
|
31
|
-
sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, this.platform
|
|
60
|
+
sample_count: this.summary.sample_count + 1, limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', code],
|
|
32
61
|
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: null, snapshot_revision: null }].slice(-8)
|
|
33
62
|
};
|
|
34
63
|
return this.summary;
|
|
@@ -38,7 +67,7 @@ export class ReadinessTracker {
|
|
|
38
67
|
return this.summary;
|
|
39
68
|
}
|
|
40
69
|
observe(sample, acquisitionStartedAtMs, now = performance.now()) {
|
|
41
|
-
const paused = this.
|
|
70
|
+
const paused = pausedReadiness(this.deviceId, this.platform);
|
|
42
71
|
if (paused) {
|
|
43
72
|
this.summary = paused;
|
|
44
73
|
return this.summary;
|
|
@@ -66,7 +95,7 @@ export class ReadinessTracker {
|
|
|
66
95
|
captured_at_ms: tree.captured_at_ms, snapshot_session_id: tree.snapshot_session_id, snapshot_revision: tree.snapshot_revision,
|
|
67
96
|
sample_count: this.summary.sample_count + 1, stable_for_ms: stableFor,
|
|
68
97
|
evidence: [...this.summary.evidence, { code, source: 'ui_tree', captured_at_ms: tree.captured_at_ms, snapshot_revision: tree.snapshot_revision }].slice(-8),
|
|
69
|
-
limitations: [...baselineLimitations, this.platform
|
|
98
|
+
limitations: [...baselineLimitations, ['android', 'ios'].includes(this.platform) ? 'MANAGED_DEBUGGER_ONLY' : 'DEBUGGER_SIGNAL_UNSUPPORTED', ...(state === 'unknown' ? ['INSUFFICIENT_SAMPLES'] : [])], capabilities: { ...capabilities, debugger_paused: ['android', 'ios'].includes(this.platform) }
|
|
70
99
|
};
|
|
71
100
|
return this.summary;
|
|
72
101
|
}
|
|
@@ -105,3 +134,4 @@ function readinessState(loading, previous, unchanged, stableFor, stabilityMs) {
|
|
|
105
134
|
function readinessCode(nativeLoading, textLoading, state) {
|
|
106
135
|
return nativeLoading ? 'NATIVE_LOADING_INDICATOR' : textLoading ? 'LOADING_LABEL' : state === 'transitioning' ? 'SNAPSHOT_CHANGED' : state === 'settled' ? 'SNAPSHOT_STABLE' : 'INSUFFICIENT_SAMPLES';
|
|
107
136
|
}
|
|
137
|
+
export async function observeReadinessDeviceWait() { await waits.getStore()?.observeDevice(); }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { benchmarkStage } from '../utils/benchmark-metrics.js';
|
|
1
2
|
async function convert(buffer, platform) {
|
|
2
3
|
const { default: sharp } = await import('sharp');
|
|
3
4
|
const image = sharp(buffer);
|
|
@@ -22,6 +23,9 @@ async function convert(buffer, platform) {
|
|
|
22
23
|
return { hasAlpha, webp, jpeg };
|
|
23
24
|
}
|
|
24
25
|
export async function encodeScreenshot(buffer, platform) {
|
|
26
|
+
return benchmarkStage('screenshot_encoding_ms', () => encode(buffer, platform));
|
|
27
|
+
}
|
|
28
|
+
async function encode(buffer, platform) {
|
|
25
29
|
const png = buffer.toString('base64');
|
|
26
30
|
try {
|
|
27
31
|
const { hasAlpha, webp, jpeg } = await convert(buffer, platform);
|