testdossier 0.1.3 → 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/README.md +70 -53
- package/lib/testdossier.mjs +281 -38
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,38 +1,51 @@
|
|
|
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
|
|
4
|
+
job. Run these commands in the project containing your automated tests:
|
|
5
5
|
|
|
6
|
-
1.
|
|
6
|
+
1. One-time setup — creates `.env.testdossier` with owner-only permissions and
|
|
7
|
+
adds it to `.gitignore`:
|
|
7
8
|
|
|
8
|
-
```
|
|
9
|
-
|
|
9
|
+
```bash
|
|
10
|
+
npx testdossier init
|
|
10
11
|
```
|
|
11
12
|
|
|
12
|
-
2.
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
35
|
-
|
|
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
51
|
After merging, the CLI automatically sends JSON reports (Playwright, Cypress,
|
|
@@ -50,77 +63,75 @@ The uploader does one thing: read a completed report and make an outbound HTTPS
|
|
|
50
63
|
request to TestDossier. It does not run tests, accept remote commands, install a
|
|
51
64
|
background service, watch files, or keep a process running.
|
|
52
65
|
|
|
53
|
-
The access token is read from
|
|
54
|
-
`TD_CI_TOKEN` process environment
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
59
78
|
|
|
60
79
|
Before sending anything, inspect what the CLI detected:
|
|
61
80
|
|
|
62
81
|
```bash
|
|
63
|
-
npx --yes testdossier@0.
|
|
82
|
+
npx --yes testdossier@0.2.0 upload playwright-results.json --dry-run
|
|
64
83
|
```
|
|
65
84
|
|
|
66
85
|
## Supported reports
|
|
67
86
|
|
|
68
87
|
| Runner or format | Example |
|
|
69
88
|
|---|---|
|
|
70
|
-
| Playwright JSON | `npx --yes testdossier@0.
|
|
71
|
-
| Cypress JSON | `npx --yes testdossier@0.
|
|
72
|
-
| Cucumber JSON | `npx --yes testdossier@0.
|
|
73
|
-
| JUnit XML | `npx --yes testdossier@0.
|
|
74
|
-
| Generic JSON | `npx --yes testdossier@0.
|
|
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` |
|
|
75
94
|
|
|
76
95
|
Detection uses the report's contents, not its filename. If a custom reporter
|
|
77
96
|
creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
|
|
78
|
-
`--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.
|
|
79
99
|
|
|
80
100
|
## Local setup
|
|
81
101
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
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:
|
|
85
104
|
|
|
86
105
|
```bash
|
|
87
106
|
npm install --save-dev testdossier
|
|
88
|
-
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
Alternatively, create `.env.testdossier` yourself with the single placeholder
|
|
92
|
-
line shown at the top of this guide. Replace `replace_with_your_td_token`, then
|
|
93
|
-
ensure the project ignores and protects the real file:
|
|
94
|
-
|
|
95
|
-
```gitignore
|
|
96
|
-
.env.testdossier
|
|
107
|
+
npx testdossier init
|
|
97
108
|
```
|
|
98
109
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
```bash
|
|
106
|
-
npx testdossier upload playwright-results.json --env-file .env.testdossier
|
|
107
|
-
```
|
|
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.
|
|
108
115
|
|
|
109
116
|
For a one-off session, setting `TD_CI_TOKEN` in the process environment still
|
|
110
|
-
works. An explicitly passed
|
|
117
|
+
works and takes precedence over the discovered file. An explicitly passed
|
|
118
|
+
`--env-file` takes precedence over both.
|
|
111
119
|
|
|
112
120
|
The default destination is `https://testdossier.com`. A self-hosted or local
|
|
113
121
|
instance can be selected without putting the token on the command line:
|
|
114
122
|
|
|
115
123
|
```bash
|
|
116
|
-
|
|
124
|
+
npx testdossier upload junit-results.xml --url "https://dossier.example.com"
|
|
117
125
|
```
|
|
118
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
|
+
|
|
119
131
|
Useful metadata options:
|
|
120
132
|
|
|
121
133
|
```bash
|
|
122
134
|
npx testdossier upload junit-results.xml \
|
|
123
|
-
--env-file .env.testdossier \
|
|
124
135
|
--run-id "local-2026-07-23-1" \
|
|
125
136
|
--build "abc123" \
|
|
126
137
|
--branch "main"
|
|
@@ -136,10 +147,16 @@ conflict, and server errors are retried with the same run ID.
|
|
|
136
147
|
|
|
137
148
|
Run `npx testdossier --help` for every option.
|
|
138
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
|
+
|
|
139
160
|
If `.env.testdossier` is ever committed or shared, revoke that token in
|
|
140
161
|
TestDossier immediately and create a replacement. Hosted CI should use the
|
|
141
|
-
provider's encrypted secret storage instead of an env file.
|
|
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.
|
|
162
|
+
provider's encrypted secret storage instead of an env file.
|
package/lib/testdossier.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { readFile, stat } from "node:fs/promises";
|
|
3
|
-
import { basename } from "node:path";
|
|
2
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
|
|
5
|
-
export const VERSION = "0.
|
|
5
|
+
export const VERSION = "0.2.0";
|
|
6
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"]);
|
|
7
11
|
const MAX_REPORT_BYTES = 25 * 1024 * 1024;
|
|
8
12
|
const MAX_TESTS_PER_PAGE = 200;
|
|
9
13
|
const RETRYABLE_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
@@ -385,14 +389,134 @@ export function parseUploadArgs(args) {
|
|
|
385
389
|
options.reportPath = arg;
|
|
386
390
|
}
|
|
387
391
|
|
|
388
|
-
const formats = new Set(["auto", "cypress", "playwright", "cucumber", "junit", "generic"]);
|
|
389
392
|
options.format = String(options.format).toLowerCase();
|
|
390
|
-
if (!
|
|
393
|
+
if (options.format !== "auto" && !REPORT_FORMATS.has(options.format)) {
|
|
391
394
|
throw new CliError("--format must be auto, cypress, playwright, cucumber, junit, or generic.");
|
|
392
395
|
}
|
|
393
396
|
return options;
|
|
394
397
|
}
|
|
395
398
|
|
|
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
|
+
|
|
396
520
|
function normalizedHeader(value, name) {
|
|
397
521
|
if (value == null || value === "") return "";
|
|
398
522
|
const normalized = String(value);
|
|
@@ -435,10 +559,20 @@ function defaultRunId() {
|
|
|
435
559
|
return `local-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
436
560
|
}
|
|
437
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
|
+
|
|
438
572
|
function inferredMetadata(options, env) {
|
|
439
|
-
//
|
|
440
|
-
// would otherwise share a run id, and the server
|
|
441
|
-
// upload with an idempotent replay of the first.
|
|
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.
|
|
442
576
|
const runId = boundedRunId(
|
|
443
577
|
options.runId ||
|
|
444
578
|
env.TD_RUN_ID ||
|
|
@@ -446,26 +580,44 @@ function inferredMetadata(options, env) {
|
|
|
446
580
|
? [env.GITHUB_RUN_ID, env.GITHUB_JOB, env.GITHUB_RUN_ATTEMPT || 1].filter(Boolean).join("-")
|
|
447
581
|
: "") ||
|
|
448
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
|
+
: "") ||
|
|
449
596
|
env.BUILD_TAG ||
|
|
450
597
|
defaultRunId(),
|
|
451
598
|
);
|
|
452
599
|
const build = boundedHeader(
|
|
453
|
-
options.build || env.TD_BUILD || env.GITHUB_SHA || env.CI_COMMIT_SHA ||
|
|
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,
|
|
454
602
|
"build",
|
|
455
603
|
80,
|
|
456
604
|
);
|
|
457
605
|
const branch = boundedHeader(
|
|
458
|
-
options.branch || env.TD_BRANCH || env.GITHUB_REF_NAME || env.CI_COMMIT_REF_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,
|
|
459
608
|
"branch",
|
|
460
609
|
80,
|
|
461
610
|
);
|
|
462
611
|
const provider = boundedHeader(
|
|
463
|
-
options.provider || env.TD_PROVIDER ||
|
|
464
|
-
(env.GITHUB_ACTIONS ? "github_actions" : env.GITLAB_CI ? "gitlab_ci" : env.JENKINS_URL ? "jenkins" : "local"),
|
|
612
|
+
options.provider || env.TD_PROVIDER || inferredProvider(env),
|
|
465
613
|
"provider",
|
|
466
614
|
80,
|
|
467
615
|
);
|
|
468
|
-
const ciUrl = boundedHeader(
|
|
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
|
+
);
|
|
469
621
|
return { runId, build, branch, provider, ciUrl };
|
|
470
622
|
}
|
|
471
623
|
|
|
@@ -509,6 +661,39 @@ async function tokenFromEnvFile(path, stderr) {
|
|
|
509
661
|
return parseTokenEnvFile(await readFile(path, "utf8"));
|
|
510
662
|
}
|
|
511
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
|
+
|
|
512
697
|
function retryDelayMs(attempt, retryAfter, baseDelay) {
|
|
513
698
|
if (retryAfter) {
|
|
514
699
|
const seconds = Number(retryAfter);
|
|
@@ -519,13 +704,13 @@ function retryDelayMs(attempt, retryAfter, baseDelay) {
|
|
|
519
704
|
return Math.min(baseDelay * (2 ** (attempt - 1)), 30_000);
|
|
520
705
|
}
|
|
521
706
|
|
|
522
|
-
function responseError(status, body) {
|
|
707
|
+
function responseError(status, body, action) {
|
|
523
708
|
const code = body && typeof body.error === "string" ? ` ${body.error}` : "";
|
|
524
709
|
const message = body && typeof body.message === "string" ? `: ${body.message.slice(0, 500)}` : "";
|
|
525
|
-
return
|
|
710
|
+
return `${action} failed (${status})${code}${message}`;
|
|
526
711
|
}
|
|
527
712
|
|
|
528
|
-
async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr }) {
|
|
713
|
+
async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr, action = "upload" }) {
|
|
529
714
|
const attempts = integerEnv(env.TD_UPLOAD_MAX_ATTEMPTS, 5, 1, 10);
|
|
530
715
|
const baseDelay = integerEnv(env.TD_UPLOAD_BASE_DELAY_MS, 1000, 1, 30_000);
|
|
531
716
|
const timeout = integerEnv(env.TD_UPLOAD_TIMEOUT_MS, 60_000, 1000, 300_000);
|
|
@@ -559,21 +744,25 @@ async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr }
|
|
|
559
744
|
}
|
|
560
745
|
if (response.ok) return responseBody;
|
|
561
746
|
if (!RETRYABLE_STATUSES.has(response.status) || attempt === attempts) {
|
|
562
|
-
throw new CliError(responseError(response.status, responseBody), 1);
|
|
747
|
+
throw new CliError(responseError(response.status, responseBody, action), 1);
|
|
563
748
|
}
|
|
564
749
|
const wait = retryDelayMs(attempt, response.headers.get("retry-after"), baseDelay);
|
|
565
|
-
stderr(`
|
|
750
|
+
stderr(`Server returned ${response.status}; retrying ${attempt}/${attempts - 1} in ${wait}ms.`);
|
|
566
751
|
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
567
752
|
}
|
|
568
753
|
|
|
569
|
-
throw new CliError(
|
|
754
|
+
throw new CliError(`${action} failed after ${attempts} attempt(s): ${lastNetworkError || "network error"}`, 1);
|
|
570
755
|
}
|
|
571
756
|
|
|
572
757
|
function helpText() {
|
|
573
758
|
return `TestDossier report uploader ${VERSION}
|
|
574
759
|
|
|
575
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
|
|
576
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}
|
|
577
766
|
|
|
578
767
|
Options:
|
|
579
768
|
--format <format> auto (default), cypress, playwright, cucumber, junit, generic
|
|
@@ -589,9 +778,17 @@ Options:
|
|
|
589
778
|
--version Show version
|
|
590
779
|
|
|
591
780
|
Authentication:
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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.
|
|
595
792
|
|
|
596
793
|
Large reports:
|
|
597
794
|
JSON reports (Playwright, Cypress, Cucumber, generic) above 200 tests are
|
|
@@ -600,9 +797,12 @@ Large reports:
|
|
|
600
797
|
split it per suite or switch the runner to a JSON reporter.
|
|
601
798
|
|
|
602
799
|
Examples:
|
|
603
|
-
npx testdossier
|
|
604
|
-
npx testdossier
|
|
605
|
-
npx testdossier upload
|
|
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`;
|
|
606
806
|
}
|
|
607
807
|
|
|
608
808
|
function outputSummary(result, stdout, stderr) {
|
|
@@ -646,6 +846,7 @@ export async function runCli(
|
|
|
646
846
|
fetchImpl = globalThis.fetch,
|
|
647
847
|
stdout = (message) => console.log(message),
|
|
648
848
|
stderr = (message) => console.error(message),
|
|
849
|
+
cwd = process.cwd(),
|
|
649
850
|
} = {},
|
|
650
851
|
) {
|
|
651
852
|
try {
|
|
@@ -653,20 +854,65 @@ export async function runCli(
|
|
|
653
854
|
stdout(VERSION);
|
|
654
855
|
return 0;
|
|
655
856
|
}
|
|
656
|
-
if (args
|
|
857
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
657
858
|
stdout(helpText());
|
|
658
859
|
return 0;
|
|
659
860
|
}
|
|
660
|
-
if (args[0]
|
|
661
|
-
throw new CliError(
|
|
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;
|
|
662
895
|
}
|
|
663
896
|
|
|
664
|
-
const options = parseUploadArgs(args.slice(1));
|
|
897
|
+
const options = parseUploadArgs(args[0] === "upload" ? args.slice(1) : args);
|
|
665
898
|
if (options.help) {
|
|
666
899
|
stdout(helpText());
|
|
667
900
|
return 0;
|
|
668
901
|
}
|
|
669
|
-
|
|
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
|
+
}
|
|
670
916
|
|
|
671
917
|
const fileStats = await stat(options.reportPath).catch((error) => {
|
|
672
918
|
throw new CliError(`could not read report file: ${error.message}`);
|
|
@@ -678,11 +924,12 @@ export async function runCli(
|
|
|
678
924
|
}
|
|
679
925
|
const report = await readFile(options.reportPath);
|
|
680
926
|
const reportText = report.toString("utf8");
|
|
681
|
-
const
|
|
927
|
+
const requestedFormat = options.format !== "auto" ? options.format : (config && config.format) || "auto";
|
|
928
|
+
const { format, document } = analyzeReport(reportText, requestedFormat);
|
|
682
929
|
const pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
|
|
683
930
|
const testCount = pages.reduce((sum, page) => sum + page.tests, 0);
|
|
684
931
|
|
|
685
|
-
const endpoint = normalizeEndpoint(options.url || env.TD_URL || DEFAULT_ORIGIN);
|
|
932
|
+
const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
|
|
686
933
|
const destination = new URL(endpoint).origin;
|
|
687
934
|
stdout(
|
|
688
935
|
`Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes, ${testCount} tests` +
|
|
@@ -693,12 +940,7 @@ export async function runCli(
|
|
|
693
940
|
return 0;
|
|
694
941
|
}
|
|
695
942
|
|
|
696
|
-
const token = options
|
|
697
|
-
? await tokenFromEnvFile(options.envFile, stderr)
|
|
698
|
-
: env.TD_CI_TOKEN;
|
|
699
|
-
if (!token || !token.startsWith("td_")) {
|
|
700
|
-
throw new CliError("TD_CI_TOKEN is required and must be a TestDossier td_ access token.");
|
|
701
|
-
}
|
|
943
|
+
const token = await resolveToken(options, env, cwd, stdout, stderr);
|
|
702
944
|
if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
|
|
703
945
|
|
|
704
946
|
const metadata = inferredMetadata(options, env);
|
|
@@ -753,6 +995,7 @@ export async function runCli(
|
|
|
753
995
|
: `Note: ${replayed} of ${pageResults.length} pages were idempotent replays of an earlier upload (run ${metadata.runId}).`);
|
|
754
996
|
}
|
|
755
997
|
outputSummary(aggregatePageResults(pageResults), stdout, stderr);
|
|
998
|
+
if (!config) await saveProjectConfig(cwd, options, stdout, stderr);
|
|
756
999
|
return 0;
|
|
757
1000
|
} catch (error) {
|
|
758
1001
|
const message = error && error.message ? error.message : String(error);
|