@sdods/core 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/a11y/audit.d.ts +88 -0
- package/dist/a11y/audit.js +134 -0
- package/dist/analyze/index.d.ts +2 -0
- package/dist/analyze/index.js +2 -0
- package/dist/analyze/traceability-render.d.ts +5 -0
- package/dist/analyze/traceability-render.js +254 -0
- package/dist/analyze/traceability.d.ts +87 -0
- package/dist/analyze/traceability.js +559 -0
- package/dist/api/json-path.d.ts +11 -1
- package/dist/api/json-path.js +23 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/emulation.d.ts +74 -0
- package/dist/config/emulation.js +154 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/resolve.js +1 -1
- package/dist/config/runner.js +4 -1
- package/dist/config/tags.d.ts +1 -1
- package/dist/config/tags.js +7 -0
- package/dist/data/index.d.ts +1 -0
- package/dist/data/index.js +1 -0
- package/dist/data/pool-capacity.d.ts +58 -0
- package/dist/data/pool-capacity.js +138 -0
- package/dist/data/user-pool.d.ts +43 -2
- package/dist/data/user-pool.js +170 -40
- package/dist/errors.d.ts +1 -1
- package/dist/fixtures/api-context.d.ts +19 -1
- package/dist/fixtures/api-context.js +28 -1
- package/dist/fixtures/scenario.d.ts +11 -0
- package/dist/fixtures/scenario.js +15 -0
- package/dist/fixtures/test.js +60 -3
- package/dist/fixtures/types.d.ts +5 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lint/index.js +86 -0
- package/dist/load/index.d.ts +4 -0
- package/dist/load/index.js +4 -0
- package/dist/load/profile.d.ts +22 -0
- package/dist/load/profile.js +109 -0
- package/dist/load/run.d.ts +85 -0
- package/dist/load/run.js +255 -0
- package/dist/load/script.d.ts +27 -0
- package/dist/load/script.js +188 -0
- package/dist/mail/extract.d.ts +38 -0
- package/dist/mail/extract.js +106 -0
- package/dist/mail/index.d.ts +8 -0
- package/dist/mail/index.js +11 -0
- package/dist/mail/mailpit.d.ts +26 -0
- package/dist/mail/mailpit.js +124 -0
- package/dist/mail/types.d.ts +38 -0
- package/dist/mail/types.js +2 -0
- package/dist/matrix/index.d.ts +117 -0
- package/dist/matrix/index.js +507 -0
- package/dist/perf/vitals.d.ts +118 -0
- package/dist/perf/vitals.js +236 -0
- package/dist/quality/a11y-scenario.d.ts +68 -0
- package/dist/quality/a11y-scenario.js +63 -0
- package/dist/quality/gates.d.ts +54 -0
- package/dist/quality/gates.js +144 -0
- package/dist/quality/hooks.d.ts +2 -0
- package/dist/quality/hooks.js +38 -0
- package/dist/quality/index.d.ts +4 -0
- package/dist/quality/index.js +2 -0
- package/dist/quality/perf-scenario.d.ts +132 -0
- package/dist/quality/perf-scenario.js +241 -0
- package/dist/shots/baselines.d.ts +72 -0
- package/dist/shots/baselines.js +201 -0
- package/dist/shots/hooks.js +2 -2
- package/dist/shots/index.d.ts +1 -0
- package/dist/shots/index.js +1 -0
- package/dist/shots/narrator.d.ts +10 -2
- package/dist/shots/narrator.js +106 -10
- package/dist/shots/policy.d.ts +5 -1
- package/dist/shots/policy.js +2 -0
- package/dist/steps/a11y.steps.d.ts +18 -51
- package/dist/steps/a11y.steps.js +6 -107
- package/dist/steps/api.steps.js +5 -7
- package/dist/steps/browser.steps.d.ts +12 -0
- package/dist/steps/browser.steps.js +154 -3
- package/dist/steps/canvas.steps.d.ts +9 -0
- package/dist/steps/canvas.steps.js +331 -0
- package/dist/steps/data.steps.js +17 -5
- package/dist/steps/email.steps.d.ts +2 -0
- package/dist/steps/email.steps.js +182 -0
- package/dist/steps/hooks.steps.d.ts +1 -0
- package/dist/steps/hooks.steps.js +3 -1
- package/dist/steps/hybrid.steps.js +2 -2
- package/dist/steps/index.d.ts +3 -0
- package/dist/steps/index.js +3 -0
- package/dist/steps/perf.steps.d.ts +3 -129
- package/dist/steps/perf.steps.js +4 -227
- package/dist/steps/ui.steps.js +5 -0
- package/package.json +4 -3
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* The axe audit itself, shared by the explicit accessibility steps (`steps/a11y.steps.ts`) and the
|
|
4
|
+
* end-of-scenario audit every `@a11y` scenario gets (`quality/hooks.ts`).
|
|
5
|
+
*
|
|
6
|
+
* It lives outside the step file on purpose. A step file registers its phrases when it is imported,
|
|
7
|
+
* and a project can switch a whole core library off with `steps.core.exclude: [a11y]` because its
|
|
8
|
+
* own steps already use those phrases. If the tag hook imported the step file, it would register
|
|
9
|
+
* those phrases again behind the project's back and bring back the collision it had excluded.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Structural subset of axe-core's result types. Declared locally rather than imported from
|
|
13
|
+
* `axe-core`: that package is a transitive dependency of `@axe-core/playwright`, not a direct
|
|
14
|
+
* dependency of this one, so importing its types would couple the whole step library's
|
|
15
|
+
* compilation to a package a consumer may not have installed.
|
|
16
|
+
*/
|
|
17
|
+
export interface A11yViolationNode {
|
|
18
|
+
target: unknown[];
|
|
19
|
+
html?: string;
|
|
20
|
+
failureSummary?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface A11yViolation {
|
|
23
|
+
id: string;
|
|
24
|
+
impact?: string | null;
|
|
25
|
+
help?: string;
|
|
26
|
+
helpUrl?: string;
|
|
27
|
+
nodes: A11yViolationNode[];
|
|
28
|
+
}
|
|
29
|
+
export interface A11yResults {
|
|
30
|
+
violations: A11yViolation[];
|
|
31
|
+
passes: {
|
|
32
|
+
id: string;
|
|
33
|
+
}[];
|
|
34
|
+
incomplete: {
|
|
35
|
+
id: string;
|
|
36
|
+
}[];
|
|
37
|
+
inapplicable: {
|
|
38
|
+
id: string;
|
|
39
|
+
}[];
|
|
40
|
+
url?: string;
|
|
41
|
+
}
|
|
42
|
+
/** axe impact levels, weakest first. `impact: null` is treated as `minor`. */
|
|
43
|
+
export declare const IMPACT_ORDER: readonly ["minor", "moderate", "serious", "critical"];
|
|
44
|
+
/**
|
|
45
|
+
* WCAG 2.0/2.1/2.2 level A and AA, and deliberately nothing else. axe's `best-practice` tag set
|
|
46
|
+
* changes between axe releases, so including it would let the same unchanged page pass one week
|
|
47
|
+
* and fail the next — a step whose verdict moves on its own teaches teams to ignore it.
|
|
48
|
+
*/
|
|
49
|
+
export declare const WCAG_AA_TAGS: string[];
|
|
50
|
+
/** Rank of an axe impact level; an absent impact ranks as `minor` rather than vanishing. */
|
|
51
|
+
export declare function impactRank(impact: string | null | undefined): number;
|
|
52
|
+
/** Rejects an impact level the feature file invented, instead of silently matching nothing. */
|
|
53
|
+
export declare function parseImpactFloor(level: string): number;
|
|
54
|
+
export declare function violationsAtOrAbove(violations: A11yViolation[], floor: number): A11yViolation[];
|
|
55
|
+
/** Human-readable violation list: rule, impact, help text and the first few offending nodes. */
|
|
56
|
+
export declare function describeViolations(violations: A11yViolation[]): string;
|
|
57
|
+
/**
|
|
58
|
+
* PROVES the audit actually executed. axe reporting zero violations is indistinguishable from
|
|
59
|
+
* axe never having run: a nonce'd CSP that blocks the injected source, an `about:blank` page, or
|
|
60
|
+
* an `include` that matched nothing all produce an empty, green-looking result. If no rule landed
|
|
61
|
+
* in passes, violations or incomplete, then nothing was examined and the verdict means nothing.
|
|
62
|
+
*/
|
|
63
|
+
export declare function assertAxeChecked(results: A11yResults, where: string): void;
|
|
64
|
+
export interface AxeRunOptions {
|
|
65
|
+
include?: string | string[];
|
|
66
|
+
exclude?: string[];
|
|
67
|
+
tags?: string[];
|
|
68
|
+
rules?: string[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Runs axe through `@axe-core/playwright`, which injects the source with `page.evaluate` rather
|
|
72
|
+
* than `addScriptTag`. That matters: an app serving a nonce'd CSP blocks an injected `<script>`
|
|
73
|
+
* silently, and the audit would report zero violations because it never ran.
|
|
74
|
+
*
|
|
75
|
+
* The import is lazy so a project that never audits does not need the dependency — a top-level
|
|
76
|
+
* import would fail the load of every step file that reaches this module.
|
|
77
|
+
*/
|
|
78
|
+
export declare function runAxe(page: Page, opts: AxeRunOptions): Promise<A11yResults>;
|
|
79
|
+
/** The file writer and attacher both audits use; the name and path are the caller's. */
|
|
80
|
+
export declare function writeAndAttachJson(testInfo: {
|
|
81
|
+
attach(name: string, opts: {
|
|
82
|
+
path: string;
|
|
83
|
+
contentType: string;
|
|
84
|
+
}): Promise<void>;
|
|
85
|
+
}, name: string, file: string, body: unknown): Promise<void>;
|
|
86
|
+
export declare function markPageAudited(scenario: object, url: string): void;
|
|
87
|
+
export declare function wasPageAudited(scenario: object, url: string): boolean;
|
|
88
|
+
//# sourceMappingURL=audit.d.ts.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { SdodsError } from '../errors.js';
|
|
3
|
+
/** axe impact levels, weakest first. `impact: null` is treated as `minor`. */
|
|
4
|
+
export const IMPACT_ORDER = ['minor', 'moderate', 'serious', 'critical'];
|
|
5
|
+
/**
|
|
6
|
+
* WCAG 2.0/2.1/2.2 level A and AA, and deliberately nothing else. axe's `best-practice` tag set
|
|
7
|
+
* changes between axe releases, so including it would let the same unchanged page pass one week
|
|
8
|
+
* and fail the next — a step whose verdict moves on its own teaches teams to ignore it.
|
|
9
|
+
*/
|
|
10
|
+
export const WCAG_AA_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
|
|
11
|
+
/* ── pure judges ───────────────────────────────────────────────────────── */
|
|
12
|
+
/** Rank of an axe impact level; an absent impact ranks as `minor` rather than vanishing. */
|
|
13
|
+
export function impactRank(impact) {
|
|
14
|
+
const i = IMPACT_ORDER.indexOf((impact ?? 'minor'));
|
|
15
|
+
return i === -1 ? 0 : i;
|
|
16
|
+
}
|
|
17
|
+
/** Rejects an impact level the feature file invented, instead of silently matching nothing. */
|
|
18
|
+
export function parseImpactFloor(level) {
|
|
19
|
+
const i = IMPACT_ORDER.indexOf(level);
|
|
20
|
+
if (i === -1)
|
|
21
|
+
throw new SdodsError('CONFIG_INVALID', `"${level}" is not an axe impact level.`, {
|
|
22
|
+
hint: `Use one of: ${IMPACT_ORDER.join(', ')}.`,
|
|
23
|
+
});
|
|
24
|
+
return i;
|
|
25
|
+
}
|
|
26
|
+
export function violationsAtOrAbove(violations, floor) {
|
|
27
|
+
return violations.filter((v) => impactRank(v.impact) >= floor);
|
|
28
|
+
}
|
|
29
|
+
/** Human-readable violation list: rule, impact, help text and the first few offending nodes. */
|
|
30
|
+
export function describeViolations(violations) {
|
|
31
|
+
return violations
|
|
32
|
+
.map((v) => {
|
|
33
|
+
const nodes = v.nodes ?? [];
|
|
34
|
+
const shown = nodes
|
|
35
|
+
.slice(0, 3)
|
|
36
|
+
.map((n) => (Array.isArray(n.target) ? n.target.join(' ') : String(n.target)))
|
|
37
|
+
.join(' | ');
|
|
38
|
+
const more = nodes.length > 3 ? ` (+${nodes.length - 3} more node(s))` : '';
|
|
39
|
+
return `${v.id} [${v.impact ?? 'minor'}] ${v.help ?? ''} → ${nodes.length} node(s): ${shown}${more}`;
|
|
40
|
+
})
|
|
41
|
+
.join('\n');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* PROVES the audit actually executed. axe reporting zero violations is indistinguishable from
|
|
45
|
+
* axe never having run: a nonce'd CSP that blocks the injected source, an `about:blank` page, or
|
|
46
|
+
* an `include` that matched nothing all produce an empty, green-looking result. If no rule landed
|
|
47
|
+
* in passes, violations or incomplete, then nothing was examined and the verdict means nothing.
|
|
48
|
+
*/
|
|
49
|
+
export function assertAxeChecked(results, where) {
|
|
50
|
+
const checked = (results.passes?.length ?? 0) +
|
|
51
|
+
(results.violations?.length ?? 0) +
|
|
52
|
+
(results.incomplete?.length ?? 0);
|
|
53
|
+
if (checked === 0)
|
|
54
|
+
throw new SdodsError('RUN_FAILED', `axe examined nothing ${where}; the audit proves nothing.`, {
|
|
55
|
+
hint: 'The page may be blank, the scoped selector may match an empty element, or a Content-Security-Policy may have blocked axe from running. Open the attached sdods/a11y JSON to see what axe reported.',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Runs axe through `@axe-core/playwright`, which injects the source with `page.evaluate` rather
|
|
60
|
+
* than `addScriptTag`. That matters: an app serving a nonce'd CSP blocks an injected `<script>`
|
|
61
|
+
* silently, and the audit would report zero violations because it never ran.
|
|
62
|
+
*
|
|
63
|
+
* The import is lazy so a project that never audits does not need the dependency — a top-level
|
|
64
|
+
* import would fail the load of every step file that reaches this module.
|
|
65
|
+
*/
|
|
66
|
+
export async function runAxe(page, opts) {
|
|
67
|
+
let AxeBuilder;
|
|
68
|
+
try {
|
|
69
|
+
const mod = await import('@axe-core/playwright');
|
|
70
|
+
// Named export first: the package exports the class as both `AxeBuilder` and `default`, and a
|
|
71
|
+
// CJS interop path can hand `default` back as the namespace object rather than the class.
|
|
72
|
+
AxeBuilder = (mod.AxeBuilder ?? mod.default);
|
|
73
|
+
}
|
|
74
|
+
catch (cause) {
|
|
75
|
+
throw new SdodsError('NOT_SUPPORTED', 'Accessibility audits need @axe-core/playwright.', {
|
|
76
|
+
hint: 'Install it in the project that runs these steps: `npm i -D @axe-core/playwright`.',
|
|
77
|
+
cause,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const include = typeof opts.include === 'string' ? [opts.include] : (opts.include ?? []);
|
|
81
|
+
const builder = new AxeBuilder({ page });
|
|
82
|
+
for (const sel of include)
|
|
83
|
+
builder.include(sel);
|
|
84
|
+
for (const sel of opts.exclude ?? [])
|
|
85
|
+
builder.exclude(sel);
|
|
86
|
+
// withTags and withRules are mutually exclusive in axe-core; never set both.
|
|
87
|
+
if (opts.rules)
|
|
88
|
+
builder.withRules(opts.rules);
|
|
89
|
+
else
|
|
90
|
+
builder.withTags(opts.tags ?? WCAG_AA_TAGS);
|
|
91
|
+
try {
|
|
92
|
+
return await builder.analyze();
|
|
93
|
+
}
|
|
94
|
+
catch (cause) {
|
|
95
|
+
// axe raises "unknown rule `x` in options.runOnly" for a rule id that does not exist, and
|
|
96
|
+
// "No elements found for include in page Context" for a selector that matched nothing. Both
|
|
97
|
+
// are defects in the step rather than in the page under test, so both are re-raised as such.
|
|
98
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
99
|
+
if (/unknown rule/i.test(message))
|
|
100
|
+
throw new SdodsError('CONFIG_INVALID', `axe rejected the rule id: ${message}`, {
|
|
101
|
+
hint: 'Check the rule id against https://dequeuniversity.com/rules/axe.',
|
|
102
|
+
cause,
|
|
103
|
+
});
|
|
104
|
+
if (/no elements found for include/i.test(message))
|
|
105
|
+
throw new SdodsError('RUN_FAILED', `axe found nothing to scan: ${message}`, {
|
|
106
|
+
hint: `The selector "${include.join(', ')}" matched no element by the time axe ran. Wait for the region to render first, or fix the selector.`,
|
|
107
|
+
cause,
|
|
108
|
+
});
|
|
109
|
+
throw new SdodsError('RUN_FAILED', `axe failed to analyse the page: ${message}`, { cause });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** The file writer and attacher both audits use; the name and path are the caller's. */
|
|
113
|
+
export async function writeAndAttachJson(testInfo, name, file, body) {
|
|
114
|
+
writeFileSync(file, JSON.stringify(body, null, 2));
|
|
115
|
+
await testInfo.attach(name, { path: file, contentType: 'application/json' });
|
|
116
|
+
}
|
|
117
|
+
/* ── which URLs a scenario has already audited ─────────────────────────── */
|
|
118
|
+
/**
|
|
119
|
+
* Whole-page audits an explicit step already ran in this scenario, by URL. The `@a11y` tag audit
|
|
120
|
+
* skips a URL listed here: the author wrote the step, chose its threshold, and it has already passed
|
|
121
|
+
* or failed. Scoped (`within …`) and single-rule audits are not recorded, because they did not look
|
|
122
|
+
* at the whole page. Keyed on the per-test `scenario` object, so nothing leaks between tests.
|
|
123
|
+
*/
|
|
124
|
+
const audited = new WeakMap();
|
|
125
|
+
export function markPageAudited(scenario, url) {
|
|
126
|
+
let set = audited.get(scenario);
|
|
127
|
+
if (!set)
|
|
128
|
+
audited.set(scenario, (set = new Set()));
|
|
129
|
+
set.add(url);
|
|
130
|
+
}
|
|
131
|
+
export function wasPageAudited(scenario, url) {
|
|
132
|
+
return audited.get(scenario)?.has(url) ?? false;
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=audit.js.map
|
package/dist/analyze/index.d.ts
CHANGED
|
@@ -4,4 +4,6 @@ export { proposeProject, slugify, type ProposeOptions } from './propose.js';
|
|
|
4
4
|
export { applyProposal, importPlaywrightSpecs, type ApplyOptions, type ApplyResult, } from './apply.js';
|
|
5
5
|
export { computeCoverage, parseFeatures, pomRouteSteps, type CoverageOptions } from './coverage.js';
|
|
6
6
|
export * from './detectors.js';
|
|
7
|
+
export { REQ_TAG, buildTraceabilityReport, latestRunFor, loadRequirements, projectRequirements, readRunManifest, readRunResults, requirementUrl, traceScenarios, type RequirementDef, type RunAttempt, type RunResults, type TraceabilityOptions, } from './traceability.js';
|
|
8
|
+
export { TRACEABILITY_FORMATS, renderTraceability, type TraceabilityFormat, } from './traceability-render.js';
|
|
7
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/analyze/index.js
CHANGED
|
@@ -4,4 +4,6 @@ export { proposeProject, slugify } from './propose.js';
|
|
|
4
4
|
export { applyProposal, importPlaywrightSpecs, } from './apply.js';
|
|
5
5
|
export { computeCoverage, parseFeatures, pomRouteSteps } from './coverage.js';
|
|
6
6
|
export * from './detectors.js';
|
|
7
|
+
export { REQ_TAG, buildTraceabilityReport, latestRunFor, loadRequirements, projectRequirements, readRunManifest, readRunResults, requirementUrl, traceScenarios, } from './traceability.js';
|
|
8
|
+
export { TRACEABILITY_FORMATS, renderTraceability, } from './traceability-render.js';
|
|
7
9
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TraceabilityReport } from '@sdods/contracts';
|
|
2
|
+
export declare const TRACEABILITY_FORMATS: readonly ["json", "csv", "md", "html"];
|
|
3
|
+
export type TraceabilityFormat = (typeof TRACEABILITY_FORMATS)[number];
|
|
4
|
+
export declare function renderTraceability(report: TraceabilityReport, format: TraceabilityFormat): string;
|
|
5
|
+
//# sourceMappingURL=traceability-render.d.ts.map
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
export const TRACEABILITY_FORMATS = ['json', 'csv', 'md', 'html'];
|
|
2
|
+
const SIGN_OFF_FIELDS = [
|
|
3
|
+
['signedBy', 'Signed by'],
|
|
4
|
+
['role', 'Role'],
|
|
5
|
+
['date', 'Date'],
|
|
6
|
+
['decision', 'Decision'],
|
|
7
|
+
['notes', 'Notes'],
|
|
8
|
+
];
|
|
9
|
+
export function renderTraceability(report, format) {
|
|
10
|
+
switch (format) {
|
|
11
|
+
case 'json':
|
|
12
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
13
|
+
case 'csv':
|
|
14
|
+
return renderCsv(report);
|
|
15
|
+
case 'md':
|
|
16
|
+
return renderMarkdown(report);
|
|
17
|
+
case 'html':
|
|
18
|
+
return renderHtml(report);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const resultLabel = (r) => `${r.runnerProject}${r.exampleIndex != null ? ` #${r.exampleIndex}` : ''}: ${r.status}${r.flaky ? ' (flaky)' : ''}`;
|
|
22
|
+
const duration = (ms) => (ms == null ? '' : `${(ms / 1000).toFixed(1)}s`);
|
|
23
|
+
function runLines(report) {
|
|
24
|
+
const run = report.run;
|
|
25
|
+
if (!run)
|
|
26
|
+
return [['Run', 'none (static matrix)']];
|
|
27
|
+
const git = run.git?.sha
|
|
28
|
+
? `${run.git.sha}${run.git.branch ? ` (${run.git.branch})` : ''}${run.git.dirty ? ', uncommitted changes' : ''}`
|
|
29
|
+
: '';
|
|
30
|
+
return [
|
|
31
|
+
['Run', run.id],
|
|
32
|
+
['Environment', run.env ?? ''],
|
|
33
|
+
['Started', run.startedAt ?? ''],
|
|
34
|
+
['Finished', run.finishedAt ?? ''],
|
|
35
|
+
['Exit code', run.exitCode == null ? '' : String(run.exitCode)],
|
|
36
|
+
['Git', git],
|
|
37
|
+
['SDODS version', run.sdodsVersion ?? ''],
|
|
38
|
+
['Tags', run.tagsExpr ?? ''],
|
|
39
|
+
['CI', run.ci?.url ?? ''],
|
|
40
|
+
['Results from', run.resultsSource],
|
|
41
|
+
].filter(([, v]) => v !== '');
|
|
42
|
+
}
|
|
43
|
+
function summaryLines(report) {
|
|
44
|
+
const s = report.summary;
|
|
45
|
+
return [
|
|
46
|
+
['Requirements', String(s.requirements)],
|
|
47
|
+
['Covered', String(s.covered)],
|
|
48
|
+
['Passed', String(s.passed)],
|
|
49
|
+
['Failed', String(s.failed)],
|
|
50
|
+
['Not run', String(s.notRun)],
|
|
51
|
+
['Not covered', s.notCovered == null ? 'unknown (no requirements file)' : String(s.notCovered)],
|
|
52
|
+
['Undeclared ids', String(s.undeclared)],
|
|
53
|
+
['Scenarios', `${s.scenarios} (${s.tracedScenarios} traced, ${s.untracedScenarios} untraced)`],
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
// ── Markdown ─────────────────────────────────────────────────────────────────
|
|
57
|
+
const md = (s) => s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
58
|
+
function mdTable(head, rows) {
|
|
59
|
+
return [
|
|
60
|
+
`| ${head.join(' | ')} |`,
|
|
61
|
+
`| ${head.map(() => '---').join(' | ')} |`,
|
|
62
|
+
...rows.map((r) => `| ${r.map(md).join(' | ')} |`),
|
|
63
|
+
].join('\n');
|
|
64
|
+
}
|
|
65
|
+
function renderMarkdown(report) {
|
|
66
|
+
const out = [];
|
|
67
|
+
out.push(`# Traceability: ${md(report.projectName)} (${report.project})`, '');
|
|
68
|
+
out.push(`Generated ${report.generatedAt}.`, '');
|
|
69
|
+
out.push(mdTable(['Field', 'Value'], runLines(report)), '');
|
|
70
|
+
out.push('## Summary', '', mdTable(['Field', 'Value'], summaryLines(report)), '');
|
|
71
|
+
out.push('## Requirements', '');
|
|
72
|
+
out.push(report.requirements.length
|
|
73
|
+
? mdTable(['Requirement', 'Title', 'Status', 'Scenarios'], report.requirements.map((r) => [
|
|
74
|
+
r.url ? `[${r.id}](${r.url})` : r.id,
|
|
75
|
+
`${r.title ?? ''}${r.declared === false ? ' (not in requirements file)' : ''}`,
|
|
76
|
+
r.status,
|
|
77
|
+
String(r.scenarios.length),
|
|
78
|
+
]))
|
|
79
|
+
: 'No requirements: no scenario carries an `@req:<id>` tag and no requirements file is configured.', '');
|
|
80
|
+
for (const r of report.requirements) {
|
|
81
|
+
out.push(`### ${md(r.id)}${r.title ? `: ${md(r.title)}` : ''}`, '');
|
|
82
|
+
out.push(`Status: **${r.status}**`, '');
|
|
83
|
+
if (!r.scenarios.length) {
|
|
84
|
+
out.push('No scenario covers this requirement.', '');
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
out.push(scenarioTable(r.scenarios), '');
|
|
88
|
+
}
|
|
89
|
+
if (report.untraced.length) {
|
|
90
|
+
out.push('## Scenarios without a requirement', '');
|
|
91
|
+
out.push(mdTable(['Scenario', 'Location', 'Status'], report.untraced.map((s) => [s.name, `${s.feature}:${s.line}`, s.status])), '');
|
|
92
|
+
}
|
|
93
|
+
out.push('## Notes', '', ...report.notes.map((n) => `- ${n}`), '');
|
|
94
|
+
out.push('## Sign-off', '');
|
|
95
|
+
out.push('To be completed by a person. SDODS does not fill in this section.', '');
|
|
96
|
+
out.push(mdTable(['Field', 'Value'], SIGN_OFF_FIELDS.map(([, label]) => [label, ''])), '');
|
|
97
|
+
return out.join('\n');
|
|
98
|
+
}
|
|
99
|
+
function scenarioTable(scenarios) {
|
|
100
|
+
return mdTable(['Scenario', 'Location', 'Tags', 'Status', 'Results', 'Duration'], scenarios.map((s) => [
|
|
101
|
+
s.name,
|
|
102
|
+
`${s.feature}:${s.line}`,
|
|
103
|
+
s.tags.join(' '),
|
|
104
|
+
s.status,
|
|
105
|
+
s.results.map(resultLabel).join('; '),
|
|
106
|
+
duration(s.results.reduce((n, r) => n + (r.durationMs ?? 0), 0) || undefined),
|
|
107
|
+
]));
|
|
108
|
+
}
|
|
109
|
+
// ── CSV ──────────────────────────────────────────────────────────────────────
|
|
110
|
+
const csvCell = (v) => {
|
|
111
|
+
const s = v == null ? '' : String(v);
|
|
112
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
113
|
+
};
|
|
114
|
+
const csvRow = (cells) => cells.map(csvCell).join(',');
|
|
115
|
+
/**
|
|
116
|
+
* One row per requirement × scenario × result, so a spreadsheet can filter and pivot it. The
|
|
117
|
+
* sign-off block follows after an empty line, with its values left blank.
|
|
118
|
+
*/
|
|
119
|
+
function renderCsv(report) {
|
|
120
|
+
const run = report.run;
|
|
121
|
+
const head = [
|
|
122
|
+
'requirement_id',
|
|
123
|
+
'requirement_title',
|
|
124
|
+
'requirement_url',
|
|
125
|
+
'declared',
|
|
126
|
+
'requirement_status',
|
|
127
|
+
'feature',
|
|
128
|
+
'line',
|
|
129
|
+
'scenario',
|
|
130
|
+
'tags',
|
|
131
|
+
'scenario_status',
|
|
132
|
+
'runner_project',
|
|
133
|
+
'browser',
|
|
134
|
+
'example',
|
|
135
|
+
'result_status',
|
|
136
|
+
'attempts',
|
|
137
|
+
'flaky',
|
|
138
|
+
'duration_ms',
|
|
139
|
+
'finished_at',
|
|
140
|
+
'run_id',
|
|
141
|
+
'env',
|
|
142
|
+
'git_sha',
|
|
143
|
+
'sdods_version',
|
|
144
|
+
];
|
|
145
|
+
const runCells = [run?.id, run?.env, run?.git?.sha, run?.sdodsVersion];
|
|
146
|
+
const rows = [csvRow(head)];
|
|
147
|
+
for (const r of report.requirements) {
|
|
148
|
+
const reqCells = [r.id, r.title, r.url, r.declared == null ? '' : r.declared, r.status];
|
|
149
|
+
if (!r.scenarios.length) {
|
|
150
|
+
rows.push(csvRow([...reqCells, '', '', '', '', '', '', '', '', '', '', '', '', '', ...runCells]));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
for (const s of r.scenarios) {
|
|
154
|
+
const scCells = [s.feature, s.line, s.name, s.tags.join(' '), s.status];
|
|
155
|
+
if (!s.results.length) {
|
|
156
|
+
rows.push(csvRow([...reqCells, ...scCells, '', '', '', '', '', '', '', '', ...runCells]));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
for (const res of s.results) {
|
|
160
|
+
rows.push(csvRow([
|
|
161
|
+
...reqCells,
|
|
162
|
+
...scCells,
|
|
163
|
+
res.runnerProject,
|
|
164
|
+
res.browser,
|
|
165
|
+
res.exampleIndex,
|
|
166
|
+
res.status,
|
|
167
|
+
res.attempts,
|
|
168
|
+
res.flaky,
|
|
169
|
+
res.durationMs,
|
|
170
|
+
res.finishedAt,
|
|
171
|
+
...runCells,
|
|
172
|
+
]));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
rows.push('', csvRow(['sign_off', 'value']));
|
|
177
|
+
for (const [key] of SIGN_OFF_FIELDS)
|
|
178
|
+
rows.push(csvRow([key, '']));
|
|
179
|
+
return `${rows.join('\n')}\n`;
|
|
180
|
+
}
|
|
181
|
+
// ── HTML ─────────────────────────────────────────────────────────────────────
|
|
182
|
+
const esc = (s) => String(s ?? '')
|
|
183
|
+
.replace(/&/g, '&')
|
|
184
|
+
.replace(/</g, '<')
|
|
185
|
+
.replace(/>/g, '>')
|
|
186
|
+
.replace(/"/g, '"')
|
|
187
|
+
.replace(/'/g, ''');
|
|
188
|
+
const safeHref = (url) => (/^https?:\/\//i.test(url) ? esc(url) : '');
|
|
189
|
+
const badge = (status) => `<span class="st st-${esc(status)}">${esc(status)}</span>`;
|
|
190
|
+
function renderHtml(report) {
|
|
191
|
+
const kv = (rows) => `<table class="kv">${rows.map(([k, v]) => `<tr><th>${esc(k)}</th><td>${esc(v)}</td></tr>`).join('')}</table>`;
|
|
192
|
+
const reqLink = (id, url) => url && safeHref(url) ? `<a href="${safeHref(url)}">${esc(id)}</a>` : esc(id);
|
|
193
|
+
const overview = report.requirements
|
|
194
|
+
.map((r) => `<tr><td>${reqLink(r.id, r.url)}</td><td>${esc(r.title)}${r.declared === false ? ' <em>(not in requirements file)</em>' : ''}</td><td>${badge(r.status)}</td><td>${r.scenarios.length}</td></tr>`)
|
|
195
|
+
.join('');
|
|
196
|
+
const details = report.requirements
|
|
197
|
+
.map((r) => {
|
|
198
|
+
const body = r.scenarios.length
|
|
199
|
+
? `<table><thead><tr><th>Scenario</th><th>Location</th><th>Tags</th><th>Status</th><th>Results</th></tr></thead><tbody>${r.scenarios
|
|
200
|
+
.map((s) => `<tr><td>${esc(s.name)}</td><td><code>${esc(`${s.feature}:${s.line}`)}</code></td><td>${esc(s.tags.join(' '))}</td><td>${badge(s.status)}</td><td>${s.results.length
|
|
201
|
+
? s.results
|
|
202
|
+
.map((x) => `${esc(resultLabel(x))}${x.durationMs != null ? ` <span class="dim">${esc(duration(x.durationMs))}</span>` : ''}`)
|
|
203
|
+
.join('<br>')
|
|
204
|
+
: '<span class="dim">no result</span>'}</td></tr>`)
|
|
205
|
+
.join('')}</tbody></table>`
|
|
206
|
+
: '<p class="dim">No scenario covers this requirement.</p>';
|
|
207
|
+
return `<section><h3>${reqLink(r.id, r.url)}${r.title ? `: ${esc(r.title)}` : ''} ${badge(r.status)}</h3>${body}</section>`;
|
|
208
|
+
})
|
|
209
|
+
.join('');
|
|
210
|
+
const untraced = report.untraced.length
|
|
211
|
+
? `<h2>Scenarios without a requirement</h2><table><thead><tr><th>Scenario</th><th>Location</th><th>Status</th></tr></thead><tbody>${report.untraced
|
|
212
|
+
.map((s) => `<tr><td>${esc(s.name)}</td><td><code>${esc(`${s.feature}:${s.line}`)}</code></td><td>${badge(s.status)}</td></tr>`)
|
|
213
|
+
.join('')}</tbody></table>`
|
|
214
|
+
: '';
|
|
215
|
+
const signOff = SIGN_OFF_FIELDS.map(([, label]) => `<tr><th>${esc(label)}</th><td class="blank"></td></tr>`).join('');
|
|
216
|
+
return `<!doctype html>
|
|
217
|
+
<html lang="en">
|
|
218
|
+
<head>
|
|
219
|
+
<meta charset="utf-8">
|
|
220
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
221
|
+
<title>Traceability: ${esc(report.projectName)}</title>
|
|
222
|
+
<style>
|
|
223
|
+
body{font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;color:#1a1a1a;background:#fff;margin:0;padding:24px 16px;max-width:1100px;margin-inline:auto}
|
|
224
|
+
h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:28px 0 8px}h3{font-size:15px;margin:20px 0 6px}
|
|
225
|
+
table{border-collapse:collapse;width:100%;margin:6px 0}th,td{border:1px solid #d9d9d9;padding:5px 8px;text-align:left;vertical-align:top}
|
|
226
|
+
thead th{background:#f4f4f4}table.kv{width:auto}table.kv th{background:#f8f8f8;font-weight:600}
|
|
227
|
+
code{font-size:12px}.dim{color:#6b6b6b}.wrap{overflow-x:auto}
|
|
228
|
+
.st{display:inline-block;padding:0 6px;border-radius:3px;font-size:12px;font-weight:600;border:1px solid}
|
|
229
|
+
.st-passed{color:#11633a;border-color:#11633a}.st-failed{color:#a1161c;border-color:#a1161c}
|
|
230
|
+
.st-not-run,.st-skipped{color:#6b5300;border-color:#6b5300}.st-not-covered{color:#5a2d91;border-color:#5a2d91}
|
|
231
|
+
td.blank{height:28px;min-width:320px}
|
|
232
|
+
@media print{body{padding:0}section{break-inside:avoid}}
|
|
233
|
+
</style>
|
|
234
|
+
</head>
|
|
235
|
+
<body>
|
|
236
|
+
<h1>Traceability: ${esc(report.projectName)} <span class="dim">(${esc(report.project)})</span></h1>
|
|
237
|
+
<p class="dim">Generated ${esc(report.generatedAt)}</p>
|
|
238
|
+
${kv(runLines(report))}
|
|
239
|
+
<h2>Summary</h2>
|
|
240
|
+
${kv(summaryLines(report))}
|
|
241
|
+
<h2>Requirements</h2>
|
|
242
|
+
<div class="wrap"><table><thead><tr><th>Requirement</th><th>Title</th><th>Status</th><th>Scenarios</th></tr></thead><tbody>${overview}</tbody></table></div>
|
|
243
|
+
<div class="wrap">${details}</div>
|
|
244
|
+
${untraced}
|
|
245
|
+
<h2>Notes</h2>
|
|
246
|
+
<ul>${report.notes.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>
|
|
247
|
+
<h2>Sign-off</h2>
|
|
248
|
+
<p>To be completed by a person. SDODS does not fill in this section.</p>
|
|
249
|
+
<table class="kv">${signOff}</table>
|
|
250
|
+
</body>
|
|
251
|
+
</html>
|
|
252
|
+
`;
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=traceability-render.js.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type ProjectConfig, type RunManifest, type SuiteStatus, type TraceabilityReport, type TraceRunInfo } from '@sdods/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* Requirement traceability: `@req:<id>` tags on scenarios → the scenarios' results in one run.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately read-only over files that already exist (feature files, the run directory). The
|
|
6
|
+
* requirement id is opaque — SDODS does not know or decide what it points at — and the sign-off
|
|
7
|
+
* block of the export is left empty for a person to fill in.
|
|
8
|
+
*/
|
|
9
|
+
export declare const REQ_TAG = "req";
|
|
10
|
+
export interface RequirementDef {
|
|
11
|
+
id: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
}
|
|
14
|
+
type Project = ProjectConfig & {
|
|
15
|
+
root: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Read a requirements file. YAML/JSON accepts a list (`- id: REQ-1` / `title:` or plain strings),
|
|
19
|
+
* a map (`REQ-1: Title`), or either under a top-level `requirements:` key. CSV needs an `id`
|
|
20
|
+
* column and may have a `title` column.
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadRequirements(file: string): RequirementDef[];
|
|
23
|
+
/** The requirements declared by the project, or null when it configures no requirements file. */
|
|
24
|
+
export declare function projectRequirements(project: Project): RequirementDef[] | null;
|
|
25
|
+
export declare function requirementUrl(project: ProjectConfig, id: string): string | undefined;
|
|
26
|
+
interface StaticScenario {
|
|
27
|
+
feature: string;
|
|
28
|
+
featureName: string;
|
|
29
|
+
line: number;
|
|
30
|
+
name: string;
|
|
31
|
+
tags: string[];
|
|
32
|
+
requirements: string[];
|
|
33
|
+
}
|
|
34
|
+
/** Every scenario of the project with its effective tags (Feature and Rule tags inherited). */
|
|
35
|
+
export declare function traceScenarios(project: Project, files?: string[]): StaticScenario[];
|
|
36
|
+
/** One attempt of one scenario in one runner project, as recorded in the run directory. */
|
|
37
|
+
export interface RunAttempt {
|
|
38
|
+
runnerProject: string;
|
|
39
|
+
/** feature path as the run recorded it (repo- or project-relative) */
|
|
40
|
+
uri: string;
|
|
41
|
+
/** line of the Scenario / Scenario Outline keyword, when known */
|
|
42
|
+
line?: number;
|
|
43
|
+
name: string;
|
|
44
|
+
exampleIndex: number | null;
|
|
45
|
+
attempt: number;
|
|
46
|
+
status: SuiteStatus;
|
|
47
|
+
durationMs?: number;
|
|
48
|
+
startedAt?: string;
|
|
49
|
+
finishedAt?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
export interface RunResults {
|
|
53
|
+
source: TraceRunInfo['resultsSource'];
|
|
54
|
+
attempts: RunAttempt[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Read the scenario results of a run directory without a database.
|
|
58
|
+
*
|
|
59
|
+
* Cucumber messages (`messages.ndjson`, or its shard files) are the authoritative record: they hold
|
|
60
|
+
* every attempt in every runner project. The per-scenario `meta.json` files are the fallback — their
|
|
61
|
+
* directory is keyed without the browser, so a multi-browser run keeps only one of them per scenario.
|
|
62
|
+
*/
|
|
63
|
+
export declare function readRunResults(runDir: string): Promise<RunResults>;
|
|
64
|
+
export declare function readRunManifest(runDir: string): RunManifest | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* The most recent run of `project` (and `env`, when given) under the artifacts root. Runs without a
|
|
67
|
+
* `run.json` are skipped: without it there is no way to tell which project or environment they ran.
|
|
68
|
+
*/
|
|
69
|
+
export declare function latestRunFor(artifactsRoot: string, project: string, env?: string): {
|
|
70
|
+
runId: string;
|
|
71
|
+
dir: string;
|
|
72
|
+
} | null;
|
|
73
|
+
export interface TraceabilityOptions {
|
|
74
|
+
project: Project;
|
|
75
|
+
/** run directory to read results from; omit for a static requirement → scenario matrix */
|
|
76
|
+
run?: {
|
|
77
|
+
runId: string;
|
|
78
|
+
dir: string;
|
|
79
|
+
};
|
|
80
|
+
/** feature files to consider (default: every feature of the project) */
|
|
81
|
+
files?: string[];
|
|
82
|
+
/** clock for `generatedAt` (tests pass a fixed date) */
|
|
83
|
+
now?: Date;
|
|
84
|
+
}
|
|
85
|
+
export declare function buildTraceabilityReport(opts: TraceabilityOptions): Promise<TraceabilityReport>;
|
|
86
|
+
export {};
|
|
87
|
+
//# sourceMappingURL=traceability.d.ts.map
|