@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.
- package/README.md +59 -78
- package/SPEC_FORMAT.md +38 -78
- package/dist/cli.js +194 -86
- package/dist/cli.js.map +1 -1
- package/dist/featureSpecs.d.ts +1 -1
- package/dist/featureSpecs.d.ts.map +1 -1
- package/dist/featureSpecs.js +57 -10
- package/dist/featureSpecs.js.map +1 -1
- package/dist/githubActionDiffReport.d.ts +61 -0
- package/dist/githubActionDiffReport.d.ts.map +1 -1
- package/dist/githubActionDiffReport.js +410 -120
- package/dist/githubActionDiffReport.js.map +1 -1
- package/dist/githubActionReport.d.ts.map +1 -1
- package/dist/githubActionReport.js +27 -22
- package/dist/githubActionReport.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/mockReports.d.ts +36 -0
- package/dist/mockReports.d.ts.map +1 -0
- package/dist/mockReports.js +280 -0
- package/dist/mockReports.js.map +1 -0
- package/dist/mocks/current/app/supportDesk.d.ts +12 -0
- package/dist/mocks/current/app/supportDesk.d.ts.map +1 -0
- package/dist/mocks/current/app/supportDesk.js +19 -0
- package/dist/mocks/current/app/supportDesk.js.map +1 -0
- package/dist/mocks/current/tests/supportDesk.spec.d.ts +3 -0
- package/dist/mocks/current/tests/supportDesk.spec.d.ts.map +1 -0
- package/dist/mocks/current/tests/supportDesk.spec.js +15 -0
- package/dist/mocks/current/tests/supportDesk.spec.js.map +1 -0
- package/dist/mocks/previous/app/supportDesk.d.ts +10 -0
- package/dist/mocks/previous/app/supportDesk.d.ts.map +1 -0
- package/dist/mocks/previous/app/supportDesk.js +9 -0
- package/dist/mocks/previous/app/supportDesk.js.map +1 -0
- package/dist/mocks/previous/tests/supportDesk.spec.d.ts +3 -0
- package/dist/mocks/previous/tests/supportDesk.spec.d.ts.map +1 -0
- package/dist/mocks/previous/tests/supportDesk.spec.js +15 -0
- package/dist/mocks/previous/tests/supportDesk.spec.js.map +1 -0
- package/dist/reportArtifacts.d.ts +7 -0
- package/dist/reportArtifacts.d.ts.map +1 -0
- package/dist/reportArtifacts.js +26 -0
- package/dist/reportArtifacts.js.map +1 -0
- package/dist/reportHtml.d.ts +9 -0
- package/dist/reportHtml.d.ts.map +1 -0
- package/dist/reportHtml.js +36 -0
- package/dist/reportHtml.js.map +1 -0
- package/dist/reportMetadata.d.ts +9 -0
- package/dist/reportMetadata.d.ts.map +1 -0
- package/dist/reportMetadata.js +28 -0
- package/dist/reportMetadata.js.map +1 -0
- package/dist/screenshots.d.ts +3 -1
- package/dist/screenshots.d.ts.map +1 -1
- package/dist/screenshots.js +23 -0
- package/dist/screenshots.js.map +1 -1
- package/dist/types.d.ts +11 -1
- package/dist/types.d.ts.map +1 -1
- package/docs/evidence-policy.md +63 -0
- package/docs/report-development.md +102 -0
- package/docs/spec-driven-flow.md +67 -8
- package/package.json +3 -1
- package/src/mocks/current/app/supportDesk.ts +27 -0
- package/src/mocks/current/screenshots/SUPPORT-INBOX-S001-line-27-current.svg +10 -0
- package/src/mocks/current/screenshots/SUPPORT-REPLY-S001-line-27-current.svg +10 -0
- package/src/mocks/current/screenshots/screenshots.json +18 -0
- package/src/mocks/current/specs/support-desk.model.md +29 -0
- package/src/mocks/current/specs/ticket-inbox.feature.md +27 -0
- package/src/mocks/current/specs/ticket-reply.feature.md +33 -0
- package/src/mocks/current/tests/supportDesk.spec.ts +17 -0
- package/src/mocks/previous/app/supportDesk.ts +17 -0
- package/src/mocks/previous/screenshots/SUPPORT-INBOX-S001-line-27-previous.svg +10 -0
- package/src/mocks/previous/screenshots/screenshots.json +11 -0
- package/src/mocks/previous/specs/support-desk.model.md +29 -0
- package/src/mocks/previous/specs/ticket-inbox.feature.md +26 -0
- package/src/mocks/previous/specs/ticket-reply.feature.md +26 -0
- 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
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
|
58
|
-
|
|
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
|
|
88
|
+
const baseFiles = await listRemoteFilesRecursive(baseRoot, config);
|
|
61
89
|
const currentFiles = await listRemoteFilesRecursive(currentRoot, config);
|
|
62
|
-
const
|
|
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([...
|
|
65
|
-
const localRoot = join(tmpdir(), `feature-spec-md-build-compare-${process.pid}-${
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
79
|
-
await downloadRemoteFile(
|
|
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 = !
|
|
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:
|
|
111
|
+
previousHash: baseInfo?.hash,
|
|
98
112
|
currentHash: currentInfo?.hash,
|
|
99
|
-
previousSize:
|
|
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
|
-
|
|
127
|
+
baseBuild,
|
|
106
128
|
currentBuild,
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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 (
|
|
231
|
+
if (filePath.startsWith("__feature-spec-md/"))
|
|
134
232
|
return "report";
|
|
135
|
-
|
|
136
|
-
|
|
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(/</g, "<")
|
|
282
|
+
.replace(/>/g, ">")
|
|
283
|
+
.replace(/"/g, '"')
|
|
284
|
+
.replace(/'/g, "'")
|
|
285
|
+
.replace(/&/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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
<html
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 ${
|
|
477
|
+
`[Open the diff report](${reportUrl}) for ${comparisonText(report)}.`,
|
|
205
478
|
"",
|
|
206
|
-
`Changed
|
|
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
|