@sun-asterisk/sungen 3.2.4 → 3.2.6

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 (102) hide show
  1. package/dist/cli/commands/dashboard.js +2 -2
  2. package/dist/cli/commands/dashboard.js.map +1 -1
  3. package/dist/cli/commands/delivery.d.ts.map +1 -1
  4. package/dist/cli/commands/delivery.js +22 -5
  5. package/dist/cli/commands/delivery.js.map +1 -1
  6. package/dist/dashboard/snapshot-builder.d.ts +3 -0
  7. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  8. package/dist/dashboard/snapshot-builder.js +47 -19
  9. package/dist/dashboard/snapshot-builder.js.map +1 -1
  10. package/dist/dashboard/templates/index.html +4 -4
  11. package/dist/dashboard/types.d.ts +4 -0
  12. package/dist/dashboard/types.d.ts.map +1 -1
  13. package/dist/exporters/api-testcase-formatter.d.ts +55 -0
  14. package/dist/exporters/api-testcase-formatter.d.ts.map +1 -0
  15. package/dist/exporters/api-testcase-formatter.js +304 -0
  16. package/dist/exporters/api-testcase-formatter.js.map +1 -0
  17. package/dist/exporters/csv-exporter.d.ts +13 -1
  18. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  19. package/dist/exporters/csv-exporter.js +44 -28
  20. package/dist/exporters/csv-exporter.js.map +1 -1
  21. package/dist/exporters/feature-parser.d.ts +6 -0
  22. package/dist/exporters/feature-parser.d.ts.map +1 -1
  23. package/dist/exporters/feature-parser.js +9 -3
  24. package/dist/exporters/feature-parser.js.map +1 -1
  25. package/dist/exporters/json-exporter.d.ts.map +1 -1
  26. package/dist/exporters/json-exporter.js +58 -48
  27. package/dist/exporters/json-exporter.js.map +1 -1
  28. package/dist/exporters/result-variants.d.ts +29 -0
  29. package/dist/exporters/result-variants.d.ts.map +1 -0
  30. package/dist/exporters/result-variants.js +33 -0
  31. package/dist/exporters/result-variants.js.map +1 -0
  32. package/dist/exporters/types.d.ts +7 -0
  33. package/dist/exporters/types.d.ts.map +1 -1
  34. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  35. package/dist/exporters/xlsx-exporter.js +45 -13
  36. package/dist/exporters/xlsx-exporter.js.map +1 -1
  37. package/dist/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
  38. package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
  39. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
  40. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
  41. package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -1
  42. package/dist/generators/test-generator/patterns/expect-patterns.js +23 -0
  43. package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -1
  44. package/dist/generators/test-generator/patterns/index.d.ts +12 -0
  45. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  46. package/dist/generators/test-generator/patterns/index.js +21 -1
  47. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  48. package/dist/generators/test-generator/utils/selector-resolver.d.ts +7 -0
  49. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  50. package/dist/generators/test-generator/utils/selector-resolver.js +13 -2
  51. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  52. package/dist/orchestrator/project-initializer.d.ts +23 -0
  53. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  54. package/dist/orchestrator/project-initializer.js +118 -3
  55. package/dist/orchestrator/project-initializer.js.map +1 -1
  56. package/dist/orchestrator/templates/ai-src/commands/add-api.md +76 -0
  57. package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  58. package/dist/orchestrator/templates/ai-src/config/codex.md +3 -2
  59. package/dist/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
  60. package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
  61. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
  62. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
  63. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
  64. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
  65. package/dist/orchestrator/templates/specs-api.d.ts +26 -0
  66. package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
  67. package/dist/orchestrator/templates/specs-api.js +131 -3
  68. package/dist/orchestrator/templates/specs-api.js.map +1 -1
  69. package/dist/orchestrator/templates/specs-api.ts +118 -5
  70. package/dist/orchestrator/templates/specs-test-data.ts +20 -1
  71. package/package.json +4 -3
  72. package/src/cli/commands/dashboard.ts +2 -2
  73. package/src/cli/commands/delivery.ts +24 -6
  74. package/src/dashboard/snapshot-builder.ts +46 -19
  75. package/src/dashboard/templates/index.html +4 -4
  76. package/src/dashboard/types.ts +5 -1
  77. package/src/exporters/api-testcase-formatter.ts +325 -0
  78. package/src/exporters/csv-exporter.ts +55 -27
  79. package/src/exporters/feature-parser.ts +8 -2
  80. package/src/exporters/json-exporter.ts +56 -47
  81. package/src/exporters/result-variants.ts +47 -0
  82. package/src/exporters/types.ts +7 -0
  83. package/src/exporters/xlsx-exporter.ts +48 -13
  84. package/src/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
  85. package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
  86. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
  87. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
  88. package/src/generators/test-generator/patterns/expect-patterns.ts +23 -0
  89. package/src/generators/test-generator/patterns/index.ts +21 -1
  90. package/src/generators/test-generator/utils/selector-resolver.ts +30 -2
  91. package/src/orchestrator/project-initializer.ts +120 -3
  92. package/src/orchestrator/templates/ai-src/commands/add-api.md +76 -0
  93. package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  94. package/src/orchestrator/templates/ai-src/config/codex.md +3 -2
  95. package/src/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
  96. package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
  97. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
  98. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
  99. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
  100. package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
  101. package/src/orchestrator/templates/specs-api.ts +118 -5
  102. package/src/orchestrator/templates/specs-test-data.ts +20 -1
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Result-variant resolution — the single seam the CSV (delivery) and JSON (dashboard) exporters
3
+ * share so they can never drift on @cases handling again (they did: the dashboard mislabelled
4
+ * every @cases row while delivery expanded them).
5
+ */
6
+ import { MergedScenario } from './scenario-merger';
7
+ import { PlaywrightResult } from './types';
8
+
9
+ export interface ResultVariant {
10
+ /** Appended to the scenario display name: `''` for a plain scenario, ` — <label>` per executed
11
+ * @cases dataset row (so each row gets a distinct category1). */
12
+ nameSuffix: string;
13
+ result?: PlaywrightResult;
14
+ }
15
+
16
+ /**
17
+ * Resolve the execution-result variant(s) for a merged scenario.
18
+ *
19
+ * A `@cases:<dataset>` scenario compiles to one `test()` per dataset row, whose Playwright title is
20
+ * `<scenario> — <label>` — expand into one variant per EXECUTED row by matching that prefix (so an
21
+ * already-run row carries its real pass/fail). A plain scenario resolves its single result by the
22
+ * spec test title. With no results — or a @cases scenario with no matching result yet — a lone
23
+ * variant with no result is returned, so the caller marks it Pending / N/A.
24
+ *
25
+ * NOTE: this deliberately does NOT gate on `m.spec`. For @cases the compiled test title carries a
26
+ * `— ${__row.__label}` suffix, so `findMatchingSpecTest` (base-name match) often leaves `m.spec`
27
+ * null even though the rows ran; the marker match still finds their results. Callers must let a
28
+ * present `result` win over the "not compiled" (`!m.spec`) heuristic.
29
+ */
30
+ export function resolveResultVariants(
31
+ m: MergedScenario,
32
+ results: Map<string, PlaywrightResult> | null,
33
+ ): ResultVariant[] {
34
+ const isCases = m.feature.tags.some((t) => t.startsWith('@cases:'));
35
+ if (isCases && results) {
36
+ const marker = `${m.feature.name} — `;
37
+ const rows = [...results.entries()].filter(([t]) => t.includes(marker));
38
+ if (rows.length) {
39
+ return rows.map(([t, r]) => ({ nameSuffix: ` — ${t.slice(t.indexOf(marker) + marker.length)}`, result: r }));
40
+ }
41
+ return [{ nameSuffix: '' }];
42
+ }
43
+ if (results && m.spec) {
44
+ return [{ nameSuffix: '', result: results.get(m.spec.testTitle) }];
45
+ }
46
+ return [{ nameSuffix: '' }];
47
+ }
@@ -170,5 +170,12 @@ export interface ApiCatalogEntry {
170
170
  description?: string;
171
171
  body?: unknown;
172
172
  params?: unknown;
173
+ /** Per-endpoint header templates (e.g. authorization: "Bearer :token"); `:param` tokens
174
+ * bind at runtime from the same args as the body/path. */
175
+ headers?: Record<string, string>;
176
+ /** Request-body wire format — decides the Content-Type the request sends.
177
+ * Absent/`json` → application/json, `form` → application/x-www-form-urlencoded,
178
+ * `multipart` → multipart/form-data. */
179
+ encoding?: 'json' | 'form' | 'multipart';
173
180
  expect?: { status?: number | string };
174
181
  }
@@ -267,11 +267,29 @@ function addOverviewSheet(wb: ExcelJS.Workbook, sheets: LocaleSheet[]): void {
267
267
  });
268
268
  };
269
269
 
270
+ // Each detail sheet carries a LIVE summary band (row 7): C7 Total · D7 Passed · E7 Failed ·
271
+ // F7 Pending · G7 N/A · H7 Remaining, all COUNTA/COUNTIF over the data area. The Overview
272
+ // REFERENCES those cells (`='<sheet>'!D7`) instead of freezing a number at export time — so when
273
+ // QA adds/edits/removes a row in the Auto or Manual sheet, the roll-up recomputes automatically.
274
+ const qname = (name: string): string => name.replace(/'/g, "''"); // Excel escapes ' by doubling
275
+ const ref = (name: string, cell: string, result: number): ExcelJS.CellFormulaValue =>
276
+ ({ formula: `'${qname(name)}'!${cell}`, result });
277
+
270
278
  let rowIdx = HEADER_ROW + 1;
279
+ const firstDataRow = rowIdx;
271
280
  sheets.forEach((s, i) => {
272
281
  const su = s.summary;
273
282
  const remain = Math.max(0, su.total - su.passed - su.na);
274
- writeDataRow(rowIdx, [i + 1, s.sheetName, su.passed, su.failed, su.pending, su.na, remain, su.total], false);
283
+ writeDataRow(rowIdx, [
284
+ i + 1,
285
+ s.sheetName,
286
+ ref(s.sheetName, 'D7', su.passed),
287
+ ref(s.sheetName, 'E7', su.failed),
288
+ ref(s.sheetName, 'F7', su.pending),
289
+ ref(s.sheetName, 'G7', su.na),
290
+ ref(s.sheetName, 'H7', remain),
291
+ ref(s.sheetName, 'C7', su.total),
292
+ ], false);
275
293
  totals.passed += su.passed;
276
294
  totals.failed += su.failed;
277
295
  totals.pending += su.pending;
@@ -280,10 +298,23 @@ function addOverviewSheet(wb: ExcelJS.Workbook, sheets: LocaleSheet[]): void {
280
298
  totals.total += su.total;
281
299
  rowIdx += 1;
282
300
  });
301
+ const lastDataRow = rowIdx - 1;
283
302
 
303
+ // Grand total = SUM of the per-sheet Overview cells (also live — recomputes with the references
304
+ // above). Overview count columns are D..I (Passed·Failed·Pending·N/A·Remain·Total).
305
+ const sumCol = (col: string, result: number): ExcelJS.CellFormulaValue =>
306
+ ({ formula: `SUM(${col}${firstDataRow}:${col}${lastDataRow})`, result });
284
307
  writeDataRow(
285
308
  rowIdx,
286
- ['', 'Total', totals.passed, totals.failed, totals.pending, totals.na, totals.remain, totals.total],
309
+ [
310
+ '', 'Total',
311
+ sumCol('D', totals.passed),
312
+ sumCol('E', totals.failed),
313
+ sumCol('F', totals.pending),
314
+ sumCol('G', totals.na),
315
+ sumCol('H', totals.remain),
316
+ sumCol('I', totals.total),
317
+ ],
287
318
  true,
288
319
  );
289
320
  }
@@ -313,7 +344,7 @@ function addTestcaseSheet(
313
344
  { width: 17 }, // E — Test Object
314
345
  { width: 25.5 }, // F — Pre-condition
315
346
  { width: 24.88 }, // G — Test Data
316
- { width: 24.88 }, // H — Steps
347
+ { width: 50 }, // H — Steps (widened ~2× — steps are the longest cell)
317
348
  { width: 41 }, // I — Expected results
318
349
  { width: 12 }, // J — Priority
319
350
  { width: 13.5 }, // K — Testcase type
@@ -346,16 +377,18 @@ function addTestcaseSheet(
346
377
  // No explicit height → Excel auto-fits this row's content.
347
378
 
348
379
  // -- Row 7: Counts (cols C..H), live formulas referencing the data area --
349
- // Data table starts at row 15 (col header at row 14, divider at row 14? actually
350
- // header at row 13 + divider at 14 + first data at 15). The formulas read col L
351
- // (Test Result) for status counts, col A (TC ID) for total via COUNTA.
380
+ // The data region begins at row 14 the row immediately AFTER the column-header row (row 13).
381
+ // Start the ranges there (NOT row 15): the first category divider usually sits at row 14 with a
382
+ // BLANK col A (so COUNTA skips it), but a sheet whose first row after the header is real data —
383
+ // no leading divider — would be UNDER-counted by a range that started at 15. Row 14 covers both.
384
+ const COUNT_RANGE_START = 14;
352
385
  const COUNT_RANGE_END = 10000;
353
386
  const r7Values: Record<string, ExcelJS.CellValue> = {
354
- C: { formula: `COUNTA(A15:A${COUNT_RANGE_END})`, result: summary.total } as ExcelJS.CellFormulaValue,
355
- D: { formula: `COUNTIF(L15:L${COUNT_RANGE_END},"Passed")`, result: summary.passed } as ExcelJS.CellFormulaValue,
356
- E: { formula: `COUNTIF(L15:L${COUNT_RANGE_END},"Failed")`, result: summary.failed } as ExcelJS.CellFormulaValue,
357
- F: { formula: `COUNTIF(L15:L${COUNT_RANGE_END},"Pending")`, result: summary.pending } as ExcelJS.CellFormulaValue,
358
- G: { formula: `COUNTIF(L15:L${COUNT_RANGE_END},"N/A")`, result: summary.na } as ExcelJS.CellFormulaValue,
387
+ C: { formula: `COUNTA(A${COUNT_RANGE_START}:A${COUNT_RANGE_END})`, result: summary.total } as ExcelJS.CellFormulaValue,
388
+ D: { formula: `COUNTIF(L${COUNT_RANGE_START}:L${COUNT_RANGE_END},"Passed")`, result: summary.passed } as ExcelJS.CellFormulaValue,
389
+ E: { formula: `COUNTIF(L${COUNT_RANGE_START}:L${COUNT_RANGE_END},"Failed")`, result: summary.failed } as ExcelJS.CellFormulaValue,
390
+ F: { formula: `COUNTIF(L${COUNT_RANGE_START}:L${COUNT_RANGE_END},"Pending")`, result: summary.pending } as ExcelJS.CellFormulaValue,
391
+ G: { formula: `COUNTIF(L${COUNT_RANGE_START}:L${COUNT_RANGE_END},"N/A")`, result: summary.na } as ExcelJS.CellFormulaValue,
359
392
  H: { formula: 'C7-D7-G7', result: summary.failed + summary.pending } as ExcelJS.CellFormulaValue,
360
393
  };
361
394
  for (const [col, val] of Object.entries(r7Values)) {
@@ -435,8 +468,10 @@ function addTestcaseSheet(
435
468
  applyBorder(cell);
436
469
  });
437
470
 
438
- // Freeze everything above and below the header row reveal header when scrolling.
439
- ws.views = [{ state: 'frozen', ySplit: headerRow.number }];
471
+ // No freeze the whole sheet scrolls freely (the masthead + summary band + header scroll away
472
+ // with the data). QA asked for this so the wide Steps/Expected columns aren't pinned under a frozen
473
+ // header on smaller screens.
474
+ ws.views = [{ state: 'normal' }];
440
475
 
441
476
  // -- Data rows grouped by category --
442
477
  const order = ['Accessing', 'GUI', 'Function'];
@@ -1,4 +1,4 @@
1
- it('{{scenarioName}}', async () => {
1
+ it('{{escapeQuotes scenarioName}}', async () => {
2
2
  {{#each steps}}
3
3
  {{#if comment}}
4
4
  // {{comment}}
@@ -1,9 +1,9 @@
1
1
  {{#if casesDataset}}
2
2
  for (const __row of testData.cases('{{casesDataset}}')) {
3
3
  {{#if tags}}
4
- test(`{{scenarioName}} — ${__row.__label}`, { tag: [{{{tags}}}] }, async ({{#if isParallel}}{ page }{{/if}}) => {
4
+ test('{{escapeQuotes scenarioName}} — ' + __row.__label, { tag: [{{{tags}}}] }, async ({{#if isParallel}}{ page }{{/if}}) => {
5
5
  {{else}}
6
- test(`{{scenarioName}} — ${__row.__label}`, async ({{#if isParallel}}{ page }{{/if}}) => {
6
+ test('{{escapeQuotes scenarioName}} — ' + __row.__label, async ({{#if isParallel}}{ page }{{/if}}) => {
7
7
  {{/if}}
8
8
  const rowData = testData.withRow(__row);
9
9
  {{#each steps}}
@@ -17,9 +17,9 @@
17
17
  {{else}}
18
18
  {{#if isParallel}}
19
19
  {{#if tags}}
20
- test('{{scenarioName}}', { tag: [{{{tags}}}] }, async ({ page }) => {
20
+ test('{{escapeQuotes scenarioName}}', { tag: [{{{tags}}}] }, async ({ page }) => {
21
21
  {{else}}
22
- test('{{scenarioName}}', async ({ page }) => {
22
+ test('{{escapeQuotes scenarioName}}', async ({ page }) => {
23
23
  {{/if}}
24
24
  {{#each steps}}
25
25
  {{#if comment}}
@@ -30,9 +30,9 @@
30
30
  });
31
31
  {{else}}
32
32
  {{#if tags}}
33
- test('{{scenarioName}}', { tag: [{{{tags}}}] }, async () => {
33
+ test('{{escapeQuotes scenarioName}}', { tag: [{{{tags}}}] }, async () => {
34
34
  {{else}}
35
- test('{{scenarioName}}', async () => {
35
+ test('{{escapeQuotes scenarioName}}', async () => {
36
36
  {{/if}}
37
37
  {{#each steps}}
38
38
  {{#if comment}}
@@ -1,11 +1,33 @@
1
+ { const el = {{> locator}};
2
+ {{#if expanded.state}}
3
+ {{!-- Toggle-only: component exposes no observable expanded state → click without asserting. --}}
4
+ await el.click();
5
+ {{else if expanded.class}}
6
+ {{!-- State signalled by a class on a descendant (e.g. an accordion chevron icon). --}}
1
7
  {{#if (eq direction 'expand')}}
2
- if (await {{> locator}}.getAttribute('aria-expanded') !== 'true') {
3
- await {{> locator}}.click();
4
- }
5
- await expect({{> locator}}).toHaveAttribute('aria-expanded', 'true');
8
+ if (!(await el.locator('.{{expanded.class}}').count())) { await el.click(); }
9
+ await expect(el.locator('.{{expanded.class}}')).toBeVisible();
10
+ {{else}}
11
+ if (await el.locator('.{{expanded.class}}').count()) { await el.click(); }
12
+ await expect(el.locator('.{{expanded.class}}')).toHaveCount(0);
13
+ {{/if}}
14
+ {{else if expanded.attribute}}
15
+ {{!-- Explicit boolean attribute ('true'/'false'), custom or aria-expanded. --}}
16
+ {{#if (eq direction 'expand')}}
17
+ if (await el.getAttribute('{{expanded.attribute}}') !== 'true') { await el.click(); }
18
+ await expect(el).toHaveAttribute('{{expanded.attribute}}', 'true');
19
+ {{else}}
20
+ if (await el.getAttribute('{{expanded.attribute}}') !== 'false') { await el.click(); }
21
+ await expect(el).toHaveAttribute('{{expanded.attribute}}', 'false');
22
+ {{/if}}
23
+ {{else}}
24
+ {{!-- Default (zero-config): drive by aria-expanded, but assert it ONLY when the element exposes it. --}}
25
+ {{#if (eq direction 'expand')}}
26
+ if (await el.getAttribute('aria-expanded') !== 'true') { await el.click(); }
27
+ if (await el.getAttribute('aria-expanded') !== null) { await expect(el).toHaveAttribute('aria-expanded', 'true'); }
6
28
  {{else}}
7
- if (await {{> locator}}.getAttribute('aria-expanded') !== 'false') {
8
- await {{> locator}}.click();
29
+ if (await el.getAttribute('aria-expanded') !== 'false') { await el.click(); }
30
+ if (await el.getAttribute('aria-expanded') !== null) { await expect(el).toHaveAttribute('aria-expanded', 'false'); }
31
+ {{/if}}
32
+ {{/if}}
9
33
  }
10
- await expect({{> locator}}).toHaveAttribute('aria-expanded', 'false');
11
- {{/if}}
@@ -1 +1,16 @@
1
- await expect({{> locator}}).toHaveAttribute('aria-selected', 'true');
1
+ {{#if (or (eq role 'checkbox') (eq role 'radio') (eq role 'switch'))}}
2
+ {{!-- Form control: "selected" is the checked state, never aria-selected. A native checkbox/radio
3
+ exposes no aria-selected attribute — its state lives in the `checked` property. --}}
4
+ await expect({{> locator}}).toBeChecked();
5
+ {{else}}
6
+ {{!-- Other/unknown role (tab, option, or a text/testid/css selector): probe at runtime. An ARIA
7
+ single-select widget exposes aria-selected → assert it; anything without it falls back to the
8
+ checked state. Never hardcode aria-selected — it breaks on native form controls. --}}
9
+ { const el = {{> locator}};
10
+ if ((await el.getAttribute('aria-selected')) !== null) {
11
+ await expect(el).toHaveAttribute('aria-selected', 'true');
12
+ } else {
13
+ await expect(el).toBeChecked();
14
+ }
15
+ }
16
+ {{/if}}
@@ -17,6 +17,14 @@ import { MappedStep } from '../step-mapper';
17
17
  const VALUE = String.raw`\{\{[^}]+\}\}|"[^"]*"|'[^']*'|-?\d+(?:\.\d+)?`;
18
18
  const reExpect = new RegExp(`^\\s*(?:User\\s+)?expect\\s+(${VALUE})\\s+is\\s+(not\\s+|at\\s+least\\s+|at\\s+most\\s+)?(${VALUE})\\s*$`, 'i');
19
19
 
20
+ /** `expect {{name.body}} matches schema [Ref]` — validate a bound API response body against a named
21
+ * JSON Schema (from qa/api/schemas.yaml, populated by `sungen api import`). Requires the API Driver
22
+ * (the scenario carries `@api:<name>`, so the `api` runtime helper is imported).
23
+ * Two regexes: the MATCHER sees ref contents masked (`{{~}}`, `[~]` — see maskRefsForMatching); the
24
+ * GENERATOR gets the ORIGINAL step text, so it extracts the real `[Ref]`. */
25
+ const reMatchesSchemaMasked = /^\s*(?:User\s+)?expect\s+\{\{~\}\}\s+matches\s+schema\s+\[~\]\s*$/i;
26
+ const reMatchesSchema = new RegExp(`^\\s*(?:User\\s+)?expect\\s+(\\{\\{[^}]+\\}\\})\\s+matches\\s+schema\\s+\\[([A-Za-z0-9_.-]+)\\]\\s*$`, 'i');
27
+
20
28
  /** Render a value token (`{{var}}` | "literal" | 'literal' | number) as a JS expression. */
21
29
  function valueExpr(token: string): string {
22
30
  const t = token.trim();
@@ -29,6 +37,21 @@ function valueExpr(token: string): string {
29
37
  }
30
38
 
31
39
  export const expectPatterns: StepPattern[] = [
40
+ {
41
+ name: 'expect-matches-schema',
42
+ priority: 63, // above expect-data; its regex needs `matches schema`, so no overlap
43
+ matcher: (step: ParsedStep) => reMatchesSchemaMasked.test(step.text),
44
+ generator: (step: ParsedStep, _ctx: PatternContext): MappedStep => {
45
+ // The masked matcher is looser than this extraction regex, so a malformed step (empty var/ref,
46
+ // odd chars) can claim the pattern yet not re-match here — emit a clear TODO instead of crashing.
47
+ const m = step.text.match(reMatchesSchema);
48
+ if (!m) return { code: `// TODO: malformed 'matches schema' step (expected: expect {{name.body}} matches schema [Ref]): ${step.text}`, comment: 'Unrecognized matches-schema step' };
49
+ const value = valueExpr(m[1]);
50
+ const ref = m[2];
51
+ const code = `{ const __schema = api.assertSchema(${value}, ${JSON.stringify(ref)}); expect(__schema.ok, \`matches schema [${ref}]: \${__schema.errors}\`).toBe(true); }`;
52
+ return { code, comment: `Expect ${m[1]} matches schema [${ref}]` };
53
+ },
54
+ },
32
55
  {
33
56
  name: 'expect-data',
34
57
  priority: 62, // above generic see-assertions; sibling of the DB assertions
@@ -4,6 +4,23 @@ import { StepPattern, PatternContext } from './types';
4
4
  import { capabilityRegistry } from '../../../capabilities/registry';
5
5
  import { discoverAndRegisterCapabilities } from '../../../capabilities/discover';
6
6
 
7
+ /**
8
+ * Mask the CONTENT of selector refs `[...]` and data placeholders `{{...}}` before verb matching,
9
+ * keeping the delimiters. A step's verb/keywords come from the SENTENCE — never from a selector or
10
+ * variable NAME — but matchers scan `step.text` with substring checks (e.g. `includes('click')`),
11
+ * so a data ref like `{{double_click_guard_account.email}}` would spuriously satisfy the click
12
+ * matcher and pre-empt fill. The content is replaced with an inert placeholder (`~` — carries no
13
+ * verb/keyword/digit substring) rather than emptied, so structural matchers that require a NON-EMPTY
14
+ * ref survive too — e.g. `expect {{a}} is {{b}}` matches `\{\{[^}]+\}\}`, and `in [X] table` /
15
+ * `/\bto\s+\[/` keep their delimiters. The resolver/generator always receives the ORIGINAL step, so
16
+ * value extraction and codegen are unaffected.
17
+ */
18
+ export function maskRefsForMatching(text: string): string {
19
+ return text
20
+ .replace(/\{\{[^}]*\}\}/g, '{{~}}')
21
+ .replace(/\[[^\]]*\]/g, '[~]');
22
+ }
23
+
7
24
  /**
8
25
  * Pattern Registry - manages all step patterns
9
26
  */
@@ -40,8 +57,11 @@ export class PatternRegistry {
40
57
  * Find matching pattern for a step
41
58
  */
42
59
  findPattern(step: ParsedStep): StepPattern | null {
60
+ // Match against text with ref CONTENTS masked, so a verb substring inside a selector/data name
61
+ // (e.g. "click" in `{{double_click_guard_account.email}}`) can't hijack the verb classification.
62
+ const matchStep: ParsedStep = { ...step, text: maskRefsForMatching(step.text) };
43
63
  for (const pattern of this.patterns) {
44
- if (this.matchesPattern(step, pattern.matcher)) {
64
+ if (this.matchesPattern(matchStep, pattern.matcher)) {
45
65
  return pattern;
46
66
  }
47
67
  }
@@ -39,6 +39,17 @@ interface SelectorEntry {
39
39
  // === v2: Assertion Helpers ===
40
40
  attribute?: string; // Attribute to check (for "has" assertions)
41
41
  pattern?: string; // Regex for attribute value
42
+
43
+ // === Expand/collapse state signal ===
44
+ // How the open/closed state of an expandable element is observed. Many accordions expose no
45
+ // `aria-expanded` (state shows only via an icon class swap), so hardcoding it makes the assertion
46
+ // time out. When omitted, codegen drives by aria-expanded but asserts it ONLY if present — a safe
47
+ // zero-config fallback. Provide one of these to verify a non-aria component explicitly.
48
+ expanded?: {
49
+ class?: string; // class present on a descendant when open (e.g. 'mdi-chevron-up')
50
+ attribute?: string; // boolean attribute ('true'/'false') on the toggle element
51
+ state?: 'none'; // no observable state → toggle-click only, skip the assertion
52
+ };
42
53
  }
43
54
 
44
55
  // New selector file structure: flat key-value pairs
@@ -64,6 +75,12 @@ export interface ResolvedSelector {
64
75
  strategy: 'testid' | 'id' | 'placeholder' | 'label' | 'text' | 'css' | 'role' | 'locator'
65
76
  | 'accessibility-id' | 'xpath' | 'android-uiautomator' | 'ios-predicate';
66
77
  value: string;
78
+ // Original YAML `type:` of the matched entry (before it collapses into a `strategy`). Callers
79
+ // that assign meaning to the entry kind — e.g. page assertions, whose `value` is a URL ONLY when
80
+ // the entry is a `page` selector — must verify this instead of trusting a key match. A key can
81
+ // collide across types (one `読取結果一覧` entry, type `role`, reused for a page assertion), so a
82
+ // key-only lookup can hand back an entry whose `value` ('button') is meaningless as a URL.
83
+ selectorType?: SelectorType;
67
84
  // Mobile per-platform variant (`android:`/`ios:` in selectors.yaml). When set, the appium codegen
68
85
  // emits a runtime `driver.isIOS ? '<ios>' : '<android>'` so one @platform:mobile spec resolves the
69
86
  // right native selector per OS. Each side carries its own strategy+value (issue #392).
@@ -87,6 +104,7 @@ export interface ResolvedSelector {
87
104
  pagination?: Record<string, string>;
88
105
  attribute?: string; // Attribute to check
89
106
  pattern?: string; // Regex for attribute value
107
+ expanded?: { class?: string; attribute?: string; state?: 'none' }; // Expand/collapse state signal
90
108
  }
91
109
 
92
110
  /**
@@ -460,8 +478,14 @@ export class SelectorResolver {
460
478
  const type = entry.type || 'placeholder';
461
479
  // Check if value exists in entry (even if empty string), use it; otherwise use originalLabel
462
480
  const rawValue = entry.value !== undefined && entry.value !== null ? entry.value : originalLabel;
463
- // Check if name exists in entry (even if empty string), use it; otherwise use originalLabel
464
- const rawName = entry.name !== undefined && entry.name !== null ? entry.name : originalLabel;
481
+ // Name resolution has three cases:
482
+ // - key absent (undefined) → fall back to originalLabel (the [Reference] text)
483
+ // - explicit null / empty string → disable the name filter entirely (rawName = '')
484
+ // - a real string → use it verbatim
485
+ // Explicit `name: null` in YAML is a deliberate opt-out (e.g. a role selector that must NOT
486
+ // filter by accessible name); it must not be conflated with "unset". The role template omits
487
+ // `{ name }` when this resolves to a falsy value.
488
+ const rawName = entry.name === undefined ? originalLabel : (entry.name ?? '');
465
489
  const value = SelectorResolver.resolveTemplateVars(rawValue);
466
490
  const name = SelectorResolver.resolveTemplateVars(rawName);
467
491
  const nth = entry.nth !== undefined && entry.nth !== null ? entry.nth : undefined;
@@ -480,10 +504,14 @@ export class SelectorResolver {
480
504
  if (entry.pagination) v2Fields.pagination = entry.pagination;
481
505
  if (entry.attribute) v2Fields.attribute = entry.attribute;
482
506
  if (entry.pattern) v2Fields.pattern = entry.pattern;
507
+ if (entry.expanded) v2Fields.expanded = entry.expanded;
483
508
 
484
509
  // Helper to attach v2 fields and inputMethod
485
510
  const withExtras = (resolved: ResolvedSelector): ResolvedSelector => {
486
511
  if (inputMethod) resolved.inputMethod = inputMethod;
512
+ // Preserve the entry's original YAML type so callers can verify the entry kind (a key match
513
+ // alone does not guarantee the entry means what the step expects — see ResolvedSelector.selectorType).
514
+ resolved.selectorType = type;
487
515
  return { ...resolved, ...v2Fields };
488
516
  };
489
517
 
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import * as fs from 'fs';
7
+ import * as os from 'os';
7
8
  import * as path from 'path';
8
9
  import { execSync } from 'child_process';
9
10
  import { buildOutputs } from './ai-skills/build';
@@ -23,11 +24,77 @@ function sungenVersion(): string {
23
24
  catch { return '0.0.0'; }
24
25
  }
25
26
 
27
+ /**
28
+ * Header written atop a NEW `.codex/config.toml`. Codex loads a PROJECT-scoped config.toml only
29
+ * for TRUSTED projects — without trust the MCP server silently never loads — so the note tells the
30
+ * user how to enable it. (Claude reads `.mcp.json` and Copilot `.vscode/mcp.json` with no such gate.)
31
+ */
32
+ const CODEX_MCP_HEADER = [
33
+ '# Sungen — Codex MCP config (project-scoped, gives run-test the same live browser as Claude/Copilot).',
34
+ '# NOTE: Codex loads a project .codex/config.toml ONLY for TRUSTED projects. If the browser tools',
35
+ '# never appear, trust this project (open it with `codex` and approve, or set trust_level = "trusted"',
36
+ '# for this path in ~/.codex/config.toml). To add Figma too, see https://mcp.figma.com/mcp.',
37
+ '',
38
+ '',
39
+ ].join('\n');
40
+
41
+ /**
42
+ * Append-if-absent merge for Codex's TOML MCP config. Mirrors `mergeMCPConfig`'s "user edits win"
43
+ * contract: preserves the existing file and any user-defined `[mcp_servers.*]`, and only appends the
44
+ * sungen server table when it is missing. Returns `null` when the target server is already present
45
+ * (nothing to write). TOML by hand (no toml dep) — the block is a fixed, simple table.
46
+ */
47
+ export function mergeCodexMcpToml(existing: string, mobileOnly: boolean): string | null {
48
+ const serverKey = mobileOnly ? 'appium-mcp' : 'playwright';
49
+ // Already configured (by the user or a previous run) → preserve, do nothing.
50
+ if (new RegExp(String.raw`^\s*\[mcp_servers\.(?:"?${serverKey}"?)\]`, 'm').test(existing)) return null;
51
+ // startup_timeout_sec: Codex's default is 10s, which `npx <pkg>@latest` blows past on a cold
52
+ // cache (it downloads the package first) — the server then "fails to start" and Codex drops it.
53
+ // default_tools_approval_mode: Codex gates every MCP tool call behind an approval elicitation;
54
+ // non-interactive runs (`codex exec`, full-auto) auto-CANCEL it — "user cancelled MCP tool call"
55
+ // — so run-test could never drive the browser headlessly. Auto-approve is scoped to this server
56
+ // only and the whole file already requires project trust.
57
+ const serverOpts = 'startup_timeout_sec = 120\ndefault_tools_approval_mode = "approve"\n';
58
+ const block = mobileOnly
59
+ ? `[mcp_servers.appium-mcp]\ncommand = "npx"\nargs = ["-y", "appium-mcp"]\n${serverOpts}`
60
+ : `[mcp_servers.playwright]\ncommand = "npx"\nargs = ["@playwright/mcp@latest"]\n${serverOpts}`;
61
+ if (existing.trim() === '') return CODEX_MCP_HEADER + block;
62
+ return existing + (existing.endsWith('\n') ? '\n' : '\n\n') + block;
63
+ }
64
+
65
+ /**
66
+ * Whether the user's global Codex config (`~/.codex/config.toml`) marks `projectDir` trusted.
67
+ * Codex loads a project `.codex/config.toml` ONLY for trusted projects and skips it SILENTLY
68
+ * otherwise — the #1 "Codex doesn't see the Playwright MCP server" failure — so init checks and
69
+ * warns instead of leaving the user with a config that never loads. Pure string scan (no toml dep).
70
+ */
71
+ export function codexTrustsProject(configText: string, projectDir: string): boolean {
72
+ let currentProject: string | null = null;
73
+ const trusted: string[] = [];
74
+ for (const line of configText.split('\n')) {
75
+ const header = line.match(/^\s*\[(.+)\]\s*$/);
76
+ if (header) {
77
+ const m = header[1].trim().match(/^projects\.(?:"(.+)"|'(.+)'|(.+))$/);
78
+ currentProject = m ? (m[1] ?? m[2] ?? m[3]) : null;
79
+ continue;
80
+ }
81
+ if (currentProject && /^\s*trust_level\s*=\s*"trusted"\s*$/.test(line)) trusted.push(currentProject);
82
+ }
83
+ // Codex resolves the project by walking UP from cwd (git root / trusted ancestor), so a dir
84
+ // nested inside a trusted project is itself trusted — verified live: `codex mcp list` inside
85
+ // examples/<new>/ loads its .codex/config.toml with only the repo root in the trust list.
86
+ return trusted.some(
87
+ (dir) => projectDir === dir || projectDir.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep),
88
+ );
89
+ }
90
+
26
91
  export class ProjectInitializer {
27
92
  private baseCwd: string;
28
93
  private cwd: string;
29
94
  private createdItems: string[] = [];
30
95
  private skippedItems: string[] = [];
96
+ /** set by createCodexMCPConfig when the project isn't Codex-trusted (config would never load) */
97
+ private codexTrustWarning = false;
31
98
  private agents: AdapterKey[];
32
99
  private force: boolean;
33
100
  private mobile: MobileInitOptions;
@@ -116,6 +183,12 @@ export class ProjectInitializer {
116
183
  // Create root .mcp.json for Claude Code
117
184
  this.createClaudeMCPConfig();
118
185
 
186
+ // Create .codex/config.toml for Codex CLI — only when Codex is a selected assistant
187
+ // (`sungen init --assistant codex`), since it is Codex-specific config.
188
+ if (this.agents.includes('codex')) {
189
+ this.createCodexMCPConfig();
190
+ }
191
+
119
192
  // Display summary
120
193
  this.displaySummary(normalizedProjectName);
121
194
  }
@@ -309,6 +382,39 @@ export class ProjectInitializer {
309
382
  );
310
383
  }
311
384
 
385
+ /**
386
+ * Create `.codex/config.toml` for Codex CLI — the Codex analog of `.mcp.json` (Claude) and
387
+ * `.vscode/mcp.json` (Copilot), so `sungen run-test` under Codex drives the same live Playwright
388
+ * MCP browser instead of Codex's own (headless) fallback. Mobile-only → appium-mcp. Append-only:
389
+ * preserves an existing file + any user server table (via `mergeCodexMcpToml`).
390
+ */
391
+ private createCodexMCPConfig(): void {
392
+ const filePath = path.join(this.cwd, '.codex', 'config.toml');
393
+ const dir = path.dirname(filePath);
394
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
395
+
396
+ const existed = fs.existsSync(filePath);
397
+ const existing = existed ? fs.readFileSync(filePath, 'utf-8') : '';
398
+ const merged = mergeCodexMcpToml(existing, this.mobileOnly);
399
+ if (merged !== null) {
400
+ fs.writeFileSync(filePath, merged, 'utf-8');
401
+ this.createdItems.push(existed ? '.codex/config.toml (updated)' : '.codex/config.toml');
402
+ } else {
403
+ this.skippedItems.push('.codex/config.toml');
404
+ }
405
+
406
+ // Trust gate: without a trusted entry in the GLOBAL Codex config, the project config above
407
+ // is silently ignored and run-test never gets the MCP browser. Warn in the summary.
408
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
409
+ let trusted = false;
410
+ try {
411
+ trusted = codexTrustsProject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf-8'), this.cwd);
412
+ } catch {
413
+ // no global config yet → definitely untrusted
414
+ }
415
+ this.codexTrustWarning = !trusted;
416
+ }
417
+
312
418
  /**
313
419
  * Add MCP server entries without overwriting user config.
314
420
  * Preserves existing entries with the same key — user edits always win.
@@ -377,6 +483,16 @@ export class ProjectInitializer {
377
483
  });
378
484
  }
379
485
 
486
+ if (this.codexTrustWarning) {
487
+ console.log('\n⚠ Codex: this project is not trusted yet, so .codex/config.toml (the MCP browser');
488
+ console.log(' config for run-test) will be SILENTLY IGNORED by Codex. To fix, either:');
489
+ console.log(' • open the project with `codex` once and approve the trust prompt, or');
490
+ console.log(' • add to ~/.codex/config.toml:');
491
+ console.log(` [projects."${this.cwd}"]`);
492
+ console.log(' trust_level = "trusted"');
493
+ console.log(' Then verify with `codex mcp list` — it should show the sungen MCP server.');
494
+ }
495
+
380
496
  console.log('\nNext steps:');
381
497
  let stepIndex = 1;
382
498
  if (projectName) {
@@ -538,10 +654,11 @@ export class ProjectInitializer {
538
654
  // Ensure standard scripts exist in package.json
539
655
  this.ensurePackageScripts(packageJsonPath);
540
656
 
541
- // Mobile-only drops @playwright/test (no web runner needed).
657
+ // Mobile-only drops @playwright/test (no web runner needed). `ajv`/`ajv-formats` back the API
658
+ // Driver's `expect {{name.body}} matches schema [Ref]` (loaded lazily; harmless if unused).
542
659
  const requiredDeps = this.mobileOnly
543
- ? ['@types/node', 'yaml']
544
- : ['@playwright/test', '@types/node', 'yaml'];
660
+ ? ['@types/node', 'yaml', 'ajv', 'ajv-formats']
661
+ : ['@playwright/test', '@types/node', 'yaml', 'ajv', 'ajv-formats'];
545
662
 
546
663
  let missingDeps: string[] = requiredDeps;
547
664
  try {