@sun-asterisk/sungen 3.2.5 → 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 (73) hide show
  1. package/dist/dashboard/templates/index.html +2 -2
  2. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  3. package/dist/exporters/csv-exporter.js +20 -26
  4. package/dist/exporters/csv-exporter.js.map +1 -1
  5. package/dist/exporters/feature-parser.d.ts +6 -0
  6. package/dist/exporters/feature-parser.d.ts.map +1 -1
  7. package/dist/exporters/feature-parser.js +9 -3
  8. package/dist/exporters/feature-parser.js.map +1 -1
  9. package/dist/exporters/json-exporter.d.ts.map +1 -1
  10. package/dist/exporters/json-exporter.js +58 -48
  11. package/dist/exporters/json-exporter.js.map +1 -1
  12. package/dist/exporters/result-variants.d.ts +29 -0
  13. package/dist/exporters/result-variants.d.ts.map +1 -0
  14. package/dist/exporters/result-variants.js +33 -0
  15. package/dist/exporters/result-variants.js.map +1 -0
  16. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  17. package/dist/exporters/xlsx-exporter.js +45 -13
  18. package/dist/exporters/xlsx-exporter.js.map +1 -1
  19. package/dist/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
  20. package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
  21. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
  22. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
  23. package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -1
  24. package/dist/generators/test-generator/patterns/expect-patterns.js +23 -0
  25. package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -1
  26. package/dist/generators/test-generator/utils/selector-resolver.d.ts +7 -0
  27. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  28. package/dist/generators/test-generator/utils/selector-resolver.js +13 -2
  29. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  30. package/dist/orchestrator/project-initializer.d.ts +9 -0
  31. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  32. package/dist/orchestrator/project-initializer.js +66 -9
  33. package/dist/orchestrator/project-initializer.js.map +1 -1
  34. package/dist/orchestrator/templates/ai-src/commands/add-api.md +76 -0
  35. package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  36. package/dist/orchestrator/templates/ai-src/config/codex.md +3 -2
  37. package/dist/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
  38. package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
  39. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
  40. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
  41. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
  42. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
  43. package/dist/orchestrator/templates/specs-api.d.ts +26 -0
  44. package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
  45. package/dist/orchestrator/templates/specs-api.js +131 -3
  46. package/dist/orchestrator/templates/specs-api.js.map +1 -1
  47. package/dist/orchestrator/templates/specs-api.ts +118 -5
  48. package/dist/orchestrator/templates/specs-test-data.ts +20 -1
  49. package/package.json +3 -3
  50. package/src/dashboard/templates/index.html +2 -2
  51. package/src/exporters/csv-exporter.ts +19 -24
  52. package/src/exporters/feature-parser.ts +8 -2
  53. package/src/exporters/json-exporter.ts +56 -47
  54. package/src/exporters/result-variants.ts +47 -0
  55. package/src/exporters/xlsx-exporter.ts +48 -13
  56. package/src/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
  57. package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
  58. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
  59. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
  60. package/src/generators/test-generator/patterns/expect-patterns.ts +23 -0
  61. package/src/generators/test-generator/utils/selector-resolver.ts +30 -2
  62. package/src/orchestrator/project-initializer.ts +67 -9
  63. package/src/orchestrator/templates/ai-src/commands/add-api.md +76 -0
  64. package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  65. package/src/orchestrator/templates/ai-src/config/codex.md +3 -2
  66. package/src/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
  67. package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
  68. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
  69. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
  70. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
  71. package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
  72. package/src/orchestrator/templates/specs-api.ts +118 -5
  73. package/src/orchestrator/templates/specs-test-data.ts +20 -1
@@ -22,6 +22,7 @@ import {
22
22
  } from './playwright-report-parser';
23
23
  import { EnvironmentInfo, PlaywrightResult } from './types';
24
24
  import { SelectorKeyMap, substituteSelectorKeysInStep } from './selector-key-resolver';
25
+ import { resolveResultVariants } from './result-variants';
25
26
  import {
26
27
  ScenarioSnapshot,
27
28
  ScreenSnapshot,
@@ -62,11 +63,6 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
62
63
  let fallbackIndex = 1;
63
64
 
64
65
  for (const m of input.merged) {
65
- const { vpId, category1 } = splitVpAndName(m.feature.name);
66
- const tcId = generateTcId(input.screen, vpId, fallbackIndex);
67
- if (!vpId) fallbackIndex++;
68
-
69
- const category2 = mapVpToCategory2(vpId, category1);
70
66
  const priority = extractPriority(m.feature.tags) as ScenarioSnapshot['priority'];
71
67
  const baseType = extractTestcaseType(m.feature.tags);
72
68
  const authRole = extractAuthRole(m.feature.tags);
@@ -87,27 +83,32 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
87
83
  // key/value per line instead of the CSV-friendly "; " join.
88
84
  const testDataStr = formatTestData(m.feature.referencedVars, input.testData, Infinity, '\n');
89
85
 
90
- let result: PlaywrightResult | undefined;
91
- if (input.results && m.spec) {
92
- result = input.results.get(m.spec.testTitle);
93
- }
86
+ // @cases scenarios expand to one snapshot entry per executed dataset row (shared with the CSV
87
+ // exporter via resolveResultVariants), so their per-row pass/fail is counted — not collapsed
88
+ // into one Pending/N/A entry as before.
89
+ const variants = resolveResultVariants(m, input.results ?? null);
90
+ for (const variant of variants) {
91
+ const displayName = `${m.feature.name}${variant.nameSuffix}`;
92
+ const { vpId, category1 } = splitVpAndName(displayName);
93
+ const tcId = generateTcId(input.screen, vpId, fallbackIndex);
94
+ if (!vpId) fallbackIndex++;
95
+ const category2 = mapVpToCategory2(vpId, category1);
96
+ const result = variant.result;
94
97
 
95
- let status: ScenarioSnapshot['status'];
96
- let executedDate: string | undefined;
97
- let errorMessage: string | undefined;
98
- let tracePath: string | undefined;
99
- let testEnvironment: string | undefined;
100
- let testExecutor: string | undefined;
101
- let type: ScenarioSnapshot['type'];
98
+ let status: ScenarioSnapshot['status'];
99
+ let executedDate: string | undefined;
100
+ let errorMessage: string | undefined;
101
+ let tracePath: string | undefined;
102
+ let testEnvironment: string | undefined;
103
+ let testExecutor: string | undefined;
104
+ let type: ScenarioSnapshot['type'];
102
105
 
103
- if (!m.spec) {
104
- type = baseType === 'Manual' ? 'Manual' : 'Not compiled';
105
- status = baseType === 'Manual' ? 'Pending' : 'N/A';
106
- } else {
107
- type = baseType;
108
- if (!result) {
109
- status = 'Pending';
110
- } else {
106
+ if (result) {
107
+ // A result exists the scenario compiled AND ran; authoritative over the `!m.spec`
108
+ // "not compiled" heuristic. A @cases test title carries a `— <label>` suffix, so
109
+ // findMatchingSpecTest (base-name match) often leaves m.spec null even though rows ran —
110
+ // without checking `result` first, those passed/failed rows were mislabelled N/A / Pending.
111
+ type = baseType;
111
112
  status = statusToTestResult(result.status) as ScenarioSnapshot['status'];
112
113
  executedDate = formatExecutedDate(result.startTime);
113
114
  if (result.error) {
@@ -126,31 +127,39 @@ export function buildScreenSnapshot(input: BuildScreenSnapshotInput): ScreenSnap
126
127
  if (result.tracePath) tracePath = result.tracePath;
127
128
  testEnvironment = formatTestEnvironment(input.env.baseURL, input.featurePath, result.projectName || input.env.projectName);
128
129
  testExecutor = input.env.executor;
130
+ } else if (!m.spec) {
131
+ // No result AND no compiled test → Manual if @manual, else genuinely not compiled.
132
+ type = baseType === 'Manual' ? 'Manual' : 'Not compiled';
133
+ status = baseType === 'Manual' ? 'Pending' : 'N/A';
134
+ } else {
135
+ // Compiled but no execution record → Pending.
136
+ type = baseType;
137
+ status = 'Pending';
129
138
  }
130
- }
131
139
 
132
- scenarios.push({
133
- tcId,
134
- category1,
135
- category2,
136
- category3: input.featureName,
137
- category4: input.screen,
138
- featureBaseName: input.featureBaseName ?? input.screen,
139
- vpId,
140
- priority,
141
- type,
142
- status,
143
- tags: m.feature.tags,
144
- precondition,
145
- testData: testDataStr,
146
- steps,
147
- expectedResults,
148
- executedDate,
149
- errorMessage,
150
- tracePath,
151
- testEnvironment,
152
- testExecutor,
153
- });
140
+ scenarios.push({
141
+ tcId,
142
+ category1,
143
+ category2,
144
+ category3: input.featureName,
145
+ category4: input.screen,
146
+ featureBaseName: input.featureBaseName ?? input.screen,
147
+ vpId,
148
+ priority,
149
+ type,
150
+ status,
151
+ tags: m.feature.tags,
152
+ precondition,
153
+ testData: testDataStr,
154
+ steps,
155
+ expectedResults,
156
+ executedDate,
157
+ errorMessage,
158
+ tracePath,
159
+ testEnvironment,
160
+ testExecutor,
161
+ });
162
+ }
154
163
  }
155
164
 
156
165
  return {
@@ -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
+ }
@@ -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
@@ -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';
@@ -47,18 +48,53 @@ export function mergeCodexMcpToml(existing: string, mobileOnly: boolean): string
47
48
  const serverKey = mobileOnly ? 'appium-mcp' : 'playwright';
48
49
  // Already configured (by the user or a previous run) → preserve, do nothing.
49
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';
50
58
  const block = mobileOnly
51
- ? '[mcp_servers.appium-mcp]\ncommand = "npx"\nargs = ["-y", "appium-mcp"]\n'
52
- : '[mcp_servers.playwright]\ncommand = "npx"\nargs = ["@playwright/mcp@latest"]\n';
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}`;
53
61
  if (existing.trim() === '') return CODEX_MCP_HEADER + block;
54
62
  return existing + (existing.endsWith('\n') ? '\n' : '\n\n') + block;
55
63
  }
56
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
+
57
91
  export class ProjectInitializer {
58
92
  private baseCwd: string;
59
93
  private cwd: string;
60
94
  private createdItems: string[] = [];
61
95
  private skippedItems: string[] = [];
96
+ /** set by createCodexMCPConfig when the project isn't Codex-trusted (config would never load) */
97
+ private codexTrustWarning = false;
62
98
  private agents: AdapterKey[];
63
99
  private force: boolean;
64
100
  private mobile: MobileInitOptions;
@@ -360,12 +396,23 @@ export class ProjectInitializer {
360
396
  const existed = fs.existsSync(filePath);
361
397
  const existing = existed ? fs.readFileSync(filePath, 'utf-8') : '';
362
398
  const merged = mergeCodexMcpToml(existing, this.mobileOnly);
363
- if (merged === null) {
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 {
364
403
  this.skippedItems.push('.codex/config.toml');
365
- return;
366
404
  }
367
- fs.writeFileSync(filePath, merged, 'utf-8');
368
- this.createdItems.push(existed ? '.codex/config.toml (updated)' : '.codex/config.toml');
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;
369
416
  }
370
417
 
371
418
  /**
@@ -436,6 +483,16 @@ export class ProjectInitializer {
436
483
  });
437
484
  }
438
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
+
439
496
  console.log('\nNext steps:');
440
497
  let stepIndex = 1;
441
498
  if (projectName) {
@@ -597,10 +654,11 @@ export class ProjectInitializer {
597
654
  // Ensure standard scripts exist in package.json
598
655
  this.ensurePackageScripts(packageJsonPath);
599
656
 
600
- // 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).
601
659
  const requiredDeps = this.mobileOnly
602
- ? ['@types/node', 'yaml']
603
- : ['@playwright/test', '@types/node', 'yaml'];
660
+ ? ['@types/node', 'yaml', 'ajv', 'ajv-formats']
661
+ : ['@playwright/test', '@types/node', 'yaml', 'ajv', 'ajv-formats'];
604
662
 
605
663
  let missingDeps: string[] = requiredDeps;
606
664
  try {