testdossier 0.1.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 +83 -0
- package/bin/testdossier.mjs +5 -0
- package/lib/testdossier.mjs +420 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# TestDossier CLI
|
|
2
|
+
|
|
3
|
+
Upload a completed test report from a developer laptop, test machine, or CI job:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
export TD_CI_TOKEN="td_..."
|
|
7
|
+
npx testdossier upload playwright-results.json
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The CLI auto-detects Playwright JSON, Cypress JSON, Cucumber JSON, JUnit XML,
|
|
11
|
+
and TestDossier's generic JSON format. It has zero runtime dependencies and
|
|
12
|
+
requires Node.js 18 or newer.
|
|
13
|
+
|
|
14
|
+
## Why this is intentionally small
|
|
15
|
+
|
|
16
|
+
The uploader does one thing: read a completed report and make an outbound HTTPS
|
|
17
|
+
request to TestDossier. It does not run tests, accept remote commands, install a
|
|
18
|
+
background service, watch files, or keep a process running.
|
|
19
|
+
|
|
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.
|
|
24
|
+
|
|
25
|
+
Before sending anything, inspect what the CLI detected:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx testdossier upload playwright-results.json --dry-run
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Supported reports
|
|
32
|
+
|
|
33
|
+
| Runner or format | Example |
|
|
34
|
+
|---|---|
|
|
35
|
+
| Playwright JSON | `npx testdossier upload playwright-results.json` |
|
|
36
|
+
| Cypress JSON | `npx testdossier upload cypress-results.json` |
|
|
37
|
+
| Cucumber JSON | `npx testdossier upload cucumber-results.json` |
|
|
38
|
+
| JUnit XML | `npx testdossier upload junit-results.xml` |
|
|
39
|
+
| Generic JSON | `npx testdossier upload testdossier-ci.json` |
|
|
40
|
+
|
|
41
|
+
Detection uses the report's contents, not its filename. If a custom reporter
|
|
42
|
+
creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
|
|
43
|
+
`--format cucumber`, `--format junit`, or `--format generic`.
|
|
44
|
+
|
|
45
|
+
## Local setup
|
|
46
|
+
|
|
47
|
+
Create a project access token in TestDossier with the **CI ingestion**
|
|
48
|
+
capability, copy it once, and place it in an environment variable:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export TD_CI_TOKEN="td_..."
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
For repeat use, install and pin the CLI in the test repository so its source and
|
|
55
|
+
version are captured by the lockfile:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm install --save-dev testdossier
|
|
59
|
+
npx testdossier upload playwright-results.json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The default destination is `https://testdossier.com`. A self-hosted or local
|
|
63
|
+
instance can be selected without putting the token on the command line:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
TD_URL="https://dossier.example.com" npx testdossier upload junit-results.xml
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Useful metadata options:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx testdossier upload junit-results.xml \
|
|
73
|
+
--run-id "local-2026-07-23-1" \
|
|
74
|
+
--build "abc123" \
|
|
75
|
+
--branch "main"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`--run-id` is the idempotency key. Repeating the same upload with the same run
|
|
79
|
+
ID returns the existing ingest result rather than creating a duplicate. Without
|
|
80
|
+
one, the CLI creates a new local run ID. Transient network, rate-limit, conflict,
|
|
81
|
+
and server errors are retried with the same run ID.
|
|
82
|
+
|
|
83
|
+
Run `npx testdossier --help` for every option.
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export const VERSION = "0.1.0";
|
|
5
|
+
const DEFAULT_ORIGIN = "https://testdossier.com";
|
|
6
|
+
const MAX_REPORT_BYTES = 25 * 1024 * 1024;
|
|
7
|
+
const RETRYABLE_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
8
|
+
const FORMAT_LABELS = {
|
|
9
|
+
cypress: "Cypress JSON",
|
|
10
|
+
playwright: "Playwright JSON",
|
|
11
|
+
cucumber: "Cucumber JSON",
|
|
12
|
+
junit: "JUnit XML",
|
|
13
|
+
generic: "generic TestDossier JSON",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class CliError extends Error {
|
|
17
|
+
constructor(message, exitCode = 2) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "CliError";
|
|
20
|
+
this.exitCode = exitCode;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseJson(text) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
throw new CliError(`report is not valid JSON: ${error.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function matchingJsonFormats(value) {
|
|
33
|
+
const matches = [];
|
|
34
|
+
if (value && !Array.isArray(value) && typeof value === "object") {
|
|
35
|
+
if (Array.isArray(value.runs)) matches.push("cypress");
|
|
36
|
+
if (Array.isArray(value.suites)) matches.push("playwright");
|
|
37
|
+
if (Array.isArray(value.tests)) matches.push("generic");
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(value)) matches.push("cucumber");
|
|
40
|
+
return matches;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertFormatShape(format, text) {
|
|
44
|
+
const trimmed = text.replace(/^\uFEFF/, "").trimStart();
|
|
45
|
+
if (format === "junit") {
|
|
46
|
+
if (!trimmed.startsWith("<") || !/<testsuites?\b/i.test(trimmed)) {
|
|
47
|
+
throw new CliError("JUnit reports must be XML containing a <testsuite> or <testsuites> root.");
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const value = parseJson(trimmed);
|
|
53
|
+
const matches = matchingJsonFormats(value);
|
|
54
|
+
if (!matches.includes(format)) {
|
|
55
|
+
const expected = {
|
|
56
|
+
cypress: "a JSON object with runs[]",
|
|
57
|
+
playwright: "a JSON object with suites[]",
|
|
58
|
+
cucumber: "a JSON array of features",
|
|
59
|
+
generic: "a JSON object with tests[]",
|
|
60
|
+
}[format];
|
|
61
|
+
throw new CliError(`${FORMAT_LABELS[format]} must be ${expected}.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function detectReportFormat(text) {
|
|
66
|
+
const trimmed = text.replace(/^\uFEFF/, "").trimStart();
|
|
67
|
+
if (!trimmed) throw new CliError("report file is empty.");
|
|
68
|
+
if (trimmed.startsWith("<")) {
|
|
69
|
+
assertFormatShape("junit", trimmed);
|
|
70
|
+
return "junit";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const value = parseJson(trimmed);
|
|
74
|
+
const matches = matchingJsonFormats(value);
|
|
75
|
+
if (matches.length === 0) {
|
|
76
|
+
throw new CliError(
|
|
77
|
+
"could not detect the report format; expected Cypress runs[], Playwright suites[], " +
|
|
78
|
+
"a Cucumber feature array, generic tests[], or JUnit XML.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (matches.length > 1) {
|
|
82
|
+
throw new CliError(`report shape is ambiguous (${matches.join(", ")}); pass --format explicitly.`);
|
|
83
|
+
}
|
|
84
|
+
return matches[0];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isLoopback(hostname) {
|
|
88
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function normalizeEndpoint(input = DEFAULT_ORIGIN) {
|
|
92
|
+
let url;
|
|
93
|
+
try {
|
|
94
|
+
url = new URL(input);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new CliError(`invalid TestDossier URL: ${input}`);
|
|
97
|
+
}
|
|
98
|
+
if (url.username || url.password) {
|
|
99
|
+
throw new CliError("TestDossier URL must not contain credentials.");
|
|
100
|
+
}
|
|
101
|
+
if (url.search || url.hash) {
|
|
102
|
+
throw new CliError("TestDossier URL must not contain a query string or fragment.");
|
|
103
|
+
}
|
|
104
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
|
|
105
|
+
throw new CliError("TestDossier URL must use HTTPS (HTTP is allowed only for localhost).");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
109
|
+
if (!path) {
|
|
110
|
+
url.pathname = "/api/ci/ingest";
|
|
111
|
+
} else if (path !== "/api/ci/ingest") {
|
|
112
|
+
throw new CliError("TestDossier URL must be an origin or end with /api/ci/ingest.");
|
|
113
|
+
}
|
|
114
|
+
return url.toString();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function optionValue(args, index, inlineValue, option) {
|
|
118
|
+
if (inlineValue !== undefined) return [inlineValue, index];
|
|
119
|
+
const next = args[index + 1];
|
|
120
|
+
if (!next || next.startsWith("--")) throw new CliError(`${option} requires a value.`);
|
|
121
|
+
return [next, index + 1];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseUploadArgs(args) {
|
|
125
|
+
const options = {
|
|
126
|
+
format: "auto",
|
|
127
|
+
url: undefined,
|
|
128
|
+
runId: undefined,
|
|
129
|
+
build: undefined,
|
|
130
|
+
branch: undefined,
|
|
131
|
+
provider: undefined,
|
|
132
|
+
ciUrl: undefined,
|
|
133
|
+
dryRun: false,
|
|
134
|
+
reportPath: undefined,
|
|
135
|
+
};
|
|
136
|
+
const valueOptions = new Map([
|
|
137
|
+
["--format", "format"],
|
|
138
|
+
["--url", "url"],
|
|
139
|
+
["--run-id", "runId"],
|
|
140
|
+
["--build", "build"],
|
|
141
|
+
["--branch", "branch"],
|
|
142
|
+
["--provider", "provider"],
|
|
143
|
+
["--ci-url", "ciUrl"],
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
147
|
+
const arg = args[i];
|
|
148
|
+
if (arg === "--dry-run") {
|
|
149
|
+
options.dryRun = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (arg === "--help" || arg === "-h") {
|
|
153
|
+
options.help = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (arg.startsWith("--")) {
|
|
157
|
+
const equalsAt = arg.indexOf("=");
|
|
158
|
+
const name = equalsAt === -1 ? arg : arg.slice(0, equalsAt);
|
|
159
|
+
const inlineValue = equalsAt === -1 ? undefined : arg.slice(equalsAt + 1);
|
|
160
|
+
const property = valueOptions.get(name);
|
|
161
|
+
if (!property) throw new CliError(`unknown option: ${name}`);
|
|
162
|
+
const [value, consumedIndex] = optionValue(args, i, inlineValue, name);
|
|
163
|
+
if (!value) throw new CliError(`${name} requires a non-empty value.`);
|
|
164
|
+
options[property] = value;
|
|
165
|
+
i = consumedIndex;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (options.reportPath) throw new CliError("upload accepts exactly one report file.");
|
|
169
|
+
options.reportPath = arg;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const formats = new Set(["auto", "cypress", "playwright", "cucumber", "junit", "generic"]);
|
|
173
|
+
options.format = String(options.format).toLowerCase();
|
|
174
|
+
if (!formats.has(options.format)) {
|
|
175
|
+
throw new CliError("--format must be auto, cypress, playwright, cucumber, junit, or generic.");
|
|
176
|
+
}
|
|
177
|
+
return options;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function boundedHeader(value, name, maxLength) {
|
|
181
|
+
if (value == null || value === "") return "";
|
|
182
|
+
const normalized = String(value);
|
|
183
|
+
if (/[\r\n]/.test(normalized)) throw new CliError(`${name} must not contain a newline.`);
|
|
184
|
+
return normalized.slice(0, maxLength);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function boundedRunId(value) {
|
|
188
|
+
const normalized = boundedHeader(value, "run id", 1000);
|
|
189
|
+
if (normalized.length <= 120) return normalized;
|
|
190
|
+
const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 20);
|
|
191
|
+
return `${normalized.slice(0, 95)}-${digest}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function defaultRunId() {
|
|
195
|
+
return `local-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function inferredMetadata(options, env) {
|
|
199
|
+
const runId = boundedRunId(
|
|
200
|
+
options.runId ||
|
|
201
|
+
env.TD_RUN_ID ||
|
|
202
|
+
(env.GITHUB_RUN_ID ? `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT || 1}` : "") ||
|
|
203
|
+
(env.CI_PIPELINE_ID ? `${env.CI_PIPELINE_ID}-${env.CI_JOB_ID || 1}` : "") ||
|
|
204
|
+
env.BUILD_TAG ||
|
|
205
|
+
defaultRunId(),
|
|
206
|
+
);
|
|
207
|
+
const build = boundedHeader(
|
|
208
|
+
options.build || env.TD_BUILD || env.GITHUB_SHA || env.CI_COMMIT_SHA || env.GIT_COMMIT,
|
|
209
|
+
"build",
|
|
210
|
+
80,
|
|
211
|
+
);
|
|
212
|
+
const branch = boundedHeader(
|
|
213
|
+
options.branch || env.TD_BRANCH || env.GITHUB_REF_NAME || env.CI_COMMIT_REF_NAME || env.BRANCH_NAME,
|
|
214
|
+
"branch",
|
|
215
|
+
80,
|
|
216
|
+
);
|
|
217
|
+
const provider = boundedHeader(
|
|
218
|
+
options.provider || env.TD_PROVIDER ||
|
|
219
|
+
(env.GITHUB_ACTIONS ? "github_actions" : env.GITLAB_CI ? "gitlab_ci" : env.JENKINS_URL ? "jenkins" : "local"),
|
|
220
|
+
"provider",
|
|
221
|
+
80,
|
|
222
|
+
);
|
|
223
|
+
const ciUrl = boundedHeader(options.ciUrl || env.TD_CI_URL || env.CI_JOB_URL || env.BUILD_URL, "CI URL", 500);
|
|
224
|
+
return { runId, build, branch, provider, ciUrl };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function integerEnv(value, fallback, min, max) {
|
|
228
|
+
const parsed = Number(value);
|
|
229
|
+
return Number.isInteger(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function retryDelayMs(attempt, retryAfter, baseDelay) {
|
|
233
|
+
if (retryAfter) {
|
|
234
|
+
const seconds = Number(retryAfter);
|
|
235
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 300_000);
|
|
236
|
+
const date = Date.parse(retryAfter);
|
|
237
|
+
if (Number.isFinite(date)) return Math.min(Math.max(0, date - Date.now()), 300_000);
|
|
238
|
+
}
|
|
239
|
+
return Math.min(baseDelay * (2 ** (attempt - 1)), 30_000);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function responseError(status, body) {
|
|
243
|
+
const code = body && typeof body.error === "string" ? ` ${body.error}` : "";
|
|
244
|
+
const message = body && typeof body.message === "string" ? `: ${body.message.slice(0, 500)}` : "";
|
|
245
|
+
return `upload failed (${status})${code}${message}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr }) {
|
|
249
|
+
const attempts = integerEnv(env.TD_UPLOAD_MAX_ATTEMPTS, 5, 1, 10);
|
|
250
|
+
const baseDelay = integerEnv(env.TD_UPLOAD_BASE_DELAY_MS, 1000, 1, 30_000);
|
|
251
|
+
const timeout = integerEnv(env.TD_UPLOAD_TIMEOUT_MS, 60_000, 1000, 300_000);
|
|
252
|
+
let lastNetworkError = "";
|
|
253
|
+
|
|
254
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
255
|
+
let response;
|
|
256
|
+
try {
|
|
257
|
+
response = await fetchImpl(endpoint, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers,
|
|
260
|
+
body,
|
|
261
|
+
redirect: "error",
|
|
262
|
+
signal: AbortSignal.timeout(timeout),
|
|
263
|
+
});
|
|
264
|
+
} catch (error) {
|
|
265
|
+
lastNetworkError = error && error.message ? error.message : String(error);
|
|
266
|
+
if (attempt === attempts) break;
|
|
267
|
+
const wait = retryDelayMs(attempt, null, baseDelay);
|
|
268
|
+
stderr(`Network error; retrying ${attempt}/${attempts - 1} in ${wait}ms.`);
|
|
269
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const responseText = await response.text();
|
|
274
|
+
let responseBody = {};
|
|
275
|
+
try {
|
|
276
|
+
responseBody = responseText ? JSON.parse(responseText) : {};
|
|
277
|
+
} catch {
|
|
278
|
+
responseBody = {};
|
|
279
|
+
}
|
|
280
|
+
if (response.ok) return responseBody;
|
|
281
|
+
if (!RETRYABLE_STATUSES.has(response.status) || attempt === attempts) {
|
|
282
|
+
throw new CliError(responseError(response.status, responseBody), 1);
|
|
283
|
+
}
|
|
284
|
+
const wait = retryDelayMs(attempt, response.headers.get("retry-after"), baseDelay);
|
|
285
|
+
stderr(`Upload returned ${response.status}; retrying ${attempt}/${attempts - 1} in ${wait}ms.`);
|
|
286
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
throw new CliError(`upload failed after ${attempts} attempt(s): ${lastNetworkError || "network error"}`, 1);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function helpText() {
|
|
293
|
+
return `TestDossier report uploader ${VERSION}
|
|
294
|
+
|
|
295
|
+
Usage:
|
|
296
|
+
testdossier upload <report-file> [options]
|
|
297
|
+
|
|
298
|
+
Options:
|
|
299
|
+
--format <format> auto (default), cypress, playwright, cucumber, junit, generic
|
|
300
|
+
--url <url> TestDossier origin or /api/ci/ingest endpoint
|
|
301
|
+
--run-id <id> Stable idempotency key for this execution
|
|
302
|
+
--build <value> Commit, build number, or release identifier
|
|
303
|
+
--branch <value> Branch name
|
|
304
|
+
--provider <value> Provider label (defaults to local or detected CI)
|
|
305
|
+
--ci-url <url> Link to the originating CI run
|
|
306
|
+
--dry-run Validate and detect only; send nothing
|
|
307
|
+
-h, --help Show help
|
|
308
|
+
--version Show version
|
|
309
|
+
|
|
310
|
+
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.
|
|
314
|
+
|
|
315
|
+
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
|
|
318
|
+
npx testdossier upload testdossier-ci.json --dry-run`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function outputSummary(result, stdout, stderr) {
|
|
322
|
+
const counts = result && result.counts && typeof result.counts === "object" ? result.counts : null;
|
|
323
|
+
if (counts) {
|
|
324
|
+
const parts = Object.entries(counts)
|
|
325
|
+
.filter(([, value]) => typeof value === "number")
|
|
326
|
+
.map(([name, value]) => `${name}=${value}`);
|
|
327
|
+
if (parts.length) stdout(`Results: ${parts.join(", ")}`);
|
|
328
|
+
}
|
|
329
|
+
if (Array.isArray(result && result.warnings)) {
|
|
330
|
+
for (const warning of result.warnings) stderr(`Warning: ${String(warning)}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function runCli(
|
|
335
|
+
args,
|
|
336
|
+
{
|
|
337
|
+
env = process.env,
|
|
338
|
+
fetchImpl = globalThis.fetch,
|
|
339
|
+
stdout = (message) => console.log(message),
|
|
340
|
+
stderr = (message) => console.error(message),
|
|
341
|
+
} = {},
|
|
342
|
+
) {
|
|
343
|
+
try {
|
|
344
|
+
if (args.includes("--version")) {
|
|
345
|
+
stdout(VERSION);
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
349
|
+
stdout(helpText());
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
if (args[0] !== "upload") {
|
|
353
|
+
throw new CliError(`unknown command: ${args[0]}\n\n${helpText()}`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const options = parseUploadArgs(args.slice(1));
|
|
357
|
+
if (options.help) {
|
|
358
|
+
stdout(helpText());
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
if (!options.reportPath) throw new CliError("upload requires a report file.");
|
|
362
|
+
|
|
363
|
+
const fileStats = await stat(options.reportPath).catch((error) => {
|
|
364
|
+
throw new CliError(`could not read report file: ${error.message}`);
|
|
365
|
+
});
|
|
366
|
+
if (!fileStats.isFile()) throw new CliError("report path must point to a file.");
|
|
367
|
+
if (fileStats.size === 0) throw new CliError("report file is empty.");
|
|
368
|
+
if (fileStats.size > MAX_REPORT_BYTES) {
|
|
369
|
+
throw new CliError("report exceeds the 25 MiB request limit; split it before uploading.");
|
|
370
|
+
}
|
|
371
|
+
const report = await readFile(options.reportPath);
|
|
372
|
+
const reportText = report.toString("utf8");
|
|
373
|
+
const format = options.format === "auto" ? detectReportFormat(reportText) : options.format;
|
|
374
|
+
assertFormatShape(format, reportText);
|
|
375
|
+
|
|
376
|
+
const endpoint = normalizeEndpoint(options.url || env.TD_URL || DEFAULT_ORIGIN);
|
|
377
|
+
const destination = new URL(endpoint).origin;
|
|
378
|
+
stdout(`Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes).`);
|
|
379
|
+
if (options.dryRun) {
|
|
380
|
+
stdout(`Dry run complete; no data was sent to ${destination}.`);
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const token = env.TD_CI_TOKEN;
|
|
385
|
+
if (!token || !token.startsWith("td_")) {
|
|
386
|
+
throw new CliError("TD_CI_TOKEN is required and must be a TestDossier td_ access token.");
|
|
387
|
+
}
|
|
388
|
+
if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
|
|
389
|
+
|
|
390
|
+
const metadata = inferredMetadata(options, env);
|
|
391
|
+
const headers = {
|
|
392
|
+
Authorization: `Bearer ${token}`,
|
|
393
|
+
"Content-Type": format === "junit" ? "application/xml" : "application/json",
|
|
394
|
+
"User-Agent": `testdossier-cli/${VERSION}`,
|
|
395
|
+
"X-CI-Run-Id": metadata.runId,
|
|
396
|
+
"X-CI-Page-Id": "results",
|
|
397
|
+
"X-CI-Provider": metadata.provider,
|
|
398
|
+
};
|
|
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
|
+
});
|
|
412
|
+
stdout(`Uploaded ${FORMAT_LABELS[format]} to ${destination} (run ${metadata.runId}).`);
|
|
413
|
+
outputSummary(result, stdout, stderr);
|
|
414
|
+
return 0;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
const message = error && error.message ? error.message : String(error);
|
|
417
|
+
stderr(`Error: ${message}`);
|
|
418
|
+
return error instanceof CliError ? error.exitCode : 1;
|
|
419
|
+
}
|
|
420
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "testdossier",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Upload completed test reports to TestDossier from a local machine or CI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"testdossier": "bin/testdossier.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/devkalu/test-dossier.git",
|
|
20
|
+
"directory": "cli"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://testdossier.com",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/devkalu/test-dossier/issues"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"testing",
|
|
28
|
+
"junit",
|
|
29
|
+
"playwright",
|
|
30
|
+
"cypress",
|
|
31
|
+
"cucumber",
|
|
32
|
+
"testdossier"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|