swipium 1.4.0 → 2.0.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/CHANGELOG.md +313 -0
- package/README.md +175 -219
- package/THREAT_MODEL.md +223 -47
- package/dist/appMap/automationLink.js +39 -26
- package/dist/appMap/automationLink.js.map +1 -1
- package/dist/appMap/build.js +68 -20
- package/dist/appMap/build.js.map +1 -1
- package/dist/appMap/codeIndex.js +31 -5
- package/dist/appMap/codeIndex.js.map +1 -1
- package/dist/appMap/featureIndex.js +23 -5
- package/dist/appMap/featureIndex.js.map +1 -1
- package/dist/appMap/featureModel.js +44 -8
- package/dist/appMap/featureModel.js.map +1 -1
- package/dist/appMap/firstRunApply.js +24 -6
- package/dist/appMap/firstRunApply.js.map +1 -1
- package/dist/appMap/fsWalk.js +19 -2
- package/dist/appMap/fsWalk.js.map +1 -1
- package/dist/appMap/issues.js +10 -30
- package/dist/appMap/issues.js.map +1 -1
- package/dist/appMap/migrations.js +6 -6
- package/dist/appMap/migrations.js.map +1 -1
- package/dist/appMap/prelaunch.js +2 -2
- package/dist/appMap/prelaunch.js.map +1 -1
- package/dist/appMap/projectRegistry.js +83 -11
- package/dist/appMap/projectRegistry.js.map +1 -1
- package/dist/appMap/provenance.js +2 -2
- package/dist/appMap/provenance.js.map +1 -1
- package/dist/appMap/query.js +21 -5
- package/dist/appMap/query.js.map +1 -1
- package/dist/appMap/runtimeMerge.js +12 -5
- package/dist/appMap/runtimeMerge.js.map +1 -1
- package/dist/appMap/schema.js +1 -1
- package/dist/appMap/schema.js.map +1 -1
- package/dist/appMap/screenMatch.js +6 -2
- package/dist/appMap/screenMatch.js.map +1 -1
- package/dist/appMap/staticScan.js +151 -39
- package/dist/appMap/staticScan.js.map +1 -1
- package/dist/appMap/store.js +58 -85
- package/dist/appMap/store.js.map +1 -1
- package/dist/appMap/tsAstScan.js +34 -12
- package/dist/appMap/tsAstScan.js.map +1 -1
- package/dist/artifacts/bundletool.js +49 -12
- package/dist/artifacts/bundletool.js.map +1 -1
- package/dist/artifacts/resolve.js +41 -14
- package/dist/artifacts/resolve.js.map +1 -1
- package/dist/automation/capabilities.js +112 -30
- package/dist/automation/capabilities.js.map +1 -1
- package/dist/automation/gestures.js +6 -2
- package/dist/automation/gestures.js.map +1 -1
- package/dist/automation/plan.js +71 -23
- package/dist/automation/plan.js.map +1 -1
- package/dist/automation/report.js +15 -5
- package/dist/automation/report.js.map +1 -1
- package/dist/automation/selectors.js +22 -28
- package/dist/automation/selectors.js.map +1 -1
- package/dist/automation/types.js +1 -1
- package/dist/automation/types.js.map +1 -1
- package/dist/automation/waits.js +14 -8
- package/dist/automation/waits.js.map +1 -1
- package/dist/automation/webview.js +9 -3
- package/dist/automation/webview.js.map +1 -1
- package/dist/automationGen/appiumModel.js +16 -8
- package/dist/automationGen/appiumModel.js.map +1 -1
- package/dist/automationGen/ciEmitter.js +6 -6
- package/dist/automationGen/ciEmitter.js.map +1 -1
- package/dist/automationGen/identifiers.js +224 -0
- package/dist/automationGen/identifiers.js.map +1 -0
- package/dist/automationGen/jsEmitter.js +288 -121
- package/dist/automationGen/jsEmitter.js.map +1 -1
- package/dist/automationGen/packagePatch.js +2 -2
- package/dist/automationGen/packagePatch.js.map +1 -1
- package/dist/automationGen/platformResolve.js +36 -0
- package/dist/automationGen/platformResolve.js.map +1 -0
- package/dist/automationGen/projectProfile.js +76 -27
- package/dist/automationGen/projectProfile.js.map +1 -1
- package/dist/automationGen/pythonEmitter.js +278 -107
- package/dist/automationGen/pythonEmitter.js.map +1 -1
- package/dist/automationGen/readmeEmitter.js +7 -6
- package/dist/automationGen/readmeEmitter.js.map +1 -1
- package/dist/automationGen/run.js +388 -0
- package/dist/automationGen/run.js.map +1 -0
- package/dist/automationGen/suitePlan.js +17 -10
- package/dist/automationGen/suitePlan.js.map +1 -1
- package/dist/automationGen/validation.js +52 -12
- package/dist/automationGen/validation.js.map +1 -1
- package/dist/build/parseBuildLog.js +235 -31
- package/dist/build/parseBuildLog.js.map +1 -1
- package/dist/build/plan.js +39 -18
- package/dist/build/plan.js.map +1 -1
- package/dist/ci/preflight.js +13 -4
- package/dist/ci/preflight.js.map +1 -1
- package/dist/cli/gc.js +74 -0
- package/dist/cli/gc.js.map +1 -0
- package/dist/cli/init.js +217 -26
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.js +50 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/report.js +218 -0
- package/dist/cli/report.js.map +1 -0
- package/dist/cli/scan.js +36 -15
- package/dist/cli/scan.js.map +1 -1
- package/dist/cli/suite.js +7 -7
- package/dist/cli/suite.js.map +1 -1
- package/dist/cli/verify.js +7 -7
- package/dist/cli/verify.js.map +1 -1
- package/dist/consent/consent.js +162 -5
- package/dist/consent/consent.js.map +1 -1
- package/dist/context/detect.js +55 -18
- package/dist/context/detect.js.map +1 -1
- package/dist/context/findApps.js +22 -8
- package/dist/context/findApps.js.map +1 -1
- package/dist/context/projectRoot.js +174 -19
- package/dist/context/projectRoot.js.map +1 -1
- package/dist/context/scan.js +1 -1
- package/dist/context/scan.js.map +1 -1
- package/dist/core/capabilityGroups.js +90 -0
- package/dist/core/capabilityGroups.js.map +1 -0
- package/dist/core/target.js +10 -8
- package/dist/core/target.js.map +1 -1
- package/dist/core/targetPlan.js +160 -39
- package/dist/core/targetPlan.js.map +1 -1
- package/dist/drivers/DirectDriver.js +317 -43
- package/dist/drivers/DirectDriver.js.map +1 -1
- package/dist/drivers/SimctlDriver.js +124 -8
- package/dist/drivers/SimctlDriver.js.map +1 -1
- package/dist/drivers/WdaDriver.js +365 -55
- package/dist/drivers/WdaDriver.js.map +1 -1
- package/dist/explore/candidates.js +7 -3
- package/dist/explore/candidates.js.map +1 -1
- package/dist/explore/graph.js +15 -6
- package/dist/explore/graph.js.map +1 -1
- package/dist/explore/memory.js +4 -1
- package/dist/explore/memory.js.map +1 -1
- package/dist/explore/planner.js +54 -9
- package/dist/explore/planner.js.map +1 -1
- package/dist/explore/policy.js +11 -6
- package/dist/explore/policy.js.map +1 -1
- package/dist/explore/runner.js +363 -190
- package/dist/explore/runner.js.map +1 -1
- package/dist/explore/signatures.js +4 -2
- package/dist/explore/signatures.js.map +1 -1
- package/dist/explore/suite.js +3 -1
- package/dist/explore/suite.js.map +1 -1
- package/dist/featureTesting/executionBootstrap.js +137 -29
- package/dist/featureTesting/executionBootstrap.js.map +1 -1
- package/dist/featureTesting/featureMap.js +6 -3
- package/dist/featureTesting/featureMap.js.map +1 -1
- package/dist/featureTesting/featureScope.js +101 -20
- package/dist/featureTesting/featureScope.js.map +1 -1
- package/dist/featureTesting/mapFeatureScope.js +44 -15
- package/dist/featureTesting/mapFeatureScope.js.map +1 -1
- package/dist/featureTesting/objectiveModel.js +15 -11
- package/dist/featureTesting/objectiveModel.js.map +1 -1
- package/dist/featureTesting/resultMerge.js +16 -8
- package/dist/featureTesting/resultMerge.js.map +1 -1
- package/dist/featureTesting/sources.js +8 -5
- package/dist/featureTesting/sources.js.map +1 -1
- package/dist/featureTesting/suiteBridge.js +8 -2
- package/dist/featureTesting/suiteBridge.js.map +1 -1
- package/dist/featureTesting/synonyms.js +32 -4
- package/dist/featureTesting/synonyms.js.map +1 -1
- package/dist/featureTesting/testCaseFactory.js +27 -15
- package/dist/featureTesting/testCaseFactory.js.map +1 -1
- package/dist/featureTesting/testPlan.js +34 -12
- package/dist/featureTesting/testPlan.js.map +1 -1
- package/dist/firstRun/authStateMachine.js +1 -1
- package/dist/firstRun/authStateMachine.js.map +1 -1
- package/dist/firstRun/classifyScreen.js +9 -6
- package/dist/firstRun/classifyScreen.js.map +1 -1
- package/dist/firstRun/firstRunPlanner.js +16 -13
- package/dist/firstRun/firstRunPlanner.js.map +1 -1
- package/dist/firstRun/firstRunRunner.js +92 -31
- package/dist/firstRun/firstRunRunner.js.map +1 -1
- package/dist/firstRun/generatedDataPolicy.js +25 -15
- package/dist/firstRun/generatedDataPolicy.js.map +1 -1
- package/dist/firstRun/inputPlanner.js +45 -11
- package/dist/firstRun/inputPlanner.js.map +1 -1
- package/dist/firstRun/onboardingStateMachine.js +1 -1
- package/dist/firstRun/onboardingStateMachine.js.map +1 -1
- package/dist/firstRun/paywallPolicy.js +3 -3
- package/dist/firstRun/paywallPolicy.js.map +1 -1
- package/dist/firstRun/types.js +1 -1
- package/dist/firstRun/types.js.map +1 -1
- package/dist/fixtures/catalog.js +72 -9
- package/dist/fixtures/catalog.js.map +1 -1
- package/dist/fixtures/load.js +50 -0
- package/dist/fixtures/load.js.map +1 -0
- package/dist/flows/discover.js +2 -2
- package/dist/flows/discover.js.map +1 -1
- package/dist/flows/generate.js +68 -17
- package/dist/flows/generate.js.map +1 -1
- package/dist/flows/lint.js +88 -14
- package/dist/flows/lint.js.map +1 -1
- package/dist/flows/pack.js +21 -5
- package/dist/flows/pack.js.map +1 -1
- package/dist/flows/paths.js +57 -0
- package/dist/flows/paths.js.map +1 -0
- package/dist/flows/repair.js +176 -37
- package/dist/flows/repair.js.map +1 -1
- package/dist/flows/run.js +308 -99
- package/dist/flows/run.js.map +1 -1
- package/dist/flows/schema.js +107 -25
- package/dist/flows/schema.js.map +1 -1
- package/dist/flows/seedExec.js +8 -4
- package/dist/flows/seedExec.js.map +1 -1
- package/dist/flows/templates.js +5 -5
- package/dist/index.js +50 -8
- package/dist/index.js.map +1 -1
- package/dist/ios/signing.js +32 -3
- package/dist/ios/signing.js.map +1 -1
- package/dist/issues/classify.js +28 -20
- package/dist/issues/classify.js.map +1 -1
- package/dist/issues/fingerprint.js +35 -9
- package/dist/issues/fingerprint.js.map +1 -1
- package/dist/issues/index.js +93 -46
- package/dist/issues/index.js.map +1 -1
- package/dist/issues/metrics.js +6 -5
- package/dist/issues/metrics.js.map +1 -1
- package/dist/issues/recurrence.js +35 -8
- package/dist/issues/recurrence.js.map +1 -1
- package/dist/issues/report.js +12 -8
- package/dist/issues/report.js.map +1 -1
- package/dist/issues/reportBridge.js +40 -9
- package/dist/issues/reportBridge.js.map +1 -1
- package/dist/issues/schema.js +2 -2
- package/dist/issues/schema.js.map +1 -1
- package/dist/issues/sourceRevision.js +2 -2
- package/dist/issues/sourceRevision.js.map +1 -1
- package/dist/issues/store.js +110 -32
- package/dist/issues/store.js.map +1 -1
- package/dist/lib/abortScope.js +48 -0
- package/dist/lib/abortScope.js.map +1 -0
- package/dist/lib/android.js +84 -8
- package/dist/lib/android.js.map +1 -1
- package/dist/lib/coordSpace.js +1 -1
- package/dist/lib/coordSpace.js.map +1 -1
- package/dist/lib/device.js +53 -12
- package/dist/lib/device.js.map +1 -1
- package/dist/lib/gestures.js +141 -0
- package/dist/lib/gestures.js.map +1 -0
- package/dist/lib/gitignore.js +4 -3
- package/dist/lib/gitignore.js.map +1 -1
- package/dist/lib/image.js +12 -3
- package/dist/lib/image.js.map +1 -1
- package/dist/lib/lockfile.js +399 -0
- package/dist/lib/lockfile.js.map +1 -0
- package/dist/lib/needsInput.js +9 -6
- package/dist/lib/needsInput.js.map +1 -1
- package/dist/lib/png.js +4 -3
- package/dist/lib/png.js.map +1 -1
- package/dist/lib/redact.js +170 -9
- package/dist/lib/redact.js.map +1 -1
- package/dist/lib/result.js +94 -10
- package/dist/lib/result.js.map +1 -1
- package/dist/lib/schemaHash.js +7 -5
- package/dist/lib/schemaHash.js.map +1 -1
- package/dist/lib/sensitive.js +6 -3
- package/dist/lib/sensitive.js.map +1 -1
- package/dist/lib/simctl.js +3 -3
- package/dist/lib/simctl.js.map +1 -1
- package/dist/lib/spawn.js +78 -17
- package/dist/lib/spawn.js.map +1 -1
- package/dist/lib/toolAnnotations.js +114 -0
- package/dist/lib/toolAnnotations.js.map +1 -0
- package/dist/lib/wda.js +321 -25
- package/dist/lib/wda.js.map +1 -1
- package/dist/lib/wdaConfig.js +12 -5
- package/dist/lib/wdaConfig.js.map +1 -1
- package/dist/lib/wdaTune.js +52 -23
- package/dist/lib/wdaTune.js.map +1 -1
- package/dist/mobileAudit/checks.js +150 -28
- package/dist/mobileAudit/checks.js.map +1 -1
- package/dist/mobileAudit/evidence.js +4 -4
- package/dist/mobileAudit/evidence.js.map +1 -1
- package/dist/mobileAudit/profiles.js +121 -20
- package/dist/mobileAudit/profiles.js.map +1 -1
- package/dist/mobileAudit/results.js +9 -4
- package/dist/mobileAudit/results.js.map +1 -1
- package/dist/mobileAudit/runner.js +214 -35
- package/dist/mobileAudit/runner.js.map +1 -1
- package/dist/oracle/auth.js +1 -1
- package/dist/oracle/auth.js.map +1 -1
- package/dist/oracle/failures.js +1085 -145
- package/dist/oracle/failures.js.map +1 -1
- package/dist/oracle/health.js +148 -26
- package/dist/oracle/health.js.map +1 -1
- package/dist/oracle/locator.js +148 -19
- package/dist/oracle/locator.js.map +1 -1
- package/dist/oracle/record.js +3 -3
- package/dist/oracle/record.js.map +1 -1
- package/dist/orchestration/envelope.js +2 -2
- package/dist/orchestration/envelope.js.map +1 -1
- package/dist/orchestration/goal.js +7 -13
- package/dist/orchestration/goal.js.map +1 -1
- package/dist/orchestration/testThis/execute.js +167 -0
- package/dist/orchestration/testThis/execute.js.map +1 -0
- package/dist/orchestration/testThis/pipeline.js +413 -0
- package/dist/orchestration/testThis/pipeline.js.map +1 -0
- package/dist/orchestration/testThis/plan.js +519 -0
- package/dist/orchestration/testThis/plan.js.map +1 -0
- package/dist/orchestration/testThis/sessionIntent.js +72 -0
- package/dist/orchestration/testThis/sessionIntent.js.map +1 -0
- package/dist/orchestration/testThis/terminal.js +136 -0
- package/dist/orchestration/testThis/terminal.js.map +1 -0
- package/dist/orchestration/testThis/types.js +12 -0
- package/dist/orchestration/testThis/types.js.map +1 -0
- package/dist/plan/plan.js +9 -9
- package/dist/plan/plan.js.map +1 -1
- package/dist/prompts/index.js +24 -24
- package/dist/prompts/index.js.map +1 -1
- package/dist/report/coverage.js +0 -1
- package/dist/report/coverage.js.map +1 -1
- package/dist/report/evidence.js.map +1 -1
- package/dist/report/export.js +449 -72
- package/dist/report/export.js.map +1 -1
- package/dist/report/findingsDedupe.js +41 -0
- package/dist/report/findingsDedupe.js.map +1 -0
- package/dist/report/flake.js +13 -14
- package/dist/report/flake.js.map +1 -1
- package/dist/report/history.js +72 -38
- package/dist/report/history.js.map +1 -1
- package/dist/report/policy.js +18 -7
- package/dist/report/policy.js.map +1 -1
- package/dist/report/qaLevel.js +10 -16
- package/dist/report/qaLevel.js.map +1 -1
- package/dist/report/readiness.js +42 -12
- package/dist/report/readiness.js.map +1 -1
- package/dist/report/sarifSources.js +160 -0
- package/dist/report/sarifSources.js.map +1 -0
- package/dist/report/summary.js +2 -2
- package/dist/report/summary.js.map +1 -1
- package/dist/report/testCatalog.js +4 -4
- package/dist/report/testCatalog.js.map +1 -1
- package/dist/report/timing.js +23 -23
- package/dist/report/timing.js.map +1 -1
- package/dist/report/toolHealth.js +124 -0
- package/dist/report/toolHealth.js.map +1 -0
- package/dist/server.js +501 -69
- package/dist/server.js.map +1 -1
- package/dist/services/automationGenerate.js +8 -2
- package/dist/services/automationGenerate.js.map +1 -1
- package/dist/services/build.js +10 -4
- package/dist/services/build.js.map +1 -1
- package/dist/services/flowGenerate.js +75 -0
- package/dist/services/flowGenerate.js.map +1 -0
- package/dist/services/preflight.js +40 -8
- package/dist/services/preflight.js.map +1 -1
- package/dist/services/prepareAndroid.js +92 -18
- package/dist/services/prepareAndroid.js.map +1 -1
- package/dist/services/prepareIos.js +94 -17
- package/dist/services/prepareIos.js.map +1 -1
- package/dist/services/report.js +271 -80
- package/dist/services/report.js.map +1 -1
- package/dist/services/smoke.js +68 -22
- package/dist/services/smoke.js.map +1 -1
- package/dist/services/suiteGenerate.js +43 -6
- package/dist/services/suiteGenerate.js.map +1 -1
- package/dist/services/testSuiteKnowledge.js +78 -33
- package/dist/services/testSuiteKnowledge.js.map +1 -1
- package/dist/session/attach.js +284 -4
- package/dist/session/attach.js.map +1 -1
- package/dist/session/processRegistry.js +433 -0
- package/dist/session/processRegistry.js.map +1 -0
- package/dist/session/progress.js +1 -1
- package/dist/session/progress.js.map +1 -1
- package/dist/session/retention.js +201 -0
- package/dist/session/retention.js.map +1 -0
- package/dist/session/store.js +459 -59
- package/dist/session/store.js.map +1 -1
- package/dist/snapshot/overlays.js +60 -10
- package/dist/snapshot/overlays.js.map +1 -1
- package/dist/snapshot/parse.js +30 -12
- package/dist/snapshot/parse.js.map +1 -1
- package/dist/snapshot/present.js +36 -6
- package/dist/snapshot/present.js.map +1 -1
- package/dist/snapshot/settle.js +39 -9
- package/dist/snapshot/settle.js.map +1 -1
- package/dist/state/consent.js +99 -0
- package/dist/state/consent.js.map +1 -0
- package/dist/state/profile.js +82 -11
- package/dist/state/profile.js.map +1 -1
- package/dist/suite/compile.js +15 -6
- package/dist/suite/compile.js.map +1 -1
- package/dist/suite/lint.js +22 -4
- package/dist/suite/lint.js.map +1 -1
- package/dist/suite/pom.js +75 -44
- package/dist/suite/pom.js.map +1 -1
- package/dist/suite/secretGuard.js +266 -0
- package/dist/suite/secretGuard.js.map +1 -0
- package/dist/suite/testcase.js +15 -11
- package/dist/suite/testcase.js.map +1 -1
- package/dist/testSuite/exporter.js +7 -3
- package/dist/testSuite/exporter.js.map +1 -1
- package/dist/testSuite/generator.js +43 -14
- package/dist/testSuite/generator.js.map +1 -1
- package/dist/testSuite/history.js +1 -1
- package/dist/testSuite/history.js.map +1 -1
- package/dist/testSuite/issueLinks.js +3 -3
- package/dist/testSuite/issueLinks.js.map +1 -1
- package/dist/testSuite/lint.js +4 -4
- package/dist/testSuite/lint.js.map +1 -1
- package/dist/testSuite/merge.js +13 -7
- package/dist/testSuite/merge.js.map +1 -1
- package/dist/testSuite/schema.js +21 -16
- package/dist/testSuite/schema.js.map +1 -1
- package/dist/testSuite/store.js +12 -4
- package/dist/testSuite/store.js.map +1 -1
- package/dist/testSuite/traceability.js +2 -2
- package/dist/testSuite/traceability.js.map +1 -1
- package/dist/tools/act.js +868 -174
- package/dist/tools/act.js.map +1 -1
- package/dist/tools/agent.js +521 -112
- package/dist/tools/agent.js.map +1 -1
- package/dist/tools/appControl.js +82 -19
- package/dist/tools/appControl.js.map +1 -1
- package/dist/tools/appMap.js +393 -189
- package/dist/tools/appMap.js.map +1 -1
- package/dist/tools/build.js +92 -51
- package/dist/tools/build.js.map +1 -1
- package/dist/tools/bundletool.js +141 -40
- package/dist/tools/bundletool.js.map +1 -1
- package/dist/tools/clearOverlay.js +98 -18
- package/dist/tools/clearOverlay.js.map +1 -1
- package/dist/tools/device.js +108 -19
- package/dist/tools/device.js.map +1 -1
- package/dist/tools/doctor.js +98 -35
- package/dist/tools/doctor.js.map +1 -1
- package/dist/tools/explore.js +110 -42
- package/dist/tools/explore.js.map +1 -1
- package/dist/tools/featureTesting.js +152 -130
- package/dist/tools/featureTesting.js.map +1 -1
- package/dist/tools/firstRun.js +94 -66
- package/dist/tools/firstRun.js.map +1 -1
- package/dist/tools/flow.js +347 -132
- package/dist/tools/flow.js.map +1 -1
- package/dist/tools/flowRepair.js +42 -10
- package/dist/tools/flowRepair.js.map +1 -1
- package/dist/tools/generate.js +199 -0
- package/dist/tools/generate.js.map +1 -0
- package/dist/tools/getArtifact.js +37 -8
- package/dist/tools/getArtifact.js.map +1 -1
- package/dist/tools/health.js +17 -12
- package/dist/tools/health.js.map +1 -1
- package/dist/tools/ios.js +148 -194
- package/dist/tools/ios.js.map +1 -1
- package/dist/tools/issues.js +256 -171
- package/dist/tools/issues.js.map +1 -1
- package/dist/tools/jobs.js +55 -11
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/metro.js +84 -38
- package/dist/tools/metro.js.map +1 -1
- package/dist/tools/mobileAudit.js +83 -26
- package/dist/tools/mobileAudit.js.map +1 -1
- package/dist/tools/network.js +36 -14
- package/dist/tools/network.js.map +1 -1
- package/dist/tools/note.js +31 -17
- package/dist/tools/note.js.map +1 -1
- package/dist/tools/prepareIosTarget.js +31 -14
- package/dist/tools/prepareIosTarget.js.map +1 -1
- package/dist/tools/prepareTarget.js +193 -47
- package/dist/tools/prepareTarget.js.map +1 -1
- package/dist/tools/report.js +16 -8
- package/dist/tools/report.js.map +1 -1
- package/dist/tools/resolveArtifact.js +12 -12
- package/dist/tools/resolveArtifact.js.map +1 -1
- package/dist/tools/resolveTarget.js +81 -18
- package/dist/tools/resolveTarget.js.map +1 -1
- package/dist/tools/screenRecord.js +85 -20
- package/dist/tools/screenRecord.js.map +1 -1
- package/dist/tools/screenshot.js +42 -18
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/smoke.js +29 -12
- package/dist/tools/smoke.js.map +1 -1
- package/dist/tools/snapshot.js +157 -107
- package/dist/tools/snapshot.js.map +1 -1
- package/dist/tools/startSession.js +117 -84
- package/dist/tools/startSession.js.map +1 -1
- package/dist/tools/suite.js +391 -310
- package/dist/tools/suite.js.map +1 -1
- package/dist/tools/testSuite.js +112 -36
- package/dist/tools/testSuite.js.map +1 -1
- package/dist/tools/testThis.js +35 -802
- package/dist/tools/testThis.js.map +1 -1
- package/dist/tools/visual.js +698 -116
- package/dist/tools/visual.js.map +1 -1
- package/dist/tools/wait.js +9 -23
- package/dist/tools/wait.js.map +1 -1
- package/dist/tools/wda.js +312 -54
- package/dist/tools/wda.js.map +1 -1
- package/dist/version.js +37 -64
- package/dist/version.js.map +1 -1
- package/dist/visual/ocr.js +65 -8
- package/dist/visual/ocr.js.map +1 -1
- package/dist/visual/provider.js +87 -15
- package/dist/visual/provider.js.map +1 -1
- package/docs/README.md +24 -10
- package/docs/ci-reports.md +328 -0
- package/docs/concepts.md +227 -0
- package/docs/flows.md +153 -0
- package/docs/mcp-server.md +210 -104
- package/docs/physical-devices.md +84 -0
- package/docs/tools.md +808 -217
- package/package.json +16 -6
- package/dist/appMap/diff.js +0 -60
- package/dist/appMap/diff.js.map +0 -1
- package/dist/automation/maestroIr.js +0 -282
- package/dist/automation/maestroIr.js.map +0 -1
- package/dist/interop/maestro.js +0 -151
- package/dist/interop/maestro.js.map +0 -1
- package/dist/tools/assertVisual.js +0 -74
- package/dist/tools/assertVisual.js.map +0 -1
- package/dist/tools/automationGenerate.js +0 -394
- package/dist/tools/automationGenerate.js.map +0 -1
- package/dist/tools/capabilities.js +0 -209
- package/dist/tools/capabilities.js.map +0 -1
- package/dist/tools/detectContext.js +0 -41
- package/dist/tools/detectContext.js.map +0 -1
- package/dist/tools/flowGenerate.js +0 -55
- package/dist/tools/flowGenerate.js.map +0 -1
- package/dist/tools/history.js +0 -56
- package/dist/tools/history.js.map +0 -1
- package/dist/tools/idling.js +0 -104
- package/dist/tools/idling.js.map +0 -1
- package/dist/tools/inputCapabilities.js +0 -30
- package/dist/tools/inputCapabilities.js.map +0 -1
- package/dist/tools/locator.js +0 -57
- package/dist/tools/locator.js.map +0 -1
- package/dist/tools/maestro.js +0 -78
- package/dist/tools/maestro.js.map +0 -1
- package/dist/tools/permissions.js +0 -149
- package/dist/tools/permissions.js.map +0 -1
- package/dist/tools/plan.js +0 -49
- package/dist/tools/plan.js.map +0 -1
- package/dist/tools/screenInfo.js +0 -70
- package/dist/tools/screenInfo.js.map +0 -1
- package/dist/tools/seed.js +0 -101
- package/dist/tools/seed.js.map +0 -1
- package/dist/tools/state.js +0 -170
- package/dist/tools/state.js.map +0 -1
- package/dist/tools/visualText.js +0 -61
- package/dist/tools/visualText.js.map +0 -1
package/dist/tools/act.js
CHANGED
|
@@ -1,26 +1,356 @@
|
|
|
1
|
-
// qa_act
|
|
1
|
+
// qa_act: the single consolidated action tool (locked schema).
|
|
2
2
|
// Resolves a target (ref | selector | coords), performs the action, waits for the screen
|
|
3
3
|
// to settle, then returns the post-action snapshot + a deterministic health check. So one
|
|
4
4
|
// call both acts AND observes the result.
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import { qaOk, qaError, qaStop } from '../lib/result.js';
|
|
6
|
+
import { qaOk, qaError, qaStop, unknownSessionError, cancelledResult } from '../lib/result.js';
|
|
7
7
|
import { parseSnapshot, signature } from '../snapshot/parse.js';
|
|
8
8
|
import { presentElements } from '../snapshot/present.js';
|
|
9
9
|
import { obstructionAt } from '../snapshot/overlays.js';
|
|
10
10
|
import { settle } from '../snapshot/settle.js';
|
|
11
11
|
import { checkHealth } from '../oracle/health.js';
|
|
12
12
|
import { recordHealthFindings } from '../oracle/record.js';
|
|
13
|
-
import { makeRedactor } from '../lib/redact.js';
|
|
14
|
-
import { getDriver } from '../session/attach.js';
|
|
15
|
-
import {
|
|
13
|
+
import { isSecureNode, makeRedactor } from '../lib/redact.js';
|
|
14
|
+
import { blockedDeviceResult, getDriver } from '../session/attach.js';
|
|
15
|
+
import { adbInputTextChunks, escapeAdbInputText } from '../drivers/DirectDriver.js';
|
|
16
|
+
import { GESTURE_EDGE_INSET, SCROLL_CONTAINER_INSET, largestScrollableRect, swipeFromPoint, swipeInRect, swipeVector, } from '../lib/gestures.js';
|
|
17
|
+
import { center, resolveTarget, setsEqual } from '../core/target.js';
|
|
16
18
|
import { recordableNativeSelector, recordableTap } from '../flows/generate.js';
|
|
17
19
|
import { classifyFlowDriverError } from '../flows/run.js';
|
|
18
20
|
import { structuredSignature } from '../explore/signatures.js';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
import { isAbortError, runWithSignal } from '../lib/abortScope.js';
|
|
22
|
+
import { SECRET_VAR_NAME } from '../flows/schema.js';
|
|
23
|
+
/** Drop the (already-handled) native selector before generic ref/text/id/coords resolution. */
|
|
24
|
+
function stripSelector(t) {
|
|
25
|
+
if (!t)
|
|
26
|
+
return undefined;
|
|
27
|
+
const { selector: _selector, ...rest } = t;
|
|
28
|
+
return rest;
|
|
29
|
+
}
|
|
30
|
+
/** Discriminated native-selector schema (1.5.0: replaces the free-form "strategy=value" string). */
|
|
31
|
+
const nativeSelectorSchema = z.object({
|
|
32
|
+
using: z
|
|
33
|
+
.enum(['accessibility id', 'name', 'predicate string', 'class chain'])
|
|
34
|
+
.describe('"accessibility id" = accessibilityIdentifier/testID; predicate string = NSPredicate; class chain = XCUITest.'),
|
|
35
|
+
value: z.string(),
|
|
36
|
+
});
|
|
37
|
+
/** After the focus tap, wait for the soft keyboard instead of a fixed sleep. Poll
|
|
38
|
+
* imeShown() in ~100ms steps up to capMs (the old sleep), proceeding as soon as it's up.
|
|
39
|
+
* The tap stays a REAL touch (RN ignores synthetic focus, so tap-then-type is required);
|
|
40
|
+
* backends whose imeShown() throws get the original fixed sleep. */
|
|
41
|
+
async function awaitIme(d, capMs, floorMs = 0) {
|
|
42
|
+
const started = Date.now();
|
|
43
|
+
const deadline = started + capMs;
|
|
44
|
+
try {
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
if (await d.imeShown()) {
|
|
47
|
+
// Field hop: the IME was ALREADY up before the tap, so "shown" says nothing about the
|
|
48
|
+
// new field having focus yet, so give RN a floor to move focus before typing.
|
|
49
|
+
const rest = floorMs - (Date.now() - started);
|
|
50
|
+
if (rest > 0)
|
|
51
|
+
await new Promise((r) => setTimeout(r, rest));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await new Promise((r) => setTimeout(r, Math.min(100, Math.max(1, deadline - Date.now()))));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// imeShown unsupported here: keep the original fixed-sleep behavior
|
|
59
|
+
await new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Values to scrub from driver error text: session secrets + the value just typed, in raw AND
|
|
63
|
+
* adb-escaped form (escaping would otherwise defeat plain-substring redaction, H2). */
|
|
64
|
+
function errorSecrets(session, typed) {
|
|
65
|
+
const out = [...session.secrets];
|
|
66
|
+
for (const v of [...session.secrets, ...(typed ? [typed] : [])]) {
|
|
67
|
+
out.push(v);
|
|
68
|
+
for (const chunk of adbInputTextChunks(v))
|
|
69
|
+
out.push(escapeAdbInputText(chunk));
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** A typed value that IS (or contains) a value already registered as a session secret (typed
|
|
74
|
+
* into a secure field earlier, or provided as a secret input) must be recorded exactly like a
|
|
75
|
+
* secure-field value: `secret:true`, no plaintext (> a ${SECRET_n} variable at generate time),
|
|
76
|
+
* even when the field it goes into is an ordinary text field. Containment needs >= 3 chars so a
|
|
77
|
+
* 1-2 char secret can't mark every value that happens to include it. */
|
|
78
|
+
export function matchesSessionSecret(secrets, text) {
|
|
79
|
+
if (!text)
|
|
80
|
+
return false;
|
|
81
|
+
for (const s of secrets) {
|
|
82
|
+
if (!s)
|
|
83
|
+
continue;
|
|
84
|
+
if (text === s || (s.length >= 3 && text.includes(s)))
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/** How many elements (DFS order) enter the positional change fingerprint. */
|
|
90
|
+
const POSITION_FINGERPRINT_ELEMENTS = 80;
|
|
91
|
+
/** Positional fingerprint of a screen: identity + bounds of its first N surfaced elements. The
|
|
92
|
+
* presence-only signature set misses a scroll that moved content without adding/removing any
|
|
93
|
+
* element (WDA keeps off-screen cells in its tree). Bounds catch it. */
|
|
94
|
+
export function positionalFingerprint(byRef) {
|
|
95
|
+
if (!byRef?.size)
|
|
96
|
+
return undefined;
|
|
97
|
+
return [...byRef.values()]
|
|
98
|
+
.slice(0, POSITION_FINGERPRINT_ELEMENTS)
|
|
99
|
+
.map((n) => `${n.cls}|${n.id}|${n.desc}|${n.text}@${n.bounds.join(',')}`)
|
|
100
|
+
.join('\n');
|
|
101
|
+
}
|
|
102
|
+
/** Per-node toggle/selection state (Android checked/selected, a `value` attr where a backend
|
|
103
|
+
* exposes one), keyed by identity (class|id|desc|text, with a #n suffix for repeats). */
|
|
104
|
+
export function stateByIdentity(byRef) {
|
|
105
|
+
const out = new Map();
|
|
106
|
+
if (!byRef)
|
|
107
|
+
return out;
|
|
108
|
+
const seen = new Map();
|
|
109
|
+
for (const n of byRef.values()) {
|
|
110
|
+
const base = `${n.cls}|${n.id}|${n.desc}|${n.text}`;
|
|
111
|
+
const k = (seen.get(base) ?? 0) + 1;
|
|
112
|
+
seen.set(base, k);
|
|
113
|
+
out.set(k > 1 ? `${base}#${k}` : base, `${n.attrs.checked ?? ''}/${n.attrs.selected ?? ''}/${n.attrs.value ?? ''}`);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** Refs (in `post`) whose checked/selected/value state differs from `pre` for the SAME identity:
|
|
118
|
+
* a Switch/Checkbox toggle, a tab selection. The presence-only signature misses these, so a real
|
|
119
|
+
* toggle looked like "no change" and the press retry toggled it straight back. */
|
|
120
|
+
export function stateChangedRefs(pre, postByRef) {
|
|
121
|
+
if (!pre.size)
|
|
122
|
+
return [];
|
|
123
|
+
const post = stateByIdentity(postByRef);
|
|
124
|
+
const keys = [...post.keys()];
|
|
125
|
+
const refs = [...postByRef.keys()];
|
|
126
|
+
const out = [];
|
|
127
|
+
keys.forEach((k, i) => {
|
|
128
|
+
const before = pre.get(k);
|
|
129
|
+
if (before !== undefined && before !== post.get(k))
|
|
130
|
+
out.push(refs[i]);
|
|
131
|
+
});
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/** Human form of a node's toggle state for the diff line. */
|
|
135
|
+
function stateLabel(n) {
|
|
136
|
+
const parts = [];
|
|
137
|
+
if (n.attrs.checkable === 'true' || n.attrs.checked === 'true')
|
|
138
|
+
parts.push(`checked=${n.attrs.checked === 'true'}`);
|
|
139
|
+
if (n.attrs.selected === 'true' || !parts.length)
|
|
140
|
+
parts.push(`selected=${n.attrs.selected === 'true'}`);
|
|
141
|
+
if (n.attrs.value)
|
|
142
|
+
parts.push(`value=${JSON.stringify(n.attrs.value)}`);
|
|
143
|
+
return parts.join(' ');
|
|
144
|
+
}
|
|
145
|
+
/** Share of post-action elements that are new before observe:"diff" falls back to the full list
|
|
146
|
+
* (after a navigation "diff" = every new element + every old one listed as removed, larger
|
|
147
|
+
* than "full"). */
|
|
148
|
+
export const DIFF_FULL_FALLBACK_RATIO = 0.5;
|
|
149
|
+
/** Only `${SWIPIUM_*}` placeholders are expanded in typed text (anything else stays literal). */
|
|
150
|
+
const INPUT_PLACEHOLDER_RE = /\$\{(SWIPIUM_[A-Z0-9_]+)\}/g;
|
|
151
|
+
/** Env-sourced placeholder values whose NAME looks secret join the redaction set. Uses the shared
|
|
152
|
+
* SECRET_VAR_NAME (flows/schema.ts), so `code` (SWIPIUM_VERIFICATION_CODE) counts here too. */
|
|
153
|
+
const SECRET_VAR_NAME_RE = SECRET_VAR_NAME;
|
|
154
|
+
/** Expand `${SWIPIUM_*}` placeholders from session inputs (qa_continue_from_blocker values), else the server
|
|
155
|
+
* env. Returns the expanded text, the variable names used, which of them are secret, and any
|
|
156
|
+
* that could not be resolved. PURE apart from reading `env`. */
|
|
157
|
+
export function expandInputPlaceholders(text, inputs, env = process.env) {
|
|
158
|
+
const vars = [];
|
|
159
|
+
const missing = [];
|
|
160
|
+
const secretValues = [];
|
|
161
|
+
const out = text.replace(INPUT_PLACEHOLDER_RE, (whole, name) => {
|
|
162
|
+
const fromSession = inputs.values.get(name);
|
|
163
|
+
const value = fromSession ?? env[name];
|
|
164
|
+
if (value === undefined) {
|
|
165
|
+
missing.push(name);
|
|
166
|
+
return whole;
|
|
167
|
+
}
|
|
168
|
+
if (!vars.includes(name))
|
|
169
|
+
vars.push(name);
|
|
170
|
+
if (fromSession !== undefined ? inputs.secretVars.has(name) : SECRET_VAR_NAME_RE.test(name))
|
|
171
|
+
secretValues.push(value);
|
|
172
|
+
return value;
|
|
173
|
+
});
|
|
174
|
+
return { text: out, vars, secretValues, missing };
|
|
175
|
+
}
|
|
176
|
+
/** What to RECORD for a typed value: the placeholder template when the agent typed one, else
|
|
177
|
+
* `${VAR}` when the value equals a stored session input (email/username included), else the
|
|
178
|
+
* literal. Keeps provided test data out of generated flows as literals. */
|
|
179
|
+
export function recordableTypedText(template, typed, vars, inputs) {
|
|
180
|
+
if (vars.length)
|
|
181
|
+
return template;
|
|
182
|
+
if (typed)
|
|
183
|
+
for (const [name, value] of inputs)
|
|
184
|
+
if (value && value === typed)
|
|
185
|
+
return `\${${name}}`;
|
|
186
|
+
return typed;
|
|
187
|
+
}
|
|
188
|
+
/** Typed failure code for a resolveTarget() error message. */
|
|
189
|
+
function targetErrorCode(error) {
|
|
190
|
+
if (/^AMBIGUOUS_SELECTOR/.test(error))
|
|
191
|
+
return 'AMBIGUOUS_SELECTOR';
|
|
192
|
+
if (/^No target provided/.test(error))
|
|
193
|
+
return 'INVALID_ARGUMENT';
|
|
194
|
+
if (/^No @e\d+ in the latest snapshot|no longer on screen/.test(error))
|
|
195
|
+
return 'STALE_REF';
|
|
196
|
+
if (/^No element matched/.test(error))
|
|
197
|
+
return 'ELEMENT_NOT_FOUND';
|
|
198
|
+
return 'UNKNOWN';
|
|
199
|
+
}
|
|
200
|
+
/** Minimum wait after a focus tap when the keyboard was already shown (field hop). */
|
|
201
|
+
export const IME_HOP_FLOOR_MS = 250;
|
|
202
|
+
/** A reported IME frame taller than this fraction of the screen is not a keyboard rect (older
|
|
203
|
+
* builds size the IME window near full-screen; accessory bars/suggestion strips can inflate it),
|
|
204
|
+
* so it is treated as UNKNOWN rather than as "the keyboard covers everything". */
|
|
205
|
+
export const IME_MAX_HEIGHT_FRACTION = 0.55;
|
|
206
|
+
/** Warning attached when the keyboard is up but its area could not be determined. */
|
|
207
|
+
export const IME_UNKNOWN_AREA_WARNING = 'keyboard is up; could not determine its area; tapped without hiding it';
|
|
208
|
+
/** Warning attached to a qa_act result when the WDA session had to be re-created (after an
|
|
209
|
+
* invalid-session error). The recovery sends forceAppLaunch:false + shouldTerminateApp:false, but
|
|
210
|
+
* WDA can still have lost the app (e.g. it crashed / WDA restarted), so this does not promise it. */
|
|
211
|
+
export const WDA_SESSION_RECOVERED_WARNING = 'WDA session was re-created (requested without relaunching the app); verify the screen state';
|
|
212
|
+
/** Keyboard state for the obstruction check: shown? and, when KNOWN, where. `rect` is absent
|
|
213
|
+
* when the backend cannot report a plausible frame. Callers must not guess (a bottom-40%
|
|
214
|
+
* guess hid the keyboard for targets that were actually above it: accessory bars, chips). */
|
|
215
|
+
async function keyboardArea(d) {
|
|
216
|
+
let frame;
|
|
217
|
+
if (d.imeState) {
|
|
218
|
+
// One round trip for shown + frame (Android: one adb shell; WDA: one keyboard lookup).
|
|
219
|
+
const st = await d.imeState().catch(() => ({ shown: false, frame: null }));
|
|
220
|
+
if (!st.shown)
|
|
221
|
+
return { shown: false };
|
|
222
|
+
frame = st.frame;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
if (!(await d.imeShown().catch(() => false)))
|
|
226
|
+
return { shown: false };
|
|
227
|
+
frame = d.imeFrame ? await d.imeFrame().catch(() => null) : null;
|
|
228
|
+
}
|
|
229
|
+
if (!frame)
|
|
230
|
+
return { shown: true };
|
|
231
|
+
const size = await d.screenSize().catch(() => null);
|
|
232
|
+
if (size && frame[3] - frame[1] > size.height * IME_MAX_HEIGHT_FRACTION)
|
|
233
|
+
return { shown: true };
|
|
234
|
+
return { shown: true, rect: frame };
|
|
235
|
+
}
|
|
236
|
+
function inRect(r, x, y) {
|
|
237
|
+
return !!r && x >= r[0] && x < r[2] && y >= r[1] && y < r[3];
|
|
238
|
+
}
|
|
239
|
+
/** After the keyboard hid, the layout may shift and refs renumber: re-find the SAME node
|
|
240
|
+
* (class/id/text/desc, nearest to its old position) in a fresh dump. */
|
|
241
|
+
async function reresolveAfterKeyboard(session, d, t, target) {
|
|
242
|
+
if (!t.ref || !target?.ref)
|
|
243
|
+
return resolveTarget(session, target); // text/id re-dump + re-match
|
|
244
|
+
const old = session.lastSnapshot?.fullByRef.get(t.ref);
|
|
245
|
+
const parsed = parseSnapshot(await d.dumpXml());
|
|
246
|
+
session.lastSnapshot = { fullByRef: parsed.fullByRef, signatures: new Set(parsed.elements.map(signature)), allNodes: parsed.allNodes };
|
|
247
|
+
if (!old)
|
|
248
|
+
return { error: `${t.ref} is no longer on screen after hiding the keyboard; re-run qa_snapshot.` };
|
|
249
|
+
let best;
|
|
250
|
+
for (const [ref, node] of parsed.fullByRef) {
|
|
251
|
+
if (node.cls !== old.cls || node.id !== old.id || node.text !== old.text || node.desc !== old.desc)
|
|
252
|
+
continue;
|
|
253
|
+
const c = center(node.bounds);
|
|
254
|
+
const dist = Math.abs(c.x - t.x) + Math.abs(c.y - t.y);
|
|
255
|
+
if (!best || dist < best.dist)
|
|
256
|
+
best = { ref, node, dist };
|
|
257
|
+
}
|
|
258
|
+
if (!best)
|
|
259
|
+
return { error: `${t.ref} is no longer on screen after hiding the keyboard; re-run qa_snapshot.` };
|
|
260
|
+
return { ...center(best.node.bounds), via: best.ref, ref: best.ref, textLen: best.node.text.length, secure: isSecureNode(best.node) };
|
|
261
|
+
}
|
|
262
|
+
/** H5: a tap on a point under the soft keyboard lands on a KEY (typing a stray character).
|
|
263
|
+
* Only when a KNOWN keyboard frame contains the target's center: hide the keyboard (never a
|
|
264
|
+
* blind BACK), re-resolve the target, and proceed when it is uncovered, else
|
|
265
|
+
* KEYBOARD_OBSTRUCTION. When the frame is UNKNOWN (no/implausible frame) the keyboard is NOT
|
|
266
|
+
* hidden: the tap proceeds with a warning. Returns the (possibly re-resolved) point, whether the
|
|
267
|
+
* IME is up at tap time, and an optional warning for the result. */
|
|
268
|
+
async function guardKeyboard(session, d, t, target) {
|
|
269
|
+
const kb = await keyboardArea(d);
|
|
270
|
+
if (!kb.shown)
|
|
271
|
+
return { t, imeUp: false };
|
|
272
|
+
if (!kb.rect)
|
|
273
|
+
return { t, imeUp: true, warning: IME_UNKNOWN_AREA_WARNING };
|
|
274
|
+
if (!inRect(kb.rect, t.x, t.y))
|
|
275
|
+
return { t, imeUp: true };
|
|
276
|
+
const hid = d.hideKeyboard ? await d.hideKeyboard().catch(() => false) : false;
|
|
277
|
+
if (!hid) {
|
|
278
|
+
return {
|
|
279
|
+
result: qaError({
|
|
280
|
+
what: `Target at (${t.x},${t.y}) is covered by the soft keyboard and this backend could not hide it. Nothing was tapped.`,
|
|
281
|
+
changedState: false,
|
|
282
|
+
retrySafe: true,
|
|
283
|
+
failureCode: 'KEYBOARD_OBSTRUCTION',
|
|
284
|
+
nextSteps: [
|
|
285
|
+
'Dismiss the keyboard (qa_clear_overlay strategy:"hide_keyboard", or press enter to submit), scroll the target above it, then retry.',
|
|
286
|
+
],
|
|
287
|
+
}, { blockedByOverlay: true, obstructedBy: { type: 'keyboard', bounds: kb.rect } }),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
await new Promise((r) => setTimeout(r, 300)); // let the layout reflow after the IME hides
|
|
291
|
+
const again = await reresolveAfterKeyboard(session, d, t, target);
|
|
292
|
+
if ('error' in again)
|
|
293
|
+
return {
|
|
294
|
+
result: qaError({
|
|
295
|
+
what: `The soft keyboard was hidden (it covered the target), but ${again.error} Nothing was tapped.`,
|
|
296
|
+
changedState: true,
|
|
297
|
+
retrySafe: true,
|
|
298
|
+
failureCode: 'KEYBOARD_OBSTRUCTION',
|
|
299
|
+
nextSteps: ['Run qa_snapshot to see the screen with the keyboard hidden, then retry.'],
|
|
300
|
+
}, { keyboardHidden: true }),
|
|
301
|
+
};
|
|
302
|
+
const after = await keyboardArea(d);
|
|
303
|
+
if (after.shown && after.rect && inRect(after.rect, again.x, again.y)) {
|
|
304
|
+
return {
|
|
305
|
+
result: qaError({
|
|
306
|
+
what: `The soft keyboard was hidden, but the target at (${again.x},${again.y}) is still covered by it. Nothing was tapped.`,
|
|
307
|
+
changedState: true,
|
|
308
|
+
retrySafe: true,
|
|
309
|
+
failureCode: 'KEYBOARD_OBSTRUCTION',
|
|
310
|
+
nextSteps: [
|
|
311
|
+
'Dismiss the keyboard (qa_clear_overlay strategy:"hide_keyboard", or press enter to submit), scroll the target above it, then retry.',
|
|
312
|
+
],
|
|
313
|
+
}, { blockedByOverlay: true, keyboardHidden: true, obstructedBy: { type: 'keyboard', bounds: after.rect } }),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
t: again,
|
|
318
|
+
imeUp: after.shown,
|
|
319
|
+
hidKeyboard: true,
|
|
320
|
+
...(after.shown && !after.rect ? { warning: IME_UNKNOWN_AREA_WARNING } : {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
// Per-action schema contract feasibility note: SDK ^1.19 registerTool DOES accept an
|
|
324
|
+
// arbitrary ZodType (AnySchema) as inputSchema, and validateToolInput parses unions correctly,
|
|
325
|
+
// but the tools/list serializer goes through normalizeObjectSchema(), which only understands
|
|
326
|
+
// plain object schemas / raw shapes and silently falls back to an EMPTY object schema for a
|
|
327
|
+
// root z.discriminatedUnion (verified empirically: the tool lists as {"type":"object",
|
|
328
|
+
// "properties":{}}, so the model would see NO fields at all). The strongest contract the SDK
|
|
329
|
+
// permits is therefore a flat ZodRawShape whose field descriptions name the action(s) they
|
|
330
|
+
// belong to, with the cross-field requirements enforced by this single table + validator
|
|
331
|
+
// (replacing the scattered per-case fail('X requires Y') calls).
|
|
332
|
+
const REQUIRED_BY_ACTION = {
|
|
333
|
+
type: ['text'],
|
|
334
|
+
swipe: ['direction'],
|
|
335
|
+
scroll: ['direction'],
|
|
336
|
+
press: ['key'],
|
|
337
|
+
open_url: ['url'],
|
|
338
|
+
};
|
|
339
|
+
/** The ONE cross-field validation layer. Names both the action and the missing field. */
|
|
340
|
+
function missingRequiredField(action, args) {
|
|
341
|
+
for (const field of REQUIRED_BY_ACTION[action] ?? []) {
|
|
342
|
+
// `text` may legitimately be '' (typing nothing in replace mode); the other fields may not.
|
|
343
|
+
if (args[field] == null || (field !== 'text' && args[field] === '')) {
|
|
344
|
+
return qaError({
|
|
345
|
+
what: `Action "${action}" requires \`${field}\`.`,
|
|
346
|
+
changedState: false,
|
|
347
|
+
retrySafe: true,
|
|
348
|
+
failureCode: 'INVALID_ARGUMENT',
|
|
349
|
+
nextSteps: [`Re-call qa_act with action:"${action}" and \`${field}\` set (see the ${field} parameter description).`],
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return undefined;
|
|
24
354
|
}
|
|
25
355
|
function screenTitleFromNodes(nodes) {
|
|
26
356
|
if (!nodes.length)
|
|
@@ -41,7 +371,13 @@ function recordingScreenContext(session) {
|
|
|
41
371
|
return { screen: screenTitleFromNodes(nodes), screenSig: structuredSignature(els) };
|
|
42
372
|
}
|
|
43
373
|
function recordableNativeTarget(session, native) {
|
|
44
|
-
const selectorKind = native.using === 'accessibility id'
|
|
374
|
+
const selectorKind = native.using === 'accessibility id'
|
|
375
|
+
? 'accessibility_id'
|
|
376
|
+
: native.using === 'name'
|
|
377
|
+
? 'name'
|
|
378
|
+
: native.using === 'predicate string'
|
|
379
|
+
? 'predicate'
|
|
380
|
+
: 'class_chain';
|
|
45
381
|
return {
|
|
46
382
|
selector: native.value,
|
|
47
383
|
selectorKind,
|
|
@@ -52,16 +388,10 @@ function recordableNativeTarget(session, native) {
|
|
|
52
388
|
export function registerAct(server, sessions) {
|
|
53
389
|
server.registerTool('qa_act', {
|
|
54
390
|
title: 'Act on the screen',
|
|
55
|
-
description: 'Perform one
|
|
56
|
-
'-
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
'- swipe: direction up|down|left|right (optional target as start point)\n' +
|
|
60
|
-
'- scroll: direction + optional untilVisible {text|id} + maxScrolls (default 8)\n' +
|
|
61
|
-
'- press: key back|home|enter\n' +
|
|
62
|
-
'- open_url: url (deep link)\n' +
|
|
63
|
-
'- wait: for {settled:true | ref | text | id | selector} + timeoutMs\n' +
|
|
64
|
-
'Every non-wait action auto-waits for the screen to settle, then returns the post snapshot (quality + @eN elements), whether the screen changed, and a health check.',
|
|
391
|
+
description: 'Perform one UI action, wait for the screen to settle, and observe: changed/settled, snapshot quality, a health check, and ' +
|
|
392
|
+
'post-action elements per observe. action: tap, type, clear, swipe, scroll, press, open_url, wait (each param names its ' +
|
|
393
|
+
'actions). Target @eN refs from qa_snapshot (invalid after navigation), text, id, a native selector (WDA iOS), or x/y. A field ' +
|
|
394
|
+
'hidden under the keyboard is handled (hide + re-resolve; KEYBOARD_OBSTRUCTION if still covered). See docs/tools.md#qa_act.',
|
|
65
395
|
inputSchema: {
|
|
66
396
|
sessionId: z.string(),
|
|
67
397
|
action: z.enum(['tap', 'type', 'clear', 'swipe', 'scroll', 'press', 'open_url', 'wait']),
|
|
@@ -70,44 +400,66 @@ export function registerAct(server, sessions) {
|
|
|
70
400
|
ref: z.string().optional(),
|
|
71
401
|
text: z.string().optional(),
|
|
72
402
|
id: z.string().optional(),
|
|
73
|
-
selector:
|
|
403
|
+
selector: nativeSelectorSchema.optional(),
|
|
74
404
|
index: z.number().optional(),
|
|
75
405
|
x: z.number().optional(),
|
|
76
406
|
y: z.number().optional(),
|
|
77
407
|
})
|
|
78
|
-
.optional()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
408
|
+
.optional()
|
|
409
|
+
.describe('tap/type/clear (required); swipe: optional start point.'),
|
|
410
|
+
text: z.string().optional().describe('type (required)'),
|
|
411
|
+
mode: z.enum(['replace', 'append']).optional().describe('type: replace (default) clears first.'),
|
|
412
|
+
submit: z.boolean().optional().describe('type: press enter after.'),
|
|
413
|
+
direction: z.enum(['up', 'down', 'left', 'right']).optional().describe('swipe/scroll (required)'),
|
|
414
|
+
untilVisible: z
|
|
415
|
+
.object({ text: z.string().optional(), id: z.string().optional() })
|
|
416
|
+
.optional()
|
|
417
|
+
.describe('scroll: stop once visible (checked before the first swipe).'),
|
|
418
|
+
maxScrolls: z.number().optional().describe('scroll: max swipes (default 8).'),
|
|
419
|
+
key: z.enum(['back', 'home', 'enter']).optional().describe('press (required)'),
|
|
420
|
+
url: z.string().optional().describe('open_url (required)'),
|
|
421
|
+
durationMs: z.number().optional().describe('tap: press duration (coordinate taps default ~100ms).'),
|
|
422
|
+
ignoreOverlay: z.boolean().optional().describe('tap: tap even if an overlay/keyboard covers the target.'),
|
|
89
423
|
for: z
|
|
90
424
|
.object({
|
|
91
425
|
settled: z.boolean().optional(),
|
|
92
426
|
ref: z.string().optional(),
|
|
93
427
|
text: z.string().optional(),
|
|
94
428
|
id: z.string().optional(),
|
|
95
|
-
selector:
|
|
429
|
+
selector: nativeSelectorSchema.optional(),
|
|
96
430
|
})
|
|
97
|
-
.optional()
|
|
98
|
-
|
|
431
|
+
.optional()
|
|
432
|
+
.describe('wait: {settled:true} (default) or an element.'),
|
|
433
|
+
timeoutMs: z.number().optional().describe('wait: max ms (default 8000); others: settle-wait cap.'),
|
|
434
|
+
observe: z
|
|
435
|
+
.enum(['diff', 'full', 'none'])
|
|
436
|
+
.optional()
|
|
437
|
+
.describe('diff (default after a snapshot): elements added/removed; full: capped list; none: verdicts only.'),
|
|
99
438
|
},
|
|
100
|
-
},
|
|
439
|
+
},
|
|
440
|
+
// Cancellation (MCP notifications/cancelled): the call's signal is scoped to THIS call
|
|
441
|
+
// (abortScope): an in-flight adb child / WDA request is aborted without touching a
|
|
442
|
+
// concurrently running job's own cancellation.
|
|
443
|
+
async (args, extra) => runWithSignal(extra?.signal, async () => {
|
|
101
444
|
const { sessionId, action } = args;
|
|
445
|
+
// Single validation layer for the per-action field contract (see REQUIRED_BY_ACTION).
|
|
446
|
+
const invalid = missingRequiredField(action, args);
|
|
447
|
+
if (invalid)
|
|
448
|
+
return invalid;
|
|
102
449
|
const session = sessions.get(sessionId);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
450
|
+
if (!session)
|
|
451
|
+
return unknownSessionError(sessionId);
|
|
452
|
+
const { driver: d, blocked } = await getDriver(session);
|
|
453
|
+
if (!d) {
|
|
454
|
+
// H6: a device is online but refused (physical) or not ready (still booting).
|
|
455
|
+
return (blockedDeviceResult(blocked) ??
|
|
456
|
+
qaError({
|
|
457
|
+
what: 'No device attached to this session',
|
|
458
|
+
changedState: false,
|
|
459
|
+
retrySafe: true,
|
|
460
|
+
failureCode: 'NO_DEVICE',
|
|
461
|
+
nextSteps: ['Call qa_prepare_target first.'],
|
|
462
|
+
}));
|
|
111
463
|
}
|
|
112
464
|
if (d.kind === 'simulator') {
|
|
113
465
|
return qaError({
|
|
@@ -115,34 +467,43 @@ export function registerAct(server, sessions) {
|
|
|
115
467
|
changedState: false,
|
|
116
468
|
retrySafe: false,
|
|
117
469
|
failureCode: 'BACKEND_UNSUPPORTED',
|
|
118
|
-
nextSteps: [
|
|
470
|
+
nextSteps: [
|
|
471
|
+
'Attach WebDriverAgent with qa_wda for structured tap/type/snapshot. Without WDA, locate targets with qa_visual (mode:"find_text" OCR or mode:"find_image" return tappable device coordinates; tap:true taps via idb when installed), navigate via qa_ios deep links, and verify with qa_visual mode:"assert" or mode:"diff".',
|
|
472
|
+
],
|
|
119
473
|
});
|
|
120
474
|
}
|
|
121
475
|
// Budget gate (review §4.1 / Rec 4): refuse new work once the session budget is spent.
|
|
122
476
|
// `wait` is exempt from the action/screenshot caps (it's synchronization, not an
|
|
123
|
-
// action), but it is NOT exempt from the TIME budget
|
|
477
|
+
// action), but it is NOT exempt from the TIME budget, otherwise repeated waits could
|
|
124
478
|
// burn the clock indefinitely.
|
|
125
479
|
const stopReason = sessions.budgetStop(session);
|
|
126
480
|
if (stopReason && (action !== 'wait' || /time budget/.test(stopReason))) {
|
|
127
481
|
return qaStop(stopReason, { counters: session.counters, mode: session.mode });
|
|
128
482
|
}
|
|
129
|
-
const fail = (what, changedState) => qaError({
|
|
483
|
+
const fail = (what, changedState) => qaError({
|
|
484
|
+
what,
|
|
485
|
+
changedState,
|
|
486
|
+
retrySafe: true,
|
|
487
|
+
failureCode: targetErrorCode(what),
|
|
488
|
+
nextSteps: ['Run qa_snapshot to see the current screen, then retry.'],
|
|
489
|
+
});
|
|
130
490
|
// ---- wait is its own path (no settle/health afterward) ----
|
|
131
491
|
if (action === 'wait') {
|
|
132
492
|
const timeoutMs = args.timeoutMs ?? 8000;
|
|
133
493
|
if (args.for?.settled || !args.for) {
|
|
134
494
|
const s = await settle(d, { timeoutMs });
|
|
135
495
|
const post = parseSnapshot(s.xml);
|
|
136
|
-
session.lastSnapshot = {
|
|
137
|
-
|
|
138
|
-
|
|
496
|
+
session.lastSnapshot = {
|
|
497
|
+
fullByRef: post.fullByRef,
|
|
498
|
+
signatures: new Set(post.elements.map(signature)),
|
|
499
|
+
allNodes: post.allNodes,
|
|
500
|
+
};
|
|
501
|
+
const { elements: shown, rendered, omitted } = presentElements(post.elements, makeRedactor(session.secrets));
|
|
502
|
+
return qaOk({ action, settled: s.settled, quality: post.quality.verdict, elementsOmitted: omitted, elements: shown }, `wait(settled)=${s.settled}\n\n${rendered}`, { textOmit: ['elements'] });
|
|
139
503
|
}
|
|
140
504
|
const deadline = Date.now() + timeoutMs;
|
|
141
505
|
const want = args.for;
|
|
142
|
-
const native =
|
|
143
|
-
if (want?.selector && !native) {
|
|
144
|
-
return qaError({ what: `Unsupported wait selector ${JSON.stringify(want.selector)}`, changedState: false, retrySafe: true, nextSteps: ['Use accessibility id=..., name=..., predicate string=..., class chain=..., or wait by text/id/ref.'] });
|
|
145
|
-
}
|
|
506
|
+
const native = want?.selector ?? null;
|
|
146
507
|
if (native) {
|
|
147
508
|
if (!d.existsBySelector)
|
|
148
509
|
return qaError({
|
|
@@ -154,37 +515,69 @@ export function registerAct(server, sessions) {
|
|
|
154
515
|
});
|
|
155
516
|
while (Date.now() < deadline) {
|
|
156
517
|
if (await d.existsBySelector(native.using, native.value)) {
|
|
157
|
-
return qaOk({ action, found: true, selector: want?.selector, via: 'native-selector' }, `wait: found ${
|
|
518
|
+
return qaOk({ action, found: true, selector: want?.selector, via: 'native-selector' }, `wait: found ${native.using}=${native.value}`);
|
|
158
519
|
}
|
|
159
520
|
await new Promise((r) => setTimeout(r, 400));
|
|
160
521
|
}
|
|
161
|
-
return qaError({
|
|
522
|
+
return qaError({
|
|
523
|
+
what: `wait timed out (${timeoutMs}ms) for ${JSON.stringify(want)}`,
|
|
524
|
+
changedState: false,
|
|
525
|
+
retrySafe: true,
|
|
526
|
+
failureCode: 'ELEMENT_NOT_FOUND',
|
|
527
|
+
nextSteps: ['Re-check the native selector value, or run qa_snapshot to inspect the current screen.'],
|
|
528
|
+
});
|
|
162
529
|
}
|
|
163
530
|
while (Date.now() < deadline) {
|
|
164
531
|
const parsed = parseSnapshot(await d.dumpXml());
|
|
165
|
-
session.lastSnapshot = {
|
|
532
|
+
session.lastSnapshot = {
|
|
533
|
+
fullByRef: parsed.fullByRef,
|
|
534
|
+
signatures: new Set(parsed.elements.map(signature)),
|
|
535
|
+
allNodes: parsed.allNodes,
|
|
536
|
+
};
|
|
166
537
|
const hit = parsed.elements.find((e) => (want.ref && e.ref === want.ref) ||
|
|
167
538
|
(want.id && e.id === want.id) ||
|
|
168
|
-
(want.text &&
|
|
539
|
+
(want.text &&
|
|
540
|
+
(e.text?.toLowerCase().includes(want.text.toLowerCase()) || e.label?.toLowerCase().includes(want.text.toLowerCase()))));
|
|
169
541
|
if (hit)
|
|
170
542
|
return qaOk({ action, found: true, ref: hit.ref }, `wait: found ${hit.ref}`);
|
|
171
543
|
await new Promise((r) => setTimeout(r, 400));
|
|
172
544
|
}
|
|
173
|
-
return qaError({
|
|
545
|
+
return qaError({
|
|
546
|
+
what: `wait timed out (${timeoutMs}ms) for ${JSON.stringify(want)}`,
|
|
547
|
+
changedState: false,
|
|
548
|
+
retrySafe: true,
|
|
549
|
+
failureCode: 'ELEMENT_NOT_FOUND',
|
|
550
|
+
nextSteps: ['Re-snapshot; the element may use different text/id.'],
|
|
551
|
+
});
|
|
174
552
|
}
|
|
175
553
|
// Phase timing (P1.6): mark the first real action so the report can split setup vs active.
|
|
176
554
|
sessions.milestone(session, 'first_action');
|
|
177
555
|
const preSigs = session.lastSnapshot?.signatures ?? new Set();
|
|
556
|
+
// F: positions too. A scroll/swipe can move content without adding/removing elements.
|
|
557
|
+
const prePositions = positionalFingerprint(session.lastSnapshot?.fullByRef);
|
|
558
|
+
// Toggle/selection state of the pre-action screen (a Switch flip changes no signature).
|
|
559
|
+
const preState = stateByIdentity(session.lastSnapshot?.fullByRef);
|
|
560
|
+
// The value actually typed (after ${SWIPIUM_*} expansion), redacted from results/errors.
|
|
561
|
+
let typedValue;
|
|
562
|
+
// untilVisible's last probe dump. Seeds the post-action settle (no redundant dump).
|
|
563
|
+
let settleSeed;
|
|
564
|
+
// I: set when the soft keyboard was hidden because it covered the target.
|
|
565
|
+
let keyboardHidden = false;
|
|
566
|
+
// Observation mode is fixed at action start; diff needs a pre-action baseline.
|
|
567
|
+
const observe = args.observe ?? (session.lastSnapshot ? 'diff' : 'full');
|
|
178
568
|
let meta = {};
|
|
179
569
|
// remembered so a no-change tap can be retried as a longer press (RN tap quirk)
|
|
570
|
+
// imeUp: the soft keyboard was shown at tap time, so a re-press could hit a key (H5).
|
|
180
571
|
let tapRetry;
|
|
181
572
|
// action-IR step to record once the action succeeds (built here while lastSnapshot is
|
|
182
573
|
// still the PRE-navigation screen, so a tapped @ref still resolves to its label).
|
|
183
574
|
let toRecord;
|
|
575
|
+
// Non-fatal caveats for the result (unknown keyboard area, recovered WDA session, …).
|
|
576
|
+
const warnings = [];
|
|
184
577
|
try {
|
|
185
578
|
switch (action) {
|
|
186
579
|
case 'tap': {
|
|
187
|
-
const native =
|
|
580
|
+
const native = args.target?.selector ?? null;
|
|
188
581
|
if (native) {
|
|
189
582
|
if (!d.tapBySelector)
|
|
190
583
|
return qaError({
|
|
@@ -199,13 +592,31 @@ export function registerAct(server, sessions) {
|
|
|
199
592
|
toRecord = { action: 'tap', ...recordableNativeTarget(session, native) };
|
|
200
593
|
break;
|
|
201
594
|
}
|
|
202
|
-
const
|
|
203
|
-
if ('error' in
|
|
204
|
-
return fail(
|
|
595
|
+
const resolved = await resolveTarget(session, stripSelector(args.target));
|
|
596
|
+
if ('error' in resolved)
|
|
597
|
+
return fail(resolved.error, false);
|
|
598
|
+
let t = resolved;
|
|
599
|
+
// H5: keyboard obstruction (the IME window is not in the app's UI dump, so the
|
|
600
|
+
// overlay check below can't see it). Coordinate taps are taken as deliberate.
|
|
601
|
+
let imeUp = false;
|
|
602
|
+
if (!args.ignoreOverlay && t.via !== 'coords') {
|
|
603
|
+
const g = await guardKeyboard(session, d, t, stripSelector(args.target));
|
|
604
|
+
if ('result' in g)
|
|
605
|
+
return g.result;
|
|
606
|
+
t = g.t;
|
|
607
|
+
imeUp = g.imeUp;
|
|
608
|
+
if (g.warning)
|
|
609
|
+
warnings.push(g.warning);
|
|
610
|
+
if (g.hidKeyboard)
|
|
611
|
+
keyboardHidden = true;
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
imeUp = await d.imeShown().catch(() => false);
|
|
615
|
+
}
|
|
205
616
|
// Overlay obstruction check (CR4): if another element is drawn over the target
|
|
206
617
|
// point, return a structured blockedByOverlay instead of tapping blindly.
|
|
207
618
|
if (!args.ignoreOverlay && t.via !== 'coords' && session.lastSnapshot?.allNodes) {
|
|
208
|
-
// works for ref AND selector taps
|
|
619
|
+
// works for ref AND selector taps: t.ref is the resolved @eN in either case
|
|
209
620
|
const node = t.ref ? session.lastSnapshot.fullByRef.get(t.ref) : undefined;
|
|
210
621
|
const obs = obstructionAt(session.lastSnapshot.allNodes, node, t.x, t.y);
|
|
211
622
|
if (obs.obstructed) {
|
|
@@ -214,7 +625,9 @@ export function registerAct(server, sessions) {
|
|
|
214
625
|
changedState: false,
|
|
215
626
|
retrySafe: true,
|
|
216
627
|
failureCode: 'OVERLAY_OBSTRUCTION',
|
|
217
|
-
nextSteps: [
|
|
628
|
+
nextSteps: [
|
|
629
|
+
'Call qa_clear_overlay (auto, or hide_keyboard/minimize_logbox), then retry. Or pass ignoreOverlay:true to tap anyway.',
|
|
630
|
+
],
|
|
218
631
|
}, { blockedByOverlay: true, obstructedBy: obs.by });
|
|
219
632
|
}
|
|
220
633
|
}
|
|
@@ -226,15 +639,49 @@ export function registerAct(server, sessions) {
|
|
|
226
639
|
await d.pressXY(t.x, t.y, durationMs);
|
|
227
640
|
else
|
|
228
641
|
await d.tapXY(t.x, t.y);
|
|
229
|
-
tapRetry = { x: t.x, y: t.y, instant: !durationMs };
|
|
642
|
+
tapRetry = { x: t.x, y: t.y, instant: !durationMs, imeUp };
|
|
230
643
|
meta = { tappedAt: [t.x, t.y], via: t.via, ...(durationMs ? { durationMs } : {}) };
|
|
231
|
-
toRecord = { action: 'tap', ...recordableTap(session, args.target, t) };
|
|
644
|
+
toRecord = { action: 'tap', ...recordableTap(session, stripSelector(args.target), t) };
|
|
232
645
|
break;
|
|
233
646
|
}
|
|
234
647
|
case 'type': {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
648
|
+
// presence enforced by missingRequiredField
|
|
649
|
+
// P1: `${SWIPIUM_*}` placeholders expand from session inputs (qa_continue_from_blocker) or the env,
|
|
650
|
+
// so credentials never have to pass through the agent transcript.
|
|
651
|
+
const secretVars = new Set(session.inputs.filter((i) => i.secret).map((i) => i.varName));
|
|
652
|
+
const expanded = expandInputPlaceholders(args.text, { values: session.inputValues, secretVars });
|
|
653
|
+
if (expanded.missing.length) {
|
|
654
|
+
return qaError({
|
|
655
|
+
what: `No value for ${expanded.missing.map((v) => `\${${v}}`).join(', ')}. Nothing was typed.`,
|
|
656
|
+
changedState: false,
|
|
657
|
+
retrySafe: true,
|
|
658
|
+
failureCode: 'MISSING_TEST_DATA',
|
|
659
|
+
nextSteps: [
|
|
660
|
+
'Provide it via qa_continue_from_blocker (needs_input credentials) or set the env var for the Swipium server, then retry. Only ${SWIPIUM_*} placeholders are expanded.',
|
|
661
|
+
],
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
for (const v of expanded.secretValues)
|
|
665
|
+
session.secrets.add(v);
|
|
666
|
+
const text = expanded.text;
|
|
667
|
+
typedValue = text;
|
|
668
|
+
const recordText = recordableTypedText(args.text, text, expanded.vars, session.inputValues);
|
|
669
|
+
// D: validate deliverability BEFORE any focus tap / clear. A refused value must
|
|
670
|
+
// leave the field (and the device) exactly as it was.
|
|
671
|
+
const deliverable = d.canDeliverText?.(text);
|
|
672
|
+
if (deliverable && !deliverable.ok) {
|
|
673
|
+
return qaError({
|
|
674
|
+
what: `${deliverable.reason} Nothing was tapped or cleared.`,
|
|
675
|
+
changedState: false,
|
|
676
|
+
retrySafe: false,
|
|
677
|
+
failureCode: 'TEXT_INPUT_UNSUPPORTED',
|
|
678
|
+
nextSteps: ['Use ASCII-safe text on this backend (adb `input text`), or type it on a Unicode-safe backend.'],
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
// A: a value that is/contains a registered secret is recorded as secret even when the
|
|
682
|
+
// target field is not a secure one (checked before this call can register it).
|
|
683
|
+
const knownSecret = matchesSessionSecret(session.secrets, text);
|
|
684
|
+
const native = args.target?.selector ?? null;
|
|
238
685
|
if (native) {
|
|
239
686
|
if (!d.typeBySelector)
|
|
240
687
|
return qaError({
|
|
@@ -244,61 +691,121 @@ export function registerAct(server, sessions) {
|
|
|
244
691
|
failureCode: 'BACKEND_UNSUPPORTED',
|
|
245
692
|
nextSteps: ['Use a WDA-backed iOS session, or target by ref/text/id/coordinates on this backend.'],
|
|
246
693
|
});
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
694
|
+
const replace = (args.mode ?? 'replace') === 'replace';
|
|
695
|
+
if (replace && !d.clearBySelector)
|
|
696
|
+
return qaError({
|
|
697
|
+
what: `replace-mode typing by ${native.using} requires backend-native clear support`,
|
|
698
|
+
changedState: false,
|
|
699
|
+
retrySafe: false,
|
|
700
|
+
failureCode: 'BACKEND_UNSUPPORTED',
|
|
701
|
+
nextSteps: ['Use append mode, or attach a WDA backend that supports element clear.'],
|
|
702
|
+
});
|
|
703
|
+
// This path must capture secrets exactly like the generic path below, or a
|
|
704
|
+
// password typed by native selector is recorded VERBATIM into generated flows.
|
|
705
|
+
// Heuristic first (selector value looks secret, same SECRET_RE the generic
|
|
706
|
+
// resolver applies to ids), then the real signal: probe the resolved element's
|
|
707
|
+
// type (XCUIElementTypeSecureTextField). A failing probe keeps the heuristic verdict.
|
|
708
|
+
let secure = isSecureNode({ id: native.value, desc: '', attrs: {} });
|
|
709
|
+
if (!secure && d.isSecureBySelector) {
|
|
710
|
+
try {
|
|
711
|
+
secure = await d.isSecureBySelector(native.using, native.value);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
// probe unavailable (older WDA / element churn): heuristic alone decides
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (secure) {
|
|
718
|
+
session.secrets.add(text);
|
|
719
|
+
sessions.markAuth(session, { loginPerformed: true, loginPerformedAt: Date.now() });
|
|
720
|
+
sessions.milestone(session, 'login_performed');
|
|
721
|
+
}
|
|
722
|
+
if (replace)
|
|
256
723
|
await d.clearBySelector(native.using, native.value);
|
|
724
|
+
await d.typeBySelector(native.using, native.value, text);
|
|
725
|
+
const recordSecret = secure || knownSecret;
|
|
726
|
+
meta = {
|
|
727
|
+
typedChars: text.length,
|
|
728
|
+
// The response never echoes the value; `redacted`/`secret` flag a value treated as
|
|
729
|
+
// secret (registered for scrubbing, recorded only as a placeholder).
|
|
730
|
+
...(recordSecret ? { redacted: true, secret: true } : {}),
|
|
731
|
+
...(expanded.vars.length ? { placeholders: expanded.vars } : {}),
|
|
732
|
+
mode: args.mode ?? 'replace',
|
|
733
|
+
via: 'native-selector',
|
|
734
|
+
selector: args.target?.selector,
|
|
735
|
+
submit: !!args.submit,
|
|
736
|
+
};
|
|
737
|
+
// Never store a secret's value in the IR; secrets become a ${VAR} at generate time.
|
|
738
|
+
{
|
|
739
|
+
const nativeTarget = recordableNativeTarget(session, native);
|
|
740
|
+
toRecord = {
|
|
741
|
+
action: 'type',
|
|
742
|
+
...nativeTarget,
|
|
743
|
+
secret: recordSecret,
|
|
744
|
+
// a secret is recorded only as its ${VAR} placeholder (never the value)
|
|
745
|
+
text: recordSecret ? (recordText !== text ? recordText : undefined) : recordText,
|
|
746
|
+
exportability: recordSecret ? 'needs-human-data' : nativeTarget.exportability,
|
|
747
|
+
};
|
|
257
748
|
}
|
|
258
|
-
await d.typeBySelector(native.using, native.value, args.text);
|
|
259
|
-
meta = { typedChars: args.text.length, redacted: true, mode: args.mode ?? 'replace', via: 'native-selector', selector: args.target?.selector, submit: !!args.submit };
|
|
260
|
-
toRecord = { action: 'type', ...recordableNativeTarget(session, native), text: args.text };
|
|
261
749
|
if (args.submit)
|
|
262
750
|
await d.pressKey('enter');
|
|
263
751
|
break;
|
|
264
752
|
}
|
|
265
|
-
const
|
|
266
|
-
if ('error' in
|
|
267
|
-
return fail(
|
|
268
|
-
|
|
753
|
+
const resolved = await resolveTarget(session, stripSelector(args.target));
|
|
754
|
+
if ('error' in resolved)
|
|
755
|
+
return fail(resolved.error, false);
|
|
756
|
+
let t = resolved;
|
|
757
|
+
// H5: focusing a field hidden under the keyboard would type a stray key character.
|
|
758
|
+
const g = await guardKeyboard(session, d, t, stripSelector(args.target));
|
|
759
|
+
if ('result' in g)
|
|
760
|
+
return g.result;
|
|
761
|
+
t = g.t;
|
|
762
|
+
if (g.warning)
|
|
763
|
+
warnings.push(g.warning);
|
|
764
|
+
if (g.hidKeyboard)
|
|
765
|
+
keyboardHidden = true;
|
|
766
|
+
// Typing into a secure field > remember the value so it's scrubbed everywhere, and
|
|
269
767
|
// record that a login was performed (auth-state reporting, P1.5).
|
|
270
768
|
if (t.secure) {
|
|
271
|
-
session.secrets.add(
|
|
769
|
+
session.secrets.add(text);
|
|
272
770
|
sessions.markAuth(session, { loginPerformed: true, loginPerformedAt: Date.now() });
|
|
273
771
|
sessions.milestone(session, 'login_performed');
|
|
274
772
|
}
|
|
275
773
|
await d.tapXY(t.x, t.y); // focus + raise IME (real touch)
|
|
276
|
-
await
|
|
774
|
+
await awaitIme(d, 700, g.imeUp ? IME_HOP_FLOOR_MS : 0);
|
|
277
775
|
if ((args.mode ?? 'replace') === 'replace')
|
|
278
776
|
await d.clearFocusedText(t.textLen);
|
|
279
|
-
await d.inputText(
|
|
777
|
+
await d.inputText(text);
|
|
280
778
|
if (args.submit)
|
|
281
779
|
await d.pressKey('enter');
|
|
282
|
-
// Never echo the typed value
|
|
283
|
-
// leak into the agent transcript + artifacts (
|
|
284
|
-
|
|
285
|
-
|
|
780
|
+
// Never echo the typed value. It may be a password/OTP/email/token and would
|
|
781
|
+
// leak into the agent transcript + artifacts (sensitive-mode).
|
|
782
|
+
const recordSecret = !!t.secure || knownSecret;
|
|
783
|
+
meta = {
|
|
784
|
+
typedChars: text.length,
|
|
785
|
+
...(recordSecret ? { redacted: true, secret: true } : {}),
|
|
786
|
+
...(expanded.vars.length ? { placeholders: expanded.vars } : {}),
|
|
787
|
+
mode: args.mode ?? 'replace',
|
|
788
|
+
via: t.via,
|
|
789
|
+
submit: !!args.submit,
|
|
790
|
+
};
|
|
791
|
+
// Never store a secret's value in the IR; secrets become a ${VAR} at generate time.
|
|
286
792
|
{
|
|
287
|
-
const targetRecord = recordableTap(session, args.target, t);
|
|
793
|
+
const targetRecord = recordableTap(session, stripSelector(args.target), t);
|
|
288
794
|
toRecord = {
|
|
289
795
|
action: 'type',
|
|
290
796
|
selector: targetRecord.selector,
|
|
291
797
|
selectorKind: targetRecord.selectorKind,
|
|
292
|
-
secret:
|
|
293
|
-
|
|
294
|
-
|
|
798
|
+
secret: recordSecret,
|
|
799
|
+
// a secret is recorded only as its ${VAR} placeholder (never the value)
|
|
800
|
+
text: recordSecret ? (recordText !== text ? recordText : undefined) : recordText,
|
|
801
|
+
exportability: recordSecret ? 'needs-human-data' : targetRecord.exportability,
|
|
295
802
|
provenance: targetRecord.provenance,
|
|
296
803
|
};
|
|
297
804
|
}
|
|
298
805
|
break;
|
|
299
806
|
}
|
|
300
807
|
case 'clear': {
|
|
301
|
-
const native =
|
|
808
|
+
const native = args.target?.selector ?? null;
|
|
302
809
|
if (native) {
|
|
303
810
|
if (!d.clearBySelector)
|
|
304
811
|
return qaError({
|
|
@@ -313,97 +820,190 @@ export function registerAct(server, sessions) {
|
|
|
313
820
|
toRecord = { action: 'clear', ...recordableNativeTarget(session, native) };
|
|
314
821
|
break;
|
|
315
822
|
}
|
|
316
|
-
const
|
|
317
|
-
if ('error' in
|
|
318
|
-
return fail(
|
|
319
|
-
await
|
|
320
|
-
|
|
823
|
+
const resolved = await resolveTarget(session, stripSelector(args.target));
|
|
824
|
+
if ('error' in resolved)
|
|
825
|
+
return fail(resolved.error, false);
|
|
826
|
+
const g = await guardKeyboard(session, d, resolved, stripSelector(args.target));
|
|
827
|
+
if ('result' in g)
|
|
828
|
+
return g.result;
|
|
829
|
+
const t = g.t;
|
|
830
|
+
if (g.warning)
|
|
831
|
+
warnings.push(g.warning);
|
|
832
|
+
if (g.hidKeyboard)
|
|
833
|
+
keyboardHidden = true;
|
|
834
|
+
await d.tapXY(t.x, t.y); // focus + raise IME (real touch)
|
|
835
|
+
await awaitIme(d, 400, g.imeUp ? IME_HOP_FLOOR_MS : 0);
|
|
321
836
|
await d.clearFocusedText(t.textLen);
|
|
322
837
|
meta = { cleared: true, via: t.via };
|
|
323
|
-
toRecord = { action: 'clear', ...recordableTap(session, args.target, t) };
|
|
838
|
+
toRecord = { action: 'clear', ...recordableTap(session, stripSelector(args.target), t) };
|
|
324
839
|
break;
|
|
325
840
|
}
|
|
326
841
|
case 'swipe': {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
842
|
+
const direction = args.direction; // presence enforced by missingRequiredField
|
|
843
|
+
// Derive the gesture from the real screen size. WDA swipes are in POINTS
|
|
844
|
+
// (≤~440pt wide), so the old fixed 540/1200 constants were off-screen on iOS; the
|
|
845
|
+
// legacy constants survive only inside the shared fallback for screenSize()===null.
|
|
846
|
+
// A supplied target that fails to resolve is a structured error (not a
|
|
847
|
+
// silent default swipe), and a resolved point is used verbatim (0 is a legitimate
|
|
848
|
+
// coordinate). Endpoints keep an ~8% inset clear of iOS system-gesture zones.
|
|
849
|
+
const size = await d.screenSize().catch(() => null);
|
|
850
|
+
let vec;
|
|
851
|
+
if (args.target) {
|
|
852
|
+
const start = await resolveTarget(session, stripSelector(args.target));
|
|
853
|
+
if ('error' in start)
|
|
854
|
+
return fail(start.error, false);
|
|
855
|
+
vec = swipeFromPoint(size, { x: start.x, y: start.y }, direction, 0.5, GESTURE_EDGE_INSET);
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
vec = swipeVector(size, direction, 'center', 0.5, GESTURE_EDGE_INSET);
|
|
859
|
+
}
|
|
860
|
+
await d.swipe(vec[0], vec[1], vec[2], vec[3], 300);
|
|
861
|
+
meta = { direction };
|
|
862
|
+
toRecord = { action: 'swipe', direction, exportability: 'coordinate' };
|
|
343
863
|
break;
|
|
344
864
|
}
|
|
345
865
|
case 'scroll': {
|
|
346
|
-
if (!args.direction)
|
|
347
|
-
return fail('scroll requires `direction`.', false);
|
|
348
866
|
const max = args.maxScrolls ?? 8;
|
|
349
|
-
// scroll down =
|
|
350
|
-
|
|
867
|
+
// scroll down = FINGER swipes up (same for the other directions where finger ==
|
|
868
|
+
// content axis). Screen-relative + clamped vector, size fetched once.
|
|
869
|
+
const finger = { down: 'up', up: 'down', left: 'left', right: 'right' };
|
|
870
|
+
const size = await d.screenSize().catch(() => null);
|
|
871
|
+
const dir = args.direction; // presence enforced by missingRequiredField
|
|
872
|
+
const u = args.untilVisible;
|
|
873
|
+
// A match only counts as FOUND when its center is on screen and not under the soft
|
|
874
|
+
// keyboard: a row just crossing the bottom edge is "in the tree" but a center tap on it
|
|
875
|
+
// would land off-screen / on a key, so keep swiping instead.
|
|
876
|
+
const kb = u ? await keyboardArea(d) : undefined;
|
|
877
|
+
const probe = async () => {
|
|
878
|
+
const xml = await d.dumpXml();
|
|
879
|
+
const at = Date.now();
|
|
880
|
+
const parsed = parseSnapshot(xml);
|
|
881
|
+
const [sw, sh] = size ? [size.width, size.height] : parsed.screen;
|
|
882
|
+
const hit = parsed.elements.some((e) => {
|
|
883
|
+
const match = (u?.id && e.id === u.id) ||
|
|
884
|
+
(u?.text &&
|
|
885
|
+
(e.text?.toLowerCase().includes(u.text.toLowerCase()) || e.label?.toLowerCase().includes(u.text.toLowerCase())));
|
|
886
|
+
if (!match)
|
|
887
|
+
return false;
|
|
888
|
+
const c = center(e.bounds);
|
|
889
|
+
const onScreen = !(sw > 0 && sh > 0) || (c.x >= 0 && c.y >= 0 && c.x < sw && c.y < sh);
|
|
890
|
+
return onScreen && !inRect(kb?.rect, c.x, c.y);
|
|
891
|
+
});
|
|
892
|
+
// Positions are part of the signature: a list that moved but still shows the same
|
|
893
|
+
// labels is not at its end (the label-only signature reported endOfList mid-list).
|
|
894
|
+
const sig = parsed.elements
|
|
895
|
+
.map((e) => `${signature(e)}@${e.bounds.join(',')}`)
|
|
896
|
+
.sort()
|
|
897
|
+
.join('\n');
|
|
898
|
+
settleSeed = { xml, at };
|
|
899
|
+
return { hit, sig, nodes: parsed.allNodes };
|
|
900
|
+
};
|
|
901
|
+
// B: anchor the swipe INSIDE the largest scrollable container (a swipe that starts on
|
|
902
|
+
// a sticky app bar moves nothing); screen center only when no container is known.
|
|
903
|
+
let anchorNodes = session.lastSnapshot?.allNodes;
|
|
904
|
+
if (!anchorNodes && !u)
|
|
905
|
+
anchorNodes = await d.dumpXml().then((x) => parseSnapshot(x).allNodes, () => undefined);
|
|
906
|
+
let anchoredIn = 'screen';
|
|
907
|
+
const nextVec = () => {
|
|
908
|
+
const rect = largestScrollableRect(anchorNodes, size);
|
|
909
|
+
anchoredIn = rect ? 'scrollable' : 'screen';
|
|
910
|
+
return rect
|
|
911
|
+
? swipeInRect(rect, finger[dir], size, 0.6, SCROLL_CONTAINER_INSET, GESTURE_EDGE_INSET)
|
|
912
|
+
: swipeVector(size, finger[dir], 'center', 0.6, GESTURE_EDGE_INSET);
|
|
913
|
+
};
|
|
351
914
|
let found = false;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
915
|
+
let endOfList = false;
|
|
916
|
+
let swipes = 0;
|
|
917
|
+
let prevSig;
|
|
918
|
+
// Already visible? Then don't swipe at all (it could scroll the target away).
|
|
919
|
+
if (u) {
|
|
920
|
+
const first = await probe();
|
|
921
|
+
found = first.hit;
|
|
922
|
+
prevSig = first.sig;
|
|
923
|
+
anchorNodes = first.nodes;
|
|
924
|
+
}
|
|
925
|
+
// C: a plain scroll is exactly ONE swipe; only untilVisible loops (up to maxScrolls).
|
|
926
|
+
const limit = u ? max : 1;
|
|
927
|
+
for (let i = 0; i < limit && !found; i++) {
|
|
928
|
+
const vec = nextVec();
|
|
362
929
|
await d.swipe(vec[0], vec[1], vec[2], vec[3], 300);
|
|
363
|
-
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
930
|
+
swipes++;
|
|
931
|
+
if (u) {
|
|
932
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
933
|
+
const now = await probe();
|
|
934
|
+
anchorNodes = now.nodes;
|
|
935
|
+
found = now.hit;
|
|
368
936
|
if (found)
|
|
369
937
|
break;
|
|
938
|
+
// Screen identical after a swipe > end of the list; more swipes can't help.
|
|
939
|
+
if (now.sig === prevSig) {
|
|
940
|
+
endOfList = true;
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
prevSig = now.sig;
|
|
370
944
|
}
|
|
371
945
|
}
|
|
372
|
-
meta = {
|
|
373
|
-
|
|
946
|
+
meta = {
|
|
947
|
+
direction: dir,
|
|
948
|
+
swipes,
|
|
949
|
+
...(swipes ? { anchoredIn } : {}),
|
|
950
|
+
untilVisibleFound: u ? found : undefined,
|
|
951
|
+
...(endOfList ? { endOfList: true } : {}),
|
|
952
|
+
};
|
|
953
|
+
toRecord = {
|
|
954
|
+
action: 'scroll',
|
|
955
|
+
direction: dir,
|
|
956
|
+
selector: args.untilVisible?.text,
|
|
957
|
+
exportability: args.untilVisible?.text ? 'semantic' : 'coordinate',
|
|
958
|
+
};
|
|
374
959
|
break;
|
|
375
960
|
}
|
|
376
961
|
case 'press': {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
962
|
+
const key = args.key; // presence enforced by missingRequiredField
|
|
963
|
+
await d.pressKey(key);
|
|
964
|
+
meta = { key };
|
|
965
|
+
// iOS has no back key: WdaDriver taps the nav-bar back button or edge-swipes. Say which.
|
|
966
|
+
if (key === 'back' && d.kind === 'wda') {
|
|
967
|
+
const via = d.lastBackVia;
|
|
968
|
+
if (via)
|
|
969
|
+
meta.backVia = via;
|
|
970
|
+
}
|
|
971
|
+
toRecord = { action: 'press', key, exportability: 'semantic' };
|
|
382
972
|
break;
|
|
383
973
|
}
|
|
384
974
|
case 'open_url': {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
toRecord = { action: 'open_url', url: args.url, exportability: 'semantic' };
|
|
975
|
+
const url = args.url; // presence enforced by missingRequiredField
|
|
976
|
+
await d.openUrl(url);
|
|
977
|
+
meta = { url };
|
|
978
|
+
toRecord = { action: 'open_url', url, exportability: 'semantic' };
|
|
390
979
|
break;
|
|
391
980
|
}
|
|
392
981
|
}
|
|
393
982
|
}
|
|
394
983
|
catch (e) {
|
|
395
|
-
|
|
984
|
+
// Cancelled mid-action: not a driver failure (no WDA_UNREACHABLE / SNAPSHOT_FAILED tool
|
|
985
|
+
// error, no finding). The action may have partly run, so changedState stays true.
|
|
986
|
+
if (isAbortError(e))
|
|
987
|
+
return cancelledResult(`Action "${action}" cancelled: the call was aborted before it finished`, true);
|
|
988
|
+
const msg = String(e?.message ?? e);
|
|
989
|
+
const failureCode = /BACKEND_UNSUPPORTED|not supported by the WDA backend/.test(msg)
|
|
990
|
+
? 'BACKEND_UNSUPPORTED'
|
|
991
|
+
: classifyFlowDriverError(e);
|
|
992
|
+
// H2: driver errors can echo argv/stderr. Scrub known secrets AND the value just typed.
|
|
993
|
+
const redactErr = makeRedactor(errorSecrets(session, action === 'type' ? (typedValue ?? args.text) : undefined));
|
|
396
994
|
return qaError({
|
|
397
|
-
what: `Action "${action}" failed: ${String(e)}`,
|
|
995
|
+
what: `Action "${action}" failed: ${redactErr(String(e)) ?? ''}`,
|
|
398
996
|
changedState: true,
|
|
399
997
|
retrySafe: !['WDA_SESSION_FAILED', 'UNKNOWN'].includes(failureCode),
|
|
400
998
|
failureCode,
|
|
401
|
-
nextSteps: failureCode === 'UNKNOWN'
|
|
999
|
+
nextSteps: failureCode === 'UNKNOWN'
|
|
1000
|
+
? ['Confirm the device is online and re-snapshot.']
|
|
1001
|
+
: ['Use the failureCode to choose recovery, then re-snapshot before retrying.'],
|
|
402
1002
|
});
|
|
403
1003
|
}
|
|
404
1004
|
// count this as an action (wait already returned earlier)
|
|
405
1005
|
sessions.bump(session, 'actions');
|
|
406
|
-
// Record the action into the IR for
|
|
1006
|
+
// Record the action into the IR for qa_generate target:"flow" (the action definitely happened here;
|
|
407
1007
|
// recorded now so a later post-snapshot failure doesn't lose the step).
|
|
408
1008
|
if (toRecord) {
|
|
409
1009
|
const sc = recordingScreenContext(session);
|
|
@@ -412,24 +1012,41 @@ export function registerAct(server, sessions) {
|
|
|
412
1012
|
// Post-action observation is wrapped so a settle/dump/parse failure (e.g. the
|
|
413
1013
|
// looping-animation case) returns a Swipium-shaped result, never a raw MCP error.
|
|
414
1014
|
try {
|
|
415
|
-
// settle
|
|
416
|
-
|
|
1015
|
+
// settle > observe > health (seeded with untilVisible's last probe; it was taken after the
|
|
1016
|
+
// last swipe, so re-dumping it first would be pure latency)
|
|
1017
|
+
let s = await settle(d, { timeoutMs: args.timeoutMs ?? 8000, ...(settleSeed ? { seed: settleSeed } : {}) });
|
|
1018
|
+
// Cancelled while observing: an empty/aborted dump is not evidence (no WDA_UNREACHABLE
|
|
1019
|
+
// health finding, no visual-fallback switch).
|
|
1020
|
+
if (isAbortError(undefined))
|
|
1021
|
+
return cancelledResult(`Action "${action}" ran, but the call was cancelled before its result was observed`, true);
|
|
417
1022
|
let post = parseSnapshot(s.xml);
|
|
418
1023
|
let postSigs = new Set(post.elements.map(signature));
|
|
419
|
-
|
|
1024
|
+
// F: for gestures that move content, a bounds shift counts as a change too.
|
|
1025
|
+
const positional = action === 'scroll' || action === 'swipe';
|
|
1026
|
+
const movedContent = () => positional && prePositions !== undefined && prePositions !== positionalFingerprint(post.fullByRef);
|
|
1027
|
+
// Toggle state counts: a Switch/Checkbox flip keeps every signature (role|label|id|text).
|
|
1028
|
+
let toggled = stateChangedRefs(preState, post.fullByRef);
|
|
1029
|
+
let changed = !setsEqual(preSigs, postSigs) || movedContent() || toggled.length > 0;
|
|
420
1030
|
// No-change retry (review §4.5/§4.7): an instant tap that did nothing is often the RN
|
|
421
|
-
// tap quirk
|
|
1031
|
+
// tap quirk. Retry ONCE as a longer press before believing it's blocked.
|
|
422
1032
|
let retriedAsPress = false;
|
|
423
|
-
|
|
1033
|
+
// Never when the keyboard was up at tap time: the retry would re-press a point the IME
|
|
1034
|
+
// may own and type a second stray character (H5).
|
|
1035
|
+
if (!changed && action === 'tap' && tapRetry?.instant && !tapRetry.imeUp) {
|
|
424
1036
|
await d.pressXY(tapRetry.x, tapRetry.y, 120);
|
|
425
1037
|
retriedAsPress = true;
|
|
426
1038
|
s = await settle(d, { timeoutMs: args.timeoutMs ?? 8000 });
|
|
427
1039
|
post = parseSnapshot(s.xml);
|
|
428
1040
|
postSigs = new Set(post.elements.map(signature));
|
|
429
|
-
|
|
1041
|
+
toggled = stateChangedRefs(preState, post.fullByRef);
|
|
1042
|
+
changed = !setsEqual(preSigs, postSigs) || movedContent() || toggled.length > 0;
|
|
430
1043
|
}
|
|
431
1044
|
session.lastSnapshot = { fullByRef: post.fullByRef, signatures: postSigs, allNodes: post.allNodes };
|
|
432
|
-
|
|
1045
|
+
// A structured dump succeeded > a visual-fallback session is structured again (per-screen).
|
|
1046
|
+
const modeRecovered = s.xml ? sessions.noteStructuredDump(session) : false;
|
|
1047
|
+
const health = await checkHealth(d, session.appId, s.xml, { nodes: post.allNodes });
|
|
1048
|
+
if (health.cancelled)
|
|
1049
|
+
return cancelledResult(`Action "${action}" ran, but the call was cancelled before its result was observed`, true);
|
|
433
1050
|
// Track no-change actions for the budget / no-op-loop detector.
|
|
434
1051
|
if (!changed && (action === 'tap' || action === 'swipe' || action === 'scroll' || action === 'press')) {
|
|
435
1052
|
sessions.bump(session, 'noChangeActions');
|
|
@@ -437,18 +1054,85 @@ export function registerAct(server, sessions) {
|
|
|
437
1054
|
const budgetReached = sessions.budgetStop(session);
|
|
438
1055
|
// Sensitive-mode present: mask secure fields + scrub known secrets AND the just-typed
|
|
439
1056
|
// value (covers non-secure fields like email for this immediate response).
|
|
440
|
-
const redact = makeRedactor([...session.secrets, ...(action === 'type' &&
|
|
441
|
-
const { elements: outElements, rendered } = presentElements(post.elements, redact);
|
|
1057
|
+
const redact = makeRedactor([...session.secrets, ...(action === 'type' && typedValue ? [typedValue] : [])]);
|
|
442
1058
|
// Record non-info findings for qa_report (deterministic bug trail) + app-error screenshot.
|
|
443
1059
|
await recordHealthFindings(sessions, session, health.findings, d, health.foreground);
|
|
444
|
-
const banner = `${action} ${JSON.stringify(meta)}
|
|
1060
|
+
const banner = `${action} ${JSON.stringify(meta)} > changed=${changed}${retriedAsPress ? ' (retried as press)' : ''} ` +
|
|
445
1061
|
`settled=${s.settled} quality=${post.quality.verdict} native=${health.nativeHealthy ? 'ok' : health.nativeStatus} app=${health.appStatus}` +
|
|
446
|
-
(budgetReached ? `\n⏹ budget reached: ${budgetReached}
|
|
447
|
-
(
|
|
448
|
-
|
|
1062
|
+
(budgetReached ? `\n⏹ budget reached: ${budgetReached}, call qa_report.` : '') +
|
|
1063
|
+
(modeRecovered ? '\nmode: structured again (a UI tree dump succeeded; visual-fallback cleared)' : '') +
|
|
1064
|
+
(!changed && retriedAsPress
|
|
1065
|
+
? `\nNo change even after a press retry. Likely wrong coords / disabled element / overlay / auth wall.`
|
|
1066
|
+
: '');
|
|
1067
|
+
const findings = health.findings.length
|
|
1068
|
+
? '\n' +
|
|
1069
|
+
health.findings
|
|
1070
|
+
.map((f) => `[${f.severity}] ${f.layer ?? '?'}/${f.kind}: ${f.detail}${f.evidence ? `: "${f.evidence}"` : ''}`)
|
|
1071
|
+
.join('\n')
|
|
1072
|
+
: '';
|
|
1073
|
+
// `observe` changes ONLY the element presentation below. Everything above
|
|
1074
|
+
// (lastSnapshot bookkeeping, press retry, health recording, counters, budget lines)
|
|
1075
|
+
// is identical in all modes.
|
|
1076
|
+
let elementPayload = {};
|
|
1077
|
+
let elementsText = '';
|
|
1078
|
+
const added = observe === 'diff' ? post.elements.filter((e) => !preSigs.has(signature(e))) : [];
|
|
1079
|
+
// Mostly-new screen (navigation): a diff would list every new element AND every old one
|
|
1080
|
+
// as removed, bigger than the full list. Return the (capped) full list + removedCount.
|
|
1081
|
+
const diffAsFull = observe === 'diff' && post.elements.length > 0 && added.length > post.elements.length * DIFF_FULL_FALLBACK_RATIO;
|
|
1082
|
+
if (observe === 'diff' && !diffAsFull) {
|
|
1083
|
+
const removed = [...preSigs].filter((sig) => !postSigs.has(sig)).map((sig) => redact(sig) ?? sig);
|
|
1084
|
+
const { elements: addedShown, rendered: renderedAdded, omitted } = presentElements(added, redact);
|
|
1085
|
+
const unchangedElements = post.elements.length - added.length;
|
|
1086
|
+
const hint = `${unchangedElements} unchanged element(s) not shown; pass observe:"full" to see the whole screen.`;
|
|
1087
|
+
elementPayload = { elementsOmitted: omitted, elements: addedShown, removed, unchangedElements, hint };
|
|
1088
|
+
elementsText =
|
|
1089
|
+
`\n\nDIFF vs pre-action: +${added.length} / -${removed.length}` +
|
|
1090
|
+
(added.length ? `\n${renderedAdded}` : '') +
|
|
1091
|
+
(removed.length ? `\nremoved: ${removed.join(' | ')}` : '') +
|
|
1092
|
+
`\n${hint}`;
|
|
1093
|
+
}
|
|
1094
|
+
else if (diffAsFull) {
|
|
1095
|
+
const removedCount = [...preSigs].filter((sig) => !postSigs.has(sig)).length;
|
|
1096
|
+
const { elements: outElements, rendered, omitted } = presentElements(post.elements, redact);
|
|
1097
|
+
elementPayload = {
|
|
1098
|
+
diffAsFull: true,
|
|
1099
|
+
elementsOmitted: omitted,
|
|
1100
|
+
elements: outElements,
|
|
1101
|
+
addedCount: added.length,
|
|
1102
|
+
removedCount,
|
|
1103
|
+
};
|
|
1104
|
+
elementsText =
|
|
1105
|
+
`\n\nNEW SCREEN (${added.length}/${post.elements.length} elements new, ${removedCount} previous gone), full list:` +
|
|
1106
|
+
`\n${rendered}`;
|
|
1107
|
+
}
|
|
1108
|
+
else if (observe === 'full') {
|
|
1109
|
+
const { elements: outElements, rendered, omitted } = presentElements(post.elements, redact);
|
|
1110
|
+
elementPayload = { elementsOmitted: omitted, elements: outElements };
|
|
1111
|
+
elementsText = `\n\n${rendered}`;
|
|
1112
|
+
}
|
|
1113
|
+
else {
|
|
1114
|
+
const hint = `${post.elements.length} element(s) not shown (observe:"none"); pass observe:"full" or run qa_snapshot to see the screen.`;
|
|
1115
|
+
elementPayload = { hint };
|
|
1116
|
+
elementsText = `\n\n${hint}`;
|
|
1117
|
+
}
|
|
1118
|
+
if (toggled.length && observe !== 'none') {
|
|
1119
|
+
const lines = toggled.map((ref) => {
|
|
1120
|
+
const n = post.fullByRef.get(ref);
|
|
1121
|
+
const name = isSecureNode(n) ? '«secure»' : (redact(n.desc || n.text) ?? '');
|
|
1122
|
+
return `${ref} ${JSON.stringify(name)} ${stateLabel(n)}`;
|
|
1123
|
+
});
|
|
1124
|
+
elementPayload = { ...elementPayload, stateChanged: lines };
|
|
1125
|
+
elementsText += `\nstate changed: ${lines.join(' | ')}`;
|
|
1126
|
+
}
|
|
1127
|
+
// The WDA driver transparently re-creates a reaped session (without relaunching the app);
|
|
1128
|
+
// the agent must know the screen may not be where it left it.
|
|
1129
|
+
if (d.consumeSessionRecovered?.())
|
|
1130
|
+
warnings.push(WDA_SESSION_RECOVERED_WARNING);
|
|
449
1131
|
return qaOk({
|
|
450
1132
|
action,
|
|
451
1133
|
...meta,
|
|
1134
|
+
...(keyboardHidden ? { keyboardHidden: true } : {}),
|
|
1135
|
+
...(modeRecovered ? { modeRecovered: true, mode: 'structured' } : {}),
|
|
452
1136
|
changed,
|
|
453
1137
|
retriedAsPress,
|
|
454
1138
|
settled: s.settled,
|
|
@@ -456,23 +1140,33 @@ export function registerAct(server, sessions) {
|
|
|
456
1140
|
health,
|
|
457
1141
|
counters: session.counters,
|
|
458
1142
|
...(budgetReached ? { budgetReached } : {}),
|
|
459
|
-
|
|
460
|
-
|
|
1143
|
+
observe,
|
|
1144
|
+
...elementPayload,
|
|
1145
|
+
...(warnings.length ? { warnings } : {}),
|
|
1146
|
+
}, `${banner}${keyboardHidden ? '\nNote: the soft keyboard covered the target and was hidden first (keyboardHidden:true).' : ''}` +
|
|
1147
|
+
`${findings}${warnings.map((w) => `\n⚠ ${w}`).join('')}${elementsText}`, { textOmit: ['elements', 'removed', 'hint', 'stateChanged'] });
|
|
461
1148
|
}
|
|
462
1149
|
catch (e) {
|
|
1150
|
+
if (isAbortError(e))
|
|
1151
|
+
return cancelledResult(`Action "${action}" ran, but the call was cancelled before its result was observed`, true);
|
|
463
1152
|
// The action ran; observing the result failed (often a UI that never reaches idle).
|
|
1153
|
+
// visual-fallback is per-screen: the next successful qa_snapshot / qa_act dump clears it.
|
|
464
1154
|
const idle = /idle|dump|hierarchy/i.test(String(e));
|
|
465
1155
|
if (idle)
|
|
466
1156
|
sessions.setMode(session, 'visual-fallback');
|
|
1157
|
+
const redactErr = makeRedactor(errorSecrets(session, action === 'type' ? (typedValue ?? args.text) : undefined));
|
|
467
1158
|
return qaError({
|
|
468
|
-
what: `Action "${action}" ran, but observing the result failed: ${String(e)}`,
|
|
1159
|
+
what: `Action "${action}" ran, but observing the result failed: ${redactErr(String(e)) ?? ''}`,
|
|
469
1160
|
changedState: true,
|
|
470
1161
|
retrySafe: false,
|
|
1162
|
+
failureCode: classifyFlowDriverError(e, 'SNAPSHOT_FAILED'),
|
|
471
1163
|
nextSteps: idle
|
|
472
|
-
? [
|
|
1164
|
+
? [
|
|
1165
|
+
'Switched to visual-fallback for this screen (cleared by the next successful qa_snapshot / qa_act dump). Use qa_screenshot; qa_check_health still works.',
|
|
1166
|
+
]
|
|
473
1167
|
: ['Re-check the device is online, then qa_screenshot / qa_check_health.'],
|
|
474
1168
|
});
|
|
475
1169
|
}
|
|
476
|
-
});
|
|
1170
|
+
}));
|
|
477
1171
|
}
|
|
478
1172
|
//# sourceMappingURL=act.js.map
|