From 91b00e68ef45f1becd8c213fb54f2d999c8b8c26 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Tue, 29 Sep 2026 13:28:55 +0200 Subject: [PATCH 01/27] feat(cli): investigate recorded run attempts --- README.md | 37 ++- bun.lock | 77 +++-- knip.config.ts | 6 +- package.json | 2 + scripts/build.ts | 10 + scripts/buildBinary.ts | 3 + scripts/postbuild.ts | 2 +- skills/qawolf-cli/SKILL.md | 3 + src/commands/__snapshots__/help.test.ts.snap | 3 +- src/commands/investigation/index.ts | 107 +++++++ src/commands/program.ts | 2 + src/domains/investigation/contracts.ts | 92 ++++++ src/domains/investigation/download.test.ts | 44 +++ src/domains/investigation/download.ts | 115 +++++++ .../investigation/handle.artifacts.test.ts | 80 +++++ src/domains/investigation/handle.ts | 287 ++++++++++++++++++ src/domains/investigation/lazyParser.ts | 24 ++ src/domains/investigation/types.ts | 48 +++ src/investigationParser/executionLogs.ts | 128 ++++++++ .../index.security.test.ts | 156 ++++++++++ src/investigationParser/index.test.ts | 168 ++++++++++ src/investigationParser/index.ts | 184 +++++++++++ .../projection/attemptEvidenceText.ts | 165 ++++++++++ .../projection/chronologicallySortActions.ts | 18 ++ .../projection/inspectAttemptActions.ts | 126 ++++++++ .../projection/inspectAttemptNetwork.ts | 107 +++++++ .../projection/inspectAttemptPage.ts | 104 +++++++ .../projection/inspectAttemptRecorded.ts | 201 ++++++++++++ .../inspectAttemptRequestHeaders.ts | 39 +++ .../projection/inspectAttemptShared.ts | 95 ++++++ .../projection/summarizeAttemptNetwork.ts | 74 +++++ .../projection/summarizeAttemptTrace.ts | 198 ++++++++++++ .../projection/summarizeAttemptWaitingLogs.ts | 26 ++ .../snapshot/snapshotHtmlProjection.test.ts | 29 ++ .../snapshot/snapshotHtmlProjection.ts | 228 ++++++++++++++ .../snapshot/snapshotProjection.ts | 116 +++++++ .../snapshot/snapshotVocabulary.ts | 41 +++ .../trace/playwrightTrace.ts | 27 ++ .../trace/playwrightTraceActions.ts | 140 +++++++++ .../trace/playwrightTraceEvent.ts | 136 +++++++++ .../trace/playwrightTraceMedia.ts | 178 +++++++++++ .../trace/playwrightTraceNetwork.ts | 205 +++++++++++++ .../trace/playwrightTraceNetworkStreams.ts | 14 + .../trace/playwrightTraceParse.ts | 86 ++++++ .../trace/playwrightTraceParseEntry.ts | 157 ++++++++++ .../trace/playwrightTraceSnapshotSerialize.ts | 172 +++++++++++ .../trace/playwrightTraceSnapshotWriter.ts | 50 +++ .../trace/playwrightTraceSnapshots.ts | 132 ++++++++ .../trace/playwrightTraceTypes.ts | 170 +++++++++++ .../trace/playwrightTraceUtils.ts | 141 +++++++++ src/investigationParser/trace/runLogs.ts | 173 +++++++++++ src/investigationParser/trace/runLogsTypes.ts | 36 +++ src/shell/embeddedWorkerCli.ts | 12 +- src/shell/fs.testUtils.test.ts | 7 + src/shell/fs.testUtils.ts | 10 + src/shell/fs.ts | 20 +- 56 files changed, 4969 insertions(+), 42 deletions(-) create mode 100644 src/commands/investigation/index.ts create mode 100644 src/domains/investigation/contracts.ts create mode 100644 src/domains/investigation/download.test.ts create mode 100644 src/domains/investigation/download.ts create mode 100644 src/domains/investigation/handle.artifacts.test.ts create mode 100644 src/domains/investigation/handle.ts create mode 100644 src/domains/investigation/lazyParser.ts create mode 100644 src/domains/investigation/types.ts create mode 100644 src/investigationParser/executionLogs.ts create mode 100644 src/investigationParser/index.security.test.ts create mode 100644 src/investigationParser/index.test.ts create mode 100644 src/investigationParser/index.ts create mode 100644 src/investigationParser/projection/attemptEvidenceText.ts create mode 100644 src/investigationParser/projection/chronologicallySortActions.ts create mode 100644 src/investigationParser/projection/inspectAttemptActions.ts create mode 100644 src/investigationParser/projection/inspectAttemptNetwork.ts create mode 100644 src/investigationParser/projection/inspectAttemptPage.ts create mode 100644 src/investigationParser/projection/inspectAttemptRecorded.ts create mode 100644 src/investigationParser/projection/inspectAttemptRequestHeaders.ts create mode 100644 src/investigationParser/projection/inspectAttemptShared.ts create mode 100644 src/investigationParser/projection/summarizeAttemptNetwork.ts create mode 100644 src/investigationParser/projection/summarizeAttemptTrace.ts create mode 100644 src/investigationParser/projection/summarizeAttemptWaitingLogs.ts create mode 100644 src/investigationParser/snapshot/snapshotHtmlProjection.test.ts create mode 100644 src/investigationParser/snapshot/snapshotHtmlProjection.ts create mode 100644 src/investigationParser/snapshot/snapshotProjection.ts create mode 100644 src/investigationParser/snapshot/snapshotVocabulary.ts create mode 100644 src/investigationParser/trace/playwrightTrace.ts create mode 100644 src/investigationParser/trace/playwrightTraceActions.ts create mode 100644 src/investigationParser/trace/playwrightTraceEvent.ts create mode 100644 src/investigationParser/trace/playwrightTraceMedia.ts create mode 100644 src/investigationParser/trace/playwrightTraceNetwork.ts create mode 100644 src/investigationParser/trace/playwrightTraceNetworkStreams.ts create mode 100644 src/investigationParser/trace/playwrightTraceParse.ts create mode 100644 src/investigationParser/trace/playwrightTraceParseEntry.ts create mode 100644 src/investigationParser/trace/playwrightTraceSnapshotSerialize.ts create mode 100644 src/investigationParser/trace/playwrightTraceSnapshotWriter.ts create mode 100644 src/investigationParser/trace/playwrightTraceSnapshots.ts create mode 100644 src/investigationParser/trace/playwrightTraceTypes.ts create mode 100644 src/investigationParser/trace/playwrightTraceUtils.ts create mode 100644 src/investigationParser/trace/runLogs.ts create mode 100644 src/investigationParser/trace/runLogsTypes.ts diff --git a/README.md b/README.md index f68bf19d0..f9fba96c9 100644 --- a/README.md +++ b/README.md @@ -56,17 +56,38 @@ qawolf flows run examples/example.flow.ts ## Commands -| Command | What it does | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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 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) | +| Command | What it does | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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 investigation` | Discover attempt IDs and inspect bounded run evidence | +| `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, choose an attempt, then narrow the evidence only when the summary points to it: + +```bash +qawolf investigation list --run-id +qawolf investigation summary --attempt-id +qawolf investigation inspect --attempt-id --type timeline --limit 20 +qawolf investigation inspect --attempt-id --type request --request-id +``` + +Snapshot HTML and screenshots are written only to a new file; the CLI refuses to overwrite an existing path: + +```bash +qawolf investigation inspect --attempt-id --type snapshot --snapshot-id --format html --output-file snapshot.html +qawolf investigation inspect --attempt-id --type screenshot --screenshot-id --output-file screenshot.jpeg +``` + +These commands require the platform's `public.run.getAttemptArtifacts` endpoint to be deployed. The endpoint authorizes every request and returns fresh signed artifact URLs. Parsing happens locally in a separate lazy bundle, so normal CLI startup does not load the trace parser or JSZip. Downloads, decompression, 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..411f15caa 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@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", @@ -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..3b8eb6219 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -4,17 +4,13 @@ const config: KnipConfig = { entry: [ "*.config.ts", "src/runnerSdk/index.ts", + "src/investigationParser/index.ts", "src/**/*.test.ts", "src/**/*.mock.ts", "src/**/*.fixtures.ts", "src/**/*.testUtils.ts", ], project: ["src/**/*.ts"], - ignoreBinaries: [ - // the built bundle, invoked as `node dist/cli.js` in the runtime-smoke CI - // job; not present when knip runs (it runs before the build step) - "dist/cli.js", - ], ignoreDependencies: [ // devDependencies kept only so genDependencyVersions.ts can read their // installed versions. The CLI never imports either: ensureRuntimeEnv diff --git a/package.json b/package.json index f07eca01a..91ffb4b8a 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", @@ -78,6 +79,7 @@ "@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..c69db4f09 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/investigationParser/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..20025c07c 100644 --- a/scripts/buildBinary.ts +++ b/scripts/buildBinary.ts @@ -25,8 +25,11 @@ 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;", + "process.env.QAWOLF_EMBEDDED_INVESTIGATION_PARSER_PATH = investigationParserAsset;", + "process.env.QAWOLF_INVESTIGATION_PARSER_PATH = 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..daceaefd5 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -167,6 +167,9 @@ that `url`; never guess a route and never send a repository link in its place. | `qawolf install browsers` | local | Install Playwright browsers used by the project's web flows | | `qawolf install clear` | local | Remove the managed runtime cache (all installed runtime versions) | | `qawolf investigation get` | read | Read what the QA Wolf AI investigating a run has concluded: one finding per cause of its failures, with the question waiting for a person and any answer or dispute the investigation hasn't acted on yet. | +| `qawolf investigation inspect` | read | Inspect one kind of evidence for a finished attempt | +| `qawolf investigation list` | read | List finished attempt IDs for a run | +| `qawolf investigation summary` | read | Summarize the evidence for one finished attempt | | `qawolf issue addFlows` | write | Add flows to a coverage request owned by the caller's team. Flows already covered stay covered. Bug and maintenance reports link to flows through the runs that reproduce them; use run.diagnose to record one. | | `qawolf issue create` | write | Create a bug report, maintenance report, or coverage request issue for the caller's team. A user can create a maintenance report only when they can see the workspace's maintenance reports; a team or organization API key credits it to the workspace's automation user. Link its failed flows afterwards with run.diagnose. | | `qawolf issue find` | read | List the team's bug reports, maintenance reports, or coverage requests, newest first. | diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 41c8b1752..0bd60a8dd 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 + investigation 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 @@ -30,7 +32,6 @@ Commands: environment QA Wolf public API environment commands file QA Wolf public API file commands flow QA Wolf public API flow commands - investigation QA Wolf public API investigation commands issue QA Wolf public API issue commands legacyTrigger QA Wolf public API legacyTrigger commands run Trigger and manage QA Wolf runs on the platform diff --git a/src/commands/investigation/index.ts b/src/commands/investigation/index.ts new file mode 100644 index 000000000..7967ba344 --- /dev/null +++ b/src/commands/investigation/index.ts @@ -0,0 +1,107 @@ +import type { Command } from "commander"; + +import { declareCommandKind } from "~/commands/commandKind.js"; +import { withAuthContext } from "~/commands/context.js"; +import { + handleInvestigationInspect, + handleInvestigationList, + handleInvestigationSummary, + type InspectOptions, +} from "~/domains/investigation/handle.js"; +import type { InspectType } from "~/domains/investigation/types.js"; +import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; + +const inspectTypes: InspectType[] = [ + "action", + "timeline", + "network", + "request", + "snapshot", + "screenshot", + "console", + "log", +]; + +function parseInspectType(value: string): InspectType { + if (inspectTypes.includes(value as InspectType)) return value as InspectType; + throw new Error(`Expected one of: ${inspectTypes.join(", ")}.`); +} + +function parseFormat(value: string): "html" | "text" { + if (value === "html" || value === "text") return value; + throw new Error("Expected text or html."); +} + +function parseSource(value: string): "qawolfTraceCollection" | "serverConsole" { + if (value === "qawolfTraceCollection" || value === "serverConsole") + return value; + throw new Error("Expected qawolfTraceCollection or serverConsole."); +} + +export function registerInvestigationCommand( + program: Command, + signals: SignalRegistry, +): void { + const investigation = program + .command("investigation") + .description("Investigate a finished run attempt from recorded evidence"); + + declareCommandKind(investigation.command("list"), "read") + .description("List finished attempt IDs for a run") + .requiredOption("--run-id ", "Run to discover attempts for") + .action((options: { runId: string }, command: Command) => + withAuthContext(signals, (ctx) => + handleInvestigationList(ctx, options.runId), + )(options, command), + ); + + declareCommandKind(investigation.command("summary"), "read") + .description("Summarize the evidence for one finished attempt") + .requiredOption("--attempt-id ", "Run attempt to investigate") + .action((options: { attemptId: string }, command: Command) => + withAuthContext(signals, (ctx) => + handleInvestigationSummary(ctx, options.attemptId), + )(options, command), + ); + + declareCommandKind(investigation.command("inspect"), "read") + .description("Inspect one kind of evidence for a finished attempt") + .requiredOption("--attempt-id ", "Run attempt to investigate") + .requiredOption( + "--type ", + `Evidence type (${inspectTypes.join("|")})`, + parseInspectType, + ) + .option("--action-id ", "Action evidence ID") + .option("--evidence-id ", "Console or log evidence ID") + .option("--request-id ", "Network request evidence ID") + .option("--snapshot-id ", "Page snapshot evidence ID") + .option("--screenshot-id ", "Screenshot evidence ID") + .option("--limit ", "Maximum entries to return", "20") + .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") + .option("--source ", "Execution log source", parseSource) + .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: InspectOptions, command: Command) => + withAuthContext(signals, (ctx) => + handleInvestigationInspect(ctx, options), + )(options, command), + ); +} diff --git a/src/commands/program.ts b/src/commands/program.ts index a82a1f575..a26a514a1 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 { registerInvestigationCommand } from "./investigation/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); + registerInvestigationCommand(program, signals); registerRunnerCommand(program, signals); registerPublicApiCommands(program, signals); registerHelpCommand(program); diff --git a/src/domains/investigation/contracts.ts b/src/domains/investigation/contracts.ts new file mode 100644 index 000000000..2a41c74d6 --- /dev/null +++ b/src/domains/investigation/contracts.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; + +const attemptStatus = z.enum([ + "passed", + "failed", + "canceled", + "aborted", + "skipped", +]); + +const attempt = z + .object({ + attemptId: z.string(), + completedAt: z.string().optional(), + createdAt: z.string(), + error: z.string().optional(), + kind: z.enum(["automated", "manual"]), + startedAt: z.string().optional(), + status: attemptStatus, + }) + .loose(); + +const attemptNeighbor = z + .object({ attemptId: z.string(), status: attemptStatus }) + .loose(); + +export const getAttemptArtifactsContract = { + input: z.object({ attemptId: z.string().min(1) }), + kind: "read" as const, + name: "run.getAttemptArtifacts", + output: z + .object({ + artifactStatus: z.enum(["signed", "not-captured", "signing-failed"]), + artifacts: z + .object({ + logsUrl: z.url().optional(), + traceUrl: z.url().optional(), + videoUrl: z.url().optional(), + }) + .loose(), + attempt, + flowId: z.string(), + flowStatus: z.enum(["queued", "running", "passed", "failed", "canceled"]), + retryContext: z + .object({ + currentOrdinal: z.number().int().positive(), + next: attemptNeighbor.optional(), + previous: attemptNeighbor.optional(), + total: z.number().int().positive(), + }) + .loose(), + runId: z.string(), + runStatus: z.enum(["queued", "running", "passed", "failed", "canceled"]), + }) + .loose(), +}; + +const discoveredAttempt = z + .object({ + attemptId: z.string(), + completedAt: z.string().optional(), + kind: z.enum(["automated", "manual"]), + startedAt: z.string().optional(), + status: attemptStatus, + }) + .loose(); + +export const discoverInvestigationsContract = { + input: z.object({ runId: z.string().min(1) }), + kind: "read" as const, + name: "run.get", + output: z + .object({ + flows: z.array( + z + .object({ + attempts: z.array(discoveredAttempt).optional(), + flowId: z.string(), + name: z.string(), + status: z.string(), + }) + .loose(), + ), + runId: z.string(), + status: z.string(), + }) + .loose(), +}; + +export type AttemptArtifacts = z.infer< + typeof getAttemptArtifactsContract.output +>; diff --git a/src/domains/investigation/download.test.ts b/src/domains/investigation/download.test.ts new file mode 100644 index 000000000..fa679bf38 --- /dev/null +++ b/src/domains/investigation/download.test.ts @@ -0,0 +1,44 @@ +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"); + }); +}); diff --git a/src/domains/investigation/download.ts b/src/domains/investigation/download.ts new file mode 100644 index 000000000..caa2ec035 --- /dev/null +++ b/src/domains/investigation/download.ts @@ -0,0 +1,115 @@ +export type ArtifactDownload = + | { bytes: Uint8Array; offset: number; status: "available" } + | { status: "expired-url" | "not-found" | "too-large" | "unavailable" }; + +const traceLimit = 50 * 1024 * 1024; +const logTailLimit = 5 * 1024 * 1024; +const logTransferLimit = 50 * 1024 * 1024; + +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(30_000); + const response = await fetchImpl(url, { redirect: "follow", signal }); + return await readBounded(response, { + keepTail: false, + retainBytes: traceLimit, + transferBytes: traceLimit, + }); + } catch { + return { status: "unavailable" }; + } +} + +export async function downloadLogTail( + url: string, + fetchImpl: typeof globalThis.fetch = globalThis.fetch, +): Promise { + try { + const signal = AbortSignal.timeout(30_000); + const response = await fetchImpl(url, { + headers: { range: `bytes=-${logTailLimit}` }, + redirect: "follow", + signal, + }); + return await readBounded(response, { + keepTail: true, + retainBytes: logTailLimit, + transferBytes: logTransferLimit, + }); + } catch { + return { status: "unavailable" }; + } +} diff --git a/src/domains/investigation/handle.artifacts.test.ts b/src/domains/investigation/handle.artifacts.test.ts new file mode 100644 index 000000000..79d41cecc --- /dev/null +++ b/src/domains/investigation/handle.artifacts.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; + +import type { AttemptArtifacts } from "./contracts.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; +}); + +function fixture(): 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", + }; +} + +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.ts b/src/domains/investigation/handle.ts new file mode 100644 index 000000000..ae1a91692 --- /dev/null +++ b/src/domains/investigation/handle.ts @@ -0,0 +1,287 @@ +// oxlint-disable eslint/max-lines -- Keep authorization, artifact fetch, and rendering order visible in the command handler. + +import { basename } from "node:path"; + +import { failureFields } from "~/shell/platform/requestWithRetry.js"; +import type { + AuthCommandContext, + CommandResult, +} from "~/shell/commandContext.js"; + +import { + discoverInvestigationsContract, + getAttemptArtifactsContract, +} from "./contracts.js"; +import { downloadLogTail, downloadTrace } from "./download.js"; +import { loadInvestigationParser } from "./lazyParser.js"; +import type { + ArtifactInput, + InspectType, + InvestigationRequest, +} from "./types.js"; + +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: Awaited>, +): 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: Awaited>, + 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, + }; +} + +function numberFlag( + value: string | undefined, + name: string, + options: { integer?: boolean; 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}.` }; + return parsed; +} + +function validateInspect(options: InspectOptions): CommandResult { + if (options.type === "action" && !options.actionId) + return { error: "--action-id is required for --type action." }; + if (options.type === "request" && !options.requestId) + return { error: "--request-id is required for --type request." }; + if (options.type === "snapshot" && !options.snapshotId) + return { error: "--snapshot-id is required for --type snapshot." }; + if (options.type === "screenshot" && !options.screenshotId) + return { error: "--screenshot-id is required for --type screenshot." }; + if (options.format === "html" && options.type !== "snapshot") + return { error: "--format html is supported only for snapshots." }; + if ( + (options.format === "html" || options.type === "screenshot") && + !options.outputFile + ) + return { + error: "--output-file is required for HTML and screenshot evidence.", + }; + if ( + options.outputFile && + options.format !== "html" && + options.type !== "screenshot" + ) + return { + error: + "--output-file is supported only for HTML and screenshot evidence.", + }; + return undefined; +} + +function renderText(result: Record): string { + return JSON.stringify(result, undefined, 2); +} + +async function fetchAttempt(ctx: AuthCommandContext, attemptId: string) { + return ctx.platformClient.callPublicApi(getAttemptArtifactsContract, { + 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); + 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, "--limit", { integer: true, 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 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); + 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; + } + ctx.ui.output(result, renderText(result)); + return undefined; +} + +export async function handleInvestigationList( + ctx: AuthCommandContext, + runId: string, +): Promise { + const result = await ctx.platformClient.callPublicApi( + discoverInvestigationsContract, + { runId }, + ); + if (!result.ok) return failureFields(result); + const investigations = result.value.flows.flatMap((flow) => + (flow.attempts ?? []).map((attempt, index, attempts) => ({ + attemptId: attempt.attemptId, + flowId: flow.flowId, + flowName: flow.name, + kind: attempt.kind, + ordinal: index + 1, + status: attempt.status, + totalAttempts: attempts.length, + })), + ); + ctx.ui.output( + { investigations, runId: result.value.runId, status: result.value.status }, + investigations.length + ? investigations + .map( + (item) => + `${item.attemptId}\t${item.status}\t${item.flowName}\t${item.ordinal}/${item.totalAttempts}`, + ) + .join("\n") + : "No finished attempts were found.", + ); + return undefined; +} diff --git a/src/domains/investigation/lazyParser.ts b/src/domains/investigation/lazyParser.ts new file mode 100644 index 000000000..6a9ec5de9 --- /dev/null +++ b/src/domains/investigation/lazyParser.ts @@ -0,0 +1,24 @@ +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 = process.env["QAWOLF_INVESTIGATION_PARSER_PATH"]; + if (embedded) return embedded; + const sourceModule = import.meta.url.includes("/src/domains/investigation/"); + return new URL( + sourceModule + ? "../../../dist/investigation-parser.js" + : "./investigation-parser.js", + import.meta.url, + ).href; +} diff --git a/src/domains/investigation/types.ts b/src/domains/investigation/types.ts new file mode 100644 index 000000000..7362047f9 --- /dev/null +++ b/src/domains/investigation/types.ts @@ -0,0 +1,48 @@ +export type ArtifactInput = + | { bytes: Uint8Array; offset: number; status: "available" } + | { status: "expired-url" | "not-found" | "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/investigationParser/executionLogs.ts b/src/investigationParser/executionLogs.ts new file mode 100644 index 000000000..d011f3439 --- /dev/null +++ b/src/investigationParser/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/investigationParser/index.security.test.ts b/src/investigationParser/index.security.test.ts new file mode 100644 index 000000000..c4a055244 --- /dev/null +++ b/src/investigationParser/index.security.test.ts @@ -0,0 +1,156 @@ +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); + }); +}); diff --git a/src/investigationParser/index.test.ts b/src/investigationParser/index.test.ts new file mode 100644 index 000000000..38b9294f7 --- /dev/null +++ b/src/investigationParser/index.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "bun:test"; +import JSZip from "jszip"; + +import { investigate, redactEvidence } from "./index.js"; + +describe("investigation parser", () => { + it("redacts conventional secret fields and signed URLs", () => { + const value = redactEvidence( + '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 = redactEvidence( + String.raw`payload={\"token\":\"synthetic-secret\"}`, + ); + const unterminated = redactEvidence( + '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("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/investigationParser/index.ts b/src/investigationParser/index.ts new file mode 100644 index 000000000..e235112b9 --- /dev/null +++ b/src/investigationParser/index.ts @@ -0,0 +1,184 @@ +// 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, + ...(target.method !== undefined && { method: target.method }), + 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); +} + +export { redactAttemptEvidence as redactEvidence } from "./projection/attemptEvidenceText.js"; diff --git a/src/investigationParser/projection/attemptEvidenceText.ts b/src/investigationParser/projection/attemptEvidenceText.ts new file mode 100644 index 000000000..14f539774 --- /dev/null +++ b/src/investigationParser/projection/attemptEvidenceText.ts @@ -0,0 +1,165 @@ +// oxlint-disable eslint/max-lines -- Keep the conventional redaction passes and UTF-8 bounding logic aligned with the platform implementation. + +const redacted = "[REDACTED]"; +const sensitiveFieldNames = [ + "accessToken", + "access_token", + "access-token", + "refreshToken", + "refresh_token", + "refresh-token", + "api_key", + "api-key", + "apiKey", + "apikey", + "set-cookie", + "authorization", + "cookie", + "password", + "token", + "value", +]; +const sensitiveFieldPattern = sensitiveFieldNames.join("|"); +const signedUrlFieldPattern = [ + "accessToken", + "access_token", + "access-token", + "refreshToken", + "refresh_token", + "refresh-token", + "api_key", + "api-key", + "apiKey", + "apikey", + "authorization", + "password", + "token", + "signature", + "sig", + "x-amz-signature", + "x-amz-credential", + "x-amz-security-token", + "x-goog-signature", + "x-goog-credential", + "awsaccesskeyid", + "googleaccessid", + "key-pair-id", + "policy", +].join("|"); + +function redactQuotedAssignments( + text: string, + assignmentPattern: RegExp, + escaped: boolean, +) { + 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] !== '"') { + precedingBackslashes = 0; + continue; + } + const isClosingQuote = escaped + ? precedingBackslashes === 1 + : precedingBackslashes % 2 === 0; + const suffix = text.slice(index + 1); + if (isClosingQuote && /^\s*(?:[,}\]]|$)/.test(suffix)) { + closingQuote = escaped ? index - 1 : index; + break; + } + precedingBackslashes = 0; + } + + if (closingQuote === -1) + return result + (text.endsWith("[TRUNCATED]") ? "[TRUNCATED]" : ""); + const quoteLength = escaped ? 2 : 1; + result += text.slice(closingQuote, closingQuote + quoteLength); + cursor = closingQuote + quoteLength; + assignmentPattern.lastIndex = cursor; + match = assignmentPattern.exec(text); + } + + return result + text.slice(cursor); +} + +export function redactAttemptEvidence(text: string): string { + const userInfoRedacted = text.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"), + false, + ); + const escapedJsonRedacted = redactQuotedAssignments( + jsonRedacted, + new RegExp(`\\\\"(?:${sensitiveFieldPattern})\\\\"\\s*:\\s*\\\\"`, "gi"), + true, + ); + + return escapedJsonRedacted + .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/investigationParser/projection/chronologicallySortActions.ts b/src/investigationParser/projection/chronologicallySortActions.ts new file mode 100644 index 000000000..398212a1b --- /dev/null +++ b/src/investigationParser/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/investigationParser/projection/inspectAttemptActions.ts b/src/investigationParser/projection/inspectAttemptActions.ts new file mode 100644 index 000000000..a69b682b6 --- /dev/null +++ b/src/investigationParser/projection/inspectAttemptActions.ts @@ -0,0 +1,126 @@ +import { + type PlaywrightTrace, + listTraceActionLogs, + listTraceActions, + listTraceNetwork, + listTraceSnapshots, +} from "../trace/playwrightTrace.js"; + +import { chronologicallySortActions } from "./chronologicallySortActions.js"; +import { + type InspectionCommon, + emptyInspectionCoverage, + shapeInspectionAction, +} from "./inspectAttemptShared.js"; + +export function inspectActionTimeline( + trace: PlaywrightTrace, + common: InspectionCommon, + target: { beforeActionId?: string; limit: number; type: "timeline" }, +) { + const listed = listTraceActions(trace, { limit: Number.MAX_SAFE_INTEGER }); + const actions = chronologicallySortActions(listed.items); + const boundary = target.beforeActionId + ? actions.findIndex((item) => item.id === target.beforeActionId) + : actions.length; + if (boundary === -1) { + return { + ...common, + actions: [], + coverage: { ...listed.coverage, returned: 0 }, + status: "not-found" as const, + type: target.type, + }; + } + const start = Math.max(0, boundary - target.limit); + const selected = actions.slice(start, boundary); + 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 || boundary < actions.length, + }, + ...(start > 0 && selected[0] && { nextBeforeActionId: selected[0].id }), + 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/investigationParser/projection/inspectAttemptNetwork.ts b/src/investigationParser/projection/inspectAttemptNetwork.ts new file mode 100644 index 000000000..f1904cf62 --- /dev/null +++ b/src/investigationParser/projection/inspectAttemptNetwork.ts @@ -0,0 +1,107 @@ +import { + type PlaywrightTrace, + listTraceNetwork, +} from "../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/investigationParser/projection/inspectAttemptPage.ts b/src/investigationParser/projection/inspectAttemptPage.ts new file mode 100644 index 000000000..3baa591c4 --- /dev/null +++ b/src/investigationParser/projection/inspectAttemptPage.ts @@ -0,0 +1,104 @@ +import { + type PlaywrightTrace, + nearestScreenshotForSnapshot, + readTraceSnapshot, +} from "../trace/playwrightTrace.js"; + +import { + boundAttemptPageText, + redactAttemptEvidence, +} from "./attemptEvidenceText.js"; +import type { InspectionCommon } from "./inspectAttemptShared.js"; +import { serializeSnapshotToHtml } from "../snapshot/snapshotHtmlProjection.js"; +import { + findSmallestTextRegions, + projectToText, + resolveSnapshot, +} from "../snapshot/snapshotProjection.js"; + +const maxSnapshotReconstructionBytes = 256 * 1024; + +export function inspectSnapshot( + trace: PlaywrightTrace, + common: InspectionCommon, + target: { + format?: "html" | "text"; + regionText?: string; + snapshotId: string; + type: "snapshot"; + }, +) { + const page = readTraceSnapshot(trace, target.snapshotId, { + maxBytes: maxSnapshotReconstructionBytes, + }); + if (!page) { + return { + ...common, + focusedRegionTruncated: false, + ...(target.format === "html" && { htmlTruncated: false }), + status: "not-found" as const, + type: target.type, + }; + } + const root = resolveSnapshot([page.html], 0); + const projected = boundAttemptPageText( + redactAttemptEvidence(projectToText(root, Number.MAX_SAFE_INTEGER)), + ); + const regions = target.regionText + ? findSmallestTextRegions(root, target.regionText) + : []; + const region = regions.length === 1 ? regions[0] : undefined; + const focusedRegion = region + ? boundAttemptPageText( + redactAttemptEvidence(projectToText(region, Number.MAX_SAFE_INTEGER)), + ) + : undefined; + const nearest = nearestScreenshotForSnapshot(trace, target.snapshotId); + const serializedHtml = + target.format === "html" && !page.truncated + ? serializeSnapshotToHtml(root) + : undefined; + return { + ...common, + ...(focusedRegion && { focusedRegion: focusedRegion.text }), + focusedRegionTruncated: focusedRegion?.truncated ?? false, + ...(target.regionText && { + focusedRegionStatus: + regions.length === 0 + ? ("not-found" as const) + : regions.length === 1 + ? ("available" as const) + : ("multiple-match" as const), + }), + ...(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/investigationParser/projection/inspectAttemptRecorded.ts b/src/investigationParser/projection/inspectAttemptRecorded.ts new file mode 100644 index 000000000..5408b640e --- /dev/null +++ b/src/investigationParser/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 "../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/investigationParser/projection/inspectAttemptRequestHeaders.ts b/src/investigationParser/projection/inspectAttemptRequestHeaders.ts new file mode 100644 index 000000000..7168771ef --- /dev/null +++ b/src/investigationParser/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/investigationParser/projection/inspectAttemptShared.ts b/src/investigationParser/projection/inspectAttemptShared.ts new file mode 100644 index 000000000..d58e905bd --- /dev/null +++ b/src/investigationParser/projection/inspectAttemptShared.ts @@ -0,0 +1,95 @@ +// oxlint-disable eslint/max-lines -- Keep shared typed fallback and action shaping aligned with the platform implementation. + +import type { listTraceActionLogs } from "../trace/playwrightTrace.js"; +import type { PlaywrightTrace, TraceAction } from "../trace/playwrightTrace.js"; +import { readTraceActionStack } from "../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/investigationParser/projection/summarizeAttemptNetwork.ts b/src/investigationParser/projection/summarizeAttemptNetwork.ts new file mode 100644 index 000000000..6ea6514b9 --- /dev/null +++ b/src/investigationParser/projection/summarizeAttemptNetwork.ts @@ -0,0 +1,74 @@ +import type { Bounded, TraceNetworkEntry } from "../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/investigationParser/projection/summarizeAttemptTrace.ts b/src/investigationParser/projection/summarizeAttemptTrace.ts new file mode 100644 index 000000000..531151b83 --- /dev/null +++ b/src/investigationParser/projection/summarizeAttemptTrace.ts @@ -0,0 +1,198 @@ +// 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, + readTraceSnapshot, +} from "../trace/playwrightTrace.js"; + +import { + boundAttemptPageText, + redactAndBoundAttemptEvidenceResult, + redactAttemptEvidence, +} from "./attemptEvidenceText.js"; +import { chronologicallySortActions } from "./chronologicallySortActions.js"; +import { + projectToText, + resolveSnapshot, +} from "../snapshot/snapshotProjection.js"; +import { summarizeAttemptNetworkErrors } from "./summarizeAttemptNetwork.js"; +import { summarizeAttemptWaitingLogs } from "./summarizeAttemptWaitingLogs.js"; + +const evidenceLimit = 10; +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 = + failure?.action.id === action.id + ? failure.frames.map((frame) => `${frame.file}:${frame.compiledLine}`) + : []; + return { + actionId: action.id, + apiName: action.apiName, + ...(action.endTime !== undefined && { + endedAtMilliseconds: action.endTime, + }), + ...(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 + ? 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/investigationParser/projection/summarizeAttemptWaitingLogs.ts b/src/investigationParser/projection/summarizeAttemptWaitingLogs.ts new file mode 100644 index 000000000..e3a28c32d --- /dev/null +++ b/src/investigationParser/projection/summarizeAttemptWaitingLogs.ts @@ -0,0 +1,26 @@ +import type { Bounded, TraceActionLog } from "../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/investigationParser/snapshot/snapshotHtmlProjection.test.ts b/src/investigationParser/snapshot/snapshotHtmlProjection.test.ts new file mode 100644 index 000000000..b62525047 --- /dev/null +++ b/src/investigationParser/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/investigationParser/snapshot/snapshotHtmlProjection.ts b/src/investigationParser/snapshot/snapshotHtmlProjection.ts new file mode 100644 index 000000000..68782ed8b --- /dev/null +++ b/src/investigationParser/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 "~/investigationParser/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/investigationParser/snapshot/snapshotProjection.ts b/src/investigationParser/snapshot/snapshotProjection.ts new file mode 100644 index 000000000..54c8c76ab --- /dev/null +++ b/src/investigationParser/snapshot/snapshotProjection.ts @@ -0,0 +1,116 @@ +// oxlint-disable eslint/max-lines -- Keep snapshot resolution and text projection aligned with the platform implementation. + +import { reconstructSnapshotHtml } from "../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(); + +function textOf(node: Node): string { + return typeof node === "string" ? node : node.children.map(textOf).join(" "); +} + +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"); +} + +export function findSmallestTextRegions(root: Node, query: string): Node[] { + const find = (node: Node, parent: Element | undefined): Element[] => { + if (typeof node === "string") return []; + const childMatches = node.children.flatMap((child) => find(child, node)); + if (childMatches.length) return childMatches; + return collapse(textOf(node)).toLowerCase().includes(query.toLowerCase()) + ? [parent ?? node] + : []; + }; + return [...new Set(find(root, undefined))]; +} diff --git a/src/investigationParser/snapshot/snapshotVocabulary.ts b/src/investigationParser/snapshot/snapshotVocabulary.ts new file mode 100644 index 000000000..cdaaf1585 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTrace.ts b/src/investigationParser/trace/playwrightTrace.ts new file mode 100644 index 000000000..191eb8f26 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceActions.ts b/src/investigationParser/trace/playwrightTraceActions.ts new file mode 100644 index 000000000..495dec281 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceEvent.ts b/src/investigationParser/trace/playwrightTraceEvent.ts new file mode 100644 index 000000000..102b937d1 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceMedia.ts b/src/investigationParser/trace/playwrightTraceMedia.ts new file mode 100644 index 000000000..75e676934 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceNetwork.ts b/src/investigationParser/trace/playwrightTraceNetwork.ts new file mode 100644 index 000000000..1fce15910 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceNetworkStreams.ts b/src/investigationParser/trace/playwrightTraceNetworkStreams.ts new file mode 100644 index 000000000..fff534589 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceParse.ts b/src/investigationParser/trace/playwrightTraceParse.ts new file mode 100644 index 000000000..5301997c9 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceParseEntry.ts b/src/investigationParser/trace/playwrightTraceParseEntry.ts new file mode 100644 index 000000000..12c6bd817 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceSnapshotSerialize.ts b/src/investigationParser/trace/playwrightTraceSnapshotSerialize.ts new file mode 100644 index 000000000..feec8d922 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceSnapshotWriter.ts b/src/investigationParser/trace/playwrightTraceSnapshotWriter.ts new file mode 100644 index 000000000..03c93c42e --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceSnapshots.ts b/src/investigationParser/trace/playwrightTraceSnapshots.ts new file mode 100644 index 000000000..b65dc191e --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceTypes.ts b/src/investigationParser/trace/playwrightTraceTypes.ts new file mode 100644 index 000000000..5006e5963 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/playwrightTraceUtils.ts b/src/investigationParser/trace/playwrightTraceUtils.ts new file mode 100644 index 000000000..daee7890d --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/runLogs.ts b/src/investigationParser/trace/runLogs.ts new file mode 100644 index 000000000..1c95ee3b0 --- /dev/null +++ b/src/investigationParser/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/investigationParser/trace/runLogsTypes.ts b/src/investigationParser/trace/runLogsTypes.ts new file mode 100644 index 000000000..077b4f69f --- /dev/null +++ b/src/investigationParser/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 = { + /** Bounds the tail window parsed after `readFileIfExists` returns a Buffer. */ + 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/shell/embeddedWorkerCli.ts b/src/shell/embeddedWorkerCli.ts index 142cf7537..88ad5fd3f 100644 --- a/src/shell/embeddedWorkerCli.ts +++ b/src/shell/embeddedWorkerCli.ts @@ -10,6 +10,8 @@ import { getDataDir } from "~/core/paths.js"; * executes the entry script directly. */ const embeddedCliPathEnv = "QAWOLF_EMBEDDED_CLI_PATH"; +const embeddedInvestigationParserPathEnv = + "QAWOLF_EMBEDDED_INVESTIGATION_PARSER_PATH"; /** * Extracts the embedded cli.js bundle to a real on-disk file so a BUN_BE_BUN @@ -24,8 +26,14 @@ export function extractEmbeddedWorkerCli(): string | undefined { if (assetPath === undefined || assetPath === "") return undefined; const dir = join(getDataDir(), "worker"); const dest = join(dir, basename(assetPath)); - if (existsSync(dest)) return dest; mkdirSync(dir, { recursive: true }); - writeFileSync(dest, readFileSync(assetPath)); + if (!existsSync(dest)) writeFileSync(dest, readFileSync(assetPath)); + const parserAssetPath = process.env[embeddedInvestigationParserPathEnv]; + if (parserAssetPath) { + const parserDest = join(dir, basename(parserAssetPath)); + if (!existsSync(parserDest)) + writeFileSync(parserDest, readFileSync(parserAssetPath)); + process.env["QAWOLF_INVESTIGATION_PARSER_PATH"] = parserDest; + } return dest; } 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); From 303133b88c33633e8b747b0f761c165f83addabf Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Tue, 29 Sep 2026 13:34:41 +0200 Subject: [PATCH 02/27] chore(cli): add investigation release metadata --- .changeset/quiet-wolves-investigate.md | 5 +++++ knip.config.ts | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/quiet-wolves-investigate.md diff --git a/.changeset/quiet-wolves-investigate.md b/.changeset/quiet-wolves-investigate.md new file mode 100644 index 000000000..943d34222 --- /dev/null +++ b/.changeset/quiet-wolves-investigate.md @@ -0,0 +1,5 @@ +--- +"@qawolf/cli": minor +--- + +Add local run-attempt investigation commands with lazily loaded trace and log parsing. diff --git a/knip.config.ts b/knip.config.ts index 3b8eb6219..5977a6db1 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -11,6 +11,11 @@ const config: KnipConfig = { "src/**/*.testUtils.ts", ], project: ["src/**/*.ts"], + ignoreBinaries: [ + // the built bundle, invoked as `node dist/cli.js` in the runtime-smoke CI + // job; not present when knip runs (it runs before the build step) + "dist/cli.js", + ], ignoreDependencies: [ // devDependencies kept only so genDependencyVersions.ts can read their // installed versions. The CLI never imports either: ensureRuntimeEnv From 630777a13103b7527ccb74cc1f6e2397cfc18544 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Tue, 29 Sep 2026 14:00:49 +0200 Subject: [PATCH 03/27] feat(cli): simplify attempt investigation commands --- .changeset/quiet-wolves-investigate.md | 2 +- README.md | 35 ++++---- skills/qawolf-cli/SKILL.md | 6 +- src/commands/__snapshots__/help.test.ts.snap | 60 +++++++++++++- src/commands/help.test.ts | 12 +++ src/commands/investigation/index.test.ts | 74 +++++++++++++++++ src/commands/investigation/index.ts | 83 +++++++++---------- .../investigation/inspectSelection.ts | 62 ++++++++++++++ src/commands/program.ts | 4 +- .../investigation/handle.output.test.ts | 36 ++++++++ src/domains/investigation/handle.ts | 33 +++++--- 11 files changed, 331 insertions(+), 76 deletions(-) create mode 100644 src/commands/investigation/index.test.ts create mode 100644 src/commands/investigation/inspectSelection.ts create mode 100644 src/domains/investigation/handle.output.test.ts diff --git a/.changeset/quiet-wolves-investigate.md b/.changeset/quiet-wolves-investigate.md index 943d34222..ccbfcd4f6 100644 --- a/.changeset/quiet-wolves-investigate.md +++ b/.changeset/quiet-wolves-investigate.md @@ -2,4 +2,4 @@ "@qawolf/cli": minor --- -Add local run-attempt investigation commands with lazily loaded trace and log parsing. +Add `qawolf attempt` commands with lazily loaded, bounded trace and log parsing. diff --git a/README.md b/README.md index f9fba96c9..a37772096 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,15 @@ qawolf flows run examples/example.flow.ts ## Commands -| Command | What it does | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `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 investigation` | Discover attempt IDs and inspect bounded run evidence | -| `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) | +| Command | What it does | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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` | Discover attempt IDs and inspect bounded run evidence | +| `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. @@ -73,17 +73,20 @@ Run any command with `--help` for its flags and options. `qawolf help ref -qawolf investigation summary --attempt-id -qawolf investigation inspect --attempt-id --type timeline --limit 20 -qawolf investigation inspect --attempt-id --type request --request-id +qawolf attempt list --run-id +qawolf attempt investigate +qawolf attempt inspect --timeline --limit 20 +qawolf attempt inspect --request ``` -Snapshot HTML and screenshots are written only to a new file; the CLI refuses to overwrite an existing path: +`attempt inspect` requires exactly one of `--action`, `--timeline`, `--network`, `--request`, `--snapshot`, `--screenshot`, `--console`, or `--log`. Use `--evidence-id` to start console or execution-log output at a specific 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 investigation inspect --attempt-id --type snapshot --snapshot-id --format html --output-file snapshot.html -qawolf investigation inspect --attempt-id --type screenshot --screenshot-id --output-file screenshot.jpeg +qawolf attempt inspect --snapshot --format html +qawolf attempt inspect --snapshot --format html --output-file snapshot.html +qawolf attempt inspect --screenshot --output-file screenshot.jpeg ``` These commands require the platform's `public.run.getAttemptArtifacts` endpoint to be deployed. The endpoint authorizes every request and returns fresh signed artifact URLs. Parsing happens locally in a separate lazy bundle, so normal CLI startup does not load the trace parser or JSZip. Downloads, decompression, 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. diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index daceaefd5..ff1352469 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 inspect` | read | Inspect one kind of evidence for a finished attempt | +| `qawolf attempt investigate` | read | Summarize the evidence for one finished attempt | +| `qawolf attempt list` | read | List finished attempt IDs for a run | | `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 | @@ -167,9 +170,6 @@ that `url`; never guess a route and never send a repository link in its place. | `qawolf install browsers` | local | Install Playwright browsers used by the project's web flows | | `qawolf install clear` | local | Remove the managed runtime cache (all installed runtime versions) | | `qawolf investigation get` | read | Read what the QA Wolf AI investigating a run has concluded: one finding per cause of its failures, with the question waiting for a person and any answer or dispute the investigation hasn't acted on yet. | -| `qawolf investigation inspect` | read | Inspect one kind of evidence for a finished attempt | -| `qawolf investigation list` | read | List finished attempt IDs for a run | -| `qawolf investigation summary` | read | Summarize the evidence for one finished attempt | | `qawolf issue addFlows` | write | Add flows to a coverage request owned by the caller's team. Flows already covered stay covered. Bug and maintenance reports link to flows through the runs that reproduce them; use run.diagnose to record one. | | `qawolf issue create` | write | Create a bug report, maintenance report, or coverage request issue for the caller's team. A user can create a maintenance report only when they can see the workspace's maintenance reports; a team or organization API key credits it to the workspace's automation user. Link its failed flows afterwards with run.diagnose. | | `qawolf issue find` | read | List the team's bug reports, maintenance reports, or coverage requests, newest first. | diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 0bd60a8dd..329749d19 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -22,7 +22,7 @@ Commands: directory install [options] [pattern] Install every runtime dependency the project's flows need - investigation Investigate a finished run attempt from recorded + attempt Investigate a finished run attempt from recorded evidence runner Drive an interactive runner on the QA Wolf platform @@ -32,6 +32,7 @@ Commands: environment QA Wolf public API environment commands file QA Wolf public API file commands flow QA Wolf public API flow commands + investigation QA Wolf public API investigation commands issue QA Wolf public API issue commands legacyTrigger QA Wolf public API legacyTrigger commands run Trigger and manage QA Wolf runs on the platform @@ -150,6 +151,63 @@ 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: + list [options] List finished attempt IDs for a run + investigate Summarize the evidence for one finished attempt + inspect [options] Inspect one kind of evidence for a finished + attempt + 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: + -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: + --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 Console or log evidence ID + --limit Maximum entries to return (default: "20") + --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 + --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/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/investigation/index.test.ts b/src/commands/investigation/index.test.ts new file mode 100644 index 000000000..96c9d104c --- /dev/null +++ b/src/commands/investigation/index.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import { Command } from "commander"; + +import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js"; +import { createProgram } from "~/commands/program.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([ + "list", + "investigate", + "inspect", + ]); + expect(investigation.commands.map((command) => command.name())).toContain( + "get", + ); + }); + + it("requires one selector before entering authenticated command handling", () => { + const missing = attemptProgram().parseAsync(["attempt", "inspect", "a1"], { + from: "user", + }); + expect(missing).rejects.toThrow("Choose exactly one evidence selector"); + + const conflicting = attemptProgram().parseAsync( + ["attempt", "inspect", "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 [ + "--action", + "--timeline", + "--network", + "--request", + "--snapshot", + "--screenshot", + "--console", + "--log", + "--evidence-id", + ]) + expect(flags).toContain(selector); + expect(flags).not.toContain("--attempt-id"); + expect(flags).not.toContain("--type"); + }); +}); diff --git a/src/commands/investigation/index.ts b/src/commands/investigation/index.ts index 7967ba344..0b07a1752 100644 --- a/src/commands/investigation/index.ts +++ b/src/commands/investigation/index.ts @@ -6,26 +6,12 @@ import { handleInvestigationInspect, handleInvestigationList, handleInvestigationSummary, - type InspectOptions, } from "~/domains/investigation/handle.js"; -import type { InspectType } from "~/domains/investigation/types.js"; import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; - -const inspectTypes: InspectType[] = [ - "action", - "timeline", - "network", - "request", - "snapshot", - "screenshot", - "console", - "log", -]; - -function parseInspectType(value: string): InspectType { - if (inspectTypes.includes(value as InspectType)) return value as InspectType; - throw new Error(`Expected one of: ${inspectTypes.join(", ")}.`); -} +import { + type InspectCommandOptions, + resolveInspectSelection, +} from "./inspectSelection.js"; function parseFormat(value: string): "html" | "text" { if (value === "html" || value === "text") return value; @@ -38,15 +24,15 @@ function parseSource(value: string): "qawolfTraceCollection" | "serverConsole" { throw new Error("Expected qawolfTraceCollection or serverConsole."); } -export function registerInvestigationCommand( +export function registerAttemptCommand( program: Command, signals: SignalRegistry, ): void { - const investigation = program - .command("investigation") + const attempt = program + .command("attempt") .description("Investigate a finished run attempt from recorded evidence"); - declareCommandKind(investigation.command("list"), "read") + declareCommandKind(attempt.command("list"), "read") .description("List finished attempt IDs for a run") .requiredOption("--run-id ", "Run to discover attempts for") .action((options: { runId: string }, command: Command) => @@ -55,28 +41,25 @@ export function registerInvestigationCommand( )(options, command), ); - declareCommandKind(investigation.command("summary"), "read") + declareCommandKind(attempt.command("investigate "), "read") .description("Summarize the evidence for one finished attempt") - .requiredOption("--attempt-id ", "Run attempt to investigate") - .action((options: { attemptId: string }, command: Command) => + .action((attemptId: string, _options: unknown, command: Command) => withAuthContext(signals, (ctx) => - handleInvestigationSummary(ctx, options.attemptId), - )(options, command), + handleInvestigationSummary(ctx, attemptId), + )({}, command), ); - declareCommandKind(investigation.command("inspect"), "read") + declareCommandKind(attempt.command("inspect "), "read") .description("Inspect one kind of evidence for a finished attempt") - .requiredOption("--attempt-id ", "Run attempt to investigate") - .requiredOption( - "--type ", - `Evidence type (${inspectTypes.join("|")})`, - parseInspectType, - ) - .option("--action-id ", "Action evidence ID") + .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 ", "Console or log evidence ID") - .option("--request-id ", "Network request evidence ID") - .option("--snapshot-id ", "Page snapshot evidence ID") - .option("--screenshot-id ", "Screenshot evidence ID") .option("--limit ", "Maximum entries to return", "20") .option( "--start-time-ms ", @@ -99,9 +82,25 @@ export function registerInvestigationCommand( "--output-file ", "Write HTML or screenshot bytes without overwriting", ) - .action((options: InspectOptions, command: Command) => - withAuthContext(signals, (ctx) => - handleInvestigationInspect(ctx, options), - )(options, command), + .action( + (attemptId: string, 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, attemptId }; + return withAuthContext(signals, (ctx) => + handleInvestigationInspect(ctx, normalized), + )(options, command); + }, ); } diff --git a/src/commands/investigation/inspectSelection.ts b/src/commands/investigation/inspectSelection.ts new file mode 100644 index 000000000..71fc1c097 --- /dev/null +++ b/src/commands/investigation/inspectSelection.ts @@ -0,0 +1,62 @@ +import type { InspectOptions } from "~/domains/investigation/handle.js"; + +export type InspectCommandOptions = Omit< + InspectOptions, + | "actionId" + | "attemptId" + | "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/program.ts b/src/commands/program.ts index a26a514a1..ffd21647f 100644 --- a/src/commands/program.ts +++ b/src/commands/program.ts @@ -8,7 +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 { registerInvestigationCommand } from "./investigation/index.js"; +import { registerAttemptCommand } from "./investigation/index.js"; import { registerPublicApiCommands } from "./publicApi/index.js"; import { registerRunnerCommand } from "./runner/index.js"; import { exitCodes, exit } from "~/shell/exit.js"; @@ -36,7 +36,7 @@ export function createProgram({ registerFlowsCommand(program, signals); registerInitCommand(program, signals); registerInstallCommand(program, signals); - registerInvestigationCommand(program, signals); + registerAttemptCommand(program, signals); registerRunnerCommand(program, signals); registerPublicApiCommands(program, signals); registerHelpCommand(program); 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.ts b/src/domains/investigation/handle.ts index ae1a91692..d7b9fa4c8 100644 --- a/src/domains/investigation/handle.ts +++ b/src/domains/investigation/handle.ts @@ -18,6 +18,7 @@ import type { ArtifactInput, InspectType, InvestigationRequest, + InvestigationResult, } from "./types.js"; export type InspectOptions = { @@ -108,21 +109,18 @@ function numberFlag( function validateInspect(options: InspectOptions): CommandResult { if (options.type === "action" && !options.actionId) - return { error: "--action-id is required for --type action." }; + return { error: "--action requires an evidence ID." }; if (options.type === "request" && !options.requestId) - return { error: "--request-id is required for --type request." }; + return { error: "--request requires an evidence ID." }; if (options.type === "snapshot" && !options.snapshotId) - return { error: "--snapshot-id is required for --type snapshot." }; + return { error: "--snapshot requires an evidence ID." }; if (options.type === "screenshot" && !options.screenshotId) - return { error: "--screenshot-id is required for --type screenshot." }; + return { error: "--screenshot requires an evidence ID." }; if (options.format === "html" && options.type !== "snapshot") - return { error: "--format html is supported only for snapshots." }; - if ( - (options.format === "html" || options.type === "screenshot") && - !options.outputFile - ) + return { error: "--format html is supported only with --snapshot." }; + if (options.type === "screenshot" && !options.outputFile) return { - error: "--output-file is required for HTML and screenshot evidence.", + error: "--output-file is required for screenshot evidence.", }; if ( options.outputFile && @@ -131,7 +129,7 @@ function validateInspect(options: InspectOptions): CommandResult { ) return { error: - "--output-file is supported only for HTML and screenshot evidence.", + "--output-file is supported only with --snapshot and --format html, or with --screenshot.", }; return undefined; } @@ -226,6 +224,14 @@ export async function handleInvestigationInspect( }; 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) @@ -248,6 +254,11 @@ export async function handleInvestigationInspect( ); 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; } From ec7ae8ca7a31c31ebefe98951f4b05d13f3c8fd5 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Tue, 29 Sep 2026 14:53:14 +0200 Subject: [PATCH 04/27] fix(cli): harden investigation evidence redaction --- .../index.security.test.ts | 53 ++++++++++ src/investigationParser/index.test.ts | 51 ++++++++++ .../projection/attemptEvidenceText.ts | 96 ++++++++++++++++--- .../projection/inspectAttemptPage.ts | 19 ++-- .../projection/summarizeAttemptTrace.ts | 20 ++-- 5 files changed, 211 insertions(+), 28 deletions(-) diff --git a/src/investigationParser/index.security.test.ts b/src/investigationParser/index.security.test.ts index c4a055244..b320d36d9 100644 --- a/src/investigationParser/index.security.test.ts +++ b/src/investigationParser/index.security.test.ts @@ -153,4 +153,57 @@ describe("investigation parser security", () => { 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/investigationParser/index.test.ts b/src/investigationParser/index.test.ts index 38b9294f7..86c94a709 100644 --- a/src/investigationParser/index.test.ts +++ b/src/investigationParser/index.test.ts @@ -26,6 +26,57 @@ describe("investigation parser", () => { expect(unterminated).toContain("[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 = redactEvidence(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: { diff --git a/src/investigationParser/projection/attemptEvidenceText.ts b/src/investigationParser/projection/attemptEvidenceText.ts index 14f539774..17b8a0fb5 100644 --- a/src/investigationParser/projection/attemptEvidenceText.ts +++ b/src/investigationParser/projection/attemptEvidenceText.ts @@ -50,7 +50,7 @@ const signedUrlFieldPattern = [ function redactQuotedAssignments( text: string, assignmentPattern: RegExp, - escaped: boolean, + options: { escaped: boolean; quote: '"' | "'" }, ) { let result = ""; let cursor = 0; @@ -67,16 +67,16 @@ function redactQuotedAssignments( precedingBackslashes += 1; continue; } - if (text[index] !== '"') { + if (text[index] !== options.quote) { precedingBackslashes = 0; continue; } - const isClosingQuote = escaped + const isClosingQuote = options.escaped ? precedingBackslashes === 1 : precedingBackslashes % 2 === 0; const suffix = text.slice(index + 1); if (isClosingQuote && /^\s*(?:[,}\]]|$)/.test(suffix)) { - closingQuote = escaped ? index - 1 : index; + closingQuote = options.escaped ? index - 1 : index; break; } precedingBackslashes = 0; @@ -84,7 +84,7 @@ function redactQuotedAssignments( if (closingQuote === -1) return result + (text.endsWith("[TRUNCATED]") ? "[TRUNCATED]" : ""); - const quoteLength = escaped ? 2 : 1; + const quoteLength = options.escaped ? 2 : 1; result += text.slice(closingQuote, closingQuote + quoteLength); cursor = closingQuote + quoteLength; assignmentPattern.lastIndex = cursor; @@ -94,6 +94,51 @@ function redactQuotedAssignments( 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 userInfoRedacted = text.replace( /((?:https?:)?\/\/)[^/\s?#]*@/gi, @@ -106,23 +151,52 @@ export function redactAttemptEvidence(text: string): string { const jsonRedacted = redactQuotedAssignments( queryRedacted, new RegExp(`"(?:${sensitiveFieldPattern})"\\s*:\\s*"`, "gi"), - false, + { escaped: false, quote: '"' }, ); const escapedJsonRedacted = redactQuotedAssignments( jsonRedacted, new RegExp(`\\\\"(?:${sensitiveFieldPattern})\\\\"\\s*:\\s*\\\\"`, "gi"), - true, + { 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 escapedJsonRedacted - .replace( + return redactCallArguments( + escapedScalarJsonRedacted.replace( new RegExp( `(? Date: Thu, 1 Oct 2026 22:09:59 +0200 Subject: [PATCH 05/27] fix(cli): redact JWTs that no field name points at --- src/investigationParser/index.test.ts | 18 ++++++++++++++++++ .../projection/attemptEvidenceText.ts | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/investigationParser/index.test.ts b/src/investigationParser/index.test.ts index 86c94a709..c56d0bda6 100644 --- a/src/investigationParser/index.test.ts +++ b/src/investigationParser/index.test.ts @@ -26,6 +26,24 @@ describe("investigation parser", () => { expect(unterminated).toContain("[REDACTED]"); }); + it("redacts a JWT that no field name points at", () => { + const jwt = + "eyJhbGciOiJSUzI1NiJ9.eyJzaWQiOiJzeW50aGV0aWMifQ.c3ludGhldGljLXNpZw"; + const value = redactEvidence(`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 = redactEvidence( + "session eyJhbGciOiJSUzI1NiJ9.eyJzaWQiOiJzeW50aGV0aWMifQ", + ); + + expect(value).toBe("session [REDACTED]"); + }); + it.each([ { evidence: "Request failed: {'password': 'hunter2', 'safe': 'visible'}", diff --git a/src/investigationParser/projection/attemptEvidenceText.ts b/src/investigationParser/projection/attemptEvidenceText.ts index 17b8a0fb5..4c6e1706d 100644 --- a/src/investigationParser/projection/attemptEvidenceText.ts +++ b/src/investigationParser/projection/attemptEvidenceText.ts @@ -46,6 +46,11 @@ const signedUrlFieldPattern = [ "key-pair-id", "policy", ].join("|"); +// 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, @@ -140,7 +145,8 @@ function redactCallArguments(text: string): string { } export function redactAttemptEvidence(text: string): string { - const userInfoRedacted = text.replace( + const jwtRedacted = text.replace(jwtPattern, redacted); + const userInfoRedacted = jwtRedacted.replace( /((?:https?:)?\/\/)[^/\s?#]*@/gi, `$1${redacted}@`, ); From 26dc86b0bbbda666b4d17e58fb0b568210bc6657 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Thu, 1 Oct 2026 22:11:20 +0200 Subject: [PATCH 06/27] fix(cli): show each summarized action's error and selector --- src/investigationParser/index.summary.test.ts | 57 +++++++++++++++++++ .../projection/summarizeAttemptTrace.ts | 29 +++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/investigationParser/index.summary.test.ts diff --git a/src/investigationParser/index.summary.test.ts b/src/investigationParser/index.summary.test.ts new file mode 100644 index 000000000..88106d4f0 --- /dev/null +++ b/src/investigationParser/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/investigationParser/projection/summarizeAttemptTrace.ts b/src/investigationParser/projection/summarizeAttemptTrace.ts index 1449036f3..a79fb3b63 100644 --- a/src/investigationParser/projection/summarizeAttemptTrace.ts +++ b/src/investigationParser/projection/summarizeAttemptTrace.ts @@ -9,6 +9,7 @@ import { listTraceSnapshots, nearestScreenshotForSnapshot, readFailureStack, + readTraceActionStack, readTraceSnapshot, } from "../trace/playwrightTrace.js"; @@ -26,6 +27,7 @@ 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 { @@ -55,15 +57,36 @@ export async function summarizeAttemptTrace( ); const toAction = (action: (typeof actions.items)[number]) => { const stack = - failure?.action.id === action.id - ? failure.frames.map((frame) => `${frame.file}:${frame.compiledLine}`) - : []; + 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, }), From 3f03b487c699d8af96b2710d0d583d8339cdc6f5 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Thu, 1 Oct 2026 22:12:16 +0200 Subject: [PATCH 07/27] docs(cli): say which entries attempt inspect --limit keeps --- src/commands/__snapshots__/help.test.ts.snap | 4 +++- src/commands/investigation/index.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 329749d19..fc3755e80 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -193,7 +193,9 @@ Options: --console Inspect browser console evidence --log Inspect execution log evidence --evidence-id Console or log evidence ID - --limit Maximum entries to return (default: "20") + --limit Maximum entries to return: the most recent for + --timeline, --network and --log, the earliest for + --console (default: "20") --start-time-ms Earliest trace timestamp in milliseconds --end-time-ms Latest trace timestamp in milliseconds --start-timestamp Earliest execution log timestamp diff --git a/src/commands/investigation/index.ts b/src/commands/investigation/index.ts index 0b07a1752..ee4bde773 100644 --- a/src/commands/investigation/index.ts +++ b/src/commands/investigation/index.ts @@ -60,7 +60,11 @@ export function registerAttemptCommand( .option("--console", "Inspect browser console evidence") .option("--log", "Inspect execution log evidence") .option("--evidence-id ", "Console or log evidence ID") - .option("--limit ", "Maximum entries to return", "20") + .option( + "--limit ", + "Maximum entries to return: the most recent for --timeline, --network and --log, the earliest for --console", + "20", + ) .option( "--start-time-ms ", "Earliest trace timestamp in milliseconds", From 98e153775fd17a22dcfaf9eb1daebacb59f1cf93 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Fri, 2 Oct 2026 10:02:27 +0200 Subject: [PATCH 08/27] refactor(cli): drop attempt list now that run get lists attempt IDs --- README.md | 4 +- skills/qawolf-cli/SKILL.md | 1 - src/commands/__snapshots__/help.test.ts.snap | 1 - src/commands/investigation/index.test.ts | 1 - src/commands/investigation/index.ts | 10 ----- src/domains/investigation/contracts.ts | 32 ---------------- src/domains/investigation/handle.ts | 39 +------------------- 7 files changed, 3 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index a37772096..06f115eeb 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,10 @@ Run any command with `--help` for its flags and options. `qawolf help ref +qawolf run get --run-id --flow-statuses failed qawolf attempt investigate qawolf attempt inspect --timeline --limit 20 qawolf attempt inspect --request diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index ff1352469..731481607 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -131,7 +131,6 @@ that `url`; never guess a route and never send a repository link in its place. | `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 inspect` | read | Inspect one kind of evidence for a finished attempt | | `qawolf attempt investigate` | read | Summarize the evidence for one finished attempt | -| `qawolf attempt list` | read | List finished attempt IDs for a run | | `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 | diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index fc3755e80..dd07f0351 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -160,7 +160,6 @@ Options: -h, --help display help for command Commands: - list [options] List finished attempt IDs for a run investigate Summarize the evidence for one finished attempt inspect [options] Inspect one kind of evidence for a finished attempt diff --git a/src/commands/investigation/index.test.ts b/src/commands/investigation/index.test.ts index 96c9d104c..3e3ba17a1 100644 --- a/src/commands/investigation/index.test.ts +++ b/src/commands/investigation/index.test.ts @@ -27,7 +27,6 @@ describe("attempt command registration", () => { const investigation = subcommand(program, "investigation"); expect(attempt.commands.map((command) => command.name())).toEqual([ - "list", "investigate", "inspect", ]); diff --git a/src/commands/investigation/index.ts b/src/commands/investigation/index.ts index ee4bde773..cda3abbb1 100644 --- a/src/commands/investigation/index.ts +++ b/src/commands/investigation/index.ts @@ -4,7 +4,6 @@ import { declareCommandKind } from "~/commands/commandKind.js"; import { withAuthContext } from "~/commands/context.js"; import { handleInvestigationInspect, - handleInvestigationList, handleInvestigationSummary, } from "~/domains/investigation/handle.js"; import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js"; @@ -32,15 +31,6 @@ export function registerAttemptCommand( .command("attempt") .description("Investigate a finished run attempt from recorded evidence"); - declareCommandKind(attempt.command("list"), "read") - .description("List finished attempt IDs for a run") - .requiredOption("--run-id ", "Run to discover attempts for") - .action((options: { runId: string }, command: Command) => - withAuthContext(signals, (ctx) => - handleInvestigationList(ctx, options.runId), - )(options, command), - ); - declareCommandKind(attempt.command("investigate "), "read") .description("Summarize the evidence for one finished attempt") .action((attemptId: string, _options: unknown, command: Command) => diff --git a/src/domains/investigation/contracts.ts b/src/domains/investigation/contracts.ts index 2a41c74d6..79f008b26 100644 --- a/src/domains/investigation/contracts.ts +++ b/src/domains/investigation/contracts.ts @@ -55,38 +55,6 @@ export const getAttemptArtifactsContract = { .loose(), }; -const discoveredAttempt = z - .object({ - attemptId: z.string(), - completedAt: z.string().optional(), - kind: z.enum(["automated", "manual"]), - startedAt: z.string().optional(), - status: attemptStatus, - }) - .loose(); - -export const discoverInvestigationsContract = { - input: z.object({ runId: z.string().min(1) }), - kind: "read" as const, - name: "run.get", - output: z - .object({ - flows: z.array( - z - .object({ - attempts: z.array(discoveredAttempt).optional(), - flowId: z.string(), - name: z.string(), - status: z.string(), - }) - .loose(), - ), - runId: z.string(), - status: z.string(), - }) - .loose(), -}; - export type AttemptArtifacts = z.infer< typeof getAttemptArtifactsContract.output >; diff --git a/src/domains/investigation/handle.ts b/src/domains/investigation/handle.ts index d7b9fa4c8..0b40d6d89 100644 --- a/src/domains/investigation/handle.ts +++ b/src/domains/investigation/handle.ts @@ -8,10 +8,7 @@ import type { CommandResult, } from "~/shell/commandContext.js"; -import { - discoverInvestigationsContract, - getAttemptArtifactsContract, -} from "./contracts.js"; +import { getAttemptArtifactsContract } from "./contracts.js"; import { downloadLogTail, downloadTrace } from "./download.js"; import { loadInvestigationParser } from "./lazyParser.js"; import type { @@ -262,37 +259,3 @@ export async function outputInvestigationInspection( ctx.ui.output(result, renderText(result)); return undefined; } - -export async function handleInvestigationList( - ctx: AuthCommandContext, - runId: string, -): Promise { - const result = await ctx.platformClient.callPublicApi( - discoverInvestigationsContract, - { runId }, - ); - if (!result.ok) return failureFields(result); - const investigations = result.value.flows.flatMap((flow) => - (flow.attempts ?? []).map((attempt, index, attempts) => ({ - attemptId: attempt.attemptId, - flowId: flow.flowId, - flowName: flow.name, - kind: attempt.kind, - ordinal: index + 1, - status: attempt.status, - totalAttempts: attempts.length, - })), - ); - ctx.ui.output( - { investigations, runId: result.value.runId, status: result.value.status }, - investigations.length - ? investigations - .map( - (item) => - `${item.attemptId}\t${item.status}\t${item.flowName}\t${item.ordinal}/${item.totalAttempts}`, - ) - .join("\n") - : "No finished attempts were found.", - ); - return undefined; -} From 17c313de0eff699a2938a814c8119d52230ee26d Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Fri, 2 Oct 2026 10:03:19 +0200 Subject: [PATCH 09/27] refactor(cli): read attempt artifacts through the published contract --- src/domains/investigation/contracts.ts | 60 ------------------- .../investigation/handle.artifacts.test.ts | 7 ++- src/domains/investigation/handle.ts | 21 +++++-- 3 files changed, 20 insertions(+), 68 deletions(-) delete mode 100644 src/domains/investigation/contracts.ts diff --git a/src/domains/investigation/contracts.ts b/src/domains/investigation/contracts.ts deleted file mode 100644 index 79f008b26..000000000 --- a/src/domains/investigation/contracts.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { z } from "zod"; - -const attemptStatus = z.enum([ - "passed", - "failed", - "canceled", - "aborted", - "skipped", -]); - -const attempt = z - .object({ - attemptId: z.string(), - completedAt: z.string().optional(), - createdAt: z.string(), - error: z.string().optional(), - kind: z.enum(["automated", "manual"]), - startedAt: z.string().optional(), - status: attemptStatus, - }) - .loose(); - -const attemptNeighbor = z - .object({ attemptId: z.string(), status: attemptStatus }) - .loose(); - -export const getAttemptArtifactsContract = { - input: z.object({ attemptId: z.string().min(1) }), - kind: "read" as const, - name: "run.getAttemptArtifacts", - output: z - .object({ - artifactStatus: z.enum(["signed", "not-captured", "signing-failed"]), - artifacts: z - .object({ - logsUrl: z.url().optional(), - traceUrl: z.url().optional(), - videoUrl: z.url().optional(), - }) - .loose(), - attempt, - flowId: z.string(), - flowStatus: z.enum(["queued", "running", "passed", "failed", "canceled"]), - retryContext: z - .object({ - currentOrdinal: z.number().int().positive(), - next: attemptNeighbor.optional(), - previous: attemptNeighbor.optional(), - total: z.number().int().positive(), - }) - .loose(), - runId: z.string(), - runStatus: z.enum(["queued", "running", "passed", "failed", "canceled"]), - }) - .loose(), -}; - -export type AttemptArtifacts = z.infer< - typeof getAttemptArtifactsContract.output ->; diff --git a/src/domains/investigation/handle.artifacts.test.ts b/src/domains/investigation/handle.artifacts.test.ts index 79d41cecc..18be64c99 100644 --- a/src/domains/investigation/handle.artifacts.test.ts +++ b/src/domains/investigation/handle.artifacts.test.ts @@ -1,7 +1,10 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; -import type { AttemptArtifacts } from "./contracts.js"; -import { loadInspectArtifact, loadSummaryArtifacts } from "./handle.js"; +import { + type AttemptArtifacts, + loadInspectArtifact, + loadSummaryArtifacts, +} from "./handle.js"; const originalFetch = globalThis.fetch; diff --git a/src/domains/investigation/handle.ts b/src/domains/investigation/handle.ts index 0b40d6d89..68a4aa408 100644 --- a/src/domains/investigation/handle.ts +++ b/src/domains/investigation/handle.ts @@ -2,13 +2,15 @@ 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 { getAttemptArtifactsContract } from "./contracts.js"; import { downloadLogTail, downloadTrace } from "./download.js"; import { loadInvestigationParser } from "./lazyParser.js"; import type { @@ -18,6 +20,10 @@ import type { InvestigationResult, } from "./types.js"; +export type AttemptArtifacts = z.infer< + typeof publicContractsV1.run.getAttemptArtifacts.output +>; + export type InspectOptions = { actionId?: string; attemptId: string; @@ -44,7 +50,7 @@ function artifactUnavailable(status: "not-captured" | "signing-failed") { } export async function loadSummaryArtifacts( - value: Awaited>, + value: AttemptArtifacts, ): Promise<{ logs: ArtifactInput; trace: ArtifactInput }> { if (value.artifactStatus !== "signed") { const unavailable = artifactUnavailable(value.artifactStatus); @@ -62,7 +68,7 @@ export async function loadSummaryArtifacts( } export async function loadInspectArtifact( - value: Awaited>, + value: AttemptArtifacts, type: InspectType, ): Promise<{ logs: ArtifactInput; trace: ArtifactInput }> { const inactive = artifactUnavailable("not-captured"); @@ -136,9 +142,12 @@ function renderText(result: Record): string { } async function fetchAttempt(ctx: AuthCommandContext, attemptId: string) { - return ctx.platformClient.callPublicApi(getAttemptArtifactsContract, { - attemptId, - }); + return ctx.platformClient.callPublicApi( + publicContractsV1.run.getAttemptArtifacts, + { + attemptId, + }, + ); } export async function handleInvestigationSummary( From 6600e7cae835768781189e1a9d74cb843f676c75 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Fri, 2 Oct 2026 10:10:07 +0200 Subject: [PATCH 10/27] feat(cli): take the attempt as --attempt-id like generated commands --- .changeset/quiet-wolves-investigate.md | 2 +- README.md | 14 ++--- src/commands/__snapshots__/help.test.ts.snap | 17 +++--- src/commands/investigation/index.test.ts | 42 +++++++++++++-- src/commands/investigation/index.ts | 52 +++++++++---------- .../investigation/inspectSelection.ts | 7 +-- 6 files changed, 81 insertions(+), 53 deletions(-) diff --git a/.changeset/quiet-wolves-investigate.md b/.changeset/quiet-wolves-investigate.md index ccbfcd4f6..65e81d55d 100644 --- a/.changeset/quiet-wolves-investigate.md +++ b/.changeset/quiet-wolves-investigate.md @@ -2,4 +2,4 @@ "@qawolf/cli": minor --- -Add `qawolf attempt` commands with lazily loaded, bounded trace and log parsing. +`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. diff --git a/README.md b/README.md index 06f115eeb..47a8dbe8c 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ Start with a run ID, pick an attempt ID from its failed flows, then narrow the e ```bash qawolf run get --run-id --flow-statuses failed -qawolf attempt investigate -qawolf attempt inspect --timeline --limit 20 -qawolf attempt inspect --request +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` to start console or execution-log output at a specific evidence record; the other filters are listed in `qawolf attempt inspect --help`. @@ -84,12 +84,12 @@ qawolf attempt inspect --request 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 --snapshot --format html -qawolf attempt inspect --snapshot --format html --output-file snapshot.html -qawolf attempt inspect --screenshot --output-file screenshot.jpeg +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 ``` -These commands require the platform's `public.run.getAttemptArtifacts` endpoint to be deployed. The endpoint authorizes every request and returns fresh signed artifact URLs. Parsing happens locally in a separate lazy bundle, so normal CLI startup does not load the trace parser or JSZip. Downloads, decompression, 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. +The trace and logs are downloaded from the signed links `qawolf run getAttemptArtifacts` 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 diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index dd07f0351..1c91c7262 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -157,32 +157,33 @@ exports[`--help output qawolf attempt 1`] = ` Investigate a finished run attempt from recorded evidence Options: - -h, --help display help for command + -h, --help display help for command Commands: - investigate Summarize the evidence for one finished attempt - inspect [options] Inspect one kind of evidence for a finished - attempt - help [command] display help for command + investigate [options] Summarize the evidence for one finished attempt + inspect [options] Inspect one kind of evidence for a finished attempt + help [command] display help for command " `; exports[`--help output qawolf attempt investigate 1`] = ` -"Usage: qawolf attempt investigate [options] +"Usage: qawolf attempt investigate [options] Summarize the evidence for one finished attempt Options: - -h, --help display help for command + --attempt-id Attempt to summarize + -h, --help display help for command " `; exports[`--help output qawolf attempt inspect 1`] = ` -"Usage: qawolf attempt inspect [options] +"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 diff --git a/src/commands/investigation/index.test.ts b/src/commands/investigation/index.test.ts index 3e3ba17a1..06df6f9a5 100644 --- a/src/commands/investigation/index.test.ts +++ b/src/commands/investigation/index.test.ts @@ -1,8 +1,10 @@ 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 { registerAttemptCommand } from "./index.js"; function subcommand(parent: Command, name: string): Command { @@ -35,14 +37,44 @@ describe("attempt command registration", () => { ); }); - it("requires one selector before entering authenticated command handling", () => { - const missing = attemptProgram().parseAsync(["attempt", "inspect", "a1"], { - from: "user", + it("shares the attempt group with commands generated from an attempt contract", () => { + const program = attemptProgram(); + registerPublicApiCommands(program, makeNoopSignals(), { + contracts: { + attempt: { + get: { + 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", "a1", "--timeline", "--network"], + ["attempt", "inspect", "--attempt-id", "a1", "--timeline", "--network"], { from: "user" }, ); expect(conflicting).rejects.toThrow("Choose exactly one evidence selector"); @@ -56,6 +88,7 @@ describe("attempt command registration", () => { const flags = inspect.options.map((option) => option.long); for (const selector of [ + "--attempt-id", "--action", "--timeline", "--network", @@ -67,7 +100,6 @@ describe("attempt command registration", () => { "--evidence-id", ]) expect(flags).toContain(selector); - expect(flags).not.toContain("--attempt-id"); expect(flags).not.toContain("--type"); }); }); diff --git a/src/commands/investigation/index.ts b/src/commands/investigation/index.ts index cda3abbb1..b2bab8842 100644 --- a/src/commands/investigation/index.ts +++ b/src/commands/investigation/index.ts @@ -31,16 +31,18 @@ export function registerAttemptCommand( .command("attempt") .description("Investigate a finished run attempt from recorded evidence"); - declareCommandKind(attempt.command("investigate "), "read") + declareCommandKind(attempt.command("investigate"), "read") .description("Summarize the evidence for one finished attempt") - .action((attemptId: string, _options: unknown, command: Command) => + .requiredOption("--attempt-id ", "Attempt to summarize") + .action((options: { attemptId: string }, command: Command) => withAuthContext(signals, (ctx) => - handleInvestigationSummary(ctx, attemptId), - )({}, command), + handleInvestigationSummary(ctx, options.attemptId), + )(options, command), ); - declareCommandKind(attempt.command("inspect "), "read") + 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") @@ -76,25 +78,23 @@ export function registerAttemptCommand( "--output-file ", "Write HTML or screenshot bytes without overwriting", ) - .action( - (attemptId: string, 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, attemptId }; - return withAuthContext(signals, (ctx) => - handleInvestigationInspect(ctx, normalized), - )(options, command); - }, - ); + .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/investigation/inspectSelection.ts b/src/commands/investigation/inspectSelection.ts index 71fc1c097..e6bc8cf66 100644 --- a/src/commands/investigation/inspectSelection.ts +++ b/src/commands/investigation/inspectSelection.ts @@ -2,12 +2,7 @@ import type { InspectOptions } from "~/domains/investigation/handle.js"; export type InspectCommandOptions = Omit< InspectOptions, - | "actionId" - | "attemptId" - | "requestId" - | "screenshotId" - | "snapshotId" - | "type" + "actionId" | "requestId" | "screenshotId" | "snapshotId" | "type" > & { action?: string; console?: boolean; From 3610df1cca32fbea977e656b48ab225ba9f1c20f Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:22:08 +0200 Subject: [PATCH 11/27] test(cli): give the synthetic attempt contract annotation justifications --- src/commands/investigation/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands/investigation/index.test.ts b/src/commands/investigation/index.test.ts index 06df6f9a5..e5af389e1 100644 --- a/src/commands/investigation/index.test.ts +++ b/src/commands/investigation/index.test.ts @@ -5,6 +5,7 @@ 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 { @@ -43,6 +44,7 @@ describe("attempt command registration", () => { contracts: { attempt: { get: { + annotationJustifications: standInAnnotationJustifications, annotations: { destructiveHint: false, openWorldHint: false, From b33138f3726ee641228556e04f6d4b723da00b5c Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:24:30 +0200 Subject: [PATCH 12/27] fix(cli): read attempt artifacts through attempt.get --- README.md | 2 +- bun.lock | 4 ++-- package.json | 2 +- skills/qawolf-cli/SKILL.md | 6 +++++- src/commands/__snapshots__/help.test.ts.snap | 5 +++++ src/commands/investigation/index.test.ts | 1 + src/domains/investigation/handle.ts | 11 ++++------- 7 files changed, 19 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 47a8dbe8c..ca0e26022 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ qawolf attempt inspect --attempt-id --snapshot --form qawolf attempt inspect --attempt-id --screenshot --output-file screenshot.jpeg ``` -The trace and logs are downloaded from the signed links `qawolf run getAttemptArtifacts` 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. +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 diff --git a/bun.lock b/bun.lock index 411f15caa..e7dad80b5 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@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", @@ -463,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=="], diff --git a/package.json b/package.json index 91ffb4b8a..1d1ce9c9a 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@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", diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index 731481607..2d90b177c 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -129,6 +129,7 @@ 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 | @@ -149,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. | @@ -178,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 1c91c7262..6fe271156 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -35,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 @@ -162,6 +163,10 @@ Options: 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 " `; diff --git a/src/commands/investigation/index.test.ts b/src/commands/investigation/index.test.ts index e5af389e1..5e51ea7a0 100644 --- a/src/commands/investigation/index.test.ts +++ b/src/commands/investigation/index.test.ts @@ -32,6 +32,7 @@ describe("attempt command registration", () => { expect(attempt.commands.map((command) => command.name())).toEqual([ "investigate", "inspect", + "get", ]); expect(investigation.commands.map((command) => command.name())).toContain( "get", diff --git a/src/domains/investigation/handle.ts b/src/domains/investigation/handle.ts index 68a4aa408..439030b0b 100644 --- a/src/domains/investigation/handle.ts +++ b/src/domains/investigation/handle.ts @@ -21,7 +21,7 @@ import type { } from "./types.js"; export type AttemptArtifacts = z.infer< - typeof publicContractsV1.run.getAttemptArtifacts.output + typeof publicContractsV1.attempt.get.output >; export type InspectOptions = { @@ -142,12 +142,9 @@ function renderText(result: Record): string { } async function fetchAttempt(ctx: AuthCommandContext, attemptId: string) { - return ctx.platformClient.callPublicApi( - publicContractsV1.run.getAttemptArtifacts, - { - attemptId, - }, - ); + return ctx.platformClient.callPublicApi(publicContractsV1.attempt.get, { + attemptId, + }); } export async function handleInvestigationSummary( From 19d60bdf2e2fdc4ea8658a0f90fd854f723ed91d Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:42:13 +0200 Subject: [PATCH 13/27] refactor(cli): move the investigation parser under domains/investigation --- .oxlintrc.json | 75 +++++++++++++++++++ AGENTS.md | 1 + knip.config.ts | 2 +- scripts/build.ts | 2 +- .../investigation/parser}/executionLogs.ts | 2 +- .../parser}/index.security.test.ts | 0 .../parser}/index.summary.test.ts | 0 .../investigation/parser}/index.test.ts | 0 .../investigation/parser}/index.ts | 2 +- .../parser}/projection/attemptEvidenceText.ts | 0 .../projection/chronologicallySortActions.ts | 0 .../projection/inspectAttemptActions.ts | 2 +- .../projection/inspectAttemptNetwork.ts | 2 +- .../parser}/projection/inspectAttemptPage.ts | 6 +- .../projection/inspectAttemptRecorded.ts | 2 +- .../inspectAttemptRequestHeaders.ts | 0 .../projection/inspectAttemptShared.ts | 9 ++- .../projection/summarizeAttemptNetwork.ts | 5 +- .../projection/summarizeAttemptTrace.ts | 4 +- .../projection/summarizeAttemptWaitingLogs.ts | 5 +- .../snapshot/snapshotHtmlProjection.test.ts | 0 .../snapshot/snapshotHtmlProjection.ts | 2 +- .../parser}/snapshot/snapshotProjection.ts | 2 +- .../parser}/snapshot/snapshotVocabulary.ts | 0 .../parser}/trace/playwrightTrace.ts | 0 .../parser}/trace/playwrightTraceActions.ts | 0 .../parser}/trace/playwrightTraceEvent.ts | 0 .../parser}/trace/playwrightTraceMedia.ts | 0 .../parser}/trace/playwrightTraceNetwork.ts | 0 .../trace/playwrightTraceNetworkStreams.ts | 0 .../parser}/trace/playwrightTraceParse.ts | 0 .../trace/playwrightTraceParseEntry.ts | 0 .../trace/playwrightTraceSnapshotSerialize.ts | 0 .../trace/playwrightTraceSnapshotWriter.ts | 0 .../parser}/trace/playwrightTraceSnapshots.ts | 0 .../parser}/trace/playwrightTraceTypes.ts | 0 .../parser}/trace/playwrightTraceUtils.ts | 0 .../investigation/parser}/trace/runLogs.ts | 0 .../parser}/trace/runLogsTypes.ts | 0 39 files changed, 104 insertions(+), 19 deletions(-) rename src/{investigationParser => domains/investigation/parser}/executionLogs.ts (98%) rename src/{investigationParser => domains/investigation/parser}/index.security.test.ts (100%) rename src/{investigationParser => domains/investigation/parser}/index.summary.test.ts (100%) rename src/{investigationParser => domains/investigation/parser}/index.test.ts (100%) rename src/{investigationParser => domains/investigation/parser}/index.ts (99%) rename src/{investigationParser => domains/investigation/parser}/projection/attemptEvidenceText.ts (100%) rename src/{investigationParser => domains/investigation/parser}/projection/chronologicallySortActions.ts (100%) rename src/{investigationParser => domains/investigation/parser}/projection/inspectAttemptActions.ts (98%) rename src/{investigationParser => domains/investigation/parser}/projection/inspectAttemptNetwork.ts (98%) rename src/{investigationParser => domains/investigation/parser}/projection/inspectAttemptPage.ts (92%) rename src/{investigationParser => domains/investigation/parser}/projection/inspectAttemptRecorded.ts (98%) rename src/{investigationParser => domains/investigation/parser}/projection/inspectAttemptRequestHeaders.ts (100%) rename src/{investigationParser => domains/investigation/parser}/projection/inspectAttemptShared.ts (89%) rename src/{investigationParser => domains/investigation/parser}/projection/summarizeAttemptNetwork.ts (95%) rename src/{investigationParser => domains/investigation/parser}/projection/summarizeAttemptTrace.ts (98%) rename src/{investigationParser => domains/investigation/parser}/projection/summarizeAttemptWaitingLogs.ts (87%) rename src/{investigationParser => domains/investigation/parser}/snapshot/snapshotHtmlProjection.test.ts (100%) rename src/{investigationParser => domains/investigation/parser}/snapshot/snapshotHtmlProjection.ts (98%) rename src/{investigationParser => domains/investigation/parser}/snapshot/snapshotProjection.ts (97%) rename src/{investigationParser => domains/investigation/parser}/snapshot/snapshotVocabulary.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTrace.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceActions.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceEvent.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceMedia.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceNetwork.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceNetworkStreams.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceParse.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceParseEntry.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceSnapshotSerialize.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceSnapshotWriter.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceSnapshots.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceTypes.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/playwrightTraceUtils.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/runLogs.ts (100%) rename src/{investigationParser => domains/investigation/parser}/trace/runLogsTypes.ts (100%) 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..c0fda0366 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,7 @@ 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 diff --git a/knip.config.ts b/knip.config.ts index 5977a6db1..e8225ab98 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -4,7 +4,7 @@ const config: KnipConfig = { entry: [ "*.config.ts", "src/runnerSdk/index.ts", - "src/investigationParser/index.ts", + "src/domains/investigation/parser/index.ts", "src/**/*.test.ts", "src/**/*.mock.ts", "src/**/*.fixtures.ts", diff --git a/scripts/build.ts b/scripts/build.ts index c69db4f09..cbb0bafc6 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -50,7 +50,7 @@ const bundles = [ buildArgs("./src/runnerSdk/index.ts", "runner-sdk"), [ "build", - "./src/investigationParser/index.ts", + "./src/domains/investigation/parser/index.ts", "--outdir", "dist", "--entry-naming=investigation-parser.[ext]", diff --git a/src/investigationParser/executionLogs.ts b/src/domains/investigation/parser/executionLogs.ts similarity index 98% rename from src/investigationParser/executionLogs.ts rename to src/domains/investigation/parser/executionLogs.ts index d011f3439..65b860629 100644 --- a/src/investigationParser/executionLogs.ts +++ b/src/domains/investigation/parser/executionLogs.ts @@ -1,7 +1,7 @@ import type { ArtifactInput, InvestigationRequest, -} from "../domains/investigation/types.js"; +} from "~/domains/investigation/types.js"; import { redactAttemptEvidence } from "./projection/attemptEvidenceText.js"; import { parseRunLogs } from "./trace/runLogs.js"; diff --git a/src/investigationParser/index.security.test.ts b/src/domains/investigation/parser/index.security.test.ts similarity index 100% rename from src/investigationParser/index.security.test.ts rename to src/domains/investigation/parser/index.security.test.ts diff --git a/src/investigationParser/index.summary.test.ts b/src/domains/investigation/parser/index.summary.test.ts similarity index 100% rename from src/investigationParser/index.summary.test.ts rename to src/domains/investigation/parser/index.summary.test.ts diff --git a/src/investigationParser/index.test.ts b/src/domains/investigation/parser/index.test.ts similarity index 100% rename from src/investigationParser/index.test.ts rename to src/domains/investigation/parser/index.test.ts diff --git a/src/investigationParser/index.ts b/src/domains/investigation/parser/index.ts similarity index 99% rename from src/investigationParser/index.ts rename to src/domains/investigation/parser/index.ts index e235112b9..b376cda1d 100644 --- a/src/investigationParser/index.ts +++ b/src/domains/investigation/parser/index.ts @@ -3,7 +3,7 @@ import type { InvestigationRequest, InvestigationResult, -} from "../domains/investigation/types.js"; +} from "~/domains/investigation/types.js"; import { inspectExecutionLogs, summarizeExecutionLogs, diff --git a/src/investigationParser/projection/attemptEvidenceText.ts b/src/domains/investigation/parser/projection/attemptEvidenceText.ts similarity index 100% rename from src/investigationParser/projection/attemptEvidenceText.ts rename to src/domains/investigation/parser/projection/attemptEvidenceText.ts diff --git a/src/investigationParser/projection/chronologicallySortActions.ts b/src/domains/investigation/parser/projection/chronologicallySortActions.ts similarity index 100% rename from src/investigationParser/projection/chronologicallySortActions.ts rename to src/domains/investigation/parser/projection/chronologicallySortActions.ts diff --git a/src/investigationParser/projection/inspectAttemptActions.ts b/src/domains/investigation/parser/projection/inspectAttemptActions.ts similarity index 98% rename from src/investigationParser/projection/inspectAttemptActions.ts rename to src/domains/investigation/parser/projection/inspectAttemptActions.ts index a69b682b6..a1801e4b2 100644 --- a/src/investigationParser/projection/inspectAttemptActions.ts +++ b/src/domains/investigation/parser/projection/inspectAttemptActions.ts @@ -4,7 +4,7 @@ import { listTraceActions, listTraceNetwork, listTraceSnapshots, -} from "../trace/playwrightTrace.js"; +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; import { chronologicallySortActions } from "./chronologicallySortActions.js"; import { diff --git a/src/investigationParser/projection/inspectAttemptNetwork.ts b/src/domains/investigation/parser/projection/inspectAttemptNetwork.ts similarity index 98% rename from src/investigationParser/projection/inspectAttemptNetwork.ts rename to src/domains/investigation/parser/projection/inspectAttemptNetwork.ts index f1904cf62..476c40b77 100644 --- a/src/investigationParser/projection/inspectAttemptNetwork.ts +++ b/src/domains/investigation/parser/projection/inspectAttemptNetwork.ts @@ -1,7 +1,7 @@ import { type PlaywrightTrace, listTraceNetwork, -} from "../trace/playwrightTrace.js"; +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; import type { InspectionCommon } from "./inspectAttemptShared.js"; diff --git a/src/investigationParser/projection/inspectAttemptPage.ts b/src/domains/investigation/parser/projection/inspectAttemptPage.ts similarity index 92% rename from src/investigationParser/projection/inspectAttemptPage.ts rename to src/domains/investigation/parser/projection/inspectAttemptPage.ts index 18bfd0b4a..f2f70b38e 100644 --- a/src/investigationParser/projection/inspectAttemptPage.ts +++ b/src/domains/investigation/parser/projection/inspectAttemptPage.ts @@ -2,19 +2,19 @@ import { type PlaywrightTrace, nearestScreenshotForSnapshot, readTraceSnapshot, -} from "../trace/playwrightTrace.js"; +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; import { boundAttemptPageText, redactAttemptEvidence, } from "./attemptEvidenceText.js"; import type { InspectionCommon } from "./inspectAttemptShared.js"; -import { serializeSnapshotToHtml } from "../snapshot/snapshotHtmlProjection.js"; +import { serializeSnapshotToHtml } from "~/domains/investigation/parser/snapshot/snapshotHtmlProjection.js"; import { findSmallestTextRegions, projectToText, resolveSnapshot, -} from "../snapshot/snapshotProjection.js"; +} from "~/domains/investigation/parser/snapshot/snapshotProjection.js"; const maxSnapshotReconstructionBytes = 256 * 1024; diff --git a/src/investigationParser/projection/inspectAttemptRecorded.ts b/src/domains/investigation/parser/projection/inspectAttemptRecorded.ts similarity index 98% rename from src/investigationParser/projection/inspectAttemptRecorded.ts rename to src/domains/investigation/parser/projection/inspectAttemptRecorded.ts index 5408b640e..d310b5084 100644 --- a/src/investigationParser/projection/inspectAttemptRecorded.ts +++ b/src/domains/investigation/parser/projection/inspectAttemptRecorded.ts @@ -6,7 +6,7 @@ import { listTraceNetwork, readTraceResponseBody, readTraceScreenshot, -} from "../trace/playwrightTrace.js"; +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; import { redactInspectionHeaders } from "./inspectAttemptRequestHeaders.js"; diff --git a/src/investigationParser/projection/inspectAttemptRequestHeaders.ts b/src/domains/investigation/parser/projection/inspectAttemptRequestHeaders.ts similarity index 100% rename from src/investigationParser/projection/inspectAttemptRequestHeaders.ts rename to src/domains/investigation/parser/projection/inspectAttemptRequestHeaders.ts diff --git a/src/investigationParser/projection/inspectAttemptShared.ts b/src/domains/investigation/parser/projection/inspectAttemptShared.ts similarity index 89% rename from src/investigationParser/projection/inspectAttemptShared.ts rename to src/domains/investigation/parser/projection/inspectAttemptShared.ts index d58e905bd..fba5f6820 100644 --- a/src/investigationParser/projection/inspectAttemptShared.ts +++ b/src/domains/investigation/parser/projection/inspectAttemptShared.ts @@ -1,8 +1,11 @@ // oxlint-disable eslint/max-lines -- Keep shared typed fallback and action shaping aligned with the platform implementation. -import type { listTraceActionLogs } from "../trace/playwrightTrace.js"; -import type { PlaywrightTrace, TraceAction } from "../trace/playwrightTrace.js"; -import { readTraceActionStack } from "../trace/playwrightTrace.js"; +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"; diff --git a/src/investigationParser/projection/summarizeAttemptNetwork.ts b/src/domains/investigation/parser/projection/summarizeAttemptNetwork.ts similarity index 95% rename from src/investigationParser/projection/summarizeAttemptNetwork.ts rename to src/domains/investigation/parser/projection/summarizeAttemptNetwork.ts index 6ea6514b9..170bf0f5d 100644 --- a/src/investigationParser/projection/summarizeAttemptNetwork.ts +++ b/src/domains/investigation/parser/projection/summarizeAttemptNetwork.ts @@ -1,4 +1,7 @@ -import type { Bounded, TraceNetworkEntry } from "../trace/playwrightTrace.js"; +import type { + Bounded, + TraceNetworkEntry, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; diff --git a/src/investigationParser/projection/summarizeAttemptTrace.ts b/src/domains/investigation/parser/projection/summarizeAttemptTrace.ts similarity index 98% rename from src/investigationParser/projection/summarizeAttemptTrace.ts rename to src/domains/investigation/parser/projection/summarizeAttemptTrace.ts index a79fb3b63..c8f19f200 100644 --- a/src/investigationParser/projection/summarizeAttemptTrace.ts +++ b/src/domains/investigation/parser/projection/summarizeAttemptTrace.ts @@ -11,7 +11,7 @@ import { readFailureStack, readTraceActionStack, readTraceSnapshot, -} from "../trace/playwrightTrace.js"; +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; import { boundAttemptPageText, @@ -22,7 +22,7 @@ import { chronologicallySortActions } from "./chronologicallySortActions.js"; import { projectToText, resolveSnapshot, -} from "../snapshot/snapshotProjection.js"; +} from "~/domains/investigation/parser/snapshot/snapshotProjection.js"; import { summarizeAttemptNetworkErrors } from "./summarizeAttemptNetwork.js"; import { summarizeAttemptWaitingLogs } from "./summarizeAttemptWaitingLogs.js"; diff --git a/src/investigationParser/projection/summarizeAttemptWaitingLogs.ts b/src/domains/investigation/parser/projection/summarizeAttemptWaitingLogs.ts similarity index 87% rename from src/investigationParser/projection/summarizeAttemptWaitingLogs.ts rename to src/domains/investigation/parser/projection/summarizeAttemptWaitingLogs.ts index e3a28c32d..ebe7ac2e4 100644 --- a/src/investigationParser/projection/summarizeAttemptWaitingLogs.ts +++ b/src/domains/investigation/parser/projection/summarizeAttemptWaitingLogs.ts @@ -1,4 +1,7 @@ -import type { Bounded, TraceActionLog } from "../trace/playwrightTrace.js"; +import type { + Bounded, + TraceActionLog, +} from "~/domains/investigation/parser/trace/playwrightTrace.js"; import { redactAndBoundAttemptEvidenceResult } from "./attemptEvidenceText.js"; diff --git a/src/investigationParser/snapshot/snapshotHtmlProjection.test.ts b/src/domains/investigation/parser/snapshot/snapshotHtmlProjection.test.ts similarity index 100% rename from src/investigationParser/snapshot/snapshotHtmlProjection.test.ts rename to src/domains/investigation/parser/snapshot/snapshotHtmlProjection.test.ts diff --git a/src/investigationParser/snapshot/snapshotHtmlProjection.ts b/src/domains/investigation/parser/snapshot/snapshotHtmlProjection.ts similarity index 98% rename from src/investigationParser/snapshot/snapshotHtmlProjection.ts rename to src/domains/investigation/parser/snapshot/snapshotHtmlProjection.ts index 68782ed8b..ac8f1e6d1 100644 --- a/src/investigationParser/snapshot/snapshotHtmlProjection.ts +++ b/src/domains/investigation/parser/snapshot/snapshotHtmlProjection.ts @@ -1,6 +1,6 @@ // oxlint-disable eslint/max-lines -- Keep the sanitizer and its byte-budget writer aligned with the platform implementation. -import { redactAttemptEvidence } from "~/investigationParser/projection/attemptEvidenceText.js"; +import { redactAttemptEvidence } from "~/domains/investigation/parser/projection/attemptEvidenceText.js"; import type { Element, Node } from "./snapshotProjection.js"; diff --git a/src/investigationParser/snapshot/snapshotProjection.ts b/src/domains/investigation/parser/snapshot/snapshotProjection.ts similarity index 97% rename from src/investigationParser/snapshot/snapshotProjection.ts rename to src/domains/investigation/parser/snapshot/snapshotProjection.ts index 54c8c76ab..e02905879 100644 --- a/src/investigationParser/snapshot/snapshotProjection.ts +++ b/src/domains/investigation/parser/snapshot/snapshotProjection.ts @@ -1,6 +1,6 @@ // oxlint-disable eslint/max-lines -- Keep snapshot resolution and text projection aligned with the platform implementation. -import { reconstructSnapshotHtml } from "../trace/playwrightTraceSnapshots.js"; +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]] diff --git a/src/investigationParser/snapshot/snapshotVocabulary.ts b/src/domains/investigation/parser/snapshot/snapshotVocabulary.ts similarity index 100% rename from src/investigationParser/snapshot/snapshotVocabulary.ts rename to src/domains/investigation/parser/snapshot/snapshotVocabulary.ts diff --git a/src/investigationParser/trace/playwrightTrace.ts b/src/domains/investigation/parser/trace/playwrightTrace.ts similarity index 100% rename from src/investigationParser/trace/playwrightTrace.ts rename to src/domains/investigation/parser/trace/playwrightTrace.ts diff --git a/src/investigationParser/trace/playwrightTraceActions.ts b/src/domains/investigation/parser/trace/playwrightTraceActions.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceActions.ts rename to src/domains/investigation/parser/trace/playwrightTraceActions.ts diff --git a/src/investigationParser/trace/playwrightTraceEvent.ts b/src/domains/investigation/parser/trace/playwrightTraceEvent.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceEvent.ts rename to src/domains/investigation/parser/trace/playwrightTraceEvent.ts diff --git a/src/investigationParser/trace/playwrightTraceMedia.ts b/src/domains/investigation/parser/trace/playwrightTraceMedia.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceMedia.ts rename to src/domains/investigation/parser/trace/playwrightTraceMedia.ts diff --git a/src/investigationParser/trace/playwrightTraceNetwork.ts b/src/domains/investigation/parser/trace/playwrightTraceNetwork.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceNetwork.ts rename to src/domains/investigation/parser/trace/playwrightTraceNetwork.ts diff --git a/src/investigationParser/trace/playwrightTraceNetworkStreams.ts b/src/domains/investigation/parser/trace/playwrightTraceNetworkStreams.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceNetworkStreams.ts rename to src/domains/investigation/parser/trace/playwrightTraceNetworkStreams.ts diff --git a/src/investigationParser/trace/playwrightTraceParse.ts b/src/domains/investigation/parser/trace/playwrightTraceParse.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceParse.ts rename to src/domains/investigation/parser/trace/playwrightTraceParse.ts diff --git a/src/investigationParser/trace/playwrightTraceParseEntry.ts b/src/domains/investigation/parser/trace/playwrightTraceParseEntry.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceParseEntry.ts rename to src/domains/investigation/parser/trace/playwrightTraceParseEntry.ts diff --git a/src/investigationParser/trace/playwrightTraceSnapshotSerialize.ts b/src/domains/investigation/parser/trace/playwrightTraceSnapshotSerialize.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceSnapshotSerialize.ts rename to src/domains/investigation/parser/trace/playwrightTraceSnapshotSerialize.ts diff --git a/src/investigationParser/trace/playwrightTraceSnapshotWriter.ts b/src/domains/investigation/parser/trace/playwrightTraceSnapshotWriter.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceSnapshotWriter.ts rename to src/domains/investigation/parser/trace/playwrightTraceSnapshotWriter.ts diff --git a/src/investigationParser/trace/playwrightTraceSnapshots.ts b/src/domains/investigation/parser/trace/playwrightTraceSnapshots.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceSnapshots.ts rename to src/domains/investigation/parser/trace/playwrightTraceSnapshots.ts diff --git a/src/investigationParser/trace/playwrightTraceTypes.ts b/src/domains/investigation/parser/trace/playwrightTraceTypes.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceTypes.ts rename to src/domains/investigation/parser/trace/playwrightTraceTypes.ts diff --git a/src/investigationParser/trace/playwrightTraceUtils.ts b/src/domains/investigation/parser/trace/playwrightTraceUtils.ts similarity index 100% rename from src/investigationParser/trace/playwrightTraceUtils.ts rename to src/domains/investigation/parser/trace/playwrightTraceUtils.ts diff --git a/src/investigationParser/trace/runLogs.ts b/src/domains/investigation/parser/trace/runLogs.ts similarity index 100% rename from src/investigationParser/trace/runLogs.ts rename to src/domains/investigation/parser/trace/runLogs.ts diff --git a/src/investigationParser/trace/runLogsTypes.ts b/src/domains/investigation/parser/trace/runLogsTypes.ts similarity index 100% rename from src/investigationParser/trace/runLogsTypes.ts rename to src/domains/investigation/parser/trace/runLogsTypes.ts From 97797bc2b9e11264fd85d1b1043f5c0f44cca22f Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:42:32 +0200 Subject: [PATCH 14/27] refactor(cli): rename the investigation commands dir to attempt --- AGENTS.md | 1 + src/commands/{investigation => attempt}/index.test.ts | 0 src/commands/{investigation => attempt}/index.ts | 0 src/commands/{investigation => attempt}/inspectSelection.ts | 0 src/commands/program.ts | 2 +- 5 files changed, 2 insertions(+), 1 deletion(-) rename src/commands/{investigation => attempt}/index.test.ts (100%) rename src/commands/{investigation => attempt}/index.ts (100%) rename src/commands/{investigation => attempt}/inspectSelection.ts (100%) diff --git a/AGENTS.md b/AGENTS.md index c0fda0366..3f665756f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ src/ ├── 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/src/commands/investigation/index.test.ts b/src/commands/attempt/index.test.ts similarity index 100% rename from src/commands/investigation/index.test.ts rename to src/commands/attempt/index.test.ts diff --git a/src/commands/investigation/index.ts b/src/commands/attempt/index.ts similarity index 100% rename from src/commands/investigation/index.ts rename to src/commands/attempt/index.ts diff --git a/src/commands/investigation/inspectSelection.ts b/src/commands/attempt/inspectSelection.ts similarity index 100% rename from src/commands/investigation/inspectSelection.ts rename to src/commands/attempt/inspectSelection.ts diff --git a/src/commands/program.ts b/src/commands/program.ts index ffd21647f..c5f9f8f6f 100644 --- a/src/commands/program.ts +++ b/src/commands/program.ts @@ -8,7 +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 "./investigation/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"; From caa929c0fa9e6b82d640658b2326328040924545 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:43:17 +0200 Subject: [PATCH 15/27] fix(cli): hand the binary its parser path without touching worker setup --- scripts/buildBinary.ts | 7 ++++--- src/domains/investigation/lazyParser.ts | 3 ++- src/shell/embeddedWorkerCli.ts | 12 ++---------- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/scripts/buildBinary.ts b/scripts/buildBinary.ts index 20025c07c..504f69e9d 100644 --- a/scripts/buildBinary.ts +++ b/scripts/buildBinary.ts @@ -19,7 +19,9 @@ 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, @@ -28,8 +30,7 @@ writeFileSync( 'import investigationParserAsset from "./investigation-parser.js" with { type: "file" };', "", "process.env.QAWOLF_EMBEDDED_CLI_PATH = cliAsset;", - "process.env.QAWOLF_EMBEDDED_INVESTIGATION_PARSER_PATH = investigationParserAsset;", - "process.env.QAWOLF_INVESTIGATION_PARSER_PATH = investigationParserAsset;", + "globalThis.qawolfInvestigationParserPath = investigationParserAsset;", "", 'await import("../src/main.ts");', "", diff --git a/src/domains/investigation/lazyParser.ts b/src/domains/investigation/lazyParser.ts index 6a9ec5de9..835b01ee2 100644 --- a/src/domains/investigation/lazyParser.ts +++ b/src/domains/investigation/lazyParser.ts @@ -12,7 +12,8 @@ export function loadInvestigationParser(): Promise { } function parserSpecifier(): string { - const embedded = process.env["QAWOLF_INVESTIGATION_PARSER_PATH"]; + const embedded = (globalThis as { qawolfInvestigationParserPath?: string }) + .qawolfInvestigationParserPath; if (embedded) return embedded; const sourceModule = import.meta.url.includes("/src/domains/investigation/"); return new URL( diff --git a/src/shell/embeddedWorkerCli.ts b/src/shell/embeddedWorkerCli.ts index 88ad5fd3f..142cf7537 100644 --- a/src/shell/embeddedWorkerCli.ts +++ b/src/shell/embeddedWorkerCli.ts @@ -10,8 +10,6 @@ import { getDataDir } from "~/core/paths.js"; * executes the entry script directly. */ const embeddedCliPathEnv = "QAWOLF_EMBEDDED_CLI_PATH"; -const embeddedInvestigationParserPathEnv = - "QAWOLF_EMBEDDED_INVESTIGATION_PARSER_PATH"; /** * Extracts the embedded cli.js bundle to a real on-disk file so a BUN_BE_BUN @@ -26,14 +24,8 @@ export function extractEmbeddedWorkerCli(): string | undefined { if (assetPath === undefined || assetPath === "") return undefined; const dir = join(getDataDir(), "worker"); const dest = join(dir, basename(assetPath)); + if (existsSync(dest)) return dest; mkdirSync(dir, { recursive: true }); - if (!existsSync(dest)) writeFileSync(dest, readFileSync(assetPath)); - const parserAssetPath = process.env[embeddedInvestigationParserPathEnv]; - if (parserAssetPath) { - const parserDest = join(dir, basename(parserAssetPath)); - if (!existsSync(parserDest)) - writeFileSync(parserDest, readFileSync(parserAssetPath)); - process.env["QAWOLF_INVESTIGATION_PARSER_PATH"] = parserDest; - } + writeFileSync(dest, readFileSync(assetPath)); return dest; } From 16b69312b44ffe5d522b05fdc2dbe483375327aa Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:43:37 +0200 Subject: [PATCH 16/27] fix(cli): load the investigation parser from source when running unbuilt --- src/domains/investigation/lazyParser.test.ts | 20 ++++++++++++++++++++ src/domains/investigation/lazyParser.ts | 4 +--- 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 src/domains/investigation/lazyParser.test.ts 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 index 835b01ee2..a89a51a12 100644 --- a/src/domains/investigation/lazyParser.ts +++ b/src/domains/investigation/lazyParser.ts @@ -17,9 +17,7 @@ function parserSpecifier(): string { if (embedded) return embedded; const sourceModule = import.meta.url.includes("/src/domains/investigation/"); return new URL( - sourceModule - ? "../../../dist/investigation-parser.js" - : "./investigation-parser.js", + sourceModule ? "./parser/index.ts" : "./investigation-parser.js", import.meta.url, ).href; } From a7cde29aeb77bcb7463be6bb71aa48fd39adf31b Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:45:05 +0200 Subject: [PATCH 17/27] fix(cli): redact secrets by field-name pattern and bearer tokens --- .../projection/attemptEvidenceText.test.ts | 39 +++++++++++ .../parser/projection/attemptEvidenceText.ts | 68 ++++++++----------- .../inspectAttemptRequestHeaders.test.ts | 20 ++++++ 3 files changed, 86 insertions(+), 41 deletions(-) create mode 100644 src/domains/investigation/parser/projection/attemptEvidenceText.test.ts create mode 100644 src/domains/investigation/parser/projection/inspectAttemptRequestHeaders.test.ts 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 index 4c6e1706d..9e379b705 100644 --- a/src/domains/investigation/parser/projection/attemptEvidenceText.ts +++ b/src/domains/investigation/parser/projection/attemptEvidenceText.ts @@ -1,51 +1,35 @@ // oxlint-disable eslint/max-lines -- Keep the conventional redaction passes and UTF-8 bounding logic aligned with the platform implementation. const redacted = "[REDACTED]"; -const sensitiveFieldNames = [ - "accessToken", - "access_token", - "access-token", - "refreshToken", - "refresh_token", - "refresh-token", - "api_key", - "api-key", - "apiKey", - "apikey", - "set-cookie", - "authorization", - "cookie", - "password", - "token", - "value", -]; -const sensitiveFieldPattern = sensitiveFieldNames.join("|"); -const signedUrlFieldPattern = [ - "accessToken", - "access_token", - "access-token", - "refreshToken", - "refresh_token", - "refresh-token", - "api_key", - "api-key", - "apiKey", - "apikey", - "authorization", - "password", +// 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", - "x-amz-signature", - "x-amz-credential", - "x-amz-security-token", - "x-goog-signature", - "x-goog-credential", - "awsaccesskeyid", - "googleaccessid", - "key-pair-id", "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 @@ -145,7 +129,9 @@ function redactCallArguments(text: string): string { } export function redactAttemptEvidence(text: string): string { - const jwtRedacted = text.replace(jwtPattern, redacted); + const jwtRedacted = text + .replace(jwtPattern, redacted) + .replace(bearerPattern, `$1${redacted}`); const userInfoRedacted = jwtRedacted.replace( /((?:https?:)?\/\/)[^/\s?#]*@/gi, `$1${redacted}@`, 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]", + }); + }); +}); From 0aadfcb187637642ad012ddff736cc9ec2764a74 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:45:55 +0200 Subject: [PATCH 18/27] refactor(cli): drop unreachable investigation parser options --- .../investigation/parser/index.test.ts | 17 ++++++++------ src/domains/investigation/parser/index.ts | 3 --- .../projection/inspectAttemptActions.ts | 21 ++++------------- .../parser/projection/inspectAttemptPage.ts | 23 ------------------- .../parser/snapshot/snapshotProjection.ts | 16 ------------- 5 files changed, 14 insertions(+), 66 deletions(-) diff --git a/src/domains/investigation/parser/index.test.ts b/src/domains/investigation/parser/index.test.ts index c56d0bda6..ad4ef3d37 100644 --- a/src/domains/investigation/parser/index.test.ts +++ b/src/domains/investigation/parser/index.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "bun:test"; import JSZip from "jszip"; -import { investigate, redactEvidence } from "./index.js"; +import { investigate } from "./index.js"; +import { redactAttemptEvidence } from "./projection/attemptEvidenceText.js"; describe("investigation parser", () => { it("redacts conventional secret fields and signed URLs", () => { - const value = redactEvidence( + 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"); @@ -14,10 +15,10 @@ describe("investigation parser", () => { }); it("redacts escaped and unterminated credential values", () => { - const escaped = redactEvidence( + const escaped = redactAttemptEvidence( String.raw`payload={\"token\":\"synthetic-secret\"}`, ); - const unterminated = redactEvidence( + const unterminated = redactAttemptEvidence( 'payload={"password":"synthetic-secret[TRUNCATED]', ); expect(escaped).not.toContain("synthetic-secret"); @@ -29,7 +30,9 @@ describe("investigation parser", () => { it("redacts a JWT that no field name points at", () => { const jwt = "eyJhbGciOiJSUzI1NiJ9.eyJzaWQiOiJzeW50aGV0aWMifQ.c3ludGhldGljLXNpZw"; - const value = redactEvidence(`0:["$","div"]\n2:T4a3,${jwt}\n3:"visible"`); + const value = redactAttemptEvidence( + `0:["$","div"]\n2:T4a3,${jwt}\n3:"visible"`, + ); expect(value).not.toContain(jwt); expect(value).toContain("2:T4a3,[REDACTED]"); @@ -37,7 +40,7 @@ describe("investigation parser", () => { }); it("redacts a JWT cut off before its signature", () => { - const value = redactEvidence( + const value = redactAttemptEvidence( "session eyJhbGciOiJSUzI1NiJ9.eyJzaWQiOiJzeW50aGV0aWMifQ", ); @@ -88,7 +91,7 @@ describe("investigation parser", () => { secret: "later-secret", }, ])("redacts structured credential evidence", ({ evidence, safe, secret }) => { - const redacted = redactEvidence(evidence); + const redacted = redactAttemptEvidence(evidence); expect(redacted).toContain("[REDACTED]"); expect(redacted).not.toContain(secret); diff --git a/src/domains/investigation/parser/index.ts b/src/domains/investigation/parser/index.ts index b376cda1d..ca54208cc 100644 --- a/src/domains/investigation/parser/index.ts +++ b/src/domains/investigation/parser/index.ts @@ -100,7 +100,6 @@ async function inspect( case "timeline": return inspectActionTimeline(parsed.trace, common, { limit: target.limit, - ...(target.method !== undefined && { method: target.method }), type: target.type, }); case "network": @@ -180,5 +179,3 @@ export async function investigate( ): Promise { return request.mode === "summary" ? summarize(request) : inspect(request); } - -export { redactAttemptEvidence as redactEvidence } from "./projection/attemptEvidenceText.js"; diff --git a/src/domains/investigation/parser/projection/inspectAttemptActions.ts b/src/domains/investigation/parser/projection/inspectAttemptActions.ts index a1801e4b2..0e2a31280 100644 --- a/src/domains/investigation/parser/projection/inspectAttemptActions.ts +++ b/src/domains/investigation/parser/projection/inspectAttemptActions.ts @@ -16,24 +16,12 @@ import { export function inspectActionTimeline( trace: PlaywrightTrace, common: InspectionCommon, - target: { beforeActionId?: string; limit: number; type: "timeline" }, + target: { limit: number; type: "timeline" }, ) { const listed = listTraceActions(trace, { limit: Number.MAX_SAFE_INTEGER }); const actions = chronologicallySortActions(listed.items); - const boundary = target.beforeActionId - ? actions.findIndex((item) => item.id === target.beforeActionId) - : actions.length; - if (boundary === -1) { - return { - ...common, - actions: [], - coverage: { ...listed.coverage, returned: 0 }, - status: "not-found" as const, - type: target.type, - }; - } - const start = Math.max(0, boundary - target.limit); - const selected = actions.slice(start, boundary); + 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, @@ -46,9 +34,8 @@ export function inspectActionTimeline( coverage: { ...listed.coverage, returned: selected.length, - truncated: start > 0 || boundary < actions.length, + truncated: start > 0, }, - ...(start > 0 && selected[0] && { nextBeforeActionId: 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 index f2f70b38e..ef2a8ae06 100644 --- a/src/domains/investigation/parser/projection/inspectAttemptPage.ts +++ b/src/domains/investigation/parser/projection/inspectAttemptPage.ts @@ -11,7 +11,6 @@ import { import type { InspectionCommon } from "./inspectAttemptShared.js"; import { serializeSnapshotToHtml } from "~/domains/investigation/parser/snapshot/snapshotHtmlProjection.js"; import { - findSmallestTextRegions, projectToText, resolveSnapshot, } from "~/domains/investigation/parser/snapshot/snapshotProjection.js"; @@ -23,7 +22,6 @@ export function inspectSnapshot( common: InspectionCommon, target: { format?: "html" | "text"; - regionText?: string; snapshotId: string; type: "snapshot"; }, @@ -34,7 +32,6 @@ export function inspectSnapshot( if (!page) { return { ...common, - focusedRegionTruncated: false, ...(target.format === "html" && { htmlTruncated: false }), status: "not-found" as const, type: target.type, @@ -46,16 +43,6 @@ export function inspectSnapshot( redactAttemptEvidence(projectToText(root, Number.MAX_SAFE_INTEGER)), ) : { text: "", truncated: true }; - const regions = - target.regionText && root - ? findSmallestTextRegions(root, target.regionText) - : []; - const region = regions.length === 1 ? regions[0] : undefined; - const focusedRegion = region - ? boundAttemptPageText( - redactAttemptEvidence(projectToText(region, Number.MAX_SAFE_INTEGER)), - ) - : undefined; const nearest = nearestScreenshotForSnapshot(trace, target.snapshotId); const serializedHtml = target.format === "html" && root @@ -63,16 +50,6 @@ export function inspectSnapshot( : undefined; return { ...common, - ...(focusedRegion && { focusedRegion: focusedRegion.text }), - focusedRegionTruncated: focusedRegion?.truncated ?? false, - ...(target.regionText && { - focusedRegionStatus: - regions.length === 0 - ? ("not-found" as const) - : regions.length === 1 - ? ("available" as const) - : ("multiple-match" as const), - }), ...(nearest && { nearestScreenshot: { deltaMilliseconds: nearest.deltaMilliseconds, diff --git a/src/domains/investigation/parser/snapshot/snapshotProjection.ts b/src/domains/investigation/parser/snapshot/snapshotProjection.ts index e02905879..e226faeea 100644 --- a/src/domains/investigation/parser/snapshot/snapshotProjection.ts +++ b/src/domains/investigation/parser/snapshot/snapshotProjection.ts @@ -58,10 +58,6 @@ export function resolveSnapshot( const collapse = (text: string) => text.replace(/\s+/g, " ").trim(); -function textOf(node: Node): string { - return typeof node === "string" ? node : node.children.map(textOf).join(" "); -} - type Projection = { depth: number; lines: string[]; @@ -102,15 +98,3 @@ export function projectToText(node: Node, max: number): string { project(node, { depth: 0, lines, max, redactText: false }); return lines.join("\n"); } - -export function findSmallestTextRegions(root: Node, query: string): Node[] { - const find = (node: Node, parent: Element | undefined): Element[] => { - if (typeof node === "string") return []; - const childMatches = node.children.flatMap((child) => find(child, node)); - if (childMatches.length) return childMatches; - return collapse(textOf(node)).toLowerCase().includes(query.toLowerCase()) - ? [parent ?? node] - : []; - }; - return [...new Set(find(root, undefined))]; -} From 7a5a0460b8bf4b244b2b06dea011ed41431a8969 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:46:11 +0200 Subject: [PATCH 19/27] docs(cli): fix the stale run log limit comment --- src/domains/investigation/parser/trace/runLogsTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domains/investigation/parser/trace/runLogsTypes.ts b/src/domains/investigation/parser/trace/runLogsTypes.ts index 077b4f69f..b46c504ee 100644 --- a/src/domains/investigation/parser/trace/runLogsTypes.ts +++ b/src/domains/investigation/parser/trace/runLogsTypes.ts @@ -13,7 +13,7 @@ export type RunLogRecord = { }; export type RunLogLimits = { - /** Bounds the tail window parsed after `readFileIfExists` returns a Buffer. */ + /** Parses at most this many bytes from the end of the downloaded log. */ maxArtifactBytes: number; maxMessageBytes: number; maxRecords: number; From 3242ad042faa027814b13feb3064d5255a9aaaa8 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:47:11 +0200 Subject: [PATCH 20/27] fix(cli): cap attempt inspect --limit at 100 --- src/commands/__snapshots__/help.test.ts.snap | 6 ++-- src/commands/attempt/index.ts | 2 +- src/domains/investigation/handle.ts | 12 ++++++-- .../investigation/handle.validation.test.ts | 29 +++++++++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 src/domains/investigation/handle.validation.test.ts diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 6fe271156..017be8f29 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -198,9 +198,9 @@ Options: --console Inspect browser console evidence --log Inspect execution log evidence --evidence-id Console or log evidence ID - --limit Maximum entries to return: the most recent for - --timeline, --network and --log, the earliest for - --console (default: "20") + --limit Maximum entries to return, up to 100: the most + recent for --timeline, --network and --log, the + earliest for --console (default: "20") --start-time-ms Earliest trace timestamp in milliseconds --end-time-ms Latest trace timestamp in milliseconds --start-timestamp Earliest execution log timestamp diff --git a/src/commands/attempt/index.ts b/src/commands/attempt/index.ts index b2bab8842..b962c3e3c 100644 --- a/src/commands/attempt/index.ts +++ b/src/commands/attempt/index.ts @@ -54,7 +54,7 @@ export function registerAttemptCommand( .option("--evidence-id ", "Console or log evidence ID") .option( "--limit ", - "Maximum entries to return: the most recent for --timeline, --network and --log, the earliest for --console", + "Maximum entries to return, up to 100: the most recent for --timeline, --network and --log, the earliest for --console", "20", ) .option( diff --git a/src/domains/investigation/handle.ts b/src/domains/investigation/handle.ts index 439030b0b..444b94ad4 100644 --- a/src/domains/investigation/handle.ts +++ b/src/domains/investigation/handle.ts @@ -94,10 +94,12 @@ export async function loadInspectArtifact( }; } +const maxInspectLimit = 100; + function numberFlag( value: string | undefined, name: string, - options: { integer?: boolean; min?: number } = {}, + options: { integer?: boolean; max?: number; min?: number } = {}, ): number | undefined | { error: string } { if (value === undefined) return undefined; const parsed = Number(value); @@ -107,6 +109,8 @@ function numberFlag( (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; } @@ -182,7 +186,11 @@ export async function handleInvestigationInspect( error: `Refusing to overwrite existing file: ${options.outputFile}`, }; - const limit = numberFlag(options.limit, "--limit", { integer: true, min: 1 }); + const limit = numberFlag(options.limit, "--limit", { + integer: true, + max: maxInspectLimit, + min: 1, + }); if (typeof limit !== "number") return limit; const startTimeMilliseconds = numberFlag( options.startTimeMs, diff --git a/src/domains/investigation/handle.validation.test.ts b/src/domains/investigation/handle.validation.test.ts new file mode 100644 index 000000000..22daf056f --- /dev/null +++ b/src/domains/investigation/handle.validation.test.ts @@ -0,0 +1,29 @@ +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", + limit: "20", + 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.", + }); + }); +}); From 2b05013ba3c9144013adf06beba3c3cff2abd68b Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:47:43 +0200 Subject: [PATCH 21/27] fix(cli): reject unparseable inspect timestamps before calling the API --- src/domains/investigation/handle.ts | 14 ++++++++++++++ .../investigation/handle.validation.test.ts | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/domains/investigation/handle.ts b/src/domains/investigation/handle.ts index 444b94ad4..a930ba7e6 100644 --- a/src/domains/investigation/handle.ts +++ b/src/domains/investigation/handle.ts @@ -114,6 +114,16 @@ function numberFlag( 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." }; @@ -204,6 +214,10 @@ export async function handleInvestigationInspect( 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); diff --git a/src/domains/investigation/handle.validation.test.ts b/src/domains/investigation/handle.validation.test.ts index 22daf056f..5b32ee8eb 100644 --- a/src/domains/investigation/handle.validation.test.ts +++ b/src/domains/investigation/handle.validation.test.ts @@ -26,4 +26,18 @@ describe("investigation inspect flag validation", () => { 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.`, + }, + ); + }, + ); }); From 8b7e322afb7ddced48707edb006407c4ba934855 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:48:29 +0200 Subject: [PATCH 22/27] fix(cli): reject inspect filters that do not apply to the selector --- src/commands/__snapshots__/help.test.ts.snap | 9 +++--- src/commands/attempt/index.ts | 18 +++++------ src/domains/investigation/handle.ts | 32 +++++++++++++++++-- .../investigation/handle.validation.test.ts | 21 +++++++++++- 4 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index 017be8f29..d15e26b4e 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -198,14 +198,15 @@ Options: --console Inspect browser console evidence --log Inspect execution log evidence --evidence-id Console or log evidence ID - --limit Maximum entries to return, up to 100: the most - recent for --timeline, --network and --log, the - earliest for --console (default: "20") + --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 + --source Execution log source (choices: + "qawolfTraceCollection", "serverConsole") --method Network HTTP method --status Network HTTP status --url-contains Network URL substring diff --git a/src/commands/attempt/index.ts b/src/commands/attempt/index.ts index b962c3e3c..71f513a32 100644 --- a/src/commands/attempt/index.ts +++ b/src/commands/attempt/index.ts @@ -1,4 +1,4 @@ -import type { Command } from "commander"; +import { type Command, Option } from "commander"; import { declareCommandKind } from "~/commands/commandKind.js"; import { withAuthContext } from "~/commands/context.js"; @@ -17,12 +17,6 @@ function parseFormat(value: string): "html" | "text" { throw new Error("Expected text or html."); } -function parseSource(value: string): "qawolfTraceCollection" | "serverConsole" { - if (value === "qawolfTraceCollection" || value === "serverConsole") - return value; - throw new Error("Expected qawolfTraceCollection or serverConsole."); -} - export function registerAttemptCommand( program: Command, signals: SignalRegistry, @@ -54,8 +48,7 @@ export function registerAttemptCommand( .option("--evidence-id ", "Console or log evidence ID") .option( "--limit ", - "Maximum entries to return, up to 100: the most recent for --timeline, --network and --log, the earliest for --console", - "20", + "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 ", @@ -64,7 +57,12 @@ export function registerAttemptCommand( .option("--end-time-ms ", "Latest trace timestamp in milliseconds") .option("--start-timestamp ", "Earliest execution log timestamp") .option("--end-timestamp ", "Latest execution log timestamp") - .option("--source ", "Execution log source", parseSource) + .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") diff --git a/src/domains/investigation/handle.ts b/src/domains/investigation/handle.ts index a930ba7e6..d0906c851 100644 --- a/src/domains/investigation/handle.ts +++ b/src/domains/investigation/handle.ts @@ -31,7 +31,7 @@ export type InspectOptions = { endTimestamp?: string; evidenceId?: string; format: "html" | "text"; - limit: string; + limit?: string; method?: string; outputFile?: string; requestId?: string; @@ -94,8 +94,34 @@ export async function loadInspectArtifact( }; } +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, @@ -189,14 +215,14 @@ export async function handleInvestigationInspect( ctx: AuthCommandContext, options: InspectOptions, ): Promise { - const invalid = validateInspect(options); + 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, "--limit", { + const limit = numberFlag(options.limit ?? defaultInspectLimit, "--limit", { integer: true, max: maxInspectLimit, min: 1, diff --git a/src/domains/investigation/handle.validation.test.ts b/src/domains/investigation/handle.validation.test.ts index 5b32ee8eb..3f6730155 100644 --- a/src/domains/investigation/handle.validation.test.ts +++ b/src/domains/investigation/handle.validation.test.ts @@ -6,7 +6,6 @@ import { handleInvestigationInspect, type InspectOptions } from "./handle.js"; const base: InspectOptions = { attemptId: "attempt-1", format: "text", - limit: "20", type: "network", }; @@ -40,4 +39,24 @@ describe("investigation inspect flag validation", () => { ); }, ); + + 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 }); + }, + ); }); From 279fd4c7da0aba4ef48cbded5eaf934bf0591662 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:49:07 +0200 Subject: [PATCH 23/27] fix(cli): allow two minutes per artifact download and report timeouts --- src/domains/investigation/download.test.ts | 21 +++++++++++++++ src/domains/investigation/download.ts | 30 +++++++++++++++++----- src/domains/investigation/types.ts | 9 ++++++- 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/domains/investigation/download.test.ts b/src/domains/investigation/download.test.ts index fa679bf38..e2f2c9739 100644 --- a/src/domains/investigation/download.test.ts +++ b/src/domains/investigation/download.test.ts @@ -41,4 +41,25 @@ describe("investigation artifact downloads", () => { 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 index caa2ec035..cdd0140da 100644 --- a/src/domains/investigation/download.ts +++ b/src/domains/investigation/download.ts @@ -1,10 +1,26 @@ +import { isTimeoutError } from "~/core/errors.js"; + export type ArtifactDownload = | { bytes: Uint8Array; offset: number; status: "available" } - | { status: "expired-url" | "not-found" | "too-large" | "unavailable" }; + | { + 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); @@ -81,15 +97,15 @@ export async function downloadTrace( fetchImpl: typeof globalThis.fetch = globalThis.fetch, ): Promise { try { - const signal = AbortSignal.timeout(30_000); + const signal = AbortSignal.timeout(downloadTimeoutMs); const response = await fetchImpl(url, { redirect: "follow", signal }); return await readBounded(response, { keepTail: false, retainBytes: traceLimit, transferBytes: traceLimit, }); - } catch { - return { status: "unavailable" }; + } catch (error) { + return downloadFailure(error); } } @@ -98,7 +114,7 @@ export async function downloadLogTail( fetchImpl: typeof globalThis.fetch = globalThis.fetch, ): Promise { try { - const signal = AbortSignal.timeout(30_000); + const signal = AbortSignal.timeout(downloadTimeoutMs); const response = await fetchImpl(url, { headers: { range: `bytes=-${logTailLimit}` }, redirect: "follow", @@ -109,7 +125,7 @@ export async function downloadLogTail( retainBytes: logTailLimit, transferBytes: logTransferLimit, }); - } catch { - return { status: "unavailable" }; + } catch (error) { + return downloadFailure(error); } } diff --git a/src/domains/investigation/types.ts b/src/domains/investigation/types.ts index 7362047f9..d3a1ab8b4 100644 --- a/src/domains/investigation/types.ts +++ b/src/domains/investigation/types.ts @@ -1,6 +1,13 @@ export type ArtifactInput = | { bytes: Uint8Array; offset: number; status: "available" } - | { status: "expired-url" | "not-found" | "too-large" | "unavailable" } + | { + status: + | "expired-url" + | "not-found" + | "timed-out" + | "too-large" + | "unavailable"; + } | { status: "not-captured" | "signing-failed" }; export type InspectType = From 95bdca869066b2388f195fefa950f97c6c811944 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:50:02 +0200 Subject: [PATCH 24/27] test(cli): cover the attempt investigate and inspect handlers --- .../attemptArtifacts.fixtures.ts | 22 +++ .../investigation/handle.artifacts.test.ts | 28 +--- src/domains/investigation/handle.test.ts | 141 ++++++++++++++++++ 3 files changed, 165 insertions(+), 26 deletions(-) create mode 100644 src/domains/investigation/attemptArtifacts.fixtures.ts create mode 100644 src/domains/investigation/handle.test.ts 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/handle.artifacts.test.ts b/src/domains/investigation/handle.artifacts.test.ts index 18be64c99..7032ea016 100644 --- a/src/domains/investigation/handle.artifacts.test.ts +++ b/src/domains/investigation/handle.artifacts.test.ts @@ -1,10 +1,7 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; -import { - type AttemptArtifacts, - loadInspectArtifact, - loadSummaryArtifacts, -} from "./handle.js"; +import { attemptArtifactsFixture as fixture } from "./attemptArtifacts.fixtures.js"; +import { loadInspectArtifact, loadSummaryArtifacts } from "./handle.js"; const originalFetch = globalThis.fetch; @@ -16,27 +13,6 @@ afterEach(() => { globalThis.fetch = originalFetch; }); -function fixture(): 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", - }; -} - describe("investigation artifact selection", () => { it("downloads only logs for log inspection", async () => { const requested: string[] = []; 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", + }); + }); +}); From 1975f3d14ca24adea62e42545d8005956c4bf30e Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:50:18 +0200 Subject: [PATCH 25/27] docs(cli): say --evidence-id selects exactly one record --- README.md | 2 +- src/commands/__snapshots__/help.test.ts.snap | 2 +- src/commands/attempt/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ca0e26022..a0fbd15ee 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ 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` to start console or execution-log output at a specific evidence record; the other filters are listed in `qawolf attempt inspect --help`. +`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: diff --git a/src/commands/__snapshots__/help.test.ts.snap b/src/commands/__snapshots__/help.test.ts.snap index d15e26b4e..efa0dc794 100644 --- a/src/commands/__snapshots__/help.test.ts.snap +++ b/src/commands/__snapshots__/help.test.ts.snap @@ -197,7 +197,7 @@ Options: --screenshot Export one screenshot evidence ID --console Inspect browser console evidence --log Inspect execution log evidence - --evidence-id Console or log evidence ID + --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 diff --git a/src/commands/attempt/index.ts b/src/commands/attempt/index.ts index 71f513a32..e7cebcaee 100644 --- a/src/commands/attempt/index.ts +++ b/src/commands/attempt/index.ts @@ -45,7 +45,7 @@ export function registerAttemptCommand( .option("--screenshot ", "Export one screenshot evidence ID") .option("--console", "Inspect browser console evidence") .option("--log", "Inspect execution log evidence") - .option("--evidence-id ", "Console or log evidence ID") + .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", From d687eeac8351124c732e49e65b1a8e702a32f320 Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:50:31 +0200 Subject: [PATCH 26/27] docs(cli): describe qawolf attempt without the dropped attempt list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a0fbd15ee..497af05c7 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ 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` | Discover attempt IDs and inspect bounded run evidence | +| `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) | From a6bb3e530f2747d4bba8db581887b00896386c1c Mon Sep 17 00:00:00 2001 From: Goran Gajic Date: Wed, 7 Oct 2026 09:50:46 +0200 Subject: [PATCH 27/27] docs(cli): note the commands the api-contracts bump adds and removes --- .changeset/quiet-wolves-investigate.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/quiet-wolves-investigate.md b/.changeset/quiet-wolves-investigate.md index 65e81d55d..cc3afe34b 100644 --- a/.changeset/quiet-wolves-investigate.md +++ b/.changeset/quiet-wolves-investigate.md @@ -3,3 +3,5 @@ --- `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`.