testdossier 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,14 @@
1
+ <img src="https://raw.githubusercontent.com/devkalu/test-dossier/main/cli/assets/mark.svg" width="44" height="44" alt="TestDossier">
2
+
1
3
  # TestDossier CLI
2
4
 
5
+ **Publish test evidence from CI without adding another agent, daemon, or hosted runner.**
6
+
3
7
  Upload a completed test report from a developer laptop, test machine, or CI
4
8
  job. Run these commands in the project containing your automated tests:
5
9
 
6
10
  1. Install the latest CLI in the project. To pin a release instead, replace
7
- `@latest` with an exact version such as `@0.2.2`:
11
+ `@latest` with an exact version such as `@0.3.0`:
8
12
 
9
13
  ```bash
10
14
  npm install --save-dev testdossier@latest
@@ -66,9 +70,11 @@ requires Node.js 18 or newer.
66
70
 
67
71
  ## Why this is intentionally small
68
72
 
69
- The uploader does one thing: read a completed report and make an outbound HTTPS
70
- request to TestDossier. It does not run tests, accept remote commands, install a
71
- 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.
72
78
 
73
79
  The access token is read from the first available source: an explicitly passed
74
80
  `--env-file`, the `TD_CI_TOKEN` process environment, then `.env.testdossier` in
@@ -104,6 +110,43 @@ creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
104
110
  `--format cucumber`, `--format junit`, or `--format generic`. The `upload`
105
111
  word may be omitted: `npx testdossier playwright-results.json` works too.
106
112
 
113
+ For Playwright, configure `screenshot: 'only-on-failure'`. During upload the
114
+ CLI reads one screenshot from each test whose final attempt failed and sends it
115
+ through TestDossier's durable image path. Screenshots from passing tests and
116
+ earlier retries are omitted, while video/trace files remain link-first. Every
117
+ CI run stays immutable; identical images are hash-deduplicated in storage.
118
+
119
+ ### Generate the Cypress helper from the installed package
120
+
121
+ Cypress's normal command does not write its complete Module API result to a
122
+ file, and local screenshot paths are not enough once the runner disappears.
123
+ After installing TestDossier in the client repository, generate the helper
124
+ from that installed, lockfile-pinned version:
125
+
126
+ ```bash
127
+ npx testdossier setup cypress
128
+ ```
129
+
130
+ The command creates only `scripts/dossier-cypress.mjs`, prints its SHA-256
131
+ digest, and never downloads code or contacts TestDossier. It is safe to run
132
+ again when the file still matches. If the file has been edited or came from a
133
+ different template, setup stops and leaves it untouched—there is deliberately
134
+ no force-overwrite option.
135
+
136
+ Review the generated file before committing it, then add a project script:
137
+
138
+ ```json
139
+ {
140
+ "scripts": {
141
+ "cy:testdossier": "node scripts/dossier-cypress.mjs --output cypress-results.json"
142
+ }
143
+ }
144
+ ```
145
+
146
+ Run `npm run cy:testdossier`, then upload `cypress-results.json`. The helper
147
+ uses the client project's own Cypress dependency; TestDossier does not bundle
148
+ or silently install Cypress.
149
+
107
150
  ## Local setup
108
151
 
109
152
  For repeat use, install and pin the CLI in the test repository so its source
@@ -114,7 +157,7 @@ npm install --save-dev testdossier@latest
114
157
  npx testdossier init
115
158
  ```
116
159
 
117
- Use `npm install --save-dev testdossier@0.2.2` when the repository should pin
160
+ Use `npm install --save-dev testdossier@0.3.0` when the repository should pin
118
161
  that exact release. In either case, subsequent commands remain
119
162
  `npx testdossier ...`; the version selector belongs only to installation.
120
163
 
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TestDossier">
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 6h32v14H2z"/>
5
+ </svg>
@@ -1,14 +1,20 @@
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
4
 
5
- export const VERSION = "0.2.2";
5
+ export const VERSION = "0.3.0";
6
6
  const DEFAULT_ORIGIN = "https://testdossier.com";
7
7
  const ENV_FILE_NAME = ".env.testdossier";
8
8
  const CONFIG_FILE_NAME = "testdossier.json";
9
+ const CYPRESS_HELPER_PATH = "scripts/dossier-cypress.mjs";
10
+ const CYPRESS_HELPER_TEMPLATE_URL = new URL("../templates/dossier-cypress.mjs", import.meta.url);
9
11
  const TOKEN_PLACEHOLDER = "replace_with_your_td_token";
10
12
  const REPORT_FORMATS = new Set(["cypress", "playwright", "cucumber", "junit", "generic"]);
11
13
  const MAX_REPORT_BYTES = 25 * 1024 * 1024;
14
+ // Keep encoded bodies below both the endpoint's 25 MiB request ceiling and
15
+ // its 16 MiB decoded-evidence budget (base64 expands bytes by roughly 4/3).
16
+ const MAX_PAGE_BYTES = 20 * 1024 * 1024;
17
+ const MAX_FAILURE_SCREENSHOT_BYTES = 10 * 1024 * 1024;
12
18
  const MAX_TESTS_PER_PAGE = 200;
13
19
  const RETRYABLE_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
14
20
  const FORMAT_LABELS = {
@@ -130,6 +136,86 @@ export function detectReportFormat(text) {
130
136
  return analyzeReport(text).format;
131
137
  }
132
138
 
139
+ const SCREENSHOT_MIME = new Map([
140
+ [".png", "image/png"], [".jpg", "image/jpeg"],
141
+ [".jpeg", "image/jpeg"], [".gif", "image/gif"], [".webp", "image/webp"],
142
+ ]);
143
+
144
+ function isPlaywrightScreenshot(att) {
145
+ if (!isObject(att)) return false;
146
+ const contentType = String(att.contentType || "").toLowerCase();
147
+ const extension = extname(String(att.path || att.name || "")).toLowerCase();
148
+ return contentType.startsWith("image/") || SCREENSHOT_MIME.has(extension);
149
+ }
150
+
151
+ function playwrightFinalFailure(test, result) {
152
+ const status = String(result && result.status || "").toLowerCase();
153
+ if (status === "timedout" || status === "interrupted") return true;
154
+ if (status !== "failed") return false;
155
+ return String(test && test.expectedStatus || "passed").toLowerCase() !== "failed";
156
+ }
157
+
158
+ /** Promote one screenshot from each final Playwright failure into the inline
159
+ * transport understood by the ingest endpoint. Earlier-retry screenshots are
160
+ * deliberately omitted from this upload copy to keep evidence bounded. */
161
+ export async function hydratePlaywrightFailureScreenshots(document, reportPath) {
162
+ if (!isObject(document) || !Array.isArray(document.suites)) return { inlined: 0, skipped: 0 };
163
+ let inlined = 0;
164
+ let skipped = 0;
165
+
166
+ const visitSuites = async (suites) => {
167
+ for (const suite of suites || []) {
168
+ for (const spec of Array.isArray(suite && suite.specs) ? suite.specs : []) {
169
+ for (const test of Array.isArray(spec && spec.tests) ? spec.tests : []) {
170
+ const results = Array.isArray(test && test.results) ? test.results : [];
171
+ const ordered = results.slice().sort((a, b) => Number(a.retry || 0) - Number(b.retry || 0));
172
+ const finalResult = ordered[ordered.length - 1];
173
+ const finalAttachments = Array.isArray(finalResult && finalResult.attachments) ? finalResult.attachments : [];
174
+ const selected = finalResult && playwrightFinalFailure(test, finalResult)
175
+ ? finalAttachments.find((att) => isPlaywrightScreenshot(att) && typeof att.path === "string" && att.path.trim()) || null
176
+ : null;
177
+
178
+ for (const result of results) {
179
+ if (!Array.isArray(result.attachments)) continue;
180
+ const next = [];
181
+ for (const att of result.attachments) {
182
+ if (!isPlaywrightScreenshot(att)) { next.push(att); continue; }
183
+ if (att !== selected) continue;
184
+ const rawPath = String(att.path || "").trim();
185
+ const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(dirname(reportPath), rawPath);
186
+ const mime = SCREENSHOT_MIME.get(extname(absolutePath).toLowerCase())
187
+ || (/^image\/(png|jpeg|gif|webp)$/i.test(String(att.contentType || "")) ? String(att.contentType).toLowerCase() : "");
188
+ try {
189
+ const info = await stat(absolutePath);
190
+ if (!mime || !info.isFile() || info.size <= 0 || info.size > MAX_FAILURE_SCREENSHOT_BYTES) {
191
+ skipped += 1;
192
+ continue;
193
+ }
194
+ const bytes = await readFile(absolutePath);
195
+ next.push({
196
+ ...att,
197
+ name: att.name || basename(absolutePath),
198
+ contentType: mime,
199
+ data_url: `data:${mime};base64,${bytes.toString("base64")}`,
200
+ path: undefined,
201
+ });
202
+ inlined += 1;
203
+ } catch {
204
+ skipped += 1;
205
+ }
206
+ }
207
+ result.attachments = next;
208
+ }
209
+ }
210
+ }
211
+ if (Array.isArray(suite && suite.suites)) await visitSuites(suite.suites);
212
+ }
213
+ };
214
+
215
+ await visitSuites(document.suites);
216
+ return { inlined, skipped };
217
+ }
218
+
133
219
  function playwrightTestNodes(suites, found = []) {
134
220
  for (const suite of Array.isArray(suites) ? suites : []) {
135
221
  if (!suite || typeof suite !== "object") continue;
@@ -160,6 +246,175 @@ function selectedPlaywrightSuite(suite, selectedTests) {
160
246
  return next.specs.length > 0 || next.suites.length > 0 ? next : null;
161
247
  }
162
248
 
249
+ async function inspectPlaywrightFailureScreenshot(test, reportPath) {
250
+ const results = Array.isArray(test && test.results) ? test.results : [];
251
+ const ordered = results.slice().sort((a, b) => Number(a.retry || 0) - Number(b.retry || 0));
252
+ const finalResult = ordered[ordered.length - 1];
253
+ const attachments = Array.isArray(finalResult && finalResult.attachments) ? finalResult.attachments : [];
254
+ const selected = finalResult && playwrightFinalFailure(test, finalResult)
255
+ ? attachments.find((att) => isPlaywrightScreenshot(att) && typeof att.path === "string" && att.path.trim()) || null
256
+ : null;
257
+ if (!selected) return { selected: null, skipped: 0, dataUrlLength: 0 };
258
+
259
+ const rawPath = String(selected.path || "").trim();
260
+ const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(dirname(reportPath), rawPath);
261
+ const mime = SCREENSHOT_MIME.get(extname(absolutePath).toLowerCase())
262
+ || (/^image\/(png|jpeg|gif|webp)$/i.test(String(selected.contentType || "")) ? String(selected.contentType).toLowerCase() : "");
263
+ try {
264
+ const info = await stat(absolutePath);
265
+ if (!mime || !info.isFile() || info.size <= 0 || info.size > MAX_FAILURE_SCREENSHOT_BYTES) {
266
+ return { selected, skipped: 1, dataUrlLength: 0 };
267
+ }
268
+ const prefix = `data:${mime};base64,`;
269
+ return {
270
+ selected,
271
+ absolutePath,
272
+ mime,
273
+ size: info.size,
274
+ skipped: 0,
275
+ dataUrlLength: prefix.length + 4 * Math.ceil(info.size / 3),
276
+ };
277
+ } catch {
278
+ return { selected, skipped: 1, dataUrlLength: 0 };
279
+ }
280
+ }
281
+
282
+ function playwrightUploadTest(test, evidence, dataUrl, estimate) {
283
+ return {
284
+ ...test,
285
+ results: (Array.isArray(test && test.results) ? test.results : []).map((result) => ({
286
+ ...result,
287
+ attachments: (Array.isArray(result && result.attachments) ? result.attachments : []).flatMap((att) => {
288
+ if (!isPlaywrightScreenshot(att)) return [att];
289
+ if (att !== evidence.selected || !evidence.absolutePath || !evidence.mime) return [];
290
+ if (!estimate && typeof dataUrl !== "string") return [];
291
+ return [{
292
+ ...att,
293
+ name: att.name || basename(evidence.absolutePath),
294
+ contentType: evidence.mime,
295
+ data_url: estimate ? "" : dataUrl,
296
+ path: undefined,
297
+ }];
298
+ }),
299
+ })),
300
+ };
301
+ }
302
+
303
+ function playwrightUploadPageBody(document, tests, evidenceByTest, dataUrls = null, estimate = false) {
304
+ const selected = new Set(tests);
305
+ const replacements = new Map(tests.map((test) => [
306
+ test,
307
+ playwrightUploadTest(test, evidenceByTest.get(test), dataUrls && dataUrls.get(test), estimate),
308
+ ]));
309
+ const selectSuite = (suite) => {
310
+ if (!suite || typeof suite !== "object") return null;
311
+ const next = { ...suite };
312
+ next.specs = (Array.isArray(suite.specs) ? suite.specs : [])
313
+ .filter((spec) => spec && typeof spec === "object")
314
+ .map((spec) => ({
315
+ ...spec,
316
+ tests: (Array.isArray(spec.tests) ? spec.tests : [])
317
+ .filter((test) => selected.has(test))
318
+ .map((test) => replacements.get(test)),
319
+ }))
320
+ .filter((spec) => spec.tests.length > 0);
321
+ next.suites = (Array.isArray(suite.suites) ? suite.suites : [])
322
+ .map(selectSuite)
323
+ .filter(Boolean);
324
+ return next.specs.length > 0 || next.suites.length > 0 ? next : null;
325
+ };
326
+ const suites = document.suites.map(selectSuite).filter(Boolean);
327
+ return Buffer.from(JSON.stringify({ ...document, suites }));
328
+ }
329
+
330
+ function estimatedPlaywrightUploadPageBytes(document, tests, evidenceByTest) {
331
+ const placeholder = playwrightUploadPageBody(document, tests, evidenceByTest, null, true);
332
+ return placeholder.byteLength + tests.reduce(
333
+ (sum, test) => sum + Number(evidenceByTest.get(test)?.dataUrlLength || 0),
334
+ 0,
335
+ );
336
+ }
337
+
338
+ /** Plan Playwright pages using screenshot stat metadata only. No image bytes or
339
+ * base64 strings are retained here, so a failure storm cannot exhaust the Node
340
+ * heap before the first request is sent. */
341
+ export async function planPlaywrightUploadPages(
342
+ document,
343
+ reportPath,
344
+ maxTests = MAX_TESTS_PER_PAGE,
345
+ maxPageBytes = MAX_PAGE_BYTES,
346
+ ) {
347
+ const tests = playwrightTestNodes(document.suites);
348
+ const evidenceByTest = new Map();
349
+ let inlined = 0;
350
+ let skipped = 0;
351
+ for (const test of tests) {
352
+ const evidence = await inspectPlaywrightFailureScreenshot(test, reportPath);
353
+ evidenceByTest.set(test, evidence);
354
+ if (evidence.dataUrlLength > 0) inlined += 1;
355
+ skipped += evidence.skipped;
356
+ }
357
+
358
+ const pages = [];
359
+ let current = [];
360
+ const pushCurrent = () => {
361
+ if (!current.length) return;
362
+ pages.push({
363
+ tests: current.slice(),
364
+ testCount: current.length,
365
+ estimatedBytes: estimatedPlaywrightUploadPageBytes(document, current, evidenceByTest),
366
+ maxPageBytes,
367
+ });
368
+ };
369
+ for (const test of tests) {
370
+ const candidate = [...current, test];
371
+ const estimatedBytes = estimatedPlaywrightUploadPageBytes(document, candidate, evidenceByTest);
372
+ if (candidate.length > maxTests || estimatedBytes > maxPageBytes) {
373
+ if (current.length === 0) {
374
+ throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
375
+ }
376
+ pushCurrent();
377
+ current = [test];
378
+ if (estimatedPlaywrightUploadPageBytes(document, current, evidenceByTest) > maxPageBytes) {
379
+ throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
380
+ }
381
+ } else {
382
+ current = candidate;
383
+ }
384
+ }
385
+ pushCurrent();
386
+ if (pages.length === 0) {
387
+ pages.push({ tests: [], testCount: 0, estimatedBytes: playwrightUploadPageBody(document, [], evidenceByTest).byteLength, maxPageBytes });
388
+ }
389
+ return { pages, evidenceByTest, inlined, skipped };
390
+ }
391
+
392
+ /** Read and encode only one already-planned page. The caller uploads it before
393
+ * materializing the next page, bounding live screenshot data to one request. */
394
+ export async function materializePlaywrightUploadPage(document, page, evidenceByTest) {
395
+ const dataUrls = new Map();
396
+ let skipped = 0;
397
+ for (const test of page.tests) {
398
+ const evidence = evidenceByTest.get(test);
399
+ if (!evidence?.absolutePath || !evidence.mime) continue;
400
+ try {
401
+ const bytes = await readFile(evidence.absolutePath);
402
+ if (bytes.length <= 0 || bytes.length > MAX_FAILURE_SCREENSHOT_BYTES) {
403
+ skipped += 1;
404
+ continue;
405
+ }
406
+ dataUrls.set(test, `data:${evidence.mime};base64,${bytes.toString("base64")}`);
407
+ } catch {
408
+ skipped += 1;
409
+ }
410
+ }
411
+ const body = playwrightUploadPageBody(document, page.tests, evidenceByTest, dataUrls, false);
412
+ if (body.byteLength > page.maxPageBytes) {
413
+ throw new CliError("one Playwright page exceeds the 20 MiB page limit because its screenshot changed during upload; reduce the screenshot size.");
414
+ }
415
+ return { body, skipped };
416
+ }
417
+
163
418
  // Counts <testcase> elements the way the server's parser does: real tags
164
419
  // only, so a failure message inside CDATA that happens to contain
165
420
  // "<testcase" is not counted.
@@ -231,7 +486,7 @@ function selectedCypressRun(run, selectedTests) {
231
486
  };
232
487
  }
233
488
 
234
- export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null) {
489
+ export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null, maxPageBytes = MAX_PAGE_BYTES) {
235
490
  if (!Number.isInteger(maxTests) || maxTests < 1 || maxTests > MAX_TESTS_PER_PAGE) {
236
491
  throw new CliError(`page size must be an integer from 1 to ${MAX_TESTS_PER_PAGE}.`);
237
492
  }
@@ -259,14 +514,37 @@ export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE,
259
514
 
260
515
  if (format === "playwright") {
261
516
  const tests = playwrightTestNodes(doc.suites);
262
- if (tests.length <= maxTests) return [{ body: report, tests: tests.length }];
263
- return pagedSlices(tests, maxTests).map((slice) => {
517
+ const pageBody = (slice) => {
264
518
  const selected = new Set(slice);
265
519
  const suites = doc.suites
266
520
  .map((suite) => selectedPlaywrightSuite(suite, selected))
267
521
  .filter(Boolean);
268
- return { body: Buffer.from(JSON.stringify({ ...doc, suites })), tests: slice.length };
269
- });
522
+ return Buffer.from(JSON.stringify({ ...doc, suites }));
523
+ };
524
+ if (tests.length <= maxTests && report.byteLength <= maxPageBytes) {
525
+ return [{ body: report, tests: tests.length }];
526
+ }
527
+ const pages = [];
528
+ let current = [];
529
+ for (const test of tests) {
530
+ const candidate = [...current, test];
531
+ const candidateBody = pageBody(candidate);
532
+ if (candidate.length > maxTests || candidateBody.byteLength > maxPageBytes) {
533
+ if (current.length === 0) {
534
+ throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
535
+ }
536
+ const body = pageBody(current);
537
+ pages.push({ body, tests: current.length });
538
+ current = [test];
539
+ if (pageBody(current).byteLength > maxPageBytes) {
540
+ throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
541
+ }
542
+ } else {
543
+ current = candidate;
544
+ }
545
+ }
546
+ if (current.length > 0) pages.push({ body: pageBody(current), tests: current.length });
547
+ return pages;
270
548
  }
271
549
 
272
550
  if (format === "cypress") {
@@ -519,6 +797,57 @@ async function runInit({ cwd, stdout }) {
519
797
  return 0;
520
798
  }
521
799
 
800
+ async function runCypressSetup({ cwd, stdout }) {
801
+ let template;
802
+ try {
803
+ template = await readFile(CYPRESS_HELPER_TEMPLATE_URL, "utf8");
804
+ } catch (error) {
805
+ throw new CliError(`installed Cypress helper template is unavailable: ${error && error.message}`);
806
+ }
807
+ const targetPath = join(cwd, "scripts", "dossier-cypress.mjs");
808
+ const digest = createHash("sha256").update(template).digest("hex");
809
+
810
+ const describeExisting = async () => {
811
+ let existing;
812
+ try {
813
+ existing = await readFile(targetPath, "utf8");
814
+ } catch (error) {
815
+ if (error && error.code === "ENOENT") return false;
816
+ throw new CliError(
817
+ `${CYPRESS_HELPER_PATH} already exists but could not be read; left unchanged: ${error && error.message}`,
818
+ );
819
+ }
820
+ if (existing !== template) {
821
+ throw new CliError(
822
+ `${CYPRESS_HELPER_PATH} already exists and differs from the testdossier ${VERSION} template; ` +
823
+ "left unchanged. Review or remove it before trying again.",
824
+ );
825
+ }
826
+ stdout(`${CYPRESS_HELPER_PATH} already matches testdossier ${VERSION}; left unchanged.`);
827
+ stdout(`SHA-256 ${digest}`);
828
+ return true;
829
+ };
830
+
831
+ if (await describeExisting()) return 0;
832
+ try {
833
+ await mkdir(dirname(targetPath), { recursive: true });
834
+ await writeFile(targetPath, template, { flag: "wx", mode: 0o644 });
835
+ } catch (error) {
836
+ // Another process may have created the fixed target after our first read.
837
+ // Re-check it rather than overwriting or reporting a false failure.
838
+ if (error && error.code === "EEXIST" && await describeExisting()) return 0;
839
+ throw new CliError(`could not create ${CYPRESS_HELPER_PATH}: ${error && error.message}`);
840
+ }
841
+
842
+ stdout(`Created ${CYPRESS_HELPER_PATH} from installed testdossier ${VERSION}.`);
843
+ stdout(`SHA-256 ${digest}`);
844
+ stdout([
845
+ "Review the generated file before committing it, then add this script to package.json:",
846
+ ' "cy:testdossier": "node scripts/dossier-cypress.mjs --output cypress-results.json"',
847
+ ].join("\n"));
848
+ return 0;
849
+ }
850
+
522
851
  function normalizedHeader(value, name) {
523
852
  if (value == null || value === "") return "";
524
853
  const normalized = String(value);
@@ -800,10 +1129,12 @@ async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr,
800
1129
  }
801
1130
 
802
1131
  function helpText() {
803
- return `TestDossier report uploader ${VERSION}
1132
+ return `TD TestDossier
1133
+ CI report uploader · ${VERSION}
804
1134
 
805
1135
  Usage:
806
1136
  testdossier init One-time setup: create ${ENV_FILE_NAME} and gitignore it
1137
+ testdossier setup cypress Create a reviewable Cypress helper without overwriting files
807
1138
  testdossier verify Check the token and destination without sending a report
808
1139
  testdossier upload <report-file> [options]
809
1140
  testdossier <report-file> [options] "upload" may be omitted
@@ -844,6 +1175,7 @@ Large reports:
844
1175
 
845
1176
  Examples:
846
1177
  npx testdossier init
1178
+ npx testdossier setup cypress
847
1179
  npx testdossier verify
848
1180
  npx testdossier upload playwright-results.json
849
1181
  npx testdossier upload junit-results.xml --build abc123
@@ -908,6 +1240,12 @@ export async function runCli(
908
1240
  if (args.length > 1) throw new CliError("init takes no arguments.");
909
1241
  return await runInit({ cwd, stdout });
910
1242
  }
1243
+ if (args[0] === "setup") {
1244
+ if (args.length !== 2 || args[1] !== "cypress") {
1245
+ throw new CliError("setup requires exactly one supported target: cypress.");
1246
+ }
1247
+ return await runCypressSetup({ cwd, stdout });
1248
+ }
911
1249
  if (args[0] === "verify") {
912
1250
  const options = parseUploadArgs(args.slice(1));
913
1251
  if (options.help) {
@@ -980,13 +1318,26 @@ export async function runCli(
980
1318
  const reportText = report.toString("utf8");
981
1319
  const requestedFormat = options.format !== "auto" ? options.format : (config && config.format) || "auto";
982
1320
  const { format, document } = analyzeReport(reportText, requestedFormat);
983
- const pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
984
- const testCount = pages.reduce((sum, page) => sum + page.tests, 0);
1321
+ let playwrightUpload = null;
1322
+ let pages;
1323
+ if (format === "playwright" && document) {
1324
+ playwrightUpload = await planPlaywrightUploadPages(document, options.reportPath);
1325
+ pages = playwrightUpload.pages;
1326
+ if (playwrightUpload.inlined || playwrightUpload.skipped) {
1327
+ stdout(`Prepared ${playwrightUpload.inlined} failure screenshot${playwrightUpload.inlined === 1 ? "" : "s"} for durable CI evidence${playwrightUpload.skipped ? ` (${playwrightUpload.skipped} skipped)` : ""}.`);
1328
+ }
1329
+ } else {
1330
+ pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
1331
+ }
1332
+ const testCount = pages.reduce((sum, page) => sum + (page.testCount ?? page.tests), 0);
1333
+ const validatedBytes = playwrightUpload
1334
+ ? pages.reduce((sum, page) => sum + page.estimatedBytes, 0)
1335
+ : report.byteLength;
985
1336
 
986
1337
  const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
987
1338
  const destination = new URL(endpoint).origin;
988
1339
  stdout(
989
- `Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes, ${testCount} tests` +
1340
+ `Validated ${FORMAT_LABELS[format]} (${validatedBytes} bytes, ${testCount} tests` +
990
1341
  `${pages.length > 1 ? ` across ${pages.length} pages` : ""}).`,
991
1342
  );
992
1343
  if (options.dryRun) {
@@ -1013,7 +1364,7 @@ export async function runCli(
1013
1364
 
1014
1365
  if (pages.length > 1) {
1015
1366
  stdout(
1016
- `Uploading ${pages.reduce((sum, page) => sum + (page.tests || 0), 0)} tests in ` +
1367
+ `Uploading ${pages.reduce((sum, page) => sum + (page.testCount ?? page.tests ?? 0), 0)} tests in ` +
1017
1368
  `${pages.length} pages (run ${metadata.runId}).`,
1018
1369
  );
1019
1370
  }
@@ -1025,6 +1376,12 @@ export async function runCli(
1025
1376
  const pageResults = [];
1026
1377
  for (let index = 0; index < pages.length; index += 1) {
1027
1378
  const page = pages[index];
1379
+ const materialized = playwrightUpload
1380
+ ? await materializePlaywrightUploadPage(document, page, playwrightUpload.evidenceByTest)
1381
+ : null;
1382
+ if (materialized?.skipped) {
1383
+ stderr(`Warning: ${materialized.skipped} failure screenshot${materialized.skipped === 1 ? " was" : "s were"} no longer readable and were skipped.`);
1384
+ }
1028
1385
  const result = await postWithRetry({
1029
1386
  endpoint,
1030
1387
  headers: {
@@ -1034,14 +1391,14 @@ export async function runCli(
1034
1391
  "X-CI-Page-Index": String(index + 1),
1035
1392
  "X-CI-Page-Count": String(pages.length),
1036
1393
  },
1037
- body: page.body,
1394
+ body: materialized ? materialized.body : page.body,
1038
1395
  env,
1039
1396
  fetchImpl,
1040
1397
  stderr,
1041
1398
  });
1042
1399
  pageResults.push(result);
1043
1400
  if (pages.length > 1) {
1044
- stdout(`Uploaded page ${index + 1}/${pages.length} (${page.tests} tests).`);
1401
+ stdout(`Uploaded page ${index + 1}/${pages.length} (${page.testCount ?? page.tests} tests).`);
1045
1402
  }
1046
1403
  }
1047
1404
  stdout(`Uploaded ${FORMAT_LABELS[format]} to ${destination} (run ${metadata.runId}).`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "testdossier",
3
- "version": "0.2.2",
4
- "description": "Upload completed test reports to TestDossier from a local machine or CI.",
3
+ "version": "0.3.0",
4
+ "description": "Upload Playwright, Cypress, Cucumber, JUnit, and JSON test evidence to TestDossier.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -10,6 +10,8 @@
10
10
  "files": [
11
11
  "bin",
12
12
  "lib",
13
+ "templates",
14
+ "assets",
13
15
  "README.md",
14
16
  "LICENSE",
15
17
  ".env.testdossier.example"
@@ -18,7 +20,13 @@
18
20
  "node": ">=18"
19
21
  },
20
22
  "homepage": "https://testdossier.com",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/devkalu/test-dossier.git",
26
+ "directory": "cli"
27
+ },
21
28
  "bugs": {
29
+ "url": "https://github.com/devkalu/test-dossier/issues",
22
30
  "email": "support@testdossier.com"
23
31
  },
24
32
  "keywords": [
@@ -27,7 +35,11 @@
27
35
  "playwright",
28
36
  "cypress",
29
37
  "cucumber",
30
- "testdossier"
38
+ "testdossier",
39
+ "test-management",
40
+ "test-evidence",
41
+ "continuous-integration",
42
+ "qa"
31
43
  ],
32
44
  "publishConfig": {
33
45
  "access": "public"
@@ -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;