@kinora/reporter 0.12.0 → 0.14.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 +14 -0
- package/dist/check-base-url.d.mts +36 -0
- package/dist/check-base-url.mjs +119 -0
- package/dist/index.mjs +10 -19
- package/package.json +9 -8
package/README.md
CHANGED
|
@@ -93,6 +93,20 @@ A scan you build yourself works too, as long as it is attached to the test with
|
|
|
93
93
|
An assertion alone is invisible to a reporter. More in the
|
|
94
94
|
[accessibility guide](https://docs.kinora.dev/guides/reporter/#accessibility).
|
|
95
95
|
|
|
96
|
+
## Checking the app is up
|
|
97
|
+
|
|
98
|
+
List `@kinora/reporter/check-base-url` in `globalSetup` and the run stops before any test when a
|
|
99
|
+
project's `baseURL` does not answer (refused, unresolved, timed out, or a `502` / `503` / `504`):
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
export default defineConfig({
|
|
103
|
+
globalSetup: ['@kinora/reporter/check-base-url', './global-setup.ts'],
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`KINORA_CHECK_BASE_URL=false` skips it. More in the
|
|
108
|
+
[reporter guide](https://docs.kinora.dev/guides/reporter/#checking-the-app-is-up).
|
|
109
|
+
|
|
96
110
|
## Documentation
|
|
97
111
|
|
|
98
112
|
Full reporter options, PR/MR comments, CI examples, and self-hosting are in the docs:
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { FullConfig, FullProject } from "@playwright/test";
|
|
2
|
+
export declare const CHECK_ATTEMPTS = 3;
|
|
3
|
+
export declare const CHECK_TIMEOUT_MS = 10000;
|
|
4
|
+
export declare const CHECK_DELAY_MS = 2000;
|
|
5
|
+
type Project = Pick<FullProject, 'name' | 'dependencies' | 'use'>;
|
|
6
|
+
export interface BaseUrlTarget {
|
|
7
|
+
url: string;
|
|
8
|
+
projects: string[];
|
|
9
|
+
ignoreHTTPSErrors: boolean;
|
|
10
|
+
proxy?: FullProject['use']['proxy'];
|
|
11
|
+
remoteBrowser: boolean;
|
|
12
|
+
}
|
|
13
|
+
export type ProbeOutcome = {
|
|
14
|
+
status: number;
|
|
15
|
+
} | {
|
|
16
|
+
error: string;
|
|
17
|
+
};
|
|
18
|
+
export type Probe = (target: BaseUrlTarget, timeoutMs: number) => Promise<ProbeOutcome>;
|
|
19
|
+
export interface CheckOptions {
|
|
20
|
+
attempts?: number;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
delayMs?: number;
|
|
23
|
+
probe?: Probe;
|
|
24
|
+
argv?: string[];
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
26
|
+
}
|
|
27
|
+
export declare function selectedProjects<P extends Project>(projects: P[], filtered: Project[] | undefined, argv: string[]): P[];
|
|
28
|
+
export declare function baseUrlTargets(config: Pick<FullConfig, 'projects'> & {
|
|
29
|
+
filteredProjects?: Project[];
|
|
30
|
+
}, argv?: string[], env?: Record<string, string | undefined>): BaseUrlTarget[];
|
|
31
|
+
export declare function checkBaseUrl(target: BaseUrlTarget, options?: CheckOptions): Promise<string | null>;
|
|
32
|
+
export declare function checkBaseUrls(config: Pick<FullConfig, 'projects'> & {
|
|
33
|
+
filteredProjects?: Project[];
|
|
34
|
+
}, options?: CheckOptions): Promise<void>;
|
|
35
|
+
export default function globalSetup(config: FullConfig): Promise<void>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
// src/check-base-url.ts
|
|
3
|
+
const CHECK_ATTEMPTS = 3;
|
|
4
|
+
const CHECK_TIMEOUT_MS = 1e4;
|
|
5
|
+
const CHECK_DELAY_MS = 2e3;
|
|
6
|
+
const GATEWAY_DOWN = {
|
|
7
|
+
502: "bad gateway",
|
|
8
|
+
503: "service unavailable",
|
|
9
|
+
504: "gateway timeout"
|
|
10
|
+
};
|
|
11
|
+
function cliProjects(argv) {
|
|
12
|
+
let names = null;
|
|
13
|
+
for (let i = 0; i < argv.length; i++) if (argv[i].startsWith("--project=")) (names ??= []).push(argv[i].slice(10));
|
|
14
|
+
else if (argv[i] === "--project") while (argv[i + 1] && !argv[i + 1].startsWith("-")) (names ??= []).push(argv[++i]);
|
|
15
|
+
return names;
|
|
16
|
+
}
|
|
17
|
+
const wildcard = (pattern) => new RegExp(`^${pattern.split("*").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`, "i");
|
|
18
|
+
function selectedProjects(projects, filtered, argv) {
|
|
19
|
+
let chosen;
|
|
20
|
+
if (filtered) {
|
|
21
|
+
const names = new Set(filtered.map((p) => p.name));
|
|
22
|
+
chosen = new Set(projects.filter((p) => filtered.includes(p) || names.has(p.name)));
|
|
23
|
+
} else {
|
|
24
|
+
const names = cliProjects(argv);
|
|
25
|
+
if (!names?.length) return projects;
|
|
26
|
+
const patterns = names.map(wildcard);
|
|
27
|
+
chosen = new Set(projects.filter((p) => patterns.some((re) => re.test(p.name))));
|
|
28
|
+
}
|
|
29
|
+
if (!argv.includes("--no-deps")) {
|
|
30
|
+
const byName = new Map(projects.map((p) => [p.name, p]));
|
|
31
|
+
const queue = [...chosen];
|
|
32
|
+
while (queue.length) for (const dep of queue.shift().dependencies) {
|
|
33
|
+
const p = byName.get(dep);
|
|
34
|
+
if (p && !chosen.has(p)) {
|
|
35
|
+
chosen.add(p);
|
|
36
|
+
queue.push(p);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return projects.filter((p) => chosen.has(p));
|
|
41
|
+
}
|
|
42
|
+
function baseUrlTargets(config, argv = process.argv, env = process.env) {
|
|
43
|
+
const targets = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const project of selectedProjects(config.projects, config.filteredProjects, argv)) {
|
|
45
|
+
const url = project.use.baseURL;
|
|
46
|
+
if (!url || !/^https?:\/\//i.test(url)) continue;
|
|
47
|
+
const remoteBrowser = !!project.use.connectOptions || !!env.PW_TEST_CONNECT_WS_ENDPOINT;
|
|
48
|
+
const target = targets.get(url);
|
|
49
|
+
if (target) {
|
|
50
|
+
if (project.name) target.projects.push(project.name);
|
|
51
|
+
target.remoteBrowser ||= remoteBrowser;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
targets.set(url, {
|
|
55
|
+
url,
|
|
56
|
+
projects: project.name ? [project.name] : [],
|
|
57
|
+
ignoreHTTPSErrors: !!project.use.ignoreHTTPSErrors,
|
|
58
|
+
proxy: project.use.proxy,
|
|
59
|
+
remoteBrowser
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return [...targets.values()];
|
|
63
|
+
}
|
|
64
|
+
const playwrightProbe = async (target, timeoutMs) => {
|
|
65
|
+
const { request } = await import("@playwright/test");
|
|
66
|
+
const context = await request.newContext({
|
|
67
|
+
ignoreHTTPSErrors: target.ignoreHTTPSErrors,
|
|
68
|
+
proxy: target.proxy
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
return { status: (await context.get(target.url, {
|
|
72
|
+
timeout: timeoutMs,
|
|
73
|
+
maxRedirects: 0,
|
|
74
|
+
failOnStatusCode: false
|
|
75
|
+
})).status() };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return { error: (error instanceof Error ? error.message : String(error)).split("\n")[0].trim().replace(/^apiRequestContext\.\w+:\s*/, "") };
|
|
78
|
+
} finally {
|
|
79
|
+
await context.dispose().catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
async function checkBaseUrl(target, options = {}) {
|
|
83
|
+
const attempts = Math.max(1, options.attempts ?? 3);
|
|
84
|
+
const probe = options.probe ?? playwrightProbe;
|
|
85
|
+
let reason = "";
|
|
86
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
87
|
+
const outcome = await probe(target, options.timeoutMs ?? 1e4);
|
|
88
|
+
if ("status" in outcome) {
|
|
89
|
+
const down = GATEWAY_DOWN[outcome.status];
|
|
90
|
+
if (!down) return null;
|
|
91
|
+
reason = `${outcome.status} ${down}`;
|
|
92
|
+
} else reason = outcome.error;
|
|
93
|
+
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, options.delayMs ?? 2e3));
|
|
94
|
+
}
|
|
95
|
+
return `${reason} (${attempts} ${attempts === 1 ? "attempt" : "attempts"})`;
|
|
96
|
+
}
|
|
97
|
+
async function checkBaseUrls(config, options = {}) {
|
|
98
|
+
const env = options.env ?? process.env;
|
|
99
|
+
if (env.KINORA_CHECK_BASE_URL === "false") return;
|
|
100
|
+
const targets = baseUrlTargets(config, options.argv, env);
|
|
101
|
+
const reasons = await Promise.all(targets.map((t) => checkBaseUrl(t, options)));
|
|
102
|
+
const down = targets.flatMap((t, i) => reasons[i] ? [{
|
|
103
|
+
t,
|
|
104
|
+
reason: reasons[i]
|
|
105
|
+
}] : []);
|
|
106
|
+
if (!down.length) return;
|
|
107
|
+
const message = [
|
|
108
|
+
"[kinora] The app under test did not answer, so no test ran:",
|
|
109
|
+
...down.map(({ t, reason }) => ` ${t.url}${t.projects.length ? ` (${t.projects.join(", ")})` : ""}: ${reason}${t.remoteBrowser ? `\n The check ran from this machine, but the browser runs on another one and may reach ${new URL(t.url).host} from its own network.` : ""}`),
|
|
110
|
+
"Bring the environment back and run again, or set KINORA_CHECK_BASE_URL=false to run the tests anyway."
|
|
111
|
+
].join("\n");
|
|
112
|
+
const error = new Error(message);
|
|
113
|
+
error.stack = message;
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
async function globalSetup(config) {
|
|
117
|
+
await checkBaseUrls(config);
|
|
118
|
+
}
|
|
119
|
+
export { CHECK_ATTEMPTS, CHECK_DELAY_MS, CHECK_TIMEOUT_MS, baseUrlTargets, checkBaseUrl, checkBaseUrls, globalSetup as default, selectedProjects };
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { DEFAULT_KINORA_URL, DEFAULT_UPLOAD_ATTACHMENTS, IngestError, a11yFromScans, createIngestClient, describeRetry, detectCiEnv, effectiveAttachments, flakyTestsOf,
|
|
4
|
+
import { DEFAULT_KINORA_URL, DEFAULT_UPLOAD_ATTACHMENTS, IngestError, a11yFromScans, artifactsToUpload, createIngestClient, describeRetry, detectCiEnv, effectiveAttachments, flakyTestsOf, makeTestKey, parseAxeScan, postPrComment, resolvePrContext, resolveShardGroup, uploadArtifacts } from "@kinora/core";
|
|
5
5
|
// src/index.ts
|
|
6
6
|
function identity(test) {
|
|
7
7
|
let file = "";
|
|
@@ -109,6 +109,7 @@ var KinoraReporter = class {
|
|
|
109
109
|
groupKey: this.options.groupKey,
|
|
110
110
|
leg: this.prCommentLabel()
|
|
111
111
|
}) : void 0;
|
|
112
|
+
const baseUrls = [...new Set((this.config?.projects ?? []).flatMap((p) => p.use.baseURL ? [p.use.baseURL] : []))];
|
|
112
113
|
const payload = {
|
|
113
114
|
project: {
|
|
114
115
|
slug: this.options.project.slug,
|
|
@@ -121,6 +122,7 @@ var KinoraReporter = class {
|
|
|
121
122
|
playwrightVersion: this.config?.version,
|
|
122
123
|
git: this.options.git ?? detected.git,
|
|
123
124
|
ci: this.options.ci ?? detected.ci,
|
|
125
|
+
...baseUrls.length && { baseUrls },
|
|
124
126
|
...shard && group && {
|
|
125
127
|
...group,
|
|
126
128
|
shard: {
|
|
@@ -139,24 +141,12 @@ var KinoraReporter = class {
|
|
|
139
141
|
onRetry: (info) => console.warn(`[kinora] ${describeRetry(info)}`)
|
|
140
142
|
});
|
|
141
143
|
const res = await client.uploadRun(payload);
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
await client.uploadArtifact({
|
|
148
|
-
runId: res.runId,
|
|
149
|
-
testKey: t.testKey,
|
|
150
|
-
name: a.name,
|
|
151
|
-
contentType: a.contentType,
|
|
152
|
-
body: await readFile(a.path)
|
|
153
|
-
});
|
|
154
|
-
artifacts++;
|
|
155
|
-
} catch (err) {
|
|
156
|
-
console.warn(`[kinora] ${a.name} upload failed for ${t.testKey}:`, err instanceof Error ? err.message : err);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
144
|
+
const artifacts = await uploadArtifacts(client, res.runId, artifactsToUpload(tests, this.options.uploadAttachments ?? DEFAULT_UPLOAD_ATTACHMENTS), {
|
|
145
|
+
read: readFile,
|
|
146
|
+
onFailed: (a, err) => console.warn(`[kinora] ${a.name} upload failed for ${a.testKey}:`, err instanceof Error ? err.message : err)
|
|
147
|
+
});
|
|
159
148
|
console.log(`[kinora] uploaded ${res.tests} tests + ${artifacts} artifacts (run ${res.runId}${res.shardProgress ? `, ${res.shardProgress.landed} of ${res.shardProgress.total} shards` : ""})`);
|
|
149
|
+
if (res.incident) console.warn(`[kinora] environment incident, not counted: ${res.incident.reason}`);
|
|
160
150
|
await this.maybePostPrComment(payload, res);
|
|
161
151
|
} catch (err) {
|
|
162
152
|
if (err instanceof IngestError && err.status === 402) console.warn(`[kinora] ${err.message}`);
|
|
@@ -199,7 +189,8 @@ var KinoraReporter = class {
|
|
|
199
189
|
...res.shardProgress && { shards: {
|
|
200
190
|
runId: res.runId,
|
|
201
191
|
...res.shardProgress
|
|
202
|
-
} }
|
|
192
|
+
} },
|
|
193
|
+
...res.incident && { incident: res.incident }
|
|
203
194
|
}, policy ?? "always");
|
|
204
195
|
if (outcome !== "skipped") console.warn(`[kinora] PR comment ${outcome}`);
|
|
205
196
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kinora/reporter",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.14.0",
|
|
5
5
|
"description": "Playwright reporter that uploads test results to a kinora server",
|
|
6
6
|
"author": "Joris Gallot",
|
|
7
7
|
"license": "MIT",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"exports": {
|
|
23
23
|
".": "./dist/index.mjs",
|
|
24
24
|
"./ct": "./dist/ct.mjs",
|
|
25
|
-
"./a11y": "./dist/a11y.mjs"
|
|
25
|
+
"./a11y": "./dist/a11y.mjs",
|
|
26
|
+
"./check-base-url": "./dist/check-base-url.mjs"
|
|
26
27
|
},
|
|
27
28
|
"main": "./dist/index.mjs",
|
|
28
29
|
"files": [
|
|
@@ -44,16 +45,16 @@
|
|
|
44
45
|
}
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
|
-
"@kinora/core": "0.
|
|
48
|
+
"@kinora/core": "0.16.0"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@axe-core/playwright": "^4.14.0",
|
|
51
|
-
"@playwright/test": "^1.
|
|
52
|
-
"@types/node": "^26.6.
|
|
53
|
-
"@vitest/coverage-v8": "^5.0
|
|
54
|
-
"tsdown": "^0.23.
|
|
52
|
+
"@playwright/test": "^1.65.0",
|
|
53
|
+
"@types/node": "^26.6.6",
|
|
54
|
+
"@vitest/coverage-v8": "^5.1.0",
|
|
55
|
+
"tsdown": "^0.23.7",
|
|
55
56
|
"typescript": "~6.0.3",
|
|
56
|
-
"vitest": "^5.0
|
|
57
|
+
"vitest": "^5.1.0"
|
|
57
58
|
},
|
|
58
59
|
"scripts": {
|
|
59
60
|
"build": "tsdown",
|