@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.
- package/dist/dashboard/templates/index.html +2 -2
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +20 -26
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts +6 -0
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +9 -3
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/json-exporter.d.ts.map +1 -1
- package/dist/exporters/json-exporter.js +58 -48
- package/dist/exporters/json-exporter.js.map +1 -1
- package/dist/exporters/result-variants.d.ts +29 -0
- package/dist/exporters/result-variants.d.ts.map +1 -0
- package/dist/exporters/result-variants.js +33 -0
- package/dist/exporters/result-variants.js.map +1 -0
- package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.js +45 -13
- package/dist/exporters/xlsx-exporter.js.map +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
- package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/expect-patterns.js +23 -0
- package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts +7 -0
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +13 -2
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/orchestrator/project-initializer.d.ts +9 -0
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +66 -9
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/add-api.md +76 -0
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/dist/orchestrator/templates/ai-src/config/codex.md +3 -2
- package/dist/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
- package/dist/orchestrator/templates/specs-api.d.ts +26 -0
- package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-api.js +131 -3
- package/dist/orchestrator/templates/specs-api.js.map +1 -1
- package/dist/orchestrator/templates/specs-api.ts +118 -5
- package/dist/orchestrator/templates/specs-test-data.ts +20 -1
- package/package.json +3 -3
- package/src/dashboard/templates/index.html +2 -2
- package/src/exporters/csv-exporter.ts +19 -24
- package/src/exporters/feature-parser.ts +8 -2
- package/src/exporters/json-exporter.ts +56 -47
- package/src/exporters/result-variants.ts +47 -0
- package/src/exporters/xlsx-exporter.ts +48 -13
- package/src/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
- package/src/generators/test-generator/patterns/expect-patterns.ts +23 -0
- package/src/generators/test-generator/utils/selector-resolver.ts +30 -2
- package/src/orchestrator/project-initializer.ts +67 -9
- package/src/orchestrator/templates/ai-src/commands/add-api.md +76 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/src/orchestrator/templates/ai-src/config/codex.md +3 -2
- package/src/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
- package/src/orchestrator/templates/specs-api.ts +118 -5
- 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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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, [
|
|
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
|
-
[
|
|
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:
|
|
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
|
-
//
|
|
350
|
-
//
|
|
351
|
-
// (
|
|
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(
|
|
355
|
-
D: { formula: `COUNTIF(
|
|
356
|
-
E: { formula: `COUNTIF(
|
|
357
|
-
F: { formula: `COUNTIF(
|
|
358
|
-
G: { formula: `COUNTIF(
|
|
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
|
-
//
|
|
439
|
-
|
|
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,9 +1,9 @@
|
|
|
1
1
|
{{#if casesDataset}}
|
|
2
2
|
for (const __row of testData.cases('{{casesDataset}}')) {
|
|
3
3
|
{{#if tags}}
|
|
4
|
-
test(
|
|
4
|
+
test('{{escapeQuotes scenarioName}} — ' + __row.__label, { tag: [{{{tags}}}] }, async ({{#if isParallel}}{ page }{{/if}}) => {
|
|
5
5
|
{{else}}
|
|
6
|
-
test(
|
|
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}}
|
package/src/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs
CHANGED
|
@@ -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 {{
|
|
3
|
-
|
|
4
|
-
}
|
|
5
|
-
await
|
|
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
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
464
|
-
|
|
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
|
-
?
|
|
52
|
-
:
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
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 {
|