@agentrhq/webcmd 0.1.1 → 0.1.2
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/README.md +317 -24
- package/dist/src/adapter-shadow.d.ts +11 -0
- package/dist/src/adapter-shadow.js +72 -0
- package/dist/src/adapter-shadow.test.d.ts +1 -0
- package/dist/src/adapter-shadow.test.js +49 -0
- package/dist/src/adapter-source.d.ts +11 -0
- package/dist/src/adapter-source.js +24 -0
- package/dist/src/adapter-source.test.d.ts +1 -0
- package/dist/src/adapter-source.test.js +29 -0
- package/dist/src/brand.d.ts +9 -0
- package/dist/src/brand.js +9 -0
- package/dist/src/brand.test.d.ts +1 -0
- package/dist/src/brand.test.js +15 -0
- package/dist/src/browser/analyze.d.ts +118 -0
- package/dist/src/browser/analyze.js +405 -0
- package/dist/src/browser/analyze.test.d.ts +1 -0
- package/dist/src/browser/analyze.test.js +209 -0
- package/dist/src/browser/article-extract.d.ts +57 -0
- package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
- package/dist/src/browser/article-extract.e2e.test.js +105 -0
- package/dist/src/browser/article-extract.js +169 -0
- package/dist/src/browser/article-extract.test.d.ts +1 -0
- package/dist/src/browser/article-extract.test.js +94 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +151 -0
- package/dist/src/browser/base-page.js +1060 -0
- package/dist/src/browser/base-page.test.d.ts +1 -0
- package/dist/src/browser/base-page.test.js +803 -0
- package/dist/src/browser/bridge-readiness.d.ts +14 -0
- package/dist/src/browser/bridge-readiness.js +24 -0
- package/dist/src/browser/bridge-readiness.test.d.ts +1 -0
- package/dist/src/browser/bridge-readiness.test.js +58 -0
- package/dist/src/browser/bridge.d.ts +26 -0
- package/dist/src/browser/bridge.js +58 -0
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.d.ts +46 -0
- package/dist/src/browser/cdp.js +536 -0
- package/dist/src/browser/cdp.test.d.ts +1 -0
- package/dist/src/browser/cdp.test.js +79 -0
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.d.ts +1 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/config.d.ts +2 -0
- package/dist/src/browser/config.js +14 -0
- package/dist/src/browser/daemon-client.d.ts +32 -0
- package/dist/src/browser/daemon-client.js +135 -0
- package/dist/src/browser/daemon-client.test.d.ts +1 -0
- package/dist/src/browser/daemon-client.test.js +326 -0
- package/dist/src/browser/daemon-lifecycle.d.ts +37 -0
- package/dist/src/browser/daemon-lifecycle.js +147 -0
- package/dist/src/browser/daemon-transport.d.ts +54 -0
- package/dist/src/browser/daemon-transport.js +68 -0
- package/dist/src/browser/daemon-version.d.ts +4 -0
- package/dist/src/browser/daemon-version.js +12 -0
- package/dist/src/browser/dom-helpers.d.ts +41 -0
- package/dist/src/browser/dom-helpers.js +239 -0
- package/dist/src/browser/dom-helpers.test.d.ts +1 -0
- package/dist/src/browser/dom-helpers.test.js +106 -0
- package/dist/src/browser/dom-snapshot.d.ts +93 -0
- package/dist/src/browser/dom-snapshot.js +894 -0
- package/dist/src/browser/dom-snapshot.test.d.ts +11 -0
- package/dist/src/browser/dom-snapshot.test.js +313 -0
- package/dist/src/browser/errors.d.ts +38 -0
- package/dist/src/browser/errors.js +79 -0
- package/dist/src/browser/errors.test.d.ts +1 -0
- package/dist/src/browser/errors.test.js +60 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.d.ts +1 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +84 -0
- package/dist/src/browser/find.js +398 -0
- package/dist/src/browser/find.test.d.ts +1 -0
- package/dist/src/browser/find.test.js +180 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/index.d.ts +14 -0
- package/dist/src/browser/index.js +12 -0
- package/dist/src/browser/network-cache.d.ts +49 -0
- package/dist/src/browser/network-cache.js +78 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +75 -0
- package/dist/src/browser/network-interceptor.d.ts +11 -0
- package/dist/src/browser/network-interceptor.js +11 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/page.d.ts +83 -0
- package/dist/src/browser/page.js +453 -0
- package/dist/src/browser/page.test.d.ts +1 -0
- package/dist/src/browser/page.test.js +406 -0
- package/dist/src/browser/profile.d.ts +14 -0
- package/dist/src/browser/profile.js +86 -0
- package/dist/src/browser/protocol.d.ts +63 -0
- package/dist/src/browser/protocol.js +1 -0
- package/dist/src/browser/runtime/local-cloak/actions.d.ts +3 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +240 -0
- package/dist/src/browser/runtime/local-cloak/downloads.d.ts +3 -0
- package/dist/src/browser/runtime/local-cloak/downloads.js +28 -0
- package/dist/src/browser/runtime/local-cloak/network.d.ts +28 -0
- package/dist/src/browser/runtime/local-cloak/network.js +120 -0
- package/dist/src/browser/runtime/local-cloak/network.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/network.test.js +124 -0
- package/dist/src/browser/runtime/local-cloak/profiles.d.ts +6 -0
- package/dist/src/browser/runtime/local-cloak/profiles.js +17 -0
- package/dist/src/browser/runtime/local-cloak/profiles.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/profiles.test.js +19 -0
- package/dist/src/browser/runtime/local-cloak/provider.d.ts +15 -0
- package/dist/src/browser/runtime/local-cloak/provider.js +27 -0
- package/dist/src/browser/runtime/local-cloak/provider.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/provider.test.js +244 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +77 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +280 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.test.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +92 -0
- package/dist/src/browser/runtime/provider.d.ts +9 -0
- package/dist/src/browser/runtime/provider.js +1 -0
- package/dist/src/browser/runtime/provider.test.d.ts +2 -0
- package/dist/src/browser/runtime/provider.test.js +49 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/stealth.d.ts +11 -0
- package/dist/src/browser/stealth.js +359 -0
- package/dist/src/browser/stealth.test.d.ts +1 -0
- package/dist/src/browser/stealth.test.js +134 -0
- package/dist/src/browser/tabs.d.ts +13 -0
- package/dist/src/browser/tabs.js +70 -0
- package/dist/src/browser/target-errors.d.ts +39 -0
- package/dist/src/browser/target-errors.js +45 -0
- package/dist/src/browser/target-errors.test.d.ts +1 -0
- package/dist/src/browser/target-errors.test.js +94 -0
- package/dist/src/browser/target-resolver.d.ts +158 -0
- package/dist/src/browser/target-resolver.js +570 -0
- package/dist/src/browser/target-resolver.test.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +118 -0
- package/dist/src/browser/utils.d.ts +20 -0
- package/dist/src/browser/utils.js +64 -0
- package/dist/src/browser/utils.test.d.ts +1 -0
- package/dist/src/browser/utils.test.js +29 -0
- package/dist/src/browser/verify-fixture.d.ts +65 -0
- package/dist/src/browser/verify-fixture.js +318 -0
- package/dist/src/browser/verify-fixture.test.d.ts +1 -0
- package/dist/src/browser/verify-fixture.test.js +219 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/browser.test.d.ts +1 -0
- package/dist/src/browser.test.js +297 -0
- package/dist/src/build-manifest.d.ts +103 -0
- package/dist/src/build-manifest.js +363 -0
- package/dist/src/build-manifest.test.d.ts +1 -0
- package/dist/src/build-manifest.test.js +341 -0
- package/dist/src/capabilityRouting.d.ts +19 -0
- package/dist/src/capabilityRouting.js +56 -0
- package/dist/src/capabilityRouting.test.d.ts +1 -0
- package/dist/src/capabilityRouting.test.js +63 -0
- package/dist/src/cli-argv-preprocess.d.ts +63 -0
- package/dist/src/cli-argv-preprocess.js +303 -0
- package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
- package/dist/src/cli-argv-preprocess.test.js +304 -0
- package/dist/src/cli.d.ts +79 -0
- package/dist/src/cli.js +3350 -0
- package/dist/src/cli.test.d.ts +1 -0
- package/dist/src/cli.test.js +3041 -0
- package/dist/src/commanderAdapter.d.ts +21 -0
- package/dist/src/commanderAdapter.js +208 -0
- package/dist/src/commanderAdapter.test.d.ts +1 -0
- package/dist/src/commanderAdapter.test.js +352 -0
- package/dist/src/commands/auth.d.ts +40 -0
- package/dist/src/commands/auth.js +434 -0
- package/dist/src/commands/auth.test.d.ts +1 -0
- package/dist/src/commands/auth.test.js +252 -0
- package/dist/src/commands/daemon.d.ts +9 -0
- package/dist/src/commands/daemon.js +91 -0
- package/dist/src/commands/daemon.test.d.ts +1 -0
- package/dist/src/commands/daemon.test.js +293 -0
- package/dist/src/completion-fast.d.ts +22 -0
- package/dist/src/completion-fast.js +88 -0
- package/dist/src/completion-shared.d.ts +13 -0
- package/dist/src/completion-shared.js +61 -0
- package/dist/src/completion.d.ts +21 -0
- package/dist/src/completion.js +68 -0
- package/dist/src/completion.test.d.ts +1 -0
- package/dist/src/completion.test.js +24 -0
- package/dist/src/constants.d.ts +18 -0
- package/dist/src/constants.js +41 -0
- package/dist/src/convention-audit.d.ts +50 -0
- package/dist/src/convention-audit.js +553 -0
- package/dist/src/convention-audit.test.d.ts +1 -0
- package/dist/src/convention-audit.test.js +255 -0
- package/dist/src/daemon/server.d.ts +13 -0
- package/dist/src/daemon/server.js +185 -0
- package/dist/src/daemon/server.test.d.ts +1 -0
- package/dist/src/daemon/server.test.js +132 -0
- package/dist/src/daemon-utils.d.ts +18 -0
- package/dist/src/daemon-utils.js +37 -0
- package/dist/src/daemon.d.ts +1 -0
- package/dist/src/daemon.js +27 -0
- package/dist/src/daemon.test.d.ts +1 -0
- package/dist/src/daemon.test.js +60 -0
- package/dist/src/discovery.d.ts +46 -0
- package/dist/src/discovery.js +341 -0
- package/dist/src/doctor.d.ts +39 -0
- package/dist/src/doctor.js +168 -0
- package/dist/src/doctor.test.d.ts +1 -0
- package/dist/src/doctor.test.js +302 -0
- package/dist/src/download/article-download.d.ts +72 -0
- package/dist/src/download/article-download.js +305 -0
- package/dist/src/download/article-download.test.d.ts +1 -0
- package/dist/src/download/article-download.test.js +235 -0
- package/dist/src/download/index.d.ts +71 -0
- package/dist/src/download/index.js +363 -0
- package/dist/src/download/index.test.d.ts +1 -0
- package/dist/src/download/index.test.js +174 -0
- package/dist/src/download/media-download.d.ts +49 -0
- package/dist/src/download/media-download.js +127 -0
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.d.ts +36 -0
- package/dist/src/download/progress.js +120 -0
- package/dist/src/download/progress.test.d.ts +1 -0
- package/dist/src/download/progress.test.js +36 -0
- package/dist/src/electron-apps.d.ts +31 -0
- package/dist/src/electron-apps.js +91 -0
- package/dist/src/electron-apps.test.d.ts +1 -0
- package/dist/src/electron-apps.test.js +76 -0
- package/dist/src/engine.test.d.ts +1 -0
- package/dist/src/engine.test.js +312 -0
- package/dist/src/errors.d.ts +114 -0
- package/dist/src/errors.js +174 -0
- package/dist/src/errors.test.d.ts +1 -0
- package/dist/src/errors.test.js +109 -0
- package/dist/src/execution.d.ts +24 -0
- package/dist/src/execution.js +509 -0
- package/dist/src/execution.test.d.ts +1 -0
- package/dist/src/execution.test.js +645 -0
- package/dist/src/external.d.ts +49 -0
- package/dist/src/external.js +211 -0
- package/dist/src/external.test.d.ts +1 -0
- package/dist/src/external.test.js +110 -0
- package/dist/src/help.d.ts +88 -0
- package/dist/src/help.js +556 -0
- package/dist/src/help.test.d.ts +1 -0
- package/dist/src/help.test.js +58 -0
- package/dist/src/hooks.d.ts +46 -0
- package/dist/src/hooks.js +58 -0
- package/dist/src/hooks.test.d.ts +4 -0
- package/dist/src/hooks.test.js +92 -0
- package/dist/src/interceptor.d.ts +44 -0
- package/dist/src/interceptor.js +183 -0
- package/dist/src/interceptor.test.d.ts +4 -0
- package/dist/src/interceptor.test.js +81 -0
- package/dist/src/launcher.d.ts +41 -0
- package/dist/src/launcher.js +226 -0
- package/dist/src/launcher.test.d.ts +1 -0
- package/dist/src/launcher.test.js +153 -0
- package/dist/src/logger.d.ts +26 -0
- package/dist/src/logger.js +49 -0
- package/dist/src/main.d.ts +5 -0
- package/dist/src/main.js +177 -0
- package/dist/src/manifest-types.d.ts +43 -0
- package/dist/src/manifest-types.js +9 -0
- package/dist/src/node-network.d.ts +10 -0
- package/dist/src/node-network.js +174 -0
- package/dist/src/node-network.test.d.ts +1 -0
- package/dist/src/node-network.test.js +55 -0
- package/dist/src/observation/artifact.d.ts +16 -0
- package/dist/src/observation/artifact.js +260 -0
- package/dist/src/observation/artifact.test.d.ts +1 -0
- package/dist/src/observation/artifact.test.js +121 -0
- package/dist/src/observation/events.d.ts +89 -0
- package/dist/src/observation/events.js +1 -0
- package/dist/src/observation/index.d.ts +7 -0
- package/dist/src/observation/index.js +7 -0
- package/dist/src/observation/manager.d.ts +9 -0
- package/dist/src/observation/manager.js +27 -0
- package/dist/src/observation/manager.test.d.ts +1 -0
- package/dist/src/observation/manager.test.js +13 -0
- package/dist/src/observation/redaction.d.ts +11 -0
- package/dist/src/observation/redaction.js +81 -0
- package/dist/src/observation/redaction.test.d.ts +1 -0
- package/dist/src/observation/redaction.test.js +32 -0
- package/dist/src/observation/retention.d.ts +32 -0
- package/dist/src/observation/retention.js +160 -0
- package/dist/src/observation/retention.test.d.ts +1 -0
- package/dist/src/observation/retention.test.js +118 -0
- package/dist/src/observation/ring-buffer.d.ts +22 -0
- package/dist/src/observation/ring-buffer.js +45 -0
- package/dist/src/observation/ring-buffer.test.d.ts +1 -0
- package/dist/src/observation/ring-buffer.test.js +22 -0
- package/dist/src/observation/session.d.ts +25 -0
- package/dist/src/observation/session.js +50 -0
- package/dist/src/output.d.ts +14 -0
- package/dist/src/output.js +152 -0
- package/dist/src/output.test.d.ts +1 -0
- package/dist/src/output.test.js +53 -0
- package/dist/src/package-exports.test.d.ts +1 -0
- package/dist/src/package-exports.test.js +112 -0
- package/dist/src/package-paths.d.ts +8 -0
- package/dist/src/package-paths.js +41 -0
- package/dist/src/pipeline/executor.d.ts +11 -0
- package/dist/src/pipeline/executor.js +93 -0
- package/dist/src/pipeline/executor.test.d.ts +4 -0
- package/dist/src/pipeline/executor.test.js +182 -0
- package/dist/src/pipeline/index.d.ts +5 -0
- package/dist/src/pipeline/index.js +5 -0
- package/dist/src/pipeline/registry.d.ts +28 -0
- package/dist/src/pipeline/registry.js +55 -0
- package/dist/src/pipeline/steps/browser.d.ts +13 -0
- package/dist/src/pipeline/steps/browser.js +86 -0
- package/dist/src/pipeline/steps/download.d.ts +18 -0
- package/dist/src/pipeline/steps/download.js +252 -0
- package/dist/src/pipeline/steps/download.test.d.ts +1 -0
- package/dist/src/pipeline/steps/download.test.js +102 -0
- package/dist/src/pipeline/steps/fetch.d.ts +5 -0
- package/dist/src/pipeline/steps/fetch.js +122 -0
- package/dist/src/pipeline/steps/fetch.test.d.ts +1 -0
- package/dist/src/pipeline/steps/fetch.test.js +117 -0
- package/dist/src/pipeline/steps/intercept.d.ts +5 -0
- package/dist/src/pipeline/steps/intercept.js +50 -0
- package/dist/src/pipeline/steps/tap.d.ts +12 -0
- package/dist/src/pipeline/steps/tap.js +93 -0
- package/dist/src/pipeline/steps/transform.d.ts +9 -0
- package/dist/src/pipeline/steps/transform.js +70 -0
- package/dist/src/pipeline/template.d.ts +17 -0
- package/dist/src/pipeline/template.js +344 -0
- package/dist/src/pipeline/template.test.d.ts +4 -0
- package/dist/src/pipeline/template.test.js +197 -0
- package/dist/src/pipeline/transform.test.d.ts +4 -0
- package/dist/src/pipeline/transform.test.js +133 -0
- package/dist/src/plugin-manifest.d.ts +70 -0
- package/dist/src/plugin-manifest.js +160 -0
- package/dist/src/plugin-manifest.test.d.ts +4 -0
- package/dist/src/plugin-manifest.test.js +179 -0
- package/dist/src/plugin-scaffold.d.ts +28 -0
- package/dist/src/plugin-scaffold.js +143 -0
- package/dist/src/plugin-scaffold.test.d.ts +4 -0
- package/dist/src/plugin-scaffold.test.js +83 -0
- package/dist/src/plugin.d.ts +146 -0
- package/dist/src/plugin.js +1303 -0
- package/dist/src/plugin.test.d.ts +4 -0
- package/dist/src/plugin.test.js +1363 -0
- package/dist/src/registry-api.d.ts +13 -0
- package/dist/src/registry-api.js +10 -0
- package/dist/src/registry.d.ts +123 -0
- package/dist/src/registry.js +125 -0
- package/dist/src/registry.test.d.ts +4 -0
- package/dist/src/registry.test.js +209 -0
- package/dist/src/runtime-copy.test.d.ts +1 -0
- package/dist/src/runtime-copy.test.js +29 -0
- package/dist/src/runtime-detect.d.ts +31 -0
- package/dist/src/runtime-detect.js +47 -0
- package/dist/src/runtime-detect.test.d.ts +1 -0
- package/dist/src/runtime-detect.test.js +39 -0
- package/dist/src/runtime-identity.test.d.ts +1 -0
- package/dist/src/runtime-identity.test.js +18 -0
- package/dist/src/runtime.d.ts +47 -0
- package/dist/src/runtime.js +54 -0
- package/dist/src/scripts/framework.d.ts +7 -0
- package/dist/src/scripts/framework.js +25 -0
- package/dist/src/scripts/interact.d.ts +4 -0
- package/dist/src/scripts/interact.js +20 -0
- package/dist/src/scripts/store.d.ts +13 -0
- package/dist/src/scripts/store.js +48 -0
- package/dist/src/serialization.d.ts +42 -0
- package/dist/src/serialization.js +103 -0
- package/dist/src/serialization.test.d.ts +1 -0
- package/dist/src/serialization.test.js +78 -0
- package/dist/src/skills.d.ts +14 -0
- package/dist/src/skills.js +127 -0
- package/dist/src/skills.test.d.ts +1 -0
- package/dist/src/skills.test.js +71 -0
- package/dist/src/snapshotFormatter.d.ts +11 -0
- package/dist/src/snapshotFormatter.js +338 -0
- package/dist/src/snapshotFormatter.test.d.ts +7 -0
- package/dist/src/snapshotFormatter.test.js +521 -0
- package/dist/src/tui.d.ts +28 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/tui.test.d.ts +1 -0
- package/dist/src/tui.test.js +19 -0
- package/dist/src/types.d.ts +236 -0
- package/dist/src/types.js +7 -0
- package/dist/src/update-check.d.ts +66 -0
- package/dist/src/update-check.js +206 -0
- package/dist/src/update-check.test.d.ts +1 -0
- package/dist/src/update-check.test.js +117 -0
- package/dist/src/utils.d.ts +57 -0
- package/dist/src/utils.js +152 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/dist/src/validate.d.ts +21 -0
- package/dist/src/validate.js +130 -0
- package/dist/src/validate.test.d.ts +9 -0
- package/dist/src/validate.test.js +90 -0
- package/dist/src/verify.d.ts +26 -0
- package/dist/src/verify.js +67 -0
- package/dist/src/version.d.ts +4 -0
- package/dist/src/version.js +22 -0
- package/dist/src/weixin-download.test.d.ts +1 -0
- package/dist/src/weixin-download.test.js +45 -0
- package/package.json +4 -3
- package/scripts/check-package-bin.mjs +95 -0
|
@@ -0,0 +1,3041 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import { cli, getRegistry, Strategy } from './registry.js';
|
|
7
|
+
import { BrowserCommandError } from './browser/daemon-client.js';
|
|
8
|
+
import { TargetError } from './browser/target-errors.js';
|
|
9
|
+
import { PKG_VERSION } from './version.js';
|
|
10
|
+
import { classifyAdapter } from './help.js';
|
|
11
|
+
const { mockBrowserConnect, mockBrowserClose, mockBindTab, mockSendCommand, mockExecFileSync, browserState, } = vi.hoisted(() => ({
|
|
12
|
+
mockBrowserConnect: vi.fn(),
|
|
13
|
+
mockBrowserClose: vi.fn(),
|
|
14
|
+
mockBindTab: vi.fn(),
|
|
15
|
+
mockSendCommand: vi.fn(),
|
|
16
|
+
mockExecFileSync: vi.fn(),
|
|
17
|
+
browserState: { page: null },
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('./browser/index.js', () => {
|
|
20
|
+
mockBrowserConnect.mockImplementation(async () => browserState.page);
|
|
21
|
+
return {
|
|
22
|
+
BrowserBridge: class {
|
|
23
|
+
connect = mockBrowserConnect;
|
|
24
|
+
close = mockBrowserClose;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
vi.mock('./browser/daemon-client.js', async () => {
|
|
29
|
+
const actual = await vi.importActual('./browser/daemon-client.js');
|
|
30
|
+
return {
|
|
31
|
+
...actual,
|
|
32
|
+
bindTab: mockBindTab,
|
|
33
|
+
sendCommand: mockSendCommand,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
vi.mock('node:child_process', async () => {
|
|
37
|
+
const actual = await vi.importActual('node:child_process');
|
|
38
|
+
return {
|
|
39
|
+
...actual,
|
|
40
|
+
execFileSync: mockExecFileSync,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
import { createProgram, findPackageRoot, normalizeVerifyRows, renderVerifyPreview, resolveBrowserVerifyInvocation, resolveSitemapAvailabilityForUrl, selectFreshByTimestamp } from './cli.js';
|
|
44
|
+
describe('createProgram root help descriptions', () => {
|
|
45
|
+
function descriptionFor(program, name) {
|
|
46
|
+
return program.commands.find(cmd => cmd.name() === name)?.description();
|
|
47
|
+
}
|
|
48
|
+
it('summarizes built-in command groups with their subcommands', () => {
|
|
49
|
+
const program = createProgram('', '');
|
|
50
|
+
expect(descriptionFor(program, 'browser')).toContain('open');
|
|
51
|
+
expect(descriptionFor(program, 'browser')).toContain('type');
|
|
52
|
+
expect(descriptionFor(program, 'browser')).toContain('verify');
|
|
53
|
+
expect(descriptionFor(program, 'browser')).not.toContain('Browser control');
|
|
54
|
+
expect(descriptionFor(program, 'auth')).toBe('refresh, status');
|
|
55
|
+
expect(descriptionFor(program, 'plugin')).toBe('create, install, list, uninstall, update');
|
|
56
|
+
expect(descriptionFor(program, 'adapter')).toBe('eject, reset, status');
|
|
57
|
+
expect(descriptionFor(program, 'profile')).toBe('list, rename, use');
|
|
58
|
+
expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop');
|
|
59
|
+
expect(descriptionFor(program, 'external')).toBe('install, list, register');
|
|
60
|
+
});
|
|
61
|
+
it('renders auth namespace structured help', () => {
|
|
62
|
+
const argv = process.argv;
|
|
63
|
+
try {
|
|
64
|
+
const program = createProgram('', '');
|
|
65
|
+
const auth = program.commands.find(cmd => cmd.name() === 'auth');
|
|
66
|
+
expect(auth).toBeTruthy();
|
|
67
|
+
process.argv = ['node', 'webcmd', 'auth', '--help', '-f', 'yaml'];
|
|
68
|
+
const data = yaml.load(auth.helpInformation());
|
|
69
|
+
expect(data).toMatchObject({
|
|
70
|
+
namespace: 'auth',
|
|
71
|
+
description: 'Inspect website login status',
|
|
72
|
+
command_count: 2,
|
|
73
|
+
});
|
|
74
|
+
expect(data.commands.map((cmd) => cmd.name)).toEqual(['refresh', 'status']);
|
|
75
|
+
const status = auth.commands.find(cmd => cmd.name() === 'status');
|
|
76
|
+
process.argv = ['node', 'webcmd', 'auth', 'status', '--help', '-f', 'yaml'];
|
|
77
|
+
const statusData = yaml.load(status.helpInformation());
|
|
78
|
+
expect(statusData.command).toBe('webcmd auth status');
|
|
79
|
+
expect(statusData.command_options.map((option) => option.name)).toEqual(expect.arrayContaining([
|
|
80
|
+
'site',
|
|
81
|
+
'full',
|
|
82
|
+
'concurrency',
|
|
83
|
+
'timeout',
|
|
84
|
+
'only',
|
|
85
|
+
'format',
|
|
86
|
+
]));
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
process.argv = argv;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
it('keeps leaf command descriptions unchanged', () => {
|
|
93
|
+
const program = createProgram('', '');
|
|
94
|
+
expect(descriptionFor(program, 'list')).toBe('List all available CLI commands');
|
|
95
|
+
expect(descriptionFor(program, 'doctor')).toBe('Diagnose webcmd browser bridge connectivity');
|
|
96
|
+
});
|
|
97
|
+
it('keeps site adapters out of root commands and lists sites in the root help tail', () => {
|
|
98
|
+
const registry = getRegistry();
|
|
99
|
+
const snapshot = new Map(registry);
|
|
100
|
+
registry.clear();
|
|
101
|
+
try {
|
|
102
|
+
cli({
|
|
103
|
+
site: 'bilibili',
|
|
104
|
+
name: 'hot',
|
|
105
|
+
access: 'read',
|
|
106
|
+
description: 'Bilibili hot videos',
|
|
107
|
+
strategy: Strategy.PUBLIC,
|
|
108
|
+
browser: false,
|
|
109
|
+
});
|
|
110
|
+
cli({
|
|
111
|
+
site: 'youtube',
|
|
112
|
+
name: 'search',
|
|
113
|
+
access: 'read',
|
|
114
|
+
description: 'Search YouTube',
|
|
115
|
+
strategy: Strategy.PUBLIC,
|
|
116
|
+
browser: false,
|
|
117
|
+
});
|
|
118
|
+
const program = createProgram('', '');
|
|
119
|
+
const help = program.helpInformation();
|
|
120
|
+
expect(help).toContain('Site adapters (2):');
|
|
121
|
+
expect(help).toContain('bilibili, youtube');
|
|
122
|
+
expect(help).toContain("webcmd <site> --help -f yaml");
|
|
123
|
+
expect(help).not.toMatch(/\n bilibili\s+hot/);
|
|
124
|
+
expect(help).not.toMatch(/\n youtube\s+search/);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
registry.clear();
|
|
128
|
+
for (const [key, value] of snapshot)
|
|
129
|
+
registry.set(key, value);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
it('groups adapters into App / Site buckets by domain field', () => {
|
|
133
|
+
const registry = getRegistry();
|
|
134
|
+
const snapshot = new Map(registry);
|
|
135
|
+
registry.clear();
|
|
136
|
+
try {
|
|
137
|
+
cli({
|
|
138
|
+
site: 'bilibili',
|
|
139
|
+
name: 'hot',
|
|
140
|
+
access: 'read',
|
|
141
|
+
description: 'Bilibili hot videos',
|
|
142
|
+
domain: 'www.bilibili.com',
|
|
143
|
+
strategy: Strategy.PUBLIC,
|
|
144
|
+
browser: false,
|
|
145
|
+
});
|
|
146
|
+
cli({
|
|
147
|
+
site: 'chatwise',
|
|
148
|
+
name: 'ask',
|
|
149
|
+
access: 'write',
|
|
150
|
+
description: 'Ask Chatwise desktop app',
|
|
151
|
+
domain: 'localhost',
|
|
152
|
+
strategy: Strategy.UI,
|
|
153
|
+
browser: true,
|
|
154
|
+
});
|
|
155
|
+
const program = createProgram('', '');
|
|
156
|
+
const help = program.helpInformation();
|
|
157
|
+
// Two separate sections, each with own count
|
|
158
|
+
expect(help).toContain('App adapters (1):');
|
|
159
|
+
expect(help).toMatch(/App adapters \(1\):\n {2}chatwise/);
|
|
160
|
+
expect(help).toContain('Site adapters (1):');
|
|
161
|
+
expect(help).toMatch(/Site adapters \(1\):\n {2}bilibili/);
|
|
162
|
+
// App adapters appear before Site adapters (External CLIs are absent here)
|
|
163
|
+
expect(help.indexOf('App adapters')).toBeLessThan(help.indexOf('Site adapters'));
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
registry.clear();
|
|
167
|
+
for (const [key, value] of snapshot)
|
|
168
|
+
registry.set(key, value);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
it('classifies local IP domains as app adapters', () => {
|
|
172
|
+
expect(classifyAdapter('localhost')).toBe('app');
|
|
173
|
+
expect(classifyAdapter('127.0.0.1')).toBe('app');
|
|
174
|
+
expect(classifyAdapter('::1')).toBe('app');
|
|
175
|
+
expect(classifyAdapter('www.bilibili.com')).toBe('site');
|
|
176
|
+
});
|
|
177
|
+
it('splits list table output into App and Site sections without changing per-site rows', async () => {
|
|
178
|
+
const registry = getRegistry();
|
|
179
|
+
const snapshot = new Map(registry);
|
|
180
|
+
const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
181
|
+
const restoreStdoutSpy = () => stdoutSpy.mockImplementation(() => { });
|
|
182
|
+
registry.clear();
|
|
183
|
+
try {
|
|
184
|
+
cli({
|
|
185
|
+
site: 'antigravity',
|
|
186
|
+
name: 'history',
|
|
187
|
+
access: 'read',
|
|
188
|
+
description: 'Read Antigravity history',
|
|
189
|
+
domain: '127.0.0.1',
|
|
190
|
+
strategy: Strategy.UI,
|
|
191
|
+
browser: true,
|
|
192
|
+
});
|
|
193
|
+
cli({
|
|
194
|
+
site: 'chatwise',
|
|
195
|
+
name: 'ask',
|
|
196
|
+
access: 'write',
|
|
197
|
+
description: 'Ask Chatwise desktop app',
|
|
198
|
+
domain: 'localhost',
|
|
199
|
+
strategy: Strategy.UI,
|
|
200
|
+
browser: true,
|
|
201
|
+
});
|
|
202
|
+
cli({
|
|
203
|
+
site: 'bilibili',
|
|
204
|
+
name: 'hot',
|
|
205
|
+
access: 'read',
|
|
206
|
+
description: 'Bilibili hot videos',
|
|
207
|
+
domain: 'www.bilibili.com',
|
|
208
|
+
strategy: Strategy.PUBLIC,
|
|
209
|
+
browser: false,
|
|
210
|
+
});
|
|
211
|
+
const program = createProgram('', '');
|
|
212
|
+
await program.parseAsync(['node', 'webcmd', 'list']);
|
|
213
|
+
const output = stdoutSpy.mock.calls.flat().join('\n');
|
|
214
|
+
expect(output).toContain('App adapters');
|
|
215
|
+
expect(output).toContain('Site adapters');
|
|
216
|
+
expect(output.indexOf('App adapters')).toBeLessThan(output.indexOf('Site adapters'));
|
|
217
|
+
expect(output).toMatch(/App adapters[\s\S]*antigravity[\s\S]*history \[ui\] — Read Antigravity history/);
|
|
218
|
+
expect(output).toMatch(/App adapters[\s\S]*chatwise[\s\S]*ask \[ui\] — Ask Chatwise desktop app/);
|
|
219
|
+
expect(output).toMatch(/Site adapters[\s\S]*bilibili[\s\S]*hot \[public\] — Bilibili hot videos/);
|
|
220
|
+
expect(output).toContain('3 built-in commands across 2 apps + 1 sites,');
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
restoreStdoutSpy();
|
|
224
|
+
stdoutSpy.mockClear();
|
|
225
|
+
registry.clear();
|
|
226
|
+
for (const [key, value] of snapshot)
|
|
227
|
+
registry.set(key, value);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
it('omits empty list table sections and leaves structured list rows unchanged', async () => {
|
|
231
|
+
const registry = getRegistry();
|
|
232
|
+
const snapshot = new Map(registry);
|
|
233
|
+
const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
234
|
+
const restoreStdoutSpy = () => stdoutSpy.mockImplementation(() => { });
|
|
235
|
+
registry.clear();
|
|
236
|
+
try {
|
|
237
|
+
cli({
|
|
238
|
+
site: 'bilibili',
|
|
239
|
+
name: 'hot',
|
|
240
|
+
access: 'read',
|
|
241
|
+
description: 'Bilibili hot videos',
|
|
242
|
+
domain: 'www.bilibili.com',
|
|
243
|
+
strategy: Strategy.PUBLIC,
|
|
244
|
+
browser: false,
|
|
245
|
+
columns: ['title', 'url'],
|
|
246
|
+
});
|
|
247
|
+
const tableProgram = createProgram('', '');
|
|
248
|
+
await tableProgram.parseAsync(['node', 'webcmd', 'list']);
|
|
249
|
+
const tableOutput = stdoutSpy.mock.calls.flat().join('\n');
|
|
250
|
+
expect(tableOutput).not.toContain('App adapters');
|
|
251
|
+
expect(tableOutput).toContain('Site adapters');
|
|
252
|
+
expect(tableOutput).toContain('1 built-in commands across 0 apps + 1 sites,');
|
|
253
|
+
stdoutSpy.mockClear();
|
|
254
|
+
const jsonProgram = createProgram('', '');
|
|
255
|
+
await jsonProgram.parseAsync(['node', 'webcmd', 'list', '-f', 'json']);
|
|
256
|
+
const jsonOutput = stdoutSpy.mock.calls.flat().join('\n');
|
|
257
|
+
const rows = JSON.parse(jsonOutput);
|
|
258
|
+
expect(rows).toMatchObject([
|
|
259
|
+
{
|
|
260
|
+
site: 'bilibili',
|
|
261
|
+
name: 'hot',
|
|
262
|
+
domain: 'www.bilibili.com',
|
|
263
|
+
columns: ['title', 'url'],
|
|
264
|
+
},
|
|
265
|
+
]);
|
|
266
|
+
expect(rows[0]).not.toHaveProperty('adapterKind');
|
|
267
|
+
expect(rows[0]).not.toHaveProperty('section');
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
restoreStdoutSpy();
|
|
271
|
+
stdoutSpy.mockClear();
|
|
272
|
+
registry.clear();
|
|
273
|
+
for (const [key, value] of snapshot)
|
|
274
|
+
registry.set(key, value);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
it('exposes external_clis / app_adapters / site_adapters in structured help', () => {
|
|
278
|
+
const registry = getRegistry();
|
|
279
|
+
const snapshot = new Map(registry);
|
|
280
|
+
const argv = process.argv;
|
|
281
|
+
registry.clear();
|
|
282
|
+
try {
|
|
283
|
+
cli({
|
|
284
|
+
site: 'bilibili',
|
|
285
|
+
name: 'hot',
|
|
286
|
+
access: 'read',
|
|
287
|
+
description: 'Bilibili hot videos',
|
|
288
|
+
domain: 'www.bilibili.com',
|
|
289
|
+
strategy: Strategy.PUBLIC,
|
|
290
|
+
browser: false,
|
|
291
|
+
});
|
|
292
|
+
cli({
|
|
293
|
+
site: 'chatwise',
|
|
294
|
+
name: 'ask',
|
|
295
|
+
access: 'write',
|
|
296
|
+
description: 'Ask Chatwise desktop app',
|
|
297
|
+
domain: 'localhost',
|
|
298
|
+
strategy: Strategy.UI,
|
|
299
|
+
browser: true,
|
|
300
|
+
});
|
|
301
|
+
const program = createProgram('', '');
|
|
302
|
+
process.argv = ['node', 'webcmd', '--help', '-f', 'yaml'];
|
|
303
|
+
const data = yaml.load(program.helpInformation());
|
|
304
|
+
expect(data.app_adapters.count).toBe(1);
|
|
305
|
+
expect(data.app_adapters.apps).toEqual(['chatwise']);
|
|
306
|
+
expect(data.site_adapters.count).toBe(1);
|
|
307
|
+
expect(data.site_adapters.sites).toEqual(['bilibili']);
|
|
308
|
+
expect(data.external_clis.count).toBeGreaterThanOrEqual(0);
|
|
309
|
+
expect(Array.isArray(data.external_clis.clis)).toBe(true);
|
|
310
|
+
expect(Array.isArray(data.external_clis.display)).toBe(true);
|
|
311
|
+
// Adapters must NOT leak into the core commands list
|
|
312
|
+
const commandNames = data.commands.map((cmd) => cmd.name);
|
|
313
|
+
expect(commandNames).not.toContain('bilibili');
|
|
314
|
+
expect(commandNames).not.toContain('chatwise');
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
process.argv = argv;
|
|
318
|
+
registry.clear();
|
|
319
|
+
for (const [key, value] of snapshot)
|
|
320
|
+
registry.set(key, value);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
it('renders root structured help with built-ins and site adapter names', () => {
|
|
324
|
+
const registry = getRegistry();
|
|
325
|
+
const snapshot = new Map(registry);
|
|
326
|
+
const argv = process.argv;
|
|
327
|
+
registry.clear();
|
|
328
|
+
try {
|
|
329
|
+
cli({
|
|
330
|
+
site: 'bilibili',
|
|
331
|
+
name: 'hot',
|
|
332
|
+
access: 'read',
|
|
333
|
+
description: 'Bilibili hot videos',
|
|
334
|
+
strategy: Strategy.PUBLIC,
|
|
335
|
+
browser: false,
|
|
336
|
+
});
|
|
337
|
+
const program = createProgram('', '');
|
|
338
|
+
process.argv = ['node', 'webcmd', '--help', '-f', 'yaml'];
|
|
339
|
+
const data = yaml.load(program.helpInformation());
|
|
340
|
+
expect(data.site_adapters.count).toBe(1);
|
|
341
|
+
expect(data.site_adapters.sites).toEqual(['bilibili']);
|
|
342
|
+
expect(data.commands.map((cmd) => cmd.name)).toContain('list');
|
|
343
|
+
expect(data.commands.map((cmd) => cmd.name)).not.toContain('bilibili');
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
process.argv = argv;
|
|
347
|
+
registry.clear();
|
|
348
|
+
for (const [key, value] of snapshot)
|
|
349
|
+
registry.set(key, value);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
it('renders per-site structured help with all commands, access, args, and examples', () => {
|
|
353
|
+
const registry = getRegistry();
|
|
354
|
+
const snapshot = new Map(registry);
|
|
355
|
+
const argv = process.argv;
|
|
356
|
+
registry.clear();
|
|
357
|
+
try {
|
|
358
|
+
cli({
|
|
359
|
+
site: 'bilibili',
|
|
360
|
+
name: 'hot',
|
|
361
|
+
access: 'read',
|
|
362
|
+
description: 'Bilibili hot videos',
|
|
363
|
+
strategy: Strategy.PUBLIC,
|
|
364
|
+
browser: false,
|
|
365
|
+
args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
|
|
366
|
+
columns: ['title', 'url'],
|
|
367
|
+
});
|
|
368
|
+
const program = createProgram('', '');
|
|
369
|
+
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
|
|
370
|
+
expect(site).toBeTruthy();
|
|
371
|
+
process.argv = ['node', 'webcmd', 'bilibili', '--help', '-f', 'yaml'];
|
|
372
|
+
const data = yaml.load(site.helpInformation());
|
|
373
|
+
expect(data.site).toBe('bilibili');
|
|
374
|
+
expect(data.commands).toMatchObject([
|
|
375
|
+
{
|
|
376
|
+
name: 'hot',
|
|
377
|
+
access: 'read',
|
|
378
|
+
description: 'Bilibili hot videos',
|
|
379
|
+
browser: false,
|
|
380
|
+
example: 'webcmd bilibili hot -f yaml',
|
|
381
|
+
command_options: [{ name: 'limit', type: 'int', default: 20 }],
|
|
382
|
+
columns: ['title', 'url'],
|
|
383
|
+
},
|
|
384
|
+
]);
|
|
385
|
+
expect(data.commands[0]).not.toHaveProperty('args');
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
process.argv = argv;
|
|
389
|
+
registry.clear();
|
|
390
|
+
for (const [key, value] of snapshot)
|
|
391
|
+
registry.set(key, value);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
it('renders per-site text help without per-command common option noise', () => {
|
|
395
|
+
const registry = getRegistry();
|
|
396
|
+
const snapshot = new Map(registry);
|
|
397
|
+
registry.clear();
|
|
398
|
+
try {
|
|
399
|
+
cli({
|
|
400
|
+
site: 'bilibili',
|
|
401
|
+
name: 'hot',
|
|
402
|
+
access: 'read',
|
|
403
|
+
description: 'Bilibili hot videos',
|
|
404
|
+
strategy: Strategy.PUBLIC,
|
|
405
|
+
browser: false,
|
|
406
|
+
args: [{ name: 'limit', type: 'int', default: 20, help: 'Number of videos' }],
|
|
407
|
+
});
|
|
408
|
+
cli({
|
|
409
|
+
site: 'bilibili',
|
|
410
|
+
name: 'video',
|
|
411
|
+
access: 'read',
|
|
412
|
+
description: 'Read one video',
|
|
413
|
+
domain: 'www.bilibili.com',
|
|
414
|
+
strategy: Strategy.PUBLIC,
|
|
415
|
+
browser: true,
|
|
416
|
+
args: [{ name: 'bvid', positional: true, required: true, help: 'Video id' }],
|
|
417
|
+
});
|
|
418
|
+
const program = createProgram('', '');
|
|
419
|
+
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
|
|
420
|
+
expect(site).toBeTruthy();
|
|
421
|
+
const help = site.helpInformation();
|
|
422
|
+
expect(help).toContain('hot [options] [read] Bilibili hot videos');
|
|
423
|
+
expect(help).toContain('video <bvid> [read] Read one video');
|
|
424
|
+
expect(help).toContain('hot [options]');
|
|
425
|
+
expect(help).not.toContain('video <bvid> [options]');
|
|
426
|
+
expect(help).not.toContain('\nOptions:');
|
|
427
|
+
expect(help).toContain('Common options:');
|
|
428
|
+
expect(help).toContain('-f, --format <fmt>');
|
|
429
|
+
expect(help).toContain('--trace <mode>');
|
|
430
|
+
expect(help).toContain('get all command args/options in one structured response');
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
registry.clear();
|
|
434
|
+
for (const [key, value] of snapshot)
|
|
435
|
+
registry.set(key, value);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
it('separates command args from common options in structured help', () => {
|
|
439
|
+
const registry = getRegistry();
|
|
440
|
+
const snapshot = new Map(registry);
|
|
441
|
+
const argv = process.argv;
|
|
442
|
+
registry.clear();
|
|
443
|
+
try {
|
|
444
|
+
cli({
|
|
445
|
+
site: 'bilibili',
|
|
446
|
+
name: 'video',
|
|
447
|
+
access: 'read',
|
|
448
|
+
description: 'Read one video',
|
|
449
|
+
strategy: Strategy.PUBLIC,
|
|
450
|
+
domain: 'www.bilibili.com',
|
|
451
|
+
browser: true,
|
|
452
|
+
args: [
|
|
453
|
+
{ name: 'bvid', positional: true, required: true, help: 'Video id' },
|
|
454
|
+
{ name: 'with-comments', type: 'boolean', default: false, help: 'Include comments' },
|
|
455
|
+
],
|
|
456
|
+
columns: ['title', 'url'],
|
|
457
|
+
});
|
|
458
|
+
const program = createProgram('', '');
|
|
459
|
+
const site = program.commands.find(cmd => cmd.name() === 'bilibili');
|
|
460
|
+
const command = site.commands.find(cmd => cmd.name() === 'video');
|
|
461
|
+
expect(command).toBeTruthy();
|
|
462
|
+
process.argv = ['node', 'webcmd', 'bilibili', 'video', '--help', '-f', 'yaml'];
|
|
463
|
+
const data = yaml.load(command.helpInformation());
|
|
464
|
+
expect(data.usage).toBe('webcmd bilibili video <bvid> [options]');
|
|
465
|
+
expect(data.browser).toBe(true);
|
|
466
|
+
expect(data.domain).toBe('www.bilibili.com');
|
|
467
|
+
expect(data.positionals).toMatchObject([{ name: 'bvid', positional: true, required: true }]);
|
|
468
|
+
expect(data.command_options).toMatchObject([{ name: 'with-comments', default: false }]);
|
|
469
|
+
expect(data.common_options.map((option) => option.name)).toEqual(['format', 'trace', 'verbose', 'help']);
|
|
470
|
+
expect(data.columns).toEqual(['title', 'url']);
|
|
471
|
+
expect(data).not.toHaveProperty('args');
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
process.argv = argv;
|
|
475
|
+
registry.clear();
|
|
476
|
+
for (const [key, value] of snapshot)
|
|
477
|
+
registry.set(key, value);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
it('renders browser namespace structured help from Commander metadata', () => {
|
|
481
|
+
const argv = process.argv;
|
|
482
|
+
try {
|
|
483
|
+
const program = createProgram('', '');
|
|
484
|
+
const browser = program.commands.find(cmd => cmd.name() === 'browser');
|
|
485
|
+
expect(browser).toBeTruthy();
|
|
486
|
+
process.argv = ['node', 'webcmd', 'browser', '--session', 'test', '--help', '-f', 'yaml'];
|
|
487
|
+
const data = yaml.load(browser.helpInformation());
|
|
488
|
+
expect(data.namespace).toBe('browser');
|
|
489
|
+
expect(data.command).toBe('webcmd browser');
|
|
490
|
+
expect(data.description).toBe('Browser control — navigate, click, type, extract, wait (no LLM needed)');
|
|
491
|
+
expect(data.command_count).toBeGreaterThan(20);
|
|
492
|
+
// `--session` is now a hidden internal option; user-facing surface is the
|
|
493
|
+
// <session> positional declared via `.usage()`. Structured help drops
|
|
494
|
+
// hidden options, so namespace_options shouldn't expose it.
|
|
495
|
+
expect(data.namespace_options).not.toEqual(expect.arrayContaining([
|
|
496
|
+
expect.objectContaining({ name: 'session' }),
|
|
497
|
+
]));
|
|
498
|
+
expect(data.namespace_options).toEqual(expect.arrayContaining([
|
|
499
|
+
expect.objectContaining({
|
|
500
|
+
name: 'window',
|
|
501
|
+
flags: '--window <mode>',
|
|
502
|
+
takes_value: 'required',
|
|
503
|
+
}),
|
|
504
|
+
]));
|
|
505
|
+
expect(data.usage).toBe('webcmd browser <session> <command> [options]');
|
|
506
|
+
expect(data.global_options).toEqual(expect.arrayContaining([
|
|
507
|
+
expect.objectContaining({
|
|
508
|
+
name: 'version',
|
|
509
|
+
flags: '-V, --version',
|
|
510
|
+
}),
|
|
511
|
+
expect.objectContaining({
|
|
512
|
+
name: 'profile',
|
|
513
|
+
flags: '--profile <name>',
|
|
514
|
+
takes_value: 'required',
|
|
515
|
+
}),
|
|
516
|
+
]));
|
|
517
|
+
const click = data.commands.find((cmd) => cmd.name === 'click');
|
|
518
|
+
// Structured help command/usage paths include the <session> positional so
|
|
519
|
+
// agents construct the correct full invocation. `name` is the leaf
|
|
520
|
+
// identifier (placeholder positionals are stripped).
|
|
521
|
+
expect(click).toMatchObject({
|
|
522
|
+
command: 'webcmd browser <session> click',
|
|
523
|
+
usage: 'webcmd browser <session> click [target] [options]',
|
|
524
|
+
positionals: [{ name: 'target' }],
|
|
525
|
+
});
|
|
526
|
+
expect(click.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
|
|
527
|
+
const tabList = data.commands.find((cmd) => cmd.name === 'tab list');
|
|
528
|
+
expect(tabList).toMatchObject({
|
|
529
|
+
command: 'webcmd browser <session> tab list',
|
|
530
|
+
usage: 'webcmd browser <session> tab list [options]',
|
|
531
|
+
command_options: [],
|
|
532
|
+
});
|
|
533
|
+
const getText = data.commands.find((cmd) => cmd.name === 'get text');
|
|
534
|
+
expect(getText).toMatchObject({
|
|
535
|
+
command: 'webcmd browser <session> get text',
|
|
536
|
+
positionals: [{ name: 'target' }],
|
|
537
|
+
});
|
|
538
|
+
expect(data.structured_help).toMatchObject({
|
|
539
|
+
formats: ['yaml', 'json'],
|
|
540
|
+
usage: 'webcmd browser --help -f yaml',
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
finally {
|
|
544
|
+
process.argv = argv;
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
it('renders nested browser parent structured help for a subtree', () => {
|
|
548
|
+
const argv = process.argv;
|
|
549
|
+
try {
|
|
550
|
+
const program = createProgram('', '');
|
|
551
|
+
const browser = program.commands.find(cmd => cmd.name() === 'browser');
|
|
552
|
+
const tab = browser.commands.find(cmd => cmd.name() === 'tab');
|
|
553
|
+
expect(tab).toBeTruthy();
|
|
554
|
+
process.argv = ['node', 'webcmd', 'browser', '--session', 'test', 'tab', '--help', '-f', 'yaml'];
|
|
555
|
+
const data = yaml.load(tab.helpInformation());
|
|
556
|
+
expect(data).toMatchObject({
|
|
557
|
+
namespace: 'browser',
|
|
558
|
+
group: 'tab',
|
|
559
|
+
command: 'webcmd browser <session> tab',
|
|
560
|
+
usage: 'webcmd browser <session> tab <command> [args] [options]',
|
|
561
|
+
command_count: 4,
|
|
562
|
+
});
|
|
563
|
+
expect(data.commands.map((cmd) => cmd.name)).toEqual([
|
|
564
|
+
'tab close',
|
|
565
|
+
'tab list',
|
|
566
|
+
'tab new',
|
|
567
|
+
'tab select',
|
|
568
|
+
]);
|
|
569
|
+
expect(data.commands.find((cmd) => cmd.name === 'tab close')).toMatchObject({
|
|
570
|
+
command: 'webcmd browser <session> tab close',
|
|
571
|
+
usage: 'webcmd browser <session> tab close [targetId] [options]',
|
|
572
|
+
positionals: [{ name: 'targetId', help: 'Target tab/page identity returned by "browser open", "browser tab new", or "browser tab list"' }],
|
|
573
|
+
});
|
|
574
|
+
// session is now a hidden internal option (consumed from the <session> positional).
|
|
575
|
+
// namespace_options should only list user-facing options.
|
|
576
|
+
expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
|
|
577
|
+
expect(data.structured_help).toMatchObject({
|
|
578
|
+
usage: 'webcmd browser <session> tab --help -f yaml',
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
finally {
|
|
582
|
+
process.argv = argv;
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
it('renders browser command structured help without needing the full namespace dump', () => {
|
|
586
|
+
const argv = process.argv;
|
|
587
|
+
try {
|
|
588
|
+
const program = createProgram('', '');
|
|
589
|
+
const browser = program.commands.find(cmd => cmd.name() === 'browser');
|
|
590
|
+
const click = browser.commands.find(cmd => cmd.name() === 'click');
|
|
591
|
+
expect(click).toBeTruthy();
|
|
592
|
+
process.argv = ['node', 'webcmd', 'browser', '--session', 'test', 'click', '--help', '-f', 'yaml'];
|
|
593
|
+
const data = yaml.load(click.helpInformation());
|
|
594
|
+
expect(data).toMatchObject({
|
|
595
|
+
namespace: 'browser',
|
|
596
|
+
name: 'click',
|
|
597
|
+
command: 'webcmd browser <session> click',
|
|
598
|
+
usage: 'webcmd browser <session> click [target] [options]',
|
|
599
|
+
positionals: [{ name: 'target' }],
|
|
600
|
+
structured_help: {
|
|
601
|
+
usage: 'webcmd browser <session> click --help -f yaml',
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
expect(data.command_options.map((option) => option.name)).toEqual(['role', 'name', 'label', 'text', 'testid', 'nth', 'tab']);
|
|
605
|
+
// session is hidden; only `window` surfaces as a namespace option.
|
|
606
|
+
expect(data.namespace_options.map((option) => option.name)).toEqual(['window']);
|
|
607
|
+
expect(data.global_options.map((option) => option.name)).toContain('profile');
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
process.argv = argv;
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
it('renders daemon namespace structured help with leaves and global options', () => {
|
|
614
|
+
const argv = process.argv;
|
|
615
|
+
try {
|
|
616
|
+
const program = createProgram('', '');
|
|
617
|
+
const daemon = program.commands.find(cmd => cmd.name() === 'daemon');
|
|
618
|
+
expect(daemon).toBeTruthy();
|
|
619
|
+
process.argv = ['node', 'webcmd', 'daemon', '--help', '-f', 'yaml'];
|
|
620
|
+
const data = yaml.load(daemon.helpInformation());
|
|
621
|
+
expect(data).toMatchObject({
|
|
622
|
+
namespace: 'daemon',
|
|
623
|
+
command: 'webcmd daemon',
|
|
624
|
+
usage: 'webcmd daemon <command> [args] [options]',
|
|
625
|
+
description: 'Manage the webcmd daemon',
|
|
626
|
+
command_count: 3,
|
|
627
|
+
namespace_options: [],
|
|
628
|
+
structured_help: { usage: 'webcmd daemon --help -f yaml' },
|
|
629
|
+
});
|
|
630
|
+
expect(data.commands.map((cmd) => cmd.name)).toEqual(['restart', 'status', 'stop']);
|
|
631
|
+
expect(data.global_options.map((option) => option.name)).toEqual(expect.arrayContaining(['version', 'profile']));
|
|
632
|
+
}
|
|
633
|
+
finally {
|
|
634
|
+
process.argv = argv;
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
it('renders plugin namespace structured help with positional + option leaves', () => {
|
|
638
|
+
const argv = process.argv;
|
|
639
|
+
try {
|
|
640
|
+
const program = createProgram('', '');
|
|
641
|
+
const plugin = program.commands.find(cmd => cmd.name() === 'plugin');
|
|
642
|
+
expect(plugin).toBeTruthy();
|
|
643
|
+
process.argv = ['node', 'webcmd', 'plugin', '--help', '-f', 'yaml'];
|
|
644
|
+
const data = yaml.load(plugin.helpInformation());
|
|
645
|
+
expect(data).toMatchObject({
|
|
646
|
+
namespace: 'plugin',
|
|
647
|
+
command: 'webcmd plugin',
|
|
648
|
+
description: 'Manage webcmd plugins',
|
|
649
|
+
namespace_options: [],
|
|
650
|
+
});
|
|
651
|
+
expect(data.commands.map((cmd) => cmd.name)).toEqual(['create', 'install', 'list', 'uninstall', 'update']);
|
|
652
|
+
const update = data.commands.find((cmd) => cmd.name === 'update');
|
|
653
|
+
expect(update).toMatchObject({
|
|
654
|
+
usage: 'webcmd plugin update [name] [options]',
|
|
655
|
+
positionals: [{ name: 'name' }],
|
|
656
|
+
});
|
|
657
|
+
expect(update.command_options.map((option) => option.name)).toEqual(['all']);
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
process.argv = argv;
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
it('renders adapter namespace structured help preserving original description after applyRootSubcommandSummaries', () => {
|
|
664
|
+
const argv = process.argv;
|
|
665
|
+
try {
|
|
666
|
+
const program = createProgram('', '');
|
|
667
|
+
const adapter = program.commands.find(cmd => cmd.name() === 'adapter');
|
|
668
|
+
expect(adapter).toBeTruthy();
|
|
669
|
+
process.argv = ['node', 'webcmd', 'adapter', '--help', '-f', 'yaml'];
|
|
670
|
+
const data = yaml.load(adapter.helpInformation());
|
|
671
|
+
// applyRootSubcommandSummaries() rewrites .description() to a child-name listing;
|
|
672
|
+
// structured help must surface the original product description via the snapshot.
|
|
673
|
+
expect(data.description).toBe('Manage CLI adapters');
|
|
674
|
+
expect(data.commands.map((cmd) => cmd.name)).toEqual(['eject', 'reset', 'status']);
|
|
675
|
+
const reset = data.commands.find((cmd) => cmd.name === 'reset');
|
|
676
|
+
expect(reset).toMatchObject({
|
|
677
|
+
usage: 'webcmd adapter reset [site] [options]',
|
|
678
|
+
positionals: [{ name: 'site' }],
|
|
679
|
+
});
|
|
680
|
+
expect(reset.command_options.map((option) => option.name)).toEqual(['all']);
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
process.argv = argv;
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
it('renders profile namespace structured help including required positionals', () => {
|
|
687
|
+
const argv = process.argv;
|
|
688
|
+
try {
|
|
689
|
+
const program = createProgram('', '');
|
|
690
|
+
const profile = program.commands.find(cmd => cmd.name() === 'profile');
|
|
691
|
+
expect(profile).toBeTruthy();
|
|
692
|
+
process.argv = ['node', 'webcmd', 'profile', '--help', '-f', 'yaml'];
|
|
693
|
+
const data = yaml.load(profile.helpInformation());
|
|
694
|
+
expect(data).toMatchObject({
|
|
695
|
+
namespace: 'profile',
|
|
696
|
+
description: 'Manage webcmd browser runtime profiles',
|
|
697
|
+
command_count: 3,
|
|
698
|
+
});
|
|
699
|
+
expect(data.commands.map((cmd) => cmd.name)).toEqual(['list', 'rename', 'use']);
|
|
700
|
+
const list = data.commands.find((cmd) => cmd.name === 'list');
|
|
701
|
+
expect(list).toMatchObject({
|
|
702
|
+
description: 'List Chrome and Chromium profiles available through the Cloak runtime',
|
|
703
|
+
});
|
|
704
|
+
const rename = data.commands.find((cmd) => cmd.name === 'rename');
|
|
705
|
+
expect(rename).toMatchObject({
|
|
706
|
+
usage: 'webcmd profile rename <contextId> <alias> [options]',
|
|
707
|
+
description: 'Assign a local alias to an available Cloak profile',
|
|
708
|
+
positionals: [
|
|
709
|
+
{ name: 'contextId', required: true },
|
|
710
|
+
{ name: 'alias', required: true },
|
|
711
|
+
],
|
|
712
|
+
});
|
|
713
|
+
const use = data.commands.find((cmd) => cmd.name === 'use');
|
|
714
|
+
expect(use).toMatchObject({
|
|
715
|
+
description: 'Set the default Cloak profile for future commands',
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
process.argv = argv;
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
describe('resolveBrowserVerifyInvocation', () => {
|
|
724
|
+
it('prefers the built entry declared in package metadata', () => {
|
|
725
|
+
const projectRoot = path.join('repo-root');
|
|
726
|
+
const exists = new Set([
|
|
727
|
+
path.join(projectRoot, 'dist', 'src', 'main.js'),
|
|
728
|
+
]);
|
|
729
|
+
expect(resolveBrowserVerifyInvocation({
|
|
730
|
+
projectRoot,
|
|
731
|
+
readFile: () => JSON.stringify({ bin: { webcmd: 'dist/src/main.js' } }),
|
|
732
|
+
fileExists: (candidate) => exists.has(candidate),
|
|
733
|
+
})).toEqual({
|
|
734
|
+
binary: process.execPath,
|
|
735
|
+
args: [path.join(projectRoot, 'dist', 'src', 'main.js')],
|
|
736
|
+
cwd: projectRoot,
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
it('falls back to compatibility built-entry candidates when package metadata is unavailable', () => {
|
|
740
|
+
const projectRoot = path.join('repo-root');
|
|
741
|
+
const exists = new Set([
|
|
742
|
+
path.join(projectRoot, 'dist', 'src', 'main.js'),
|
|
743
|
+
]);
|
|
744
|
+
expect(resolveBrowserVerifyInvocation({
|
|
745
|
+
projectRoot,
|
|
746
|
+
readFile: () => { throw new Error('no package json'); },
|
|
747
|
+
fileExists: (candidate) => exists.has(candidate),
|
|
748
|
+
})).toEqual({
|
|
749
|
+
binary: process.execPath,
|
|
750
|
+
args: [path.join(projectRoot, 'dist', 'src', 'main.js')],
|
|
751
|
+
cwd: projectRoot,
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
it('falls back to the local tsx binary in source checkouts on Windows', () => {
|
|
755
|
+
const projectRoot = path.join('repo-root');
|
|
756
|
+
const exists = new Set([
|
|
757
|
+
path.join(projectRoot, 'src', 'main.ts'),
|
|
758
|
+
path.join(projectRoot, 'node_modules', '.bin', 'tsx.cmd'),
|
|
759
|
+
]);
|
|
760
|
+
expect(resolveBrowserVerifyInvocation({
|
|
761
|
+
projectRoot,
|
|
762
|
+
platform: 'win32',
|
|
763
|
+
fileExists: (candidate) => exists.has(candidate),
|
|
764
|
+
})).toEqual({
|
|
765
|
+
binary: path.join(projectRoot, 'node_modules', '.bin', 'tsx.cmd'),
|
|
766
|
+
args: [path.join(projectRoot, 'src', 'main.ts')],
|
|
767
|
+
cwd: projectRoot,
|
|
768
|
+
shell: true,
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
it('falls back to npx tsx when local tsx is unavailable', () => {
|
|
772
|
+
const projectRoot = path.join('repo-root');
|
|
773
|
+
const exists = new Set([
|
|
774
|
+
path.join(projectRoot, 'src', 'main.ts'),
|
|
775
|
+
]);
|
|
776
|
+
expect(resolveBrowserVerifyInvocation({
|
|
777
|
+
projectRoot,
|
|
778
|
+
platform: 'linux',
|
|
779
|
+
fileExists: (candidate) => exists.has(candidate),
|
|
780
|
+
})).toEqual({
|
|
781
|
+
binary: 'npx',
|
|
782
|
+
args: ['tsx', path.join(projectRoot, 'src', 'main.ts')],
|
|
783
|
+
cwd: projectRoot,
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
describe('selectFreshByTimestamp', () => {
|
|
788
|
+
it('uses timestamp watermarks so rolled buffers still emit new messages', () => {
|
|
789
|
+
const first = selectFreshByTimestamp([
|
|
790
|
+
{ timestamp: 1, text: 'a' },
|
|
791
|
+
{ timestamp: 2, text: 'b' },
|
|
792
|
+
], 0);
|
|
793
|
+
expect(first.fresh.map((item) => item.text)).toEqual(['a', 'b']);
|
|
794
|
+
expect(first.lastSeenTs).toBe(2);
|
|
795
|
+
const rolled = selectFreshByTimestamp([
|
|
796
|
+
{ timestamp: 2, text: 'b' },
|
|
797
|
+
{ timestamp: 3, text: 'c' },
|
|
798
|
+
], first.lastSeenTs);
|
|
799
|
+
expect(rolled.fresh.map((item) => item.text)).toEqual(['c']);
|
|
800
|
+
expect(rolled.lastSeenTs).toBe(3);
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
describe('resolveSitemapAvailabilityForUrl', () => {
|
|
804
|
+
function registryFor(site, domain) {
|
|
805
|
+
return new Map([[`${site}:read`, {
|
|
806
|
+
site,
|
|
807
|
+
name: 'read',
|
|
808
|
+
access: 'read',
|
|
809
|
+
description: 'read',
|
|
810
|
+
domain,
|
|
811
|
+
browser: false,
|
|
812
|
+
args: [],
|
|
813
|
+
}]]);
|
|
814
|
+
}
|
|
815
|
+
it('detects local sitemap overlays using adapter registry domain matches', () => {
|
|
816
|
+
const homeDir = path.join(os.tmpdir(), 'webcmd-sitemap-home');
|
|
817
|
+
const packageRoot = path.join(os.tmpdir(), 'webcmd-sitemap-package');
|
|
818
|
+
const localSitemap = path.join(homeDir, '.webcmd', 'sites', 'hackernews', 'sitemap');
|
|
819
|
+
const exists = new Set([localSitemap]);
|
|
820
|
+
const report = resolveSitemapAvailabilityForUrl('https://news.ycombinator.com/item?id=1', {
|
|
821
|
+
homeDir,
|
|
822
|
+
packageRoot,
|
|
823
|
+
registry: registryFor('hackernews', 'news.ycombinator.com'),
|
|
824
|
+
fileExists: (candidate) => exists.has(candidate),
|
|
825
|
+
});
|
|
826
|
+
expect(report).toMatchObject({
|
|
827
|
+
site: 'hackernews',
|
|
828
|
+
available: true,
|
|
829
|
+
source: 'local',
|
|
830
|
+
paths: { local: localSitemap },
|
|
831
|
+
});
|
|
832
|
+
expect(report?.hint).toContain('webcmd-browser-sitemap');
|
|
833
|
+
});
|
|
834
|
+
it('reports global+local when both sitemap layers exist', () => {
|
|
835
|
+
const homeDir = path.join(os.tmpdir(), 'webcmd-sitemap-home');
|
|
836
|
+
const packageRoot = path.join(os.tmpdir(), 'webcmd-sitemap-package');
|
|
837
|
+
const localSitemap = path.join(homeDir, '.webcmd', 'sites', 'twitter', 'sitemap.md');
|
|
838
|
+
const globalSitemap = path.join(packageRoot, 'sitemaps', 'twitter');
|
|
839
|
+
const exists = new Set([localSitemap, globalSitemap]);
|
|
840
|
+
const report = resolveSitemapAvailabilityForUrl('https://x.com/webcmd', {
|
|
841
|
+
homeDir,
|
|
842
|
+
packageRoot,
|
|
843
|
+
registry: registryFor('twitter', 'x.com'),
|
|
844
|
+
fileExists: (candidate) => exists.has(candidate),
|
|
845
|
+
});
|
|
846
|
+
expect(report).toMatchObject({
|
|
847
|
+
site: 'twitter',
|
|
848
|
+
source: 'local+global',
|
|
849
|
+
paths: { local: localSitemap, global: globalSitemap },
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
it('returns null when no sitemap layer exists', () => {
|
|
853
|
+
const report = resolveSitemapAvailabilityForUrl('https://example.com/', {
|
|
854
|
+
homeDir: path.join(os.tmpdir(), 'webcmd-sitemap-home'),
|
|
855
|
+
packageRoot: path.join(os.tmpdir(), 'webcmd-sitemap-package'),
|
|
856
|
+
registry: new Map(),
|
|
857
|
+
fileExists: () => false,
|
|
858
|
+
});
|
|
859
|
+
expect(report).toBeNull();
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
describe('browser verify', () => {
|
|
863
|
+
beforeEach(() => {
|
|
864
|
+
process.exitCode = undefined;
|
|
865
|
+
mockExecFileSync.mockReset().mockReturnValue('[]');
|
|
866
|
+
});
|
|
867
|
+
it('passes --trace through to the adapter subprocess', async () => {
|
|
868
|
+
const originalHome = process.env.HOME;
|
|
869
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
870
|
+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-verify-trace-'));
|
|
871
|
+
process.env.HOME = fakeHome;
|
|
872
|
+
process.env.USERPROFILE = fakeHome;
|
|
873
|
+
try {
|
|
874
|
+
const adapterDir = path.join(fakeHome, '.webcmd', 'clis', 'hn');
|
|
875
|
+
fs.mkdirSync(adapterDir, { recursive: true });
|
|
876
|
+
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
|
877
|
+
const program = createProgram('', '');
|
|
878
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']);
|
|
879
|
+
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
|
880
|
+
const [, execArgs] = mockExecFileSync.mock.calls[0];
|
|
881
|
+
expect(execArgs.slice(-6)).toEqual(['hn', 'top', '--trace', 'retain-on-failure', '--format', 'json']);
|
|
882
|
+
}
|
|
883
|
+
finally {
|
|
884
|
+
if (originalHome === undefined)
|
|
885
|
+
delete process.env.HOME;
|
|
886
|
+
else
|
|
887
|
+
process.env.HOME = originalHome;
|
|
888
|
+
if (originalUserProfile === undefined)
|
|
889
|
+
delete process.env.USERPROFILE;
|
|
890
|
+
else
|
|
891
|
+
process.env.USERPROFILE = originalUserProfile;
|
|
892
|
+
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
it('uses --seed-args when no fixture args exist', async () => {
|
|
896
|
+
const originalHome = process.env.HOME;
|
|
897
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
898
|
+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-verify-seed-'));
|
|
899
|
+
process.env.HOME = fakeHome;
|
|
900
|
+
process.env.USERPROFILE = fakeHome;
|
|
901
|
+
try {
|
|
902
|
+
const adapterDir = path.join(fakeHome, '.webcmd', 'clis', 'hn');
|
|
903
|
+
fs.mkdirSync(adapterDir, { recursive: true });
|
|
904
|
+
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
|
905
|
+
const program = createProgram('', '');
|
|
906
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'webcmd-verify']);
|
|
907
|
+
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
|
|
908
|
+
const [, execArgs] = mockExecFileSync.mock.calls[0];
|
|
909
|
+
expect(execArgs.slice(-5)).toEqual(['hn', 'top', 'webcmd-verify', '--format', 'json']);
|
|
910
|
+
}
|
|
911
|
+
finally {
|
|
912
|
+
if (originalHome === undefined)
|
|
913
|
+
delete process.env.HOME;
|
|
914
|
+
else
|
|
915
|
+
process.env.HOME = originalHome;
|
|
916
|
+
if (originalUserProfile === undefined)
|
|
917
|
+
delete process.env.USERPROFILE;
|
|
918
|
+
else
|
|
919
|
+
process.env.USERPROFILE = originalUserProfile;
|
|
920
|
+
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
it('writes --seed-args into a starter fixture', async () => {
|
|
924
|
+
const originalHome = process.env.HOME;
|
|
925
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
926
|
+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-verify-write-seed-'));
|
|
927
|
+
process.env.HOME = fakeHome;
|
|
928
|
+
process.env.USERPROFILE = fakeHome;
|
|
929
|
+
mockExecFileSync.mockReturnValue(JSON.stringify([{ title: 'ok' }]));
|
|
930
|
+
try {
|
|
931
|
+
const adapterDir = path.join(fakeHome, '.webcmd', 'clis', 'hn');
|
|
932
|
+
fs.mkdirSync(adapterDir, { recursive: true });
|
|
933
|
+
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
|
934
|
+
const program = createProgram('', '');
|
|
935
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'webcmd-verify']);
|
|
936
|
+
const fixtureFile = path.join(fakeHome, '.webcmd', 'sites', 'hn', 'verify', 'top.json');
|
|
937
|
+
const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf-8'));
|
|
938
|
+
expect(fixture.args).toEqual(['webcmd-verify']);
|
|
939
|
+
expect(fixture.expect.columns).toEqual(['title']);
|
|
940
|
+
}
|
|
941
|
+
finally {
|
|
942
|
+
if (originalHome === undefined)
|
|
943
|
+
delete process.env.HOME;
|
|
944
|
+
else
|
|
945
|
+
process.env.HOME = originalHome;
|
|
946
|
+
if (originalUserProfile === undefined)
|
|
947
|
+
delete process.env.USERPROFILE;
|
|
948
|
+
else
|
|
949
|
+
process.env.USERPROFILE = originalUserProfile;
|
|
950
|
+
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
it('fails before fixture handling when output row shape is not agent-native', async () => {
|
|
954
|
+
const originalHome = process.env.HOME;
|
|
955
|
+
const originalUserProfile = process.env.USERPROFILE;
|
|
956
|
+
const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-verify-shape-'));
|
|
957
|
+
process.env.HOME = fakeHome;
|
|
958
|
+
process.env.USERPROFILE = fakeHome;
|
|
959
|
+
mockExecFileSync.mockReturnValue(JSON.stringify([{ title: 'ok', author: { user_id: 'u1' } }]));
|
|
960
|
+
const consoleLogSpy = vi.mocked(console.log);
|
|
961
|
+
consoleLogSpy.mockClear();
|
|
962
|
+
try {
|
|
963
|
+
const adapterDir = path.join(fakeHome, '.webcmd', 'clis', 'hn');
|
|
964
|
+
fs.mkdirSync(adapterDir, { recursive: true });
|
|
965
|
+
fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8');
|
|
966
|
+
const program = createProgram('', '');
|
|
967
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'verify', 'hn/top', '--no-fixture']);
|
|
968
|
+
expect(process.exitCode).toBe(1);
|
|
969
|
+
const output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n');
|
|
970
|
+
expect(output).toContain('Adapter output violates row shape conventions');
|
|
971
|
+
expect(output).toContain('author.user_id');
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
consoleLogSpy.mockClear();
|
|
975
|
+
if (originalHome === undefined)
|
|
976
|
+
delete process.env.HOME;
|
|
977
|
+
else
|
|
978
|
+
process.env.HOME = originalHome;
|
|
979
|
+
if (originalUserProfile === undefined)
|
|
980
|
+
delete process.env.USERPROFILE;
|
|
981
|
+
else
|
|
982
|
+
process.env.USERPROFILE = originalUserProfile;
|
|
983
|
+
fs.rmSync(fakeHome, { recursive: true, force: true });
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
describe('profile list', () => {
|
|
988
|
+
const stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
989
|
+
beforeEach(() => {
|
|
990
|
+
process.exitCode = undefined;
|
|
991
|
+
stdoutSpy.mockClear();
|
|
992
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
993
|
+
});
|
|
994
|
+
it('reports stale daemon instead of no profiles when status lacks profile support', async () => {
|
|
995
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
996
|
+
ok: true,
|
|
997
|
+
json: async () => ({
|
|
998
|
+
ok: true,
|
|
999
|
+
pid: 123,
|
|
1000
|
+
uptime: 1,
|
|
1001
|
+
daemonVersion: '1.7.6',
|
|
1002
|
+
runtimeConnected: true,
|
|
1003
|
+
runtimeName: 'Cloak',
|
|
1004
|
+
runtimeVersion: '1.0.3',
|
|
1005
|
+
pending: 0,
|
|
1006
|
+
memoryMB: 20,
|
|
1007
|
+
port: 19825,
|
|
1008
|
+
}),
|
|
1009
|
+
});
|
|
1010
|
+
const program = createProgram('', '');
|
|
1011
|
+
await program.parseAsync(['node', 'webcmd', 'profile', 'list']);
|
|
1012
|
+
const output = stdoutSpy.mock.calls.flat().join('\n');
|
|
1013
|
+
expect(output).toContain('stale');
|
|
1014
|
+
expect(output).toContain('webcmd daemon restart');
|
|
1015
|
+
expect(output).not.toContain('No Cloak profiles available');
|
|
1016
|
+
});
|
|
1017
|
+
it('uses runtime profile wording when current daemon status has no profiles', async () => {
|
|
1018
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
1019
|
+
ok: true,
|
|
1020
|
+
json: async () => ({
|
|
1021
|
+
ok: true,
|
|
1022
|
+
pid: 123,
|
|
1023
|
+
uptime: 1,
|
|
1024
|
+
daemonVersion: PKG_VERSION,
|
|
1025
|
+
runtimeConnected: false,
|
|
1026
|
+
runtimeName: 'Cloak',
|
|
1027
|
+
profiles: [],
|
|
1028
|
+
pending: 0,
|
|
1029
|
+
memoryMB: 20,
|
|
1030
|
+
port: 19825,
|
|
1031
|
+
}),
|
|
1032
|
+
});
|
|
1033
|
+
const program = createProgram('', '');
|
|
1034
|
+
await program.parseAsync(['node', 'webcmd', 'profile', 'list']);
|
|
1035
|
+
const output = stdoutSpy.mock.calls.flat().join('\n');
|
|
1036
|
+
expect(output).toContain('No Cloak runtime profiles are active');
|
|
1037
|
+
expect(output).toContain('Run a browser-backed command or webcmd <site> login to create one');
|
|
1038
|
+
expect(output).not.toContain(`Browser ${'Bridge'}`);
|
|
1039
|
+
expect(output).not.toContain(`Webcmd ${'extension'}`);
|
|
1040
|
+
expect(output).not.toContain('webcmd daemon restart');
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
describe('browser tab targeting commands', () => {
|
|
1044
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
1045
|
+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
1046
|
+
function getBrowserStateFile(cacheDir, session = 'test') {
|
|
1047
|
+
return path.join(cacheDir, 'browser-state', `${session}.json`);
|
|
1048
|
+
}
|
|
1049
|
+
beforeEach(() => {
|
|
1050
|
+
process.exitCode = undefined;
|
|
1051
|
+
process.env.WEBCMD_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-tab-state-'));
|
|
1052
|
+
consoleLogSpy.mockClear();
|
|
1053
|
+
stderrSpy.mockClear();
|
|
1054
|
+
mockBrowserConnect.mockClear();
|
|
1055
|
+
mockBrowserClose.mockReset().mockResolvedValue(undefined);
|
|
1056
|
+
delete process.env.WEBCMD_WINDOW;
|
|
1057
|
+
mockBindTab.mockReset().mockResolvedValue({
|
|
1058
|
+
session: 'test',
|
|
1059
|
+
page: 'tab-2',
|
|
1060
|
+
url: 'https://user.example/inbox',
|
|
1061
|
+
title: 'Inbox',
|
|
1062
|
+
});
|
|
1063
|
+
mockSendCommand.mockReset().mockResolvedValue({ closed: true });
|
|
1064
|
+
browserState.page = {
|
|
1065
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
1066
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
1067
|
+
setActivePage: vi.fn(),
|
|
1068
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
1069
|
+
getCurrentUrl: vi.fn().mockResolvedValue('https://one.example'),
|
|
1070
|
+
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
|
1071
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
1072
|
+
evaluate: vi.fn().mockResolvedValue({ ok: true }),
|
|
1073
|
+
snapshot: vi.fn().mockResolvedValue('snapshot'),
|
|
1074
|
+
tabs: vi.fn().mockResolvedValue([
|
|
1075
|
+
{ index: 0, page: 'tab-1', url: 'https://one.example', title: 'one', active: true },
|
|
1076
|
+
{ index: 1, page: 'tab-2', url: 'https://two.example', title: 'two', active: false },
|
|
1077
|
+
]),
|
|
1078
|
+
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
1079
|
+
newTab: vi.fn().mockResolvedValue('tab-3'),
|
|
1080
|
+
closeTab: vi.fn().mockResolvedValue(undefined),
|
|
1081
|
+
handleJavaScriptDialog: vi.fn().mockResolvedValue(undefined),
|
|
1082
|
+
frames: vi.fn().mockResolvedValue([
|
|
1083
|
+
{ index: 0, frameId: 'frame-1', url: 'https://x.example/embed', name: 'x-embed' },
|
|
1084
|
+
]),
|
|
1085
|
+
evaluateInFrame: vi.fn().mockResolvedValue('inside frame'),
|
|
1086
|
+
screenshot: vi.fn().mockResolvedValue('base64-shot'),
|
|
1087
|
+
annotatedScreenshot: vi.fn().mockResolvedValue('annotated-base64-shot'),
|
|
1088
|
+
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
|
1089
|
+
closeWindow: vi.fn().mockResolvedValue(undefined),
|
|
1090
|
+
waitForDownload: vi.fn().mockResolvedValue({
|
|
1091
|
+
downloaded: true,
|
|
1092
|
+
filename: '/tmp/receipt.pdf',
|
|
1093
|
+
url: 'https://app.example/receipt.pdf',
|
|
1094
|
+
state: 'complete',
|
|
1095
|
+
elapsedMs: 10,
|
|
1096
|
+
}),
|
|
1097
|
+
session: 'test',
|
|
1098
|
+
};
|
|
1099
|
+
});
|
|
1100
|
+
function lastJsonLog() {
|
|
1101
|
+
const calls = consoleLogSpy.mock.calls;
|
|
1102
|
+
if (calls.length === 0)
|
|
1103
|
+
throw new Error('Expected at least one console.log call');
|
|
1104
|
+
const last = calls[calls.length - 1][0];
|
|
1105
|
+
if (typeof last !== 'string')
|
|
1106
|
+
throw new Error(`Expected string arg to console.log, got ${typeof last}`);
|
|
1107
|
+
return JSON.parse(last);
|
|
1108
|
+
}
|
|
1109
|
+
it('binds an existing Cloak tab by page id into a browser session', async () => {
|
|
1110
|
+
const program = createProgram('', '');
|
|
1111
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--page', 'tab-2']);
|
|
1112
|
+
expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser' });
|
|
1113
|
+
expect(mockBindTab).toHaveBeenCalledWith('test', { page: 'tab-2' });
|
|
1114
|
+
const out = lastJsonLog();
|
|
1115
|
+
expect(out.session).toBe('test');
|
|
1116
|
+
expect(out.url).toBe('https://user.example/inbox');
|
|
1117
|
+
});
|
|
1118
|
+
it('binds an existing Cloak tab by index into a browser session', async () => {
|
|
1119
|
+
const program = createProgram('', '');
|
|
1120
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--index', '1']);
|
|
1121
|
+
expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser' });
|
|
1122
|
+
expect(mockBindTab).toHaveBeenCalledWith('test', { index: 1 });
|
|
1123
|
+
const out = lastJsonLog();
|
|
1124
|
+
expect(out.session).toBe('test');
|
|
1125
|
+
});
|
|
1126
|
+
it('requires an explicit Cloak tab target for bind', async () => {
|
|
1127
|
+
const program = createProgram('', '');
|
|
1128
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind']);
|
|
1129
|
+
expect(mockBindTab).not.toHaveBeenCalled();
|
|
1130
|
+
const out = lastJsonLog();
|
|
1131
|
+
expect(out.error.code).toBe('invalid_request');
|
|
1132
|
+
expect(out.error.message).toContain('exactly one Cloak tab target');
|
|
1133
|
+
expect(process.exitCode).toBeDefined();
|
|
1134
|
+
});
|
|
1135
|
+
it('rejects bind with both page id and index', async () => {
|
|
1136
|
+
const program = createProgram('', '');
|
|
1137
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--page', 'tab-2', '--index', '1']);
|
|
1138
|
+
expect(mockBindTab).not.toHaveBeenCalled();
|
|
1139
|
+
const out = lastJsonLog();
|
|
1140
|
+
expect(out.error.code).toBe('invalid_request');
|
|
1141
|
+
});
|
|
1142
|
+
it('requires an explicit session for browser commands', async () => {
|
|
1143
|
+
const program = createProgram('', '');
|
|
1144
|
+
// --session is now a hidden internal flag; commander no longer guards it.
|
|
1145
|
+
// The action body throws via getBrowserSession(), surfacing the
|
|
1146
|
+
// <session> positional in the error message.
|
|
1147
|
+
await program.parseAsync(['node', 'webcmd', 'browser', 'state']);
|
|
1148
|
+
expect(mockBrowserConnect).not.toHaveBeenCalled();
|
|
1149
|
+
expect(stderrSpy.mock.calls.flat().join('')).toContain('<session> is a required positional argument');
|
|
1150
|
+
});
|
|
1151
|
+
it('runs browser commands against an explicit session', async () => {
|
|
1152
|
+
const program = createProgram('', '');
|
|
1153
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'state']);
|
|
1154
|
+
expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser', windowMode: 'foreground' });
|
|
1155
|
+
expect(browserState.page?.snapshot).toHaveBeenCalled();
|
|
1156
|
+
});
|
|
1157
|
+
it('passes browser --window through Commander options without relying on env pre-processing', async () => {
|
|
1158
|
+
const program = createProgram('', '');
|
|
1159
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', '--window', 'background', 'state']);
|
|
1160
|
+
expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser', windowMode: 'background' });
|
|
1161
|
+
expect(browserState.page?.snapshot).toHaveBeenCalled();
|
|
1162
|
+
});
|
|
1163
|
+
it('passes the opt-in AX source to browser state', async () => {
|
|
1164
|
+
const program = createProgram('', '');
|
|
1165
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'state', '--source', 'ax']);
|
|
1166
|
+
expect(browserState.page?.snapshot).toHaveBeenCalledWith({ viewportExpand: 2000, source: 'ax' });
|
|
1167
|
+
});
|
|
1168
|
+
it('prints DOM vs AX snapshot metrics without changing default state output', async () => {
|
|
1169
|
+
browserState.page = {
|
|
1170
|
+
...browserState.page,
|
|
1171
|
+
snapshot: vi.fn(async (opts) => {
|
|
1172
|
+
if (opts?.source === 'ax') {
|
|
1173
|
+
return 'source: ax\n---\n[1]button "Save"\nframe "https://app.example/embed":\n [2]button "Frame Save"\n---\ninteractive: 2';
|
|
1174
|
+
}
|
|
1175
|
+
return 'URL: https://app.example\n[1] button "Save"';
|
|
1176
|
+
}),
|
|
1177
|
+
};
|
|
1178
|
+
const program = createProgram('', '');
|
|
1179
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'state', '--compare-sources']);
|
|
1180
|
+
expect(browserState.page?.snapshot).toHaveBeenCalledWith({ viewportExpand: 2000, source: 'dom' });
|
|
1181
|
+
expect(browserState.page?.snapshot).toHaveBeenCalledWith({ viewportExpand: 2000, source: 'ax' });
|
|
1182
|
+
const out = lastJsonLog();
|
|
1183
|
+
expect(out.url).toBe('https://one.example');
|
|
1184
|
+
expect(out.sources.dom).toMatchObject({ ok: true, refs: 1, frame_sections: 0 });
|
|
1185
|
+
expect(out.sources.ax).toMatchObject({ ok: true, refs: 2, frame_sections: 1, interactive: 2 });
|
|
1186
|
+
});
|
|
1187
|
+
it('keeps compare-sources usable when one observation backend fails', async () => {
|
|
1188
|
+
browserState.page = {
|
|
1189
|
+
...browserState.page,
|
|
1190
|
+
snapshot: vi.fn(async (opts) => {
|
|
1191
|
+
if (opts?.source === 'ax')
|
|
1192
|
+
throw new Error('AX unavailable');
|
|
1193
|
+
return '[1] button "Save"';
|
|
1194
|
+
}),
|
|
1195
|
+
};
|
|
1196
|
+
const program = createProgram('', '');
|
|
1197
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'state', '--compare-sources']);
|
|
1198
|
+
const out = lastJsonLog();
|
|
1199
|
+
expect(out.sources.dom).toMatchObject({ ok: true, refs: 1 });
|
|
1200
|
+
expect(out.sources.ax).toMatchObject({
|
|
1201
|
+
ok: false,
|
|
1202
|
+
error: { message: 'AX unavailable' },
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
it('rejects unknown browser state sources before touching the page', async () => {
|
|
1206
|
+
const program = createProgram('', '');
|
|
1207
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'state', '--source', 'magic']);
|
|
1208
|
+
expect(browserState.page?.snapshot).not.toHaveBeenCalled();
|
|
1209
|
+
const out = lastJsonLog();
|
|
1210
|
+
expect(out.error.code).toBe('invalid_source');
|
|
1211
|
+
expect(process.exitCode).toBeDefined();
|
|
1212
|
+
});
|
|
1213
|
+
it('captures annotated screenshots through the visual ref overlay path', async () => {
|
|
1214
|
+
const program = createProgram('', '');
|
|
1215
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'screenshot', '--annotate']);
|
|
1216
|
+
expect(browserState.page?.annotatedScreenshot).toHaveBeenCalledWith({
|
|
1217
|
+
fullPage: false,
|
|
1218
|
+
annotate: true,
|
|
1219
|
+
width: undefined,
|
|
1220
|
+
height: undefined,
|
|
1221
|
+
format: 'png',
|
|
1222
|
+
});
|
|
1223
|
+
expect(browserState.page?.screenshot).not.toHaveBeenCalled();
|
|
1224
|
+
expect(consoleLogSpy).toHaveBeenLastCalledWith('annotated-base64-shot');
|
|
1225
|
+
});
|
|
1226
|
+
it('allows history navigation in a bound session', async () => {
|
|
1227
|
+
browserState.page = {
|
|
1228
|
+
...browserState.page,
|
|
1229
|
+
evaluate: vi.fn(),
|
|
1230
|
+
wait: vi.fn(),
|
|
1231
|
+
session: 'test',
|
|
1232
|
+
};
|
|
1233
|
+
const program = createProgram('', '');
|
|
1234
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'back']);
|
|
1235
|
+
expect(browserState.page?.evaluate).toHaveBeenCalledWith('history.back()');
|
|
1236
|
+
});
|
|
1237
|
+
it('unbinds a session through the daemon close-window command', async () => {
|
|
1238
|
+
const program = createProgram('', '');
|
|
1239
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'unbind']);
|
|
1240
|
+
expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser' });
|
|
1241
|
+
expect(mockSendCommand).toHaveBeenCalledWith('close-window', { session: 'test', surface: 'browser' });
|
|
1242
|
+
const out = lastJsonLog();
|
|
1243
|
+
expect(out).toEqual({ unbound: true, session: 'test' });
|
|
1244
|
+
});
|
|
1245
|
+
it('does not print false success when unbind fails', async () => {
|
|
1246
|
+
mockSendCommand.mockRejectedValueOnce(new BrowserCommandError('Session "test" is not attached to a tab.', 'bound_session_missing', 'Run bind again, then retry the browser command.'));
|
|
1247
|
+
const program = createProgram('', '');
|
|
1248
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'unbind']);
|
|
1249
|
+
const out = lastJsonLog();
|
|
1250
|
+
expect(out.error.code).toBe('bound_session_missing');
|
|
1251
|
+
expect(process.exitCode).toBeDefined();
|
|
1252
|
+
});
|
|
1253
|
+
it('accepts JavaScript dialogs through the browser dialog command', async () => {
|
|
1254
|
+
const program = createProgram('', '');
|
|
1255
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'dialog', 'accept', '--text', 'ok']);
|
|
1256
|
+
expect(browserState.page?.handleJavaScriptDialog).toHaveBeenCalledWith(true, 'ok');
|
|
1257
|
+
const out = lastJsonLog();
|
|
1258
|
+
expect(out).toEqual({ handled: true, action: 'accept', text: 'ok' });
|
|
1259
|
+
});
|
|
1260
|
+
it('emits a structured error when a browser action is blocked by a JavaScript dialog', async () => {
|
|
1261
|
+
browserState.page = {
|
|
1262
|
+
...browserState.page,
|
|
1263
|
+
evaluate: vi.fn().mockRejectedValue(new Error('JavaScript dialog showing')),
|
|
1264
|
+
};
|
|
1265
|
+
const program = createProgram('', '');
|
|
1266
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'eval', 'document.title']);
|
|
1267
|
+
const out = lastJsonLog();
|
|
1268
|
+
expect(out.error.code).toBe('javascript_dialog_open');
|
|
1269
|
+
expect(out.error.hint).toContain('browser dialog accept');
|
|
1270
|
+
expect(process.exitCode).toBeDefined();
|
|
1271
|
+
});
|
|
1272
|
+
it('binds browser commands to an explicit target tab via --tab', async () => {
|
|
1273
|
+
const program = createProgram('', '');
|
|
1274
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'eval', '--tab', 'tab-2', 'document.title']);
|
|
1275
|
+
expect(browserState.page?.setActivePage).toHaveBeenCalledWith('tab-2');
|
|
1276
|
+
expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
|
|
1277
|
+
});
|
|
1278
|
+
it('rejects an explicit --tab target that is no longer in the current session', async () => {
|
|
1279
|
+
browserState.page = {
|
|
1280
|
+
setActivePage: vi.fn(),
|
|
1281
|
+
getActivePage: vi.fn(),
|
|
1282
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
1283
|
+
evaluate: vi.fn(),
|
|
1284
|
+
};
|
|
1285
|
+
const program = createProgram('', '');
|
|
1286
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'eval', '--tab', 'tab-stale', 'document.title']);
|
|
1287
|
+
expect(process.exitCode).toBeDefined();
|
|
1288
|
+
expect(browserState.page?.setActivePage).not.toHaveBeenCalled();
|
|
1289
|
+
expect(browserState.page?.evaluate).not.toHaveBeenCalled();
|
|
1290
|
+
expect(stderrSpy.mock.calls.flat().join('\n')).toContain('Target tab tab-stale is not part of the current browser session');
|
|
1291
|
+
});
|
|
1292
|
+
it('lists tabs with target IDs via browser tab list', async () => {
|
|
1293
|
+
const program = createProgram('', '');
|
|
1294
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tab', 'list']);
|
|
1295
|
+
expect(browserState.page?.tabs).toHaveBeenCalledTimes(1);
|
|
1296
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-1"');
|
|
1297
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-2"');
|
|
1298
|
+
});
|
|
1299
|
+
it('creates a new tab and prints its target ID', async () => {
|
|
1300
|
+
const program = createProgram('', '');
|
|
1301
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tab', 'new', 'https://three.example']);
|
|
1302
|
+
expect(browserState.page?.newTab).toHaveBeenCalledWith('https://three.example');
|
|
1303
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-3"');
|
|
1304
|
+
});
|
|
1305
|
+
it('prints the resolved target ID when browser open creates or navigates a tab', async () => {
|
|
1306
|
+
const program = createProgram('', '');
|
|
1307
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'open', 'https://example.com']);
|
|
1308
|
+
expect(browserState.page?.goto).toHaveBeenCalledWith('https://example.com');
|
|
1309
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"url": "https://one.example"');
|
|
1310
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"page": "tab-1"');
|
|
1311
|
+
});
|
|
1312
|
+
it('lists cross-origin frames via browser frames', async () => {
|
|
1313
|
+
const program = createProgram('', '');
|
|
1314
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'frames']);
|
|
1315
|
+
expect(browserState.page?.frames).toHaveBeenCalledTimes(1);
|
|
1316
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"frameId": "frame-1"');
|
|
1317
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"url": "https://x.example/embed"');
|
|
1318
|
+
});
|
|
1319
|
+
it('routes browser eval --frame through frame-targeted evaluation', async () => {
|
|
1320
|
+
const program = createProgram('', '');
|
|
1321
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'eval', '--frame', '0', 'document.title']);
|
|
1322
|
+
expect(browserState.page?.evaluateInFrame).toHaveBeenCalledWith('document.title', 0);
|
|
1323
|
+
expect(browserState.page?.evaluate).not.toHaveBeenCalled();
|
|
1324
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('inside frame');
|
|
1325
|
+
});
|
|
1326
|
+
it('does not promote a newly created tab to the persisted default target', async () => {
|
|
1327
|
+
const program = createProgram('', '');
|
|
1328
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tab', 'new', 'https://three.example']);
|
|
1329
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'eval', 'document.title']);
|
|
1330
|
+
expect(browserState.page?.newTab).toHaveBeenCalledWith('https://three.example');
|
|
1331
|
+
expect(browserState.page?.setActivePage).not.toHaveBeenCalled();
|
|
1332
|
+
expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
|
|
1333
|
+
});
|
|
1334
|
+
it('persists an explicitly selected tab as the default target for later untargeted commands', async () => {
|
|
1335
|
+
const program = createProgram('', '');
|
|
1336
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tab', 'select', 'tab-2']);
|
|
1337
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'eval', 'document.title']);
|
|
1338
|
+
expect(browserState.page?.selectTab).toHaveBeenCalledWith('tab-2');
|
|
1339
|
+
expect(browserState.page?.setActivePage).toHaveBeenCalledWith('tab-2');
|
|
1340
|
+
expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
|
|
1341
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"selected": "tab-2"');
|
|
1342
|
+
});
|
|
1343
|
+
it('clears a saved default target when it is no longer present in the current session', async () => {
|
|
1344
|
+
const cacheDir = String(process.env.WEBCMD_CACHE_DIR);
|
|
1345
|
+
const program = createProgram('', '');
|
|
1346
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tab', 'select', 'tab-2']);
|
|
1347
|
+
expect(fs.existsSync(getBrowserStateFile(cacheDir))).toBe(true);
|
|
1348
|
+
browserState.page = {
|
|
1349
|
+
setActivePage: vi.fn(),
|
|
1350
|
+
getActivePage: vi.fn(),
|
|
1351
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
1352
|
+
evaluate: vi.fn().mockResolvedValue({ ok: true }),
|
|
1353
|
+
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
|
1354
|
+
};
|
|
1355
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'eval', 'document.title']);
|
|
1356
|
+
expect(browserState.page?.setActivePage).not.toHaveBeenCalled();
|
|
1357
|
+
expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
|
|
1358
|
+
expect(fs.existsSync(getBrowserStateFile(cacheDir))).toBe(false);
|
|
1359
|
+
});
|
|
1360
|
+
it('clears the persisted default target when that tab is closed', async () => {
|
|
1361
|
+
const program = createProgram('', '');
|
|
1362
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tab', 'select', 'tab-2']);
|
|
1363
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tab', 'close', 'tab-2']);
|
|
1364
|
+
vi.mocked(browserState.page?.setActivePage).mockClear();
|
|
1365
|
+
vi.mocked(browserState.page?.evaluate).mockClear();
|
|
1366
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'eval', 'document.title']);
|
|
1367
|
+
expect(browserState.page?.closeTab).toHaveBeenCalledWith('tab-2');
|
|
1368
|
+
expect(browserState.page?.setActivePage).not.toHaveBeenCalled();
|
|
1369
|
+
expect(browserState.page?.evaluate).toHaveBeenCalledWith('document.title');
|
|
1370
|
+
});
|
|
1371
|
+
it('closes a tab by target ID', async () => {
|
|
1372
|
+
const program = createProgram('', '');
|
|
1373
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tab', 'close', 'tab-2']);
|
|
1374
|
+
expect(browserState.page?.closeTab).toHaveBeenCalledWith('tab-2');
|
|
1375
|
+
expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('"closed": "tab-2"');
|
|
1376
|
+
});
|
|
1377
|
+
it('rejects closing a stale tab target ID that is no longer in the current session', async () => {
|
|
1378
|
+
browserState.page = {
|
|
1379
|
+
session: 'test',
|
|
1380
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
1381
|
+
closeTab: vi.fn(),
|
|
1382
|
+
};
|
|
1383
|
+
const program = createProgram('', '');
|
|
1384
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'tab', 'close', 'tab-stale']);
|
|
1385
|
+
expect(process.exitCode).toBeDefined();
|
|
1386
|
+
expect(browserState.page?.closeTab).not.toHaveBeenCalled();
|
|
1387
|
+
expect(stderrSpy.mock.calls.flat().join('\n')).toContain('Target tab tab-stale is not part of the current browser session');
|
|
1388
|
+
});
|
|
1389
|
+
it('browser analyze merges HttpOnly cookie names from page.getCookies and drains stale capture before verdict', async () => {
|
|
1390
|
+
browserState.page = {
|
|
1391
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
1392
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
1393
|
+
setActivePage: vi.fn(),
|
|
1394
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
1395
|
+
getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
|
|
1396
|
+
startNetworkCapture: vi.fn().mockResolvedValue(true),
|
|
1397
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'cf_clearance', value: 'x', domain: '.target.example' }]),
|
|
1398
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
1399
|
+
cookieNames: [],
|
|
1400
|
+
initialState: {
|
|
1401
|
+
__INITIAL_STATE__: false,
|
|
1402
|
+
__NUXT__: false,
|
|
1403
|
+
__NEXT_DATA__: false,
|
|
1404
|
+
__APOLLO_STATE__: false,
|
|
1405
|
+
},
|
|
1406
|
+
title: 'Target',
|
|
1407
|
+
finalUrl: 'https://target.example/',
|
|
1408
|
+
}),
|
|
1409
|
+
tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
|
|
1410
|
+
readNetworkCapture: vi.fn()
|
|
1411
|
+
.mockResolvedValueOnce([
|
|
1412
|
+
{
|
|
1413
|
+
url: 'https://stale.example/api/old',
|
|
1414
|
+
method: 'GET',
|
|
1415
|
+
responseStatus: 200,
|
|
1416
|
+
responseContentType: 'application/json',
|
|
1417
|
+
responsePreview: '{"stale":true}',
|
|
1418
|
+
},
|
|
1419
|
+
])
|
|
1420
|
+
.mockResolvedValueOnce([
|
|
1421
|
+
{
|
|
1422
|
+
url: 'https://target.example/api/items',
|
|
1423
|
+
method: 'GET',
|
|
1424
|
+
responseStatus: 200,
|
|
1425
|
+
responseContentType: 'application/json',
|
|
1426
|
+
responsePreview: '{"items":[{"title":"A","id":"1"}]}',
|
|
1427
|
+
},
|
|
1428
|
+
]),
|
|
1429
|
+
};
|
|
1430
|
+
const program = createProgram('', '');
|
|
1431
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'analyze', 'https://target.example/']);
|
|
1432
|
+
const out = lastJsonLog();
|
|
1433
|
+
expect(browserState.page?.readNetworkCapture).toHaveBeenCalledTimes(2);
|
|
1434
|
+
expect(out.pattern.pattern).toBe('A');
|
|
1435
|
+
expect(out.api_candidates[0].url).toBe('https://target.example/api/items');
|
|
1436
|
+
expect(out.api_candidates[0].verdict).toBe('likely_data');
|
|
1437
|
+
expect(out.anti_bot.evidence).toContain('cookie:cf_clearance');
|
|
1438
|
+
});
|
|
1439
|
+
it('browser analyze falls back to interceptor buffer when network capture is unsupported', async () => {
|
|
1440
|
+
let bufferReads = 0;
|
|
1441
|
+
browserState.page = {
|
|
1442
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
1443
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
1444
|
+
setActivePage: vi.fn(),
|
|
1445
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
1446
|
+
getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
|
|
1447
|
+
startNetworkCapture: vi.fn().mockResolvedValue(false),
|
|
1448
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'cf_clearance', value: 'x', domain: '.target.example' }]),
|
|
1449
|
+
evaluate: vi.fn().mockImplementation(async (arg) => {
|
|
1450
|
+
if (typeof arg === 'string' && arg.includes('document.cookie')) {
|
|
1451
|
+
return {
|
|
1452
|
+
cookieNames: [],
|
|
1453
|
+
initialState: {
|
|
1454
|
+
__INITIAL_STATE__: false,
|
|
1455
|
+
__NUXT__: false,
|
|
1456
|
+
__NEXT_DATA__: false,
|
|
1457
|
+
__APOLLO_STATE__: false,
|
|
1458
|
+
},
|
|
1459
|
+
title: 'Target',
|
|
1460
|
+
finalUrl: 'https://target.example/',
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
if (typeof arg === 'string' && arg.includes('window.__webcmd_net = []')) {
|
|
1464
|
+
bufferReads += 1;
|
|
1465
|
+
if (bufferReads === 1) {
|
|
1466
|
+
return JSON.stringify([
|
|
1467
|
+
{
|
|
1468
|
+
url: 'https://stale.example/api/old',
|
|
1469
|
+
method: 'GET',
|
|
1470
|
+
status: 200,
|
|
1471
|
+
size: 12,
|
|
1472
|
+
ct: 'application/json',
|
|
1473
|
+
body: { stale: true },
|
|
1474
|
+
},
|
|
1475
|
+
]);
|
|
1476
|
+
}
|
|
1477
|
+
return JSON.stringify([
|
|
1478
|
+
{
|
|
1479
|
+
url: 'https://target.example/waf',
|
|
1480
|
+
method: 'GET',
|
|
1481
|
+
status: 403,
|
|
1482
|
+
size: 17,
|
|
1483
|
+
ct: 'text/html',
|
|
1484
|
+
body: 'Cloudflare Ray ID',
|
|
1485
|
+
},
|
|
1486
|
+
]);
|
|
1487
|
+
}
|
|
1488
|
+
return undefined;
|
|
1489
|
+
}),
|
|
1490
|
+
tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
|
|
1491
|
+
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
|
1492
|
+
};
|
|
1493
|
+
const program = createProgram('', '');
|
|
1494
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'analyze', 'https://target.example/']);
|
|
1495
|
+
const out = lastJsonLog();
|
|
1496
|
+
expect(browserState.page?.readNetworkCapture).toHaveBeenCalledTimes(2);
|
|
1497
|
+
expect(bufferReads).toBe(2);
|
|
1498
|
+
expect(out.anti_bot.vendor).toBe('cloudflare');
|
|
1499
|
+
expect(out.anti_bot.evidence).toContain('cookie:cf_clearance');
|
|
1500
|
+
expect(out.anti_bot.evidence).toContain('body:https://target.example/waf');
|
|
1501
|
+
});
|
|
1502
|
+
it('browser wait xhr starts capture, injects interceptor on fallback, and ignores stale ring entries', async () => {
|
|
1503
|
+
browserState.page = {
|
|
1504
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
1505
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
1506
|
+
setActivePage: vi.fn(),
|
|
1507
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
1508
|
+
getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
|
|
1509
|
+
startNetworkCapture: vi.fn().mockResolvedValue(false),
|
|
1510
|
+
evaluate: vi.fn().mockResolvedValue(undefined),
|
|
1511
|
+
tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
|
|
1512
|
+
readNetworkCapture: vi.fn()
|
|
1513
|
+
.mockResolvedValueOnce([
|
|
1514
|
+
{
|
|
1515
|
+
url: 'https://stale.example/api/old',
|
|
1516
|
+
method: 'GET',
|
|
1517
|
+
responseStatus: 200,
|
|
1518
|
+
responseContentType: 'application/json',
|
|
1519
|
+
responsePreview: '{"stale":true}',
|
|
1520
|
+
},
|
|
1521
|
+
])
|
|
1522
|
+
.mockResolvedValueOnce([
|
|
1523
|
+
{
|
|
1524
|
+
url: 'https://target.example/api/target',
|
|
1525
|
+
method: 'GET',
|
|
1526
|
+
responseStatus: 200,
|
|
1527
|
+
responseContentType: 'application/json',
|
|
1528
|
+
responsePreview: '{"ok":true}',
|
|
1529
|
+
},
|
|
1530
|
+
]),
|
|
1531
|
+
};
|
|
1532
|
+
const program = createProgram('', '');
|
|
1533
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'wait', 'xhr', '/api/target', '--timeout', '900']);
|
|
1534
|
+
const out = lastJsonLog();
|
|
1535
|
+
expect(browserState.page?.startNetworkCapture).toHaveBeenCalledTimes(1);
|
|
1536
|
+
expect(browserState.page?.evaluate).toHaveBeenCalledWith(expect.stringContaining('window.__webcmd_net'));
|
|
1537
|
+
expect(browserState.page?.readNetworkCapture).toHaveBeenCalledTimes(2);
|
|
1538
|
+
expect(out.matched.url).toBe('https://target.example/api/target');
|
|
1539
|
+
});
|
|
1540
|
+
it('browser wait xhr reads interceptor buffer when network capture is unsupported', async () => {
|
|
1541
|
+
let bufferReads = 0;
|
|
1542
|
+
browserState.page = {
|
|
1543
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
1544
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
1545
|
+
setActivePage: vi.fn(),
|
|
1546
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
1547
|
+
getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
|
|
1548
|
+
startNetworkCapture: vi.fn().mockResolvedValue(false),
|
|
1549
|
+
evaluate: vi.fn().mockImplementation(async (arg) => {
|
|
1550
|
+
if (typeof arg === 'string' && arg.includes('window.__webcmd_net = []')) {
|
|
1551
|
+
bufferReads += 1;
|
|
1552
|
+
if (bufferReads === 1) {
|
|
1553
|
+
return JSON.stringify([
|
|
1554
|
+
{
|
|
1555
|
+
url: 'https://stale.example/api/old',
|
|
1556
|
+
method: 'GET',
|
|
1557
|
+
status: 200,
|
|
1558
|
+
size: 12,
|
|
1559
|
+
ct: 'application/json',
|
|
1560
|
+
body: { stale: true },
|
|
1561
|
+
},
|
|
1562
|
+
]);
|
|
1563
|
+
}
|
|
1564
|
+
return JSON.stringify([
|
|
1565
|
+
{
|
|
1566
|
+
url: 'https://target.example/api/target',
|
|
1567
|
+
method: 'GET',
|
|
1568
|
+
status: 200,
|
|
1569
|
+
size: 11,
|
|
1570
|
+
ct: 'application/json',
|
|
1571
|
+
body: { ok: true },
|
|
1572
|
+
},
|
|
1573
|
+
]);
|
|
1574
|
+
}
|
|
1575
|
+
return undefined;
|
|
1576
|
+
}),
|
|
1577
|
+
tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
|
|
1578
|
+
readNetworkCapture: vi.fn().mockResolvedValue([]),
|
|
1579
|
+
};
|
|
1580
|
+
const program = createProgram('', '');
|
|
1581
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'wait', 'xhr', '/api/target', '--timeout', '900']);
|
|
1582
|
+
const out = lastJsonLog();
|
|
1583
|
+
expect(browserState.page?.startNetworkCapture).toHaveBeenCalledTimes(1);
|
|
1584
|
+
expect(browserState.page?.readNetworkCapture).toHaveBeenCalledTimes(2);
|
|
1585
|
+
expect(bufferReads).toBe(2);
|
|
1586
|
+
expect(out.matched.url).toBe('https://target.example/api/target');
|
|
1587
|
+
});
|
|
1588
|
+
it('browser wait download delegates to the browser runtime download observer', async () => {
|
|
1589
|
+
browserState.page = {
|
|
1590
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
1591
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
1592
|
+
waitForDownload: vi.fn().mockResolvedValue({
|
|
1593
|
+
downloaded: true,
|
|
1594
|
+
filename: '/tmp/receipt.pdf',
|
|
1595
|
+
url: 'https://app.example/receipt.pdf',
|
|
1596
|
+
state: 'complete',
|
|
1597
|
+
elapsedMs: 10,
|
|
1598
|
+
}),
|
|
1599
|
+
setActivePage: vi.fn(),
|
|
1600
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
1601
|
+
getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
|
|
1602
|
+
tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
|
|
1603
|
+
};
|
|
1604
|
+
const program = createProgram('', '');
|
|
1605
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'wait', 'download', 'receipt', '--timeout', '900']);
|
|
1606
|
+
expect(browserState.page?.waitForDownload).toHaveBeenCalledWith('receipt', 900);
|
|
1607
|
+
expect(lastJsonLog()).toEqual({
|
|
1608
|
+
downloaded: true,
|
|
1609
|
+
filename: '/tmp/receipt.pdf',
|
|
1610
|
+
url: 'https://app.example/receipt.pdf',
|
|
1611
|
+
state: 'complete',
|
|
1612
|
+
elapsedMs: 10,
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
it('browser wait download reports an error envelope when no matching download completes', async () => {
|
|
1616
|
+
browserState.page = {
|
|
1617
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
1618
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
1619
|
+
waitForDownload: vi.fn().mockResolvedValue({
|
|
1620
|
+
downloaded: false,
|
|
1621
|
+
state: 'interrupted',
|
|
1622
|
+
error: 'No download matched "receipt" within 900ms',
|
|
1623
|
+
elapsedMs: 900,
|
|
1624
|
+
}),
|
|
1625
|
+
setActivePage: vi.fn(),
|
|
1626
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
1627
|
+
getCurrentUrl: vi.fn().mockResolvedValue('https://target.example'),
|
|
1628
|
+
tabs: vi.fn().mockResolvedValue([{ index: 0, page: 'tab-1', url: 'https://target.example', title: 'Target', active: true }]),
|
|
1629
|
+
};
|
|
1630
|
+
const program = createProgram('', '');
|
|
1631
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'wait', 'download', 'receipt', '--timeout', '900']);
|
|
1632
|
+
const out = lastJsonLog();
|
|
1633
|
+
expect(out.error.code).toBe('download_not_seen');
|
|
1634
|
+
expect(out.download.elapsedMs).toBe(900);
|
|
1635
|
+
expect(process.exitCode).toBeDefined();
|
|
1636
|
+
});
|
|
1637
|
+
});
|
|
1638
|
+
describe('browser network command', () => {
|
|
1639
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
1640
|
+
function getNetworkCachePath(cacheDir) {
|
|
1641
|
+
return path.join(cacheDir, 'browser-network', 'test.json');
|
|
1642
|
+
}
|
|
1643
|
+
function getCustomNetworkCachePath(cacheDir) {
|
|
1644
|
+
return path.join(cacheDir, 'browser-network', 'custom.json');
|
|
1645
|
+
}
|
|
1646
|
+
function lastJsonLog() {
|
|
1647
|
+
const calls = consoleLogSpy.mock.calls;
|
|
1648
|
+
if (calls.length === 0)
|
|
1649
|
+
throw new Error('Expected at least one console.log call');
|
|
1650
|
+
const last = calls[calls.length - 1][0];
|
|
1651
|
+
if (typeof last !== 'string')
|
|
1652
|
+
throw new Error(`Expected string arg to console.log, got ${typeof last}`);
|
|
1653
|
+
return JSON.parse(last);
|
|
1654
|
+
}
|
|
1655
|
+
beforeEach(() => {
|
|
1656
|
+
process.exitCode = undefined;
|
|
1657
|
+
process.env.WEBCMD_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-browser-net-'));
|
|
1658
|
+
consoleLogSpy.mockClear();
|
|
1659
|
+
mockBrowserConnect.mockClear();
|
|
1660
|
+
mockBrowserClose.mockReset().mockResolvedValue(undefined);
|
|
1661
|
+
browserState.page = {
|
|
1662
|
+
session: 'test',
|
|
1663
|
+
setActivePage: vi.fn(),
|
|
1664
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
1665
|
+
tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]),
|
|
1666
|
+
evaluate: vi.fn().mockResolvedValue(''),
|
|
1667
|
+
readNetworkCapture: vi.fn().mockResolvedValue([
|
|
1668
|
+
{
|
|
1669
|
+
url: 'https://x.com/i/api/graphql/qid/UserTweets?v=1',
|
|
1670
|
+
method: 'GET',
|
|
1671
|
+
responseStatus: 200,
|
|
1672
|
+
responseContentType: 'application/json',
|
|
1673
|
+
responsePreview: JSON.stringify({ data: { user: { rest_id: '42' } } }),
|
|
1674
|
+
timestamp: Date.now(),
|
|
1675
|
+
},
|
|
1676
|
+
{
|
|
1677
|
+
url: 'https://cdn.example.com/app.js',
|
|
1678
|
+
method: 'GET',
|
|
1679
|
+
responseStatus: 200,
|
|
1680
|
+
responseContentType: 'application/javascript',
|
|
1681
|
+
responsePreview: '// js',
|
|
1682
|
+
},
|
|
1683
|
+
]),
|
|
1684
|
+
};
|
|
1685
|
+
});
|
|
1686
|
+
it('emits JSON with shape previews and persists the capture to disk', async () => {
|
|
1687
|
+
const cacheDir = String(process.env.WEBCMD_CACHE_DIR);
|
|
1688
|
+
const program = createProgram('', '');
|
|
1689
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
1690
|
+
const out = lastJsonLog();
|
|
1691
|
+
expect(out.count).toBe(1);
|
|
1692
|
+
expect(out.filtered_out).toBe(1);
|
|
1693
|
+
expect(out.entries[0].key).toBe('UserTweets');
|
|
1694
|
+
expect(out.entries[0].shape['$.data.user.rest_id']).toBe('string');
|
|
1695
|
+
expect(out.entries[0]).not.toHaveProperty('body');
|
|
1696
|
+
expect(fs.existsSync(getNetworkCachePath(cacheDir))).toBe(true);
|
|
1697
|
+
});
|
|
1698
|
+
it('uses the selected browser session for network cache scope', async () => {
|
|
1699
|
+
const cacheDir = String(process.env.WEBCMD_CACHE_DIR);
|
|
1700
|
+
browserState.page = {
|
|
1701
|
+
...browserState.page,
|
|
1702
|
+
session: 'custom',
|
|
1703
|
+
};
|
|
1704
|
+
const program = createProgram('', '');
|
|
1705
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'custom', 'network']);
|
|
1706
|
+
const out = lastJsonLog();
|
|
1707
|
+
expect(out.session).toBe('custom');
|
|
1708
|
+
expect(fs.existsSync(getCustomNetworkCachePath(cacheDir))).toBe(true);
|
|
1709
|
+
expect(fs.existsSync(getNetworkCachePath(cacheDir))).toBe(false);
|
|
1710
|
+
});
|
|
1711
|
+
it('--all includes static resources that the default filter drops', async () => {
|
|
1712
|
+
const program = createProgram('', '');
|
|
1713
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--all']);
|
|
1714
|
+
const out = lastJsonLog();
|
|
1715
|
+
expect(out.count).toBe(2);
|
|
1716
|
+
expect(out.entries.map((e) => e.key)).toContain('UserTweets');
|
|
1717
|
+
expect(out.entries.map((e) => e.key)).toContain('GET cdn.example.com/app.js');
|
|
1718
|
+
});
|
|
1719
|
+
it('--failed and --since filter captured entries by status and time window', async () => {
|
|
1720
|
+
const now = Date.now();
|
|
1721
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
1722
|
+
{
|
|
1723
|
+
url: 'https://api.example.com/new-fail',
|
|
1724
|
+
method: 'GET',
|
|
1725
|
+
responseStatus: 500,
|
|
1726
|
+
responseContentType: 'application/json',
|
|
1727
|
+
responsePreview: JSON.stringify({ error: true }),
|
|
1728
|
+
timestamp: now,
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
url: 'https://api.example.com/old-fail',
|
|
1732
|
+
method: 'GET',
|
|
1733
|
+
responseStatus: 500,
|
|
1734
|
+
responseContentType: 'application/json',
|
|
1735
|
+
responsePreview: JSON.stringify({ error: true }),
|
|
1736
|
+
timestamp: now - 180_000,
|
|
1737
|
+
},
|
|
1738
|
+
{
|
|
1739
|
+
url: 'https://api.example.com/new-ok',
|
|
1740
|
+
method: 'GET',
|
|
1741
|
+
responseStatus: 200,
|
|
1742
|
+
responseContentType: 'application/json',
|
|
1743
|
+
responsePreview: JSON.stringify({ ok: true }),
|
|
1744
|
+
timestamp: now,
|
|
1745
|
+
},
|
|
1746
|
+
]);
|
|
1747
|
+
const program = createProgram('', '');
|
|
1748
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--since', '120s', '--failed']);
|
|
1749
|
+
const out = lastJsonLog();
|
|
1750
|
+
expect(out.count).toBe(1);
|
|
1751
|
+
expect(out.entries[0].url).toBe('https://api.example.com/new-fail');
|
|
1752
|
+
expect(out.entries[0].timestamp).toMatch(/T/);
|
|
1753
|
+
});
|
|
1754
|
+
it('default output keeps text/javascript API responses while dropping static JS files', async () => {
|
|
1755
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
1756
|
+
{
|
|
1757
|
+
url: 'https://hw.mail.163.com/js6/s?sid=abc&func=mbox:listMessages',
|
|
1758
|
+
method: 'POST',
|
|
1759
|
+
responseStatus: 200,
|
|
1760
|
+
responseContentType: 'text/javascript',
|
|
1761
|
+
responsePreview: JSON.stringify({ messages: [{ id: 'm1', subject: 'hello' }] }),
|
|
1762
|
+
},
|
|
1763
|
+
{
|
|
1764
|
+
url: 'https://cdn.example.com/app.js',
|
|
1765
|
+
method: 'GET',
|
|
1766
|
+
responseStatus: 200,
|
|
1767
|
+
responseContentType: 'application/javascript',
|
|
1768
|
+
responsePreview: '// js',
|
|
1769
|
+
},
|
|
1770
|
+
]);
|
|
1771
|
+
const program = createProgram('', '');
|
|
1772
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
1773
|
+
const out = lastJsonLog();
|
|
1774
|
+
expect(out.count).toBe(1);
|
|
1775
|
+
expect(out.filtered_out).toBe(1);
|
|
1776
|
+
expect(out.entries[0].key).toBe('POST hw.mail.163.com/js6/s');
|
|
1777
|
+
expect(out.entries[0].ct).toBe('text/javascript');
|
|
1778
|
+
expect(out.entries[0].shape['$.messages']).toBe('array(1)');
|
|
1779
|
+
});
|
|
1780
|
+
it('--raw emits full bodies inline for every entry', async () => {
|
|
1781
|
+
const program = createProgram('', '');
|
|
1782
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--raw']);
|
|
1783
|
+
const out = lastJsonLog();
|
|
1784
|
+
expect(out.entries[0].body).toEqual({ data: { user: { rest_id: '42' } } });
|
|
1785
|
+
expect(out.entries[0].timestamp).toMatch(/T/);
|
|
1786
|
+
});
|
|
1787
|
+
it('--detail <key> returns the full body for the requested entry', async () => {
|
|
1788
|
+
const program = createProgram('', '');
|
|
1789
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
1790
|
+
consoleLogSpy.mockClear();
|
|
1791
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--detail', 'UserTweets']);
|
|
1792
|
+
const out = lastJsonLog();
|
|
1793
|
+
expect(out.key).toBe('UserTweets');
|
|
1794
|
+
expect(out.body).toEqual({ data: { user: { rest_id: '42' } } });
|
|
1795
|
+
expect(out.shape['$.data.user.rest_id']).toBe('string');
|
|
1796
|
+
expect(out.timestamp).toMatch(/T/);
|
|
1797
|
+
});
|
|
1798
|
+
it('--detail reports key_not_found with the list of available keys', async () => {
|
|
1799
|
+
const program = createProgram('', '');
|
|
1800
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
1801
|
+
consoleLogSpy.mockClear();
|
|
1802
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--detail', 'NopeOp']);
|
|
1803
|
+
const out = lastJsonLog();
|
|
1804
|
+
expect(out.error.code).toBe('key_not_found');
|
|
1805
|
+
expect(out.error.available_keys).toContain('UserTweets');
|
|
1806
|
+
expect(process.exitCode).toBeDefined();
|
|
1807
|
+
});
|
|
1808
|
+
it('--detail reports cache_missing when no capture has been persisted yet', async () => {
|
|
1809
|
+
const program = createProgram('', '');
|
|
1810
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--detail', 'UserTweets']);
|
|
1811
|
+
const out = lastJsonLog();
|
|
1812
|
+
expect(out.error.code).toBe('cache_missing');
|
|
1813
|
+
expect(process.exitCode).toBeDefined();
|
|
1814
|
+
});
|
|
1815
|
+
it('emits capture_failed when readNetworkCapture throws', async () => {
|
|
1816
|
+
browserState.page.readNetworkCapture = vi.fn().mockRejectedValue(new Error('CDP disconnected'));
|
|
1817
|
+
const program = createProgram('', '');
|
|
1818
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
1819
|
+
const out = lastJsonLog();
|
|
1820
|
+
expect(out.error.code).toBe('capture_failed');
|
|
1821
|
+
expect(out.error.message).toContain('CDP disconnected');
|
|
1822
|
+
expect(process.exitCode).toBeDefined();
|
|
1823
|
+
});
|
|
1824
|
+
it('surfaces cache_warning in the envelope when persistence fails', async () => {
|
|
1825
|
+
const cacheDir = String(process.env.WEBCMD_CACHE_DIR);
|
|
1826
|
+
// Pre-create the target path as a file where a directory is expected,
|
|
1827
|
+
// forcing the mkdir inside saveNetworkCache to throw.
|
|
1828
|
+
const clashDir = path.join(cacheDir, 'browser-network');
|
|
1829
|
+
fs.writeFileSync(clashDir, 'not-a-directory');
|
|
1830
|
+
const program = createProgram('', '');
|
|
1831
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
1832
|
+
const out = lastJsonLog();
|
|
1833
|
+
expect(out.cache_warning).toMatch(/Could not persist capture cache/);
|
|
1834
|
+
expect(out.count).toBe(1);
|
|
1835
|
+
expect(process.exitCode).toBeUndefined();
|
|
1836
|
+
});
|
|
1837
|
+
describe('--filter', () => {
|
|
1838
|
+
function apiResponse(url, body) {
|
|
1839
|
+
return {
|
|
1840
|
+
url,
|
|
1841
|
+
method: 'GET',
|
|
1842
|
+
responseStatus: 200,
|
|
1843
|
+
responseContentType: 'application/json',
|
|
1844
|
+
responsePreview: JSON.stringify(body),
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
beforeEach(() => {
|
|
1848
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
1849
|
+
apiResponse('https://x.com/i/api/graphql/qid/UserTweets?v=1', { data: { items: [{ author: 'a', text: 't', likes: 1 }] } }),
|
|
1850
|
+
apiResponse('https://x.com/i/api/graphql/qid/UserProfile?v=1', { data: { user: { id: 'u1', followers: 10 } } }),
|
|
1851
|
+
apiResponse('https://x.com/i/api/graphql/qid/Settings?v=1', { config: { theme: 'dark' } }),
|
|
1852
|
+
]);
|
|
1853
|
+
});
|
|
1854
|
+
it('narrows entries to those whose shape has ALL named fields', async () => {
|
|
1855
|
+
const program = createProgram('', '');
|
|
1856
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', 'author,text,likes']);
|
|
1857
|
+
const out = lastJsonLog();
|
|
1858
|
+
expect(out.count).toBe(1);
|
|
1859
|
+
expect(out.filter).toEqual(['author', 'text', 'likes']);
|
|
1860
|
+
expect(out.filter_dropped).toBe(2);
|
|
1861
|
+
expect(out.entries[0].key).toBe('UserTweets');
|
|
1862
|
+
});
|
|
1863
|
+
it('matches container segments too, not just leaf names (any-segment rule)', async () => {
|
|
1864
|
+
const program = createProgram('', '');
|
|
1865
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', 'data,items']);
|
|
1866
|
+
const out = lastJsonLog();
|
|
1867
|
+
expect(out.count).toBe(1);
|
|
1868
|
+
expect(out.entries[0].key).toBe('UserTweets');
|
|
1869
|
+
});
|
|
1870
|
+
it('drops entries that are missing any required field (AND semantics)', async () => {
|
|
1871
|
+
const program = createProgram('', '');
|
|
1872
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', 'author,followers']);
|
|
1873
|
+
const out = lastJsonLog();
|
|
1874
|
+
expect(out.count).toBe(0);
|
|
1875
|
+
expect(out.entries).toEqual([]);
|
|
1876
|
+
expect(out.filter).toEqual(['author', 'followers']);
|
|
1877
|
+
expect(out.filter_dropped).toBe(3);
|
|
1878
|
+
});
|
|
1879
|
+
it('returns empty entries (not an error) when nothing matches', async () => {
|
|
1880
|
+
const program = createProgram('', '');
|
|
1881
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', 'nonexistent_field']);
|
|
1882
|
+
const out = lastJsonLog();
|
|
1883
|
+
expect(out.count).toBe(0);
|
|
1884
|
+
expect(out.entries).toEqual([]);
|
|
1885
|
+
expect(out).not.toHaveProperty('error');
|
|
1886
|
+
expect(process.exitCode).toBeUndefined();
|
|
1887
|
+
});
|
|
1888
|
+
it('is case-sensitive so agents do not conflate `Id` with `id`', async () => {
|
|
1889
|
+
const program = createProgram('', '');
|
|
1890
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', 'Data']);
|
|
1891
|
+
const out = lastJsonLog();
|
|
1892
|
+
expect(out.count).toBe(0);
|
|
1893
|
+
});
|
|
1894
|
+
it('persists the full (unfiltered) capture so --detail lookups still find filtered-out keys', async () => {
|
|
1895
|
+
const program = createProgram('', '');
|
|
1896
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', 'author,text,likes']);
|
|
1897
|
+
consoleLogSpy.mockClear();
|
|
1898
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--detail', 'UserProfile']);
|
|
1899
|
+
const out = lastJsonLog();
|
|
1900
|
+
expect(out.key).toBe('UserProfile');
|
|
1901
|
+
expect(out.body).toEqual({ data: { user: { id: 'u1', followers: 10 } } });
|
|
1902
|
+
});
|
|
1903
|
+
it('composes with --raw: entries keep full bodies, filter still narrows', async () => {
|
|
1904
|
+
const program = createProgram('', '');
|
|
1905
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', 'author', '--raw']);
|
|
1906
|
+
const out = lastJsonLog();
|
|
1907
|
+
expect(out.count).toBe(1);
|
|
1908
|
+
expect(out.entries[0].body).toEqual({ data: { items: [{ author: 'a', text: 't', likes: 1 }] } });
|
|
1909
|
+
});
|
|
1910
|
+
it('reports invalid_filter for empty value', async () => {
|
|
1911
|
+
const program = createProgram('', '');
|
|
1912
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', '']);
|
|
1913
|
+
const out = lastJsonLog();
|
|
1914
|
+
expect(out.error.code).toBe('invalid_filter');
|
|
1915
|
+
expect(process.exitCode).toBeDefined();
|
|
1916
|
+
});
|
|
1917
|
+
it('reports invalid_filter for commas-only value', async () => {
|
|
1918
|
+
const program = createProgram('', '');
|
|
1919
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', ',,,']);
|
|
1920
|
+
const out = lastJsonLog();
|
|
1921
|
+
expect(out.error.code).toBe('invalid_filter');
|
|
1922
|
+
expect(process.exitCode).toBeDefined();
|
|
1923
|
+
});
|
|
1924
|
+
it('rejects --filter combined with --detail as invalid_args', async () => {
|
|
1925
|
+
const program = createProgram('', '');
|
|
1926
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--filter', 'author', '--detail', 'UserTweets']);
|
|
1927
|
+
const out = lastJsonLog();
|
|
1928
|
+
expect(out.error.code).toBe('invalid_args');
|
|
1929
|
+
expect(out.error.message).toContain('--filter');
|
|
1930
|
+
expect(out.error.message).toContain('--detail');
|
|
1931
|
+
expect(process.exitCode).toBeDefined();
|
|
1932
|
+
});
|
|
1933
|
+
});
|
|
1934
|
+
describe('body truncation signals', () => {
|
|
1935
|
+
it('flags body_truncated in list view when the capture layer capped the body', async () => {
|
|
1936
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
1937
|
+
{
|
|
1938
|
+
url: 'https://api.example.com/huge',
|
|
1939
|
+
method: 'GET',
|
|
1940
|
+
responseStatus: 200,
|
|
1941
|
+
responseContentType: 'application/json',
|
|
1942
|
+
responsePreview: '{"data":"x"}',
|
|
1943
|
+
responseBodyFullSize: 99_999_999,
|
|
1944
|
+
responseBodyTruncated: true,
|
|
1945
|
+
},
|
|
1946
|
+
]);
|
|
1947
|
+
const program = createProgram('', '');
|
|
1948
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
1949
|
+
const out = lastJsonLog();
|
|
1950
|
+
expect(out.body_truncated_count).toBe(1);
|
|
1951
|
+
expect(out.entries[0].body_truncated).toBe(true);
|
|
1952
|
+
expect(out.entries[0].size).toBe(99_999_999);
|
|
1953
|
+
});
|
|
1954
|
+
it('--detail surfaces body_truncated + body_full_size when capture had to cap the body', async () => {
|
|
1955
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
1956
|
+
{
|
|
1957
|
+
url: 'https://api.example.com/huge',
|
|
1958
|
+
method: 'GET',
|
|
1959
|
+
responseStatus: 200,
|
|
1960
|
+
responseContentType: 'application/json',
|
|
1961
|
+
responsePreview: 'truncated-prefix-not-valid-json',
|
|
1962
|
+
responseBodyFullSize: 50_000_000,
|
|
1963
|
+
responseBodyTruncated: true,
|
|
1964
|
+
},
|
|
1965
|
+
]);
|
|
1966
|
+
const program = createProgram('', '');
|
|
1967
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
1968
|
+
consoleLogSpy.mockClear();
|
|
1969
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--detail', 'GET api.example.com/huge']);
|
|
1970
|
+
const out = lastJsonLog();
|
|
1971
|
+
expect(out.body_truncated).toBe(true);
|
|
1972
|
+
expect(out.body_full_size).toBe(50_000_000);
|
|
1973
|
+
expect(out.body_truncation_reason).toBe('capture-limit');
|
|
1974
|
+
});
|
|
1975
|
+
it('--max-body caps the emitted body and marks body_truncation_reason = max-body', async () => {
|
|
1976
|
+
const longString = 'x'.repeat(5000);
|
|
1977
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
1978
|
+
{
|
|
1979
|
+
url: 'https://api.example.com/plain',
|
|
1980
|
+
method: 'GET',
|
|
1981
|
+
responseStatus: 200,
|
|
1982
|
+
responseContentType: 'text/plain',
|
|
1983
|
+
responsePreview: longString,
|
|
1984
|
+
},
|
|
1985
|
+
]);
|
|
1986
|
+
const program = createProgram('', '');
|
|
1987
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
1988
|
+
consoleLogSpy.mockClear();
|
|
1989
|
+
await program.parseAsync([
|
|
1990
|
+
'node', 'webcmd', 'browser', '--session', 'test', 'network',
|
|
1991
|
+
'--detail', 'GET api.example.com/plain',
|
|
1992
|
+
'--max-body', '100',
|
|
1993
|
+
]);
|
|
1994
|
+
const out = lastJsonLog();
|
|
1995
|
+
expect(typeof out.body).toBe('string');
|
|
1996
|
+
expect(out.body).toHaveLength(100);
|
|
1997
|
+
expect(out.body_truncated).toBe(true);
|
|
1998
|
+
expect(out.body_truncation_reason).toBe('max-body');
|
|
1999
|
+
expect(out.body_full_size).toBe(5000);
|
|
2000
|
+
});
|
|
2001
|
+
it('--max-body leaves parsed JSON bodies untouched (no mid-object cut)', async () => {
|
|
2002
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
2003
|
+
{
|
|
2004
|
+
url: 'https://api.example.com/json',
|
|
2005
|
+
method: 'GET',
|
|
2006
|
+
responseStatus: 200,
|
|
2007
|
+
responseContentType: 'application/json',
|
|
2008
|
+
responsePreview: JSON.stringify({ data: { user: { rest_id: 'u1' } } }),
|
|
2009
|
+
},
|
|
2010
|
+
]);
|
|
2011
|
+
const program = createProgram('', '');
|
|
2012
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
2013
|
+
consoleLogSpy.mockClear();
|
|
2014
|
+
await program.parseAsync([
|
|
2015
|
+
'node', 'webcmd', 'browser', '--session', 'test', 'network',
|
|
2016
|
+
'--detail', 'GET api.example.com/json',
|
|
2017
|
+
'--max-body', '10',
|
|
2018
|
+
]);
|
|
2019
|
+
const out = lastJsonLog();
|
|
2020
|
+
// JSON body already parsed at capture time — --max-body only applies to
|
|
2021
|
+
// string bodies (which is where the agent-visible hazard lives).
|
|
2022
|
+
expect(out.body).toEqual({ data: { user: { rest_id: 'u1' } } });
|
|
2023
|
+
expect(out).not.toHaveProperty('body_truncated');
|
|
2024
|
+
});
|
|
2025
|
+
it('rejects non-numeric --max-body with invalid_max_body', async () => {
|
|
2026
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
2027
|
+
{
|
|
2028
|
+
url: 'https://api.example.com/x',
|
|
2029
|
+
method: 'GET',
|
|
2030
|
+
responseStatus: 200,
|
|
2031
|
+
responseContentType: 'application/json',
|
|
2032
|
+
responsePreview: '{"a":1}',
|
|
2033
|
+
},
|
|
2034
|
+
]);
|
|
2035
|
+
const program = createProgram('', '');
|
|
2036
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network']);
|
|
2037
|
+
consoleLogSpy.mockClear();
|
|
2038
|
+
await program.parseAsync([
|
|
2039
|
+
'node', 'webcmd', 'browser', '--session', 'test', 'network',
|
|
2040
|
+
'--detail', 'GET api.example.com/x',
|
|
2041
|
+
'--max-body', 'abc',
|
|
2042
|
+
]);
|
|
2043
|
+
expect(lastJsonLog().error.code).toBe('invalid_max_body');
|
|
2044
|
+
expect(process.exitCode).toBeDefined();
|
|
2045
|
+
});
|
|
2046
|
+
it('--raw emits snake_case body_truncated / body_full_size, matching non-raw + detail', async () => {
|
|
2047
|
+
browserState.page.readNetworkCapture = vi.fn().mockResolvedValue([
|
|
2048
|
+
{
|
|
2049
|
+
url: 'https://api.example.com/huge',
|
|
2050
|
+
method: 'GET',
|
|
2051
|
+
responseStatus: 200,
|
|
2052
|
+
responseContentType: 'application/json',
|
|
2053
|
+
responsePreview: 'truncated-prefix',
|
|
2054
|
+
responseBodyFullSize: 20_000_000,
|
|
2055
|
+
responseBodyTruncated: true,
|
|
2056
|
+
},
|
|
2057
|
+
]);
|
|
2058
|
+
const program = createProgram('', '');
|
|
2059
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'network', '--raw']);
|
|
2060
|
+
const out = lastJsonLog();
|
|
2061
|
+
expect(out.entries).toHaveLength(1);
|
|
2062
|
+
const entry = out.entries[0];
|
|
2063
|
+
expect(entry.body_truncated).toBe(true);
|
|
2064
|
+
expect(entry.body_full_size).toBe(20_000_000);
|
|
2065
|
+
// Internal camelCase must not leak into the agent-facing envelope.
|
|
2066
|
+
expect(entry).not.toHaveProperty('bodyTruncated');
|
|
2067
|
+
expect(entry).not.toHaveProperty('bodyFullSize');
|
|
2068
|
+
});
|
|
2069
|
+
});
|
|
2070
|
+
});
|
|
2071
|
+
describe('browser console command', () => {
|
|
2072
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
2073
|
+
beforeEach(() => {
|
|
2074
|
+
process.exitCode = undefined;
|
|
2075
|
+
consoleLogSpy.mockClear();
|
|
2076
|
+
mockBrowserConnect.mockClear();
|
|
2077
|
+
mockBrowserClose.mockReset().mockResolvedValue(undefined);
|
|
2078
|
+
const now = Date.now();
|
|
2079
|
+
browserState.page = {
|
|
2080
|
+
session: 'test',
|
|
2081
|
+
setActivePage: vi.fn(),
|
|
2082
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
2083
|
+
tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]),
|
|
2084
|
+
consoleMessages: vi.fn().mockResolvedValue([
|
|
2085
|
+
{ type: 'error', text: 'boom', timestamp: now },
|
|
2086
|
+
{ type: 'log', text: 'ok', timestamp: now },
|
|
2087
|
+
{ type: 'warning', text: 'old warning', timestamp: now - 180_000 },
|
|
2088
|
+
]),
|
|
2089
|
+
};
|
|
2090
|
+
});
|
|
2091
|
+
function lastJsonLog() {
|
|
2092
|
+
const calls = consoleLogSpy.mock.calls;
|
|
2093
|
+
if (calls.length === 0)
|
|
2094
|
+
throw new Error('Expected at least one console.log call');
|
|
2095
|
+
const last = calls[calls.length - 1][0];
|
|
2096
|
+
if (typeof last !== 'string')
|
|
2097
|
+
throw new Error(`Expected string arg to console.log, got ${typeof last}`);
|
|
2098
|
+
return JSON.parse(last);
|
|
2099
|
+
}
|
|
2100
|
+
it('filters console messages by level and time window', async () => {
|
|
2101
|
+
const program = createProgram('', '');
|
|
2102
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'console', '--level', 'error', '--since', '120s']);
|
|
2103
|
+
const out = lastJsonLog();
|
|
2104
|
+
expect(out.count).toBe(1);
|
|
2105
|
+
expect(out.messages[0]).toMatchObject({ type: 'error', text: 'boom' });
|
|
2106
|
+
});
|
|
2107
|
+
});
|
|
2108
|
+
describe('browser get html command', () => {
|
|
2109
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
2110
|
+
function lastLogArg() {
|
|
2111
|
+
const calls = consoleLogSpy.mock.calls;
|
|
2112
|
+
if (calls.length === 0)
|
|
2113
|
+
throw new Error('expected console.log call');
|
|
2114
|
+
return calls[calls.length - 1][0];
|
|
2115
|
+
}
|
|
2116
|
+
function lastJsonLog() {
|
|
2117
|
+
const arg = lastLogArg();
|
|
2118
|
+
if (typeof arg !== 'string')
|
|
2119
|
+
throw new Error(`expected string arg, got ${typeof arg}`);
|
|
2120
|
+
return JSON.parse(arg);
|
|
2121
|
+
}
|
|
2122
|
+
beforeEach(() => {
|
|
2123
|
+
process.exitCode = undefined;
|
|
2124
|
+
process.env.WEBCMD_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-html-'));
|
|
2125
|
+
consoleLogSpy.mockClear();
|
|
2126
|
+
mockBrowserConnect.mockClear();
|
|
2127
|
+
mockBrowserClose.mockReset().mockResolvedValue(undefined);
|
|
2128
|
+
browserState.page = {
|
|
2129
|
+
setActivePage: vi.fn(),
|
|
2130
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
2131
|
+
tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]),
|
|
2132
|
+
evaluate: vi.fn(),
|
|
2133
|
+
};
|
|
2134
|
+
});
|
|
2135
|
+
it('returns full outerHTML by default with no truncation', async () => {
|
|
2136
|
+
const big = '<div>' + 'x'.repeat(100_000) + '</div>';
|
|
2137
|
+
browserState.page.evaluate.mockResolvedValueOnce({ kind: 'ok', html: big });
|
|
2138
|
+
const program = createProgram('', '');
|
|
2139
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html']);
|
|
2140
|
+
expect(lastLogArg()).toBe(big);
|
|
2141
|
+
});
|
|
2142
|
+
it('caps output with --max and prepends a visible truncation marker', async () => {
|
|
2143
|
+
const big = '<div>' + 'x'.repeat(500) + '</div>';
|
|
2144
|
+
browserState.page.evaluate.mockResolvedValueOnce({ kind: 'ok', html: big });
|
|
2145
|
+
const program = createProgram('', '');
|
|
2146
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--max', '100']);
|
|
2147
|
+
const out = String(lastLogArg());
|
|
2148
|
+
expect(out.startsWith('<!-- webcmd: truncated 100 of')).toBe(true);
|
|
2149
|
+
expect(out.length).toBeGreaterThan(100);
|
|
2150
|
+
expect(out.length).toBeLessThan(big.length);
|
|
2151
|
+
});
|
|
2152
|
+
it('rejects negative --max with invalid_max error', async () => {
|
|
2153
|
+
const program = createProgram('', '');
|
|
2154
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--max', '-1']);
|
|
2155
|
+
expect(lastJsonLog().error.code).toBe('invalid_max');
|
|
2156
|
+
expect(process.exitCode).toBeDefined();
|
|
2157
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
2158
|
+
});
|
|
2159
|
+
it('rejects fractional --max with invalid_max error', async () => {
|
|
2160
|
+
const program = createProgram('', '');
|
|
2161
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--max', '1.5']);
|
|
2162
|
+
expect(lastJsonLog().error.code).toBe('invalid_max');
|
|
2163
|
+
expect(process.exitCode).toBeDefined();
|
|
2164
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
2165
|
+
});
|
|
2166
|
+
it('rejects non-numeric --max (e.g. "10abc") with invalid_max error', async () => {
|
|
2167
|
+
const program = createProgram('', '');
|
|
2168
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--max', '10abc']);
|
|
2169
|
+
expect(lastJsonLog().error.code).toBe('invalid_max');
|
|
2170
|
+
expect(process.exitCode).toBeDefined();
|
|
2171
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
2172
|
+
});
|
|
2173
|
+
it('--as json returns structured tree envelope', async () => {
|
|
2174
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2175
|
+
selector: '.hero',
|
|
2176
|
+
matched: 1,
|
|
2177
|
+
tree: { tag: 'div', attrs: { class: 'hero' }, text: 'Hi', children: [] },
|
|
2178
|
+
});
|
|
2179
|
+
const program = createProgram('', '');
|
|
2180
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--selector', '.hero', '--as', 'json']);
|
|
2181
|
+
const out = lastJsonLog();
|
|
2182
|
+
expect(out.matched).toBe(1);
|
|
2183
|
+
expect(out.tree.tag).toBe('div');
|
|
2184
|
+
expect(out.tree.attrs.class).toBe('hero');
|
|
2185
|
+
});
|
|
2186
|
+
it('--as json emits selector_not_found when matched is 0', async () => {
|
|
2187
|
+
browserState.page.evaluate.mockResolvedValueOnce({ selector: '.missing', matched: 0, tree: null });
|
|
2188
|
+
const program = createProgram('', '');
|
|
2189
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--selector', '.missing', '--as', 'json']);
|
|
2190
|
+
expect(lastJsonLog().error.code).toBe('selector_not_found');
|
|
2191
|
+
expect(process.exitCode).toBeDefined();
|
|
2192
|
+
});
|
|
2193
|
+
it('raw mode emits selector_not_found when the selector matches nothing', async () => {
|
|
2194
|
+
browserState.page.evaluate.mockResolvedValueOnce({ kind: 'ok', html: null });
|
|
2195
|
+
const program = createProgram('', '');
|
|
2196
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--selector', '.missing']);
|
|
2197
|
+
expect(lastJsonLog().error.code).toBe('selector_not_found');
|
|
2198
|
+
expect(process.exitCode).toBeDefined();
|
|
2199
|
+
});
|
|
2200
|
+
it('raw mode emits invalid_selector when the page rejects the selector syntax', async () => {
|
|
2201
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2202
|
+
kind: 'invalid_selector',
|
|
2203
|
+
reason: "'##$@@' is not a valid selector",
|
|
2204
|
+
});
|
|
2205
|
+
const program = createProgram('', '');
|
|
2206
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--selector', '##$@@']);
|
|
2207
|
+
const err = lastJsonLog().error;
|
|
2208
|
+
expect(err.code).toBe('invalid_selector');
|
|
2209
|
+
expect(err.message).toContain('##$@@');
|
|
2210
|
+
expect(err.message).toContain('not a valid selector');
|
|
2211
|
+
expect(process.exitCode).toBeDefined();
|
|
2212
|
+
});
|
|
2213
|
+
it('--as json emits invalid_selector when the page rejects the selector syntax', async () => {
|
|
2214
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2215
|
+
selector: '##$@@',
|
|
2216
|
+
invalidSelector: true,
|
|
2217
|
+
reason: "'##$@@' is not a valid selector",
|
|
2218
|
+
});
|
|
2219
|
+
const program = createProgram('', '');
|
|
2220
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--selector', '##$@@', '--as', 'json']);
|
|
2221
|
+
const err = lastJsonLog().error;
|
|
2222
|
+
expect(err.code).toBe('invalid_selector');
|
|
2223
|
+
expect(err.message).toContain('##$@@');
|
|
2224
|
+
expect(process.exitCode).toBeDefined();
|
|
2225
|
+
});
|
|
2226
|
+
it('rejects unknown --as format with invalid_format error', async () => {
|
|
2227
|
+
const program = createProgram('', '');
|
|
2228
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'html', '--as', 'yaml']);
|
|
2229
|
+
expect(lastJsonLog().error.code).toBe('invalid_format');
|
|
2230
|
+
expect(process.exitCode).toBeDefined();
|
|
2231
|
+
});
|
|
2232
|
+
});
|
|
2233
|
+
// Shared helper for the selector-first describe blocks below.
|
|
2234
|
+
// Each block spies console.log, mocks the IPage surface it touches, and
|
|
2235
|
+
// parses the last stringified call to inspect the JSON envelope — the
|
|
2236
|
+
// canonical agent-facing contract for the selector-first commands.
|
|
2237
|
+
function installSelectorFirstTestHarness(label, pageOverrides) {
|
|
2238
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
2239
|
+
function lastLogArg() {
|
|
2240
|
+
const calls = consoleLogSpy.mock.calls;
|
|
2241
|
+
if (calls.length === 0)
|
|
2242
|
+
throw new Error('expected console.log call');
|
|
2243
|
+
return calls[calls.length - 1][0];
|
|
2244
|
+
}
|
|
2245
|
+
function lastJsonLog() {
|
|
2246
|
+
const arg = lastLogArg();
|
|
2247
|
+
if (typeof arg !== 'string')
|
|
2248
|
+
throw new Error(`expected string arg, got ${typeof arg}`);
|
|
2249
|
+
return JSON.parse(arg);
|
|
2250
|
+
}
|
|
2251
|
+
beforeEach(() => {
|
|
2252
|
+
process.exitCode = undefined;
|
|
2253
|
+
process.env.WEBCMD_CACHE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), `webcmd-${label}-`));
|
|
2254
|
+
consoleLogSpy.mockClear();
|
|
2255
|
+
mockBrowserConnect.mockClear();
|
|
2256
|
+
mockBrowserClose.mockReset().mockResolvedValue(undefined);
|
|
2257
|
+
browserState.page = {
|
|
2258
|
+
setActivePage: vi.fn(),
|
|
2259
|
+
getActivePage: vi.fn().mockReturnValue('tab-1'),
|
|
2260
|
+
tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]),
|
|
2261
|
+
session: 'test',
|
|
2262
|
+
...pageOverrides(),
|
|
2263
|
+
};
|
|
2264
|
+
});
|
|
2265
|
+
return { lastJsonLog };
|
|
2266
|
+
}
|
|
2267
|
+
describe('browser find command', () => {
|
|
2268
|
+
const { lastJsonLog } = installSelectorFirstTestHarness('find', () => ({
|
|
2269
|
+
evaluate: vi.fn(),
|
|
2270
|
+
}));
|
|
2271
|
+
it('returns a {matches_n, entries} envelope for a matching selector', async () => {
|
|
2272
|
+
// `find` always returns numeric refs (existing on snapshot-tagged elements,
|
|
2273
|
+
// allocated on the spot for fresh matches) — see reviewer contract in
|
|
2274
|
+
// #webcmd-browser msg 52c51eb6.
|
|
2275
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2276
|
+
matches_n: 2,
|
|
2277
|
+
entries: [
|
|
2278
|
+
{ nth: 0, ref: 5, tag: 'button', role: '', text: 'OK', attrs: { class: 'btn' }, visible: true },
|
|
2279
|
+
{ nth: 1, ref: 17, tag: 'button', role: '', text: 'Cancel', attrs: { class: 'btn' }, visible: true },
|
|
2280
|
+
],
|
|
2281
|
+
});
|
|
2282
|
+
const program = createProgram('', '');
|
|
2283
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'find', '--css', '.btn']);
|
|
2284
|
+
const out = lastJsonLog();
|
|
2285
|
+
expect(out.matches_n).toBe(2);
|
|
2286
|
+
expect(out.entries).toHaveLength(2);
|
|
2287
|
+
expect(out.entries[0].ref).toBe(5);
|
|
2288
|
+
expect(out.entries[1].ref).toBe(17);
|
|
2289
|
+
expect(process.exitCode).toBeUndefined();
|
|
2290
|
+
});
|
|
2291
|
+
it('finds elements by semantic role/name without requiring CSS', async () => {
|
|
2292
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2293
|
+
matches_n: 1,
|
|
2294
|
+
entries: [
|
|
2295
|
+
{ nth: 0, ref: 9, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
|
|
2296
|
+
],
|
|
2297
|
+
});
|
|
2298
|
+
const program = createProgram('', '');
|
|
2299
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'find', '--role', 'button', '--name', 'Save']);
|
|
2300
|
+
const js = browserState.page.evaluate.mock.calls[0][0];
|
|
2301
|
+
expect(js).toContain('CRITERIA');
|
|
2302
|
+
expect(js).toContain('function accessibleName');
|
|
2303
|
+
expect(lastJsonLog()).toEqual({
|
|
2304
|
+
matches_n: 1,
|
|
2305
|
+
entries: [
|
|
2306
|
+
{ nth: 0, ref: 9, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
|
|
2307
|
+
],
|
|
2308
|
+
});
|
|
2309
|
+
});
|
|
2310
|
+
it('forwards --limit / --text-max into the generated JS', async () => {
|
|
2311
|
+
browserState.page.evaluate.mockResolvedValueOnce({ matches_n: 0, entries: [] });
|
|
2312
|
+
const program = createProgram('', '');
|
|
2313
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'find', '--css', '.btn', '--limit', '3', '--text-max', '20']);
|
|
2314
|
+
const js = browserState.page.evaluate.mock.calls[0][0];
|
|
2315
|
+
expect(js).toContain('LIMIT = 3');
|
|
2316
|
+
expect(js).toContain('TEXT_MAX = 20');
|
|
2317
|
+
});
|
|
2318
|
+
it('emits invalid_selector envelope when the page rejects selector syntax', async () => {
|
|
2319
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2320
|
+
error: { code: 'invalid_selector', message: 'Invalid CSS selector: ">>>"', hint: 'Check the selector syntax.' },
|
|
2321
|
+
});
|
|
2322
|
+
const program = createProgram('', '');
|
|
2323
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'find', '--css', '>>>']);
|
|
2324
|
+
expect(lastJsonLog().error.code).toBe('invalid_selector');
|
|
2325
|
+
expect(process.exitCode).toBeDefined();
|
|
2326
|
+
});
|
|
2327
|
+
it('emits selector_not_found envelope when the selector matches nothing', async () => {
|
|
2328
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2329
|
+
error: { code: 'selector_not_found', message: 'CSS selector ".missing" matched 0 elements', hint: 'Use browser state to inspect the page.' },
|
|
2330
|
+
});
|
|
2331
|
+
const program = createProgram('', '');
|
|
2332
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'find', '--css', '.missing']);
|
|
2333
|
+
expect(lastJsonLog().error.code).toBe('selector_not_found');
|
|
2334
|
+
expect(process.exitCode).toBeDefined();
|
|
2335
|
+
});
|
|
2336
|
+
it('rejects missing --css with usage_error (no evaluate call)', async () => {
|
|
2337
|
+
const program = createProgram('', '');
|
|
2338
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'find']);
|
|
2339
|
+
expect(lastJsonLog().error.code).toBe('usage_error');
|
|
2340
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
2341
|
+
expect(process.exitCode).toBeDefined();
|
|
2342
|
+
});
|
|
2343
|
+
it('rejects malformed --limit with usage_error (no evaluate call)', async () => {
|
|
2344
|
+
const program = createProgram('', '');
|
|
2345
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'find', '--css', '.btn', '--limit', 'abc']);
|
|
2346
|
+
expect(lastJsonLog().error.code).toBe('usage_error');
|
|
2347
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
2348
|
+
expect(process.exitCode).toBeDefined();
|
|
2349
|
+
});
|
|
2350
|
+
});
|
|
2351
|
+
describe('browser get text/value/attributes commands', () => {
|
|
2352
|
+
const { lastJsonLog } = installSelectorFirstTestHarness('get-sel', () => ({
|
|
2353
|
+
evaluate: vi.fn(),
|
|
2354
|
+
}));
|
|
2355
|
+
it('emits {value, matches_n, match_level} envelope for a numeric ref', async () => {
|
|
2356
|
+
const evalMock = browserState.page.evaluate;
|
|
2357
|
+
// 1st call: resolveTargetJs -> { ok: true, matches_n: 1, match_level: 'exact' }
|
|
2358
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
2359
|
+
// 2nd call: getTextResolvedJs -> the element's text
|
|
2360
|
+
evalMock.mockResolvedValueOnce('Hello world');
|
|
2361
|
+
const program = createProgram('', '');
|
|
2362
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'text', '7']);
|
|
2363
|
+
expect(lastJsonLog()).toEqual({ value: 'Hello world', matches_n: 1, match_level: 'exact' });
|
|
2364
|
+
});
|
|
2365
|
+
it('resolves a semantic locator to a ref before get text', async () => {
|
|
2366
|
+
const evalMock = browserState.page.evaluate;
|
|
2367
|
+
evalMock.mockResolvedValueOnce({
|
|
2368
|
+
matches_n: 1,
|
|
2369
|
+
entries: [
|
|
2370
|
+
{ nth: 0, ref: 12, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
|
|
2371
|
+
],
|
|
2372
|
+
});
|
|
2373
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
2374
|
+
evalMock.mockResolvedValueOnce('Save');
|
|
2375
|
+
const program = createProgram('', '');
|
|
2376
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'text', '--role', 'button', '--name', 'Save']);
|
|
2377
|
+
expect(evalMock.mock.calls[0][0]).toContain('function accessibleName');
|
|
2378
|
+
expect(evalMock.mock.calls[1][0]).toContain('const ref = "12"');
|
|
2379
|
+
expect(lastJsonLog()).toEqual({ value: 'Save', matches_n: 1, match_level: 'exact' });
|
|
2380
|
+
});
|
|
2381
|
+
it('reports total_matches when semantic get reads the first of multiple matches', async () => {
|
|
2382
|
+
const evalMock = browserState.page.evaluate;
|
|
2383
|
+
evalMock.mockResolvedValueOnce({
|
|
2384
|
+
matches_n: 3,
|
|
2385
|
+
entries: [
|
|
2386
|
+
{ nth: 0, ref: 12, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
|
|
2387
|
+
{ nth: 1, ref: 13, tag: 'button', role: 'button', text: 'Save draft', attrs: {}, visible: true },
|
|
2388
|
+
{ nth: 2, ref: 14, tag: 'button', role: 'button', text: 'Save copy', attrs: {}, visible: true },
|
|
2389
|
+
],
|
|
2390
|
+
});
|
|
2391
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
2392
|
+
evalMock.mockResolvedValueOnce('Save');
|
|
2393
|
+
const program = createProgram('', '');
|
|
2394
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'text', '--role', 'button', '--name', 'Save']);
|
|
2395
|
+
expect(evalMock.mock.calls[0][0]).toContain('const LIMIT = 6');
|
|
2396
|
+
expect(evalMock.mock.calls[1][0]).toContain('const ref = "12"');
|
|
2397
|
+
expect(lastJsonLog()).toEqual({ value: 'Save', matches_n: 1, match_level: 'exact', total_matches: 3 });
|
|
2398
|
+
});
|
|
2399
|
+
it('reports matches_n on multi-match CSS (read path: first match wins)', async () => {
|
|
2400
|
+
const evalMock = browserState.page.evaluate;
|
|
2401
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 3, match_level: 'exact' });
|
|
2402
|
+
evalMock.mockResolvedValueOnce('first');
|
|
2403
|
+
const program = createProgram('', '');
|
|
2404
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'text', '.btn']);
|
|
2405
|
+
expect(lastJsonLog()).toEqual({ value: 'first', matches_n: 3, match_level: 'exact' });
|
|
2406
|
+
});
|
|
2407
|
+
it('parses the attributes payload back into a real object', async () => {
|
|
2408
|
+
const evalMock = browserState.page.evaluate;
|
|
2409
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
2410
|
+
// getAttributesResolvedJs returns a JSON-encoded string — the CLI must parse it
|
|
2411
|
+
evalMock.mockResolvedValueOnce(JSON.stringify({ id: 'nav', class: 'hero' }));
|
|
2412
|
+
const program = createProgram('', '');
|
|
2413
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'attributes', '#nav']);
|
|
2414
|
+
const out = lastJsonLog();
|
|
2415
|
+
expect(out.matches_n).toBe(1);
|
|
2416
|
+
expect(out.match_level).toBe('exact');
|
|
2417
|
+
expect(out.value).toEqual({ id: 'nav', class: 'hero' });
|
|
2418
|
+
});
|
|
2419
|
+
it('propagates selector_not_found from the resolver as an error envelope', async () => {
|
|
2420
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2421
|
+
ok: false,
|
|
2422
|
+
code: 'selector_not_found',
|
|
2423
|
+
message: 'CSS selector ".missing" matched 0 elements',
|
|
2424
|
+
hint: 'Try a less specific selector.',
|
|
2425
|
+
});
|
|
2426
|
+
const program = createProgram('', '');
|
|
2427
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'text', '.missing']);
|
|
2428
|
+
expect(lastJsonLog().error.code).toBe('selector_not_found');
|
|
2429
|
+
expect(process.exitCode).toBeDefined();
|
|
2430
|
+
});
|
|
2431
|
+
it('forwards --nth into the resolver opts and reports matches_n', async () => {
|
|
2432
|
+
const evalMock = browserState.page.evaluate;
|
|
2433
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 4, match_level: 'exact' });
|
|
2434
|
+
evalMock.mockResolvedValueOnce('second');
|
|
2435
|
+
const program = createProgram('', '');
|
|
2436
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'value', '.btn', '--nth', '1']);
|
|
2437
|
+
const resolveJs = evalMock.mock.calls[0][0];
|
|
2438
|
+
// resolveTargetJs embeds nth as a raw number literal; look for the binding
|
|
2439
|
+
expect(resolveJs).toContain('const nth = 1');
|
|
2440
|
+
expect(lastJsonLog()).toEqual({ value: 'second', matches_n: 4, match_level: 'exact' });
|
|
2441
|
+
});
|
|
2442
|
+
it('rejects malformed --nth with usage_error before touching the page', async () => {
|
|
2443
|
+
const program = createProgram('', '');
|
|
2444
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'get', 'text', '.btn', '--nth', 'abc']);
|
|
2445
|
+
expect(lastJsonLog().error.code).toBe('usage_error');
|
|
2446
|
+
expect(browserState.page.evaluate).not.toHaveBeenCalled();
|
|
2447
|
+
expect(process.exitCode).toBeDefined();
|
|
2448
|
+
});
|
|
2449
|
+
});
|
|
2450
|
+
describe('browser click/type commands', () => {
|
|
2451
|
+
const { lastJsonLog } = installSelectorFirstTestHarness('click-type', () => ({
|
|
2452
|
+
evaluate: vi.fn().mockResolvedValue(false),
|
|
2453
|
+
click: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
|
|
2454
|
+
dblClick: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
|
|
2455
|
+
hover: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
|
|
2456
|
+
focus: vi.fn().mockResolvedValue({ focused: true, matches_n: 1, match_level: 'exact' }),
|
|
2457
|
+
setChecked: vi.fn().mockResolvedValue({ checked: true, changed: true, matches_n: 1, match_level: 'exact', kind: 'checkbox' }),
|
|
2458
|
+
uploadFiles: vi.fn().mockResolvedValue({
|
|
2459
|
+
uploaded: true,
|
|
2460
|
+
files: 1,
|
|
2461
|
+
file_names: ['receipt.pdf'],
|
|
2462
|
+
target: '#file',
|
|
2463
|
+
matches_n: 1,
|
|
2464
|
+
match_level: 'exact',
|
|
2465
|
+
multiple: false,
|
|
2466
|
+
}),
|
|
2467
|
+
drag: vi.fn().mockResolvedValue({
|
|
2468
|
+
dragged: true,
|
|
2469
|
+
source: '#card',
|
|
2470
|
+
target: '#lane',
|
|
2471
|
+
source_matches_n: 1,
|
|
2472
|
+
target_matches_n: 1,
|
|
2473
|
+
source_match_level: 'exact',
|
|
2474
|
+
target_match_level: 'exact',
|
|
2475
|
+
}),
|
|
2476
|
+
typeText: vi.fn().mockResolvedValue({ matches_n: 1, match_level: 'exact' }),
|
|
2477
|
+
fillText: vi.fn().mockResolvedValue({
|
|
2478
|
+
filled: true,
|
|
2479
|
+
verified: true,
|
|
2480
|
+
expected: '',
|
|
2481
|
+
actual: '',
|
|
2482
|
+
length: 0,
|
|
2483
|
+
matches_n: 1,
|
|
2484
|
+
match_level: 'exact',
|
|
2485
|
+
mode: 'input',
|
|
2486
|
+
}),
|
|
2487
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
2488
|
+
}));
|
|
2489
|
+
it('emits {clicked, target, matches_n, match_level} on success', async () => {
|
|
2490
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
2491
|
+
const program = createProgram('', '');
|
|
2492
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'click', '#save']);
|
|
2493
|
+
expect(browserState.page.click).toHaveBeenCalledWith('#save', {});
|
|
2494
|
+
expect(lastJsonLog()).toEqual({ clicked: true, target: '#save', matches_n: 1, match_level: 'exact' });
|
|
2495
|
+
});
|
|
2496
|
+
it('clicks a unique semantic locator without a prior state call', async () => {
|
|
2497
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2498
|
+
matches_n: 1,
|
|
2499
|
+
entries: [
|
|
2500
|
+
{ nth: 0, ref: 23, tag: 'button', role: 'button', text: 'Submit', attrs: {}, visible: true },
|
|
2501
|
+
],
|
|
2502
|
+
});
|
|
2503
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
2504
|
+
const program = createProgram('', '');
|
|
2505
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'click', '--role', 'button', '--name', 'Submit']);
|
|
2506
|
+
expect(browserState.page.click).toHaveBeenCalledWith('23', {});
|
|
2507
|
+
expect(lastJsonLog()).toEqual({ clicked: true, target: '23', matches_n: 1, match_level: 'exact' });
|
|
2508
|
+
});
|
|
2509
|
+
it('rejects ambiguous semantic locators before write actions', async () => {
|
|
2510
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2511
|
+
matches_n: 2,
|
|
2512
|
+
entries: [
|
|
2513
|
+
{ nth: 0, ref: 1, tag: 'button', role: 'button', text: 'Save', attrs: {}, visible: true },
|
|
2514
|
+
{ nth: 1, ref: 2, tag: 'button', role: 'button', text: 'Save draft', attrs: {}, visible: true },
|
|
2515
|
+
],
|
|
2516
|
+
});
|
|
2517
|
+
const program = createProgram('', '');
|
|
2518
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'click', '--role', 'button', '--name', 'Save']);
|
|
2519
|
+
const err = lastJsonLog().error;
|
|
2520
|
+
expect(err.code).toBe('semantic_ambiguous');
|
|
2521
|
+
expect(err.matches_n).toBe(2);
|
|
2522
|
+
expect(browserState.page.click).not.toHaveBeenCalled();
|
|
2523
|
+
expect(process.exitCode).toBeDefined();
|
|
2524
|
+
});
|
|
2525
|
+
it('hover: resolves a semantic locator before moving the mouse', async () => {
|
|
2526
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2527
|
+
matches_n: 1,
|
|
2528
|
+
entries: [
|
|
2529
|
+
{ nth: 0, ref: 31, tag: 'button', role: 'button', text: 'Settings', attrs: {}, visible: true },
|
|
2530
|
+
],
|
|
2531
|
+
});
|
|
2532
|
+
const program = createProgram('', '');
|
|
2533
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'hover', '--role', 'button', '--name', 'Settings']);
|
|
2534
|
+
expect(browserState.page.hover).toHaveBeenCalledWith('31', {});
|
|
2535
|
+
expect(lastJsonLog()).toEqual({ hovered: true, target: '31', matches_n: 1, match_level: 'exact' });
|
|
2536
|
+
});
|
|
2537
|
+
it('check: resolves a semantic locator before setting checked state', async () => {
|
|
2538
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2539
|
+
matches_n: 1,
|
|
2540
|
+
entries: [
|
|
2541
|
+
{ nth: 0, ref: 32, tag: 'input', role: 'checkbox', text: 'Accept', attrs: {}, visible: true },
|
|
2542
|
+
],
|
|
2543
|
+
});
|
|
2544
|
+
browserState.page.setChecked.mockResolvedValueOnce({ checked: true, changed: false, matches_n: 1, match_level: 'exact', kind: 'checkbox' });
|
|
2545
|
+
const program = createProgram('', '');
|
|
2546
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'check', '--role', 'checkbox', '--name', 'Accept']);
|
|
2547
|
+
expect(browserState.page.setChecked).toHaveBeenCalledWith('32', true, {});
|
|
2548
|
+
expect(lastJsonLog()).toEqual({ checked: true, changed: false, target: '32', matches_n: 1, match_level: 'exact', kind: 'checkbox' });
|
|
2549
|
+
});
|
|
2550
|
+
it('upload: treats the first positional as a file when using semantic locator flags', async () => {
|
|
2551
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-upload-semantic-'));
|
|
2552
|
+
const file = path.join(dir, 'receipt.pdf');
|
|
2553
|
+
fs.writeFileSync(file, 'pdf');
|
|
2554
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2555
|
+
matches_n: 1,
|
|
2556
|
+
entries: [
|
|
2557
|
+
{ nth: 0, ref: 33, tag: 'input', role: 'button', text: 'Upload receipt', attrs: {}, visible: true },
|
|
2558
|
+
],
|
|
2559
|
+
});
|
|
2560
|
+
browserState.page.uploadFiles.mockResolvedValueOnce({
|
|
2561
|
+
uploaded: true,
|
|
2562
|
+
files: 1,
|
|
2563
|
+
file_names: ['receipt.pdf'],
|
|
2564
|
+
target: '33',
|
|
2565
|
+
matches_n: 1,
|
|
2566
|
+
match_level: 'exact',
|
|
2567
|
+
multiple: false,
|
|
2568
|
+
});
|
|
2569
|
+
const program = createProgram('', '');
|
|
2570
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'upload', '--role', 'button', '--name', 'Upload receipt', file]);
|
|
2571
|
+
expect(browserState.page.uploadFiles).toHaveBeenCalledWith('33', [file], {});
|
|
2572
|
+
expect(lastJsonLog()).toMatchObject({ uploaded: true, target: '33', files: 1 });
|
|
2573
|
+
});
|
|
2574
|
+
it('type: treats the first positional as text when using semantic locator flags', async () => {
|
|
2575
|
+
browserState.page.evaluate
|
|
2576
|
+
.mockResolvedValueOnce({
|
|
2577
|
+
matches_n: 1,
|
|
2578
|
+
entries: [
|
|
2579
|
+
{ nth: 0, ref: 34, tag: 'input', role: 'textbox', text: '', attrs: {}, visible: true },
|
|
2580
|
+
],
|
|
2581
|
+
})
|
|
2582
|
+
.mockResolvedValueOnce(false);
|
|
2583
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
2584
|
+
browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
2585
|
+
const program = createProgram('', '');
|
|
2586
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'type', '--label', 'Email', 'me@example.com']);
|
|
2587
|
+
expect(browserState.page.click).toHaveBeenCalledWith('34', {});
|
|
2588
|
+
expect(browserState.page.typeText).toHaveBeenCalledWith('34', 'me@example.com', {});
|
|
2589
|
+
expect(lastJsonLog()).toMatchObject({ typed: true, target: '34', text: 'me@example.com' });
|
|
2590
|
+
});
|
|
2591
|
+
it('fill: treats the first positional as text when using semantic locator flags', async () => {
|
|
2592
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2593
|
+
matches_n: 1,
|
|
2594
|
+
entries: [
|
|
2595
|
+
{ nth: 0, ref: 35, tag: 'input', role: 'textbox', text: '', attrs: {}, visible: true },
|
|
2596
|
+
],
|
|
2597
|
+
});
|
|
2598
|
+
browserState.page.fillText.mockResolvedValueOnce({
|
|
2599
|
+
filled: true,
|
|
2600
|
+
verified: true,
|
|
2601
|
+
expected: 'me@example.com',
|
|
2602
|
+
actual: 'me@example.com',
|
|
2603
|
+
length: 14,
|
|
2604
|
+
matches_n: 1,
|
|
2605
|
+
match_level: 'exact',
|
|
2606
|
+
});
|
|
2607
|
+
const program = createProgram('', '');
|
|
2608
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'fill', '--label', 'Email', 'me@example.com']);
|
|
2609
|
+
expect(browserState.page.fillText).toHaveBeenCalledWith('35', 'me@example.com', {});
|
|
2610
|
+
expect(lastJsonLog()).toMatchObject({ filled: true, verified: true, target: '35', text: 'me@example.com' });
|
|
2611
|
+
});
|
|
2612
|
+
it('drag: resolves source and target from prefixed semantic locators', async () => {
|
|
2613
|
+
browserState.page.evaluate
|
|
2614
|
+
.mockResolvedValueOnce({
|
|
2615
|
+
matches_n: 1,
|
|
2616
|
+
entries: [
|
|
2617
|
+
{ nth: 0, ref: 40, tag: 'div', role: 'button', text: 'Card A', attrs: {}, visible: true },
|
|
2618
|
+
],
|
|
2619
|
+
})
|
|
2620
|
+
.mockResolvedValueOnce({
|
|
2621
|
+
matches_n: 1,
|
|
2622
|
+
entries: [
|
|
2623
|
+
{ nth: 0, ref: 41, tag: 'div', role: 'region', text: 'Done', attrs: {}, visible: true },
|
|
2624
|
+
],
|
|
2625
|
+
});
|
|
2626
|
+
browserState.page.drag.mockResolvedValueOnce({
|
|
2627
|
+
dragged: true,
|
|
2628
|
+
source: '40',
|
|
2629
|
+
target: '41',
|
|
2630
|
+
source_matches_n: 1,
|
|
2631
|
+
target_matches_n: 1,
|
|
2632
|
+
source_match_level: 'exact',
|
|
2633
|
+
target_match_level: 'exact',
|
|
2634
|
+
});
|
|
2635
|
+
const program = createProgram('', '');
|
|
2636
|
+
await program.parseAsync([
|
|
2637
|
+
'node',
|
|
2638
|
+
'webcmd',
|
|
2639
|
+
'browser',
|
|
2640
|
+
'--session',
|
|
2641
|
+
'test',
|
|
2642
|
+
'drag',
|
|
2643
|
+
'--from-role',
|
|
2644
|
+
'button',
|
|
2645
|
+
'--from-name',
|
|
2646
|
+
'Card A',
|
|
2647
|
+
'--to-role',
|
|
2648
|
+
'region',
|
|
2649
|
+
'--to-name',
|
|
2650
|
+
'Done',
|
|
2651
|
+
]);
|
|
2652
|
+
expect(browserState.page.drag).toHaveBeenCalledWith('40', '41', { from: {}, to: {} });
|
|
2653
|
+
expect(lastJsonLog()).toMatchObject({ dragged: true, source: '40', target: '41' });
|
|
2654
|
+
});
|
|
2655
|
+
it('surfaces match_level=stable when resolver falls back to fingerprint match', async () => {
|
|
2656
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'stable' });
|
|
2657
|
+
const program = createProgram('', '');
|
|
2658
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'click', '7']);
|
|
2659
|
+
expect(lastJsonLog()).toEqual({ clicked: true, target: '7', matches_n: 1, match_level: 'stable' });
|
|
2660
|
+
});
|
|
2661
|
+
it('forwards --nth as ResolveOptions.nth to page.click', async () => {
|
|
2662
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 3, match_level: 'exact' });
|
|
2663
|
+
const program = createProgram('', '');
|
|
2664
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'click', '.btn', '--nth', '2']);
|
|
2665
|
+
expect(browserState.page.click).toHaveBeenCalledWith('.btn', { nth: 2 });
|
|
2666
|
+
expect(lastJsonLog()).toEqual({ clicked: true, target: '.btn', matches_n: 3, match_level: 'exact' });
|
|
2667
|
+
});
|
|
2668
|
+
it('surfaces selector_ambiguous from page.click as an error envelope', async () => {
|
|
2669
|
+
browserState.page.click.mockRejectedValueOnce(new TargetError({
|
|
2670
|
+
code: 'selector_ambiguous',
|
|
2671
|
+
message: 'CSS selector ".btn" matched 3 elements; clicks require a unique target.',
|
|
2672
|
+
hint: 'Pass --nth <n> to pick one (0-based).',
|
|
2673
|
+
matches_n: 3,
|
|
2674
|
+
}));
|
|
2675
|
+
const program = createProgram('', '');
|
|
2676
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'click', '.btn']);
|
|
2677
|
+
const err = lastJsonLog().error;
|
|
2678
|
+
expect(err.code).toBe('selector_ambiguous');
|
|
2679
|
+
expect(err.matches_n).toBe(3);
|
|
2680
|
+
expect(process.exitCode).toBeDefined();
|
|
2681
|
+
});
|
|
2682
|
+
it('surfaces selector_nth_out_of_range from page.click as an error envelope', async () => {
|
|
2683
|
+
browserState.page.click.mockRejectedValueOnce(new TargetError({
|
|
2684
|
+
code: 'selector_nth_out_of_range',
|
|
2685
|
+
message: '--nth 99 is out of range for CSS selector ".btn" (matches_n=3).',
|
|
2686
|
+
hint: 'Pick an index in [0, 2].',
|
|
2687
|
+
matches_n: 3,
|
|
2688
|
+
}));
|
|
2689
|
+
const program = createProgram('', '');
|
|
2690
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'click', '.btn', '--nth', '99']);
|
|
2691
|
+
expect(lastJsonLog().error.code).toBe('selector_nth_out_of_range');
|
|
2692
|
+
expect(process.exitCode).toBeDefined();
|
|
2693
|
+
});
|
|
2694
|
+
it('rejects malformed --nth on click with usage_error before touching the page', async () => {
|
|
2695
|
+
const program = createProgram('', '');
|
|
2696
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'click', '.btn', '--nth', 'abc']);
|
|
2697
|
+
expect(lastJsonLog().error.code).toBe('usage_error');
|
|
2698
|
+
expect(browserState.page.click).not.toHaveBeenCalled();
|
|
2699
|
+
expect(process.exitCode).toBeDefined();
|
|
2700
|
+
});
|
|
2701
|
+
it('hover: delegates to page.hover and emits a structured envelope', async () => {
|
|
2702
|
+
browserState.page.hover.mockResolvedValueOnce({ matches_n: 2, match_level: 'exact' });
|
|
2703
|
+
const program = createProgram('', '');
|
|
2704
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'hover', '.menu', '--nth', '1']);
|
|
2705
|
+
expect(browserState.page.hover).toHaveBeenCalledWith('.menu', { nth: 1 });
|
|
2706
|
+
expect(lastJsonLog()).toEqual({ hovered: true, target: '.menu', matches_n: 2, match_level: 'exact' });
|
|
2707
|
+
});
|
|
2708
|
+
it('focus: delegates to page.focus and reports whether the element took focus', async () => {
|
|
2709
|
+
browserState.page.focus.mockResolvedValueOnce({ focused: true, matches_n: 1, match_level: 'stable' });
|
|
2710
|
+
const program = createProgram('', '');
|
|
2711
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'focus', '7']);
|
|
2712
|
+
expect(browserState.page.focus).toHaveBeenCalledWith('7', {});
|
|
2713
|
+
expect(lastJsonLog()).toEqual({ focused: true, target: '7', matches_n: 1, match_level: 'stable' });
|
|
2714
|
+
});
|
|
2715
|
+
it('dblclick: delegates to page.dblClick and emits a structured envelope', async () => {
|
|
2716
|
+
browserState.page.dblClick.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
2717
|
+
const program = createProgram('', '');
|
|
2718
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'dblclick', '#row']);
|
|
2719
|
+
expect(browserState.page.dblClick).toHaveBeenCalledWith('#row', {});
|
|
2720
|
+
expect(lastJsonLog()).toEqual({ dblclicked: true, target: '#row', matches_n: 1, match_level: 'exact' });
|
|
2721
|
+
});
|
|
2722
|
+
it('check: ensures target is checked through page.setChecked', async () => {
|
|
2723
|
+
browserState.page.setChecked.mockResolvedValueOnce({
|
|
2724
|
+
checked: true,
|
|
2725
|
+
changed: true,
|
|
2726
|
+
matches_n: 2,
|
|
2727
|
+
match_level: 'exact',
|
|
2728
|
+
kind: 'checkbox',
|
|
2729
|
+
});
|
|
2730
|
+
const program = createProgram('', '');
|
|
2731
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'check', '.todo', '--nth', '1']);
|
|
2732
|
+
expect(browserState.page.setChecked).toHaveBeenCalledWith('.todo', true, { nth: 1 });
|
|
2733
|
+
expect(lastJsonLog()).toEqual({ checked: true, changed: true, target: '.todo', matches_n: 2, match_level: 'exact', kind: 'checkbox' });
|
|
2734
|
+
});
|
|
2735
|
+
it('uncheck: ensures target is unchecked through page.setChecked', async () => {
|
|
2736
|
+
browserState.page.setChecked.mockResolvedValueOnce({
|
|
2737
|
+
checked: false,
|
|
2738
|
+
changed: false,
|
|
2739
|
+
matches_n: 1,
|
|
2740
|
+
match_level: 'stable',
|
|
2741
|
+
kind: 'checkbox',
|
|
2742
|
+
});
|
|
2743
|
+
const program = createProgram('', '');
|
|
2744
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'uncheck', '#subscribe']);
|
|
2745
|
+
expect(browserState.page.setChecked).toHaveBeenCalledWith('#subscribe', false, {});
|
|
2746
|
+
expect(lastJsonLog()).toEqual({ checked: false, changed: false, target: '#subscribe', matches_n: 1, match_level: 'stable', kind: 'checkbox' });
|
|
2747
|
+
});
|
|
2748
|
+
it('upload: validates local files and delegates to page.uploadFiles', async () => {
|
|
2749
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-upload-'));
|
|
2750
|
+
const file = path.join(dir, 'receipt.pdf');
|
|
2751
|
+
fs.writeFileSync(file, 'pdf');
|
|
2752
|
+
browserState.page.uploadFiles.mockResolvedValueOnce({
|
|
2753
|
+
uploaded: true,
|
|
2754
|
+
files: 1,
|
|
2755
|
+
file_names: ['receipt.pdf'],
|
|
2756
|
+
target: '#file',
|
|
2757
|
+
matches_n: 1,
|
|
2758
|
+
match_level: 'exact',
|
|
2759
|
+
multiple: false,
|
|
2760
|
+
});
|
|
2761
|
+
const program = createProgram('', '');
|
|
2762
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'upload', '#file', file]);
|
|
2763
|
+
expect(browserState.page.uploadFiles).toHaveBeenCalledWith('#file', [file], {});
|
|
2764
|
+
expect(lastJsonLog()).toEqual({
|
|
2765
|
+
uploaded: true,
|
|
2766
|
+
files: 1,
|
|
2767
|
+
file_names: ['receipt.pdf'],
|
|
2768
|
+
target: '#file',
|
|
2769
|
+
matches_n: 1,
|
|
2770
|
+
match_level: 'exact',
|
|
2771
|
+
multiple: false,
|
|
2772
|
+
});
|
|
2773
|
+
});
|
|
2774
|
+
it('upload: rejects missing files before touching the page', async () => {
|
|
2775
|
+
const program = createProgram('', '');
|
|
2776
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'upload', '#file', '/tmp/webcmd-missing-file']);
|
|
2777
|
+
expect(lastJsonLog().error.code).toBe('file_not_found');
|
|
2778
|
+
expect(browserState.page.uploadFiles).not.toHaveBeenCalled();
|
|
2779
|
+
expect(process.exitCode).toBeDefined();
|
|
2780
|
+
});
|
|
2781
|
+
it('drag: delegates to page.drag and forwards source/target nth options', async () => {
|
|
2782
|
+
browserState.page.drag.mockResolvedValueOnce({
|
|
2783
|
+
dragged: true,
|
|
2784
|
+
source: '.card',
|
|
2785
|
+
target: '.lane',
|
|
2786
|
+
source_matches_n: 3,
|
|
2787
|
+
target_matches_n: 2,
|
|
2788
|
+
source_match_level: 'exact',
|
|
2789
|
+
target_match_level: 'stable',
|
|
2790
|
+
});
|
|
2791
|
+
const program = createProgram('', '');
|
|
2792
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'drag', '.card', '.lane', '--from-nth', '2', '--to-nth', '1']);
|
|
2793
|
+
expect(browserState.page.drag).toHaveBeenCalledWith('.card', '.lane', { from: { nth: 2 }, to: { nth: 1 } });
|
|
2794
|
+
expect(lastJsonLog()).toEqual({
|
|
2795
|
+
dragged: true,
|
|
2796
|
+
source: '.card',
|
|
2797
|
+
target: '.lane',
|
|
2798
|
+
source_matches_n: 3,
|
|
2799
|
+
target_matches_n: 2,
|
|
2800
|
+
source_match_level: 'exact',
|
|
2801
|
+
target_match_level: 'stable',
|
|
2802
|
+
});
|
|
2803
|
+
});
|
|
2804
|
+
it('drag: rejects malformed --from-nth before touching the page', async () => {
|
|
2805
|
+
const program = createProgram('', '');
|
|
2806
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'drag', '.card', '.lane', '--from-nth', 'abc']);
|
|
2807
|
+
expect(lastJsonLog().error.code).toBe('usage_error');
|
|
2808
|
+
expect(browserState.page.drag).not.toHaveBeenCalled();
|
|
2809
|
+
expect(process.exitCode).toBeDefined();
|
|
2810
|
+
});
|
|
2811
|
+
it('type: clicks, waits, then typeText — emits {typed, text, target, matches_n, match_level, autocomplete}', async () => {
|
|
2812
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
2813
|
+
browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
2814
|
+
browserState.page.evaluate.mockResolvedValueOnce(false); // isAutocomplete
|
|
2815
|
+
const program = createProgram('', '');
|
|
2816
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'type', '#q', 'hello']);
|
|
2817
|
+
expect(browserState.page.click).toHaveBeenCalledWith('#q', {});
|
|
2818
|
+
expect(browserState.page.wait).toHaveBeenCalledWith(0.3);
|
|
2819
|
+
expect(browserState.page.typeText).toHaveBeenCalledWith('#q', 'hello', {});
|
|
2820
|
+
expect(lastJsonLog()).toEqual({
|
|
2821
|
+
typed: true, text: 'hello', target: '#q', matches_n: 1, match_level: 'exact', autocomplete: false,
|
|
2822
|
+
});
|
|
2823
|
+
});
|
|
2824
|
+
it('type: waits an extra 0.4s when the input reports autocomplete=true', async () => {
|
|
2825
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
2826
|
+
browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'exact' });
|
|
2827
|
+
browserState.page.evaluate.mockResolvedValueOnce(true);
|
|
2828
|
+
const program = createProgram('', '');
|
|
2829
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'type', '#q', 'hi']);
|
|
2830
|
+
const waitCalls = browserState.page.wait.mock.calls;
|
|
2831
|
+
expect(waitCalls).toContainEqual([0.3]);
|
|
2832
|
+
expect(waitCalls).toContainEqual([0.4]);
|
|
2833
|
+
expect(lastJsonLog().autocomplete).toBe(true);
|
|
2834
|
+
expect(lastJsonLog().match_level).toBe('exact');
|
|
2835
|
+
});
|
|
2836
|
+
it('type: surfaces match_level=reidentified when ref had to be reidentified by fingerprint', async () => {
|
|
2837
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 1, match_level: 'reidentified' });
|
|
2838
|
+
browserState.page.typeText.mockResolvedValueOnce({ matches_n: 1, match_level: 'reidentified' });
|
|
2839
|
+
browserState.page.evaluate.mockResolvedValueOnce(false);
|
|
2840
|
+
const program = createProgram('', '');
|
|
2841
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'type', '9', 'hi']);
|
|
2842
|
+
// The typeText call is the authoritative match_level source for the `type` envelope.
|
|
2843
|
+
expect(lastJsonLog().match_level).toBe('reidentified');
|
|
2844
|
+
});
|
|
2845
|
+
it('type: forwards --nth to both click and typeText', async () => {
|
|
2846
|
+
browserState.page.click.mockResolvedValueOnce({ matches_n: 5, match_level: 'exact' });
|
|
2847
|
+
browserState.page.typeText.mockResolvedValueOnce({ matches_n: 5, match_level: 'exact' });
|
|
2848
|
+
const program = createProgram('', '');
|
|
2849
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'type', '.field', 'x', '--nth', '3']);
|
|
2850
|
+
expect(browserState.page.click).toHaveBeenCalledWith('.field', { nth: 3 });
|
|
2851
|
+
expect(browserState.page.typeText).toHaveBeenCalledWith('.field', 'x', { nth: 3 });
|
|
2852
|
+
});
|
|
2853
|
+
it('fill: delegates exact raw text to page.fillText and emits verification details', async () => {
|
|
2854
|
+
browserState.page.fillText.mockResolvedValueOnce({
|
|
2855
|
+
filled: true,
|
|
2856
|
+
verified: true,
|
|
2857
|
+
expected: 'line1\\n/ / raw',
|
|
2858
|
+
actual: 'line1\\n/ / raw',
|
|
2859
|
+
length: 14,
|
|
2860
|
+
matches_n: 1,
|
|
2861
|
+
match_level: 'exact',
|
|
2862
|
+
mode: 'textarea',
|
|
2863
|
+
});
|
|
2864
|
+
const program = createProgram('', '');
|
|
2865
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'fill', '#msg', 'line1\\n/ / raw']);
|
|
2866
|
+
expect(browserState.page.fillText).toHaveBeenCalledWith('#msg', 'line1\\n/ / raw', {});
|
|
2867
|
+
expect(lastJsonLog()).toEqual({
|
|
2868
|
+
filled: true,
|
|
2869
|
+
verified: true,
|
|
2870
|
+
target: '#msg',
|
|
2871
|
+
text: 'line1\\n/ / raw',
|
|
2872
|
+
actual: 'line1\\n/ / raw',
|
|
2873
|
+
length: 14,
|
|
2874
|
+
matches_n: 1,
|
|
2875
|
+
match_level: 'exact',
|
|
2876
|
+
mode: 'textarea',
|
|
2877
|
+
});
|
|
2878
|
+
expect(process.exitCode).toBeUndefined();
|
|
2879
|
+
});
|
|
2880
|
+
it('fill: sets a non-zero exit code when verification fails', async () => {
|
|
2881
|
+
browserState.page.fillText.mockResolvedValueOnce({
|
|
2882
|
+
filled: true,
|
|
2883
|
+
verified: false,
|
|
2884
|
+
expected: 'expected',
|
|
2885
|
+
actual: 'actual',
|
|
2886
|
+
length: 6,
|
|
2887
|
+
matches_n: 1,
|
|
2888
|
+
match_level: 'exact',
|
|
2889
|
+
});
|
|
2890
|
+
const program = createProgram('', '');
|
|
2891
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'fill', '#msg', 'expected']);
|
|
2892
|
+
expect(lastJsonLog()).toEqual({
|
|
2893
|
+
filled: true,
|
|
2894
|
+
verified: false,
|
|
2895
|
+
target: '#msg',
|
|
2896
|
+
text: 'expected',
|
|
2897
|
+
actual: 'actual',
|
|
2898
|
+
length: 6,
|
|
2899
|
+
matches_n: 1,
|
|
2900
|
+
match_level: 'exact',
|
|
2901
|
+
});
|
|
2902
|
+
expect(process.exitCode).toBeDefined();
|
|
2903
|
+
});
|
|
2904
|
+
it('fill: forwards --nth to page.fillText', async () => {
|
|
2905
|
+
const program = createProgram('', '');
|
|
2906
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'fill', '.field', 'x', '--nth', '2']);
|
|
2907
|
+
expect(browserState.page.fillText).toHaveBeenCalledWith('.field', 'x', { nth: 2 });
|
|
2908
|
+
});
|
|
2909
|
+
});
|
|
2910
|
+
describe('browser select command', () => {
|
|
2911
|
+
const { lastJsonLog } = installSelectorFirstTestHarness('select', () => ({
|
|
2912
|
+
evaluate: vi.fn(),
|
|
2913
|
+
}));
|
|
2914
|
+
it('emits {selected, target, matches_n, match_level} on success', async () => {
|
|
2915
|
+
const evalMock = browserState.page.evaluate;
|
|
2916
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
2917
|
+
evalMock.mockResolvedValueOnce({ selected: 'US' });
|
|
2918
|
+
const program = createProgram('', '');
|
|
2919
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'select', '#country', 'US']);
|
|
2920
|
+
expect(lastJsonLog()).toEqual({ selected: 'US', target: '#country', matches_n: 1, match_level: 'exact' });
|
|
2921
|
+
});
|
|
2922
|
+
it('maps "Not a <select>" to a not_a_select error envelope', async () => {
|
|
2923
|
+
const evalMock = browserState.page.evaluate;
|
|
2924
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
2925
|
+
evalMock.mockResolvedValueOnce({ error: 'Not a <select>' });
|
|
2926
|
+
const program = createProgram('', '');
|
|
2927
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'select', '#not-select', 'US']);
|
|
2928
|
+
const err = lastJsonLog().error;
|
|
2929
|
+
expect(err.code).toBe('not_a_select');
|
|
2930
|
+
expect(err.matches_n).toBe(1);
|
|
2931
|
+
expect(process.exitCode).toBeDefined();
|
|
2932
|
+
});
|
|
2933
|
+
it('maps missing-option failures to an option_not_found envelope with available list', async () => {
|
|
2934
|
+
const evalMock = browserState.page.evaluate;
|
|
2935
|
+
evalMock.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' });
|
|
2936
|
+
evalMock.mockResolvedValueOnce({ error: 'Option "XX" not found', available: ['US', 'CA'] });
|
|
2937
|
+
const program = createProgram('', '');
|
|
2938
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'select', '#country', 'XX']);
|
|
2939
|
+
const err = lastJsonLog().error;
|
|
2940
|
+
expect(err.code).toBe('option_not_found');
|
|
2941
|
+
expect(err.available).toEqual(['US', 'CA']);
|
|
2942
|
+
expect(process.exitCode).toBeDefined();
|
|
2943
|
+
});
|
|
2944
|
+
it('select: treats the first positional as option when using semantic locator flags', async () => {
|
|
2945
|
+
const evalMock = browserState.page.evaluate;
|
|
2946
|
+
evalMock
|
|
2947
|
+
.mockResolvedValueOnce({
|
|
2948
|
+
matches_n: 1,
|
|
2949
|
+
entries: [
|
|
2950
|
+
{ nth: 0, ref: 36, tag: 'select', role: 'combobox', text: 'Country', attrs: {}, visible: true },
|
|
2951
|
+
],
|
|
2952
|
+
})
|
|
2953
|
+
.mockResolvedValueOnce({ ok: true, matches_n: 1, match_level: 'exact' })
|
|
2954
|
+
.mockResolvedValueOnce({ selected: 'Uruguay' });
|
|
2955
|
+
const program = createProgram('', '');
|
|
2956
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'select', '--label', 'Country', 'Uruguay']);
|
|
2957
|
+
expect(lastJsonLog()).toEqual({ selected: 'Uruguay', target: '36', matches_n: 1, match_level: 'exact' });
|
|
2958
|
+
});
|
|
2959
|
+
it('surfaces selector_ambiguous from the resolver before calling selectResolvedJs', async () => {
|
|
2960
|
+
browserState.page.evaluate.mockResolvedValueOnce({
|
|
2961
|
+
ok: false,
|
|
2962
|
+
code: 'selector_ambiguous',
|
|
2963
|
+
message: 'CSS selector ".dropdown" matched 2 elements.',
|
|
2964
|
+
hint: 'Pass --nth <n>.',
|
|
2965
|
+
matches_n: 2,
|
|
2966
|
+
});
|
|
2967
|
+
const program = createProgram('', '');
|
|
2968
|
+
await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'select', '.dropdown', 'US']);
|
|
2969
|
+
expect(lastJsonLog().error.code).toBe('selector_ambiguous');
|
|
2970
|
+
// The select payload JS must not fire when resolution fails
|
|
2971
|
+
expect(browserState.page.evaluate.mock.calls).toHaveLength(1);
|
|
2972
|
+
expect(process.exitCode).toBeDefined();
|
|
2973
|
+
});
|
|
2974
|
+
});
|
|
2975
|
+
describe('findPackageRoot', () => {
|
|
2976
|
+
it('walks up from dist/src to the package root', () => {
|
|
2977
|
+
const packageRoot = path.join('repo-root');
|
|
2978
|
+
const cliFile = path.join(packageRoot, 'dist', 'src', 'cli.js');
|
|
2979
|
+
const exists = new Set([
|
|
2980
|
+
path.join(packageRoot, 'package.json'),
|
|
2981
|
+
]);
|
|
2982
|
+
expect(findPackageRoot(cliFile, (candidate) => exists.has(candidate))).toBe(packageRoot);
|
|
2983
|
+
});
|
|
2984
|
+
it('walks up from src to the package root', () => {
|
|
2985
|
+
const packageRoot = path.join('repo-root');
|
|
2986
|
+
const cliFile = path.join(packageRoot, 'src', 'cli.ts');
|
|
2987
|
+
const exists = new Set([
|
|
2988
|
+
path.join(packageRoot, 'package.json'),
|
|
2989
|
+
]);
|
|
2990
|
+
expect(findPackageRoot(cliFile, (candidate) => exists.has(candidate))).toBe(packageRoot);
|
|
2991
|
+
});
|
|
2992
|
+
});
|
|
2993
|
+
describe('normalizeVerifyRows', () => {
|
|
2994
|
+
it('returns an empty array for null / primitives', () => {
|
|
2995
|
+
expect(normalizeVerifyRows(null)).toEqual([]);
|
|
2996
|
+
expect(normalizeVerifyRows(undefined)).toEqual([]);
|
|
2997
|
+
expect(normalizeVerifyRows('hello')).toEqual([]);
|
|
2998
|
+
});
|
|
2999
|
+
it('passes through array-of-objects', () => {
|
|
3000
|
+
const rows = [{ a: 1 }, { a: 2 }];
|
|
3001
|
+
expect(normalizeVerifyRows(rows)).toEqual(rows);
|
|
3002
|
+
});
|
|
3003
|
+
it('wraps array-of-primitives as { value } rows', () => {
|
|
3004
|
+
expect(normalizeVerifyRows([1, 'two', null])).toEqual([
|
|
3005
|
+
{ value: 1 }, { value: 'two' }, { value: null },
|
|
3006
|
+
]);
|
|
3007
|
+
});
|
|
3008
|
+
it('unwraps common envelope shapes', () => {
|
|
3009
|
+
expect(normalizeVerifyRows({ rows: [{ a: 1 }] })).toEqual([{ a: 1 }]);
|
|
3010
|
+
expect(normalizeVerifyRows({ items: [{ b: 2 }] })).toEqual([{ b: 2 }]);
|
|
3011
|
+
expect(normalizeVerifyRows({ data: [{ c: 3 }] })).toEqual([{ c: 3 }]);
|
|
3012
|
+
expect(normalizeVerifyRows({ results: [{ d: 4 }] })).toEqual([{ d: 4 }]);
|
|
3013
|
+
});
|
|
3014
|
+
it('wraps a single object as a one-row array', () => {
|
|
3015
|
+
expect(normalizeVerifyRows({ ok: true })).toEqual([{ ok: true }]);
|
|
3016
|
+
});
|
|
3017
|
+
});
|
|
3018
|
+
describe('renderVerifyPreview', () => {
|
|
3019
|
+
it('emits a placeholder for empty rows', () => {
|
|
3020
|
+
expect(renderVerifyPreview([])).toContain('no rows');
|
|
3021
|
+
});
|
|
3022
|
+
it('prints column headers followed by row cells', () => {
|
|
3023
|
+
const out = renderVerifyPreview([{ a: 'x', b: 1 }, { a: 'y', b: 2 }]);
|
|
3024
|
+
const lines = out.split('\n');
|
|
3025
|
+
expect(lines[0]).toContain('a');
|
|
3026
|
+
expect(lines[0]).toContain('b');
|
|
3027
|
+
expect(lines.some((l) => l.includes('x') && l.includes('1'))).toBe(true);
|
|
3028
|
+
expect(lines.some((l) => l.includes('y') && l.includes('2'))).toBe(true);
|
|
3029
|
+
});
|
|
3030
|
+
it('truncates long cells and reports hidden rows / columns', () => {
|
|
3031
|
+
const rows = Array.from({ length: 15 }, (_, i) => ({
|
|
3032
|
+
a: i, b: 'x'.repeat(100), c: i, d: i, e: i, f: i, g: i, h: i,
|
|
3033
|
+
}));
|
|
3034
|
+
const out = renderVerifyPreview(rows, { maxRows: 5, maxCols: 3, cellMax: 10 });
|
|
3035
|
+
expect(out).toContain('and 10 more row');
|
|
3036
|
+
expect(out).toContain('more column');
|
|
3037
|
+
// cell gets truncated
|
|
3038
|
+
expect(out).toContain('xxxxxxxxxx');
|
|
3039
|
+
expect(out).not.toContain('xxxxxxxxxxx'); // never 11 consecutive
|
|
3040
|
+
});
|
|
3041
|
+
});
|