kwikqa 0.0.0-stage → 0.1.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.
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+ // KwikQA's Cypress reporter (KQA-081). A Mocha reporter, loaded by Cypress in its Node process:
3
+ //
4
+ // reporter: "node_modules/kwikqa/dist/cypress-reporter.cjs"
5
+ //
6
+ // It writes one `kwikqa-test-report` JSON per spec run into `test-results/kwikqa/` (or
7
+ // `reporterOptions.outputDir`, or $KWIKQA_REPORT_DIR), keeping what other Cypress reporters drop:
8
+ // every retry attempt, the full title path, where each test is declared, and each error's code
9
+ // frame and expected/actual values. `npx kwikqa analyze --report test-results/kwikqa` reads it.
10
+ // This file has no dependencies and changes nothing about how tests run.
11
+
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+
15
+ const SCHEMA = "kwikqa-test-report";
16
+ const SCHEMA_VERSION = "1.0";
17
+ const CAPTURE_TYPE = "application/vnd.kwikqa.capture+json";
18
+ const MAX_SCREENSHOT_FILES = 5000;
19
+
20
+ function fnv1a(text) {
21
+ let hash = 0x811c9dc5;
22
+ for (const byte of new TextEncoder().encode(text)) {
23
+ hash ^= byte;
24
+ hash = Math.imul(hash, 0x01000193) >>> 0;
25
+ }
26
+ return hash.toString(16).padStart(8, "0");
27
+ }
28
+
29
+ /** Must match the key cypress-capture.js writes a failure capture under. */
30
+ function captureKey(spec, titlePath, retry) {
31
+ const slug = String(spec)
32
+ .replace(/[^A-Za-z0-9]+/g, "_")
33
+ .slice(-60);
34
+ return `${slug}--${fnv1a(`${spec}\u0000${titlePath.join("\u0000")}`)}--${retry}`;
35
+ }
36
+
37
+ function text(value) {
38
+ if (value === undefined) return undefined;
39
+ if (typeof value === "string") return value;
40
+ try {
41
+ return JSON.stringify(value);
42
+ } catch {
43
+ return String(value);
44
+ }
45
+ }
46
+
47
+ function convertError(err) {
48
+ if (err === undefined || err === null) return undefined;
49
+ const out = { message: String(err.message !== undefined ? err.message : err) };
50
+ if (err.name) out.name = String(err.name);
51
+ if (err.stack) out.stack = String(err.stack);
52
+ const frame = err.codeFrame;
53
+ if (frame && (frame.relativeFile || frame.originalFile) && frame.line) {
54
+ out.location = {
55
+ file: String(frame.relativeFile || frame.originalFile),
56
+ line: Number(frame.line),
57
+ column: Number(frame.column || 1),
58
+ };
59
+ if (frame.frame) out.snippet = String(frame.frame);
60
+ }
61
+ if (err.showDiff !== false && (err.actual !== undefined || err.expected !== undefined)) {
62
+ out.actual = text(err.actual);
63
+ out.expected = text(err.expected);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function titlePathOf(runnable) {
69
+ if (typeof runnable.titlePath === "function") return runnable.titlePath();
70
+ const titles = [];
71
+ for (let current = runnable; current; current = current.parent) {
72
+ if (current.title) titles.unshift(current.title);
73
+ }
74
+ return titles;
75
+ }
76
+
77
+ function frameworkVersion(cwd) {
78
+ try {
79
+ const manifest = require.resolve("cypress/package.json", { paths: [cwd] });
80
+ return JSON.parse(fs.readFileSync(manifest, "utf8")).version;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ function configFile(cwd) {
87
+ for (const extension of ["ts", "js", "mjs", "cjs", "mts", "cts"]) {
88
+ const candidate = path.join(cwd, `cypress.config.${extension}`);
89
+ if (fs.existsSync(candidate)) return candidate;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ function screenshotIndex(folder) {
95
+ const found = new Map();
96
+ let seen = 0;
97
+ const walk = (directory, depth) => {
98
+ if (depth > 6 || seen > MAX_SCREENSHOT_FILES) return;
99
+ let entries;
100
+ try {
101
+ entries = fs.readdirSync(directory, { withFileTypes: true });
102
+ } catch {
103
+ return;
104
+ }
105
+ for (const entry of entries) {
106
+ seen += 1;
107
+ const full = path.join(directory, entry.name);
108
+ if (entry.isDirectory()) walk(full, depth + 1);
109
+ else if (/\.png$/i.test(entry.name)) {
110
+ const list = found.get(entry.name) || [];
111
+ list.push(full);
112
+ found.set(entry.name, list);
113
+ }
114
+ }
115
+ };
116
+ walk(folder, 0);
117
+ return found;
118
+ }
119
+
120
+ function KwikqaCypressReporter(runner, options) {
121
+ const settings = options?.reporterOptions || {};
122
+ const cwd = process.cwd();
123
+ const outputDir = path.resolve(
124
+ cwd,
125
+ process.env.KWIKQA_REPORT_DIR || settings.outputDir || "test-results/kwikqa",
126
+ );
127
+ const captureDir = process.env.KWIKQA_CAPTURE_DIR || settings.captureDir;
128
+ const screenshotsFolder = path.resolve(
129
+ cwd,
130
+ process.env.KWIKQA_SCREENSHOTS_DIR || settings.screenshotsFolder || "cypress/screenshots",
131
+ );
132
+ const startedAt = new Date();
133
+ const tests = new Map();
134
+ const errors = [];
135
+ const specs = new Set();
136
+ let currentSpec = "";
137
+ let interrupted = false;
138
+
139
+ const recordFor = (test) => {
140
+ const titlePath = titlePathOf(test);
141
+ const details = test.invocationDetails || {};
142
+ const file = String(details.relativeFile || test.file || currentSpec || "");
143
+ const key = `${file}\u0000${titlePath.join("\u0000")}`;
144
+ let entry = tests.get(key);
145
+ if (!entry) {
146
+ entry = {
147
+ file,
148
+ titlePath,
149
+ project: "",
150
+ attempts: [],
151
+ };
152
+ if (details.line) {
153
+ entry.line = Number(details.line);
154
+ entry.column = Number(details.column || 1);
155
+ }
156
+ if (typeof test._timeout === "number") entry.timeout = test._timeout;
157
+ tests.set(key, entry);
158
+ }
159
+ return entry;
160
+ };
161
+ const open = (entry, retry) => {
162
+ const attempt = {
163
+ status: "interrupted",
164
+ retry,
165
+ startTime: new Date().toISOString(),
166
+ duration: 0,
167
+ errors: [],
168
+ attachments: [],
169
+ };
170
+ entry.attempts.push(attempt);
171
+ return attempt;
172
+ };
173
+ const current = (entry) => {
174
+ const last = entry.attempts[entry.attempts.length - 1];
175
+ return last && last.status === "interrupted" ? last : open(entry, entry.attempts.length);
176
+ };
177
+ const close = (entry, status, err, duration) => {
178
+ const attempt = current(entry);
179
+ attempt.status = status;
180
+ attempt.duration =
181
+ typeof duration === "number" ? duration : Date.now() - Date.parse(attempt.startTime);
182
+ const converted = convertError(err);
183
+ if (converted) attempt.errors.push(converted);
184
+ };
185
+
186
+ runner.on("suite", (suite) => {
187
+ if (suite.root && suite.file) {
188
+ currentSpec = String(suite.file);
189
+ specs.add(currentSpec);
190
+ }
191
+ });
192
+ // The test Cypress hands to `retry` is a detached copy without its parent suites, so the
193
+ // attempt is filed under the test that is running, as `test` announced it.
194
+ let running;
195
+ runner.on("test", (test) => {
196
+ running = recordFor(test);
197
+ open(running, 0);
198
+ });
199
+ runner.on("retry", (test, err) => {
200
+ const entry =
201
+ running && running.titlePath[running.titlePath.length - 1] === test.title
202
+ ? running
203
+ : recordFor(test);
204
+ close(entry, "failed", err || test.err, test.duration);
205
+ open(entry, entry.attempts.length);
206
+ });
207
+ runner.on("pass", (test) => close(recordFor(test), "passed", undefined, test.duration));
208
+ runner.on("pending", (test) => {
209
+ const entry = recordFor(test);
210
+ entry.attempts = [{ status: "skipped", retry: 0, duration: 0, errors: [], attachments: [] }];
211
+ });
212
+ runner.on("fail", (runnable, err) => {
213
+ if (runnable && runnable.type === "hook") {
214
+ const test = runnable.ctx?.currentTest;
215
+ const hookError = convertError(err) || { message: "hook failed" };
216
+ hookError.message = `${runnable.title}: ${hookError.message}`;
217
+ if (test) {
218
+ const entry = recordFor(test);
219
+ const attempt = current(entry);
220
+ attempt.status = "failed";
221
+ attempt.errors.push(hookError);
222
+ } else {
223
+ errors.push(hookError);
224
+ }
225
+ return;
226
+ }
227
+ if (!runnable) return;
228
+ const timedOut =
229
+ runnable.timedOut === true || /^Timeout of \d+ms exceeded/.test(String(err?.message));
230
+ close(recordFor(runnable), timedOut ? "timedOut" : "failed", err, runnable.duration);
231
+ });
232
+ runner.on("end", () => {
233
+ const shots = screenshotIndex(screenshotsFolder);
234
+ for (const entry of tests.values()) {
235
+ for (const attempt of entry.attempts) {
236
+ if (attempt.status !== "failed" && attempt.status !== "timedOut") continue;
237
+ const name = `${entry.titlePath.join(" -- ")} (failed)${attempt.retry > 0 ? ` (attempt ${attempt.retry + 1})` : ""}.png`;
238
+ for (const shot of shots.get(name) || []) {
239
+ attempt.attachments.push({ name: "screenshot", contentType: "image/png", path: shot });
240
+ }
241
+ if (captureDir) {
242
+ const capture = path.resolve(
243
+ cwd,
244
+ captureDir,
245
+ `${captureKey(entry.file, entry.titlePath, attempt.retry)}.json`,
246
+ );
247
+ if (fs.existsSync(capture)) {
248
+ attempt.attachments.push({
249
+ name: "kwikqa-capture",
250
+ contentType: CAPTURE_TYPE,
251
+ path: capture,
252
+ });
253
+ }
254
+ }
255
+ }
256
+ if (entry.attempts.some((attempt) => attempt.status === "interrupted")) interrupted = true;
257
+ }
258
+ const version = frameworkVersion(cwd);
259
+ const config = configFile(cwd);
260
+ const report = {
261
+ schema: SCHEMA,
262
+ schemaVersion: SCHEMA_VERSION,
263
+ framework: "cypress",
264
+ ...(version === undefined ? {} : { frameworkVersion: version }),
265
+ rootDir: cwd,
266
+ ...(config === undefined ? {} : { configFile: config }),
267
+ startTime: startedAt.toISOString(),
268
+ duration: Date.now() - startedAt.getTime(),
269
+ interrupted,
270
+ projects: [{ id: "", name: "", retries: 0 }],
271
+ tests: [...tests.values()],
272
+ errors,
273
+ };
274
+ const specKey = [...specs].sort().join("\u0000") || "run";
275
+ const fileName = `cypress-${specKey.replace(/[^A-Za-z0-9]+/g, "_").slice(-50)}-${fnv1a(specKey)}.json`;
276
+ fs.mkdirSync(outputDir, { recursive: true });
277
+ fs.writeFileSync(path.join(outputDir, fileName), `${JSON.stringify(report, null, 2)}\n`);
278
+ });
279
+ }
280
+
281
+ module.exports = KwikqaCypressReporter;
282
+ module.exports.captureKey = captureKey;
@@ -0,0 +1,20 @@
1
+ import type { EventEmitter } from "node:events";
2
+
3
+ /** KwikQA's Cypress reporter: `reporter: "node_modules/kwikqa/dist/cypress-reporter.cjs"`. */
4
+ declare function KwikqaCypressReporter(
5
+ runner: EventEmitter,
6
+ options?: {
7
+ readonly reporterOptions?: {
8
+ readonly outputDir?: string;
9
+ readonly captureDir?: string;
10
+ readonly screenshotsFolder?: string;
11
+ };
12
+ },
13
+ ): void;
14
+
15
+ declare namespace KwikqaCypressReporter {
16
+ /** The file name a failure capture of one attempt is written under. */
17
+ function captureKey(spec: string, titlePath: readonly string[], retry: number): string;
18
+ }
19
+
20
+ export = KwikqaCypressReporter;