@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 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, isUploadableAttachment, makeTestKey, parseAxeScan, postPrComment, resolvePrContext, resolveShardGroup } from "@kinora/core";
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 kinds = this.options.uploadAttachments ?? DEFAULT_UPLOAD_ATTACHMENTS;
143
- let artifacts = 0;
144
- for (const t of tests) for (const a of t.attachments) {
145
- if (!a.path || !isUploadableAttachment(a, kinds)) continue;
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.12.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.14.0"
48
+ "@kinora/core": "0.16.0"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@axe-core/playwright": "^4.14.0",
51
- "@playwright/test": "^1.64.1",
52
- "@types/node": "^26.6.5",
53
- "@vitest/coverage-v8": "^5.0.5",
54
- "tsdown": "^0.23.2",
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.5"
57
+ "vitest": "^5.1.0"
57
58
  },
58
59
  "scripts": {
59
60
  "build": "tsdown",