mobile-debug-mcp 0.34.0 → 0.36.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/.agents/skills/mcp-builder/SKILL.md +44 -64
- package/.agents/skills/mcp-builder/references/build-flags.md +44 -43
- package/.agents/skills/mcp-builder/references/diagnostics-schema.md +4 -1
- package/.agents/skills/mcp-builder/references/toolchain-details.md +49 -62
- package/.agents/skills/spec-review/references/spec-review-checklist.md +7 -0
- package/.agents/skills/test-authoring/SKILL.md +7 -6
- package/.agents/skills/test-authoring/references/repo-test-layout.md +3 -1
- package/.agents/skills/test-authoring/references/test-authoring-workflow.md +16 -7
- package/.agents/skills/test-authoring/references/test-quality-checklist.md +2 -1
- package/.cursor-plugin/marketplace.json +1 -1
- package/.github/copilot-instructions.md +14 -36
- package/.github/workflows/ci.yml +4 -4
- package/AGENTS.md +12 -3
- package/CONTRIBUTING.md +79 -0
- package/README.md +218 -129
- 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 +50 -11
- 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 +253 -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 +62 -17
- package/dist/contracts/execution-controls.js +24 -0
- package/dist/contracts/selector.js +8 -0
- package/dist/contracts/target.js +15 -0
- package/dist/contracts/ui.js +1 -0
- package/dist/debugger/android.js +2 -1
- package/dist/debugger/diagnostics.js +54 -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/protocol.js +33 -0
- package/dist/debugger/sessions.js +133 -74
- package/dist/debugger/worker.js +59 -46
- package/dist/interact/adjustment/adjustment-context.js +41 -0
- package/dist/interact/adjustment/adjustment-gestures.js +55 -0
- package/dist/interact/adjustment/adjustment-plan.js +18 -0
- package/dist/interact/adjustment/adjustment-resolution.js +127 -0
- package/dist/interact/adjustment/adjustment-result.js +19 -0
- package/dist/interact/adjustment/adjustment-semantic.js +64 -0
- package/dist/interact/adjustment/adjustment-verification.js +35 -0
- package/dist/interact/adjustment/adjustment.js +52 -0
- package/dist/interact/adjustment/control-geometry.js +87 -0
- package/dist/interact/adjustment/control-values.js +136 -0
- package/dist/interact/adjustment/slider-labels.js +84 -0
- package/dist/interact/assertions/screen-assertion.js +37 -0
- package/dist/interact/assertions/state-assertion-context.js +35 -0
- package/dist/interact/assertions/state-assertion.js +76 -0
- package/dist/interact/assertions/state-comparison.js +141 -0
- package/dist/interact/assertions/visibility-assertion.js +34 -0
- package/dist/interact/conditional-action.js +12 -7
- package/dist/interact/index.js +77 -3092
- package/dist/interact/input-service.js +1 -0
- package/dist/interact/interaction-trace.js +21 -0
- package/dist/interact/model.js +1 -0
- package/dist/interact/observation.js +1 -0
- package/dist/interact/targeting/candidate-ancestor.js +58 -0
- package/dist/interact/targeting/candidate-result.js +23 -0
- package/dist/interact/targeting/candidate-scoring.js +50 -0
- package/dist/interact/targeting/element-helpers.js +216 -0
- package/dist/interact/targeting/find-element.js +65 -0
- package/dist/interact/targeting/nearby-control.js +56 -0
- package/dist/interact/targeting/resolved-elements.js +21 -0
- package/dist/interact/targeting/tap-target.js +17 -0
- package/dist/interact/targeting/target-scope.js +104 -0
- package/dist/interact/waits/legacy-log-wait.js +38 -0
- package/dist/interact/waits/legacy-wait-observations.js +103 -0
- package/dist/interact/waits/legacy-wait-state.js +34 -0
- package/dist/interact/waits/legacy-wait.js +48 -0
- package/dist/interact/waits/screen-wait.js +49 -0
- package/dist/interact/waits/ui-change-helpers.js +157 -0
- package/dist/interact/waits/ui-change-state.js +18 -0
- package/dist/interact/waits/ui-change-wait.js +77 -0
- package/dist/interact/waits/ui-wait-condition.js +56 -0
- package/dist/interact/waits/ui-wait-state.js +38 -0
- package/dist/interact/waits/ui-wait.js +106 -0
- package/dist/manage/android.js +55 -51
- package/dist/manage/index.js +38 -75
- package/dist/manage/ios/build-types.js +1 -0
- package/dist/manage/ios/build.js +210 -0
- package/dist/manage/ios/project-discovery.js +29 -0
- package/dist/manage/ios/scheme.js +18 -0
- package/dist/manage/ios.js +44 -256
- package/dist/observe/android/context.js +1 -0
- package/dist/observe/android/current-screen.js +55 -0
- package/dist/observe/android/fingerprint.js +13 -0
- package/dist/observe/android/log-stream.js +132 -0
- package/dist/observe/android/logs.js +107 -0
- package/dist/observe/android/screenshot.js +70 -0
- package/dist/observe/android/ui-tree.js +107 -0
- package/dist/observe/android.js +16 -520
- package/dist/observe/evidence.js +114 -75
- package/dist/observe/index.js +16 -263
- package/dist/observe/ios/context.js +16 -0
- package/dist/observe/ios/fingerprint.js +12 -0
- package/dist/observe/ios/log-stream.js +105 -0
- package/dist/observe/ios/logs.js +168 -0
- package/dist/observe/ios/screenshot.js +22 -0
- package/dist/observe/ios/ui-elements.js +241 -0
- package/dist/observe/ios/ui-tree.js +137 -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 +244 -0
- package/dist/observe/ios.js +17 -715
- package/dist/observe/logs/model.js +1 -0
- package/dist/{network → observe/network}/index.js +2 -2
- package/dist/observe/readiness-service.js +5 -3
- package/dist/observe/readiness.js +33 -14
- package/dist/observe/screenshot-encoding.js +41 -0
- package/dist/observe/snapshot-collection.js +79 -0
- package/dist/observe/snapshot-logs.js +30 -0
- package/dist/observe/snapshot-metadata.js +43 -31
- package/dist/observe/snapshot-semantic.js +115 -0
- package/dist/observe/system-trace-error.js +7 -0
- package/dist/observe/system-trace-router.js +81 -0
- package/dist/observe/system-trace.js +26 -21
- package/dist/runtime/action-results.js +270 -0
- package/dist/runtime/clock.js +4 -0
- package/dist/server/common.js +1 -256
- package/dist/server/definitions/assert.js +162 -0
- package/dist/server/definitions/diagnostic.js +118 -0
- package/dist/server/definitions/input.js +274 -0
- package/dist/server/definitions/manage.js +194 -0
- package/dist/server/definitions/observe.js +149 -0
- package/dist/server/definitions/target.js +138 -0
- package/dist/server/definitions/wait.js +105 -0
- package/dist/server/handlers/action-execution.js +163 -0
- package/dist/server/handlers/correlation.js +23 -0
- package/dist/server/handlers/debugger-handlers.js +67 -0
- package/dist/server/handlers/debugger-response.js +9 -0
- package/dist/server/handlers/diagnostic.js +77 -0
- package/dist/server/handlers/handler-types.js +1 -0
- package/dist/server/handlers/input.js +154 -0
- package/dist/server/handlers/interact.js +113 -0
- package/dist/server/handlers/journey-assertions.js +35 -0
- package/dist/server/handlers/journey.js +147 -0
- package/dist/server/handlers/manage.js +119 -0
- package/dist/server/handlers/observe.js +95 -0
- package/dist/server/handlers/payload.js +4 -0
- package/dist/server/handlers/tool-handlers.js +170 -0
- package/dist/server/schemas/condition-schema.js +2 -0
- package/dist/server/{debugger-schema.js → schemas/debugger-schema.js} +20 -12
- package/dist/server/{journey-schema.js → schemas/journey-schema.js} +7 -22
- package/dist/server/schemas/system-trace-schema.js +22 -0
- package/dist/server/tool-definitions.js +58 -1135
- package/dist/server-core.js +2 -2
- package/dist/server.js +4 -4
- package/dist/system/android-status.js +101 -0
- package/dist/system/android.js +37 -27
- package/dist/system/index.js +13 -439
- package/dist/system/ios-status.js +132 -0
- package/dist/system/ios.js +11 -8
- package/dist/system/model.js +1 -0
- package/dist/system/probes.js +105 -0
- package/dist/system/status-summary.js +43 -0
- package/dist/system/unsupported-status.js +52 -0
- package/dist/utils/android/commands.js +89 -0
- package/dist/utils/android/gradle.js +139 -0
- package/dist/utils/android/logs.js +103 -0
- package/dist/utils/android/ui-tree.js +233 -0
- package/dist/utils/android/utils.js +6 -564
- package/dist/utils/cli/idb/idb-helper.js +4 -6
- package/dist/utils/cli/idb/install-idb.js +13 -9
- package/dist/utils/cli/ios/preflight-ios.js +42 -38
- package/dist/utils/ios/utils.js +48 -46
- package/dist/utils/java.js +65 -49
- package/dist/utils/resolve-device.js +4 -2
- package/dist/utils/ui/index.js +26 -26
- package/docs/CHANGELOG.md +67 -1
- package/docs/README.md +38 -0
- package/docs/agent-plugin-installation-verification.md +39 -0
- package/docs/benchmarks/README.md +18 -0
- package/docs/benchmarks/interaction-latency/README.md +14 -0
- package/docs/benchmarks/interaction-latency/final.md +4 -1
- package/docs/benchmarks/interaction-latency/ios-validation.md +2 -0
- package/docs/benchmarks/ui-tree-performance/README.md +24 -16
- package/docs/benchmarks/ui-tree-performance/comparison.md +11 -10
- package/docs/code-structure.md +120 -0
- package/docs/specs/001-state-verification/spec.md +16 -14
- package/docs/specs/002-richer-element-identity/spec.md +12 -10
- package/docs/specs/003-wait-and-synchronization-reliability/spec.md +12 -10
- package/docs/specs/004-action-verification-routing/spec.md +3 -3
- package/docs/specs/005-unified-action-execution-and-verification-model/spec.md +2 -0
- package/docs/specs/006-runtime-action-instrumentation-and-binding-layer/spec.md +2 -0
- package/docs/specs/007-actionability-resolution-and-executable-target-selection/spec.md +3 -1
- package/docs/specs/008-adjustable-control-support-and-semantic-value-manipulation/spec.md +3 -4
- package/docs/specs/009-semantic-control-modeling-for-custom-and-composite-controls/spec.md +2 -0
- package/docs/specs/010-verification-stabilization-and-temporal-convergence/spec.md +2 -2
- package/docs/specs/011-recovery-and-replanning-for-failed-or-ambiguous-interaction-flows/spec.md +2 -2
- package/docs/specs/011.1-recovery-contract-types-and-runtime-wiring-spec/spec.md +2 -0
- package/docs/specs/012-action-trace-and-xecution-observability/spec.md +2 -3
- package/docs/specs/013-wait-and-synchronization-reliability/spec.md +19 -19
- package/docs/specs/014-actionability-resolution/spec.md +20 -18
- package/docs/specs/015-adjustable-control-support/spec.md +17 -15
- package/docs/specs/016-environment-tools/spec.md +20 -20
- package/docs/specs/018-interaction-latency-and-batched-journeys/spec.md +2 -0
- package/docs/specs/019-ui-tree-observation-performance/spec.md +3 -1
- package/docs/specs/020-conditional-actions/intent.md +5 -3
- package/docs/specs/020-conditional-actions/plan.md +8 -9
- package/docs/specs/020-conditional-actions/spec.md +4 -2
- package/docs/specs/021-app-readiness-signals/intent.md +4 -2
- package/docs/specs/021-app-readiness-signals/plan.md +7 -6
- package/docs/specs/021-app-readiness-signals/spec.md +3 -1
- package/docs/specs/022-correlated-evidence/intent.md +4 -4
- package/docs/specs/022-correlated-evidence/plan.md +10 -11
- package/docs/specs/022-correlated-evidence/spec.md +3 -1
- package/docs/specs/023-android-system-traces/intent.md +3 -2
- package/docs/specs/023-android-system-traces/plan.md +5 -7
- package/docs/specs/023-android-system-traces/spec.md +3 -1
- package/docs/specs/024-android-heap-dump-capture/intent.md +3 -1
- package/docs/specs/025-android-runtime-debugger/intent.md +4 -3
- package/docs/specs/025-android-runtime-debugger/plan.md +3 -1
- package/docs/specs/025-android-runtime-debugger/spec.md +4 -3
- package/docs/specs/026-code-structure-and-quality-gates/health-plan.md +88 -0
- package/docs/specs/026-code-structure-and-quality-gates/intent.md +47 -0
- package/docs/specs/026-code-structure-and-quality-gates/plan.md +191 -0
- package/docs/specs/026-code-structure-and-quality-gates/spec.md +138 -0
- package/docs/specs/026-code-structure-and-quality-gates/verification-final.json +200 -0
- package/docs/specs/026-code-structure-and-quality-gates/verification-thin-facade.json +154 -0
- package/docs/specs/026-code-structure-and-quality-gates/verification.json +136 -0
- package/docs/specs/027-debugger-failure-diagnostics/intent.md +43 -0
- package/docs/specs/027-debugger-failure-diagnostics/plan.md +86 -0
- package/docs/specs/027-debugger-failure-diagnostics/spec.md +133 -0
- package/docs/specs/028-compact-debugger-responses/intent.md +41 -0
- package/docs/specs/028-compact-debugger-responses/plan.md +112 -0
- package/docs/specs/028-compact-debugger-responses/spec.md +108 -0
- package/docs/specs/029-kotlin-locals-validation/report.md +119 -0
- package/docs/specs/030-android-coroutine-validation/evidence.json +475 -0
- package/docs/specs/030-android-coroutine-validation/intent.md +47 -0
- package/docs/specs/030-android-coroutine-validation/plan.md +147 -0
- package/docs/specs/030-android-coroutine-validation/report.md +240 -0
- package/docs/specs/030-android-coroutine-validation/spec.md +107 -0
- 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 +138 -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/tools/TOOLS.md +31 -18
- package/docs/tools/{evidence.md → correlated-evidence.md} +9 -1
- package/docs/tools/debugger.md +120 -4
- package/docs/tools/interact.md +42 -20
- package/docs/tools/manage.md +25 -44
- package/docs/tools/observe.md +55 -23
- package/docs/tools/system-traces.md +89 -14
- package/docs/tools/system.md +12 -5
- package/package.json +12 -8
- package/scripts/agent-plugin.mjs +55 -5
- package/scripts/architecture-graph.mjs +69 -0
- package/scripts/check-architecture.mjs +47 -0
- package/scripts/code-quality-baseline.json +1 -0
- package/scripts/code-quality.mjs +161 -0
- package/src/contracts/execution-controls.ts +26 -0
- package/src/contracts/selector.ts +17 -0
- package/src/contracts/target.ts +19 -0
- package/src/contracts/ui.ts +94 -0
- package/src/debugger/android.ts +3 -2
- package/src/debugger/diagnostics.ts +43 -0
- package/src/debugger/guard.ts +10 -5
- package/src/debugger/ios-worker.ts +49 -0
- package/src/debugger/ios.ts +45 -0
- package/{worker → src/debugger/java}/DebuggerWorker.java +9 -3
- 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/protocol.ts +37 -0
- package/src/debugger/sessions.ts +326 -155
- package/src/debugger/worker.ts +57 -41
- package/src/interact/adjustment/adjustment-context.ts +83 -0
- package/src/interact/adjustment/adjustment-gestures.ts +48 -0
- package/src/interact/adjustment/adjustment-plan.ts +23 -0
- package/src/interact/adjustment/adjustment-resolution.ts +104 -0
- package/src/interact/adjustment/adjustment-result.ts +34 -0
- package/src/interact/adjustment/adjustment-semantic.ts +53 -0
- package/src/interact/adjustment/adjustment-verification.ts +60 -0
- package/src/interact/adjustment/adjustment.ts +49 -0
- package/src/interact/adjustment/control-geometry.ts +120 -0
- package/src/interact/adjustment/control-values.ts +141 -0
- package/src/interact/adjustment/slider-labels.ts +84 -0
- package/src/interact/assertions/screen-assertion.ts +39 -0
- package/src/interact/assertions/state-assertion-context.ts +48 -0
- package/src/interact/assertions/state-assertion.ts +64 -0
- package/src/interact/assertions/state-comparison.ts +168 -0
- package/src/interact/assertions/visibility-assertion.ts +41 -0
- package/src/interact/conditional-action.ts +18 -12
- package/src/interact/index.ts +89 -3494
- package/src/interact/input-service.ts +11 -0
- package/src/interact/interaction-trace.ts +36 -0
- package/src/interact/model.ts +109 -0
- package/src/interact/observation.ts +7 -0
- package/src/interact/targeting/candidate-ancestor.ts +49 -0
- package/src/interact/targeting/candidate-result.ts +24 -0
- package/src/interact/targeting/candidate-scoring.ts +42 -0
- package/src/interact/targeting/element-helpers.ts +243 -0
- package/src/interact/targeting/find-element.ts +47 -0
- package/src/interact/targeting/nearby-control.ts +46 -0
- package/src/interact/targeting/resolved-elements.ts +20 -0
- package/src/interact/targeting/tap-target.ts +22 -0
- package/src/interact/targeting/target-scope.ts +121 -0
- package/src/interact/waits/legacy-log-wait.ts +30 -0
- package/src/interact/waits/legacy-wait-observations.ts +55 -0
- package/src/interact/waits/legacy-wait-state.ts +48 -0
- package/src/interact/waits/legacy-wait.ts +34 -0
- package/src/interact/waits/screen-wait.ts +37 -0
- package/src/interact/waits/ui-change-helpers.ts +184 -0
- package/src/interact/waits/ui-change-state.ts +26 -0
- package/src/interact/waits/ui-change-wait.ts +59 -0
- package/src/interact/waits/ui-wait-condition.ts +52 -0
- package/src/interact/waits/ui-wait-state.ts +55 -0
- package/src/interact/waits/ui-wait.ts +84 -0
- package/src/manage/android.ts +56 -50
- package/src/manage/index.ts +63 -93
- package/src/manage/ios/build-types.ts +14 -0
- package/src/manage/ios/build.ts +230 -0
- package/src/manage/ios/project-discovery.ts +26 -0
- package/src/manage/ios/scheme.ts +16 -0
- package/src/manage/ios.ts +67 -298
- package/src/observe/android/context.ts +1 -0
- package/src/observe/android/current-screen.ts +63 -0
- package/src/observe/android/fingerprint.ts +13 -0
- package/src/observe/android/log-stream.ts +127 -0
- package/src/observe/android/logs.ts +110 -0
- package/src/observe/android/screenshot.ts +79 -0
- package/src/observe/android/ui-tree.ts +113 -0
- package/src/observe/android.ts +17 -531
- package/src/observe/evidence.ts +122 -70
- package/src/observe/index.ts +24 -286
- package/src/observe/ios/context.ts +22 -0
- package/src/observe/ios/fingerprint.ts +12 -0
- package/src/observe/ios/log-stream.ts +103 -0
- package/src/observe/ios/logs.ts +179 -0
- package/src/observe/ios/screenshot.ts +26 -0
- package/src/observe/ios/ui-elements.ts +266 -0
- package/src/observe/ios/ui-tree.ts +143 -0
- 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 +204 -0
- package/src/observe/ios.ts +18 -749
- package/src/observe/logs/model.ts +11 -0
- package/src/{network → observe/network}/index.ts +2 -2
- package/src/observe/readiness-service.ts +6 -4
- package/src/observe/readiness.ts +42 -21
- package/src/observe/screenshot-encoding.ts +32 -0
- package/src/observe/snapshot-collection.ts +69 -0
- package/src/observe/snapshot-logs.ts +26 -0
- package/src/observe/snapshot-metadata.ts +51 -31
- package/src/observe/snapshot-semantic.ts +153 -0
- package/src/observe/system-trace-error.ts +3 -0
- package/src/observe/system-trace-router.ts +61 -0
- package/src/observe/system-trace.ts +28 -19
- package/src/runtime/action-results.ts +376 -0
- package/src/runtime/clock.ts +9 -0
- package/src/server/common.ts +9 -359
- package/src/server/definitions/assert.ts +164 -0
- package/src/server/definitions/diagnostic.ts +120 -0
- package/src/server/definitions/input.ts +276 -0
- package/src/server/definitions/manage.ts +196 -0
- package/src/server/definitions/observe.ts +151 -0
- package/src/server/definitions/target.ts +140 -0
- package/src/server/definitions/wait.ts +107 -0
- package/src/server/handlers/action-execution.ts +164 -0
- package/src/server/handlers/correlation.ts +20 -0
- package/src/server/handlers/debugger-handlers.ts +58 -0
- package/src/server/handlers/debugger-response.ts +7 -0
- package/src/server/handlers/diagnostic.ts +86 -0
- package/src/server/handlers/handler-types.ts +7 -0
- package/src/server/handlers/input.ts +173 -0
- package/src/server/handlers/interact.ts +135 -0
- package/src/server/handlers/journey-assertions.ts +39 -0
- package/src/server/handlers/journey.ts +132 -0
- package/src/server/handlers/manage.ts +141 -0
- package/src/server/handlers/observe.ts +116 -0
- package/src/server/handlers/payload.ts +36 -0
- package/src/server/handlers/tool-handlers.ts +170 -0
- package/src/server/schemas/condition-schema.ts +2 -0
- package/src/server/schemas/debugger-schema.ts +46 -0
- package/src/server/{journey-schema.ts → schemas/journey-schema.ts} +7 -22
- package/src/server/schemas/system-trace-schema.ts +23 -0
- package/src/server/tool-definitions.ts +67 -1143
- package/src/server-core.ts +2 -2
- package/src/server.ts +4 -4
- package/src/system/android-status.ts +124 -0
- package/src/system/android.ts +33 -21
- package/src/system/index.ts +21 -525
- package/src/system/ios-status.ts +147 -0
- package/src/system/ios.ts +14 -10
- package/src/system/model.ts +41 -0
- package/src/system/probes.ts +114 -0
- package/src/system/status-summary.ts +47 -0
- package/src/system/unsupported-status.ts +55 -0
- package/src/types.ts +16 -106
- package/src/utils/android/commands.ts +81 -0
- package/src/utils/android/gradle.ts +132 -0
- package/src/utils/android/logs.ts +103 -0
- package/src/utils/android/ui-tree.ts +239 -0
- package/src/utils/android/utils.ts +16 -556
- package/src/utils/cli/idb/check-idb.ts +4 -4
- package/src/utils/cli/idb/idb-helper.ts +6 -8
- package/src/utils/cli/idb/install-idb.ts +15 -9
- package/src/utils/cli/ios/preflight-ios.ts +39 -31
- package/src/utils/cli/ios/run-ios-ui-tree-tap.ts +4 -4
- package/src/utils/ios/utils.ts +60 -61
- package/src/utils/java.ts +62 -43
- package/src/utils/resolve-device.ts +9 -6
- package/src/utils/ui/index.ts +28 -28
- package/test/device/automated/debugger/coroutine-lifecycle.ts +27 -0
- package/test/device/automated/debugger/coroutine-support.ts +140 -0
- package/test/device/automated/debugger/coroutine.android.test.ts +169 -0
- package/test/device/automated/debugger/kotlin.android.test.ts +127 -0
- package/test/device/automated/debugger/runtime.android.test.ts +19 -3
- package/test/device/automated/debugger/runtime.ios.test.ts +221 -0
- package/test/device/automated/interact/interaction.android.test.ts +56 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
- package/test/device/automated/observe/system-trace.smoke.ts +1 -1
- package/test/device/manual/interact/modul8_selector_tap.manual.ts +1 -1
- package/test/fixtures/contracts/tool-definitions.json +3696 -0
- package/test/fixtures/coroutine-app/MainActivity.kt +100 -0
- package/test/fixtures/coroutine-app/README.md +48 -0
- package/test/fixtures/coroutine-app/build.ts +35 -0
- package/test/fixtures/debugger-app/MainActivity.java +21 -0
- package/test/fixtures/debugger-app/MainActivity.kt +32 -0
- package/test/fixtures/debugger-app/README.md +52 -0
- package/test/fixtures/debugger-app/build.ts +25 -15
- 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/README.md +2 -0
- package/test/fixtures/readiness-app/README.md +2 -0
- package/test/helpers/ios-trace-shutdown.ts +29 -0
- package/test/unit/debugger/coroutine-lifecycle.test.ts +43 -0
- package/test/unit/debugger/diagnostic-sessions.test.ts +67 -0
- package/test/unit/debugger/diagnostics.test.ts +80 -0
- package/test/unit/debugger/guards.test.ts +4 -0
- package/test/unit/debugger/handle-guards.test.ts +88 -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/debugger/preparation.test.ts +32 -0
- package/test/unit/debugger/protocol.test.ts +15 -0
- package/test/unit/debugger/sessions.test.ts +1 -1
- package/test/unit/interact/adjustment_service.test.ts +42 -0
- package/test/unit/interact/adjustment_verification.test.ts +35 -0
- package/test/unit/interact/clock_dependencies.test.ts +63 -0
- package/test/unit/interact/element_helpers.test.ts +19 -0
- package/test/unit/interact/legacy_wait_service.test.ts +17 -0
- package/test/unit/interact/state_comparison.test.ts +20 -0
- package/test/unit/interact/target_scope.test.ts +35 -0
- package/test/unit/network/get_network_activity.test.ts +1 -1
- package/test/unit/observe/ios-trace-native.test.ts +90 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +131 -0
- package/test/unit/observe/readiness.test.ts +1 -1
- package/test/unit/observe/screenshot-encoding.test.ts +30 -0
- package/test/unit/observe/snapshot-logs.test.ts +14 -0
- 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/quality/architecture.test.ts +60 -0
- package/test/unit/quality/code-quality.test.ts +58 -0
- package/test/unit/quality/interaction_boundaries.test.ts +24 -0
- package/test/unit/server/conditional_actions.test.ts +1 -1
- package/test/unit/server/conditional_schema.test.ts +2 -2
- package/test/unit/server/debugger-response.test.ts +63 -0
- package/test/unit/server/system-trace.test.ts +12 -3
- package/test/unit/server/tool_definitions_parity.test.ts +8 -0
- package/dist/server/debugger-handlers.js +0 -38
- package/dist/server/system-trace-schema.js +0 -17
- package/dist/server/tool-handlers.js +0 -1000
- package/docs/ROADMAP.md +0 -646
- package/docs/benchmarks/ui-tree-performance/bridge-probe.json +0 -29
- package/docs/benchmarks/ui-tree-performance/initial-baseline.json +0 -59560
- package/docs/specs/baseline-spec-v0.md +0 -312
- package/docs/specs/mcp-tooling-spec-v1.md +0 -446
- package/src/server/debugger-handlers.ts +0 -29
- package/src/server/debugger-schema.ts +0 -37
- package/src/server/system-trace-schema.ts +0 -17
- package/src/server/tool-handlers.ts +0 -1008
- /package/dist/{server/condition-schema.js → contracts/condition.js} +0 -0
- /package/dist/server/{readiness-schema.js → schemas/readiness-schema.js} +0 -0
- /package/src/{server/condition-schema.ts → contracts/condition.ts} +0 -0
- /package/src/server/{readiness-schema.ts → schemas/readiness-schema.ts} +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
4
|
+
import { getXcrunCmd } from '../../utils/ios/utils.js';
|
|
5
|
+
import { traceCommand, spawnTrace } from './process.js';
|
|
6
|
+
export const IOS_TRACE_MAX_BYTES = 256 * 1024 * 1024;
|
|
7
|
+
export async function traceSize(root, secure = false) {
|
|
8
|
+
const stat = await fs.lstat(root);
|
|
9
|
+
if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory()))
|
|
10
|
+
throw new Error('UNSAFE_ARTIFACT');
|
|
11
|
+
if (secure)
|
|
12
|
+
await fs.chmod(root, stat.isDirectory() ? 0o700 : 0o600);
|
|
13
|
+
if (stat.isFile())
|
|
14
|
+
return stat.size;
|
|
15
|
+
let size = 0;
|
|
16
|
+
for (const name of await fs.readdir(root)) {
|
|
17
|
+
size += await traceSize(path.join(root, name), secure);
|
|
18
|
+
if (size > IOS_TRACE_MAX_BYTES)
|
|
19
|
+
throw new Error('STORAGE_LIMIT');
|
|
20
|
+
}
|
|
21
|
+
return size;
|
|
22
|
+
}
|
|
23
|
+
export function validateToc(xml, device, app) {
|
|
24
|
+
if (XMLValidator.validate(xml) !== true)
|
|
25
|
+
throw new Error('INVALID_TOC');
|
|
26
|
+
const parsed = new XMLParser({ ignoreAttributes: false }).parse(xml);
|
|
27
|
+
const raw = parsed['trace-toc']?.run;
|
|
28
|
+
if (!raw || Array.isArray(raw))
|
|
29
|
+
throw new Error('INVALID_TOC');
|
|
30
|
+
const target = raw.info?.target;
|
|
31
|
+
if (target?.device?.['@_uuid'] !== device || Number(target.process?.['@_pid']) !== app.pid || raw.info?.summary?.['template-name'] !== 'Time Profiler')
|
|
32
|
+
throw new Error('ARTIFACT_TARGET_MISMATCH');
|
|
33
|
+
const processes = raw.processes?.process;
|
|
34
|
+
if (!(Array.isArray(processes) ? processes : [processes]).some(p => Number(p?.['@_pid']) === app.pid && p?.['@_path'] === app.executable))
|
|
35
|
+
throw new Error('ARTIFACT_APP_MISMATCH');
|
|
36
|
+
const tables = raw.data?.table;
|
|
37
|
+
if (!(Array.isArray(tables) ? tables : [tables]).some(t => t?.['@_schema'] === 'time-sample'))
|
|
38
|
+
throw new Error('SAMPLE_SCHEMA_MISSING');
|
|
39
|
+
}
|
|
40
|
+
export async function validateArtifact(file, device, app, spawn = spawnTrace) {
|
|
41
|
+
const size = await traceSize(file, true);
|
|
42
|
+
if (size <= 0 || size > IOS_TRACE_MAX_BYTES)
|
|
43
|
+
throw new Error('STORAGE_LIMIT');
|
|
44
|
+
const toc = path.join(path.dirname(file), 'toc.xml'), samples = path.join(path.dirname(file), 'samples.xml');
|
|
45
|
+
try {
|
|
46
|
+
await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--toc', '--output', toc], 15000, spawn);
|
|
47
|
+
if ((await fs.stat(toc)).size > 1024 * 1024)
|
|
48
|
+
throw new Error('INVALID_TOC');
|
|
49
|
+
validateToc(await fs.readFile(toc, 'utf8'), device, app);
|
|
50
|
+
await traceCommand(getXcrunCmd(), ['xctrace', 'export', '--input', file, '--xpath', '/trace-toc/run[@number="1"]/data/table[@schema="time-sample"]', '--output', samples], 15000, spawn);
|
|
51
|
+
if ((await fs.stat(samples)).size > IOS_TRACE_MAX_BYTES)
|
|
52
|
+
throw new Error('STORAGE_LIMIT');
|
|
53
|
+
const xml = await fs.readFile(samples, 'utf8');
|
|
54
|
+
if (XMLValidator.validate(xml) !== true || !xml.includes('<trace-query-result>'))
|
|
55
|
+
throw new Error('INVALID_SAMPLES');
|
|
56
|
+
const sample_rows = (xml.match(/<row(?:\s|>)/g) ?? []).length;
|
|
57
|
+
return { path: file, format: 'instruments', kind: (await fs.stat(file)).isDirectory() ? 'directory' : 'file',
|
|
58
|
+
size_bytes: size, validation: { readable: true, schema: 'time-sample', sample_rows } };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await Promise.all([toc, samples].map(p => fs.rm(p, { force: true })));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getXcrunCmd } from '../../utils/ios/utils.js';
|
|
5
|
+
import { traceCommand, spawnTrace } from './process.js';
|
|
6
|
+
import { prepareNotification } from './notification.js';
|
|
7
|
+
export const IOS_TEMPLATES = ['System Trace', 'Time Profiler', 'Animation Hitches', 'Hangs'];
|
|
8
|
+
export async function resolveIosApp(device, appId, spawn = spawnTrace) {
|
|
9
|
+
const run = (args) => traceCommand(getXcrunCmd(), args, 10000, spawn);
|
|
10
|
+
const container = await run(['simctl', 'get_app_container', device, appId, 'app']);
|
|
11
|
+
if (!path.isAbsolute(container) || !container.endsWith('.app'))
|
|
12
|
+
throw new Error('APP_IDENTITY_UNAVAILABLE');
|
|
13
|
+
const output = await run(['simctl', 'spawn', device, 'launchctl', 'list']);
|
|
14
|
+
const ids = output.split('\n').filter(line => line.includes(`UIKitApplication:${appId}[`))
|
|
15
|
+
.map(line => Number(line.trim().split(/\s+/)[0])).filter(pid => Number.isSafeInteger(pid) && pid > 1);
|
|
16
|
+
if (ids.length !== 1)
|
|
17
|
+
throw new Error('APP_NOT_RUNNING');
|
|
18
|
+
const executable = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'comm='], 10000, spawn);
|
|
19
|
+
if (!executable.startsWith(container + '/') || executable.includes('\n'))
|
|
20
|
+
throw new Error('APP_IDENTITY_UNAVAILABLE');
|
|
21
|
+
const started_at = await traceCommand('/bin/ps', ['-p', String(ids[0]), '-o', 'lstart='], 10000, spawn);
|
|
22
|
+
if (!started_at || started_at.includes('\n'))
|
|
23
|
+
throw new Error('APP_IDENTITY_UNAVAILABLE');
|
|
24
|
+
return { app_id: appId, pid: ids[0], executable, started_at };
|
|
25
|
+
}
|
|
26
|
+
export async function iosCapabilities(device, appId, template = 'Time Profiler', spawn = spawnTrace, host = process.platform) {
|
|
27
|
+
const result = {
|
|
28
|
+
supported: false, backend: 'xctrace', device: null, xcode_version: null,
|
|
29
|
+
prerequisites: { host: host === 'darwin' ? 'available' : 'missing', permissions: 'unknown', symbols: 'unknown' },
|
|
30
|
+
reasons: [], recovery: [], discovered_templates: [], templates: [], app: null,
|
|
31
|
+
limitations: ['App attachment may include sensitive system metadata, paths, logs and signposts.', 'Permissions and symbolication are not established by discovery.', 'Physical iOS capture is not supported in the initial matrix.']
|
|
32
|
+
};
|
|
33
|
+
if (host !== 'darwin') {
|
|
34
|
+
result.reasons.push('MACOS_REQUIRED');
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
const probe = async (key, fn, recovery) => {
|
|
38
|
+
try {
|
|
39
|
+
await fn();
|
|
40
|
+
result.prerequisites[key] = 'available';
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
result.prerequisites[key] = 'missing';
|
|
44
|
+
result.reasons.push(error instanceof Error && ['APP_NOT_RUNNING', 'APP_IDENTITY_UNAVAILABLE'].includes(error.message) ? error.message : key.toUpperCase() + '_UNAVAILABLE');
|
|
45
|
+
result.recovery.push(recovery);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
await probe('xcode', async () => { result.xcode_version = await traceCommand(getXcrunCmd(), ['xcodebuild', '-version'], 10000, spawn); }, 'Select a full Xcode installation with xcode-select.');
|
|
49
|
+
await probe('templates', async () => {
|
|
50
|
+
const output = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'templates'], 10000, spawn);
|
|
51
|
+
result.discovered_templates = output.split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('=='));
|
|
52
|
+
}, 'Install/select Xcode with Instruments and run xcrun xctrace list templates.');
|
|
53
|
+
await probe('instruments_targets', async () => {
|
|
54
|
+
const targets = await traceCommand(getXcrunCmd(), ['xctrace', 'list', 'devices'], 15000, spawn);
|
|
55
|
+
if (!targets.includes(`(${device})`))
|
|
56
|
+
throw new Error('TARGET_UNAVAILABLE');
|
|
57
|
+
}, 'Check xcrun xctrace list devices; use a simulator visible to Instruments.');
|
|
58
|
+
await probe('target', async () => {
|
|
59
|
+
const data = JSON.parse(await traceCommand(getXcrunCmd(), ['simctl', 'list', 'devices', '--json'], 10000, spawn));
|
|
60
|
+
const entries = Object.entries(data.devices).flatMap(([runtime, values]) => values.map(value => ({ ...value, runtime })));
|
|
61
|
+
const target = entries.find(d => d.udid === device);
|
|
62
|
+
if (!target || !target.isAvailable || target.state !== 'Booted')
|
|
63
|
+
throw new Error('TARGET_UNAVAILABLE');
|
|
64
|
+
result.device = { platform: 'ios', id: target.udid, simulator: true, model: target.name, osVersion: target.runtime.split('iOS-')[1]?.replaceAll('-', '.') ?? target.runtime };
|
|
65
|
+
}, 'Use an explicit booted simulator UDID. Physical devices and the Mac host are unsupported.');
|
|
66
|
+
await probe('notification_helper', async () => {
|
|
67
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-trace-probe-'));
|
|
68
|
+
try {
|
|
69
|
+
await prepareNotification(directory, spawn);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}, 'Allow Xcode clang to compile the local Darwin-notification listener.');
|
|
75
|
+
if (appId && result.device)
|
|
76
|
+
await probe('app', async () => { result.app = await resolveIosApp(device, appId, spawn); }, 'Start the requested development app on the selected simulator before tracing.');
|
|
77
|
+
result.templates = IOS_TEMPLATES.map(name => ({ name,
|
|
78
|
+
supported: name === 'Time Profiler' && result.discovered_templates.includes(name) && result.device !== null,
|
|
79
|
+
coverage: name === 'Time Profiler' ? 'CPU samples; bundled Hangs instrument is not independently verified' : name === 'System Trace' ? 'Scheduling coverage unverified' : name === 'Hangs' ? 'Not a standalone template here' : 'Unsupported on tested simulator',
|
|
80
|
+
reason: name === 'Time Profiler' ? null : 'TEMPLATE_TARGET_UNSUPPORTED'
|
|
81
|
+
}));
|
|
82
|
+
if (!result.templates.find(t => t.name === template)?.supported)
|
|
83
|
+
result.reasons.push('TEMPLATE_TARGET_UNSUPPORTED');
|
|
84
|
+
result.supported = result.reasons.length === 0;
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
@@ -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,244 @@
|
|
|
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
|
+
if (s.result.reason === 'STORAGE_LIMIT' && !s.recording?.recorder.alive())
|
|
167
|
+
await this.discardOversized(s);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async discardOversized(s) {
|
|
171
|
+
try {
|
|
172
|
+
if (s.directory)
|
|
173
|
+
await fs.rm(s.directory, { recursive: true, force: true });
|
|
174
|
+
s.result.cleanup = { status: 'complete', remaining_resources: [] };
|
|
175
|
+
s.terminal = this.time.mono();
|
|
176
|
+
}
|
|
177
|
+
catch { /* retain failed cleanup and owned path for cancel retry */ }
|
|
178
|
+
}
|
|
179
|
+
async finishRecorder(s, op, timeout) {
|
|
180
|
+
if (!s.recording || s.recorderOk !== undefined)
|
|
181
|
+
return;
|
|
182
|
+
const ended = await this.native.stop(s.recording, timeout);
|
|
183
|
+
s.recorderOk = ended.code === 0 && !ended.timedOut;
|
|
184
|
+
if (ended.output.includes('[Warning]'))
|
|
185
|
+
s.result.warnings = ['INSTRUMENTS_RUN_WARNING: one or more Instruments warnings; coverage may be incomplete.'];
|
|
186
|
+
if (!s.recorderOk && !s.discard)
|
|
187
|
+
throw new Error(classifyTraceFailure(ended));
|
|
188
|
+
if (op === 'duration' && !ended.output.includes('Reached specified time limit') && this.time.mono() < s.deadline && !s.discard)
|
|
189
|
+
throw new Error('RECORDER_EXITED');
|
|
190
|
+
}
|
|
191
|
+
async finishArtifact(s) {
|
|
192
|
+
if (!s.discard && !s.result.artifact) {
|
|
193
|
+
if (!s.recording || !s.app || !s.recorderOk)
|
|
194
|
+
throw new Error('RECORDER_EXITED');
|
|
195
|
+
s.result.artifact = await this.native.validate(s.recording, s.result.device.id, s.app);
|
|
196
|
+
}
|
|
197
|
+
if (s.discard && s.directory)
|
|
198
|
+
await fs.rm(s.directory, { recursive: true, force: true });
|
|
199
|
+
else if (s.directory)
|
|
200
|
+
await Promise.all(['notification', 'notification.c'].map(p => fs.rm(path.join(s.directory, p), { force: true })));
|
|
201
|
+
}
|
|
202
|
+
async link(id, device, correlation) {
|
|
203
|
+
const s = this.get(id);
|
|
204
|
+
if (device.platform !== 'ios' || device.id !== s.result.device.id)
|
|
205
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
206
|
+
const status = await this.control(id, 'status');
|
|
207
|
+
if (status.state !== 'recording')
|
|
208
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
209
|
+
return this.serial(s, async () => {
|
|
210
|
+
if (s.result.state !== 'recording' || !s.recording?.recorder.alive() || this.time.mono() >= s.deadline)
|
|
211
|
+
throw new TraceError('TRACE_NOT_FOUND');
|
|
212
|
+
if (!s.result.correlation_ids.includes(correlation)) {
|
|
213
|
+
if (s.result.correlation_ids.length >= 1000)
|
|
214
|
+
throw new TraceError('TRACE_BUSY');
|
|
215
|
+
s.result.correlation_ids.push(correlation);
|
|
216
|
+
}
|
|
217
|
+
s.linked.add(correlation);
|
|
218
|
+
let finished;
|
|
219
|
+
return () => finished ??= (async () => {
|
|
220
|
+
let active = false;
|
|
221
|
+
try {
|
|
222
|
+
active = (await this.control(id, 'status')).state === 'recording';
|
|
223
|
+
}
|
|
224
|
+
catch { /* unknown is partial */ }
|
|
225
|
+
s.linked.delete(correlation);
|
|
226
|
+
return { trace_id: id, coverage: !active || s.partial.has(correlation) ? 'partial' : 'covered', clock_alignment: 'unknown' };
|
|
227
|
+
})();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
async shutdown() {
|
|
231
|
+
this.closing = true;
|
|
232
|
+
this.native.shutdown();
|
|
233
|
+
const active = [...this.sessions.values()].filter(s => s.result.cleanup.status !== 'complete');
|
|
234
|
+
// Signal immediately even when an in-flight start/validation owns the serial queue.
|
|
235
|
+
for (const s of active)
|
|
236
|
+
s.recording?.recorder.signal('SIGINT');
|
|
237
|
+
let timer;
|
|
238
|
+
await Promise.race([Promise.allSettled(active.map(s => this.control(s.result.trace_id, 'cancel', 8000))),
|
|
239
|
+
new Promise(resolve => { timer = setTimeout(resolve, 9000); })]);
|
|
240
|
+
if (timer)
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
return active.filter(s => s.result.cleanup.status !== 'complete').map(s => this.snapshot(s));
|
|
243
|
+
}
|
|
244
|
+
}
|