testdossier 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,43 @@
1
1
  # TestDossier CLI
2
2
 
3
- Upload a completed test report from a developer laptop, test machine, or CI job:
3
+ Upload a completed test report from a developer laptop, test machine, or CI
4
+ job. Run these steps in the project containing your automated tests:
4
5
 
5
- Create `.env.testdossier` in the project containing the test report:
6
+ 1. Create `.env.testdossier`:
6
7
 
7
8
  ```dotenv
8
9
  TD_CI_TOKEN=replace_with_your_td_token
9
10
  ```
10
11
 
11
- Replace the placeholder with the CI-ingestion token shown once by TestDossier,
12
- then:
12
+ 2. Replace the placeholder with the CI-ingestion token shown once by
13
+ TestDossier, add the file to `.gitignore`, and protect it:
13
14
 
14
15
  ```bash
15
16
  chmod 600 .env.testdossier
17
+ ```
18
+
19
+ 3. Run your tests so they create a supported report. For example, with
20
+ Playwright's JSON reporter configured to write `playwright-results.json`:
21
+
22
+ ```bash
23
+ npx playwright test
24
+ test -f playwright-results.json
25
+ ```
26
+
27
+ 4. Upload the completed report:
28
+
29
+ ```bash
16
30
  npx testdossier upload playwright-results.json --env-file .env.testdossier
17
31
  ```
18
32
 
33
+ `playwright-results.json` is an example filename, not a file supplied by
34
+ TestDossier. Use the actual report path produced by your runner. Repeat steps 3
35
+ and 4 whenever you want to publish a new run.
36
+ If tests run in multiple shards or batches, merge all shard reports before
37
+ uploading; selecting one shard's file uploads only that shard.
38
+ After merging, the CLI automatically sends Playwright and generic JSON reports
39
+ larger than 200 tests as sequential pages under one stable Test Run.
40
+
19
41
  The CLI auto-detects Playwright JSON, Cypress JSON, Cucumber JSON, JUnit XML,
20
42
  and TestDossier's generic JSON format. It has zero runtime dependencies and
21
43
  requires Node.js 18 or newer.
@@ -1,9 +1,10 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
3
 
4
- export const VERSION = "0.1.1";
4
+ export const VERSION = "0.1.2";
5
5
  const DEFAULT_ORIGIN = "https://testdossier.com";
6
6
  const MAX_REPORT_BYTES = 25 * 1024 * 1024;
7
+ const MAX_TESTS_PER_PAGE = 200;
7
8
  const RETRYABLE_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
8
9
  const FORMAT_LABELS = {
9
10
  cypress: "Cypress JSON",
@@ -84,6 +85,76 @@ export function detectReportFormat(text) {
84
85
  return matches[0];
85
86
  }
86
87
 
88
+ function playwrightTestNodes(suites, found = []) {
89
+ for (const suite of Array.isArray(suites) ? suites : []) {
90
+ if (!suite || typeof suite !== "object") continue;
91
+ for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
92
+ if (!spec || typeof spec !== "object") continue;
93
+ for (const test of Array.isArray(spec.tests) ? spec.tests : []) {
94
+ found.push(test);
95
+ }
96
+ }
97
+ playwrightTestNodes(suite.suites, found);
98
+ }
99
+ return found;
100
+ }
101
+
102
+ function selectedPlaywrightSuite(suite, selectedTests) {
103
+ if (!suite || typeof suite !== "object") return null;
104
+ const next = { ...suite };
105
+ next.specs = (Array.isArray(suite.specs) ? suite.specs : [])
106
+ .filter((spec) => spec && typeof spec === "object")
107
+ .map((spec) => ({
108
+ ...spec,
109
+ tests: (Array.isArray(spec.tests) ? spec.tests : []).filter((test) => selectedTests.has(test)),
110
+ }))
111
+ .filter((spec) => spec.tests.length > 0);
112
+ next.suites = (Array.isArray(suite.suites) ? suite.suites : [])
113
+ .map((child) => selectedPlaywrightSuite(child, selectedTests))
114
+ .filter(Boolean);
115
+ return next.specs.length > 0 || next.suites.length > 0 ? next : null;
116
+ }
117
+
118
+ export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE) {
119
+ if (!Number.isInteger(maxTests) || maxTests < 1 || maxTests > MAX_TESTS_PER_PAGE) {
120
+ throw new CliError(`page size must be an integer from 1 to ${MAX_TESTS_PER_PAGE}.`);
121
+ }
122
+ if (format !== "playwright" && format !== "generic") {
123
+ return [{ body: report, tests: null }];
124
+ }
125
+
126
+ const document = parseJson(report.toString("utf8").replace(/^\uFEFF/, ""));
127
+ if (format === "generic") {
128
+ const tests = document.tests;
129
+ if (tests.length <= maxTests) return [{ body: report, tests: tests.length }];
130
+ const pages = [];
131
+ for (let start = 0; start < tests.length; start += maxTests) {
132
+ const slice = tests.slice(start, start + maxTests);
133
+ pages.push({
134
+ body: Buffer.from(JSON.stringify({ ...document, tests: slice })),
135
+ tests: slice.length,
136
+ });
137
+ }
138
+ return pages;
139
+ }
140
+
141
+ const tests = playwrightTestNodes(document.suites);
142
+ if (tests.length <= maxTests) return [{ body: report, tests: tests.length }];
143
+ const pages = [];
144
+ for (let start = 0; start < tests.length; start += maxTests) {
145
+ const slice = tests.slice(start, start + maxTests);
146
+ const selected = new Set(slice);
147
+ const suites = document.suites
148
+ .map((suite) => selectedPlaywrightSuite(suite, selected))
149
+ .filter(Boolean);
150
+ pages.push({
151
+ body: Buffer.from(JSON.stringify({ ...document, suites })),
152
+ tests: slice.length,
153
+ });
154
+ }
155
+ return pages;
156
+ }
157
+
87
158
  function isLoopback(hostname) {
88
159
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
89
160
  }
@@ -350,6 +421,10 @@ Authentication:
350
421
  Tokens are intentionally not accepted as command-line arguments, keeping
351
422
  them out of shell history and process listings.
352
423
 
424
+ Large reports:
425
+ Playwright and generic JSON reports above 200 tests are automatically sent
426
+ as sequential pages that remain grouped under one TestDossier run.
427
+
353
428
  Examples:
354
429
  npx testdossier upload playwright-results.json --env-file .env.testdossier
355
430
  npx testdossier upload junit-results.xml --env-file .env.testdossier --build abc123
@@ -369,6 +444,21 @@ function outputSummary(result, stdout, stderr) {
369
444
  }
370
445
  }
371
446
 
447
+ function aggregatePageResults(results) {
448
+ const aggregate = { counts: {}, warnings: [] };
449
+ for (const result of results) {
450
+ if (result && result.counts && typeof result.counts === "object") {
451
+ for (const [name, value] of Object.entries(result.counts)) {
452
+ if (typeof value === "number") {
453
+ aggregate.counts[name] = (aggregate.counts[name] || 0) + value;
454
+ }
455
+ }
456
+ }
457
+ if (Array.isArray(result && result.warnings)) aggregate.warnings.push(...result.warnings);
458
+ }
459
+ return aggregate;
460
+ }
461
+
372
462
  export async function runCli(
373
463
  args,
374
464
  {
@@ -410,10 +500,20 @@ export async function runCli(
410
500
  const reportText = report.toString("utf8");
411
501
  const format = options.format === "auto" ? detectReportFormat(reportText) : options.format;
412
502
  assertFormatShape(format, reportText);
503
+ const pages = splitReportPages(format, report);
504
+ const testCount = pages.every((page) => typeof page.tests === "number")
505
+ ? pages.reduce((sum, page) => sum + page.tests, 0)
506
+ : null;
413
507
 
414
508
  const endpoint = normalizeEndpoint(options.url || env.TD_URL || DEFAULT_ORIGIN);
415
509
  const destination = new URL(endpoint).origin;
416
- stdout(`Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes).`);
510
+ stdout(
511
+ `Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes` +
512
+ (testCount == null
513
+ ? ""
514
+ : `, ${testCount} tests${pages.length > 1 ? ` across ${pages.length} pages` : ""}`) +
515
+ ").",
516
+ );
417
517
  if (options.dryRun) {
418
518
  stdout(`Dry run complete; no data was sent to ${destination}.`);
419
519
  return 0;
@@ -428,29 +528,45 @@ export async function runCli(
428
528
  if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
429
529
 
430
530
  const metadata = inferredMetadata(options, env);
431
- const headers = {
531
+ const baseHeaders = {
432
532
  Authorization: `Bearer ${token}`,
433
533
  "Content-Type": format === "junit" ? "application/xml" : "application/json",
434
534
  "User-Agent": `testdossier-cli/${VERSION}`,
435
535
  "X-CI-Run-Id": metadata.runId,
436
- "X-CI-Page-Id": "results",
437
536
  "X-CI-Provider": metadata.provider,
438
537
  };
439
- if (format !== "junit" && format !== "generic") headers["X-CI-Format"] = format;
440
- if (metadata.build) headers["X-Build"] = metadata.build;
441
- if (metadata.branch) headers["X-Branch"] = metadata.branch;
442
- if (metadata.ciUrl) headers["X-CI-URL"] = metadata.ciUrl;
443
-
444
- const result = await postWithRetry({
445
- endpoint,
446
- headers,
447
- body: report,
448
- env,
449
- fetchImpl,
450
- stderr,
451
- });
538
+ if (format !== "junit" && format !== "generic") baseHeaders["X-CI-Format"] = format;
539
+ if (metadata.build) baseHeaders["X-Build"] = metadata.build;
540
+ if (metadata.branch) baseHeaders["X-Branch"] = metadata.branch;
541
+ if (metadata.ciUrl) baseHeaders["X-CI-URL"] = metadata.ciUrl;
542
+
543
+ if (pages.length > 1) {
544
+ stdout(
545
+ `Uploading ${pages.reduce((sum, page) => sum + (page.tests || 0), 0)} tests in ` +
546
+ `${pages.length} pages (run ${metadata.runId}).`,
547
+ );
548
+ }
549
+ const pageResults = [];
550
+ for (let index = 0; index < pages.length; index += 1) {
551
+ const page = pages[index];
552
+ const result = await postWithRetry({
553
+ endpoint,
554
+ headers: {
555
+ ...baseHeaders,
556
+ "X-CI-Page-Id": pages.length === 1 ? "results" : String(index + 1),
557
+ },
558
+ body: page.body,
559
+ env,
560
+ fetchImpl,
561
+ stderr,
562
+ });
563
+ pageResults.push(result);
564
+ if (pages.length > 1) {
565
+ stdout(`Uploaded page ${index + 1}/${pages.length} (${page.tests} tests).`);
566
+ }
567
+ }
452
568
  stdout(`Uploaded ${FORMAT_LABELS[format]} to ${destination} (run ${metadata.runId}).`);
453
- outputSummary(result, stdout, stderr);
569
+ outputSummary(aggregatePageResults(pageResults), stdout, stderr);
454
570
  return 0;
455
571
  } catch (error) {
456
572
  const message = error && error.message ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdossier",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Upload completed test reports to TestDossier from a local machine or CI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,14 +15,9 @@
15
15
  "engines": {
16
16
  "node": ">=18"
17
17
  },
18
- "repository": {
19
- "type": "git",
20
- "url": "git+https://github.com/devkalu/test-dossier.git",
21
- "directory": "cli"
22
- },
23
18
  "homepage": "https://testdossier.com",
24
19
  "bugs": {
25
- "url": "https://github.com/devkalu/test-dossier/issues"
20
+ "email": "support@testdossier.com"
26
21
  },
27
22
  "keywords": [
28
23
  "testing",