@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.
- package/dist/cli/commands/dashboard.js +2 -2
- package/dist/cli/commands/dashboard.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +22 -5
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/dashboard/snapshot-builder.d.ts +3 -0
- package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
- package/dist/dashboard/snapshot-builder.js +47 -19
- package/dist/dashboard/snapshot-builder.js.map +1 -1
- package/dist/dashboard/templates/index.html +4 -4
- package/dist/dashboard/types.d.ts +4 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/exporters/api-testcase-formatter.d.ts +55 -0
- package/dist/exporters/api-testcase-formatter.d.ts.map +1 -0
- package/dist/exporters/api-testcase-formatter.js +304 -0
- package/dist/exporters/api-testcase-formatter.js.map +1 -0
- package/dist/exporters/csv-exporter.d.ts +13 -1
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +44 -28
- 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/types.d.ts +7 -0
- package/dist/exporters/types.d.ts.map +1 -1
- 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/patterns/index.d.ts +12 -0
- package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/index.js +21 -1
- package/dist/generators/test-generator/patterns/index.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 +23 -0
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +118 -3
- 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 +4 -3
- package/src/cli/commands/dashboard.ts +2 -2
- package/src/cli/commands/delivery.ts +24 -6
- package/src/dashboard/snapshot-builder.ts +46 -19
- package/src/dashboard/templates/index.html +4 -4
- package/src/dashboard/types.ts +5 -1
- package/src/exporters/api-testcase-formatter.ts +325 -0
- package/src/exporters/csv-exporter.ts +55 -27
- 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/types.ts +7 -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/patterns/index.ts +21 -1
- package/src/generators/test-generator/utils/selector-resolver.ts +30 -2
- package/src/orchestrator/project-initializer.ts +120 -3
- 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
|
@@ -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
|
+
}
|
package/src/exporters/types.ts
CHANGED
|
@@ -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, [
|
|
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
|
|
@@ -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(
|
|
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
|
-
//
|
|
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';
|
|
@@ -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 {
|