@anselmdk/feature-spec-md 0.3.1 → 0.5.0-rc.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 +170 -39
- package/SPEC_FORMAT.md +38 -78
- package/dist/cli.js +98 -81
- 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 +4 -0
- package/dist/githubActionDiffReport.d.ts.map +1 -0
- package/dist/githubActionDiffReport.js +209 -0
- package/dist/githubActionDiffReport.js.map +1 -0
- package/dist/githubActionFtp.d.ts +25 -0
- package/dist/githubActionFtp.d.ts.map +1 -0
- package/dist/githubActionFtp.js +210 -0
- package/dist/githubActionFtp.js.map +1 -0
- package/dist/githubActionOutput.d.ts +3 -0
- package/dist/githubActionOutput.d.ts.map +1 -0
- package/dist/githubActionOutput.js +33 -0
- package/dist/githubActionOutput.js.map +1 -0
- package/dist/githubActionReport.d.ts +2 -1
- package/dist/githubActionReport.d.ts.map +1 -1
- package/dist/githubActionReport.js +15 -182
- package/dist/githubActionReport.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/reportTemplate.d.ts +3 -0
- package/dist/reportTemplate.d.ts.map +1 -1
- package/dist/reportTemplate.js +86 -29
- package/dist/reportTemplate.js.map +1 -1
- 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/spec-driven-flow.md +67 -8
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join, relative, sep } from "node:path";
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
4
3
|
import { tmpdir } from "node:os";
|
|
4
|
+
import { ftpConfig, listBuildNumbers, pathJoin, publicUrl, uploadDirectory, uploadFile, } from "./githubActionFtp.js";
|
|
5
|
+
import { writeGithubOutput, writeGithubSummary } from "./githubActionOutput.js";
|
|
5
6
|
export async function publishGithubActionReport(options) {
|
|
6
7
|
const reportDir = value(options, "report-dir", "FEATURE_SPEC_REPORT_DIR") ??
|
|
7
8
|
"test-results/spec-report";
|
|
@@ -15,13 +16,13 @@ export async function publishGithubActionReport(options) {
|
|
|
15
16
|
"## Feature Spec Report",
|
|
16
17
|
"",
|
|
17
18
|
`<p><strong>Hosted HTML report:</strong> <a href=\"${escapeHtml(reportUrl)}\">${escapeHtml(reportName)}</a></p>`,
|
|
18
|
-
`<p><strong>
|
|
19
|
+
`<p><strong>Build report index:</strong> <a href=\"${escapeHtml(buildIndexUrl(config.baseUrl))}\">all builds</a></p>`,
|
|
19
20
|
"",
|
|
20
21
|
].join("\n"));
|
|
21
22
|
await writeGithubOutput({
|
|
22
23
|
"upload-github-artifact": "false",
|
|
23
24
|
"report-url": reportUrl,
|
|
24
|
-
"index-url":
|
|
25
|
+
"index-url": buildIndexUrl(config.baseUrl),
|
|
25
26
|
});
|
|
26
27
|
console.log(`Feature spec report uploaded to ${reportUrl}`);
|
|
27
28
|
return;
|
|
@@ -39,165 +40,26 @@ export async function publishGithubActionReport(options) {
|
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
42
|
async function publishReportToFtp(reportDir, config) {
|
|
42
|
-
const
|
|
43
|
+
const buildsRoot = pathJoin(config.remoteDir, "build");
|
|
44
|
+
const buildRemoteDir = pathJoin(buildsRoot, config.buildNumber);
|
|
43
45
|
await uploadDirectory(reportDir, buildRemoteDir, config);
|
|
44
|
-
const builds = await listBuildNumbers(
|
|
46
|
+
const builds = await listBuildNumbers(buildsRoot, config);
|
|
45
47
|
if (!builds.includes(config.buildNumber))
|
|
46
48
|
builds.push(config.buildNumber);
|
|
47
49
|
builds.sort((a, b) => Number(b) - Number(a));
|
|
48
50
|
const localIndex = join(tmpdir(), `feature-spec-md-build-index-${process.pid}.html`);
|
|
49
51
|
await writeFile(localIndex, renderBuildIndex(config.baseUrl, builds), "utf8");
|
|
50
|
-
await uploadFile(localIndex, pathJoin(
|
|
52
|
+
await uploadFile(localIndex, pathJoin(buildsRoot, "index.html"), config);
|
|
51
53
|
return buildUrl(config.baseUrl, config.buildNumber);
|
|
52
54
|
}
|
|
53
|
-
async function uploadDirectory(localDir, remoteDir, config) {
|
|
54
|
-
const files = await walkFiles(localDir);
|
|
55
|
-
if (!files.length) {
|
|
56
|
-
throw new Error(`Report directory contains no files: ${localDir}`);
|
|
57
|
-
}
|
|
58
|
-
for (const file of files) {
|
|
59
|
-
const remotePath = pathJoin(remoteDir, relative(localDir, file).split(sep).join("/"));
|
|
60
|
-
await uploadFile(file, remotePath, config);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
async function uploadFile(localFile, remotePath, config) {
|
|
64
|
-
await runCurl([
|
|
65
|
-
"--silent",
|
|
66
|
-
"--show-error",
|
|
67
|
-
"--fail",
|
|
68
|
-
"--ftp-create-dirs",
|
|
69
|
-
"-u",
|
|
70
|
-
`${config.user}:${config.password}`,
|
|
71
|
-
"-T",
|
|
72
|
-
localFile,
|
|
73
|
-
ftpUrl(config, remotePath),
|
|
74
|
-
]);
|
|
75
|
-
}
|
|
76
|
-
async function listBuildNumbers(remoteDir, config) {
|
|
77
|
-
try {
|
|
78
|
-
const listing = await listRemoteDirectory(remoteDir, config);
|
|
79
|
-
return Array.from(new Set(parseBuildNumbersFromDirectoryListing(listing)));
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
return [];
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
async function listRemoteDirectory(remoteDir, config) {
|
|
86
|
-
const directoryUrl = ftpUrl(config, asDirectoryPath(remoteDir));
|
|
87
|
-
const commonArgs = [
|
|
88
|
-
"--silent",
|
|
89
|
-
"--show-error",
|
|
90
|
-
"--fail",
|
|
91
|
-
"-u",
|
|
92
|
-
`${config.user}:${config.password}`,
|
|
93
|
-
];
|
|
94
|
-
try {
|
|
95
|
-
return await runCurl([...commonArgs, "--list-only", directoryUrl]);
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return runCurl([...commonArgs, directoryUrl]);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
function parseBuildNumbersFromDirectoryListing(listing) {
|
|
102
|
-
return listing
|
|
103
|
-
.split(/\r?\n/)
|
|
104
|
-
.flatMap((line) => {
|
|
105
|
-
const hrefBuilds = Array.from(line.matchAll(/href=["'][^"']*?(\d+)\/?["']/gi), (match) => match[1]);
|
|
106
|
-
const lastToken = line.trim().split(/\s+/).at(-1) ?? "";
|
|
107
|
-
return /^\d+$/.test(lastToken) ? [...hrefBuilds, lastToken] : hrefBuilds;
|
|
108
|
-
})
|
|
109
|
-
.filter((name) => /^\d+$/.test(name));
|
|
110
|
-
}
|
|
111
|
-
async function runCurl(args) {
|
|
112
|
-
return new Promise((resolve, reject) => {
|
|
113
|
-
const child = spawn("curl", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
114
|
-
let stdout = "";
|
|
115
|
-
let stderr = "";
|
|
116
|
-
child.stdout.on("data", (chunk) => {
|
|
117
|
-
stdout += chunk.toString();
|
|
118
|
-
});
|
|
119
|
-
child.stderr.on("data", (chunk) => {
|
|
120
|
-
stderr += chunk.toString();
|
|
121
|
-
});
|
|
122
|
-
child.on("error", reject);
|
|
123
|
-
child.on("close", (code) => {
|
|
124
|
-
if (code === 0) {
|
|
125
|
-
resolve(stdout);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
reject(new Error(stderr.trim() || `curl exited with status ${code}`));
|
|
129
|
-
});
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
async function walkFiles(dir) {
|
|
133
|
-
const entries = await readdir(dir, { withFileTypes: true });
|
|
134
|
-
const files = await Promise.all(entries.map(async (entry) => {
|
|
135
|
-
const fullPath = join(dir, entry.name);
|
|
136
|
-
if (entry.isDirectory())
|
|
137
|
-
return walkFiles(fullPath);
|
|
138
|
-
if (entry.isFile())
|
|
139
|
-
return [fullPath];
|
|
140
|
-
return [];
|
|
141
|
-
}));
|
|
142
|
-
return files.flat();
|
|
143
|
-
}
|
|
144
|
-
function ftpConfig(options) {
|
|
145
|
-
const host = required(options, "ftp-host", "FEATURE_SPEC_FTP_HOST");
|
|
146
|
-
const user = required(options, "ftp-user", "FEATURE_SPEC_FTP_USER");
|
|
147
|
-
const password = required(options, "ftp-password", "FEATURE_SPEC_FTP_PASSWORD");
|
|
148
|
-
const baseUrl = required(options, "base-url", "FEATURE_SPEC_REPORT_BASE_URL");
|
|
149
|
-
const remoteDir = value(options, "ftp-remote-dir", "FEATURE_SPEC_FTP_REMOTE_DIR") ?? "";
|
|
150
|
-
const buildNumber = value(options, "build-number", "GITHUB_RUN_NUMBER") ??
|
|
151
|
-
value(options, "build-number", "FEATURE_SPEC_BUILD_NUMBER") ??
|
|
152
|
-
"local";
|
|
153
|
-
const secure = booleanValue(value(options, "ftp-secure", "FEATURE_SPEC_FTP_SECURE"));
|
|
154
|
-
const port = value(options, "ftp-port", "FEATURE_SPEC_FTP_PORT");
|
|
155
|
-
if (!/^\d+$/.test(buildNumber)) {
|
|
156
|
-
throw new Error(`Build number must be numeric for FTP publishing: ${buildNumber}`);
|
|
157
|
-
}
|
|
158
|
-
return { host, user, password, port, secure, remoteDir, baseUrl, buildNumber };
|
|
159
|
-
}
|
|
160
55
|
function publishMode(value) {
|
|
161
56
|
if (value === "ftp" || value === "artifact")
|
|
162
57
|
return value;
|
|
163
58
|
throw new Error(`Unknown report publish mode: ${value}`);
|
|
164
59
|
}
|
|
165
|
-
function required(options, key, envKey) {
|
|
166
|
-
const result = value(options, key, envKey);
|
|
167
|
-
if (!result)
|
|
168
|
-
throw new Error(`Missing required option --${key} or ${envKey}.`);
|
|
169
|
-
return result;
|
|
170
|
-
}
|
|
171
60
|
function value(options, key, envKey) {
|
|
172
61
|
return options[key] ?? process.env[envKey];
|
|
173
62
|
}
|
|
174
|
-
function booleanValue(value) {
|
|
175
|
-
return value === "true" || value === "1" || value === "yes";
|
|
176
|
-
}
|
|
177
|
-
function ftpUrl(config, remotePath) {
|
|
178
|
-
const protocol = config.secure ? "ftps" : "ftp";
|
|
179
|
-
const port = config.port ? `:${config.port}` : "";
|
|
180
|
-
const hasTrailingSlash = remotePath.endsWith("/");
|
|
181
|
-
const encodedPath = remotePath
|
|
182
|
-
.split("/")
|
|
183
|
-
.filter(Boolean)
|
|
184
|
-
.map(encodeURIComponent)
|
|
185
|
-
.join("/");
|
|
186
|
-
const directorySlash = hasTrailingSlash ? "/" : "";
|
|
187
|
-
return `${protocol}://${config.host}${port}/${encodedPath}${directorySlash}`;
|
|
188
|
-
}
|
|
189
|
-
function asDirectoryPath(remotePath) {
|
|
190
|
-
const path = pathJoin(remotePath);
|
|
191
|
-
return path ? `${path}/` : "";
|
|
192
|
-
}
|
|
193
|
-
function pathJoin(...parts) {
|
|
194
|
-
const joined = parts
|
|
195
|
-
.flatMap((part) => part.split("/"))
|
|
196
|
-
.map((part) => part.trim())
|
|
197
|
-
.filter((part) => part && part !== ".")
|
|
198
|
-
.join("/");
|
|
199
|
-
return joined;
|
|
200
|
-
}
|
|
201
63
|
function renderBuildIndex(baseUrl, builds) {
|
|
202
64
|
const generatedAt = new Date().toISOString();
|
|
203
65
|
const rows = builds
|
|
@@ -208,14 +70,14 @@ function renderBuildIndex(baseUrl, builds) {
|
|
|
208
70
|
<head>
|
|
209
71
|
<meta charset=\"utf-8\">
|
|
210
72
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
|
|
211
|
-
<title>Feature spec reports</title>
|
|
73
|
+
<title>Feature spec build reports</title>
|
|
212
74
|
<style>
|
|
213
75
|
body { font-family: system-ui, sans-serif; max-width: 48rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
|
|
214
76
|
li { margin: 0.35rem 0; }
|
|
215
77
|
</style>
|
|
216
78
|
</head>
|
|
217
79
|
<body>
|
|
218
|
-
<h1>Feature spec reports</h1>
|
|
80
|
+
<h1>Feature spec build reports</h1>
|
|
219
81
|
<p>Generated ${escapeHtml(generatedAt)}.</p>
|
|
220
82
|
<ol>
|
|
221
83
|
${rows}
|
|
@@ -225,13 +87,10 @@ ${rows}
|
|
|
225
87
|
`;
|
|
226
88
|
}
|
|
227
89
|
function buildUrl(baseUrl, build) {
|
|
228
|
-
return
|
|
229
|
-
}
|
|
230
|
-
function indexUrl(baseUrl) {
|
|
231
|
-
return `${trimTrailingSlash(baseUrl)}/`;
|
|
90
|
+
return publicUrl(baseUrl, "build", build, "");
|
|
232
91
|
}
|
|
233
|
-
function
|
|
234
|
-
return
|
|
92
|
+
function buildIndexUrl(baseUrl) {
|
|
93
|
+
return publicUrl(baseUrl, "build", "");
|
|
235
94
|
}
|
|
236
95
|
function escapeHtml(value) {
|
|
237
96
|
return value
|
|
@@ -240,30 +99,4 @@ function escapeHtml(value) {
|
|
|
240
99
|
.replace(/>/g, ">")
|
|
241
100
|
.replace(/\"/g, """);
|
|
242
101
|
}
|
|
243
|
-
async function writeGithubSummary(markdown) {
|
|
244
|
-
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
245
|
-
if (!summaryPath)
|
|
246
|
-
return;
|
|
247
|
-
await mkdir(dirname(summaryPath), { recursive: true });
|
|
248
|
-
const existing = await readTextIfExists(summaryPath);
|
|
249
|
-
await writeFile(summaryPath, `${existing}${markdown}`, "utf8");
|
|
250
|
-
}
|
|
251
|
-
async function writeGithubOutput(values) {
|
|
252
|
-
const outputPath = process.env.GITHUB_OUTPUT;
|
|
253
|
-
if (!outputPath)
|
|
254
|
-
return;
|
|
255
|
-
const lines = Object.entries(values)
|
|
256
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
257
|
-
.join("\n");
|
|
258
|
-
const existing = await readTextIfExists(outputPath);
|
|
259
|
-
await writeFile(outputPath, `${existing}${lines}\n`, "utf8");
|
|
260
|
-
}
|
|
261
|
-
async function readTextIfExists(filePath) {
|
|
262
|
-
try {
|
|
263
|
-
return await readFile(filePath, "utf8");
|
|
264
|
-
}
|
|
265
|
-
catch {
|
|
266
|
-
return "";
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
102
|
//# sourceMappingURL=githubActionReport.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"githubActionReport.js","sourceRoot":"","sources":["../src/githubActionReport.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"githubActionReport.js","sourceRoot":"","sources":["../src/githubActionReport.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export { expandFilePatterns } from "./filePatterns.js";
|
|
2
2
|
export { buildCoverageSummary, checkFeatureSpecs, collectTestReferences, loadFeatureSpecs, parseFeatureSpec, parseTestReferences, validateCoverage, validateFeatureSpec, } from "./featureSpecs.js";
|
|
3
3
|
export { renderHtmlReport } from "./reportTemplate.js";
|
|
4
|
-
export { collectSpecScreenshots } from "./screenshots.js";
|
|
4
|
+
export { collectSpecScreenshots, validateScenarioScreenshots, } from "./screenshots.js";
|
|
5
5
|
export { buildSpecCoverageSummary, checkSpecDocuments, collectSpecTestReferences, loadSpecDocuments, parseDesignSpec, parseModelSpec, parseSpecDocument, parseSpecTestReferences, parseStackSpec, validateDesignSpec, validateModelSpec, validateSpecDocument, validateSpecGraph, validateStackSpec, } from "./specDocuments.js";
|
|
6
|
-
export type { CoverageItem, CoverageSummary, DesignFrontmatter, DesignSpec, FeatureFrontmatter, FeatureRule, FeatureScenario, FeatureSpec, FeatureStep, ModelItem, ModelReferenceFrontmatter, ModelSpec, RuleKeyword, SpecDocument, SpecFrontmatter, SpecScreenshot, StackSpec, StepKeyword, TestReference, ValidationIssue, } from "./types.js";
|
|
6
|
+
export type { CoverageItem, CoverageSummary, DesignFrontmatter, DesignSpec, FeatureFrontmatter, FeatureRule, FeatureScenario, FeatureSpec, FeatureStep, ModelItem, ModelReferenceFrontmatter, ModelSpec, RuleKeyword, ScenarioEvidencePolicy, ScenarioTestType, ScreenshotPolicy, SpecDocument, SpecFrontmatter, SpecScreenshot, StackSpec, StepKeyword, TestReference, ValidationIssue, } from "./types.js";
|
|
7
7
|
/** Write text content to a path, creating the parent directory when needed. */
|
|
8
8
|
export declare function writeTextFile(filePath: string, content: string): Promise<void>;
|
|
9
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,EACL,sBAAsB,EACtB,2BAA2B,GAC5B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,yBAAyB,EACzB,iBAAiB,EACjB,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,uBAAuB,EACvB,cAAc,EACd,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EACV,YAAY,EACZ,eAAe,EACf,iBAAiB,EACjB,UAAU,EACV,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,WAAW,EACX,WAAW,EACX,SAAS,EACT,yBAAyB,EACzB,SAAS,EACT,WAAW,EACX,sBAAsB,EACtB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,eAAe,EACf,cAAc,EACd,SAAS,EACT,WAAW,EACX,aAAa,EACb,eAAe,GAChB,MAAM,YAAY,CAAC;AAEpB,+EAA+E;AAC/E,wBAAsB,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,iBAGpE"}
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
export { expandFilePatterns } from "./filePatterns.js";
|
|
8
8
|
export { buildCoverageSummary, checkFeatureSpecs, collectTestReferences, loadFeatureSpecs, parseFeatureSpec, parseTestReferences, validateCoverage, validateFeatureSpec, } from "./featureSpecs.js";
|
|
9
9
|
export { renderHtmlReport } from "./reportTemplate.js";
|
|
10
|
-
export { collectSpecScreenshots } from "./screenshots.js";
|
|
10
|
+
export { collectSpecScreenshots, validateScenarioScreenshots, } from "./screenshots.js";
|
|
11
11
|
export { buildSpecCoverageSummary, checkSpecDocuments, collectSpecTestReferences, loadSpecDocuments, parseDesignSpec, parseModelSpec, parseSpecDocument, parseSpecTestReferences, parseStackSpec, validateDesignSpec, validateModelSpec, validateSpecDocument, validateSpecGraph, validateStackSpec, } from "./specDocuments.js";
|
|
12
12
|
/** Write text content to a path, creating the parent directory when needed. */
|
|
13
13
|
export async function writeTextFile(filePath, content) {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,gBAAgB,EAChB,gBAAgB,EAChB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AACvD,OAAO,EACL,sBAAsB,EACtB,2BAA2B,GAC5B,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAClB,yBAAyB,EACzB,iBAAiB,EACjB,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,uBAAuB,EACvB,cAAc,EACd,kBAAkB,EAClB,iBAAiB,EACjB,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,oBAAoB,CAAC;AA2B5B,+EAA+E;AAC/E,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,QAAgB,EAAE,OAAe;IACnE,MAAM,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACzD,MAAM,SAAS,CAAC,QAAQ,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;AAC7C,CAAC"}
|
package/dist/reportTemplate.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export type ReportOptions = {
|
|
|
8
8
|
validationIssues?: ValidationIssue[];
|
|
9
9
|
title?: string;
|
|
10
10
|
generatedAt?: string;
|
|
11
|
+
githubBaseUrl?: string;
|
|
12
|
+
githubRef?: string;
|
|
13
|
+
repositoryUrl?: string;
|
|
11
14
|
};
|
|
12
15
|
/** Render a complete self-contained feature spec report as HTML. */
|
|
13
16
|
export declare function renderHtmlReport(specs: FeatureSpec[], options?: ReportOptions): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"reportTemplate.d.ts","sourceRoot":"","sources":["../src/reportTemplate.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAEV,eAAe,EACf,UAAU,EACV,WAAW,EAEX,SAAS,EACT,cAAc,EACd,SAAS,
|
|
1
|
+
{"version":3,"file":"reportTemplate.d.ts","sourceRoot":"","sources":["../src/reportTemplate.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAEV,eAAe,EACf,UAAU,EACV,WAAW,EAEX,SAAS,EACT,cAAc,EACd,SAAS,EAET,eAAe,EAChB,MAAM,YAAY,CAAC;AAEpB,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC;IACrB,OAAO,CAAC,EAAE,UAAU,EAAE,CAAC;IACvB,WAAW,CAAC,EAAE,cAAc,EAAE,CAAC;IAC/B,gBAAgB,CAAC,EAAE,eAAe,EAAE,CAAC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAYF,oEAAoE;AACpE,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,WAAW,EAAE,EACpB,OAAO,GAAE,aAAkB,UAuE5B"}
|
package/dist/reportTemplate.js
CHANGED
|
@@ -9,6 +9,10 @@ export function renderHtmlReport(specs, options = {}) {
|
|
|
9
9
|
const title = options.title ?? "Feature Spec Report";
|
|
10
10
|
const issues = options.validationIssues ?? [];
|
|
11
11
|
const screenshots = options.screenshots ?? [];
|
|
12
|
+
const sourceLinks = {
|
|
13
|
+
githubBaseUrl: options.githubBaseUrl,
|
|
14
|
+
githubRef: options.githubRef,
|
|
15
|
+
};
|
|
12
16
|
return `<!doctype html>
|
|
13
17
|
<html lang="en">
|
|
14
18
|
<head>
|
|
@@ -32,21 +36,25 @@ export function renderHtmlReport(specs, options = {}) {
|
|
|
32
36
|
table{border-collapse:collapse;width:100%;font-size:14px}
|
|
33
37
|
th,td{border:1px solid #d0d7de;padding:6px 8px;text-align:left;vertical-align:top}
|
|
34
38
|
th{background:#f6f8fa}
|
|
39
|
+
h1 a{color:#0969da;text-decoration:underline;text-underline-offset:3px}
|
|
40
|
+
h1 a:hover{text-decoration-thickness:2px}
|
|
35
41
|
.step{border-left:3px solid #d0d7de;margin:12px 0;padding:2px 0 2px 12px}
|
|
36
42
|
.step p{margin:8px 0}
|
|
37
43
|
.screenshots{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px;margin:10px 0 14px}
|
|
38
44
|
.screenshot{border:1px solid #d0d7de;border-radius:8px;overflow:hidden;background:#f6f8fa}
|
|
39
45
|
.screenshot img{display:block;width:100%;height:auto}
|
|
40
46
|
.screenshot figcaption{font-size:12px;padding:8px;color:#57606a}
|
|
41
|
-
.coverage-
|
|
47
|
+
.coverage-refs{display:inline-flex;gap:2px;margin-left:4px}
|
|
48
|
+
.coverage-ref{color:inherit;text-decoration:underline;text-underline-offset:2px}
|
|
49
|
+
.line-link{color:inherit;text-decoration:underline;text-underline-offset:2px}
|
|
42
50
|
</style>
|
|
43
51
|
</head>
|
|
44
52
|
<body>
|
|
45
|
-
<h1>${
|
|
53
|
+
<h1>${renderReportTitle(title, options.repositoryUrl)}</h1>
|
|
46
54
|
<p>Generated ${html(formatGeneratedAt(options.generatedAt))}.</p>
|
|
47
55
|
${renderIssues(issues)}
|
|
48
|
-
${renderModels(options.models ?? [], options.coverage)}
|
|
49
|
-
${specs.map((spec) => renderSpec(spec, options.coverage, screenshots)).join("\n")}
|
|
56
|
+
${renderModels(options.models ?? [], options.coverage, sourceLinks)}
|
|
57
|
+
${specs.map((spec) => renderSpec(spec, options.coverage, screenshots, sourceLinks)).join("\n")}
|
|
50
58
|
<script>
|
|
51
59
|
document.addEventListener("toggle", (event) => {
|
|
52
60
|
const target = event.target;
|
|
@@ -67,6 +75,16 @@ export function renderHtmlReport(specs, options = {}) {
|
|
|
67
75
|
</body>
|
|
68
76
|
</html>`;
|
|
69
77
|
}
|
|
78
|
+
function renderReportTitle(title, repositoryUrl) {
|
|
79
|
+
if (!repositoryUrl)
|
|
80
|
+
return html(title);
|
|
81
|
+
const prefix = "Feature Spec Report for ";
|
|
82
|
+
const attributes = `href="${html(repositoryUrl)}" target="_blank" rel="noopener noreferrer"`;
|
|
83
|
+
if (title.startsWith(prefix) && title.length > prefix.length) {
|
|
84
|
+
return `${html(prefix)}<a ${attributes}>${html(title.slice(prefix.length))}</a>`;
|
|
85
|
+
}
|
|
86
|
+
return `<a ${attributes}>${html(title)}</a>`;
|
|
87
|
+
}
|
|
70
88
|
function formatGeneratedAt(value) {
|
|
71
89
|
const date = value ? new Date(value) : new Date();
|
|
72
90
|
if (Number.isNaN(date.getTime()))
|
|
@@ -108,7 +126,7 @@ function renderIssues(issues) {
|
|
|
108
126
|
.map((issue) => `<li class="${issue.severity}"><code>${html(`${issue.filePath ?? ""}${issue.line ? `:${issue.line}` : ""}`)}</code> ${html(issue.message)}</li>`)
|
|
109
127
|
.join("")}</ul></section>`;
|
|
110
128
|
}
|
|
111
|
-
function renderModels(models, coverage) {
|
|
129
|
+
function renderModels(models, coverage, sourceLinks = {}) {
|
|
112
130
|
if (!models.length)
|
|
113
131
|
return "";
|
|
114
132
|
const modelCoverage = coverage?.modelCoverage ?? [];
|
|
@@ -129,7 +147,7 @@ function renderModels(models, coverage) {
|
|
|
129
147
|
.map((item) => {
|
|
130
148
|
const coverageItem = modelCoverage.find((candidate) => candidate.id === item.id);
|
|
131
149
|
return `<details class="model-item">
|
|
132
|
-
<summary><code>${html(item.id)}</code>: ${html(item.title)} ${coverageBadge(coverageItem?.covered
|
|
150
|
+
<summary><code>${html(item.id)}</code>: ${html(item.title)} ${coverageBadge(coverageItem?.covered, [], coverageItem, sourceLinks)}</summary>
|
|
133
151
|
<div class="model-item-body">${renderModelItemBody(item.body)}</div>
|
|
134
152
|
</details>`;
|
|
135
153
|
})
|
|
@@ -138,7 +156,7 @@ function renderModels(models, coverage) {
|
|
|
138
156
|
? `<h4>Rules</h4><ul>${model.rules
|
|
139
157
|
.map((rule) => {
|
|
140
158
|
const item = ruleCoverage.find((coverageItem) => coverageItem.id === rule.id);
|
|
141
|
-
return `<li><code>${html(rule.id)}</code>: ${html(rule.text)} ${ruleCoverageBadge(item, ruleScenarioIds(rule.id, ruleScenarioLinks))}
|
|
159
|
+
return `<li><code>${html(rule.id)}</code>: ${html(rule.text)} ${ruleCoverageBadge(item, ruleScenarioIds(rule.id, ruleScenarioLinks), sourceLinks)}</li>`;
|
|
142
160
|
})
|
|
143
161
|
.join("")}</ul>`
|
|
144
162
|
: ""}
|
|
@@ -146,7 +164,7 @@ function renderModels(models, coverage) {
|
|
|
146
164
|
.join("\n")}
|
|
147
165
|
</section>`;
|
|
148
166
|
}
|
|
149
|
-
function renderSpec(spec, coverage, screenshots = []) {
|
|
167
|
+
function renderSpec(spec, coverage, screenshots = [], sourceLinks = {}) {
|
|
150
168
|
const screenshotsByLine = groupScreenshotsByLine(screenshots);
|
|
151
169
|
const ruleCoverage = coverage?.ruleCoverage ?? [];
|
|
152
170
|
const scenarioCoverage = coverage?.scenarioCoverage ?? [];
|
|
@@ -161,7 +179,7 @@ function renderSpec(spec, coverage, screenshots = []) {
|
|
|
161
179
|
<ul>${spec.rules
|
|
162
180
|
.map((rule) => {
|
|
163
181
|
const item = ruleCoverage.find((coverageItem) => coverageItem.id === rule.id);
|
|
164
|
-
return `<li><code>${html(rule.id)}</code>: ${html(rule.text)} ${ruleCoverageBadge(item, ruleScenarioIds(rule.id, ruleScenarioLinks))}
|
|
182
|
+
return `<li><code>${html(rule.id)}</code>: ${html(rule.text)} ${ruleCoverageBadge(item, ruleScenarioIds(rule.id, ruleScenarioLinks), sourceLinks)}</li>`;
|
|
165
183
|
})
|
|
166
184
|
.join("")}</ul>
|
|
167
185
|
<h3>Scenarios</h3>
|
|
@@ -171,9 +189,10 @@ function renderSpec(spec, coverage, screenshots = []) {
|
|
|
171
189
|
(screenshotsByLine.get(screenshotKey(spec.filePath, step.line))
|
|
172
190
|
?.length ?? 0), 0);
|
|
173
191
|
const scenarioRuleIds = ruleIdsForScenario(scenario.id, spec.rules.map((rule) => rule.id), ruleScenarioLinks);
|
|
192
|
+
const scenarioCoverageItem = scenarioCoverage.find((item) => item.id === scenario.id);
|
|
174
193
|
return `<details class="scenario" data-has-images="${screenshotCount > 0 ? "true" : "false"}">
|
|
175
|
-
<summary><code>${html(scenario.id)}</code>: ${html(scenario.title)} ${coverageBadge(
|
|
176
|
-
<div class="scenario-body">${renderScenarioRuleCoverage(scenarioRuleIds)}${scenario.steps.map((step) => renderStep(spec, step, screenshotsByLine)).join("")}</div>
|
|
194
|
+
<summary><code>${html(scenario.id)}</code>: ${html(scenario.title)} ${coverageBadge(scenarioCoverageItem?.covered, [], scenarioCoverageItem, sourceLinks)} <span class="badge">${screenshotCount} screenshot${screenshotCount === 1 ? "" : "s"}</span></summary>
|
|
195
|
+
<div class="scenario-body">${renderScenarioRuleCoverage(scenarioRuleIds)}${scenario.steps.map((step) => renderStep(spec, step, screenshotsByLine, sourceLinks)).join("")}</div>
|
|
177
196
|
</details>`;
|
|
178
197
|
})
|
|
179
198
|
.join("\n")}
|
|
@@ -259,12 +278,19 @@ function renderTable(lines) {
|
|
|
259
278
|
.join("")}</tr>`)
|
|
260
279
|
.join("")}</tbody></table></div>`;
|
|
261
280
|
}
|
|
262
|
-
function renderStep(spec, step, screenshotsByLine) {
|
|
281
|
+
function renderStep(spec, step, screenshotsByLine, sourceLinks) {
|
|
263
282
|
const screenshots = screenshotsByLine.get(screenshotKey(spec.filePath, step.line)) ?? [];
|
|
264
283
|
const evidenceBadge = screenshots.length
|
|
265
284
|
? `<span class="badge ok">${screenshots.length} screenshot${screenshots.length === 1 ? "" : "s"}</span>`
|
|
266
285
|
: `<span class="badge missing">missing screenshot</span>`;
|
|
267
|
-
return `<div class="step"><p><strong>${html(step.keyword)}</strong> ${html(step.text)}
|
|
286
|
+
return `<div class="step"><p><strong>${html(step.keyword)}</strong> ${html(step.text)} ${renderLineBadge(spec.filePath, step.line, sourceLinks)} ${evidenceBadge}</p>${renderScreenshots(screenshots)}</div>`;
|
|
287
|
+
}
|
|
288
|
+
function renderLineBadge(filePath, line, sourceLinks) {
|
|
289
|
+
const label = `line ${line}`;
|
|
290
|
+
const url = sourceLineUrl(filePath, line, sourceLinks);
|
|
291
|
+
if (!url)
|
|
292
|
+
return `<span class="badge">${html(label)}</span>`;
|
|
293
|
+
return `<a class="badge line-link" href="${html(url)}" title="${html(`${filePath}:${line}`)}" target="_blank" rel="noopener noreferrer">${html(label)}</a>`;
|
|
268
294
|
}
|
|
269
295
|
function renderScreenshots(screenshots) {
|
|
270
296
|
if (!screenshots.length)
|
|
@@ -273,34 +299,65 @@ function renderScreenshots(screenshots) {
|
|
|
273
299
|
.map((screenshot) => `<figure class="screenshot"><img src="${html(screenshot.path)}" alt="${html(screenshot.title ?? `Screenshot for ${screenshot.specPath}:${screenshot.line}`)}"><figcaption>${html(screenshot.title ?? `${screenshot.specPath}:${screenshot.line}`)}</figcaption></figure>`)
|
|
274
300
|
.join("")}</div>`;
|
|
275
301
|
}
|
|
276
|
-
function coverageBadge(covered, suffixes = []) {
|
|
302
|
+
function coverageBadge(covered, suffixes = [], item, sourceLinks = {}) {
|
|
277
303
|
return covered === undefined
|
|
278
304
|
? ""
|
|
279
305
|
: covered
|
|
280
|
-
? `<span class="badge ok">covered${suffixes.length ? ` by ${suffixes.map(html).join(" ")}` : ""}</span>`
|
|
306
|
+
? `<span class="badge ok">covered${suffixes.length ? ` by ${suffixes.map(html).join(" ")}` : ""}${renderCoverageReferenceMarkers(item, sourceLinks)}</span>`
|
|
281
307
|
: `<span class="badge missing">missing coverage</span>`;
|
|
282
308
|
}
|
|
283
|
-
function ruleCoverageBadge(ruleCoverage, scenarioIds) {
|
|
309
|
+
function ruleCoverageBadge(ruleCoverage, scenarioIds, sourceLinks) {
|
|
284
310
|
if (ruleCoverage?.covered && !scenarioIds.length) {
|
|
285
|
-
return coverageBadge(true, ["direct test"]);
|
|
311
|
+
return coverageBadge(true, ["direct test"], ruleCoverage, sourceLinks);
|
|
286
312
|
}
|
|
287
|
-
return coverageBadge(ruleCoverage?.covered, scenarioIds);
|
|
313
|
+
return coverageBadge(ruleCoverage?.covered, scenarioIds, ruleCoverage, sourceLinks);
|
|
288
314
|
}
|
|
289
|
-
function
|
|
290
|
-
const
|
|
291
|
-
if (!
|
|
315
|
+
function renderCoverageReferenceMarkers(item, sourceLinks) {
|
|
316
|
+
const references = uniqueCoverageReferences(item);
|
|
317
|
+
if (!references.length)
|
|
292
318
|
return "";
|
|
293
|
-
return ` <span class="coverage-
|
|
294
|
-
.map((
|
|
295
|
-
.join("
|
|
319
|
+
return ` <span class="coverage-refs">${references
|
|
320
|
+
.map((reference, index) => renderCoverageReferenceMarker(reference, index + 1, sourceLinks))
|
|
321
|
+
.join(",")}</span>`;
|
|
322
|
+
}
|
|
323
|
+
function renderCoverageReferenceMarker(reference, index, sourceLinks) {
|
|
324
|
+
const label = coverageReferenceLabel(reference);
|
|
325
|
+
const url = coverageReferenceUrl(reference, sourceLinks);
|
|
326
|
+
if (!url)
|
|
327
|
+
return `<span class="coverage-ref" title="${html(label)}">${index}</span>`;
|
|
328
|
+
return `<a class="coverage-ref" href="${html(url)}" title="${html(label)}" target="_blank" rel="noopener noreferrer">${index}</a>`;
|
|
296
329
|
}
|
|
297
|
-
function
|
|
330
|
+
function uniqueCoverageReferences(item) {
|
|
298
331
|
if (!item)
|
|
299
332
|
return [];
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
333
|
+
const seen = new Set();
|
|
334
|
+
const references = [];
|
|
335
|
+
for (const reference of item.references) {
|
|
336
|
+
const key = coverageReferenceLabel(reference);
|
|
337
|
+
if (seen.has(key))
|
|
338
|
+
continue;
|
|
339
|
+
seen.add(key);
|
|
340
|
+
references.push(reference);
|
|
341
|
+
}
|
|
342
|
+
return references;
|
|
343
|
+
}
|
|
344
|
+
function coverageReferenceLabel(reference) {
|
|
345
|
+
const line = reference.line ? `:${reference.line}` : "";
|
|
346
|
+
return `${reference.filePath}${line}`;
|
|
347
|
+
}
|
|
348
|
+
function coverageReferenceUrl(reference, sourceLinks) {
|
|
349
|
+
return sourceLineUrl(reference.filePath, reference.line, sourceLinks);
|
|
350
|
+
}
|
|
351
|
+
function sourceLineUrl(filePath, line, sourceLinks) {
|
|
352
|
+
if (!sourceLinks.githubBaseUrl || !sourceLinks.githubRef)
|
|
353
|
+
return undefined;
|
|
354
|
+
const baseUrl = sourceLinks.githubBaseUrl.replace(/\/$/, "");
|
|
355
|
+
const ref = encodeURIComponent(sourceLinks.githubRef);
|
|
356
|
+
const encodedFilePath = filePath
|
|
357
|
+
.split("/")
|
|
358
|
+
.map((segment) => encodeURIComponent(segment))
|
|
359
|
+
.join("/");
|
|
360
|
+
return `${baseUrl}/blob/${ref}/${encodedFilePath}#L${line}`;
|
|
304
361
|
}
|
|
305
362
|
function ruleScenarioIds(ruleId, ruleScenarioLinks) {
|
|
306
363
|
return Array.from(new Set(ruleScenarioLinks
|