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/visual.js
CHANGED
|
@@ -1,139 +1,721 @@
|
|
|
1
|
-
// qa_visual
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// qa_visual: consolidated local visual intelligence for screens with no usable UI
|
|
2
|
+
// tree (maps/canvases/games) and for visual regression. Its whole point is that it stays
|
|
3
|
+
// backend-neutral: only driver.screenshot() is required, so it works in visual-only iOS
|
|
4
|
+
// simulator mode (no WDA) where qa_act / qa_snapshot are rejected. Modes:
|
|
5
|
+
// baseline : save the current screen as a named baseline (<repo>/.swipium/baselines/<name>.png)
|
|
6
|
+
// diff : compare the current screen to a baseline > changed-ratio + changed region
|
|
7
|
+
// find_text : consent-gated OCR (locally-configured provider, none bundled) > text matches
|
|
8
|
+
// find_image : locate a reference PNG in the current screen > tappable coordinates
|
|
9
|
+
// assert : record a visual pass/fail with screenshot evidence (a qa_note with
|
|
10
|
+
// verifiedVisually=true; a pass is also recorded as a semantic IR step)
|
|
11
|
+
// Every result declares its coordinateSpace so screenshot-pixel hits convert
|
|
12
|
+
// honestly to device (tap) coordinates: POINTS on iOS (WDA and the idb fallback), pixels on
|
|
13
|
+
// Android. Policy matches qa_screenshot: refuse in sensitive mode, and
|
|
14
|
+
// withhold capture when a secure field is (or may be) on screen unless force:true
|
|
15
|
+
// (THREAT_MODEL "Sensitive-screen capture": pixels are not redactable).
|
|
16
|
+
//
|
|
17
|
+
// Each mode is one handler in MODE_HANDLERS sharing a VisualContext, so adding a mode is one
|
|
18
|
+
// enum value + one handler.
|
|
8
19
|
import { z } from 'zod';
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
-
import { isAbsolute, join } from 'node:path';
|
|
11
|
-
import { qaOk, qaError } from '../lib/result.js';
|
|
20
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
22
|
+
import { qaOk, qaError, qaStop, unknownSessionError } from '../lib/result.js';
|
|
12
23
|
import { isSecureNode, makeRedactor } from '../lib/redact.js';
|
|
24
|
+
import { parseSnapshot } from '../snapshot/parse.js';
|
|
13
25
|
import { displayArgv } from '../lib/commandTemplate.js';
|
|
14
|
-
import { GitScopeForbiddenError } from '../lib/spawn.js';
|
|
26
|
+
import { GitScopeForbiddenError, run } from '../lib/spawn.js';
|
|
27
|
+
import { which } from '../lib/android.js';
|
|
15
28
|
import { sensitiveRefusal } from '../lib/sensitive.js';
|
|
16
29
|
import { imageDiff, findTemplate } from '../lib/image.js';
|
|
17
30
|
import { captureCoordinateSpace, toDevicePoint } from '../lib/coordSpace.js';
|
|
18
|
-
import { configuredOcrCommand, runOcr } from '../visual/ocr.js';
|
|
19
|
-
import { boundedText, resolveMaskProvider, resolveVisualProvider } from '../visual/provider.js';
|
|
31
|
+
import { configuredOcrCommand, ocrCommandSource, findOcrRegion, runOcr, OCR_PROVIDER_CONTRACT, TESSERACT_OCR_EXAMPLE, } from '../visual/ocr.js';
|
|
32
|
+
import { boundedText, maskCommandSource, providerSourceLabel, resolveMaskProvider, resolveVisualProvider, VisualProviderFailedError, } from '../visual/provider.js';
|
|
20
33
|
import { requireConsent, consumeConsent } from '../consent/consent.js';
|
|
21
|
-
import { getDriver } from '../session/attach.js';
|
|
34
|
+
import { blockedDeviceResult, getDriver } from '../session/attach.js';
|
|
35
|
+
import { recordableTap } from '../flows/generate.js';
|
|
36
|
+
export const VISUAL_MODES = ['assert', 'baseline', 'diff', 'find_text', 'find_image'];
|
|
37
|
+
// ---------------------------------------------------------------------------------------------
|
|
38
|
+
// B5: path containment for baselines and templates.
|
|
39
|
+
/** Baseline names are plain file stems: 1-64 of [A-Za-z0-9._-], never starting with a dot. */
|
|
40
|
+
const BASELINE_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
41
|
+
export function baselinesDirFor(root) {
|
|
42
|
+
return join(root, '.swipium', 'baselines');
|
|
43
|
+
}
|
|
44
|
+
function pathRefused(what, nextStep) {
|
|
45
|
+
return qaError({ what, changedState: false, retrySafe: false, failureCode: 'VISUAL_PATH_REFUSED', nextSteps: [nextStep] });
|
|
46
|
+
}
|
|
47
|
+
/** Resolve `<root>/.swipium/baselines/<name>.png`, refusing any name that could escape it. */
|
|
48
|
+
export function resolveBaselinePath(root, name) {
|
|
49
|
+
if (!BASELINE_NAME_RE.test(name) || name.startsWith('.')) {
|
|
50
|
+
return { error: `Invalid baseline name ${JSON.stringify(name)}. Use 1-64 of [A-Za-z0-9._-], not starting with "."` };
|
|
51
|
+
}
|
|
52
|
+
const dir = resolve(baselinesDirFor(root));
|
|
53
|
+
const path = resolve(dir, `${name}.png`);
|
|
54
|
+
if (!path.startsWith(dir + sep))
|
|
55
|
+
return { error: `Baseline name ${JSON.stringify(name)} resolves outside .swipium/baselines` };
|
|
56
|
+
// A symlinked baseline file could still redirect the write/read elsewhere, so refuse it.
|
|
57
|
+
try {
|
|
58
|
+
if (lstatSync(path).isSymbolicLink())
|
|
59
|
+
return { error: `Baseline ${JSON.stringify(name)} is a symlink, refusing to follow it` };
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// does not exist yet, fine
|
|
63
|
+
}
|
|
64
|
+
return { path };
|
|
65
|
+
}
|
|
66
|
+
function realOrResolved(p) {
|
|
67
|
+
try {
|
|
68
|
+
return realpathSync(p);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return resolve(p);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** A find_image template must be a PNG inside the project root (symlinks resolved) or a
|
|
75
|
+
* swipium:// artifact of this session's project, never an arbitrary absolute path. */
|
|
76
|
+
export function resolveTemplatePath(sessions, session, template) {
|
|
77
|
+
if (template.startsWith('swipium://')) {
|
|
78
|
+
const found = sessions.findArtifact(template);
|
|
79
|
+
if (!found || found.session.root !== session.root)
|
|
80
|
+
return { error: `Unknown artifact ${template} for this project`, missing: true };
|
|
81
|
+
return { path: found.rec.path };
|
|
82
|
+
}
|
|
83
|
+
const root = realOrResolved(session.root);
|
|
84
|
+
const candidate = isAbsolute(template) ? resolve(template) : resolve(session.root, template);
|
|
85
|
+
if (!existsSync(candidate))
|
|
86
|
+
return { error: `Template not found: ${candidate}`, missing: true };
|
|
87
|
+
const real = realOrResolved(candidate);
|
|
88
|
+
if (real !== root && !real.startsWith(root + sep)) {
|
|
89
|
+
return {
|
|
90
|
+
error: `Template ${JSON.stringify(template)} is outside the project root. Only project files or swipium:// artifacts are allowed`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { path: real };
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------------------------------------
|
|
96
|
+
// B8: secure-screen detection.
|
|
97
|
+
/** Snapshots taken BEFORE a qa_visual tap describe a screen that may no longer be showing. */
|
|
98
|
+
const staleSnapshots = new WeakSet();
|
|
99
|
+
/** secure/clear from a FRESH UI tree; unknown when there is none (WDA-less simulator, no
|
|
100
|
+
* qa_snapshot yet) or it predates a visual tap. */
|
|
101
|
+
export function secureScreenState(session) {
|
|
102
|
+
const snap = session.lastSnapshot;
|
|
103
|
+
if (!snap || staleSnapshots.has(snap))
|
|
104
|
+
return 'unknown';
|
|
105
|
+
return [...snap.fullByRef.values()].some((n) => isSecureNode(n)) ? 'secure' : 'clear';
|
|
106
|
+
}
|
|
107
|
+
/** When the cached tree is missing or stale (e.g. after a qa_visual tap), a structured backend
|
|
108
|
+
* (Android adb / WDA) can simply re-dump it: a qa_visual tap must not, by itself, turn the next
|
|
109
|
+
* qa_visual call into an "unverified" one there. The fresh dump only informs the secure gate:
|
|
110
|
+
* session.lastSnapshot (the agent's @ref numbering) is left untouched. WDA-less simulators
|
|
111
|
+
* cannot dump a tree, so they stay 'unknown'. */
|
|
112
|
+
async function refreshSecureState(session, driver) {
|
|
113
|
+
const cached = secureScreenState(session);
|
|
114
|
+
if (cached !== 'unknown' || driver.kind === 'simulator')
|
|
115
|
+
return cached;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = parseSnapshot(await driver.dumpXml());
|
|
118
|
+
if (parsed.allNodes.length === 0)
|
|
119
|
+
return 'unknown';
|
|
120
|
+
return parsed.allNodes.some((n) => isSecureNode(n)) ? 'secure' : 'clear';
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return 'unknown';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** The session has handled credentials (typed secrets, secret inputs, a login, or a
|
|
127
|
+
* rehydrated session whose secrets were dropped). An unverified screen may be sensitive. */
|
|
128
|
+
function hasSecretContext(session) {
|
|
129
|
+
return (session.secrets.size > 0 ||
|
|
130
|
+
!!session.redactionDegraded ||
|
|
131
|
+
(session.inputs ?? []).some((i) => i.secret) ||
|
|
132
|
+
!!session.auth?.loginPerformed);
|
|
133
|
+
}
|
|
134
|
+
/** OCR text that reads like a password/OTP/payment screen. */
|
|
135
|
+
const SECURE_TEXT_RE = /password|passcode|one[- ]?time|\bOTP\b|verification code|security code|\bCVV\b|\bCVC\b|card number|\bPIN\b/i;
|
|
136
|
+
function redactRegion(r, redact) {
|
|
137
|
+
return { ...r, text: redact(r.text) ?? '' };
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------------------------
|
|
140
|
+
// Tapping (B6 units, B7 recording/budget).
|
|
141
|
+
/** Tap a found device-space point. On the WDA-less simulator the driver cannot inject input,
|
|
142
|
+
* so fall back to Meta's `idb` when it is on PATH (`idb ui tap` takes POINTS, which is what
|
|
143
|
+
* SimctlDriver.screenSize() reports, so devicePoint is already in points); otherwise return a
|
|
144
|
+
* typed refusal that still carries the coordinates (the locate itself succeeded). */
|
|
145
|
+
async function tapFoundPoint(driver, point, foundPayload) {
|
|
146
|
+
if (driver.kind !== 'simulator') {
|
|
147
|
+
try {
|
|
148
|
+
await driver.tapXY(point.x, point.y);
|
|
149
|
+
return { via: 'driver' };
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
return {
|
|
153
|
+
error: qaError({
|
|
154
|
+
what: `Found the target but the tap failed: ${String(e)}`,
|
|
155
|
+
changedState: false,
|
|
156
|
+
retrySafe: true,
|
|
157
|
+
nextSteps: ['Tap the returned devicePoint with qa_act { action:"tap", target:{ x, y } }.'],
|
|
158
|
+
}, foundPayload),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const udid = driver.currentDevice();
|
|
163
|
+
if (udid && (await which('idb'))) {
|
|
164
|
+
const r = await run('idb', ['ui', 'tap', String(point.x), String(point.y), '--udid', udid], { timeoutMs: 15000 }).catch((e) => ({
|
|
165
|
+
code: -1,
|
|
166
|
+
stdout: '',
|
|
167
|
+
stderr: String(e),
|
|
168
|
+
timedOut: false,
|
|
169
|
+
}));
|
|
170
|
+
if (r.code === 0)
|
|
171
|
+
return { via: 'idb' };
|
|
172
|
+
return {
|
|
173
|
+
error: qaError({
|
|
174
|
+
what: `Found the target but \`idb ui tap\` failed: ${r.stderr.trim() || `exit ${r.code}`}`,
|
|
175
|
+
changedState: false,
|
|
176
|
+
retrySafe: true,
|
|
177
|
+
failureCode: 'BACKEND_UNSUPPORTED',
|
|
178
|
+
nextSteps: ['Attach WebDriverAgent with qa_wda, then tap the returned devicePoint via qa_act { action:"tap", target:{ x, y } }.'],
|
|
179
|
+
}, foundPayload),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
error: qaError({
|
|
184
|
+
what: 'Found the target, but tapping is not supported on the WDA-less iOS simulator backend (no idb on PATH)',
|
|
185
|
+
changedState: false,
|
|
186
|
+
retrySafe: true,
|
|
187
|
+
failureCode: 'BACKEND_UNSUPPORTED',
|
|
188
|
+
nextSteps: [
|
|
189
|
+
'Attach WebDriverAgent with qa_wda, then tap the returned devicePoint via qa_act { action:"tap", target:{ x, y } }.',
|
|
190
|
+
"Or install Meta's idb (`brew tap facebook/fb && brew install idb-companion`, then `pipx install fb-idb`) so qa_visual can tap here.",
|
|
191
|
+
],
|
|
192
|
+
}, foundPayload),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** Tap + record it like qa_act does: bump the action counter, append a coordinate-kind action
|
|
196
|
+
* to the IR (so qa_generate / next-best-action see WDA-less progress), mark the UI tree stale,
|
|
197
|
+
* and surface budget exhaustion. */
|
|
198
|
+
async function tapAndRecord(ctx, devicePoint, payload, visual, label) {
|
|
199
|
+
const { sessions, session, driver } = ctx;
|
|
200
|
+
const tapped = await tapFoundPoint(driver, devicePoint, payload);
|
|
201
|
+
if ('error' in tapped)
|
|
202
|
+
return tapped.error;
|
|
203
|
+
sessions.bump(session, 'actions');
|
|
204
|
+
if (session.lastSnapshot)
|
|
205
|
+
staleSnapshots.add(session.lastSnapshot);
|
|
206
|
+
const point = { x: devicePoint.x, y: devicePoint.y, via: 'coords' };
|
|
207
|
+
const rec = recordableTap(session, { x: devicePoint.x, y: devicePoint.y }, point);
|
|
208
|
+
sessions.addRecordedAction(session, {
|
|
209
|
+
at: Date.now(),
|
|
210
|
+
action: 'tap',
|
|
211
|
+
...rec,
|
|
212
|
+
warning: `Located visually (${visual.ocrText ? `OCR "${visual.ocrText}"` : 'template match'}). Coordinate-only replay; add an accessibilityIdentifier/testID for a durable selector`,
|
|
213
|
+
provenance: {
|
|
214
|
+
...(rec.provenance ?? {}),
|
|
215
|
+
selectorKind: 'coords',
|
|
216
|
+
visual: {
|
|
217
|
+
screenshotCrop: visual.screenshotCrop,
|
|
218
|
+
ocrText: visual.ocrText,
|
|
219
|
+
confidence: visual.confidence,
|
|
220
|
+
density: visual.coordinateSpace.density,
|
|
221
|
+
orientation: visual.coordinateSpace.orientation,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
const budgetReached = sessions.budgetStop(session);
|
|
226
|
+
return qaOk({ ...payload, tapped: true, tapVia: tapped.via, recorded: true, ...(budgetReached ? { budgetReached } : {}) }, `${label} > tapped device (${devicePoint.x}, ${devicePoint.y}) via ${tapped.via}` +
|
|
227
|
+
(budgetReached ? `\n⏹ budget reached: ${budgetReached}. Call qa_report.` : ''));
|
|
228
|
+
}
|
|
229
|
+
/** One capture for the pixel modes; counts against the screenshot budget. */
|
|
230
|
+
async function capture(ctx) {
|
|
231
|
+
let png;
|
|
232
|
+
try {
|
|
233
|
+
png = await ctx.driver.screenshot();
|
|
234
|
+
}
|
|
235
|
+
catch (e) {
|
|
236
|
+
return {
|
|
237
|
+
error: qaError({
|
|
238
|
+
what: `Screenshot failed: ${String(e)}`,
|
|
239
|
+
changedState: false,
|
|
240
|
+
retrySafe: true,
|
|
241
|
+
nextSteps: ['Confirm the device is online.'],
|
|
242
|
+
}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
ctx.sessions.bump(ctx.session, 'screenshots');
|
|
246
|
+
return { png, coordinateSpace: await captureCoordinateSpace(ctx.driver, png) };
|
|
247
|
+
}
|
|
248
|
+
/** Pixel outputs carry an explicit warning when the secure-field check could not run.
|
|
249
|
+
* `ocrChecked`: the OCR text of this very capture was screened for password/OTP wording (the
|
|
250
|
+
* only signal without a UI tree), reported as secureFieldCheck:"ocr". */
|
|
251
|
+
function unverifiedNote(ctx, opts = {}) {
|
|
252
|
+
if (ctx.secureState !== 'unknown' || ctx.args.force)
|
|
253
|
+
return { secureFieldCheck: ctx.secureState === 'unknown' ? 'forced' : ctx.secureState };
|
|
254
|
+
const withheld = ctx.withholdCapture ? { captureWithheld: true } : {};
|
|
255
|
+
return opts.ocrChecked
|
|
256
|
+
? {
|
|
257
|
+
secureFieldCheck: 'ocr',
|
|
258
|
+
warning: 'No fresh UI tree. The secure-field check was OCR-text only (no password/OTP wording found).',
|
|
259
|
+
...withheld,
|
|
260
|
+
}
|
|
261
|
+
: {
|
|
262
|
+
secureFieldCheck: 'unverified',
|
|
263
|
+
warning: 'No fresh UI tree. Could not verify that no password/OTP field is on screen.',
|
|
264
|
+
...withheld,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/** Persist a capture as a session artifact, unless the screen is unverified in a
|
|
268
|
+
* credential-handling session (then nothing is written and the result says so). */
|
|
269
|
+
function saveCapture(ctx, name, png, label) {
|
|
270
|
+
if (ctx.withholdCapture)
|
|
271
|
+
return undefined;
|
|
272
|
+
return ctx.sessions.saveArtifact(ctx.session, 'screenshot', name, png, 'image/png', label);
|
|
273
|
+
}
|
|
274
|
+
const WITHHELD_EVIDENCE = 'evidence: withheld (no fresh UI tree to rule out a password/OTP field in a credential-handling session. Pass force:true to keep the screenshot)';
|
|
275
|
+
// ---------------------------------------------------------------------------------------------
|
|
276
|
+
// Mode handlers.
|
|
277
|
+
async function findText(ctx) {
|
|
278
|
+
const { session, driver, args, redact } = ctx;
|
|
279
|
+
const { query, minConfidence, consentId, approve, tap, force } = args;
|
|
280
|
+
if (!query)
|
|
281
|
+
return qaError({
|
|
282
|
+
what: 'find_text requires a query',
|
|
283
|
+
changedState: false,
|
|
284
|
+
retrySafe: true,
|
|
285
|
+
failureCode: 'INVALID_ARGUMENT',
|
|
286
|
+
nextSteps: ['Pass query="Log in".'],
|
|
287
|
+
});
|
|
288
|
+
const command = configuredOcrCommand(session.root);
|
|
289
|
+
if (!command) {
|
|
290
|
+
return qaError({
|
|
291
|
+
what: 'OCR is not configured. find_text needs a local OCR provider (none is bundled)',
|
|
292
|
+
changedState: false,
|
|
293
|
+
retrySafe: false,
|
|
294
|
+
failureCode: 'OCR_NOT_CONFIGURED',
|
|
295
|
+
nextSteps: [
|
|
296
|
+
`Configure a provider: ${OCR_PROVIDER_CONTRACT}`,
|
|
297
|
+
'Tesseract example: save the exampleProvider script from this result as .swipium/ocr_tesseract.py (needs `brew install tesseract`), then set "ocrCommand": ["python3", ".swipium/ocr_tesseract.py", "{image}"] in .swipium/config.json.',
|
|
298
|
+
'Or locate by image with qa_visual mode:"find_image" (no provider needed).',
|
|
299
|
+
],
|
|
300
|
+
}, {
|
|
301
|
+
exampleProvider: {
|
|
302
|
+
path: '.swipium/ocr_tesseract.py',
|
|
303
|
+
ocrCommand: ['python3', '.swipium/ocr_tesseract.py', '{image}'],
|
|
304
|
+
script: TESSERACT_OCR_EXAMPLE,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
let preview;
|
|
309
|
+
let maskPreview;
|
|
310
|
+
try {
|
|
311
|
+
preview = resolveVisualProvider(command, { image: '<screenshot>' }, 30000);
|
|
312
|
+
maskPreview = resolveMaskProvider(session.root);
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
return qaError({
|
|
316
|
+
what: e instanceof GitScopeForbiddenError ? e.message : `Invalid OCR command template: ${String(e)}`,
|
|
317
|
+
changedState: false,
|
|
318
|
+
retrySafe: !(e instanceof GitScopeForbiddenError),
|
|
319
|
+
failureCode: e instanceof GitScopeForbiddenError ? 'GIT_SCOPE_FORBIDDEN' : 'INVALID_FLOW',
|
|
320
|
+
nextSteps: e instanceof GitScopeForbiddenError
|
|
321
|
+
? ['Run Git yourself outside Swipium; configure ocrCommand to use a non-Git executable.']
|
|
322
|
+
: ['Use an argv array in .swipium/config.json, e.g. ["node","ocr.js","{image}"].'],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const maskConfigured = !!maskPreview;
|
|
326
|
+
// BOTH commands that will run are disclosed (argv + provenance). A repo config could otherwise
|
|
327
|
+
// pair a harmless-looking ocrCommand with an arbitrary visualMaskCommand the user never sees.
|
|
328
|
+
const ocrSource = providerSourceLabel(ocrCommandSource(session.root) ?? 'environment', 'SWIPIUM_OCR_CMD');
|
|
329
|
+
const maskSource = maskPreview ? providerSourceLabel(maskCommandSource(session.root) ?? 'environment', 'SWIPIUM_VISUAL_MASK_CMD') : null;
|
|
330
|
+
const affects = {
|
|
331
|
+
argv: preview.argv,
|
|
332
|
+
io: preview.io,
|
|
333
|
+
ocrCommandSource: ocrSource,
|
|
334
|
+
query,
|
|
335
|
+
maskConfigured,
|
|
336
|
+
maskArgv: maskPreview?.argv ?? null,
|
|
337
|
+
maskIo: maskPreview?.io ?? null,
|
|
338
|
+
maskCommandSource: maskSource,
|
|
339
|
+
};
|
|
340
|
+
const gate = consumeConsent(consentId, approve, { action: 'ocr_run', affects });
|
|
341
|
+
if (!gate.approved) {
|
|
342
|
+
const exactCommand = maskPreview
|
|
343
|
+
? `1) mask [${maskSource}]: ${displayArgv(maskPreview.argv)}\n2) OCR [${ocrSource}]: ${displayArgv(preview.argv)}`
|
|
344
|
+
: `OCR [${ocrSource}]: ${displayArgv(preview.argv)}`;
|
|
345
|
+
return requireConsent({
|
|
346
|
+
action: 'ocr_run',
|
|
347
|
+
risk: 'medium',
|
|
348
|
+
exactCommand,
|
|
349
|
+
affects,
|
|
350
|
+
explain: maskPreview
|
|
351
|
+
? `Run TWO local programs on the current screenshot to find "${query}": first the visualMaskCommand (${maskSource}), then the OCR command (${ocrSource}) on its masked output. The screen image is passed to both.`
|
|
352
|
+
: `Run the OCR command (${ocrSource}) on the current screenshot to find "${query}"? The screen image is passed to that local program.`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
let ocr;
|
|
356
|
+
try {
|
|
357
|
+
ocr = await runOcr(driver, session.root, command);
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
if (e instanceof VisualProviderFailedError) {
|
|
361
|
+
// A crashing provider must not read as "text not on screen" (real-device smoke 2.0.0).
|
|
362
|
+
const stderr = boundedText(e.stderr, redact, 1200);
|
|
363
|
+
return qaError({
|
|
364
|
+
what: `${e.provider === 'ocr' ? 'OCR' : 'Visual mask'} provider failed (${e.timedOut ? 'timed out' : `exit code ${e.exitCode}`})${stderr.text ? `: ${stderr.text.split('\n').pop()}` : ''}`,
|
|
365
|
+
changedState: false,
|
|
366
|
+
retrySafe: false,
|
|
367
|
+
failureCode: 'OCR_PROVIDER_FAILED',
|
|
368
|
+
nextSteps: [
|
|
369
|
+
`Run the provider standalone from the project root (${session.root}) on a PNG and fix the error in stderr.`,
|
|
370
|
+
'Relative paths in ocrCommand resolve against the project root; the tesseract example needs `brew install tesseract`.',
|
|
371
|
+
],
|
|
372
|
+
}, { provider: e.provider, exitCode: e.exitCode, timedOut: e.timedOut, stderr: stderr.text, stderrTruncated: stderr.truncated });
|
|
373
|
+
}
|
|
374
|
+
return qaError({
|
|
375
|
+
what: `OCR command failed: ${redact(String(e)) ?? ''}`,
|
|
376
|
+
changedState: false,
|
|
377
|
+
retrySafe: true,
|
|
378
|
+
failureCode: 'OCR_PROVIDER_FAILED',
|
|
379
|
+
nextSteps: ['Check the configured OCR command runs standalone on a PNG.'],
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
ctx.sessions.bump(session, 'screenshots'); // runOcr captured the screen
|
|
383
|
+
// B8: no fresh UI tree, so the OCR text itself is the only signal; a password/OTP screen is
|
|
384
|
+
// withheld (no text, no regions, no tap) unless force:true.
|
|
385
|
+
if (ctx.secureState === 'unknown' && !force && (SECURE_TEXT_RE.test(ocr.text) || ocr.regions.some((r) => SECURE_TEXT_RE.test(r.text)))) {
|
|
386
|
+
return qaError({
|
|
387
|
+
what: 'Withheld: the screen reads like a password/OTP/payment screen and no fresh UI tree could confirm otherwise',
|
|
388
|
+
changedState: false,
|
|
389
|
+
retrySafe: true,
|
|
390
|
+
failureCode: 'CAPTURE_WITHHELD_SECURE',
|
|
391
|
+
nextSteps: [
|
|
392
|
+
'Pass force:true to return OCR results for this screen anyway (text is still secret-redacted), or navigate to a non-sensitive screen.',
|
|
393
|
+
],
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
const regions = ocr.regions.map((r) => redactRegion(r, redact));
|
|
397
|
+
const hit = findOcrRegion(ocr, query, minConfidence ?? 0.8);
|
|
398
|
+
if (!hit) {
|
|
399
|
+
const bounded = boundedText(ocr.text, redact, 8000);
|
|
400
|
+
return qaOk({
|
|
401
|
+
mode: 'find_text',
|
|
402
|
+
found: false,
|
|
403
|
+
query,
|
|
404
|
+
text: bounded.text,
|
|
405
|
+
truncated: bounded.truncated,
|
|
406
|
+
regions,
|
|
407
|
+
coordinateSpace: ocr.coordinateSpace,
|
|
408
|
+
provider: ocr.provider,
|
|
409
|
+
masking: ocr.masking,
|
|
410
|
+
method: 'ocr',
|
|
411
|
+
evidenceKind: 'ocr_text',
|
|
412
|
+
...unverifiedNote(ctx, { ocrChecked: true }),
|
|
413
|
+
}, `OCR did not find "${query}" at confidence >= ${minConfidence ?? 0.8}.`);
|
|
414
|
+
}
|
|
415
|
+
const region = redactRegion(hit.region, redact);
|
|
416
|
+
const payload = {
|
|
417
|
+
mode: 'find_text',
|
|
418
|
+
found: true,
|
|
419
|
+
query,
|
|
420
|
+
region,
|
|
421
|
+
devicePoint: hit.devicePoint,
|
|
422
|
+
coordinateSpace: ocr.coordinateSpace,
|
|
423
|
+
method: 'ocr',
|
|
424
|
+
locatorStrategy: 'ocr_text',
|
|
425
|
+
evidenceKind: 'ocr_text',
|
|
426
|
+
provider: ocr.provider,
|
|
427
|
+
masking: ocr.masking,
|
|
428
|
+
...unverifiedNote(ctx, { ocrChecked: true }),
|
|
429
|
+
};
|
|
430
|
+
const label = `found "${region.text}" (${region.confidence})`;
|
|
431
|
+
if (tap)
|
|
432
|
+
return tapAndRecord(ctx, hit.devicePoint, payload, { screenshotCrop: hit.region.bbox, ocrText: region.text, confidence: region.confidence, coordinateSpace: ocr.coordinateSpace }, label);
|
|
433
|
+
return qaOk(payload, `${label} > tap device (${hit.devicePoint.x}, ${hit.devicePoint.y}) via qa_act { action:"tap", target:{ x:${hit.devicePoint.x}, y:${hit.devicePoint.y} } }`);
|
|
434
|
+
}
|
|
435
|
+
async function baseline(ctx) {
|
|
436
|
+
const { sessions, session, args } = ctx;
|
|
437
|
+
const name = args.name;
|
|
438
|
+
if (!name)
|
|
439
|
+
return qaError({
|
|
440
|
+
what: 'baseline requires a name',
|
|
441
|
+
changedState: false,
|
|
442
|
+
retrySafe: true,
|
|
443
|
+
failureCode: 'INVALID_ARGUMENT',
|
|
444
|
+
nextSteps: ['Pass name="home-screen".'],
|
|
445
|
+
});
|
|
446
|
+
const target = resolveBaselinePath(session.root, name);
|
|
447
|
+
if ('error' in target)
|
|
448
|
+
return pathRefused(target.error, 'Use a plain name such as "home-screen" or "checkout.step-2".');
|
|
449
|
+
const shot = await capture(ctx);
|
|
450
|
+
if ('error' in shot)
|
|
451
|
+
return shot.error;
|
|
452
|
+
const { png, coordinateSpace } = shot;
|
|
453
|
+
mkdirSync(baselinesDirFor(session.root), { recursive: true });
|
|
454
|
+
writeFileSync(target.path, png);
|
|
455
|
+
const uri = sessions.saveArtifact(session, 'baseline', `${name}.png`, png, 'image/png', `visual baseline: ${name}`);
|
|
456
|
+
return qaOk({ mode: 'baseline', name, uri, path: target.path, coordinateSpace, ...unverifiedNote(ctx) }, `saved baseline "${name}" (${png.length} bytes) > .swipium/baselines/${name}.png + ${uri}`);
|
|
457
|
+
}
|
|
458
|
+
async function diff(ctx) {
|
|
459
|
+
const { session, args } = ctx;
|
|
460
|
+
const name = args.name;
|
|
461
|
+
if (!name)
|
|
462
|
+
return qaError({
|
|
463
|
+
what: 'diff requires a baseline name',
|
|
464
|
+
changedState: false,
|
|
465
|
+
retrySafe: true,
|
|
466
|
+
failureCode: 'INVALID_ARGUMENT',
|
|
467
|
+
nextSteps: ['Pass the name used with mode:"baseline".'],
|
|
468
|
+
});
|
|
469
|
+
const target = resolveBaselinePath(session.root, name);
|
|
470
|
+
if ('error' in target)
|
|
471
|
+
return pathRefused(target.error, 'Use the plain name you passed to mode:"baseline".');
|
|
472
|
+
if (!existsSync(target.path)) {
|
|
473
|
+
return qaError({
|
|
474
|
+
what: `No baseline "${name}". Capture one first`,
|
|
475
|
+
changedState: false,
|
|
476
|
+
retrySafe: true,
|
|
477
|
+
nextSteps: [`Call qa_visual { mode: "baseline", name: "${name}" } on the reference screen.`],
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
const shot = await capture(ctx);
|
|
481
|
+
if ('error' in shot)
|
|
482
|
+
return shot.error;
|
|
483
|
+
const { png, coordinateSpace } = shot;
|
|
484
|
+
const result = imageDiff(readFileSync(target.path), png);
|
|
485
|
+
const tol = args.threshold ?? 0.02;
|
|
486
|
+
const pass = result.comparable && result.ratio <= tol;
|
|
487
|
+
const currentUri = saveCapture(ctx, `diff-${name}-${Date.now()}.png`, png, `diff vs baseline ${name}`);
|
|
488
|
+
const deviceBox = result.box
|
|
489
|
+
? {
|
|
490
|
+
x: toDevicePoint(coordinateSpace, result.box.x, result.box.y).x,
|
|
491
|
+
y: toDevicePoint(coordinateSpace, result.box.x, result.box.y).y,
|
|
492
|
+
width: Math.round(result.box.width / (coordinateSpace.scale ?? 1)),
|
|
493
|
+
height: Math.round(result.box.height / (coordinateSpace.scale ?? 1)),
|
|
494
|
+
}
|
|
495
|
+
: null;
|
|
496
|
+
return qaOk({
|
|
497
|
+
mode: 'diff',
|
|
498
|
+
name,
|
|
499
|
+
method: 'visual',
|
|
500
|
+
comparable: result.comparable,
|
|
501
|
+
reason: result.reason,
|
|
502
|
+
changedRatio: Math.round(result.ratio * 10000) / 10000,
|
|
503
|
+
threshold: tol,
|
|
504
|
+
pass,
|
|
505
|
+
changedBox: result.box,
|
|
506
|
+
changedBoxDevice: deviceBox,
|
|
507
|
+
currentUri: currentUri ?? null,
|
|
508
|
+
coordinateSpace,
|
|
509
|
+
...unverifiedNote(ctx),
|
|
510
|
+
}, `diff vs "${name}": ${result.comparable ? `${(result.ratio * 100).toFixed(2)}% changed (threshold ${(tol * 100).toFixed(1)}%) > ${pass ? '✅ PASS' : '❌ FAIL'}` : `not comparable: ${result.reason}`}\n${currentUri ? `evidence: ${currentUri}` : WITHHELD_EVIDENCE}`);
|
|
511
|
+
}
|
|
512
|
+
async function findImage(ctx) {
|
|
513
|
+
const { sessions, session, args } = ctx;
|
|
514
|
+
const { template, minScore, tap } = args;
|
|
515
|
+
if (!template)
|
|
516
|
+
return qaError({
|
|
517
|
+
what: 'find_image requires a template path',
|
|
518
|
+
changedState: false,
|
|
519
|
+
retrySafe: true,
|
|
520
|
+
failureCode: 'INVALID_ARGUMENT',
|
|
521
|
+
nextSteps: ['Pass template="assets/reference.png" (inside the project root) or a swipium:// artifact URI.'],
|
|
522
|
+
});
|
|
523
|
+
const tpl = resolveTemplatePath(sessions, session, template);
|
|
524
|
+
if ('error' in tpl) {
|
|
525
|
+
if (tpl.missing)
|
|
526
|
+
return qaError({
|
|
527
|
+
what: tpl.error,
|
|
528
|
+
changedState: false,
|
|
529
|
+
retrySafe: true,
|
|
530
|
+
nextSteps: ['Provide an existing PNG inside the project root, or a swipium:// artifact URI from this project.'],
|
|
531
|
+
});
|
|
532
|
+
return pathRefused(tpl.error, 'Copy the reference PNG into the project (e.g. .swipium/templates/) and pass its project-relative path.');
|
|
533
|
+
}
|
|
534
|
+
const shot = await capture(ctx);
|
|
535
|
+
if ('error' in shot)
|
|
536
|
+
return shot.error;
|
|
537
|
+
const { png, coordinateSpace } = shot;
|
|
538
|
+
let match;
|
|
539
|
+
try {
|
|
540
|
+
match = findTemplate(png, readFileSync(tpl.path), minScore ?? 0.85);
|
|
541
|
+
}
|
|
542
|
+
catch (e) {
|
|
543
|
+
return qaError({
|
|
544
|
+
what: `Image match failed: ${String(e)}`,
|
|
545
|
+
changedState: false,
|
|
546
|
+
retrySafe: true,
|
|
547
|
+
nextSteps: ['Ensure the template is an 8-bit PNG smaller than the screen.'],
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
const devicePoint = match.found ? toDevicePoint(coordinateSpace, match.x, match.y) : null;
|
|
551
|
+
const payload = {
|
|
552
|
+
mode: 'find_image',
|
|
553
|
+
found: match.found,
|
|
554
|
+
score: match.score,
|
|
555
|
+
screenshotPoint: match.found ? { x: match.x, y: match.y } : null,
|
|
556
|
+
devicePoint,
|
|
557
|
+
coordinateSpace,
|
|
558
|
+
method: 'visual',
|
|
559
|
+
evidenceKind: 'visual_match',
|
|
560
|
+
...unverifiedNote(ctx),
|
|
561
|
+
};
|
|
562
|
+
if (match.found && tap)
|
|
563
|
+
return tapAndRecord(ctx, devicePoint, payload, { confidence: match.score, coordinateSpace }, `found (score ${match.score})`);
|
|
564
|
+
return qaOk(payload, match.found
|
|
565
|
+
? `found (score ${match.score}) at screenshot (${match.x},${match.y}) > tap device (${devicePoint.x},${devicePoint.y}) via qa_act { action:"tap", target:{ x:${devicePoint.x}, y:${devicePoint.y} } }`
|
|
566
|
+
: `not found (best score ${match.score} < ${minScore ?? 0.85})`);
|
|
567
|
+
}
|
|
568
|
+
/** Record a visual assertion in one call: screenshot evidence + a qa_note(verifiedVisually) pass/fail
|
|
569
|
+
* (the former qa_assert_visual). For screens with no usable UI tree where the agent confirms a
|
|
570
|
+
* rendered result by eye. A pass is also recorded as a semantic `assert_visual` IR step so
|
|
571
|
+
* generated suites keep the check. */
|
|
572
|
+
async function assertVisual(ctx) {
|
|
573
|
+
const { sessions, session, args } = ctx;
|
|
574
|
+
const assertion = args.assertion?.trim();
|
|
575
|
+
if (!assertion)
|
|
576
|
+
return qaError({
|
|
577
|
+
what: 'assert requires an assertion',
|
|
578
|
+
changedState: false,
|
|
579
|
+
retrySafe: true,
|
|
580
|
+
failureCode: 'INVALID_ARGUMENT',
|
|
581
|
+
nextSteps: ['Pass assertion="Live Map rendered with the route polyline" (and pass:false if it is NOT visible).'],
|
|
582
|
+
});
|
|
583
|
+
const pass = args.pass ?? true;
|
|
584
|
+
const shot = await capture(ctx);
|
|
585
|
+
if ('error' in shot)
|
|
586
|
+
return shot.error;
|
|
587
|
+
const { png, coordinateSpace } = shot;
|
|
588
|
+
const uri = saveCapture(ctx, `visual-${Date.now()}.png`, png, `visual assertion: ${assertion}`);
|
|
589
|
+
sessions.addNote(session, {
|
|
590
|
+
at: Date.now(),
|
|
591
|
+
workflow: assertion,
|
|
592
|
+
outcome: pass ? 'pass' : 'fail',
|
|
593
|
+
reason: args.reason,
|
|
594
|
+
method: 'visual',
|
|
595
|
+
evidenceKind: 'visual_match',
|
|
596
|
+
artifactUris: uri ? [uri] : [],
|
|
597
|
+
verifiedVisually: true,
|
|
598
|
+
});
|
|
599
|
+
if (pass) {
|
|
600
|
+
const screenshotCrop = coordinateSpace.screenshot
|
|
601
|
+
? { x: 0, y: 0, width: coordinateSpace.screenshot.width, height: coordinateSpace.screenshot.height }
|
|
602
|
+
: undefined;
|
|
603
|
+
sessions.addRecordedAction(session, {
|
|
604
|
+
at: Date.now(),
|
|
605
|
+
action: 'assert_visual',
|
|
606
|
+
assertion,
|
|
607
|
+
exportability: 'semantic',
|
|
608
|
+
provenance: {
|
|
609
|
+
...(uri ? { screenshotUri: uri } : {}),
|
|
610
|
+
selectorKind: 'visual_region',
|
|
611
|
+
selectorValue: assertion,
|
|
612
|
+
visual: { screenshotCrop, confidence: 1, density: coordinateSpace.density ?? null, orientation: coordinateSpace.orientation },
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return qaOk({
|
|
617
|
+
mode: 'assert',
|
|
618
|
+
assertion,
|
|
619
|
+
pass,
|
|
620
|
+
verifiedVisually: true,
|
|
621
|
+
method: 'visual',
|
|
622
|
+
evidenceKind: 'visual_match',
|
|
623
|
+
screenshotUri: uri ?? null,
|
|
624
|
+
coordinateSpace,
|
|
625
|
+
...unverifiedNote(ctx),
|
|
626
|
+
}, `visual assertion ${pass ? '✅ PASS' : '❌ FAIL'}: "${assertion}"\n${uri ? `evidence: ${uri}` : WITHHELD_EVIDENCE}`);
|
|
627
|
+
}
|
|
628
|
+
const MODE_HANDLERS = {
|
|
629
|
+
assert: assertVisual,
|
|
630
|
+
baseline,
|
|
631
|
+
diff,
|
|
632
|
+
find_text: findText,
|
|
633
|
+
find_image: findImage,
|
|
634
|
+
};
|
|
22
635
|
export function registerVisual(server, sessions) {
|
|
23
636
|
server.registerTool('qa_visual', {
|
|
24
|
-
title: 'Visual
|
|
25
|
-
description: '
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
637
|
+
title: 'Visual assert / baseline / diff / find text / find image',
|
|
638
|
+
description: 'Screenshot-based checks for screens without a usable UI tree (maps, canvases, WDA-less iOS). mode: assert (pass/fail ' +
|
|
639
|
+
'with screenshot evidence; pass:false if NOT visible), baseline (save .swipium/baselines/<name>.png in the repo), diff ' +
|
|
640
|
+
'(vs a baseline; pass within threshold), find_text (OCR via a locally configured provider; consent-gated), find_image ' +
|
|
641
|
+
'(template-match a project PNG). Finds return device coordinates + coordinateSpace; tap:true taps the hit (recorded + ' +
|
|
642
|
+
'budgeted). Withheld when a password/OTP field is on screen unless force:true; on an unverified screen in a credential ' +
|
|
643
|
+
'session only baseline needs force (diff/assert evidence is not saved). Details: docs/tools.md#qa_visual.',
|
|
29
644
|
inputSchema: {
|
|
30
645
|
sessionId: z.string(),
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
646
|
+
mode: z.enum(VISUAL_MODES),
|
|
647
|
+
assertion: z.string().optional().describe('assert: what you visually confirmed, e.g. "Live Map shows the route".'),
|
|
648
|
+
pass: z.boolean().optional().describe('assert: default true; false if the expected result is NOT visible.'),
|
|
649
|
+
reason: z.string().optional().describe('assert: extra detail (what you saw / why it failed).'),
|
|
650
|
+
name: z.string().optional().describe('baseline/diff: baseline name, [A-Za-z0-9._-]{1,64}.'),
|
|
651
|
+
query: z.string().optional().describe('find_text: text to find.'),
|
|
652
|
+
template: z.string().optional().describe('find_image: PNG inside the project root, or a swipium:// artifact URI.'),
|
|
653
|
+
threshold: z.number().optional().describe('diff: max changed fraction to pass (default 0.02).'),
|
|
654
|
+
minScore: z.number().optional().describe('find_image: min match score 0..1 (default 0.85).'),
|
|
655
|
+
minConfidence: z.number().optional().describe('find_text: min OCR confidence 0..1 (default 0.8).'),
|
|
656
|
+
tap: z.boolean().optional().describe('find_text/find_image: tap the found point.'),
|
|
657
|
+
force: z.boolean().optional().describe('Capture even if a secure field may be visible.'),
|
|
37
658
|
consentId: z.string().optional(),
|
|
38
659
|
approve: z.boolean().optional(),
|
|
39
660
|
},
|
|
40
|
-
}, async (
|
|
41
|
-
const session = sessions.get(sessionId);
|
|
42
|
-
|
|
661
|
+
}, async (args) => {
|
|
662
|
+
const session = sessions.get(args.sessionId);
|
|
663
|
+
if (!session)
|
|
664
|
+
return unknownSessionError(args.sessionId);
|
|
665
|
+
const { driver, blocked } = await getDriver(session);
|
|
43
666
|
if (!session || !driver) {
|
|
44
|
-
return
|
|
667
|
+
return (blockedDeviceResult(blocked) ??
|
|
668
|
+
qaError({
|
|
669
|
+
what: 'No device attached to this session',
|
|
670
|
+
changedState: false,
|
|
671
|
+
retrySafe: true,
|
|
672
|
+
nextSteps: ['Call qa_prepare_target (Android) or qa_prepare_ios_target (iOS) first.'],
|
|
673
|
+
}));
|
|
45
674
|
}
|
|
46
675
|
if (session.sensitive)
|
|
47
676
|
return sensitiveRefusal('Visual capture');
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
return
|
|
677
|
+
// Budget gate (B7): same rule as qa_act / qa_screenshot: no new capture or tap once spent.
|
|
678
|
+
const stopReason = sessions.budgetStop(session);
|
|
679
|
+
if (stopReason)
|
|
680
|
+
return qaStop(stopReason, { counters: session.counters, mode: session.mode });
|
|
681
|
+
// Secure-screen guard (same policy as qa_screenshot, THREAT_MODEL "Sensitive-screen
|
|
682
|
+
// capture"): never persist or OCR password/OTP pixels by default. Without a fresh UI tree
|
|
683
|
+
// (B8) the screen is unverified; in a session that has handled credentials only the
|
|
684
|
+
// mode that persists a capture into the repo (baseline) requires force:true; the others
|
|
685
|
+
// run, but never persist the capture (diff/assert evidence withheld), and find_text
|
|
686
|
+
// screens the OCR text itself for password/OTP wording (withheld on a hit).
|
|
687
|
+
const secureState = await refreshSecureState(session, driver);
|
|
688
|
+
if (!args.force && secureState === 'secure') {
|
|
689
|
+
return qaError({
|
|
690
|
+
what: 'Withheld: a secure field (password/OTP) is on screen',
|
|
691
|
+
changedState: false,
|
|
692
|
+
retrySafe: true,
|
|
693
|
+
failureCode: 'CAPTURE_WITHHELD_SECURE',
|
|
694
|
+
nextSteps: ['Pass force:true to proceed (pixels are NOT redactable), or use a non-sensitive screen.'],
|
|
695
|
+
});
|
|
52
696
|
}
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
let preview;
|
|
67
|
-
let maskPreview;
|
|
68
|
-
try {
|
|
69
|
-
preview = resolveVisualProvider(ocrCommand, { image: '<screenshot>' }, 30000);
|
|
70
|
-
maskPreview = resolveMaskProvider(session.root);
|
|
71
|
-
}
|
|
72
|
-
catch (e) {
|
|
73
|
-
return qaError({ what: e instanceof GitScopeForbiddenError ? e.message : `Invalid OCR command template: ${String(e)}`, changedState: false, retrySafe: !(e instanceof GitScopeForbiddenError), failureCode: e instanceof GitScopeForbiddenError ? 'GIT_SCOPE_FORBIDDEN' : 'INVALID_FLOW', nextSteps: e instanceof GitScopeForbiddenError ? ['Run Git yourself outside Swipium; configure ocrCommand to use a non-Git executable.'] : ['Use an argv array in .swipium/config.json, e.g. ["node","ocr.js","{image}"].'] });
|
|
74
|
-
}
|
|
75
|
-
const maskConfigured = !!maskPreview;
|
|
76
|
-
const gate = consumeConsent(consentId, approve, { action: 'ocr_run', affects: { argv: preview.argv, io: preview.io, maskConfigured } });
|
|
77
|
-
if (!gate.approved) {
|
|
78
|
-
return requireConsent({ action: 'ocr_run', risk: 'medium', exactCommand: displayArgv(preview.argv), affects: { argv: preview.argv, io: preview.io, maskConfigured }, explain: 'Run the configured OCR command on a screenshot of the current screen? The screen image is passed to that local program or via the Swipium JSON provider contract. If visualMaskCommand is configured, Swipium runs it first and sends the masked image.' });
|
|
79
|
-
}
|
|
80
|
-
try {
|
|
81
|
-
const ocr = await runOcr(driver, session.root, ocrCommand);
|
|
82
|
-
const bounded = boundedText(ocr.text.trim(), makeRedactor(session.secrets), 8000);
|
|
83
|
-
return qaOk({ action, method: 'ocr', evidenceKind: 'ocr_text', text: bounded.text, truncated: bounded.truncated, regions: ocr.regions, coordinateSpace: ocr.coordinateSpace, provider: ocr.provider, masking: ocr.masking }, `OCR text (${bounded.text.length}${bounded.truncated ? '+ truncated' : ''} chars):\n${bounded.text.slice(0, 2000)}`);
|
|
84
|
-
}
|
|
85
|
-
catch (e) {
|
|
86
|
-
return qaError({ what: `OCR command failed: ${String(e)}`, changedState: false, retrySafe: true, nextSteps: ['Check the configured OCR command runs standalone on a PNG.'] });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
// capture once for the pixel actions
|
|
90
|
-
let png;
|
|
91
|
-
try {
|
|
92
|
-
png = await driver.screenshot();
|
|
93
|
-
}
|
|
94
|
-
catch (e) {
|
|
95
|
-
return qaError({ what: `Screenshot failed: ${String(e)}`, changedState: false, retrySafe: true, nextSteps: ['Confirm the device is online.'] });
|
|
96
|
-
}
|
|
97
|
-
const coordinateSpace = await captureCoordinateSpace(driver, png);
|
|
98
|
-
if (action === 'baseline') {
|
|
99
|
-
if (!name)
|
|
100
|
-
return qaError({ what: 'baseline requires a name', changedState: false, retrySafe: true, nextSteps: ['Pass name="home-screen".'] });
|
|
101
|
-
mkdirSync(baselinesDir, { recursive: true });
|
|
102
|
-
writeFileSync(join(baselinesDir, `${name}.png`), png);
|
|
103
|
-
const uri = sessions.saveArtifact(session, 'baseline', `${name}.png`, png, 'image/png', `visual baseline: ${name}`);
|
|
104
|
-
return qaOk({ action, name, uri, coordinateSpace }, `saved baseline "${name}" (${png.length} bytes) → ${uri}`);
|
|
105
|
-
}
|
|
106
|
-
if (action === 'diff') {
|
|
107
|
-
if (!name)
|
|
108
|
-
return qaError({ what: 'diff requires a baseline name', changedState: false, retrySafe: true, nextSteps: ['Pass the name used with action:"baseline".'] });
|
|
109
|
-
const basePath = join(baselinesDir, `${name}.png`);
|
|
110
|
-
if (!existsSync(basePath)) {
|
|
111
|
-
return qaError({ what: `No baseline "${name}" — capture one first`, changedState: false, retrySafe: true, nextSteps: [`Call qa_visual { action: "baseline", name: "${name}" } on the reference screen.`] });
|
|
112
|
-
}
|
|
113
|
-
const result = imageDiff(readFileSync(basePath), png);
|
|
114
|
-
const tol = threshold ?? 0.02;
|
|
115
|
-
const pass = result.comparable && result.ratio <= tol;
|
|
116
|
-
const currentUri = sessions.saveArtifact(session, 'screenshot', `diff-${name}-${Date.now()}.png`, png, 'image/png', `diff vs baseline ${name}`);
|
|
117
|
-
const deviceBox = result.box ? { x: toDevicePoint(coordinateSpace, result.box.x, result.box.y).x, y: toDevicePoint(coordinateSpace, result.box.x, result.box.y).y, width: Math.round(result.box.width / (coordinateSpace.scale ?? 1)), height: Math.round(result.box.height / (coordinateSpace.scale ?? 1)) } : null;
|
|
118
|
-
return qaOk({ action, name, method: 'visual', comparable: result.comparable, reason: result.reason, changedRatio: Math.round(result.ratio * 10000) / 10000, threshold: tol, pass, changedBox: result.box, changedBoxDevice: deviceBox, currentUri, coordinateSpace }, `diff vs "${name}": ${result.comparable ? `${(result.ratio * 100).toFixed(2)}% changed (threshold ${(tol * 100).toFixed(1)}%) → ${pass ? '✅ PASS' : '❌ FAIL'}` : `not comparable: ${result.reason}`}\nevidence: ${currentUri}`);
|
|
119
|
-
}
|
|
120
|
-
// action === 'find_image'
|
|
121
|
-
if (!template)
|
|
122
|
-
return qaError({ what: 'find_image requires a template path', changedState: false, retrySafe: true, nextSteps: ['Pass template="/abs/or/project-relative/reference.png".'] });
|
|
123
|
-
const tplPath = isAbsolute(template) ? template : join(session.root, template);
|
|
124
|
-
if (!existsSync(tplPath))
|
|
125
|
-
return qaError({ what: `Template not found: ${tplPath}`, changedState: false, retrySafe: true, nextSteps: ['Provide an existing PNG path.'] });
|
|
126
|
-
let match;
|
|
127
|
-
try {
|
|
128
|
-
match = findTemplate(png, readFileSync(tplPath), minScore ?? 0.85);
|
|
129
|
-
}
|
|
130
|
-
catch (e) {
|
|
131
|
-
return qaError({ what: `Image match failed: ${String(e)}`, changedState: false, retrySafe: true, nextSteps: ['Ensure the template is an 8-bit PNG smaller than the screen.'] });
|
|
697
|
+
const unverifiedCredentialScreen = !args.force && secureState === 'unknown' && hasSecretContext(session);
|
|
698
|
+
if (unverifiedCredentialScreen && args.mode === 'baseline') {
|
|
699
|
+
return qaError({
|
|
700
|
+
what: 'Withheld: a baseline persists the screenshot, but no fresh UI tree can verify the screen has no password/OTP field, and this session has handled credentials',
|
|
701
|
+
changedState: false,
|
|
702
|
+
retrySafe: true,
|
|
703
|
+
failureCode: 'CAPTURE_WITHHELD_SECURE',
|
|
704
|
+
nextSteps: [
|
|
705
|
+
'Run qa_snapshot to refresh the UI tree (when a structured backend is attached), or pass force:true to proceed (pixels are NOT redactable).',
|
|
706
|
+
],
|
|
707
|
+
});
|
|
132
708
|
}
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
709
|
+
const ctx = {
|
|
710
|
+
sessions,
|
|
711
|
+
session,
|
|
712
|
+
driver,
|
|
713
|
+
args,
|
|
714
|
+
redact: makeRedactor(session.secrets),
|
|
715
|
+
secureState,
|
|
716
|
+
withholdCapture: unverifiedCredentialScreen,
|
|
717
|
+
};
|
|
718
|
+
return MODE_HANDLERS[args.mode](ctx);
|
|
137
719
|
});
|
|
138
720
|
}
|
|
139
721
|
//# sourceMappingURL=visual.js.map
|