@sun-asterisk/sungen 3.2.6 → 3.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/cli/commands/delivery.d.ts +25 -0
  2. package/dist/cli/commands/delivery.d.ts.map +1 -1
  3. package/dist/cli/commands/delivery.js +30 -47
  4. package/dist/cli/commands/delivery.js.map +1 -1
  5. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  6. package/dist/dashboard/snapshot-builder.js +28 -6
  7. package/dist/dashboard/snapshot-builder.js.map +1 -1
  8. package/dist/dashboard/templates/index.html +20 -20
  9. package/dist/dashboard/types.d.ts +18 -0
  10. package/dist/dashboard/types.d.ts.map +1 -1
  11. package/dist/exporters/api-catalog-loader.d.ts +18 -0
  12. package/dist/exporters/api-catalog-loader.d.ts.map +1 -0
  13. package/dist/exporters/api-catalog-loader.js +77 -0
  14. package/dist/exporters/api-catalog-loader.js.map +1 -0
  15. package/dist/exporters/api-testcase-formatter.d.ts.map +1 -1
  16. package/dist/exporters/api-testcase-formatter.js +80 -4
  17. package/dist/exporters/api-testcase-formatter.js.map +1 -1
  18. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  19. package/dist/exporters/csv-exporter.js +6 -1
  20. package/dist/exporters/csv-exporter.js.map +1 -1
  21. package/dist/exporters/json-exporter.d.ts +10 -1
  22. package/dist/exporters/json-exporter.d.ts.map +1 -1
  23. package/dist/exporters/json-exporter.js +34 -2
  24. package/dist/exporters/json-exporter.js.map +1 -1
  25. package/dist/exporters/playwright-report-parser.d.ts +14 -0
  26. package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
  27. package/dist/exporters/playwright-report-parser.js +30 -0
  28. package/dist/exporters/playwright-report-parser.js.map +1 -1
  29. package/dist/exporters/types.d.ts +5 -0
  30. package/dist/exporters/types.d.ts.map +1 -1
  31. package/dist/exporters/xlsx-exporter.d.ts +18 -17
  32. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  33. package/dist/exporters/xlsx-exporter.js +25 -595
  34. package/dist/exporters/xlsx-exporter.js.map +1 -1
  35. package/dist/exporters/xlsx-report-builder.d.ts +196 -0
  36. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -0
  37. package/dist/exporters/xlsx-report-builder.js +653 -0
  38. package/dist/exporters/xlsx-report-builder.js.map +1 -0
  39. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/column-cell-assertion.hbs +3 -3
  40. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-empty.hbs +1 -1
  41. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +2 -2
  42. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-count.hbs +1 -1
  43. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +2 -2
  44. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-not-exists.hbs +2 -2
  45. package/dist/generators/test-generator/step-mapper.d.ts +6 -2
  46. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  47. package/dist/generators/test-generator/step-mapper.js +10 -6
  48. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  49. package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +8 -0
  50. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +12 -1
  51. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +3 -0
  52. package/dist/orchestrator/templates/specs-api.d.ts +14 -2
  53. package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
  54. package/dist/orchestrator/templates/specs-api.js +38 -14
  55. package/dist/orchestrator/templates/specs-api.js.map +1 -1
  56. package/dist/orchestrator/templates/specs-api.ts +43 -14
  57. package/package.json +3 -3
  58. package/src/cli/commands/delivery.ts +32 -46
  59. package/src/dashboard/snapshot-builder.ts +31 -7
  60. package/src/dashboard/templates/index.html +20 -20
  61. package/src/dashboard/types.ts +19 -0
  62. package/src/exporters/api-catalog-loader.ts +44 -0
  63. package/src/exporters/api-testcase-formatter.ts +77 -4
  64. package/src/exporters/csv-exporter.ts +6 -1
  65. package/src/exporters/json-exporter.ts +46 -4
  66. package/src/exporters/playwright-report-parser.ts +27 -0
  67. package/src/exporters/types.ts +5 -0
  68. package/src/exporters/xlsx-exporter.ts +34 -658
  69. package/src/exporters/xlsx-report-builder.ts +806 -0
  70. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/column-cell-assertion.hbs +3 -3
  71. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-empty.hbs +1 -1
  72. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-match-data.hbs +2 -2
  73. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-count.hbs +1 -1
  74. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-exists.hbs +2 -2
  75. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/table-row-not-exists.hbs +2 -2
  76. package/src/generators/test-generator/step-mapper.ts +10 -6
  77. package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +8 -0
  78. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +12 -1
  79. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +3 -0
  80. package/src/orchestrator/templates/specs-api.ts +43 -14
  81. package/dist/cli/types.d.ts +0 -9
  82. package/dist/cli/types.d.ts.map +0 -1
  83. package/dist/cli/types.js +0 -7
  84. package/dist/cli/types.js.map +0 -1
  85. package/dist/generators/gherkin-parser/selector-extractor.d.ts +0 -37
  86. package/dist/generators/gherkin-parser/selector-extractor.d.ts.map +0 -1
  87. package/dist/generators/gherkin-parser/selector-extractor.js +0 -108
  88. package/dist/generators/gherkin-parser/selector-extractor.js.map +0 -1
  89. package/dist/generators/test-generator/types.d.ts +0 -25
  90. package/dist/generators/test-generator/types.d.ts.map +0 -1
  91. package/dist/generators/test-generator/types.js +0 -7
  92. package/dist/generators/test-generator/types.js.map +0 -1
  93. package/dist/generators/types.d.ts +0 -119
  94. package/dist/generators/types.d.ts.map +0 -1
  95. package/dist/generators/types.js +0 -48
  96. package/dist/generators/types.js.map +0 -1
  97. package/dist/orchestrator/figma/node-path-collapser.d.ts +0 -16
  98. package/dist/orchestrator/figma/node-path-collapser.d.ts.map +0 -1
  99. package/dist/orchestrator/figma/node-path-collapser.js +0 -37
  100. package/dist/orchestrator/figma/node-path-collapser.js.map +0 -1
  101. package/src/cli/types.ts +0 -9
  102. package/src/generators/gherkin-parser/selector-extractor.ts +0 -142
  103. package/src/generators/test-generator/types.ts +0 -26
  104. package/src/generators/types.ts +0 -227
  105. package/src/orchestrator/figma/node-path-collapser.ts +0 -38
@@ -0,0 +1,806 @@
1
+ /**
2
+ * Pure XLSX workbook builder — the single source of truth for the BM-2-901-13
3
+ * delivery report rendering, shared by the CLI exporter
4
+ * (`xlsx-exporter.ts`, Node) and the dashboard's in-browser exporter
5
+ * (`dashboard/src/lib/export.ts`, bundled via vite alias).
6
+ *
7
+ * BROWSER-SAFE: this module may import ONLY `exceljs` and `./sun-logo`.
8
+ * No `fs`, no `path`, no `./package-info` (version lookup is fs-based) and no
9
+ * `./feature-parser` (it transitively pulls the fs-reading gherkin parser).
10
+ * Anything environment-specific is injected by the caller:
11
+ * - the sungen version string → `opts.sungenVersion` / `sungenVersion` param
12
+ * - the tag-extractor helpers used by `buildApiDetailRows` → `TagExtractors`
13
+ *
14
+ * Render the same rows/summary the CSV exporter produces into a styled .xlsx
15
+ * workbook. Keeps the BM-2-901-13 layout but adds:
16
+ * - bold header row with filled background + white font
17
+ * - borders on the data region
18
+ * - text wrapping in Steps / Expected / Pre-condition / Test Data
19
+ * - sensible column widths
20
+ * - category-separator rows rendered as a filled bar spanning the table
21
+ */
22
+
23
+ import ExcelJS from 'exceljs';
24
+ import { SUN_LOGO_PNG_BASE64 } from './sun-logo';
25
+
26
+ export const COL_COUNT = 16;
27
+ export const HEADER_FILL = 'FFD9D2E9'; // lavender — matches the summary-header band on row 6
28
+ export const HEADER_FONT = 'FF000000'; // black text reads better on the light lavender
29
+ export const META_FILL = 'FFDCE6F1'; // soft blue for meta rows
30
+ export const CATEGORY_FILL = 'FFD9D2E9'; // same lavender for group dividers (Accessing / GUI / Function)
31
+ export const BORDER_COLOR = 'FFBFBFBF';
32
+
33
+ // Shared masthead + table styling (BM-2-901-13 header band, summary band, Overview table).
34
+ export const TIMES = 'Times New Roman';
35
+ export const ARIAL_FONT = 'Arial';
36
+ export const BLACK = { argb: 'FF000000' };
37
+ export const WHITE = { argb: 'FFFFFFFF' };
38
+ export const LAVENDER = { argb: 'FFD9D2E9' };
39
+ const thinBlack = { style: 'thin' as const, color: BLACK };
40
+ export const allBordersBlack = { top: thinBlack, left: thinBlack, bottom: thinBlack, right: thinBlack };
41
+
42
+ type AnyCell = ExcelJS.Cell;
43
+
44
+ export function applyBorder(cell: AnyCell): void {
45
+ cell.border = {
46
+ top: { style: 'thin', color: { argb: BORDER_COLOR } },
47
+ left: { style: 'thin', color: { argb: BORDER_COLOR } },
48
+ bottom: { style: 'thin', color: { argb: BORDER_COLOR } },
49
+ right: { style: 'thin', color: { argb: BORDER_COLOR } },
50
+ };
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Builder input shapes — neutral, builder-local. Both the CLI's domain types
55
+ // (ScreenSummary / TestCaseRow / LocaleSheet / ApiDetailRow / ApiCatalogEntry)
56
+ // and the dashboard's snapshot types map structurally onto these; the adapters
57
+ // on each side are pure field mappings, so no app domain type leaks in here.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Summary stats rendered into the sheet's summary band (rows 6-8). */
61
+ export interface BuilderSummary {
62
+ screen: string;
63
+ total: number;
64
+ passed: number;
65
+ failed: number;
66
+ pending: number;
67
+ na: number;
68
+ }
69
+
70
+ /** One testcase data row (BM-2-901-13 columns A..P). All fields are strings. */
71
+ export interface BuilderRow {
72
+ tcId: string;
73
+ category1: string;
74
+ category2: string;
75
+ precondition: string;
76
+ testData: string;
77
+ steps: string;
78
+ expectedResults: string;
79
+ priority: string;
80
+ testcaseType: string;
81
+ testResult: string;
82
+ executedDate: string;
83
+ testExecutor: string;
84
+ testEnvironment: string;
85
+ note: string;
86
+ }
87
+
88
+ /** One testcase worksheet: name + summary + data rows + spec link. */
89
+ export interface BuilderSheet {
90
+ sheetName: string; // e.g. "home-VI" or "home-EN" (or "Testcases")
91
+ summary: BuilderSummary;
92
+ rows: BuilderRow[];
93
+ specLink: string;
94
+ }
95
+
96
+ /** One row in the supplementary "API detail" worksheet. */
97
+ export interface BuilderApiDetailRow {
98
+ endpoint: string;
99
+ method: string;
100
+ authDatasource: string;
101
+ requestShape: string;
102
+ expectedStatusMatrix: string;
103
+ flowSteps: string;
104
+ concurrencyInvariant: string;
105
+ }
106
+
107
+ /** The apis.yaml catalog fields `buildApiDetailRows` consumes. */
108
+ export interface BuilderApiCatalogEntry {
109
+ method?: string;
110
+ path?: string;
111
+ datasource?: string;
112
+ body?: unknown;
113
+ params?: unknown;
114
+ expect?: { status?: number | string };
115
+ }
116
+
117
+ /**
118
+ * Tag-extractor helpers injected into `buildApiDetailRows`. Implemented by
119
+ * `feature-parser.ts` on the CLI side — injected (not imported) because that
120
+ * module transitively requires the fs-reading gherkin parser, which would
121
+ * break this module's browser-safety.
122
+ */
123
+ export interface TagExtractors {
124
+ extractApiCallOrder(tags: string[]): string[];
125
+ extractCasesDataset(tags: string[]): string | null;
126
+ extractConcurrencyInvariant(tags: string[]): string;
127
+ }
128
+
129
+ /** Environment-specific options injected by the caller. */
130
+ export interface WorkbookBuildOptions {
131
+ /** CLI/package version rendered into the masthead ("Version: x.y.z"). */
132
+ sungenVersion?: string;
133
+ }
134
+
135
+ /**
136
+ * Render the shared BM-2-901-13 masthead (rows 1-4) onto a worksheet:
137
+ * Sun* logo (A1:C3), TESTCASE title (D1:F3), No/Version/Issue-Date block
138
+ * (G1:H3), company line (A4:F4), ISO line (G4:H4). Every sheet in a delivery
139
+ * workbook — Overview, Auto, Manual — carries this same header band.
140
+ */
141
+ export function renderReportHeaderBand(
142
+ wb: ExcelJS.Workbook,
143
+ ws: ExcelJS.Worksheet,
144
+ titleLabel: string,
145
+ sungenVersion?: string,
146
+ ): void {
147
+ const issueDate = (() => {
148
+ const d = new Date();
149
+ return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
150
+ })();
151
+
152
+ // Ensure rows 1..4 exist before merging.
153
+ for (let r = 1; r <= 4; r++) ws.getRow(r);
154
+
155
+ ws.mergeCells('A1:C3');
156
+ ws.mergeCells('D1:F3');
157
+ ws.mergeCells('G1:H1');
158
+ ws.mergeCells('G2:H2');
159
+ ws.mergeCells('G3:H3');
160
+ ws.mergeCells('A4:F4');
161
+ ws.mergeCells('G4:H4');
162
+
163
+ // A1 (logo band, merged A1:C3) — border + embedded Sun* logo (base64 inline).
164
+ const a1 = ws.getCell('A1');
165
+ a1.alignment = { horizontal: 'center', vertical: 'middle' };
166
+ a1.border = allBordersBlack;
167
+
168
+ try {
169
+ // base64 form (not Buffer.from) keeps this module Buffer-free / browser-safe;
170
+ // ExcelJS decodes it to the identical PNG media bytes either way.
171
+ const imageId = wb.addImage({
172
+ base64: SUN_LOGO_PNG_BASE64,
173
+ extension: 'png',
174
+ });
175
+ // Centre a fixed-size 90×51 logo inside A1:C3 using absolute EMU offsets.
176
+ ws.addImage(imageId, {
177
+ tl: { nativeCol: 0, nativeColOff: 1237500, nativeRow: 0, nativeRowOff: 42862 } as unknown as ExcelJS.Anchor,
178
+ ext: { width: 90, height: 51 },
179
+ editAs: 'oneCell',
180
+ } as unknown as ExcelJS.ImageRange);
181
+ } catch { /* logo is decorative — never block export */ }
182
+
183
+ // D1 (TESTCASE title)
184
+ const d1 = ws.getCell('D1');
185
+ d1.value = titleLabel;
186
+ d1.font = { bold: true, size: 18, name: TIMES };
187
+ d1.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
188
+ d1.border = allBordersBlack;
189
+
190
+ // G1 — No: BM-2-901-13
191
+ const g1 = ws.getCell('G1');
192
+ g1.value = 'No: BM-2-901-13';
193
+ g1.font = { size: 12, name: TIMES };
194
+ g1.alignment = { vertical: 'middle' };
195
+ g1.border = { top: thinBlack, left: thinBlack, right: thinBlack };
196
+
197
+ // G2 — Version
198
+ const g2 = ws.getCell('G2');
199
+ g2.value = `Version: ${sungenVersion ?? 'unknown'}`;
200
+ g2.font = { size: 12, name: TIMES };
201
+ g2.alignment = { vertical: 'middle' };
202
+ g2.border = { left: thinBlack, right: thinBlack };
203
+
204
+ // G3 — Issue Date
205
+ const g3 = ws.getCell('G3');
206
+ g3.value = `Issue Date: ${issueDate}`;
207
+ g3.font = { size: 12, name: TIMES };
208
+ g3.alignment = { vertical: 'middle' };
209
+ g3.border = { left: thinBlack, right: thinBlack, bottom: thinBlack };
210
+
211
+ // A4 — SUN ASTERISK VIETNAM CO., LTD
212
+ const a4 = ws.getCell('A4');
213
+ a4.value = 'SUN ASTERISK VIETNAM CO., LTD';
214
+ a4.font = { bold: true, size: 10, name: TIMES };
215
+ a4.alignment = { vertical: 'middle' };
216
+ a4.border = allBordersBlack;
217
+
218
+ // G4 — ISO/IEC ...
219
+ const g4 = ws.getCell('G4');
220
+ g4.value = 'ISO/IEC 27001:2022 & ISO 9001:2015';
221
+ g4.font = { bold: true, size: 10, name: TIMES };
222
+ g4.alignment = { horizontal: 'right', vertical: 'middle', wrapText: true };
223
+ g4.border = allBordersBlack;
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Overview sheet — the first tab in every multi-sheet delivery workbook.
228
+ // Rolls up each testcase sheet's Passed/Failed/Pending/N/A/Remain/Total into
229
+ // one summary table (+ a grand-total row) under the shared masthead. Reads
230
+ // only each sheet's summary — never touches the detail sheets themselves.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /** Column headers for the Overview roll-up table (cols A..H). */
234
+ export const OVERVIEW_HEADERS = ['#', 'Sheet name', 'Passed', 'Failed', 'Pending', 'N/A', 'Remain', 'Total'];
235
+
236
+ /**
237
+ * Prepend an "Overview" worksheet summarizing every sheet in `sheets`.
238
+ *
239
+ * `Remain = Total − Passed − N/A` (i.e. Failed + Pending) so the number
240
+ * reconciles with the "Remaining" cell in each detail sheet's summary band.
241
+ */
242
+ export function addOverviewSheet(
243
+ wb: ExcelJS.Workbook,
244
+ sheets: BuilderSheet[],
245
+ sungenVersion?: string,
246
+ ): void {
247
+ const ws = wb.addWorksheet('Overview');
248
+
249
+ // Table starts at column B row 7 (A is a thin left margin). Widths keep the
250
+ // A..H masthead readable AND the count columns tidy.
251
+ ws.columns = [
252
+ { width: 4 }, // A — left margin
253
+ { width: 6 }, // B — #
254
+ { width: 26 }, // C — Sheet name
255
+ { width: 13 }, // D — Passed
256
+ { width: 13 }, // E — Failed
257
+ { width: 13 }, // F — Pending
258
+ { width: 13 }, // G — N/A
259
+ { width: 13 }, // H — Remain
260
+ { width: 13 }, // I — Total
261
+ ];
262
+
263
+ const titleScreen = sheets[0]?.summary.screen ?? '';
264
+ renderReportHeaderBand(wb, ws, `${titleScreen.toUpperCase()} TESTCASE`, sungenVersion);
265
+
266
+ // Roll-up table: header on row 7, first column B (index 2).
267
+ const HEADER_ROW = 7;
268
+ const FIRST_COL = 2; // column B
269
+ const headerRow = ws.getRow(HEADER_ROW);
270
+ headerRow.height = 20;
271
+ OVERVIEW_HEADERS.forEach((label, i) => {
272
+ const c = headerRow.getCell(FIRST_COL + i);
273
+ c.value = label;
274
+ c.font = { bold: true, size: 10, color: BLACK, name: ARIAL_FONT };
275
+ c.fill = { type: 'pattern', pattern: 'solid', fgColor: LAVENDER };
276
+ c.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
277
+ c.border = allBordersBlack;
278
+ });
279
+
280
+ const totals = { passed: 0, failed: 0, pending: 0, na: 0, remain: 0, total: 0 };
281
+
282
+ const writeDataRow = (rowIdx: number, values: ExcelJS.CellValue[], isTotal: boolean): void => {
283
+ const row = ws.getRow(rowIdx);
284
+ values.forEach((v, i) => {
285
+ const c = row.getCell(FIRST_COL + i);
286
+ c.value = v;
287
+ c.font = { bold: isTotal, size: 10, color: BLACK, name: ARIAL_FONT };
288
+ // The '#' + 'Sheet name' columns read left; the counts centre like the band.
289
+ c.alignment = { horizontal: i <= 1 ? 'left' : 'center', vertical: 'middle' };
290
+ c.border = allBordersBlack;
291
+ if (isTotal) c.fill = { type: 'pattern', pattern: 'solid', fgColor: LAVENDER };
292
+ });
293
+ };
294
+
295
+ // Each detail sheet carries a LIVE summary band (row 7): C7 Total · D7 Passed · E7 Failed ·
296
+ // F7 Pending · G7 N/A · H7 Remaining, all COUNTA/COUNTIF over the data area. The Overview
297
+ // REFERENCES those cells (`='<sheet>'!D7`) instead of freezing a number at export time — so when
298
+ // QA adds/edits/removes a row in the Auto or Manual sheet, the roll-up recomputes automatically.
299
+ const qname = (name: string): string => name.replace(/'/g, "''"); // Excel escapes ' by doubling
300
+ const ref = (name: string, cell: string, result: number): ExcelJS.CellFormulaValue =>
301
+ ({ formula: `'${qname(name)}'!${cell}`, result });
302
+
303
+ let rowIdx = HEADER_ROW + 1;
304
+ const firstDataRow = rowIdx;
305
+ sheets.forEach((s, i) => {
306
+ const su = s.summary;
307
+ const remain = Math.max(0, su.total - su.passed - su.na);
308
+ writeDataRow(rowIdx, [
309
+ i + 1,
310
+ s.sheetName,
311
+ ref(s.sheetName, 'D7', su.passed),
312
+ ref(s.sheetName, 'E7', su.failed),
313
+ ref(s.sheetName, 'F7', su.pending),
314
+ ref(s.sheetName, 'G7', su.na),
315
+ ref(s.sheetName, 'H7', remain),
316
+ ref(s.sheetName, 'C7', su.total),
317
+ ], false);
318
+ totals.passed += su.passed;
319
+ totals.failed += su.failed;
320
+ totals.pending += su.pending;
321
+ totals.na += su.na;
322
+ totals.remain += remain;
323
+ totals.total += su.total;
324
+ rowIdx += 1;
325
+ });
326
+ const lastDataRow = rowIdx - 1;
327
+
328
+ // Grand total = SUM of the per-sheet Overview cells (also live — recomputes with the references
329
+ // above). Overview count columns are D..I (Passed·Failed·Pending·N/A·Remain·Total).
330
+ const sumCol = (col: string, result: number): ExcelJS.CellFormulaValue =>
331
+ ({ formula: `SUM(${col}${firstDataRow}:${col}${lastDataRow})`, result });
332
+ writeDataRow(
333
+ rowIdx,
334
+ [
335
+ '', 'Total',
336
+ sumCol('D', totals.passed),
337
+ sumCol('E', totals.failed),
338
+ sumCol('F', totals.pending),
339
+ sumCol('G', totals.na),
340
+ sumCol('H', totals.remain),
341
+ sumCol('I', totals.total),
342
+ ],
343
+ true,
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Append one BM-2-901-13 testcase sheet to the given workbook. Sheet content
349
+ * (layout, summary band, data rows, auto-filter, protection) is identical to
350
+ * the single-sheet `renderXlsx` output — only the sheet name differs.
351
+ */
352
+ export function addTestcaseSheet(
353
+ wb: ExcelJS.Workbook,
354
+ sheetName: string,
355
+ summary: BuilderSummary,
356
+ rows: BuilderRow[],
357
+ specLink: string,
358
+ sungenVersion?: string,
359
+ ): void {
360
+ const ws = wb.addWorksheet(sheetName, {
361
+ views: [{ state: 'frozen', ySplit: 0, xSplit: 0 }],
362
+ });
363
+
364
+ // -- Column widths matching template_report.xlsx (Sample sheet) --
365
+ ws.columns = [
366
+ { width: 20 }, // A — TC ID (wider to fit long flow IDs like FLOW-KUDO-…)
367
+ { width: 12.5 }, // B — Screen/Function
368
+ { width: 15.38 }, // C — Big item
369
+ { width: 16.25 }, // D — Medium item
370
+ { width: 17 }, // E — Test Object
371
+ { width: 25.5 }, // F — Pre-condition
372
+ { width: 24.88 }, // G — Test Data
373
+ { width: 50 }, // H — Steps (widened ~2× — steps are the longest cell)
374
+ { width: 41 }, // I — Expected results
375
+ { width: 12 }, // J — Priority
376
+ { width: 13.5 }, // K — Testcase type
377
+ { width: 15.88 }, // L — Test Result
378
+ { width: 13.5 }, // M — Executed Date
379
+ { width: 16.5 }, // N — Test Executor
380
+ { width: 23 }, // O — Test Environment
381
+ { width: 29 }, // P — Note
382
+ ];
383
+
384
+ // -- Top metadata band (rows 1-4) — shared BM-2-901-13 masthead --
385
+ renderReportHeaderBand(wb, ws, `${summary.screen.toUpperCase()} TESTCASE`, sungenVersion);
386
+ // Materialize rows 5..8 so the summary band + row-9 spacer land at the right offsets.
387
+ for (let r = 5; r <= 8; r++) ws.getRow(r);
388
+
389
+ // -- Row 5: spacer (height auto-fit). --
390
+
391
+ // -- Row 6: Summary headers (cols C..H), lavender fill --
392
+ const sumLabels: Record<string, string> = {
393
+ C: 'Total TCs', D: 'Passed', E: 'Failed', F: 'Pending', G: 'N/A', H: 'Remaining',
394
+ };
395
+ for (const [col, label] of Object.entries(sumLabels)) {
396
+ const c = ws.getCell(`${col}6`);
397
+ c.value = label;
398
+ c.font = { bold: true, size: 10, color: BLACK, name: ARIAL_FONT };
399
+ c.fill = { type: 'pattern', pattern: 'solid', fgColor: LAVENDER };
400
+ c.alignment = { horizontal: 'center', vertical: 'top' };
401
+ c.border = allBordersBlack;
402
+ }
403
+ // No explicit height → Excel auto-fits this row's content.
404
+
405
+ // -- Row 7: Counts (cols C..H), live formulas referencing the data area --
406
+ // The data region begins at row 14 — the row immediately AFTER the column-header row (row 13).
407
+ // Start the ranges there (NOT row 15): the first category divider usually sits at row 14 with a
408
+ // BLANK col A (so COUNTA skips it), but a sheet whose first row after the header is real data —
409
+ // no leading divider — would be UNDER-counted by a range that started at 15. Row 14 covers both.
410
+ const COUNT_RANGE_START = 14;
411
+ const COUNT_RANGE_END = 10000;
412
+ const r7Values: Record<string, ExcelJS.CellValue> = {
413
+ C: { formula: `COUNTA(A${COUNT_RANGE_START}:A${COUNT_RANGE_END})`, result: summary.total } as ExcelJS.CellFormulaValue,
414
+ D: { formula: `COUNTIF(L${COUNT_RANGE_START}:L${COUNT_RANGE_END},"Passed")`, result: summary.passed } as ExcelJS.CellFormulaValue,
415
+ E: { formula: `COUNTIF(L${COUNT_RANGE_START}:L${COUNT_RANGE_END},"Failed")`, result: summary.failed } as ExcelJS.CellFormulaValue,
416
+ F: { formula: `COUNTIF(L${COUNT_RANGE_START}:L${COUNT_RANGE_END},"Pending")`, result: summary.pending } as ExcelJS.CellFormulaValue,
417
+ G: { formula: `COUNTIF(L${COUNT_RANGE_START}:L${COUNT_RANGE_END},"N/A")`, result: summary.na } as ExcelJS.CellFormulaValue,
418
+ H: { formula: 'C7-D7-G7', result: summary.failed + summary.pending } as ExcelJS.CellFormulaValue,
419
+ };
420
+ for (const [col, val] of Object.entries(r7Values)) {
421
+ const c = ws.getCell(`${col}7`);
422
+ c.value = val;
423
+ c.font = { size: 10, color: BLACK, name: ARIAL_FONT };
424
+ c.fill = { type: 'pattern', pattern: 'solid', fgColor: WHITE };
425
+ c.alignment = { horizontal: 'center', vertical: 'top' };
426
+ c.border = allBordersBlack;
427
+ if (col === 'H') c.numFmt = '#,##0';
428
+ }
429
+ // No explicit height → Excel auto-fits.
430
+
431
+ // C8 has no value in the template but still needs a border so the band
432
+ // visually closes underneath "Total TCs".
433
+ const c8 = ws.getCell('C8');
434
+ c8.font = { size: 10, color: BLACK, name: ARIAL_FONT };
435
+ c8.fill = { type: 'pattern', pattern: 'solid', fgColor: WHITE };
436
+ c8.alignment = { horizontal: 'center', vertical: 'top' };
437
+ c8.border = allBordersBlack;
438
+
439
+ // -- Row 8: Percentages (cols D..H), formula = count / (Total − N/A) --
440
+ const r8Values: Record<string, ExcelJS.CellValue> = {
441
+ D: { formula: 'IFERROR(D7/(C7-G7),0)', result: summary.passed / Math.max(1, summary.total - summary.na) } as ExcelJS.CellFormulaValue,
442
+ E: { formula: 'IFERROR(E7/(C7-G7),0)', result: summary.failed / Math.max(1, summary.total - summary.na) } as ExcelJS.CellFormulaValue,
443
+ F: { formula: 'IFERROR(F7/(C7-G7),0)', result: summary.pending / Math.max(1, summary.total - summary.na) } as ExcelJS.CellFormulaValue,
444
+ G: { formula: 'IFERROR(G7/(C7-G7),0)', result: summary.na / Math.max(1, summary.total - summary.na) } as ExcelJS.CellFormulaValue,
445
+ H: { formula: 'IFERROR(H7/(C7-G7),0)', result: (summary.failed + summary.pending) / Math.max(1, summary.total - summary.na) } as ExcelJS.CellFormulaValue,
446
+ };
447
+ for (const [col, val] of Object.entries(r8Values)) {
448
+ const c = ws.getCell(`${col}8`);
449
+ c.value = val;
450
+ c.font = { size: 10, color: BLACK, name: ARIAL_FONT };
451
+ c.fill = { type: 'pattern', pattern: 'solid', fgColor: WHITE };
452
+ c.alignment = { horizontal: 'center', vertical: 'top' };
453
+ c.border = allBordersBlack;
454
+ c.numFmt = '0%';
455
+ }
456
+ // No explicit height → Excel auto-fits.
457
+
458
+ // After rows 1-8 are populated, append a blank row 9 spacer.
459
+ while (ws.rowCount < 9) ws.addRow([]);
460
+
461
+ const specRow = ws.addRow(['Spec/Design link:', specLink]);
462
+ specRow.getCell(1).font = { bold: true };
463
+ specRow.getCell(2).font = { color: { argb: 'FF1F4E78' }, underline: true };
464
+
465
+ ws.addRow([]); // spacer
466
+
467
+ const legendRow = ws.addRow(['*: Mandatory']);
468
+ legendRow.getCell(1).font = { italic: true, color: { argb: 'FF808080' } };
469
+
470
+ // -- Column header row (bold, filled, wrapped) --
471
+ const headerRow = ws.addRow([
472
+ 'TC ID*',
473
+ 'Screen/Function',
474
+ 'Big item',
475
+ 'Medium item',
476
+ 'Test Object',
477
+ 'Pre-condition',
478
+ 'Test Data',
479
+ 'Steps*',
480
+ 'Expected results*',
481
+ 'Priority',
482
+ 'Testcase type',
483
+ 'Test Result*',
484
+ 'Executed Date*',
485
+ 'Test Executor*',
486
+ 'Test Environment',
487
+ 'Note\n(Test evidence, DefectID, Actual result)',
488
+ ]);
489
+ headerRow.height = 38;
490
+ headerRow.eachCell((cell) => {
491
+ cell.font = { bold: true, color: { argb: HEADER_FONT } };
492
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: HEADER_FILL } };
493
+ cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
494
+ applyBorder(cell);
495
+ });
496
+
497
+ // No freeze — the whole sheet scrolls freely (the masthead + summary band + header scroll away
498
+ // with the data). QA asked for this so the wide Steps/Expected columns aren't pinned under a frozen
499
+ // header on smaller screens.
500
+ ws.views = [{ state: 'normal' }];
501
+
502
+ // -- Data rows grouped by category --
503
+ const order = ['Accessing', 'GUI', 'Function'];
504
+ const grouped = new Map<string, BuilderRow[]>();
505
+ for (const r of rows) {
506
+ const g = grouped.get(r.category2) || [];
507
+ g.push(r);
508
+ grouped.set(r.category2, g);
509
+ }
510
+ const emittedGroups = new Set<string>();
511
+
512
+ function emitGroup(groupName: string, groupRows: BuilderRow[]): void {
513
+ // Column A (TC ID) must stay TRULY blank on a divider — never write '' here. COUNTA counts
514
+ // empty-string cells, so an '' in A made the generated total (=COUNTA(A15:A…)) over-count by
515
+ // one per group divider when recalculated (e.g. in Google Sheets). Set only the group name in B;
516
+ // the lavender band still spans every column via the fill loop below (style only, no value).
517
+ const divider = ws.addRow([]);
518
+ divider.getCell(2).value = groupName;
519
+ divider.getCell(2).font = { bold: true };
520
+ divider.eachCell({ includeEmpty: false }, (cell) => {
521
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: CATEGORY_FILL } };
522
+ });
523
+ // Fill the full width so the band spans the whole table
524
+ for (let i = 1; i <= COL_COUNT; i++) {
525
+ const c = divider.getCell(i);
526
+ c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: CATEGORY_FILL } };
527
+ applyBorder(c);
528
+ }
529
+
530
+ for (const r of groupRows) {
531
+ const row = ws.addRow([
532
+ r.tcId,
533
+ '',
534
+ '',
535
+ '',
536
+ r.category1,
537
+ r.precondition,
538
+ r.testData,
539
+ r.steps,
540
+ r.expectedResults,
541
+ r.priority,
542
+ r.testcaseType,
543
+ r.testResult,
544
+ r.executedDate,
545
+ r.testExecutor,
546
+ r.testEnvironment,
547
+ r.note,
548
+ ]);
549
+ row.alignment = { vertical: 'top', wrapText: true };
550
+ row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
551
+ applyBorder(cell);
552
+ if (colNumber === 1) {
553
+ cell.font = { bold: true };
554
+ }
555
+ if (colNumber === 12) {
556
+ // Test Result — colour-code
557
+ const val = String(cell.value || '');
558
+ if (val === 'Passed') {
559
+ cell.font = { color: { argb: 'FF107C41' }, bold: true };
560
+ } else if (val === 'Failed') {
561
+ cell.font = { color: { argb: 'FFC00000' }, bold: true };
562
+ } else if (val === 'Pending') {
563
+ cell.font = { color: { argb: 'FF996D00' }, bold: true };
564
+ } else if (val === 'N/A') {
565
+ cell.font = { color: { argb: 'FF808080' } };
566
+ }
567
+ }
568
+ });
569
+ }
570
+ }
571
+
572
+ for (const groupName of order) {
573
+ const groupRows = grouped.get(groupName);
574
+ if (!groupRows || groupRows.length === 0) continue;
575
+ emittedGroups.add(groupName);
576
+ emitGroup(groupName, groupRows);
577
+ }
578
+ for (const [groupName, groupRows] of grouped.entries()) {
579
+ if (emittedGroups.has(groupName)) continue;
580
+ emitGroup(groupName, groupRows);
581
+ }
582
+
583
+ // Auto-filter on the header row so QA can filter by result / priority / category
584
+ ws.autoFilter = {
585
+ from: { row: headerRow.number, column: 1 },
586
+ to: { row: ws.rowCount, column: COL_COUNT },
587
+ };
588
+
589
+ // Sheet protection lets the picLocks on the embedded logo take effect
590
+ // (Excel honours image locks only when the sheet is protected). Empty
591
+ // password — QA can run Review → Unprotect Sheet if they need to edit.
592
+ // `objects: false` here is ExcelJS's inverted semantic that writes
593
+ // `objects="1"` in XML (= objects ARE locked).
594
+ (ws as unknown as { sheetProtection: object }).sheetProtection = {
595
+ sheet: true,
596
+ objects: false,
597
+ scenarios: false,
598
+ };
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // API detail sheet (api-kind units only)
603
+ // Second worksheet appended after Testcases — never alters the Testcases sheet.
604
+ // ---------------------------------------------------------------------------
605
+
606
+ export const API_DETAIL_HEADER_FILL = 'FF2E5984'; // dark blue header for differentiation
607
+ export const API_DETAIL_HEADER_FONT = 'FFFFFFFF'; // white text on dark header
608
+
609
+ /**
610
+ * Build BuilderApiDetailRow[] from the apis.yaml catalog + feature-level
611
+ * annotations. Called once per feature file for api-kind units in the delivery
612
+ * pipeline. The tag extractors are injected (see `TagExtractors`).
613
+ *
614
+ * @param catalog Parsed apis.yaml keyed by endpoint name
615
+ * @param scenarios Scenario-level tag arrays from parseFeatureMetadata().scenarios
616
+ * @param extractors Tag-parsing helpers (feature-parser on the CLI side)
617
+ */
618
+ export function buildApiDetailRows(
619
+ catalog: Record<string, BuilderApiCatalogEntry>,
620
+ scenarios: Array<{ tags: string[] }>,
621
+ extractors: TagExtractors,
622
+ ): BuilderApiDetailRow[] {
623
+ const { extractApiCallOrder, extractCasesDataset, extractConcurrencyInvariant } = extractors;
624
+ const rows: BuilderApiDetailRow[] = [];
625
+
626
+ for (const [endpointName, entry] of Object.entries(catalog)) {
627
+ const method = (entry.method ?? '').toUpperCase();
628
+ const endpoint = entry.path ?? endpointName;
629
+ const datasource = entry.datasource ?? '';
630
+
631
+ // Auth: look for @auth: tag in any scenario that calls this endpoint.
632
+ const authTags = scenarios.flatMap((s) => {
633
+ const calls = extractApiCallOrder(s.tags);
634
+ if (!calls.includes(endpointName)) return [];
635
+ return s.tags.filter((t) => t.startsWith('@auth:'));
636
+ });
637
+ const uniqueAuth = [...new Set(authTags.map((t) => t.slice('@auth:'.length)))];
638
+ const authDatasource = [datasource, ...uniqueAuth].filter(Boolean).join('; ');
639
+
640
+ // Request shape: compose from body + params + encoding.
641
+ const bodyStr = entry.body
642
+ ? `body: ${typeof entry.body === 'string' ? entry.body : JSON.stringify(entry.body)}`
643
+ : '';
644
+ const paramsArr = Array.isArray(entry.params) ? entry.params as string[] : [];
645
+ const paramsStr = paramsArr.length > 0 ? `params: [${paramsArr.join(', ')}]` : '';
646
+ const requestShape = [bodyStr, paramsStr].filter(Boolean).join('; ') || '—';
647
+
648
+ // Expected-status matrix: aggregate @cases dataset labels + expected status
649
+ // from scenarios that call this endpoint. Fall back to catalog expect.status.
650
+ const statusEntries: string[] = [];
651
+ for (const sc of scenarios) {
652
+ const calls = extractApiCallOrder(sc.tags);
653
+ if (!calls.includes(endpointName)) continue;
654
+ const dataset = extractCasesDataset(sc.tags);
655
+ if (dataset) {
656
+ // @cases dataset name as label — actual per-row statuses live in test-data.yaml
657
+ statusEntries.push(`@cases:${dataset}`);
658
+ }
659
+ }
660
+ // Show the catalog baseline status plus a pointer to any @cases matrix dataset (the per-row
661
+ // statuses live in test-data; resolving them into this cell is a later enrichment).
662
+ const catalogStatus = entry.expect?.status != null ? String(entry.expect.status) : '';
663
+ const expectedStatusMatrix =
664
+ [catalogStatus, ...new Set(statusEntries)].filter(Boolean).join('; ') || '—';
665
+
666
+ // Flow steps: ordered @api names from flow-tagged scenarios referencing this endpoint.
667
+ const flowStepsSet = new Set<string>();
668
+ for (const sc of scenarios) {
669
+ const calls = extractApiCallOrder(sc.tags);
670
+ if (!calls.includes(endpointName)) continue;
671
+ // All scenarios show their call order; flow scenarios show multi-step chains.
672
+ if (calls.length > 1) {
673
+ flowStepsSet.add(calls.join(' → '));
674
+ }
675
+ }
676
+ const flowSteps = [...flowStepsSet].join('; ') || '—';
677
+
678
+ // Concurrency invariant: from @concurrent scenarios calling this endpoint.
679
+ const concurrencyParts: string[] = [];
680
+ for (const sc of scenarios) {
681
+ const calls = extractApiCallOrder(sc.tags);
682
+ if (!calls.includes(endpointName)) continue;
683
+ const inv = extractConcurrencyInvariant(sc.tags);
684
+ if (inv) concurrencyParts.push(inv);
685
+ }
686
+ const concurrencyInvariant = concurrencyParts.join('; ') || '—';
687
+
688
+ rows.push({
689
+ endpoint,
690
+ method,
691
+ authDatasource,
692
+ requestShape,
693
+ expectedStatusMatrix,
694
+ flowSteps,
695
+ concurrencyInvariant,
696
+ });
697
+ }
698
+
699
+ return rows;
700
+ }
701
+
702
+ /**
703
+ * Append a second "API detail" worksheet to the workbook.
704
+ * Called only for api-kind units; no effect on the Testcases sheet or other sheets.
705
+ *
706
+ * Columns: Endpoint · Method · Auth/Datasource · Request shape ·
707
+ * Expected-status matrix · Flow steps · Concurrency invariant
708
+ */
709
+ export function addApiDetailSheet(wb: ExcelJS.Workbook, detailRows: BuilderApiDetailRow[]): void {
710
+ const ws = wb.addWorksheet('API detail');
711
+ const ARIAL_FONT = 'Arial';
712
+
713
+ ws.columns = [
714
+ { header: 'Endpoint', width: 35 },
715
+ { header: 'Method', width: 10 },
716
+ { header: 'Auth / Datasource', width: 22 },
717
+ { header: 'Request shape', width: 40 },
718
+ { header: 'Expected-status matrix', width: 30 },
719
+ { header: 'Flow steps', width: 40 },
720
+ { header: 'Concurrency invariant', width: 35 },
721
+ ];
722
+
723
+ // Style the auto-generated header row (row 1).
724
+ const headerRow = ws.getRow(1);
725
+ headerRow.height = 30;
726
+ headerRow.eachCell((cell) => {
727
+ cell.font = { bold: true, color: { argb: API_DETAIL_HEADER_FONT }, name: ARIAL_FONT };
728
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: API_DETAIL_HEADER_FILL } };
729
+ cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
730
+ applyBorder(cell);
731
+ });
732
+
733
+ ws.views = [{ state: 'frozen', ySplit: 1 }];
734
+
735
+ for (const r of detailRows) {
736
+ const row = ws.addRow([
737
+ r.endpoint,
738
+ r.method,
739
+ r.authDatasource,
740
+ r.requestShape,
741
+ r.expectedStatusMatrix,
742
+ r.flowSteps,
743
+ r.concurrencyInvariant,
744
+ ]);
745
+ row.alignment = { vertical: 'top', wrapText: true };
746
+ row.eachCell({ includeEmpty: true }, (cell) => {
747
+ applyBorder(cell);
748
+ cell.font = { name: ARIAL_FONT };
749
+ });
750
+ }
751
+
752
+ ws.autoFilter = {
753
+ from: { row: 1, column: 1 },
754
+ to: { row: ws.rowCount, column: 7 },
755
+ };
756
+ }
757
+
758
+ // ---------------------------------------------------------------------------
759
+ // High-level entry points — one call builds a whole workbook. These carry the
760
+ // exact behavior of the CLI's `renderXlsx` / `renderXlsxMultiSheet`; the CLI
761
+ // and dashboard wrappers only adapt their domain types and inject opts.
762
+ // ---------------------------------------------------------------------------
763
+
764
+ /**
765
+ * Build a single-sheet testcase workbook (plus an optional trailing
766
+ * "API detail" sheet). Mirrors the CLI's `renderXlsx` — the caller passes
767
+ * `sheetName: 'Testcases'` for the canonical single-sheet delivery.
768
+ */
769
+ export function buildSingleTestcaseWorkbook(
770
+ sheet: BuilderSheet,
771
+ apiDetail?: { rows: BuilderApiDetailRow[] },
772
+ opts?: WorkbookBuildOptions,
773
+ ): ExcelJS.Workbook {
774
+ const wb = new ExcelJS.Workbook();
775
+ wb.creator = 'sungen delivery';
776
+ wb.created = new Date();
777
+ addTestcaseSheet(wb, sheet.sheetName, sheet.summary, sheet.rows, sheet.specLink, opts?.sungenVersion);
778
+ if (apiDetail) {
779
+ addApiDetailSheet(wb, apiDetail.rows);
780
+ }
781
+ return wb;
782
+ }
783
+
784
+ /**
785
+ * Build a multi-sheet testcase workbook: an Overview roll-up first, then one
786
+ * BM-2-901-13 sheet per entry. Mirrors the CLI's `renderXlsxMultiSheet`.
787
+ */
788
+ export function buildTestcaseWorkbook(
789
+ sheets: BuilderSheet[],
790
+ opts?: WorkbookBuildOptions,
791
+ ): ExcelJS.Workbook {
792
+ if (sheets.length === 0) {
793
+ // Message kept verbatim from the original wrapper for behavior stability.
794
+ throw new Error('renderXlsxMultiSheet requires at least one locale sheet');
795
+ }
796
+ const wb = new ExcelJS.Workbook();
797
+ wb.creator = 'sungen delivery';
798
+ wb.created = new Date();
799
+ // Overview first — a per-sheet roll-up (Passed/Failed/Pending/N/A/Remain/Total)
800
+ // plus a grand-total row, so QA sees the whole picture before the detail sheets.
801
+ addOverviewSheet(wb, sheets, opts?.sungenVersion);
802
+ for (const s of sheets) {
803
+ addTestcaseSheet(wb, s.sheetName, s.summary, s.rows, s.specLink, opts?.sungenVersion);
804
+ }
805
+ return wb;
806
+ }