@kinora/reporter 0.11.0 → 0.13.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:
package/dist/a11y.d.mts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Page } from "@playwright/test";
2
2
  import AxeBuilder from "@axe-core/playwright";
3
- //#region src/a11y.d.ts
4
3
  export type AxeResults = Awaited<ReturnType<AxeBuilder['analyze']>>;
5
4
  export declare const A11Y_ATTACHMENT = "accessibility-scan-results";
6
5
  /**
@@ -11,5 +10,4 @@ export declare const A11Y_ATTACHMENT = "accessibility-scan-results";
11
10
  * record them as warnings. `configure` narrows or widens the scan, e.g.
12
11
  * `axe => axe.withTags(['wcag2a', 'wcag2aa'])`.
13
12
  */
14
- export declare function scanA11y(page: Page, configure?: (axe: AxeBuilder) => AxeBuilder): Promise<AxeResults>;
15
- //#endregion
13
+ export declare function scanA11y(page: Page, configure?: (axe: AxeBuilder) => AxeBuilder): Promise<AxeResults>;
package/dist/a11y.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { test } from "@playwright/test";
2
- //#region src/a11y.ts
2
+ // src/a11y.ts
3
3
  const A11Y_ATTACHMENT = "accessibility-scan-results";
4
4
  /**
5
5
  * Scans the page with axe (`@axe-core/playwright`) and attaches the result to the running test,
@@ -21,5 +21,4 @@ async function scanA11y(page, configure) {
21
21
  });
22
22
  return results;
23
23
  }
24
- //#endregion
25
24
  export { A11Y_ATTACHMENT, scanA11y };
@@ -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/ct.d.mts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { TestInfo, expect } from "@playwright/test";
2
- //#region src/ct.d.ts
3
2
  type Annotations = TestInfo['annotations'];
4
3
  export declare function recordStory(annotations: Annotations, storyId: string): void;
5
4
  export declare function withStoryAnnotations<M extends (storyId: string, ...rest: any[]) => unknown>(mount: M, annotations: Annotations): M;
@@ -8,5 +7,4 @@ export declare function withStoryAnnotations<M extends (storyId: string, ...rest
8
7
  * the mounted story id on the test, which lets kinora group results by component and story.
9
8
  */
10
9
  export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
11
- //#endregion
12
10
  export { expect };
package/dist/ct.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { STORY_ANNOTATION } from "@kinora/core";
2
2
  import { expect, test as test$1 } from "@playwright/test";
3
- //#region src/ct.ts
3
+ // src/ct.ts
4
4
  function recordStory(annotations, storyId) {
5
5
  if (!annotations.some((a) => a.type === STORY_ANNOTATION && a.description === storyId)) annotations.push({
6
6
  type: STORY_ANNOTATION,
@@ -20,5 +20,4 @@ function withStoryAnnotations(mount, annotations) {
20
20
  const test = test$1.extend({ mount: async ({ mount }, use, testInfo) => {
21
21
  await use(withStoryAnnotations(mount, testInfo.annotations));
22
22
  } });
23
- //#endregion
24
23
  export { expect, recordStory, test, withStoryAnnotations };
package/dist/index.d.mts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { AttachmentKind, CiMeta, GitMeta } from "@kinora/core";
2
2
  import { FullConfig, FullResult, Reporter, Suite } from "@playwright/test/reporter";
3
- //#region src/index.d.ts
4
3
  export interface KinoraReporterOptions {
5
4
  /** kinora server base URL. Defaults to env KINORA_URL, then the hosted cloud. Set for self-host. */
6
5
  url?: string;
@@ -45,5 +44,4 @@ export default class KinoraReporter implements Reporter {
45
44
  onEnd(result: FullResult): Promise<void>;
46
45
  private prCommentLabel;
47
46
  private maybePostPrComment;
48
- }
49
- //#endregion
47
+ }
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
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, detectCiEnv, effectiveAttachments, flakyTestsOf, isUploadableAttachment, makeTestKey, parseAxeScan, postPrComment, resolvePrContext, resolveShardGroup } from "@kinora/core";
5
- //#region src/index.ts
4
+ import { DEFAULT_KINORA_URL, DEFAULT_UPLOAD_ATTACHMENTS, IngestError, a11yFromScans, createIngestClient, describeRetry, detectCiEnv, effectiveAttachments, flakyTestsOf, isUploadableAttachment, makeTestKey, parseAxeScan, postPrComment, resolvePrContext, resolveShardGroup } from "@kinora/core";
5
+ // src/index.ts
6
6
  function identity(test) {
7
7
  let file = "";
8
8
  let projectName = "";
@@ -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: {
@@ -135,7 +137,8 @@ var KinoraReporter = class {
135
137
  const client = createIngestClient({
136
138
  baseUrl: url,
137
139
  token,
138
- regression: !!this.options.prComment
140
+ regression: !!this.options.prComment,
141
+ onRetry: (info) => console.warn(`[kinora] ${describeRetry(info)}`)
139
142
  });
140
143
  const res = await client.uploadRun(payload);
141
144
  const kinds = this.options.uploadAttachments ?? DEFAULT_UPLOAD_ATTACHMENTS;
@@ -156,6 +159,7 @@ var KinoraReporter = class {
156
159
  }
157
160
  }
158
161
  console.log(`[kinora] uploaded ${res.tests} tests + ${artifacts} artifacts (run ${res.runId}${res.shardProgress ? `, ${res.shardProgress.landed} of ${res.shardProgress.total} shards` : ""})`);
162
+ if (res.incident) console.warn(`[kinora] environment incident, not counted: ${res.incident.reason}`);
159
163
  await this.maybePostPrComment(payload, res);
160
164
  } catch (err) {
161
165
  if (err instanceof IngestError && err.status === 402) console.warn(`[kinora] ${err.message}`);
@@ -198,7 +202,8 @@ var KinoraReporter = class {
198
202
  ...res.shardProgress && { shards: {
199
203
  runId: res.runId,
200
204
  ...res.shardProgress
201
- } }
205
+ } },
206
+ ...res.incident && { incident: res.incident }
202
207
  }, policy ?? "always");
203
208
  if (outcome !== "skipped") console.warn(`[kinora] PR comment ${outcome}`);
204
209
  } catch (err) {
@@ -206,5 +211,4 @@ var KinoraReporter = class {
206
211
  }
207
212
  }
208
213
  };
209
- //#endregion
210
214
  export { KinoraReporter as default };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kinora/reporter",
3
3
  "type": "module",
4
- "version": "0.11.0",
4
+ "version": "0.13.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,14 +45,14 @@
44
45
  }
45
46
  },
46
47
  "dependencies": {
47
- "@kinora/core": "0.13.0"
48
+ "@kinora/core": "0.15.0"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@axe-core/playwright": "^4.14.0",
51
52
  "@playwright/test": "^1.64.1",
52
53
  "@types/node": "^26.6.5",
53
54
  "@vitest/coverage-v8": "^5.0.5",
54
- "tsdown": "^0.23.1",
55
+ "tsdown": "^0.23.3",
55
56
  "typescript": "~6.0.3",
56
57
  "vitest": "^5.0.5"
57
58
  },