kagami-playwright-reporter 0.0.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.
@@ -0,0 +1,23 @@
1
+ import { Reporter, FullConfig, TestCase, TestResult, FullResult } from '@playwright/test/reporter';
2
+
3
+ interface PwInsightsOptions {
4
+ /** Base URL of the Insights API, e.g. https://kagami.shyim.de */
5
+ apiUrl: string;
6
+ /** OIDC audience configured on the server (must match OIDC_AUDIENCE). */
7
+ audience: string;
8
+ /** Optional: silence the reporter entirely. */
9
+ enabled?: boolean;
10
+ }
11
+ declare class PwInsightsReporter implements Reporter {
12
+ private readonly options;
13
+ private readonly specs;
14
+ private readonly uploads;
15
+ private clientIdSeq;
16
+ private active;
17
+ constructor(options: PwInsightsOptions);
18
+ onBegin(_config: FullConfig): void;
19
+ onTestEnd(test: TestCase, result: TestResult): void;
20
+ onEnd(result: FullResult): Promise<void>;
21
+ }
22
+
23
+ export { type PwInsightsOptions, PwInsightsReporter as default };
package/dist/index.js ADDED
@@ -0,0 +1,199 @@
1
+ // src/index.ts
2
+ import { readFile } from "fs/promises";
3
+
4
+ // src/oidc.ts
5
+ async function requestOidcToken(audience) {
6
+ const reqUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
7
+ const reqToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
8
+ if (!reqUrl || !reqToken) return null;
9
+ const url = new URL(reqUrl);
10
+ url.searchParams.set("audience", audience);
11
+ const res = await fetch(url, {
12
+ headers: { Authorization: `Bearer ${reqToken}` }
13
+ });
14
+ if (!res.ok) {
15
+ throw new Error(
16
+ `Failed to fetch OIDC token: ${res.status} ${await res.text()}`
17
+ );
18
+ }
19
+ const json = await res.json();
20
+ return json.value ?? null;
21
+ }
22
+
23
+ // src/index.ts
24
+ var PwInsightsReporter = class {
25
+ options;
26
+ specs = /* @__PURE__ */ new Map();
27
+ uploads = [];
28
+ clientIdSeq = 0;
29
+ active = false;
30
+ constructor(options) {
31
+ this.options = options;
32
+ }
33
+ onBegin(_config) {
34
+ this.active = this.options.enabled !== false && !!process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
35
+ if (!this.active) {
36
+ console.log(
37
+ "[pw-insights] not in GitHub Actions with id-token permission; reporter is a no-op."
38
+ );
39
+ }
40
+ }
41
+ onTestEnd(test, result) {
42
+ if (!this.active) return;
43
+ const project = test.parent.project()?.name || void 0;
44
+ const file = test.location.file;
45
+ const key = `${file}::${project ?? ""}`;
46
+ let spec = this.specs.get(key);
47
+ if (!spec) {
48
+ spec = { file, project, tests: [] };
49
+ this.specs.set(key, spec);
50
+ }
51
+ let wireTest = spec.tests.find((t) => t.testId === test.id);
52
+ if (!wireTest) {
53
+ wireTest = {
54
+ testId: test.id,
55
+ title: test.titlePath().slice(3).join(" \u203A ") || test.title,
56
+ location: `${test.location.file}:${test.location.line}:${test.location.column}`,
57
+ tags: test.tags?.length ? test.tags : void 0,
58
+ expectedStatus: test.expectedStatus,
59
+ finalStatus: mapStatus(result.status),
60
+ attempts: []
61
+ };
62
+ spec.tests.push(wireTest);
63
+ }
64
+ wireTest.finalStatus = mapStatus(result.status);
65
+ const attachments = [];
66
+ for (const a of result.attachments) {
67
+ if (!a.path) continue;
68
+ const kind = classifyAttachment(a.name, a.contentType);
69
+ const clientId = `u${this.clientIdSeq++}`;
70
+ attachments.push({
71
+ clientId,
72
+ kind,
73
+ name: a.name,
74
+ contentType: a.contentType
75
+ });
76
+ this.uploads.push({ clientId, path: a.path, contentType: a.contentType });
77
+ }
78
+ wireTest.attempts.push({
79
+ attemptIndex: result.retry,
80
+ status: mapStatus(result.status),
81
+ durationMs: result.duration,
82
+ startedAt: Math.floor(result.startTime.getTime() / 1e3),
83
+ errorMessage: result.error?.message,
84
+ errorStack: result.error?.stack,
85
+ stdout: joinChunks(result.stdout),
86
+ stderr: joinChunks(result.stderr),
87
+ steps: flattenSteps(result.steps),
88
+ attachments
89
+ });
90
+ }
91
+ async onEnd(result) {
92
+ if (!this.active) return;
93
+ try {
94
+ const token = await requestOidcToken(this.options.audience);
95
+ if (!token) {
96
+ console.warn("[pw-insights] no OIDC token; skipping upload.");
97
+ return;
98
+ }
99
+ const base = this.options.apiUrl.replace(/\/$/, "");
100
+ const auth = { Authorization: `Bearer ${token}` };
101
+ const open = await postJson(`${base}/v1/runs`, auth, {});
102
+ console.log(
103
+ `[pw-insights] run ${open.runId} (${open.repo} @ ${open.branch})`
104
+ );
105
+ const payload = {
106
+ pwVersion: process.env.PLAYWRIGHT_VERSION,
107
+ specs: [...this.specs.values()]
108
+ };
109
+ const submit = await postJson(
110
+ `${base}/v1/runs/${open.runId}/specs`,
111
+ auth,
112
+ payload
113
+ );
114
+ const urlByClient = new Map(submit.uploads.map((u) => [u.clientId, u.url]));
115
+ let uploaded = 0;
116
+ for (const up of this.uploads) {
117
+ const url = urlByClient.get(up.clientId);
118
+ if (!url) continue;
119
+ try {
120
+ const body = await readFile(up.path);
121
+ const put = await fetch(url, {
122
+ method: "PUT",
123
+ headers: up.contentType ? { "Content-Type": up.contentType } : void 0,
124
+ body
125
+ });
126
+ if (put.ok) uploaded++;
127
+ else
128
+ console.warn(
129
+ `[pw-insights] upload failed (${put.status}) for ${up.path}`
130
+ );
131
+ } catch (err) {
132
+ console.warn(`[pw-insights] could not read ${up.path}: ${err}`);
133
+ }
134
+ }
135
+ await postJson(`${base}/v1/runs/${open.runId}/complete`, auth, {
136
+ status: result.status === "passed" ? "passed" : "failed"
137
+ });
138
+ console.log(
139
+ `[pw-insights] uploaded ${uploaded}/${this.uploads.length} artifacts; run finalized.`
140
+ );
141
+ } catch (err) {
142
+ console.error(`[pw-insights] upload error: ${err.message}`);
143
+ }
144
+ }
145
+ };
146
+ function mapStatus(s) {
147
+ switch (s) {
148
+ case "passed":
149
+ case "failed":
150
+ case "timedOut":
151
+ case "skipped":
152
+ case "interrupted":
153
+ return s;
154
+ default:
155
+ return "failed";
156
+ }
157
+ }
158
+ function classifyAttachment(name, contentType) {
159
+ if (name === "trace" || contentType === "application/zip") return "trace";
160
+ if (name === "video" || contentType?.startsWith("video/")) return "video";
161
+ if (name === "screenshot" || contentType?.startsWith("image/"))
162
+ return "screenshot";
163
+ if (name === "stdout" || name === "stderr") return "stdout";
164
+ return "attachment";
165
+ }
166
+ function flattenSteps(steps) {
167
+ const out = [];
168
+ const walk = (s) => {
169
+ if (s.category === "test.step" || s.category === "expect" || s.error) {
170
+ out.push({
171
+ title: s.title,
172
+ category: s.category,
173
+ duration: s.duration,
174
+ error: s.error?.message
175
+ });
176
+ }
177
+ s.steps?.forEach(walk);
178
+ };
179
+ steps.forEach(walk);
180
+ return out;
181
+ }
182
+ function joinChunks(chunks) {
183
+ if (!chunks.length) return void 0;
184
+ return chunks.map((c) => typeof c === "string" ? c : c.toString("utf8")).join("").slice(0, 2e4);
185
+ }
186
+ async function postJson(url, auth, body) {
187
+ const res = await fetch(url, {
188
+ method: "POST",
189
+ headers: { ...auth, "Content-Type": "application/json" },
190
+ body: JSON.stringify(body)
191
+ });
192
+ if (!res.ok) {
193
+ throw new Error(`${url} -> ${res.status} ${await res.text()}`);
194
+ }
195
+ return res.json();
196
+ }
197
+ export {
198
+ PwInsightsReporter as default
199
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "kagami-playwright-reporter",
3
+ "version": "0.0.1",
4
+ "description": "Playwright reporter that uploads test runs to Kagami via GitHub OIDC — no secrets required.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist"],
15
+ "keywords": [
16
+ "playwright",
17
+ "reporter",
18
+ "kagami",
19
+ "ci",
20
+ "test-reporting",
21
+ "github-oidc"
22
+ ],
23
+ "license": "MIT",
24
+ "homepage": "https://kagami.shyim.de",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "peerDependencies": {
33
+ "@playwright/test": ">=1.40.0"
34
+ },
35
+ "devDependencies": {
36
+ "@playwright/test": "^1.49.0",
37
+ "@kagami/shared": "workspace:*",
38
+ "@types/node": "^22.10.2",
39
+ "tsup": "^8.3.5"
40
+ }
41
+ }