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 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,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from "../lib/testdossier.mjs";
4
+
5
+ process.exitCode = await runCli(process.argv.slice(2));
@@ -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
+ }