kwikqa 0.0.0-stage → 0.1.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/LICENSE +122 -0
- package/README.md +240 -2
- package/dist/THIRD_PARTY_NOTICES.md +54 -0
- package/dist/cypress-capture.js +190 -0
- package/dist/cypress-reporter.cjs +282 -0
- package/dist/cypress-reporter.d.cts +20 -0
- package/dist/kwikqa.mjs +127 -0
- package/dist/parse-check.mjs +11 -0
- package/dist/pnp-resolver.mjs +5 -0
- package/dist/wdio-capture.d.mts +11 -0
- package/dist/wdio-capture.mjs +261 -0
- package/dist/wdio-reporter.d.mts +12 -0
- package/dist/wdio-reporter.mjs +243 -0
- package/package.json +60 -4
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { createRequire as __kwikqaCreateRequire } from "node:module";const require=__kwikqaCreateRequire(import.meta.url);
|
|
2
|
+
import{createHash as k}from"node:crypto";import{lstatSync as v,readFileSync as j,realpathSync as s,statSync as y}from"node:fs";import u,{createRequire as E}from"node:module";import{dirname as L,isAbsolute as w,relative as M,sep as x}from"node:path";var F=16*1024*1024,A=/^[a-f0-9]{64}$/,I=new Map([["@playwright/test",["@playwright/test/cli"]],["@biomejs/biome",["@biomejs/biome/bin/biome"]],["prettier",["prettier/bin/prettier.cjs","prettier/bin/prettier.js"]],["typescript",["typescript/bin/tsc"]],["eslint",["eslint/bin/eslint.js"]]]);function g(t,e){let r=M(t,e);return r===""||!w(r)&&r!==".."&&!r.startsWith(`..${x}`)}var[d,i,a,p,f,...N]=process.argv.slice(2),b=I.get(f);if(N.length!==0||typeof d!="string"||typeof i!="string"||typeof a!="string"||typeof p!="string"||typeof f!="string"||[d,i,p].some(t=>!w(t)||t.includes("\0"))||!A.test(a)||b===void 0)process.stderr.write(`invalid resolver request
|
|
3
|
+
`),process.exitCode=2;else try{let t=s(d),e=s(i),r=s(p),o=v(i);if(e!==i||o.isSymbolicLink()||!o.isFile()||o.size>F||!g(t,e)||!g(t,r))throw new Error("loader or issuer is unauthorized");let c=j(e);if(c.byteLength!==o.size||k("sha256").update(c).digest("hex")!==a)throw new Error("loader identity changed");let l=new u(e);l.filename=e,l.paths=u._nodeModulePaths(L(e)),l._compile(c.toString("utf8"),e);let h=E(r),m=s(h.resolve(`${f}/package.json`)),n;for(let S of b)try{n=s(h.resolve(S));break}catch{}if(n===void 0||!y(m).isFile()||!y(n).isFile())throw new Error("resolved package files are unavailable");process.stdout.write(`${JSON.stringify({manifestPath:m,entryPath:n,loaderSha256:a})}
|
|
4
|
+
`)}catch{process.stderr.write(`package resolution failed
|
|
5
|
+
`),process.exitCode=1}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface KwikqaRunSettings {
|
|
2
|
+
readonly rootDir?: string;
|
|
3
|
+
readonly reportDir?: string;
|
|
4
|
+
readonly captureDir?: string;
|
|
5
|
+
readonly grep?: string;
|
|
6
|
+
readonly noRetries?: boolean;
|
|
7
|
+
readonly keepReporters?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The project's WebdriverIO config with KwikQA's reporter, and its capture hooks when asked. */
|
|
11
|
+
export function withKwikqa<T extends object>(base: T, settings?: KwikqaRunSettings): T;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// KwikQA's WebdriverIO run wrapper (KQA-081). `npx kwikqa analyze --capture` and `kwikqa
|
|
2
|
+
// validate` run WebdriverIO through a generated config that imports the project's own wdio.conf
|
|
3
|
+
// and passes it through withKwikqa(), which only adds to it:
|
|
4
|
+
//
|
|
5
|
+
// - KwikQA's reporter, so every attempt is recorded with its full suite path;
|
|
6
|
+
// - with `capture`, hooks that record each failed attempt's page: the DOM (as KwikQA's snapshot
|
|
7
|
+
// tree), a screenshot, the WebdriverIO commands the test ran, and, over WebDriver BiDi, console
|
|
8
|
+
// messages and network responses;
|
|
9
|
+
// - with `grep`, a Mocha or Jasmine filter that runs only the named test.
|
|
10
|
+
//
|
|
11
|
+
// The project's capabilities, services, and its own hooks all still run, in their order.
|
|
12
|
+
/* global browser */
|
|
13
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join, relative, resolve } from "node:path";
|
|
15
|
+
import KwikqaWdioReporter from "./wdio-reporter.mjs";
|
|
16
|
+
|
|
17
|
+
const MAX_LOG = 300;
|
|
18
|
+
const MAX_TEXT = 2000;
|
|
19
|
+
/**
|
|
20
|
+
* WebDriver and BiDi protocol calls WebdriverIO makes on a command's behalf. The log keeps the
|
|
21
|
+
* commands the test itself called, such as `$`, `click`, and `waitForExist`.
|
|
22
|
+
*/
|
|
23
|
+
const PROTOCOL_COMMAND =
|
|
24
|
+
/^(?:browsingContext|script|session|network|storage|input|log|emulation|webExtension)[A-Z]|^(?:element[A-Z]\w*|getElement\w*|findElements?\w*|navigateTo|executeScript|executeAsyncScript|takeScreenshot|takeElementScreenshot|getWindowHandles?|switchToFrame|switchToParentFrame|getTitle|getUrl|getPageSource|performActions|releaseActions)$/;
|
|
25
|
+
|
|
26
|
+
function asArray(hook) {
|
|
27
|
+
if (hook === undefined || hook === null) return [];
|
|
28
|
+
return Array.isArray(hook) ? hook : [hook];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fnv1a(text) {
|
|
32
|
+
let hash = 0x811c9dc5;
|
|
33
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
34
|
+
hash ^= byte;
|
|
35
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
36
|
+
}
|
|
37
|
+
return hash.toString(16).padStart(8, "0");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function summarize(value) {
|
|
41
|
+
if (value === undefined) return "";
|
|
42
|
+
if (typeof value === "string") return value.slice(0, 200);
|
|
43
|
+
try {
|
|
44
|
+
return JSON.stringify(value).slice(0, 200);
|
|
45
|
+
} catch {
|
|
46
|
+
return String(value).slice(0, 200);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Runs in the page: the DOM as `[TAG, {attributes}, ...children]`, text as strings. */
|
|
51
|
+
function serializePage(maxNodes, maxText) {
|
|
52
|
+
const skipped = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "LINK", "META"]);
|
|
53
|
+
const FORM_FIELDS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
|
54
|
+
let budget = maxNodes;
|
|
55
|
+
const visit = (node) => {
|
|
56
|
+
if (budget <= 0) return undefined;
|
|
57
|
+
if (node.nodeType === 3) {
|
|
58
|
+
const value = node.textContent || "";
|
|
59
|
+
return value.trim() === "" ? undefined : value.slice(0, maxText);
|
|
60
|
+
}
|
|
61
|
+
if (node.nodeType !== 1 || skipped.has(node.tagName)) return undefined;
|
|
62
|
+
budget -= 1;
|
|
63
|
+
const attributes = {};
|
|
64
|
+
for (const attribute of Array.from(node.attributes)) {
|
|
65
|
+
attributes[attribute.name] = String(attribute.value).slice(0, maxText);
|
|
66
|
+
}
|
|
67
|
+
if (FORM_FIELDS.has(node.tagName) && typeof node.value === "string") {
|
|
68
|
+
attributes.__playwright_value_ = node.value.slice(0, maxText);
|
|
69
|
+
}
|
|
70
|
+
if (node.checked === true) attributes.__playwright_checked_ = "true";
|
|
71
|
+
const children = [];
|
|
72
|
+
const childNodes = node.shadowRoot
|
|
73
|
+
? [...Array.from(node.shadowRoot.childNodes), ...Array.from(node.childNodes)]
|
|
74
|
+
: Array.from(node.childNodes);
|
|
75
|
+
for (const child of childNodes) {
|
|
76
|
+
const value = visit(child);
|
|
77
|
+
if (value !== undefined) children.push(value);
|
|
78
|
+
}
|
|
79
|
+
return [node.tagName, attributes, ...children];
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
url: location.href,
|
|
83
|
+
title: document.title,
|
|
84
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
85
|
+
dom: visit(document.documentElement),
|
|
86
|
+
truncated: budget <= 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function withKwikqa(base, settings = {}) {
|
|
91
|
+
const cwd = process.cwd();
|
|
92
|
+
const captureDir = settings.captureDir ? resolve(cwd, settings.captureDir) : undefined;
|
|
93
|
+
const state = { commands: [], console: [], network: [], capturing: false };
|
|
94
|
+
const framework = base.framework || "mocha";
|
|
95
|
+
const config = {
|
|
96
|
+
...base,
|
|
97
|
+
...(settings.rootDir ? { rootDir: settings.rootDir } : {}),
|
|
98
|
+
reporters: [
|
|
99
|
+
...(settings.keepReporters ? asArray(base.reporters) : []),
|
|
100
|
+
[KwikqaWdioReporter, { reportDir: settings.reportDir }],
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
if (settings.noRetries) {
|
|
104
|
+
config.specFileRetries = 0;
|
|
105
|
+
if (framework === "mocha") config.mochaOpts = { ...(base.mochaOpts || {}), retries: 0 };
|
|
106
|
+
if (framework === "jasmine") config.jasmineOpts = { ...(base.jasmineOpts || {}) };
|
|
107
|
+
}
|
|
108
|
+
if (settings.grep) {
|
|
109
|
+
if (framework === "mocha")
|
|
110
|
+
config.mochaOpts = { ...(config.mochaOpts || base.mochaOpts || {}), grep: settings.grep };
|
|
111
|
+
if (framework === "jasmine")
|
|
112
|
+
config.jasmineOpts = {
|
|
113
|
+
...(config.jasmineOpts || base.jasmineOpts || {}),
|
|
114
|
+
grep: settings.grep,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (!captureDir) return config;
|
|
118
|
+
|
|
119
|
+
config.before = [
|
|
120
|
+
...asArray(base.before),
|
|
121
|
+
async () => {
|
|
122
|
+
if (browser.isMultiremote || !browser.isBidi) return;
|
|
123
|
+
try {
|
|
124
|
+
await browser.sessionSubscribe({
|
|
125
|
+
events: ["log.entryAdded", "network.responseCompleted", "network.fetchError"],
|
|
126
|
+
});
|
|
127
|
+
browser.on("log.entryAdded", (entry) => {
|
|
128
|
+
if (state.console.length >= MAX_LOG) return;
|
|
129
|
+
if (entry.level === "error" || entry.level === "warn" || entry.type === "javascript") {
|
|
130
|
+
state.console.push({
|
|
131
|
+
level: entry.type === "javascript" ? "pageerror" : entry.level,
|
|
132
|
+
text: String(entry.text).slice(0, MAX_TEXT),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
browser.on("network.responseCompleted", (event) => {
|
|
137
|
+
if (state.network.length >= MAX_LOG) return;
|
|
138
|
+
const request = event.request || {};
|
|
139
|
+
const response = event.response || {};
|
|
140
|
+
state.network.push({
|
|
141
|
+
method: request.method,
|
|
142
|
+
url: String(request.url || "").slice(0, MAX_TEXT),
|
|
143
|
+
status: response.status,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
browser.on("network.fetchError", (event) => {
|
|
147
|
+
if (state.network.length >= MAX_LOG) return;
|
|
148
|
+
const request = event.request || {};
|
|
149
|
+
state.network.push({
|
|
150
|
+
method: request.method,
|
|
151
|
+
url: String(request.url || "").slice(0, MAX_TEXT),
|
|
152
|
+
failure: String(event.errorText || "failed"),
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
} catch {
|
|
156
|
+
// A driver without BiDi still gets DOM, screenshot, and command capture.
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
config.beforeTest = [
|
|
161
|
+
...asArray(base.beforeTest),
|
|
162
|
+
() => {
|
|
163
|
+
state.commands = [];
|
|
164
|
+
state.console = [];
|
|
165
|
+
state.network = [];
|
|
166
|
+
state.capturing = false;
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
config.afterCommand = [
|
|
170
|
+
...asArray(base.afterCommand),
|
|
171
|
+
(commandName, args, _result, error) => {
|
|
172
|
+
const name = String(commandName);
|
|
173
|
+
if (state.capturing || PROTOCOL_COMMAND.test(name)) return;
|
|
174
|
+
const entry = {
|
|
175
|
+
name,
|
|
176
|
+
message: (args || [])
|
|
177
|
+
.filter((arg) => typeof arg !== "function")
|
|
178
|
+
.map(summarize)
|
|
179
|
+
.join(", ")
|
|
180
|
+
.slice(0, MAX_TEXT),
|
|
181
|
+
state: error ? "failed" : "passed",
|
|
182
|
+
...(error ? { error: String(error.message || error).slice(0, MAX_TEXT) } : {}),
|
|
183
|
+
};
|
|
184
|
+
// Waits poll the same few commands many times; one entry each, with a count, says the same.
|
|
185
|
+
const repeat = state.commands
|
|
186
|
+
.slice(-4)
|
|
187
|
+
.find(
|
|
188
|
+
(item) =>
|
|
189
|
+
item.name === entry.name &&
|
|
190
|
+
item.message === entry.message &&
|
|
191
|
+
item.state === entry.state,
|
|
192
|
+
);
|
|
193
|
+
if (repeat) {
|
|
194
|
+
repeat.repeated = (repeat.repeated || 1) + 1;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (state.commands.length >= MAX_LOG) state.commands.shift();
|
|
198
|
+
state.commands.push(entry);
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
config.afterTest = [
|
|
202
|
+
...asArray(base.afterTest),
|
|
203
|
+
async (test, _context, result) => {
|
|
204
|
+
if (result?.passed) return;
|
|
205
|
+
if (browser.isMultiremote) return;
|
|
206
|
+
state.capturing = true;
|
|
207
|
+
const spec = relative(cwd, test.file || "")
|
|
208
|
+
.split("\\")
|
|
209
|
+
.join("/");
|
|
210
|
+
const parent = typeof test.parent === "string" ? test.parent : "";
|
|
211
|
+
const titlePath = parent === "" ? [test.title] : [parent, test.title];
|
|
212
|
+
if (globalThis.__kwikqaWdioCaptures === undefined) globalThis.__kwikqaWdioCaptures = [];
|
|
213
|
+
const captures = globalThis.__kwikqaWdioCaptures;
|
|
214
|
+
const retry = captures.filter(
|
|
215
|
+
(item) => item.parent === parent && item.title === test.title,
|
|
216
|
+
).length;
|
|
217
|
+
const key = `${spec.replace(/[^A-Za-z0-9]+/g, "_").slice(-60)}--${fnv1a(`${spec}\u0000${titlePath.join("\u0000")}`)}--${retry}`;
|
|
218
|
+
mkdirSync(captureDir, { recursive: true });
|
|
219
|
+
let page = {};
|
|
220
|
+
try {
|
|
221
|
+
page = await browser.execute(serializePage, 20000, MAX_TEXT);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
page = { error: String(error?.message ? error.message : error) };
|
|
224
|
+
}
|
|
225
|
+
const screenshots = [];
|
|
226
|
+
try {
|
|
227
|
+
const screenshot = join(captureDir, `${key}.png`);
|
|
228
|
+
await browser.saveScreenshot(screenshot);
|
|
229
|
+
screenshots.push(screenshot);
|
|
230
|
+
} catch {
|
|
231
|
+
// A closed or crashed session has no page to photograph; the rest is still recorded.
|
|
232
|
+
}
|
|
233
|
+
const capture = {
|
|
234
|
+
schema: "kwikqa-capture",
|
|
235
|
+
schemaVersion: "1.0",
|
|
236
|
+
framework: "webdriverio",
|
|
237
|
+
spec,
|
|
238
|
+
titlePath,
|
|
239
|
+
retry,
|
|
240
|
+
url: page.url || "",
|
|
241
|
+
title: page.title || "",
|
|
242
|
+
...(page.viewport ? { viewport: page.viewport } : {}),
|
|
243
|
+
dom: page.dom,
|
|
244
|
+
domTruncated: page.truncated === true,
|
|
245
|
+
...(page.error ? { domError: page.error } : {}),
|
|
246
|
+
commands: state.commands,
|
|
247
|
+
console: state.console,
|
|
248
|
+
network: state.network.map((item) => ({
|
|
249
|
+
message: `${item.method || "GET"} ${item.status === undefined ? item.failure : item.status} ${item.url}`,
|
|
250
|
+
state: item.failure || (item.status ?? 0) >= 400 ? "failed" : "passed",
|
|
251
|
+
url: item.url,
|
|
252
|
+
})),
|
|
253
|
+
screenshots: screenshots.map((path) => ({ path, testFailure: true })),
|
|
254
|
+
};
|
|
255
|
+
const path = join(captureDir, `${key}.json`);
|
|
256
|
+
writeFileSync(path, `${JSON.stringify(capture)}\n`);
|
|
257
|
+
captures.push({ parent, title: test.title, path, screenshots });
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
return config;
|
|
261
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { EventEmitter } from "node:events";
|
|
2
|
+
|
|
3
|
+
export interface KwikqaWdioReporterOptions {
|
|
4
|
+
/** Where each worker writes its report; `test-results/kwikqa` by default. */
|
|
5
|
+
readonly reportDir?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** KwikQA's WebdriverIO reporter: `reporters: [[KwikqaReporter, {}]]` in wdio.conf. */
|
|
9
|
+
export default class KwikqaWdioReporter extends EventEmitter {
|
|
10
|
+
constructor(options?: KwikqaWdioReporterOptions);
|
|
11
|
+
readonly isSynchronised: boolean;
|
|
12
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// KwikQA's WebdriverIO reporter (KQA-081). Add it to wdio.conf:
|
|
2
|
+
//
|
|
3
|
+
// import KwikqaReporter from "kwikqa/wdio-reporter";
|
|
4
|
+
// reporters: ["spec", [KwikqaReporter, {}]],
|
|
5
|
+
//
|
|
6
|
+
// Each worker writes one `kwikqa-test-report` JSON into `test-results/kwikqa/` (or the
|
|
7
|
+
// `reportDir` option, or $KWIKQA_REPORT_DIR). Unlike @wdio/json-reporter it keeps the full suite
|
|
8
|
+
// path, every retry attempt, and each failure's stack, so `npx kwikqa analyze --report
|
|
9
|
+
// test-results/kwikqa` can tell nested tests apart. It has no dependencies and changes nothing
|
|
10
|
+
// about how tests run.
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join, resolve } from "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
|
+
|
|
19
|
+
function packageVersion(name, from) {
|
|
20
|
+
let directory = resolve(from);
|
|
21
|
+
for (;;) {
|
|
22
|
+
const manifest = join(directory, "node_modules", ...name.split("/"), "package.json");
|
|
23
|
+
if (existsSync(manifest)) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(readFileSync(manifest, "utf8")).version;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const parent = dirname(directory);
|
|
31
|
+
if (parent === directory) return undefined;
|
|
32
|
+
directory = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function browserName(capabilities) {
|
|
37
|
+
if (!capabilities || typeof capabilities !== "object") return "";
|
|
38
|
+
if (typeof capabilities.browserName === "string") return capabilities.browserName;
|
|
39
|
+
const values = Object.values(capabilities);
|
|
40
|
+
if (values.length > 0 && values.every((value) => value && typeof value === "object")) {
|
|
41
|
+
return "multiremote";
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function convertError(error) {
|
|
47
|
+
if (!error) return undefined;
|
|
48
|
+
const out = { message: String(error.message !== undefined ? error.message : error) };
|
|
49
|
+
if (error.name) out.name = String(error.name);
|
|
50
|
+
if (error.stack) out.stack = String(error.stack);
|
|
51
|
+
if (error.expected !== undefined || error.actual !== undefined) {
|
|
52
|
+
out.expected =
|
|
53
|
+
typeof error.expected === "string" ? error.expected : JSON.stringify(error.expected);
|
|
54
|
+
out.actual = typeof error.actual === "string" ? error.actual : JSON.stringify(error.actual);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cleanFile(file) {
|
|
60
|
+
if (typeof file !== "string") return "";
|
|
61
|
+
if (file.startsWith("file://")) {
|
|
62
|
+
const path = decodeURIComponent(file.slice("file://".length));
|
|
63
|
+
return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path;
|
|
64
|
+
}
|
|
65
|
+
return file;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default class KwikqaWdioReporter extends EventEmitter {
|
|
69
|
+
constructor(options = {}) {
|
|
70
|
+
super();
|
|
71
|
+
this.options = options;
|
|
72
|
+
this.cwd = process.cwd();
|
|
73
|
+
this.reportDir = resolve(
|
|
74
|
+
this.cwd,
|
|
75
|
+
process.env.KWIKQA_REPORT_DIR || options.reportDir || "test-results/kwikqa",
|
|
76
|
+
);
|
|
77
|
+
this.startedAt = new Date();
|
|
78
|
+
this.suites = [];
|
|
79
|
+
this.tests = new Map();
|
|
80
|
+
this.errors = [];
|
|
81
|
+
this.cid = "0-0";
|
|
82
|
+
this.specs = [];
|
|
83
|
+
this.browser = "";
|
|
84
|
+
this.configFile = undefined;
|
|
85
|
+
this.on("runner:start", (runner) => {
|
|
86
|
+
this.cid = String(runner.cid || this.cid);
|
|
87
|
+
this.specs = (runner.specs || []).map(cleanFile);
|
|
88
|
+
this.browser = browserName(runner.capabilities);
|
|
89
|
+
const config = runner.config || {};
|
|
90
|
+
if (typeof config.configFilePath === "string") this.configFile = config.configFilePath;
|
|
91
|
+
});
|
|
92
|
+
this.on("suite:start", (suite) => {
|
|
93
|
+
if (suite.title) this.suites.push(String(suite.title));
|
|
94
|
+
});
|
|
95
|
+
this.on("suite:end", (suite) => {
|
|
96
|
+
if (suite.title && this.suites[this.suites.length - 1] === String(suite.title)) {
|
|
97
|
+
this.suites.pop();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
this.on("test:start", (test) => this.open(test));
|
|
101
|
+
this.on("test:pass", (test) => this.close(test, "passed"));
|
|
102
|
+
this.on("test:fail", (test) => this.close(test, "failed", test.error));
|
|
103
|
+
this.on("test:retry", (test) => {
|
|
104
|
+
this.close(test, "failed", test.error);
|
|
105
|
+
});
|
|
106
|
+
this.on("test:pending", (test) => {
|
|
107
|
+
const entry = this.entry(test);
|
|
108
|
+
entry.attempts = [{ status: "skipped", retry: 0, duration: 0, errors: [], attachments: [] }];
|
|
109
|
+
});
|
|
110
|
+
this.on("hook:end", (hook) => {
|
|
111
|
+
if (!hook.error) return;
|
|
112
|
+
const failure = convertError(hook.error);
|
|
113
|
+
failure.message = `${hook.title}: ${failure.message}`;
|
|
114
|
+
const running = [...this.tests.values()].find((entry) =>
|
|
115
|
+
entry.attempts.some((attempt) => attempt.status === "interrupted"),
|
|
116
|
+
);
|
|
117
|
+
if (running) {
|
|
118
|
+
const attempt = running.attempts.find((item) => item.status === "interrupted");
|
|
119
|
+
attempt.status = "failed";
|
|
120
|
+
attempt.errors.push(failure);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// A hook outside any test (before all, after all) fails under its own title.
|
|
124
|
+
const entry = this.entry({ title: String(hook.title), file: hook.file });
|
|
125
|
+
entry.attempts.push({
|
|
126
|
+
status: "failed",
|
|
127
|
+
retry: entry.attempts.length,
|
|
128
|
+
startTime: new Date().toISOString(),
|
|
129
|
+
duration: 0,
|
|
130
|
+
errors: [failure],
|
|
131
|
+
attachments: [],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
this.on("runner:end", () => this.write());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get isSynchronised() {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
entry(test) {
|
|
142
|
+
const titlePath = [...this.suites, String(test.title)];
|
|
143
|
+
const file = cleanFile(test.file) || this.specs[0] || "";
|
|
144
|
+
const key = `${file}\u0000${titlePath.join("\u0000")}`;
|
|
145
|
+
let entry = this.tests.get(key);
|
|
146
|
+
if (!entry) {
|
|
147
|
+
entry = { file, titlePath, project: this.browser, attempts: [] };
|
|
148
|
+
this.tests.set(key, entry);
|
|
149
|
+
}
|
|
150
|
+
return entry;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
open(test) {
|
|
154
|
+
const entry = this.entry(test);
|
|
155
|
+
entry.attempts.push({
|
|
156
|
+
status: "interrupted",
|
|
157
|
+
retry: entry.attempts.length,
|
|
158
|
+
startTime: new Date().toISOString(),
|
|
159
|
+
duration: 0,
|
|
160
|
+
errors: [],
|
|
161
|
+
attachments: [],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
close(test, status, error) {
|
|
166
|
+
const entry = this.entry(test);
|
|
167
|
+
let attempt = entry.attempts.findLast((item) => item.status === "interrupted");
|
|
168
|
+
if (!attempt) {
|
|
169
|
+
this.open(test);
|
|
170
|
+
attempt = entry.attempts[entry.attempts.length - 1];
|
|
171
|
+
}
|
|
172
|
+
attempt.status = status;
|
|
173
|
+
attempt.duration =
|
|
174
|
+
typeof test.duration === "number"
|
|
175
|
+
? test.duration
|
|
176
|
+
: Date.now() - Date.parse(attempt.startTime);
|
|
177
|
+
const converted = convertError(error);
|
|
178
|
+
if (converted) attempt.errors.push(converted);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Captures written by KwikQA's capture hooks in this worker, matched to failed attempts in order. */
|
|
182
|
+
attachCaptures() {
|
|
183
|
+
const captures = globalThis.__kwikqaWdioCaptures || [];
|
|
184
|
+
const used = new Map();
|
|
185
|
+
for (const capture of captures) {
|
|
186
|
+
const key = `${capture.parent}\u0000${capture.title}`;
|
|
187
|
+
const index = used.get(key) || 0;
|
|
188
|
+
used.set(key, index + 1);
|
|
189
|
+
const entry = [...this.tests.values()].find(
|
|
190
|
+
(item) =>
|
|
191
|
+
item.titlePath[item.titlePath.length - 1] === capture.title &&
|
|
192
|
+
(item.titlePath.length < 2 ||
|
|
193
|
+
item.titlePath[item.titlePath.length - 2] === capture.parent),
|
|
194
|
+
);
|
|
195
|
+
if (!entry) continue;
|
|
196
|
+
const failed = entry.attempts.filter(
|
|
197
|
+
(attempt) => attempt.status !== "passed" && attempt.status !== "skipped",
|
|
198
|
+
);
|
|
199
|
+
const attempt = failed[index];
|
|
200
|
+
if (!attempt) continue;
|
|
201
|
+
attempt.attachments.push({
|
|
202
|
+
name: "kwikqa-capture",
|
|
203
|
+
contentType: CAPTURE_TYPE,
|
|
204
|
+
path: capture.path,
|
|
205
|
+
});
|
|
206
|
+
for (const screenshot of capture.screenshots || []) {
|
|
207
|
+
attempt.attachments.push({
|
|
208
|
+
name: "screenshot",
|
|
209
|
+
contentType: "image/png",
|
|
210
|
+
path: screenshot,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
write() {
|
|
217
|
+
this.attachCaptures();
|
|
218
|
+
const tests = [...this.tests.values()];
|
|
219
|
+
const interrupted = tests.some((entry) =>
|
|
220
|
+
entry.attempts.some((attempt) => attempt.status === "interrupted"),
|
|
221
|
+
);
|
|
222
|
+
const version = packageVersion("webdriverio", this.cwd);
|
|
223
|
+
const report = {
|
|
224
|
+
schema: SCHEMA,
|
|
225
|
+
schemaVersion: SCHEMA_VERSION,
|
|
226
|
+
framework: "webdriverio",
|
|
227
|
+
...(version === undefined ? {} : { frameworkVersion: version }),
|
|
228
|
+
rootDir: this.cwd,
|
|
229
|
+
...(this.configFile === undefined ? {} : { configFile: this.configFile }),
|
|
230
|
+
startTime: this.startedAt.toISOString(),
|
|
231
|
+
duration: Date.now() - this.startedAt.getTime(),
|
|
232
|
+
interrupted,
|
|
233
|
+
projects: [{ id: this.browser, name: this.browser, retries: 0 }],
|
|
234
|
+
tests,
|
|
235
|
+
errors: this.errors,
|
|
236
|
+
};
|
|
237
|
+
mkdirSync(this.reportDir, { recursive: true });
|
|
238
|
+
writeFileSync(
|
|
239
|
+
join(this.reportDir, `wdio-${this.cid.replace(/[^A-Za-z0-9-]+/g, "_")}.json`),
|
|
240
|
+
`${JSON.stringify(report, null, 2)}\n`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kwikqa",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Evidence-backed diagnoses and safe, validated repairs for failing Playwright, Cypress, and WebdriverIO tests",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"playwright",
|
|
9
|
+
"cypress",
|
|
10
|
+
"webdriverio",
|
|
11
|
+
"wdio",
|
|
12
|
+
"testing",
|
|
13
|
+
"flaky-tests",
|
|
14
|
+
"test-repair",
|
|
15
|
+
"e2e"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://kwikqa.com",
|
|
18
|
+
"bin": {
|
|
19
|
+
"kwikqa": "dist/kwikqa.mjs"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
"./cypress-reporter": {
|
|
23
|
+
"types": "./dist/cypress-reporter.d.cts",
|
|
24
|
+
"default": "./dist/cypress-reporter.cjs"
|
|
25
|
+
},
|
|
26
|
+
"./wdio-reporter": {
|
|
27
|
+
"types": "./dist/wdio-reporter.d.mts",
|
|
28
|
+
"default": "./dist/wdio-reporter.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"packageManager": "pnpm@11.22.0",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20.11"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "node scripts/build.mjs",
|
|
43
|
+
"smoke": "node scripts/smoke.mjs",
|
|
44
|
+
"check": "corepack pnpm run format:check && corepack pnpm run lint && corepack pnpm run typecheck && corepack pnpm run test",
|
|
45
|
+
"format": "biome format --write .",
|
|
46
|
+
"format:check": "biome format .",
|
|
47
|
+
"lint": "biome lint .",
|
|
48
|
+
"typecheck": "tsc --pretty false",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:validation:labs": "vitest run tests/validation/labs-matrix.test.ts",
|
|
51
|
+
"prepublishOnly": "corepack pnpm run check && corepack pnpm run build && corepack pnpm run smoke"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@babel/parser": "7.29.9",
|
|
55
|
+
"@biomejs/biome": "2.5.10",
|
|
56
|
+
"@types/node": "26.2.0",
|
|
57
|
+
"esbuild": "0.28.1",
|
|
58
|
+
"fflate": "0.8.3",
|
|
59
|
+
"typescript": "7.0.2",
|
|
60
|
+
"vitest": "4.1.11"
|
|
61
|
+
}
|
|
62
|
+
}
|