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 +70 -2
- package/assets/mark.svg +2 -2
- package/lib/source-context.mjs +212 -0
- package/lib/testdossier.mjs +56 -3
- package/package.json +1 -1
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.
|
|
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.
|
|
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="#
|
|
4
|
-
<path fill="#
|
|
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
|
+
}
|
package/lib/testdossier.mjs
CHANGED
|
@@ -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.
|
|
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:
|
|
1447
|
+
body: uploadBody,
|
|
1395
1448
|
env,
|
|
1396
1449
|
fetchImpl,
|
|
1397
1450
|
stderr,
|