testdossier 0.1.2 → 0.2.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/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
@@ -1,42 +1,57 @@
1
1
  # TestDossier CLI
2
2
 
3
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
+ job. Run these commands in the project containing your automated tests:
5
5
 
6
- 1. Create `.env.testdossier`:
6
+ 1. One-time setup — creates `.env.testdossier` with owner-only permissions and
7
+ adds it to `.gitignore`:
7
8
 
8
- ```dotenv
9
- TD_CI_TOKEN=replace_with_your_td_token
9
+ ```bash
10
+ npx testdossier init
10
11
  ```
11
12
 
12
- 2. Replace the placeholder with the CI-ingestion token shown once by
13
- TestDossier, add the file to `.gitignore`, and protect it:
13
+ 2. Paste the CI-ingestion token shown once by TestDossier into
14
+ `.env.testdossier`, replacing the placeholder, then prove the wiring in
15
+ seconds instead of after a full CI round-trip:
14
16
 
15
17
  ```bash
16
- chmod 600 .env.testdossier
18
+ npx testdossier verify
17
19
  ```
18
20
 
21
+ `verify` makes one authenticated request with no report data. It confirms the
22
+ token is valid, has the CI-ingestion capability, and reaches the right
23
+ project — and prints that project's name.
24
+
19
25
  3. Run your tests so they create a supported report. For example, with
20
26
  Playwright's JSON reporter configured to write `playwright-results.json`:
21
27
 
22
28
  ```bash
23
29
  npx playwright test
24
- test -f playwright-results.json
25
30
  ```
26
31
 
27
32
  4. Upload the completed report:
28
33
 
29
34
  ```bash
30
- npx testdossier upload playwright-results.json --env-file .env.testdossier
35
+ npx testdossier upload playwright-results.json
31
36
  ```
32
37
 
33
38
  `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.
39
+ TestDossier. Use the actual report path produced by your runner.
40
+
41
+ The first successful upload writes `testdossier.json` in the directory you ran
42
+ the CLI from — the report path, plus `--format` and `--url` when you passed
43
+ them. Commit that file; every later publish is just:
44
+
45
+ ```bash
46
+ npx testdossier
47
+ ```
48
+
36
49
  If tests run in multiple shards or batches, merge all shard reports before
37
50
  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.
51
+ After merging, the CLI automatically sends JSON reports (Playwright, Cypress,
52
+ Cucumber, generic) larger than 200 tests as sequential pages under one stable
53
+ Test Run. JUnit XML larger than 200 tests is rejected before anything is sent;
54
+ split it per suite or switch the runner to a JSON reporter.
40
55
 
41
56
  The CLI auto-detects Playwright JSON, Cypress JSON, Cucumber JSON, JUnit XML,
42
57
  and TestDossier's generic JSON format. It has zero runtime dependencies and
@@ -48,89 +63,100 @@ The uploader does one thing: read a completed report and make an outbound HTTPS
48
63
  request to TestDossier. It does not run tests, accept remote commands, install a
49
64
  background service, watch files, or keep a process running.
50
65
 
51
- The access token is read from an explicitly selected env file or from the
52
- `TD_CI_TOKEN` process environment. There is deliberately no `--token` option,
53
- so a token cannot accidentally land in shell history or a process listing.
54
- The CLI reads only `TD_CI_TOKEN` from the selected file; it does not import or
55
- execute other entries. Redirects are rejected, and non-HTTPS destinations are
56
- rejected except for localhost development.
66
+ The access token is read from the first available source: an explicitly passed
67
+ `--env-file`, the `TD_CI_TOKEN` process environment, then `.env.testdossier` in
68
+ the current directory (the CLI announces when it uses the discovered file).
69
+ There is deliberately no `--token` option, so a token cannot accidentally land
70
+ in shell history or a process listing. The CLI reads only `TD_CI_TOKEN` from
71
+ the selected file; it does not import or execute other entries. Redirects are
72
+ rejected, and non-HTTPS destinations are rejected except for localhost
73
+ development.
74
+
75
+ `testdossier.json` is deliberately never rewritten once it exists — an upload
76
+ with a different path never silently repoints the committed default. Edit or
77
+ delete the file to change it.
57
78
 
58
79
  Before sending anything, inspect what the CLI detected:
59
80
 
60
81
  ```bash
61
- npx testdossier upload playwright-results.json --dry-run
82
+ npx --yes testdossier@0.2.0 upload playwright-results.json --dry-run
62
83
  ```
63
84
 
64
85
  ## Supported reports
65
86
 
66
87
  | Runner or format | Example |
67
88
  |---|---|
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` |
89
+ | Playwright JSON | `npx --yes testdossier@0.2.0 upload playwright-results.json` |
90
+ | Cypress JSON | `npx --yes testdossier@0.2.0 upload cypress-results.json` |
91
+ | Cucumber JSON | `npx --yes testdossier@0.2.0 upload cucumber-results.json` |
92
+ | JUnit XML | `npx --yes testdossier@0.2.0 upload junit-results.xml` |
93
+ | Generic JSON | `npx --yes testdossier@0.2.0 upload testdossier-ci.json` |
73
94
 
74
95
  Detection uses the report's contents, not its filename. If a custom reporter
75
96
  creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
76
- `--format cucumber`, `--format junit`, or `--format generic`.
97
+ `--format cucumber`, `--format junit`, or `--format generic`. The `upload`
98
+ word may be omitted: `npx testdossier playwright-results.json` works too.
77
99
 
78
100
  ## Local setup
79
101
 
80
- Create a project access token in TestDossier with the **CI ingestion**
81
- capability and copy it once. For repeat use, install and pin the CLI in the test
82
- repository so its source and version are captured by the lockfile:
102
+ For repeat use, install and pin the CLI in the test repository so its source
103
+ and version are captured by the lockfile:
83
104
 
84
105
  ```bash
85
106
  npm install --save-dev testdossier
86
- cp node_modules/testdossier/.env.testdossier.example .env.testdossier
87
- ```
88
-
89
- Alternatively, create `.env.testdossier` yourself with the single placeholder
90
- line shown at the top of this guide. Replace `replace_with_your_td_token`, then
91
- ensure the project ignores and protects the real file:
92
-
93
- ```gitignore
94
- .env.testdossier
107
+ npx testdossier init
95
108
  ```
96
109
 
97
- ```bash
98
- chmod 600 .env.testdossier
99
- ```
100
-
101
- Upload with:
102
-
103
- ```bash
104
- npx testdossier upload playwright-results.json --env-file .env.testdossier
105
- ```
110
+ `init` is idempotent: it never overwrites an existing `.env.testdossier` and
111
+ never duplicates the `.gitignore` entry. If you prefer manual setup, create
112
+ `.env.testdossier` yourself with the single line
113
+ `TD_CI_TOKEN=replace_with_your_td_token`, replace the placeholder, gitignore
114
+ the file, and `chmod 600` it.
106
115
 
107
116
  For a one-off session, setting `TD_CI_TOKEN` in the process environment still
108
- works. An explicitly passed `--env-file` takes precedence.
117
+ works and takes precedence over the discovered file. An explicitly passed
118
+ `--env-file` takes precedence over both.
109
119
 
110
120
  The default destination is `https://testdossier.com`. A self-hosted or local
111
121
  instance can be selected without putting the token on the command line:
112
122
 
113
123
  ```bash
114
- TD_URL="https://dossier.example.com" npx testdossier upload junit-results.xml --env-file .env.testdossier
124
+ npx testdossier upload junit-results.xml --url "https://dossier.example.com"
115
125
  ```
116
126
 
127
+ `--url` is remembered in `testdossier.json` on the first successful upload, so
128
+ later bare runs keep targeting the same instance. `TD_URL` in the environment
129
+ also works and overrides the saved value.
130
+
117
131
  Useful metadata options:
118
132
 
119
133
  ```bash
120
134
  npx testdossier upload junit-results.xml \
121
- --env-file .env.testdossier \
122
135
  --run-id "local-2026-07-23-1" \
123
136
  --build "abc123" \
124
137
  --branch "main"
125
138
  ```
126
139
 
127
140
  `--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.
141
+ ID returns the existing ingest result rather than creating a duplicate; the CLI
142
+ prints a note when the server answers with such a replay. Different report
143
+ files uploaded under one run ID accumulate as separate pages of that run (the
144
+ page identity includes the report's file name and a path fingerprint). Without
145
+ a run ID, the CLI creates a new local one. Transient network, rate-limit,
146
+ conflict, and server errors are retried with the same run ID.
131
147
 
132
148
  Run `npx testdossier --help` for every option.
133
149
 
150
+ ## Using the same flow in CI
151
+
152
+ Commit `testdossier.json` and set `TD_CI_TOKEN` in the provider's encrypted
153
+ secret storage; the pipeline step is then `npx --yes testdossier@0.2.0` with
154
+ no arguments. Run identity, build, branch, and provider metadata are inferred
155
+ automatically on GitHub Actions, GitLab CI, Jenkins, Azure DevOps, Bitbucket
156
+ Pipelines, and CircleCI. The Access Tokens dialog still generates
157
+ provider-specific snippets that pass the report path explicitly — either style
158
+ works; the explicit one fails louder when the report artifact is missing.
159
+
134
160
  If `.env.testdossier` is ever committed or shared, revoke that token in
135
161
  TestDossier immediately and create a replacement. Hosted CI should use the
136
162
  provider's encrypted secret storage instead of an env file.
@@ -1,8 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { readFile, stat } from "node:fs/promises";
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
3
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
3
4
 
4
- export const VERSION = "0.1.2";
5
+ export const VERSION = "0.2.0";
5
6
  const DEFAULT_ORIGIN = "https://testdossier.com";
7
+ const ENV_FILE_NAME = ".env.testdossier";
8
+ const CONFIG_FILE_NAME = "testdossier.json";
9
+ const TOKEN_PLACEHOLDER = "replace_with_your_td_token";
10
+ const REPORT_FORMATS = new Set(["cypress", "playwright", "cucumber", "junit", "generic"]);
6
11
  const MAX_REPORT_BYTES = 25 * 1024 * 1024;
7
12
  const MAX_TESTS_PER_PAGE = 200;
8
13
  const RETRYABLE_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
@@ -41,48 +46,88 @@ function matchingJsonFormats(value) {
41
46
  return matches;
42
47
  }
43
48
 
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
- }
49
+ function isObject(value) {
50
+ return !!value && typeof value === "object" && !Array.isArray(value);
51
+ }
52
52
 
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}.`);
53
+ function validPlaywrightSuites(suites) {
54
+ return Array.isArray(suites) && suites.every((suite) => {
55
+ if (!isObject(suite)) return false;
56
+ if (suite.specs != null && !Array.isArray(suite.specs)) return false;
57
+ if (suite.suites != null && !validPlaywrightSuites(suite.suites)) return false;
58
+ return (suite.specs || []).every((spec) => {
59
+ if (!isObject(spec)) return false;
60
+ if (spec.tests != null && !Array.isArray(spec.tests)) return false;
61
+ return (spec.tests || []).every((test) =>
62
+ isObject(test) &&
63
+ (test.results == null ||
64
+ (Array.isArray(test.results) && test.results.every(isObject))));
65
+ });
66
+ });
67
+ }
68
+
69
+ function validJsonFormat(format, value) {
70
+ if (format === "generic") return isObject(value) && Array.isArray(value.tests);
71
+ if (format === "playwright") return isObject(value) && validPlaywrightSuites(value.suites);
72
+ if (format === "cypress") {
73
+ return isObject(value) && Array.isArray(value.runs) && value.runs.every((run) =>
74
+ isObject(run) &&
75
+ (run.tests == null ||
76
+ (Array.isArray(run.tests) && run.tests.every((test) =>
77
+ isObject(test) && (test.attempts == null || Array.isArray(test.attempts))))) &&
78
+ (run.screenshots == null || Array.isArray(run.screenshots)));
63
79
  }
80
+ return Array.isArray(value) && value.every((feature) =>
81
+ isObject(feature) &&
82
+ (feature.elements == null ||
83
+ (Array.isArray(feature.elements) && feature.elements.every((element) =>
84
+ isObject(element) && (element.steps == null || Array.isArray(element.steps))))));
64
85
  }
65
86
 
66
- export function detectReportFormat(text) {
87
+ function invalidFormatMessage(format) {
88
+ const expected = {
89
+ cypress: "a JSON object with well-formed runs[]",
90
+ playwright: "a JSON object with well-formed suites[]",
91
+ cucumber: "a JSON array of features with well-formed elements[]",
92
+ generic: "a JSON object with tests[]",
93
+ }[format];
94
+ return `${FORMAT_LABELS[format]} must be ${expected}.`;
95
+ }
96
+
97
+ export function analyzeReport(text, requestedFormat = "auto") {
67
98
  const trimmed = text.replace(/^\uFEFF/, "").trimStart();
68
99
  if (!trimmed) throw new CliError("report file is empty.");
69
- if (trimmed.startsWith("<")) {
70
- assertFormatShape("junit", trimmed);
71
- return "junit";
100
+ if (requestedFormat === "junit" || (requestedFormat === "auto" && trimmed.startsWith("<"))) {
101
+ if (!/^(?:<\?xml[\s\S]*?\?>\s*)?(?:<!--[\s\S]*?-->\s*)*<(?:[\w.-]+:)?testsuites?\b/i.test(trimmed)) {
102
+ throw new CliError("JUnit reports must be XML containing a <testsuite> or <testsuites> root.");
103
+ }
104
+ return { format: "junit", document: null };
72
105
  }
73
106
 
74
107
  const value = parseJson(trimmed);
75
108
  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
- );
109
+ if (requestedFormat === "auto") {
110
+ if (matches.length === 0) {
111
+ throw new CliError(
112
+ "could not detect the report format; expected Cypress runs[], Playwright suites[], " +
113
+ "a Cucumber feature array, generic tests[], or JUnit XML.",
114
+ );
115
+ }
116
+ if (matches.length > 1) {
117
+ throw new CliError(`report shape is ambiguous (${matches.join(", ")}); pass --format explicitly.`);
118
+ }
119
+ const format = matches[0];
120
+ if (!validJsonFormat(format, value)) throw new CliError(invalidFormatMessage(format));
121
+ return { format, document: value };
81
122
  }
82
- if (matches.length > 1) {
83
- throw new CliError(`report shape is ambiguous (${matches.join(", ")}); pass --format explicitly.`);
123
+ if (!matches.includes(requestedFormat) || !validJsonFormat(requestedFormat, value)) {
124
+ throw new CliError(invalidFormatMessage(requestedFormat));
84
125
  }
85
- return matches[0];
126
+ return { format: requestedFormat, document: value };
127
+ }
128
+
129
+ export function detectReportFormat(text) {
130
+ return analyzeReport(text).format;
86
131
  }
87
132
 
88
133
  function playwrightTestNodes(suites, found = []) {
@@ -115,44 +160,146 @@ function selectedPlaywrightSuite(suite, selectedTests) {
115
160
  return next.specs.length > 0 || next.suites.length > 0 ? next : null;
116
161
  }
117
162
 
118
- export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE) {
163
+ // Counts <testcase> elements the way the server's parser does: real tags
164
+ // only, so a failure message inside CDATA that happens to contain
165
+ // "<testcase" is not counted.
166
+ function countJunitTests(text) {
167
+ const stripped = text
168
+ .replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "")
169
+ .replace(/<!--[\s\S]*?-->/g, "");
170
+ return (stripped.match(/<testcase[\s/>]/gi) || []).length;
171
+ }
172
+
173
+ function isCucumberScenario(element) {
174
+ return !!element && typeof element === "object" &&
175
+ String(element.type || "scenario").toLowerCase() === "scenario";
176
+ }
177
+
178
+ function pagedSlices(items, maxTests) {
179
+ const slices = [];
180
+ for (let start = 0; start < items.length; start += maxTests) {
181
+ slices.push(items.slice(start, start + maxTests));
182
+ }
183
+ return slices;
184
+ }
185
+
186
+ function cypressTestFailed(test) {
187
+ const attempts = Array.isArray(test.attempts) ? test.attempts : [];
188
+ const state = attempts[attempts.length - 1]?.state || test.state;
189
+ return String(state || "").toLowerCase() === "failed";
190
+ }
191
+
192
+ function normalizedCypressTitle(value) {
193
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
194
+ }
195
+
196
+ function cypressScreenshotMatchesTest(screenshot, test) {
197
+ if (!isObject(screenshot)) return false;
198
+ const title = Array.isArray(test.title) ? test.title : [String(test.title || "")];
199
+ const haystack = normalizedCypressTitle(`${screenshot.name || ""} ${screenshot.path || ""}`);
200
+ const fullTitle = normalizedCypressTitle(title.join(" "));
201
+ const leafTitle = normalizedCypressTitle(title[title.length - 1] || "");
202
+ return !!haystack &&
203
+ ((fullTitle.length >= 4 && haystack.includes(fullTitle)) ||
204
+ (leafTitle.length >= 4 && haystack.includes(leafTitle)));
205
+ }
206
+
207
+ function selectedCypressRun(run, selectedTests) {
208
+ const runTests = Array.isArray(run.tests) ? run.tests : [];
209
+ const tests = runTests.filter((test) => selectedTests.has(test));
210
+ if (tests.length === 0) return null;
211
+
212
+ // The server associates run-level screenshots using the complete run's set
213
+ // of failures. Keep that context while paging: otherwise two failures split
214
+ // across pages each look like the run's sole failure and both inherit every
215
+ // unmatched screenshot.
216
+ const allFailures = runTests.filter(cypressTestFailed);
217
+ const pageFailures = tests.filter(cypressTestFailed);
218
+ let screenshots = [];
219
+ if (allFailures.length === 1 && pageFailures.length === 1) {
220
+ screenshots = Array.isArray(run.screenshots) ? run.screenshots : [];
221
+ } else if (allFailures.length > 1 && pageFailures.length > 0) {
222
+ screenshots = (Array.isArray(run.screenshots) ? run.screenshots : [])
223
+ .filter((screenshot) => pageFailures.some((test) => cypressScreenshotMatchesTest(screenshot, test)));
224
+ }
225
+
226
+ return {
227
+ ...run,
228
+ tests,
229
+ screenshots,
230
+ video: pageFailures.length > 0 ? run.video : null,
231
+ };
232
+ }
233
+
234
+ export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null) {
119
235
  if (!Number.isInteger(maxTests) || maxTests < 1 || maxTests > MAX_TESTS_PER_PAGE) {
120
236
  throw new CliError(`page size must be an integer from 1 to ${MAX_TESTS_PER_PAGE}.`);
121
237
  }
122
- if (format !== "playwright" && format !== "generic") {
123
- return [{ body: report, tests: null }];
238
+ if (format === "junit") {
239
+ const count = countJunitTests(report.toString("utf8"));
240
+ if (count > maxTests) {
241
+ throw new CliError(
242
+ `JUnit report has ${count} tests; the server accepts at most ${maxTests} per request ` +
243
+ "and JUnit XML cannot be paged automatically. Split it per suite, or switch the runner " +
244
+ "to a JSON reporter (Playwright, Cypress, Cucumber, or generic), which the CLI pages for you.",
245
+ );
246
+ }
247
+ return [{ body: report, tests: count }];
124
248
  }
125
249
 
126
- const document = parseJson(report.toString("utf8").replace(/^\uFEFF/, ""));
250
+ const doc = document ?? parseJson(report.toString("utf8").replace(/^\uFEFF/, ""));
127
251
  if (format === "generic") {
128
- const tests = document.tests;
252
+ const tests = doc.tests;
129
253
  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;
254
+ return pagedSlices(tests, maxTests).map((slice) => ({
255
+ body: Buffer.from(JSON.stringify({ ...doc, tests: slice })),
256
+ tests: slice.length,
257
+ }));
139
258
  }
140
259
 
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,
260
+ if (format === "playwright") {
261
+ const tests = playwrightTestNodes(doc.suites);
262
+ if (tests.length <= maxTests) return [{ body: report, tests: tests.length }];
263
+ return pagedSlices(tests, maxTests).map((slice) => {
264
+ const selected = new Set(slice);
265
+ const suites = doc.suites
266
+ .map((suite) => selectedPlaywrightSuite(suite, selected))
267
+ .filter(Boolean);
268
+ return { body: Buffer.from(JSON.stringify({ ...doc, suites })), tests: slice.length };
153
269
  });
154
270
  }
155
- return pages;
271
+
272
+ if (format === "cypress") {
273
+ const runs = doc.runs;
274
+ const tests = runs.flatMap((run) => Array.isArray(run.tests) ? run.tests : []);
275
+ if (tests.length <= maxTests) return [{ body: report, tests: tests.length }];
276
+ return pagedSlices(tests, maxTests).map((slice) => {
277
+ const selected = new Set(slice);
278
+ const pageRuns = runs
279
+ .map((run) => selectedCypressRun(run, selected))
280
+ .filter(Boolean);
281
+ return { body: Buffer.from(JSON.stringify({ ...doc, runs: pageRuns })), tests: slice.length };
282
+ });
283
+ }
284
+
285
+ // cucumber: the test unit is a scenario element. Background (and other
286
+ // non-scenario) elements apply to every scenario in their feature, so
287
+ // they are replicated onto every page that carries that feature.
288
+ const features = (Array.isArray(doc) ? doc : []).filter((f) => f && typeof f === "object");
289
+ const scenarios = features.flatMap((f) =>
290
+ (Array.isArray(f.elements) ? f.elements : []).filter(isCucumberScenario));
291
+ if (scenarios.length <= maxTests) return [{ body: report, tests: scenarios.length }];
292
+ return pagedSlices(scenarios, maxTests).map((slice) => {
293
+ const selected = new Set(slice);
294
+ const pageFeatures = features
295
+ .map((f) => {
296
+ const elements = (Array.isArray(f.elements) ? f.elements : [])
297
+ .filter((el) => !isCucumberScenario(el) || selected.has(el));
298
+ return elements.some((el) => isCucumberScenario(el)) ? { ...f, elements } : null;
299
+ })
300
+ .filter(Boolean);
301
+ return { body: Buffer.from(JSON.stringify(pageFeatures)), tests: slice.length };
302
+ });
156
303
  }
157
304
 
158
305
  function isLoopback(hostname) {
@@ -242,58 +389,235 @@ export function parseUploadArgs(args) {
242
389
  options.reportPath = arg;
243
390
  }
244
391
 
245
- const formats = new Set(["auto", "cypress", "playwright", "cucumber", "junit", "generic"]);
246
392
  options.format = String(options.format).toLowerCase();
247
- if (!formats.has(options.format)) {
393
+ if (options.format !== "auto" && !REPORT_FORMATS.has(options.format)) {
248
394
  throw new CliError("--format must be auto, cypress, playwright, cucumber, junit, or generic.");
249
395
  }
250
396
  return options;
251
397
  }
252
398
 
253
- function boundedHeader(value, name, maxLength) {
399
+ export async function readProjectConfig(cwd) {
400
+ const path = join(cwd, CONFIG_FILE_NAME);
401
+ let text;
402
+ try {
403
+ text = await readFile(path, "utf8");
404
+ } catch (error) {
405
+ if (error && error.code === "ENOENT") return null;
406
+ throw new CliError(`could not read ${CONFIG_FILE_NAME}: ${error.message}`);
407
+ }
408
+ let value;
409
+ try {
410
+ value = JSON.parse(text.replace(/^\uFEFF/, ""));
411
+ } catch (error) {
412
+ throw new CliError(`${CONFIG_FILE_NAME} is not valid JSON: ${error.message}`);
413
+ }
414
+ if (!isObject(value)) throw new CliError(`${CONFIG_FILE_NAME} must be a JSON object.`);
415
+ const config = {};
416
+ if (value.report !== undefined) {
417
+ if (typeof value.report !== "string" || !value.report.trim()) {
418
+ throw new CliError(`${CONFIG_FILE_NAME} "report" must be a non-empty string.`);
419
+ }
420
+ config.report = value.report;
421
+ }
422
+ if (value.format !== undefined) {
423
+ const format = String(value.format).toLowerCase();
424
+ if (!REPORT_FORMATS.has(format)) {
425
+ throw new CliError(`${CONFIG_FILE_NAME} "format" must be cypress, playwright, cucumber, junit, or generic.`);
426
+ }
427
+ config.format = format;
428
+ }
429
+ if (value.url !== undefined) {
430
+ if (typeof value.url !== "string" || !value.url.trim()) {
431
+ throw new CliError(`${CONFIG_FILE_NAME} "url" must be a non-empty string.`);
432
+ }
433
+ config.url = value.url;
434
+ }
435
+ return config;
436
+ }
437
+
438
+ // A committed config must work on teammates' machines and Linux CI, so only
439
+ // a cwd-relative POSIX path is worth remembering.
440
+ function portableReportPath(cwd, reportPath) {
441
+ const rel = relative(cwd, resolve(cwd, reportPath));
442
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
443
+ return rel.split(sep).join("/");
444
+ }
445
+
446
+ // Created only when absent: the config is a committed project default, so an
447
+ // upload must never silently repoint it. Users edit or delete the file.
448
+ async function saveProjectConfig(cwd, options, stdout, stderr) {
449
+ const report = portableReportPath(cwd, options.reportPath);
450
+ if (!report) {
451
+ stderr(
452
+ `Warning: not saving ${CONFIG_FILE_NAME} — the report path points outside ` +
453
+ "this directory; run from the project root to remember it.",
454
+ );
455
+ return;
456
+ }
457
+ const saved = { report };
458
+ if (options.format !== "auto") saved.format = options.format;
459
+ if (options.url) saved.url = options.url;
460
+ try {
461
+ await writeFile(join(cwd, CONFIG_FILE_NAME), `${JSON.stringify(saved, null, 2)}\n`, { flag: "wx" });
462
+ stdout(`Saved report path to ${CONFIG_FILE_NAME}; commit it, and later runs are just: npx testdossier`);
463
+ } catch (error) {
464
+ if (error && error.code !== "EEXIST") {
465
+ stderr(`Warning: could not write ${CONFIG_FILE_NAME}: ${error.message}`);
466
+ }
467
+ }
468
+ }
469
+
470
+ async function runInit({ cwd, stdout }) {
471
+ const envPath = join(cwd, ENV_FILE_NAME);
472
+ const contents =
473
+ "# Replace the placeholder with the CI-ingestion token shown once by TestDossier.\n" +
474
+ `TD_CI_TOKEN=${TOKEN_PLACEHOLDER}\n`;
475
+ let created = false;
476
+ try {
477
+ await writeFile(envPath, contents, { flag: "wx", mode: 0o600 });
478
+ created = true;
479
+ } catch (error) {
480
+ if (!error || error.code !== "EEXIST") {
481
+ throw new CliError(`could not create ${ENV_FILE_NAME}: ${error && error.message}`);
482
+ }
483
+ }
484
+ stdout(created
485
+ ? `Created ${ENV_FILE_NAME} (readable only by you).`
486
+ : `${ENV_FILE_NAME} already exists; left unchanged.`);
487
+
488
+ const gitignorePath = join(cwd, ".gitignore");
489
+ let gitignore = "";
490
+ try {
491
+ gitignore = await readFile(gitignorePath, "utf8");
492
+ } catch (error) {
493
+ if (!error || error.code !== "ENOENT") {
494
+ throw new CliError(`could not read .gitignore: ${error && error.message}`);
495
+ }
496
+ }
497
+ const ignored = gitignore.split(/\r?\n/).some((line) => {
498
+ const entry = line.trim();
499
+ return entry === ENV_FILE_NAME || entry === `/${ENV_FILE_NAME}`;
500
+ });
501
+ if (!ignored) {
502
+ const separator = gitignore && !gitignore.endsWith("\n") ? "\n" : "";
503
+ try {
504
+ await writeFile(gitignorePath, `${gitignore}${separator}${ENV_FILE_NAME}\n`);
505
+ } catch (error) {
506
+ throw new CliError(`could not update .gitignore: ${error && error.message}`);
507
+ }
508
+ stdout(`Added ${ENV_FILE_NAME} to .gitignore.`);
509
+ }
510
+
511
+ stdout([
512
+ `Next: paste your td_ access token into ${ENV_FILE_NAME} (TestDossier → Access tokens), then upload a report:`,
513
+ " npx testdossier upload <report-file>",
514
+ `The first successful upload remembers the report path in ${CONFIG_FILE_NAME}; after that:`,
515
+ " npx testdossier",
516
+ ].join("\n"));
517
+ return 0;
518
+ }
519
+
520
+ function normalizedHeader(value, name) {
254
521
  if (value == null || value === "") return "";
255
522
  const normalized = String(value);
256
523
  if (/[\r\n]/.test(normalized)) throw new CliError(`${name} must not contain a newline.`);
257
- return normalized.slice(0, maxLength);
524
+ // fetch rejects header values outside ISO-8859-1; percent-encode anything
525
+ // beyond printable ASCII so a Unicode branch name degrades to a readable
526
+ // token instead of a TypeError that looks like a network failure.
527
+ const ascii = normalized.replace(/[^\x20-\x7e]/gu, (char) => {
528
+ try { return encodeURIComponent(char); } catch { return "_"; }
529
+ });
530
+ return ascii.trim();
531
+ }
532
+
533
+ function boundedHeader(value, name, maxLength) {
534
+ return normalizedHeader(value, name).slice(0, maxLength).trim();
258
535
  }
259
536
 
260
537
  function boundedRunId(value) {
261
- const normalized = boundedHeader(value, "run id", 1000);
538
+ const normalized = normalizedHeader(value, "run id");
262
539
  if (normalized.length <= 120) return normalized;
263
540
  const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 20);
264
541
  return `${normalized.slice(0, 95)}-${digest}`;
265
542
  }
266
543
 
544
+ function boundedPageBase(value) {
545
+ const normalized = normalizedHeader(value, "report name") || "results";
546
+ if (normalized.length <= 100) return normalized;
547
+ const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
548
+ return `${normalized.slice(0, 83)}-${digest}`;
549
+ }
550
+
551
+ function reportPageBase(reportPath) {
552
+ const reportName = basename(reportPath);
553
+ const pathKey = String(reportPath).replace(/\\/g, "/");
554
+ const pathDigest = createHash("sha256").update(pathKey).digest("hex").slice(0, 12);
555
+ return boundedPageBase(`${reportName}-${pathDigest}`);
556
+ }
557
+
267
558
  function defaultRunId() {
268
559
  return `local-${Date.now()}-${randomUUID().slice(0, 8)}`;
269
560
  }
270
561
 
562
+ function inferredProvider(env) {
563
+ if (env.GITHUB_ACTIONS) return "github_actions";
564
+ if (env.GITLAB_CI) return "gitlab_ci";
565
+ if (env.TF_BUILD) return "azure_devops";
566
+ if (env.BITBUCKET_BUILD_NUMBER) return "bitbucket_pipelines";
567
+ if (env.CIRCLECI) return "circleci";
568
+ if (env.JENKINS_URL) return "jenkins";
569
+ return "local";
570
+ }
571
+
271
572
  function inferredMetadata(options, env) {
573
+ // The job identifier is part of every inferred key: two jobs of one
574
+ // workflow/pipeline run would otherwise share a run id, and the server
575
+ // answers the second upload with an idempotent replay of the first.
272
576
  const runId = boundedRunId(
273
577
  options.runId ||
274
578
  env.TD_RUN_ID ||
275
- (env.GITHUB_RUN_ID ? `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT || 1}` : "") ||
579
+ (env.GITHUB_RUN_ID
580
+ ? [env.GITHUB_RUN_ID, env.GITHUB_JOB, env.GITHUB_RUN_ATTEMPT || 1].filter(Boolean).join("-")
581
+ : "") ||
276
582
  (env.CI_PIPELINE_ID ? `${env.CI_PIPELINE_ID}-${env.CI_JOB_ID || 1}` : "") ||
583
+ (env.BUILD_BUILDID
584
+ ? [env.BUILD_BUILDID, env.SYSTEM_JOBID, env.SYSTEM_JOBATTEMPT].filter(Boolean).join("-")
585
+ : "") ||
586
+ (env.BITBUCKET_BUILD_NUMBER
587
+ ? [
588
+ env.BITBUCKET_BUILD_NUMBER,
589
+ env.BITBUCKET_STEP_UUID,
590
+ env.BITBUCKET_STEP_RUN_NUMBER || 1,
591
+ ].filter(Boolean).join("-")
592
+ : "") ||
593
+ (env.CIRCLE_WORKFLOW_ID
594
+ ? [env.CIRCLE_WORKFLOW_ID, env.CIRCLE_WORKFLOW_JOB_ID || env.CIRCLE_JOB].filter(Boolean).join("-")
595
+ : "") ||
277
596
  env.BUILD_TAG ||
278
597
  defaultRunId(),
279
598
  );
280
599
  const build = boundedHeader(
281
- options.build || env.TD_BUILD || env.GITHUB_SHA || env.CI_COMMIT_SHA || env.GIT_COMMIT,
600
+ options.build || env.TD_BUILD || env.GITHUB_SHA || env.CI_COMMIT_SHA ||
601
+ env.BUILD_SOURCEVERSION || env.BITBUCKET_COMMIT || env.CIRCLE_SHA1 || env.GIT_COMMIT,
282
602
  "build",
283
603
  80,
284
604
  );
285
605
  const branch = boundedHeader(
286
- options.branch || env.TD_BRANCH || env.GITHUB_REF_NAME || env.CI_COMMIT_REF_NAME || env.BRANCH_NAME,
606
+ options.branch || env.TD_BRANCH || env.GITHUB_REF_NAME || env.CI_COMMIT_REF_NAME ||
607
+ env.BUILD_SOURCEBRANCHNAME || env.BITBUCKET_BRANCH || env.CIRCLE_BRANCH || env.BRANCH_NAME,
287
608
  "branch",
288
609
  80,
289
610
  );
290
611
  const provider = boundedHeader(
291
- options.provider || env.TD_PROVIDER ||
292
- (env.GITHUB_ACTIONS ? "github_actions" : env.GITLAB_CI ? "gitlab_ci" : env.JENKINS_URL ? "jenkins" : "local"),
612
+ options.provider || env.TD_PROVIDER || inferredProvider(env),
293
613
  "provider",
294
614
  80,
295
615
  );
296
- const ciUrl = boundedHeader(options.ciUrl || env.TD_CI_URL || env.CI_JOB_URL || env.BUILD_URL, "CI URL", 500);
616
+ const ciUrl = boundedHeader(
617
+ options.ciUrl || env.TD_CI_URL || env.CI_JOB_URL || env.CIRCLE_BUILD_URL || env.BUILD_URL,
618
+ "CI URL",
619
+ 500,
620
+ );
297
621
  return { runId, build, branch, provider, ciUrl };
298
622
  }
299
623
 
@@ -337,6 +661,39 @@ async function tokenFromEnvFile(path, stderr) {
337
661
  return parseTokenEnvFile(await readFile(path, "utf8"));
338
662
  }
339
663
 
664
+ // Single token-resolution path for upload and verify: --env-file, then the
665
+ // process environment, then the init-created file in the working directory.
666
+ async function resolveToken(options, env, cwd, stdout, stderr) {
667
+ let token;
668
+ if (options.envFile) {
669
+ token = await tokenFromEnvFile(options.envFile, stderr);
670
+ } else if (env.TD_CI_TOKEN) {
671
+ token = env.TD_CI_TOKEN;
672
+ } else {
673
+ const discovered = join(cwd, ENV_FILE_NAME);
674
+ const discoveredStats = await stat(discovered).catch(() => null);
675
+ if (discoveredStats && discoveredStats.isFile()) {
676
+ token = await tokenFromEnvFile(discovered, stderr);
677
+ stdout(`Using token from ${ENV_FILE_NAME}.`);
678
+ }
679
+ }
680
+ if (!token) {
681
+ throw new CliError(
682
+ `TD_CI_TOKEN is not set. Run \`npx testdossier init\` to create ${ENV_FILE_NAME}, ` +
683
+ "then paste your td_ access token into it.",
684
+ );
685
+ }
686
+ if (token === TOKEN_PLACEHOLDER) {
687
+ throw new CliError(
688
+ `TD_CI_TOKEN is still the placeholder; paste your real td_ access token into ${ENV_FILE_NAME}.`,
689
+ );
690
+ }
691
+ if (!token.startsWith("td_")) {
692
+ throw new CliError("TD_CI_TOKEN must be a TestDossier td_ access token.");
693
+ }
694
+ return token;
695
+ }
696
+
340
697
  function retryDelayMs(attempt, retryAfter, baseDelay) {
341
698
  if (retryAfter) {
342
699
  const seconds = Number(retryAfter);
@@ -347,13 +704,13 @@ function retryDelayMs(attempt, retryAfter, baseDelay) {
347
704
  return Math.min(baseDelay * (2 ** (attempt - 1)), 30_000);
348
705
  }
349
706
 
350
- function responseError(status, body) {
707
+ function responseError(status, body, action) {
351
708
  const code = body && typeof body.error === "string" ? ` ${body.error}` : "";
352
709
  const message = body && typeof body.message === "string" ? `: ${body.message.slice(0, 500)}` : "";
353
- return `upload failed (${status})${code}${message}`;
710
+ return `${action} failed (${status})${code}${message}`;
354
711
  }
355
712
 
356
- async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr }) {
713
+ async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr, action = "upload" }) {
357
714
  const attempts = integerEnv(env.TD_UPLOAD_MAX_ATTEMPTS, 5, 1, 10);
358
715
  const baseDelay = integerEnv(env.TD_UPLOAD_BASE_DELAY_MS, 1000, 1, 30_000);
359
716
  const timeout = integerEnv(env.TD_UPLOAD_TIMEOUT_MS, 60_000, 1000, 300_000);
@@ -387,21 +744,25 @@ async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr }
387
744
  }
388
745
  if (response.ok) return responseBody;
389
746
  if (!RETRYABLE_STATUSES.has(response.status) || attempt === attempts) {
390
- throw new CliError(responseError(response.status, responseBody), 1);
747
+ throw new CliError(responseError(response.status, responseBody, action), 1);
391
748
  }
392
749
  const wait = retryDelayMs(attempt, response.headers.get("retry-after"), baseDelay);
393
- stderr(`Upload returned ${response.status}; retrying ${attempt}/${attempts - 1} in ${wait}ms.`);
750
+ stderr(`Server returned ${response.status}; retrying ${attempt}/${attempts - 1} in ${wait}ms.`);
394
751
  await new Promise((resolve) => setTimeout(resolve, wait));
395
752
  }
396
753
 
397
- throw new CliError(`upload failed after ${attempts} attempt(s): ${lastNetworkError || "network error"}`, 1);
754
+ throw new CliError(`${action} failed after ${attempts} attempt(s): ${lastNetworkError || "network error"}`, 1);
398
755
  }
399
756
 
400
757
  function helpText() {
401
758
  return `TestDossier report uploader ${VERSION}
402
759
 
403
760
  Usage:
761
+ testdossier init One-time setup: create ${ENV_FILE_NAME} and gitignore it
762
+ testdossier verify Check the token and destination without sending a report
404
763
  testdossier upload <report-file> [options]
764
+ testdossier <report-file> [options] "upload" may be omitted
765
+ testdossier Upload the report remembered in ${CONFIG_FILE_NAME}
405
766
 
406
767
  Options:
407
768
  --format <format> auto (default), cypress, playwright, cucumber, junit, generic
@@ -417,18 +778,31 @@ Options:
417
778
  --version Show version
418
779
 
419
780
  Authentication:
420
- Set TD_CI_TOKEN in the environment or pass --env-file .env.testdossier.
421
- Tokens are intentionally not accepted as command-line arguments, keeping
422
- them out of shell history and process listings.
781
+ The token is read from the first available source: --env-file, the
782
+ TD_CI_TOKEN process environment, then ${ENV_FILE_NAME} in the current
783
+ directory (created by "testdossier init"). Tokens are intentionally not
784
+ accepted as command-line arguments, keeping them out of shell history
785
+ and process listings.
786
+
787
+ Project config:
788
+ The first successful upload writes ${CONFIG_FILE_NAME} (the report path,
789
+ plus --format and --url when passed) if the file does not exist. Commit
790
+ it; later runs are just "npx testdossier". Edit or delete the file to
791
+ change the saved defaults — uploads never rewrite an existing config.
423
792
 
424
793
  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.
794
+ JSON reports (Playwright, Cypress, Cucumber, generic) above 200 tests are
795
+ automatically sent as sequential pages that remain grouped under one
796
+ TestDossier run. JUnit XML above 200 tests is rejected before upload —
797
+ split it per suite or switch the runner to a JSON reporter.
427
798
 
428
799
  Examples:
429
- npx testdossier upload playwright-results.json --env-file .env.testdossier
430
- npx testdossier upload junit-results.xml --env-file .env.testdossier --build abc123
431
- npx testdossier upload testdossier-ci.json --dry-run`;
800
+ npx testdossier init
801
+ npx testdossier verify
802
+ npx testdossier upload playwright-results.json
803
+ npx testdossier upload junit-results.xml --build abc123
804
+ npx testdossier upload testdossier-ci.json --dry-run
805
+ npx testdossier`;
432
806
  }
433
807
 
434
808
  function outputSummary(result, stdout, stderr) {
@@ -446,6 +820,7 @@ function outputSummary(result, stdout, stderr) {
446
820
 
447
821
  function aggregatePageResults(results) {
448
822
  const aggregate = { counts: {}, warnings: [] };
823
+ const seenWarnings = new Set();
449
824
  for (const result of results) {
450
825
  if (result && result.counts && typeof result.counts === "object") {
451
826
  for (const [name, value] of Object.entries(result.counts)) {
@@ -454,7 +829,12 @@ function aggregatePageResults(results) {
454
829
  }
455
830
  }
456
831
  }
457
- if (Array.isArray(result && result.warnings)) aggregate.warnings.push(...result.warnings);
832
+ for (const warning of Array.isArray(result && result.warnings) ? result.warnings : []) {
833
+ const key = String(warning);
834
+ if (seenWarnings.has(key)) continue;
835
+ seenWarnings.add(key);
836
+ aggregate.warnings.push(warning);
837
+ }
458
838
  }
459
839
  return aggregate;
460
840
  }
@@ -466,27 +846,73 @@ export async function runCli(
466
846
  fetchImpl = globalThis.fetch,
467
847
  stdout = (message) => console.log(message),
468
848
  stderr = (message) => console.error(message),
849
+ cwd = process.cwd(),
469
850
  } = {},
470
851
  ) {
471
852
  try {
472
- if (args.includes("--version")) {
853
+ if (args[0] === "--version") {
473
854
  stdout(VERSION);
474
855
  return 0;
475
856
  }
476
- if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
857
+ if (args[0] === "--help" || args[0] === "-h") {
477
858
  stdout(helpText());
478
859
  return 0;
479
860
  }
480
- if (args[0] !== "upload") {
481
- throw new CliError(`unknown command: ${args[0]}\n\n${helpText()}`);
861
+ if (args[0] === "init") {
862
+ if (args.length > 1) throw new CliError("init takes no arguments.");
863
+ return await runInit({ cwd, stdout });
864
+ }
865
+ if (args[0] === "verify") {
866
+ const options = parseUploadArgs(args.slice(1));
867
+ if (options.help) {
868
+ stdout(helpText());
869
+ return 0;
870
+ }
871
+ if (options.reportPath) throw new CliError("verify takes no report file.");
872
+ const config = await readProjectConfig(cwd);
873
+ const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
874
+ const token = await resolveToken(options, env, cwd, stdout, stderr);
875
+ if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
876
+ const result = await postWithRetry({
877
+ endpoint,
878
+ headers: {
879
+ Authorization: `Bearer ${token}`,
880
+ "User-Agent": `testdossier-cli/${VERSION}`,
881
+ "X-CI-Verify": "1",
882
+ },
883
+ body: null,
884
+ env,
885
+ fetchImpl,
886
+ stderr,
887
+ action: "verify",
888
+ });
889
+ const name = result && typeof result.project_name === "string" && result.project_name
890
+ ? ` "${result.project_name}"`
891
+ : "";
892
+ const id = result && result.project_id ? ` (${result.project_id})` : "";
893
+ stdout(`Token verified for project${name}${id} at ${new URL(endpoint).origin} — CI ingestion enabled.`);
894
+ return 0;
482
895
  }
483
896
 
484
- const options = parseUploadArgs(args.slice(1));
897
+ const options = parseUploadArgs(args[0] === "upload" ? args.slice(1) : args);
485
898
  if (options.help) {
486
899
  stdout(helpText());
487
900
  return 0;
488
901
  }
489
- if (!options.reportPath) throw new CliError("upload requires a report file.");
902
+ const config = await readProjectConfig(cwd);
903
+ if (!options.reportPath && config && config.report) {
904
+ options.reportPath = config.report;
905
+ stdout(`Using report ${config.report} from ${CONFIG_FILE_NAME}.`);
906
+ }
907
+ if (!options.reportPath) {
908
+ if (args.length === 0) {
909
+ stdout(helpText());
910
+ return 0;
911
+ }
912
+ throw new CliError(
913
+ `upload requires a report file (pass a path, or run one upload with a path to save it in ${CONFIG_FILE_NAME}).`,
914
+ );
915
+ }
490
916
 
491
917
  const fileStats = await stat(options.reportPath).catch((error) => {
492
918
  throw new CliError(`could not read report file: ${error.message}`);
@@ -498,33 +924,23 @@ export async function runCli(
498
924
  }
499
925
  const report = await readFile(options.reportPath);
500
926
  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;
507
-
508
- const endpoint = normalizeEndpoint(options.url || env.TD_URL || DEFAULT_ORIGIN);
927
+ const requestedFormat = options.format !== "auto" ? options.format : (config && config.format) || "auto";
928
+ const { format, document } = analyzeReport(reportText, requestedFormat);
929
+ const pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
930
+ const testCount = pages.reduce((sum, page) => sum + page.tests, 0);
931
+
932
+ const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
509
933
  const destination = new URL(endpoint).origin;
510
934
  stdout(
511
- `Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes` +
512
- (testCount == null
513
- ? ""
514
- : `, ${testCount} tests${pages.length > 1 ? ` across ${pages.length} pages` : ""}`) +
515
- ").",
935
+ `Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes, ${testCount} tests` +
936
+ `${pages.length > 1 ? ` across ${pages.length} pages` : ""}).`,
516
937
  );
517
938
  if (options.dryRun) {
518
939
  stdout(`Dry run complete; no data was sent to ${destination}.`);
519
940
  return 0;
520
941
  }
521
942
 
522
- const token = options.envFile
523
- ? await tokenFromEnvFile(options.envFile, stderr)
524
- : env.TD_CI_TOKEN;
525
- if (!token || !token.startsWith("td_")) {
526
- throw new CliError("TD_CI_TOKEN is required and must be a TestDossier td_ access token.");
527
- }
943
+ const token = await resolveToken(options, env, cwd, stdout, stderr);
528
944
  if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
529
945
 
530
946
  const metadata = inferredMetadata(options, env);
@@ -546,6 +962,11 @@ export async function runCli(
546
962
  `${pages.length} pages (run ${metadata.runId}).`,
547
963
  );
548
964
  }
965
+ // Page ids carry a readable file name plus a path fingerprint so two
966
+ // reports uploaded under one run id become distinct pages even when their
967
+ // base names match (for example chromium/results.json and
968
+ // firefox/results.json).
969
+ const pageBase = reportPageBase(options.reportPath);
549
970
  const pageResults = [];
550
971
  for (let index = 0; index < pages.length; index += 1) {
551
972
  const page = pages[index];
@@ -553,7 +974,7 @@ export async function runCli(
553
974
  endpoint,
554
975
  headers: {
555
976
  ...baseHeaders,
556
- "X-CI-Page-Id": pages.length === 1 ? "results" : String(index + 1),
977
+ "X-CI-Page-Id": pages.length === 1 ? pageBase : `${pageBase}-${index + 1}`,
557
978
  },
558
979
  body: page.body,
559
980
  env,
@@ -566,7 +987,15 @@ export async function runCli(
566
987
  }
567
988
  }
568
989
  stdout(`Uploaded ${FORMAT_LABELS[format]} to ${destination} (run ${metadata.runId}).`);
990
+ const replayed = pageResults.filter((result) => result && result.idempotent_replay === true).length;
991
+ if (replayed > 0) {
992
+ stderr(pageResults.length === 1
993
+ ? `Note: the server replayed an earlier upload for run ${metadata.runId}; nothing new was ingested. ` +
994
+ "Pass a unique --run-id if this was not a retry."
995
+ : `Note: ${replayed} of ${pageResults.length} pages were idempotent replays of an earlier upload (run ${metadata.runId}).`);
996
+ }
569
997
  outputSummary(aggregatePageResults(pageResults), stdout, stderr);
998
+ if (!config) await saveProjectConfig(cwd, options, stdout, stderr);
570
999
  return 0;
571
1000
  } catch (error) {
572
1001
  const message = error && error.message ? error.message : String(error);
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "testdossier",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
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": {