testdossier 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -5
- package/assets/mark.svg +1 -1
- package/lib/testdossier.mjs +370 -14
- 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
|
|
@@ -108,6 +110,43 @@ creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
|
|
|
108
110
|
`--format cucumber`, `--format junit`, or `--format generic`. The `upload`
|
|
109
111
|
word may be omitted: `npx testdossier playwright-results.json` works too.
|
|
110
112
|
|
|
113
|
+
For Playwright, configure `screenshot: 'only-on-failure'`. During upload the
|
|
114
|
+
CLI reads one screenshot from each test whose final attempt failed and sends it
|
|
115
|
+
through TestDossier's durable image path. Screenshots from passing tests and
|
|
116
|
+
earlier retries are omitted, while video/trace files remain link-first. Every
|
|
117
|
+
CI run stays immutable; identical images are hash-deduplicated in storage.
|
|
118
|
+
|
|
119
|
+
### Generate the Cypress helper from the installed package
|
|
120
|
+
|
|
121
|
+
Cypress's normal command does not write its complete Module API result to a
|
|
122
|
+
file, and local screenshot paths are not enough once the runner disappears.
|
|
123
|
+
After installing TestDossier in the client repository, generate the helper
|
|
124
|
+
from that installed, lockfile-pinned version:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npx testdossier setup cypress
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The command creates only `scripts/dossier-cypress.mjs`, prints its SHA-256
|
|
131
|
+
digest, and never downloads code or contacts TestDossier. It is safe to run
|
|
132
|
+
again when the file still matches. If the file has been edited or came from a
|
|
133
|
+
different template, setup stops and leaves it untouched—there is deliberately
|
|
134
|
+
no force-overwrite option.
|
|
135
|
+
|
|
136
|
+
Review the generated file before committing it, then add a project script:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"scripts": {
|
|
141
|
+
"cy:testdossier": "node scripts/dossier-cypress.mjs --output cypress-results.json"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Run `npm run cy:testdossier`, then upload `cypress-results.json`. The helper
|
|
147
|
+
uses the client project's own Cypress dependency; TestDossier does not bundle
|
|
148
|
+
or silently install Cypress.
|
|
149
|
+
|
|
111
150
|
## Local setup
|
|
112
151
|
|
|
113
152
|
For repeat use, install and pin the CLI in the test repository so its source
|
|
@@ -118,7 +157,7 @@ npm install --save-dev testdossier@latest
|
|
|
118
157
|
npx testdossier init
|
|
119
158
|
```
|
|
120
159
|
|
|
121
|
-
Use `npm install --save-dev testdossier@0.
|
|
160
|
+
Use `npm install --save-dev testdossier@0.3.0` when the repository should pin
|
|
122
161
|
that exact release. In either case, subsequent commands remain
|
|
123
162
|
`npx testdossier ...`; the version selector belongs only to installation.
|
|
124
163
|
|
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
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
|
|
4
|
+
<path fill="#006b61" d="M2 6h32v14H2z"/>
|
|
5
5
|
</svg>
|
package/lib/testdossier.mjs
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
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
4
|
|
|
5
|
-
export const VERSION = "0.
|
|
5
|
+
export const VERSION = "0.3.0";
|
|
6
6
|
const DEFAULT_ORIGIN = "https://testdossier.com";
|
|
7
7
|
const ENV_FILE_NAME = ".env.testdossier";
|
|
8
8
|
const CONFIG_FILE_NAME = "testdossier.json";
|
|
9
|
+
const CYPRESS_HELPER_PATH = "scripts/dossier-cypress.mjs";
|
|
10
|
+
const CYPRESS_HELPER_TEMPLATE_URL = new URL("../templates/dossier-cypress.mjs", import.meta.url);
|
|
9
11
|
const TOKEN_PLACEHOLDER = "replace_with_your_td_token";
|
|
10
12
|
const REPORT_FORMATS = new Set(["cypress", "playwright", "cucumber", "junit", "generic"]);
|
|
11
13
|
const MAX_REPORT_BYTES = 25 * 1024 * 1024;
|
|
14
|
+
// Keep encoded bodies below both the endpoint's 25 MiB request ceiling and
|
|
15
|
+
// its 16 MiB decoded-evidence budget (base64 expands bytes by roughly 4/3).
|
|
16
|
+
const MAX_PAGE_BYTES = 20 * 1024 * 1024;
|
|
17
|
+
const MAX_FAILURE_SCREENSHOT_BYTES = 10 * 1024 * 1024;
|
|
12
18
|
const MAX_TESTS_PER_PAGE = 200;
|
|
13
19
|
const RETRYABLE_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
14
20
|
const FORMAT_LABELS = {
|
|
@@ -130,6 +136,86 @@ export function detectReportFormat(text) {
|
|
|
130
136
|
return analyzeReport(text).format;
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
const SCREENSHOT_MIME = new Map([
|
|
140
|
+
[".png", "image/png"], [".jpg", "image/jpeg"],
|
|
141
|
+
[".jpeg", "image/jpeg"], [".gif", "image/gif"], [".webp", "image/webp"],
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
function isPlaywrightScreenshot(att) {
|
|
145
|
+
if (!isObject(att)) return false;
|
|
146
|
+
const contentType = String(att.contentType || "").toLowerCase();
|
|
147
|
+
const extension = extname(String(att.path || att.name || "")).toLowerCase();
|
|
148
|
+
return contentType.startsWith("image/") || SCREENSHOT_MIME.has(extension);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function playwrightFinalFailure(test, result) {
|
|
152
|
+
const status = String(result && result.status || "").toLowerCase();
|
|
153
|
+
if (status === "timedout" || status === "interrupted") return true;
|
|
154
|
+
if (status !== "failed") return false;
|
|
155
|
+
return String(test && test.expectedStatus || "passed").toLowerCase() !== "failed";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Promote one screenshot from each final Playwright failure into the inline
|
|
159
|
+
* transport understood by the ingest endpoint. Earlier-retry screenshots are
|
|
160
|
+
* deliberately omitted from this upload copy to keep evidence bounded. */
|
|
161
|
+
export async function hydratePlaywrightFailureScreenshots(document, reportPath) {
|
|
162
|
+
if (!isObject(document) || !Array.isArray(document.suites)) return { inlined: 0, skipped: 0 };
|
|
163
|
+
let inlined = 0;
|
|
164
|
+
let skipped = 0;
|
|
165
|
+
|
|
166
|
+
const visitSuites = async (suites) => {
|
|
167
|
+
for (const suite of suites || []) {
|
|
168
|
+
for (const spec of Array.isArray(suite && suite.specs) ? suite.specs : []) {
|
|
169
|
+
for (const test of Array.isArray(spec && spec.tests) ? spec.tests : []) {
|
|
170
|
+
const results = Array.isArray(test && test.results) ? test.results : [];
|
|
171
|
+
const ordered = results.slice().sort((a, b) => Number(a.retry || 0) - Number(b.retry || 0));
|
|
172
|
+
const finalResult = ordered[ordered.length - 1];
|
|
173
|
+
const finalAttachments = Array.isArray(finalResult && finalResult.attachments) ? finalResult.attachments : [];
|
|
174
|
+
const selected = finalResult && playwrightFinalFailure(test, finalResult)
|
|
175
|
+
? finalAttachments.find((att) => isPlaywrightScreenshot(att) && typeof att.path === "string" && att.path.trim()) || null
|
|
176
|
+
: null;
|
|
177
|
+
|
|
178
|
+
for (const result of results) {
|
|
179
|
+
if (!Array.isArray(result.attachments)) continue;
|
|
180
|
+
const next = [];
|
|
181
|
+
for (const att of result.attachments) {
|
|
182
|
+
if (!isPlaywrightScreenshot(att)) { next.push(att); continue; }
|
|
183
|
+
if (att !== selected) continue;
|
|
184
|
+
const rawPath = String(att.path || "").trim();
|
|
185
|
+
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(dirname(reportPath), rawPath);
|
|
186
|
+
const mime = SCREENSHOT_MIME.get(extname(absolutePath).toLowerCase())
|
|
187
|
+
|| (/^image\/(png|jpeg|gif|webp)$/i.test(String(att.contentType || "")) ? String(att.contentType).toLowerCase() : "");
|
|
188
|
+
try {
|
|
189
|
+
const info = await stat(absolutePath);
|
|
190
|
+
if (!mime || !info.isFile() || info.size <= 0 || info.size > MAX_FAILURE_SCREENSHOT_BYTES) {
|
|
191
|
+
skipped += 1;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const bytes = await readFile(absolutePath);
|
|
195
|
+
next.push({
|
|
196
|
+
...att,
|
|
197
|
+
name: att.name || basename(absolutePath),
|
|
198
|
+
contentType: mime,
|
|
199
|
+
data_url: `data:${mime};base64,${bytes.toString("base64")}`,
|
|
200
|
+
path: undefined,
|
|
201
|
+
});
|
|
202
|
+
inlined += 1;
|
|
203
|
+
} catch {
|
|
204
|
+
skipped += 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
result.attachments = next;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (Array.isArray(suite && suite.suites)) await visitSuites(suite.suites);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
await visitSuites(document.suites);
|
|
216
|
+
return { inlined, skipped };
|
|
217
|
+
}
|
|
218
|
+
|
|
133
219
|
function playwrightTestNodes(suites, found = []) {
|
|
134
220
|
for (const suite of Array.isArray(suites) ? suites : []) {
|
|
135
221
|
if (!suite || typeof suite !== "object") continue;
|
|
@@ -160,6 +246,175 @@ function selectedPlaywrightSuite(suite, selectedTests) {
|
|
|
160
246
|
return next.specs.length > 0 || next.suites.length > 0 ? next : null;
|
|
161
247
|
}
|
|
162
248
|
|
|
249
|
+
async function inspectPlaywrightFailureScreenshot(test, reportPath) {
|
|
250
|
+
const results = Array.isArray(test && test.results) ? test.results : [];
|
|
251
|
+
const ordered = results.slice().sort((a, b) => Number(a.retry || 0) - Number(b.retry || 0));
|
|
252
|
+
const finalResult = ordered[ordered.length - 1];
|
|
253
|
+
const attachments = Array.isArray(finalResult && finalResult.attachments) ? finalResult.attachments : [];
|
|
254
|
+
const selected = finalResult && playwrightFinalFailure(test, finalResult)
|
|
255
|
+
? attachments.find((att) => isPlaywrightScreenshot(att) && typeof att.path === "string" && att.path.trim()) || null
|
|
256
|
+
: null;
|
|
257
|
+
if (!selected) return { selected: null, skipped: 0, dataUrlLength: 0 };
|
|
258
|
+
|
|
259
|
+
const rawPath = String(selected.path || "").trim();
|
|
260
|
+
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(dirname(reportPath), rawPath);
|
|
261
|
+
const mime = SCREENSHOT_MIME.get(extname(absolutePath).toLowerCase())
|
|
262
|
+
|| (/^image\/(png|jpeg|gif|webp)$/i.test(String(selected.contentType || "")) ? String(selected.contentType).toLowerCase() : "");
|
|
263
|
+
try {
|
|
264
|
+
const info = await stat(absolutePath);
|
|
265
|
+
if (!mime || !info.isFile() || info.size <= 0 || info.size > MAX_FAILURE_SCREENSHOT_BYTES) {
|
|
266
|
+
return { selected, skipped: 1, dataUrlLength: 0 };
|
|
267
|
+
}
|
|
268
|
+
const prefix = `data:${mime};base64,`;
|
|
269
|
+
return {
|
|
270
|
+
selected,
|
|
271
|
+
absolutePath,
|
|
272
|
+
mime,
|
|
273
|
+
size: info.size,
|
|
274
|
+
skipped: 0,
|
|
275
|
+
dataUrlLength: prefix.length + 4 * Math.ceil(info.size / 3),
|
|
276
|
+
};
|
|
277
|
+
} catch {
|
|
278
|
+
return { selected, skipped: 1, dataUrlLength: 0 };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function playwrightUploadTest(test, evidence, dataUrl, estimate) {
|
|
283
|
+
return {
|
|
284
|
+
...test,
|
|
285
|
+
results: (Array.isArray(test && test.results) ? test.results : []).map((result) => ({
|
|
286
|
+
...result,
|
|
287
|
+
attachments: (Array.isArray(result && result.attachments) ? result.attachments : []).flatMap((att) => {
|
|
288
|
+
if (!isPlaywrightScreenshot(att)) return [att];
|
|
289
|
+
if (att !== evidence.selected || !evidence.absolutePath || !evidence.mime) return [];
|
|
290
|
+
if (!estimate && typeof dataUrl !== "string") return [];
|
|
291
|
+
return [{
|
|
292
|
+
...att,
|
|
293
|
+
name: att.name || basename(evidence.absolutePath),
|
|
294
|
+
contentType: evidence.mime,
|
|
295
|
+
data_url: estimate ? "" : dataUrl,
|
|
296
|
+
path: undefined,
|
|
297
|
+
}];
|
|
298
|
+
}),
|
|
299
|
+
})),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function playwrightUploadPageBody(document, tests, evidenceByTest, dataUrls = null, estimate = false) {
|
|
304
|
+
const selected = new Set(tests);
|
|
305
|
+
const replacements = new Map(tests.map((test) => [
|
|
306
|
+
test,
|
|
307
|
+
playwrightUploadTest(test, evidenceByTest.get(test), dataUrls && dataUrls.get(test), estimate),
|
|
308
|
+
]));
|
|
309
|
+
const selectSuite = (suite) => {
|
|
310
|
+
if (!suite || typeof suite !== "object") return null;
|
|
311
|
+
const next = { ...suite };
|
|
312
|
+
next.specs = (Array.isArray(suite.specs) ? suite.specs : [])
|
|
313
|
+
.filter((spec) => spec && typeof spec === "object")
|
|
314
|
+
.map((spec) => ({
|
|
315
|
+
...spec,
|
|
316
|
+
tests: (Array.isArray(spec.tests) ? spec.tests : [])
|
|
317
|
+
.filter((test) => selected.has(test))
|
|
318
|
+
.map((test) => replacements.get(test)),
|
|
319
|
+
}))
|
|
320
|
+
.filter((spec) => spec.tests.length > 0);
|
|
321
|
+
next.suites = (Array.isArray(suite.suites) ? suite.suites : [])
|
|
322
|
+
.map(selectSuite)
|
|
323
|
+
.filter(Boolean);
|
|
324
|
+
return next.specs.length > 0 || next.suites.length > 0 ? next : null;
|
|
325
|
+
};
|
|
326
|
+
const suites = document.suites.map(selectSuite).filter(Boolean);
|
|
327
|
+
return Buffer.from(JSON.stringify({ ...document, suites }));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function estimatedPlaywrightUploadPageBytes(document, tests, evidenceByTest) {
|
|
331
|
+
const placeholder = playwrightUploadPageBody(document, tests, evidenceByTest, null, true);
|
|
332
|
+
return placeholder.byteLength + tests.reduce(
|
|
333
|
+
(sum, test) => sum + Number(evidenceByTest.get(test)?.dataUrlLength || 0),
|
|
334
|
+
0,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Plan Playwright pages using screenshot stat metadata only. No image bytes or
|
|
339
|
+
* base64 strings are retained here, so a failure storm cannot exhaust the Node
|
|
340
|
+
* heap before the first request is sent. */
|
|
341
|
+
export async function planPlaywrightUploadPages(
|
|
342
|
+
document,
|
|
343
|
+
reportPath,
|
|
344
|
+
maxTests = MAX_TESTS_PER_PAGE,
|
|
345
|
+
maxPageBytes = MAX_PAGE_BYTES,
|
|
346
|
+
) {
|
|
347
|
+
const tests = playwrightTestNodes(document.suites);
|
|
348
|
+
const evidenceByTest = new Map();
|
|
349
|
+
let inlined = 0;
|
|
350
|
+
let skipped = 0;
|
|
351
|
+
for (const test of tests) {
|
|
352
|
+
const evidence = await inspectPlaywrightFailureScreenshot(test, reportPath);
|
|
353
|
+
evidenceByTest.set(test, evidence);
|
|
354
|
+
if (evidence.dataUrlLength > 0) inlined += 1;
|
|
355
|
+
skipped += evidence.skipped;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const pages = [];
|
|
359
|
+
let current = [];
|
|
360
|
+
const pushCurrent = () => {
|
|
361
|
+
if (!current.length) return;
|
|
362
|
+
pages.push({
|
|
363
|
+
tests: current.slice(),
|
|
364
|
+
testCount: current.length,
|
|
365
|
+
estimatedBytes: estimatedPlaywrightUploadPageBytes(document, current, evidenceByTest),
|
|
366
|
+
maxPageBytes,
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
for (const test of tests) {
|
|
370
|
+
const candidate = [...current, test];
|
|
371
|
+
const estimatedBytes = estimatedPlaywrightUploadPageBytes(document, candidate, evidenceByTest);
|
|
372
|
+
if (candidate.length > maxTests || estimatedBytes > maxPageBytes) {
|
|
373
|
+
if (current.length === 0) {
|
|
374
|
+
throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
|
|
375
|
+
}
|
|
376
|
+
pushCurrent();
|
|
377
|
+
current = [test];
|
|
378
|
+
if (estimatedPlaywrightUploadPageBytes(document, current, evidenceByTest) > maxPageBytes) {
|
|
379
|
+
throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
current = candidate;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
pushCurrent();
|
|
386
|
+
if (pages.length === 0) {
|
|
387
|
+
pages.push({ tests: [], testCount: 0, estimatedBytes: playwrightUploadPageBody(document, [], evidenceByTest).byteLength, maxPageBytes });
|
|
388
|
+
}
|
|
389
|
+
return { pages, evidenceByTest, inlined, skipped };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Read and encode only one already-planned page. The caller uploads it before
|
|
393
|
+
* materializing the next page, bounding live screenshot data to one request. */
|
|
394
|
+
export async function materializePlaywrightUploadPage(document, page, evidenceByTest) {
|
|
395
|
+
const dataUrls = new Map();
|
|
396
|
+
let skipped = 0;
|
|
397
|
+
for (const test of page.tests) {
|
|
398
|
+
const evidence = evidenceByTest.get(test);
|
|
399
|
+
if (!evidence?.absolutePath || !evidence.mime) continue;
|
|
400
|
+
try {
|
|
401
|
+
const bytes = await readFile(evidence.absolutePath);
|
|
402
|
+
if (bytes.length <= 0 || bytes.length > MAX_FAILURE_SCREENSHOT_BYTES) {
|
|
403
|
+
skipped += 1;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
dataUrls.set(test, `data:${evidence.mime};base64,${bytes.toString("base64")}`);
|
|
407
|
+
} catch {
|
|
408
|
+
skipped += 1;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const body = playwrightUploadPageBody(document, page.tests, evidenceByTest, dataUrls, false);
|
|
412
|
+
if (body.byteLength > page.maxPageBytes) {
|
|
413
|
+
throw new CliError("one Playwright page exceeds the 20 MiB page limit because its screenshot changed during upload; reduce the screenshot size.");
|
|
414
|
+
}
|
|
415
|
+
return { body, skipped };
|
|
416
|
+
}
|
|
417
|
+
|
|
163
418
|
// Counts <testcase> elements the way the server's parser does: real tags
|
|
164
419
|
// only, so a failure message inside CDATA that happens to contain
|
|
165
420
|
// "<testcase" is not counted.
|
|
@@ -231,7 +486,7 @@ function selectedCypressRun(run, selectedTests) {
|
|
|
231
486
|
};
|
|
232
487
|
}
|
|
233
488
|
|
|
234
|
-
export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null) {
|
|
489
|
+
export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE, document = null, maxPageBytes = MAX_PAGE_BYTES) {
|
|
235
490
|
if (!Number.isInteger(maxTests) || maxTests < 1 || maxTests > MAX_TESTS_PER_PAGE) {
|
|
236
491
|
throw new CliError(`page size must be an integer from 1 to ${MAX_TESTS_PER_PAGE}.`);
|
|
237
492
|
}
|
|
@@ -259,14 +514,37 @@ export function splitReportPages(format, report, maxTests = MAX_TESTS_PER_PAGE,
|
|
|
259
514
|
|
|
260
515
|
if (format === "playwright") {
|
|
261
516
|
const tests = playwrightTestNodes(doc.suites);
|
|
262
|
-
|
|
263
|
-
return pagedSlices(tests, maxTests).map((slice) => {
|
|
517
|
+
const pageBody = (slice) => {
|
|
264
518
|
const selected = new Set(slice);
|
|
265
519
|
const suites = doc.suites
|
|
266
520
|
.map((suite) => selectedPlaywrightSuite(suite, selected))
|
|
267
521
|
.filter(Boolean);
|
|
268
|
-
return
|
|
269
|
-
}
|
|
522
|
+
return Buffer.from(JSON.stringify({ ...doc, suites }));
|
|
523
|
+
};
|
|
524
|
+
if (tests.length <= maxTests && report.byteLength <= maxPageBytes) {
|
|
525
|
+
return [{ body: report, tests: tests.length }];
|
|
526
|
+
}
|
|
527
|
+
const pages = [];
|
|
528
|
+
let current = [];
|
|
529
|
+
for (const test of tests) {
|
|
530
|
+
const candidate = [...current, test];
|
|
531
|
+
const candidateBody = pageBody(candidate);
|
|
532
|
+
if (candidate.length > maxTests || candidateBody.byteLength > maxPageBytes) {
|
|
533
|
+
if (current.length === 0) {
|
|
534
|
+
throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
|
|
535
|
+
}
|
|
536
|
+
const body = pageBody(current);
|
|
537
|
+
pages.push({ body, tests: current.length });
|
|
538
|
+
current = [test];
|
|
539
|
+
if (pageBody(current).byteLength > maxPageBytes) {
|
|
540
|
+
throw new CliError("one Playwright test plus its failure screenshot exceeds the 20 MiB page limit; reduce the screenshot size.");
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
current = candidate;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (current.length > 0) pages.push({ body: pageBody(current), tests: current.length });
|
|
547
|
+
return pages;
|
|
270
548
|
}
|
|
271
549
|
|
|
272
550
|
if (format === "cypress") {
|
|
@@ -519,6 +797,57 @@ async function runInit({ cwd, stdout }) {
|
|
|
519
797
|
return 0;
|
|
520
798
|
}
|
|
521
799
|
|
|
800
|
+
async function runCypressSetup({ cwd, stdout }) {
|
|
801
|
+
let template;
|
|
802
|
+
try {
|
|
803
|
+
template = await readFile(CYPRESS_HELPER_TEMPLATE_URL, "utf8");
|
|
804
|
+
} catch (error) {
|
|
805
|
+
throw new CliError(`installed Cypress helper template is unavailable: ${error && error.message}`);
|
|
806
|
+
}
|
|
807
|
+
const targetPath = join(cwd, "scripts", "dossier-cypress.mjs");
|
|
808
|
+
const digest = createHash("sha256").update(template).digest("hex");
|
|
809
|
+
|
|
810
|
+
const describeExisting = async () => {
|
|
811
|
+
let existing;
|
|
812
|
+
try {
|
|
813
|
+
existing = await readFile(targetPath, "utf8");
|
|
814
|
+
} catch (error) {
|
|
815
|
+
if (error && error.code === "ENOENT") return false;
|
|
816
|
+
throw new CliError(
|
|
817
|
+
`${CYPRESS_HELPER_PATH} already exists but could not be read; left unchanged: ${error && error.message}`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (existing !== template) {
|
|
821
|
+
throw new CliError(
|
|
822
|
+
`${CYPRESS_HELPER_PATH} already exists and differs from the testdossier ${VERSION} template; ` +
|
|
823
|
+
"left unchanged. Review or remove it before trying again.",
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
stdout(`${CYPRESS_HELPER_PATH} already matches testdossier ${VERSION}; left unchanged.`);
|
|
827
|
+
stdout(`SHA-256 ${digest}`);
|
|
828
|
+
return true;
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
if (await describeExisting()) return 0;
|
|
832
|
+
try {
|
|
833
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
834
|
+
await writeFile(targetPath, template, { flag: "wx", mode: 0o644 });
|
|
835
|
+
} catch (error) {
|
|
836
|
+
// Another process may have created the fixed target after our first read.
|
|
837
|
+
// Re-check it rather than overwriting or reporting a false failure.
|
|
838
|
+
if (error && error.code === "EEXIST" && await describeExisting()) return 0;
|
|
839
|
+
throw new CliError(`could not create ${CYPRESS_HELPER_PATH}: ${error && error.message}`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
stdout(`Created ${CYPRESS_HELPER_PATH} from installed testdossier ${VERSION}.`);
|
|
843
|
+
stdout(`SHA-256 ${digest}`);
|
|
844
|
+
stdout([
|
|
845
|
+
"Review the generated file before committing it, then add this script to package.json:",
|
|
846
|
+
' "cy:testdossier": "node scripts/dossier-cypress.mjs --output cypress-results.json"',
|
|
847
|
+
].join("\n"));
|
|
848
|
+
return 0;
|
|
849
|
+
}
|
|
850
|
+
|
|
522
851
|
function normalizedHeader(value, name) {
|
|
523
852
|
if (value == null || value === "") return "";
|
|
524
853
|
const normalized = String(value);
|
|
@@ -805,6 +1134,7 @@ function helpText() {
|
|
|
805
1134
|
|
|
806
1135
|
Usage:
|
|
807
1136
|
testdossier init One-time setup: create ${ENV_FILE_NAME} and gitignore it
|
|
1137
|
+
testdossier setup cypress Create a reviewable Cypress helper without overwriting files
|
|
808
1138
|
testdossier verify Check the token and destination without sending a report
|
|
809
1139
|
testdossier upload <report-file> [options]
|
|
810
1140
|
testdossier <report-file> [options] "upload" may be omitted
|
|
@@ -845,6 +1175,7 @@ Large reports:
|
|
|
845
1175
|
|
|
846
1176
|
Examples:
|
|
847
1177
|
npx testdossier init
|
|
1178
|
+
npx testdossier setup cypress
|
|
848
1179
|
npx testdossier verify
|
|
849
1180
|
npx testdossier upload playwright-results.json
|
|
850
1181
|
npx testdossier upload junit-results.xml --build abc123
|
|
@@ -909,6 +1240,12 @@ export async function runCli(
|
|
|
909
1240
|
if (args.length > 1) throw new CliError("init takes no arguments.");
|
|
910
1241
|
return await runInit({ cwd, stdout });
|
|
911
1242
|
}
|
|
1243
|
+
if (args[0] === "setup") {
|
|
1244
|
+
if (args.length !== 2 || args[1] !== "cypress") {
|
|
1245
|
+
throw new CliError("setup requires exactly one supported target: cypress.");
|
|
1246
|
+
}
|
|
1247
|
+
return await runCypressSetup({ cwd, stdout });
|
|
1248
|
+
}
|
|
912
1249
|
if (args[0] === "verify") {
|
|
913
1250
|
const options = parseUploadArgs(args.slice(1));
|
|
914
1251
|
if (options.help) {
|
|
@@ -981,13 +1318,26 @@ export async function runCli(
|
|
|
981
1318
|
const reportText = report.toString("utf8");
|
|
982
1319
|
const requestedFormat = options.format !== "auto" ? options.format : (config && config.format) || "auto";
|
|
983
1320
|
const { format, document } = analyzeReport(reportText, requestedFormat);
|
|
984
|
-
|
|
985
|
-
|
|
1321
|
+
let playwrightUpload = null;
|
|
1322
|
+
let pages;
|
|
1323
|
+
if (format === "playwright" && document) {
|
|
1324
|
+
playwrightUpload = await planPlaywrightUploadPages(document, options.reportPath);
|
|
1325
|
+
pages = playwrightUpload.pages;
|
|
1326
|
+
if (playwrightUpload.inlined || playwrightUpload.skipped) {
|
|
1327
|
+
stdout(`Prepared ${playwrightUpload.inlined} failure screenshot${playwrightUpload.inlined === 1 ? "" : "s"} for durable CI evidence${playwrightUpload.skipped ? ` (${playwrightUpload.skipped} skipped)` : ""}.`);
|
|
1328
|
+
}
|
|
1329
|
+
} else {
|
|
1330
|
+
pages = splitReportPages(format, report, MAX_TESTS_PER_PAGE, document);
|
|
1331
|
+
}
|
|
1332
|
+
const testCount = pages.reduce((sum, page) => sum + (page.testCount ?? page.tests), 0);
|
|
1333
|
+
const validatedBytes = playwrightUpload
|
|
1334
|
+
? pages.reduce((sum, page) => sum + page.estimatedBytes, 0)
|
|
1335
|
+
: report.byteLength;
|
|
986
1336
|
|
|
987
1337
|
const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
|
|
988
1338
|
const destination = new URL(endpoint).origin;
|
|
989
1339
|
stdout(
|
|
990
|
-
`Validated ${FORMAT_LABELS[format]} (${
|
|
1340
|
+
`Validated ${FORMAT_LABELS[format]} (${validatedBytes} bytes, ${testCount} tests` +
|
|
991
1341
|
`${pages.length > 1 ? ` across ${pages.length} pages` : ""}).`,
|
|
992
1342
|
);
|
|
993
1343
|
if (options.dryRun) {
|
|
@@ -1014,7 +1364,7 @@ export async function runCli(
|
|
|
1014
1364
|
|
|
1015
1365
|
if (pages.length > 1) {
|
|
1016
1366
|
stdout(
|
|
1017
|
-
`Uploading ${pages.reduce((sum, page) => sum + (page.tests
|
|
1367
|
+
`Uploading ${pages.reduce((sum, page) => sum + (page.testCount ?? page.tests ?? 0), 0)} tests in ` +
|
|
1018
1368
|
`${pages.length} pages (run ${metadata.runId}).`,
|
|
1019
1369
|
);
|
|
1020
1370
|
}
|
|
@@ -1026,6 +1376,12 @@ export async function runCli(
|
|
|
1026
1376
|
const pageResults = [];
|
|
1027
1377
|
for (let index = 0; index < pages.length; index += 1) {
|
|
1028
1378
|
const page = pages[index];
|
|
1379
|
+
const materialized = playwrightUpload
|
|
1380
|
+
? await materializePlaywrightUploadPage(document, page, playwrightUpload.evidenceByTest)
|
|
1381
|
+
: null;
|
|
1382
|
+
if (materialized?.skipped) {
|
|
1383
|
+
stderr(`Warning: ${materialized.skipped} failure screenshot${materialized.skipped === 1 ? " was" : "s were"} no longer readable and were skipped.`);
|
|
1384
|
+
}
|
|
1029
1385
|
const result = await postWithRetry({
|
|
1030
1386
|
endpoint,
|
|
1031
1387
|
headers: {
|
|
@@ -1035,14 +1391,14 @@ export async function runCli(
|
|
|
1035
1391
|
"X-CI-Page-Index": String(index + 1),
|
|
1036
1392
|
"X-CI-Page-Count": String(pages.length),
|
|
1037
1393
|
},
|
|
1038
|
-
body: page.body,
|
|
1394
|
+
body: materialized ? materialized.body : page.body,
|
|
1039
1395
|
env,
|
|
1040
1396
|
fetchImpl,
|
|
1041
1397
|
stderr,
|
|
1042
1398
|
});
|
|
1043
1399
|
pageResults.push(result);
|
|
1044
1400
|
if (pages.length > 1) {
|
|
1045
|
-
stdout(`Uploaded page ${index + 1}/${pages.length} (${page.tests} tests).`);
|
|
1401
|
+
stdout(`Uploaded page ${index + 1}/${pages.length} (${page.testCount ?? page.tests} tests).`);
|
|
1046
1402
|
}
|
|
1047
1403
|
}
|
|
1048
1404
|
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.0",
|
|
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;
|