testdossier 0.3.0 → 0.3.2

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
@@ -8,7 +8,7 @@ Upload a completed test report from a developer laptop, test machine, or CI
8
8
  job. Run these commands in the project containing your automated tests:
9
9
 
10
10
  1. Install the latest CLI in the project. To pin a release instead, replace
11
- `@latest` with an exact version such as `@0.3.0`:
11
+ `@latest` with an exact version such as `@0.3.2`:
12
12
 
13
13
  ```bash
14
14
  npm install --save-dev testdossier@latest
@@ -95,6 +95,74 @@ Before sending anything, inspect what the CLI detected:
95
95
  npx testdossier upload playwright-results.json --dry-run
96
96
  ```
97
97
 
98
+ ### Optional source context
99
+
100
+ Source snippets are opt-in. Add this to the committed `testdossier.json` when
101
+ the checked-out CI workspace may be read for evidence:
102
+
103
+ ```json
104
+ {
105
+ "report": "playwright-results.json",
106
+ "source": {
107
+ "enabled": true,
108
+ "root": ".",
109
+ "before": 20,
110
+ "after": 80
111
+ }
112
+ }
113
+ ```
114
+
115
+ Only files identified by uploaded test records are considered. Paths must stay
116
+ inside the real, non-symlink source root; binary, invalid UTF-8, symlinked,
117
+ missing, and files over 2 MiB are skipped. Each persisted snippet is limited to
118
+ 240 lines and 16 KiB, common credential literals are masked, and the digest is
119
+ computed after masking. Source is evidence only: the CLI never executes,
120
+ formats, or infers test steps from it. `--dry-run` prints counts, safe relative
121
+ paths, ranges, hashes, truncation, and redaction counts—never source contents.
122
+
123
+ If a Playwright report records files relative to `testDir`, set `source.root`
124
+ to that directory (for example `"tests/e2e"`). An unchanged CI snippet keeps
125
+ its provenance; a new digest becomes a Review queue proposal and never silently
126
+ overwrites an approved case or an older run snapshot.
127
+
128
+ ## Automatic source context (opt in once)
129
+
130
+ The uploader can attach the repository source around each test declaration as
131
+ read-only execution evidence. Enable it in the committed `testdossier.json`:
132
+
133
+ ```json
134
+ {
135
+ "report": "playwright-results.json",
136
+ "source": {
137
+ "enabled": true,
138
+ "root": ".",
139
+ "before": 20,
140
+ "after": 80
141
+ }
142
+ }
143
+ ```
144
+
145
+ After that one-time opt-in, the same `npx testdossier` command captures source
146
+ locally, in GitHub Actions, and in GitLab CI. No TestDossier ID is required in
147
+ each `test()` or `it()` block: existing ID, alias, and report-signature matching
148
+ still apply. An explicit TestDossier ID, when present, remains the strongest
149
+ identity signal; disagreement with the report signature is quarantined for a
150
+ reviewer instead of being attached silently.
151
+
152
+ Capture is framework-neutral and best effort. It uses the source file and line
153
+ reported by Playwright, Cypress, Cucumber, or JUnit-compatible runners. Files
154
+ must resolve inside `source.root`; symlinks, binary files, files over 2 MiB, and
155
+ paths outside that root are skipped. Each persisted snippet is bounded, common
156
+ credential literals are redacted, and the CLI prints every captured path and
157
+ digest during `--dry-run` so the upload can be inspected before it is sent.
158
+
159
+ The Editor holds the current, editable source reference. Every Test Run keeps
160
+ the source uploaded for that execution as an immutable snapshot. When a later
161
+ upload changes a source digest, Review queue shows the incoming version for
162
+ comparison; accepting it updates the Editor reference without rewriting the
163
+ completed run. Editing in TestDossier never changes the repository or what CI
164
+ executes.
165
+
98
166
  ## Supported reports
99
167
 
100
168
  | Runner or format | Example |
@@ -157,7 +225,7 @@ npm install --save-dev testdossier@latest
157
225
  npx testdossier init
158
226
  ```
159
227
 
160
- Use `npm install --save-dev testdossier@0.3.0` when the repository should pin
228
+ Use `npm install --save-dev testdossier@0.3.2` when the repository should pin
161
229
  that exact release. In either case, subsequent commands remain
162
230
  `npx testdossier ...`; the version selector belongs only to installation.
163
231
 
package/assets/mark.svg CHANGED
@@ -1,5 +1,5 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TestDossier">
2
2
  <title>TestDossier</title>
3
- <path fill="#006b61" fill-rule="evenodd" d="M20 6h14c17 0 28 10 28 26S51 58 34 58H20V6Zm14 14v24c9 0 14-4 14-12s-5-12-14-12Z"/>
4
- <path fill="#006b61" d="M2 6h32v14H2z"/>
3
+ <path fill="#4335de" fill-rule="evenodd" d="M20 6h14c17 0 28 10 28 26S51 58 34 58H20V6Zm14 14v24c9 0 14-4 14-12s-5-12-14-12Z"/>
4
+ <path fill="#4335de" d="M2 6h32v14H2z"/>
5
5
  </svg>
@@ -0,0 +1,212 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, realpath, stat } from "node:fs/promises";
3
+ import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
4
+
5
+ export const SOURCE_ENVELOPE_VERSION = 1;
6
+ export const MAX_SOURCE_CONTEXT_BYTES = 16 * 1024;
7
+ export const MAX_SOURCE_CONTEXT_LINES = 240;
8
+ const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024;
9
+ const LANGUAGE_BY_EXTENSION = new Map([
10
+ [".js", "javascript"], [".jsx", "jsx"], [".mjs", "javascript"], [".cjs", "javascript"],
11
+ [".ts", "typescript"], [".tsx", "tsx"], [".py", "python"], [".rb", "ruby"],
12
+ [".java", "java"], [".kt", "kotlin"], [".kts", "kotlin"], [".cs", "csharp"],
13
+ [".go", "go"], [".rs", "rust"], [".php", "php"], [".feature", "gherkin"],
14
+ [".xml", "xml"], [".json", "json"], [".yaml", "yaml"], [".yml", "yaml"],
15
+ [".sh", "shell"], [".bash", "shell"], [".zsh", "shell"],
16
+ ]);
17
+
18
+ function object(value) {
19
+ return !!value && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+
22
+ export function parseSourceConfig(value) {
23
+ if (value === undefined) return null;
24
+ if (!object(value)) throw new Error('testdossier.json "source" must be an object.');
25
+ if (value.enabled !== true && value.enabled !== false) {
26
+ throw new Error('testdossier.json "source.enabled" must be true or false.');
27
+ }
28
+ if (!value.enabled) return null;
29
+ const root = value.root === undefined ? "." : value.root;
30
+ if (typeof root !== "string" || !root.trim() || isAbsolute(root) || root.split(/[\\/]/).includes("..")) {
31
+ throw new Error('testdossier.json "source.root" must be a relative path inside the project.');
32
+ }
33
+ const integer = (field, fallback, max) => {
34
+ const n = value[field];
35
+ if (n === undefined) return fallback;
36
+ if (!Number.isInteger(n) || n < 0 || n > max) throw new Error(`testdossier.json "source.${field}" must be an integer from 0 to ${max}.`);
37
+ return n;
38
+ };
39
+ return { enabled: true, root: root.trim(), before: integer("before", 20, 200), after: integer("after", 80, 200) };
40
+ }
41
+
42
+ export function redactSourceText(value) {
43
+ let content = String(value);
44
+ let redactions = 0;
45
+ const replace = (pattern, replacement) => {
46
+ content = content.replace(pattern, (...args) => {
47
+ redactions += 1;
48
+ return typeof replacement === "function" ? replacement(...args) : replacement;
49
+ });
50
+ };
51
+ replace(
52
+ /(\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth(?:orization)?|cookie)\b\s*[:=]\s*)(?:(["'`])[^\r\n]*?\2|[^\s,;]+)/gi,
53
+ (_match, prefix, quote) => `${prefix}${quote || ""}[REDACTED]${quote || ""}`,
54
+ );
55
+ replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, "Bearer [REDACTED]");
56
+ replace(/\btd_[A-Za-z0-9_-]{8,}\b/g, "td_[REDACTED]");
57
+ replace(/(https?:\/\/)([^\s/@:]+):([^\s/@]+)@/gi, (_match, scheme) => `${scheme}[REDACTED]@`);
58
+ replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
59
+ "-----BEGIN PRIVATE KEY-----\n[REDACTED]\n-----END PRIVATE KEY-----");
60
+ return { content, redactions };
61
+ }
62
+
63
+ function utf8Bound(value, maxBytes) {
64
+ const bytes = Buffer.from(value, "utf8");
65
+ if (bytes.byteLength <= maxBytes) return { content: value, truncated: false };
66
+ let content = bytes.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD$/, "");
67
+ return { content, truncated: true };
68
+ }
69
+
70
+ function sourceTargets(format, bodyText) {
71
+ if (format === "junit") {
72
+ const targets = [];
73
+ const open = /<testcase\b((?:[^>"']|"[^"]*"|'[^']*')*?)(?:\/?)>/gi;
74
+ let match;
75
+ while ((match = open.exec(bodyText))) {
76
+ const attr = (name) => {
77
+ const found = match[1].match(new RegExp(`\\b${name}\\s*=\\s*("[^"]*"|'[^']*')`, "i"));
78
+ return found ? found[1].slice(1, -1) : "";
79
+ };
80
+ targets.push({ path: attr("file"), line: Number(attr("line")) || undefined });
81
+ }
82
+ return targets;
83
+ }
84
+ let doc;
85
+ try { doc = JSON.parse(bodyText.replace(/^\uFEFF/, "")); } catch { return []; }
86
+ if (format === "generic") return (Array.isArray(doc?.tests) ? doc.tests : []).map((test) => ({
87
+ path: typeof test?.file === "string" ? test.file : "",
88
+ line: Number(test?.line ?? test?.location?.line) || undefined,
89
+ }));
90
+ if (format === "cypress") return (Array.isArray(doc?.runs) ? doc.runs : []).flatMap((run) => {
91
+ const path = run?.spec?.relative || run?.spec?.name || "";
92
+ return (Array.isArray(run?.tests) ? run.tests : []).map((test) => ({
93
+ path,
94
+ line: Number(test?.line ?? test?.location?.line) || undefined,
95
+ }));
96
+ });
97
+ if (format === "cucumber") return (Array.isArray(doc) ? doc : []).flatMap((feature) =>
98
+ (Array.isArray(feature?.elements) ? feature.elements : [])
99
+ .filter((element) => String(element?.type || "scenario").toLowerCase() === "scenario")
100
+ .map((element) => ({ path: feature?.uri || "", line: Number(element?.line) || undefined })));
101
+ const targets = [];
102
+ const visit = (suites) => {
103
+ for (const suite of Array.isArray(suites) ? suites : []) {
104
+ for (const spec of Array.isArray(suite?.specs) ? suite.specs : []) {
105
+ for (const test of Array.isArray(spec?.tests) ? spec.tests : []) {
106
+ targets.push({ path: spec.file || "", line: Number(spec.line) || undefined });
107
+ }
108
+ }
109
+ visit(suite?.suites);
110
+ }
111
+ };
112
+ visit(doc?.suites);
113
+ return targets;
114
+ }
115
+
116
+ function inside(root, candidate) {
117
+ const rel = relative(root, candidate);
118
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
119
+ }
120
+
121
+ async function hasSymlink(root, candidate) {
122
+ const rel = relative(root, candidate);
123
+ let cursor = root;
124
+ for (const part of rel.split(sep).filter(Boolean)) {
125
+ cursor = resolve(cursor, part);
126
+ if ((await lstat(cursor)).isSymbolicLink()) return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ async function readOneSource(root, target, config, commit) {
132
+ if (!target.path || typeof target.path !== "string") return { reason: "report has no source path" };
133
+ const candidate = isAbsolute(target.path) ? resolve(target.path) : resolve(root, target.path);
134
+ if (!inside(root, candidate)) return { reason: "path is outside source root", path: target.path };
135
+ try {
136
+ if (await hasSymlink(root, candidate)) return { reason: "symlink paths are not captured", path: target.path };
137
+ const candidateReal = await realpath(candidate);
138
+ if (!inside(root, candidateReal)) return { reason: "resolved path is outside source root", path: target.path };
139
+ const info = await stat(candidateReal);
140
+ if (!info.isFile()) return { reason: "source path is not a file", path: target.path };
141
+ if (info.size <= 0 || info.size > MAX_SOURCE_FILE_BYTES) return { reason: "source file is empty or over 2 MiB", path: target.path };
142
+ const bytes = await readFile(candidateReal);
143
+ if (bytes.includes(0)) return { reason: "binary source is not captured", path: target.path };
144
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/\r\n?/g, "\n");
145
+ const allLines = decoded.split("\n");
146
+ const focus = Number.isInteger(target.line) && target.line > 0 ? Math.min(target.line, allLines.length) : undefined;
147
+ const start = focus ? Math.max(1, focus - config.before) : 1;
148
+ const requestedEnd = focus ? Math.min(allLines.length, focus + config.after) : Math.min(allLines.length, MAX_SOURCE_CONTEXT_LINES);
149
+ const end = Math.min(requestedEnd, start + MAX_SOURCE_CONTEXT_LINES - 1);
150
+ const selected = allLines.slice(start - 1, end).join("\n");
151
+ const redacted = redactSourceText(selected);
152
+ const bounded = utf8Bound(redacted.content, MAX_SOURCE_CONTEXT_BYTES);
153
+ const relativePath = relative(root, candidateReal).split(sep).join("/");
154
+ const context = {
155
+ schema_version: SOURCE_ENVELOPE_VERSION,
156
+ origin: "ci",
157
+ content: bounded.content,
158
+ language: LANGUAGE_BY_EXTENSION.get(extname(relativePath).toLowerCase()) || "text",
159
+ digest: `sha256:${createHash("sha256").update(bounded.content).digest("hex")}`,
160
+ path: relativePath,
161
+ start_line: start,
162
+ end_line: start + bounded.content.split("\n").length - 1,
163
+ ...(focus ? { focus_line: focus } : {}),
164
+ ...(commit ? { commit: String(commit).slice(0, 80) } : {}),
165
+ ...((bounded.truncated || end < requestedEnd || requestedEnd < allLines.length || start > 1) ? { truncated: true } : {}),
166
+ ...(redacted.redactions ? { redactions: redacted.redactions } : {}),
167
+ };
168
+ return { context };
169
+ } catch (error) {
170
+ return { reason: error?.code === "ENOENT" ? "source file was not found" : "source file could not be read", path: target.path };
171
+ }
172
+ }
173
+
174
+ export async function collectSourceContexts({ format, body, cwd, config, commit = "" }) {
175
+ if (!config) return { carriers: [], captured: [], skipped: [] };
176
+ const configuredRoot = resolve(cwd, config.root);
177
+ let root;
178
+ try {
179
+ if (!inside(resolve(cwd), configuredRoot)) throw new Error("outside");
180
+ if (await hasSymlink(resolve(cwd), configuredRoot)) throw new Error("symlink");
181
+ root = await realpath(configuredRoot);
182
+ if (!inside(await realpath(cwd), root)) throw new Error("outside");
183
+ } catch {
184
+ throw new Error('testdossier.json "source.root" must resolve to a real, non-symlink directory inside the project.');
185
+ }
186
+ const targets = sourceTargets(format, Buffer.from(body).toString("utf8"));
187
+ const carriers = [];
188
+ const captured = [];
189
+ const skipped = [];
190
+ for (let testIndex = 0; testIndex < targets.length; testIndex += 1) {
191
+ const found = await readOneSource(root, targets[testIndex], config, commit);
192
+ if (found.context) {
193
+ carriers.push({ test_index: testIndex, ...found.context });
194
+ captured.push(found.context);
195
+ } else {
196
+ skipped.push({ test_index: testIndex, path: found.path || "", reason: found.reason });
197
+ }
198
+ }
199
+ return { carriers, captured, skipped };
200
+ }
201
+
202
+ export function wrapSourceEnvelope(format, body, carriers) {
203
+ if (!carriers?.length) return Buffer.from(body);
204
+ const text = Buffer.from(body).toString("utf8");
205
+ const report = format === "junit" ? text : JSON.parse(text.replace(/^\uFEFF/, ""));
206
+ return Buffer.from(JSON.stringify({
207
+ schema_version: SOURCE_ENVELOPE_VERSION,
208
+ format,
209
+ report,
210
+ source_contexts: carriers,
211
+ }));
212
+ }
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { collectSourceContexts, parseSourceConfig, wrapSourceEnvelope } from "./source-context.mjs";
4
5
 
5
- export const VERSION = "0.3.0";
6
+ export const VERSION = "0.3.2";
6
7
  const DEFAULT_ORIGIN = "https://testdossier.com";
7
8
  const ENV_FILE_NAME = ".env.testdossier";
8
9
  const CONFIG_FILE_NAME = "testdossier.json";
@@ -712,6 +713,13 @@ export async function readProjectConfig(cwd) {
712
713
  }
713
714
  config.url = value.url;
714
715
  }
716
+ if (value.source !== undefined) {
717
+ try {
718
+ config.source = parseSourceConfig(value.source);
719
+ } catch (error) {
720
+ throw new CliError(error.message);
721
+ }
722
+ }
715
723
  return config;
716
724
  }
717
725
 
@@ -1330,6 +1338,42 @@ export async function runCli(
1330
1338
  pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
1331
1339
  }
1332
1340
  const testCount = pages.reduce((sum, page) => sum + (page.testCount ?? page.tests), 0);
1341
+ const metadata = inferredMetadata(options, env);
1342
+ let sourceCaptured = 0;
1343
+ let sourceSkipped = 0;
1344
+ const sourcePaths = new Map();
1345
+ if (config?.source) {
1346
+ for (const page of pages) {
1347
+ // The source mapper only needs the report structure. Playwright's
1348
+ // failure screenshots remain lazily materialized at upload time.
1349
+ const structuralBody = playwrightUpload
1350
+ ? playwrightUploadPageBody(document, page.tests, playwrightUpload.evidenceByTest, new Map(), false)
1351
+ : page.body;
1352
+ const source = await collectSourceContexts({
1353
+ format,
1354
+ body: structuralBody,
1355
+ cwd,
1356
+ config: config.source,
1357
+ commit: metadata.build,
1358
+ });
1359
+ page.sourceCarriers = source.carriers;
1360
+ sourceCaptured += source.captured.length;
1361
+ sourceSkipped += source.skipped.length;
1362
+ for (const context of source.captured) {
1363
+ const key = `${context.path}:${context.start_line}-${context.end_line}`;
1364
+ if (!sourcePaths.has(key)) sourcePaths.set(key, context);
1365
+ }
1366
+ }
1367
+ stdout(`Source capture: ${sourceCaptured} of ${testCount} test records captured (${sourcePaths.size} unique snippet${sourcePaths.size === 1 ? "" : "s"}); ${sourceSkipped} skipped.`);
1368
+ const sourcePreviewLimit = 20;
1369
+ for (const context of [...sourcePaths.values()].slice(0, sourcePreviewLimit)) {
1370
+ stdout(` ${context.path}:${context.start_line}-${context.end_line} · ${context.digest.slice(0, 23)}${context.truncated ? " · truncated" : ""}${context.redactions ? ` · ${context.redactions} redaction${context.redactions === 1 ? "" : "s"}` : ""}`);
1371
+ }
1372
+ if (sourcePaths.size > sourcePreviewLimit) {
1373
+ stdout(` … ${sourcePaths.size - sourcePreviewLimit} more safe snippet paths omitted from console output.`);
1374
+ }
1375
+ if (sourceSkipped) stderr(`Warning: ${sourceSkipped} test record${sourceSkipped === 1 ? " had" : "s had"} no safe, readable source file and will upload without source context.`);
1376
+ }
1333
1377
  const validatedBytes = playwrightUpload
1334
1378
  ? pages.reduce((sum, page) => sum + page.estimatedBytes, 0)
1335
1379
  : report.byteLength;
@@ -1348,7 +1392,6 @@ export async function runCli(
1348
1392
  const token = await resolveToken(options, env, cwd, stdout, stderr);
1349
1393
  if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
1350
1394
 
1351
- const metadata = inferredMetadata(options, env);
1352
1395
  const baseHeaders = {
1353
1396
  Authorization: `Bearer ${token}`,
1354
1397
  "Content-Type": format === "junit" ? "application/xml" : "application/json",
@@ -1382,16 +1425,26 @@ export async function runCli(
1382
1425
  if (materialized?.skipped) {
1383
1426
  stderr(`Warning: ${materialized.skipped} failure screenshot${materialized.skipped === 1 ? " was" : "s were"} no longer readable and were skipped.`);
1384
1427
  }
1428
+ const rawBody = materialized ? materialized.body : page.body;
1429
+ const uploadBody = wrapSourceEnvelope(format, rawBody, page.sourceCarriers);
1430
+ if (uploadBody.byteLength > MAX_REPORT_BYTES) {
1431
+ throw new CliError("one report page plus source context exceeds the 25 MiB request limit; reduce the source window or screenshot size.");
1432
+ }
1385
1433
  const result = await postWithRetry({
1386
1434
  endpoint,
1387
1435
  headers: {
1388
1436
  ...baseHeaders,
1437
+ ...(page.sourceCarriers?.length ? {
1438
+ "Content-Type": "application/json",
1439
+ "X-TD-Source-Envelope": "1",
1440
+ "X-CI-Format": format,
1441
+ } : {}),
1389
1442
  "X-CI-Page-Id": pages.length === 1 ? pageBase : `${pageBase}-${index + 1}`,
1390
1443
  "X-CI-Upload-Id": pageBase,
1391
1444
  "X-CI-Page-Index": String(index + 1),
1392
1445
  "X-CI-Page-Count": String(pages.length),
1393
1446
  },
1394
- body: materialized ? materialized.body : page.body,
1447
+ body: uploadBody,
1395
1448
  env,
1396
1449
  fetchImpl,
1397
1450
  stderr,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testdossier",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Upload Playwright, Cypress, Cucumber, JUnit, and JSON test evidence to TestDossier.",
5
5
  "license": "MIT",
6
6
  "type": "module",