testdossier 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ Upload a completed test report from a developer laptop, test machine, or CI
8
8
  job. Run these commands in the project containing your automated tests:
9
9
 
10
10
  1. Install the latest CLI in the project. To pin a release instead, replace
11
- `@latest` with an exact version such as `@0.2.3`:
11
+ `@latest` with an exact version such as `@0.3.0`:
12
12
 
13
13
  ```bash
14
14
  npm install --save-dev testdossier@latest
@@ -70,9 +70,11 @@ requires Node.js 18 or newer.
70
70
 
71
71
  ## Why this is intentionally small
72
72
 
73
- The uploader does one thing: read a completed report and make an outbound HTTPS
74
- request to TestDossier. It does not run tests, accept remote commands, install a
75
- background service, watch files, or keep a process running.
73
+ The upload path does one thing: read a completed report and make an outbound
74
+ HTTPS request to TestDossier. It does not run tests, accept remote commands,
75
+ install a background service, watch files, or keep a process running. The
76
+ optional `setup cypress` command performs one fixed local write using the
77
+ template already present in the installed package.
76
78
 
77
79
  The access token is read from the first available source: an explicitly passed
78
80
  `--env-file`, the `TD_CI_TOKEN` process environment, then `.env.testdossier` in
@@ -93,6 +95,74 @@ Before sending anything, inspect what the CLI detected:
93
95
  npx testdossier upload playwright-results.json --dry-run
94
96
  ```
95
97
 
98
+ ### Optional source context
99
+
100
+ Source snippets are opt-in. Add this to the committed `testdossier.json` when
101
+ the checked-out CI workspace may be read for evidence:
102
+
103
+ ```json
104
+ {
105
+ "report": "playwright-results.json",
106
+ "source": {
107
+ "enabled": true,
108
+ "root": ".",
109
+ "before": 20,
110
+ "after": 80
111
+ }
112
+ }
113
+ ```
114
+
115
+ Only files identified by uploaded test records are considered. Paths must stay
116
+ inside the real, non-symlink source root; binary, invalid UTF-8, symlinked,
117
+ missing, and files over 2 MiB are skipped. Each persisted snippet is limited to
118
+ 240 lines and 16 KiB, common credential literals are masked, and the digest is
119
+ computed after masking. Source is evidence only: the CLI never executes,
120
+ formats, or infers test steps from it. `--dry-run` prints counts, safe relative
121
+ paths, ranges, hashes, truncation, and redaction counts—never source contents.
122
+
123
+ If a Playwright report records files relative to `testDir`, set `source.root`
124
+ to that directory (for example `"tests/e2e"`). An unchanged CI snippet keeps
125
+ its provenance; a new digest becomes a Review queue proposal and never silently
126
+ overwrites an approved case or an older run snapshot.
127
+
128
+ ## Automatic source context (opt in once)
129
+
130
+ The uploader can attach the repository source around each test declaration as
131
+ read-only execution evidence. Enable it in the committed `testdossier.json`:
132
+
133
+ ```json
134
+ {
135
+ "report": "playwright-results.json",
136
+ "source": {
137
+ "enabled": true,
138
+ "root": ".",
139
+ "before": 20,
140
+ "after": 80
141
+ }
142
+ }
143
+ ```
144
+
145
+ After that one-time opt-in, the same `npx testdossier` command captures source
146
+ locally, in GitHub Actions, and in GitLab CI. No TestDossier ID is required in
147
+ each `test()` or `it()` block: existing ID, alias, and report-signature matching
148
+ still apply. An explicit TestDossier ID, when present, remains the strongest
149
+ identity signal; disagreement with the report signature is quarantined for a
150
+ reviewer instead of being attached silently.
151
+
152
+ Capture is framework-neutral and best effort. It uses the source file and line
153
+ reported by Playwright, Cypress, Cucumber, or JUnit-compatible runners. Files
154
+ must resolve inside `source.root`; symlinks, binary files, files over 2 MiB, and
155
+ paths outside that root are skipped. Each persisted snippet is bounded, common
156
+ credential literals are redacted, and the CLI prints every captured path and
157
+ digest during `--dry-run` so the upload can be inspected before it is sent.
158
+
159
+ The Editor holds the current, editable source reference. Every Test Run keeps
160
+ the source uploaded for that execution as an immutable snapshot. When a later
161
+ upload changes a source digest, Review queue shows the incoming version for
162
+ comparison; accepting it updates the Editor reference without rewriting the
163
+ completed run. Editing in TestDossier never changes the repository or what CI
164
+ executes.
165
+
96
166
  ## Supported reports
97
167
 
98
168
  | Runner or format | Example |
@@ -108,6 +178,43 @@ creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
108
178
  `--format cucumber`, `--format junit`, or `--format generic`. The `upload`
109
179
  word may be omitted: `npx testdossier playwright-results.json` works too.
110
180
 
181
+ For Playwright, configure `screenshot: 'only-on-failure'`. During upload the
182
+ CLI reads one screenshot from each test whose final attempt failed and sends it
183
+ through TestDossier's durable image path. Screenshots from passing tests and
184
+ earlier retries are omitted, while video/trace files remain link-first. Every
185
+ CI run stays immutable; identical images are hash-deduplicated in storage.
186
+
187
+ ### Generate the Cypress helper from the installed package
188
+
189
+ Cypress's normal command does not write its complete Module API result to a
190
+ file, and local screenshot paths are not enough once the runner disappears.
191
+ After installing TestDossier in the client repository, generate the helper
192
+ from that installed, lockfile-pinned version:
193
+
194
+ ```bash
195
+ npx testdossier setup cypress
196
+ ```
197
+
198
+ The command creates only `scripts/dossier-cypress.mjs`, prints its SHA-256
199
+ digest, and never downloads code or contacts TestDossier. It is safe to run
200
+ again when the file still matches. If the file has been edited or came from a
201
+ different template, setup stops and leaves it untouched—there is deliberately
202
+ no force-overwrite option.
203
+
204
+ Review the generated file before committing it, then add a project script:
205
+
206
+ ```json
207
+ {
208
+ "scripts": {
209
+ "cy:testdossier": "node scripts/dossier-cypress.mjs --output cypress-results.json"
210
+ }
211
+ }
212
+ ```
213
+
214
+ Run `npm run cy:testdossier`, then upload `cypress-results.json`. The helper
215
+ uses the client project's own Cypress dependency; TestDossier does not bundle
216
+ or silently install Cypress.
217
+
111
218
  ## Local setup
112
219
 
113
220
  For repeat use, install and pin the CLI in the test repository so its source
@@ -118,7 +225,7 @@ npm install --save-dev testdossier@latest
118
225
  npx testdossier init
119
226
  ```
120
227
 
121
- Use `npm install --save-dev testdossier@0.2.3` when the repository should pin
228
+ Use `npm install --save-dev testdossier@0.3.0` when the repository should pin
122
229
  that exact release. In either case, subsequent commands remain
123
230
  `npx testdossier ...`; the version selector belongs only to installation.
124
231
 
package/assets/mark.svg CHANGED
@@ -1,5 +1,5 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TestDossier">
2
2
  <title>TestDossier</title>
3
- <path fill="#006b61" fill-rule="evenodd" d="M20 6h14c17 0 28 10 28 26S51 58 34 58H20V6Zm14 14v24c9 0 14-4 14-12s-5-12-14-12Z"/>
4
- <path fill="#006b61" d="M2 6h32v10H2z"/>
3
+ <path fill="#4335de" fill-rule="evenodd" d="M20 6h14c17 0 28 10 28 26S51 58 34 58H20V6Zm14 14v24c9 0 14-4 14-12s-5-12-14-12Z"/>
4
+ <path fill="#4335de" d="M2 6h32v14H2z"/>
5
5
  </svg>
@@ -0,0 +1,212 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
3
+ import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
4
+
5
+ export const SOURCE_ENVELOPE_VERSION = 1;
6
+ export const MAX_SOURCE_CONTEXT_BYTES = 16 * 1024;
7
+ export const MAX_SOURCE_CONTEXT_LINES = 240;
8
+ const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024;
9
+ const LANGUAGE_BY_EXTENSION = new Map([
10
+ [".js", "javascript"], [".jsx", "jsx"], [".mjs", "javascript"], [".cjs", "javascript"],
11
+ [".ts", "typescript"], [".tsx", "tsx"], [".py", "python"], [".rb", "ruby"],
12
+ [".java", "java"], [".kt", "kotlin"], [".kts", "kotlin"], [".cs", "csharp"],
13
+ [".go", "go"], [".rs", "rust"], [".php", "php"], [".feature", "gherkin"],
14
+ [".xml", "xml"], [".json", "json"], [".yaml", "yaml"], [".yml", "yaml"],
15
+ [".sh", "shell"], [".bash", "shell"], [".zsh", "shell"],
16
+ ]);
17
+
18
+ function object(value) {
19
+ return !!value && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+
22
+ export function parseSourceConfig(value) {
23
+ if (value === undefined) return null;
24
+ if (!object(value)) throw new Error('testdossier.json "source" must be an object.');
25
+ if (value.enabled !== true && value.enabled !== false) {
26
+ throw new Error('testdossier.json "source.enabled" must be true or false.');
27
+ }
28
+ if (!value.enabled) return null;
29
+ const root = value.root === undefined ? "." : value.root;
30
+ if (typeof root !== "string" || !root.trim() || isAbsolute(root) || root.split(/[\\/]/).includes("..")) {
31
+ throw new Error('testdossier.json "source.root" must be a relative path inside the project.');
32
+ }
33
+ const integer = (field, fallback, max) => {
34
+ const n = value[field];
35
+ if (n === undefined) return fallback;
36
+ if (!Number.isInteger(n) || n < 0 || n > max) throw new Error(`testdossier.json "source.${field}" must be an integer from 0 to ${max}.`);
37
+ return n;
38
+ };
39
+ return { enabled: true, root: root.trim(), before: integer("before", 20, 200), after: integer("after", 80, 200) };
40
+ }
41
+
42
+ export function redactSourceText(value) {
43
+ let content = String(value);
44
+ let redactions = 0;
45
+ const replace = (pattern, replacement) => {
46
+ content = content.replace(pattern, (...args) => {
47
+ redactions += 1;
48
+ return typeof replacement === "function" ? replacement(...args) : replacement;
49
+ });
50
+ };
51
+ replace(
52
+ /(\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth(?:orization)?|cookie)\b\s*[:=]\s*)(?:(["'`])[^\r\n]*?\2|[^\s,;]+)/gi,
53
+ (_match, prefix, quote) => `${prefix}${quote || ""}[REDACTED]${quote || ""}`,
54
+ );
55
+ replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, "Bearer [REDACTED]");
56
+ replace(/\btd_[A-Za-z0-9_-]{8,}\b/g, "td_[REDACTED]");
57
+ replace(/(https?:\/\/)([^\s/@:]+):([^\s/@]+)@/gi, (_match, scheme) => `${scheme}[REDACTED]@`);
58
+ replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
59
+ "-----BEGIN PRIVATE KEY-----\n[REDACTED]\n-----END PRIVATE KEY-----");
60
+ return { content, redactions };
61
+ }
62
+
63
+ function utf8Bound(value, maxBytes) {
64
+ const bytes = Buffer.from(value, "utf8");
65
+ if (bytes.byteLength <= maxBytes) return { content: value, truncated: false };
66
+ let content = bytes.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD$/, "");
67
+ return { content, truncated: true };
68
+ }
69
+
70
+ function sourceTargets(format, bodyText) {
71
+ if (format === "junit") {
72
+ const targets = [];
73
+ const open = /<testcase\b((?:[^>"']|"[^"]*"|'[^']*')*?)(?:\/?)>/gi;
74
+ let match;
75
+ while ((match = open.exec(bodyText))) {
76
+ const attr = (name) => {
77
+ const found = match[1].match(new RegExp(`\\b${name}\\s*=\\s*("[^"]*"|'[^']*')`, "i"));
78
+ return found ? found[1].slice(1, -1) : "";
79
+ };
80
+ targets.push({ path: attr("file"), line: Number(attr("line")) || undefined });
81
+ }
82
+ return targets;
83
+ }
84
+ let doc;
85
+ try { doc = JSON.parse(bodyText.replace(/^\uFEFF/, "")); } catch { return []; }
86
+ if (format === "generic") return (Array.isArray(doc?.tests) ? doc.tests : []).map((test) => ({
87
+ path: typeof test?.file === "string" ? test.file : "",
88
+ line: Number(test?.line ?? test?.location?.line) || undefined,
89
+ }));
90
+ if (format === "cypress") return (Array.isArray(doc?.runs) ? doc.runs : []).flatMap((run) => {
91
+ const path = run?.spec?.relative || run?.spec?.name || "";
92
+ return (Array.isArray(run?.tests) ? run.tests : []).map((test) => ({
93
+ path,
94
+ line: Number(test?.line ?? test?.location?.line) || undefined,
95
+ }));
96
+ });
97
+ if (format === "cucumber") return (Array.isArray(doc) ? doc : []).flatMap((feature) =>
98
+ (Array.isArray(feature?.elements) ? feature.elements : [])
99
+ .filter((element) => String(element?.type || "scenario").toLowerCase() === "scenario")
100
+ .map((element) => ({ path: feature?.uri || "", line: Number(element?.line) || undefined })));
101
+ const targets = [];
102
+ const visit = (suites) => {
103
+ for (const suite of Array.isArray(suites) ? suites : []) {
104
+ for (const spec of Array.isArray(suite?.specs) ? suite.specs : []) {
105
+ for (const test of Array.isArray(spec?.tests) ? spec.tests : []) {
106
+ targets.push({ path: spec.file || "", line: Number(spec.line) || undefined });
107
+ }
108
+ }
109
+ visit(suite?.suites);
110
+ }
111
+ };
112
+ visit(doc?.suites);
113
+ return targets;
114
+ }
115
+
116
+ function inside(root, candidate) {
117
+ const rel = relative(root, candidate);
118
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
119
+ }
120
+
121
+ async function hasSymlink(root, candidate) {
122
+ const rel = relative(root, candidate);
123
+ let cursor = root;
124
+ for (const part of rel.split(sep).filter(Boolean)) {
125
+ cursor = resolve(cursor, part);
126
+ if ((await lstat(cursor)).isSymbolicLink()) return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ async function readOneSource(root, target, config, commit) {
132
+ if (!target.path || typeof target.path !== "string") return { reason: "report has no source path" };
133
+ const candidate = isAbsolute(target.path) ? resolve(target.path) : resolve(root, target.path);
134
+ if (!inside(root, candidate)) return { reason: "path is outside source root", path: target.path };
135
+ try {
136
+ if (await hasSymlink(root, candidate)) return { reason: "symlink paths are not captured", path: target.path };
137
+ const candidateReal = await realpath(candidate);
138
+ if (!inside(root, candidateReal)) return { reason: "resolved path is outside source root", path: target.path };
139
+ const info = await stat(candidateReal);
140
+ if (!info.isFile()) return { reason: "source path is not a file", path: target.path };
141
+ if (info.size <= 0 || info.size > MAX_SOURCE_FILE_BYTES) return { reason: "source file is empty or over 2 MiB", path: target.path };
142
+ const bytes = await readFile(candidateReal);
143
+ if (bytes.includes(0)) return { reason: "binary source is not captured", path: target.path };
144
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/\r\n?/g, "\n");
145
+ const allLines = decoded.split("\n");
146
+ const focus = Number.isInteger(target.line) && target.line > 0 ? Math.min(target.line, allLines.length) : undefined;
147
+ const start = focus ? Math.max(1, focus - config.before) : 1;
148
+ const requestedEnd = focus ? Math.min(allLines.length, focus + config.after) : Math.min(allLines.length, MAX_SOURCE_CONTEXT_LINES);
149
+ const end = Math.min(requestedEnd, start + MAX_SOURCE_CONTEXT_LINES - 1);
150
+ const selected = allLines.slice(start - 1, end).join("\n");
151
+ const redacted = redactSourceText(selected);
152
+ const bounded = utf8Bound(redacted.content, MAX_SOURCE_CONTEXT_BYTES);
153
+ const relativePath = relative(root, candidateReal).split(sep).join("/");
154
+ const context = {
155
+ schema_version: SOURCE_ENVELOPE_VERSION,
156
+ origin: "ci",
157
+ content: bounded.content,
158
+ language: LANGUAGE_BY_EXTENSION.get(extname(relativePath).toLowerCase()) || "text",
159
+ digest: `sha256:${createHash("sha256").update(bounded.content).digest("hex")}`,
160
+ path: relativePath,
161
+ start_line: start,
162
+ end_line: start + bounded.content.split("\n").length - 1,
163
+ ...(focus ? { focus_line: focus } : {}),
164
+ ...(commit ? { commit: String(commit).slice(0, 80) } : {}),
165
+ ...((bounded.truncated || end < requestedEnd || requestedEnd < allLines.length || start > 1) ? { truncated: true } : {}),
166
+ ...(redacted.redactions ? { redactions: redacted.redactions } : {}),
167
+ };
168
+ return { context };
169
+ } catch (error) {
170
+ return { reason: error?.code === "ENOENT" ? "source file was not found" : "source file could not be read", path: target.path };
171
+ }
172
+ }
173
+
174
+ export async function collectSourceContexts({ format, body, cwd, config, commit = "" }) {
175
+ if (!config) return { carriers: [], captured: [], skipped: [] };
176
+ const configuredRoot = resolve(cwd, config.root);
177
+ let root;
178
+ try {
179
+ if (!inside(resolve(cwd), configuredRoot)) throw new Error("outside");
180
+ if (await hasSymlink(resolve(cwd), configuredRoot)) throw new Error("symlink");
181
+ root = await realpath(configuredRoot);
182
+ if (!inside(await realpath(cwd), root)) throw new Error("outside");
183
+ } catch {
184
+ throw new Error('testdossier.json "source.root" must resolve to a real, non-symlink directory inside the project.');
185
+ }
186
+ const targets = sourceTargets(format, Buffer.from(body).toString("utf8"));
187
+ const carriers = [];
188
+ const captured = [];
189
+ const skipped = [];
190
+ for (let testIndex = 0; testIndex < targets.length; testIndex += 1) {
191
+ const found = await readOneSource(root, targets[testIndex], config, commit);
192
+ if (found.context) {
193
+ carriers.push({ test_index: testIndex, ...found.context });
194
+ captured.push(found.context);
195
+ } else {
196
+ skipped.push({ test_index: testIndex, path: found.path || "", reason: found.reason });
197
+ }
198
+ }
199
+ return { carriers, captured, skipped };
200
+ }
201
+
202
+ export function wrapSourceEnvelope(format, body, carriers) {
203
+ if (!carriers?.length) return Buffer.from(body);
204
+ const text = Buffer.from(body).toString("utf8");
205
+ const report = format === "junit" ? text : JSON.parse(text.replace(/^\uFEFF/, ""));
206
+ return Buffer.from(JSON.stringify({
207
+ schema_version: SOURCE_ENVELOPE_VERSION,
208
+ format,
209
+ report,
210
+ source_contexts: carriers,
211
+ }));
212
+ }
@@ -1,14 +1,21 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { readFile, stat, writeFile } from "node:fs/promises";
3
- import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { collectSourceContexts, parseSourceConfig, wrapSourceEnvelope } from "./source-context.mjs";
4
5
 
5
- export const VERSION = "0.2.3";
6
+ export const VERSION = "0.3.0";
6
7
  const DEFAULT_ORIGIN = "https://testdossier.com";
7
8
  const ENV_FILE_NAME = ".env.testdossier";
8
9
  const CONFIG_FILE_NAME = "testdossier.json";
10
+ const CYPRESS_HELPER_PATH = "scripts/dossier-cypress.mjs";
11
+ const CYPRESS_HELPER_TEMPLATE_URL = new URL("../templates/dossier-cypress.mjs", import.meta.url);
9
12
  const TOKEN_PLACEHOLDER = "replace_with_your_td_token";
10
13
  const REPORT_FORMATS = new Set(["cypress", "playwright", "cucumber", "junit", "generic"]);
11
14
  const MAX_REPORT_BYTES = 25 * 1024 * 1024;
15
+ // Keep encoded bodies below both the endpoint's 25 MiB request ceiling and
16
+ // its 16 MiB decoded-evidence budget (base64 expands bytes by roughly 4/3).
17
+ const MAX_PAGE_BYTES = 20 * 1024 * 1024;
18
+ const MAX_FAILURE_SCREENSHOT_BYTES = 10 * 1024 * 1024;
12
19
  const MAX_TESTS_PER_PAGE = 200;
13
20
  const RETRYABLE_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
14
21
  const FORMAT_LABELS = {
@@ -130,6 +137,86 @@ export function detectReportFormat(text) {
130
137
  return analyzeReport(text).format;
131
138
  }
132
139
 
140
+ const SCREENSHOT_MIME = new Map([
141
+ [".png", "image/png"], [".jpg", "image/jpeg"],
142
+ [".jpeg", "image/jpeg"], [".gif", "image/gif"], [".webp", "image/webp"],
143
+ ]);
144
+
145
+ function isPlaywrightScreenshot(att) {
146
+ if (!isObject(att)) return false;
147
+ const contentType = String(att.contentType || "").toLowerCase();
148
+ const extension = extname(String(att.path || att.name || "")).toLowerCase();
149
+ return contentType.startsWith("image/") || SCREENSHOT_MIME.has(extension);
150
+ }
151
+
152
+ function playwrightFinalFailure(test, result) {
153
+ const status = String(result && result.status || "").toLowerCase();
154
+ if (status === "timedout" || status === "interrupted") return true;
155
+ if (status !== "failed") return false;
156
+ return String(test && test.expectedStatus || "passed").toLowerCase() !== "failed";
157
+ }
158
+
159
+ /** Promote one screenshot from each final Playwright failure into the inline
160
+ * transport understood by the ingest endpoint. Earlier-retry screenshots are
161
+ * deliberately omitted from this upload copy to keep evidence bounded. */
162
+ export async function hydratePlaywrightFailureScreenshots(document, reportPath) {
163
+ if (!isObject(document) || !Array.isArray(document.suites)) return { inlined: 0, skipped: 0 };
164
+ let inlined = 0;
165
+ let skipped = 0;
166
+
167
+ const visitSuites = async (suites) => {
168
+ for (const suite of suites || []) {
169
+ for (const spec of Array.isArray(suite && suite.specs) ? suite.specs : []) {
170
+ for (const test of Array.isArray(spec && spec.tests) ? spec.tests : []) {
171
+ const results = Array.isArray(test && test.results) ? test.results : [];
172
+ const ordered = results.slice().sort((a, b) => Number(a.retry || 0) - Number(b.retry || 0));
173
+ const finalResult = ordered[ordered.length - 1];
174
+ const finalAttachments = Array.isArray(finalResult && finalResult.attachments) ? finalResult.attachments : [];
175
+ const selected = finalResult && playwrightFinalFailure(test, finalResult)
176
+ ? finalAttachments.find((att) => isPlaywrightScreenshot(att) && typeof att.path === "string" && att.path.trim()) || null
177
+ : null;
178
+
179
+ for (const result of results) {
180
+ if (!Array.isArray(result.attachments)) continue;
181
+ const next = [];
182
+ for (const att of result.attachments) {
183
+ if (!isPlaywrightScreenshot(att)) { next.push(att); continue; }
184
+ if (att !== selected) continue;
185
+ const rawPath = String(att.path || "").trim();
186
+ const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(dirname(reportPath), rawPath);
187
+ const mime = SCREENSHOT_MIME.get(extname(absolutePath).toLowerCase())
188
+ || (/^image\/(png|jpeg|gif|webp)$/i.test(String(att.contentType || "")) ? String(att.contentType).toLowerCase() : "");
189
+ try {
190
+ const info = await stat(absolutePath);
191
+ if (!mime || !info.isFile() || info.size <= 0 || info.size > MAX_FAILURE_SCREENSHOT_BYTES) {
192
+ skipped += 1;
193
+ continue;
194
+ }
195
+ const bytes = await readFile(absolutePath);
196
+ next.push({
197
+ ...att,
198
+ name: att.name || basename(absolutePath),
199
+ contentType: mime,
200
+ data_url: `data:${mime};base64,${bytes.toString("base64")}`,
201
+ path: undefined,
202
+ });
203
+ inlined += 1;
204
+ } catch {
205
+ skipped += 1;
206
+ }
207
+ }
208
+ result.attachments = next;
209
+ }
210
+ }
211
+ }
212
+ if (Array.isArray(suite && suite.suites)) await visitSuites(suite.suites);
213
+ }
214
+ };
215
+
216
+ await visitSuites(document.suites);
217
+ return { inlined, skipped };
218
+ }
219
+
133
220
  function playwrightTestNodes(suites, found = []) {
134
221
  for (const suite of Array.isArray(suites) ? suites : []) {
135
222
  if (!suite || typeof suite !== "object") continue;
@@ -160,6 +247,175 @@ function selectedPlaywrightSuite(suite, selectedTests) {
160
247
  return next.specs.length > 0 || next.suites.length > 0 ? next : null;
161
248
  }
162
249
 
250
+ async function inspectPlaywrightFailureScreenshot(test, reportPath) {
251
+ const results = Array.isArray(test && test.results) ? test.results : [];
252
+ const ordered = results.slice().sort((a, b) => Number(a.retry || 0) - Number(b.retry || 0));
253
+ const finalResult = ordered[ordered.length - 1];
254
+ const attachments = Array.isArray(finalResult && finalResult.attachments) ? finalResult.attachments : [];
255
+ const selected = finalResult && playwrightFinalFailure(test, finalResult)
256
+ ? attachments.find((att) => isPlaywrightScreenshot(att) && typeof att.path === "string" && att.path.trim()) || null
257
+ : null;
258
+ if (!selected) return { selected: null, skipped: 0, dataUrlLength: 0 };
259
+
260
+ const rawPath = String(selected.path || "").trim();
261
+ const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(dirname(reportPath), rawPath);
262
+ const mime = SCREENSHOT_MIME.get(extname(absolutePath).toLowerCase())
263
+ || (/^image\/(png|jpeg|gif|webp)$/i.test(String(selected.contentType || "")) ? String(selected.contentType).toLowerCase() : "");
264
+ try {
265
+ const info = await stat(absolutePath);
266
+ if (!mime || !info.isFile() || info.size <= 0 || info.size > MAX_FAILURE_SCREENSHOT_BYTES) {
267
+ return { selected, skipped: 1, dataUrlLength: 0 };
268
+ }
269
+ const prefix = `data:${mime};base64,`;
270
+ return {
271
+ selected,
272
+ absolutePath,
273
+ mime,
274
+ size: info.size,
275
+ skipped: 0,
276
+ dataUrlLength: prefix.length + 4 * Math.ceil(info.size / 3),
277
+ };
278
+ } catch {
279
+ return { selected, skipped: 1, dataUrlLength: 0 };
280
+ }
281
+ }
282
+
283
+ function playwrightUploadTest(test, evidence, dataUrl, estimate) {
284
+ return {
285
+ ...test,
286
+ results: (Array.isArray(test && test.results) ? test.results : []).map((result) => ({
287
+ ...result,
288
+ attachments: (Array.isArray(result && result.attachments) ? result.attachments : []).flatMap((att) => {
289
+ if (!isPlaywrightScreenshot(att)) return [att];
290
+ if (att !== evidence.selected || !evidence.absolutePath || !evidence.mime) return [];
291
+ if (!estimate && typeof dataUrl !== "string") return [];
292
+ return [{
293
+ ...att,
294
+ name: att.name || basename(evidence.absolutePath),
295
+ contentType: evidence.mime,
296
+ data_url: estimate ? "" : dataUrl,
297
+ path: undefined,
298
+ }];
299
+ }),
300
+ })),
301
+ };
302
+ }
303
+
304
+ function playwrightUploadPageBody(document, tests, evidenceByTest, dataUrls = null, estimate = false) {
305
+ const selected = new Set(tests);
306
+ const replacements = new Map(tests.map((test) => [
307
+ test,
308
+ playwrightUploadTest(test, evidenceByTest.get(test), dataUrls && dataUrls.get(test), estimate),
309
+ ]));
310
+ const selectSuite = (suite) => {
311
+ if (!suite || typeof suite !== "object") return null;
312
+ const next = { ...suite };
313
+ next.specs = (Array.isArray(suite.specs) ? suite.specs : [])
314
+ .filter((spec) => spec && typeof spec === "object")
315
+ .map((spec) => ({
316
+ ...spec,
317
+ tests: (Array.isArray(spec.tests) ? spec.tests : [])
318
+ .filter((test) => selected.has(test))
319
+ .map((test) => replacements.get(test)),
320
+ }))
321
+ .filter((spec) => spec.tests.length > 0);
322
+ next.suites = (Array.isArray(suite.suites) ? suite.suites : [])
323
+ .map(selectSuite)
324
+ .filter(Boolean);
325
+ return next.specs.length > 0 || next.suites.length > 0 ? next : null;
326
+ };
327
+ const suites = document.suites.map(selectSuite).filter(Boolean);
328
+ return Buffer.from(JSON.stringify({ ...document, suites }));
329
+ }
330
+
331
+ function estimatedPlaywrightUploadPageBytes(document, tests, evidenceByTest) {
332
+ const placeholder = playwrightUploadPageBody(document, tests, evidenceByTest, null, true);
333
+ return placeholder.byteLength + tests.reduce(
334
+ (sum, test) => sum + Number(evidenceByTest.get(test)?.dataUrlLength || 0),
335
+ 0,
336
+ );
337
+ }
338
+
339
+ /** Plan Playwright pages using screenshot stat metadata only. No image bytes or
340
+ * base64 strings are retained here, so a failure storm cannot exhaust the Node
341
+ * heap before the first request is sent. */
342
+ export async function planPlaywrightUploadPages(
343
+ document,
344
+ reportPath,
345
+ maxTests = MAX_TESTS_PER_PAGE,
346
+ maxPageBytes = MAX_PAGE_BYTES,
347
+ ) {
348
+ const tests = playwrightTestNodes(document.suites);
349
+ const evidenceByTest = new Map();
350
+ let inlined = 0;
351
+ let skipped = 0;
352
+ for (const test of tests) {
353
+ const evidence = await inspectPlaywrightFailureScreenshot(test, reportPath);
354
+ evidenceByTest.set(test, evidence);
355
+ if (evidence.dataUrlLength > 0) inlined += 1;
356
+ skipped += evidence.skipped;
357
+ }
358
+
359
+ const pages = [];
360
+ let current = [];
361
+ const pushCurrent = () => {
362
+ if (!current.length) return;
363
+ pages.push({
364
+ tests: current.slice(),
365
+ testCount: current.length,
366
+ estimatedBytes: estimatedPlaywrightUploadPageBytes(document, current, evidenceByTest),
367
+ maxPageBytes,
368
+ });
369
+ };
370
+ for (const test of tests) {
371
+ const candidate = [...current, test];
372
+ const estimatedBytes = estimatedPlaywrightUploadPageBytes(document, candidate, evidenceByTest);
373
+ if (candidate.length > maxTests || estimatedBytes > maxPageBytes) {
374
+ if (current.length === 0) {
375
+ throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
376
+ }
377
+ pushCurrent();
378
+ current = [test];
379
+ if (estimatedPlaywrightUploadPageBytes(document, current, evidenceByTest) > maxPageBytes) {
380
+ throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
381
+ }
382
+ } else {
383
+ current = candidate;
384
+ }
385
+ }
386
+ pushCurrent();
387
+ if (pages.length === 0) {
388
+ pages.push({ tests: [], testCount: 0, estimatedBytes: playwrightUploadPageBody(document, [], evidenceByTest).byteLength, maxPageBytes });
389
+ }
390
+ return { pages, evidenceByTest, inlined, skipped };
391
+ }
392
+
393
+ /** Read and encode only one already-planned page. The caller uploads it before
394
+ * materializing the next page, bounding live screenshot data to one request. */
395
+ export async function materializePlaywrightUploadPage(document, page, evidenceByTest) {
396
+ const dataUrls = new Map();
397
+ let skipped = 0;
398
+ for (const test of page.tests) {
399
+ const evidence = evidenceByTest.get(test);
400
+ if (!evidence?.absolutePath || !evidence.mime) continue;
401
+ try {
402
+ const bytes = await readFile(evidence.absolutePath);
403
+ if (bytes.length <= 0 || bytes.length > MAX_FAILURE_SCREENSHOT_BYTES) {
404
+ skipped += 1;
405
+ continue;
406
+ }
407
+ dataUrls.set(test, `data:${evidence.mime};base64,${bytes.toString("base64")}`);
408
+ } catch {
409
+ skipped += 1;
410
+ }
411
+ }
412
+ const body = playwrightUploadPageBody(document, page.tests, evidenceByTest, dataUrls, false);
413
+ if (body.byteLength > page.maxPageBytes) {
414
+ throw new CliError("one Playwright page exceeds the 20 MiB page limit because its screenshot changed during upload; reduce the screenshot size.");
415
+ }
416
+ return { body, skipped };
417
+ }
418
+
163
419
  // Counts <testcase> elements the way the server's parser does: real tags
164
420
  // only, so a failure message inside CDATA that happens to contain
165
421
  // "<testcase" is not counted.
@@ -231,7 +487,7 @@ function selectedCypressRun(run, selectedTests) {
231
487
  };
232
488
  }
233
489
 
234
- export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null) {
490
+ export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null, maxPageBytes = MAX_PAGE_BYTES) {
235
491
  if (!Number.isInteger(maxTests) || maxTests < 1 || maxTests > MAX_TESTS_PER_PAGE) {
236
492
  throw new CliError(`page size must be an integer from 1 to ${MAX_TESTS_PER_PAGE}.`);
237
493
  }
@@ -259,14 +515,37 @@ export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE,
259
515
 
260
516
  if (format === "playwright") {
261
517
  const tests = playwrightTestNodes(doc.suites);
262
- if (tests.length <= maxTests) return [{ body: report, tests: tests.length }];
263
- return pagedSlices(tests, maxTests).map((slice) => {
518
+ const pageBody = (slice) => {
264
519
  const selected = new Set(slice);
265
520
  const suites = doc.suites
266
521
  .map((suite) => selectedPlaywrightSuite(suite, selected))
267
522
  .filter(Boolean);
268
- return { body: Buffer.from(JSON.stringify({ ...doc, suites })), tests: slice.length };
269
- });
523
+ return Buffer.from(JSON.stringify({ ...doc, suites }));
524
+ };
525
+ if (tests.length <= maxTests && report.byteLength <= maxPageBytes) {
526
+ return [{ body: report, tests: tests.length }];
527
+ }
528
+ const pages = [];
529
+ let current = [];
530
+ for (const test of tests) {
531
+ const candidate = [...current, test];
532
+ const candidateBody = pageBody(candidate);
533
+ if (candidate.length > maxTests || candidateBody.byteLength > maxPageBytes) {
534
+ if (current.length === 0) {
535
+ throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
536
+ }
537
+ const body = pageBody(current);
538
+ pages.push({ body, tests: current.length });
539
+ current = [test];
540
+ if (pageBody(current).byteLength > maxPageBytes) {
541
+ throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
542
+ }
543
+ } else {
544
+ current = candidate;
545
+ }
546
+ }
547
+ if (current.length > 0) pages.push({ body: pageBody(current), tests: current.length });
548
+ return pages;
270
549
  }
271
550
 
272
551
  if (format === "cypress") {
@@ -434,6 +713,13 @@ export async function readProjectConfig(cwd) {
434
713
  }
435
714
  config.url = value.url;
436
715
  }
716
+ if (value.source !== undefined) {
717
+ try {
718
+ config.source = parseSourceConfig(value.source);
719
+ } catch (error) {
720
+ throw new CliError(error.message);
721
+ }
722
+ }
437
723
  return config;
438
724
  }
439
725
 
@@ -519,6 +805,57 @@ async function runInit({ cwd, stdout }) {
519
805
  return 0;
520
806
  }
521
807
 
808
+ async function runCypressSetup({ cwd, stdout }) {
809
+ let template;
810
+ try {
811
+ template = await readFile(CYPRESS_HELPER_TEMPLATE_URL, "utf8");
812
+ } catch (error) {
813
+ throw new CliError(`installed Cypress helper template is unavailable: ${error && error.message}`);
814
+ }
815
+ const targetPath = join(cwd, "scripts", "dossier-cypress.mjs");
816
+ const digest = createHash("sha256").update(template).digest("hex");
817
+
818
+ const describeExisting = async () => {
819
+ let existing;
820
+ try {
821
+ existing = await readFile(targetPath, "utf8");
822
+ } catch (error) {
823
+ if (error && error.code === "ENOENT") return false;
824
+ throw new CliError(
825
+ `${CYPRESS_HELPER_PATH} already exists but could not be read; left unchanged: ${error && error.message}`,
826
+ );
827
+ }
828
+ if (existing !== template) {
829
+ throw new CliError(
830
+ `${CYPRESS_HELPER_PATH} already exists and differs from the testdossier ${VERSION} template; ` +
831
+ "left unchanged. Review or remove it before trying again.",
832
+ );
833
+ }
834
+ stdout(`${CYPRESS_HELPER_PATH} already matches testdossier ${VERSION}; left unchanged.`);
835
+ stdout(`SHA-256 ${digest}`);
836
+ return true;
837
+ };
838
+
839
+ if (await describeExisting()) return 0;
840
+ try {
841
+ await mkdir(dirname(targetPath), { recursive: true });
842
+ await writeFile(targetPath, template, { flag: "wx", mode: 0o644 });
843
+ } catch (error) {
844
+ // Another process may have created the fixed target after our first read.
845
+ // Re-check it rather than overwriting or reporting a false failure.
846
+ if (error && error.code === "EEXIST" && await describeExisting()) return 0;
847
+ throw new CliError(`could not create ${CYPRESS_HELPER_PATH}: ${error && error.message}`);
848
+ }
849
+
850
+ stdout(`Created ${CYPRESS_HELPER_PATH} from installed testdossier ${VERSION}.`);
851
+ stdout(`SHA-256 ${digest}`);
852
+ stdout([
853
+ "Review the generated file before committing it, then add this script to package.json:",
854
+ ' "cy:testdossier": "node scripts/dossier-cypress.mjs --output cypress-results.json"',
855
+ ].join("\n"));
856
+ return 0;
857
+ }
858
+
522
859
  function normalizedHeader(value, name) {
523
860
  if (value == null || value === "") return "";
524
861
  const normalized = String(value);
@@ -805,6 +1142,7 @@ function helpText() {
805
1142
 
806
1143
  Usage:
807
1144
  testdossier init One-time setup: create ${ENV_FILE_NAME} and gitignore it
1145
+ testdossier setup cypress Create a reviewable Cypress helper without overwriting files
808
1146
  testdossier verify Check the token and destination without sending a report
809
1147
  testdossier upload <report-file> [options]
810
1148
  testdossier <report-file> [options] "upload" may be omitted
@@ -845,6 +1183,7 @@ Large reports:
845
1183
 
846
1184
  Examples:
847
1185
  npx testdossier init
1186
+ npx testdossier setup cypress
848
1187
  npx testdossier verify
849
1188
  npx testdossier upload playwright-results.json
850
1189
  npx testdossier upload junit-results.xml --build abc123
@@ -909,6 +1248,12 @@ export async function runCli(
909
1248
  if (args.length > 1) throw new CliError("init takes no arguments.");
910
1249
  return await runInit({ cwd, stdout });
911
1250
  }
1251
+ if (args[0] === "setup") {
1252
+ if (args.length !== 2 || args[1] !== "cypress") {
1253
+ throw new CliError("setup requires exactly one supported target: cypress.");
1254
+ }
1255
+ return await runCypressSetup({ cwd, stdout });
1256
+ }
912
1257
  if (args[0] === "verify") {
913
1258
  const options = parseUploadArgs(args.slice(1));
914
1259
  if (options.help) {
@@ -981,13 +1326,62 @@ export async function runCli(
981
1326
  const reportText = report.toString("utf8");
982
1327
  const requestedFormat = options.format !== "auto" ? options.format : (config && config.format) || "auto";
983
1328
  const { format, document } = analyzeReport(reportText, requestedFormat);
984
- const pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
985
- const testCount = pages.reduce((sum, page) => sum + page.tests, 0);
1329
+ let playwrightUpload = null;
1330
+ let pages;
1331
+ if (format === "playwright" && document) {
1332
+ playwrightUpload = await planPlaywrightUploadPages(document, options.reportPath);
1333
+ pages = playwrightUpload.pages;
1334
+ if (playwrightUpload.inlined || playwrightUpload.skipped) {
1335
+ stdout(`Prepared ${playwrightUpload.inlined} failure screenshot${playwrightUpload.inlined === 1 ? "" : "s"} for durable CI evidence${playwrightUpload.skipped ? ` (${playwrightUpload.skipped} skipped)` : ""}.`);
1336
+ }
1337
+ } else {
1338
+ pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
1339
+ }
1340
+ const testCount = pages.reduce((sum, page) => sum + (page.testCount ?? page.tests), 0);
1341
+ const metadata = inferredMetadata(options, env);
1342
+ let sourceCaptured = 0;
1343
+ let sourceSkipped = 0;
1344
+ const sourcePaths = new Map();
1345
+ if (config?.source) {
1346
+ for (const page of pages) {
1347
+ // The source mapper only needs the report structure. Playwright's
1348
+ // failure screenshots remain lazily materialized at upload time.
1349
+ const structuralBody = playwrightUpload
1350
+ ? playwrightUploadPageBody(document, page.tests, playwrightUpload.evidenceByTest, new Map(), false)
1351
+ : page.body;
1352
+ const source = await collectSourceContexts({
1353
+ format,
1354
+ body: structuralBody,
1355
+ cwd,
1356
+ config: config.source,
1357
+ commit: metadata.build,
1358
+ });
1359
+ page.sourceCarriers = source.carriers;
1360
+ sourceCaptured += source.captured.length;
1361
+ sourceSkipped += source.skipped.length;
1362
+ for (const context of source.captured) {
1363
+ const key = `${context.path}:${context.start_line}-${context.end_line}`;
1364
+ if (!sourcePaths.has(key)) sourcePaths.set(key, context);
1365
+ }
1366
+ }
1367
+ stdout(`Source capture: ${sourceCaptured} of ${testCount} test records captured (${sourcePaths.size} unique snippet${sourcePaths.size === 1 ? "" : "s"}); ${sourceSkipped} skipped.`);
1368
+ const sourcePreviewLimit = 20;
1369
+ for (const context of [...sourcePaths.values()].slice(0, sourcePreviewLimit)) {
1370
+ stdout(` ${context.path}:${context.start_line}-${context.end_line} · ${context.digest.slice(0, 23)}${context.truncated ? " · truncated" : ""}${context.redactions ? ` · ${context.redactions} redaction${context.redactions === 1 ? "" : "s"}` : ""}`);
1371
+ }
1372
+ if (sourcePaths.size > sourcePreviewLimit) {
1373
+ stdout(` … ${sourcePaths.size - sourcePreviewLimit} more safe snippet paths omitted from console output.`);
1374
+ }
1375
+ if (sourceSkipped) stderr(`Warning: ${sourceSkipped} test record${sourceSkipped === 1 ? " had" : "s had"} no safe, readable source file and will upload without source context.`);
1376
+ }
1377
+ const validatedBytes = playwrightUpload
1378
+ ? pages.reduce((sum, page) => sum + page.estimatedBytes, 0)
1379
+ : report.byteLength;
986
1380
 
987
1381
  const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
988
1382
  const destination = new URL(endpoint).origin;
989
1383
  stdout(
990
- `Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes, ${testCount} tests` +
1384
+ `Validated ${FORMAT_LABELS[format]} (${validatedBytes} bytes, ${testCount} tests` +
991
1385
  `${pages.length > 1 ? ` across ${pages.length} pages` : ""}).`,
992
1386
  );
993
1387
  if (options.dryRun) {
@@ -998,7 +1392,6 @@ export async function runCli(
998
1392
  const token = await resolveToken(options, env, cwd, stdout, stderr);
999
1393
  if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
1000
1394
 
1001
- const metadata = inferredMetadata(options, env);
1002
1395
  const baseHeaders = {
1003
1396
  Authorization: `Bearer ${token}`,
1004
1397
  "Content-Type": format === "junit" ? "application/xml" : "application/json",
@@ -1014,7 +1407,7 @@ export async function runCli(
1014
1407
 
1015
1408
  if (pages.length > 1) {
1016
1409
  stdout(
1017
- `Uploading ${pages.reduce((sum, page) => sum + (page.tests || 0), 0)} tests in ` +
1410
+ `Uploading ${pages.reduce((sum, page) => sum + (page.testCount ?? page.tests ?? 0), 0)} tests in ` +
1018
1411
  `${pages.length} pages (run ${metadata.runId}).`,
1019
1412
  );
1020
1413
  }
@@ -1026,23 +1419,39 @@ export async function runCli(
1026
1419
  const pageResults = [];
1027
1420
  for (let index = 0; index < pages.length; index += 1) {
1028
1421
  const page = pages[index];
1422
+ const materialized = playwrightUpload
1423
+ ? await materializePlaywrightUploadPage(document, page, playwrightUpload.evidenceByTest)
1424
+ : null;
1425
+ if (materialized?.skipped) {
1426
+ stderr(`Warning: ${materialized.skipped} failure screenshot${materialized.skipped === 1 ? " was" : "s were"} no longer readable and were skipped.`);
1427
+ }
1428
+ const rawBody = materialized ? materialized.body : page.body;
1429
+ const uploadBody = wrapSourceEnvelope(format, rawBody, page.sourceCarriers);
1430
+ if (uploadBody.byteLength > MAX_REPORT_BYTES) {
1431
+ throw new CliError("one report page plus source context exceeds the 25 MiB request limit; reduce the source window or screenshot size.");
1432
+ }
1029
1433
  const result = await postWithRetry({
1030
1434
  endpoint,
1031
1435
  headers: {
1032
1436
  ...baseHeaders,
1437
+ ...(page.sourceCarriers?.length ? {
1438
+ "Content-Type": "application/json",
1439
+ "X-TD-Source-Envelope": "1",
1440
+ "X-CI-Format": format,
1441
+ } : {}),
1033
1442
  "X-CI-Page-Id": pages.length === 1 ? pageBase : `${pageBase}-${index + 1}`,
1034
1443
  "X-CI-Upload-Id": pageBase,
1035
1444
  "X-CI-Page-Index": String(index + 1),
1036
1445
  "X-CI-Page-Count": String(pages.length),
1037
1446
  },
1038
- body: page.body,
1447
+ body: uploadBody,
1039
1448
  env,
1040
1449
  fetchImpl,
1041
1450
  stderr,
1042
1451
  });
1043
1452
  pageResults.push(result);
1044
1453
  if (pages.length > 1) {
1045
- stdout(`Uploaded page ${index + 1}/${pages.length} (${page.tests} tests).`);
1454
+ stdout(`Uploaded page ${index + 1}/${pages.length} (${page.testCount ?? page.tests} tests).`);
1046
1455
  }
1047
1456
  }
1048
1457
  stdout(`Uploaded ${FORMAT_LABELS[format]} to ${destination} (run ${metadata.runId}).`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdossier",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "Upload Playwright, Cypress, Cucumber, JUnit, and JSON test evidence to TestDossier.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "bin",
12
12
  "lib",
13
+ "templates",
13
14
  "assets",
14
15
  "README.md",
15
16
  "LICENSE",
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // TestDossier Cypress runner — zero dependencies beyond the project's Cypress.
3
+ // Uses Cypress's Module API so the output is the native { runs: [...] } shape
4
+ // accepted by X-CI-Format: cypress. Failure screenshots are embedded as data
5
+ // URLs; /api/ci/ingest uploads them to R2 and stores only durable references.
6
+
7
+ import cypress from "cypress";
8
+ import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
9
+ import { extname, resolve } from "node:path";
10
+
11
+ const args = process.argv.slice(2);
12
+ const outputFlag = args.indexOf("--output");
13
+ const output = outputFlag >= 0 && args[outputFlag + 1]
14
+ ? args[outputFlag + 1]
15
+ : "cypress-results.json";
16
+ const cypressArgs = outputFlag >= 0
17
+ ? args.filter((_, i) => i !== outputFlag && i !== outputFlag + 1)
18
+ : args;
19
+
20
+ const MIME = {
21
+ ".png": "image/png",
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".gif": "image/gif",
25
+ ".webp": "image/webp",
26
+ };
27
+ const MAX_SCREENSHOTS = 20;
28
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
29
+ // Base64 adds roughly 33%; keeping raw bytes at 15 MB leaves room under the
30
+ // ingest endpoint's 25 MB request limit for the Cypress result JSON itself.
31
+ const MAX_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024;
32
+
33
+ const options = {};
34
+ for (let i = 0; i < cypressArgs.length; i += 1) {
35
+ const arg = cypressArgs[i];
36
+ if (arg === "--browser" && cypressArgs[i + 1]) options.browser = cypressArgs[++i];
37
+ else if (arg === "--spec" && cypressArgs[i + 1]) options.spec = cypressArgs[++i];
38
+ else if (arg === "--config-file" && cypressArgs[i + 1]) options.configFile = cypressArgs[++i];
39
+ else if (arg === "--component") options.component = true;
40
+ else if (arg === "--headed") options.headed = true;
41
+ }
42
+
43
+ let result;
44
+ try {
45
+ result = await cypress.run(options);
46
+ } catch (error) {
47
+ console.error("Cypress could not start:", error instanceof Error ? error.message : error);
48
+ process.exit(2);
49
+ }
50
+
51
+ if (!result || !Array.isArray(result.runs)) {
52
+ console.error("Cypress did not return a test-run result:", result?.message || "unknown error");
53
+ process.exit(2);
54
+ }
55
+
56
+ let embedded = 0;
57
+ let embeddedBytes = 0;
58
+ let skipped = 0;
59
+ for (const run of result.runs) {
60
+ for (const screenshot of run.screenshots || []) {
61
+ const path = resolve(String(screenshot.path || ""));
62
+ const mime = MIME[extname(path).toLowerCase()];
63
+ if (!path || !mime || !existsSync(path)) { skipped += 1; continue; }
64
+ const bytes = statSync(path).size;
65
+ if (
66
+ bytes > MAX_IMAGE_BYTES ||
67
+ embedded >= MAX_SCREENSHOTS ||
68
+ embeddedBytes + bytes > MAX_TOTAL_IMAGE_BYTES
69
+ ) {
70
+ skipped += 1;
71
+ continue;
72
+ }
73
+ screenshot.data_url = `data:${mime};base64,${readFileSync(path).toString("base64")}`;
74
+ screenshot.content_type = mime;
75
+ embedded += 1;
76
+ embeddedBytes += bytes;
77
+ }
78
+ }
79
+
80
+ writeFileSync(output, JSON.stringify(result));
81
+ console.log(`Wrote ${output}: ${result.totalTests || 0} test(s), ${embedded} screenshot(s) embedded${skipped ? `, ${skipped} skipped by size/count limits` : ""}.`);
82
+ console.warn("Review CI screenshots for secrets or personal data before sharing them externally.");
83
+
84
+ const failed = Number(result.totalFailed || 0) || result.runs.reduce(
85
+ (sum, run) => sum + Number(run?.stats?.failures || 0),
86
+ 0,
87
+ );
88
+ process.exitCode = failed > 0 ? 1 : 0;