diff --git a/.changeset/quiet-wolves-investigate.md b/.changeset/quiet-wolves-investigate.md new file mode 100644 index 000000000..cc3afe34b --- /dev/null +++ b/.changeset/quiet-wolves-investigate.md @@ -0,0 +1,7 @@ +--- +"@qawolf/cli": minor +--- + +`qawolf attempt investigate --attempt-id ` summarizes a finished run attempt's trace and execution log, and `qawolf attempt inspect --attempt-id ` reads one action, request, snapshot, screenshot, console window or log record from them. + +Pins `@qawolf/api-contracts` 0.83.0. That removes `qawolf run getAttemptArtifacts`; use `qawolf attempt get --attempt-id ` for an attempt's signed artifact links. It also adds `qawolf environment discard`, `qawolf environment promote`, `qawolf promotion find` and `qawolf promotion get`. diff --git a/.oxlintrc.json b/.oxlintrc.json index 0ff230e46..f35227584 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -750,6 +750,81 @@ ] } }, + { + "files": ["src/domains/investigation/**/*.ts"], + "rules": { + "eslint/no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["../*"], + "message": "Use \"~/\" path alias instead of relative parent imports." + }, + { + "group": ["bun", "bun:*"], + "message": "Bun module imports are not available in the nodejs build \u2014 use a portable alternative." + }, + { + "group": ["~/commands/*", "~/commands/**"], + "message": "domains/ must not import from commands/." + }, + { + "group": ["~/domains/auth/*", "~/domains/auth/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/config/*", "~/domains/config/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/doctor/*", "~/domains/doctor/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/emails/*", "~/domains/emails/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/flows/*", "~/domains/flows/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/install/*", "~/domains/install/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/publicApi/*", "~/domains/publicApi/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": [ + "~/domains/interactiveRunner/*", + "~/domains/interactiveRunner/**" + ], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["~/domains/runner/*", "~/domains/runner/**"], + "message": "Cross-domain import: shared types belong in core/ or shell/." + }, + { + "group": ["node:fs", "fs", "node:fs/promises", "fs/promises"], + "message": "domains/ must not import node:fs \u2014 file I/O belongs in shell/." + }, + { + "group": ["node:child_process", "child_process"], + "message": "domains/ must not import node:child_process \u2014 use SpawnFn from shell/spawn.js." + }, + { + "group": ["node:readline", "readline"], + "message": "domains/ must not import node:readline \u2014 use UI from shell/ui/." + } + ] + } + ] + } + }, { "files": ["src/domains/runner/**/*.ts"], "rules": { diff --git a/AGENTS.md b/AGENTS.md index e177da7e5..3f665756f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,11 +72,13 @@ src/ │ ├── init/ # init handler + templates │ ├── install/ # installBrowsers, installBrowserList │ ├── interactiveRunner/ # remote runners: launch, stop, keepalive, runFlow, journal, screenshot, act, exec +│ ├── investigation/ # attempt investigate/inspect: artifact download; parser/ is a lazily loaded bundle (dist/investigation-parser.js) │ ├── runner/ # the LOCAL execution engine: flowsRun, runWebFlow, runAndroidFlow, worker dispatch + pool │ └── updateCheck/ # startUpdateCheck: new-version notice after commands ├── commands/ # Thin CLI glue — Commander registration + composite root ├── context.ts # withContext() / withAuthContext() action wrappers ├── program.ts # createProgram() factory + ├── attempt/ # attempt investigate/inspect (domains/investigation) ├── auth/ # login, logout, whoami handlers ├── doctor/ # doctor handler ├── flows/ # flows run/list/pull handlers; runDefaults composite root diff --git a/README.md b/README.md index f68bf19d0..497af05c7 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,36 @@ qawolf flows run examples/example.flow.ts | `qawolf auth` | [Authenticate with QA Wolf](https://docs.qawolf.com/qawolf/local-execution/authenticate) | | `qawolf flows` | [Run flows locally](https://docs.qawolf.com/qawolf/local-execution/run-flows-locally), [pull flows](https://docs.qawolf.com/qawolf/local-execution/pull-flows) | | `qawolf run` | Trigger and manage QA Wolf runs on the platform (public API) | +| `qawolf attempt` | Summarize and inspect a finished run attempt's trace and logs | | `qawolf install` | [Install runtime dependencies](https://docs.qawolf.com/qawolf/local-execution/install-dependencies) | | `qawolf init` | [Set up a local-only project](https://docs.qawolf.com/qawolf/local-execution/set-up-a-project) | | `qawolf doctor` | [Diagnose problems](https://docs.qawolf.com/qawolf/local-execution/diagnose-problems) | Run any command with `--help` for its flags and options. `qawolf help ref ` prints the help of a command and of every command under it, as Markdown. +### Investigate a run attempt + +Start with a run ID, pick an attempt ID from its failed flows, then narrow the evidence only when the summary points to it: + +```bash +qawolf run get --run-id --flow-statuses failed +qawolf attempt investigate --attempt-id +qawolf attempt inspect --attempt-id --timeline --limit 20 +qawolf attempt inspect --attempt-id --request +``` + +`attempt inspect` requires exactly one of `--action`, `--timeline`, `--network`, `--request`, `--snapshot`, `--screenshot`, `--console`, or `--log`. Use `--evidence-id` with `--console` or `--log` to return exactly that one evidence record; the other filters are listed in `qawolf attempt inspect --help`. + +Snapshot HTML can be returned directly or written to a new file. Screenshots require a file. The CLI refuses to overwrite an existing path: + +```bash +qawolf attempt inspect --attempt-id --snapshot --format html +qawolf attempt inspect --attempt-id --snapshot --format html --output-file snapshot.html +qawolf attempt inspect --attempt-id --screenshot --output-file screenshot.jpeg +``` + +The trace and logs are downloaded from the signed links `qawolf attempt get` returns and parsed on your machine. Downloads, output text, HTML, request bodies, and log retention are bounded; `incomplete`, `truncated`, `malformed`, and artifact status fields describe evidence that could not be read in full. + ## Agent integration This repository ships a [`qawolf-cli` Agent Skill](skills/qawolf-cli/SKILL.md) that follows the open [Agent Skills specification](https://agentskills.io/specification). It works with compatible harnesses such as Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, and OpenCode. diff --git a/bun.lock b/bun.lock index efe64d03e..e7dad80b5 100644 --- a/bun.lock +++ b/bun.lock @@ -8,13 +8,14 @@ "@clack/prompts": "1.5.1", "@napi-rs/keyring": "1.3.0", "@oxc-node/core": "0.1.0", - "@qawolf/api-contracts": "0.81.0", + "@qawolf/api-contracts": "0.83.0", "@qawolf/emails": "1.1.1", "@qawolf/flow-targets": "1.0.0", "@qawolf/flows": "0.1.4", "@qawolf/testkit": "1.2.1", "commander": "14.0.3", "env-paths": "4.0.0", + "jszip": "3.10.1", "picomatch": "4.0.4", "pino": "10.3.1", "playwright": "1.62.0", @@ -462,7 +463,7 @@ "@puppeteer/browsers": ["@puppeteer/browsers@2.13.2", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw=="], - "@qawolf/api-contracts": ["@qawolf/api-contracts@0.81.0", "", { "dependencies": { "tslib": "^2.5.3" }, "peerDependencies": { "zod": "^4.1.11" } }, "sha512-qER3FCKt2G9R739rG/K1DH/LbZCJP+qU/4NMQWDVtDTIcb9vNKxkBy+FB+Z7Ls7fzuS3hIWh0qRVU6/FflsBEg=="], + "@qawolf/api-contracts": ["@qawolf/api-contracts@0.83.0", "", { "dependencies": { "tslib": "^2.5.3" }, "peerDependencies": { "zod": "^4.1.11" } }, "sha512-ZGZ1GcCYmjUqGv2pc3W7iR7RIDZxMKyu7jdpAmngkbdEIm7lCtFG6YIGkokK/aQPGw2kA/cPD+tevo93YTb9Og=="], "@qawolf/emailer-types": ["@qawolf/emailer-types@1.1.0", "", { "dependencies": { "email-addresses": "^5.0.0", "tslib": "^2.5.3", "zod": "^4.1.11", "zod-form-data": "^3.0.1" } }, "sha512-7cX+e31L9dpO8fntQ2cfSMt/1Lla/yUjaLUr/KLpXuscBSajy7CgNzjAGURa1KsCcxz7n+9PGQw6cM3RlC1bSg=="], @@ -1322,7 +1323,7 @@ "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], @@ -1356,7 +1357,7 @@ "safaridriver": ["safaridriver@1.0.1", "", {}, "sha512-jkg4434cYgtrIF2AeY/X0Wmd2W73cK5qIEFE3hDrrQenJH/2SDJIXGvPAigfvQTcE9+H31zkiNHbUqcihEiMRA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-buffer": ["safe-buffer@5.1.1", "", {}, "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="], "safe-regex2": ["safe-regex2@5.1.1", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA=="], @@ -1458,7 +1459,7 @@ "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -1686,6 +1687,8 @@ "basic-auth/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "compress-commons/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "crc32-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], @@ -1724,8 +1727,6 @@ "get-stream/is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], - "hpack.js/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -1746,10 +1747,6 @@ "jsftp/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - - "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "locate-app/type-fest": ["type-fest@4.26.0", "", {}, "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw=="], "lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -1812,12 +1809,18 @@ "serve-favicon/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "serve-favicon/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "spdy-transport/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], @@ -1834,6 +1837,10 @@ "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "winston/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "winston-transport/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1940,12 +1947,22 @@ "archiver-utils/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "archiver/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "archiver/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "compress-commons/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "compress-commons/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "crc32-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "crc32-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "edge-paths/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -1966,24 +1983,12 @@ "geckodriver/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "hpack.js/readable-stream/safe-buffer": ["safe-buffer@5.1.1", "", {}, "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="], - - "hpack.js/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "jest-mock/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "jest-util/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "jsftp/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.1", "", {}, "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="], - - "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - - "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.1", "", {}, "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="], - - "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "method-override/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -2000,18 +2005,28 @@ "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + "spdy-transport/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "webdriver/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "winston-transport/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "winston/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "zip-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "zip-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "@appium/support/archiver/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "@appium/support/archiver/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "@appium/support/archiver/zip-stream/compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="], "@puppeteer/browsers/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2056,11 +2071,15 @@ "archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "hpack.js/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "archiver-utils/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "archiver/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "jszip/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "bl/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "lazystream/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "compress-commons/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "crc32-stream/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "msw/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -2072,6 +2091,16 @@ "readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "spdy-transport/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "winston-transport/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "winston/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "zip-stream/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "@appium/support/archiver/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "@appium/support/archiver/zip-stream/compress-commons/crc32-stream": ["crc32-stream@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g=="], "@puppeteer/browsers/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/knip.config.ts b/knip.config.ts index 752c1049f..e8225ab98 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -4,6 +4,7 @@ const config: KnipConfig = { entry: [ "*.config.ts", "src/runnerSdk/index.ts", + "src/domains/investigation/parser/index.ts", "src/**/*.test.ts", "src/**/*.mock.ts", "src/**/*.fixtures.ts", diff --git a/package.json b/package.json index f07eca01a..1d1ce9c9a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "LICENSE", "README.md", "dist/cli.js", + "dist/investigation-parser.js", "dist/runner-sdk.js", "dist/types/runnerSdk/index.d.ts", "dist/types/runnerSdk/types.d.ts", @@ -71,13 +72,14 @@ "@clack/prompts": "1.5.1", "@napi-rs/keyring": "1.3.0", "@oxc-node/core": "0.1.0", - "@qawolf/api-contracts": "0.81.0", + "@qawolf/api-contracts": "0.83.0", "@qawolf/emails": "1.1.1", "@qawolf/flow-targets": "1.0.0", "@qawolf/flows": "0.1.4", "@qawolf/testkit": "1.2.1", "commander": "14.0.3", "env-paths": "4.0.0", + "jszip": "3.10.1", "picomatch": "4.0.4", "pino": "10.3.1", "playwright": "1.62.0", diff --git a/scripts/build.ts b/scripts/build.ts index 8386a45ee..cbb0bafc6 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -48,6 +48,16 @@ function buildArgs(entry: string, name: string): string[] { const bundles = [ buildArgs("./src/main.ts", "cli"), buildArgs("./src/runnerSdk/index.ts", "runner-sdk"), + [ + "build", + "./src/domains/investigation/parser/index.ts", + "--outdir", + "dist", + "--entry-naming=investigation-parser.[ext]", + "--target", + "node", + "--sourcemap=external", + ], ]; for (const args of bundles) { diff --git a/scripts/buildBinary.ts b/scripts/buildBinary.ts index 49b404cca..504f69e9d 100644 --- a/scripts/buildBinary.ts +++ b/scripts/buildBinary.ts @@ -19,14 +19,18 @@ const { values } = parseArgs({ // spawns as a BUN_BE_BUN worker so flows resolve their own node_modules — // including native modules like sharp — at runtime, which the in-process // compiled resolver cannot. Never bundled into cli.js (build.ts builds -// src/main.ts), so there is no embed cycle. +// src/main.ts), so there is no embed cycle. The investigation parser bundle is +// embedded too; its path goes through a global rather than env so it does not +// leak into the flow worker and other child processes. const entryPath = "dist/binary-entry.ts"; writeFileSync( entryPath, [ 'import cliAsset from "./cli.js" with { type: "file" };', + 'import investigationParserAsset from "./investigation-parser.js" with { type: "file" };', "", "process.env.QAWOLF_EMBEDDED_CLI_PATH = cliAsset;", + "globalThis.qawolfInvestigationParserPath = investigationParserAsset;", "", 'await import("../src/main.ts");', "", diff --git a/scripts/postbuild.ts b/scripts/postbuild.ts index a895cb1a0..aff99f047 100644 --- a/scripts/postbuild.ts +++ b/scripts/postbuild.ts @@ -6,7 +6,7 @@ import { chmodSync, readFileSync, statSync, writeFileSync } from "node:fs"; const outfile = "dist/cli.js"; -const bundles = [outfile, "dist/runner-sdk.js"]; +const bundles = [outfile, "dist/runner-sdk.js", "dist/investigation-parser.js"]; // Externals that must only ever be reached through a dynamic import(). A static // import anywhere in src/ becomes a top-level import here, and Node would load diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index 18e3f1c0c..2d90b177c 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -129,6 +129,9 @@ that `url`; never guess a route and never send a repository link in its place. | --- | --- | --- | | `qawolf agent get` | read | Read what the QA Wolf AI has said and whether it is still working | | `qawolf agent send` | write | Ask the QA Wolf AI to do a piece of work, such as covering a journey or fixing a broken flow | +| `qawolf attempt get` | read | Get a finished run attempt: its run and flow status, its place among that flow's attempts in the run, and signed links to its logs, trace, and video when they were captured. | +| `qawolf attempt inspect` | read | Inspect one kind of evidence for a finished attempt | +| `qawolf attempt investigate` | read | Summarize the evidence for one finished attempt | | `qawolf auth login` | local | Authenticate with QA Wolf in a browser or with an API key | | `qawolf auth logout` | local | Remove stored credentials | | `qawolf auth switch` | local | Choose which workspace to work in | @@ -147,10 +150,12 @@ that `url`; never guess a route and never send a repository link in its place. | `qawolf email send` | write | Send an email from one of the workspace's inbox addresses, for example to exercise a flow that reacts to incoming mail. Returns the sent email; read it back with email.get. | | `qawolf environment create` | write | Create an environment on the caller's team and return it in the environment.get shape. This can also create a branch on the team's connected Git provider. | | `qawolf environment deleteVariable` | write | Remove one environment variable by name. Succeeds whether or not the variable existed. | +| `qawolf environment discard` | write | Declare a preview environment's work discarded, typically when its pull request closes without merging: the environment is terminated and its flow changes are not promoted into its base. Returns the environment, now terminated. A terminated environment cannot be discarded again. Use environment.promote instead when the work is accepted. | | `qawolf environment find` | read | List the team's environments, newest first. | | `qawolf environment get` | read | Read a single environment's name, kind, standing run health, flow-code branch and reconciliation state, run concurrency limit, and termination state. If flowCodeBranch exists, use its syncStatus for Git reconciliation and read lastSyncedCommitHash only when syncStatus is reconciled. | | `qawolf environment getVariable` | read | Read the values of named environment variables in one call. Values are secrets. Names that do not exist go to missingNames and do not fail the call. | | `qawolf environment listVariableNames` | read | Use this to answer which QA Wolf environment variables are available to test code. Returns names only; values never leave the server. | +| `qawolf environment promote` | write | Declare a preview environment's work accepted, typically when its pull request merges: its flow changes are promoted into its base environment and the environment is terminated once they land. Returns the promotion in pending; poll promotion.get to learn how it settled. Calling again while a promotion is open returns that promotion. A conflict settles the promotion conflicted and cancels the environment's in-progress runs; promotion.get names the conflicting paths. Use environment.discard instead when the work is abandoned. | | `qawolf environment setVariable` | write | Create or replace an environment variable. If the user asks to create one for "my email" without naming it, use DEFAULT_EMAIL. The value is never returned. | | `qawolf environment update` | write | Update an environment owned by the caller's team and return it in the environment.get shape. Omitted fields remain unchanged. | | `qawolf file requestDownload` | read | Get a URL for reading a file out of the caller's team storage. Answers 404 when nothing is stored at that path. | @@ -176,12 +181,13 @@ that `url`; never guess a route and never send a repository link in its place. | `qawolf legacyTrigger find` | read | List a workspace's legacy per-environment triggers; trigger.find lists the current ones. For migration only; it goes when legacy triggers go. adaptiveFlowSelection becomes a generativeSuite action, which takes no tags. | | `qawolf legacyTrigger pause` | write | Pause one legacy trigger, the older per-environment kind, so it stops firing; use trigger.pause for a current one. Its copies in pull request environments pause too. For migration only; it goes when legacy triggers go. | | `qawolf legacyTrigger resume` | write | Resume one legacy trigger, the older per-environment kind; use trigger.resume for a current one. Its copies in pull request environments resume too, and a scheduled one jumps to the next upcoming slot even if it was not paused. For migration only; it goes when legacy triggers go. | +| `qawolf promotion find` | read | List environment promotions, newest first. A promotion moves the flow changes made in a head environment into its base environment. Filter by status conflicted to find the promotions whose conflicts still need a resolution. | +| `qawolf promotion get` | read | Read one environment promotion: its status, the conflicting paths and commit hashes while it is conflicted or resolving, the commit it landed when promoted, and the AI task resolving it. Poll it after environment.promote to learn how the promotion settled. It does not serve file contents; a resolver reads both branches from the flow code repository. | | `qawolf run acceptScreenshotBaseline` | write | Make the new screenshot of a failed screenshot comparison the baseline, so later runs compare with it. Use it only when the difference is an intended change of the app, not a bug, noise, or a page that was not ready. Every later run of every environment of the workspace compares with the new baseline. Refuses without changing anything when the baseline changed since the run. | | `qawolf run create` | write | Create a run for the selected flows and/or tags in an environment. | | `qawolf run diagnose` | write | Diagnose failed flows in a run as reproductions of a bug or maintenance report owned by the caller's team. The issue's type selects the diagnosis. Each flow must have failed in the run. A flow that is already diagnosed in the run moves to this issue, and may stay linked to other open reports. The diagnosis appears on the run, and the reproduction appears under the issue's reproductions. Coverage requests cannot be diagnosed; use issue.addFlows to cover flows instead. | | `qawolf run find` | read | List an environment's recent runs, newest first, including failed-attempt discovery metadata. | | `qawolf run get` | read | Get a run's status, per-flow results, links, and how many of its bugs are blocking. | -| `qawolf run getAttemptArtifacts` | read | Get metadata and signed artifact URLs for one finished run attempt. | | `qawolf run listScreenshotComparisons` | read | List the screenshot comparisons that failed a run's flows, with the expected, actual and diff images of each. Only comparisons that caused a failure are listed: a screenshot that differs within tolerance, or that differs on a line that did not fail, is left out. Each comparison tells whether its baseline changed since the run and whether the baseline still passes in other environments. The image URLs expire after a few minutes; fetch the preview URLs to look at the images. The first call for an image makes its missing JPEG preview and keeps it in team storage, so later calls reuse it. | | `qawolf run optOutOfInvestigation` | write | Mark failed flows in a run as "do not investigate" (DNI): the failures need no bug or maintenance report. Each flow must have failed in the run and still be under investigation. A flow that already has a diagnosis cannot be opted out. A team or organization key records the opt-out as the workspace's automation user. To link a failure to a report instead, use run.diagnose. | | `qawolf run reattempt` | write | Request new attempts for a run's flows, in the same run. A flow is eligible once its result is failed or canceled and QA Wolf's automatic retries have finished. A fully investigated run no longer accepts reattempts. Attempts run with the latest flow code. Poll run.get for results. | diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 41c8b1752..efa0dc794 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -22,6 +22,8 @@ Commands: directory install [options] [pattern] Install every runtime dependency the project's flows need + attempt Investigate a finished run attempt from recorded + evidence runner Drive an interactive runner on the QA Wolf platform codeHostIntegration QA Wolf public API codeHostIntegration commands @@ -33,6 +35,7 @@ Commands: investigation QA Wolf public API investigation commands issue QA Wolf public API issue commands legacyTrigger QA Wolf public API legacyTrigger commands + promotion QA Wolf public API promotion commands run Trigger and manage QA Wolf runs on the platform skill QA Wolf public API skill commands tag QA Wolf public API tag commands @@ -149,6 +152,70 @@ Examples: " `; +exports[`--help output qawolf attempt 1`] = ` +"Usage: qawolf attempt [options] [command] + +Investigate a finished run attempt from recorded evidence + +Options: + -h, --help display help for command + +Commands: + investigate [options] Summarize the evidence for one finished attempt + inspect [options] Inspect one kind of evidence for a finished attempt + get [options] Get a finished run attempt: its run and flow status, + its place among that flow's attempts in the run, and + signed links to its logs, trace, and video when they + were captured. + help [command] display help for command +" +`; + +exports[`--help output qawolf attempt investigate 1`] = ` +"Usage: qawolf attempt investigate [options] + +Summarize the evidence for one finished attempt + +Options: + --attempt-id Attempt to summarize + -h, --help display help for command +" +`; + +exports[`--help output qawolf attempt inspect 1`] = ` +"Usage: qawolf attempt inspect [options] + +Inspect one kind of evidence for a finished attempt + +Options: + --attempt-id Attempt to inspect + --action Inspect one action evidence ID + --timeline Inspect the action timeline + --network Inspect network requests + --request Inspect one network request evidence ID + --snapshot Inspect one page snapshot evidence ID + --screenshot Export one screenshot evidence ID + --console Inspect browser console evidence + --log Inspect execution log evidence + --evidence-id Return only this console or log record + --limit Maximum entries to return, 20 by default and up to + 100: the most recent for --timeline, --network and + --log, the earliest for --console + --start-time-ms Earliest trace timestamp in milliseconds + --end-time-ms Latest trace timestamp in milliseconds + --start-timestamp Earliest execution log timestamp + --end-timestamp Latest execution log timestamp + --source Execution log source (choices: + "qawolfTraceCollection", "serverConsole") + --method Network HTTP method + --status Network HTTP status + --url-contains Network URL substring + --format Snapshot format (text or html) (default: "text") + --output-file Write HTML or screenshot bytes without overwriting + -h, --help display help for command +" +`; + exports[`--help output qawolf flows 1`] = ` "Usage: qawolf flows [options] [command] diff --git a/src/commands/attempt/index.test.ts b/src/commands/attempt/index.test.ts new file mode 100644 index 000000000..5e51ea7a0 --- /dev/null +++ b/src/commands/attempt/index.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "bun:test"; +import { Command } from "commander"; +import { z } from "zod"; + +import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; +import { createProgram } from "~/commands/program.js"; +import { registerPublicApiCommands } from "~/commands/publicApi/index.js"; +import { standInAnnotationJustifications } from "~/domains/publicApi/contract.fixtures.js"; +import { registerAttemptCommand } from "./index.js"; + +function subcommand(parent: Command, name: string): Command { + const command = parent.commands.find( + (candidate) => candidate.name() === name, + ); + if (!command) throw new Error(`Missing command: ${name}`); + return command; +} + +function attemptProgram(): Command { + const program = new Command().exitOverride(); + program.configureOutput({ writeErr: () => {}, writeOut: () => {} }); + registerAttemptCommand(program, makeNoopSignals()); + return program; +} + +describe("attempt command registration", () => { + it("keeps generated investigation get separate from attempt commands", () => { + const program = createProgram({ signals: makeNoopSignals() }); + const attempt = subcommand(program, "attempt"); + const investigation = subcommand(program, "investigation"); + + expect(attempt.commands.map((command) => command.name())).toEqual([ + "investigate", + "inspect", + "get", + ]); + expect(investigation.commands.map((command) => command.name())).toContain( + "get", + ); + }); + + it("shares the attempt group with commands generated from an attempt contract", () => { + const program = attemptProgram(); + registerPublicApiCommands(program, makeNoopSignals(), { + contracts: { + attempt: { + get: { + annotationJustifications: standInAnnotationJustifications, + annotations: { + destructiveHint: false, + openWorldHint: false, + readOnlyHint: true, + }, + description: "Synthetic attempt contract.", + input: z.object({ attemptId: z.string() }), + kind: "read" as const, + name: "attempt.get", + output: z.object({ attemptId: z.string() }), + }, + }, + }, + }); + + expect( + program.commands.filter((command) => command.name() === "attempt"), + ).toHaveLength(1); + expect( + subcommand(program, "attempt").commands.map((command) => command.name()), + ).toEqual(["investigate", "inspect", "get"]); + }); + + it("requires one selector before entering authenticated command handling", () => { + const missing = attemptProgram().parseAsync( + ["attempt", "inspect", "--attempt-id", "a1"], + { from: "user" }, + ); + expect(missing).rejects.toThrow("Choose exactly one evidence selector"); + + const conflicting = attemptProgram().parseAsync( + ["attempt", "inspect", "--attempt-id", "a1", "--timeline", "--network"], + { from: "user" }, + ); + expect(conflicting).rejects.toThrow("Choose exactly one evidence selector"); + }); + + it("exposes only the selector-based inspect surface", () => { + const inspect = subcommand( + subcommand(attemptProgram(), "attempt"), + "inspect", + ); + const flags = inspect.options.map((option) => option.long); + + for (const selector of [ + "--attempt-id", + "--action", + "--timeline", + "--network", + "--request", + "--snapshot", + "--screenshot", + "--console", + "--log", + "--evidence-id", + ]) + expect(flags).toContain(selector); + expect(flags).not.toContain("--type"); + }); +}); diff --git a/src/commands/attempt/index.ts b/src/commands/attempt/index.ts new file mode 100644 index 000000000..e7cebcaee --- /dev/null +++ b/src/commands/attempt/index.ts @@ -0,0 +1,98 @@ +import { type Command, Option } from "commander"; + +import { declareCommandKind } from "~/commands/commandKind.js"; +import { withAuthContext } from "~/commands/context.js"; +import { + handleInvestigationInspect, + handleInvestigationSummary, +} from "~/domains/investigation/handle.js"; +import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; +import { + type InspectCommandOptions, + resolveInspectSelection, +} from "./inspectSelection.js"; + +function parseFormat(value: string): "html" | "text" { + if (value === "html" || value === "text") return value; + throw new Error("Expected text or html."); +} + +export function registerAttemptCommand( + program: Command, + signals: SignalRegistry, +): void { + const attempt = program + .command("attempt") + .description("Investigate a finished run attempt from recorded evidence"); + + declareCommandKind(attempt.command("investigate"), "read") + .description("Summarize the evidence for one finished attempt") + .requiredOption("--attempt-id ", "Attempt to summarize") + .action((options: { attemptId: string }, command: Command) => + withAuthContext(signals, (ctx) => + handleInvestigationSummary(ctx, options.attemptId), + )(options, command), + ); + + declareCommandKind(attempt.command("inspect"), "read") + .description("Inspect one kind of evidence for a finished attempt") + .requiredOption("--attempt-id ", "Attempt to inspect") + .option("--action ", "Inspect one action evidence ID") + .option("--timeline", "Inspect the action timeline") + .option("--network", "Inspect network requests") + .option("--request ", "Inspect one network request evidence ID") + .option("--snapshot ", "Inspect one page snapshot evidence ID") + .option("--screenshot ", "Export one screenshot evidence ID") + .option("--console", "Inspect browser console evidence") + .option("--log", "Inspect execution log evidence") + .option("--evidence-id ", "Return only this console or log record") + .option( + "--limit ", + "Maximum entries to return, 20 by default and up to 100: the most recent for --timeline, --network and --log, the earliest for --console", + ) + .option( + "--start-time-ms ", + "Earliest trace timestamp in milliseconds", + ) + .option("--end-time-ms ", "Latest trace timestamp in milliseconds") + .option("--start-timestamp ", "Earliest execution log timestamp") + .option("--end-timestamp ", "Latest execution log timestamp") + .addOption( + new Option("--source ", "Execution log source").choices([ + "qawolfTraceCollection", + "serverConsole", + ]), + ) + .option("--method ", "Network HTTP method") + .option("--status ", "Network HTTP status") + .option("--url-contains ", "Network URL substring") + .option( + "--format ", + "Snapshot format (text or html)", + parseFormat, + "text", + ) + .option( + "--output-file ", + "Write HTML or screenshot bytes without overwriting", + ) + .action((options: InspectCommandOptions, command: Command) => { + const selection = resolveInspectSelection(options); + if ("error" in selection) return command.error(selection.error); + const { + action: _action, + console: _console, + log: _log, + network: _network, + request: _request, + screenshot: _screenshot, + snapshot: _snapshot, + timeline: _timeline, + ...common + } = options; + const normalized = { ...common, ...selection }; + return withAuthContext(signals, (ctx) => + handleInvestigationInspect(ctx, normalized), + )(options, command); + }); +} diff --git a/src/commands/attempt/inspectSelection.ts b/src/commands/attempt/inspectSelection.ts new file mode 100644 index 000000000..e6bc8cf66 --- /dev/null +++ b/src/commands/attempt/inspectSelection.ts @@ -0,0 +1,57 @@ +import type { InspectOptions } from "~/domains/investigation/handle.js"; + +export type InspectCommandOptions = Omit< + InspectOptions, + "actionId" | "requestId" | "screenshotId" | "snapshotId" | "type" +> & { + action?: string; + console?: boolean; + log?: boolean; + network?: boolean; + request?: string; + screenshot?: string; + snapshot?: string; + timeline?: boolean; +}; + +type InspectSelection = + | { actionId: string; type: "action" } + | { type: "timeline" } + | { type: "network" } + | { requestId: string; type: "request" } + | { snapshotId: string; type: "snapshot" } + | { screenshotId: string; type: "screenshot" } + | { type: "console" } + | { type: "log" }; + +export function resolveInspectSelection( + options: InspectCommandOptions, +): InspectSelection | { error: string } { + const candidates: (InspectSelection | undefined)[] = [ + options.action === undefined + ? undefined + : { actionId: options.action, type: "action" }, + options.timeline ? { type: "timeline" } : undefined, + options.network ? { type: "network" } : undefined, + options.request === undefined + ? undefined + : { requestId: options.request, type: "request" }, + options.snapshot === undefined + ? undefined + : { snapshotId: options.snapshot, type: "snapshot" }, + options.screenshot === undefined + ? undefined + : { screenshotId: options.screenshot, type: "screenshot" }, + options.console ? { type: "console" } : undefined, + options.log ? { type: "log" } : undefined, + ]; + const selected = candidates.filter( + (selection): selection is InspectSelection => selection !== undefined, + ); + return selected.length === 1 + ? selected[0]! + : { + error: + "Choose exactly one evidence selector: --action, --timeline, --network, --request, --snapshot, --screenshot, --console, or --log.", + }; +} diff --git a/src/commands/help.test.ts b/src/commands/help.test.ts index 917e63569..815dd7fba 100644 --- a/src/commands/help.test.ts +++ b/src/commands/help.test.ts @@ -62,6 +62,18 @@ describe("--help output", () => { expect(helpFor("doctor")).toMatchSnapshot(); }); + it("qawolf attempt", () => { + expect(helpFor("attempt")).toMatchSnapshot(); + }); + + it("qawolf attempt investigate", () => { + expect(helpFor("attempt", "investigate")).toMatchSnapshot(); + }); + + it("qawolf attempt inspect", () => { + expect(helpFor("attempt", "inspect")).toMatchSnapshot(); + }); + it("qawolf flows", () => { expect(helpFor("flows")).toMatchSnapshot(); }); diff --git a/src/commands/program.ts b/src/commands/program.ts index a82a1f575..c5f9f8f6f 100644 --- a/src/commands/program.ts +++ b/src/commands/program.ts @@ -8,6 +8,7 @@ import { registerFlowsCommand } from "./flows/index.js"; import { registerHelpCommand } from "./help/index.js"; import { registerInitCommand } from "./init/index.js"; import { registerInstallCommand } from "./install/index.js"; +import { registerAttemptCommand } from "./attempt/index.js"; import { registerPublicApiCommands } from "./publicApi/index.js"; import { registerRunnerCommand } from "./runner/index.js"; import { exitCodes, exit } from "~/shell/exit.js"; @@ -35,6 +36,7 @@ export function createProgram({ registerFlowsCommand(program, signals); registerInitCommand(program, signals); registerInstallCommand(program, signals); + registerAttemptCommand(program, signals); registerRunnerCommand(program, signals); registerPublicApiCommands(program, signals); registerHelpCommand(program); diff --git a/src/domains/investigation/attemptArtifacts.fixtures.ts b/src/domains/investigation/attemptArtifacts.fixtures.ts new file mode 100644 index 000000000..b4f41fe6e --- /dev/null +++ b/src/domains/investigation/attemptArtifacts.fixtures.ts @@ -0,0 +1,22 @@ +import type { AttemptArtifacts } from "./handle.js"; + +export function attemptArtifactsFixture(): AttemptArtifacts { + return { + artifactStatus: "signed", + artifacts: { + logsUrl: "https://storage.invalid/logs.txt?token=secret", + traceUrl: "https://storage.invalid/trace.zip?token=secret", + }, + attempt: { + attemptId: "attempt-1", + createdAt: "2026-09-29T00:00:00.000Z", + kind: "automated", + status: "failed", + }, + flowId: "flow-1", + flowStatus: "failed", + retryContext: { currentOrdinal: 1, total: 1 }, + runId: "run-1", + runStatus: "failed", + }; +} diff --git a/src/domains/investigation/download.test.ts b/src/domains/investigation/download.test.ts new file mode 100644 index 000000000..e2f2c9739 --- /dev/null +++ b/src/domains/investigation/download.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "bun:test"; + +import { downloadLogTail, downloadTrace } from "./download.js"; + +describe("investigation artifact downloads", () => { + it.each([ + [404, "not-found"], + [403, "expired-url"], + ] as const)("distinguishes HTTP %s", async (status, expected) => { + const fetchImpl = (async () => + new Response("", { status })) as unknown as typeof fetch; + const result = await downloadTrace( + "https://storage.invalid/trace", + fetchImpl, + ); + expect(result.status).toBe(expected); + }); + + it("rejects a declared oversize trace without parsing a prefix", async () => { + const fetchImpl = (async () => + new Response("small", { + headers: { "content-length": String(50 * 1024 * 1024 + 1) }, + })) as unknown as typeof fetch; + const result = await downloadTrace( + "https://storage.invalid/trace", + fetchImpl, + ); + expect(result).toEqual({ status: "too-large" }); + }); + + it("requests a bounded log tail without authorization", async () => { + let request: RequestInit | undefined; + const fetchImpl = (async ( + _url: string | URL | Request, + init?: RequestInit, + ) => { + request = init; + return new Response("{}\n"); + }) as unknown as typeof fetch; + await downloadLogTail("https://storage.invalid/logs", fetchImpl); + expect(request?.headers).toEqual({ range: "bytes=-5242880" }); + expect(request?.headers).not.toHaveProperty("authorization"); + }); + + it("reports a timed-out transfer separately from other failures", async () => { + const timedOutBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.error(new DOMException("timed out", "TimeoutError")); + }, + }); + const timingOut = (async () => + new Response(timedOutBody)) as unknown as typeof fetch; + const failing = (async () => { + throw new TypeError("network down"); + }) as unknown as typeof fetch; + + expect( + await downloadTrace("https://storage.invalid/trace", timingOut), + ).toEqual({ status: "timed-out" }); + expect( + await downloadLogTail("https://storage.invalid/logs", failing), + ).toEqual({ status: "unavailable" }); + }); +}); diff --git a/src/domains/investigation/download.ts b/src/domains/investigation/download.ts new file mode 100644 index 000000000..cdd0140da --- /dev/null +++ b/src/domains/investigation/download.ts @@ -0,0 +1,131 @@ +import { isTimeoutError } from "~/core/errors.js"; + +export type ArtifactDownload = + | { bytes: Uint8Array; offset: number; status: "available" } + | { + status: + | "expired-url" + | "not-found" + | "timed-out" + | "too-large" + | "unavailable"; + }; + +const traceLimit = 50 * 1024 * 1024; +const logTailLimit = 5 * 1024 * 1024; +const logTransferLimit = 50 * 1024 * 1024; +// Covers the whole transfer, not just the response headers: 50 MB in two +// minutes still allows a slow (~3.5 Mbit/s) connection. +const downloadTimeoutMs = 120_000; + +function downloadFailure(error: unknown): ArtifactDownload { + return { status: isTimeoutError(error) ? "timed-out" : "unavailable" }; +} + +async function responseFailure(response: Response): Promise { + await response.body?.cancel().catch(() => undefined); + if (response.status === 404) return { status: "not-found" }; + if (response.status === 401 || response.status === 403) + return { status: "expired-url" }; + return { status: "unavailable" }; +} + +async function readBounded( + response: Response, + options: { keepTail: boolean; retainBytes: number; transferBytes: number }, +): Promise { + if (!response.ok) return responseFailure(response); + const declaredLength = Number(response.headers.get("content-length")); + if ( + Number.isFinite(declaredLength) && + declaredLength > options.transferBytes + ) { + await response.body?.cancel().catch(() => undefined); + return { status: "too-large" }; + } + if (!response.body) return { status: "unavailable" }; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let retained = 0; + let transferred = 0; + while (true) { + const next = await reader.read(); + if (next.done) break; + const chunk: unknown = next.value; + if (!(chunk instanceof Uint8Array)) { + await reader.cancel(); + return { status: "unavailable" }; + } + transferred += chunk.byteLength; + if (transferred > options.transferBytes) { + await reader.cancel(); + return { status: "too-large" }; + } + chunks.push(chunk); + retained += chunk.byteLength; + if (options.keepTail) { + while ( + chunks.length > 1 && + retained - chunks[0]!.byteLength >= options.retainBytes + ) { + retained -= chunks.shift()!.byteLength; + } + } else if (retained > options.retainBytes) { + await reader.cancel(); + return { status: "too-large" }; + } + } + let bytes = Buffer.concat(chunks); + const contentRange = response.headers.get("content-range"); + const rangeStart = contentRange + ? Number(/^bytes (\d+)-/.exec(contentRange)?.[1]) + : Number.NaN; + let offset = Number.isFinite(rangeStart) + ? rangeStart + : transferred - bytes.byteLength; + if (bytes.byteLength > options.retainBytes) { + const removed = bytes.byteLength - options.retainBytes; + bytes = bytes.subarray(removed); + offset += removed; + } + return { bytes, offset, status: "available" }; +} + +export async function downloadTrace( + url: string, + fetchImpl: typeof globalThis.fetch = globalThis.fetch, +): Promise { + try { + const signal = AbortSignal.timeout(downloadTimeoutMs); + const response = await fetchImpl(url, { redirect: "follow", signal }); + return await readBounded(response, { + keepTail: false, + retainBytes: traceLimit, + transferBytes: traceLimit, + }); + } catch (error) { + return downloadFailure(error); + } +} + +export async function downloadLogTail( + url: string, + fetchImpl: typeof globalThis.fetch = globalThis.fetch, +): Promise { + try { + const signal = AbortSignal.timeout(downloadTimeoutMs); + const response = await fetchImpl(url, { + headers: { range: `bytes=-${logTailLimit}` }, + redirect: "follow", + signal, + }); + return await readBounded(response, { + keepTail: true, + retainBytes: logTailLimit, + transferBytes: logTransferLimit, + }); + } catch (error) { + return downloadFailure(error); + } +} diff --git a/src/domains/investigation/handle.artifacts.test.ts b/src/domains/investigation/handle.artifacts.test.ts new file mode 100644 index 000000000..7032ea016 --- /dev/null +++ b/src/domains/investigation/handle.artifacts.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; + +import { attemptArtifactsFixture as fixture } from "./attemptArtifacts.fixtures.js"; +import { loadInspectArtifact, loadSummaryArtifacts } from "./handle.js"; + +const originalFetch = globalThis.fetch; + +function requestUrl(input: string | URL | Request): string { + return input instanceof Request ? input.url : String(input); +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("investigation artifact selection", () => { + it("downloads only logs for log inspection", async () => { + const requested: string[] = []; + globalThis.fetch = mock(async (input: string | URL | Request) => { + requested.push(requestUrl(input)); + return new Response("", { status: 200 }); + }) as unknown as typeof fetch; + + await loadInspectArtifact(fixture(), "log"); + + expect(requested).toEqual([ + "https://storage.invalid/logs.txt?token=secret", + ]); + }); + + it("downloads only trace for non-log inspection", async () => { + const requested: string[] = []; + globalThis.fetch = mock(async (input: string | URL | Request) => { + requested.push(requestUrl(input)); + return new Response("", { status: 200 }); + }) as unknown as typeof fetch; + + await loadInspectArtifact(fixture(), "timeline"); + + expect(requested).toEqual([ + "https://storage.invalid/trace.zip?token=secret", + ]); + }); + + it("downloads both artifacts for a summary", async () => { + const requested: string[] = []; + globalThis.fetch = mock(async (input: string | URL | Request) => { + requested.push(requestUrl(input)); + return new Response("", { status: 200 }); + }) as unknown as typeof fetch; + + await loadSummaryArtifacts(fixture()); + + expect(requested.sort()).toEqual([ + "https://storage.invalid/logs.txt?token=secret", + "https://storage.invalid/trace.zip?token=secret", + ]); + }); +}); diff --git a/src/domains/investigation/handle.output.test.ts b/src/domains/investigation/handle.output.test.ts new file mode 100644 index 000000000..bf107d881 --- /dev/null +++ b/src/domains/investigation/handle.output.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; + +import { makeCtx } from "~/shell/commandContext.testUtils.js"; +import { + outputInvestigationInspection, + type InspectOptions, +} from "./handle.js"; + +const options: InspectOptions = { + attemptId: "attempt-1", + format: "html", + limit: "20", + snapshotId: "snapshot-1", + type: "snapshot", +}; + +describe("investigation inspection output", () => { + it("returns sanitized HTML document bytes inline when no file is requested", async () => { + const ctx = makeCtx("json"); + const html = + 'safe'; + + await outputInvestigationInspection(ctx, options, { + binary: { + bytesBase64: Buffer.from(html).toString("base64"), + extension: "html", + }, + status: "available", + }); + + expect(ctx.ui.output).toHaveBeenCalledWith( + { html, status: "available" }, + html, + ); + }); +}); diff --git a/src/domains/investigation/handle.test.ts b/src/domains/investigation/handle.test.ts new file mode 100644 index 000000000..522dc2126 --- /dev/null +++ b/src/domains/investigation/handle.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import JSZip from "jszip"; + +import { makeAuthCtx } from "~/shell/commandContext.testUtils.js"; +import { attemptArtifactsFixture } from "./attemptArtifacts.fixtures.js"; +import { + handleInvestigationInspect, + handleInvestigationSummary, +} from "./handle.js"; + +const originalFetch = globalThis.fetch; +const actionId = "trace.trace#0:action:call@1"; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +async function failedClickTrace(): Promise { + const zip = new JSZip(); + zip.file( + "trace.trace", + [ + { type: "context-options" }, + { + callId: "call@1", + class: "Frame", + method: "click", + params: { selector: "internal:role=button" }, + startTime: 1, + type: "before", + }, + { + callId: "call@1", + endTime: 2, + error: { message: "Click failed" }, + type: "after", + }, + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", + ); + return zip.generateAsync({ type: "uint8array" }); +} + +function serveArtifacts(trace: Response) { + const fetchMock = mock(async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input); + return url.includes("trace.zip") ? trace : new Response(""); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +describe("handleInvestigationSummary", () => { + it("summarizes the trace without echoing the signed artifact URLs", async () => { + const { callPublicApi, ctx, outputs } = makeAuthCtx("json"); + callPublicApi.mockResolvedValue({ + ok: true, + value: attemptArtifactsFixture(), + }); + serveArtifacts(new Response(await failedClickTrace())); + + const result = await handleInvestigationSummary(ctx, "attempt-1"); + + expect(result).toBeUndefined(); + expect(callPublicApi.mock.calls[0]?.[1]).toEqual({ + attemptId: "attempt-1", + }); + expect(outputs()[0]?.data).toMatchObject({ + evidence: { + actions: { + items: [{ actionId, error: "Click failed" }], + }, + traceStatus: "ready", + }, + runId: "run-1", + }); + expect(JSON.stringify(outputs()[0]?.data)).not.toContain("token=secret"); + }); + + it("returns the API failure without downloading anything", async () => { + const { callPublicApi, ctx, outputs } = makeAuthCtx("json"); + callPublicApi.mockResolvedValue({ error: "Attempt not found", ok: false }); + const fetchMock = serveArtifacts(new Response("")); + + const result = await handleInvestigationSummary(ctx, "attempt-1"); + + expect(result).toEqual({ error: "Attempt not found" }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(outputs()).toEqual([]); + }); +}); + +describe("handleInvestigationInspect", () => { + it("returns one action from the trace", async () => { + const { callPublicApi, ctx, outputs } = makeAuthCtx("json"); + callPublicApi.mockResolvedValue({ + ok: true, + value: attemptArtifactsFixture(), + }); + serveArtifacts(new Response(await failedClickTrace())); + + const result = await handleInvestigationInspect(ctx, { + actionId, + attemptId: "attempt-1", + format: "text", + type: "action", + }); + + expect(result).toBeUndefined(); + expect(outputs()[0]?.data).toMatchObject({ + action: { actionId }, + status: "available", + traceStatus: "ready", + }); + }); + + it("reports an expired trace URL as unavailable", async () => { + const { callPublicApi, ctx, outputs } = makeAuthCtx("json"); + callPublicApi.mockResolvedValue({ + ok: true, + value: attemptArtifactsFixture(), + }); + serveArtifacts(new Response("", { status: 403 })); + + const result = await handleInvestigationInspect(ctx, { + actionId, + attemptId: "attempt-1", + format: "text", + type: "action", + }); + + expect(result).toBeUndefined(); + expect(outputs()[0]?.data).toEqual({ + attemptId: "attempt-1", + status: "unavailable", + traceStatus: "expired-url", + type: "action", + }); + }); +}); diff --git a/src/domains/investigation/handle.ts b/src/domains/investigation/handle.ts new file mode 100644 index 000000000..d0906c851 --- /dev/null +++ b/src/domains/investigation/handle.ts @@ -0,0 +1,315 @@ +// oxlint-disable eslint/max-lines -- Keep authorization, artifact fetch, and rendering order visible in the command handler. + +import { basename } from "node:path"; + +import { publicContractsV1 } from "@qawolf/api-contracts/v1"; +import type { z } from "zod"; + +import { failureFields } from "~/shell/platform/requestWithRetry.js"; +import type { + AuthCommandContext, + CommandResult, +} from "~/shell/commandContext.js"; + +import { downloadLogTail, downloadTrace } from "./download.js"; +import { loadInvestigationParser } from "./lazyParser.js"; +import type { + ArtifactInput, + InspectType, + InvestigationRequest, + InvestigationResult, +} from "./types.js"; + +export type AttemptArtifacts = z.infer< + typeof publicContractsV1.attempt.get.output +>; + +export type InspectOptions = { + actionId?: string; + attemptId: string; + endTimeMs?: string; + endTimestamp?: string; + evidenceId?: string; + format: "html" | "text"; + limit?: string; + method?: string; + outputFile?: string; + requestId?: string; + screenshotId?: string; + snapshotId?: string; + source?: "qawolfTraceCollection" | "serverConsole"; + startTimeMs?: string; + startTimestamp?: string; + status?: string; + type: InspectType; + urlContains?: string; +}; + +function artifactUnavailable(status: "not-captured" | "signing-failed") { + return { status } as const; +} + +export async function loadSummaryArtifacts( + value: AttemptArtifacts, +): Promise<{ logs: ArtifactInput; trace: ArtifactInput }> { + if (value.artifactStatus !== "signed") { + const unavailable = artifactUnavailable(value.artifactStatus); + return { logs: unavailable, trace: unavailable }; + } + const [logs, trace] = await Promise.all([ + value.artifacts.logsUrl + ? downloadLogTail(value.artifacts.logsUrl) + : artifactUnavailable("not-captured"), + value.artifacts.traceUrl + ? downloadTrace(value.artifacts.traceUrl) + : artifactUnavailable("not-captured"), + ]); + return { logs, trace }; +} + +export async function loadInspectArtifact( + value: AttemptArtifacts, + type: InspectType, +): Promise<{ logs: ArtifactInput; trace: ArtifactInput }> { + const inactive = artifactUnavailable("not-captured"); + if (value.artifactStatus !== "signed") { + const unavailable = artifactUnavailable(value.artifactStatus); + return type === "log" + ? { logs: unavailable, trace: inactive } + : { logs: inactive, trace: unavailable }; + } + if (type === "log") { + return { + logs: value.artifacts.logsUrl + ? await downloadLogTail(value.artifacts.logsUrl) + : inactive, + trace: inactive, + }; + } + return { + logs: inactive, + trace: value.artifacts.traceUrl + ? await downloadTrace(value.artifacts.traceUrl) + : inactive, + }; +} + +const defaultInspectLimit = "20"; +const maxInspectLimit = 100; + +const filterSelectors: [keyof InspectOptions, string, InspectType[]][] = [ + ["evidenceId", "--evidence-id", ["console", "log"]], + ["limit", "--limit", ["timeline", "network", "console", "log"]], + ["startTimeMs", "--start-time-ms", ["network", "console"]], + ["endTimeMs", "--end-time-ms", ["network", "console"]], + ["startTimestamp", "--start-timestamp", ["log"]], + ["endTimestamp", "--end-timestamp", ["log"]], + ["source", "--source", ["log"]], + ["method", "--method", ["network"]], + ["status", "--status", ["network"]], + ["urlContains", "--url-contains", ["network"]], +]; + +function inapplicableFilter(options: InspectOptions): CommandResult { + for (const [field, flag, selectors] of filterSelectors) { + if (options[field] === undefined || selectors.includes(options.type)) + continue; + const allowed = selectors.map((selector) => `--${selector}`).join(", "); + return { + error: `${flag} does not apply to --${options.type}; use it with ${allowed}.`, + }; + } + return undefined; +} + +function numberFlag( + value: string | undefined, + name: string, + options: { integer?: boolean; max?: number; min?: number } = {}, +): number | undefined | { error: string } { + if (value === undefined) return undefined; + const parsed = Number(value); + if ( + !Number.isFinite(parsed) || + (options.integer === true && !Number.isInteger(parsed)) || + (options.min !== undefined && parsed < options.min) + ) + return { error: `Invalid ${name}: ${value}.` }; + if (options.max !== undefined && parsed > options.max) + return { error: `${name} must be at most ${options.max}, got ${value}.` }; + return parsed; +} + +function timestampFlag( + value: string | undefined, + name: string, +): { error: string } | undefined { + if (value === undefined || !Number.isNaN(Date.parse(value))) return undefined; + return { + error: `Invalid ${name}: ${value}. Use an ISO 8601 timestamp such as 2026-01-31T12:00:00Z.`, + }; +} + +function validateInspect(options: InspectOptions): CommandResult { + if (options.type === "action" && !options.actionId) + return { error: "--action requires an evidence ID." }; + if (options.type === "request" && !options.requestId) + return { error: "--request requires an evidence ID." }; + if (options.type === "snapshot" && !options.snapshotId) + return { error: "--snapshot requires an evidence ID." }; + if (options.type === "screenshot" && !options.screenshotId) + return { error: "--screenshot requires an evidence ID." }; + if (options.format === "html" && options.type !== "snapshot") + return { error: "--format html is supported only with --snapshot." }; + if (options.type === "screenshot" && !options.outputFile) + return { + error: "--output-file is required for screenshot evidence.", + }; + if ( + options.outputFile && + options.format !== "html" && + options.type !== "screenshot" + ) + return { + error: + "--output-file is supported only with --snapshot and --format html, or with --screenshot.", + }; + return undefined; +} + +function renderText(result: Record): string { + return JSON.stringify(result, undefined, 2); +} + +async function fetchAttempt(ctx: AuthCommandContext, attemptId: string) { + return ctx.platformClient.callPublicApi(publicContractsV1.attempt.get, { + attemptId, + }); +} + +export async function handleInvestigationSummary( + ctx: AuthCommandContext, + attemptId: string, +): Promise { + const authorized = await fetchAttempt(ctx, attemptId); + if (!authorized.ok) return failureFields(authorized); + const artifacts = await loadSummaryArtifacts(authorized.value); + const parser = await loadInvestigationParser(); + const evidence = await parser.investigate({ + artifacts, + attempt: authorized.value.attempt, + flowId: authorized.value.flowId, + mode: "summary", + runId: authorized.value.runId, + }); + const result = { + ...authorized.value, + artifacts: undefined, + evidence, + }; + ctx.ui.output(result, renderText(result)); + return undefined; +} + +export async function handleInvestigationInspect( + ctx: AuthCommandContext, + options: InspectOptions, +): Promise { + const invalid = validateInspect(options) ?? inapplicableFilter(options); + if (invalid) return invalid; + if (options.outputFile && (await ctx.fs.pathExists(options.outputFile))) + return { + error: `Refusing to overwrite existing file: ${options.outputFile}`, + }; + + const limit = numberFlag(options.limit ?? defaultInspectLimit, "--limit", { + integer: true, + max: maxInspectLimit, + min: 1, + }); + if (typeof limit !== "number") return limit; + const startTimeMilliseconds = numberFlag( + options.startTimeMs, + "--start-time-ms", + ); + if (typeof startTimeMilliseconds === "object") return startTimeMilliseconds; + const endTimeMilliseconds = numberFlag(options.endTimeMs, "--end-time-ms"); + if (typeof endTimeMilliseconds === "object") return endTimeMilliseconds; + const status = numberFlag(options.status, "--status", { + integer: true, + min: 0, + }); + if (typeof status === "object") return status; + const timestampError = + timestampFlag(options.startTimestamp, "--start-timestamp") ?? + timestampFlag(options.endTimestamp, "--end-timestamp"); + if (timestampError) return timestampError; + + const authorized = await fetchAttempt(ctx, options.attemptId); + if (!authorized.ok) return failureFields(authorized); + const artifacts = await loadInspectArtifact(authorized.value, options.type); + const request: InvestigationRequest = { + artifacts, + attempt: authorized.value.attempt, + flowId: authorized.value.flowId, + inspect: { + actionId: options.actionId, + endTimeMilliseconds, + endTimestamp: options.endTimestamp, + evidenceId: options.evidenceId, + format: options.format, + limit, + method: options.method, + requestId: options.requestId, + screenshotId: options.screenshotId, + snapshotId: options.snapshotId, + source: options.source, + startTimeMilliseconds, + startTimestamp: options.startTimestamp, + status, + type: options.type, + urlContains: options.urlContains, + }, + mode: "inspect", + runId: authorized.value.runId, + }; + const parser = await loadInvestigationParser(); + const parsed = await parser.investigate(request); + return outputInvestigationInspection(ctx, options, parsed); +} + +export async function outputInvestigationInspection( + ctx: Pick, + options: InspectOptions, + parsed: InvestigationResult, +): Promise { + const { binary, ...result } = parsed; + if (options.outputFile) { + if (!binary) + return { + error: "The requested evidence did not contain exportable data.", + }; + const written = await ctx.fs.writeFileExclusive( + options.outputFile, + Buffer.from(binary.bytesBase64, "base64"), + { mode: 0o600 }, + ); + if (!written) + return { + error: `Refusing to overwrite existing file: ${options.outputFile}`, + }; + const output = { ...result, outputFile: options.outputFile }; + ctx.ui.output( + output, + `${renderText(result)}\nSaved ${binary.extension}: ${basename(options.outputFile)}`, + ); + return undefined; + } + if (binary?.extension === "html") { + const html = Buffer.from(binary.bytesBase64, "base64").toString("utf8"); + ctx.ui.output({ ...result, html }, html); + return undefined; + } + ctx.ui.output(result, renderText(result)); + return undefined; +} diff --git a/src/domains/investigation/handle.validation.test.ts b/src/domains/investigation/handle.validation.test.ts new file mode 100644 index 000000000..3f6730155 --- /dev/null +++ b/src/domains/investigation/handle.validation.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; + +import { makeAuthCtx } from "~/shell/commandContext.testUtils.js"; +import { handleInvestigationInspect, type InspectOptions } from "./handle.js"; + +const base: InspectOptions = { + attemptId: "attempt-1", + format: "text", + type: "network", +}; + +async function inspectError(options: Partial) { + const { callPublicApi, ctx } = makeAuthCtx("json"); + const result = await handleInvestigationInspect(ctx, { + ...base, + ...options, + }); + expect(callPublicApi).not.toHaveBeenCalled(); + return result; +} + +describe("investigation inspect flag validation", () => { + it("rejects a --limit above 100 before calling the API", async () => { + expect(await inspectError({ limit: "101" })).toEqual({ + error: "--limit must be at most 100, got 101.", + }); + }); + + it.each([ + ["startTimestamp", "--start-timestamp"], + ["endTimestamp", "--end-timestamp"], + ] as const)( + "rejects an unparseable %s before calling the API", + async (field, flag) => { + expect(await inspectError({ [field]: "yesterday", type: "log" })).toEqual( + { + error: `Invalid ${flag}: yesterday. Use an ISO 8601 timestamp such as 2026-01-31T12:00:00Z.`, + }, + ); + }, + ); + + it.each([ + [ + { method: "GET", type: "console" }, + "--method does not apply to --console; use it with --network.", + ], + [ + { source: "serverConsole", type: "network" }, + "--source does not apply to --network; use it with --log.", + ], + [ + { limit: "5", actionId: "action-1", type: "action" }, + "--limit does not apply to --action; use it with --timeline, --network, --console, --log.", + ], + ] as const)( + "rejects a filter that does not apply to the selector", + async (options, error) => { + expect(await inspectError(options)).toEqual({ error }); + }, + ); +}); diff --git a/src/domains/investigation/lazyParser.test.ts b/src/domains/investigation/lazyParser.test.ts new file mode 100644 index 000000000..9ba03b251 --- /dev/null +++ b/src/domains/investigation/lazyParser.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; + +import { loadInvestigationParser } from "./lazyParser.js"; + +describe("loadInvestigationParser", () => { + it("loads the parser from source without a build", async () => { + const parser = await loadInvestigationParser(); + const result = await parser.investigate({ + artifacts: { + logs: { status: "not-captured" }, + trace: { status: "not-captured" }, + }, + attempt: { attemptId: "attempt-1", status: "failed" }, + flowId: "flow-1", + mode: "summary", + runId: "run-1", + }); + expect(result["traceStatus"]).toBe("not-captured"); + }); +}); diff --git a/src/domains/investigation/lazyParser.ts b/src/domains/investigation/lazyParser.ts new file mode 100644 index 000000000..a89a51a12 --- /dev/null +++ b/src/domains/investigation/lazyParser.ts @@ -0,0 +1,23 @@ +import type { InvestigationRequest, InvestigationResult } from "./types.js"; + +type InvestigationParser = { + investigate(request: InvestigationRequest): Promise; +}; + +let parserPromise: Promise | undefined; + +export function loadInvestigationParser(): Promise { + parserPromise ??= import(parserSpecifier()) as Promise; + return parserPromise; +} + +function parserSpecifier(): string { + const embedded = (globalThis as { qawolfInvestigationParserPath?: string }) + .qawolfInvestigationParserPath; + if (embedded) return embedded; + const sourceModule = import.meta.url.includes("/src/domains/investigation/"); + return new URL( + sourceModule ? "./parser/index.ts" : "./investigation-parser.js", + import.meta.url, + ).href; +} diff --git a/src/domains/investigation/parser/executionLogs.ts b/src/domains/investigation/parser/executionLogs.ts new file mode 100644 index 000000000..65b860629 --- /dev/null +++ b/src/domains/investigation/parser/executionLogs.ts @@ -0,0 +1,128 @@ +import type { + ArtifactInput, + InvestigationRequest, +} from "~/domains/investigation/types.js"; +import { redactAttemptEvidence } from "./projection/attemptEvidenceText.js"; +import { parseRunLogs } from "./trace/runLogs.js"; + +type PublicLog = { + evidenceId: string; + message: string; + severity: string; + source: "qawolfTraceCollection" | "serverConsole"; + timestamp: string; +}; + +const emptyCoverage = { + incomplete: false, + malformed: 0, + returned: 0, + total: 0, + truncated: false, +}; + +function parseExecutionLogs(input: ArtifactInput) { + if (input.status !== "available") + return { + coverage: emptyCoverage, + items: [] as PublicLog[], + status: input.status, + }; + const firstNewline = input.offset > 0 ? input.bytes.indexOf(0x0a) : -1; + const skipped = firstNewline === -1 ? 0 : firstNewline + 1; + const parsed = parseRunLogs(Buffer.from(input.bytes.subarray(skipped))); + const records = "logs" in parsed ? parsed.logs.records : []; + const items = records.flatMap((record) => { + const source = + record.source === "serverConsole" + ? ("serverConsole" as const) + : record.source === "qawolf" && + /playwright trace|tracing on context/i.test(record.message) + ? ("qawolfTraceCollection" as const) + : undefined; + return source + ? [ + { + evidenceId: `log:${input.offset + skipped + record.byteOffset}`, + message: redactAttemptEvidence(record.message), + severity: record.severity, + source, + timestamp: record.timestamp, + }, + ] + : []; + }); + const incomplete = input.offset > 0 || parsed.status === "incomplete"; + return { + coverage: + "logs" in parsed + ? { + ...parsed.logs.coverage, + incomplete, + total: items.length, + returned: items.length, + truncated: incomplete || parsed.logs.coverage.truncated, + } + : emptyCoverage, + items, + status: incomplete + ? "incomplete" + : parsed.status === "ready" + ? "available" + : parsed.status, + }; +} + +export function inspectExecutionLogs(request: InvestigationRequest) { + const target = request.inspect!; + const parsed = parseExecutionLogs(request.artifacts.logs); + const matching = parsed.items.filter( + (record) => + (target.evidenceId === undefined || + record.evidenceId === target.evidenceId) && + (target.source === undefined || record.source === target.source) && + (target.startTimestamp === undefined || + Date.parse(record.timestamp) >= Date.parse(target.startTimestamp)) && + (target.endTimestamp === undefined || + Date.parse(record.timestamp) <= Date.parse(target.endTimestamp)), + ); + const entries = matching.slice(-target.limit); + return { + attemptId: request.attempt.attemptId, + coverage: { + ...parsed.coverage, + returned: entries.length, + total: matching.length, + truncated: parsed.coverage.truncated || matching.length > entries.length, + }, + entries, + logStatus: parsed.status, + status: entries.length ? "available" : "not-found", + traceStatus: "unchecked", + type: "log", + }; +} + +export function summarizeExecutionLogs(input: ArtifactInput) { + const parsed = parseExecutionLogs(input); + const selected = parsed.items + .filter( + (record) => + record.source === "qawolfTraceCollection" || + record.severity === "error", + ) + .slice(-20); + return { + artifactState: parsed.status, + evidence: { + coverage: { + ...parsed.coverage, + returned: selected.length, + total: parsed.items.length, + truncated: + parsed.coverage.truncated || selected.length < parsed.items.length, + }, + items: selected, + }, + }; +} diff --git a/src/domains/investigation/parser/index.security.test.ts b/src/domains/investigation/parser/index.security.test.ts new file mode 100644 index 000000000..b320d36d9 --- /dev/null +++ b/src/domains/investigation/parser/index.security.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "bun:test"; +import JSZip from "jszip"; + +import { investigate } from "./index.js"; + +async function snapshotTrace(html: unknown) { + const zip = new JSZip(); + zip.file( + "trace.trace", + [ + { type: "context-options" }, + { + snapshot: { + frameId: "frame-1", + frameUrl: "https://x.test", + html, + isMainFrame: true, + pageId: "page-1", + snapshotName: "after@call-1", + timestamp: 1, + }, + type: "frame-snapshot", + }, + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", + ); + return zip.generateAsync({ type: "uint8array" }); +} + +describe("investigation parser security", () => { + it("filters network inspection by HTTP method and redacts URL userinfo", async () => { + const zip = new JSZip(); + zip.file("trace.trace", '{"type":"context-options"}\n'); + zip.file( + "trace.network", + ["GET", "POST"] + .map((method) => + JSON.stringify({ + snapshot: { + request: { + headers: [], + method, + url: + method === "POST" + ? "https://user:synthetic-secret@x.test/post" + : "https://x.test/get", + }, + response: { headers: [], status: 200 }, + }, + type: "resource-snapshot", + }), + ) + .join("\n") + "\n", + ); + const trace = await zip.generateAsync({ type: "uint8array" }); + const result = await investigate({ + artifacts: { + logs: { status: "not-captured" }, + trace: { bytes: trace, offset: 0, status: "available" }, + }, + attempt: { attemptId: "attempt-1", status: "failed" }, + flowId: "flow-1", + inspect: { + format: "text", + limit: 20, + method: "POST", + type: "network", + }, + mode: "inspect", + runId: "run-1", + }); + const entries = result["entries"] as { method: string; url: string }[]; + + expect(entries).toHaveLength(1); + expect(entries[0]?.method).toBe("POST"); + expect(entries[0]?.url).not.toContain("synthetic-secret"); + expect(entries[0]?.url).toContain("[REDACTED]"); + }); + + it("redacts password textarea content from snapshot text metadata", async () => { + const trace = await snapshotTrace([ + "HTML", + {}, + ["BODY", {}, ["TEXTAREA", { name: "password" }, "synthetic-secret"]], + ]); + for (const format of ["text", "html"] as const) { + const result = await investigate({ + artifacts: { + logs: { status: "not-captured" }, + trace: { bytes: trace, offset: 0, status: "available" }, + }, + attempt: { attemptId: "attempt-1", status: "failed" }, + flowId: "flow-1", + inspect: { + format, + limit: 20, + snapshotId: "trace.trace#0:snapshot:1", + type: "snapshot", + }, + mode: "inspect", + runId: "run-1", + }); + + expect(result["text"]).not.toContain("synthetic-secret"); + expect(result["text"]).toContain("[REDACTED]"); + if (format === "html") { + const binary = result.binary!; + const html = Buffer.from(binary.bytesBase64, "base64").toString(); + expect(html).not.toContain("synthetic-secret"); + expect(html).toContain("[REDACTED]"); + } + } + }); + + it("preserves long DOM text and attributes until the reported final bound", async () => { + const longText = `${"x".repeat(150)}failure-suffix`; + const longLabel = `${"y".repeat(60)}label-suffix`; + const trace = await snapshotTrace([ + "HTML", + {}, + ["BODY", {}, ["BUTTON", { "aria-label": longLabel }, longText]], + ]); + const common = { + artifacts: { + logs: { status: "not-captured" }, + trace: { bytes: trace, offset: 0, status: "available" }, + }, + attempt: { attemptId: "attempt-1", status: "failed" }, + flowId: "flow-1", + runId: "run-1", + } as const; + const inspected = await investigate({ + ...common, + inspect: { + format: "text", + limit: 20, + snapshotId: "trace.trace#0:snapshot:1", + type: "snapshot", + }, + mode: "inspect", + }); + const summarized = await investigate({ ...common, mode: "summary" }); + const pageState = summarized["pageState"] as { + text: string; + truncated: boolean; + }; + + expect(inspected["text"]).toContain("failure-suffix"); + expect(inspected["text"]).toContain("label-suffix"); + expect(inspected["textTruncated"]).toBe(false); + expect(pageState.text).toContain("failure-suffix"); + expect(pageState.text).toContain("label-suffix"); + expect(pageState.truncated).toBe(false); + }); + + it("does not project an unstructured prefix from a truncated snapshot", async () => { + const secret = "REVIEW_SYNTHETIC_SECRET"; + const trace = await snapshotTrace([ + "HTML", + {}, + [ + "BODY", + {}, + ["TEXTAREA", { name: "password" }, secret], + ["DIV", {}, "x".repeat(300_000)], + ], + ]); + const common = { + artifacts: { + logs: { status: "not-captured" }, + trace: { bytes: trace, offset: 0, status: "available" }, + }, + attempt: { attemptId: "attempt-1", status: "failed" }, + flowId: "flow-1", + runId: "run-1", + } as const; + + for (const format of ["text", "html"] as const) { + const inspected = await investigate({ + ...common, + inspect: { + format, + limit: 20, + snapshotId: "trace.trace#0:snapshot:1", + type: "snapshot", + }, + mode: "inspect", + }); + + expect(JSON.stringify(inspected)).not.toContain(secret); + expect(inspected["text"]).toBe(""); + expect(inspected["textTruncated"]).toBe(true); + if (format === "html") { + expect(inspected["htmlTruncated"]).toBe(true); + expect(inspected.binary).toBeUndefined(); + } + } + + const summarized = await investigate({ ...common, mode: "summary" }); + const pageState = summarized["pageState"] as { + text: string; + truncated: boolean; + }; + expect(JSON.stringify(summarized)).not.toContain(secret); + expect(pageState.text).toBe(""); + expect(pageState.truncated).toBe(true); + }); +}); diff --git a/src/domains/investigation/parser/index.summary.test.ts b/src/domains/investigation/parser/index.summary.test.ts new file mode 100644 index 000000000..88106d4f0 --- /dev/null +++ b/src/domains/investigation/parser/index.summary.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test"; +import JSZip from "jszip"; + +import { investigate } from "./index.js"; + +describe("investigation summary", () => { + it("shows which summarized action failed when the attempt error differs", async () => { + const zip = new JSZip(); + zip.file( + "trace.trace", + [ + { type: "context-options" }, + { + callId: "call@1", + class: "Frame", + method: "expect", + params: { selector: "internal:role=button" }, + startTime: 1, + type: "before", + }, + { + callId: "call@1", + endTime: 2, + error: { message: "Expect failed" }, + type: "after", + }, + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", + ); + const trace = await zip.generateAsync({ type: "uint8array" }); + const result = await investigate({ + artifacts: { + logs: { status: "not-captured" }, + trace: { bytes: trace, offset: 0, status: "available" }, + }, + attempt: { + attemptId: "attempt-1", + error: "expect(locator).toBeVisible() failed", + status: "failed", + }, + flowId: "flow-1", + mode: "summary", + runId: "run-1", + }); + const actions = result["actions"] as { items: unknown[] }; + + expect(result["failedBrowserActionAttribution"]).toBe("none"); + expect(actions.items).toEqual([ + expect.objectContaining({ + actionId: "trace.trace#0:action:call@1", + error: "Expect failed", + selector: "internal:role=button", + }), + ]); + }); +}); diff --git a/src/domains/investigation/parser/index.test.ts b/src/domains/investigation/parser/index.test.ts new file mode 100644 index 000000000..ad4ef3d37 --- /dev/null +++ b/src/domains/investigation/parser/index.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "bun:test"; +import JSZip from "jszip"; + +import { investigate } from "./index.js"; +import { redactAttemptEvidence } from "./projection/attemptEvidenceText.js"; + +describe("investigation parser", () => { + it("redacts conventional secret fields and signed URLs", () => { + const value = redactAttemptEvidence( + 'authorization: Bearer secret\n{"token":"abc"}\nhttps://x.test/a?x-goog-signature=secret&ok=1\nembedded https://user:synthetic@secret@example.test/path and //user:protocol@secret@example.test/path', + ); + expect(value).not.toContain("secret"); + expect(value).not.toContain('"abc"'); + expect(value).toContain("[REDACTED]"); + }); + + it("redacts escaped and unterminated credential values", () => { + const escaped = redactAttemptEvidence( + String.raw`payload={\"token\":\"synthetic-secret\"}`, + ); + const unterminated = redactAttemptEvidence( + 'payload={"password":"synthetic-secret[TRUNCATED]', + ); + expect(escaped).not.toContain("synthetic-secret"); + expect(unterminated).not.toContain("synthetic-secret"); + expect(escaped).toContain("[REDACTED]"); + expect(unterminated).toContain("[REDACTED]"); + }); + + it("redacts a JWT that no field name points at", () => { + const jwt = + "eyJhbGciOiJSUzI1NiJ9.eyJzaWQiOiJzeW50aGV0aWMifQ.c3ludGhldGljLXNpZw"; + const value = redactAttemptEvidence( + `0:["$","div"]\n2:T4a3,${jwt}\n3:"visible"`, + ); + + expect(value).not.toContain(jwt); + expect(value).toContain("2:T4a3,[REDACTED]"); + expect(value).toContain('3:"visible"'); + }); + + it("redacts a JWT cut off before its signature", () => { + const value = redactAttemptEvidence( + "session eyJhbGciOiJSUzI1NiJ9.eyJzaWQiOiJzeW50aGV0aWMifQ", + ); + + expect(value).toBe("session [REDACTED]"); + }); + + it.each([ + { + evidence: "Request failed: {'password': 'hunter2', 'safe': 'visible'}", + safe: "visible", + secret: "hunter2", + }, + { + evidence: '{"password":123456,"safe":"visible"}', + safe: "visible", + secret: "123456", + }, + { + evidence: 'fill("abc)secret-password") at visible locator', + safe: "visible locator", + secret: "secret-password", + }, + { + evidence: `{"password":'mixed-secret',"safe":"visible"}`, + safe: "visible", + secret: "mixed-secret", + }, + { + evidence: `{'password':"mixed-secret",'safe':'visible'}`, + safe: "visible", + secret: "mixed-secret", + }, + { + evidence: JSON.stringify( + JSON.stringify({ password: 123_456, safe: "visible" }), + ), + safe: "visible", + secret: "123456", + }, + { + evidence: 'fill(\n "multiline-secret"\n) visible', + safe: "visible", + secret: "multiline-secret", + }, + { + evidence: 'fill(unclosed\nvisible type("later-secret")', + safe: "fill([REDACTED]", + secret: "later-secret", + }, + ])("redacts structured credential evidence", ({ evidence, safe, secret }) => { + const redacted = redactAttemptEvidence(evidence); + + expect(redacted).toContain("[REDACTED]"); + expect(redacted).not.toContain(secret); + expect(redacted).toContain(safe); + }); + + it("preserves malformed trace status", async () => { + const result = await investigate({ + artifacts: { + logs: { status: "not-captured" }, + trace: { + bytes: Buffer.from("not a zip"), + offset: 0, + status: "available", + }, + }, + attempt: { attemptId: "attempt-1", status: "failed" }, + flowId: "flow-1", + inspect: { format: "text", limit: 20, type: "timeline" }, + mode: "inspect", + runId: "run-1", + }); + expect(result["traceStatus"]).toBe("malformed"); + }); + + it("preserves absolute log offsets in a bounded tail", async () => { + const first = "partial line\n"; + const record = JSON.stringify({ + message: + "late failure https://user:synthetic@secret@example.test/a and //user:protocol@secret@example.test/b", + severity: "error", + source: "serverConsole", + timestamp: "2026-09-29T00:00:00.000Z", + }); + const result = await investigate({ + artifacts: { + logs: { + bytes: Buffer.from(first + record + "\n"), + offset: 100, + status: "available", + }, + trace: { status: "not-captured" }, + }, + attempt: { attemptId: "attempt-1", status: "failed" }, + flowId: "flow-1", + inspect: { format: "text", limit: 20, type: "log" }, + mode: "inspect", + runId: "run-1", + }); + const entries = result["entries"] as { + evidenceId: string; + message: string; + }[]; + expect(entries).toHaveLength(1); + expect(entries[0]?.evidenceId).toBe( + `log:${100 + Buffer.byteLength(first)}`, + ); + expect(entries[0]?.message).toBe( + "late failure https://[REDACTED]@example.test/a and //[REDACTED]@example.test/b", + ); + }); + + it("reports status-zero and failure text as network errors", async () => { + const zip = new JSZip(); + zip.file("trace.trace", '{"type":"context-options"}\n'); + zip.file( + "trace.network", + `${JSON.stringify({ + snapshot: { + request: { headers: [], method: "GET", url: "https://x.test" }, + response: { + _failureText: "net::ERR_FAILED token=secret", + headers: [], + status: 0, + }, + }, + type: "resource-snapshot", + })}\n`, + ); + const trace = await zip.generateAsync({ type: "uint8array" }); + const result = await investigate({ + artifacts: { + logs: { status: "not-captured" }, + trace: { bytes: trace, offset: 0, status: "available" }, + }, + attempt: { attemptId: "attempt-1", status: "failed" }, + flowId: "flow-1", + mode: "summary", + runId: "run-1", + }); + const errors = result["networkErrors"] as { + items: { failureText: string; status: number }[]; + }; + expect(errors.items).toHaveLength(1); + expect(errors.items[0]).toMatchObject({ + failureText: "net::ERR_FAILED token=[REDACTED]", + status: 0, + }); + }); + + it("attributes a trace failure after conventional secret redaction", async () => { + const zip = new JSZip(); + zip.file( + "trace.trace", + [ + { type: "context-options" }, + { + callId: "call@1", + class: "Frame", + method: "click", + startTime: 1, + type: "before", + }, + { + callId: "call@1", + endTime: 2, + error: { + message: + "request failed https://example.test/a?token=synthetic-secret", + }, + type: "after", + }, + ] + .map((event) => JSON.stringify(event)) + .join("\n") + "\n", + ); + const trace = await zip.generateAsync({ type: "uint8array" }); + const result = await investigate({ + artifacts: { + logs: { status: "not-captured" }, + trace: { bytes: trace, offset: 0, status: "available" }, + }, + attempt: { + attemptId: "attempt-1", + error: "request failed https://example.test/a?token=[REDACTED]", + status: "failed", + }, + flowId: "flow-1", + mode: "summary", + runId: "run-1", + }); + expect(result["failedBrowserActionAttribution"]).toBe( + "trace-error-matches-attempt", + ); + }); +}); diff --git a/src/domains/investigation/parser/index.ts b/src/domains/investigation/parser/index.ts new file mode 100644 index 000000000..ca54208cc --- /dev/null +++ b/src/domains/investigation/parser/index.ts @@ -0,0 +1,181 @@ +// oxlint-disable eslint/max-lines -- Keep the lazy parser entry's typed dispatch visible in one module. + +import type { + InvestigationRequest, + InvestigationResult, +} from "~/domains/investigation/types.js"; +import { + inspectExecutionLogs, + summarizeExecutionLogs, +} from "./executionLogs.js"; +import { + inspectAction, + inspectActionTimeline, +} from "./projection/inspectAttemptActions.js"; +import { inspectNetwork } from "./projection/inspectAttemptNetwork.js"; +import { inspectSnapshot } from "./projection/inspectAttemptPage.js"; +import { + inspectConsole, + inspectRequest, + inspectScreenshot, +} from "./projection/inspectAttemptRecorded.js"; +import { summarizeAttemptTrace } from "./projection/summarizeAttemptTrace.js"; +import { parsePlaywrightTraceArchive } from "./trace/playwrightTrace.js"; + +function traceUnavailable( + request: InvestigationRequest, + traceStatus: string, +): InvestigationResult { + return { + attemptId: request.attempt.attemptId, + status: "unavailable", + traceStatus, + type: request.inspect?.type ?? "summary", + }; +} + +async function parseTrace(request: InvestigationRequest) { + const input = request.artifacts.trace; + return input.status === "available" + ? parsePlaywrightTraceArchive(Buffer.from(input.bytes)) + : undefined; +} + +async function summarize( + request: InvestigationRequest, +): Promise { + const parsed = await parseTrace(request); + const logs = summarizeExecutionLogs(request.artifacts.logs); + const traceStatus = parsed?.status ?? request.artifacts.trace.status; + const emptyCoverage = { + incomplete: false, + malformed: 0, + returned: 0, + total: 0, + truncated: false, + }; + const traceEvidence = + parsed && "trace" in parsed + ? await summarizeAttemptTrace(parsed.trace, request.attempt.error) + : { + actions: { coverage: emptyCoverage, items: [] }, + browserConsole: { coverage: emptyCoverage, items: [] }, + failedBrowserActionAttribution: "none", + networkErrors: { coverage: emptyCoverage, items: [] }, + }; + return { + ...traceEvidence, + artifactStates: { executionLog: logs.artifactState, trace: traceStatus }, + importantExecutionLogs: logs.evidence, + traceStatus, + }; +} + +async function inspect( + request: InvestigationRequest, +): Promise { + const target = request.inspect!; + if (target.type === "log") return inspectExecutionLogs(request); + const parsed = await parseTrace(request); + if (!parsed || !("trace" in parsed)) + return traceUnavailable( + request, + parsed?.status ?? request.artifacts.trace.status, + ); + const common = { + attemptId: request.attempt.attemptId, + flowId: request.flowId, + runId: request.runId, + traceStatus: + parsed.status === "incomplete" + ? ("partial" as const) + : ("ready" as const), + }; + switch (target.type) { + case "action": + return inspectAction(parsed.trace, common, { + actionId: target.actionId!, + type: target.type, + }); + case "timeline": + return inspectActionTimeline(parsed.trace, common, { + limit: target.limit, + type: target.type, + }); + case "network": + return inspectNetwork(parsed.trace, common, { + ...(target.endTimeMilliseconds !== undefined && { + endTimeMilliseconds: target.endTimeMilliseconds, + }), + limit: target.limit, + ...(target.method !== undefined && { method: target.method }), + ...(target.startTimeMilliseconds !== undefined && { + startTimeMilliseconds: target.startTimeMilliseconds, + }), + ...(target.status !== undefined && { status: target.status }), + type: target.type, + ...(target.urlContains !== undefined && { + urlContains: target.urlContains, + }), + }); + case "request": + return inspectRequest(parsed.trace, common, { + requestId: target.requestId!, + type: target.type, + }); + case "snapshot": { + const result = inspectSnapshot(parsed.trace, common, { + format: target.format, + snapshotId: target.snapshotId!, + type: target.type, + }); + if (!("html" in result) || typeof result.html !== "string") return result; + const { html, ...metadata } = result; + return { + ...metadata, + binary: { + bytesBase64: Buffer.from( + `${html}`, + ).toString("base64"), + extension: "html", + }, + }; + } + case "screenshot": { + const result = await inspectScreenshot(parsed.trace, common, { + screenshotId: target.screenshotId!, + type: target.type, + }); + if ( + !("imageJpegBase64" in result) || + typeof result.imageJpegBase64 !== "string" + ) + return result; + const { imageJpegBase64, ...metadata } = result; + return { + ...metadata, + binary: { bytesBase64: imageJpegBase64, extension: "jpeg" }, + }; + } + case "console": + return inspectConsole(parsed.trace, common, { + ...(target.endTimeMilliseconds !== undefined && { + endTimeMilliseconds: target.endTimeMilliseconds, + }), + ...(target.evidenceId !== undefined && { + evidenceId: target.evidenceId, + }), + limit: target.limit, + ...(target.startTimeMilliseconds !== undefined && { + startTimeMilliseconds: target.startTimeMilliseconds, + }), + type: target.type, + }); + } +} + +export async function investigate( + request: InvestigationRequest, +): Promise { + return request.mode === "summary" ? summarize(request) : inspect(request); +} diff --git a/src/domains/investigation/parser/projection/attemptEvidenceText.test.ts b/src/domains/investigation/parser/projection/attemptEvidenceText.test.ts new file mode 100644 index 000000000..f408f9ec6 --- /dev/null +++ b/src/domains/investigation/parser/projection/attemptEvidenceText.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; + +import { redactAttemptEvidence } from "./attemptEvidenceText.js"; + +describe("redactAttemptEvidence", () => { + it.each([ + ["client_secret=abc123", "client_secret=[REDACTED]"], + ['{"client_secret":"abc123"}', '{"client_secret":"[REDACTED]"}'], + ['{"secret":"abc"}', '{"secret":"[REDACTED]"}'], + ['{"id_token":"abc"}', '{"id_token":"[REDACTED]"}'], + ['{"idToken":"abc"}', '{"idToken":"[REDACTED]"}'], + ["apiToken: abc", "apiToken: [REDACTED]"], + ["my_token=abc", "my_token=[REDACTED]"], + ['{"privateKey":"abc"}', '{"privateKey":"[REDACTED]"}'], + ["sent Bearer abcdef123456 upstream", "sent Bearer [REDACTED] upstream"], + [ + "https://x.test/callback?code=AUTHCODE&state=1", + "https://x.test/callback?code=[REDACTED]&state=1", + ], + [ + "https://x.test/token?client_secret=zz&grant=1", + "https://x.test/token?client_secret=[REDACTED]&grant=1", + ], + ])("redacts %s", (input, expected) => { + expect(redactAttemptEvidence(input)).toBe(expected); + }); + + it.each([ + '{"keyboard":"qwerty"}', + '{"key":"Enter"}', + '{"author":"Jane"}', + ":authority: x.test", + "monkey=banana", + "https://x.test/search?zipcode=10001", + "the bearer of news", + ])("keeps %s", (input) => { + expect(redactAttemptEvidence(input)).toBe(input); + }); +}); diff --git a/src/domains/investigation/parser/projection/attemptEvidenceText.ts b/src/domains/investigation/parser/projection/attemptEvidenceText.ts new file mode 100644 index 000000000..9e379b705 --- /dev/null +++ b/src/domains/investigation/parser/projection/attemptEvidenceText.ts @@ -0,0 +1,231 @@ +// oxlint-disable eslint/max-lines -- Keep the conventional redaction passes and UTF-8 bounding logic aligned with the platform implementation. + +const redacted = "[REDACTED]"; +// Field names are matched by the sensitive word they contain, so variants such +// as client_secret, idToken and x-session-id are caught without listing each. +// "key" counts only after a qualifier like api or private, so keyboard events +// stay readable; "auth" skips author and :authority. +const sensitiveWords = [ + "secret", + "token", + "passw(?:or)?d", + "passphrase", + "session", + "auth(?!or)", + "authoriz", + "credential", + "cookie", + "signature", + "(?:api|private|secret|access|client|signing|encryption|master)[_-]?key", +].join("|"); +// Bounded so a long run of word characters cannot make the match quadratic. +const sensitiveName = `[\\w-]{0,40}(?:${sensitiveWords})[\\w-]{0,40}`; +const sensitiveFieldPattern = `${sensitiveName}|value`; +const signedUrlFieldPattern = [ + sensitiveName, + "code", + "sig", + "policy", + "key-pair-id", + "googleaccessid", +].join("|"); +const bearerPattern = /\b(Bearer\s+)[\w.~+/-]{8,}=*/gi; +// A JWT's header is base64url JSON, so it always starts with "eyJ" (`{"`). +// Matching the token itself catches the ones no field name points at, such as +// a session token streamed inside a React Server Components payload. The +// signature is optional so a token cut off by a truncated body still matches. +const jwtPattern = /eyJ[\w-]+\.[\w-]+(?:\.[\w-]*){0,3}/g; + +function redactQuotedAssignments( + text: string, + assignmentPattern: RegExp, + options: { escaped: boolean; quote: '"' | "'" }, +) { + let result = ""; + let cursor = 0; + assignmentPattern.lastIndex = 0; + + for (let match = assignmentPattern.exec(text); match; ) { + const valueStart = match.index + match[0].length; + result += text.slice(cursor, valueStart) + redacted; + + let closingQuote = -1; + let precedingBackslashes = 0; + for (let index = valueStart; index < text.length; index += 1) { + if (text[index] === "\\") { + precedingBackslashes += 1; + continue; + } + if (text[index] !== options.quote) { + precedingBackslashes = 0; + continue; + } + const isClosingQuote = options.escaped + ? precedingBackslashes === 1 + : precedingBackslashes % 2 === 0; + const suffix = text.slice(index + 1); + if (isClosingQuote && /^\s*(?:[,}\]]|$)/.test(suffix)) { + closingQuote = options.escaped ? index - 1 : index; + break; + } + precedingBackslashes = 0; + } + + if (closingQuote === -1) + return result + (text.endsWith("[TRUNCATED]") ? "[TRUNCATED]" : ""); + const quoteLength = options.escaped ? 2 : 1; + result += text.slice(closingQuote, closingQuote + quoteLength); + cursor = closingQuote + quoteLength; + assignmentPattern.lastIndex = cursor; + match = assignmentPattern.exec(text); + } + + return result + text.slice(cursor); +} + +function redactCallArguments(text: string): string { + const callPattern = /\b(?:fill|type)\(/gi; + let result = ""; + let cursor = 0; + + for (let match = callPattern.exec(text); match; ) { + const valueStart = match.index + match[0].length; + result += text.slice(cursor, valueStart) + redacted; + let depth = 1; + let quote: '"' | "'" | "`" | undefined; + let escaped = false; + let valueEnd = valueStart; + + for (; valueEnd < text.length; valueEnd += 1) { + const character = text[valueEnd]; + if (quote) { + if (escaped) { + escaped = false; + } else if (character === "\\") { + escaped = true; + } else if (character === quote) { + quote = undefined; + } + continue; + } + if (character === '"' || character === "'" || character === "`") { + quote = character; + } else if (character === "(") { + depth += 1; + } else if (character === ")") { + depth -= 1; + if (depth === 0) break; + } + } + + if (depth !== 0) return result + text.slice(valueEnd); + result += ")"; + cursor = valueEnd + 1; + callPattern.lastIndex = cursor; + match = callPattern.exec(text); + } + + return result + text.slice(cursor); +} + +export function redactAttemptEvidence(text: string): string { + const jwtRedacted = text + .replace(jwtPattern, redacted) + .replace(bearerPattern, `$1${redacted}`); + const userInfoRedacted = jwtRedacted.replace( + /((?:https?:)?\/\/)[^/\s?#]*@/gi, + `$1${redacted}@`, + ); + const queryRedacted = userInfoRedacted.replace( + new RegExp(`([?&](?:${signedUrlFieldPattern})=)([^&#\\s"'\\\\]*)`, "gi"), + `$1${redacted}`, + ); + const jsonRedacted = redactQuotedAssignments( + queryRedacted, + new RegExp(`"(?:${sensitiveFieldPattern})"\\s*:\\s*"`, "gi"), + { escaped: false, quote: '"' }, + ); + const escapedJsonRedacted = redactQuotedAssignments( + jsonRedacted, + new RegExp(`\\\\"(?:${sensitiveFieldPattern})\\\\"\\s*:\\s*\\\\"`, "gi"), + { escaped: true, quote: '"' }, + ); + const singleQuotedJsonRedacted = redactQuotedAssignments( + escapedJsonRedacted, + new RegExp(`'(?:${sensitiveFieldPattern})'\\s*:\\s*'`, "gi"), + { escaped: false, quote: "'" }, + ); + const doubleKeySingleValueRedacted = redactQuotedAssignments( + singleQuotedJsonRedacted, + new RegExp(`"(?:${sensitiveFieldPattern})"\\s*:\\s*'`, "gi"), + { escaped: false, quote: "'" }, + ); + const mixedQuotedJsonRedacted = redactQuotedAssignments( + doubleKeySingleValueRedacted, + new RegExp(`'(?:${sensitiveFieldPattern})'\\s*:\\s*"`, "gi"), + { escaped: false, quote: '"' }, + ); + const scalarJsonRedacted = mixedQuotedJsonRedacted.replace( + new RegExp( + `(["'](?:${sensitiveFieldPattern})["']\\s*:\\s*)(?!["'])[^,}\\]\\r\\n]*`, + "gi", + ), + `$1${redacted}`, + ); + const escapedScalarJsonRedacted = scalarJsonRedacted.replace( + new RegExp( + `(\\\\"(?:${sensitiveFieldPattern})\\\\"\\s*:\\s*)(?!\\\\")[^,}\\]\\r\\n]*`, + "gi", + ), + `$1${redacted}`, + ); + + return redactCallArguments( + escapedScalarJsonRedacted.replace( + new RegExp( + `(?= 0 && ((bytes[sequenceStart] ?? 0) & 0xc0) === 0x80) + sequenceStart -= 1; + const leadingByte = bytes[sequenceStart] ?? 0; + const sequenceLength = + leadingByte < 0x80 + ? 1 + : leadingByte < 0xe0 + ? 2 + : leadingByte < 0xf0 + ? 3 + : 4; + if (boundary - sequenceStart < sequenceLength) boundary = sequenceStart; + const prefix = bytes.subarray(0, boundary).toString("utf8"); + return { text: prefix + suffix, truncated: true }; +} + +export function boundAttemptPageText(text: string) { + return boundAttemptEvidenceText(text, 8 * 1024); +} diff --git a/src/domains/investigation/parser/projection/chronologicallySortActions.ts b/src/domains/investigation/parser/projection/chronologicallySortActions.ts new file mode 100644 index 000000000..398212a1b --- /dev/null +++ b/src/domains/investigation/parser/projection/chronologicallySortActions.ts @@ -0,0 +1,18 @@ +export function chronologicallySortActions< + Action extends { + id: string; + startTime: number | undefined; + streamId: string; + }, +>(actions: Action[]): Action[] { + return actions.toSorted((left, right) => { + if (left.startTime === undefined && right.startTime !== undefined) return 1; + if (left.startTime !== undefined && right.startTime === undefined) + return -1; + return ( + (left.startTime ?? 0) - (right.startTime ?? 0) || + left.streamId.localeCompare(right.streamId) || + left.id.localeCompare(right.id) + ); + }); +} diff --git a/src/domains/investigation/parser/projection/inspectAttemptActions.ts b/src/domains/investigation/parser/projection/inspectAttemptActions.ts new file mode 100644 index 000000000..0e2a31280 --- /dev/null +++ b/src/domains/investigation/parser/projection/inspectAttemptActions.ts @@ -0,0 +1,113 @@ +import { + type PlaywrightTrace, + listTraceActionLogs, + listTraceActions, + listTraceNetwork, + listTraceSnapshots, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; + +import { chronologicallySortActions } from "./chronologicallySortActions.js"; +import { + type InspectionCommon, + emptyInspectionCoverage, + shapeInspectionAction, +} from "./inspectAttemptShared.js"; + +export function inspectActionTimeline( + trace: PlaywrightTrace, + common: InspectionCommon, + target: { limit: number; type: "timeline" }, +) { + const listed = listTraceActions(trace, { limit: Number.MAX_SAFE_INTEGER }); + const actions = chronologicallySortActions(listed.items); + const start = Math.max(0, actions.length - target.limit); + const selected = actions.slice(start); + const logs = listTraceActionLogs(trace, { + limit: Number.MAX_SAFE_INTEGER, + maxTextBytes: Number.MAX_SAFE_INTEGER, + }).items; + return { + ...common, + actions: selected.map((action) => + shapeInspectionAction(trace, action, logs), + ), + coverage: { + ...listed.coverage, + returned: selected.length, + truncated: start > 0, + }, + status: "available" as const, + type: target.type, + }; +} + +export async function inspectAction( + trace: PlaywrightTrace, + common: InspectionCommon, + target: { actionId: string; type: "action" }, +) { + const actions = chronologicallySortActions( + listTraceActions(trace, { limit: Number.MAX_SAFE_INTEGER }).items, + ); + const index = actions.findIndex((item) => item.id === target.actionId); + const action = actions[index]; + if (!action) { + return { + ...common, + neighbors: [], + requestIds: [], + requestIdsCoverage: emptyInspectionCoverage, + snapshotIds: [], + snapshotIdsCoverage: emptyInspectionCoverage, + status: "not-found" as const, + type: target.type, + }; + } + const logs = listTraceActionLogs(trace, { + limit: Number.MAX_SAFE_INTEGER, + maxTextBytes: Number.MAX_SAFE_INTEGER, + }).items; + const neighborStart = Math.max(0, index - 2); + const network = await listTraceNetwork(trace, { + limit: Number.MAX_SAFE_INTEGER, + }); + const streamRequests = network.items.filter( + (request) => + request.streamId === action.streamId && + (action.pageId === undefined || request.pageId === action.pageId), + ); + const relatedRequests = streamRequests.filter( + (request) => + request.startedAtMilliseconds !== undefined && + action.startTime !== undefined && + request.startedAtMilliseconds >= action.startTime && + (action.endTime === undefined || + request.startedAtMilliseconds <= action.endTime), + ); + const requestIds = relatedRequests.slice(0, 10).map((request) => request.id); + const snapshots = listTraceSnapshots(trace, { + actionId: target.actionId, + limit: 20, + }); + return { + ...common, + action: shapeInspectionAction(trace, action, logs), + neighbors: actions + .slice(neighborStart, index + 3) + .filter((_, neighborIndex) => neighborIndex + neighborStart !== index) + .map((neighbor) => shapeInspectionAction(trace, neighbor, logs)), + requestIds, + requestIdsCoverage: { + ...network.coverage, + returned: requestIds.length, + total: relatedRequests.length, + truncated: + network.coverage.truncated || + relatedRequests.length > requestIds.length, + }, + snapshotIds: snapshots.items.map((snapshot) => snapshot.id), + snapshotIdsCoverage: snapshots.coverage, + status: "available" as const, + type: target.type, + }; +} diff --git a/src/domains/investigation/parser/projection/inspectAttemptNetwork.ts b/src/domains/investigation/parser/projection/inspectAttemptNetwork.ts new file mode 100644 index 000000000..476c40b77 --- /dev/null +++ b/src/domains/investigation/parser/projection/inspectAttemptNetwork.ts @@ -0,0 +1,107 @@ +import { + type PlaywrightTrace, + listTraceNetwork, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; + +import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; +import type { InspectionCommon } from "./inspectAttemptShared.js"; + +const maxNetworkMetadataBytes = 2 * 1024; + +function shapeRequest( + request: Awaited>["items"][number], +) { + const url = redactAndBoundAttemptEvidenceResult( + request.url, + maxNetworkMetadataBytes, + ); + const failureText = + request.failureText === undefined + ? undefined + : redactAndBoundAttemptEvidenceResult( + request.failureText, + maxNetworkMetadataBytes, + ); + return { + durationMilliseconds: request.durationMilliseconds, + ...(failureText && { failureText: failureText.text }), + ...((url.truncated || failureText?.truncated === true) && { + metadataTruncated: true, + }), + method: request.method, + requestId: request.id, + startedAtMilliseconds: request.startedAtMilliseconds, + status: request.status, + streamId: request.streamId, + url: url.text, + }; +} + +export async function inspectNetwork( + trace: PlaywrightTrace, + common: InspectionCommon, + target: { + beforeRequestId?: string; + endTimeMilliseconds?: number; + limit: number; + method?: string; + startTimeMilliseconds?: number; + status?: number; + type: "network"; + urlContains?: string; + }, +) { + const listed = await listTraceNetwork(trace, { + limit: Number.MAX_SAFE_INTEGER, + }); + const filtered = listed.items + .filter( + (request) => + (target.method === undefined || + request.method.toLowerCase() === target.method.toLowerCase()) && + (target.status === undefined || request.status === target.status) && + (target.urlContains === undefined || + request.url.includes(target.urlContains)) && + (target.startTimeMilliseconds === undefined || + (request.startedAtMilliseconds ?? -Infinity) >= + target.startTimeMilliseconds) && + (target.endTimeMilliseconds === undefined || + (request.startedAtMilliseconds ?? Infinity) <= + target.endTimeMilliseconds), + ) + .toSorted( + (left, right) => + (left.startedAtMilliseconds ?? Infinity) - + (right.startedAtMilliseconds ?? Infinity) || + left.streamId.localeCompare(right.streamId) || + left.id.localeCompare(right.id), + ); + const boundary = target.beforeRequestId + ? filtered.findIndex((request) => request.id === target.beforeRequestId) + : filtered.length; + if (boundary === -1) { + return { + ...common, + coverage: { ...listed.coverage, returned: 0, total: filtered.length }, + entries: [], + status: "not-found" as const, + type: target.type, + }; + } + const start = Math.max(0, boundary - target.limit); + const selected = filtered.slice(start, boundary); + return { + ...common, + coverage: { + ...listed.coverage, + returned: selected.length, + total: filtered.length, + truncated: + listed.coverage.truncated || start > 0 || boundary < filtered.length, + }, + entries: selected.map(shapeRequest), + ...(start > 0 && selected[0] && { nextBeforeRequestId: selected[0].id }), + status: "available" as const, + type: target.type, + }; +} diff --git a/src/domains/investigation/parser/projection/inspectAttemptPage.ts b/src/domains/investigation/parser/projection/inspectAttemptPage.ts new file mode 100644 index 000000000..ef2a8ae06 --- /dev/null +++ b/src/domains/investigation/parser/projection/inspectAttemptPage.ts @@ -0,0 +1,84 @@ +import { + type PlaywrightTrace, + nearestScreenshotForSnapshot, + readTraceSnapshot, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; + +import { + boundAttemptPageText, + redactAttemptEvidence, +} from "./attemptEvidenceText.js"; +import type { InspectionCommon } from "./inspectAttemptShared.js"; +import { serializeSnapshotToHtml } from "~/domains/investigation/parser/snapshot/snapshotHtmlProjection.js"; +import { + projectToText, + resolveSnapshot, +} from "~/domains/investigation/parser/snapshot/snapshotProjection.js"; + +const maxSnapshotReconstructionBytes = 256 * 1024; + +export function inspectSnapshot( + trace: PlaywrightTrace, + common: InspectionCommon, + target: { + format?: "html" | "text"; + snapshotId: string; + type: "snapshot"; + }, +) { + const page = readTraceSnapshot(trace, target.snapshotId, { + maxBytes: maxSnapshotReconstructionBytes, + }); + if (!page) { + return { + ...common, + ...(target.format === "html" && { htmlTruncated: false }), + status: "not-found" as const, + type: target.type, + }; + } + const root = page.truncated ? undefined : resolveSnapshot([page.html], 0); + const projected = root + ? boundAttemptPageText( + redactAttemptEvidence(projectToText(root, Number.MAX_SAFE_INTEGER)), + ) + : { text: "", truncated: true }; + const nearest = nearestScreenshotForSnapshot(trace, target.snapshotId); + const serializedHtml = + target.format === "html" && root + ? serializeSnapshotToHtml(root) + : undefined; + return { + ...common, + ...(nearest && { + nearestScreenshot: { + deltaMilliseconds: nearest.deltaMilliseconds, + relationship: nearest.relationship, + screenshot: { + height: nearest.screenshot.height, + pageId: nearest.screenshot.pageId, + screenshotId: nearest.screenshot.id, + timestampMilliseconds: nearest.screenshot.timestamp, + width: nearest.screenshot.width, + }, + }, + }), + snapshot: { + actionId: page.snapshot.actionId, + frameId: page.snapshot.frameId, + isMainFrame: page.snapshot.isMainFrame, + pageId: page.snapshot.pageId, + phase: page.snapshot.phase, + snapshotId: page.snapshot.id, + url: redactAttemptEvidence(page.snapshot.url), + }, + status: "available" as const, + text: projected.text, + textTruncated: page.truncated || projected.truncated, + ...(target.format === "html" && { + ...(serializedHtml && { html: serializedHtml.html }), + htmlTruncated: page.truncated || (serializedHtml?.truncated ?? false), + }), + type: target.type, + }; +} diff --git a/src/domains/investigation/parser/projection/inspectAttemptRecorded.ts b/src/domains/investigation/parser/projection/inspectAttemptRecorded.ts new file mode 100644 index 000000000..d310b5084 --- /dev/null +++ b/src/domains/investigation/parser/projection/inspectAttemptRecorded.ts @@ -0,0 +1,201 @@ +// oxlint-disable eslint/max-lines -- Keep the typed recorded-evidence projections aligned with the platform implementation. + +import { + type PlaywrightTrace, + listTraceConsole, + listTraceNetwork, + readTraceResponseBody, + readTraceScreenshot, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; + +import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; +import { redactInspectionHeaders } from "./inspectAttemptRequestHeaders.js"; +import type { InspectionCommon } from "./inspectAttemptShared.js"; + +const maxConsoleEvidenceBytes = 1024; +const maxRequestMetadataBytes = 2 * 1024; +const maxResponseBodyBytes = 64 * 1024; + +export async function inspectScreenshot( + trace: PlaywrightTrace, + common: InspectionCommon, + target: { screenshotId: string; type: "screenshot" }, +) { + const result = await readTraceScreenshot(trace, target.screenshotId, { + maxBytes: 1024 * 1024, + }); + if (!result) + return { ...common, status: "not-found" as const, type: target.type }; + const screenshot = { + height: result.screenshot.height, + pageId: result.screenshot.pageId, + screenshotId: result.screenshot.id, + timestampMilliseconds: result.screenshot.timestamp, + width: result.screenshot.width, + }; + return result.status === "available" + ? { + ...common, + imageJpegBase64: result.imageJpegBase64, + screenshot, + status: result.status, + type: target.type, + } + : { ...common, screenshot, status: result.status, type: target.type }; +} + +function isTextualContentType(contentType: string | undefined): boolean { + if (!contentType) return false; + const mime = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + return ( + mime.startsWith("text/") || + mime.endsWith("+json") || + mime.endsWith("+xml") || + ["application/javascript", "application/json", "application/xml"].includes( + mime, + ) + ); +} + +export async function inspectRequest( + trace: PlaywrightTrace, + common: InspectionCommon, + target: { requestId: string; type: "request" }, +) { + const entries = await listTraceNetwork(trace, { + limit: Number.MAX_SAFE_INTEGER, + }); + const request = entries.items.find((item) => item.id === target.requestId); + if (!request) + return { ...common, status: "not-found" as const, type: target.type }; + const contentType = Object.entries(request.responseHeaders).find( + ([name]) => name.toLowerCase() === "content-type", + )?.[1]; + const recordedBody = await readTraceResponseBody(trace, request.id, { + maxBytes: maxResponseBodyBytes, + }); + const body = + recordedBody.status !== "available" || isTextualContentType(contentType) + ? recordedBody + : { status: "unsupported-binary" as const }; + const textualBody = + body.status === "available" + ? redactAndBoundAttemptEvidenceResult( + Buffer.from(body.bodyBase64, "base64").toString("utf8"), + maxResponseBodyBytes, + ) + : undefined; + const requestHeaders = redactInspectionHeaders(request.requestHeaders); + const responseHeaders = redactInspectionHeaders(request.responseHeaders); + const url = redactAndBoundAttemptEvidenceResult( + request.url, + maxRequestMetadataBytes, + ); + const failureText = + request.failureText === undefined + ? undefined + : redactAndBoundAttemptEvidenceResult( + request.failureText, + maxRequestMetadataBytes, + ); + return { + ...common, + body: + body.status === "available" + ? { + status: body.status, + text: textualBody?.text, + truncated: body.truncated || textualBody?.truncated === true, + } + : body, + request: { + durationMilliseconds: request.durationMilliseconds, + ...(failureText && { failureText: failureText.text }), + ...((failureText?.truncated === true || + requestHeaders.truncated || + responseHeaders.truncated || + url.truncated) && { metadataTruncated: true }), + method: request.method, + requestHeaders: requestHeaders.headers, + requestId: request.id, + responseHeaders: responseHeaders.headers, + startedAtMilliseconds: request.startedAtMilliseconds, + status: request.status, + url: url.text, + }, + status: "available" as const, + type: target.type, + }; +} + +export function inspectConsole( + trace: PlaywrightTrace, + common: InspectionCommon, + target: { + endTimeMilliseconds?: number; + evidenceId?: string; + limit: number; + startTimeMilliseconds?: number; + type: "console"; + }, +) { + const listed = listTraceConsole(trace, { + limit: Number.MAX_SAFE_INTEGER, + maxTextBytes: Number.MAX_SAFE_INTEGER, + }); + const filtered = listed.items.filter( + (entry) => + (target.evidenceId === undefined || entry.id === target.evidenceId) && + (target.startTimeMilliseconds === undefined || + (entry.time ?? -Infinity) >= target.startTimeMilliseconds) && + (target.endTimeMilliseconds === undefined || + (entry.time ?? Infinity) <= target.endTimeMilliseconds), + ); + if (target.evidenceId && !filtered.length) { + const preserveUpstreamTotal = + listed.coverage.incomplete || listed.coverage.truncated; + return { + ...common, + coverage: { + ...listed.coverage, + returned: 0, + total: preserveUpstreamTotal ? listed.coverage.total : 0, + }, + entries: [], + status: "not-found" as const, + type: target.type, + }; + } + const selected = filtered.slice(0, target.limit); + const preserveUpstreamTotal = + listed.coverage.incomplete || listed.coverage.truncated; + const entries = selected.map((entry) => { + const message = redactAndBoundAttemptEvidenceResult( + entry.message, + maxConsoleEvidenceBytes, + ); + return { + evidenceId: entry.id, + ...((message.truncated || + Buffer.byteLength(entry.message) > maxConsoleEvidenceBytes) && { + evidenceTruncated: true, + }), + message: message.text, + pageId: entry.pageId, + timeMilliseconds: entry.time, + type: entry.type, + }; + }); + return { + ...common, + coverage: { + ...listed.coverage, + returned: selected.length, + total: preserveUpstreamTotal ? listed.coverage.total : filtered.length, + truncated: listed.coverage.truncated || filtered.length > selected.length, + }, + entries, + status: "available" as const, + type: target.type, + }; +} diff --git a/src/domains/investigation/parser/projection/inspectAttemptRequestHeaders.test.ts b/src/domains/investigation/parser/projection/inspectAttemptRequestHeaders.test.ts new file mode 100644 index 000000000..b9bd47035 --- /dev/null +++ b/src/domains/investigation/parser/projection/inspectAttemptRequestHeaders.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; + +import { redactInspectionHeaders } from "./inspectAttemptRequestHeaders.js"; + +describe("redactInspectionHeaders", () => { + it("redacts secret-bearing header values and keeps ordinary ones", () => { + const { headers } = redactInspectionHeaders({ + accept: "text/html", + authorization: "Bearer abcdef123456", + "x-client-secret": "xyz", + "x-session-id": "abc", + }); + expect(headers).toEqual({ + accept: "text/html", + authorization: "[REDACTED]", + "x-client-secret": "[REDACTED]", + "x-session-id": "[REDACTED]", + }); + }); +}); diff --git a/src/domains/investigation/parser/projection/inspectAttemptRequestHeaders.ts b/src/domains/investigation/parser/projection/inspectAttemptRequestHeaders.ts new file mode 100644 index 000000000..7168771ef --- /dev/null +++ b/src/domains/investigation/parser/projection/inspectAttemptRequestHeaders.ts @@ -0,0 +1,39 @@ +import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; + +const maxHeaderCount = 100; +const maxHeaderNameBytes = 256; +const maxHeaderValueBytes = 2 * 1024; +const maxHeadersBytes = 16 * 1024; + +export function redactInspectionHeaders(headers: Record) { + const entries = Object.entries(headers); + const boundedEntries: [string, string][] = []; + let aggregateBytes = 0; + let truncated = entries.length > maxHeaderCount; + for (const [rawName, rawValue] of entries.slice(0, maxHeaderCount)) { + const name = redactAndBoundAttemptEvidenceResult( + rawName, + maxHeaderNameBytes, + ); + const prefix = `${rawName}: `; + const value = redactAndBoundAttemptEvidenceResult( + `${prefix}${rawValue}`, + Buffer.byteLength(prefix) + maxHeaderValueBytes, + ); + const sanitizedValue = value.text.slice(prefix.length); + const remainingBytes = maxHeadersBytes - aggregateBytes; + const nameBytes = Buffer.byteLength(name.text); + if (nameBytes >= remainingBytes) { + truncated = true; + break; + } + const boundedValue = redactAndBoundAttemptEvidenceResult( + sanitizedValue, + Math.min(maxHeaderValueBytes, remainingBytes - nameBytes), + ); + boundedEntries.push([name.text, boundedValue.text]); + aggregateBytes += nameBytes + Buffer.byteLength(boundedValue.text); + truncated ||= name.truncated || value.truncated || boundedValue.truncated; + } + return { headers: Object.fromEntries(boundedEntries), truncated }; +} diff --git a/src/domains/investigation/parser/projection/inspectAttemptShared.ts b/src/domains/investigation/parser/projection/inspectAttemptShared.ts new file mode 100644 index 000000000..fba5f6820 --- /dev/null +++ b/src/domains/investigation/parser/projection/inspectAttemptShared.ts @@ -0,0 +1,98 @@ +// oxlint-disable eslint/max-lines -- Keep shared typed fallback and action shaping aligned with the platform implementation. + +import type { listTraceActionLogs } from "~/domains/investigation/parser/trace/playwrightTrace.js"; +import type { + PlaywrightTrace, + TraceAction, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; +import { readTraceActionStack } from "~/domains/investigation/parser/trace/playwrightTrace.js"; + +import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; + +const maxActionEvidenceBytes = 512; + +export type InspectionCommon = { + attemptId: string; + flowId: string; + runId: string; + traceStatus: + | "unchecked" + | "ready" + | "partial" + | "missing" + | "malformed" + | "storage-unavailable" + | "not-applicable"; +}; + +export const emptyInspectionCoverage = { + incomplete: false, + malformed: 0, + returned: 0, + total: 0, + truncated: false, +}; + +export function shapeInspectionAction( + trace: PlaywrightTrace, + action: TraceAction, + logs: ReturnType["items"], +) { + const stack = readTraceActionStack(trace, action).map( + (frame) => `${frame.file}:${frame.compiledLine}`, + ); + const waitingLogs = logs.filter((log) => log.actionId === action.id); + const error = action.errorMessage + ? redactAndBoundAttemptEvidenceResult( + action.errorMessage, + maxActionEvidenceBytes, + ) + : undefined; + const selector = action.selector + ? redactAndBoundAttemptEvidenceResult( + action.selector, + maxActionEvidenceBytes, + ) + : undefined; + const boundedWaitingLogs = waitingLogs + .slice(-20) + .map((log) => + redactAndBoundAttemptEvidenceResult(log.message, maxActionEvidenceBytes), + ); + const evidenceTruncated = + (action.errorMessage !== undefined && + Buffer.byteLength(action.errorMessage) > maxActionEvidenceBytes) || + (action.selector !== undefined && + Buffer.byteLength(action.selector) > maxActionEvidenceBytes) || + waitingLogs + .slice(-20) + .some( + ({ message }) => Buffer.byteLength(message) > maxActionEvidenceBytes, + ) || + error?.truncated === true || + selector?.truncated === true || + boundedWaitingLogs.some(({ truncated }) => truncated); + return { + actionId: action.id, + apiName: action.apiName, + ...(action.endTime !== undefined && { + endedAtMilliseconds: action.endTime, + }), + ...(error && { error: error.text }), + ...(evidenceTruncated && { evidenceTruncated: true }), + ...(selector && { selector: selector.text }), + stack: stack.slice(0, 20), + stackTruncated: stack.length > 20, + ...(action.startTime !== undefined && { + startedAtMilliseconds: action.startTime, + }), + streamId: action.streamId, + waitingLogs: boundedWaitingLogs.map(({ text }) => text), + waitingLogsTruncated: + waitingLogs.length > 20 || + waitingLogs.some( + ({ message }) => Buffer.byteLength(message) > maxActionEvidenceBytes, + ) || + boundedWaitingLogs.some(({ truncated }) => truncated), + }; +} diff --git a/src/domains/investigation/parser/projection/summarizeAttemptNetwork.ts b/src/domains/investigation/parser/projection/summarizeAttemptNetwork.ts new file mode 100644 index 000000000..170bf0f5d --- /dev/null +++ b/src/domains/investigation/parser/projection/summarizeAttemptNetwork.ts @@ -0,0 +1,77 @@ +import type { + Bounded, + TraceNetworkEntry, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; + +import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; + +const evidenceLimit = 10; +const maxNetworkFieldBytes = 2 * 1024; + +type SummaryNetworkEntry = TraceNetworkEntry & { + durationMilliseconds?: number; + failureText?: string; + startedAtMilliseconds?: number; +}; + +export function summarizeAttemptNetworkErrors( + network: Bounded, +) { + const errors = (network.items as SummaryNetworkEntry[]) + .map((entry, provenance) => ({ entry, provenance })) + .filter( + ({ entry }) => + !entry.status || entry.status >= 400 || entry.failureText !== undefined, + ) + .sort( + (left, right) => + (left.entry.startedAtMilliseconds ?? Number.NEGATIVE_INFINITY) - + (right.entry.startedAtMilliseconds ?? Number.NEGATIVE_INFINITY) || + left.provenance - right.provenance, + ) + .map(({ entry }) => entry); + const summaries = errors.slice(-evidenceLimit).map((entry) => { + const url = redactAndBoundAttemptEvidenceResult( + entry.url, + maxNetworkFieldBytes, + ); + const failureText = + entry.failureText !== undefined + ? redactAndBoundAttemptEvidenceResult( + entry.failureText, + maxNetworkFieldBytes, + ) + : undefined; + return { + item: { + ...(entry.durationMilliseconds !== undefined && { + durationMilliseconds: entry.durationMilliseconds, + }), + ...(failureText && { failureText: failureText.text }), + method: entry.method, + requestId: entry.id, + ...(entry.startedAtMilliseconds !== undefined && { + startedAtMilliseconds: entry.startedAtMilliseconds, + }), + status: entry.status, + url: url.text, + }, + truncated: url.truncated || failureText?.truncated === true, + }; + }); + const items = summaries.map(({ item }) => item); + const upstreamLimited = + network.coverage.incomplete || network.coverage.truncated; + return { + coverage: { + ...network.coverage, + returned: items.length, + total: upstreamLimited ? network.coverage.total : errors.length, + truncated: + upstreamLimited || + errors.length > items.length || + summaries.some(({ truncated }) => truncated), + }, + items, + }; +} diff --git a/src/domains/investigation/parser/projection/summarizeAttemptTrace.ts b/src/domains/investigation/parser/projection/summarizeAttemptTrace.ts new file mode 100644 index 000000000..c8f19f200 --- /dev/null +++ b/src/domains/investigation/parser/projection/summarizeAttemptTrace.ts @@ -0,0 +1,223 @@ +// oxlint-disable eslint/max-lines -- Keep summary selection and cross-evidence attribution aligned with the platform implementation. + +import { + type PlaywrightTrace, + listTraceActionLogs, + listTraceActions, + listTraceConsole, + listTraceNetwork, + listTraceSnapshots, + nearestScreenshotForSnapshot, + readFailureStack, + readTraceActionStack, + readTraceSnapshot, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; + +import { + boundAttemptPageText, + redactAndBoundAttemptEvidenceResult, + redactAttemptEvidence, +} from "./attemptEvidenceText.js"; +import { chronologicallySortActions } from "./chronologicallySortActions.js"; +import { + projectToText, + resolveSnapshot, +} from "~/domains/investigation/parser/snapshot/snapshotProjection.js"; +import { summarizeAttemptNetworkErrors } from "./summarizeAttemptNetwork.js"; +import { summarizeAttemptWaitingLogs } from "./summarizeAttemptWaitingLogs.js"; + +const evidenceLimit = 10; +const maxActionEvidenceBytes = 512; +const maxSnapshotReconstructionBytes = 256 * 1024; + +function normalizedFailure(value: string): string { + return redactAttemptEvidence(value).replace(/\s+/g, " ").trim(); +} + +export async function summarizeAttemptTrace( + trace: PlaywrightTrace, + attemptError: string | undefined, +) { + const actions = listTraceActions(trace, { limit: Number.MAX_SAFE_INTEGER }); + const logs = listTraceActionLogs(trace, { + limit: Number.MAX_SAFE_INTEGER, + maxTextBytes: Number.MAX_SAFE_INTEGER, + }); + const failure = readFailureStack(trace); + const traceError = failure?.errorMessage + ? normalizedFailure(failure.errorMessage) + : undefined; + const metadataError = attemptError + ? normalizedFailure(attemptError) + : undefined; + const errorMatches = Boolean( + traceError && + metadataError && + (metadataError.includes(traceError) || traceError.includes(metadataError)), + ); + const toAction = (action: (typeof actions.items)[number]) => { + const stack = + action.errorMessage === undefined + ? [] + : readTraceActionStack(trace, action).map( + (frame) => `${frame.file}:${frame.compiledLine}`, + ); + const error = + action.errorMessage === undefined + ? undefined + : redactAndBoundAttemptEvidenceResult( + action.errorMessage, + maxActionEvidenceBytes, + ); + const selector = + action.selector === undefined + ? undefined + : redactAndBoundAttemptEvidenceResult( + action.selector, + maxActionEvidenceBytes, + ); + return { + actionId: action.id, + apiName: action.apiName, + ...(action.endTime !== undefined && { + endedAtMilliseconds: action.endTime, + }), + ...(error && { error: error.text }), + ...((error?.truncated || selector?.truncated) && { + evidenceTruncated: true, + }), + ...(selector && { selector: selector.text }), + ...(action.startTime !== undefined && { + startedAtMilliseconds: action.startTime, + }), + stack: stack.slice(0, 20), + stackTruncated: stack.length > 20, + ...summarizeAttemptWaitingLogs(logs, action.id), + }; + }; + const completed = chronologicallySortActions( + actions.items.filter((action) => action.endTime !== undefined), + ); + const selectedActions = completed.slice(-evidenceLimit); + const lastCompleted = completed.at(-1); + const allConsoleEntries = listTraceConsole(trace, { + limit: Number.MAX_SAFE_INTEGER, + maxTextBytes: Number.MAX_SAFE_INTEGER, + }); + const consoleItems = allConsoleEntries.items.slice(-evidenceLimit); + const consoleSummaries = consoleItems.map((entry) => ({ + entry, + message: redactAndBoundAttemptEvidenceResult(entry.message, 1024), + })); + const network = await listTraceNetwork(trace, { + limit: Number.MAX_SAFE_INTEGER, + }); + const networkErrors = summarizeAttemptNetworkErrors(network); + const snapshots = listTraceSnapshots(trace, { + limit: Number.MAX_SAFE_INTEGER, + }); + const pageAction = errorMatches ? failure?.action : lastCompleted; + const actionSnapshots = snapshots.items.filter( + (snapshot) => + snapshot.phase === "after" && + snapshot.actionId === pageAction?.id && + (pageAction?.pageId === undefined || + snapshot.pageId === pageAction.pageId), + ); + const mainFrameSnapshot = actionSnapshots.find( + (snapshot) => snapshot.isMainFrame === true, + ); + const actionFrameIds = new Set( + actionSnapshots.map((snapshot) => snapshot.frameId), + ); + const onlyFrameSnapshot = + actionFrameIds.size === 1 ? actionSnapshots.at(-1) : undefined; + const fallbackSnapshot = snapshots.items.findLast( + (snapshot) => + snapshot.phase === "after" && + snapshot.isMainFrame === true && + (pageAction?.pageId === undefined || + snapshot.pageId === pageAction.pageId), + ); + const pageSnapshot = + mainFrameSnapshot ?? onlyFrameSnapshot ?? fallbackSnapshot; + const selectionBasis = mainFrameSnapshot + ? ("action-main-frame" as const) + : onlyFrameSnapshot + ? ("action-only-frame" as const) + : ("page-main-frame-fallback" as const); + const page = pageSnapshot + ? readTraceSnapshot(trace, pageSnapshot.id, { + maxBytes: maxSnapshotReconstructionBytes, + }) + : undefined; + const screenshot = pageSnapshot + ? nearestScreenshotForSnapshot(trace, pageSnapshot.id) + : undefined; + const projectedPage = page?.truncated + ? { text: "", truncated: true } + : page + ? boundAttemptPageText( + redactAttemptEvidence( + projectToText( + resolveSnapshot([page.html], 0), + Number.MAX_SAFE_INTEGER, + ), + ), + ) + : undefined; + + return { + actions: { + coverage: { + ...actions.coverage, + returned: selectedActions.length, + truncated: actions.coverage.total > selectedActions.length, + }, + items: selectedActions.map(toAction), + }, + browserConsole: { + coverage: { + ...allConsoleEntries.coverage, + returned: consoleSummaries.length, + truncated: + allConsoleEntries.coverage.truncated || + allConsoleEntries.coverage.total > consoleSummaries.length || + consoleSummaries.some(({ message }) => message.truncated), + }, + items: consoleSummaries.map(({ entry, message }) => ({ + evidenceId: entry.id, + message: message.text, + type: entry.type, + })), + }, + ...(errorMatches && + failure && { + failedBrowserAction: toAction(failure.action), + }), + failedBrowserActionAttribution: errorMatches + ? ("trace-error-matches-attempt" as const) + : ("none" as const), + ...(!errorMatches && + lastCompleted && { lastCompletedBrowserAction: toAction(lastCompleted) }), + networkErrors, + ...(page && + projectedPage && + pageSnapshot && { + pageState: { + frameId: pageSnapshot.frameId, + isMainFrame: pageSnapshot.isMainFrame, + pageId: pageSnapshot.pageId, + phase: pageSnapshot.phase, + screenshotRelationship: + screenshot?.relationship ?? ("unknown" as const), + ...(screenshot && { screenshotId: screenshot.screenshot.id }), + selectionBasis, + snapshotId: pageSnapshot.id, + text: projectedPage.text, + truncated: page.truncated || projectedPage.truncated, + url: redactAttemptEvidence(pageSnapshot.url), + }, + }), + }; +} diff --git a/src/domains/investigation/parser/projection/summarizeAttemptWaitingLogs.ts b/src/domains/investigation/parser/projection/summarizeAttemptWaitingLogs.ts new file mode 100644 index 000000000..ebe7ac2e4 --- /dev/null +++ b/src/domains/investigation/parser/projection/summarizeAttemptWaitingLogs.ts @@ -0,0 +1,29 @@ +import type { + Bounded, + TraceActionLog, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; + +import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; + +const evidenceLimit = 10; +const maxWaitingLogBytes = 512; + +export function summarizeAttemptWaitingLogs( + logs: Bounded, + actionId: string, +) { + const matching = logs.items.filter((log) => log.actionId === actionId); + const selected = matching.slice(-evidenceLimit); + const bounded = selected.map((log) => + redactAndBoundAttemptEvidenceResult(log.message, maxWaitingLogBytes), + ); + return { + waitingLogs: bounded.map(({ text }) => text), + waitingLogsTruncated: + logs.coverage.incomplete || + logs.coverage.truncated || + logs.coverage.total > logs.coverage.returned || + matching.length > selected.length || + bounded.some(({ truncated }) => truncated), + }; +} diff --git a/src/domains/investigation/parser/snapshot/snapshotHtmlProjection.test.ts b/src/domains/investigation/parser/snapshot/snapshotHtmlProjection.test.ts new file mode 100644 index 000000000..b62525047 --- /dev/null +++ b/src/domains/investigation/parser/snapshot/snapshotHtmlProjection.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test"; + +import { serializeSnapshotToHtml } from "./snapshotHtmlProjection.js"; + +describe("serializeSnapshotToHtml", () => { + it("omits active embedded documents", () => { + const result = serializeSnapshotToHtml({ + attrs: {}, + children: [ + { + attrs: { data: "data:text/html," }, + children: ["unsafe fallback"], + tag: "OBJECT", + }, + { + attrs: { srcdoc: "" }, + children: [], + tag: "IFRAME", + }, + "safe text", + ], + tag: "DIV", + }); + + expect(result.html).toBe("
safe text
"); + expect(result.html).not.toContain("data:text/html"); + expect(result.html).not.toContain("srcdoc"); + }); +}); diff --git a/src/domains/investigation/parser/snapshot/snapshotHtmlProjection.ts b/src/domains/investigation/parser/snapshot/snapshotHtmlProjection.ts new file mode 100644 index 000000000..ac8f1e6d1 --- /dev/null +++ b/src/domains/investigation/parser/snapshot/snapshotHtmlProjection.ts @@ -0,0 +1,228 @@ +// oxlint-disable eslint/max-lines -- Keep the sanitizer and its byte-budget writer aligned with the platform implementation. + +import { redactAttemptEvidence } from "~/domains/investigation/parser/projection/attemptEvidenceText.js"; + +import type { Element, Node } from "./snapshotProjection.js"; + +const maxHtmlBytes = 64 * 1024; +const maxHtmlDepth = 100; +const maxHtmlNodes = 10_000; +const omittedTags = new Set([ + "APPLET", + "BASE", + "EMBED", + "FRAME", + "FRAMESET", + "IFRAME", + "LINK", + "META", + "NOSCRIPT", + "OBJECT", + "PORTAL", + "SCRIPT", + "STYLE", + "SVG", +]); +const validName = /^[A-Za-z][A-Za-z0-9:._-]*$/; +const urlAttributeNames = new Set([ + "action", + "data", + "formaction", + "href", + "poster", + "src", + "srcset", + "xlink:href", +]); +const formTags = new Set(["BUTTON", "INPUT", "OPTION", "SELECT", "TEXTAREA"]); +const sensitiveAttributeName = + /(?:^|[-_:])(?:access[-_:]?token|api[-_:]?key|authorization|cookie|password|refresh[-_:]?token|set[-_:]?cookie|signature|token)(?:$|[-_:])/i; +const allowedUrlSchemes = new Set(["http", "https", "mailto", "tel"]); + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +class HtmlWriter { + readonly chunks: string[] = []; + length = 0; + stopped = false; + truncated = false; + + append(value: string, reservedBytes: number): boolean { + const bytes = Buffer.byteLength(value); + if (this.length + bytes + reservedBytes > maxHtmlBytes) { + this.stopped = true; + this.truncated = true; + return false; + } + this.chunks.push(value); + this.length += bytes; + return true; + } + + appendText(value: string, reservedBytes: number) { + if (this.append(value, reservedBytes)) return; + const availableBytes = maxHtmlBytes - this.length - reservedBytes; + if (availableBytes <= 0) return; + let low = 0; + let high = value.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (Buffer.byteLength(value.slice(0, middle)) <= availableBytes) + low = middle; + else high = middle - 1; + } + if (low > 0 && /[\uD800-\uDBFF]/.test(value[low - 1] ?? "")) low -= 1; + const unmatchedEntity = value.lastIndexOf("&", low - 1); + const lastEntityEnd = value.lastIndexOf(";", low - 1); + if (unmatchedEntity > lastEntityEnd) low = unmatchedEntity; + const prefix = value.slice(0, low); + if (prefix) { + this.chunks.push(prefix); + this.length += Buffer.byteLength(prefix); + } + } +} + +function sanitizedAttributeValue( + element: Element, + name: string, + value: string, +) { + const normalizedName = name.toLowerCase(); + const normalizedTag = element.tag.toUpperCase(); + if ( + normalizedName === "srcdoc" || + normalizedName === "style" || + normalizedName.startsWith("on") + ) + return undefined; + if ( + sensitiveAttributeName.test(normalizedName) || + (normalizedName === "value" && formTags.has(normalizedTag)) + ) + return "[REDACTED]"; + if (urlAttributeNames.has(normalizedName)) return sanitizedUrl(value); + return redactAttemptEvidence(value); +} + +function urlScheme(value: string): string | undefined { + const colon = value.indexOf(":"); + if (colon === -1) return undefined; + const firstPathDelimiter = value.search(/[/?#]/); + if (firstPathDelimiter !== -1 && firstPathDelimiter < colon) return undefined; + const scheme = Array.from(value.slice(0, colon)) + .filter((character) => { + const code = character.codePointAt(0) ?? 0; + return code > 0x20 && code !== 0x7f; + }) + .join("") + .toLowerCase(); + return /^[a-z][a-z0-9+.-]*$/.test(scheme) ? scheme : "invalid"; +} + +function redactUrlUserInfo(value: string): string | undefined { + const protocolRelative = value.startsWith("//"); + try { + const url = new URL(protocolRelative ? `https:${value}` : value); + if (url.username) url.username = "[REDACTED]"; + if (url.password) url.password = "[REDACTED]"; + const serialized = url.toString(); + return protocolRelative ? serialized.slice("https:".length) : serialized; + } catch { + return undefined; + } +} + +function sanitizedUrl(value: string): string | undefined { + let leadingCharacters = 0; + while (leadingCharacters < value.length) { + const code = value.codePointAt(leadingCharacters) ?? 0; + if (code > 0x20 && code !== 0x7f) break; + leadingCharacters += 1; + } + const normalizedValue = value.slice(leadingCharacters); + const scheme = urlScheme(normalizedValue); + if (scheme !== undefined && !allowedUrlSchemes.has(scheme)) return undefined; + const withRedactedUserInfo = + scheme === "http" || scheme === "https" || normalizedValue.startsWith("//") + ? redactUrlUserInfo(normalizedValue) + : normalizedValue; + return withRedactedUserInfo === undefined + ? undefined + : redactAttemptEvidence(withRedactedUserInfo); +} + +function serializedStartTag(element: Element): string { + const attributes = Object.entries(element.attrs ?? {}).flatMap( + ([name, value]) => { + if (!validName.test(name) || typeof value !== "string") return []; + const sanitized = sanitizedAttributeValue(element, name, value); + return sanitized === undefined + ? [] + : [` ${name}="${escapeHtml(sanitized)}"`]; + }, + ); + return `<${element.tag.toLowerCase()}${attributes.join("")}>`; +} + +type VisitState = { depth: number; redactText: boolean; reservedBytes: number }; + +export function serializeSnapshotToHtml(root: Node): { + html: string; + truncated: boolean; +} { + const writer = new HtmlWriter(); + let nodes = 0; + + const visit = (node: Node, state: VisitState) => { + if (writer.stopped) return; + nodes += 1; + if (nodes > maxHtmlNodes || state.depth > maxHtmlDepth) { + writer.stopped = true; + writer.truncated = true; + return; + } + if (typeof node === "string") { + writer.appendText( + escapeHtml( + state.redactText ? "[REDACTED]" : redactAttemptEvidence(node), + ), + state.reservedBytes, + ); + return; + } + const normalizedTag = node.tag.toUpperCase(); + if (omittedTags.has(normalizedTag)) return; + if (!validName.test(node.tag)) { + writer.truncated = true; + for (const child of node.children) visit(child, state); + return; + } + const closingTag = ``; + const childState = { + depth: state.depth + 1, + redactText: state.redactText || normalizedTag === "TEXTAREA", + reservedBytes: state.reservedBytes + Buffer.byteLength(closingTag), + }; + if (!writer.append(serializedStartTag(node), childState.reservedBytes)) + return; + for (const child of node.children) { + visit(child, childState); + if (writer.stopped) break; + } + const wasStopped = writer.stopped; + writer.stopped = false; + writer.append(closingTag, state.reservedBytes); + writer.stopped ||= wasStopped; + }; + + visit(root, { depth: 0, redactText: false, reservedBytes: 0 }); + return { html: writer.chunks.join(""), truncated: writer.truncated }; +} diff --git a/src/domains/investigation/parser/snapshot/snapshotProjection.ts b/src/domains/investigation/parser/snapshot/snapshotProjection.ts new file mode 100644 index 000000000..e226faeea --- /dev/null +++ b/src/domains/investigation/parser/snapshot/snapshotProjection.ts @@ -0,0 +1,100 @@ +// oxlint-disable eslint/max-lines -- Keep snapshot resolution and text projection aligned with the platform implementation. + +import { reconstructSnapshotHtml } from "~/domains/investigation/parser/trace/playwrightTraceSnapshots.js"; + +// Playwright stores a page snapshot as nested arrays: a text node is a +// string, an element is [tag, attributes, ...children], and [[back, index]] +// points at a node of an earlier snapshot of the same frame, `back` +// snapshots before this one, at position `index` in post-order. +type ElementArray = [string, ...RawNode[]]; +type RawNode = null | string | ElementArray | Record; + +export type Element = { + attrs: Record; + children: Node[]; + tag: string; +}; +export type Node = string | Element; + +import { + shownAttributes, + shownTags, + skippedTags, +} from "./snapshotVocabulary.js"; + +const isElementArray = (node: unknown): node is ElementArray => + Array.isArray(node) && typeof node[0] === "string"; + +/** Resolves the references of one snapshot against the earlier ones of the same frame. */ +export function resolveSnapshot( + frameSnapshots: unknown[], + index: number, +): Node { + const visit = (node: unknown): Node => { + if (typeof node === "string") return node; + if (!isElementArray(node)) return ""; + const [tag, second, ...rest] = node; + const hasAttrsSlot = + second === null || + (second !== undefined && + typeof second === "object" && + !Array.isArray(second)); + const attrs = hasAttrsSlot && second !== null ? second : {}; + const children = hasAttrsSlot + ? rest + : second === undefined + ? [] + : [second, ...rest]; + return { + attrs, + children: children.map((child) => visit(child)), + tag, + }; + }; + + const root = reconstructSnapshotHtml(frameSnapshots, index); + return root === undefined ? "" : visit(root); +} + +const collapse = (text: string) => text.replace(/\s+/g, " ").trim(); + +type Projection = { + depth: number; + lines: string[]; + max: number; + redactText: boolean; +}; + +/** A compact, model-readable projection: landmarks, controls, and text. */ +function project(node: Node, projection: Projection) { + const { depth, lines, max } = projection; + if (lines.length >= max) return; + if (typeof node === "string") { + const text = projection.redactText ? "[REDACTED]" : collapse(node); + if (text) lines.push(`${" ".repeat(depth)}${text}`); + return; + } + if (skippedTags.has(node.tag)) return; + const shown = shownTags.has(node.tag); + if (shown) { + const attrs = shownAttributes + .filter((name) => node.attrs[name] !== undefined) + .map((name) => ` ${name}="${node.attrs[name] ?? ""}"`) + .join(""); + lines.push(`${" ".repeat(depth)}<${node.tag.toLowerCase()}${attrs}>`); + } + node.children.forEach((child) => + project(child, { + depth: depth + (shown ? 1 : 0), + lines, + max, + redactText: projection.redactText || node.tag === "TEXTAREA", + }), + ); +} + +export function projectToText(node: Node, max: number): string { + const lines: string[] = []; + project(node, { depth: 0, lines, max, redactText: false }); + return lines.join("\n"); +} diff --git a/src/domains/investigation/parser/snapshot/snapshotVocabulary.ts b/src/domains/investigation/parser/snapshot/snapshotVocabulary.ts new file mode 100644 index 000000000..cdaaf1585 --- /dev/null +++ b/src/domains/investigation/parser/snapshot/snapshotVocabulary.ts @@ -0,0 +1,41 @@ +// Which parts of a page snapshot reach the model. + +export const skippedTags = new Set([ + "HEAD", + "LINK", + "META", + "NOSCRIPT", + "PATH", + "SCRIPT", + "STYLE", + "SVG", +]); +export const shownTags = new Set([ + "A", + "BUTTON", + "FOOTER", + "FORM", + "H1", + "H2", + "H3", + "H4", + "HEADER", + "IMG", + "INPUT", + "LABEL", + "LI", + "MAIN", + "NAV", + "SELECT", + "TEXTAREA", +]); +export const shownAttributes = [ + "alt", + "aria-label", + "href", + "id", + "name", + "placeholder", + "role", + "type", +]; diff --git a/src/domains/investigation/parser/trace/playwrightTrace.ts b/src/domains/investigation/parser/trace/playwrightTrace.ts new file mode 100644 index 000000000..191eb8f26 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTrace.ts @@ -0,0 +1,27 @@ +export { + listTraceActions, + readFailureStack, + readTraceActionStack, +} from "./playwrightTraceActions.js"; +export { + listTraceActionLogs, + listTraceConsole, + nearestScreenshotForSnapshot, + readTraceScreenshot, +} from "./playwrightTraceMedia.js"; +export { + listTraceNetwork, + readTraceResponseBody, +} from "./playwrightTraceNetwork.js"; +export { parsePlaywrightTraceArchive } from "./playwrightTraceParse.js"; +export { + listTraceSnapshots, + readTraceSnapshot, +} from "./playwrightTraceSnapshots.js"; +export type { + Bounded, + PlaywrightTrace, + TraceAction, + TraceActionLog, + TraceNetworkEntry, +} from "./playwrightTraceTypes.js"; diff --git a/src/domains/investigation/parser/trace/playwrightTraceActions.ts b/src/domains/investigation/parser/trace/playwrightTraceActions.ts new file mode 100644 index 000000000..495dec281 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceActions.ts @@ -0,0 +1,140 @@ +// oxlint-disable eslint/max-lines -- Keep cached action indexing and stable evidence IDs aligned with the platform reader. + +import type { + Bounded, + FailureStack, + PlaywrightTrace, + StackFrame, + TraceAction, + TraceEvent, +} from "./playwrightTraceTypes.js"; +import { traceLimits } from "./playwrightTraceTypes.js"; +import { bounded } from "./playwrightTraceUtils.js"; + +const actionsCache = new WeakMap< + PlaywrightTrace["streams"][number], + ReturnType +>(); + +function shape(event: TraceEvent) { + return { + apiName: + event.class && event.method + ? `${event.class}.${event.method}` + : "unknown action", + expression: event.params?.expression, + selector: event.params?.selector, + }; +} + +function actionShapeKey(actionShape: ReturnType): string { + return JSON.stringify([ + actionShape.apiName, + actionShape.expression, + actionShape.selector, + ]); +} + +function actionsForStream( + stream: PlaywrightTrace["streams"][number], +): { action: TraceAction; event: TraceEvent }[] { + const cached = actionsCache.get(stream); + if (cached) return cached; + const actions = readActionsForStream(stream); + actionsCache.set(stream, actions); + return actions; +} + +function readActionsForStream( + stream: PlaywrightTrace["streams"][number], +): { action: TraceAction; event: TraceEvent }[] { + const afterByCallId = new Map(); + for (const event of stream.events) { + if (event.type === "after" && event.callId) + afterByCallId.set(event.callId, event); + } + const occurrences = new Map(); + return stream.events.flatMap((event) => { + if (event.type !== "before" || !event.callId) return []; + const actionShape = shape(event); + const shapeKey = actionShapeKey(actionShape); + const occurrence = occurrences.get(shapeKey) ?? 0; + occurrences.set(shapeKey, occurrence + 1); + const after = afterByCallId.get(event.callId); + return [ + { + action: { + ...actionShape, + callId: event.callId, + endTime: after?.endTime, + errorMessage: after?.error?.message, + id: `${stream.id}:action:${event.callId}`, + occurrence, + pageId: event.pageId, + startTime: event.startTime, + streamId: stream.id, + }, + event, + }, + ]; + }); +} + +export function listTraceActions( + trace: PlaywrightTrace, + options: { limit?: number } = {}, +): Bounded { + const actions = trace.streams.flatMap((stream) => + actionsForStream(stream).map(({ action }) => action), + ); + return bounded(actions, options.limit ?? traceLimits.maxItems, { + incomplete: trace.incomplete, + malformed: trace.malformedLineCount, + }); +} + +function repoPath(compiledFile: string): string | undefined { + const marker = "generatedProgram/"; + const index = compiledFile.indexOf(marker); + if (index === -1) return undefined; + const relative = compiledFile.slice(index + marker.length); + return relative.startsWith("src/") + ? relative.replace(/\.js$/, ".ts") + : undefined; +} + +export function readTraceActionStack( + trace: PlaywrightTrace, + action: TraceAction, +): StackFrame[] { + const stream = trace.streams.find( + (candidate) => candidate.id === action.streamId, + ); + const callNumber = /^call@(\d+)$/.exec(action.callId)?.[1]; + const rawFrames = + callNumber === undefined + ? [] + : (stream?.stacks.stacks.find(([id]) => id === Number(callNumber))?.[1] ?? + []); + return rawFrames.flatMap(([fileIndex, line, , functionName]) => { + const compiledFile = stream?.stacks.files[fileIndex]; + const file = compiledFile ? repoPath(compiledFile) : undefined; + return file + ? [{ compiledLine: line, file, functionName: functionName || undefined }] + : []; + }); +} + +export function readFailureStack( + trace: PlaywrightTrace, +): FailureStack | undefined { + const action = listTraceActions(trace, { + limit: Number.MAX_SAFE_INTEGER, + }).items.findLast((candidate) => candidate.errorMessage !== undefined); + if (!action?.errorMessage) return undefined; + return { + action, + errorMessage: action.errorMessage, + frames: readTraceActionStack(trace, action), + }; +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceEvent.ts b/src/domains/investigation/parser/trace/playwrightTraceEvent.ts new file mode 100644 index 000000000..102b937d1 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceEvent.ts @@ -0,0 +1,136 @@ +import type { TraceEvent } from "./playwrightTraceTypes.js"; +import { asObject } from "./playwrightTraceUtils.js"; + +export function normalizeTraceEvent(value: unknown): TraceEvent | undefined { + const event = asObject(value); + if (!event || typeof event.type !== "string") return undefined; + const stringFields = [ + "callId", + "class", + "message", + "messageType", + "method", + "pageId", + "sha1", + "text", + ]; + const numberFields = [ + "endTime", + "height", + "startTime", + "time", + "timestamp", + "width", + ]; + if ( + stringFields.some( + (key) => event[key] !== undefined && typeof event[key] !== "string", + ) + ) + return undefined; + if ( + numberFields.some( + (key) => event[key] !== undefined && typeof event[key] !== "number", + ) + ) + return undefined; + const params = + event.params === undefined ? undefined : asObject(event.params); + if ( + event.params !== undefined && + (!params || + (params.expression !== undefined && + typeof params.expression !== "string") || + (params.selector !== undefined && typeof params.selector !== "string")) + ) + return undefined; + const error = event.error === undefined ? undefined : asObject(event.error); + if ( + event.error !== undefined && + (!error || typeof error.message !== "string") + ) + return undefined; + const snapshot = + event.snapshot === undefined ? undefined : asObject(event.snapshot); + if ( + event.snapshot !== undefined && + (!snapshot || + typeof snapshot.frameId !== "string" || + typeof snapshot.frameUrl !== "string" || + typeof snapshot.snapshotName !== "string" || + (snapshot.callId !== undefined && typeof snapshot.callId !== "string") || + (snapshot.pageId !== undefined && typeof snapshot.pageId !== "string") || + (snapshot.isMainFrame !== undefined && + typeof snapshot.isMainFrame !== "boolean") || + (snapshot.timestamp !== undefined && + typeof snapshot.timestamp !== "number")) + ) + return undefined; + if ( + ((event.type === "before" || event.type === "after") && + typeof event.callId !== "string") || + (event.type === "frame-snapshot" && !snapshot) || + (event.type === "log" && typeof event.message !== "string") || + (event.type === "console" && typeof event.text !== "string") || + (event.type === "screencast-frame" && typeof event.sha1 !== "string") + ) + return undefined; + return { + callId: typeof event.callId === "string" ? event.callId : undefined, + class: typeof event.class === "string" ? event.class : undefined, + endTime: typeof event.endTime === "number" ? event.endTime : undefined, + error: + error && typeof error.message === "string" + ? { message: error.message } + : undefined, + height: typeof event.height === "number" ? event.height : undefined, + message: typeof event.message === "string" ? event.message : undefined, + messageType: + typeof event.messageType === "string" ? event.messageType : undefined, + method: typeof event.method === "string" ? event.method : undefined, + pageId: typeof event.pageId === "string" ? event.pageId : undefined, + params: params + ? { + expression: + typeof params.expression === "string" + ? params.expression + : undefined, + selector: + typeof params.selector === "string" ? params.selector : undefined, + } + : undefined, + sha1: typeof event.sha1 === "string" ? event.sha1 : undefined, + snapshot: snapshot + ? { + callId: + typeof snapshot.callId === "string" ? snapshot.callId : undefined, + frameId: typeof snapshot.frameId === "string" ? snapshot.frameId : "", + frameUrl: + typeof snapshot.frameUrl === "string" ? snapshot.frameUrl : "", + html: snapshot.html, + isMainFrame: + typeof snapshot.isMainFrame === "boolean" + ? snapshot.isMainFrame + : undefined, + pageId: + typeof snapshot.pageId === "string" ? snapshot.pageId : undefined, + snapshotName: + typeof snapshot.snapshotName === "string" + ? snapshot.snapshotName + : "", + timestamp: + typeof snapshot.timestamp === "number" + ? snapshot.timestamp + : undefined, + } + : undefined, + startTime: + typeof event.startTime === "number" ? event.startTime : undefined, + text: typeof event.text === "string" ? event.text : undefined, + time: typeof event.time === "number" ? event.time : undefined, + timestamp: + typeof event.timestamp === "number" ? event.timestamp : undefined, + type: event.type, + width: typeof event.width === "number" ? event.width : undefined, + }; +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceMedia.ts b/src/domains/investigation/parser/trace/playwrightTraceMedia.ts new file mode 100644 index 000000000..75e676934 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceMedia.ts @@ -0,0 +1,178 @@ +// oxlint-disable eslint/max-lines -- Keep media IDs, reads, and nearest-screenshot selection aligned with the platform reader. + +import { listTraceSnapshots } from "./playwrightTraceSnapshots.js"; +import type { + Bounded, + PlaywrightTrace, + TraceActionLog, + TraceConsoleEntry, + TraceScreenshot, +} from "./playwrightTraceTypes.js"; +import { traceLimits } from "./playwrightTraceTypes.js"; +import { bounded } from "./playwrightTraceUtils.js"; +import { readZipBytes } from "./playwrightTraceUtils.js"; + +function listTraceScreenshots( + trace: PlaywrightTrace, + options: { limit?: number; pageId?: string } = {}, +): Bounded { + const items = trace.streams + .flatMap((stream) => + stream.events.flatMap((event, index) => + event.type === "screencast-frame" && event.sha1 + ? [ + { + height: event.height, + id: `${stream.id}:screenshot:${index}`, + pageId: event.pageId, + resourceName: `resources/${event.sha1}`, + streamId: stream.id, + timestamp: event.timestamp, + width: event.width, + }, + ] + : [], + ), + ) + .filter( + (item) => options.pageId === undefined || item.pageId === options.pageId, + ); + return bounded(items, options.limit ?? traceLimits.maxItems, { + incomplete: trace.incomplete, + malformed: trace.malformedLineCount, + }); +} + +export async function readTraceScreenshot( + trace: PlaywrightTrace, + screenshotId: string, + options: { maxBytes?: number } = {}, +): Promise< + | { + imageJpegBase64: string; + screenshot: TraceScreenshot; + status: "available"; + } + | { screenshot: TraceScreenshot; status: "missing" | "too-large" } + | undefined +> { + const screenshot = listTraceScreenshots(trace, { + limit: Number.MAX_SAFE_INTEGER, + }).items.find((item) => item.id === screenshotId); + if (!screenshot) return undefined; + const entry = trace.archive.file(screenshot.resourceName); + if (!entry) return { screenshot, status: "missing" }; + const content = await readZipBytes( + entry, + options.maxBytes ?? traceLimits.maxBodyBytes, + ); + if (content.malformed) return { screenshot, status: "missing" }; + if (content.incomplete) return { screenshot, status: "too-large" }; + return { + imageJpegBase64: content.bytes.toString("base64"), + screenshot, + status: "available", + }; +} + +export function listTraceConsole( + trace: PlaywrightTrace, + options: { limit?: number; maxTextBytes?: number } = {}, +): Bounded { + const maxBytes = options.maxTextBytes ?? traceLimits.maxTextBytes; + const items = trace.streams.flatMap((stream) => + stream.events.flatMap((event, index) => { + if (event.type !== "console") return []; + const text = typeof event.text === "string" ? event.text : ""; + const message = + Buffer.byteLength(text) <= maxBytes + ? text + : `${Buffer.from(text).subarray(0, maxBytes).toString("utf8")}[TRUNCATED]`; + return [ + { + id: `${stream.id}:console:${index}`, + message, + pageId: event.pageId, + streamId: stream.id, + time: event.time, + type: event.messageType ?? "log", + }, + ]; + }), + ); + return bounded(items, options.limit ?? traceLimits.maxItems, { + incomplete: trace.incomplete, + malformed: trace.malformedLineCount, + }); +} + +export function listTraceActionLogs( + trace: PlaywrightTrace, + options: { limit?: number; maxTextBytes?: number } = {}, +): Bounded { + const maxBytes = options.maxTextBytes ?? traceLimits.maxTextBytes; + const items = trace.streams.flatMap((stream) => + stream.events.flatMap((event, index) => { + if (event.type !== "log") return []; + const actionId = event.callId + ? `${stream.id}:action:${event.callId}` + : undefined; + const text = event.message ?? ""; + const message = + Buffer.byteLength(text) <= maxBytes + ? text + : `${Buffer.from(text).subarray(0, maxBytes).toString("utf8")}[TRUNCATED]`; + return [ + { + actionId, + id: `${stream.id}:action-log:${index}`, + message, + pageId: event.pageId, + streamId: stream.id, + time: event.time, + type: "action", + }, + ]; + }), + ); + return bounded(items, options.limit ?? traceLimits.maxItems, { + incomplete: trace.incomplete, + malformed: trace.malformedLineCount, + }); +} + +export function nearestScreenshotForSnapshot( + trace: PlaywrightTrace, + snapshotId: string, +) { + const snapshot = listTraceSnapshots(trace, { + limit: Number.MAX_SAFE_INTEGER, + }).items.find((item) => item.id === snapshotId); + if (!snapshot || snapshot.timestamp === undefined) return undefined; + const timestamp = snapshot.timestamp; + const nearest = listTraceScreenshots(trace, { + limit: Number.MAX_SAFE_INTEGER, + ...(snapshot.pageId !== undefined && { pageId: snapshot.pageId }), + }) + .items.filter( + (item) => + item.streamId === snapshot.streamId && item.timestamp !== undefined, + ) + .map((screenshot) => ({ + delta: (screenshot.timestamp ?? 0) - timestamp, + screenshot, + })) + .sort((left, right) => Math.abs(left.delta) - Math.abs(right.delta))[0]; + return nearest + ? { + deltaMilliseconds: Math.abs(nearest.delta), + relationship: + nearest.delta === 0 + ? ("same-time" as const) + : nearest.delta < 0 + ? ("before" as const) + : ("after" as const), + screenshot: nearest.screenshot, + } + : undefined; +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceNetwork.ts b/src/domains/investigation/parser/trace/playwrightTraceNetwork.ts new file mode 100644 index 000000000..1fce15910 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceNetwork.ts @@ -0,0 +1,205 @@ +// oxlint-disable eslint/max-lines -- Keep aggregate archive budgets and page-to-stream attribution aligned with the optimized platform reader. + +import { indexTraceStreamPages } from "./playwrightTraceNetworkStreams.js"; +import type { + Bounded, + PlaywrightTrace, + TraceNetworkEntry, +} from "./playwrightTraceTypes.js"; +import { traceLimits } from "./playwrightTraceTypes.js"; +import { + asObject, + bounded, + parseJson, + readZipBytes, + readZipText, +} from "./playwrightTraceUtils.js"; + +const sensitive = + /^(authorization|cookie|set-cookie|proxy-authorization|x-api-key)$/i; +const networkCache = new WeakMap< + PlaywrightTrace, + ReturnType +>(); +const headers = (value: unknown): Record => + Array.isArray(value) + ? Object.fromEntries( + value.flatMap((header) => { + const object = asObject(header); + return object && + typeof object.name === "string" && + typeof object.value === "string" + ? [ + [ + object.name, + sensitive.test(object.name) ? "[REDACTED]" : object.value, + ], + ] + : []; + }), + ) + : {}; + +async function parseNetwork(trace: PlaywrightTrace, maxEntryBytes: number) { + const entries = Object.values(trace.archive.files) + .filter((entry) => !entry.dir && entry.name.endsWith(".network")) + .sort((left, right) => left.name.localeCompare(right.name)); + const items: { object: Record; streamId: string }[] = []; + let decodedBytes = trace.artifactBudget.decodedBytes; + let eventCount = trace.artifactBudget.events; + let memberCount = trace.artifactBudget.members; + let malformed = 0; + let incomplete = false; + for (const entry of entries) { + if ( + decodedBytes >= trace.limits.maxTotalDecodedBytes || + eventCount >= trace.limits.maxEvents || + memberCount >= trace.limits.maxMembers + ) { + incomplete = true; + break; + } + const content = await readZipText( + entry, + Math.min( + maxEntryBytes, + trace.limits.maxEntryBytes, + trace.limits.maxTotalDecodedBytes - decodedBytes, + ), + ); + if (!content) continue; + decodedBytes += Buffer.byteLength(content.text); + memberCount += 1; + incomplete ||= content.incomplete; + if (content.malformed) { + malformed += 1; + continue; + } + const tracePrefix = `${entry.name.replace(/\.network$/, ".trace")}#`; + const pairedStreams = trace.streams.filter((stream) => + stream.id.startsWith(tracePrefix), + ); + if (!pairedStreams.length) { + malformed += 1; + continue; + } + const fallbackStream = pairedStreams[0]; + if (!fallbackStream) continue; + const streamIdsByPage = indexTraceStreamPages(pairedStreams); + const lines = content.text.split("\n").filter(Boolean); + for (const line of lines) { + if (eventCount >= trace.limits.maxEvents) { + incomplete = true; + break; + } + eventCount += 1; + const object = asObject(parseJson(line)); + if (!object) { + malformed += 1; + continue; + } + const snapshot = asObject(object.snapshot); + const pageId = + typeof snapshot?.pageref === "string" ? snapshot.pageref : undefined; + items.push({ + object, + streamId: streamIdsByPage.get(pageId) ?? fallbackStream.id, + }); + } + } + return { incomplete, items, malformed }; +} + +async function readNetwork(trace: PlaywrightTrace, maxEntryBytes: number) { + const parsed = await parseNetwork(trace, maxEntryBytes); + const items = parsed.items.flatMap(({ object, streamId }, index) => { + if (object["type"] !== "resource-snapshot") return []; + const snapshot = asObject(object["snapshot"]); + const request = asObject(snapshot?.request); + const response = asObject(snapshot?.response); + const content = asObject(response?.content); + if ( + !request || + typeof request.url !== "string" || + typeof request.method !== "string" + ) + return []; + const bodyResourceName = + typeof content?._sha1 === "string" + ? `resources/${content._sha1}` + : undefined; + return [ + { + bodyAvailable: + bodyResourceName !== undefined && + trace.archive.file(bodyResourceName) !== null, + bodyResourceName, + durationMilliseconds: + typeof snapshot?.time === "number" ? snapshot.time : undefined, + failureText: + typeof response?._failureText === "string" + ? response._failureText + : undefined, + frameId: + typeof snapshot?._frameref === "string" + ? snapshot._frameref + : undefined, + id: `${streamId}:network:${index}`, + method: request.method, + pageId: + typeof snapshot?.pageref === "string" ? snapshot.pageref : undefined, + requestHeaders: headers(request.headers), + responseHeaders: headers(response?.headers), + startedAtMilliseconds: + typeof snapshot?._monotonicTime === "number" + ? snapshot._monotonicTime + : undefined, + status: + typeof response?.status === "number" ? response.status : undefined, + streamId, + url: request.url, + }, + ]; + }); + return { ...parsed, items }; +} + +export async function listTraceNetwork( + trace: PlaywrightTrace, + options: { limit?: number; maxEntryBytes?: number } = {}, +): Promise> { + const maxEntryBytes = options.maxEntryBytes ?? traceLimits.maxEntryBytes; + const useCache = maxEntryBytes === traceLimits.maxEntryBytes; + const cached = useCache ? networkCache.get(trace) : undefined; + const pending = cached ?? readNetwork(trace, maxEntryBytes); + if (useCache && !cached) networkCache.set(trace, pending); + const parsed = await pending; + return bounded(parsed.items, options.limit ?? traceLimits.maxItems, { + incomplete: trace.incomplete || parsed.incomplete, + malformed: trace.malformedLineCount + parsed.malformed, + }); +} + +export async function readTraceResponseBody( + trace: PlaywrightTrace, + networkId: string, + options: { maxBytes?: number } = {}, +): Promise< + | { bodyBase64: string; status: "available"; truncated: boolean } + | { status: "unavailable" } +> { + const entry = ( + await listTraceNetwork(trace, { limit: Number.MAX_SAFE_INTEGER }) + ).items.find((item) => item.id === networkId); + if (!entry?.bodyResourceName) return { status: "unavailable" }; + const resource = trace.archive.file(entry.bodyResourceName); + if (!resource) return { status: "unavailable" }; + const limit = options.maxBytes ?? traceLimits.maxBodyBytes; + const content = await readZipBytes(resource, limit); + if (content.malformed) return { status: "unavailable" }; + return { + bodyBase64: content.bytes.toString("base64"), + status: "available", + truncated: content.incomplete, + }; +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceNetworkStreams.ts b/src/domains/investigation/parser/trace/playwrightTraceNetworkStreams.ts new file mode 100644 index 000000000..fff534589 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceNetworkStreams.ts @@ -0,0 +1,14 @@ +import type { TraceStream } from "./playwrightTraceTypes.js"; + +export function indexTraceStreamPages(streams: TraceStream[]) { + const streamIdsByPage = new Map(); + for (const stream of streams) { + for (const event of stream.events) { + for (const pageId of [event.pageId, event.snapshot?.pageId]) { + if (!streamIdsByPage.has(pageId)) + streamIdsByPage.set(pageId, stream.id); + } + } + } + return streamIdsByPage; +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceParse.ts b/src/domains/investigation/parser/trace/playwrightTraceParse.ts new file mode 100644 index 000000000..5301997c9 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceParse.ts @@ -0,0 +1,86 @@ +import JSZip from "jszip"; + +import { parseTraceEntry } from "./playwrightTraceParseEntry.js"; +import type { + PlaywrightTrace, + TraceLimits, + TraceReadResult, +} from "./playwrightTraceTypes.js"; +import { traceLimits } from "./playwrightTraceTypes.js"; + +export async function parsePlaywrightTraceArchive( + raw: Buffer, + options: { limits?: Partial } = {}, +): Promise { + const limits = { ...traceLimits, ...options.limits }; + if (raw.byteLength > limits.maxArchiveBytes) { + return { + incomplete: true, + malformedLineCount: 0, + reason: "archive-too-large", + status: "malformed", + }; + } + let archive: JSZip; + try { + archive = await JSZip.loadAsync(raw); + } catch { + return { + incomplete: true, + malformedLineCount: 1, + reason: "invalid-zip", + status: "malformed", + }; + } + const entries = Object.values(archive.files) + .filter((entry) => !entry.dir && entry.name.endsWith(".trace")) + .sort((left, right) => left.name.localeCompare(right.name)); + if (!entries.length) { + return { + incomplete: false, + malformedLineCount: 0, + reason: "no-trace-stream", + status: "malformed", + }; + } + const budget = { decodedBytes: 0, events: 0, members: 0 }; + const parsedEntries = []; + for (const entry of entries) { + if (budget.members >= limits.maxMembers) break; + const parsed = await parseTraceEntry({ + archive, + budget, + limits, + traceEntry: entry, + }); + budget.decodedBytes += parsed.decodedBytes; + budget.events += parsed.events; + budget.members += parsed.members; + parsedEntries.push(parsed); + } + const streams = parsedEntries.flatMap((entry) => entry.streams); + const incomplete = + entries.length > parsedEntries.length || + parsedEntries.some((entry) => entry.incomplete); + const malformedLineCount = parsedEntries.reduce( + (sum, entry) => sum + entry.malformedLineCount, + 0, + ); + if (!streams.length) { + return { + incomplete, + malformedLineCount, + reason: "no-trace-stream", + status: "malformed", + }; + } + const trace: PlaywrightTrace = { + archive, + artifactBudget: budget, + incomplete, + limits, + malformedLineCount, + streams, + }; + return { status: trace.incomplete ? "incomplete" : "ready", trace }; +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceParseEntry.ts b/src/domains/investigation/parser/trace/playwrightTraceParseEntry.ts new file mode 100644 index 000000000..12c6bd817 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceParseEntry.ts @@ -0,0 +1,157 @@ +// oxlint-disable eslint/max-lines -- Keep the per-entry archive budget and stream segmentation in one module. + +import type JSZip from "jszip"; + +import { normalizeTraceEvent } from "./playwrightTraceEvent.js"; +import type { + TraceLimits, + TraceStackFrame, + TraceStacks, + TraceStream, +} from "./playwrightTraceTypes.js"; +import { asObject, parseJson, readZipText } from "./playwrightTraceUtils.js"; + +function parseStacks(raw: string | undefined): TraceStacks { + const value = raw === undefined ? undefined : asObject(parseJson(raw)); + if (!value || !Array.isArray(value.files) || !Array.isArray(value.stacks)) + return { files: [], stacks: [] }; + const files = value.files.filter( + (file): file is string => typeof file === "string", + ); + const stacks = value.stacks.flatMap((entry) => { + if ( + !Array.isArray(entry) || + typeof entry[0] !== "number" || + !Array.isArray(entry[1]) + ) + return []; + const frames = entry[1].filter( + (frame): frame is TraceStackFrame => + Array.isArray(frame) && + typeof frame[0] === "number" && + typeof frame[1] === "number" && + typeof frame[2] === "number", + ); + return [[entry[0], frames] as [number, TraceStackFrame[]]]; + }); + return { files, stacks }; +} + +export async function parseTraceEntry({ + archive, + budget, + limits, + traceEntry, +}: { + archive: JSZip; + budget: { decodedBytes: number; events: number; members: number }; + limits: TraceLimits; + traceEntry: JSZip.JSZipObject; +}): Promise<{ + decodedBytes: number; + events: number; + incomplete: boolean; + malformedLineCount: number; + members: number; + streams: TraceStream[]; +}> { + const traceLimit = Math.min( + limits.maxEntryBytes, + Math.max(0, limits.maxTotalDecodedBytes - budget.decodedBytes), + ); + if (traceLimit === 0) { + return { + decodedBytes: 0, + events: 0, + incomplete: true, + malformedLineCount: 0, + members: 1, + streams: [], + }; + } + const traceText = await readZipText(traceEntry, traceLimit); + if (!traceText || traceText.malformed) { + return { + decodedBytes: traceText ? Buffer.byteLength(traceText.text) : 0, + events: 0, + incomplete: true, + malformedLineCount: 1, + members: 1, + streams: [], + }; + } + let decodedBytes = Buffer.byteLength(traceText.text); + const stem = traceEntry.name.replace(/\.trace$/, ""); + const stacksEntry = archive.file(`${stem}.stacks`) ?? undefined; + const stacksBudgetExceeded = + stacksEntry !== undefined && budget.members + 1 >= limits.maxMembers; + const stacksLimit = Math.min( + limits.maxEntryBytes, + Math.max( + 0, + limits.maxTotalDecodedBytes - budget.decodedBytes - decodedBytes, + ), + ); + const stacksText = await readZipText( + stacksBudgetExceeded ? undefined : stacksEntry, + stacksLimit, + ); + decodedBytes += stacksText ? Buffer.byteLength(stacksText.text) : 0; + const stacks = parseStacks( + stacksText?.malformed ? undefined : stacksText?.text, + ); + const streams: TraceStream[] = []; + let current: TraceStream | undefined; + let processedEventCount = 0; + let malformedLineCount = stacksText?.malformed ? 1 : 0; + let pendingMalformed = 0; + let segment = -1; + const lines = traceText.text.split("\n"); + const lineCount = lines.filter(Boolean).length; + let processedLineCount = 0; + let trailingMalformed = false; + for (const [lineIndex, line] of lines.entries()) { + if (!line) continue; + if (budget.events + processedEventCount >= limits.maxEvents) break; + processedLineCount += 1; + processedEventCount += 1; + const event = normalizeTraceEvent(parseJson(line)); + if (!event) { + trailingMalformed ||= lineIndex === lines.length - 1; + malformedLineCount += 1; + if (current) current.malformedLineCount += 1; + else pendingMalformed += 1; + continue; + } + if (!current || event.type === "context-options") { + current = { + events: [], + id: `${traceEntry.name}#${(segment += 1)}`, + incomplete: false, + malformedLineCount: pendingMalformed, + stacks, + }; + pendingMalformed = 0; + streams.push(current); + } + current.events.push(event); + } + const eventLimitReached = + budget.events + processedEventCount >= limits.maxEvents && + processedLineCount < lineCount; + const incomplete = + traceText.incomplete || + stacksText?.incomplete === true || + stacksBudgetExceeded || + trailingMalformed || + eventLimitReached; + if (current) current.incomplete = incomplete; + return { + decodedBytes, + events: processedEventCount, + incomplete, + malformedLineCount, + members: stacksEntry && !stacksBudgetExceeded ? 2 : 1, + streams, + }; +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceSnapshotSerialize.ts b/src/domains/investigation/parser/trace/playwrightTraceSnapshotSerialize.ts new file mode 100644 index 000000000..feec8d922 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceSnapshotSerialize.ts @@ -0,0 +1,172 @@ +// oxlint-disable eslint/max-lines -- Keep the bounded snapshot traversal aligned with the platform reader; splitting its recursive state would obscure the shared budget. + +import { + type SnapshotSerialization, + BoundedJsonWriter, +} from "./playwrightTraceSnapshotWriter.js"; + +export function serializeSnapshotHtml({ + filterStyles, + maxBytes, + maxDepth = 128, + maxNodes = 50_000, + snapshotIndex, + snapshots, +}: { + filterStyles: boolean; + maxBytes: number; + maxDepth?: number; + maxNodes?: number; + snapshotIndex: number; + snapshots: unknown[]; +}): SnapshotSerialization { + const writer = new BoundedJsonWriter(maxBytes); + const indexes = new Map(); + let visitedNodes = 0; + + const enterNode = (depth: number) => { + visitedNodes += 1; + if (visitedNodes > maxNodes || depth > maxDepth) { + writer.stop(); + return false; + } + return true; + }; + + const snapshotNodes = (index: number): unknown[] => { + const cached = indexes.get(index); + if (cached) return cached; + const nodes: unknown[] = []; + const pending: { depth: number; expanded: boolean; node: unknown }[] = [ + { depth: 0, expanded: false, node: snapshots[index] }, + ]; + while (pending.length && !writer.truncated) { + const current = pending.pop(); + if (!current || !enterNode(current.depth)) break; + if (typeof current.node === "string") { + nodes.push(current.node); + continue; + } + if (!Array.isArray(current.node) || typeof current.node[0] !== "string") + continue; + if (current.expanded) { + nodes.push(current.node); + continue; + } + pending.push({ ...current, expanded: true }); + current.node + .slice(2) + .reverse() + .forEach((node) => + pending.push({ + depth: current.depth + 1, + expanded: false, + node, + }), + ); + } + indexes.set(index, nodes); + return nodes; + }; + + const appendString = (value: string, prefix: string) => { + const prefixBytes = Buffer.byteLength(prefix); + if (Buffer.byteLength(value) + 2 > writer.remainingBytes - prefixBytes) { + writer.stop(); + return false; + } + return writer.append(`${prefix}${JSON.stringify(value)}`); + }; + + const serializeRaw = ( + value: unknown, + depth: number, + prefix: string, + ): boolean => { + if (!enterNode(depth)) return false; + if (typeof value === "string") return appendString(value, prefix); + if (value === null || typeof value !== "object") { + const serialized = JSON.stringify(value) ?? "null"; + return writer.append(`${prefix}${serialized}`); + } + if (Array.isArray(value)) { + if (!writer.append(`${prefix}[`)) return false; + value.forEach((child, index) => { + if (!writer.truncated) + serializeRaw(child, depth + 1, index === 0 ? "" : ","); + }); + return writer.append("]"); + } + if (!writer.append(`${prefix}{`)) return false; + let propertyCount = 0; + Object.entries(value).forEach(([key, child]) => { + if (writer.truncated || child === undefined) return; + const separator = propertyCount === 0 ? "" : ","; + if (!appendString(key, separator) || !writer.append(":")) return; + propertyCount += 1; + serializeRaw(child, depth + 1, ""); + }); + return writer.append("}"); + }; + + const serialize = ( + value: unknown, + { depth, index, prefix }: { depth: number; index: number; prefix: string }, + ): boolean => { + if (!enterNode(depth)) return false; + const reference = + Array.isArray(value) && value.length === 1 && Array.isArray(value[0]) + ? value[0] + : undefined; + if ( + reference && + typeof reference[0] === "number" && + typeof reference[1] === "number" + ) { + const targetIndex = index - reference[0]; + return serialize( + targetIndex < 0 ? "" : snapshotNodes(targetIndex)[reference[1]], + { depth: depth + 1, index: targetIndex, prefix }, + ); + } + if (!Array.isArray(value)) return serializeRaw(value, depth + 1, prefix); + const isElement = typeof value[0] === "string"; + if ( + filterStyles && + isElement && + (value[0] === "STYLE" || value[0] === "SCRIPT" || value[0] === "NOSCRIPT") + ) + return false; + if (!writer.append(`${prefix}[`)) return false; + serializeRaw(value[0], depth + 1, ""); + if (!writer.truncated) { + writer.append(","); + const array: unknown[] = value; + const attributes = array[1]; + const filteredAttributes = + filterStyles && + isElement && + typeof attributes === "object" && + attributes !== null && + !Array.isArray(attributes) + ? Object.fromEntries( + Object.entries(attributes).filter(([name]) => name !== "style"), + ) + : attributes; + serializeRaw(filteredAttributes, depth + 1, ""); + } + const array: unknown[] = value; + array.slice(2).forEach((child) => { + if (!writer.truncated) + serialize(child, { depth: depth + 1, index, prefix: "," }); + }); + return writer.append("]"); + }; + + serialize(snapshots[snapshotIndex], { + depth: 0, + index: snapshotIndex, + prefix: "", + }); + return writer.finish(); +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceSnapshotWriter.ts b/src/domains/investigation/parser/trace/playwrightTraceSnapshotWriter.ts new file mode 100644 index 000000000..03c93c42e --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceSnapshotWriter.ts @@ -0,0 +1,50 @@ +export type SnapshotSerialization = { html: unknown; truncated: boolean }; + +const truncationMarker = "[TRUNCATED]"; + +export class BoundedJsonWriter { + truncated = false; + get remainingBytes() { + return this.#maxBytes - this.#length; + } + readonly #chunks: string[] = []; + #length = 0; + + readonly #maxBytes: number; + + constructor(maxBytes: number) { + this.#maxBytes = Math.max(0, maxBytes); + } + + append(value: string): boolean { + const bytes = Buffer.byteLength(value); + if (bytes > this.remainingBytes) { + this.truncated = true; + return false; + } + this.#chunks.push(value); + this.#length += bytes; + return true; + } + + finish(): SnapshotSerialization { + const serialized = this.#chunks.join(""); + if (!this.truncated) + return { html: JSON.parse(serialized || "null"), truncated: false }; + if (this.#maxBytes <= Buffer.byteLength(truncationMarker)) { + return { + html: truncationMarker.slice(0, this.#maxBytes), + truncated: true, + }; + } + const contentBytes = this.#maxBytes - Buffer.byteLength(truncationMarker); + let prefix = Buffer.from(serialized).subarray(0, contentBytes).toString(); + while (Buffer.byteLength(prefix) > contentBytes) + prefix = prefix.slice(0, -1); + return { html: `${prefix}${truncationMarker}`, truncated: true }; + } + + stop() { + this.truncated = true; + } +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceSnapshots.ts b/src/domains/investigation/parser/trace/playwrightTraceSnapshots.ts new file mode 100644 index 000000000..b65dc191e --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceSnapshots.ts @@ -0,0 +1,132 @@ +import { listTraceActions } from "./playwrightTraceActions.js"; +import { serializeSnapshotHtml } from "./playwrightTraceSnapshotSerialize.js"; +import type { + Bounded, + PlaywrightTrace, + TraceSnapshot, +} from "./playwrightTraceTypes.js"; +import { traceLimits } from "./playwrightTraceTypes.js"; +import { bounded } from "./playwrightTraceUtils.js"; + +function phase(name: string): TraceSnapshot["phase"] { + if (name.startsWith("before@")) return "before"; + if (name.startsWith("input@")) return "input"; + if (name.startsWith("after@")) return "after"; + return "other"; +} + +export function listTraceSnapshots( + trace: PlaywrightTrace, + options: { + actionId?: string; + frameId?: string; + limit?: number; + pageId?: string; + phase?: TraceSnapshot["phase"]; + } = {}, +): Bounded { + const actions = listTraceActions(trace, { + limit: Number.MAX_SAFE_INTEGER, + }).items; + const actionsByStreamAndCall = new Map( + actions.map((action) => [ + `${action.streamId}\u0000${action.callId}`, + action, + ]), + ); + const snapshots = trace.streams + .flatMap((stream) => + stream.events.flatMap((event, index) => { + const value = event.snapshot; + if (event.type !== "frame-snapshot" || !value) return []; + const action = value.callId + ? actionsByStreamAndCall.get(`${stream.id}\u0000${value.callId}`) + : undefined; + return [ + { + actionId: action?.id, + frameId: value.frameId, + id: `${stream.id}:snapshot:${index}`, + isMainFrame: value.isMainFrame, + name: value.snapshotName, + pageId: value.pageId, + phase: phase(value.snapshotName), + streamId: stream.id, + timestamp: value.timestamp, + url: value.frameUrl, + }, + ]; + }), + ) + .filter( + (item) => + (options.actionId === undefined || + item.actionId === options.actionId) && + (options.frameId === undefined || item.frameId === options.frameId) && + (options.pageId === undefined || item.pageId === options.pageId) && + (options.phase === undefined || item.phase === options.phase), + ); + return bounded(snapshots, options.limit ?? traceLimits.maxItems, { + incomplete: trace.incomplete, + malformed: trace.malformedLineCount, + }); +} + +export function reconstructSnapshotHtml( + snapshots: unknown[], + snapshotIndex: number, + options: { maxBytes?: number; maxDepth?: number; maxNodes?: number } = {}, +): unknown { + return serializeSnapshotHtml({ + filterStyles: false, + maxBytes: options.maxBytes ?? traceLimits.maxBodyBytes, + ...(options.maxDepth !== undefined && { maxDepth: options.maxDepth }), + ...(options.maxNodes !== undefined && { maxNodes: options.maxNodes }), + snapshotIndex, + snapshots, + }).html; +} + +export function readTraceSnapshot( + trace: PlaywrightTrace, + snapshotId: string, + options: { maxBytes?: number } = {}, +): + | { + html: unknown; + snapshot: TraceSnapshot; + snapshotIndex: number; + truncated: boolean; + } + | undefined { + const metadata = listTraceSnapshots(trace, { + limit: Number.MAX_SAFE_INTEGER, + }).items.find((item) => item.id === snapshotId); + if (!metadata) return undefined; + const stream = trace.streams.find((item) => item.id === metadata.streamId); + if (!stream) return undefined; + const frameEvents = stream.events.filter( + (event) => + event.type === "frame-snapshot" && + event.snapshot?.frameId === metadata.frameId, + ); + const sourceIndex = Number(snapshotId.slice(snapshotId.lastIndexOf(":") + 1)); + const sourceEvent = stream.events[sourceIndex]; + if (!sourceEvent) return undefined; + const snapshotIndex = frameEvents.indexOf(sourceEvent); + if (snapshotIndex === -1) return undefined; + const snapshots = frameEvents.map((event) => event.snapshot?.html); + const maxBytes = options.maxBytes ?? traceLimits.maxBodyBytes; + const reconstructed = serializeSnapshotHtml({ + filterStyles: true, + maxBytes, + snapshotIndex, + snapshots, + }); + return { + html: reconstructed.html, + snapshot: metadata, + snapshotIndex, + truncated: reconstructed.truncated, + }; +} diff --git a/src/domains/investigation/parser/trace/playwrightTraceTypes.ts b/src/domains/investigation/parser/trace/playwrightTraceTypes.ts new file mode 100644 index 000000000..5006e5963 --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceTypes.ts @@ -0,0 +1,170 @@ +// oxlint-disable eslint/max-lines -- The lazy parser publishes one internal trace model shared by all projections. + +import type JSZip from "jszip"; + +export type TraceEvent = { + callId?: string | undefined; + class?: string | undefined; + endTime?: number | undefined; + error?: { message: string } | undefined; + height?: number | undefined; + message?: string | undefined; + messageType?: string | undefined; + method?: string | undefined; + pageId?: string | undefined; + params?: + | { expression?: string | undefined; selector?: string | undefined } + | undefined; + sha1?: string | undefined; + snapshot?: + | { + callId?: string | undefined; + frameId: string; + frameUrl: string; + html: unknown; + isMainFrame?: boolean | undefined; + pageId?: string | undefined; + snapshotName: string; + timestamp?: number | undefined; + } + | undefined; + startTime?: number | undefined; + text?: string | undefined; + time?: number | undefined; + timestamp?: number | undefined; + type: string; + width?: number | undefined; +}; +export type TraceStackFrame = [number, number, number, ...string[]]; +export type TraceStacks = { + files: string[]; + stacks: [number, TraceStackFrame[]][]; +}; +export type TraceStream = { + events: TraceEvent[]; + id: string; + incomplete: boolean; + malformedLineCount: number; + stacks: TraceStacks; +}; +export type PlaywrightTrace = { + archive: JSZip; + artifactBudget: { + decodedBytes: number; + events: number; + members: number; + }; + incomplete: boolean; + limits: TraceLimits; + malformedLineCount: number; + streams: TraceStream[]; +}; +export type TraceReadResult = + | { status: "missing" } + | { status: "storage-unavailable" } + | { + incomplete: boolean; + malformedLineCount: number; + reason: "archive-too-large" | "invalid-zip" | "no-trace-stream"; + status: "malformed"; + } + | { status: "ready" | "incomplete"; trace: PlaywrightTrace }; +export type TraceLimits = { + maxArchiveBytes: number; + maxBodyBytes: number; + maxEntryBytes: number; + maxEvents: number; + maxItems: number; + maxMembers: number; + maxTextBytes: number; + maxTotalDecodedBytes: number; +}; +export const traceLimits: TraceLimits = { + maxArchiveBytes: 50 * 1024 * 1024, + maxBodyBytes: 256 * 1024, + maxEntryBytes: 16 * 1024 * 1024, + maxEvents: 100_000, + maxItems: 100, + maxMembers: 128, + maxTextBytes: 8 * 1024, + maxTotalDecodedBytes: 64 * 1024 * 1024, +}; +export type Coverage = { + incomplete: boolean; + malformed: number; + returned: number; + total: number; + truncated: boolean; +}; +export type Bounded = { coverage: Coverage; items: Item[] }; +export type TraceAction = { + apiName: string; + callId: string; + endTime: number | undefined; + errorMessage: string | undefined; + expression: string | undefined; + id: string; + occurrence: number; + pageId: string | undefined; + selector: string | undefined; + startTime: number | undefined; + streamId: string; +}; +export type StackFrame = { + compiledLine: number; + file: string; + functionName: string | undefined; +}; +export type FailureStack = { + action: TraceAction; + errorMessage: string; + frames: StackFrame[]; +}; +export type TraceSnapshot = { + actionId: string | undefined; + frameId: string; + id: string; + isMainFrame: boolean | undefined; + name: string; + pageId: string | undefined; + phase: "after" | "before" | "input" | "other"; + streamId: string; + timestamp: number | undefined; + url: string; +}; +export type TraceScreenshot = { + height: number | undefined; + id: string; + pageId: string | undefined; + resourceName: string; + streamId: string; + timestamp: number | undefined; + width: number | undefined; +}; +export type TraceNetworkEntry = { + bodyAvailable: boolean; + bodyResourceName: string | undefined; + durationMilliseconds: number | undefined; + failureText: string | undefined; + frameId: string | undefined; + id: string; + method: string; + pageId: string | undefined; + requestHeaders: Record; + responseHeaders: Record; + startedAtMilliseconds: number | undefined; + status: number | undefined; + streamId: string; + url: string; +}; +export type TraceConsoleEntry = { + id: string; + message: string; + pageId: string | undefined; + streamId: string; + time: number | undefined; + type: string; +}; +export type TraceActionLog = TraceConsoleEntry & { + actionId: string | undefined; +}; diff --git a/src/domains/investigation/parser/trace/playwrightTraceUtils.ts b/src/domains/investigation/parser/trace/playwrightTraceUtils.ts new file mode 100644 index 000000000..daee7890d --- /dev/null +++ b/src/domains/investigation/parser/trace/playwrightTraceUtils.ts @@ -0,0 +1,141 @@ +import type { JSZipObject } from "jszip"; + +import type { Bounded } from "./playwrightTraceTypes.js"; + +export type JsonObject = Record & { + _failureText?: unknown; + _frameref?: unknown; + _monotonicTime?: unknown; + _sha1?: unknown; + callId?: unknown; + class?: unknown; + content?: unknown; + endTime?: unknown; + error?: unknown; + expression?: unknown; + files?: unknown; + frameId?: unknown; + frameUrl?: unknown; + headers?: unknown; + height?: unknown; + html?: unknown; + isMainFrame?: unknown; + message?: unknown; + messageType?: unknown; + method?: unknown; + name?: unknown; + pageId?: unknown; + pageref?: unknown; + params?: unknown; + request?: unknown; + response?: unknown; + selector?: unknown; + sha1?: unknown; + snapshot?: unknown; + snapshotName?: unknown; + stacks?: unknown; + startTime?: unknown; + status?: unknown; + text?: unknown; + time?: unknown; + timestamp?: unknown; + type?: unknown; + url?: unknown; + value?: unknown; + width?: unknown; +}; +export const asObject = (value: unknown): JsonObject | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +export function parseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} +export function bounded( + items: Item[], + limit: number, + evidence: number | { incomplete: boolean; malformed: number }, +): Bounded { + const { incomplete, malformed } = + typeof evidence === "number" + ? { incomplete: false, malformed: evidence } + : evidence; + const returned = Math.min(items.length, Math.max(0, limit)); + return { + coverage: { + incomplete, + malformed, + returned, + total: items.length, + truncated: returned < items.length, + }, + items: items.slice(0, returned), + }; +} +export async function readZipText( + entry: JSZipObject | undefined, + limit: number, +): Promise< + { incomplete: boolean; malformed: boolean; text: string } | undefined +> { + if (!entry) return undefined; + const content = await readZipBytes(entry, limit); + return { ...content, text: content.bytes.toString("utf8") }; +} + +export async function readZipBytes( + entry: JSZipObject, + limit: number, +): Promise<{ bytes: Buffer; incomplete: boolean; malformed: boolean }> { + try { + return await new Promise((resolve) => { + const stream = entry.nodeStream(); + const chunks: Buffer[] = []; + let byteLength = 0; + let settled = false; + const finish = (result: { + bytes: Buffer; + incomplete: boolean; + malformed: boolean; + }) => { + if (settled) return; + settled = true; + resolve(result); + }; + stream.on("data", (chunk: Buffer) => { + if (settled) return; + const remaining = Math.max(0, limit + 1 - byteLength); + chunks.push(chunk.subarray(0, remaining)); + byteLength += Math.min(chunk.byteLength, remaining); + if (byteLength > limit) { + stream.pause(); + finish({ + bytes: Buffer.concat(chunks).subarray(0, limit), + incomplete: true, + malformed: false, + }); + } + }); + stream.on("end", () => + finish({ + bytes: Buffer.concat(chunks), + incomplete: false, + malformed: false, + }), + ); + stream.on("error", () => + finish({ + bytes: Buffer.alloc(0), + incomplete: true, + malformed: true, + }), + ); + }); + } catch { + return { bytes: Buffer.alloc(0), incomplete: true, malformed: true }; + } +} diff --git a/src/domains/investigation/parser/trace/runLogs.ts b/src/domains/investigation/parser/trace/runLogs.ts new file mode 100644 index 000000000..1c95ee3b0 --- /dev/null +++ b/src/domains/investigation/parser/trace/runLogs.ts @@ -0,0 +1,173 @@ +// oxlint-disable eslint/max-lines -- Keep tail parsing, absolute offsets, and retention policy in one module. + +import { + type RunLogLimits, + type RunLogRecord, + type RunLogs, + type RunLogSeverity, + type RunLogsReadResult, + runLogLimits, +} from "./runLogsTypes.js"; + +export type { + RunLogLimits, + RunLogs, + RunLogsReadResult, +} from "./runLogsTypes.js"; + +type RawRunLogRecord = { + message: string; + severity: RunLogSeverity; + source: string; + timestamp: string; +}; + +const severities = new Set([ + "error", + "info", + "verbose", + "warning", +]); + +function parseRunLogRecord(raw: string): RawRunLogRecord | undefined { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return undefined; + } + if (typeof value !== "object" || value === null || Array.isArray(value)) + return undefined; + const record = value as Partial; + if ( + typeof record.message !== "string" || + typeof record.severity !== "string" || + !severities.has(record.severity) || + typeof record.source !== "string" || + record.source.length === 0 || + typeof record.timestamp !== "string" + ) + return undefined; + return { + message: record.message, + severity: record.severity, + source: record.source, + timestamp: record.timestamp, + }; +} + +function truncateUtf8( + value: string, + maxBytes: number, +): { truncated: boolean; value: string } { + const bytes = Buffer.from(value); + const boundedByteLength = Math.max(0, maxBytes); + if (bytes.byteLength <= boundedByteLength) return { truncated: false, value }; + let end = boundedByteLength; + const decoder = new TextDecoder("utf-8", { fatal: true }); + while (end > 0) { + try { + return { truncated: true, value: decoder.decode(bytes.subarray(0, end)) }; + } catch { + end -= 1; + } + } + return { truncated: true, value: "" }; +} + +export function parseRunLogs( + raw: Buffer, + options: { limits?: Partial } = {}, +): Extract { + const limits = { ...runLogLimits, ...options.limits }; + const maxArtifactBytes = Math.max(0, limits.maxArtifactBytes); + const artifactIncomplete = raw.byteLength > maxArtifactBytes; + const unalignedStart = artifactIncomplete + ? raw.byteLength - maxArtifactBytes + : 0; + const unalignedStartIsLineBoundary = raw[unalignedStart - 1] === 0x0a; + const firstNewline = raw.indexOf(0x0a, unalignedStart); + const windowStart = !artifactIncomplete + ? 0 + : unalignedStartIsLineBoundary + ? unalignedStart + : firstNewline === -1 + ? raw.byteLength + : firstNewline + 1; + const boundedRaw = raw.subarray(windowStart); + const boundedText = boundedRaw.toString("utf8"); + const endsWithCompleteLine = boundedRaw.at(-1) === 0x0a; + const lines = boundedText.split("\n"); + const parseLineCount = lines.length; + const records: RunLogRecord[] = []; + let malformed = 0; + let total = 0; + let messageTruncated = false; + let trailingMalformed = false; + let byteOffset = windowStart; + + const maxRecords = Math.max(0, limits.maxRecords); + for ( + let windowLineIndex = 0; + windowLineIndex < parseLineCount; + windowLineIndex += 1 + ) { + const encodedLine = lines[windowLineIndex] ?? ""; + const recordByteOffset = byteOffset; + byteOffset += Buffer.byteLength(encodedLine) + 1; + const rawLine = encodedLine.replace(/\r$/, ""); + if (rawLine.length === 0) { + if (windowLineIndex !== lines.length - 1) malformed += 1; + continue; + } + const parsed = parseRunLogRecord(rawLine); + if (!parsed) { + malformed += 1; + trailingMalformed = + !endsWithCompleteLine && windowLineIndex === parseLineCount - 1; + continue; + } + total += 1; + const boundedMessage = truncateUtf8(parsed.message, limits.maxMessageBytes); + const record: RunLogRecord = { + ...parsed, + byteOffset: recordByteOffset, + id: `log:${recordByteOffset}`, + message: boundedMessage.value, + messageTruncated: boundedMessage.truncated, + }; + if (maxRecords === 0) continue; + if (records.length < maxRecords) { + records.push(record); + messageTruncated ||= boundedMessage.truncated; + continue; + } + const replacementIndex = records.findIndex( + ({ severity }) => severity === "info" || severity === "verbose", + ); + const recordIsSubstantive = + record.severity === "error" || record.severity === "warning"; + if (recordIsSubstantive || replacementIndex !== -1) { + records.splice(replacementIndex === -1 ? 0 : replacementIndex, 1); + records.push(record); + messageTruncated ||= boundedMessage.truncated; + } + } + + const incomplete = artifactIncomplete || trailingMalformed; + const truncated = + artifactIncomplete || messageTruncated || records.length < total; + const logs: RunLogs = { + coverage: { + incomplete, + malformed, + returned: records.length, + total, + truncated, + }, + records, + }; + if (incomplete) return { logs, status: "incomplete" }; + if (malformed > 0 && total === 0) return { logs, status: "malformed" }; + return { logs, status: "ready" }; +} diff --git a/src/domains/investigation/parser/trace/runLogsTypes.ts b/src/domains/investigation/parser/trace/runLogsTypes.ts new file mode 100644 index 000000000..b46c504ee --- /dev/null +++ b/src/domains/investigation/parser/trace/runLogsTypes.ts @@ -0,0 +1,36 @@ +import type { Coverage } from "./playwrightTraceTypes.js"; + +export type RunLogSeverity = "error" | "info" | "verbose" | "warning"; + +export type RunLogRecord = { + byteOffset: number; + id: string; + message: string; + messageTruncated: boolean; + severity: RunLogSeverity; + source: string; + timestamp: string; +}; + +export type RunLogLimits = { + /** Parses at most this many bytes from the end of the downloaded log. */ + maxArtifactBytes: number; + maxMessageBytes: number; + maxRecords: number; +}; + +export const runLogLimits: RunLogLimits = { + maxArtifactBytes: 5 * 1024 * 1024, + maxMessageBytes: 16 * 1024, + maxRecords: 5_000, +}; + +export type RunLogs = { + coverage: Coverage; + records: RunLogRecord[]; +}; + +export type RunLogsReadResult = + | { status: "missing" } + | { status: "storage-unavailable" } + | { logs: RunLogs; status: "incomplete" | "malformed" | "ready" }; diff --git a/src/domains/investigation/types.ts b/src/domains/investigation/types.ts new file mode 100644 index 000000000..d3a1ab8b4 --- /dev/null +++ b/src/domains/investigation/types.ts @@ -0,0 +1,55 @@ +export type ArtifactInput = + | { bytes: Uint8Array; offset: number; status: "available" } + | { + status: + | "expired-url" + | "not-found" + | "timed-out" + | "too-large" + | "unavailable"; + } + | { status: "not-captured" | "signing-failed" }; + +export type InspectType = + | "action" + | "timeline" + | "network" + | "request" + | "snapshot" + | "screenshot" + | "console" + | "log"; + +export type InvestigationRequest = { + artifacts: { logs: ArtifactInput; trace: ArtifactInput }; + attempt: { + attemptId: string; + error?: string | undefined; + status: string; + }; + flowId: string; + inspect?: { + actionId?: string | undefined; + endTimeMilliseconds?: number | undefined; + endTimestamp?: string | undefined; + evidenceId?: string | undefined; + format: "html" | "text"; + limit: number; + method?: string | undefined; + requestId?: string | undefined; + screenshotId?: string | undefined; + snapshotId?: string | undefined; + source?: "qawolfTraceCollection" | "serverConsole" | undefined; + startTimeMilliseconds?: number | undefined; + startTimestamp?: string | undefined; + status?: number | undefined; + type: InspectType; + urlContains?: string | undefined; + }; + mode: "inspect" | "summary"; + runId: string; +}; + +export type InvestigationResult = Record & { + binary?: { bytesBase64: string; extension: "html" | "jpeg" }; +}; diff --git a/src/shell/fs.testUtils.test.ts b/src/shell/fs.testUtils.test.ts index de8251e3e..109fdff45 100644 --- a/src/shell/fs.testUtils.test.ts +++ b/src/shell/fs.testUtils.test.ts @@ -10,6 +10,13 @@ describe("makeMemoryFs", () => { expect(result).toBe("hello"); }); + it("writes exclusively without replacing an existing file", async () => { + const fs = makeMemoryFs(); + expect(await fs.writeFileExclusive("/file.txt", "first")).toBe(true); + expect(await fs.writeFileExclusive("/file.txt", "second")).toBe(false); + expect(await fs.readFile("/file.txt")).toBe("first"); + }); + it("should throw ENOENT when reading a missing file", async () => { const fs = makeMemoryFs(); let caughtError: unknown; diff --git a/src/shell/fs.testUtils.ts b/src/shell/fs.testUtils.ts index c25fff0a2..13d37594e 100644 --- a/src/shell/fs.testUtils.ts +++ b/src/shell/fs.testUtils.ts @@ -100,6 +100,16 @@ export function makeMemoryFs(): Fs { typeof data === "string" ? textEncoder.encode(data) : data, ); }, + async writeFileExclusive(rawPath, data, _options) { + const path = toKey(rawPath); + requireParent(dirs, path, rawPath, "open"); + if (files.has(path) || dirs.has(path)) return false; + files.set( + path, + typeof data === "string" ? textEncoder.encode(data) : data, + ); + return true; + }, async openWriteHandle(rawPath) { const path = toKey(rawPath); requireParent(dirs, path, rawPath, "open"); diff --git a/src/shell/fs.ts b/src/shell/fs.ts index d77958123..202491c22 100644 --- a/src/shell/fs.ts +++ b/src/shell/fs.ts @@ -1,7 +1,7 @@ import type { Readable } from "node:stream"; import * as fs from "node:fs"; -import { isNoEntError } from "~/core/errors.js"; +import { errorCode, isNoEntError } from "~/core/errors.js"; import { openFsWriteHandle, type FsWriteHandle } from "./fsWriteHandle.js"; export type { FsWriteHandle } from "./fsWriteHandle.js"; @@ -45,6 +45,11 @@ export type Fs = { data: string | Uint8Array, options?: { mode?: number }, ): Promise; + writeFileExclusive( + path: string, + data: string | Uint8Array, + options?: { mode?: number }, + ): Promise; openWriteHandle(path: string): Promise; readdir(path: string): Promise; readdirWithTypes(path: string): Promise; @@ -86,6 +91,19 @@ export function makeDefaultFs(): Fs { await fs.promises.writeFile(path, data, options ?? undefined); } }, + async writeFileExclusive(path, data, options) { + try { + await fs.promises.writeFile(path, data, { + encoding: typeof data === "string" ? "utf8" : undefined, + flag: "wx", + mode: options?.mode, + }); + return true; + } catch (error) { + if (errorCode(error) === "EEXIST") return false; + throw error; + } + }, openWriteHandle: openFsWriteHandle, readdir(path) { return fs.promises.readdir(path);