@anselmdk/feature-spec-md 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +59 -78
  2. package/SPEC_FORMAT.md +38 -78
  3. package/dist/cli.js +194 -86
  4. package/dist/cli.js.map +1 -1
  5. package/dist/featureSpecs.d.ts +1 -1
  6. package/dist/featureSpecs.d.ts.map +1 -1
  7. package/dist/featureSpecs.js +57 -10
  8. package/dist/featureSpecs.js.map +1 -1
  9. package/dist/githubActionDiffReport.d.ts +61 -0
  10. package/dist/githubActionDiffReport.d.ts.map +1 -1
  11. package/dist/githubActionDiffReport.js +410 -120
  12. package/dist/githubActionDiffReport.js.map +1 -1
  13. package/dist/githubActionReport.d.ts.map +1 -1
  14. package/dist/githubActionReport.js +27 -22
  15. package/dist/githubActionReport.js.map +1 -1
  16. package/dist/index.d.ts +8 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/mockReports.d.ts +36 -0
  21. package/dist/mockReports.d.ts.map +1 -0
  22. package/dist/mockReports.js +280 -0
  23. package/dist/mockReports.js.map +1 -0
  24. package/dist/mocks/current/app/supportDesk.d.ts +12 -0
  25. package/dist/mocks/current/app/supportDesk.d.ts.map +1 -0
  26. package/dist/mocks/current/app/supportDesk.js +19 -0
  27. package/dist/mocks/current/app/supportDesk.js.map +1 -0
  28. package/dist/mocks/current/tests/supportDesk.spec.d.ts +3 -0
  29. package/dist/mocks/current/tests/supportDesk.spec.d.ts.map +1 -0
  30. package/dist/mocks/current/tests/supportDesk.spec.js +15 -0
  31. package/dist/mocks/current/tests/supportDesk.spec.js.map +1 -0
  32. package/dist/mocks/previous/app/supportDesk.d.ts +10 -0
  33. package/dist/mocks/previous/app/supportDesk.d.ts.map +1 -0
  34. package/dist/mocks/previous/app/supportDesk.js +9 -0
  35. package/dist/mocks/previous/app/supportDesk.js.map +1 -0
  36. package/dist/mocks/previous/tests/supportDesk.spec.d.ts +3 -0
  37. package/dist/mocks/previous/tests/supportDesk.spec.d.ts.map +1 -0
  38. package/dist/mocks/previous/tests/supportDesk.spec.js +15 -0
  39. package/dist/mocks/previous/tests/supportDesk.spec.js.map +1 -0
  40. package/dist/reportArtifacts.d.ts +7 -0
  41. package/dist/reportArtifacts.d.ts.map +1 -0
  42. package/dist/reportArtifacts.js +26 -0
  43. package/dist/reportArtifacts.js.map +1 -0
  44. package/dist/reportHtml.d.ts +9 -0
  45. package/dist/reportHtml.d.ts.map +1 -0
  46. package/dist/reportHtml.js +36 -0
  47. package/dist/reportHtml.js.map +1 -0
  48. package/dist/reportMetadata.d.ts +9 -0
  49. package/dist/reportMetadata.d.ts.map +1 -0
  50. package/dist/reportMetadata.js +28 -0
  51. package/dist/reportMetadata.js.map +1 -0
  52. package/dist/screenshots.d.ts +3 -1
  53. package/dist/screenshots.d.ts.map +1 -1
  54. package/dist/screenshots.js +23 -0
  55. package/dist/screenshots.js.map +1 -1
  56. package/dist/types.d.ts +11 -1
  57. package/dist/types.d.ts.map +1 -1
  58. package/docs/evidence-policy.md +63 -0
  59. package/docs/report-development.md +102 -0
  60. package/docs/spec-driven-flow.md +67 -8
  61. package/package.json +3 -1
  62. package/src/mocks/current/app/supportDesk.ts +27 -0
  63. package/src/mocks/current/screenshots/SUPPORT-INBOX-S001-line-27-current.svg +10 -0
  64. package/src/mocks/current/screenshots/SUPPORT-REPLY-S001-line-27-current.svg +10 -0
  65. package/src/mocks/current/screenshots/screenshots.json +18 -0
  66. package/src/mocks/current/specs/support-desk.model.md +29 -0
  67. package/src/mocks/current/specs/ticket-inbox.feature.md +27 -0
  68. package/src/mocks/current/specs/ticket-reply.feature.md +33 -0
  69. package/src/mocks/current/tests/supportDesk.spec.ts +17 -0
  70. package/src/mocks/previous/app/supportDesk.ts +17 -0
  71. package/src/mocks/previous/screenshots/SUPPORT-INBOX-S001-line-27-previous.svg +10 -0
  72. package/src/mocks/previous/screenshots/screenshots.json +11 -0
  73. package/src/mocks/previous/specs/support-desk.model.md +29 -0
  74. package/src/mocks/previous/specs/ticket-inbox.feature.md +26 -0
  75. package/src/mocks/previous/specs/ticket-reply.feature.md +26 -0
  76. package/src/mocks/previous/tests/supportDesk.spec.ts +17 -0
@@ -1,36 +1,27 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { basename, join, relative } from "node:path";
2
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
3
  import { tmpdir } from "node:os";
4
+ import { basename, join, relative } from "node:path";
5
5
  import { html } from "./html.js";
6
6
  import { downloadRemoteFile, ftpConfig, listBuildNumbers, listRemoteFilesRecursive, pathJoin, publicUrl, uploadDirectory, } from "./githubActionFtp.js";
7
7
  import { writeGithubOutput, writeGithubSummary } from "./githubActionOutput.js";
8
+ import { publishedSpecRoot } from "./reportArtifacts.js";
9
+ import { renderHtmlPage } from "./reportHtml.js";
8
10
  export async function publishGithubActionDiffReport(options) {
9
11
  const config = ftpConfig(options);
10
12
  const prNumber = config.prNumber;
11
- if (!prNumber) {
13
+ if (!prNumber)
12
14
  throw new Error("Missing required option --pr-number or FEATURE_SPEC_PR_NUMBER.");
13
- }
14
15
  const buildsRoot = pathJoin(config.remoteDir, "build");
15
16
  const builds = await listBuildNumbers(buildsRoot, config);
16
17
  const currentBuild = config.buildNumber;
17
- const previousBuild = previousBuildNumber(builds, currentBuild);
18
- const currentBuildUrl = publicUrl(config.baseUrl, "build", currentBuild, "");
19
- const previousBuildUrl = previousBuild
20
- ? publicUrl(config.baseUrl, "build", previousBuild, "")
21
- : undefined;
22
- const report = previousBuild
23
- ? await compareBuilds(config, previousBuild, currentBuild, prNumber)
24
- : {
25
- prNumber,
26
- currentBuild,
27
- currentBuildUrl,
28
- files: [],
29
- };
30
- if (previousBuild) {
31
- report.previousBuild = previousBuild;
32
- report.previousBuildUrl = previousBuildUrl;
33
- }
18
+ const requestedBaseBuild = optionValue(options, "base-build-number", "FEATURE_SPEC_BASE_BUILD_NUMBER");
19
+ const hasRequestedBaseBuild = Boolean(requestedBaseBuild && builds.includes(requestedBaseBuild));
20
+ const baseBuild = hasRequestedBaseBuild ? requestedBaseBuild : previousBuildNumber(builds, currentBuild);
21
+ const baseLabel = hasRequestedBaseBuild ? "main" : baseBuild ? "previous" : "none";
22
+ const report = baseBuild
23
+ ? await compareBuilds(config, baseBuild, currentBuild, prNumber, baseLabel)
24
+ : emptyReport(config, prNumber, currentBuild);
34
25
  const localReportDir = join(tmpdir(), `feature-spec-md-pr-diff-${process.pid}-${prNumber}-${currentBuild}`);
35
26
  await mkdir(localReportDir, { recursive: true });
36
27
  await writeFile(join(localReportDir, "index.html"), renderDiffReport(report), "utf8");
@@ -41,145 +32,432 @@ export async function publishGithubActionDiffReport(options) {
41
32
  "## Feature Spec PR Diff",
42
33
  "",
43
34
  `<p><strong>Diff report:</strong> <a href=\"${html(reportUrl)}\">PR #${html(prNumber)} build ${html(currentBuild)}</a></p>`,
44
- report.previousBuild
45
- ? `<p>Compared build <strong>${html(report.previousBuild)}</strong> with build <strong>${html(currentBuild)}</strong>.</p>`
46
- : `<p>No earlier build was found, so this report establishes the first comparison baseline.</p>`,
35
+ summaryComparisonSentence(report),
47
36
  "",
48
37
  ].join("\n"));
49
38
  await writeGithubOutput({
50
39
  "diff-report-url": reportUrl,
51
40
  "diff-comment-body": commentBody(report, reportUrl),
52
- "previous-build": report.previousBuild ?? "",
41
+ "base-build": report.baseLabel === "main" ? report.baseBuild ?? "" : "",
42
+ "previous-build": report.baseLabel === "previous" ? report.baseBuild ?? "" : "",
53
43
  "current-build": currentBuild,
54
44
  });
55
45
  console.log(`Feature spec PR diff report uploaded to ${reportUrl}`);
56
46
  }
57
- async function compareBuilds(config, previousBuild, currentBuild, prNumber) {
58
- const previousRoot = pathJoin(config.remoteDir, "build", previousBuild);
47
+ export async function renderLocalDiffReport(options) {
48
+ return renderDiffReport(await compareLocalBuilds(options));
49
+ }
50
+ async function compareLocalBuilds(options) {
51
+ const baseBuild = options.baseBuild ?? "127";
52
+ const currentBuild = options.currentBuild ?? "128";
53
+ const files = await compareLocalFiles(options.previousDir, options.currentDir);
54
+ const previousSpecs = await loadSpecSections(options.previousDir);
55
+ const currentSpecs = await loadSpecSections(options.currentDir);
56
+ const specDiffs = compareSpecSections(previousSpecs, currentSpecs);
57
+ const scenarioToSpec = scenarioSpecMap([...previousSpecs, ...currentSpecs]);
58
+ const screenshotDiffs = groupScreenshotDiffs(files, scenarioToSpec, {
59
+ previousUrl: (filePath) => relativeAssetUrl(options.previousAssetUrlPrefix ?? "previous", filePath),
60
+ currentUrl: (filePath) => relativeAssetUrl(options.currentAssetUrlPrefix ?? "current", filePath),
61
+ });
62
+ return {
63
+ prNumber: options.prNumber ?? "42",
64
+ baseBuild,
65
+ currentBuild,
66
+ baseBuildUrl: options.baseBuildUrl ?? "previous/",
67
+ currentBuildUrl: options.currentBuildUrl ?? "current/",
68
+ baseLabel: options.baseLabel ?? "previous",
69
+ files,
70
+ specDiffs,
71
+ screenshotDiffs,
72
+ };
73
+ }
74
+ function emptyReport(config, prNumber, currentBuild) {
75
+ return {
76
+ prNumber,
77
+ currentBuild,
78
+ currentBuildUrl: publicUrl(config.baseUrl, "build", currentBuild, ""),
79
+ baseLabel: "none",
80
+ files: [],
81
+ specDiffs: [],
82
+ screenshotDiffs: [],
83
+ };
84
+ }
85
+ async function compareBuilds(config, baseBuild, currentBuild, prNumber, baseLabel) {
86
+ const baseRoot = pathJoin(config.remoteDir, "build", baseBuild);
59
87
  const currentRoot = pathJoin(config.remoteDir, "build", currentBuild);
60
- const previousFiles = await listRemoteFilesRecursive(previousRoot, config);
88
+ const baseFiles = await listRemoteFilesRecursive(baseRoot, config);
61
89
  const currentFiles = await listRemoteFilesRecursive(currentRoot, config);
62
- const previousRelative = new Set(previousFiles.map((file) => relativeRemotePath(previousRoot, file)));
90
+ const baseRelative = new Set(baseFiles.map((file) => relativeRemotePath(baseRoot, file)));
63
91
  const currentRelative = new Set(currentFiles.map((file) => relativeRemotePath(currentRoot, file)));
64
- const allPaths = Array.from(new Set([...previousRelative, ...currentRelative])).sort();
65
- const localRoot = join(tmpdir(), `feature-spec-md-build-compare-${process.pid}-${previousBuild}-${currentBuild}`);
92
+ const allPaths = Array.from(new Set([...baseRelative, ...currentRelative])).sort();
93
+ const localRoot = join(tmpdir(), `feature-spec-md-build-compare-${process.pid}-${baseBuild}-${currentBuild}`);
66
94
  const files = [];
67
95
  for (const filePath of allPaths) {
68
- const previousRemote = previousRelative.has(filePath)
69
- ? pathJoin(previousRoot, filePath)
70
- : undefined;
71
- const currentRemote = currentRelative.has(filePath)
72
- ? pathJoin(currentRoot, filePath)
73
- : undefined;
74
- const previousLocal = previousRemote
75
- ? join(localRoot, "previous", filePath)
76
- : undefined;
96
+ const baseRemote = baseRelative.has(filePath) ? pathJoin(baseRoot, filePath) : undefined;
97
+ const currentRemote = currentRelative.has(filePath) ? pathJoin(currentRoot, filePath) : undefined;
98
+ const baseLocal = baseRemote ? join(localRoot, "base", filePath) : undefined;
77
99
  const currentLocal = currentRemote ? join(localRoot, "current", filePath) : undefined;
78
- if (previousRemote && previousLocal) {
79
- await downloadRemoteFile(previousRemote, previousLocal, config);
80
- }
81
- if (currentRemote && currentLocal) {
100
+ if (baseRemote && baseLocal)
101
+ await downloadRemoteFile(baseRemote, baseLocal, config);
102
+ if (currentRemote && currentLocal)
82
103
  await downloadRemoteFile(currentRemote, currentLocal, config);
83
- }
84
- const previousInfo = previousLocal ? await fileInfo(previousLocal) : undefined;
104
+ const baseInfo = baseLocal ? await fileInfo(baseLocal) : undefined;
85
105
  const currentInfo = currentLocal ? await fileInfo(currentLocal) : undefined;
86
- const status = !previousInfo
87
- ? "added"
88
- : !currentInfo
89
- ? "removed"
90
- : previousInfo.hash === currentInfo.hash
91
- ? "unchanged"
92
- : "changed";
106
+ const status = !baseInfo ? "added" : !currentInfo ? "removed" : baseInfo.hash === currentInfo.hash ? "unchanged" : "changed";
93
107
  files.push({
94
108
  path: filePath,
95
109
  kind: fileKind(filePath),
96
110
  status,
97
- previousHash: previousInfo?.hash,
111
+ previousHash: baseInfo?.hash,
98
112
  currentHash: currentInfo?.hash,
99
- previousSize: previousInfo?.size,
113
+ previousSize: baseInfo?.size,
100
114
  currentSize: currentInfo?.size,
101
115
  });
102
116
  }
117
+ const previousSpecs = await loadSpecSections(join(localRoot, "base"));
118
+ const currentSpecs = await loadSpecSections(join(localRoot, "current"));
119
+ const specDiffs = compareSpecSections(previousSpecs, currentSpecs);
120
+ const scenarioToSpec = scenarioSpecMap([...previousSpecs, ...currentSpecs]);
121
+ const screenshotDiffs = groupScreenshotDiffs(files, scenarioToSpec, {
122
+ previousUrl: (filePath) => publicUrl(config.baseUrl, "build", baseBuild, filePath),
123
+ currentUrl: (filePath) => publicUrl(config.baseUrl, "build", currentBuild, filePath),
124
+ });
103
125
  return {
104
126
  prNumber,
105
- previousBuild,
127
+ baseBuild,
106
128
  currentBuild,
107
- previousBuildUrl: publicUrl(config.baseUrl, "build", previousBuild, ""),
129
+ baseBuildUrl: publicUrl(config.baseUrl, "build", baseBuild, ""),
108
130
  currentBuildUrl: publicUrl(config.baseUrl, "build", currentBuild, ""),
131
+ baseLabel,
109
132
  files,
133
+ specDiffs,
134
+ screenshotDiffs,
110
135
  };
111
136
  }
137
+ async function compareLocalFiles(previousDir, currentDir) {
138
+ const previousFiles = await listLocalFilesRecursive(previousDir);
139
+ const currentFiles = await listLocalFilesRecursive(currentDir);
140
+ const previousSet = new Set(previousFiles);
141
+ const currentSet = new Set(currentFiles);
142
+ const allPaths = Array.from(new Set([...previousSet, ...currentSet])).sort();
143
+ const files = [];
144
+ for (const filePath of allPaths) {
145
+ const previousInfo = previousSet.has(filePath) ? await fileInfo(join(previousDir, filePath)) : undefined;
146
+ const currentInfo = currentSet.has(filePath) ? await fileInfo(join(currentDir, filePath)) : undefined;
147
+ const status = !previousInfo ? "added" : !currentInfo ? "removed" : previousInfo.hash === currentInfo.hash ? "unchanged" : "changed";
148
+ files.push({
149
+ path: filePath,
150
+ kind: fileKind(filePath),
151
+ status,
152
+ previousHash: previousInfo?.hash,
153
+ currentHash: currentInfo?.hash,
154
+ previousSize: previousInfo?.size,
155
+ currentSize: currentInfo?.size,
156
+ });
157
+ }
158
+ return files;
159
+ }
160
+ async function listLocalFilesRecursive(root) {
161
+ const files = [];
162
+ await visit("");
163
+ return files.sort();
164
+ async function visit(relativeDir) {
165
+ const dir = relativeDir ? join(root, relativeDir) : root;
166
+ let entries;
167
+ try {
168
+ entries = await readdir(dir, { withFileTypes: true });
169
+ }
170
+ catch {
171
+ return;
172
+ }
173
+ for (const entry of entries) {
174
+ const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
175
+ if (entry.isDirectory()) {
176
+ await visit(relativePath);
177
+ }
178
+ else if (entry.isFile()) {
179
+ files.push(relativePath);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ async function loadSpecSections(root) {
185
+ const published = await loadPublishedSpecSections(root);
186
+ if (published.length)
187
+ return published;
188
+ const index = await readFileMaybe(join(root, "index.html"));
189
+ return extractSpecSections(index ?? "");
190
+ }
191
+ async function loadPublishedSpecSections(root) {
192
+ const specRoot = join(root, publishedSpecRoot);
193
+ const files = (await listLocalFilesRecursive(specRoot)).filter((file) => file.endsWith(".feature.md"));
194
+ const sections = [];
195
+ for (const file of files) {
196
+ const source = await readFile(join(specRoot, file), "utf8");
197
+ const filePath = safeRelativePath(file);
198
+ const title = source.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? filePath;
199
+ const scenarioIds = Array.from(source.matchAll(/^###\s+([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-S\d{3})\b/gm), (match) => match[1]);
200
+ sections.push({ key: filePath, title, filePath, scenarioIds, text: source.trimEnd() });
201
+ }
202
+ return sections.sort((a, b) => a.key.localeCompare(b.key));
203
+ }
112
204
  function previousBuildNumber(builds, currentBuild) {
113
205
  const current = Number(currentBuild);
114
- return builds
115
- .map(Number)
116
- .filter((build) => Number.isFinite(build) && build < current)
117
- .sort((a, b) => b - a)
118
- .at(0)
119
- ?.toString();
206
+ return builds.map(Number).filter((build) => Number.isFinite(build) && build < current).sort((a, b) => b - a).at(0)?.toString();
207
+ }
208
+ function optionValue(options, key, envKey) {
209
+ return options[key] ?? process.env[envKey];
120
210
  }
121
211
  async function fileInfo(filePath) {
122
212
  const buffer = await readFile(filePath);
123
- return {
124
- hash: createHash("sha256").update(buffer).digest("hex"),
125
- size: buffer.byteLength,
126
- };
213
+ return { hash: createHash("sha256").update(buffer).digest("hex"), size: buffer.byteLength };
214
+ }
215
+ async function readFileMaybe(filePath) {
216
+ try {
217
+ return await readFile(filePath, "utf8");
218
+ }
219
+ catch {
220
+ return undefined;
221
+ }
127
222
  }
128
223
  function relativeRemotePath(root, filePath) {
129
224
  return relative(root, filePath).split("\\").join("/");
130
225
  }
226
+ function safeRelativePath(filePath) {
227
+ return filePath.split("\\").join("/");
228
+ }
131
229
  function fileKind(filePath) {
132
230
  const name = basename(filePath).toLowerCase();
133
- if (name === "index.html" || name.endsWith(".html") || name.endsWith(".json")) {
231
+ if (filePath.startsWith("__feature-spec-md/"))
134
232
  return "report";
135
- }
136
- if (/\.(png|jpe?g|webp|gif)$/i.test(name))
233
+ if (name === "index.html" || name.endsWith(".html") || name.endsWith(".json"))
234
+ return "report";
235
+ if (/\.(png|jpe?g|webp|gif|svg)$/i.test(name))
137
236
  return "screenshot";
138
237
  return "asset";
139
238
  }
239
+ function extractSpecSections(source) {
240
+ if (!source)
241
+ return [];
242
+ const sections = [];
243
+ const matches = source.matchAll(/<section class="panel">([\s\S]*?)(?=<section class="panel">|<script>|<\/body>)/g);
244
+ for (const match of matches) {
245
+ const fragment = match[1] ?? "";
246
+ const heading = fragment.match(/<h2[^>]*>([\s\S]*?)<\/h2>/)?.[1];
247
+ if (!heading)
248
+ continue;
249
+ const title = textContent(heading).trim();
250
+ if (!title || title === "Validation" || title === "Models")
251
+ continue;
252
+ const filePaths = Array.from(fragment.matchAll(/title="([^":]+\.md):\d+"/g), (item) => item[1]);
253
+ const scenarioIds = Array.from(fragment.matchAll(/<summary><code>([^<]+)<\/code>/g), (item) => textContent(item[1] ?? ""));
254
+ const text = specText(fragment);
255
+ const filePath = filePaths[0];
256
+ sections.push({
257
+ key: filePath ?? title,
258
+ title,
259
+ filePath,
260
+ scenarioIds,
261
+ text,
262
+ });
263
+ }
264
+ return sections;
265
+ }
266
+ function specText(fragment) {
267
+ return textContent(fragment)
268
+ .split(/\n+/)
269
+ .map((line) => line.trim())
270
+ .filter(Boolean)
271
+ .join("\n");
272
+ }
273
+ function textContent(fragment) {
274
+ return decodeEntities(fragment
275
+ .replace(/<br\s*\/?>/gi, "\n")
276
+ .replace(/<\/p>|<\/li>|<\/summary>|<\/h[1-6]>|<\/div>/gi, "\n")
277
+ .replace(/<[^>]+>/g, ""));
278
+ }
279
+ function decodeEntities(value) {
280
+ return value
281
+ .replace(/&lt;/g, "<")
282
+ .replace(/&gt;/g, ">")
283
+ .replace(/&quot;/g, '"')
284
+ .replace(/&#39;/g, "'")
285
+ .replace(/&amp;/g, "&");
286
+ }
287
+ function compareSpecSections(previous, current) {
288
+ const previousByKey = new Map(previous.map((spec) => [spec.key, spec]));
289
+ const currentByKey = new Map(current.map((spec) => [spec.key, spec]));
290
+ const keys = Array.from(new Set([...previousByKey.keys(), ...currentByKey.keys()])).sort();
291
+ const diffs = [];
292
+ for (const key of keys) {
293
+ const before = previousByKey.get(key);
294
+ const after = currentByKey.get(key);
295
+ const status = !before ? "added" : !after ? "removed" : before.text === after.text ? undefined : "changed";
296
+ if (!status)
297
+ continue;
298
+ diffs.push({
299
+ key,
300
+ title: after?.title ?? before?.title ?? key,
301
+ filePath: after?.filePath ?? before?.filePath,
302
+ status,
303
+ lines: diffLines(before?.text ?? "", after?.text ?? ""),
304
+ });
305
+ }
306
+ return diffs;
307
+ }
308
+ function diffLines(previous, current) {
309
+ const a = previous.split("\n");
310
+ const b = current.split("\n");
311
+ const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
312
+ for (let i = a.length - 1; i >= 0; i -= 1) {
313
+ for (let j = b.length - 1; j >= 0; j -= 1) {
314
+ matrix[i][j] = a[i] === b[j] ? matrix[i + 1][j + 1] + 1 : Math.max(matrix[i + 1][j], matrix[i][j + 1]);
315
+ }
316
+ }
317
+ const lines = [];
318
+ let i = 0;
319
+ let j = 0;
320
+ while (i < a.length || j < b.length) {
321
+ if (i < a.length && j < b.length && a[i] === b[j]) {
322
+ lines.push({ kind: "context", previousLine: i + 1, currentLine: j + 1, text: a[i] });
323
+ i += 1;
324
+ j += 1;
325
+ }
326
+ else if (j < b.length && (i === a.length || matrix[i][j + 1] >= matrix[i + 1][j])) {
327
+ lines.push({ kind: "added", currentLine: j + 1, text: b[j] });
328
+ j += 1;
329
+ }
330
+ else if (i < a.length) {
331
+ lines.push({ kind: "removed", previousLine: i + 1, text: a[i] });
332
+ i += 1;
333
+ }
334
+ }
335
+ return compactContext(lines);
336
+ }
337
+ function compactContext(lines) {
338
+ const keep = new Set();
339
+ lines.forEach((line, index) => {
340
+ if (line.kind === "context")
341
+ return;
342
+ for (let offset = -3; offset <= 3; offset += 1) {
343
+ const candidate = index + offset;
344
+ if (candidate >= 0 && candidate < lines.length)
345
+ keep.add(candidate);
346
+ }
347
+ });
348
+ return lines.filter((line, index) => line.kind !== "context" || keep.has(index));
349
+ }
350
+ function scenarioSpecMap(specs) {
351
+ const map = new Map();
352
+ for (const spec of specs) {
353
+ for (const scenarioId of spec.scenarioIds) {
354
+ map.set(scenarioId, spec);
355
+ }
356
+ }
357
+ return map;
358
+ }
359
+ function groupScreenshotDiffs(files, scenarioMap, urls) {
360
+ const groups = new Map();
361
+ for (const file of files.filter((item) => item.kind === "screenshot" && item.status !== "unchanged")) {
362
+ const scenarioId = scenarioIdFromScreenshotPath(file.path);
363
+ const spec = scenarioId ? scenarioMap.get(scenarioId) : undefined;
364
+ const specLabel = spec?.filePath ?? spec?.title ?? scenarioId ?? "Unmapped screenshots";
365
+ const group = groups.get(specLabel) ?? { specLabel, specPath: spec?.filePath, items: [] };
366
+ group.items.push({
367
+ path: file.path,
368
+ title: screenshotTitle(file.path, scenarioId),
369
+ status: file.status,
370
+ previousUrl: file.status !== "added" ? urls.previousUrl(file.path) : undefined,
371
+ currentUrl: file.status !== "removed" ? urls.currentUrl(file.path) : undefined,
372
+ previousSize: file.previousSize,
373
+ currentSize: file.currentSize,
374
+ });
375
+ groups.set(specLabel, group);
376
+ }
377
+ return Array.from(groups.values()).sort((a, b) => a.specLabel.localeCompare(b.specLabel));
378
+ }
379
+ function scenarioIdFromScreenshotPath(filePath) {
380
+ return basename(filePath).match(/^(.+?)-line-\d+-/)?.[1];
381
+ }
382
+ function screenshotTitle(filePath, scenarioId) {
383
+ const name = basename(filePath).replace(/\.(png|jpe?g|webp|gif|svg)$/i, "");
384
+ return scenarioId ? name.replace(`${scenarioId}-`, `${scenarioId} `) : name;
385
+ }
386
+ function relativeAssetUrl(prefix, filePath) {
387
+ return [prefix.replace(/\/+$/, ""), ...filePath.split("/").filter(Boolean)]
388
+ .filter(Boolean)
389
+ .join("/");
390
+ }
140
391
  function renderDiffReport(report) {
141
- const changed = report.files.filter((file) => file.status !== "unchanged");
142
- const byKind = (kind) => changed.filter((file) => file.kind === kind);
143
- const sections = [
144
- renderFileSection("Report output", byKind("report")),
145
- renderFileSection("Screenshots", byKind("screenshot")),
146
- renderFileSection("Other assets", byKind("asset")),
147
- ].join("\n");
148
- return `<!doctype html>
149
- <html lang="en">
150
- <head>
151
- <meta charset="utf-8">
152
- <meta name="viewport" content="width=device-width,initial-scale=1">
153
- <title>Feature spec PR diff</title>
154
- <style>
155
- body{font-family:system-ui,sans-serif;max-width:1180px;margin:0 auto;padding:40px 24px;color:#1f2328}
156
- .panel{border:1px solid #d0d7de;border-radius:8px;padding:20px;margin:18px 0}
157
- .badge{border:1px solid #d0d7de;border-radius:999px;padding:2px 8px;font-size:12px;white-space:nowrap}
158
- .added{color:#1a7f37}.removed{color:#cf222e}.changed{color:#9a6700}.muted{color:#57606a}
159
- table{border-collapse:collapse;width:100%;font-size:14px} th,td{border:1px solid #d0d7de;padding:6px 8px;text-align:left;vertical-align:top} th{background:#f6f8fa}
160
- a{color:#0969da}
161
- </style>
162
- </head>
163
- <body>
164
- <h1>Feature spec PR diff for PR #${html(report.prNumber)}</h1>
165
- <p>Generated ${html(new Date().toISOString())}.</p>
166
- <section class="panel">
167
- <h2>Compared builds</h2>
168
- <p>${report.previousBuild ? `Previous: <a href="${html(report.previousBuildUrl ?? "")}">build ${html(report.previousBuild)}</a>` : "No previous build found."}</p>
169
- <p>Current: <a href="${html(report.currentBuildUrl)}">build ${html(report.currentBuild)}</a></p>
170
- <p><span class="badge">${changed.length} changed file${changed.length === 1 ? "" : "s"}</span> <span class="muted">${report.files.length} file${report.files.length === 1 ? "" : "s"} checked</span></p>
171
- </section>
172
- ${sections}
173
- </body>
174
- </html>`;
392
+ const changed = report.files.filter((file) => file.status !== "unchanged" && file.kind !== "report");
393
+ const assetChanges = changed.filter((file) => file.kind === "asset");
394
+ return renderHtmlPage({
395
+ title: "Feature spec PR diff",
396
+ styles: diffReportStyles(),
397
+ scripts: renderScreenshotToggleScript(),
398
+ body: `
399
+ <h1>Feature spec PR diff for PR #${html(report.prNumber)}</h1>
400
+ <p>Generated ${html(new Date().toISOString())}.</p>
401
+ <section class="panel"><h2>Compared builds</h2><p>${report.baseBuild ? `${baseLabel(report)}: <a href="${html(report.baseBuildUrl ?? "")}">build ${html(report.baseBuild)}</a>` : "No base build found."}</p><p>PR: <a href="${html(report.currentBuildUrl)}">build ${html(report.currentBuild)}</a></p><p><span class="badge">${report.specDiffs.length} spec change${report.specDiffs.length === 1 ? "" : "s"}</span> <span class="badge">${screenshotChangeCount(report)} screenshot change${screenshotChangeCount(report) === 1 ? "" : "s"}</span></p></section>
402
+ ${renderSpecDiffs(report.specDiffs)}
403
+ ${renderScreenshotDiffs(report.screenshotDiffs)}
404
+ ${renderFileSection("Other assets", assetChanges)}
405
+ `,
406
+ });
407
+ }
408
+ function diffReportStyles() {
409
+ return `.panel{border:1px solid #d0d7de;border-radius:8px;padding:20px;margin:18px 0}
410
+ .badge{border:1px solid #d0d7de;border-radius:999px;padding:2px 8px;font-size:12px}
411
+ .added{color:#1a7f37}.removed{color:#cf222e}.changed{color:#9a6700}.muted{color:#57606a}
412
+ .toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 16px}
413
+ .screenshot-toggle-button{border:1px solid #d0d7de;border-radius:6px;background:#f6f8fa;color:#1f2328;cursor:pointer;font:inherit;padding:6px 10px}
414
+ .screenshot-toggle-button:hover{background:#eef2f6}
415
+ table{border-collapse:collapse;width:100%;font-size:14px}
416
+ th,td{border:1px solid #d0d7de;padding:6px 8px;text-align:left;vertical-align:top}
417
+ th{background:#f6f8fa}a{color:#0969da}
418
+ .diff{width:100%;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
419
+ .diff td{padding:2px 8px}.line-no{width:1%;color:#57606a;background:#f6f8fa;text-align:right;user-select:none}
420
+ .diff-added td{background:#dafbe1}.diff-removed td{background:#ffebe9}.diff-context td{background:#fff}
421
+ .screenshot-diff{border-top:1px solid #d0d7de;padding:10px 0}.screenshot-diff summary{cursor:pointer;margin-bottom:8px}
422
+ .image-pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}
423
+ .image-card{border:1px solid #d0d7de;border-radius:8px;background:#f6f8fa;overflow:hidden}
424
+ .image-card h4{margin:0;padding:8px 10px;background:#fff;border-bottom:1px solid #d0d7de}.image-card img{display:block;width:100%;height:auto}`;
425
+ }
426
+ function renderSpecDiffs(specDiffs) {
427
+ if (!specDiffs.length)
428
+ return `<section class="panel"><h2>Spec changes</h2><p class="muted">No feature spec source changes detected.</p></section>`;
429
+ return `<section class="panel"><h2>Spec changes</h2><p class="muted">Diffed from published feature spec source files, not from rendered report HTML.</p>${specDiffs.map(renderSpecDiff).join("\n")}</section>`;
430
+ }
431
+ function renderSpecDiff(diff) {
432
+ return `<details open><summary><strong>${html(diff.filePath ?? diff.title)}</strong> <span class="badge ${diff.status}">${html(diff.status)}</span></summary><table class="diff"><tbody>${diff.lines.map(renderDiffLine).join("")}</tbody></table></details>`;
433
+ }
434
+ function renderDiffLine(line) {
435
+ const marker = line.kind === "added" ? "+" : line.kind === "removed" ? "-" : "";
436
+ return `<tr class="diff-${line.kind}"><td class="line-no">${line.previousLine ?? ""}</td><td class="line-no">${line.currentLine ?? ""}</td><td>${html(marker)} ${html(line.text)}</td></tr>`;
437
+ }
438
+ function renderScreenshotDiffs(groups) {
439
+ if (!groups.length)
440
+ return `<section class="panel"><h2>Screenshots</h2><p class="muted">No screenshot changes.</p></section>`;
441
+ return `<section class="panel"><h2>Screenshots</h2><div class="toolbar"><button class="screenshot-toggle-button" type="button" data-show-label="Show all screenshots" data-hide-label="Hide all screenshots">Show all screenshots</button></div>${groups.map(renderScreenshotGroup).join("\n")}</section>`;
442
+ }
443
+ function renderScreenshotGroup(group) {
444
+ return `<section><h3>${html(group.specPath ?? group.specLabel)}</h3>${group.items.map(renderScreenshotItem).join("\n")}</section>`;
445
+ }
446
+ function renderScreenshotItem(item) {
447
+ const before = item.previousUrl ? `<div class="image-card"><h4>Before</h4><img src="${html(item.previousUrl)}" alt="Before ${html(item.title)}"></div>` : "";
448
+ const after = item.currentUrl ? `<div class="image-card"><h4>After</h4><img src="${html(item.currentUrl)}" alt="After ${html(item.title)}"></div>` : "";
449
+ return `<details class="screenshot-diff"><summary><code>${html(item.path)}</code> <span class="badge ${item.status}">${html(item.status)}</span> <span class="muted">${html(sizeChange({ previousSize: item.previousSize, currentSize: item.currentSize }))}</span></summary><div class="image-pair">${before}${after}</div></details>`;
450
+ }
451
+ function renderScreenshotToggleScript() {
452
+ const openTag = "<" + "script>";
453
+ const closeTag = "<" + "/script>";
454
+ const source = "(function(){var button=document.querySelector('.screenshot-toggle-button');if(!button)return;var expanded=false;function apply(){document.querySelectorAll('details.screenshot-diff').forEach(function(item){item.open=expanded;});button.textContent=expanded?button.getAttribute('data-hide-label'):button.getAttribute('data-show-label');}button.addEventListener('click',function(){expanded=!expanded;apply();});apply();})();";
455
+ return openTag + source + closeTag;
175
456
  }
176
457
  function renderFileSection(title, files) {
177
- if (!files.length) {
458
+ if (!files.length)
178
459
  return `<section class="panel"><h2>${html(title)}</h2><p class="muted">No changes.</p></section>`;
179
- }
180
- return `<section class="panel"><h2>${html(title)}</h2><table><thead><tr><th>Status</th><th>File</th><th>Size change</th></tr></thead><tbody>${files
181
- .map((file) => `<tr><td class="${file.status}">${html(file.status)}</td><td><code>${html(file.path)}</code></td><td>${html(sizeChange(file))}</td></tr>`)
182
- .join("")}</tbody></table></section>`;
460
+ return `<section class="panel"><h2>${html(title)}</h2><table><thead><tr><th>Status</th><th>File</th><th>Size change</th></tr></thead><tbody>${files.map((file) => `<tr><td class="${file.status}">${html(file.status)}</td><td><code>${html(file.path)}</code></td><td>${html(sizeChange(file))}</td></tr>`).join("")}</tbody></table></section>`;
183
461
  }
184
462
  function sizeChange(file) {
185
463
  if (file.previousSize === undefined)
@@ -187,23 +465,35 @@ function sizeChange(file) {
187
465
  if (file.currentSize === undefined)
188
466
  return `-${file.previousSize} bytes`;
189
467
  const diff = file.currentSize - file.previousSize;
190
- if (diff === 0)
191
- return "same size";
192
- return `${diff > 0 ? "+" : ""}${diff} bytes`;
468
+ return diff === 0 ? "same size" : `${diff > 0 ? "+" : ""}${diff} bytes`;
469
+ }
470
+ function screenshotChangeCount(report) {
471
+ return report.screenshotDiffs.reduce((sum, group) => sum + group.items.length, 0);
193
472
  }
194
473
  function commentBody(report, reportUrl) {
195
- const changed = report.files.filter((file) => file.status !== "unchanged");
196
- const reportChanges = changed.filter((file) => file.kind === "report").length;
197
- const screenshotChanges = changed.filter((file) => file.kind === "screenshot").length;
198
- const previous = report.previousBuild
199
- ? `build ${report.previousBuild} → build ${report.currentBuild}`
200
- : `build ${report.currentBuild}`;
201
474
  return [
202
475
  "## Feature spec PR diff",
203
476
  "",
204
- `[Open the diff report](${reportUrl}) for ${previous}.`,
477
+ `[Open the diff report](${reportUrl}) for ${comparisonText(report)}.`,
205
478
  "",
206
- `Changed files: **${changed.length}** total, **${reportChanges}** report output, **${screenshotChanges}** screenshots.`,
479
+ `Changed: **${report.specDiffs.length}** spec file(s), **${screenshotChangeCount(report)}** screenshot(s).`,
207
480
  ].join("\n");
208
481
  }
482
+ function comparisonText(report) {
483
+ if (!report.baseBuild)
484
+ return `PR build ${report.currentBuild}`;
485
+ return `${baseLabel(report).toLowerCase()} build ${report.baseBuild} → PR build ${report.currentBuild}`;
486
+ }
487
+ function summaryComparisonSentence(report) {
488
+ if (!report.baseBuild)
489
+ return `<p>No base build was found, so this report establishes the first comparison baseline.</p>`;
490
+ return `<p>Compared ${html(baseLabel(report).toLowerCase())} build <strong>${html(report.baseBuild)}</strong> with PR build <strong>${html(report.currentBuild)}</strong>.</p>`;
491
+ }
492
+ function baseLabel(report) {
493
+ if (report.baseLabel === "main")
494
+ return "Main";
495
+ if (report.baseLabel === "previous")
496
+ return "Previous";
497
+ return "Base";
498
+ }
209
499
  //# sourceMappingURL=githubActionDiffReport.js.map