testdossier 0.2.3 → 0.3.1
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 +112 -5
- package/assets/mark.svg +2 -2
- package/lib/source-context.mjs +212 -0
- package/lib/testdossier.mjs +424 -15
- package/package.json +2 -1
- package/templates/dossier-cypress.mjs +88 -0
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.
|
|
11
|
+
`@latest` with an exact version such as `@0.3.0`:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
npm install --save-dev testdossier@latest
|
|
@@ -70,9 +70,11 @@ requires Node.js 18 or newer.
|
|
|
70
70
|
|
|
71
71
|
## Why this is intentionally small
|
|
72
72
|
|
|
73
|
-
The
|
|
74
|
-
request to TestDossier. It does not run tests, accept remote commands,
|
|
75
|
-
background service, watch files, or keep a process running.
|
|
73
|
+
The upload path does one thing: read a completed report and make an outbound
|
|
74
|
+
HTTPS request to TestDossier. It does not run tests, accept remote commands,
|
|
75
|
+
install a background service, watch files, or keep a process running. The
|
|
76
|
+
optional `setup cypress` command performs one fixed local write using the
|
|
77
|
+
template already present in the installed package.
|
|
76
78
|
|
|
77
79
|
The access token is read from the first available source: an explicitly passed
|
|
78
80
|
`--env-file`, the `TD_CI_TOKEN` process environment, then `.env.testdossier` in
|
|
@@ -93,6 +95,74 @@ Before sending anything, inspect what the CLI detected:
|
|
|
93
95
|
npx testdossier upload playwright-results.json --dry-run
|
|
94
96
|
```
|
|
95
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
|
+
|
|
96
166
|
## Supported reports
|
|
97
167
|
|
|
98
168
|
| Runner or format | Example |
|
|
@@ -108,6 +178,43 @@ creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
|
|
|
108
178
|
`--format cucumber`, `--format junit`, or `--format generic`. The `upload`
|
|
109
179
|
word may be omitted: `npx testdossier playwright-results.json` works too.
|
|
110
180
|
|
|
181
|
+
For Playwright, configure `screenshot: 'only-on-failure'`. During upload the
|
|
182
|
+
CLI reads one screenshot from each test whose final attempt failed and sends it
|
|
183
|
+
through TestDossier's durable image path. Screenshots from passing tests and
|
|
184
|
+
earlier retries are omitted, while video/trace files remain link-first. Every
|
|
185
|
+
CI run stays immutable; identical images are hash-deduplicated in storage.
|
|
186
|
+
|
|
187
|
+
### Generate the Cypress helper from the installed package
|
|
188
|
+
|
|
189
|
+
Cypress's normal command does not write its complete Module API result to a
|
|
190
|
+
file, and local screenshot paths are not enough once the runner disappears.
|
|
191
|
+
After installing TestDossier in the client repository, generate the helper
|
|
192
|
+
from that installed, lockfile-pinned version:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
npx testdossier setup cypress
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The command creates only `scripts/dossier-cypress.mjs`, prints its SHA-256
|
|
199
|
+
digest, and never downloads code or contacts TestDossier. It is safe to run
|
|
200
|
+
again when the file still matches. If the file has been edited or came from a
|
|
201
|
+
different template, setup stops and leaves it untouched—there is deliberately
|
|
202
|
+
no force-overwrite option.
|
|
203
|
+
|
|
204
|
+
Review the generated file before committing it, then add a project script:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"scripts": {
|
|
209
|
+
"cy:testdossier": "node scripts/dossier-cypress.mjs --output cypress-results.json"
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Run `npm run cy:testdossier`, then upload `cypress-results.json`. The helper
|
|
215
|
+
uses the client project's own Cypress dependency; TestDossier does not bundle
|
|
216
|
+
or silently install Cypress.
|
|
217
|
+
|
|
111
218
|
## Local setup
|
|
112
219
|
|
|
113
220
|
For repeat use, install and pin the CLI in the test repository so its source
|
|
@@ -118,7 +225,7 @@ npm install --save-dev testdossier@latest
|
|
|
118
225
|
npx testdossier init
|
|
119
226
|
```
|
|
120
227
|
|
|
121
|
-
Use `npm install --save-dev testdossier@0.
|
|
228
|
+
Use `npm install --save-dev testdossier@0.3.0` when the repository should pin
|
|
122
229
|
that exact release. In either case, subsequent commands remain
|
|
123
230
|
`npx testdossier ...`; the version selector belongs only to installation.
|
|
124
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,14 +1,21 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
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.
|
|
6
|
+
export const VERSION = "0.3.0";
|
|
6
7
|
const DEFAULT_ORIGIN = "https://testdossier.com";
|
|
7
8
|
const ENV_FILE_NAME = ".env.testdossier";
|
|
8
9
|
const CONFIG_FILE_NAME = "testdossier.json";
|
|
10
|
+
const CYPRESS_HELPER_PATH = "scripts/dossier-cypress.mjs";
|
|
11
|
+
const CYPRESS_HELPER_TEMPLATE_URL = new URL("../templates/dossier-cypress.mjs", import.meta.url);
|
|
9
12
|
const TOKEN_PLACEHOLDER = "replace_with_your_td_token";
|
|
10
13
|
const REPORT_FORMATS = new Set(["cypress", "playwright", "cucumber", "junit", "generic"]);
|
|
11
14
|
const MAX_REPORT_BYTES = 25 * 1024 * 1024;
|
|
15
|
+
// Keep encoded bodies below both the endpoint's 25 MiB request ceiling and
|
|
16
|
+
// its 16 MiB decoded-evidence budget (base64 expands bytes by roughly 4/3).
|
|
17
|
+
const MAX_PAGE_BYTES = 20 * 1024 * 1024;
|
|
18
|
+
const MAX_FAILURE_SCREENSHOT_BYTES = 10 * 1024 * 1024;
|
|
12
19
|
const MAX_TESTS_PER_PAGE = 200;
|
|
13
20
|
const RETRYABLE_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
14
21
|
const FORMAT_LABELS = {
|
|
@@ -130,6 +137,86 @@ export function detectReportFormat(text) {
|
|
|
130
137
|
return analyzeReport(text).format;
|
|
131
138
|
}
|
|
132
139
|
|
|
140
|
+
const SCREENSHOT_MIME = new Map([
|
|
141
|
+
[".png", "image/png"], [".jpg", "image/jpeg"],
|
|
142
|
+
[".jpeg", "image/jpeg"], [".gif", "image/gif"], [".webp", "image/webp"],
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
function isPlaywrightScreenshot(att) {
|
|
146
|
+
if (!isObject(att)) return false;
|
|
147
|
+
const contentType = String(att.contentType || "").toLowerCase();
|
|
148
|
+
const extension = extname(String(att.path || att.name || "")).toLowerCase();
|
|
149
|
+
return contentType.startsWith("image/") || SCREENSHOT_MIME.has(extension);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function playwrightFinalFailure(test, result) {
|
|
153
|
+
const status = String(result && result.status || "").toLowerCase();
|
|
154
|
+
if (status === "timedout" || status === "interrupted") return true;
|
|
155
|
+
if (status !== "failed") return false;
|
|
156
|
+
return String(test && test.expectedStatus || "passed").toLowerCase() !== "failed";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Promote one screenshot from each final Playwright failure into the inline
|
|
160
|
+
* transport understood by the ingest endpoint. Earlier-retry screenshots are
|
|
161
|
+
* deliberately omitted from this upload copy to keep evidence bounded. */
|
|
162
|
+
export async function hydratePlaywrightFailureScreenshots(document, reportPath) {
|
|
163
|
+
if (!isObject(document) || !Array.isArray(document.suites)) return { inlined: 0, skipped: 0 };
|
|
164
|
+
let inlined = 0;
|
|
165
|
+
let skipped = 0;
|
|
166
|
+
|
|
167
|
+
const visitSuites = async (suites) => {
|
|
168
|
+
for (const suite of suites || []) {
|
|
169
|
+
for (const spec of Array.isArray(suite && suite.specs) ? suite.specs : []) {
|
|
170
|
+
for (const test of Array.isArray(spec && spec.tests) ? spec.tests : []) {
|
|
171
|
+
const results = Array.isArray(test && test.results) ? test.results : [];
|
|
172
|
+
const ordered = results.slice().sort((a, b) => Number(a.retry || 0) - Number(b.retry || 0));
|
|
173
|
+
const finalResult = ordered[ordered.length - 1];
|
|
174
|
+
const finalAttachments = Array.isArray(finalResult && finalResult.attachments) ? finalResult.attachments : [];
|
|
175
|
+
const selected = finalResult && playwrightFinalFailure(test, finalResult)
|
|
176
|
+
? finalAttachments.find((att) => isPlaywrightScreenshot(att) && typeof att.path === "string" && att.path.trim()) || null
|
|
177
|
+
: null;
|
|
178
|
+
|
|
179
|
+
for (const result of results) {
|
|
180
|
+
if (!Array.isArray(result.attachments)) continue;
|
|
181
|
+
const next = [];
|
|
182
|
+
for (const att of result.attachments) {
|
|
183
|
+
if (!isPlaywrightScreenshot(att)) { next.push(att); continue; }
|
|
184
|
+
if (att !== selected) continue;
|
|
185
|
+
const rawPath = String(att.path || "").trim();
|
|
186
|
+
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(dirname(reportPath), rawPath);
|
|
187
|
+
const mime = SCREENSHOT_MIME.get(extname(absolutePath).toLowerCase())
|
|
188
|
+
|| (/^image\/(png|jpeg|gif|webp)$/i.test(String(att.contentType || "")) ? String(att.contentType).toLowerCase() : "");
|
|
189
|
+
try {
|
|
190
|
+
const info = await stat(absolutePath);
|
|
191
|
+
if (!mime || !info.isFile() || info.size <= 0 || info.size > MAX_FAILURE_SCREENSHOT_BYTES) {
|
|
192
|
+
skipped += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const bytes = await readFile(absolutePath);
|
|
196
|
+
next.push({
|
|
197
|
+
...att,
|
|
198
|
+
name: att.name || basename(absolutePath),
|
|
199
|
+
contentType: mime,
|
|
200
|
+
data_url: `data:${mime};base64,${bytes.toString("base64")}`,
|
|
201
|
+
path: undefined,
|
|
202
|
+
});
|
|
203
|
+
inlined += 1;
|
|
204
|
+
} catch {
|
|
205
|
+
skipped += 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
result.attachments = next;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(suite && suite.suites)) await visitSuites(suite.suites);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
await visitSuites(document.suites);
|
|
217
|
+
return { inlined, skipped };
|
|
218
|
+
}
|
|
219
|
+
|
|
133
220
|
function playwrightTestNodes(suites, found = []) {
|
|
134
221
|
for (const suite of Array.isArray(suites) ? suites : []) {
|
|
135
222
|
if (!suite || typeof suite !== "object") continue;
|
|
@@ -160,6 +247,175 @@ function selectedPlaywrightSuite(suite, selectedTests) {
|
|
|
160
247
|
return next.specs.length > 0 || next.suites.length > 0 ? next : null;
|
|
161
248
|
}
|
|
162
249
|
|
|
250
|
+
async function inspectPlaywrightFailureScreenshot(test, reportPath) {
|
|
251
|
+
const results = Array.isArray(test && test.results) ? test.results : [];
|
|
252
|
+
const ordered = results.slice().sort((a, b) => Number(a.retry || 0) - Number(b.retry || 0));
|
|
253
|
+
const finalResult = ordered[ordered.length - 1];
|
|
254
|
+
const attachments = Array.isArray(finalResult && finalResult.attachments) ? finalResult.attachments : [];
|
|
255
|
+
const selected = finalResult && playwrightFinalFailure(test, finalResult)
|
|
256
|
+
? attachments.find((att) => isPlaywrightScreenshot(att) && typeof att.path === "string" && att.path.trim()) || null
|
|
257
|
+
: null;
|
|
258
|
+
if (!selected) return { selected: null, skipped: 0, dataUrlLength: 0 };
|
|
259
|
+
|
|
260
|
+
const rawPath = String(selected.path || "").trim();
|
|
261
|
+
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(dirname(reportPath), rawPath);
|
|
262
|
+
const mime = SCREENSHOT_MIME.get(extname(absolutePath).toLowerCase())
|
|
263
|
+
|| (/^image\/(png|jpeg|gif|webp)$/i.test(String(selected.contentType || "")) ? String(selected.contentType).toLowerCase() : "");
|
|
264
|
+
try {
|
|
265
|
+
const info = await stat(absolutePath);
|
|
266
|
+
if (!mime || !info.isFile() || info.size <= 0 || info.size > MAX_FAILURE_SCREENSHOT_BYTES) {
|
|
267
|
+
return { selected, skipped: 1, dataUrlLength: 0 };
|
|
268
|
+
}
|
|
269
|
+
const prefix = `data:${mime};base64,`;
|
|
270
|
+
return {
|
|
271
|
+
selected,
|
|
272
|
+
absolutePath,
|
|
273
|
+
mime,
|
|
274
|
+
size: info.size,
|
|
275
|
+
skipped: 0,
|
|
276
|
+
dataUrlLength: prefix.length + 4 * Math.ceil(info.size / 3),
|
|
277
|
+
};
|
|
278
|
+
} catch {
|
|
279
|
+
return { selected, skipped: 1, dataUrlLength: 0 };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function playwrightUploadTest(test, evidence, dataUrl, estimate) {
|
|
284
|
+
return {
|
|
285
|
+
...test,
|
|
286
|
+
results: (Array.isArray(test && test.results) ? test.results : []).map((result) => ({
|
|
287
|
+
...result,
|
|
288
|
+
attachments: (Array.isArray(result && result.attachments) ? result.attachments : []).flatMap((att) => {
|
|
289
|
+
if (!isPlaywrightScreenshot(att)) return [att];
|
|
290
|
+
if (att !== evidence.selected || !evidence.absolutePath || !evidence.mime) return [];
|
|
291
|
+
if (!estimate && typeof dataUrl !== "string") return [];
|
|
292
|
+
return [{
|
|
293
|
+
...att,
|
|
294
|
+
name: att.name || basename(evidence.absolutePath),
|
|
295
|
+
contentType: evidence.mime,
|
|
296
|
+
data_url: estimate ? "" : dataUrl,
|
|
297
|
+
path: undefined,
|
|
298
|
+
}];
|
|
299
|
+
}),
|
|
300
|
+
})),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function playwrightUploadPageBody(document, tests, evidenceByTest, dataUrls = null, estimate = false) {
|
|
305
|
+
const selected = new Set(tests);
|
|
306
|
+
const replacements = new Map(tests.map((test) => [
|
|
307
|
+
test,
|
|
308
|
+
playwrightUploadTest(test, evidenceByTest.get(test), dataUrls && dataUrls.get(test), estimate),
|
|
309
|
+
]));
|
|
310
|
+
const selectSuite = (suite) => {
|
|
311
|
+
if (!suite || typeof suite !== "object") return null;
|
|
312
|
+
const next = { ...suite };
|
|
313
|
+
next.specs = (Array.isArray(suite.specs) ? suite.specs : [])
|
|
314
|
+
.filter((spec) => spec && typeof spec === "object")
|
|
315
|
+
.map((spec) => ({
|
|
316
|
+
...spec,
|
|
317
|
+
tests: (Array.isArray(spec.tests) ? spec.tests : [])
|
|
318
|
+
.filter((test) => selected.has(test))
|
|
319
|
+
.map((test) => replacements.get(test)),
|
|
320
|
+
}))
|
|
321
|
+
.filter((spec) => spec.tests.length > 0);
|
|
322
|
+
next.suites = (Array.isArray(suite.suites) ? suite.suites : [])
|
|
323
|
+
.map(selectSuite)
|
|
324
|
+
.filter(Boolean);
|
|
325
|
+
return next.specs.length > 0 || next.suites.length > 0 ? next : null;
|
|
326
|
+
};
|
|
327
|
+
const suites = document.suites.map(selectSuite).filter(Boolean);
|
|
328
|
+
return Buffer.from(JSON.stringify({ ...document, suites }));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function estimatedPlaywrightUploadPageBytes(document, tests, evidenceByTest) {
|
|
332
|
+
const placeholder = playwrightUploadPageBody(document, tests, evidenceByTest, null, true);
|
|
333
|
+
return placeholder.byteLength + tests.reduce(
|
|
334
|
+
(sum, test) => sum + Number(evidenceByTest.get(test)?.dataUrlLength || 0),
|
|
335
|
+
0,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Plan Playwright pages using screenshot stat metadata only. No image bytes or
|
|
340
|
+
* base64 strings are retained here, so a failure storm cannot exhaust the Node
|
|
341
|
+
* heap before the first request is sent. */
|
|
342
|
+
export async function planPlaywrightUploadPages(
|
|
343
|
+
document,
|
|
344
|
+
reportPath,
|
|
345
|
+
maxTests = MAX_TESTS_PER_PAGE,
|
|
346
|
+
maxPageBytes = MAX_PAGE_BYTES,
|
|
347
|
+
) {
|
|
348
|
+
const tests = playwrightTestNodes(document.suites);
|
|
349
|
+
const evidenceByTest = new Map();
|
|
350
|
+
let inlined = 0;
|
|
351
|
+
let skipped = 0;
|
|
352
|
+
for (const test of tests) {
|
|
353
|
+
const evidence = await inspectPlaywrightFailureScreenshot(test, reportPath);
|
|
354
|
+
evidenceByTest.set(test, evidence);
|
|
355
|
+
if (evidence.dataUrlLength > 0) inlined += 1;
|
|
356
|
+
skipped += evidence.skipped;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const pages = [];
|
|
360
|
+
let current = [];
|
|
361
|
+
const pushCurrent = () => {
|
|
362
|
+
if (!current.length) return;
|
|
363
|
+
pages.push({
|
|
364
|
+
tests: current.slice(),
|
|
365
|
+
testCount: current.length,
|
|
366
|
+
estimatedBytes: estimatedPlaywrightUploadPageBytes(document, current, evidenceByTest),
|
|
367
|
+
maxPageBytes,
|
|
368
|
+
});
|
|
369
|
+
};
|
|
370
|
+
for (const test of tests) {
|
|
371
|
+
const candidate = [...current, test];
|
|
372
|
+
const estimatedBytes = estimatedPlaywrightUploadPageBytes(document, candidate, evidenceByTest);
|
|
373
|
+
if (candidate.length > maxTests || estimatedBytes > maxPageBytes) {
|
|
374
|
+
if (current.length === 0) {
|
|
375
|
+
throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
|
|
376
|
+
}
|
|
377
|
+
pushCurrent();
|
|
378
|
+
current = [test];
|
|
379
|
+
if (estimatedPlaywrightUploadPageBytes(document, current, evidenceByTest) > maxPageBytes) {
|
|
380
|
+
throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
|
|
381
|
+
}
|
|
382
|
+
} else {
|
|
383
|
+
current = candidate;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
pushCurrent();
|
|
387
|
+
if (pages.length === 0) {
|
|
388
|
+
pages.push({ tests: [], testCount: 0, estimatedBytes: playwrightUploadPageBody(document, [], evidenceByTest).byteLength, maxPageBytes });
|
|
389
|
+
}
|
|
390
|
+
return { pages, evidenceByTest, inlined, skipped };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Read and encode only one already-planned page. The caller uploads it before
|
|
394
|
+
* materializing the next page, bounding live screenshot data to one request. */
|
|
395
|
+
export async function materializePlaywrightUploadPage(document, page, evidenceByTest) {
|
|
396
|
+
const dataUrls = new Map();
|
|
397
|
+
let skipped = 0;
|
|
398
|
+
for (const test of page.tests) {
|
|
399
|
+
const evidence = evidenceByTest.get(test);
|
|
400
|
+
if (!evidence?.absolutePath || !evidence.mime) continue;
|
|
401
|
+
try {
|
|
402
|
+
const bytes = await readFile(evidence.absolutePath);
|
|
403
|
+
if (bytes.length <= 0 || bytes.length > MAX_FAILURE_SCREENSHOT_BYTES) {
|
|
404
|
+
skipped += 1;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
dataUrls.set(test, `data:${evidence.mime};base64,${bytes.toString("base64")}`);
|
|
408
|
+
} catch {
|
|
409
|
+
skipped += 1;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const body = playwrightUploadPageBody(document, page.tests, evidenceByTest, dataUrls, false);
|
|
413
|
+
if (body.byteLength > page.maxPageBytes) {
|
|
414
|
+
throw new CliError("one Playwright page exceeds the 20 MiB page limit because its screenshot changed during upload; reduce the screenshot size.");
|
|
415
|
+
}
|
|
416
|
+
return { body, skipped };
|
|
417
|
+
}
|
|
418
|
+
|
|
163
419
|
// Counts <testcase> elements the way the server's parser does: real tags
|
|
164
420
|
// only, so a failure message inside CDATA that happens to contain
|
|
165
421
|
// "<testcase" is not counted.
|
|
@@ -231,7 +487,7 @@ function selectedCypressRun(run, selectedTests) {
|
|
|
231
487
|
};
|
|
232
488
|
}
|
|
233
489
|
|
|
234
|
-
export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null) {
|
|
490
|
+
export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null, maxPageBytes = MAX_PAGE_BYTES) {
|
|
235
491
|
if (!Number.isInteger(maxTests) || maxTests < 1 || maxTests > MAX_TESTS_PER_PAGE) {
|
|
236
492
|
throw new CliError(`page size must be an integer from 1 to ${MAX_TESTS_PER_PAGE}.`);
|
|
237
493
|
}
|
|
@@ -259,14 +515,37 @@ export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE,
|
|
|
259
515
|
|
|
260
516
|
if (format === "playwright") {
|
|
261
517
|
const tests = playwrightTestNodes(doc.suites);
|
|
262
|
-
|
|
263
|
-
return pagedSlices(tests, maxTests).map((slice) => {
|
|
518
|
+
const pageBody = (slice) => {
|
|
264
519
|
const selected = new Set(slice);
|
|
265
520
|
const suites = doc.suites
|
|
266
521
|
.map((suite) => selectedPlaywrightSuite(suite, selected))
|
|
267
522
|
.filter(Boolean);
|
|
268
|
-
return
|
|
269
|
-
}
|
|
523
|
+
return Buffer.from(JSON.stringify({ ...doc, suites }));
|
|
524
|
+
};
|
|
525
|
+
if (tests.length <= maxTests && report.byteLength <= maxPageBytes) {
|
|
526
|
+
return [{ body: report, tests: tests.length }];
|
|
527
|
+
}
|
|
528
|
+
const pages = [];
|
|
529
|
+
let current = [];
|
|
530
|
+
for (const test of tests) {
|
|
531
|
+
const candidate = [...current, test];
|
|
532
|
+
const candidateBody = pageBody(candidate);
|
|
533
|
+
if (candidate.length > maxTests || candidateBody.byteLength > maxPageBytes) {
|
|
534
|
+
if (current.length === 0) {
|
|
535
|
+
throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
|
|
536
|
+
}
|
|
537
|
+
const body = pageBody(current);
|
|
538
|
+
pages.push({ body, tests: current.length });
|
|
539
|
+
current = [test];
|
|
540
|
+
if (pageBody(current).byteLength > maxPageBytes) {
|
|
541
|
+
throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
current = candidate;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (current.length > 0) pages.push({ body: pageBody(current), tests: current.length });
|
|
548
|
+
return pages;
|
|
270
549
|
}
|
|
271
550
|
|
|
272
551
|
if (format === "cypress") {
|
|
@@ -434,6 +713,13 @@ export async function readProjectConfig(cwd) {
|
|
|
434
713
|
}
|
|
435
714
|
config.url = value.url;
|
|
436
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
|
+
}
|
|
437
723
|
return config;
|
|
438
724
|
}
|
|
439
725
|
|
|
@@ -519,6 +805,57 @@ async function runInit({ cwd, stdout }) {
|
|
|
519
805
|
return 0;
|
|
520
806
|
}
|
|
521
807
|
|
|
808
|
+
async function runCypressSetup({ cwd, stdout }) {
|
|
809
|
+
let template;
|
|
810
|
+
try {
|
|
811
|
+
template = await readFile(CYPRESS_HELPER_TEMPLATE_URL, "utf8");
|
|
812
|
+
} catch (error) {
|
|
813
|
+
throw new CliError(`installed Cypress helper template is unavailable: ${error && error.message}`);
|
|
814
|
+
}
|
|
815
|
+
const targetPath = join(cwd, "scripts", "dossier-cypress.mjs");
|
|
816
|
+
const digest = createHash("sha256").update(template).digest("hex");
|
|
817
|
+
|
|
818
|
+
const describeExisting = async () => {
|
|
819
|
+
let existing;
|
|
820
|
+
try {
|
|
821
|
+
existing = await readFile(targetPath, "utf8");
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (error && error.code === "ENOENT") return false;
|
|
824
|
+
throw new CliError(
|
|
825
|
+
`${CYPRESS_HELPER_PATH} already exists but could not be read; left unchanged: ${error && error.message}`,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
if (existing !== template) {
|
|
829
|
+
throw new CliError(
|
|
830
|
+
`${CYPRESS_HELPER_PATH} already exists and differs from the testdossier ${VERSION} template; ` +
|
|
831
|
+
"left unchanged. Review or remove it before trying again.",
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
stdout(`${CYPRESS_HELPER_PATH} already matches testdossier ${VERSION}; left unchanged.`);
|
|
835
|
+
stdout(`SHA-256 ${digest}`);
|
|
836
|
+
return true;
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
if (await describeExisting()) return 0;
|
|
840
|
+
try {
|
|
841
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
842
|
+
await writeFile(targetPath, template, { flag: "wx", mode: 0o644 });
|
|
843
|
+
} catch (error) {
|
|
844
|
+
// Another process may have created the fixed target after our first read.
|
|
845
|
+
// Re-check it rather than overwriting or reporting a false failure.
|
|
846
|
+
if (error && error.code === "EEXIST" && await describeExisting()) return 0;
|
|
847
|
+
throw new CliError(`could not create ${CYPRESS_HELPER_PATH}: ${error && error.message}`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
stdout(`Created ${CYPRESS_HELPER_PATH} from installed testdossier ${VERSION}.`);
|
|
851
|
+
stdout(`SHA-256 ${digest}`);
|
|
852
|
+
stdout([
|
|
853
|
+
"Review the generated file before committing it, then add this script to package.json:",
|
|
854
|
+
' "cy:testdossier": "node scripts/dossier-cypress.mjs --output cypress-results.json"',
|
|
855
|
+
].join("\n"));
|
|
856
|
+
return 0;
|
|
857
|
+
}
|
|
858
|
+
|
|
522
859
|
function normalizedHeader(value, name) {
|
|
523
860
|
if (value == null || value === "") return "";
|
|
524
861
|
const normalized = String(value);
|
|
@@ -805,6 +1142,7 @@ function helpText() {
|
|
|
805
1142
|
|
|
806
1143
|
Usage:
|
|
807
1144
|
testdossier init One-time setup: create ${ENV_FILE_NAME} and gitignore it
|
|
1145
|
+
testdossier setup cypress Create a reviewable Cypress helper without overwriting files
|
|
808
1146
|
testdossier verify Check the token and destination without sending a report
|
|
809
1147
|
testdossier upload <report-file> [options]
|
|
810
1148
|
testdossier <report-file> [options] "upload" may be omitted
|
|
@@ -845,6 +1183,7 @@ Large reports:
|
|
|
845
1183
|
|
|
846
1184
|
Examples:
|
|
847
1185
|
npx testdossier init
|
|
1186
|
+
npx testdossier setup cypress
|
|
848
1187
|
npx testdossier verify
|
|
849
1188
|
npx testdossier upload playwright-results.json
|
|
850
1189
|
npx testdossier upload junit-results.xml --build abc123
|
|
@@ -909,6 +1248,12 @@ export async function runCli(
|
|
|
909
1248
|
if (args.length > 1) throw new CliError("init takes no arguments.");
|
|
910
1249
|
return await runInit({ cwd, stdout });
|
|
911
1250
|
}
|
|
1251
|
+
if (args[0] === "setup") {
|
|
1252
|
+
if (args.length !== 2 || args[1] !== "cypress") {
|
|
1253
|
+
throw new CliError("setup requires exactly one supported target: cypress.");
|
|
1254
|
+
}
|
|
1255
|
+
return await runCypressSetup({ cwd, stdout });
|
|
1256
|
+
}
|
|
912
1257
|
if (args[0] === "verify") {
|
|
913
1258
|
const options = parseUploadArgs(args.slice(1));
|
|
914
1259
|
if (options.help) {
|
|
@@ -981,13 +1326,62 @@ export async function runCli(
|
|
|
981
1326
|
const reportText = report.toString("utf8");
|
|
982
1327
|
const requestedFormat = options.format !== "auto" ? options.format : (config && config.format) || "auto";
|
|
983
1328
|
const { format, document } = analyzeReport(reportText, requestedFormat);
|
|
984
|
-
|
|
985
|
-
|
|
1329
|
+
let playwrightUpload = null;
|
|
1330
|
+
let pages;
|
|
1331
|
+
if (format === "playwright" && document) {
|
|
1332
|
+
playwrightUpload = await planPlaywrightUploadPages(document, options.reportPath);
|
|
1333
|
+
pages = playwrightUpload.pages;
|
|
1334
|
+
if (playwrightUpload.inlined || playwrightUpload.skipped) {
|
|
1335
|
+
stdout(`Prepared ${playwrightUpload.inlined} failure screenshot${playwrightUpload.inlined === 1 ? "" : "s"} for durable CI evidence${playwrightUpload.skipped ? ` (${playwrightUpload.skipped} skipped)` : ""}.`);
|
|
1336
|
+
}
|
|
1337
|
+
} else {
|
|
1338
|
+
pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
|
|
1339
|
+
}
|
|
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
|
+
}
|
|
1377
|
+
const validatedBytes = playwrightUpload
|
|
1378
|
+
? pages.reduce((sum, page) => sum + page.estimatedBytes, 0)
|
|
1379
|
+
: report.byteLength;
|
|
986
1380
|
|
|
987
1381
|
const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
|
|
988
1382
|
const destination = new URL(endpoint).origin;
|
|
989
1383
|
stdout(
|
|
990
|
-
`Validated ${FORMAT_LABELS[format]} (${
|
|
1384
|
+
`Validated ${FORMAT_LABELS[format]} (${validatedBytes} bytes, ${testCount} tests` +
|
|
991
1385
|
`${pages.length > 1 ? ` across ${pages.length} pages` : ""}).`,
|
|
992
1386
|
);
|
|
993
1387
|
if (options.dryRun) {
|
|
@@ -998,7 +1392,6 @@ export async function runCli(
|
|
|
998
1392
|
const token = await resolveToken(options, env, cwd, stdout, stderr);
|
|
999
1393
|
if (typeof fetchImpl !== "function") throw new CliError("Node 18 or newer is required (global fetch is unavailable).");
|
|
1000
1394
|
|
|
1001
|
-
const metadata = inferredMetadata(options, env);
|
|
1002
1395
|
const baseHeaders = {
|
|
1003
1396
|
Authorization: `Bearer ${token}`,
|
|
1004
1397
|
"Content-Type": format === "junit" ? "application/xml" : "application/json",
|
|
@@ -1014,7 +1407,7 @@ export async function runCli(
|
|
|
1014
1407
|
|
|
1015
1408
|
if (pages.length > 1) {
|
|
1016
1409
|
stdout(
|
|
1017
|
-
`Uploading ${pages.reduce((sum, page) => sum + (page.tests
|
|
1410
|
+
`Uploading ${pages.reduce((sum, page) => sum + (page.testCount ?? page.tests ?? 0), 0)} tests in ` +
|
|
1018
1411
|
`${pages.length} pages (run ${metadata.runId}).`,
|
|
1019
1412
|
);
|
|
1020
1413
|
}
|
|
@@ -1026,23 +1419,39 @@ export async function runCli(
|
|
|
1026
1419
|
const pageResults = [];
|
|
1027
1420
|
for (let index = 0; index < pages.length; index += 1) {
|
|
1028
1421
|
const page = pages[index];
|
|
1422
|
+
const materialized = playwrightUpload
|
|
1423
|
+
? await materializePlaywrightUploadPage(document, page, playwrightUpload.evidenceByTest)
|
|
1424
|
+
: null;
|
|
1425
|
+
if (materialized?.skipped) {
|
|
1426
|
+
stderr(`Warning: ${materialized.skipped} failure screenshot${materialized.skipped === 1 ? " was" : "s were"} no longer readable and were skipped.`);
|
|
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
|
+
}
|
|
1029
1433
|
const result = await postWithRetry({
|
|
1030
1434
|
endpoint,
|
|
1031
1435
|
headers: {
|
|
1032
1436
|
...baseHeaders,
|
|
1437
|
+
...(page.sourceCarriers?.length ? {
|
|
1438
|
+
"Content-Type": "application/json",
|
|
1439
|
+
"X-TD-Source-Envelope": "1",
|
|
1440
|
+
"X-CI-Format": format,
|
|
1441
|
+
} : {}),
|
|
1033
1442
|
"X-CI-Page-Id": pages.length === 1 ? pageBase : `${pageBase}-${index + 1}`,
|
|
1034
1443
|
"X-CI-Upload-Id": pageBase,
|
|
1035
1444
|
"X-CI-Page-Index": String(index + 1),
|
|
1036
1445
|
"X-CI-Page-Count": String(pages.length),
|
|
1037
1446
|
},
|
|
1038
|
-
body:
|
|
1447
|
+
body: uploadBody,
|
|
1039
1448
|
env,
|
|
1040
1449
|
fetchImpl,
|
|
1041
1450
|
stderr,
|
|
1042
1451
|
});
|
|
1043
1452
|
pageResults.push(result);
|
|
1044
1453
|
if (pages.length > 1) {
|
|
1045
|
-
stdout(`Uploaded page ${index + 1}/${pages.length} (${page.tests} tests).`);
|
|
1454
|
+
stdout(`Uploaded page ${index + 1}/${pages.length} (${page.testCount ?? page.tests} tests).`);
|
|
1046
1455
|
}
|
|
1047
1456
|
}
|
|
1048
1457
|
stdout(`Uploaded ${FORMAT_LABELS[format]} to ${destination} (run ${metadata.runId}).`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testdossier",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Upload Playwright, Cypress, Cucumber, JUnit, and JSON test evidence to TestDossier.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"lib",
|
|
13
|
+
"templates",
|
|
13
14
|
"assets",
|
|
14
15
|
"README.md",
|
|
15
16
|
"LICENSE",
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// TestDossier Cypress runner — zero dependencies beyond the project's Cypress.
|
|
3
|
+
// Uses Cypress's Module API so the output is the native { runs: [...] } shape
|
|
4
|
+
// accepted by X-CI-Format: cypress. Failure screenshots are embedded as data
|
|
5
|
+
// URLs; /api/ci/ingest uploads them to R2 and stores only durable references.
|
|
6
|
+
|
|
7
|
+
import cypress from "cypress";
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
|
|
9
|
+
import { extname, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const outputFlag = args.indexOf("--output");
|
|
13
|
+
const output = outputFlag >= 0 && args[outputFlag + 1]
|
|
14
|
+
? args[outputFlag + 1]
|
|
15
|
+
: "cypress-results.json";
|
|
16
|
+
const cypressArgs = outputFlag >= 0
|
|
17
|
+
? args.filter((_, i) => i !== outputFlag && i !== outputFlag + 1)
|
|
18
|
+
: args;
|
|
19
|
+
|
|
20
|
+
const MIME = {
|
|
21
|
+
".png": "image/png",
|
|
22
|
+
".jpg": "image/jpeg",
|
|
23
|
+
".jpeg": "image/jpeg",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
};
|
|
27
|
+
const MAX_SCREENSHOTS = 20;
|
|
28
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
29
|
+
// Base64 adds roughly 33%; keeping raw bytes at 15 MB leaves room under the
|
|
30
|
+
// ingest endpoint's 25 MB request limit for the Cypress result JSON itself.
|
|
31
|
+
const MAX_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
const options = {};
|
|
34
|
+
for (let i = 0; i < cypressArgs.length; i += 1) {
|
|
35
|
+
const arg = cypressArgs[i];
|
|
36
|
+
if (arg === "--browser" && cypressArgs[i + 1]) options.browser = cypressArgs[++i];
|
|
37
|
+
else if (arg === "--spec" && cypressArgs[i + 1]) options.spec = cypressArgs[++i];
|
|
38
|
+
else if (arg === "--config-file" && cypressArgs[i + 1]) options.configFile = cypressArgs[++i];
|
|
39
|
+
else if (arg === "--component") options.component = true;
|
|
40
|
+
else if (arg === "--headed") options.headed = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
result = await cypress.run(options);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error("Cypress could not start:", error instanceof Error ? error.message : error);
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!result || !Array.isArray(result.runs)) {
|
|
52
|
+
console.error("Cypress did not return a test-run result:", result?.message || "unknown error");
|
|
53
|
+
process.exit(2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let embedded = 0;
|
|
57
|
+
let embeddedBytes = 0;
|
|
58
|
+
let skipped = 0;
|
|
59
|
+
for (const run of result.runs) {
|
|
60
|
+
for (const screenshot of run.screenshots || []) {
|
|
61
|
+
const path = resolve(String(screenshot.path || ""));
|
|
62
|
+
const mime = MIME[extname(path).toLowerCase()];
|
|
63
|
+
if (!path || !mime || !existsSync(path)) { skipped += 1; continue; }
|
|
64
|
+
const bytes = statSync(path).size;
|
|
65
|
+
if (
|
|
66
|
+
bytes > MAX_IMAGE_BYTES ||
|
|
67
|
+
embedded >= MAX_SCREENSHOTS ||
|
|
68
|
+
embeddedBytes + bytes > MAX_TOTAL_IMAGE_BYTES
|
|
69
|
+
) {
|
|
70
|
+
skipped += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
screenshot.data_url = `data:${mime};base64,${readFileSync(path).toString("base64")}`;
|
|
74
|
+
screenshot.content_type = mime;
|
|
75
|
+
embedded += 1;
|
|
76
|
+
embeddedBytes += bytes;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
writeFileSync(output, JSON.stringify(result));
|
|
81
|
+
console.log(`Wrote ${output}: ${result.totalTests || 0} test(s), ${embedded} screenshot(s) embedded${skipped ? `, ${skipped} skipped by size/count limits` : ""}.`);
|
|
82
|
+
console.warn("Review CI screenshots for secrets or personal data before sharing them externally.");
|
|
83
|
+
|
|
84
|
+
const failed = Number(result.totalFailed || 0) || result.runs.reduce(
|
|
85
|
+
(sum, run) => sum + Number(run?.stats?.failures || 0),
|
|
86
|
+
0,
|
|
87
|
+
);
|
|
88
|
+
process.exitCode = failed > 0 ? 1 : 0;
|