@sun-asterisk/sungen 3.2.15 → 3.2.16-beta.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/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +138 -1
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/dashboard/templates/index.html +1 -1
- package/dist/exporters/feature-parser.js +1 -1
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts +36 -0
- package/dist/exporters/matrix/build.d.ts.map +1 -0
- package/dist/exporters/matrix/build.js +267 -0
- package/dist/exporters/matrix/build.js.map +1 -0
- package/dist/exporters/matrix/export.d.ts +47 -0
- package/dist/exporters/matrix/export.d.ts.map +1 -0
- package/dist/exporters/matrix/export.js +136 -0
- package/dist/exporters/matrix/export.js.map +1 -0
- package/dist/exporters/matrix/fingerprint.d.ts +20 -0
- package/dist/exporters/matrix/fingerprint.d.ts.map +1 -0
- package/dist/exporters/matrix/fingerprint.js +83 -0
- package/dist/exporters/matrix/fingerprint.js.map +1 -0
- package/dist/exporters/matrix/gates.d.ts +21 -0
- package/dist/exporters/matrix/gates.d.ts.map +1 -0
- package/dist/exporters/matrix/gates.js +258 -0
- package/dist/exporters/matrix/gates.js.map +1 -0
- package/dist/exporters/matrix/map-loader.d.ts +22 -0
- package/dist/exporters/matrix/map-loader.d.ts.map +1 -0
- package/dist/exporters/matrix/map-loader.js +173 -0
- package/dist/exporters/matrix/map-loader.js.map +1 -0
- package/dist/exporters/matrix/render-csv.d.ts +8 -0
- package/dist/exporters/matrix/render-csv.d.ts.map +1 -0
- package/dist/exporters/matrix/render-csv.js +69 -0
- package/dist/exporters/matrix/render-csv.js.map +1 -0
- package/dist/exporters/matrix/render-xlsx.d.ts +17 -0
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -0
- package/dist/exporters/matrix/render-xlsx.js +298 -0
- package/dist/exporters/matrix/render-xlsx.js.map +1 -0
- package/dist/exporters/matrix/types.d.ts +153 -0
- package/dist/exporters/matrix/types.d.ts.map +1 -0
- package/dist/exporters/matrix/types.js +15 -0
- package/dist/exporters/matrix/types.js.map +1 -0
- package/dist/exporters/scenario-merger.js +1 -1
- package/dist/exporters/scenario-merger.js.map +1 -1
- package/dist/exporters/spec-parser.js +1 -1
- package/dist/exporters/spec-parser.js.map +1 -1
- package/dist/exporters/xlsx-report-builder.d.ts +1 -1
- package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
- package/dist/exporters/xlsx-report-builder.js +3 -3
- package/dist/exporters/xlsx-report-builder.js.map +1 -1
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +6 -1
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/capability.d.ts +25 -0
- package/dist/harness/capability.d.ts.map +1 -1
- package/dist/harness/capability.js +44 -2
- package/dist/harness/capability.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +1 -1
- package/dist/orchestrator/templates/ai-src/commands/delivery.md +118 -120
- package/dist/orchestrator/templates/ai-src/config/claude.md +1 -1
- package/dist/orchestrator/templates/ai-src/config/copilot.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +36 -6
- package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +1 -0
- package/package.json +4 -4
- package/src/cli/commands/delivery.ts +168 -2
- package/src/dashboard/templates/index.html +1 -1
- package/src/exporters/feature-parser.ts +1 -1
- package/src/exporters/matrix/build.ts +294 -0
- package/src/exporters/matrix/export.ts +124 -0
- package/src/exporters/matrix/fingerprint.ts +49 -0
- package/src/exporters/matrix/gates.ts +284 -0
- package/src/exporters/matrix/map-loader.ts +142 -0
- package/src/exporters/matrix/render-csv.ts +71 -0
- package/src/exporters/matrix/render-xlsx.ts +289 -0
- package/src/exporters/matrix/types.ts +176 -0
- package/src/exporters/scenario-merger.ts +1 -1
- package/src/exporters/spec-parser.ts +1 -1
- package/src/exporters/xlsx-report-builder.ts +3 -2
- package/src/harness/audit.ts +7 -2
- package/src/harness/capability.ts +53 -3
- package/src/orchestrator/templates/ai-src/commands/create-test.md +1 -1
- package/src/orchestrator/templates/ai-src/commands/delivery.md +118 -120
- package/src/orchestrator/templates/ai-src/config/claude.md +1 -1
- package/src/orchestrator/templates/ai-src/config/copilot.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +36 -6
- package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +1 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render the Test Case & Coverage Matrix workbook.
|
|
3
|
+
*
|
|
4
|
+
* Sheets:
|
|
5
|
+
* 1. Testcases — one parent row per delivery item, variant sub-rows grouped with
|
|
6
|
+
* Excel outline (expanded for execution; the level-1 outline button collapses
|
|
7
|
+
* to the customer/review view — same sheet, one model, two views).
|
|
8
|
+
* 2. Coverage — target × category grid (items + variants, `—` marks the gaps),
|
|
9
|
+
* dispositions, and the generation manifest.
|
|
10
|
+
*
|
|
11
|
+
* Every sheet keeps the company ISO masthead (renderReportHeaderBand). A draft
|
|
12
|
+
* matrix (unapproved groups / review findings) carries a red DRAFT banner.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as ExcelJS from 'exceljs';
|
|
16
|
+
import {
|
|
17
|
+
renderReportHeaderBand,
|
|
18
|
+
allBordersBlack,
|
|
19
|
+
ARIAL_FONT,
|
|
20
|
+
BLACK,
|
|
21
|
+
LAVENDER,
|
|
22
|
+
} from '../xlsx-report-builder';
|
|
23
|
+
import { statusToTestResult, formatExecutedDate } from '../playwright-report-parser';
|
|
24
|
+
import { CoverageVariant, DeliveryItem, ItemResult, MatrixModel, MapCategory } from './types';
|
|
25
|
+
|
|
26
|
+
const CATEGORY_ORDER: MapCategory[] = ['normal', 'abnormal', 'security', 'nfr'];
|
|
27
|
+
const SOFT_BLUE = { argb: 'FFDCE6F1' };
|
|
28
|
+
const DRAFT_RED = { argb: 'FFC00000' };
|
|
29
|
+
const GAP_FILL = { argb: 'FFFCE4EC' };
|
|
30
|
+
|
|
31
|
+
function headerCell(c: ExcelJS.Cell, label: string): void {
|
|
32
|
+
c.value = label;
|
|
33
|
+
c.font = { bold: true, size: 10, color: BLACK, name: ARIAL_FONT };
|
|
34
|
+
c.fill = { type: 'pattern', pattern: 'solid', fgColor: LAVENDER };
|
|
35
|
+
c.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
|
36
|
+
c.border = allBordersBlack;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function dataCell(c: ExcelJS.Cell, value: ExcelJS.CellValue, opts?: { bold?: boolean; fill?: { argb: string }; center?: boolean; color?: { argb: string } }): void {
|
|
40
|
+
c.value = value;
|
|
41
|
+
c.font = { bold: opts?.bold ?? false, size: 10, name: ARIAL_FONT, ...(opts?.color ? { color: opts.color } : {}) };
|
|
42
|
+
c.alignment = { horizontal: opts?.center ? 'center' : 'left', vertical: 'top', wrapText: true };
|
|
43
|
+
c.border = allBordersBlack;
|
|
44
|
+
if (opts?.fill) c.fill = { type: 'pattern', pattern: 'solid', fgColor: opts.fill };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function itemResultLabel(item: DeliveryItem): string {
|
|
48
|
+
const { passed, failed, blocked, notRun } = item.resultCounts;
|
|
49
|
+
const total = item.variants.length;
|
|
50
|
+
const map: Record<ItemResult, string> = {
|
|
51
|
+
passed: `${passed}/${total} Passed`,
|
|
52
|
+
failed: `${passed}/${total} Passed · ${failed} Failed`,
|
|
53
|
+
blocked: `${blocked} Blocked`,
|
|
54
|
+
partial: `${passed}/${total} Passed · ${notRun} not run`,
|
|
55
|
+
not_run: 'Pending',
|
|
56
|
+
};
|
|
57
|
+
return map[item.result];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function variantResultLabel(v: CoverageVariant): string {
|
|
61
|
+
if (!v.result) return 'Pending';
|
|
62
|
+
return statusToTestResult(v.result.status);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function numbered(lines: string[]): string {
|
|
66
|
+
if (lines.length === 0) return '';
|
|
67
|
+
if (lines.length === 1) return lines[0];
|
|
68
|
+
return lines.map((l, i) => `${i + 1}. ${l}`).join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Red DRAFT banner on row 5 when the matrix is not approved for official delivery. */
|
|
72
|
+
function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
|
|
73
|
+
if (model.manifest.approvalState !== 'draft') return;
|
|
74
|
+
ws.mergeCells('B5:H5');
|
|
75
|
+
const c = ws.getCell('B5');
|
|
76
|
+
c.value = 'DRAFT — NOT APPROVED FOR OFFICIAL DELIVERY (review-required findings or proposed groups remain)';
|
|
77
|
+
c.font = { bold: true, size: 11, name: ARIAL_FONT, color: { argb: 'FFFFFFFF' } };
|
|
78
|
+
c.fill = { type: 'pattern', pattern: 'solid', fgColor: DRAFT_RED };
|
|
79
|
+
c.alignment = { horizontal: 'center', vertical: 'middle' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Sheet 1 — Testcases (the matrix)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
const MATRIX_HEADERS = [
|
|
87
|
+
'ID', 'Target', 'Test Intent / Condition', 'Category', 'Priority', 'Precondition',
|
|
88
|
+
'Coverage / Test Data', 'Action / Trigger', 'Expected (Oracle)', 'Mode', 'Trace',
|
|
89
|
+
'Result', 'Executed Date', 'Executor', 'Note\n(Evidence, DefectID)',
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
function addMatrixSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?: string): void {
|
|
93
|
+
const ws = wb.addWorksheet('Testcases');
|
|
94
|
+
ws.properties.outlineLevelRow = 1;
|
|
95
|
+
|
|
96
|
+
ws.columns = [
|
|
97
|
+
{ width: 3 }, // A margin
|
|
98
|
+
{ width: 20 }, // B ID
|
|
99
|
+
{ width: 18 }, // C Target
|
|
100
|
+
{ width: 42 }, // D Intent / Condition
|
|
101
|
+
{ width: 11 }, // E Category
|
|
102
|
+
{ width: 9 }, // F Priority
|
|
103
|
+
{ width: 26 }, // G Precondition
|
|
104
|
+
{ width: 34 }, // H Coverage / Data
|
|
105
|
+
{ width: 34 }, // I Trigger
|
|
106
|
+
{ width: 42 }, // J Expected
|
|
107
|
+
{ width: 11 }, // K Mode
|
|
108
|
+
{ width: 13 }, // L Trace
|
|
109
|
+
{ width: 17 }, // M Result
|
|
110
|
+
{ width: 13 }, // N Executed
|
|
111
|
+
{ width: 13 }, // O Executor
|
|
112
|
+
{ width: 24 }, // P Note
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
renderReportHeaderBand(wb, ws, `${model.unit.toUpperCase()} TEST CASE & COVERAGE MATRIX`, sungenVersion, model.formNo);
|
|
116
|
+
draftBanner(ws, model);
|
|
117
|
+
|
|
118
|
+
// Summary band (row 6) — items vs variants stay separate metrics by design (I8):
|
|
119
|
+
// the ITEM count is reviewer workload, the VARIANT count is execution workload.
|
|
120
|
+
const passed = model.items.reduce((a, i) => a + i.resultCounts.passed, 0);
|
|
121
|
+
const failed = model.items.reduce((a, i) => a + i.resultCounts.failed, 0);
|
|
122
|
+
const notRun = model.items.reduce((a, i) => a + i.resultCounts.notRun + i.resultCounts.blocked, 0);
|
|
123
|
+
const band = ws.getRow(6);
|
|
124
|
+
[
|
|
125
|
+
`Delivery items: ${model.manifest.itemCount}`,
|
|
126
|
+
`Coverage variants: ${model.manifest.variantCount}`,
|
|
127
|
+
`Passed: ${passed}`, `Failed: ${failed}`, `Pending: ${notRun}`,
|
|
128
|
+
].forEach((label, i) => {
|
|
129
|
+
const c = band.getCell(2 + i);
|
|
130
|
+
dataCell(c, label, { bold: true, fill: SOFT_BLUE, center: true });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const HEADER_ROW = 8;
|
|
134
|
+
const hr = ws.getRow(HEADER_ROW);
|
|
135
|
+
hr.height = 28;
|
|
136
|
+
MATRIX_HEADERS.forEach((label, i) => headerCell(hr.getCell(2 + i), label));
|
|
137
|
+
|
|
138
|
+
let rowIdx = HEADER_ROW + 1;
|
|
139
|
+
for (const item of model.items) {
|
|
140
|
+
// Parent row — the delivery item (the review unit).
|
|
141
|
+
const pr = ws.getRow(rowIdx++);
|
|
142
|
+
const single = item.variants.length === 1;
|
|
143
|
+
const only = single ? item.variants[0] : undefined;
|
|
144
|
+
const coverage = single
|
|
145
|
+
? numbered(only!.data)
|
|
146
|
+
: `${item.variants.length} variants: ${item.variants.map((v) => v.condition).join(' · ')}`;
|
|
147
|
+
const values: ExcelJS.CellValue[] = [
|
|
148
|
+
item.id,
|
|
149
|
+
item.target,
|
|
150
|
+
item.intent,
|
|
151
|
+
item.category,
|
|
152
|
+
item.priority,
|
|
153
|
+
numbered(item.precondition),
|
|
154
|
+
coverage,
|
|
155
|
+
numbered(item.trigger),
|
|
156
|
+
item.oracle,
|
|
157
|
+
item.mode === 'manual' ? `Manual${only?.manualReason ? ` (${only.manualReason})` : ''}` : 'Auto',
|
|
158
|
+
item.traces.join(', '),
|
|
159
|
+
itemResultLabel(item),
|
|
160
|
+
single ? formatExecutedDate(only!.result?.startTime) : '',
|
|
161
|
+
'',
|
|
162
|
+
item.review === 'proposed' ? 'REVIEW REQUIRED' : '',
|
|
163
|
+
];
|
|
164
|
+
values.forEach((v, i) => dataCell(pr.getCell(2 + i), v, {
|
|
165
|
+
bold: true,
|
|
166
|
+
fill: SOFT_BLUE,
|
|
167
|
+
center: i === 3 || i === 4 || i === 9,
|
|
168
|
+
...(String(values[14]).startsWith('REVIEW') && i === 14 ? { color: DRAFT_RED } : {}),
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
// Variant sub-rows — the execution units (outline level 1; collapse for review).
|
|
172
|
+
if (!single) {
|
|
173
|
+
for (const v of item.variants) {
|
|
174
|
+
const vr = ws.getRow(rowIdx++);
|
|
175
|
+
vr.outlineLevel = 1;
|
|
176
|
+
const vValues: ExcelJS.CellValue[] = [
|
|
177
|
+
v.ref,
|
|
178
|
+
'',
|
|
179
|
+
v.condition,
|
|
180
|
+
'', '', '',
|
|
181
|
+
numbered(v.data),
|
|
182
|
+
numbered(v.trigger),
|
|
183
|
+
numbered(v.oracle),
|
|
184
|
+
v.mode === 'manual' ? `Manual${v.manualReason ? ` (${v.manualReason})` : ''}` : 'Auto',
|
|
185
|
+
v.traces.join(', '),
|
|
186
|
+
variantResultLabel(v),
|
|
187
|
+
formatExecutedDate(v.result?.startTime),
|
|
188
|
+
'',
|
|
189
|
+
v.result?.error ? String(v.result.error).slice(0, 200) : '',
|
|
190
|
+
];
|
|
191
|
+
vValues.forEach((val, i) => dataCell(vr.getCell(2 + i), val, { center: i === 9 }));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ws.autoFilter = { from: { row: HEADER_ROW, column: 2 }, to: { row: rowIdx - 1, column: 16 } };
|
|
197
|
+
ws.views = [{ state: 'frozen', ySplit: HEADER_ROW }];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Sheet 2 — Coverage (overview grid + dispositions + manifest)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
function addCoverageSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersion?: string): void {
|
|
205
|
+
const ws = wb.addWorksheet('Coverage');
|
|
206
|
+
ws.columns = [
|
|
207
|
+
{ width: 3 }, { width: 30 }, { width: 16 }, { width: 16 }, { width: 16 }, { width: 16 },
|
|
208
|
+
{ width: 16 }, { width: 40 },
|
|
209
|
+
];
|
|
210
|
+
renderReportHeaderBand(wb, ws, `${model.unit.toUpperCase()} COVERAGE OVERVIEW`, sungenVersion, model.formNo);
|
|
211
|
+
draftBanner(ws, model);
|
|
212
|
+
|
|
213
|
+
// --- Target × category grid ---
|
|
214
|
+
let rowIdx = 7;
|
|
215
|
+
dataCell(ws.getRow(rowIdx).getCell(2), 'Coverage matrix — delivery items (coverage variants) per target × category', { bold: true });
|
|
216
|
+
rowIdx += 1;
|
|
217
|
+
const gridHeader = ws.getRow(rowIdx++);
|
|
218
|
+
headerCell(gridHeader.getCell(2), 'Target');
|
|
219
|
+
CATEGORY_ORDER.forEach((cat, i) => headerCell(gridHeader.getCell(3 + i), cat));
|
|
220
|
+
headerCell(gridHeader.getCell(3 + CATEGORY_ORDER.length), 'Total');
|
|
221
|
+
|
|
222
|
+
const targets = Array.from(new Set(model.items.map((i) => i.target)));
|
|
223
|
+
for (const target of targets) {
|
|
224
|
+
const row = ws.getRow(rowIdx++);
|
|
225
|
+
dataCell(row.getCell(2), target, { bold: true });
|
|
226
|
+
let totalItems = 0;
|
|
227
|
+
let totalVariants = 0;
|
|
228
|
+
CATEGORY_ORDER.forEach((cat, i) => {
|
|
229
|
+
const cell = row.getCell(3 + i);
|
|
230
|
+
const items = model.items.filter((it) => it.target === target && it.category === cat);
|
|
231
|
+
const variants = items.reduce((a, it) => a + it.variants.length, 0);
|
|
232
|
+
totalItems += items.length;
|
|
233
|
+
totalVariants += variants;
|
|
234
|
+
// `—` is an explicit statement: this target × category cell has NO coverage.
|
|
235
|
+
dataCell(cell, items.length === 0 ? '—' : `${items.length} (${variants})`, {
|
|
236
|
+
center: true,
|
|
237
|
+
...(items.length === 0 ? { fill: GAP_FILL } : {}),
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
dataCell(row.getCell(3 + CATEGORY_ORDER.length), `${totalItems} (${totalVariants})`, { center: true, bold: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Dispositions ---
|
|
244
|
+
if (model.dispositions.length > 0) {
|
|
245
|
+
rowIdx += 1;
|
|
246
|
+
dataCell(ws.getRow(rowIdx).getCell(2), 'Not in the matrix (explicit dispositions)', { bold: true });
|
|
247
|
+
rowIdx += 1;
|
|
248
|
+
const dh = ws.getRow(rowIdx++);
|
|
249
|
+
['Source', 'Disposition', 'Reason', 'Title'].forEach((l, i) => headerCell(dh.getCell(2 + i), l));
|
|
250
|
+
for (const d of model.dispositions) {
|
|
251
|
+
const row = ws.getRow(rowIdx++);
|
|
252
|
+
dataCell(row.getCell(2), d.vpId);
|
|
253
|
+
dataCell(row.getCell(3), d.as, { center: true });
|
|
254
|
+
dataCell(row.getCell(4), d.reason);
|
|
255
|
+
dataCell(row.getCell(5), d.title);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- Manifest ---
|
|
260
|
+
rowIdx += 1;
|
|
261
|
+
dataCell(ws.getRow(rowIdx).getCell(2), 'Generation manifest', { bold: true });
|
|
262
|
+
rowIdx += 1;
|
|
263
|
+
const m = model.manifest;
|
|
264
|
+
const manifestRows: Array<[string, string]> = [
|
|
265
|
+
['Unit', m.unit],
|
|
266
|
+
['Approval state', m.approvalState],
|
|
267
|
+
['Delivery items', String(m.itemCount)],
|
|
268
|
+
['Coverage variants', String(m.variantCount)],
|
|
269
|
+
['Source fingerprint', m.sourceFingerprint],
|
|
270
|
+
['Map fingerprint', m.mapFingerprint],
|
|
271
|
+
['Transformer version', m.transformerVersion],
|
|
272
|
+
['Max variants / item (warn)', String(m.thresholds.maxVariantsPerItem)],
|
|
273
|
+
];
|
|
274
|
+
for (const [k, v] of manifestRows) {
|
|
275
|
+
const row = ws.getRow(rowIdx++);
|
|
276
|
+
dataCell(row.getCell(2), k, { bold: true, fill: SOFT_BLUE });
|
|
277
|
+
ws.mergeCells(rowIdx - 1, 3, rowIdx - 1, 7);
|
|
278
|
+
dataCell(row.getCell(3), v);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
export function renderMatrixXlsx(model: MatrixModel, sungenVersion?: string): ExcelJS.Workbook {
|
|
285
|
+
const wb = new ExcelJS.Workbook();
|
|
286
|
+
addMatrixSheet(wb, model, sungenVersion);
|
|
287
|
+
addCoverageSheet(wb, model, sungenVersion);
|
|
288
|
+
return wb;
|
|
289
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery v2 — Test Case & Coverage Matrix model (docs/spec/delivery-coverage-matrix-spec.md).
|
|
3
|
+
*
|
|
4
|
+
* Gherkin stays the source of test meaning; the matrix is a derived presentation:
|
|
5
|
+
* one delivery item = one target + one intent + one oracle family, containing one or
|
|
6
|
+
* more atomic coverage variants (scenario / @cases row). Grouping semantics live in the
|
|
7
|
+
* committed Delivery Map (`qa/<kind>/<unit>/delivery/<unit>.map.yaml`) — AI-proposed,
|
|
8
|
+
* gate-validated, QA-approved.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { PlaywrightResult } from '../types';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Delivery Map (the committed grouping artifact)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export type MapCategory = 'normal' | 'abnormal' | 'security' | 'nfr';
|
|
18
|
+
export type ReviewState = 'proposed' | 'approved';
|
|
19
|
+
export type DispositionKind = 'excluded' | 'blocked' | 'covered_elsewhere' | 'accepted_risk';
|
|
20
|
+
|
|
21
|
+
export interface MapGroup {
|
|
22
|
+
/** Stable delivery id, unique per unit, order-independent (e.g. DI-EMAIL-FORMAT). */
|
|
23
|
+
id: string;
|
|
24
|
+
/** ONE primary target: field/component dot-path, function/flow phrase, or `METHOD /path`. */
|
|
25
|
+
target: string;
|
|
26
|
+
/** One coherent test intent (the primary behavior/rule verified). */
|
|
27
|
+
intent: string;
|
|
28
|
+
/** Oracle family in words — the shared Pass/Fail observation. */
|
|
29
|
+
oracle: string;
|
|
30
|
+
category: MapCategory;
|
|
31
|
+
review: ReviewState;
|
|
32
|
+
/** Variant refs: `VP-ID` (all @cases rows when the scenario has a dataset) or `VP-ID#label`. */
|
|
33
|
+
variants: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MapDisposition {
|
|
37
|
+
as: DispositionKind;
|
|
38
|
+
reason?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DeliveryMap {
|
|
42
|
+
version: number;
|
|
43
|
+
unit: string;
|
|
44
|
+
/** ISO form number rendered in the workbook masthead (default BM-2-901-13). */
|
|
45
|
+
formNo?: string;
|
|
46
|
+
groups: MapGroup[];
|
|
47
|
+
/** VP-id → intentionally-not-grouped disposition. */
|
|
48
|
+
dispositions: Record<string, MapDisposition>;
|
|
49
|
+
/** VP-id → scenario fingerprint stamped at approval time (drift detector input). */
|
|
50
|
+
fingerprints: Record<string, string>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Derived model (deterministic, computed from the .feature at build time)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/** Execution layers a variant verifies on — cross-checked with the verification scope. */
|
|
58
|
+
export type MatrixLayer = 'ui' | 'api' | 'db';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One atomic coverage variant — the unit of execution, result, and traceability.
|
|
62
|
+
* Backed by one scenario, or one `@cases` dataset row of a scenario.
|
|
63
|
+
*/
|
|
64
|
+
export interface CoverageVariant {
|
|
65
|
+
/** `VP-ID` or `VP-ID#label` for a dataset row. */
|
|
66
|
+
ref: string;
|
|
67
|
+
vpId: string;
|
|
68
|
+
/** Dataset-row label when this variant is one `@cases` row. */
|
|
69
|
+
caseLabel?: string;
|
|
70
|
+
/** Human title (scenario name without the VP prefix; row label appended for @cases). */
|
|
71
|
+
title: string;
|
|
72
|
+
/** Viewpoint category from the VP prefix (LOGIC/VAL/UI/SEC/…). */
|
|
73
|
+
vpCategory: string;
|
|
74
|
+
priority: string; // High | Normal | Low
|
|
75
|
+
/** 'auto' | 'manual'; manual keeps its @manual:Mx reason. */
|
|
76
|
+
mode: 'auto' | 'manual';
|
|
77
|
+
manualReason?: string;
|
|
78
|
+
layers: MatrixLayer[];
|
|
79
|
+
/** @spec:* requirement traces. */
|
|
80
|
+
traces: string[];
|
|
81
|
+
/** Condition summary: differentiating dimension shown on the sub-row. */
|
|
82
|
+
condition: string;
|
|
83
|
+
/** Resolved data pairs (test-data values / dataset-row columns), `key: value` lines. */
|
|
84
|
+
data: string[];
|
|
85
|
+
/** Normalized trigger shape (When steps, `{{var}}` collapsed) — signature input. */
|
|
86
|
+
triggerShape: string[];
|
|
87
|
+
/** Trigger for display (When steps, vars resolved). */
|
|
88
|
+
trigger: string[];
|
|
89
|
+
/** Normalized oracle shape (Then steps / manual Observable+Oracle) — signature input. */
|
|
90
|
+
oracleShape: string[];
|
|
91
|
+
/** Oracle for display. */
|
|
92
|
+
oracle: string[];
|
|
93
|
+
/** Precondition profile: auth role + scenario-level Given shapes — signature input. */
|
|
94
|
+
preconditionProfile: string;
|
|
95
|
+
/** Human preconditions (auth + own Given steps). */
|
|
96
|
+
precondition: string[];
|
|
97
|
+
/** 'simple' (G*W*T*) | 'sequence' (mid-flow Given / multi round-trip) | 'manual'. */
|
|
98
|
+
procedureProfile: 'simple' | 'sequence' | 'manual';
|
|
99
|
+
/** Canonical scenario fingerprint (drift detection). */
|
|
100
|
+
fingerprint: string;
|
|
101
|
+
/** Execution result, when the run report has one for this variant. */
|
|
102
|
+
result?: PlaywrightResult;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type ItemResult = 'passed' | 'failed' | 'blocked' | 'partial' | 'not_run';
|
|
106
|
+
|
|
107
|
+
/** One summarized row in the matrix — a group of variants sharing the signature. */
|
|
108
|
+
export interface DeliveryItem {
|
|
109
|
+
id: string;
|
|
110
|
+
target: string;
|
|
111
|
+
intent: string;
|
|
112
|
+
oracle: string;
|
|
113
|
+
category: MapCategory;
|
|
114
|
+
review: ReviewState;
|
|
115
|
+
priority: string;
|
|
116
|
+
mode: 'auto' | 'manual';
|
|
117
|
+
layers: MatrixLayer[];
|
|
118
|
+
/** Union of variant traces (exact per-variant traces stay on the variants). */
|
|
119
|
+
traces: string[];
|
|
120
|
+
precondition: string[];
|
|
121
|
+
/** Common trigger when all variants share one shape, else '(per variant)'. */
|
|
122
|
+
trigger: string[];
|
|
123
|
+
variants: CoverageVariant[];
|
|
124
|
+
/** Derived roll-up — never entered independently (Gate F). */
|
|
125
|
+
result: ItemResult;
|
|
126
|
+
resultCounts: { passed: number; failed: number; blocked: number; notRun: number };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface MatrixDisposition {
|
|
130
|
+
vpId: string;
|
|
131
|
+
title: string;
|
|
132
|
+
as: DispositionKind;
|
|
133
|
+
reason: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Gate findings
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export type GateId = 'A' | 'B' | 'C' | 'D' | 'E' | 'G';
|
|
141
|
+
export type FindingSeverity = 'error' | 'review' | 'warning';
|
|
142
|
+
|
|
143
|
+
export interface MatrixFinding {
|
|
144
|
+
gate: GateId;
|
|
145
|
+
severity: FindingSeverity;
|
|
146
|
+
/** Delivery item / VP-id the finding anchors to, when applicable. */
|
|
147
|
+
ref?: string;
|
|
148
|
+
message: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// The assembled matrix
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
export interface MatrixManifest {
|
|
156
|
+
unit: string;
|
|
157
|
+
transformerVersion: string;
|
|
158
|
+
sourceFingerprint: string; // canonical hash over all scenario fingerprints
|
|
159
|
+
mapFingerprint: string; // canonical hash of the map content
|
|
160
|
+
approvalState: 'approved' | 'draft';
|
|
161
|
+
itemCount: number;
|
|
162
|
+
variantCount: number;
|
|
163
|
+
thresholds: { maxVariantsPerItem: number };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface MatrixModel {
|
|
167
|
+
unit: string;
|
|
168
|
+
formNo: string;
|
|
169
|
+
items: DeliveryItem[];
|
|
170
|
+
dispositions: MatrixDisposition[];
|
|
171
|
+
findings: MatrixFinding[];
|
|
172
|
+
manifest: MatrixManifest;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Complexity warning threshold (rules draft Gate I) — echoed in the manifest. */
|
|
176
|
+
export const MAX_VARIANTS_PER_ITEM = 12;
|
|
@@ -162,7 +162,7 @@ function findMatchingSpecTest(scenario: ScenarioMetadata, tests: SpecTest[]): Sp
|
|
|
162
162
|
if (exact) return exact;
|
|
163
163
|
|
|
164
164
|
// Fallback: match by VP-ID prefix (ignore trailing text differences)
|
|
165
|
-
const vpMatch = scenario.name.match(/^(VP-[A-Z][A-Z0-9]*-\d+[a-zA-Z]?)/);
|
|
165
|
+
const vpMatch = scenario.name.match(/^(VP-[A-Z][A-Z0-9]*-\d+(?:[a-zA-Z]|-[A-Z0-9]+)?)/);
|
|
166
166
|
if (vpMatch) {
|
|
167
167
|
const vpId = vpMatch[1];
|
|
168
168
|
const byVp = tests.find((t) => t.vpId === vpId);
|
|
@@ -165,7 +165,7 @@ function classifyBlock(block: CodeBlock): 'precondition' | 'step' | 'expectation
|
|
|
165
165
|
* Try to extract VP ID (e.g., VP-UI-001) from the start of a scenario title.
|
|
166
166
|
*/
|
|
167
167
|
function extractVpId(title: string): string | undefined {
|
|
168
|
-
const match = title.match(/^(VP-[A-Z][A-Z0-9]*-\d+[a-zA-Z]?)\b/);
|
|
168
|
+
const match = title.match(/^(VP-[A-Z][A-Z0-9]*-\d+(?:[a-zA-Z]|-[A-Z0-9]+)?)\b/);
|
|
169
169
|
return match ? match[1] : undefined;
|
|
170
170
|
}
|
|
171
171
|
|
|
@@ -143,6 +143,7 @@ export function renderReportHeaderBand(
|
|
|
143
143
|
ws: ExcelJS.Worksheet,
|
|
144
144
|
titleLabel: string,
|
|
145
145
|
sungenVersion?: string,
|
|
146
|
+
formNo: string = 'BM-2-901-13',
|
|
146
147
|
): void {
|
|
147
148
|
const issueDate = (() => {
|
|
148
149
|
const d = new Date();
|
|
@@ -187,9 +188,9 @@ export function renderReportHeaderBand(
|
|
|
187
188
|
d1.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
|
188
189
|
d1.border = allBordersBlack;
|
|
189
190
|
|
|
190
|
-
// G1 — No:
|
|
191
|
+
// G1 — No: <form number> (the company ISO form id; configurable via the delivery map)
|
|
191
192
|
const g1 = ws.getCell('G1');
|
|
192
|
-
g1.value =
|
|
193
|
+
g1.value = `No: ${formNo}`;
|
|
193
194
|
g1.font = { size: 12, name: TIMES };
|
|
194
195
|
g1.alignment = { vertical: 'middle' };
|
|
195
196
|
g1.border = { top: thinBlack, left: thinBlack, right: thinBlack };
|
package/src/harness/audit.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from './sensors';
|
|
18
18
|
import { loadFlowScenarios } from './flow-check';
|
|
19
19
|
import { manualReasonMismatches, MANUAL_REASONS, buildPlan } from './capability-plan';
|
|
20
|
-
import { readCapabilities } from './capability';
|
|
20
|
+
import { readCapabilities, verificationScopeFindings } from './capability';
|
|
21
21
|
import { readIntent, projectRootFromScreenDir, IntentProfile } from './intent';
|
|
22
22
|
import { getProvenance, Provenance } from './provenance';
|
|
23
23
|
import { specCoverage, SpecCoverageResult, parseSpecClauses } from './spec-coverage';
|
|
@@ -129,7 +129,8 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
129
129
|
discoverAndRegisterCapabilities();
|
|
130
130
|
// The active platform (web | mobile | …) activates its own capability for scoring + sensor routing.
|
|
131
131
|
// `web` has no capability of its own → scoringCap stays the default `ui` (byte-identical).
|
|
132
|
-
const
|
|
132
|
+
const capProfile = readCapabilities(projectRootFromScreenDir(screenDir));
|
|
133
|
+
const platform = capProfile.platform;
|
|
133
134
|
const defaultCap = capabilityRegistry.defaultCapabilityId();
|
|
134
135
|
const scoringCapId = scoringCapabilityFor(catalogScreenName, defaultCap, platform);
|
|
135
136
|
const scoringCap = scoringCapId ? capabilityRegistry.get(scoringCapId) : undefined;
|
|
@@ -223,6 +224,10 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
223
224
|
for (const c of flowCredits) {
|
|
224
225
|
findings.push(`COVERED-VIA-FLOW: critical theme "${c.theme}" is not covered on this screen but is deeply covered by flow "${c.flow}" — cross-screen depth correctly owned by the flow, so it is credited (not a screen gap). Verify with \`sungen flow-check\`.`);
|
|
225
226
|
}
|
|
227
|
+
// Verification-scope (advisory): API/DB verification the project's recorded test type never asked for.
|
|
228
|
+
for (const v of verificationScopeFindings(featureText, fs.existsSync(viewpointPath) ? fs.readFileSync(viewpointPath, 'utf-8') : '', capProfile)) {
|
|
229
|
+
findings.push(`VERIFICATION-OUT-OF-SCOPE (${v.layer}): this unit ${v.message}`);
|
|
230
|
+
}
|
|
226
231
|
for (const g of gate.gaps) {
|
|
227
232
|
if (g.status === 'shallow') {
|
|
228
233
|
findings.push(`GATE: critical theme "${g.theme}" is covered only by SHALLOW scenarios (no data assertion) → deepen with \`... with {{value}}\` / \`table ... with {{value}}\` (count @manual cross-screen too).`);
|
|
@@ -15,9 +15,55 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
|
15
15
|
export interface CapabilityProfile {
|
|
16
16
|
platform?: string; // web | mobile | … (the runtime adapter)
|
|
17
17
|
enabled: string[]; // driver ids enabled for this project
|
|
18
|
+
/**
|
|
19
|
+
* Which VERIFICATION layers this project's test scope uses — the recorded "test type"
|
|
20
|
+
* (`ui` = E2E-only; add `api` / `db` when the project also verifies via API/DB). Explicit here wins;
|
|
21
|
+
* when absent it is DERIVED from `enabled` (ui always; api if the api driver is on; db if db is on).
|
|
22
|
+
* Generation and the audit use it so an E2E-only project does not get @api/@query verification it
|
|
23
|
+
* never asked for.
|
|
24
|
+
*/
|
|
25
|
+
verification?: string[];
|
|
18
26
|
source: 'capabilities.yaml' | 'absent';
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
export type VerifyLayer = 'ui' | 'api' | 'db';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Effective verification scope = the recorded "test type". Explicit `verification` wins; otherwise
|
|
33
|
+
* derived from the enabled drivers. `ui` is always in scope (every project observes the UI/E2E result).
|
|
34
|
+
*/
|
|
35
|
+
export function verificationScope(profile: CapabilityProfile): Set<VerifyLayer> {
|
|
36
|
+
if (profile.verification?.length) {
|
|
37
|
+
const s = new Set<VerifyLayer>(['ui']);
|
|
38
|
+
for (const v of profile.verification) if (v === 'api' || v === 'db' || v === 'ui') s.add(v);
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
const s = new Set<VerifyLayer>(['ui']);
|
|
42
|
+
if (profile.enabled.includes('api')) s.add('api');
|
|
43
|
+
if (profile.enabled.includes('db')) s.add('db');
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface VerifyScopeFinding { layer: 'api' | 'db'; message: string; }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Flag API/DB verification that is OUT OF the project's recorded scope: a unit whose scenarios use
|
|
51
|
+
* `@api`/`@requires:api` (or `@query`/`@requires:db`) while the project is not API/DB-testing AND the
|
|
52
|
+
* viewpoint never asked for that layer. Deterministic; advisory only (a nudge, never a hard gate).
|
|
53
|
+
*/
|
|
54
|
+
export function verificationScopeFindings(featureText: string, viewpointText: string, profile: CapabilityProfile): VerifyScopeFinding[] {
|
|
55
|
+
const scope = verificationScope(profile);
|
|
56
|
+
const vp = (viewpointText || '').toLowerCase();
|
|
57
|
+
const out: VerifyScopeFinding[] = [];
|
|
58
|
+
if (/@api\b|@requires:\s*api\b/.test(featureText) && !scope.has('api') && !/\bapi\b/.test(vp)) {
|
|
59
|
+
out.push({ layer: 'api', message: 'uses @api / @requires:api verification, but this project is not API-testing (capabilities `enabled` has no `api`) and the viewpoint does not request API verification. Keep the oracle UI-observable, OR record API in scope (capabilities `verification: [ui, api]` or `sungen capability add api`). Reserve API/DB verify for the ≤20% of effects with no UI observation.' });
|
|
60
|
+
}
|
|
61
|
+
if (/@query\b|@requires:\s*db\b/.test(featureText) && !scope.has('db') && !/\b(database|db)\b/.test(vp)) {
|
|
62
|
+
out.push({ layer: 'db', message: 'uses @query / @requires:db verification, but this project is not DB-testing (capabilities `enabled` has no `db`) and the viewpoint does not request DB verification. Keep the oracle UI-observable, OR record DB in scope (capabilities `verification: [ui, db]` or `sungen capability add db`). Reserve API/DB verify for the ≤20% of effects with no UI observation.' });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
21
67
|
export interface DriverMeta {
|
|
22
68
|
id: string;
|
|
23
69
|
kind: 'platform' | 'capability';
|
|
@@ -41,6 +87,7 @@ export function readCapabilities(cwd: string): CapabilityProfile {
|
|
|
41
87
|
return {
|
|
42
88
|
platform: typeof y.platform === 'string' ? y.platform : undefined,
|
|
43
89
|
enabled: Array.isArray(y.enabled) ? y.enabled.map(String) : [],
|
|
90
|
+
verification: Array.isArray(y.verification) ? y.verification.map(String) : undefined,
|
|
44
91
|
source: 'capabilities.yaml',
|
|
45
92
|
};
|
|
46
93
|
} catch {
|
|
@@ -48,14 +95,17 @@ export function readCapabilities(cwd: string): CapabilityProfile {
|
|
|
48
95
|
}
|
|
49
96
|
}
|
|
50
97
|
|
|
51
|
-
export function writeCapabilities(cwd: string, profile: { platform?: string; enabled: string[] }): string {
|
|
98
|
+
export function writeCapabilities(cwd: string, profile: { platform?: string; enabled: string[]; verification?: string[] }): string {
|
|
52
99
|
const p = capabilitiesPath(cwd);
|
|
53
100
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
54
101
|
const body =
|
|
55
102
|
'# Sungen capability profile — which runtime/drivers this project uses.\n' +
|
|
56
103
|
'# platform: the runtime adapter (web → Playwright, mobile → Appium). No default.\n' +
|
|
57
|
-
'# enabled: drivers turned on (added via `sungen capability add <driver>`).\n
|
|
58
|
-
|
|
104
|
+
'# enabled: drivers turned on (added via `sungen capability add <driver>`).\n' +
|
|
105
|
+
'# verification: the TEST TYPE — which layers this project verifies (`ui` = E2E only; add `api`/`db`\n' +
|
|
106
|
+
'# only if the project actually verifies via API/Database). Absent → derived from `enabled`.\n' +
|
|
107
|
+
'# This keeps an E2E-only project from getting @api/@query verification it never asked for.\n\n' +
|
|
108
|
+
stringifyYaml({ platform: profile.platform, enabled: profile.enabled, ...(profile.verification ? { verification: profile.verification } : {}) });
|
|
59
109
|
fs.writeFileSync(p, body, 'utf-8');
|
|
60
110
|
return p;
|
|
61
111
|
}
|
|
@@ -22,7 +22,7 @@ If `spec_figma.md` exists OR the user provides a Figma URL for the PAT flow:
|
|
|
22
22
|
|
|
23
23
|
## Platform detection (do this FIRST)
|
|
24
24
|
|
|
25
|
-
Read `qa/capabilities.yaml` and check the `platform` field.
|
|
25
|
+
Read `qa/capabilities.yaml` and check the `platform` field — **and the verification scope** (`verification:` if set, else derived from `enabled`: `ui` always; `api`/`db` only if that driver is on). This is the project's recorded **test type**: an **E2E/UI-only** project (no `api`/`db`) must NOT get `@api`/`@query`/`@requires:api|db` verification unless the test-viewpoint explicitly asks for it — keep oracles UI-observable, and cap any in-scope API/DB-in-E2E verification at the **≤20% band** (see `sungen-tc-generation` → "Respect the project's verification scope"). `sungen audit` flags `VERIFICATION-OUT-OF-SCOPE`.
|
|
26
26
|
|
|
27
27
|
- If `platform: mobile` → the visual-capture step (Step 3, "Auto-detect visual source") uses the **`sungen-capture-mobile`** skill (Appium MCP live capture) **instead of** the web `sungen-capture` (figma/live). Scenarios use mobile gesture vocabulary from `sungen-mobile-gestures` (tap, swipe, long-press — not click/hover) and `accessibility-id`-first selectors; mobile-specific steps (permission dialogs, clipboard, geolocation) are valid Gherkin, not errors. The `Background:` is a **navigation recipe** (see `add-screen --reach`), not a URL path. Everything else — viewpoints, `sungen-tc-generation`, the harness/audit loop — is platform-agnostic and unchanged.
|
|
28
28
|
- If `platform: web` or field absent → follow the **web path** (all steps below, unchanged) — Figma/live capture via `sungen-capture` as documented in Step 3.
|