testdossier 0.2.2 → 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 +48 -5
- package/assets/mark.svg +5 -0
- package/lib/testdossier.mjs +372 -15
- package/package.json +15 -3
- package/templates/dossier-cypress.mjs +88 -0
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/devkalu/test-dossier/main/cli/assets/mark.svg" width="44" height="44" alt="TestDossier">
|
|
2
|
+
|
|
1
3
|
# TestDossier CLI
|
|
2
4
|
|
|
5
|
+
**Publish test evidence from CI without adding another agent, daemon, or hosted runner.**
|
|
6
|
+
|
|
3
7
|
Upload a completed test report from a developer laptop, test machine, or CI
|
|
4
8
|
job. Run these commands in the project containing your automated tests:
|
|
5
9
|
|
|
6
10
|
1. Install the latest CLI in the project. To pin a release instead, replace
|
|
7
|
-
`@latest` with an exact version such as `@0.
|
|
11
|
+
`@latest` with an exact version such as `@0.3.0`:
|
|
8
12
|
|
|
9
13
|
```bash
|
|
10
14
|
npm install --save-dev testdossier@latest
|
|
@@ -66,9 +70,11 @@ requires Node.js 18 or newer.
|
|
|
66
70
|
|
|
67
71
|
## Why this is intentionally small
|
|
68
72
|
|
|
69
|
-
The
|
|
70
|
-
request to TestDossier. It does not run tests, accept remote commands,
|
|
71
|
-
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.
|
|
72
78
|
|
|
73
79
|
The access token is read from the first available source: an explicitly passed
|
|
74
80
|
`--env-file`, the `TD_CI_TOKEN` process environment, then `.env.testdossier` in
|
|
@@ -104,6 +110,43 @@ creates an ambiguous shape, pass `--format playwright`, `--format cypress`,
|
|
|
104
110
|
`--format cucumber`, `--format junit`, or `--format generic`. The `upload`
|
|
105
111
|
word may be omitted: `npx testdossier playwright-results.json` works too.
|
|
106
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
|
+
|
|
107
150
|
## Local setup
|
|
108
151
|
|
|
109
152
|
For repeat use, install and pin the CLI in the test repository so its source
|
|
@@ -114,7 +157,7 @@ npm install --save-dev testdossier@latest
|
|
|
114
157
|
npx testdossier init
|
|
115
158
|
```
|
|
116
159
|
|
|
117
|
-
Use `npm install --save-dev testdossier@0.
|
|
160
|
+
Use `npm install --save-dev testdossier@0.3.0` when the repository should pin
|
|
118
161
|
that exact release. In either case, subsequent commands remain
|
|
119
162
|
`npx testdossier ...`; the version selector belongs only to installation.
|
|
120
163
|
|
package/assets/mark.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TestDossier">
|
|
2
|
+
<title>TestDossier</title>
|
|
3
|
+
<path fill="#006b61" fill-rule="evenodd" d="M20 6h14c17 0 28 10 28 26S51 58 34 58H20V6Zm14 14v24c9 0 14-4 14-12s-5-12-14-12Z"/>
|
|
4
|
+
<path fill="#006b61" d="M2 6h32v14H2z"/>
|
|
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);
|
|
@@ -800,10 +1129,12 @@ async function postWithRetry({ endpoint, headers, body, env, fetchImpl, stderr,
|
|
|
800
1129
|
}
|
|
801
1130
|
|
|
802
1131
|
function helpText() {
|
|
803
|
-
return `TestDossier
|
|
1132
|
+
return `TD TestDossier
|
|
1133
|
+
CI report uploader · ${VERSION}
|
|
804
1134
|
|
|
805
1135
|
Usage:
|
|
806
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
|
|
807
1138
|
testdossier verify Check the token and destination without sending a report
|
|
808
1139
|
testdossier upload <report-file> [options]
|
|
809
1140
|
testdossier <report-file> [options] "upload" may be omitted
|
|
@@ -844,6 +1175,7 @@ Large reports:
|
|
|
844
1175
|
|
|
845
1176
|
Examples:
|
|
846
1177
|
npx testdossier init
|
|
1178
|
+
npx testdossier setup cypress
|
|
847
1179
|
npx testdossier verify
|
|
848
1180
|
npx testdossier upload playwright-results.json
|
|
849
1181
|
npx testdossier upload junit-results.xml --build abc123
|
|
@@ -908,6 +1240,12 @@ export async function runCli(
|
|
|
908
1240
|
if (args.length > 1) throw new CliError("init takes no arguments.");
|
|
909
1241
|
return await runInit({ cwd, stdout });
|
|
910
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
|
+
}
|
|
911
1249
|
if (args[0] === "verify") {
|
|
912
1250
|
const options = parseUploadArgs(args.slice(1));
|
|
913
1251
|
if (options.help) {
|
|
@@ -980,13 +1318,26 @@ export async function runCli(
|
|
|
980
1318
|
const reportText = report.toString("utf8");
|
|
981
1319
|
const requestedFormat = options.format !== "auto" ? options.format : (config && config.format) || "auto";
|
|
982
1320
|
const { format, document } = analyzeReport(reportText, requestedFormat);
|
|
983
|
-
|
|
984
|
-
|
|
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;
|
|
985
1336
|
|
|
986
1337
|
const endpoint = normalizeEndpoint(options.url || env.TD_URL || (config && config.url) || DEFAULT_ORIGIN);
|
|
987
1338
|
const destination = new URL(endpoint).origin;
|
|
988
1339
|
stdout(
|
|
989
|
-
`Validated ${FORMAT_LABELS[format]} (${
|
|
1340
|
+
`Validated ${FORMAT_LABELS[format]} (${validatedBytes} bytes, ${testCount} tests` +
|
|
990
1341
|
`${pages.length > 1 ? ` across ${pages.length} pages` : ""}).`,
|
|
991
1342
|
);
|
|
992
1343
|
if (options.dryRun) {
|
|
@@ -1013,7 +1364,7 @@ export async function runCli(
|
|
|
1013
1364
|
|
|
1014
1365
|
if (pages.length > 1) {
|
|
1015
1366
|
stdout(
|
|
1016
|
-
`Uploading ${pages.reduce((sum, page) => sum + (page.tests
|
|
1367
|
+
`Uploading ${pages.reduce((sum, page) => sum + (page.testCount ?? page.tests ?? 0), 0)} tests in ` +
|
|
1017
1368
|
`${pages.length} pages (run ${metadata.runId}).`,
|
|
1018
1369
|
);
|
|
1019
1370
|
}
|
|
@@ -1025,6 +1376,12 @@ export async function runCli(
|
|
|
1025
1376
|
const pageResults = [];
|
|
1026
1377
|
for (let index = 0; index < pages.length; index += 1) {
|
|
1027
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
|
+
}
|
|
1028
1385
|
const result = await postWithRetry({
|
|
1029
1386
|
endpoint,
|
|
1030
1387
|
headers: {
|
|
@@ -1034,14 +1391,14 @@ export async function runCli(
|
|
|
1034
1391
|
"X-CI-Page-Index": String(index + 1),
|
|
1035
1392
|
"X-CI-Page-Count": String(pages.length),
|
|
1036
1393
|
},
|
|
1037
|
-
body: page.body,
|
|
1394
|
+
body: materialized ? materialized.body : page.body,
|
|
1038
1395
|
env,
|
|
1039
1396
|
fetchImpl,
|
|
1040
1397
|
stderr,
|
|
1041
1398
|
});
|
|
1042
1399
|
pageResults.push(result);
|
|
1043
1400
|
if (pages.length > 1) {
|
|
1044
|
-
stdout(`Uploaded page ${index + 1}/${pages.length} (${page.tests} tests).`);
|
|
1401
|
+
stdout(`Uploaded page ${index + 1}/${pages.length} (${page.testCount ?? page.tests} tests).`);
|
|
1045
1402
|
}
|
|
1046
1403
|
}
|
|
1047
1404
|
stdout(`Uploaded ${FORMAT_LABELS[format]} to ${destination} (run ${metadata.runId}).`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testdossier",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Upload
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Upload Playwright, Cypress, Cucumber, JUnit, and JSON test evidence to TestDossier.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"lib",
|
|
13
|
+
"templates",
|
|
14
|
+
"assets",
|
|
13
15
|
"README.md",
|
|
14
16
|
"LICENSE",
|
|
15
17
|
".env.testdossier.example"
|
|
@@ -18,7 +20,13 @@
|
|
|
18
20
|
"node": ">=18"
|
|
19
21
|
},
|
|
20
22
|
"homepage": "https://testdossier.com",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/devkalu/test-dossier.git",
|
|
26
|
+
"directory": "cli"
|
|
27
|
+
},
|
|
21
28
|
"bugs": {
|
|
29
|
+
"url": "https://github.com/devkalu/test-dossier/issues",
|
|
22
30
|
"email": "support@testdossier.com"
|
|
23
31
|
},
|
|
24
32
|
"keywords": [
|
|
@@ -27,7 +35,11 @@
|
|
|
27
35
|
"playwright",
|
|
28
36
|
"cypress",
|
|
29
37
|
"cucumber",
|
|
30
|
-
"testdossier"
|
|
38
|
+
"testdossier",
|
|
39
|
+
"test-management",
|
|
40
|
+
"test-evidence",
|
|
41
|
+
"continuous-integration",
|
|
42
|
+
"qa"
|
|
31
43
|
],
|
|
32
44
|
"publishConfig": {
|
|
33
45
|
"access": "public"
|
|
@@ -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;
|