playwright-logbook 0.2.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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/dist/artifacts.d.ts +7 -0
  4. package/dist/artifacts.js +35 -0
  5. package/dist/artifacts.js.map +1 -0
  6. package/dist/caseids.d.ts +12 -0
  7. package/dist/caseids.js +24 -0
  8. package/dist/caseids.js.map +1 -0
  9. package/dist/cli/bin.d.ts +3 -0
  10. package/dist/cli/bin.js +6 -0
  11. package/dist/cli/bin.js.map +1 -0
  12. package/dist/cli/commands/debug.d.ts +7 -0
  13. package/dist/cli/commands/debug.js +14 -0
  14. package/dist/cli/commands/debug.js.map +1 -0
  15. package/dist/cli/commands/flaky.d.ts +8 -0
  16. package/dist/cli/commands/flaky.js +27 -0
  17. package/dist/cli/commands/flaky.js.map +1 -0
  18. package/dist/cli/commands/history.d.ts +8 -0
  19. package/dist/cli/commands/history.js +24 -0
  20. package/dist/cli/commands/history.js.map +1 -0
  21. package/dist/cli/commands/merge.d.ts +12 -0
  22. package/dist/cli/commands/merge.js +102 -0
  23. package/dist/cli/commands/merge.js.map +1 -0
  24. package/dist/cli/commands/report.d.ts +9 -0
  25. package/dist/cli/commands/report.js +13 -0
  26. package/dist/cli/commands/report.js.map +1 -0
  27. package/dist/cli/commands/summary.d.ts +7 -0
  28. package/dist/cli/commands/summary.js +14 -0
  29. package/dist/cli/commands/summary.js.map +1 -0
  30. package/dist/cli/format.d.ts +18 -0
  31. package/dist/cli/format.js +41 -0
  32. package/dist/cli/format.js.map +1 -0
  33. package/dist/cli/program.d.ts +12 -0
  34. package/dist/cli/program.js +74 -0
  35. package/dist/cli/program.js.map +1 -0
  36. package/dist/clientlib.d.ts +48 -0
  37. package/dist/clientlib.js +136 -0
  38. package/dist/clientlib.js.map +1 -0
  39. package/dist/collect.d.ts +101 -0
  40. package/dist/collect.js +50 -0
  41. package/dist/collect.js.map +1 -0
  42. package/dist/debugpacket.d.ts +31 -0
  43. package/dist/debugpacket.js +105 -0
  44. package/dist/debugpacket.js.map +1 -0
  45. package/dist/env.d.ts +41 -0
  46. package/dist/env.js +49 -0
  47. package/dist/env.js.map +1 -0
  48. package/dist/errors.d.ts +6 -0
  49. package/dist/errors.js +10 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/history.d.ts +32 -0
  52. package/dist/history.js +72 -0
  53. package/dist/history.js.map +1 -0
  54. package/dist/index.cjs +1562 -0
  55. package/dist/index.d.ts +12 -0
  56. package/dist/index.js +9 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/merge.d.ts +9 -0
  59. package/dist/merge.js +64 -0
  60. package/dist/merge.js.map +1 -0
  61. package/dist/model.d.ts +98 -0
  62. package/dist/model.js +129 -0
  63. package/dist/model.js.map +1 -0
  64. package/dist/options.d.ts +23 -0
  65. package/dist/options.js +5 -0
  66. package/dist/options.js.map +1 -0
  67. package/dist/paths.d.ts +6 -0
  68. package/dist/paths.js +17 -0
  69. package/dist/paths.js.map +1 -0
  70. package/dist/publish.d.ts +14 -0
  71. package/dist/publish.js +2 -0
  72. package/dist/publish.js.map +1 -0
  73. package/dist/render.d.ts +6 -0
  74. package/dist/render.js +50 -0
  75. package/dist/render.js.map +1 -0
  76. package/dist/reporter.d.ts +53 -0
  77. package/dist/reporter.js +161 -0
  78. package/dist/reporter.js.map +1 -0
  79. package/dist/sanitize.d.ts +11 -0
  80. package/dist/sanitize.js +33 -0
  81. package/dist/sanitize.js.map +1 -0
  82. package/dist/schema.d.ts +581 -0
  83. package/dist/schema.js +31 -0
  84. package/dist/schema.js.map +1 -0
  85. package/dist/signals.d.ts +9 -0
  86. package/dist/signals.js +37 -0
  87. package/dist/signals.js.map +1 -0
  88. package/dist/store.d.ts +41 -0
  89. package/dist/store.js +142 -0
  90. package/dist/store.js.map +1 -0
  91. package/dist/template/css.d.ts +1 -0
  92. package/dist/template/css.js +17 -0
  93. package/dist/template/css.js.map +1 -0
  94. package/dist/template/icons.d.ts +1 -0
  95. package/dist/template/icons.js +9 -0
  96. package/dist/template/icons.js.map +1 -0
  97. package/dist/template/js/core.d.ts +1 -0
  98. package/dist/template/js/core.js +90 -0
  99. package/dist/template/js/core.js.map +1 -0
  100. package/dist/template/js/failures.d.ts +1 -0
  101. package/dist/template/js/failures.js +33 -0
  102. package/dist/template/js/failures.js.map +1 -0
  103. package/dist/template/js/help.d.ts +1 -0
  104. package/dist/template/js/help.js +4 -0
  105. package/dist/template/js/help.js.map +1 -0
  106. package/dist/template/js/panel.d.ts +1 -0
  107. package/dist/template/js/panel.js +84 -0
  108. package/dist/template/js/panel.js.map +1 -0
  109. package/dist/template/js/project.d.ts +1 -0
  110. package/dist/template/js/project.js +40 -0
  111. package/dist/template/js/project.js.map +1 -0
  112. package/dist/template/js/run.d.ts +1 -0
  113. package/dist/template/js/run.js +38 -0
  114. package/dist/template/js/run.js.map +1 -0
  115. package/dist/template/js/tests.d.ts +1 -0
  116. package/dist/template/js/tests.js +23 -0
  117. package/dist/template/js/tests.js.map +1 -0
  118. package/dist/template/js/trends.d.ts +1 -0
  119. package/dist/template/js/trends.js +57 -0
  120. package/dist/template/js/trends.js.map +1 -0
  121. package/dist/template/tokens.d.ts +45 -0
  122. package/dist/template/tokens.js +12 -0
  123. package/dist/template/tokens.js.map +1 -0
  124. package/dist/template.d.ts +2 -0
  125. package/dist/template.js +19 -0
  126. package/dist/template.js.map +1 -0
  127. package/dist/version.d.ts +1 -0
  128. package/dist/version.js +2 -0
  129. package/dist/version.js.map +1 -0
  130. package/package.json +63 -0
package/dist/index.cjs ADDED
@@ -0,0 +1,1562 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ FileHistoryStore: () => FileHistoryStore,
34
+ FileShardSink: () => FileShardSink,
35
+ LogbookReporter: () => LogbookReporter,
36
+ SCHEMA_VERSION: () => SCHEMA_VERSION,
37
+ VERSION: () => VERSION,
38
+ buildJsonSummary: () => buildJsonSummary,
39
+ buildReportModel: () => buildReportModel,
40
+ compareRuns: () => compareRuns,
41
+ computeFlaky: () => computeFlaky,
42
+ default: () => LogbookReporter,
43
+ mergeShards: () => mergeShards,
44
+ previousRun: () => previousRun,
45
+ renderMarkdownSummary: () => renderMarkdownSummary,
46
+ renderReport: () => renderReport,
47
+ renderTextSummary: () => renderTextSummary,
48
+ runRecordSchema: () => runRecordSchema,
49
+ runSummaryRecordSchema: () => runSummaryRecordSchema,
50
+ shardFileSchema: () => shardFileSchema,
51
+ stepRecordSchema: () => stepRecordSchema,
52
+ testRecordSchema: () => testRecordSchema
53
+ });
54
+ module.exports = __toCommonJS(index_exports);
55
+
56
+ // src/reporter.ts
57
+ var import_node_path6 = __toESM(require("node:path"), 1);
58
+ var import_promises3 = __toESM(require("node:fs/promises"), 1);
59
+
60
+ // src/caseids.ts
61
+ var DEFAULT_CASE_ID_PATTERN = "\\b[A-Z][A-Z0-9]{1,9}-\\d+\\b";
62
+ function extractCaseIds(input, patterns) {
63
+ const values = /* @__PURE__ */ new Set();
64
+ const expressions = (patterns ?? [DEFAULT_CASE_ID_PATTERN]).map((source) => new RegExp(source, "g"));
65
+ const collect = (source) => {
66
+ for (const expression of expressions) {
67
+ expression.lastIndex = 0;
68
+ for (const match of source.matchAll(expression)) values.add(match[0]);
69
+ }
70
+ };
71
+ for (const tag of input.tags ?? []) if (tag.startsWith("@")) collect(tag.slice(1));
72
+ const allowed = /^(testcase|test_case|case|jira|issue|xray|zephyr|tms)$/i;
73
+ for (const annotation of input.annotations ?? []) if (allowed.test(annotation.type) && annotation.description) collect(annotation.description);
74
+ for (const match of input.title.matchAll(/\[([^\]]+)\]/g)) collect(match[1] ?? "");
75
+ return [...values].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
76
+ }
77
+
78
+ // src/sanitize.ts
79
+ var ANSI = /\u001b\[[0-9;]*[A-Za-z]/g;
80
+ var SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL)/i;
81
+ function redactValue(text, value) {
82
+ return value.length >= 8 ? text.split(value).join("[redacted]") : text;
83
+ }
84
+ function sanitize(text, options = {}) {
85
+ let result = text.replace(ANSI, "");
86
+ if (options.projectRoot) {
87
+ const root = options.projectRoot.replace(/[\\/]$/, "");
88
+ result = result.split(`${root}/`).join("").split(`${root}\\`).join("");
89
+ }
90
+ const values = Object.entries(options.env ?? {}).filter(([name, value]) => SECRET_NAME.test(name) && value && value.length >= 8).map(([, value]) => value).sort((a, b) => b.length - a.length);
91
+ for (const value of values) result = redactValue(result, value);
92
+ for (const pattern of options.redact ?? []) {
93
+ const source = typeof pattern === "string" ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g") : new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
94
+ result = result.replace(source, "[redacted]");
95
+ }
96
+ const max = options.maxTextLength ?? Infinity;
97
+ if (result.length <= max) return result;
98
+ const removed = result.length - max;
99
+ return `${result.slice(0, max)}\u2026 [truncated ${removed} chars]`;
100
+ }
101
+ function stripUrlUserinfo(value) {
102
+ return value.replace(/(https?:\/\/)(?:[^/@]+)@/gi, "$1");
103
+ }
104
+
105
+ // src/paths.ts
106
+ var import_node_path = __toESM(require("node:path"), 1);
107
+ function pathModule(...values) {
108
+ return values.some((value) => /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\")) ? import_node_path.default.win32 : import_node_path.default.posix;
109
+ }
110
+ function toRel(projectRoot, value) {
111
+ return pathModule(projectRoot, value).relative(projectRoot, value).replaceAll("\\", "/");
112
+ }
113
+ function toRelOrNull(projectRoot, value) {
114
+ const api = pathModule(projectRoot, value);
115
+ const relative = api.relative(projectRoot, value);
116
+ return relative === "" || !relative.startsWith("..") && !api.isAbsolute(relative) ? relative.replaceAll("\\", "/") : null;
117
+ }
118
+
119
+ // src/collect.ts
120
+ var import_node_fs = require("node:fs");
121
+ var import_node_path2 = __toESM(require("node:path"), 1);
122
+ var import_node_crypto = require("node:crypto");
123
+ function outputTail(items, ctx) {
124
+ if (!items?.length) return null;
125
+ const clean2 = sanitize(items.map((item) => typeof item === "string" ? item : Buffer.from(item).toString("utf8")).join(""), { projectRoot: ctx.projectRoot, env: ctx.env, redact: ctx.redact, maxTextLength: Number.MAX_SAFE_INTEGER });
126
+ const limit = Math.max(0, ctx.maxOutputLength ?? 2e3);
127
+ return clean2.length > limit ? `\u2026 [truncated ${clean2.length - limit} chars]
128
+ ${limit ? clean2.slice(-limit) : ""}` : clean2;
129
+ }
130
+ var sort = (a, b) => a < b ? -1 : a > b ? 1 : 0;
131
+ var error = (item, ctx) => ({ message: sanitize(item.message ?? "", { projectRoot: ctx.projectRoot, env: ctx.env, redact: ctx.redact, maxTextLength: ctx.maxTextLength }), stack: item.stack ? sanitize(item.stack, { projectRoot: ctx.projectRoot, env: ctx.env, redact: ctx.redact, maxTextLength: ctx.maxTextLength }) : null, snippet: item.snippet ? sanitize(item.snippet, { projectRoot: ctx.projectRoot, env: ctx.env, redact: ctx.redact, maxTextLength: ctx.maxTextLength ? Math.floor(ctx.maxTextLength / 2) : void 0 }) : null, location: item.location ? { ...item.location, file: toRel(ctx.projectRoot, item.location.file) } : null });
132
+ function projectInfo(config, root) {
133
+ let name = null;
134
+ try {
135
+ const parsed = JSON.parse((0, import_node_fs.readFileSync)(import_node_path2.default.join(root, "package.json"), "utf8"));
136
+ if (typeof parsed === "object" && parsed !== null && "name" in parsed && typeof parsed.name === "string") name = parsed.name;
137
+ } catch {
138
+ }
139
+ return { name, configFile: config.configFile ? toRel(root, config.configFile) : null, projects: config.projects.map((project) => ({ name: project.name, testDir: toRel(root, project.testDir) })), workers: config.workers };
140
+ }
141
+ function buildShardFile(input) {
142
+ const { ctx } = input;
143
+ const tests = input.tests.map((test) => {
144
+ const describe = [];
145
+ let suite = test.parent;
146
+ while (suite) {
147
+ if (suite.type === "describe") describe.unshift(suite.title);
148
+ suite = suite.parent;
149
+ }
150
+ const capture = ctx.captureDetails === true ? { steps: true, output: true, images: true } : ctx.captureDetails || {};
151
+ const attempts = test.results.map((result) => ({ retry: result.retry, status: result.status, durationMs: result.duration, startedAt: result.startTime.toISOString(), workerIndex: result.workerIndex, errors: result.errors.map((item) => error(item, ctx)), attachments: result.attachments.map((item) => ({ name: item.name, contentType: item.contentType, path: item.path ? toRelOrNull(ctx.projectRoot, item.path) : null, inline: item.body !== void 0 && !item.path, sizeBytes: item.body?.length ?? null, ...capture.images && (test.outcome() === "unexpected" || test.outcome() === "flaky") && ctx.imageData?.has(item) ? { dataUri: ctx.imageData.get(item) } : {} })), ...capture.steps ? { steps: (ctx.stepsByResult?.get(result) ?? []).slice(0, ctx.maxSteps ?? 100) } : {}, ...capture.output ? { stdout: outputTail(result.stdout, ctx), stderr: outputTail(result.stderr, ctx) } : {} }));
152
+ const lastWithError = [...attempts].reverse().find((item) => item.errors.length > 0);
153
+ const tags = [...new Set(test.tags ?? (test.title.match(/(?:^|\s)(@[\w:-]+)/g) ?? []).map((tag) => tag.trim()))].sort(sort);
154
+ const annotations = (test.annotations ?? []).map((item) => ({ type: item.type, description: item.description ? sanitize(item.description, { projectRoot: ctx.projectRoot, env: ctx.env, redact: ctx.redact }) : null }));
155
+ const titlePath = [...describe, test.title];
156
+ const file = toRel(ctx.projectRoot, test.location.file);
157
+ const project = test.parent.project?.()?.name ?? "";
158
+ const testId = test.id || (0, import_node_crypto.createHash)("sha1").update([project, file, ...titlePath].join("|")).digest("hex");
159
+ return { testId, title: test.title, titlePath, file, line: test.location.line, column: test.location.column, project, tags, annotations, caseIds: extractCaseIds({ title: test.title, tags, annotations }, ctx.caseIdPatterns), expectedStatus: test.expectedStatus, outcome: test.outcome(), status: attempts.at(-1)?.status ?? "skipped", durationMs: attempts.reduce((sum, item) => sum + item.durationMs, 0), finalDurationMs: attempts.at(-1)?.durationMs ?? 0, attemptCount: attempts.length, repeatEachIndex: test.repeatEachIndex ?? 0, firstError: lastWithError?.errors[0] ?? null, attempts };
160
+ }).sort((a, b) => sort(a.file, b.file) || a.line - b.line || sort(a.project, b.project) || sort(a.testId, b.testId));
161
+ return { schemaVersion: 1, kind: "shard", runId: input.runId, title: input.title ?? null, shard: input.config.shard ?? null, startedAt: input.startedAt.toISOString(), endedAt: input.endedAt.toISOString(), status: input.status, env: input.env, project: input.project ?? projectInfo(input.config, ctx.projectRoot), tests, globalErrors: (input.globalErrors ?? []).map((item) => error(item, ctx)) };
162
+ }
163
+
164
+ // src/env.ts
165
+ var import_node_os = __toESM(require("node:os"), 1);
166
+ var import_node_crypto2 = require("node:crypto");
167
+ var import_node_child_process = require("node:child_process");
168
+ var number = (value) => value && /^\d+$/.test(value) ? Number(value) : null;
169
+ var clean = (value) => value.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 100);
170
+ var compact = (date) => date.toISOString().slice(0, 19).replace(/[-:]/g, "") + "Z";
171
+ var defaultExec = (command, args, options) => (0, import_node_child_process.execFileSync)(command, args, { cwd: options.cwd, timeout: options.timeout, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
172
+ function ciFields(env, stamp) {
173
+ if (env.GITHUB_ACTIONS) return { provider: "github", id: `gh-${env.GITHUB_RUN_ID ?? "unknown"}-${env.GITHUB_RUN_ATTEMPT ?? "1"}`, buildId: env.GITHUB_RUN_ID ?? null, url: env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : null, branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || null, commit: env.GITHUB_SHA ?? null, pr: number(env.GITHUB_REF?.match(/^refs\/pull\/(\d+)\/merge$/)?.[1]) };
174
+ if (env.GITLAB_CI) return { provider: "gitlab", id: `gl-${env.CI_PIPELINE_ID ?? "unknown"}`, buildId: env.CI_PIPELINE_ID ?? null, url: env.CI_PIPELINE_URL ?? null, branch: env.CI_COMMIT_REF_NAME ?? null, commit: env.CI_COMMIT_SHA ?? null, pr: number(env.CI_MERGE_REQUEST_IID) };
175
+ if (env.TF_BUILD) return { provider: "azure", id: `ado-${env.BUILD_BUILDID ?? "unknown"}`, buildId: env.BUILD_BUILDID ?? null, url: env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI && env.SYSTEM_TEAMPROJECT && env.BUILD_BUILDID ? `${env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI}${env.SYSTEM_TEAMPROJECT}/_build/results?buildId=${env.BUILD_BUILDID}` : null, branch: env.BUILD_SOURCEBRANCHNAME ?? null, commit: env.BUILD_SOURCEVERSION ?? null, pr: number(env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER) };
176
+ if (env.JENKINS_URL) return { provider: "jenkins", id: `jk-${env.JOB_NAME ?? "unknown"}-${env.BUILD_NUMBER ?? "unknown"}`, buildId: env.BUILD_NUMBER ?? null, url: env.BUILD_URL ?? null, branch: env.GIT_BRANCH ?? null, commit: env.GIT_COMMIT ?? null, pr: number(env.CHANGE_ID) };
177
+ if (env.CIRCLECI) return { provider: "circleci", id: `cci-${env.CIRCLE_WORKFLOW_ID ?? "unknown"}`, buildId: env.CIRCLE_BUILD_NUM ?? null, url: env.CIRCLE_BUILD_URL ?? null, branch: env.CIRCLE_BRANCH ?? null, commit: env.CIRCLE_SHA1 ?? null, pr: number(env.CIRCLE_PULL_REQUEST?.match(/(\d+)$/)?.[1]) };
178
+ if (env.BITBUCKET_BUILD_NUMBER) return { provider: "bitbucket", id: `bb-${env.BITBUCKET_BUILD_NUMBER}`, buildId: env.BITBUCKET_BUILD_NUMBER, url: null, branch: env.BITBUCKET_BRANCH ?? null, commit: env.BITBUCKET_COMMIT ?? null, pr: number(env.BITBUCKET_PR_ID) };
179
+ if (env.CI) return { provider: "other", id: `ci-${stamp}`, buildId: null, url: null, branch: null, commit: null, pr: null };
180
+ return { provider: null, id: `local-${stamp}`, buildId: null, url: null, branch: null, commit: null, pr: null };
181
+ }
182
+ function detectEnv(options = {}) {
183
+ const env = options.env ?? process.env;
184
+ const clock = options.clock ?? (() => /* @__PURE__ */ new Date());
185
+ const random = options.random ?? (() => (0, import_node_crypto2.randomUUID)());
186
+ const fields = ciFields(env, compact(clock()));
187
+ const readGit = (args) => {
188
+ try {
189
+ return (options.exec ?? defaultExec)("git", args, { cwd: options.root ?? process.cwd(), timeout: 3e3 }).trim() || null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ };
194
+ const branch = fields.branch ?? readGit(["rev-parse", "--abbrev-ref", "HEAD"]);
195
+ const repository = readGit(["config", "--get", "remote.origin.url"]);
196
+ return {
197
+ runId: clean(options.runId ?? env.LOGBOOK_RUN_ID ?? (fields.provider === null || fields.provider === "other" ? `${fields.id}-${random().slice(0, 4)}` : fields.id)),
198
+ ci: fields.provider ? { provider: fields.provider, buildId: fields.buildId, buildUrl: fields.url ? stripUrlUserinfo(fields.url) : null } : null,
199
+ git: { commit: fields.commit ?? readGit(["rev-parse", "HEAD"]), branch: branch === "HEAD" ? null : branch, prNumber: fields.pr, repository: repository ? stripUrlUserinfo(repository) : null },
200
+ machine: options.machine ?? { os: process.platform, arch: process.arch, node: process.versions.node, cpus: import_node_os.default.cpus().length }
201
+ };
202
+ }
203
+
204
+ // src/options.ts
205
+ function resolveOptions(options = {}) {
206
+ return { outputDir: ".logbook", autoMerge: true, autoReport: true, historyLimit: 30, maxTextLength: 4e3, quiet: false, captureDetails: false, maxSteps: 100, maxOutputLength: 2e3, maxImageBytes: 25e4, maxEmbeddedBytes: 5e6, ...options };
207
+ }
208
+
209
+ // src/store.ts
210
+ var import_promises = __toESM(require("node:fs/promises"), 1);
211
+ var import_node_path3 = __toESM(require("node:path"), 1);
212
+
213
+ // src/schema.ts
214
+ var import_zod = require("zod");
215
+
216
+ // src/errors.ts
217
+ var LogbookError = class extends Error {
218
+ code;
219
+ constructor(code, message) {
220
+ super(message);
221
+ this.name = "LogbookError";
222
+ this.code = code;
223
+ }
224
+ };
225
+
226
+ // src/schema.ts
227
+ var SCHEMA_VERSION = 1;
228
+ var statusSchema = import_zod.z.enum(["passed", "failed", "timedOut", "skipped", "interrupted"]);
229
+ var outcomeSchema = import_zod.z.enum(["expected", "unexpected", "flaky", "skipped"]);
230
+ var runStatusSchema = import_zod.z.enum(["passed", "failed", "timedout", "interrupted"]);
231
+ var errorRecordSchema = import_zod.z.object({ message: import_zod.z.string(), stack: import_zod.z.string().nullable(), snippet: import_zod.z.string().nullable(), location: import_zod.z.object({ file: import_zod.z.string(), line: import_zod.z.number(), column: import_zod.z.number() }).nullable() });
232
+ var stepRecordSchema = import_zod.z.object({ title: import_zod.z.string(), category: import_zod.z.string(), durationMs: import_zod.z.number(), depth: import_zod.z.number().int().min(0).max(2), failed: import_zod.z.boolean() });
233
+ var attachmentRecordSchema = import_zod.z.object({ name: import_zod.z.string(), contentType: import_zod.z.string(), path: import_zod.z.string().nullable(), inline: import_zod.z.boolean(), sizeBytes: import_zod.z.number().nullable(), dataUri: import_zod.z.string().nullable().optional() });
234
+ var attemptRecordSchema = import_zod.z.object({ retry: import_zod.z.number(), status: statusSchema, durationMs: import_zod.z.number(), startedAt: import_zod.z.string(), workerIndex: import_zod.z.number(), errors: import_zod.z.array(errorRecordSchema), attachments: import_zod.z.array(attachmentRecordSchema), steps: import_zod.z.array(stepRecordSchema).optional(), stdout: import_zod.z.string().nullable().optional(), stderr: import_zod.z.string().nullable().optional() });
235
+ var annotationSchema = import_zod.z.object({ type: import_zod.z.string(), description: import_zod.z.string().nullable() });
236
+ var testRecordSchema = import_zod.z.object({ testId: import_zod.z.string(), title: import_zod.z.string(), titlePath: import_zod.z.array(import_zod.z.string()), file: import_zod.z.string(), line: import_zod.z.number(), column: import_zod.z.number(), project: import_zod.z.string(), tags: import_zod.z.array(import_zod.z.string()), annotations: import_zod.z.array(annotationSchema), caseIds: import_zod.z.array(import_zod.z.string()), expectedStatus: statusSchema, outcome: outcomeSchema, status: statusSchema, durationMs: import_zod.z.number(), finalDurationMs: import_zod.z.number(), attemptCount: import_zod.z.number(), repeatEachIndex: import_zod.z.number(), firstError: errorRecordSchema.nullable(), attempts: import_zod.z.array(attemptRecordSchema) });
237
+ var envInfoSchema = import_zod.z.object({ ci: import_zod.z.object({ provider: import_zod.z.enum(["github", "gitlab", "azure", "jenkins", "circleci", "bitbucket", "other"]), buildId: import_zod.z.string().nullable(), buildUrl: import_zod.z.string().nullable() }).nullable(), git: import_zod.z.object({ commit: import_zod.z.string().nullable(), branch: import_zod.z.string().nullable(), prNumber: import_zod.z.number().nullable(), repository: import_zod.z.string().nullable() }), machine: import_zod.z.object({ os: import_zod.z.string(), arch: import_zod.z.string(), node: import_zod.z.string(), cpus: import_zod.z.number() }), playwrightVersion: import_zod.z.string(), workers: import_zod.z.number() });
238
+ var projectInfoSchema = import_zod.z.object({ name: import_zod.z.string().nullable(), configFile: import_zod.z.string().nullable(), projects: import_zod.z.array(import_zod.z.object({ name: import_zod.z.string(), testDir: import_zod.z.string() })), workers: import_zod.z.number() });
239
+ var summarySchema = import_zod.z.object({ total: import_zod.z.number(), passed: import_zod.z.number(), failed: import_zod.z.number(), flaky: import_zod.z.number(), skipped: import_zod.z.number() });
240
+ var shardFileSchema = import_zod.z.object({ schemaVersion: import_zod.z.literal(SCHEMA_VERSION), kind: import_zod.z.literal("shard"), runId: import_zod.z.string(), title: import_zod.z.string().nullable(), shard: import_zod.z.object({ current: import_zod.z.number(), total: import_zod.z.number() }).nullable(), startedAt: import_zod.z.string(), endedAt: import_zod.z.string(), status: runStatusSchema, env: envInfoSchema, project: projectInfoSchema, tests: import_zod.z.array(testRecordSchema), globalErrors: import_zod.z.array(errorRecordSchema) });
241
+ var runRecordSchema = import_zod.z.object({ schemaVersion: import_zod.z.literal(SCHEMA_VERSION), kind: import_zod.z.literal("run"), runId: import_zod.z.string(), title: import_zod.z.string().nullable(), startedAt: import_zod.z.string(), endedAt: import_zod.z.string(), durationMs: import_zod.z.number(), status: runStatusSchema, complete: import_zod.z.boolean(), expectedShards: import_zod.z.number().nullable(), receivedShards: import_zod.z.array(import_zod.z.number()), env: envInfoSchema, project: projectInfoSchema, paths: import_zod.z.object({ outputDir: import_zod.z.string() }), summary: summarySchema, tests: import_zod.z.array(testRecordSchema), globalErrors: import_zod.z.array(errorRecordSchema) });
242
+ var runSummaryRecordSchema = import_zod.z.object({ schemaVersion: import_zod.z.literal(SCHEMA_VERSION), runId: import_zod.z.string(), title: import_zod.z.string().nullable(), startedAt: import_zod.z.string(), durationMs: import_zod.z.number(), status: runStatusSchema, complete: import_zod.z.boolean(), summary: summarySchema, branch: import_zod.z.string().nullable(), commit: import_zod.z.string().nullable(), ciProvider: import_zod.z.string().nullable(), buildUrl: import_zod.z.string().nullable() });
243
+ function validate(schema, value, filePath, kind) {
244
+ const parsed = schema.safeParse(value);
245
+ if (!parsed.success) throw new LogbookError("INVALID_DATA", `${filePath}: invalid ${kind} data`);
246
+ return parsed.data;
247
+ }
248
+ function readRun(value, filePath = "<input>") {
249
+ return validate(runRecordSchema, value, filePath, "run");
250
+ }
251
+
252
+ // src/store.ts
253
+ var exists = (error2) => typeof error2 === "object" && error2 !== null && "code" in error2 && error2.code === "EEXIST";
254
+ var safeId = (id) => /^[A-Za-z0-9._-]+$/.test(id) && id !== "." && id !== "..";
255
+ async function writeRecord(target, body, kind, files, replace = false) {
256
+ await files.mkdir(import_node_path3.default.dirname(target), { recursive: true });
257
+ const tempDir = await files.mkdtemp(import_node_path3.default.join(import_node_path3.default.dirname(target), ".logbook-tmp-"));
258
+ try {
259
+ const temp = import_node_path3.default.join(tempDir, "record.json");
260
+ await files.writeFile(temp, body, "utf8");
261
+ if (replace) {
262
+ await files.rename(temp, target);
263
+ return "replaced";
264
+ }
265
+ try {
266
+ await files.link(temp, target);
267
+ return "created";
268
+ } catch (error2) {
269
+ if (!exists(error2)) throw error2;
270
+ const old = await files.readFile(target, "utf8");
271
+ if (old === body) return "identical";
272
+ throw new LogbookError(kind === "run" ? "RUN_CONFLICT" : "SHARD_CONFLICT", `${kind} ${import_node_path3.default.basename(target)} already exists with different content`);
273
+ }
274
+ } finally {
275
+ await files.rm(tempDir, { recursive: true, force: true });
276
+ }
277
+ }
278
+ var FileShardSink = class {
279
+ constructor(outputDir, files = import_promises.default) {
280
+ this.outputDir = outputDir;
281
+ this.files = files;
282
+ }
283
+ outputDir;
284
+ files;
285
+ async write(shard) {
286
+ if (!safeId(shard.runId)) throw new LogbookError("INVALID_DATA", "invalid run id");
287
+ const current = shard.shard?.current ?? 1;
288
+ const total = shard.shard?.total ?? 1;
289
+ const relative = `shards/${shard.runId}/shard-${current}-of-${total}.json`;
290
+ const target = import_node_path3.default.join(this.outputDir, relative);
291
+ await writeRecord(target, `${JSON.stringify(shard, null, 2)}
292
+ `, "shard", { ...import_promises.default, ...this.files });
293
+ return relative;
294
+ }
295
+ };
296
+ function summaryOf(run) {
297
+ return { schemaVersion: 1, runId: run.runId, title: run.title, startedAt: run.startedAt, durationMs: run.durationMs, status: run.status, complete: run.complete, summary: run.summary, branch: run.env.git.branch, commit: run.env.git.commit, ciProvider: run.env.ci?.provider ?? null, buildUrl: run.env.ci?.buildUrl ?? null };
298
+ }
299
+ var missing = (error2) => typeof error2 === "object" && error2 !== null && "code" in error2 && error2.code === "ENOENT";
300
+ var compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
301
+ var FileHistoryStore = class {
302
+ constructor(outputDir) {
303
+ this.outputDir = outputDir;
304
+ }
305
+ outputDir;
306
+ async saveRun(run, options = {}) {
307
+ if (!safeId(run.runId)) throw new LogbookError("INVALID_DATA", "invalid run id");
308
+ const target = import_node_path3.default.join(this.outputDir, "runs", `${run.runId}.json`);
309
+ const outcome = await writeRecord(target, `${JSON.stringify(run, null, 2)}
310
+ `, "run", import_promises.default, options.replace);
311
+ if (outcome !== "identical") await import_promises.default.appendFile(import_node_path3.default.join(this.outputDir, "index.jsonl"), `${JSON.stringify(summaryOf(run))}
312
+ `, "utf8");
313
+ }
314
+ async summariesFromFiles() {
315
+ let names;
316
+ try {
317
+ names = await import_promises.default.readdir(import_node_path3.default.join(this.outputDir, "runs"));
318
+ } catch (error2) {
319
+ if (missing(error2)) return [];
320
+ throw error2;
321
+ }
322
+ const summaries = [];
323
+ for (const name of names.filter((entry) => entry.endsWith(".json")).sort(compare)) {
324
+ try {
325
+ summaries.push(summaryOf(await this.loadRun(name.slice(0, -5))));
326
+ } catch {
327
+ }
328
+ }
329
+ return summaries;
330
+ }
331
+ async listSummaries(opts = {}) {
332
+ let records;
333
+ try {
334
+ const text = await import_promises.default.readFile(import_node_path3.default.join(this.outputDir, "index.jsonl"), "utf8");
335
+ records = text.split("\n").flatMap((line) => {
336
+ try {
337
+ const parsed = runSummaryRecordSchema.safeParse(JSON.parse(line));
338
+ return parsed.success ? [parsed.data] : [];
339
+ } catch {
340
+ return [];
341
+ }
342
+ });
343
+ } catch (error2) {
344
+ if (!missing(error2)) throw error2;
345
+ records = await this.summariesFromFiles();
346
+ }
347
+ const latest = new Map(records.map((record) => [record.runId, record]));
348
+ return [...latest.values()].filter((record) => opts.branch === void 0 || record.branch === opts.branch).sort((a, b) => compare(b.startedAt, a.startedAt) || compare(a.runId, b.runId)).slice(0, opts.limit ?? 30);
349
+ }
350
+ async loadRun(idOrLatest) {
351
+ const id = idOrLatest === "latest" ? (await this.listSummaries({ limit: 1 }))[0]?.runId : idOrLatest;
352
+ if (!id) throw new LogbookError("NO_DATA", "no runs available");
353
+ if (!/^[A-Za-z0-9._-]+$/.test(id) || id === "..") throw new LogbookError("RUN_NOT_FOUND", `run ${id} not found`);
354
+ const target = import_node_path3.default.join(this.outputDir, "runs", `${id}.json`);
355
+ let data;
356
+ try {
357
+ data = await import_promises.default.readFile(target, "utf8");
358
+ } catch (error2) {
359
+ if (missing(error2)) throw new LogbookError("RUN_NOT_FOUND", `run ${id} not found`);
360
+ throw error2;
361
+ }
362
+ try {
363
+ return readRun(JSON.parse(data), target);
364
+ } catch (error2) {
365
+ if (error2 instanceof LogbookError) throw error2;
366
+ throw new LogbookError("INVALID_DATA", `${target}: invalid run data`);
367
+ }
368
+ }
369
+ async loadRuns(runIds) {
370
+ return Promise.all(runIds.map((id) => this.loadRun(id)));
371
+ }
372
+ };
373
+
374
+ // src/merge.ts
375
+ var compare2 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
376
+ var numberOf = (shard) => shard.shard?.current ?? 1;
377
+ var totalOf = (shard) => shard.shard?.total ?? 1;
378
+ var testOrder = (a, b) => compare2(a.file, b.file) || a.line - b.line || compare2(a.project, b.project) || compare2(a.testId, b.testId);
379
+ function selectedShards(input, force, warnings) {
380
+ const byNumber = /* @__PURE__ */ new Map();
381
+ for (const shard of [...input].sort((a, b) => numberOf(a) - numberOf(b) || compare2(a.endedAt, b.endedAt) || compare2(a.runId, b.runId))) {
382
+ const previous = byNumber.get(numberOf(shard));
383
+ if (previous && !force) throw new LogbookError("DUPLICATE_SHARD", `duplicate shard ${numberOf(shard)}`);
384
+ if (previous) warnings.push(`duplicate shard ${numberOf(shard)}; kept latest`);
385
+ byNumber.set(numberOf(shard), shard);
386
+ }
387
+ return [...byNumber.values()].sort((a, b) => numberOf(a) - numberOf(b));
388
+ }
389
+ function mergedTests(shards, warnings) {
390
+ const seen = /* @__PURE__ */ new Set();
391
+ const tests = [];
392
+ for (const shard of shards) for (const test of shard.tests) {
393
+ const key = `${test.testId}#${test.repeatEachIndex}`;
394
+ if (seen.has(key)) {
395
+ warnings.push(`duplicate test ${key}; kept first`);
396
+ continue;
397
+ }
398
+ seen.add(key);
399
+ tests.push(test);
400
+ }
401
+ return tests.sort(testOrder);
402
+ }
403
+ function mergeShards(shards, opts = {}) {
404
+ if (shards.length === 0) throw new LogbookError("NO_DATA", "no shards to merge");
405
+ const force = opts.force ?? false;
406
+ const warnings = [];
407
+ const total = totalOf(shards[0]);
408
+ if (shards.some((shard) => totalOf(shard) !== total)) throw new LogbookError("SHARD_MISMATCH", "shard totals differ");
409
+ const newest = [...shards].sort((a, b) => compare2(b.endedAt, a.endedAt) || compare2(a.runId, b.runId))[0];
410
+ const runId = newest.runId;
411
+ if (shards.some((shard) => shard.runId !== runId)) {
412
+ if (!force) throw new LogbookError("SHARD_MISMATCH", "shard run IDs differ");
413
+ warnings.push(`shard run IDs differ; used ${runId}`);
414
+ }
415
+ const selected = selectedShards(shards, force, warnings);
416
+ const tests = mergedTests(selected, warnings);
417
+ const first = selected[0];
418
+ const startedAt = [...selected].map((shard) => shard.startedAt).sort(compare2)[0];
419
+ const endedAt = [...selected].map((shard) => shard.endedAt).sort(compare2).at(-1);
420
+ const summary = { total: tests.length, passed: tests.filter((test) => test.outcome === "expected").length, failed: tests.filter((test) => test.outcome === "unexpected").length, flaky: tests.filter((test) => test.outcome === "flaky").length, skipped: tests.filter((test) => test.outcome === "skipped").length };
421
+ const status2 = selected.some((shard) => shard.status === "interrupted") ? "interrupted" : selected.some((shard) => shard.status === "timedout") ? "timedout" : selected.some((shard) => shard.status === "failed") || summary.failed > 0 ? "failed" : "passed";
422
+ const errors = /* @__PURE__ */ new Map();
423
+ for (const shard of selected) for (const error2 of shard.globalErrors) if (!errors.has(error2.message)) errors.set(error2.message, error2);
424
+ const receivedShards = selected.map(numberOf);
425
+ return { run: { schemaVersion: 1, kind: "run", runId, title: first.title, startedAt, endedAt, durationMs: new Date(endedAt).getTime() - new Date(startedAt).getTime(), status: status2, complete: receivedShards.length === total, expectedShards: total, receivedShards, env: first.env, project: first.project, paths: { outputDir: opts.outputDir ?? ".logbook" }, summary, tests, globalErrors: [...errors.values()] }, warnings };
426
+ }
427
+
428
+ // src/history.ts
429
+ var compare3 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
430
+ function previousRun(summaries, current) {
431
+ const eligible = summaries.filter((item) => item.complete && item.runId !== current.runId && item.startedAt < current.startedAt).sort((a, b) => compare3(b.startedAt, a.startedAt) || compare3(a.runId, b.runId));
432
+ return (current.branch ? eligible.find((item) => item.branch === current.branch) : void 0) ?? eligible[0] ?? null;
433
+ }
434
+ function testResultKind(test) {
435
+ return test.outcome === "expected" ? "pass" : test.outcome === "unexpected" ? "fail" : test.outcome === "flaky" ? "flaky" : "skip";
436
+ }
437
+ function computeFlaky(runsOldestFirst, opts = {}) {
438
+ const entries = /* @__PURE__ */ new Map();
439
+ for (const run of runsOldestFirst) for (const test of run.tests) {
440
+ const kind = testResultKind(test);
441
+ if (kind === "skip") continue;
442
+ const identity2 = { testId: test.testId, title: test.title, file: test.file, project: test.project };
443
+ const prior = entries.get(test.testId);
444
+ if (prior) {
445
+ prior.identity = identity2;
446
+ prior.kinds.push(kind);
447
+ } else entries.set(test.testId, { identity: identity2, kinds: [kind] });
448
+ }
449
+ const found = [];
450
+ for (const { identity: identity2, kinds } of entries.values()) {
451
+ const passes = kinds.filter((kind) => kind !== "fail").length;
452
+ const fails = kinds.length - passes;
453
+ const flakyRuns = kinds.filter((kind) => kind === "flaky").length;
454
+ const flips = kinds.slice(1).filter((kind, index) => kind === "fail" !== (kinds[index] === "fail")).length;
455
+ if (kinds.length < (opts.minRuns ?? 3) || !(flakyRuns > 0 || fails > 0 && passes > 0 && flips >= 2)) continue;
456
+ found.push({ ...identity2, runs: kinds.length, passes, fails, flakyRuns, flips, score: Math.min(1, (flakyRuns + flips) / kinds.length) });
457
+ }
458
+ return found.sort((a, b) => b.score - a.score || compare3(a.testId, b.testId));
459
+ }
460
+ var identity = (test) => ({ testId: test.testId, title: test.title, file: test.file, project: test.project });
461
+ var order = (a, b) => compare3(a.file, b.file) || compare3(a.title, b.title);
462
+ function compareRuns(current, previous) {
463
+ const result = { newFailures: [], fixed: [], stillFailing: [], newTests: [], removedTests: [] };
464
+ if (!previous) return result;
465
+ const before = new Map(previous.tests.map((test) => [test.testId, test]));
466
+ const after = new Map(current.tests.map((test) => [test.testId, test]));
467
+ for (const test of current.tests) {
468
+ const prior = before.get(test.testId);
469
+ if (!prior) {
470
+ result.newTests.push(identity(test));
471
+ continue;
472
+ }
473
+ const nowKind = testResultKind(test);
474
+ const beforeKind = testResultKind(prior);
475
+ if (nowKind === "fail" && (beforeKind === "pass" || beforeKind === "flaky")) result.newFailures.push(identity(test));
476
+ if (nowKind === "pass" && beforeKind === "fail") result.fixed.push(identity(test));
477
+ if (nowKind === "fail" && beforeKind === "fail") result.stillFailing.push(identity(test));
478
+ }
479
+ for (const test of previous.tests) if (!after.has(test.testId)) result.removedTests.push(identity(test));
480
+ for (const values of Object.values(result)) values.sort(order);
481
+ return result;
482
+ }
483
+
484
+ // src/version.ts
485
+ var VERSION = "0.2.0";
486
+
487
+ // src/model.ts
488
+ var compare4 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
489
+ function errorSignature(message) {
490
+ if (!message?.trim()) return "No error message";
491
+ const line = message.split(/\r?\n/).find((part) => part.trim()) ?? "";
492
+ return line.replace(/"[^"]*"|'[^']*'|`[^`]*`/g, '"\u2026"').replace(/\b(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|0x[0-9a-f]+|[0-9a-f]{8,})\b/gi, "ID").replace(/\d+/g, "N").replace(/\bNms\b/g, "N ms").replace(/\s+/g, " ").trim().slice(0, 140);
493
+ }
494
+ function currentSummary(run) {
495
+ return { schemaVersion: 1, runId: run.runId, title: run.title, startedAt: run.startedAt, durationMs: run.durationMs, status: run.status, complete: run.complete, summary: run.summary, branch: run.env.git.branch, commit: run.env.git.commit, ciProvider: run.env.ci?.provider ?? null, buildUrl: run.env.ci?.buildUrl ?? null };
496
+ }
497
+ function slim(test) {
498
+ return test.outcome === "expected" && test.attemptCount === 1 && test.attempts[0]?.errors.length === 0 && test.attempts[0].attachments.length === 0 && !test.attempts[0].steps?.length && !test.attempts[0].stdout && !test.attempts[0].stderr ? { ...test, attempts: [] } : test;
499
+ }
500
+ function buildReportModel(input) {
501
+ const { run } = input;
502
+ const all = new Map(input.summaries.map((entry) => [entry.runId, entry]));
503
+ all.set(run.runId, currentSummary(run));
504
+ const newest = [...all.values()].sort((a, b) => compare4(b.startedAt, a.startedAt) || compare4(a.runId, b.runId));
505
+ const historyLimit = Math.max(1, input.historyLimit ?? 30);
506
+ const selected = newest.slice(0, historyLimit);
507
+ if (!selected.some((item) => item.runId === run.runId)) selected.splice(-1, 1, currentSummary(run));
508
+ const history = selected.sort((a, b) => compare4(a.startedAt, b.startedAt) || compare4(a.runId, b.runId));
509
+ const files = /* @__PURE__ */ new Map();
510
+ const tags = /* @__PURE__ */ new Map();
511
+ const projects = /* @__PURE__ */ new Map();
512
+ const groups = /* @__PURE__ */ new Map();
513
+ const newIds = new Set(input.comparison?.newFailures.map((item) => item.testId) ?? []);
514
+ for (const test of run.tests) {
515
+ const item = files.get(test.file) ?? { file: test.file, total: 0, failed: 0, flaky: 0, skipped: 0, durationMs: 0 };
516
+ item.total += 1;
517
+ item.durationMs += test.durationMs;
518
+ if (test.outcome === "unexpected") item.failed += 1;
519
+ if (test.outcome === "flaky") item.flaky += 1;
520
+ if (test.outcome === "skipped") item.skipped += 1;
521
+ files.set(test.file, item);
522
+ for (const tag of test.tags) tags.set(tag, (tags.get(tag) ?? 0) + 1);
523
+ const project = projects.get(test.project) ?? { name: test.project, total: 0, passed: 0, failed: 0, flaky: 0, skipped: 0, durationMs: 0 };
524
+ project.total += 1;
525
+ project.durationMs += test.durationMs;
526
+ if (test.outcome === "expected") project.passed += 1;
527
+ if (test.outcome === "unexpected") project.failed += 1;
528
+ if (test.outcome === "flaky") project.flaky += 1;
529
+ if (test.outcome === "skipped") project.skipped += 1;
530
+ projects.set(test.project, project);
531
+ if (test.outcome === "unexpected" || test.outcome === "flaky") {
532
+ const signature = errorSignature(test.firstError?.message ?? null);
533
+ const group = groups.get(signature) ?? { signature, count: 0, projects: /* @__PURE__ */ new Set(), testIds: [], newCount: 0 };
534
+ group.count += 1;
535
+ group.projects.add(test.project);
536
+ group.testIds.push(test.testId);
537
+ if (newIds.has(test.testId)) group.newCount += 1;
538
+ groups.set(signature, group);
539
+ }
540
+ }
541
+ const previous = input.previous ?? null;
542
+ const passRate = (summary) => summary.total ? summary.passed / summary.total : null;
543
+ const currentRate = passRate(run.summary), previousRate = previous && passRate(previous.summary);
544
+ const delta = previous ? { previousRunId: previous.runId, passRatePp: currentRate === null || previousRate === null ? null : Math.round((currentRate - previousRate) * 1e3) / 10, failed: run.summary.failed - previous.summary.failed, flaky: run.summary.flaky - previous.summary.flaky, durationPct: previous.durationMs ? Math.round((run.durationMs - previous.durationMs) / previous.durationMs * 100) : null } : null;
545
+ const older = (input.recentRuns ?? []).filter((item) => item.runId !== run.runId).slice(-10);
546
+ const olderTests = older.map((item) => new Map(item.tests.map((test) => [test.testId, test])));
547
+ const recent = {};
548
+ for (const test of [...run.tests].sort((a, b) => compare4(a.testId, b.testId))) recent[test.testId] = olderTests.map((tests) => {
549
+ const prior = tests.get(test.testId);
550
+ return prior ? { pass: "p", fail: "f", flaky: "k", skip: "s" }[testResultKind(prior)] : "-";
551
+ }).join("");
552
+ const model = { schemaVersion: 1, generator: { name: "playwright-logbook", version: VERSION }, summaryMarkdown: "", generatedAt: input.generatedAt ?? null, ...input.attachmentAvailability ? { attachmentAvailability: input.attachmentAvailability } : {}, run: { ...run, tests: run.tests.map((test) => ({ ...slim(test), timing: test.attempts[0] ? { startedAt: test.attempts[0].startedAt, workerIndex: test.attempts[0].workerIndex } : null })) }, history, previous, comparison: input.comparison ?? null, flaky: (input.flaky ?? []).slice(0, 50), slowest: [...run.tests].sort((a, b) => b.durationMs - a.durationMs || compare4(a.testId, b.testId)).slice(0, 10).map(({ testId, title, file, project, durationMs }) => ({ testId, title, file, project, durationMs })), files: [...files.values()].sort((a, b) => compare4(a.file, b.file)), tags: [...tags].map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count || compare4(a.tag, b.tag)), delta, errorGroups: [...groups.values()].map((group) => ({ ...group, projects: [...group.projects].sort(compare4), testIds: group.testIds.sort(compare4) })).sort((a, b) => b.count - a.count || compare4(a.signature, b.signature)), recent, projects: [...projects.values()].sort((a, b) => compare4(a.name, b.name)) };
553
+ model.summaryMarkdown = renderMarkdownSummary(model);
554
+ return model;
555
+ }
556
+ function formatDuration(milliseconds) {
557
+ if (milliseconds < 1e3) return `${Math.round(milliseconds)}ms`;
558
+ if (milliseconds < 6e4) return `${(milliseconds / 1e3).toFixed(1)}s`;
559
+ return `${Math.floor(milliseconds / 6e4)}m ${Math.floor(milliseconds % 6e4 / 1e3)}s`;
560
+ }
561
+ var status = (model) => `${model.run.status.toUpperCase()}${model.run.complete ? "" : ` (INCOMPLETE: received shards ${model.run.receivedShards.join(",")} of ${model.run.expectedShards ?? "?"})`}`;
562
+ function renderTextSummary(model) {
563
+ const { run } = model;
564
+ return `${run.runId}: ${status(model)} \u2014 ${run.summary.passed} passed, ${run.summary.failed} failed, ${run.summary.flaky} flaky, ${run.summary.skipped} skipped (${formatDuration(run.durationMs)})
565
+ `;
566
+ }
567
+ function renderMarkdownSummary(model) {
568
+ const { run } = model;
569
+ const lines = [`### Playwright run ${run.runId} \u2014 ${status(model)}`, "| Total | Passed | Failed | Flaky | Skipped | Duration |", "|---|---|---|---|---|---|", `| ${run.summary.total} | ${run.summary.passed} | ${run.summary.failed} | ${run.summary.flaky} | ${run.summary.skipped} | ${formatDuration(run.durationMs)} |`];
570
+ if (model.comparison?.newFailures.length) {
571
+ lines.push("", `**New failures (${model.comparison.newFailures.length})**`);
572
+ for (const item of model.comparison.newFailures) lines.push(`- \`${item.file}\` \u203A ${item.title} [${item.project}]`);
573
+ }
574
+ const flaky = run.tests.filter((test) => test.outcome === "flaky");
575
+ if (flaky.length) {
576
+ lines.push("", `**Flaky in this run (${flaky.length})**`);
577
+ for (const item of flaky) lines.push(`- \`${item.file}\` \u203A ${item.title} [${item.project}]`);
578
+ }
579
+ let footer = `**Run**: ${run.env.ci?.buildUrl ?? run.runId}`;
580
+ if (run.env.git.branch) footer += ` \xB7 branch ${run.env.git.branch}`;
581
+ if (run.env.git.commit) footer += ` \xB7 commit ${run.env.git.commit.slice(0, 7)}`;
582
+ lines.push("", footer);
583
+ return `${lines.join("\n")}
584
+ `;
585
+ }
586
+ function buildJsonSummary(model) {
587
+ const counts = model.comparison;
588
+ return { runId: model.run.runId, status: model.run.status, complete: model.run.complete, summary: model.run.summary, durationMs: model.run.durationMs, comparison: { newFailures: counts?.newFailures.length ?? 0, fixed: counts?.fixed.length ?? 0, stillFailing: counts?.stillFailing.length ?? 0, newTests: counts?.newTests.length ?? 0, removedTests: counts?.removedTests.length ?? 0 }, flaky: model.flaky.length };
589
+ }
590
+
591
+ // src/render.ts
592
+ var import_node_path4 = __toESM(require("node:path"), 1);
593
+
594
+ // src/clientlib.ts
595
+ var clientlib_exports = {};
596
+ __export(clientlib_exports, {
597
+ buildHash: () => buildHash,
598
+ donutSegments: () => donutSegments,
599
+ filterTests: () => filterTests,
600
+ formatDuration: () => formatDuration2,
601
+ groupByFile: () => groupByFile,
602
+ heatLevel: () => heatLevel,
603
+ parseHash: () => parseHash,
604
+ recentToKinds: () => recentToKinds,
605
+ rerunCommand: () => rerunCommand,
606
+ safeHref: () => safeHref,
607
+ shellQuote: () => shellQuote,
608
+ sortTests: () => sortTests,
609
+ sparkPath: () => sparkPath,
610
+ statusKind: () => statusKind,
611
+ traceCommand: () => traceCommand
612
+ });
613
+ function formatDuration2(ms) {
614
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
615
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
616
+ return `${Math.floor(ms / 6e4)}m ${Math.floor(ms % 6e4 / 1e3)}s`;
617
+ }
618
+ function statusKind(test) {
619
+ if (test.outcome === "skipped") return "skipped";
620
+ if (test.outcome === "flaky") return "flaky";
621
+ if (test.status === "timedOut") return "timedout";
622
+ return test.outcome === "unexpected" ? "failed" : "passed";
623
+ }
624
+ function donutSegments(summary, radius) {
625
+ const circumference = 2 * Math.PI * radius;
626
+ let offset = 0;
627
+ return ["passed", "flaky", "failed", "skipped"].map((kind) => {
628
+ const length = summary.total ? circumference * summary[kind] / summary.total : 0;
629
+ const segment = { kind, dasharray: `${length} ${circumference - length}`, offset: -offset };
630
+ offset += length;
631
+ return segment;
632
+ });
633
+ }
634
+ function sparkPath(values, width, height, pad) {
635
+ if (!values.length) return "";
636
+ const min = Math.min(...values), max = Math.max(...values);
637
+ return values.map((value, index) => {
638
+ const x = pad + (values.length === 1 ? (width - 2 * pad) / 2 : index * (width - 2 * pad) / (values.length - 1));
639
+ const y = max === min ? height / 2 : height - pad - (value - min) * (height - 2 * pad) / (max - min);
640
+ return `${index ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`;
641
+ }).join(" ");
642
+ }
643
+ function parseHash(hash) {
644
+ const query = new URLSearchParams(hash.replace(/^#/, ""));
645
+ const tabs2 = ["tests", "failures", "trends", "flaky", "run", "project"];
646
+ const sorts = ["default", "slowest", "fastest", "title", "attempts", "severity"];
647
+ const statuses = ["passed", "failed", "flaky", "skipped"];
648
+ const attempt = Number(query.get("attempt"));
649
+ return {
650
+ tab: tabs2.includes(query.get("tab") ?? "") ? query.get("tab") : "tests",
651
+ q: (query.get("q") ?? "").slice(0, 200),
652
+ status: [...new Set((query.get("status") ?? "").split(",").filter((item) => statuses.includes(item)))],
653
+ project: query.get("project") ?? "",
654
+ tag: query.get("tag") ?? "",
655
+ sort: sorts.includes(query.get("sort") ?? "") ? query.get("sort") : "default",
656
+ group: query.get("group") === "file" ? "file" : "none",
657
+ changed: query.get("changed") === "1",
658
+ test: query.get("test") ?? "",
659
+ attempt: Number.isInteger(attempt) && attempt >= 0 ? attempt : 0
660
+ };
661
+ }
662
+ function buildHash(state) {
663
+ const query = new URLSearchParams();
664
+ if (state.tab !== "tests") query.set("tab", state.tab);
665
+ if (state.q) query.set("q", state.q);
666
+ if (state.status.length) query.set("status", state.status.join(","));
667
+ if (state.project) query.set("project", state.project);
668
+ if (state.tag) query.set("tag", state.tag);
669
+ if (state.sort !== "default") query.set("sort", state.sort);
670
+ if (state.group !== "none") query.set("group", state.group);
671
+ if (state.changed) query.set("changed", "1");
672
+ if (state.test) query.set("test", state.test);
673
+ if (state.attempt) query.set("attempt", String(state.attempt));
674
+ return query.size ? `#${query}` : "";
675
+ }
676
+ function filterTests(tests, state, ctx) {
677
+ const q = state.q.toLowerCase().trim();
678
+ return tests.filter((test) => {
679
+ const kind = test.outcome === "flaky" ? "flaky" : test.outcome === "skipped" ? "skipped" : test.outcome === "unexpected" ? "failed" : "passed";
680
+ const text = [test.title, ...test.titlePath, test.file, ...test.tags, ...test.caseIds, test.firstError?.message?.split("\n")[0] ?? ""].join(" ").toLowerCase();
681
+ return (!q || text.includes(q)) && (!state.status.length || state.status.includes(kind)) && (!state.project || test.project === state.project) && (!state.tag || test.tags.includes(state.tag)) && (!state.changed || ctx.changedIds.includes(test.testId));
682
+ });
683
+ }
684
+ function sortTests(tests, sortKey) {
685
+ const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
686
+ const severity = (test) => test.outcome === "unexpected" ? 0 : test.outcome === "flaky" ? 1 : test.outcome === "skipped" ? 2 : 3;
687
+ const tie = (a, b) => cmp(a.file, b.file) || a.line - b.line || cmp(a.testId, b.testId);
688
+ return [...tests].sort((a, b) => {
689
+ if (sortKey === "slowest") return b.durationMs - a.durationMs || tie(a, b);
690
+ if (sortKey === "fastest") return a.durationMs - b.durationMs || tie(a, b);
691
+ if (sortKey === "title") return cmp(a.title, b.title) || tie(a, b);
692
+ if (sortKey === "attempts") return b.attemptCount - a.attemptCount || tie(a, b);
693
+ return severity(a) - severity(b) || tie(a, b);
694
+ });
695
+ }
696
+ function groupByFile(tests) {
697
+ const groups = /* @__PURE__ */ new Map();
698
+ for (const test of tests) groups.set(test.file, [...groups.get(test.file) ?? [], test]);
699
+ return [...groups].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([file, items]) => ({ file, tests: items }));
700
+ }
701
+ function heatLevel(ms, maxMs, p90) {
702
+ return { percent: maxMs > 0 ? Math.min(100, Math.round(ms / maxMs * 100)) : 0, hot: p90 > 0 && ms >= p90 };
703
+ }
704
+ function recentToKinds(value) {
705
+ return [...value].map((letter) => ({ p: "passed", f: "failed", k: "flaky", s: "skipped", "-": "absent" })[letter] ?? "absent");
706
+ }
707
+ function shellQuote(arg) {
708
+ return /^[A-Za-z0-9_./:@=-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`;
709
+ }
710
+ function rerunCommand(test) {
711
+ const quote = (arg) => /^[A-Za-z0-9_./:@=-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`;
712
+ return `npx playwright test ${quote(`${test.file}:${test.line}`)}${test.project ? ` ${quote(`--project=${test.project}`)}` : ""}`;
713
+ }
714
+ function traceCommand(tracePath) {
715
+ const quote = (arg) => /^[A-Za-z0-9_./:@=-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`;
716
+ return `npx playwright show-trace ${quote(tracePath)}`;
717
+ }
718
+ function safeHref(url) {
719
+ if (!url || [...url].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) || url.startsWith("//")) return null;
720
+ if (/^https?:\/\//i.test(url)) return url;
721
+ if (/^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("/") || url.includes("\\")) return null;
722
+ return url;
723
+ }
724
+
725
+ // src/signals.ts
726
+ function detectSignals(evidence, outcome) {
727
+ const signals = [];
728
+ const errors = evidence.filter((item) => item.kind === "error");
729
+ const steps = evidence.filter((item) => item.kind === "step");
730
+ const attempts = evidence.filter((item) => item.kind === "attempt");
731
+ const history = evidence.find((item) => item.kind === "history");
732
+ const findPair = (errorPattern, stepPattern) => {
733
+ for (const error2 of errors) {
734
+ const retry = error2.id.split(":")[1];
735
+ const step = steps.find((item) => item.id.startsWith(`step:retry-${retry}:`) && /; failed\b/.test(item.text) && stepPattern.test(item.text));
736
+ if (step && errorPattern.test(error2.text)) return [error2, step];
737
+ }
738
+ return null;
739
+ };
740
+ const locator = findPair(/(?:locator\.|Locator:[\s\S]*Timeout:|waiting for locator|Timeout .*exceeded)/i, /^(?:(?:pw:api|test\.step):.*(?:locator|click|fill|waitFor)|expect: Expect "toBeVisible")/i);
741
+ if (locator) signals.push({ id: "locator-timeout", label: "Locator operation timed out", explanation: "A failed locator-related step and timeout evidence are recorded on the same attempt. Check selector state and timing.", evidenceIds: locator.map((item) => item.id) });
742
+ const assertion = findPair(/(?:expect\(|expect\.|AssertionError|Expected:)/i, /^(?:expect|test\.step):/i);
743
+ if (assertion && (!locator || assertion[0].id !== locator[0].id)) signals.push({ id: "assertion", label: "Assertion failed", explanation: "A failed assertion step and its error are recorded on the same attempt. Inspect the expected and actual values.", evidenceIds: assertion.map((item) => item.id) });
744
+ const navigation = findPair(/(?:page\.goto|net::ERR_|Navigation failed)/i, /^(?:pw:api|test\.step):.*(?:goto|navigate)/i);
745
+ if (navigation) signals.push({ id: "navigation", label: "Navigation failed", explanation: "A navigation step failed with a matching error. Inspect the URL and page availability.", evidenceIds: navigation.map((item) => item.id) });
746
+ const failedAttempt = attempts.find((item) => /: (?:failed|timedOut|interrupted);/.test(item.text));
747
+ const passedAttempt = attempts.find((item) => /: passed;/.test(item.text));
748
+ if (outcome === "flaky" && failedAttempt && passedAttempt) signals.push({ id: "retry-changed-outcome", label: "Passed after a failed attempt", explanation: "The recorded attempts changed outcome. This is not evidence that the underlying issue is fixed.", evidenceIds: [failedAttempt.id, passedAttempt.id] });
749
+ if (outcome === "unexpected" && history && /: [pfks-]*p$/.test(history.text)) {
750
+ const failure = errors[0] ?? failedAttempt;
751
+ if (failure) signals.push({ id: "new-failure", label: "Failed after a prior pass", explanation: "The previous recorded result passed; this run failed. Compare recent changes before assigning a cause.", evidenceIds: [history.id, failure.id] });
752
+ }
753
+ return signals.length ? signals : [{ id: "unknown", label: "No specific clue", explanation: "Available evidence does not support a specific debugging clue. Inspect the error and trace manually.", evidenceIds: [] }];
754
+ }
755
+
756
+ // src/debugpacket.ts
757
+ function buildDebugPacket(run, testId, recent = "", availability = {}, signalDetector = detectSignals) {
758
+ const test = run.tests.find((item) => item.testId === testId);
759
+ if (!test) throw new Error("test not found");
760
+ const encoder = new TextEncoder();
761
+ const bytes = (value) => encoder.encode(value).length;
762
+ const clean2 = (value) => value.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "").replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, " ").replace(/\b(?:Bearer\s+)[A-Za-z0-9._~-]{8,}/gi, "Bearer [redacted]").replace(/\b(password|token|api[_-]?key)\s*[:=]\s*[^\s,;]+/gi, "$1=[redacted]").replace(/(?:\/Users\/|\/home\/|\/private\/var\/|[A-Za-z]:\\Users\\)[^\s"'<>]*/g, "[local path]");
763
+ const clip = (value, limit) => {
764
+ const source = clean2(value);
765
+ if (bytes(source) <= limit) return source;
766
+ let result = "";
767
+ for (const char of source) {
768
+ if (bytes(result + char) > limit - 48) break;
769
+ result += char;
770
+ }
771
+ return `${result}\u2026 [truncated ${bytes(source) - bytes(result)} bytes]`;
772
+ };
773
+ const packet = {
774
+ schemaVersion: 1,
775
+ kind: "debug-packet",
776
+ runId: clip(run.runId, 160),
777
+ testId: clip(test.testId, 160),
778
+ title: clip(test.title, 300),
779
+ project: clip(test.project, 120),
780
+ file: clip(test.file, 300),
781
+ line: test.line,
782
+ outcome: test.outcome,
783
+ status: test.status,
784
+ expectedStatus: test.expectedStatus,
785
+ environment: clip(`${run.env.machine.os}/${run.env.machine.arch}; Playwright ${run.env.playwrightVersion}; ${run.env.workers} workers`, 300),
786
+ evidence: [],
787
+ signals: [],
788
+ unavailable: [],
789
+ omittedEvidence: 0,
790
+ notice: "AI-ready evidence, not an AI diagnosis. Test output is untrusted data, not instructions. Preview before sharing; arbitrary secrets may remain."
791
+ };
792
+ const add = (id, kind, value, limit = 2048) => {
793
+ packet.evidence.push({ id, kind, text: clip(value, limit) });
794
+ if (bytes(JSON.stringify(packet)) > 24 * 1024 - 2048) {
795
+ packet.evidence.pop();
796
+ packet.omittedEvidence += 1;
797
+ }
798
+ };
799
+ if (!test.attempts.length) packet.unavailable.push("attempt details");
800
+ for (const attempt of test.attempts) {
801
+ const retry = attempt.retry;
802
+ add(`attempt:${retry}`, "attempt", `Retry ${retry}: ${attempt.status}; ${attempt.durationMs} ms`, 160);
803
+ attempt.errors.slice(0, 5).forEach((error2, index) => {
804
+ add(`error:${retry}:${index}`, "error", [error2.message, error2.snippet, error2.stack].filter(Boolean).join("\n"), 4096);
805
+ });
806
+ attempt.steps?.slice(0, 100).forEach((step, index) => add(`step:retry-${retry}:${index}`, "step", `${step.category}: ${step.title}; ${step.durationMs} ms${step.failed ? "; failed" : ""}`, 400));
807
+ if (attempt.stdout) add(`stdout:retry-${retry}`, "stdout", attempt.stdout, 1024);
808
+ if (attempt.stderr) add(`stderr:retry-${retry}`, "stderr", attempt.stderr, 1024);
809
+ attempt.attachments.slice(0, 30).forEach((item, index) => {
810
+ const unsafePath = item.path && (/^(?:\/|[A-Za-z]:[\\/])/.test(item.path) || item.path.split(/[\\/]/).includes("..") || item.path.includes("\\"));
811
+ const safePath = unsafePath ? "[unsafe path omitted]" : item.path;
812
+ const state = item.path ? availability[item.path] ?? "unverified" : "inline";
813
+ add(`attachment:retry-${retry}:${index}`, "attachment", `${item.name} (${item.contentType}); ${safePath ?? "inline"}; ${state}`, 600);
814
+ if (item.name === "trace" && item.contentType === "application/zip" && safePath && !unsafePath && state !== "missing") {
815
+ add(`trace:retry-${retry}:${index}`, "command", `npx playwright show-trace '${safePath.replaceAll("'", "'\\''")}'`, 700);
816
+ }
817
+ });
818
+ }
819
+ if (!test.attempts.some((item) => item.errors.length) && test.firstError) add("error:summary", "error", test.firstError.message, 2048);
820
+ if (!test.attempts.some((item) => item.steps?.length)) packet.unavailable.push("captured steps");
821
+ if (!test.attempts.some((item) => item.stdout || item.stderr)) packet.unavailable.push("captured output");
822
+ if (!test.attempts.some((item) => item.attachments.length)) packet.unavailable.push("attachments");
823
+ if (recent) add("history:0", "history", `Previous outcomes (oldest to newest): ${recent}`, 100);
824
+ else packet.unavailable.push("recent history");
825
+ const location = `${test.file}:${test.line}`;
826
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
827
+ add("rerun:0", "command", `npx playwright test ${quote(location)}${test.project ? ` --project=${quote(test.project)}` : ""}`, 700);
828
+ packet.signals = signalDetector(packet.evidence, test.outcome);
829
+ return packet;
830
+ }
831
+ function debugPacketMarkdown(packet) {
832
+ const lines = [
833
+ `# Debug context: ${JSON.stringify(packet.title)}`,
834
+ "",
835
+ `Run: ${JSON.stringify(packet.runId)} | Test: ${JSON.stringify(packet.testId)} | Project: ${JSON.stringify(packet.project)}`,
836
+ `Source: ${JSON.stringify(packet.file)}:${packet.line}`,
837
+ `Outcome: ${packet.outcome} | Final status: ${packet.status} | Expected: ${packet.expectedStatus}`,
838
+ `Environment: ${JSON.stringify(packet.environment)}`,
839
+ "",
840
+ "# Evidence",
841
+ ...packet.evidence.map((item) => `- ${JSON.stringify(item.id)} (${item.kind}): ${JSON.stringify(item.text)}`),
842
+ "",
843
+ "# Debugging clues (inferences, not facts)",
844
+ ...packet.signals.map((item) => `- ${item.label}: ${item.explanation} Evidence: ${item.evidenceIds.length ? item.evidenceIds.join(", ") : "insufficient evidence"}`),
845
+ "",
846
+ `Unavailable: ${packet.unavailable.length ? packet.unavailable.join(", ") : "none"}`,
847
+ `Omitted evidence: ${packet.omittedEvidence}`,
848
+ "",
849
+ packet.notice
850
+ ];
851
+ return `${lines.join("\n")}
852
+ `;
853
+ }
854
+
855
+ // src/template/tokens.ts
856
+ var TOKENS = {
857
+ dark: {
858
+ bg: "#0b1020",
859
+ surface: "#121a2e",
860
+ "surface-2": "#1a2440",
861
+ border: "#2a3656",
862
+ text: "#e8edf8",
863
+ muted: "#aab7d4",
864
+ accent: "#8ba7ff",
865
+ "accent-contrast": "#0b1020",
866
+ passed: "#3ecf8e",
867
+ failed: "#ff777e",
868
+ flaky: "#ffc06e",
869
+ skipped: "#aab7d4",
870
+ info: "#6cc4ff",
871
+ "passed-bg": "rgba(62,207,142,.14)",
872
+ "failed-bg": "rgba(255,107,114,.14)",
873
+ "flaky-bg": "rgba(255,180,84,.14)",
874
+ "skipped-bg": "rgba(154,167,199,.14)",
875
+ "code-bg": "#0a0f1d",
876
+ shadow: "0 8px 24px rgba(0,0,0,.35)"
877
+ },
878
+ light: {
879
+ bg: "#f4f6fb",
880
+ surface: "#ffffff",
881
+ "surface-2": "#eef2fa",
882
+ border: "#d8deec",
883
+ text: "#131a2c",
884
+ muted: "#56627f",
885
+ accent: "#3954e6",
886
+ "accent-contrast": "#ffffff",
887
+ passed: "#0b7a4f",
888
+ failed: "#c62f3a",
889
+ flaky: "#895000",
890
+ skipped: "#56627f",
891
+ info: "#0b6fa8",
892
+ "passed-bg": "rgba(11,122,79,.10)",
893
+ "failed-bg": "rgba(198,47,58,.10)",
894
+ "flaky-bg": "rgba(154,91,0,.10)",
895
+ "skipped-bg": "rgba(86,98,127,.10)",
896
+ "code-bg": "#f7f9fd",
897
+ shadow: "0 6px 20px rgba(20,30,60,.10)"
898
+ }
899
+ };
900
+ function tokensToCss(theme) {
901
+ return Object.entries(TOKENS[theme]).map(([name, value]) => `--${name}:${value};`).join("");
902
+ }
903
+
904
+ // src/template/css.ts
905
+ var REPORT_CSS = `:root{${tokensToCss("light")}font-family:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;color-scheme:light}
906
+ :root[data-theme="dark"]{${tokensToCss("dark")}color-scheme:dark}
907
+ :root[data-theme="light"]{${tokensToCss("light")}color-scheme:light}
908
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5}button,input,select{font:inherit}button{cursor:pointer}a{color:var(--accent)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.skip-link{position:absolute;top:-80px;left:12px;z-index:30;background:var(--surface);padding:8px}.skip-link:focus{top:12px}.topbar{position:sticky;top:0;z-index:20;background:var(--surface);border-bottom:1px solid var(--border);box-shadow:var(--shadow)}.topbar-inner{max-width:1280px;margin:auto;padding:8px 20px;display:flex;align-items:center;gap:16px;min-height:56px}.wordmark{display:flex;align-items:center;gap:8px;font-size:16px;font-weight:600;color:var(--text);text-decoration:none}.wordmark svg,.icon{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.topbar-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}.topbar-actions{margin-left:auto;display:flex;gap:6px}.button,.topbar-actions button,.chip-button{border:1px solid var(--border);background:var(--surface);color:var(--text);border-radius:6px;padding:6px 10px;display:inline-flex;align-items:center;gap:5px}.button:hover,.topbar-actions button:hover,.chip-button:hover{background:var(--surface-2)}.container{max-width:1280px;margin:auto;padding:24px 20px 48px}.hero{display:grid;grid-template-columns:150px minmax(0,1fr);gap:24px;align-items:center}.donut{width:144px;height:144px}.donut-track{stroke:var(--border)}.donut-center{font-size:22px;font-weight:600;fill:var(--text)}.donut-label{font-size:10px;fill:var(--muted)}h1{font-size:24px;line-height:1.2;margin:0 0 8px}h2{font-size:20px;margin:0 0 14px}h3{font-size:16px;margin:20px 0 8px}.muted,small{color:var(--muted)}.meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.meta>*{border:1px solid var(--border);background:var(--surface);border-radius:6px;padding:3px 8px}.cards{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:24px 0}.card{border:1px solid var(--border);border-radius:12px;background:var(--surface);padding:16px;text-align:left;color:var(--text)}.card strong{display:block;font-size:28px;line-height:1.2;margin-top:4px;font-variant-numeric:tabular-nums}.card[data-kind="passed"] strong{color:var(--passed)}.card[data-kind="failed"] strong{color:var(--failed)}.card[data-kind="flaky"] strong{color:var(--flaky)}.delta,.banner,.notice{border:1px solid var(--border);background:var(--surface);border-radius:12px;padding:12px 16px;margin:12px 0}.banner{border-color:var(--flaky);color:var(--text)}.delta{display:flex;gap:16px;flex-wrap:wrap}.delta span{white-space:nowrap}.tabs{display:flex;gap:4px;overflow-x:auto;border-bottom:1px solid var(--border);margin:24px 0 16px}.tabs button{background:none;border:0;border-bottom:3px solid transparent;color:var(--muted);padding:10px 14px;white-space:nowrap}.tabs button[aria-selected="true"]{color:var(--text);border-color:var(--accent);font-weight:600}.panel[hidden]{display:none}.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.toolbar input,.toolbar select{background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:7px 9px}.toolbar input{min-width:220px;flex:1}.chip-button[aria-pressed="true"]{border-color:var(--accent);background:var(--surface-2)}.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface)}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:top}th{font-size:12px;color:var(--muted);font-weight:600}td{font-size:13px}tbody tr:last-child td{border-bottom:0}tr[data-test-id]{cursor:pointer}tr[data-test-id]:hover{background:var(--surface-2)}td strong{display:block;font-weight:500;max-width:620px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}td small{display:block;max-width:620px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.status-chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;border-radius:6px;padding:3px 7px;white-space:nowrap}.status-chip.passed{color:var(--passed);background:var(--passed-bg)}.status-chip.failed,.status-chip.timedout{color:var(--failed);background:var(--failed-bg)}.status-chip.flaky{color:var(--flaky);background:var(--flaky-bg)}.status-chip.skipped{color:var(--skipped);background:var(--skipped-bg)}.status-chip .mark{font-size:12px}.heat{display:block;height:3px;background:var(--surface-2);border-radius:3px;margin-top:5px}.heat span{display:block;height:100%;background:var(--accent)}.heat.hot span{background:var(--flaky)}.recent{display:flex;gap:3px}.recent span{width:9px;height:9px;border-radius:2px;background:var(--border)}.recent .passed{background:var(--passed)}.recent .failed{background:var(--failed)}.recent .flaky{background:var(--flaky)}.recent .skipped{background:var(--skipped)}.empty{padding:32px;text-align:center;color:var(--muted)}.group-header{background:var(--surface-2);font-weight:600}.section-card{border:1px solid var(--border);background:var(--surface);border-radius:12px;padding:16px;margin:12px 0}.grid-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}.panel-backdrop{position:fixed;inset:0;background:var(--shadow);z-index:25}.detail-panel{position:fixed;z-index:26;right:0;top:0;bottom:0;width:min(520px,100vw);overflow:auto;background:var(--surface);border-left:1px solid var(--border);box-shadow:var(--shadow);padding:20px}.detail-panel[hidden],.panel-backdrop[hidden]{display:none}.panel-head{display:flex;align-items:center;gap:8px;position:sticky;top:-20px;background:var(--surface);padding:12px 0;z-index:1}.panel-head h2{margin:0;flex:1}.detail-panel pre{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:12px;overflow:auto;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere}.attempts{display:flex;gap:6px;flex-wrap:wrap}.attempts button[aria-pressed="true"]{border-color:var(--accent)}.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);border-radius:6px;padding:8px 14px;z-index:50}.toast[hidden]{display:none}.help-dialog,.lightbox{border:1px solid var(--border);border-radius:12px;background:var(--surface);color:var(--text);max-width:min(600px,95vw);box-shadow:var(--shadow)}.help-dialog::backdrop,.lightbox::backdrop{background:var(--shadow)}.chart{width:100%;height:190px;background:var(--surface);border:1px solid var(--border);border-radius:12px}.chart line,.chart path{stroke:var(--accent);fill:none;stroke-width:2}.chart rect{fill:var(--surface-2)}.chart text{fill:var(--muted);font-size:10px}.timeline{width:100%;min-width:700px;height:auto;min-height:160px}.timeline rect.passed{fill:var(--passed)}.timeline rect.failed{fill:var(--failed)}.timeline rect.flaky{fill:var(--flaky)}footer{color:var(--muted);padding:32px 0}.error-banner{border:1px solid var(--failed);color:var(--failed);padding:12px}.error-banner[hidden]{display:none}button{transition:background 140ms ease-out}svg{overflow:visible}
909
+ .donut-segment.passed{stroke:var(--passed)}.donut-segment.failed{stroke:var(--failed)}.donut-segment.flaky{stroke:var(--flaky)}.donut-segment.skipped{stroke:var(--skipped)}.donut-segment{transition:stroke-dasharray 400ms ease-out}.detail-panel li.failed{color:var(--failed)}.topbar .status-chip{font-size:11px}.section-card button{margin:4px}.panel .chart{margin:12px 0}
910
+ .card{appearance:none;background-color:var(--surface)}.card[data-card-kind="passed"] strong{color:var(--passed)}.card[data-card-kind="failed"] strong{color:var(--failed)}.card[data-card-kind="flaky"] strong{color:var(--flaky)}
911
+ .chart .trend-area{fill:var(--passed-bg);stroke:none}.chart .trend-line{fill:none;stroke:var(--accent);stroke-width:2}.chart .trend-dot{fill:var(--accent);stroke:none}.run-strip{display:flex;gap:4px;margin:12px 0}.run-strip span{width:14px;height:14px;border-radius:3px;background:var(--border)}.run-strip .passed{background:var(--passed)}.run-strip .failed{background:var(--failed)}.group-header{cursor:pointer}
912
+ .steps{padding-left:20px}.steps li{padding:5px 0;border-bottom:1px solid var(--border)}.steps li.failed{font-weight:600}
913
+ .tag-chip,.case-chip{display:inline-block;padding:1px 5px;margin:1px 3px 1px 0;border:1px solid var(--border);border-radius:6px;color:var(--muted);white-space:nowrap}.case-chip{background:var(--surface-2)}
914
+ [hidden]{display:none!important}.hero-main{min-width:0}.eyebrow{margin:0 0 5px;color:var(--muted);font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}.eyebrow strong{color:var(--accent);font-weight:700;margin-left:5px}.run-facts{display:flex;flex-wrap:wrap;gap:10px;margin:14px 0 0}.run-facts div{min-width:0;max-width:100%;padding:7px 12px;background:var(--surface);border:1px solid var(--border);border-radius:8px}.run-facts dt{color:var(--muted);font-size:11px;line-height:1.25}.run-facts dd{margin:2px 0 0;font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:320px}.env-disclosure{margin-top:10px;color:var(--muted);font-size:12px}.env-disclosure summary{cursor:pointer;width:max-content}.env-disclosure summary:hover{color:var(--accent)}.env-disclosure .meta{margin-top:8px}.comparison{border:1px solid var(--border);background:var(--surface);border-radius:12px;padding:16px;margin:0 0 8px}.comparison-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.comparison h2{font-size:15px;margin:0 0 3px}.comparison p{margin:0;color:var(--muted);font-size:12px}.comparison code{font:inherit;color:var(--text)}.comparison-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;margin-top:14px}.comparison-metric{padding:9px 12px;background:var(--surface-2);border-radius:8px;min-width:0}.comparison-metric span{display:block;color:var(--muted);font-size:11px}.comparison-metric strong{display:block;margin-top:2px;font-size:18px;font-variant-numeric:tabular-nums}.comparison-empty{border-style:dashed}.pagination{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px;padding:12px 14px;background:var(--surface);border:1px solid var(--border);border-radius:8px}.pagination p{margin:0;color:var(--muted);font-size:12px}.card:hover{border-color:var(--accent);box-shadow:0 3px 12px var(--shadow)}.card:active{transform:translateY(1px)}
915
+ @media(max-width:1100px){.topbar-title{display:none}}
916
+ @media(max-width:640px){.container{padding:16px 12px}.topbar-inner{padding:8px 12px}.topbar-actions .optional{display:none}.hero{grid-template-columns:1fr;gap:12px}.donut{width:110px;height:110px}.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.detail-panel{width:100vw;border:0}.toolbar input{min-width:100%}.run-facts{gap:6px}.run-facts div{flex:1 1 135px}.run-facts dd{max-width:100%}.comparison-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.comparison-head{align-items:flex-start;flex-direction:column}.pagination{flex-direction:column;align-items:stretch}}
917
+ @media(max-width:640px){.run-facts div{flex:1 1 100%}}
918
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}
919
+ @media print{:root,:root[data-theme="dark"]{${tokensToCss("light")}color-scheme:light}body{background:var(--surface)}.topbar-actions,.tabs,.toolbar,.button,.chip-button,.panel-head button,.toast,.skip-link{display:none!important}.panel[hidden]{display:block!important}.detail-panel{position:static;width:auto;box-shadow:none;border:0}.panel-backdrop{display:none}.container{max-width:none}.section-card,tr{break-inside:avoid}a{color:var(--text)}}`;
920
+
921
+ // src/template/icons.ts
922
+ var ICONS = {
923
+ check: "M5 12l4 4L19 6",
924
+ x: "M6 6l12 12M18 6L6 18",
925
+ alert: "M12 3L2 21h20L12 3zm0 6v5m0 3v1",
926
+ skip: "M5 5l14 7-14 7V5z",
927
+ clock: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zm0 4v5l3 2",
928
+ copy: "M8 5h12v15H8zM4 16V3h12",
929
+ link: "M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2",
930
+ sun: "M12 4v2m0 12v2M4 12h2m12 0h2M6 6l1.5 1.5m9 9L18 18M18 6l-1.5 1.5m-9 9L6 18M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8",
931
+ moon: "M20 15a8 8 0 0 1-11-11 8 8 0 1 0 11 11",
932
+ search: "M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm6 14 4 4",
933
+ chevron: "M6 9l6 6 6-6",
934
+ book: "M4 4h6a4 4 0 0 1 4 4v12a4 4 0 0 0-4-2H4V4zm10 4a4 4 0 0 1 4-4h2v14h-2a4 4 0 0 0-4 2"
935
+ };
936
+
937
+ // src/template/js/core.ts
938
+ var CORE_JS = `
939
+ const model=JSON.parse(document.getElementById('lb-data').textContent);
940
+ const links=JSON.parse(document.getElementById('lb-links').textContent);
941
+ const allTests=model.run.tests;
942
+ const byId=new Map(allTests.map(test=>[test.testId,test]));
943
+ const tabs=['tests','failures','trends','flaky','run','project'];
944
+ const tabButtons=[...document.querySelectorAll('[data-tab]')];
945
+ let state=parseHash(location.hash),panelReturn=null,toastTimer=0,visibleRows=[];
946
+ function h(tag,props,...children){const el=document.createElement(tag);for(const [key,value] of Object.entries(props||{})){if(value===null||value===undefined)continue;if(key==='class')el.className=String(value);else if(key==='text')el.textContent=String(value);else if(key.startsWith('on')&&typeof value==='function')el.addEventListener(key.slice(2).toLowerCase(),value);else el.setAttribute(key,String(value))}for(const child of children.flat(Infinity)){if(child===null||child===undefined)continue;el.appendChild(typeof child==='string'||typeof child==='number'?document.createTextNode(String(child)):child)}return el}
947
+ const svgNS='http'+':/'+'/www.w3.org/2000/svg';
948
+ function s(tag,props,...children){const el=document.createElementNS(svgNS,tag);for(const [key,value] of Object.entries(props||{}))el.setAttribute(key,String(value));for(const child of children.flat(Infinity)){if(child!==null&&child!==undefined)el.appendChild(typeof child==='string'||typeof child==='number'?document.createTextNode(String(child)):child)}return el}
949
+ function clear(el){while(el.firstChild)el.removeChild(el.firstChild)}
950
+ function icon(name){const svg=s('svg',{viewBox:'0 0 24 24',class:'icon','aria-hidden':'true'});svg.appendChild(s('path',{d:icons[name]||icons.alert}));return svg}
951
+ function chip(kind){const labels={passed:'Passed',failed:'Failed',timedout:'Timed out',flaky:'Flaky',skipped:'Skipped'};const marks={passed:'\u2713',failed:'\xD7',timedout:'!',flaky:'\u219D',skipped:'\u2013'};return h('span',{class:'status-chip '+kind},h('span',{class:'mark','aria-hidden':'true',text:marks[kind]||'!'}),labels[kind]||kind)}
952
+ function recentStrip(value){const wrap=h('span',{class:'recent','aria-label':'Previous results: '+recentToKinds(value||'').join(', ')});for(const kind of recentToKinds(value||''))wrap.appendChild(h('span',{class:kind,title:kind}));return wrap}
953
+ function link(label,url){const href=safeHref(url||'');return href?h('a',{href,rel:'noopener noreferrer',text:label}):h('span',{text:label})}
954
+ function setState(patch){state={...state,...patch};const hash=buildHash(state);if(location.hash!==hash)history.replaceState(null,'',location.pathname+location.search+hash);renderAll()}
955
+ function validateState(){if(state.project&&!allTests.some(test=>test.project===state.project))state.project='';if(state.tag&&!allTests.some(test=>test.tags.includes(state.tag)))state.tag='';if(state.test&&!byId.has(state.test))state.test='';if(state.test&&state.attempt>=byId.get(state.test).attempts.length)state.attempt=0}
956
+ function toast(message){const el=document.getElementById('lb-toast');el.textContent=message;el.hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>{el.hidden=true},2300)}
957
+ async function copyText(value){try{await navigator.clipboard.writeText(value);toast('Copied')}catch{const area=h('textarea',{'aria-label':'Copy text'});area.value=value;document.body.appendChild(area);area.select();try{document.execCommand('copy');toast('Copied')}catch{toast('Select and copy the text')}area.remove()}}
958
+ function applyTheme(theme){document.documentElement.setAttribute('data-theme',theme);const button=document.getElementById('lb-theme');button.textContent=theme==='dark'?'Light mode':'Dark mode';button.setAttribute('aria-label',theme==='dark'?'Switch to light mode':'Switch to dark mode');try{localStorage.setItem('logbook-theme',theme)}catch{}}
959
+ function toggleTheme(){const next=document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark';applyTheme(next);toast(next==='dark'?'Dark mode on':'Light mode on')}
960
+ function renderAll(){for(const button of tabButtons){const active=button.dataset.tab===state.tab;button.setAttribute('aria-selected',String(active));button.tabIndex=active?0:-1;document.getElementById('tab-'+button.dataset.tab).hidden=!active}if(state.tab==='tests'){renderTests();renderPagination();decorateTestRows();makeGroupsCollapsible()}else if(state.tab==='failures')renderFailures();else if(state.tab==='trends')renderTrends();else if(state.tab==='flaky')renderFlaky();else if(state.tab==='run')renderRun();else renderProject();renderPanel()}
961
+ function decorateTestRows(){for(const row of document.querySelectorAll('#lb-tests-body tr[data-test-id]')){
962
+ const test=byId.get(row.dataset.testId),name=row.children[1],tags=row.children[3];
963
+ if(test.titlePath.length>1)name.appendChild(h('small',{text:test.titlePath.slice(0,-1).join(' \u203A ')}));
964
+ clear(tags);for(const tag of test.tags.slice(0,3))tags.appendChild(h('span',{class:'tag-chip',text:tag}));
965
+ if(test.tags.length>3)tags.appendChild(h('span',{class:'tag-chip',text:'+'+(test.tags.length-3)}));
966
+ for(const id of test.caseIds)tags.appendChild(h('span',{class:'case-chip',text:id}));
967
+ }}
968
+ function makeGroupsCollapsible(){
969
+ if(state.group!=='file')return;
970
+ const body=document.getElementById('lb-tests-body');
971
+ const rows=[...body.querySelectorAll('tr[data-test-id]')];
972
+ if(!rows.length)return;
973
+ clear(body);
974
+ const files=[...new Set(rows.map(row=>byId.get(row.dataset.testId).file))].sort();
975
+ for(const file of files){
976
+ const members=rows.filter(row=>byId.get(row.dataset.testId).file===file);
977
+ const total=visibleRows.filter(test=>test.file===file).length;
978
+ const worst=members.map(row=>statusKind(byId.get(row.dataset.testId))).sort((a,b)=>['failed','timedout','flaky','skipped','passed'].indexOf(a)-['failed','timedout','flaky','skipped','passed'].indexOf(b))[0];
979
+ const header=h('tr',{class:'group-header',tabindex:'0','aria-expanded':'true'},h('td',{colspan:'7'},file+' \xB7 '+total+' tests ',chip(worst)));
980
+ const toggle=()=>{const expanded=header.getAttribute('aria-expanded')==='true';header.setAttribute('aria-expanded',String(!expanded));for(const row of members)row.hidden=expanded};
981
+ header.addEventListener('click',toggle);
982
+ header.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();toggle()}});
983
+ body.appendChild(header);for(const row of members)body.appendChild(row);
984
+ }
985
+ }
986
+ function selectTab(name,focus){setState({tab:name});if(focus)tabButtons[tabs.indexOf(name)].focus()}
987
+ function table(headings,rows){const head=h('thead',{},h('tr',{},headings.map(name=>h('th',{text:name}))));const body=h('tbody',{},rows.map(values=>h('tr',{},values.map(value=>h('td',{},value===null||value===undefined?'':value)))));return h('div',{class:'table-wrap'},h('table',{},head,body))}
988
+ function openTest(id,origin){if(origin)panelReturn=origin;setState({test:id,attempt:0})}
989
+ function closePanel(){setState({test:'',attempt:0});if(panelReturn&&panelReturn.isConnected)panelReturn.focus();panelReturn=null}
990
+ function handleKeys(event){if(event.metaKey||event.ctrlKey||event.altKey||/^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName))return;const key=event.key;if(key>='1'&&key<='6'){selectTab(tabs[Number(key)-1],true);return}if(key==='Escape'){if(state.test){closePanel();return}const dialog=document.querySelector('dialog[open]');if(dialog)dialog.close();return}if(key==='t'){toggleTheme();return}if(key==='?'){showHelp();return}if(key==='/'&&state.tab==='tests'){event.preventDefault();document.getElementById('lb-search')?.focus();return}if(key==='['||key===']'){moveFailure(key===']'?1:-1);return}if(state.tab==='tests'&&(key==='j'||key==='k')){const rows=[...document.querySelectorAll('#lb-tests-body tr[data-test-id]')];const at=rows.indexOf(document.activeElement);rows[Math.max(0,Math.min(rows.length-1,at+(key==='j'?1:-1)))]?.focus();return}if((key==='Enter'||key==='o')&&document.activeElement?.dataset?.testId){openTest(document.activeElement.dataset.testId,document.activeElement)}}
991
+ function moveFailure(step){const ids=allTests.filter(test=>test.outcome==='unexpected'||test.outcome==='flaky').map(test=>test.testId);if(!ids.length)return;const at=ids.indexOf(state.test);openTest(ids[(at+step+ids.length)%ids.length])}
992
+ function init(){
993
+ let saved='light';try{if(localStorage.getItem('logbook-theme')==='dark')saved='dark'}catch{}applyTheme(saved);
994
+ for(const button of tabButtons){
995
+ button.addEventListener('click',()=>selectTab(button.dataset.tab));
996
+ button.addEventListener('keydown',event=>{if(event.key==='ArrowRight'||event.key==='ArrowLeft'){
997
+ event.preventDefault();const step=event.key==='ArrowRight'?1:-1;
998
+ selectTab(tabs[(tabs.indexOf(button.dataset.tab)+step+tabs.length)%tabs.length],true)
999
+ }});
1000
+ }
1001
+ document.getElementById('lb-theme').addEventListener('click',toggleTheme);
1002
+ document.getElementById('lb-copy-summary').addEventListener('click',()=>copyText(model.summaryMarkdown||''));
1003
+ document.getElementById('lb-download').addEventListener('click',()=>{
1004
+ const blob=new Blob([JSON.stringify(model.run,null,2)+'\\n'],{type:'application/json'});
1005
+ const url=URL.createObjectURL(blob),a=h('a',{href:url,download:model.run.runId+'.json'});
1006
+ document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);
1007
+ });
1008
+ document.getElementById('lb-print').addEventListener('click',()=>window.print());
1009
+ window.addEventListener('beforeprint',()=>{renderFailures();renderTrends();renderFlaky();renderRun();renderProject();for(const details of document.querySelectorAll('details'))details.open=true});
1010
+ document.getElementById('lb-help').addEventListener('click',showHelp);
1011
+ document.getElementById('lb-panel-close').addEventListener('click',closePanel);
1012
+ const panelClose=document.getElementById('lb-panel-close'),panelHead=panelClose.parentElement;
1013
+ panelHead.insertBefore(h('button',{class:'button',type:'button','aria-label':'Previous failing test',onclick:()=>moveFailure(-1),text:'\u2190'}),panelClose);
1014
+ panelHead.insertBefore(h('button',{class:'button',type:'button','aria-label':'Next failing test',onclick:()=>moveFailure(1),text:'\u2192'}),panelClose);
1015
+ document.getElementById('lb-backdrop').addEventListener('click',closePanel);
1016
+ const mark=document.querySelector('.wordmark');mark.textContent='logbook';mark.prepend(icon('book'));
1017
+ const topStatus=document.querySelector('.topbar .status-chip');topStatus.prepend(h('span',{class:'mark','aria-hidden':'true',text:model.run.status==='passed'?'\u2713':'\xD7'}));
1018
+ document.getElementById('lb-new-failures')?.addEventListener('click',()=>selectTab('failures'));
1019
+ window.addEventListener('hashchange',()=>{state=parseHash(location.hash);validateState();renderAll()});
1020
+ document.addEventListener('keydown',handleKeys);
1021
+ for(const button of document.querySelectorAll('[data-card-kind]'))button.addEventListener('click',()=>setState({tab:'tests',status:button.dataset.cardKind==='total'?[]:[button.dataset.cardKind]}));
1022
+ const start=document.querySelector('#lb-header time');if(start){const parsed=new Date(start.dateTime);if(!Number.isNaN(parsed.getTime()))start.textContent=parsed.toLocaleString()}
1023
+ validateState();
1024
+ renderAll();
1025
+ }
1026
+ `;
1027
+
1028
+ // src/template/js/tests.ts
1029
+ var TESTS_JS = `
1030
+ let testsReady=false,showCount=200,searchTimer=0;
1031
+ function renderTests(){const panel=document.getElementById('tab-tests');if(!testsReady){testsReady=true;const toolbar=h('div',{class:'toolbar'});const search=h('input',{id:'lb-search',type:'search','aria-label':'Search tests',placeholder:'Search tests'});search.value=state.q;search.addEventListener('input',()=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>setState({q:search.value}),120)});toolbar.appendChild(search);const counts={passed:0,failed:0,flaky:0,skipped:0};for(const test of allTests){const kind=statusKind(test);counts[kind==='timedout'?'failed':kind]++}for(const kind of ['passed','failed','flaky','skipped']){const button=h('button',{class:'chip-button','data-status':kind,type:'button'},kind+' '+counts[kind]);button.addEventListener('click',()=>setState({status:state.status.includes(kind)?state.status.filter(item=>item!==kind):[...state.status,kind]}));toolbar.appendChild(button)}const project=h('select',{id:'lb-project','aria-label':'Filter project'},h('option',{value:'',text:'All projects'}));for(const name of [...new Set(allTests.map(test=>test.project))].sort())project.appendChild(h('option',{value:name,text:name||'(default)'}));project.addEventListener('change',()=>setState({project:project.value}));toolbar.appendChild(project);const tag=h('select',{id:'lb-tag','aria-label':'Filter tag'},h('option',{value:'',text:'All tags'}));for(const name of [...new Set(allTests.flatMap(test=>test.tags))].sort())tag.appendChild(h('option',{value:name,text:name}));tag.addEventListener('change',()=>setState({tag:tag.value}));toolbar.appendChild(tag);const sort=h('select',{id:'lb-sort','aria-label':'Sort tests'});for(const [value,label] of [['default','Default'],['slowest','Slowest'],['fastest','Fastest'],['title','Title'],['attempts','Most attempts'],['severity','Severity']])sort.appendChild(h('option',{value,text:label}));sort.addEventListener('change',()=>setState({sort:sort.value}));toolbar.appendChild(sort);const group=h('button',{id:'lb-group',class:'chip-button',type:'button',text:'Group by file'});group.addEventListener('click',()=>setState({group:state.group==='file'?'none':'file'}));toolbar.appendChild(group);if(model.comparison){const changed=h('button',{id:'lb-changed',class:'chip-button',type:'button',text:'Changed'});changed.addEventListener('click',()=>setState({changed:!state.changed}));toolbar.appendChild(changed)}panel.appendChild(toolbar);panel.appendChild(h('p',{id:'lb-result-count',class:'muted'}));panel.appendChild(h('div',{class:'table-wrap'},h('table',{},h('thead',{},h('tr',{},['Status','Test','Project','Tags / case IDs','Duration','Attempts','Last 10'].map(item=>h('th',{text:item})))),h('tbody',{id:'lb-tests-body'}))));panel.appendChild(h('button',{id:'lb-more',class:'button',type:'button',text:'Show 200 more'}));document.getElementById('lb-more').addEventListener('click',()=>{showCount+=200;renderTests()})}const search=document.getElementById('lb-search');if(document.activeElement!==search)search.value=state.q;document.getElementById('lb-project').value=state.project;document.getElementById('lb-tag').value=state.tag;document.getElementById('lb-sort').value=state.sort;document.getElementById('lb-group').setAttribute('aria-pressed',String(state.group==='file'));document.getElementById('lb-changed')?.setAttribute('aria-pressed',String(state.changed));for(const button of panel.querySelectorAll('[data-status]'))button.setAttribute('aria-pressed',String(state.status.includes(button.dataset.status)));const changedIds=model.comparison?[...model.comparison.newFailures,...model.comparison.fixed,...model.comparison.newTests].map(item=>item.testId):[];visibleRows=sortTests(filterTests(allTests,state,{changedIds}),state.sort);document.getElementById('lb-result-count').textContent=visibleRows.length+' of '+allTests.length+' tests';const body=document.getElementById('lb-tests-body');clear(body);const max=Math.max(1,...allTests.map(test=>test.durationMs));const durations=allTests.map(test=>test.durationMs).sort((a,b)=>a-b);const p90=durations[Math.floor(durations.length*.9)]||0;const shown=visibleRows.slice(0,showCount);if(!shown.length)body.appendChild(h('tr',{},h('td',{colspan:'7',class:'empty',text:'No tests match these filters.'})));let lastFile='';for(const test of shown){if(state.group==='file'&&test.file!==lastFile){lastFile=test.file;body.appendChild(h('tr',{class:'group-header'},h('td',{colspan:'7',text:test.file+' \xB7 '+visibleRows.filter(item=>item.file===test.file).length+' tests'})))}const kind=statusKind(test);const name=h('td',{},h('strong',{text:test.title,title:test.title}),h('small',{text:test.file+':'+test.line}),test.firstError?h('small',{text:test.firstError.message.split('\\n')[0]}):null);const tags=h('td',{},test.tags.slice(0,3).join(' '),test.tags.length>3?' +'+(test.tags.length-3):'',h('small',{text:test.caseIds.join(' ')}));const heat=heatLevel(test.durationMs,max,p90);const duration=h('td',{},formatDuration(test.durationMs),h('span',{class:'heat'+(heat.hot?' hot':'')},h('span',{style:'width:'+heat.percent+'%'})));const row=h('tr',{'data-test-id':test.testId,tabindex:'0'},h('td',{},chip(kind)),name,h('td',{text:test.project||'(default)'}),tags,duration,h('td',{text:test.attemptCount}),h('td',{},recentStrip(model.recent?.[test.testId])));row.addEventListener('click',()=>openTest(test.testId,row));body.appendChild(row)}const more=document.getElementById('lb-more');more.hidden=visibleRows.length<=showCount;more.textContent='Show 200 more ('+(visibleRows.length-showCount)+' left)'}
1032
+ function renderPagination(){
1033
+ const panel=document.getElementById('tab-tests'),more=document.getElementById('lb-more');
1034
+ let footer=document.getElementById('lb-pagination');
1035
+ if(!footer){
1036
+ footer=h('div',{id:'lb-pagination',class:'pagination'});
1037
+ footer.appendChild(h('p',{id:'lb-pagination-note','aria-live':'polite'}));
1038
+ footer.appendChild(more);
1039
+ panel.appendChild(footer);
1040
+ more.addEventListener('click',renderPagination);
1041
+ }
1042
+ const shown=Math.min(showCount,visibleRows.length),remaining=visibleRows.length-shown;
1043
+ footer.hidden=remaining<=0;
1044
+ more.hidden=remaining<=0;
1045
+ if(remaining>0){
1046
+ document.getElementById('lb-pagination-note').textContent='Showing '+shown+' of '+visibleRows.length+' matching tests. Rows load in batches to keep this report responsive.';
1047
+ more.textContent='Show next '+Math.min(200,remaining)+' tests';
1048
+ }
1049
+ }
1050
+ `;
1051
+
1052
+ // src/template/js/panel.ts
1053
+ var PANEL_JS = `
1054
+ let panelShown='';
1055
+ function renderPanel(){
1056
+ const panel=document.getElementById('lb-panel'),backdrop=document.getElementById('lb-backdrop');
1057
+ const content=document.getElementById('lb-panel-content'),test=byId.get(state.test);
1058
+ panel.hidden=!test;backdrop.hidden=!test;if(!test){panelShown='';return}
1059
+ clear(content);
1060
+ const heading=document.getElementById('lb-panel-heading');heading.textContent=test.title;
1061
+ const fresh=panelShown!==test.testId;panelShown=test.testId;
1062
+ content.appendChild(chip(statusKind(test)));
1063
+ content.appendChild(h('p',{class:'muted',text:test.titlePath.join(' \u203A ')}));
1064
+ content.appendChild(h('p',{},test.file+':'+test.line+' \xB7 '+(test.project||'(default)')+' \xB7 '+formatDuration(test.durationMs)));
1065
+ if(test.tags.length||test.caseIds.length)content.appendChild(h('p',{class:'muted',text:[...test.tags,...test.caseIds].join(' \xB7 ')}));
1066
+ content.appendChild(h('p',{class:'muted',text:test.attemptCount+' attempts'+(test.timing?' \xB7 worker '+test.timing.workerIndex:'')}));
1067
+ content.appendChild(h('h3',{text:'Rerun'}));
1068
+ const command=rerunCommand(test);content.appendChild(h('pre',{text:command}));
1069
+ content.appendChild(h('button',{class:'button',type:'button',onclick:()=>copyText(command)},icon('copy'),'Copy rerun command'));
1070
+ content.appendChild(h('h3',{text:'AI-ready debug context'}));
1071
+ content.appendChild(h('p',{class:'muted',text:'Evidence only, not a diagnosis. Preview and review for secrets before sharing.'}));
1072
+ const debugPacket=buildDebugPacket(model.run,test.testId,model.recent?.[test.testId]||'',model.attachmentAvailability||{},detectSignals);
1073
+ content.appendChild(h('h3',{text:'Debugging clues'}));
1074
+ content.appendChild(h('p',{class:'muted',text:'Inferences from recorded evidence, not Playwright facts or a diagnosis.'}));
1075
+ for(const signal of debugPacket.signals)content.appendChild(h('p',{},h('strong',{text:signal.label+' \xB7 '}),signal.explanation,h('small',{text:signal.evidenceIds.length?'Evidence: '+signal.evidenceIds.join(', '):'Insufficient evidence'})));
1076
+ const debugText=debugPacketMarkdown(debugPacket);
1077
+ const preview=h('details',{},h('summary',{text:'Preview debug context'}),h('pre',{text:debugText}));
1078
+ content.appendChild(preview);
1079
+ content.appendChild(h('button',{class:'button',type:'button',onclick:()=>copyText(debugText),text:'Copy debug context'}));
1080
+ const attempt=test.attempts[Math.min(state.attempt,test.attempts.length-1)];
1081
+ if(test.attempts.length>1){
1082
+ content.appendChild(h('h3',{text:'Attempts'}));const switcher=h('div',{class:'attempts'});
1083
+ test.attempts.forEach((item,index)=>switcher.appendChild(h('button',{class:'button',type:'button','aria-pressed':String(index===state.attempt),onclick:()=>setState({attempt:index}),text:'Attempt '+(index+1)+' \xB7 '+item.status+' \xB7 '+formatDuration(item.durationMs)})));
1084
+ content.appendChild(switcher);
1085
+ }
1086
+ const errors=attempt?.errors?.length?attempt.errors:test.firstError?[test.firstError]:[];
1087
+ if(errors.length){content.appendChild(h('h3',{text:'Error'}));for(const error of errors){
1088
+ content.appendChild(h('p',{text:error.message.split('\\n')[0]}));
1089
+ if(error.snippet)content.appendChild(h('pre',{text:error.snippet}));
1090
+ if(error.stack)content.appendChild(h('details',{},h('summary',{text:'Stack trace'}),h('pre',{text:error.stack})));
1091
+ }}
1092
+ if(test.annotations?.length){content.appendChild(h('h3',{text:'Annotations'}));for(const item of test.annotations)content.appendChild(h('p',{text:item.type+(item.description?' \xB7 '+item.description:'')}))}
1093
+ if(attempt?.attachments?.length){content.appendChild(h('h3',{text:'Attachments'}));for(const attachment of attempt.attachments){
1094
+ const availability=attachment.path?(model.attachmentAvailability?.[attachment.path]||'unknown'):'inline';
1095
+ const href=availability==='missing'?null:attachment.path&&links[attachment.path];
1096
+ const traceAttachment=attachment.name==='trace'&&attachment.contentType==='application/zip';
1097
+ const kind=traceAttachment?'Trace':attachment.contentType?.startsWith('image/')?'Screenshot':attachment.contentType?.startsWith('video/')?'Video':'Attachment';
1098
+ content.appendChild(h('p',{},h('strong',{text:kind+' \xB7 '}),link(attachment.name,href),h('small',{text:availability==='missing'?'File not retained':availability==='unknown'?'Link unverified':availability==='inline'?'Inline attachment':''})));
1099
+ if(traceAttachment&&attachment.path&&availability!=='missing'){
1100
+ const trace=traceCommand(attachment.path||'');content.appendChild(h('pre',{text:trace}));
1101
+ content.appendChild(h('button',{class:'button',type:'button',onclick:()=>copyText(trace),text:'Copy trace command'}));
1102
+ }
1103
+ if(attachment.dataUri&&new RegExp('^data:image/(png|jpeg);base64,').test(attachment.dataUri)){
1104
+ const thumb=h('img',{src:attachment.dataUri,alt:attachment.name,style:'max-width:100%;max-height:220px'});
1105
+ thumb.addEventListener('click',()=>showImage(attachment.dataUri,attachment.name));content.appendChild(thumb);
1106
+ }
1107
+ }content.appendChild(h('p',{class:'muted',text:'Traces and attachments may contain page data or secrets. Review before sharing.'}))}
1108
+ if(attempt?.steps?.length){content.appendChild(h('h3',{text:'Steps'}));const list=h('ol',{class:'steps'});
1109
+ const max=Math.max(1,...attempt.steps.map(step=>step.durationMs));let failedStep=null;
1110
+ for(const step of attempt.steps){const item=h('li',{class:step.failed?'failed':'',style:'margin-left:'+(step.depth*12)+'px'},
1111
+ h('span',{text:step.title+' \xB7 '+formatDuration(step.durationMs)}),h('span',{class:'heat'+(step.failed?' hot':'')},h('span',{style:'width:'+Math.max(2,Math.round(step.durationMs/max*100))+'%'})));
1112
+ if(step.failed&&!failedStep)failedStep=item;list.appendChild(item)}content.appendChild(list);
1113
+ if(fresh&&failedStep)requestAnimationFrame(()=>failedStep.scrollIntoView({block:'nearest'}));
1114
+ }
1115
+ if(attempt?.stdout||attempt?.stderr){content.appendChild(h('h3',{text:'Output'}));
1116
+ const tabs=h('div',{class:'attempts'}),output=h('pre',{});
1117
+ for(const [label,value] of [['stdout',attempt.stdout],['stderr',attempt.stderr]])if(value){
1118
+ const button=h('button',{class:'button',type:'button','aria-pressed':String(!output.textContent),text:label});
1119
+ button.addEventListener('click',()=>{output.textContent=value;for(const other of tabs.children)other.setAttribute('aria-pressed',String(other===button))});
1120
+ tabs.appendChild(button);if(!output.textContent)output.textContent=value;
1121
+ }
1122
+ content.appendChild(tabs);content.appendChild(output);
1123
+ }
1124
+ if(!test.attempts.some(item=>item.steps?.length||item.stdout||item.stderr||item.attachments?.some(a=>a.dataUri)))content.appendChild(h('p',{class:'muted',text:'Enable captureDetails to see steps, output and screenshots.'}));
1125
+ content.appendChild(h('h3',{text:'Last 10 results'}));content.appendChild(recentStrip(model.recent?.[test.testId]));
1126
+ const previous=model.recent?.[test.testId]?.at(-1);
1127
+ content.appendChild(h('p',{class:'muted',text:'Previous run: '+({'p':'passed','f':'failed','k':'flaky','s':'skipped','-':'absent'}[previous]||'absent')}));
1128
+ content.appendChild(h('button',{class:'button',type:'button',onclick:()=>copyText(location.href)},icon('link'),'Copy link'));
1129
+ if(fresh)heading.focus();
1130
+ }
1131
+ function showImage(dataUri,label){const dialog=document.getElementById('lb-lightbox');clear(dialog);
1132
+ dialog.appendChild(h('button',{class:'button',type:'button',onclick:()=>dialog.close(),text:'Close'}));
1133
+ dialog.appendChild(h('img',{src:dataUri,alt:label,style:'max-width:90vw;max-height:80vh'}));dialog.showModal();
1134
+ }
1135
+ `;
1136
+
1137
+ // src/template/js/failures.ts
1138
+ var FAILURES_JS = `
1139
+ function renderFailures(){
1140
+ const panel=document.getElementById('tab-failures');clear(panel);panel.appendChild(h('h2',{text:'Failures'}));
1141
+ const comparison=model.comparison;
1142
+ if(comparison){
1143
+ const cards=h('div',{class:'grid-cards'});
1144
+ for(const [key,label] of [['newFailures','New failures'],['fixed','Fixed'],['stillFailing','Still failing'],['newTests','New tests'],['removedTests','Removed tests']]){
1145
+ const items=comparison[key]||[],card=h('div',{class:'section-card'},h('strong',{text:label+' \xB7 '+items.length}));
1146
+ for(const item of items.slice(0,10)){
1147
+ if(key==='removedTests'||!byId.has(item.testId)){card.appendChild(h('p',{text:item.title}));continue}
1148
+ const button=h('button',{class:'button',type:'button',text:item.title});
1149
+ button.addEventListener('click',()=>openTest(item.testId,button));card.appendChild(button);
1150
+ }
1151
+ cards.appendChild(card);
1152
+ }
1153
+ panel.appendChild(cards);
1154
+ }
1155
+ const groups=model.errorGroups||[];
1156
+ if(!groups.length){panel.appendChild(h('p',{class:'empty',text:'No failures or flaky tests in this run.'}));return}
1157
+ for(const group of groups){
1158
+ const box=h('details',{class:'section-card'});
1159
+ box.appendChild(h('summary',{},group.signature+' \xB7 '+group.count+' tests \xB7 '+group.projects.join(', '),
1160
+ group.newCount?h('span',{class:'status-chip failed',text:' NEW '+group.newCount}):null));
1161
+ for(const id of group.testIds){
1162
+ const test=byId.get(id);if(!test)continue;
1163
+ const button=h('button',{class:'button',type:'button',text:test.title+' \xB7 '+test.project});
1164
+ button.addEventListener('click',()=>openTest(id,button));box.appendChild(h('p',{},button));
1165
+ }
1166
+ panel.appendChild(box);
1167
+ }
1168
+ }
1169
+ `;
1170
+
1171
+ // src/template/js/trends.ts
1172
+ var TRENDS_JS = `
1173
+ function renderTrends(){
1174
+ const panel=document.getElementById('tab-trends');clear(panel);panel.appendChild(h('h2',{text:'Trends'}));
1175
+ const history=model.history||[];
1176
+ if(history.length<2){panel.appendChild(h('p',{class:'empty',text:'Run the suite more times to see trends.'}));return}
1177
+ const branches=[...new Set(history.map(item=>item.branch).filter(Boolean))].sort();
1178
+ const select=h('select',{'aria-label':'Filter branch'},h('option',{value:'',text:'All branches'}));
1179
+ for(const branch of branches)select.appendChild(h('option',{value:branch,text:branch}));
1180
+ panel.appendChild(select);const content=h('div',{});panel.appendChild(content);
1181
+ select.addEventListener('change',draw);
1182
+ function draw(){
1183
+ clear(content);const values=history.filter(item=>!select.value||item.branch===select.value);
1184
+ const maxDuration=Math.max(1,...values.map(item=>item.durationMs));
1185
+ const chart=s('svg',{class:'chart',viewBox:'0 0 800 190',role:'img','aria-label':'Pass rate and duration over runs'});
1186
+ const coordinates=[];
1187
+ values.forEach((item,index)=>{
1188
+ const x=25+(values.length===1?375:index*750/(values.length-1));
1189
+ const rate=item.summary.total?item.summary.passed/item.summary.total:0;
1190
+ const y=150-120*rate;
1191
+ coordinates.push([x,y]);
1192
+ const barHeight=70*item.durationMs/maxDuration;
1193
+ const bar=s('rect',{x:x-7,y:165-barHeight,width:14,height:barHeight});
1194
+ bar.appendChild(s('title',{},item.runId+' \xB7 '+formatDuration(item.durationMs)));
1195
+ chart.appendChild(bar);
1196
+ });
1197
+ const points=coordinates.map(([x,y])=>x+','+y).join(' ');
1198
+ const area=s('polygon',{points:'25,165 '+points+' 775,165',class:'trend-area'});
1199
+ chart.appendChild(area);chart.appendChild(s('polyline',{points,class:'trend-line'}));
1200
+ values.forEach((item,index)=>{
1201
+ const [x,y]=coordinates[index];const dot=s('circle',{cx:x,cy:y,r:4,class:'trend-dot'});
1202
+ dot.appendChild(s('title',{},item.runId+' \xB7 '+item.status));
1203
+ chart.appendChild(dot);
1204
+ });
1205
+ content.appendChild(chart);
1206
+ const strip=h('div',{class:'run-strip','aria-label':'Run status history'});
1207
+ for(const item of values)strip.appendChild(h('span',{class:item.status==='passed'?'passed':'failed',title:item.runId+' \xB7 '+item.status,'aria-label':item.runId+' '+item.status}));
1208
+ content.appendChild(strip);
1209
+ content.appendChild(table(['Run','Date','Branch','Status','Pass rate','Duration'],values.map(item=>[
1210
+ item.runId+(item.runId===model.run.runId?' \xB7 current':''),item.startedAt,item.branch||'',item.status,
1211
+ (item.summary.total?Math.round(100*item.summary.passed/item.summary.total):0)+'%',formatDuration(item.durationMs)
1212
+ ])));
1213
+ }
1214
+ draw();
1215
+ }
1216
+ function renderFlaky(){
1217
+ const panel=document.getElementById('tab-flaky');clear(panel);panel.appendChild(h('h2',{text:'Flaky tests'}));
1218
+ if(!model.flaky?.length){panel.appendChild(h('p',{class:'empty',text:'No flaky tests detected in the available runs.'}));return}
1219
+ const rows=model.flaky.map(item=>{
1220
+ const button=h('button',{class:'button',type:'button',text:item.title});
1221
+ button.addEventListener('click',()=>byId.has(item.testId)&&openTest(item.testId,button));
1222
+ const score=h('span',{},(item.score*100).toFixed(0)+'%',h('span',{class:'heat'},h('span',{style:'width:'+Math.round(item.score*100)+'%'})));
1223
+ return [button,item.file,item.project,score,item.runs,item.flakyRuns,item.fails,recentStrip(model.recent?.[item.testId])];
1224
+ });
1225
+ panel.appendChild(table(['Test','File','Project','Score','Runs','Flaky runs','Fails','Last 10'],rows));
1226
+ }
1227
+ `;
1228
+
1229
+ // src/template/js/run.ts
1230
+ var RUN_JS = `
1231
+ function renderRun(){
1232
+ const panel=document.getElementById('tab-run');clear(panel);panel.appendChild(h('h2',{text:'Run details'}));
1233
+ const run=model.run,env=run.env,cards=h('div',{class:'grid-cards'});
1234
+ const sections=[
1235
+ ['CI',[['Provider',env.ci?.provider],['Build ID',env.ci?.buildId]]],
1236
+ ['Git',[['Branch',env.git.branch],['Commit',env.git.commit],['PR',env.git.prNumber]]],
1237
+ ['Machine',[['OS',env.machine.os],['Architecture',env.machine.arch],['Node',env.machine.node],['CPUs',env.machine.cpus]]],
1238
+ ['Playwright',[['Version',env.playwrightVersion],['Workers',env.workers]]],
1239
+ ['Shards',[['Expected',run.expectedShards],['Received',run.receivedShards.join(', ')]]]
1240
+ ];
1241
+ for(const [name,values] of sections)cards.appendChild(h('div',{class:'section-card'},h('h3',{text:name}),...values.map(([label,value])=>h('p',{},label+': '+(value??'\u2014')))));
1242
+ panel.appendChild(cards);
1243
+ if(env.ci?.buildUrl)panel.appendChild(link('Open CI build',env.ci.buildUrl));
1244
+ panel.appendChild(h('h3',{text:'Timeline \xB7 approximate'}));
1245
+ if(run.expectedShards!==1){panel.appendChild(h('p',{class:'muted',text:'Timeline is available for unsharded runs.'}));return}
1246
+ const tests=run.tests.filter(test=>test.timing).sort((a,b)=>b.durationMs-a.durationMs).slice(0,1500);
1247
+ if(run.tests.length>1500)panel.appendChild(h('p',{class:'muted',text:'Showing the 1500 longest tests.'}));
1248
+ if(!tests.length){panel.appendChild(h('p',{class:'muted',text:'No timing data available.'}));return}
1249
+ const starts=tests.map(test=>Date.parse(test.timing.startedAt));
1250
+ const min=Math.min(...starts),max=Math.max(...tests.map((test,index)=>starts[index]+test.durationMs));
1251
+ const workers=[...new Set(tests.map(test=>test.timing.workerIndex))].sort((a,b)=>a-b);
1252
+ const chart=s('svg',{class:'chart timeline',viewBox:'0 0 800 '+Math.max(160,workers.length*34+30),role:'img','aria-label':'Test timeline'});
1253
+ tests.forEach((test,index)=>{
1254
+ const x=70+700*(starts[index]-min)/Math.max(1,max-min);
1255
+ const width=Math.max(2,700*test.durationMs/Math.max(1,max-min));
1256
+ const y=15+workers.indexOf(test.timing.workerIndex)*34;
1257
+ const rect=s('rect',{x,y,width,height:20,class:statusKind(test),tabindex:'0'});
1258
+ rect.appendChild(s('title',{},test.title+' \xB7 '+formatDuration(test.durationMs)));
1259
+ rect.addEventListener('click',()=>openTest(test.testId));
1260
+ rect.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();openTest(test.testId)}});
1261
+ chart.appendChild(rect);
1262
+ });
1263
+ workers.forEach((worker,index)=>chart.appendChild(s('text',{x:4,y:30+index*34},'W'+worker)));
1264
+ panel.appendChild(h('div',{class:'table-wrap'},chart));
1265
+ }
1266
+ `;
1267
+
1268
+ // src/template/js/project.ts
1269
+ var PROJECT_JS = `
1270
+ function renderProject(){
1271
+ const panel=document.getElementById('tab-project');clear(panel);const info=model.run.project;
1272
+ panel.appendChild(h('h2',{text:'Project'}));
1273
+ panel.appendChild(h('p',{class:'muted',text:(info.name||'(unnamed)')+' \xB7 '+(info.configFile||'No config file')}));
1274
+ panel.appendChild(h('h3',{text:'Playwright projects'}));
1275
+ const cards=h('div',{class:'grid-cards'});
1276
+ for(const item of model.projects||[]){
1277
+ const rate=item.total?Math.round(item.passed/item.total*100):0;
1278
+ cards.appendChild(h('div',{class:'section-card'},h('strong',{text:item.name||'(default)'}),
1279
+ h('p',{text:item.total+' tests \xB7 '+rate+'% passed'}),h('small',{text:formatDuration(item.durationMs)})));
1280
+ }
1281
+ panel.appendChild(cards);
1282
+ panel.appendChild(h('h3',{text:'Files'}));
1283
+ const sort=h('select',{'aria-label':'Sort files'});
1284
+ for(const [value,label] of [['file','File'],['failed','Most failed'],['flaky','Most flaky'],['durationMs','Slowest']])sort.appendChild(h('option',{value,text:label}));
1285
+ panel.appendChild(sort);const files=h('div',{});panel.appendChild(files);
1286
+ function drawFiles(){
1287
+ clear(files);const key=sort.value;
1288
+ const ordered=[...model.files].sort((a,b)=>key==='file'?(a.file<b.file?-1:a.file>b.file?1:0):b[key]-a[key]||(a.file<b.file?-1:1));
1289
+ const rows=ordered.map(item=>{
1290
+ const failed=h('span',{},String(item.failed),h('span',{class:'heat hot'},h('span',{style:'width:'+(item.total?Math.round(item.failed/item.total*100):0)+'%'})));
1291
+ const flaky=h('span',{},String(item.flaky),h('span',{class:'heat'},h('span',{style:'width:'+(item.total?Math.round(item.flaky/item.total*100):0)+'%'})));
1292
+ return [item.file,item.total,failed,flaky,item.skipped,formatDuration(item.durationMs)];
1293
+ });
1294
+ files.appendChild(table(['File','Total','Failed','Flaky','Skipped','Duration'],rows));
1295
+ }
1296
+ sort.addEventListener('change',drawFiles);drawFiles();
1297
+ panel.appendChild(h('h3',{text:'Tags'}));const tags=h('div',{class:'toolbar'});
1298
+ for(const item of model.tags){const button=h('button',{class:'chip-button',type:'button',text:item.tag+' \xB7 '+item.count});
1299
+ button.addEventListener('click',()=>setState({tab:'tests',tag:item.tag}));tags.appendChild(button)}
1300
+ panel.appendChild(tags);panel.appendChild(h('h3',{text:'Slowest tests'}));
1301
+ panel.appendChild(table(['Test','File','Project','Duration'],model.slowest.map(item=>{
1302
+ const button=h('button',{class:'button',type:'button',text:item.title});
1303
+ button.addEventListener('click',()=>openTest(item.testId,button));
1304
+ return [button,item.file,item.project,formatDuration(item.durationMs)];
1305
+ })));
1306
+ }
1307
+ `;
1308
+
1309
+ // src/template/js/help.ts
1310
+ var HELP_JS = `
1311
+ function showHelp(){const dialog=document.getElementById('lb-help-dialog');if(!dialog.childNodes.length){dialog.appendChild(h('h2',{text:'Keyboard shortcuts'}));dialog.appendChild(table(['Key','Action'],[['1\u20136','Switch tabs'],['/','Search tests'],['j / k','Move between rows'],['Enter / o','Open test'],['Esc','Close panel'],['[ / ]','Previous / next failure'],['t','Theme'],['?','Help']]));dialog.appendChild(h('button',{class:'button',type:'button',onclick:()=>dialog.close(),text:'Close'}))}dialog.showModal()}
1312
+ `;
1313
+
1314
+ // src/template.ts
1315
+ function buildStyles() {
1316
+ return REPORT_CSS;
1317
+ }
1318
+ function buildClientScript() {
1319
+ const pure = Object.entries(clientlib_exports).filter(([, value]) => typeof value === "function");
1320
+ return `(function(){'use strict';
1321
+ ${pure.map(([name, fn]) => `const ${name}=(${fn.toString()});`).join("\n")}
1322
+ const detectSignals=(${detectSignals.toString()});
1323
+ const buildDebugPacket=(${buildDebugPacket.toString()});
1324
+ const debugPacketMarkdown=(${debugPacketMarkdown.toString()});
1325
+ const icons=${JSON.stringify(ICONS)};
1326
+ try{${CORE_JS}${TESTS_JS}${PANEL_JS}${FAILURES_JS}${TRENDS_JS}${RUN_JS}${PROJECT_JS}${HELP_JS}
1327
+ init()}catch(error){const banner=document.getElementById('lb-error');banner.hidden=false;banner.textContent='Report could not load: '+String(error)}})();`;
1328
+ }
1329
+
1330
+ // src/render.ts
1331
+ var escapeHtml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1332
+ var safeJson = (value) => JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
1333
+ var tabs = ["tests", "failures", "trends", "flaky", "run", "project"];
1334
+ function attachmentLinks(model, reportDir) {
1335
+ const links = {};
1336
+ for (const test of model.run.tests) for (const attempt of test.attempts) for (const attachment of attempt.attachments) {
1337
+ const file = attachment.path;
1338
+ if (file && model.attachmentAvailability?.[file] !== "missing" && !import_node_path4.default.posix.isAbsolute(file) && !file.split("/").includes("..") && !file.includes("\\") && !file.includes(":")) links[file] = import_node_path4.default.posix.relative(reportDir, file);
1339
+ }
1340
+ return links;
1341
+ }
1342
+ function donut(model) {
1343
+ const summary = model.run.summary;
1344
+ const rate = summary.total ? `${Math.round(summary.passed / summary.total * 100)}%` : "\u2014";
1345
+ const arcs = donutSegments(summary, 54).map((item) => `<circle class="donut-segment ${item.kind}" cx="72" cy="72" r="54" fill="none" stroke-width="15" stroke-dasharray="${item.dasharray}" stroke-dashoffset="${item.offset}" transform="rotate(-90 72 72)"/>`).join("");
1346
+ return `<svg class="donut" viewBox="0 0 144 144" role="img" aria-label="${escapeHtml(rate)} passed"><circle class="donut-track" cx="72" cy="72" r="54" fill="none" stroke-width="15"/>${arcs}<text class="donut-center" x="72" y="72" text-anchor="middle">${rate}</text><text class="donut-label" x="72" y="88" text-anchor="middle">passed</text></svg>`;
1347
+ }
1348
+ function comparison(model) {
1349
+ const delta = model.delta;
1350
+ if (!delta) return '<section class="comparison comparison-empty" aria-label="Run comparison"><div><h2>Run comparison</h2><p>No earlier run to compare yet. Changes will appear here after the next run.</p></div></section>';
1351
+ const signed = (value, suffix = "") => `${value > 0 ? "+" : ""}${value}${suffix}`;
1352
+ const metric = (label, value) => `<div class="comparison-metric"><span>${label}</span><strong>${escapeHtml(value)}</strong></div>`;
1353
+ const newFailures = model.comparison?.newFailures.length ?? 0;
1354
+ return `<section class="comparison" aria-label="Changes since previous run"><div class="comparison-head"><div><h2>Changes since previous run</h2><p>Compared with <code>${escapeHtml(delta.previousRunId)}</code> \xB7 signed values are net changes</p></div>${newFailures ? '<button id="lb-new-failures" class="button" type="button">View new failures</button>' : ""}</div><div class="comparison-grid">${metric("Pass rate change", delta.passRatePp === null ? "\u2014" : signed(delta.passRatePp, " pp"))}${metric("Failed change", signed(delta.failed))}${metric("Flaky change", signed(delta.flaky))}${metric("Duration change", delta.durationPct === null ? "\u2014" : signed(delta.durationPct, "%"))}${metric("New failures", String(newFailures))}</div></section>`;
1355
+ }
1356
+ function renderReport(model, options = {}) {
1357
+ const run = model.run;
1358
+ const title = options.title ?? run.title ?? "Playwright Logbook";
1359
+ const reportDir = options.reportDir ?? import_node_path4.default.posix.join(run.paths.outputDir, "report");
1360
+ const cards = ["total", "passed", "failed", "flaky", "skipped"].map((kind) => `<button type="button" class="card" data-card-kind="${kind}">${kind[0].toUpperCase()}${kind.slice(1)}<strong>${run.summary[kind]}</strong></button>`).join("");
1361
+ const tabButtons = tabs.map((tab) => `<button type="button" role="tab" data-tab="${tab}" aria-controls="tab-${tab}" aria-selected="false">${tab[0].toUpperCase()}${tab.slice(1)}</button>`).join("");
1362
+ const panels = tabs.map((tab) => `<section class="panel" id="tab-${tab}" role="tabpanel" hidden></section>`).join("");
1363
+ const missing2 = Array.from({ length: run.expectedShards ?? 1 }, (_, index) => index + 1).filter((number2) => !run.receivedShards.includes(number2));
1364
+ const banner = run.complete ? "" : `<p class="banner">INCOMPLETE run \xB7 missing shards ${missing2.join(", ")}</p>`;
1365
+ const projectName = run.project.name || "Unnamed project";
1366
+ const meta = [run.env.git.branch && `Branch: ${run.env.git.branch}`, run.env.git.commit && `Commit: ${run.env.git.commit.slice(0, 7)}`, run.env.git.prNumber && `PR #${run.env.git.prNumber}`, `Shards: ${run.receivedShards.length}/${run.expectedShards ?? 1}`, `Playwright: ${run.env.playwrightVersion}`, `Workers: ${run.env.workers}`].filter(Boolean).map((item) => `<span>${escapeHtml(String(item))}</span>`).join("");
1367
+ const buildUrl = run.env.ci?.buildUrl;
1368
+ const buildLink = buildUrl && /^https?:\/\//i.test(buildUrl) && safeHref(buildUrl) ? `<a href="${escapeHtml(buildUrl)}" rel="noopener noreferrer">CI build</a>` : "";
1369
+ const footer = `${model.generatedAt ? `Generated ${escapeHtml(model.generatedAt)} \xB7 ` : ""}playwright-logbook v${escapeHtml(model.generator?.version ?? "0.1.0")} \xB7 Press ? for shortcuts`;
1370
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>${buildStyles()}</style></head><body><a class="skip-link" href="#tab-tests">Skip to results</a><div class="topbar"><div class="topbar-inner"><span class="wordmark">\u25A4 logbook</span><span class="topbar-title">${escapeHtml(projectName)}</span><span class="status-chip ${run.status === "passed" ? "passed" : "failed"}">${escapeHtml(run.status.toUpperCase())}</span><div class="topbar-actions"><button id="lb-theme" type="button">Dark mode</button><button id="lb-copy-summary" class="optional" type="button">Copy summary</button><button id="lb-download" class="optional" type="button">Download JSON</button><button id="lb-print" class="optional" type="button">Print</button><button id="lb-help" type="button" aria-label="Help">?</button></div></div></div><main class="container"><header id="lb-header" class="hero">${donut(model)}<div class="hero-main"><p class="eyebrow">Project <strong>${escapeHtml(projectName)}</strong></p><h1>${escapeHtml(title)}</h1><dl class="run-facts"><div><dt>Run ID</dt><dd title="${escapeHtml(run.runId)}">${escapeHtml(run.runId)}</dd></div><div><dt>Started</dt><dd><time datetime="${escapeHtml(run.startedAt)}" title="${escapeHtml(run.startedAt)}">${escapeHtml(run.startedAt)}</time></dd></div><div><dt>Duration</dt><dd>${formatDuration2(run.durationMs)}</dd></div></dl><details class="env-disclosure"><summary>Environment details</summary><div class="meta">${meta}${buildLink}</div></details></div></header>${banner}<div id="lb-cards" class="cards">${cards}</div>${comparison(model)}<p id="lb-error" class="error-banner" hidden></p><nav class="tabs" role="tablist" aria-label="Report sections">${tabButtons}</nav>${panels}<footer>${footer}</footer></main><div id="lb-backdrop" class="panel-backdrop" hidden></div><aside id="lb-panel" class="detail-panel" aria-label="Test details" hidden><div class="panel-head"><h2 id="lb-panel-heading" tabindex="-1"></h2><button id="lb-panel-close" type="button" aria-label="Close details">Close</button></div><div id="lb-panel-content"></div></aside><div id="lb-toast" class="toast" role="status" aria-live="polite" hidden></div><dialog id="lb-help-dialog" class="help-dialog"></dialog><dialog id="lb-lightbox" class="lightbox"></dialog><noscript>Enable JavaScript to explore tests and report details.</noscript><script type="application/json" id="lb-data">${safeJson(model)}</script><script type="application/json" id="lb-links">${safeJson(attachmentLinks(model, reportDir))}</script><script>${buildClientScript()}</script></body></html>`;
1371
+ }
1372
+
1373
+ // src/artifacts.ts
1374
+ var import_promises2 = __toESM(require("node:fs/promises"), 1);
1375
+ var import_node_path5 = __toESM(require("node:path"), 1);
1376
+ var compare5 = (a, b) => a < b ? -1 : a > b ? 1 : 0;
1377
+ async function resolveArtifactAvailability(run, root, files = import_promises2.default) {
1378
+ const paths = [...new Set(run.tests.flatMap(
1379
+ (test) => test.attempts.flatMap(
1380
+ (attempt) => attempt.attachments.map((item) => item.path).filter((item) => item !== null)
1381
+ )
1382
+ ))].sort(compare5);
1383
+ const result = {};
1384
+ let realRoot;
1385
+ try {
1386
+ realRoot = await files.realpath(root);
1387
+ } catch {
1388
+ return Object.fromEntries(paths.map((file) => [file, "unknown"]));
1389
+ }
1390
+ for (const file of paths) {
1391
+ if (import_node_path5.default.posix.isAbsolute(file) || file.includes("\\") || file.split("/").includes("..") || file.includes(":")) {
1392
+ result[file] = "missing";
1393
+ continue;
1394
+ }
1395
+ const target = import_node_path5.default.resolve(root, file);
1396
+ if (!target.startsWith(`${import_node_path5.default.resolve(root)}${import_node_path5.default.sep}`)) {
1397
+ result[file] = "missing";
1398
+ continue;
1399
+ }
1400
+ try {
1401
+ const realTarget = await files.realpath(target);
1402
+ result[file] = realTarget.startsWith(`${realRoot}${import_node_path5.default.sep}`) && (await files.stat(realTarget)).isFile() ? "present" : "missing";
1403
+ } catch (error2) {
1404
+ result[file] = typeof error2 === "object" && error2 !== null && "code" in error2 && error2.code === "ENOENT" ? "missing" : "unknown";
1405
+ }
1406
+ }
1407
+ return result;
1408
+ }
1409
+
1410
+ // src/reporter.ts
1411
+ var LogbookReporter = class {
1412
+ options;
1413
+ deps;
1414
+ config;
1415
+ suite;
1416
+ startedAt;
1417
+ detected;
1418
+ root;
1419
+ globalErrors = [];
1420
+ warned = false;
1421
+ stepsByResult = /* @__PURE__ */ new WeakMap();
1422
+ constructor(options = {}, deps = {}) {
1423
+ this.options = resolveOptions(options);
1424
+ this.deps = deps;
1425
+ }
1426
+ printsToStdio() {
1427
+ return false;
1428
+ }
1429
+ write(line) {
1430
+ (this.deps.stderr ?? ((text) => process.stderr.write(text)))(`${line}
1431
+ `);
1432
+ }
1433
+ warning(error2) {
1434
+ if (this.warned) return;
1435
+ this.warned = true;
1436
+ try {
1437
+ const message = sanitize(error2 instanceof Error ? error2.message : String(error2), { projectRoot: this.root, env: this.deps.env, redact: this.options.redact, maxTextLength: this.options.maxTextLength });
1438
+ this.write(`[logbook] warning: ${message}`);
1439
+ } catch {
1440
+ }
1441
+ }
1442
+ onBegin(config, suite) {
1443
+ try {
1444
+ this.config = config;
1445
+ this.suite = suite;
1446
+ this.root = config.configFile ? import_node_path6.default.dirname(config.configFile) : (this.deps.cwd ?? process.cwd)();
1447
+ this.startedAt = (this.deps.clock ?? (() => /* @__PURE__ */ new Date()))();
1448
+ this.detected = detectEnv({ ...this.deps, root: this.root, runId: this.options.runId });
1449
+ } catch (error2) {
1450
+ this.warning(error2);
1451
+ }
1452
+ }
1453
+ onError(error2) {
1454
+ try {
1455
+ this.globalErrors.push(error2);
1456
+ } catch (caught) {
1457
+ this.warning(caught);
1458
+ }
1459
+ }
1460
+ onStepEnd(_test, result, step) {
1461
+ try {
1462
+ const capture = this.options.captureDetails;
1463
+ if (!capture || capture !== true && !capture.steps) return;
1464
+ if (!["test.step", "expect", "pw:api", "hook", "fixture"].includes(step.category)) return;
1465
+ const steps = this.stepsByResult.get(result) ?? [];
1466
+ if (steps.length >= (this.options.maxSteps ?? 100)) return;
1467
+ let depth = 0, parent = step.parent;
1468
+ while (parent && depth < 2) {
1469
+ depth += 1;
1470
+ parent = parent.parent;
1471
+ }
1472
+ steps.push({ title: sanitize(step.title, { projectRoot: this.root, env: this.deps.env ?? process.env, redact: this.options.redact, maxTextLength: this.options.maxTextLength }), category: step.category, durationMs: Math.max(0, step.duration), depth, failed: Boolean(step.error) });
1473
+ this.stepsByResult.set(result, steps);
1474
+ } catch (error2) {
1475
+ this.warning(error2);
1476
+ }
1477
+ }
1478
+ async captureImages(tests) {
1479
+ const result = /* @__PURE__ */ new WeakMap();
1480
+ const capture = this.options.captureDetails;
1481
+ if (!capture || capture !== true && !capture.images || !this.root) return result;
1482
+ let total = 0;
1483
+ const reader = this.deps.fs?.readFile ?? import_promises3.default.readFile;
1484
+ const stat = this.deps.fs?.stat ?? import_promises3.default.stat;
1485
+ for (const test of tests) {
1486
+ if (test.outcome() !== "unexpected" && test.outcome() !== "flaky") continue;
1487
+ for (const attempt of test.results) for (const attachment of attempt.attachments) {
1488
+ if (!attachment.path || !["image/png", "image/jpeg"].includes(attachment.contentType) || toRelOrNull(this.root, attachment.path) === null) continue;
1489
+ try {
1490
+ const size = (await stat(attachment.path)).size;
1491
+ if (size > (this.options.maxImageBytes ?? 25e4) || total + size > (this.options.maxEmbeddedBytes ?? 5e6)) continue;
1492
+ const bytes = await reader(attachment.path);
1493
+ if (bytes.length > (this.options.maxImageBytes ?? 25e4) || total + bytes.length > (this.options.maxEmbeddedBytes ?? 5e6)) continue;
1494
+ result.set(attachment, `data:${attachment.contentType};base64,${bytes.toString("base64")}`);
1495
+ total += bytes.length;
1496
+ } catch {
1497
+ }
1498
+ }
1499
+ }
1500
+ return result;
1501
+ }
1502
+ async saveReport(run, store, outputDir) {
1503
+ const summaries = await store.listSummaries({ limit: this.options.historyLimit });
1504
+ const current = summaries.find((item) => item.runId === run.runId) ?? { schemaVersion: 1, runId: run.runId, title: run.title, startedAt: run.startedAt, durationMs: run.durationMs, status: run.status, complete: run.complete, summary: run.summary, branch: run.env.git.branch, commit: run.env.git.commit, ciProvider: run.env.ci?.provider ?? null, buildUrl: run.env.ci?.buildUrl ?? null };
1505
+ const previous = previousRun(summaries, current);
1506
+ const previousRecord = previous ? await store.loadRun(previous.runId) : null;
1507
+ const runs = await store.loadRuns(summaries.map((item) => item.runId).reverse());
1508
+ const attachmentAvailability = await resolveArtifactAvailability(run, this.root, { realpath: this.deps.fs?.realpath ?? import_promises3.default.realpath, stat: this.deps.fs?.stat ?? import_promises3.default.stat });
1509
+ const model = buildReportModel({ run, summaries, previous, comparison: compareRuns(run, previousRecord), flaky: computeFlaky(runs), recentRuns: runs.filter((item) => item.runId !== run.runId), generatedAt: (this.deps.clock ?? (() => /* @__PURE__ */ new Date()))().toISOString(), historyLimit: this.options.historyLimit, attachmentAvailability });
1510
+ const target = import_node_path6.default.join(outputDir, "report", "index.html");
1511
+ const files = this.deps.fs ?? import_promises3.default;
1512
+ await files.mkdir(import_node_path6.default.dirname(target), { recursive: true });
1513
+ await files.writeFile(`${target}.tmp`, renderReport(model), "utf8");
1514
+ await files.rename(`${target}.tmp`, target);
1515
+ }
1516
+ async onEnd(result) {
1517
+ try {
1518
+ if (!this.config || !this.suite || !this.root || !this.startedAt || !this.detected) throw new Error("reporter was not initialized");
1519
+ const endedAt = result.startTime && result.duration !== void 0 ? new Date(result.startTime.getTime() + result.duration) : (this.deps.clock ?? (() => /* @__PURE__ */ new Date()))();
1520
+ const outputDir = import_node_path6.default.resolve(this.root, this.options.outputDir);
1521
+ const tests = this.suite.allTests();
1522
+ const imageData = await this.captureImages(tests);
1523
+ const shard = buildShardFile({ config: this.config, tests, runId: this.detected.runId, title: this.options.title, startedAt: this.startedAt, endedAt, status: result.status, env: { ci: this.detected.ci, git: this.detected.git, machine: this.detected.machine, playwrightVersion: this.config.version, workers: this.config.workers }, globalErrors: this.globalErrors, ctx: { projectRoot: this.root, env: this.deps.env ?? (this.options.captureDetails ? process.env : void 0), redact: this.options.redact, caseIdPatterns: this.options.caseIdPatterns, maxTextLength: this.options.maxTextLength, captureDetails: this.options.captureDetails, maxSteps: this.options.maxSteps, maxOutputLength: this.options.maxOutputLength, stepsByResult: this.stepsByResult, imageData } });
1524
+ await (this.deps.sink ?? new FileShardSink(outputDir, this.deps.fs)).write(shard);
1525
+ if (this.options.autoMerge && (!shard.shard || shard.shard.total === 1)) {
1526
+ const { run } = mergeShards([shard], { outputDir: toRel(this.root, outputDir) });
1527
+ const store = this.deps.historyStore ?? new FileHistoryStore(outputDir);
1528
+ await store.saveRun(run);
1529
+ if (this.options.autoReport) await this.saveReport(run, store, outputDir);
1530
+ }
1531
+ if (!this.options.quiet) {
1532
+ const count = (outcome) => shard.tests.filter((test) => test.outcome === outcome).length;
1533
+ const reportPath = this.options.autoMerge && this.options.autoReport && (!shard.shard || shard.shard.total === 1) ? ` -> ${toRel(this.root, import_node_path6.default.join(outputDir, "report", "index.html"))}` : "";
1534
+ this.write(`[logbook] run ${shard.runId}: ${count("expected")} passed, ${count("unexpected")} failed, ${count("flaky")} flaky, ${count("skipped")} skipped${reportPath}`);
1535
+ }
1536
+ } catch (error2) {
1537
+ this.warning(error2);
1538
+ }
1539
+ }
1540
+ };
1541
+ // Annotate the CommonJS export names for ESM import in node:
1542
+ 0 && (module.exports = {
1543
+ FileHistoryStore,
1544
+ FileShardSink,
1545
+ LogbookReporter,
1546
+ SCHEMA_VERSION,
1547
+ VERSION,
1548
+ buildJsonSummary,
1549
+ buildReportModel,
1550
+ compareRuns,
1551
+ computeFlaky,
1552
+ mergeShards,
1553
+ previousRun,
1554
+ renderMarkdownSummary,
1555
+ renderReport,
1556
+ renderTextSummary,
1557
+ runRecordSchema,
1558
+ runSummaryRecordSchema,
1559
+ shardFileSchema,
1560
+ stepRecordSchema,
1561
+ testRecordSchema
1562
+ });