testdossier 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,38 +1,51 @@
1
1
  # TestDossier CLI
2
2
 
3
3
  Upload a completed test report from a developer laptop, test machine, or CI
4
- job. Run these steps in the project containing your automated tests:
4
+ job. Run these commands in the project containing your automated tests:
5
5
 
6
- 1. Create `.env.testdossier`:
6
+ 1. One-time setup — creates `.env.testdossier` with owner-only permissions and
7
+ adds it to `.gitignore`:
7
8
 
8
- ```dotenv
9
- TD_CI_TOKEN=replace_with_your_td_token
9
+ ```bash
10
+ npx testdossier init
10
11
  ```
11
12
 
12
- 2. Replace the placeholder with the CI-ingestion token shown once by
13
- TestDossier, add the file to `.gitignore`, and protect it:
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
- chmod 600 .env.testdossier
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 --env-file .env.testdossier
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. Repeat steps 3
35
- and 4 whenever you want to publish a new run.
39
+ TestDossier. Use the actual report path produced by your runner.
40
+
41
+ The first successful upload writes `testdossier.json` in the directory you ran
42
+ the CLI from — the report path, plus `--format` and `--url` when you passed
43
+ them. Commit that file; every later publish is just:
44
+
45
+ ```bash
46
+ npx testdossier
47
+ ```
48
+
36
49
  If tests run in multiple shards or batches, merge all shard reports before
37
50
  uploading; selecting one shard's file uploads only that shard.
38
51
  After merging, the CLI automatically sends JSON reports (Playwright, Cypress,
@@ -50,77 +63,75 @@ The uploader does one thing: read a completed report and make an outbound HTTPS
50
63
  request to TestDossier. It does not run tests, accept remote commands, install a
51
64
  background service, watch files, or keep a process running.
52
65
 
53
- The access token is read from an explicitly selected env file or from the
54
- `TD_CI_TOKEN` process environment. There is deliberately no `--token` option,
55
- so a token cannot accidentally land in shell history or a process listing.
56
- The CLI reads only `TD_CI_TOKEN` from the selected file; it does not import or
57
- execute other entries. Redirects are rejected, and non-HTTPS destinations are
58
- rejected except for localhost development.
66
+ The access token is read from the first available source: an explicitly passed
67
+ `--env-file`, the `TD_CI_TOKEN` process environment, then `.env.testdossier` in
68
+ the current directory (the CLI announces when it uses the discovered file).
69
+ There is deliberately no `--token` option, so a token cannot accidentally land
70
+ in shell history or a process listing. The CLI reads only `TD_CI_TOKEN` from
71
+ the selected file; it does not import or execute other entries. Redirects are
72
+ rejected, and non-HTTPS destinations are rejected except for localhost
73
+ development.
74
+
75
+ `testdossier.json` is deliberately never rewritten once it exists — an upload
76
+ with a different path never silently repoints the committed default. Edit or
77
+ delete the file to change it.
59
78
 
60
79
  Before sending anything, inspect what the CLI detected:
61
80
 
62
81
  ```bash
63
- npx --yes testdossier@0.1.3 upload playwright-results.json --dry-run
82
+ npx --yes testdossier@0.2.0 upload playwright-results.json --dry-run
64
83
  ```
65
84
 
66
85
  ## Supported reports
67
86
 
68
87
  | Runner or format | Example |
69
88
  |---|---|
70
- | Playwright JSON | `npx --yes testdossier@0.1.3 upload playwright-results.json` |
71
- | Cypress JSON | `npx --yes testdossier@0.1.3 upload cypress-results.json` |
72
- | Cucumber JSON | `npx --yes testdossier@0.1.3 upload cucumber-results.json` |
73
- | JUnit XML | `npx --yes testdossier@0.1.3 upload junit-results.xml` |
74
- | Generic JSON | `npx --yes testdossier@0.1.3 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` |
75
94
 
76
95
  Detection uses the report's contents, not its filename. If a custom reporter
77
96
  creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
78
- `--format cucumber`, `--format junit`, or `--format generic`.
97
+ `--format cucumber`, `--format junit`, or `--format generic`. The `upload`
98
+ word may be omitted: `npx testdossier playwright-results.json` works too.
79
99
 
80
100
  ## Local setup
81
101
 
82
- Create a project access token in TestDossier with the **CI ingestion**
83
- capability and copy it once. For repeat use, install and pin the CLI in the test
84
- repository so its source and version are captured by the lockfile:
102
+ For repeat use, install and pin the CLI in the test repository so its source
103
+ and version are captured by the lockfile:
85
104
 
86
105
  ```bash
87
106
  npm install --save-dev testdossier
88
- cp node_modules/testdossier/.env.testdossier.example .env.testdossier
89
- ```
90
-
91
- Alternatively, create `.env.testdossier` yourself with the single placeholder
92
- line shown at the top of this guide. Replace `replace_with_your_td_token`, then
93
- ensure the project ignores and protects the real file:
94
-
95
- ```gitignore
96
- .env.testdossier
107
+ npx testdossier init
97
108
  ```
98
109
 
99
- ```bash
100
- chmod 600 .env.testdossier
101
- ```
102
-
103
- Upload with:
104
-
105
- ```bash
106
- npx testdossier upload playwright-results.json --env-file .env.testdossier
107
- ```
110
+ `init` is idempotent: it never overwrites an existing `.env.testdossier` and
111
+ never duplicates the `.gitignore` entry. If you prefer manual setup, create
112
+ `.env.testdossier` yourself with the single line
113
+ `TD_CI_TOKEN=replace_with_your_td_token`, replace the placeholder, gitignore
114
+ the file, and `chmod 600` it.
108
115
 
109
116
  For a one-off session, setting `TD_CI_TOKEN` in the process environment still
110
- works. An explicitly passed `--env-file` takes precedence.
117
+ works and takes precedence over the discovered file. An explicitly passed
118
+ `--env-file` takes precedence over both.
111
119
 
112
120
  The default destination is `https://testdossier.com`. A self-hosted or local
113
121
  instance can be selected without putting the token on the command line:
114
122
 
115
123
  ```bash
116
- TD_URL="https://dossier.example.com" npx testdossier upload junit-results.xml --env-file .env.testdossier
124
+ npx testdossier upload junit-results.xml --url "https://dossier.example.com"
117
125
  ```
118
126
 
127
+ `--url` is remembered in `testdossier.json` on the first successful upload, so
128
+ later bare runs keep targeting the same instance. `TD_URL` in the environment
129
+ also works and overrides the saved value.
130
+
119
131
  Useful metadata options:
120
132
 
121
133
  ```bash
122
134
  npx testdossier upload junit-results.xml \
123
- --env-file .env.testdossier \
124
135
  --run-id "local-2026-07-23-1" \
125
136
  --build "abc123" \
126
137
  --branch "main"
@@ -136,10 +147,16 @@ conflict, and server errors are retried with the same run ID.
136
147
 
137
148
  Run `npx testdossier --help` for every option.
138
149
 
150
+ ## Using the same flow in CI
151
+
152
+ Commit `testdossier.json` and set `TD_CI_TOKEN` in the provider's encrypted
153
+ secret storage; the pipeline step is then `npx --yes testdossier@0.2.0` with
154
+ no arguments. Run identity, build, branch, and provider metadata are inferred
155
+ automatically on GitHub Actions, GitLab CI, Jenkins, Azure DevOps, Bitbucket
156
+ Pipelines, and CircleCI. The Access Tokens dialog still generates
157
+ provider-specific snippets that pass the report path explicitly — either style
158
+ works; the explicit one fails louder when the report artifact is missing.
159
+
139
160
  If `.env.testdossier` is ever committed or shared, revoke that token in
140
161
  TestDossier immediately and create a replacement. Hosted CI should use the
141
- provider's encrypted secret storage instead of an env file. The Access Tokens
142
- dialog generates provider-specific GitHub Actions, GitLab CI, and Jenkins
143
- snippets that run after the test report is created. Those recipes pin the CLI,
144
- link back to the originating CI run, retry transient failures, and keep the
145
- token out of command history.
162
+ provider's encrypted secret storage instead of an env file.
@@ -1,9 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { readFile, stat } from "node:fs/promises";
3
- import { basename } from "node:path";
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
3
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
 
5
- export const VERSION = "0.1.3";
5
+ export const VERSION = "0.2.0";
6
6
  const DEFAULT_ORIGIN = "https://testdossier.com";
7
+ const ENV_FILE_NAME = ".env.testdossier";
8
+ const CONFIG_FILE_NAME = "testdossier.json";
9
+ const TOKEN_PLACEHOLDER = "replace_with_your_td_token";
10
+ const REPORT_FORMATS = new Set(["cypress", "playwright", "cucumber", "junit", "generic"]);
7
11
  const MAX_REPORT_BYTES = 25 * 1024 * 1024;
8
12
  const MAX_TESTS_PER_PAGE = 200;
9
13
  const RETRYABLE_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
@@ -385,14 +389,134 @@ export function parseUploadArgs(args) {
385
389
  options.reportPath = arg;
386
390
  }
387
391
 
388
- const formats = new Set(["auto", "cypress", "playwright", "cucumber", "junit", "generic"]);
389
392
  options.format = String(options.format).toLowerCase();
390
- if (!formats.has(options.format)) {
393
+ if (options.format !== "auto" && !REPORT_FORMATS.has(options.format)) {
391
394
  throw new CliError("--format must be auto, cypress, playwright, cucumber, junit, or generic.");
392
395
  }
393
396
  return options;
394
397
  }
395
398
 
399
+ export async function readProjectConfig(cwd) {
400
+ const path = join(cwd, CONFIG_FILE_NAME);
401
+ let text;
402
+ try {
403
+ text = await readFile(path, "utf8");
404
+ } catch (error) {
405
+ if (error && error.code === "ENOENT") return null;
406
+ throw new CliError(`could not read ${CONFIG_FILE_NAME}: ${error.message}`);
407
+ }
408
+ let value;
409
+ try {
410
+ value = JSON.parse(text.replace(/^\uFEFF/, ""));
411
+ } catch (error) {
412
+ throw new CliError(`${CONFIG_FILE_NAME} is not valid JSON: ${error.message}`);
413
+ }
414
+ if (!isObject(value)) throw new CliError(`${CONFIG_FILE_NAME} must be a JSON object.`);
415
+ const config = {};
416
+ if (value.report !== undefined) {
417
+ if (typeof value.report !== "string" || !value.report.trim()) {
418
+ throw new CliError(`${CONFIG_FILE_NAME} "report" must be a non-empty string.`);
419
+ }
420
+ config.report = value.report;
421
+ }
422
+ if (value.format !== undefined) {
423
+ const format = String(value.format).toLowerCase();
424
+ if (!REPORT_FORMATS.has(format)) {
425
+ throw new CliError(`${CONFIG_FILE_NAME} "format" must be cypress, playwright, cucumber, junit, or generic.`);
426
+ }
427
+ config.format = format;
428
+ }
429
+ if (value.url !== undefined) {
430
+ if (typeof value.url !== "string" || !value.url.trim()) {
431
+ throw new CliError(`${CONFIG_FILE_NAME} "url" must be a non-empty string.`);
432
+ }
433
+ config.url = value.url;
434
+ }
435
+ return config;
436
+ }
437
+
438
+ // A committed config must work on teammates' machines and Linux CI, so only
439
+ // a cwd-relative POSIX path is worth remembering.
440
+ function portableReportPath(cwd, reportPath) {
441
+ const rel = relative(cwd, resolve(cwd, reportPath));
442
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
443
+ return rel.split(sep).join("/");
444
+ }
445
+
446
+ // Created only when absent: the config is a committed project default, so an
447
+ // upload must never silently repoint it. Users edit or delete the file.
448
+ async function saveProjectConfig(cwd, options, stdout, stderr) {
449
+ const report = portableReportPath(cwd, options.reportPath);
450
+ if (!report) {
451
+ stderr(
452
+ `Warning: not saving ${CONFIG_FILE_NAME} — the report path points outside ` +
453
+ "this directory; run from the project root to remember it.",
454
+ );
455
+ return;
456
+ }
457
+ const saved = { report };
458
+ if (options.format !== "auto") saved.format = options.format;
459
+ if (options.url) saved.url = options.url;
460
+ try {
461
+ await writeFile(join(cwd, CONFIG_FILE_NAME), `${JSON.stringify(saved, null, 2)}\n`, { flag: "wx" });
462
+ stdout(`Saved report path to ${CONFIG_FILE_NAME}; commit it, and later runs are just: npx testdossier`);
463
+ } catch (error) {
464
+ if (error && error.code !== "EEXIST") {
465
+ stderr(`Warning: could not write ${CONFIG_FILE_NAME}: ${error.message}`);
466
+ }
467
+ }
468
+ }
469
+
470
+ async function runInit({ cwd, stdout }) {
471
+ const envPath = join(cwd, ENV_FILE_NAME);
472
+ const contents =
473
+ "# Replace the placeholder with the CI-ingestion token shown once by TestDossier.\n" +
474
+ `TD_CI_TOKEN=${TOKEN_PLACEHOLDER}\n`;
475
+ let created = false;
476
+ try {
477
+ await writeFile(envPath, contents, { flag: "wx", mode: 0o600 });
478
+ created = true;
479
+ } catch (error) {
480
+ if (!error || error.code !== "EEXIST") {
481
+ throw new CliError(`could not create ${ENV_FILE_NAME}: ${error && error.message}`);
482
+ }
483
+ }
484
+ stdout(created
485
+ ? `Created ${ENV_FILE_NAME} (readable only by you).`
486
+ : `${ENV_FILE_NAME} already exists; left unchanged.`);
487
+
488
+ const gitignorePath = join(cwd, ".gitignore");
489
+ let gitignore = "";
490
+ try {
491
+ gitignore = await readFile(gitignorePath, "utf8");
492
+ } catch (error) {
493
+ if (!error || error.code !== "ENOENT") {
494
+ throw new CliError(`could not read .gitignore: ${error && error.message}`);
495
+ }
496
+ }
497
+ const ignored = gitignore.split(/\r?\n/).some((line) => {
498
+ const entry = line.trim();
499
+ return entry === ENV_FILE_NAME || entry === `/${ENV_FILE_NAME}`;
500
+ });
501
+ if (!ignored) {
502
+ const separator = gitignore && !gitignore.endsWith("\n") ? "\n" : "";
503
+ try {
504
+ await writeFile(gitignorePath, `${gitignore}${separator}${ENV_FILE_NAME}\n`);
505
+ } catch (error) {
506
+ throw new CliError(`could not update .gitignore: ${error && error.message}`);
507
+ }
508
+ stdout(`Added ${ENV_FILE_NAME} to .gitignore.`);
509
+ }
510
+
511
+ stdout([
512
+ `Next: paste your td_ access token into ${ENV_FILE_NAME} (TestDossier → Access tokens), then upload a report:`,
513
+ " npx testdossier upload <report-file>",
514
+ `The first successful upload remembers the report path in ${CONFIG_FILE_NAME}; after that:`,
515
+ " npx testdossier",
516
+ ].join("\n"));
517
+ return 0;
518
+ }
519
+
396
520
  function normalizedHeader(value, name) {
397
521
  if (value == null || value === "") return "";
398
522
  const normalized = String(value);
@@ -435,10 +559,20 @@ function defaultRunId() {
435
559
  return `local-${Date.now()}-${randomUUID().slice(0, 8)}`;
436
560
  }
437
561
 
562
+ function inferredProvider(env) {
563
+ if (env.GITHUB_ACTIONS) return "github_actions";
564
+ if (env.GITLAB_CI) return "gitlab_ci";
565
+ if (env.TF_BUILD) return "azure_devops";
566
+ if (env.BITBUCKET_BUILD_NUMBER) return "bitbucket_pipelines";
567
+ if (env.CIRCLECI) return "circleci";
568
+ if (env.JENKINS_URL) return "jenkins";
569
+ return "local";
570
+ }
571
+
438
572
  function inferredMetadata(options, env) {
439
- // GITHUB_JOB is part of the inferred key: two jobs of one workflow run
440
- // would otherwise share a run id, and the server answers the second
441
- // upload with an idempotent replay of the first.
573
+ // The job identifier is part of every inferred key: two jobs of one
574
+ // workflow/pipeline run would otherwise share a run id, and the server
575
+ // answers the second upload with an idempotent replay of the first.
442
576
  const runId = boundedRunId(
443
577
  options.runId ||
444
578
  env.TD_RUN_ID ||
@@ -446,26 +580,44 @@ function inferredMetadata(options, env) {
446
580
  ? [env.GITHUB_RUN_ID, env.GITHUB_JOB, env.GITHUB_RUN_ATTEMPT || 1].filter(Boolean).join("-")
447
581
  : "") ||
448
582
  (env.CI_PIPELINE_ID ? `${env.CI_PIPELINE_ID}-${env.CI_JOB_ID || 1}` : "") ||
583
+ (env.BUILD_BUILDID
584
+ ? [env.BUILD_BUILDID, env.SYSTEM_JOBID, env.SYSTEM_JOBATTEMPT].filter(Boolean).join("-")
585
+ : "") ||
586
+ (env.BITBUCKET_BUILD_NUMBER
587
+ ? [
588
+ env.BITBUCKET_BUILD_NUMBER,
589
+ env.BITBUCKET_STEP_UUID,
590
+ env.BITBUCKET_STEP_RUN_NUMBER || 1,
591
+ ].filter(Boolean).join("-")
592
+ : "") ||
593
+ (env.CIRCLE_WORKFLOW_ID
594
+ ? [env.CIRCLE_WORKFLOW_ID, env.CIRCLE_WORKFLOW_JOB_ID || env.CIRCLE_JOB].filter(Boolean).join("-")
595
+ : "") ||
449
596
  env.BUILD_TAG ||
450
597
  defaultRunId(),
451
598
  );
452
599
  const build = boundedHeader(
453
- options.build || env.TD_BUILD || env.GITHUB_SHA || env.CI_COMMIT_SHA || env.GIT_COMMIT,
600
+ options.build || env.TD_BUILD || env.GITHUB_SHA || env.CI_COMMIT_SHA ||
601
+ env.BUILD_SOURCEVERSION || env.BITBUCKET_COMMIT || env.CIRCLE_SHA1 || env.GIT_COMMIT,
454
602
  "build",
455
603
  80,
456
604
  );
457
605
  const branch = boundedHeader(
458
- options.branch || env.TD_BRANCH || env.GITHUB_REF_NAME || env.CI_COMMIT_REF_NAME || env.BRANCH_NAME,
606
+ options.branch || env.TD_BRANCH || env.GITHUB_REF_NAME || env.CI_COMMIT_REF_NAME ||
607
+ env.BUILD_SOURCEBRANCHNAME || env.BITBUCKET_BRANCH || env.CIRCLE_BRANCH || env.BRANCH_NAME,
459
608
  "branch",
460
609
  80,
461
610
  );
462
611
  const provider = boundedHeader(
463
- options.provider || env.TD_PROVIDER ||
464
- (env.GITHUB_ACTIONS ? "github_actions" : env.GITLAB_CI ? "gitlab_ci" : env.JENKINS_URL ? "jenkins" : "local"),
612
+ options.provider || env.TD_PROVIDER || inferredProvider(env),
465
613
  "provider",
466
614
  80,
467
615
  );
468
- const ciUrl = boundedHeader(options.ciUrl || env.TD_CI_URL || env.CI_JOB_URL || env.BUILD_URL, "CI URL", 500);
616
+ const ciUrl = boundedHeader(
617
+ options.ciUrl || env.TD_CI_URL || env.CI_JOB_URL || env.CIRCLE_BUILD_URL || env.BUILD_URL,
618
+ "CI URL",
619
+ 500,
620
+ );
469
621
  return { runId, build, branch, provider, ciUrl };
470
622
  }
471
623
 
@@ -509,6 +661,39 @@ async function tokenFromEnvFile(path, stderr) {
509
661
  return parseTokenEnvFile(await readFile(path, "utf8"));
510
662
  }
511
663
 
664
+ // Single token-resolution path for upload and verify: --env-file, then the
665
+ // process environment, then the init-created file in the working directory.
666
+ async function resolveToken(options, env, cwd, stdout, stderr) {
667
+ let token;
668
+ if (options.envFile) {
669
+ token = await tokenFromEnvFile(options.envFile, stderr);
670
+ } else if (env.TD_CI_TOKEN) {
671
+ token = env.TD_CI_TOKEN;
672
+ } else {
673
+ const discovered = join(cwd, ENV_FILE_NAME);
674
+ const discoveredStats = await stat(discovered).catch(() => null);
675
+ if (discoveredStats && discoveredStats.isFile()) {
676
+ token = await tokenFromEnvFile(discovered, stderr);
677
+ stdout(`Using token from ${ENV_FILE_NAME}.`);
678
+ }
679
+ }
680
+ if (!token) {
681
+ throw new CliError(
682
+ `TD_CI_TOKEN is not set. Run \`npx testdossier init\` to create ${ENV_FILE_NAME}, ` +
683
+ "then paste your td_ access token into it.",
684
+ );
685
+ }
686
+ if (token === TOKEN_PLACEHOLDER) {
687
+ throw new CliError(
688
+ `TD_CI_TOKEN is still the placeholder; paste your real td_ access token into ${ENV_FILE_NAME}.`,
689
+ );
690
+ }
691
+ if (!token.startsWith("td_")) {
692
+ throw new CliError("TD_CI_TOKEN must be a TestDossier td_ access token.");
693
+ }
694
+ return token;
695
+ }
696
+
512
697
  function retryDelayMs(attempt, retryAfter, baseDelay) {
513
698
  if (retryAfter) {
514
699
  const seconds = Number(retryAfter);
@@ -519,13 +704,13 @@ function retryDelayMs(attempt, retryAfter, baseDelay) {
519
704
  return Math.min(baseDelay * (2 ** (attempt - 1)), 30_000);
520
705
  }
521
706
 
522
- function responseError(status, body) {
707
+ function responseError(status, body, action) {
523
708
  const code = body && typeof body.error === "string" ? ` ${body.error}` : "";
524
709
  const message = body && typeof body.message === "string" ? `: ${body.message.slice(0, 500)}` : "";
525
- return `upload failed (${status})${code}${message}`;
710
+ return `${action} failed (${status})${code}${message}`;
526
711
  }
527
712
 
528
- async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr }) {
713
+ async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr, action = "upload" }) {
529
714
  const attempts = integerEnv(env.TD_UPLOAD_MAX_ATTEMPTS, 5, 1, 10);
530
715
  const baseDelay = integerEnv(env.TD_UPLOAD_BASE_DELAY_MS, 1000, 1, 30_000);
531
716
  const timeout = integerEnv(env.TD_UPLOAD_TIMEOUT_MS, 60_000, 1000, 300_000);
@@ -559,21 +744,25 @@ async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr }
559
744
  }
560
745
  if (response.ok) return responseBody;
561
746
  if (!RETRYABLE_STATUSES.has(response.status) || attempt === attempts) {
562
- throw new CliError(responseError(response.status, responseBody), 1);
747
+ throw new CliError(responseError(response.status, responseBody, action), 1);
563
748
  }
564
749
  const wait = retryDelayMs(attempt, response.headers.get("retry-after"), baseDelay);
565
- stderr(`Upload returned ${response.status}; retrying ${attempt}/${attempts - 1} in ${wait}ms.`);
750
+ stderr(`Server returned ${response.status}; retrying ${attempt}/${attempts - 1} in ${wait}ms.`);
566
751
  await new Promise((resolve) => setTimeout(resolve, wait));
567
752
  }
568
753
 
569
- throw new CliError(`upload failed after ${attempts} attempt(s): ${lastNetworkError || "network error"}`, 1);
754
+ throw new CliError(`${action} failed after ${attempts} attempt(s): ${lastNetworkError || "network error"}`, 1);
570
755
  }
571
756
 
572
757
  function helpText() {
573
758
  return `TestDossier report uploader ${VERSION}
574
759
 
575
760
  Usage:
761
+ testdossier init One-time setup: create ${ENV_FILE_NAME} and gitignore it
762
+ testdossier verify Check the token and destination without sending a report
576
763
  testdossier upload <report-file> [options]
764
+ testdossier <report-file> [options] "upload" may be omitted
765
+ testdossier Upload the report remembered in ${CONFIG_FILE_NAME}
577
766
 
578
767
  Options:
579
768
  --format <format> auto (default), cypress, playwright, cucumber, junit, generic
@@ -589,9 +778,17 @@ Options:
589
778
  --version Show version
590
779
 
591
780
  Authentication:
592
- Set TD_CI_TOKEN in the environment or pass --env-file .env.testdossier.
593
- Tokens are intentionally not accepted as command-line arguments, keeping
594
- them out of shell history and process listings.
781
+ The token is read from the first available source: --env-file, the
782
+ TD_CI_TOKEN process environment, then ${ENV_FILE_NAME} in the current
783
+ directory (created by "testdossier init"). Tokens are intentionally not
784
+ accepted as command-line arguments, keeping them out of shell history
785
+ and process listings.
786
+
787
+ Project config:
788
+ The first successful upload writes ${CONFIG_FILE_NAME} (the report path,
789
+ plus --format and --url when passed) if the file does not exist. Commit
790
+ it; later runs are just "npx testdossier". Edit or delete the file to
791
+ change the saved defaults — uploads never rewrite an existing config.
595
792
 
596
793
  Large reports:
597
794
  JSON reports (Playwright, Cypress, Cucumber, generic) above 200 tests are
@@ -600,9 +797,12 @@ Large reports:
600
797
  split it per suite or switch the runner to a JSON reporter.
601
798
 
602
799
  Examples:
603
- npx testdossier upload playwright-results.json --env-file .env.testdossier
604
- npx testdossier upload junit-results.xml --env-file .env.testdossier --build abc123
605
- npx testdossier upload testdossier-ci.json --dry-run`;
800
+ npx testdossier init
801
+ npx testdossier verify
802
+ npx testdossier upload playwright-results.json
803
+ npx testdossier upload junit-results.xml --build abc123
804
+ npx testdossier upload testdossier-ci.json --dry-run
805
+ npx testdossier`;
606
806
  }
607
807
 
608
808
  function outputSummary(result, stdout, stderr) {
@@ -646,6 +846,7 @@ export async function runCli(
646
846
  fetchImpl = globalThis.fetch,
647
847
  stdout = (message) => console.log(message),
648
848
  stderr = (message) => console.error(message),
849
+ cwd = process.cwd(),
649
850
  } = {},
650
851
  ) {
651
852
  try {
@@ -653,20 +854,65 @@ export async function runCli(
653
854
  stdout(VERSION);
654
855
  return 0;
655
856
  }
656
- if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
857
+ if (args[0] === "--help" || args[0] === "-h") {
657
858
  stdout(helpText());
658
859
  return 0;
659
860
  }
660
- if (args[0] !== "upload") {
661
- throw new CliError(`unknown command: ${args[0]}\n\n${helpText()}`);
861
+ if (args[0] === "init") {
862
+ if (args.length > 1) throw new CliError("init takes no arguments.");
863
+ return await runInit({ cwd, stdout });
864
+ }
865
+ if (args[0] === "verify") {
866
+ const options = parseUploadArgs(args.slice(1));
867
+ if (options.help) {
868
+ stdout(helpText());
869
+ return 0;
870
+ }
871
+ if (options.reportPath) throw new CliError("verify takes no report file.");
872
+ const config = await readProjectConfig(cwd);
873
+ const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
874
+ const token = await resolveToken(options, env, cwd, stdout, stderr);
875
+ if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
876
+ const result = await postWithRetry({
877
+ endpoint,
878
+ headers: {
879
+ Authorization: `Bearer ${token}`,
880
+ "User-Agent": `testdossier-cli/${VERSION}`,
881
+ "X-CI-Verify": "1",
882
+ },
883
+ body: null,
884
+ env,
885
+ fetchImpl,
886
+ stderr,
887
+ action: "verify",
888
+ });
889
+ const name = result && typeof result.project_name === "string" && result.project_name
890
+ ? ` "${result.project_name}"`
891
+ : "";
892
+ const id = result && result.project_id ? ` (${result.project_id})` : "";
893
+ stdout(`Token verified for project${name}${id} at ${new URL(endpoint).origin} — CI ingestion enabled.`);
894
+ return 0;
662
895
  }
663
896
 
664
- const options = parseUploadArgs(args.slice(1));
897
+ const options = parseUploadArgs(args[0] === "upload" ? args.slice(1) : args);
665
898
  if (options.help) {
666
899
  stdout(helpText());
667
900
  return 0;
668
901
  }
669
- if (!options.reportPath) throw new CliError("upload requires a report file.");
902
+ const config = await readProjectConfig(cwd);
903
+ if (!options.reportPath && config && config.report) {
904
+ options.reportPath = config.report;
905
+ stdout(`Using report ${config.report} from ${CONFIG_FILE_NAME}.`);
906
+ }
907
+ if (!options.reportPath) {
908
+ if (args.length === 0) {
909
+ stdout(helpText());
910
+ return 0;
911
+ }
912
+ throw new CliError(
913
+ `upload requires a report file (pass a path, or run one upload with a path to save it in ${CONFIG_FILE_NAME}).`,
914
+ );
915
+ }
670
916
 
671
917
  const fileStats = await stat(options.reportPath).catch((error) => {
672
918
  throw new CliError(`could not read report file: ${error.message}`);
@@ -678,11 +924,12 @@ export async function runCli(
678
924
  }
679
925
  const report = await readFile(options.reportPath);
680
926
  const reportText = report.toString("utf8");
681
- const { format, document } = analyzeReport(reportText, options.format);
927
+ const requestedFormat = options.format !== "auto" ? options.format : (config && config.format) || "auto";
928
+ const { format, document } = analyzeReport(reportText, requestedFormat);
682
929
  const pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
683
930
  const testCount = pages.reduce((sum, page) => sum + page.tests, 0);
684
931
 
685
- const endpoint = normalizeEndpoint(options.url || env.TD_URL || DEFAULT_ORIGIN);
932
+ const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
686
933
  const destination = new URL(endpoint).origin;
687
934
  stdout(
688
935
  `Validated ${FORMAT_LABELS[format]} (${report.byteLength} bytes, ${testCount} tests` +
@@ -693,12 +940,7 @@ export async function runCli(
693
940
  return 0;
694
941
  }
695
942
 
696
- const token = options.envFile
697
- ? await tokenFromEnvFile(options.envFile, stderr)
698
- : env.TD_CI_TOKEN;
699
- if (!token || !token.startsWith("td_")) {
700
- throw new CliError("TD_CI_TOKEN is required and must be a TestDossier td_ access token.");
701
- }
943
+ const token = await resolveToken(options, env, cwd, stdout, stderr);
702
944
  if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
703
945
 
704
946
  const metadata = inferredMetadata(options, env);
@@ -753,6 +995,7 @@ export async function runCli(
753
995
  : `Note: ${replayed} of ${pageResults.length} pages were idempotent replays of an earlier upload (run ${metadata.runId}).`);
754
996
  }
755
997
  outputSummary(aggregatePageResults(pageResults), stdout, stderr);
998
+ if (!config) await saveProjectConfig(cwd, options, stdout, stderr);
756
999
  return 0;
757
1000
  } catch (error) {
758
1001
  const message = error && error.message ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdossier",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Upload completed test reports to TestDossier from a local machine or CI.",
5
5
  "license": "MIT",
6
6
  "type": "module",