testdossier 0.1.2 → 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TestDossier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -35,8 +35,10 @@ TestDossier. Use the actual report path produced by your runner. Repeat steps 3
35
35
  and 4 whenever you want to publish a new run.
36
36
  If tests run in multiple shards or batches, merge all shard reports before
37
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.
38
+ After merging, the CLI automatically sends JSON reports (Playwright, Cypress,
39
+ Cucumber, generic) larger than 200 tests as sequential pages under one stable
40
+ Test Run. JUnit XML larger than 200 tests is rejected before anything is sent;
41
+ split it per suite or switch the runner to a JSON reporter.
40
42
 
41
43
  The CLI auto-detects Playwright JSON, Cypress JSON, Cucumber JSON, JUnit XML,
42
44
  and TestDossier's generic JSON format. It has zero runtime dependencies and
@@ -58,18 +60,18 @@ rejected except for localhost development.
58
60
  Before sending anything, inspect what the CLI detected:
59
61
 
60
62
  ```bash
61
- npx testdossier upload playwright-results.json --dry-run
63
+ npx --yes testdossier@0.1.3 upload playwright-results.json --dry-run
62
64
  ```
63
65
 
64
66
  ## Supported reports
65
67
 
66
68
  | Runner or format | Example |
67
69
  |---|---|
68
- | Playwright JSON | `npx testdossier upload playwright-results.json` |
69
- | Cypress JSON | `npx testdossier upload cypress-results.json` |
70
- | Cucumber JSON | `npx testdossier upload cucumber-results.json` |
71
- | JUnit XML | `npx testdossier upload junit-results.xml` |
72
- | Generic JSON | `npx testdossier upload testdossier-ci.json` |
70
+ | Playwright JSON | `npx --yes testdossier@0.1.3 upload playwright-results.json` |
71
+ | Cypress JSON | `npx --yes testdossier@0.1.3 upload cypress-results.json` |
72
+ | Cucumber JSON | `npx --yes testdossier@0.1.3 upload cucumber-results.json` |
73
+ | JUnit XML | `npx --yes testdossier@0.1.3 upload junit-results.xml` |
74
+ | Generic JSON | `npx --yes testdossier@0.1.3 upload testdossier-ci.json` |
73
75
 
74
76
  Detection uses the report's contents, not its filename. If a custom reporter
75
77
  creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
@@ -125,12 +127,19 @@ npx testdossier upload junit-results.xml \
125
127
  ```
126
128
 
127
129
  `--run-id` is the idempotency key. Repeating the same upload with the same run
128
- ID returns the existing ingest result rather than creating a duplicate. Without
129
- one, the CLI creates a new local run ID. Transient network, rate-limit, conflict,
130
- and server errors are retried with the same run ID.
130
+ ID returns the existing ingest result rather than creating a duplicate; the CLI
131
+ prints a note when the server answers with such a replay. Different report
132
+ files uploaded under one run ID accumulate as separate pages of that run (the
133
+ page identity includes the report's file name and a path fingerprint). Without
134
+ a run ID, the CLI creates a new local one. Transient network, rate-limit,
135
+ conflict, and server errors are retried with the same run ID.
131
136
 
132
137
  Run `npx testdossier --help` for every option.
133
138
 
134
139
  If `.env.testdossier` is ever committed or shared, revoke that token in
135
140
  TestDossier immediately and create a replacement. Hosted CI should use the
136
- provider's encrypted secret storage instead of an env file.
141
+ provider's encrypted secret storage instead of an env file. The Access Tokens
142
+ dialog generates provider-specific GitHub Actions, GitLab CI, and Jenkins
143
+ snippets that run after the test report is created. Those recipes pin the CLI,
144
+ link back to the originating CI run, retry transient failures, and keep the
145
+ token out of command history.
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFile, stat } from "node:fs/promises";
3
+ import { basename } from "node:path";
3
4
 
4
- export const VERSION = "0.1.2";
5
+ export const VERSION = "0.1.3";
5
6
  const DEFAULT_ORIGIN = "https://testdossier.com";
6
7
  const MAX_REPORT_BYTES = 25 * 1024 * 1024;
7
8
  const MAX_TESTS_PER_PAGE = 200;
@@ -41,48 +42,88 @@ function matchingJsonFormats(value) {
41
42
  return matches;
42
43
  }
43
44
 
44
- function assertFormatShape(format, text) {
45
- const trimmed = text.replace(/^\uFEFF/, "").trimStart();
46
- if (format === "junit") {
47
- if (!trimmed.startsWith("<") || !/<testsuites?\b/i.test(trimmed)) {
48
- throw new CliError("JUnit reports must be XML containing a <testsuite> or <testsuites> root.");
49
- }
50
- return;
51
- }
45
+ function isObject(value) {
46
+ return !!value && typeof value === "object" && !Array.isArray(value);
47
+ }
52
48
 
53
- const value = parseJson(trimmed);
54
- const matches = matchingJsonFormats(value);
55
- if (!matches.includes(format)) {
56
- const expected = {
57
- cypress: "a JSON object with runs[]",
58
- playwright: "a JSON object with suites[]",
59
- cucumber: "a JSON array of features",
60
- generic: "a JSON object with tests[]",
61
- }[format];
62
- throw new CliError(`${FORMAT_LABELS[format]} must be ${expected}.`);
49
+ function validPlaywrightSuites(suites) {
50
+ return Array.isArray(suites) && suites.every((suite) => {
51
+ if (!isObject(suite)) return false;
52
+ if (suite.specs != null && !Array.isArray(suite.specs)) return false;
53
+ if (suite.suites != null && !validPlaywrightSuites(suite.suites)) return false;
54
+ return (suite.specs || []).every((spec) => {
55
+ if (!isObject(spec)) return false;
56
+ if (spec.tests != null && !Array.isArray(spec.tests)) return false;
57
+ return (spec.tests || []).every((test) =>
58
+ isObject(test) &&
59
+ (test.results == null ||
60
+ (Array.isArray(test.results) && test.results.every(isObject))));
61
+ });
62
+ });
63
+ }
64
+
65
+ function validJsonFormat(format, value) {
66
+ if (format === "generic") return isObject(value) && Array.isArray(value.tests);
67
+ if (format === "playwright") return isObject(value) && validPlaywrightSuites(value.suites);
68
+ if (format === "cypress") {
69
+ return isObject(value) && Array.isArray(value.runs) && value.runs.every((run) =>
70
+ isObject(run) &&
71
+ (run.tests == null ||
72
+ (Array.isArray(run.tests) && run.tests.every((test) =>
73
+ isObject(test) && (test.attempts == null || Array.isArray(test.attempts))))) &&
74
+ (run.screenshots == null || Array.isArray(run.screenshots)));
63
75
  }
76
+ return Array.isArray(value) && value.every((feature) =>
77
+ isObject(feature) &&
78
+ (feature.elements == null ||
79
+ (Array.isArray(feature.elements) && feature.elements.every((element) =>
80
+ isObject(element) && (element.steps == null || Array.isArray(element.steps))))));
64
81
  }
65
82
 
66
- export function detectReportFormat(text) {
83
+ function invalidFormatMessage(format) {
84
+ const expected = {
85
+ cypress: "a JSON object with well-formed runs[]",
86
+ playwright: "a JSON object with well-formed suites[]",
87
+ cucumber: "a JSON array of features with well-formed elements[]",
88
+ generic: "a JSON object with tests[]",
89
+ }[format];
90
+ return `${FORMAT_LABELS[format]} must be ${expected}.`;
91
+ }
92
+
93
+ export function analyzeReport(text, requestedFormat = "auto") {
67
94
  const trimmed = text.replace(/^\uFEFF/, "").trimStart();
68
95
  if (!trimmed) throw new CliError("report file is empty.");
69
- if (trimmed.startsWith("<")) {
70
- assertFormatShape("junit", trimmed);
71
- return "junit";
96
+ if (requestedFormat === "junit" || (requestedFormat === "auto" && trimmed.startsWith("<"))) {
97
+ if (!/^(?:<\?xml[\s\S]*?\?>\s*)?(?:<!--[\s\S]*?-->\s*)*<(?:[\w.-]+:)?testsuites?\b/i.test(trimmed)) {
98
+ throw new CliError("JUnit reports must be XML containing a <testsuite> or <testsuites> root.");
99
+ }
100
+ return { format: "junit", document: null };
72
101
  }
73
102
 
74
103
  const value = parseJson(trimmed);
75
104
  const matches = matchingJsonFormats(value);
76
- if (matches.length === 0) {
77
- throw new CliError(
78
- "could not detect the report format; expected Cypress runs[], Playwright suites[], " +
79
- "a Cucumber feature array, generic tests[], or JUnit XML.",
80
- );
105
+ if (requestedFormat === "auto") {
106
+ if (matches.length === 0) {
107
+ throw new CliError(
108
+ "could not detect the report format; expected Cypress runs[], Playwright suites[], " +
109
+ "a Cucumber feature array, generic tests[], or JUnit XML.",
110
+ );
111
+ }
112
+ if (matches.length > 1) {
113
+ throw new CliError(`report shape is ambiguous (${matches.join(", ")}); pass --format explicitly.`);
114
+ }
115
+ const format = matches[0];
116
+ if (!validJsonFormat(format, value)) throw new CliError(invalidFormatMessage(format));
117
+ return { format, document: value };
81
118
  }
82
- if (matches.length > 1) {
83
- throw new CliError(`report shape is ambiguous (${matches.join(", ")}); pass --format explicitly.`);
119
+ if (!matches.includes(requestedFormat) || !validJsonFormat(requestedFormat, value)) {
120
+ throw new CliError(invalidFormatMessage(requestedFormat));
84
121
  }
85
- return matches[0];
122
+ return { format: requestedFormat, document: value };
123
+ }
124
+
125
+ export function detectReportFormat(text) {
126
+ return analyzeReport(text).format;
86
127
  }
87
128
 
88
129
  function playwrightTestNodes(suites, found = []) {
@@ -115,44 +156,146 @@ function selectedPlaywrightSuite(suite, selectedTests) {
115
156
  return next.specs.length > 0 || next.suites.length > 0 ? next : null;
116
157
  }
117
158
 
118
- export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE) {
159
+ // Counts <testcase> elements the way the server's parser does: real tags
160
+ // only, so a failure message inside CDATA that happens to contain
161
+ // "<testcase" is not counted.
162
+ function countJunitTests(text) {
163
+ const stripped = text
164
+ .replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "")
165
+ .replace(/<!--[\s\S]*?-->/g, "");
166
+ return (stripped.match(/<testcase[\s/>]/gi) || []).length;
167
+ }
168
+
169
+ function isCucumberScenario(element) {
170
+ return !!element && typeof element === "object" &&
171
+ String(element.type || "scenario").toLowerCase() === "scenario";
172
+ }
173
+
174
+ function pagedSlices(items, maxTests) {
175
+ const slices = [];
176
+ for (let start = 0; start < items.length; start += maxTests) {
177
+ slices.push(items.slice(start, start + maxTests));
178
+ }
179
+ return slices;
180
+ }
181
+
182
+ function cypressTestFailed(test) {
183
+ const attempts = Array.isArray(test.attempts) ? test.attempts : [];
184
+ const state = attempts[attempts.length - 1]?.state || test.state;
185
+ return String(state || "").toLowerCase() === "failed";
186
+ }
187
+
188
+ function normalizedCypressTitle(value) {
189
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
190
+ }
191
+
192
+ function cypressScreenshotMatchesTest(screenshot, test) {
193
+ if (!isObject(screenshot)) return false;
194
+ const title = Array.isArray(test.title) ? test.title : [String(test.title || "")];
195
+ const haystack = normalizedCypressTitle(`${screenshot.name || ""} ${screenshot.path || ""}`);
196
+ const fullTitle = normalizedCypressTitle(title.join(" "));
197
+ const leafTitle = normalizedCypressTitle(title[title.length - 1] || "");
198
+ return !!haystack &&
199
+ ((fullTitle.length >= 4 && haystack.includes(fullTitle)) ||
200
+ (leafTitle.length >= 4 && haystack.includes(leafTitle)));
201
+ }
202
+
203
+ function selectedCypressRun(run, selectedTests) {
204
+ const runTests = Array.isArray(run.tests) ? run.tests : [];
205
+ const tests = runTests.filter((test) => selectedTests.has(test));
206
+ if (tests.length === 0) return null;
207
+
208
+ // The server associates run-level screenshots using the complete run's set
209
+ // of failures. Keep that context while paging: otherwise two failures split
210
+ // across pages each look like the run's sole failure and both inherit every
211
+ // unmatched screenshot.
212
+ const allFailures = runTests.filter(cypressTestFailed);
213
+ const pageFailures = tests.filter(cypressTestFailed);
214
+ let screenshots = [];
215
+ if (allFailures.length === 1 && pageFailures.length === 1) {
216
+ screenshots = Array.isArray(run.screenshots) ? run.screenshots : [];
217
+ } else if (allFailures.length > 1 && pageFailures.length > 0) {
218
+ screenshots = (Array.isArray(run.screenshots) ? run.screenshots : [])
219
+ .filter((screenshot) => pageFailures.some((test) => cypressScreenshotMatchesTest(screenshot, test)));
220
+ }
221
+
222
+ return {
223
+ ...run,
224
+ tests,
225
+ screenshots,
226
+ video: pageFailures.length > 0 ? run.video : null,
227
+ };
228
+ }
229
+
230
+ export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null) {
119
231
  if (!Number.isInteger(maxTests) || maxTests < 1 || maxTests > MAX_TESTS_PER_PAGE) {
120
232
  throw new CliError(`page size must be an integer from 1 to ${MAX_TESTS_PER_PAGE}.`);
121
233
  }
122
- if (format !== "playwright" && format !== "generic") {
123
- return [{ body: report, tests: null }];
234
+ if (format === "junit") {
235
+ const count = countJunitTests(report.toString("utf8"));
236
+ if (count > maxTests) {
237
+ throw new CliError(
238
+ `JUnit report has ${count} tests; the server accepts at most ${maxTests} per request ` +
239
+ "and JUnit XML cannot be paged automatically. Split it per suite, or switch the runner " +
240
+ "to a JSON reporter (Playwright, Cypress, Cucumber, or generic), which the CLI pages for you.",
241
+ );
242
+ }
243
+ return [{ body: report, tests: count }];
124
244
  }
125
245
 
126
- const document = parseJson(report.toString("utf8").replace(/^\uFEFF/, ""));
246
+ const doc = document ?? parseJson(report.toString("utf8").replace(/^\uFEFF/, ""));
127
247
  if (format === "generic") {
128
- const tests = document.tests;
248
+ const tests = doc.tests;
129
249
  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;
250
+ return pagedSlices(tests, maxTests).map((slice) => ({
251
+ body: Buffer.from(JSON.stringify({ ...doc, tests: slice })),
252
+ tests: slice.length,
253
+ }));
139
254
  }
140
255
 
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,
256
+ if (format === "playwright") {
257
+ const tests = playwrightTestNodes(doc.suites);
258
+ if (tests.length <= maxTests) return [{ body: report, tests: tests.length }];
259
+ return pagedSlices(tests, maxTests).map((slice) => {
260
+ const selected = new Set(slice);
261
+ const suites = doc.suites
262
+ .map((suite) => selectedPlaywrightSuite(suite, selected))
263
+ .filter(Boolean);
264
+ return { body: Buffer.from(JSON.stringify({ ...doc, suites })), tests: slice.length };
153
265
  });
154
266
  }
155
- return pages;
267
+
268
+ if (format === "cypress") {
269
+ const runs = doc.runs;
270
+ const tests = runs.flatMap((run) => Array.isArray(run.tests) ? run.tests : []);
271
+ if (tests.length <= maxTests) return [{ body: report, tests: tests.length }];
272
+ return pagedSlices(tests, maxTests).map((slice) => {
273
+ const selected = new Set(slice);
274
+ const pageRuns = runs
275
+ .map((run) => selectedCypressRun(run, selected))
276
+ .filter(Boolean);
277
+ return { body: Buffer.from(JSON.stringify({ ...doc, runs: pageRuns })), tests: slice.length };
278
+ });
279
+ }
280
+
281
+ // cucumber: the test unit is a scenario element. Background (and other
282
+ // non-scenario) elements apply to every scenario in their feature, so
283
+ // they are replicated onto every page that carries that feature.
284
+ const features = (Array.isArray(doc) ? doc : []).filter((f) => f && typeof f === "object");
285
+ const scenarios = features.flatMap((f) =>
286
+ (Array.isArray(f.elements) ? f.elements : []).filter(isCucumberScenario));
287
+ if (scenarios.length <= maxTests) return [{ body: report, tests: scenarios.length }];
288
+ return pagedSlices(scenarios, maxTests).map((slice) => {
289
+ const selected = new Set(slice);
290
+ const pageFeatures = features
291
+ .map((f) => {
292
+ const elements = (Array.isArray(f.elements) ? f.elements : [])
293
+ .filter((el) => !isCucumberScenario(el) || selected.has(el));
294
+ return elements.some((el) => isCucumberScenario(el)) ? { ...f, elements } : null;
295
+ })
296
+ .filter(Boolean);
297
+ return { body: Buffer.from(JSON.stringify(pageFeatures)), tests: slice.length };
298
+ });
156
299
  }
157
300
 
158
301
  function isLoopback(hostname) {
@@ -250,29 +393,58 @@ export function parseUploadArgs(args) {
250
393
  return options;
251
394
  }
252
395
 
253
- function boundedHeader(value, name, maxLength) {
396
+ function normalizedHeader(value, name) {
254
397
  if (value == null || value === "") return "";
255
398
  const normalized = String(value);
256
399
  if (/[\r\n]/.test(normalized)) throw new CliError(`${name} must not contain a newline.`);
257
- return normalized.slice(0, maxLength);
400
+ // fetch rejects header values outside ISO-8859-1; percent-encode anything
401
+ // beyond printable ASCII so a Unicode branch name degrades to a readable
402
+ // token instead of a TypeError that looks like a network failure.
403
+ const ascii = normalized.replace(/[^\x20-\x7e]/gu, (char) => {
404
+ try { return encodeURIComponent(char); } catch { return "_"; }
405
+ });
406
+ return ascii.trim();
407
+ }
408
+
409
+ function boundedHeader(value, name, maxLength) {
410
+ return normalizedHeader(value, name).slice(0, maxLength).trim();
258
411
  }
259
412
 
260
413
  function boundedRunId(value) {
261
- const normalized = boundedHeader(value, "run id", 1000);
414
+ const normalized = normalizedHeader(value, "run id");
262
415
  if (normalized.length <= 120) return normalized;
263
416
  const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 20);
264
417
  return `${normalized.slice(0, 95)}-${digest}`;
265
418
  }
266
419
 
420
+ function boundedPageBase(value) {
421
+ const normalized = normalizedHeader(value, "report name") || "results";
422
+ if (normalized.length <= 100) return normalized;
423
+ const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
424
+ return `${normalized.slice(0, 83)}-${digest}`;
425
+ }
426
+
427
+ function reportPageBase(reportPath) {
428
+ const reportName = basename(reportPath);
429
+ const pathKey = String(reportPath).replace(/\\/g, "/");
430
+ const pathDigest = createHash("sha256").update(pathKey).digest("hex").slice(0, 12);
431
+ return boundedPageBase(`${reportName}-${pathDigest}`);
432
+ }
433
+
267
434
  function defaultRunId() {
268
435
  return `local-${Date.now()}-${randomUUID().slice(0, 8)}`;
269
436
  }
270
437
 
271
438
  function inferredMetadata(options, env) {
439
+ // GITHUB_JOB is part of the inferred key: two jobs of one workflow run
440
+ // would otherwise share a run id, and the server answers the second
441
+ // upload with an idempotent replay of the first.
272
442
  const runId = boundedRunId(
273
443
  options.runId ||
274
444
  env.TD_RUN_ID ||
275
- (env.GITHUB_RUN_ID ? `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT || 1}` : "") ||
445
+ (env.GITHUB_RUN_ID
446
+ ? [env.GITHUB_RUN_ID, env.GITHUB_JOB, env.GITHUB_RUN_ATTEMPT || 1].filter(Boolean).join("-")
447
+ : "") ||
276
448
  (env.CI_PIPELINE_ID ? `${env.CI_PIPELINE_ID}-${env.CI_JOB_ID || 1}` : "") ||
277
449
  env.BUILD_TAG ||
278
450
  defaultRunId(),
@@ -422,8 +594,10 @@ Authentication:
422
594
  them out of shell history and process listings.
423
595
 
424
596
  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.
597
+ JSON reports (Playwright, Cypress, Cucumber, generic) above 200 tests are
598
+ automatically sent as sequential pages that remain grouped under one
599
+ TestDossier run. JUnit XML above 200 tests is rejected before upload —
600
+ split it per suite or switch the runner to a JSON reporter.
427
601
 
428
602
  Examples:
429
603
  npx testdossier upload playwright-results.json --env-file .env.testdossier
@@ -446,6 +620,7 @@ function outputSummary(result, stdout, stderr) {
446
620
 
447
621
  function aggregatePageResults(results) {
448
622
  const aggregate = { counts: {}, warnings: [] };
623
+ const seenWarnings = new Set();
449
624
  for (const result of results) {
450
625
  if (result && result.counts && typeof result.counts === "object") {
451
626
  for (const [name, value] of Object.entries(result.counts)) {
@@ -454,7 +629,12 @@ function aggregatePageResults(results) {
454
629
  }
455
630
  }
456
631
  }
457
- if (Array.isArray(result && result.warnings)) aggregate.warnings.push(...result.warnings);
632
+ for (const warning of Array.isArray(result && result.warnings) ? result.warnings : []) {
633
+ const key = String(warning);
634
+ if (seenWarnings.has(key)) continue;
635
+ seenWarnings.add(key);
636
+ aggregate.warnings.push(warning);
637
+ }
458
638
  }
459
639
  return aggregate;
460
640
  }
@@ -469,7 +649,7 @@ export async function runCli(
469
649
  } = {},
470
650
  ) {
471
651
  try {
472
- if (args.includes("--version")) {
652
+ if (args[0] === "--version") {
473
653
  stdout(VERSION);
474
654
  return 0;
475
655
  }
@@ -498,21 +678,15 @@ export async function runCli(
498
678
  }
499
679
  const report = await readFile(options.reportPath);
500
680
  const reportText = report.toString("utf8");
501
- const format = options.format === "auto" ? detectReportFormat(reportText) : options.format;
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;
681
+ const { format, document } = analyzeReport(reportText, options.format);
682
+ const pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
683
+ const testCount = pages.reduce((sum, page) => sum + page.tests, 0);
507
684
 
508
685
  const endpoint = normalizeEndpoint(options.url || env.TD_URL || DEFAULT_ORIGIN);
509
686
  const destination = new URL(endpoint).origin;
510
687
  stdout(
511
- `Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes` +
512
- (testCount == null
513
- ? ""
514
- : `, ${testCount} tests${pages.length > 1 ? ` across ${pages.length} pages` : ""}`) +
515
- ").",
688
+ `Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes, ${testCount} tests` +
689
+ `${pages.length > 1 ? ` across ${pages.length} pages` : ""}).`,
516
690
  );
517
691
  if (options.dryRun) {
518
692
  stdout(`Dry run complete; no data was sent to ${destination}.`);
@@ -546,6 +720,11 @@ export async function runCli(
546
720
  `${pages.length} pages (run ${metadata.runId}).`,
547
721
  );
548
722
  }
723
+ // Page ids carry a readable file name plus a path fingerprint so two
724
+ // reports uploaded under one run id become distinct pages even when their
725
+ // base names match (for example chromium/results.json and
726
+ // firefox/results.json).
727
+ const pageBase = reportPageBase(options.reportPath);
549
728
  const pageResults = [];
550
729
  for (let index = 0; index < pages.length; index += 1) {
551
730
  const page = pages[index];
@@ -553,7 +732,7 @@ export async function runCli(
553
732
  endpoint,
554
733
  headers: {
555
734
  ...baseHeaders,
556
- "X-CI-Page-Id": pages.length === 1 ? "results" : String(index + 1),
735
+ "X-CI-Page-Id": pages.length === 1 ? pageBase : `${pageBase}-${index + 1}`,
557
736
  },
558
737
  body: page.body,
559
738
  env,
@@ -566,6 +745,13 @@ export async function runCli(
566
745
  }
567
746
  }
568
747
  stdout(`Uploaded ${FORMAT_LABELS[format]} to ${destination} (run ${metadata.runId}).`);
748
+ const replayed = pageResults.filter((result) => result && result.idempotent_replay === true).length;
749
+ if (replayed > 0) {
750
+ stderr(pageResults.length === 1
751
+ ? `Note: the server replayed an earlier upload for run ${metadata.runId}; nothing new was ingested. ` +
752
+ "Pass a unique --run-id if this was not a retry."
753
+ : `Note: ${replayed} of ${pageResults.length} pages were idempotent replays of an earlier upload (run ${metadata.runId}).`);
754
+ }
569
755
  outputSummary(aggregatePageResults(pageResults), stdout, stderr);
570
756
  return 0;
571
757
  } catch (error) {
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "testdossier",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Upload completed test reports to TestDossier from a local machine or CI.",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "testdossier": "bin/testdossier.mjs"
@@ -10,6 +11,7 @@
10
11
  "bin",
11
12
  "lib",
12
13
  "README.md",
14
+ "LICENSE",
13
15
  ".env.testdossier.example"
14
16
  ],
15
17
  "engines": {