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 +21 -0
- package/README.md +79 -53
- package/lib/testdossier.mjs +540 -111
- package/package.json +3 -1
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
|
|
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
|
-
After merging, the CLI automatically sends
|
|
39
|
-
larger than 200 tests as sequential pages under one stable
|
|
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
|
|
52
|
-
`TD_CI_TOKEN` process environment
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
package/lib/testdossier.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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 (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
83
|
-
throw new CliError(
|
|
123
|
+
if (!matches.includes(requestedFormat) || !validJsonFormat(requestedFormat, value)) {
|
|
124
|
+
throw new CliError(invalidFormatMessage(requestedFormat));
|
|
84
125
|
}
|
|
85
|
-
return
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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
|
|
250
|
+
const doc = document ?? parseJson(report.toString("utf8").replace(/^\uFEFF/, ""));
|
|
127
251
|
if (format === "generic") {
|
|
128
|
-
const tests =
|
|
252
|
+
const tests = doc.tests;
|
|
129
253
|
if (tests.length <= maxTests) return [{ body: report, tests: tests.length }];
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
.
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 ||
|
|
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 ||
|
|
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(
|
|
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
|
|
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(`
|
|
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(
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
426
|
-
as sequential pages that remain grouped under one
|
|
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
|
|
430
|
-
npx testdossier
|
|
431
|
-
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`;
|
|
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
|
-
|
|
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
|
|
853
|
+
if (args[0] === "--version") {
|
|
473
854
|
stdout(VERSION);
|
|
474
855
|
return 0;
|
|
475
856
|
}
|
|
476
|
-
if (args
|
|
857
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
477
858
|
stdout(helpText());
|
|
478
859
|
return 0;
|
|
479
860
|
}
|
|
480
|
-
if (args[0]
|
|
481
|
-
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;
|
|
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
|
-
|
|
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
|
|
502
|
-
|
|
503
|
-
const pages = splitReportPages(format, report);
|
|
504
|
-
const testCount = pages.
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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.
|
|
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": {
|