testdossier 0.1.0 → 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.
@@ -0,0 +1,3 @@
1
+ # Copy this file to .env.testdossier, replace the placeholder, and keep the
2
+ # resulting file out of git.
3
+ TD_CI_TOKEN=replace_with_your_td_token
package/README.md CHANGED
@@ -1,12 +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:
5
+
6
+ 1. Create `.env.testdossier`:
7
+
8
+ ```dotenv
9
+ TD_CI_TOKEN=replace_with_your_td_token
10
+ ```
11
+
12
+ 2. Replace the placeholder with the CI-ingestion token shown once by
13
+ TestDossier, add the file to `.gitignore`, and protect it:
14
+
15
+ ```bash
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:
4
28
 
5
29
  ```bash
6
- export TD_CI_TOKEN="td_..."
7
- npx testdossier upload playwright-results.json
30
+ npx testdossier upload playwright-results.json --env-file .env.testdossier
8
31
  ```
9
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
+
10
41
  The CLI auto-detects Playwright JSON, Cypress JSON, Cucumber JSON, JUnit XML,
11
42
  and TestDossier's generic JSON format. It has zero runtime dependencies and
12
43
  requires Node.js 18 or newer.
@@ -17,10 +48,12 @@ The uploader does one thing: read a completed report and make an outbound HTTPS
17
48
  request to TestDossier. It does not run tests, accept remote commands, install a
18
49
  background service, watch files, or keep a process running.
19
50
 
20
- The access token is read only from `TD_CI_TOKEN`. There is deliberately no
21
- `--token` option, so a token cannot accidentally land in shell history or a
22
- process listing. Redirects are rejected, and non-HTTPS destinations are rejected
23
- except for localhost development.
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.
24
57
 
25
58
  Before sending anything, inspect what the CLI detected:
26
59
 
@@ -45,31 +78,47 @@ creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
45
78
  ## Local setup
46
79
 
47
80
  Create a project access token in TestDossier with the **CI ingestion**
48
- capability, copy it once, and place it in an environment variable:
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:
49
83
 
50
84
  ```bash
51
- export TD_CI_TOKEN="td_..."
85
+ npm install --save-dev testdossier
86
+ cp node_modules/testdossier/.env.testdossier.example .env.testdossier
52
87
  ```
53
88
 
54
- For repeat use, install and pin the CLI in the test repository so its source and
55
- version are captured by the lockfile:
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
95
+ ```
56
96
 
57
97
  ```bash
58
- npm install --save-dev testdossier
59
- npx testdossier upload playwright-results.json
98
+ chmod 600 .env.testdossier
60
99
  ```
61
100
 
101
+ Upload with:
102
+
103
+ ```bash
104
+ npx testdossier upload playwright-results.json --env-file .env.testdossier
105
+ ```
106
+
107
+ For a one-off session, setting `TD_CI_TOKEN` in the process environment still
108
+ works. An explicitly passed `--env-file` takes precedence.
109
+
62
110
  The default destination is `https://testdossier.com`. A self-hosted or local
63
111
  instance can be selected without putting the token on the command line:
64
112
 
65
113
  ```bash
66
- TD_URL="https://dossier.example.com" npx testdossier upload junit-results.xml
114
+ TD_URL="https://dossier.example.com" npx testdossier upload junit-results.xml --env-file .env.testdossier
67
115
  ```
68
116
 
69
117
  Useful metadata options:
70
118
 
71
119
  ```bash
72
120
  npx testdossier upload junit-results.xml \
121
+ --env-file .env.testdossier \
73
122
  --run-id "local-2026-07-23-1" \
74
123
  --build "abc123" \
75
124
  --branch "main"
@@ -81,3 +130,7 @@ one, the CLI creates a new local run ID. Transient network, rate-limit, conflict
81
130
  and server errors are retried with the same run ID.
82
131
 
83
132
  Run `npx testdossier --help` for every option.
133
+
134
+ If `.env.testdossier` is ever committed or shared, revoke that token in
135
+ TestDossier immediately and create a replacement. Hosted CI should use the
136
+ provider's encrypted secret storage instead of an env file.
@@ -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.0";
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
  }
@@ -130,6 +201,7 @@ export function parseUploadArgs(args) {
130
201
  branch: undefined,
131
202
  provider: undefined,
132
203
  ciUrl: undefined,
204
+ envFile: undefined,
133
205
  dryRun: false,
134
206
  reportPath: undefined,
135
207
  };
@@ -141,6 +213,7 @@ export function parseUploadArgs(args) {
141
213
  ["--branch", "branch"],
142
214
  ["--provider", "provider"],
143
215
  ["--ci-url", "ciUrl"],
216
+ ["--env-file", "envFile"],
144
217
  ]);
145
218
 
146
219
  for (let i = 0; i < args.length; i += 1) {
@@ -229,6 +302,41 @@ function integerEnv(value, fallback, min, max) {
229
302
  return Number.isInteger(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
230
303
  }
231
304
 
305
+ export function parseTokenEnvFile(contents) {
306
+ let token;
307
+ const lines = String(contents).replace(/^\uFEFF/, "").split(/\r?\n/);
308
+ for (let index = 0; index < lines.length; index += 1) {
309
+ const line = lines[index].trim();
310
+ if (!line || line.startsWith("#")) continue;
311
+ const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
312
+ if (!match || match[1] !== "TD_CI_TOKEN") continue;
313
+ if (token !== undefined) throw new CliError("env file contains TD_CI_TOKEN more than once.");
314
+
315
+ let value = match[2].trim();
316
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
317
+ value = value.slice(1, -1);
318
+ } else {
319
+ value = value.replace(/\s+#.*$/, "").trim();
320
+ }
321
+ if (!value) throw new CliError(`TD_CI_TOKEN is empty in the env file (line ${index + 1}).`);
322
+ token = value;
323
+ }
324
+ if (token === undefined) throw new CliError("env file does not define TD_CI_TOKEN.");
325
+ return token;
326
+ }
327
+
328
+ async function tokenFromEnvFile(path, stderr) {
329
+ const fileStats = await stat(path).catch((error) => {
330
+ throw new CliError(`could not read env file: ${error.message}`);
331
+ });
332
+ if (!fileStats.isFile()) throw new CliError("env-file path must point to a file.");
333
+ if (fileStats.size > 64 * 1024) throw new CliError("env file is unexpectedly large (maximum 64 KiB).");
334
+ if (process.platform !== "win32" && (fileStats.mode & 0o077) !== 0) {
335
+ stderr(`Warning: ${path} is readable by other users; run chmod 600 "${path}".`);
336
+ }
337
+ return parseTokenEnvFile(await readFile(path, "utf8"));
338
+ }
339
+
232
340
  function retryDelayMs(attempt, retryAfter, baseDelay) {
233
341
  if (retryAfter) {
234
342
  const seconds = Number(retryAfter);
@@ -303,18 +411,23 @@ Options:
303
411
  --branch <value> Branch name
304
412
  --provider <value> Provider label (defaults to local or detected CI)
305
413
  --ci-url <url> Link to the originating CI run
414
+ --env-file <path> Read TD_CI_TOKEN from this explicit dotenv file
306
415
  --dry-run Validate and detect only; send nothing
307
416
  -h, --help Show help
308
417
  --version Show version
309
418
 
310
419
  Authentication:
311
- Set TD_CI_TOKEN=td_... in the environment. Tokens are intentionally not
312
- accepted as command-line arguments, keeping them out of shell history and
313
- process listings.
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.
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.
314
427
 
315
428
  Examples:
316
- TD_CI_TOKEN=td_... npx testdossier upload playwright-results.json
317
- TD_CI_TOKEN=td_... npx testdossier upload junit-results.xml --build abc123
429
+ npx testdossier upload playwright-results.json --env-file .env.testdossier
430
+ npx testdossier upload junit-results.xml --env-file .env.testdossier --build abc123
318
431
  npx testdossier upload testdossier-ci.json --dry-run`;
319
432
  }
320
433
 
@@ -331,6 +444,21 @@ function outputSummary(result, stdout, stderr) {
331
444
  }
332
445
  }
333
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
+
334
462
  export async function runCli(
335
463
  args,
336
464
  {
@@ -372,45 +500,73 @@ export async function runCli(
372
500
  const reportText = report.toString("utf8");
373
501
  const format = options.format === "auto" ? detectReportFormat(reportText) : options.format;
374
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;
375
507
 
376
508
  const endpoint = normalizeEndpoint(options.url || env.TD_URL || DEFAULT_ORIGIN);
377
509
  const destination = new URL(endpoint).origin;
378
- 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
+ );
379
517
  if (options.dryRun) {
380
518
  stdout(`Dry run complete; no data was sent to ${destination}.`);
381
519
  return 0;
382
520
  }
383
521
 
384
- const token = env.TD_CI_TOKEN;
522
+ const token = options.envFile
523
+ ? await tokenFromEnvFile(options.envFile, stderr)
524
+ : env.TD_CI_TOKEN;
385
525
  if (!token || !token.startsWith("td_")) {
386
526
  throw new CliError("TD_CI_TOKEN is required and must be a TestDossier td_ access token.");
387
527
  }
388
528
  if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
389
529
 
390
530
  const metadata = inferredMetadata(options, env);
391
- const headers = {
531
+ const baseHeaders = {
392
532
  Authorization: `Bearer ${token}`,
393
533
  "Content-Type": format === "junit" ? "application/xml" : "application/json",
394
534
  "User-Agent": `testdossier-cli/${VERSION}`,
395
535
  "X-CI-Run-Id": metadata.runId,
396
- "X-CI-Page-Id": "results",
397
536
  "X-CI-Provider": metadata.provider,
398
537
  };
399
- if (format !== "junit" && format !== "generic") headers["X-CI-Format"] = format;
400
- if (metadata.build) headers["X-Build"] = metadata.build;
401
- if (metadata.branch) headers["X-Branch"] = metadata.branch;
402
- if (metadata.ciUrl) headers["X-CI-URL"] = metadata.ciUrl;
403
-
404
- const result = await postWithRetry({
405
- endpoint,
406
- headers,
407
- body: report,
408
- env,
409
- fetchImpl,
410
- stderr,
411
- });
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
+ }
412
568
  stdout(`Uploaded ${FORMAT_LABELS[format]} to ${destination} (run ${metadata.runId}).`);
413
- outputSummary(result, stdout, stderr);
569
+ outputSummary(aggregatePageResults(pageResults), stdout, stderr);
414
570
  return 0;
415
571
  } catch (error) {
416
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.0",
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": {
@@ -9,19 +9,15 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "lib",
12
- "README.md"
12
+ "README.md",
13
+ ".env.testdossier.example"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=18"
16
17
  },
17
- "repository": {
18
- "type": "git",
19
- "url": "git+https://github.com/devkalu/test-dossier.git",
20
- "directory": "cli"
21
- },
22
18
  "homepage": "https://testdossier.com",
23
19
  "bugs": {
24
- "url": "https://github.com/devkalu/test-dossier/issues"
20
+ "email": "support@testdossier.com"
25
21
  },
26
22
  "keywords": [
27
23
  "testing",