@sun-asterisk/sungen 3.2.19 → 3.2.20-beta.2
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/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +8 -0
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +7 -0
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +6 -0
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +9 -1
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/export.d.ts.map +1 -1
- package/dist/exporters/matrix/export.js +11 -0
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
- package/dist/exporters/matrix/render-xlsx.js +15 -0
- package/dist/exporters/matrix/render-xlsx.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +2 -0
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
- package/dist/exporters/playwright-report-parser.js +1 -0
- package/dist/exporters/playwright-report-parser.js.map +1 -1
- package/dist/exporters/test-data-resolver.d.ts +15 -0
- package/dist/exporters/test-data-resolver.d.ts.map +1 -1
- package/dist/exporters/test-data-resolver.js +34 -1
- package/dist/exporters/test-data-resolver.js.map +1 -1
- package/dist/exporters/types.d.ts +2 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts +1 -0
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts +1 -0
- package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/appium/appium-adapter.js.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +1 -0
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/navigation.hbs +4 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +11 -6
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +9 -1
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +10 -1
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +9 -0
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts +24 -0
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +53 -0
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/generators/test-generator/template-engine.d.ts +1 -0
- package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
- package/dist/generators/test-generator/template-engine.js +1 -1
- package/dist/generators/test-generator/template-engine.js.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.d.ts +11 -0
- package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.js +20 -0
- package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
- package/dist/harness/audit.d.ts +2 -0
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +72 -9
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/flow-contract.d.ts +71 -0
- package/dist/harness/flow-contract.d.ts.map +1 -0
- package/dist/harness/flow-contract.js +235 -0
- package/dist/harness/flow-contract.js.map +1 -0
- package/dist/harness/flow-plan.d.ts +3 -0
- package/dist/harness/flow-plan.d.ts.map +1 -1
- package/dist/harness/flow-plan.js +6 -2
- package/dist/harness/flow-plan.js.map +1 -1
- package/dist/harness/parse.d.ts +5 -0
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +29 -1
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/perf.d.ts +40 -0
- package/dist/harness/perf.d.ts.map +1 -0
- package/dist/harness/perf.js +136 -0
- package/dist/harness/perf.js.map +1 -0
- package/dist/harness/script-check.d.ts.map +1 -1
- package/dist/harness/script-check.js +9 -1
- package/dist/harness/script-check.js.map +1 -1
- package/dist/harness/secret-scan.d.ts.map +1 -1
- package/dist/harness/secret-scan.js +13 -1
- package/dist/harness/secret-scan.js.map +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +13 -1
- package/dist/harness/sensors.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/add-flow.md +41 -3
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +6 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +33 -5
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +6 -3
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +35 -16
- package/dist/orchestrator/templates/qa-context.md +14 -1
- package/dist/orchestrator/templates/specs-url-assert.d.ts +67 -0
- package/dist/orchestrator/templates/specs-url-assert.d.ts.map +1 -0
- package/dist/orchestrator/templates/specs-url-assert.js +121 -0
- package/dist/orchestrator/templates/specs-url-assert.js.map +1 -0
- package/dist/orchestrator/templates/specs-url-assert.ts +134 -0
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +8 -0
- package/src/cli/commands/delivery.ts +6 -0
- package/src/cli/commands/generate.ts +5 -0
- package/src/exporters/matrix/build.ts +9 -1
- package/src/exporters/matrix/export.ts +11 -0
- package/src/exporters/matrix/render-xlsx.ts +15 -0
- package/src/exporters/matrix/types.ts +2 -0
- package/src/exporters/playwright-report-parser.ts +2 -0
- package/src/exporters/test-data-resolver.ts +34 -1
- package/src/exporters/types.ts +2 -0
- package/src/generators/test-generator/adapters/adapter-interface.ts +1 -1
- package/src/generators/test-generator/adapters/appium/appium-adapter.ts +1 -1
- package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/navigation.hbs +4 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +11 -6
- package/src/generators/test-generator/code-generator.ts +10 -2
- package/src/generators/test-generator/diagnostics.ts +10 -1
- package/src/generators/test-generator/step-mapper.ts +54 -0
- package/src/generators/test-generator/template-engine.ts +2 -2
- package/src/generators/test-generator/utils/data-resolver.ts +19 -0
- package/src/harness/audit.ts +75 -10
- package/src/harness/flow-contract.ts +229 -0
- package/src/harness/flow-plan.ts +10 -3
- package/src/harness/parse.ts +31 -1
- package/src/harness/perf.ts +112 -0
- package/src/harness/script-check.ts +9 -1
- package/src/harness/secret-scan.ts +12 -1
- package/src/harness/sensors.ts +13 -1
- package/src/orchestrator/templates/ai-src/commands/add-flow.md +41 -3
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +6 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +33 -5
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +6 -3
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +35 -16
- package/src/orchestrator/templates/qa-context.md +14 -1
- package/src/orchestrator/templates/specs-url-assert.ts +134 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
/**
|
|
3
|
+
* Sungen URL assertion helper — auto-generated into specs/url-assert.ts. DO NOT EDIT.
|
|
4
|
+
*
|
|
5
|
+
* Every Then-side URL assertion (`see [X] page`, `is on [X] page`) compiles to one call here.
|
|
6
|
+
*
|
|
7
|
+
* Why a helper and not an inline predicate. The predicate used to be emitted inline into every
|
|
8
|
+
* spec, so each correction to what "the URL matches" MEANS was a breaking change for every
|
|
9
|
+
* generated suite in the field: regenerate everything, re-review every newly-red test. Behind a
|
|
10
|
+
* helper the same correction ships as a runtime patch. The generator's job shrinks to what it
|
|
11
|
+
* alone knows — the pathname pattern — and the semantics live in one readable place.
|
|
12
|
+
*
|
|
13
|
+
* Why a predicate and not two assertions. The shape before this one was
|
|
14
|
+
* await expect(page).toHaveURL(/\/vi\/search/); // unanchored: passes on /vi/search-x
|
|
15
|
+
* expect(page.url()).toContain('<query>'); // synchronous, one-shot: never retries
|
|
16
|
+
* — two half-checks, neither anchored, and nothing ever compared pathname and query together.
|
|
17
|
+
* `toHaveURL(predicate)` is web-first (it retries like any other assertion), receives a parsed
|
|
18
|
+
* URL, and needs no regex escaping of runtime values, which is what forced the split.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface UrlExpectation {
|
|
22
|
+
/**
|
|
23
|
+
* Anchored pathname pattern, compiled by the generator from the page selector's `value`
|
|
24
|
+
* (`:id` segments already wildcarded). Anchored against `u.pathname` ONLY: pathname never
|
|
25
|
+
* carries the query, so `^…$` is exact there — the same source that was unsafe to anchor
|
|
26
|
+
* against a full URL.
|
|
27
|
+
*/
|
|
28
|
+
pathname: RegExp;
|
|
29
|
+
/**
|
|
30
|
+
* The `with {{v}}` value, raw from test-data: '?q=1' · '/vi/search?q=1' · '#tab' · ''.
|
|
31
|
+
* `undefined` means the step declared nothing, which is itself an assertion — see below.
|
|
32
|
+
*/
|
|
33
|
+
declared?: string;
|
|
34
|
+
/** The test-data key behind `declared`, so a failure can name what an author must go fix. */
|
|
35
|
+
dataRef?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Join a page's URL with the `with {{v}}` value for a NAVIGATION step
|
|
40
|
+
* (`Given User is on [X] page with {{v}}`).
|
|
41
|
+
*
|
|
42
|
+
* Why this is not a plain `base + path + value`. It used to be, and an author writing the value as a
|
|
43
|
+
* whole relative URL — the habit the assertion side already had to accommodate — navigated to
|
|
44
|
+
* `/vi/search/vi/search?q=1`: the page's own path, twice. The Then side then asserted the real path
|
|
45
|
+
* correctly, so the scenario could never go green.
|
|
46
|
+
*
|
|
47
|
+
* `base` (the configured baseURL, possibly '') and `path` (the page selector's value) arrive
|
|
48
|
+
* SEPARATELY so a value that carries its own rooted path replaces the page's path without also
|
|
49
|
+
* discarding a path prefix baked into the baseURL — a deployment under `https://host/tenant-a`
|
|
50
|
+
* would otherwise navigate to the wrong tenant, and only when a `with {{v}}` was present, which is
|
|
51
|
+
* exactly the kind of inconsistency nobody would think to look for.
|
|
52
|
+
*
|
|
53
|
+
* What it deliberately does NOT do is sanitize. A test may legitimately drive a MALFORMED query to
|
|
54
|
+
* prove the app rejects it (issue #464), so anything that is not a duplicated path is appended
|
|
55
|
+
* verbatim: no encoding, no normalising, no reordering.
|
|
56
|
+
*/
|
|
57
|
+
export function joinPath(base: string, path: string, declared?: string | null): string {
|
|
58
|
+
if (declared === undefined || declared === null || declared === '') return base + path;
|
|
59
|
+
const value = String(declared);
|
|
60
|
+
// A query/fragment appends — the overwhelmingly common shape, and the only one #464 needs.
|
|
61
|
+
if (value.startsWith('?') || value.startsWith('#')) return base + path + value;
|
|
62
|
+
// An absolute URL replaces everything: the author pasted a full address.
|
|
63
|
+
if (/^[a-z]+:\/\//i.test(value)) return value;
|
|
64
|
+
// A rooted path carries its own path, so it replaces the PAGE's path while everything the
|
|
65
|
+
// baseURL contributed — scheme, host, and any path prefix — is kept.
|
|
66
|
+
if (value.startsWith('/')) return base + value;
|
|
67
|
+
// Anything else is raw input the test wants appended as-is.
|
|
68
|
+
return base + path + value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `u.pathname` is percent-encoded; a regex built from a selector's literal `value` is not, so a
|
|
73
|
+
* non-ASCII route (`/読取結果一覧`) could never match. Decoding can throw on a lone `%`, in which
|
|
74
|
+
* case the raw form is all there is.
|
|
75
|
+
*/
|
|
76
|
+
function decodedPath(pathname: string): string {
|
|
77
|
+
try {
|
|
78
|
+
return decodeURIComponent(pathname);
|
|
79
|
+
} catch {
|
|
80
|
+
return pathname;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the predicate for `expect(page).toHaveURL(...)`.
|
|
86
|
+
*
|
|
87
|
+
* Query semantics are deliberately asymmetric:
|
|
88
|
+
* no `with {{v}}` → the URL must carry NO query. A bare page means a bare page, so
|
|
89
|
+
* `/vi/search` does not pass on `/vi/search?x=1`.
|
|
90
|
+
* `with {{v}}` → every declared param must match; extra params are tolerated, so a
|
|
91
|
+
* tracking/UTM param the test never cared about cannot turn it red.
|
|
92
|
+
* A declared fragment is asserted too; a declared value carrying no query
|
|
93
|
+
* at all constrains the path only (the compiler warns: SG-W014).
|
|
94
|
+
*/
|
|
95
|
+
export function urlMatches({ pathname, declared, dataRef }: UrlExpectation): (u: URL) => boolean {
|
|
96
|
+
return (u: URL): boolean => {
|
|
97
|
+
if (!pathname.test(u.pathname) && !pathname.test(decodedPath(u.pathname))) return false;
|
|
98
|
+
|
|
99
|
+
// `dataRef`, not `declared`, decides which rule applies. Both "the step declared nothing" and
|
|
100
|
+
// "the step declared {{v}} but the key resolved to nothing at runtime" arrive here as
|
|
101
|
+
// undefined, and they mean opposite things: the first is a real assertion (a bare page must be
|
|
102
|
+
// bare), the second is broken test-data. Only the generator knows which, and it says so by
|
|
103
|
+
// emitting `dataRef` exactly when the step carried a `with {{v}}`.
|
|
104
|
+
if (!dataRef) return u.search === '';
|
|
105
|
+
|
|
106
|
+
// Silence is the whole bug class this assertion exists to kill, so an unusable declared value
|
|
107
|
+
// is an ERROR, not a quiet verdict. Returning false would read as "the app is wrong"; these
|
|
108
|
+
// throws say "your test-data is wrong, and here is the key". An empty value used to parse to
|
|
109
|
+
// an empty param list, and `[].every()` is vacuously TRUE — it passed on any query at all.
|
|
110
|
+
if (declared === undefined || declared === null) {
|
|
111
|
+
throw new Error(`url assertion: test-data key "${dataRef}" is missing — nothing to assert`);
|
|
112
|
+
}
|
|
113
|
+
if (String(declared).trim() === '') {
|
|
114
|
+
throw new Error(`url assertion: test-data key "${dataRef}" is empty — nothing to assert`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Parsing against the live pathname as base lets ONE expression handle every shape an author
|
|
118
|
+
// writes: '?q=1' (query only), '/vi/search?q=1' (a whole relative URL — its path is ignored,
|
|
119
|
+
// the page selector already owns the path), and '#tab' (fragment only). The predecessor
|
|
120
|
+
// regex-stripped the prefix instead, which silently turned a value with NO query
|
|
121
|
+
// ('/vi/search') into a bogus param NAME — an assertion that could never be satisfied.
|
|
122
|
+
const d = new URL(String(declared), 'http://sungen.invalid' + u.pathname);
|
|
123
|
+
|
|
124
|
+
// getAll, not get: a multi-select filter (?tag=a&tag=b) is the exact case this assertion is
|
|
125
|
+
// for, and `get()` returns only the first value — so the correct URL failed. Comparing as a
|
|
126
|
+
// subset per key keeps this consistent with "extra params are tolerated", and makes param
|
|
127
|
+
// order meaningless, which is what a URL actually means.
|
|
128
|
+
const queryOk = [...d.searchParams].every(([k, v]) => u.searchParams.getAll(k).includes(v));
|
|
129
|
+
|
|
130
|
+
// Only assert a fragment the author actually declared.
|
|
131
|
+
const hashOk = !d.hash || d.hash === u.hash;
|
|
132
|
+
return queryOk && hashOk;
|
|
133
|
+
};
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sun-asterisk/sungen",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.20-beta.2",
|
|
4
4
|
"description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"@babel/types": "^7.28.5",
|
|
40
40
|
"@cucumber/gherkin": "^37.0.0",
|
|
41
41
|
"@cucumber/messages": "^31.0.0",
|
|
42
|
-
"@sungen/driver-data-factory": "3.2.
|
|
43
|
-
"@sungen/driver-ui": "3.2.
|
|
42
|
+
"@sungen/driver-data-factory": "3.2.20-beta.2",
|
|
43
|
+
"@sungen/driver-ui": "3.2.20-beta.2",
|
|
44
44
|
"chalk": "^5.6.2",
|
|
45
45
|
"commander": "^14.0.2",
|
|
46
46
|
"dotenv": "^17.2.3",
|
|
@@ -40,6 +40,14 @@ function render(r: AuditReport): void {
|
|
|
40
40
|
if (r.gate.gaps.length) for (const g of r.gate.gaps) L(` ✗ ${g.status === 'shallow' ? 'SHALLOW' : 'MISSING'}: ${g.theme}`);
|
|
41
41
|
else L(' ✓ all critical themes covered (with data assertions)');
|
|
42
42
|
L('');
|
|
43
|
+
if (r.flowQuality?.hasContract) {
|
|
44
|
+
const fq = r.flowQuality;
|
|
45
|
+
L(` ①F Flow contract — goal: ${fq.contract!.goal}`);
|
|
46
|
+
L(` outcome [${fq.contract!.outcome.screen}]: ${fq.outcomeProven ? '✓ proven (automated data assertion)' : fq.outcomeManualOnly ? '⚠ manual-only' : '✗ UNPROVEN'}`);
|
|
47
|
+
L(` phases: ${fq.phases.map((p) => `${p.phase}=${p.covered ? (p.automated ? '✓' : 'manual') : '✗'}`).join(' ')} → flowCoverage ${(fq.phaseRatio * 100).toFixed(0)}%`);
|
|
48
|
+
L(` handoffs asserted: ${fq.handoffs.asserted}/${fq.handoffs.total}${fq.offGoal.length ? ` ⚠ off-goal scenarios: ${fq.offGoal.length} (${fq.offGoalCategories.join(', ')})` : ''}`);
|
|
49
|
+
L('');
|
|
50
|
+
}
|
|
43
51
|
L(` ② Assertion depth — ${r.depth.businessCriticalShallow}/${r.depth.businessCriticalTotal} business-critical scenarios are shallow (visibility/nav only)`);
|
|
44
52
|
for (const s of r.depth.shallowBusinessCritical.slice(0, 8)) L(` ⚠ ${s.category}: ${s.name}`);
|
|
45
53
|
if (r.depth.shallowBusinessCritical.length > 8) L(` … +${r.depth.shallowBusinessCritical.length - 8} more`);
|
|
@@ -688,6 +688,12 @@ async function exportMatrixTarget(
|
|
|
688
688
|
}
|
|
689
689
|
|
|
690
690
|
log(`${COLOR.bold}${paths.label}${COLOR.reset} — ${model.manifest.itemCount} delivery item(s), ${model.manifest.variantCount} coverage variant(s), state: ${model.manifest.approvalState}`);
|
|
691
|
+
if (model.perf) {
|
|
692
|
+
const p = model.perf;
|
|
693
|
+
const tone = p.pass ? COLOR.green : COLOR.yellow;
|
|
694
|
+
log(` ${tone}PERF ${p.pass ? 'PASS' : 'OVER BUDGET'}${COLOR.reset}: p${p.percentile} ${p.metric.replace('_ms', '')} ${p.measuredMs}ms ${p.pass ? '≤' : '>'} budget ${p.budgetMs}ms (${p.samples} run${p.samples === 1 ? '' : 's'})`);
|
|
695
|
+
for (const s of p.slowest) log(` ${COLOR.yellow}slowest:${COLOR.reset} ${s.ms}ms — ${s.title.slice(0, 90)}`);
|
|
696
|
+
}
|
|
691
697
|
printFindings(model.findings);
|
|
692
698
|
|
|
693
699
|
if (opts.check) {
|
|
@@ -208,9 +208,14 @@ export function registerGenerateCommand(program: Command): void {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
// Security S0 — warn (never block) if test-data looks to hold a real secret.
|
|
211
|
+
// Scoped to the unit being generated: --area used to fall through to the
|
|
212
|
+
// enumerate-everything branch, so `generate --area X` printed OTHER units'
|
|
213
|
+
// secret-scan and dead-data lint findings under X's run (field report: one
|
|
214
|
+
// unit's "service_url is dead data" warning repeated on all 10 API units).
|
|
211
215
|
const scanDirs: string[] = [];
|
|
212
216
|
if (screenName) scanDirs.push(path.join(process.cwd(), 'qa', 'screens', screenName));
|
|
213
217
|
else if (flowName) scanDirs.push(path.join(process.cwd(), 'qa', 'flows', flowName));
|
|
218
|
+
else if (apiName) scanDirs.push(path.join(process.cwd(), 'qa', 'api', apiName));
|
|
214
219
|
else {
|
|
215
220
|
// qa/api units are yaml sinks too (apis.yaml headers, mock bodies once shared);
|
|
216
221
|
// api/flows nests one level deeper. A file entry in the list is harmless —
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
splitVpAndName,
|
|
16
16
|
} from '../feature-parser';
|
|
17
17
|
import { getCasesDatasetRows, resolveResultVariants } from '../result-variants';
|
|
18
|
+
import { renderDynamicTokens } from '../test-data-resolver';
|
|
18
19
|
import { classifyManualComments, renderAction, renderExpected, renderPrecondition, renderSetupInstruction, runtimeVarsOf } from './wording';
|
|
19
20
|
import { scenarioFingerprint, combinedFingerprint, mapContentFingerprint } from './fingerprint';
|
|
20
21
|
import {
|
|
@@ -265,7 +266,7 @@ function substituteDisplayVars(
|
|
|
265
266
|
// `remember … as {{v}}` DECLARES v — leave that token for renderAction, which
|
|
266
267
|
// turns it into a quoted name ("note it as …"), not a value to look up.
|
|
267
268
|
const isCapture = /\bremember\b/i.test(text);
|
|
268
|
-
|
|
269
|
+
const out = text.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (m, key: string, offset: number) => {
|
|
269
270
|
if (isCapture && /\bas\s+$/i.test(text.slice(0, offset))) return m;
|
|
270
271
|
// A value the test produces at run time has no static form — name it so the
|
|
271
272
|
// tester knows to compare against what the earlier step captured.
|
|
@@ -273,6 +274,9 @@ function substituteDisplayVars(
|
|
|
273
274
|
if (!(key in vars)) return m; // unknown stays literal → Gate D flags it
|
|
274
275
|
return displayValue(vars[key]);
|
|
275
276
|
});
|
|
277
|
+
// A dynamic token written straight into a step (`{{$uuid}}`) never reaches
|
|
278
|
+
// displayValue — name it here so the same wording covers both routes.
|
|
279
|
+
return renderDynamicTokens(out);
|
|
276
280
|
}
|
|
277
281
|
|
|
278
282
|
/**
|
|
@@ -281,6 +285,10 @@ function substituteDisplayVars(
|
|
|
281
285
|
* The canonical value is never changed — only its presentation.
|
|
282
286
|
*/
|
|
283
287
|
export function displayValue(v: string): string {
|
|
288
|
+
// Runtime-generated tokens nested in a value ("Summary intro {{$timestamp}}")
|
|
289
|
+
// are presentation, not canon — name them before the fidelity checks below,
|
|
290
|
+
// which judge the value's real whitespace.
|
|
291
|
+
v = renderDynamicTokens(v);
|
|
284
292
|
if (v === '') return '(empty)';
|
|
285
293
|
if (/^\s+$/.test(v)) return `(${v.length} space${v.length > 1 ? 's' : ''})`;
|
|
286
294
|
if (v !== v.trim()) return `"${v}"`;
|
|
@@ -16,6 +16,7 @@ import { mergeFeatureAndSpec } from '../scenario-merger';
|
|
|
16
16
|
import { getPackageVersion } from '../package-info';
|
|
17
17
|
import { writeCsv } from '../csv-exporter';
|
|
18
18
|
import { writeXlsx } from '../xlsx-exporter';
|
|
19
|
+
import { loadPerfConfig, perfVerdict } from '../../harness/perf';
|
|
19
20
|
import { loadDeliveryMap, writeDeliveryMap } from './map-loader';
|
|
20
21
|
import { mapContentFingerprint } from './fingerprint';
|
|
21
22
|
import { buildMatrix, deriveVariants } from './build';
|
|
@@ -75,6 +76,16 @@ export function loadMatrixModel(paths: MatrixTargetPaths): MatrixLoadResult {
|
|
|
75
76
|
transformerVersion: getPackageVersion(),
|
|
76
77
|
specText: readSpecText(paths),
|
|
77
78
|
});
|
|
79
|
+
// Perf verdict (#569): budgets are config (qa/perf.yaml), samples are the run's
|
|
80
|
+
// per-test durations. Advisory — reported here because delivery is where runs
|
|
81
|
+
// are read; the audit's score stays a pure function of the design artifacts.
|
|
82
|
+
const perfConfig = loadPerfConfig(paths.cwd);
|
|
83
|
+
if (perfConfig && results) {
|
|
84
|
+
const samples = [...results.values()]
|
|
85
|
+
.filter((r) => (r.status === 'passed' || r.status === 'failed' || r.status === 'timedOut') && typeof r.duration === 'number')
|
|
86
|
+
.map((r) => ({ title: r.testTitle, ms: r.duration as number }));
|
|
87
|
+
model.perf = perfVerdict(perfConfig, paths.unit, samples);
|
|
88
|
+
}
|
|
78
89
|
return { model, map, mapErrors: [] };
|
|
79
90
|
}
|
|
80
91
|
|
|
@@ -315,6 +315,21 @@ function addCoverageSheet(wb: ExcelJS.Workbook, model: MatrixModel, sungenVersio
|
|
|
315
315
|
|
|
316
316
|
let rowIdx = 7;
|
|
317
317
|
|
|
318
|
+
// --- Performance budget (#569) — advisory: config (qa/perf.yaml) vs measured runs.
|
|
319
|
+
if (model.perf) {
|
|
320
|
+
const p = model.perf;
|
|
321
|
+
dataCell(ws.getRow(rowIdx).getCell(2), 'Performance budget', { bold: true });
|
|
322
|
+
rowIdx++;
|
|
323
|
+
const r = ws.getRow(rowIdx);
|
|
324
|
+
dataCell(r.getCell(2), `p${p.percentile} ${p.metric.replace('_ms', ' time')}`);
|
|
325
|
+
dataCell(r.getCell(3), p.pass ? 'PASS' : 'OVER BUDGET', { bold: true });
|
|
326
|
+
dataCell(r.getCell(4), `measured ${p.measuredMs}ms`);
|
|
327
|
+
dataCell(r.getCell(5), `budget ${p.budgetMs}ms`);
|
|
328
|
+
dataCell(r.getCell(6), `${p.samples} runs`);
|
|
329
|
+
if (!p.pass && p.slowest.length) dataCell(r.getCell(8), `slowest: ${p.slowest.map((x) => `${x.ms}ms ${x.title.slice(0, 40)}`).join(' · ')}`);
|
|
330
|
+
rowIdx += 2;
|
|
331
|
+
}
|
|
332
|
+
|
|
318
333
|
// --- Requirement coverage (review §6): every requirement id gets ONE explicit status.
|
|
319
334
|
if (model.requirements.length > 0) {
|
|
320
335
|
dataCell(ws.getRow(rowIdx).getCell(2), 'Requirement coverage — every id has an explicit status', { bold: true });
|
|
@@ -220,6 +220,8 @@ export interface MatrixModel {
|
|
|
220
220
|
requirements: RequirementCoverage[];
|
|
221
221
|
findings: MatrixFinding[];
|
|
222
222
|
manifest: MatrixManifest;
|
|
223
|
+
/** Perf verdict (#569) — present when qa/perf.yaml sets a budget AND the run produced durations. */
|
|
224
|
+
perf?: import('../../harness/perf').PerfVerdict | null;
|
|
223
225
|
}
|
|
224
226
|
|
|
225
227
|
/** Complexity warning threshold (rules draft Gate I; raised for compact grouping) — echoed in the manifest. */
|
|
@@ -54,6 +54,7 @@ interface PlaywrightSpec {
|
|
|
54
54
|
projectId?: string;
|
|
55
55
|
results?: Array<{
|
|
56
56
|
status?: string; // 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted'
|
|
57
|
+
duration?: number; // ms — Playwright emits this on every result (#569 perf budgets)
|
|
57
58
|
startTime?: string;
|
|
58
59
|
error?: { message?: string; stack?: string };
|
|
59
60
|
attachments?: Array<{ name?: string; path?: string; contentType?: string }>;
|
|
@@ -120,6 +121,7 @@ export function loadPlaywrightReport(reportPath: string): Map<string, Playwright
|
|
|
120
121
|
result.set(stripped, {
|
|
121
122
|
testTitle: stripped,
|
|
122
123
|
status,
|
|
124
|
+
duration: typeof res?.duration === 'number' ? res.duration : undefined,
|
|
123
125
|
startTime: res?.startTime,
|
|
124
126
|
error: errorMsg,
|
|
125
127
|
tracePath: trace,
|
|
@@ -141,10 +141,43 @@ function truncate(s: string, max: number): string {
|
|
|
141
141
|
* test-data map (or resolving to empty) is left as the literal `{{var}}` — never blanked.
|
|
142
142
|
*/
|
|
143
143
|
export function substituteTestDataVars(text: string, testData: Record<string, string>): string {
|
|
144
|
-
|
|
144
|
+
const out = text.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (m, key: string) => {
|
|
145
145
|
const v = testData[key];
|
|
146
146
|
return v === undefined || v === null || v === '' ? m : String(v);
|
|
147
147
|
});
|
|
148
|
+
// A substituted value may itself carry a dynamic token ("Award-{{$timestamp}}").
|
|
149
|
+
// String.replace never re-scans its own replacement, so name them afterwards.
|
|
150
|
+
return renderDynamicTokens(out);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Give the documented dynamic vars (`{{$timestamp}}`, `{{$uuid}}`,
|
|
155
|
+
* `{{$random:min:max}}`, `{{$date}}`, `{{$datetime}}`) a form a tester can act on.
|
|
156
|
+
*
|
|
157
|
+
* These are resolved by TestDataLoader at execution time, never from
|
|
158
|
+
* test-data.yaml — so at export time they have no static value, exactly like a
|
|
159
|
+
* value the run captures (`remember … as {{v}}` → `the captured v`). Left raw,
|
|
160
|
+
* the token ships into the deliverable and the matrix's Gate D rejects the
|
|
161
|
+
* whole export. Named, the tester reads what to type: any value of that shape,
|
|
162
|
+
* uniqueness being the whole point.
|
|
163
|
+
*
|
|
164
|
+
* An UNKNOWN `{{$foo}}` stays literal on purpose — the runtime resolver also
|
|
165
|
+
* leaves it raw, so it is an authoring typo and must keep failing Gate D.
|
|
166
|
+
*/
|
|
167
|
+
export function renderDynamicTokens(text: string): string {
|
|
168
|
+
return text.replace(/\{\{\s*\$(\w+)(?::([^}]*))?\s*\}\}/g, (m, name: string, args?: string) => {
|
|
169
|
+
switch (name) {
|
|
170
|
+
case 'timestamp': return '<unique timestamp>';
|
|
171
|
+
case 'uuid': return '<unique id>';
|
|
172
|
+
case 'random': {
|
|
173
|
+
const [min, max] = (args || '1:9999').split(':');
|
|
174
|
+
return `<random ${min}-${max}>`;
|
|
175
|
+
}
|
|
176
|
+
case 'date': return "<today's date>";
|
|
177
|
+
case 'datetime': return '<current date-time>';
|
|
178
|
+
default: return m;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
148
181
|
}
|
|
149
182
|
|
|
150
183
|
/**
|
package/src/exporters/types.ts
CHANGED
|
@@ -121,6 +121,8 @@ export interface SpecFileData {
|
|
|
121
121
|
export interface PlaywrightResult {
|
|
122
122
|
testTitle: string;
|
|
123
123
|
status: 'passed' | 'failed' | 'skipped' | 'timedOut' | 'interrupted' | 'unknown';
|
|
124
|
+
/** Wall-clock run time in ms (from Playwright's results[].duration) — perf budgets (#569). */
|
|
125
|
+
duration?: number;
|
|
124
126
|
startTime?: string;
|
|
125
127
|
error?: string;
|
|
126
128
|
tracePath?: string;
|
|
@@ -67,7 +67,7 @@ export interface TestGeneratorAdapter {
|
|
|
67
67
|
// Template rendering methods
|
|
68
68
|
renderTestFile(data: TestFileData): string;
|
|
69
69
|
renderScenario(data: ScenarioData): string;
|
|
70
|
-
renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean }): string;
|
|
70
|
+
renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean; urlAssertImports?: string }): string;
|
|
71
71
|
renderBeforeEach(data: { steps: Array<{ comment?: string; code: string }> }): string;
|
|
72
72
|
renderBeforeAll(data: { steps: Array<{ comment?: string; code: string }> }): string;
|
|
73
73
|
renderAfterEach(data: { steps: Array<{ comment?: string; code: string }> }): string;
|
|
@@ -31,7 +31,7 @@ export class AppiumAdapter implements TestGeneratorAdapter {
|
|
|
31
31
|
return this.templateEngine.renderScenario(data);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean }): string {
|
|
34
|
+
renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean; urlAssertImports?: string }): string {
|
|
35
35
|
return this.templateEngine.renderImports(options);
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -26,7 +26,7 @@ export class PlaywrightAdapter implements TestGeneratorAdapter {
|
|
|
26
26
|
return this.templateEngine.renderScenario(data);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean }): string {
|
|
29
|
+
renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean; urlAssertImports?: string }): string {
|
|
30
30
|
return this.templateEngine.renderImports(options);
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -12,6 +12,9 @@ import { api } from '{{basePath}}/api';
|
|
|
12
12
|
{{#if needsMock}}
|
|
13
13
|
import { mock } from '{{basePath}}/mock';
|
|
14
14
|
{{/if}}
|
|
15
|
+
{{#if urlAssertImports}}
|
|
16
|
+
import { {{urlAssertImports}} } from '{{basePath}}/url-assert';
|
|
17
|
+
{{/if}}
|
|
15
18
|
|
|
16
19
|
// This file is auto-generated from Gherkin feature files
|
|
17
20
|
// DO NOT EDIT MANUALLY - changes will be overwritten
|
package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/navigation.hbs
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
+
{{#if query}}
|
|
2
|
+
await page.goto(joinPath('{{#if baseURL}}{{baseURL}}{{/if}}', '{{path}}', '{{escapeQuotes query}}'), { waitUntil: 'load' });
|
|
3
|
+
{{else}}
|
|
1
4
|
await page.goto('{{#if baseURL}}{{baseURL}}{{/if}}{{path}}', { waitUntil: 'load' });
|
|
5
|
+
{{/if}}
|
|
2
6
|
await page.waitForLoadState('domcontentloaded');
|
package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
Both used to carry their own copy and drifted apart: `see [X] page` wildcarded a dynamic
|
|
3
3
|
`:id` segment but silently dropped `with {{v}}`, while `is on [X] page` asserted the query
|
|
4
4
|
but escaped `:id` literally into a regex that can never match. One partial, one behaviour.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
|
|
6
|
+
The predicate itself lives in specs/url-assert.ts (template: specs-url-assert.ts) — read it
|
|
7
|
+
for the semantics. It is a runtime helper rather than an inline lambda so that correcting
|
|
8
|
+
what "the URL matches" MEANS is a runtime patch, not a regenerate-the-world breaking change
|
|
9
|
+
for every suite in the field.
|
|
10
|
+
|
|
11
|
+
Only `pathRegex` is the generator's business: it alone knows the page selector's value and
|
|
12
|
+
its `:id` wildcards, and it is pre-escaped by pathToRegexSource. `\/?$` normalises a
|
|
13
|
+
trailing slash so `/vi/search/` does not read as a different page. The declared value stays
|
|
14
|
+
a plain string literal so the runtime-data marker pass can rewrite it to testData.get(…). --}}
|
|
15
|
+
await expect(page).toHaveURL(urlMatches({ pathname: /^{{pathRegex}}\/?$/{{#if query}}, dataRef: '{{escapeQuotes dataRef}}', declared: '{{escapeQuotes query}}'{{/if}} }));
|
|
@@ -316,11 +316,19 @@ export class CodeGenerator {
|
|
|
316
316
|
for (const h of capabilityRegistry.get(id)?.runtimeHelpers ?? []) this.syncGeneratedHelper(outputDir, h.file, h.template);
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
const imports = this.adapter.renderImports({ runtimeData: this.options.runtimeData, basePath, needsCleanupImport, needsDb, needsApi, needsMock });
|
|
320
|
-
|
|
321
319
|
// Generate test code (async now to support AI mapping)
|
|
322
320
|
const testCode = await this.generateTestCode(feature);
|
|
323
321
|
|
|
322
|
+
// `url-assert` is imported from what the body actually emitted, not from an active capability:
|
|
323
|
+
// page assertions belong to the DEFAULT ui capability, so a capability flag would import the
|
|
324
|
+
// helper into every UI spec — including the many that touch no URL at all. Hence the body is
|
|
325
|
+
// rendered BEFORE the imports. Importing exactly the names used keeps the import honest; the
|
|
326
|
+
// helper file itself is synced only when something needs it (an api-only unit carries none).
|
|
327
|
+
const urlAssertImports = ['urlMatches', 'joinPath'].filter((n) => testCode.includes(`${n}(`)).join(', ');
|
|
328
|
+
if (urlAssertImports) this.syncGeneratedHelper(outputDir, 'url-assert.ts', 'specs-url-assert.ts');
|
|
329
|
+
|
|
330
|
+
const imports = this.adapter.renderImports({ runtimeData: this.options.runtimeData, basePath, needsCleanupImport, needsDb, needsApi, needsMock, urlAssertImports });
|
|
331
|
+
|
|
324
332
|
// Combine and collapse any runs of 3+ newlines down to 2 (one blank line max)
|
|
325
333
|
const raw = `${imports}\n\n${testCode}`;
|
|
326
334
|
let code = raw.replace(/\n{3,}/g, '\n\n');
|
|
@@ -26,12 +26,21 @@
|
|
|
26
26
|
* SG-W012 — a mock-install step AFTER a navigation step in the same block: page.route()
|
|
27
27
|
* registered after goto() misses every request fired during page load — move
|
|
28
28
|
* the install before the navigation (or into Background)
|
|
29
|
+
* SG-W013 — a page assertion whose path came from a FALLBACK, not from a `type: page` selector
|
|
30
|
+
* entry: the `[Ref]` did not resolve (or resolved to a non-page entry), so the step
|
|
31
|
+
* asserts the feature's own path (or `/<ref>/`) instead of the page it names. The
|
|
32
|
+
* assertion is anchored, so this is now a test that cannot pass rather than one that
|
|
33
|
+
* always passed — either way it is not asserting what it reads as
|
|
34
|
+
* SG-W014 — `[X] page with {{v}}` where the value carries no query and no fragment: there is
|
|
35
|
+
* nothing for the query half to assert, so the step checks the PATH only. Not an
|
|
36
|
+
* error (the value may legitimately vary per environment), but the step asserts
|
|
37
|
+
* strictly less than it appears to
|
|
29
38
|
*
|
|
30
39
|
* Diagnostics never block generation by themselves; `sungen generate --strict`
|
|
31
40
|
* turns any collected diagnostic into a non-zero exit.
|
|
32
41
|
*/
|
|
33
42
|
|
|
34
|
-
export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010' | 'SG-W011' | 'SG-W012';
|
|
43
|
+
export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010' | 'SG-W011' | 'SG-W012' | 'SG-W013' | 'SG-W014';
|
|
35
44
|
|
|
36
45
|
export interface Diagnostic {
|
|
37
46
|
code: DiagnosticCode;
|
|
@@ -333,6 +333,8 @@ export class StepMapper {
|
|
|
333
333
|
console.log(` ✓ Pattern matched: ${step.text.substring(0, 50)}...`);
|
|
334
334
|
}
|
|
335
335
|
this.checkDataRefConsumed(step, mappedStep);
|
|
336
|
+
this.checkPageAssertionFallback(step, mappedStep);
|
|
337
|
+
this.checkDeclaredUrlHasNoQuery(step, mappedStep);
|
|
336
338
|
return mappedStep;
|
|
337
339
|
}
|
|
338
340
|
|
|
@@ -400,6 +402,58 @@ export class StepMapper {
|
|
|
400
402
|
return expr || undefined;
|
|
401
403
|
}
|
|
402
404
|
|
|
405
|
+
/**
|
|
406
|
+
* SG-W013 — a page assertion whose path came from a fallback rather than from a `type: page`
|
|
407
|
+
* selector entry. Both resolvers degrade quietly when `[Ref]` does not resolve: `see [X] page`
|
|
408
|
+
* falls back to the feature's own path, `is on [X] page` to `/<ref>/`. Before the assertion was
|
|
409
|
+
* anchored that produced a test which always PASSED; anchored, it produces one that can never
|
|
410
|
+
* pass. Neither is the assertion the author wrote, and nothing said so.
|
|
411
|
+
*
|
|
412
|
+
* Detected here rather than in the resolver on purpose: `MappedStep` carries only
|
|
413
|
+
* `{code, comment, imports}`, and the resolvers live in the driver packages while the collector
|
|
414
|
+
* lives in core. Re-asking the SAME resolver the step-mapper already owns costs three lines and
|
|
415
|
+
* duplicates no logic — the alternative is widening the adapter contract for every driver.
|
|
416
|
+
*/
|
|
417
|
+
private checkPageAssertionFallback(step: ParsedStep, mapped: MappedStep): void {
|
|
418
|
+
if (!this.diagnostics || !step.selectorRef || step.elementType !== 'page') return;
|
|
419
|
+
if (!(mapped.code || '').includes('urlMatches(')) return;
|
|
420
|
+
let resolvedAsPage = false;
|
|
421
|
+
try {
|
|
422
|
+
const resolved = this.selectorResolver.resolveSelector(
|
|
423
|
+
step.selectorRef, this.featureName, step.elementType, step.nth
|
|
424
|
+
);
|
|
425
|
+
resolvedAsPage = resolved.selectorType === 'page' && !!resolved.value;
|
|
426
|
+
} catch {
|
|
427
|
+
resolvedAsPage = false;
|
|
428
|
+
}
|
|
429
|
+
if (resolvedAsPage) return;
|
|
430
|
+
this.diagnostics.add('SG-W013',
|
|
431
|
+
`page assertion for "[${step.selectorRef}]" has no "type: page" selector entry, so it asserts a FALLBACK path — the anchored assertion cannot match the real URL. Declare the page, e.g. "${step.selectorRef.toLowerCase()}:\\n type: page\\n value: '/your/path'"`,
|
|
432
|
+
{ feature: this.featureName, step: step.text });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* SG-W014 — `[X] page with {{v}}` where the value carries neither a query nor a fragment, so the
|
|
437
|
+
* query half of the assertion has nothing to check and the step asserts the PATH only.
|
|
438
|
+
*
|
|
439
|
+
* Deliberately a warning, not an error, and deliberately judged on the BASE test-data value: an
|
|
440
|
+
* env overlay can supply a query the base file lacks, so failing generation here would block a
|
|
441
|
+
* suite over data that is fine at run time. But it must not be silent either — asserting less
|
|
442
|
+
* than the step reads as is exactly the false-positive class this assertion exists to remove.
|
|
443
|
+
* Distinct from SG-W002: there the value is DROPPED, here it is used and found to hold no query.
|
|
444
|
+
*/
|
|
445
|
+
private checkDeclaredUrlHasNoQuery(step: ParsedStep, mapped: MappedStep): void {
|
|
446
|
+
if (!this.diagnostics || !step.dataRef || step.elementType !== 'page') return;
|
|
447
|
+
if (!(mapped.code || '').includes('urlMatches(')) return;
|
|
448
|
+
// The base value, never resolveData(): in runtime mode that returns the marker, which carries
|
|
449
|
+
// no query by construction — the diagnostic would then fire on every single step.
|
|
450
|
+
const value = this.dataResolver.peekBaseValue(step.dataRef, this.featureName);
|
|
451
|
+
if (value == null || /[?#]/.test(value)) return;
|
|
452
|
+
this.diagnostics.add('SG-W014',
|
|
453
|
+
`{{${step.dataRef}}} = "${value}" carries no query and no fragment, so this page assertion checks the PATH only. Pass a value like "?q=…" (or drop the "with" and let the step assert a bare page).`,
|
|
454
|
+
{ feature: this.featureName, step: step.text });
|
|
455
|
+
}
|
|
456
|
+
|
|
403
457
|
/**
|
|
404
458
|
* SG-W002 — a step carried a parsed {{dataRef}} but the matched pattern's generated
|
|
405
459
|
* code never uses it (neither the runtime marker, the ${ref} fallback, nor the
|
|
@@ -277,8 +277,8 @@ export class TemplateEngine {
|
|
|
277
277
|
this.baseContext = { ...this.persistentContext };
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean }): string {
|
|
281
|
-
return this.render('imports', { runtimeData: options?.runtimeData, basePath: options?.basePath || '..', isParallel: options?.isParallel, needsCleanupImport: options?.needsCleanupImport, needsDb: options?.needsDb, needsApi: options?.needsApi, needsMock: options?.needsMock });
|
|
280
|
+
renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean; needsMock?: boolean; urlAssertImports?: string }): string {
|
|
281
|
+
return this.render('imports', { runtimeData: options?.runtimeData, basePath: options?.basePath || '..', isParallel: options?.isParallel, needsCleanupImport: options?.needsCleanupImport, needsDb: options?.needsDb, needsApi: options?.needsApi, needsMock: options?.needsMock, urlAssertImports: options?.urlAssertImports });
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
renderTestFile(data: {
|
|
@@ -64,6 +64,25 @@ export class DataResolver {
|
|
|
64
64
|
return this.resolveValue(dataRef, featureName);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The value as the BASE test-data file spells it, whatever the mode — for compile-time
|
|
69
|
+
* diagnostics that must judge the value itself, not the `__SUNGEN_TD$…$__` marker that
|
|
70
|
+
* `resolveData` returns in runtime mode.
|
|
71
|
+
*
|
|
72
|
+
* Returns undefined rather than throwing when the ref cannot be read (unknown path, or a
|
|
73
|
+
* captured var that only exists at runtime): a diagnostic must never break generation. Callers
|
|
74
|
+
* must treat the result as the base value only — an env overlay can change it at run time, so
|
|
75
|
+
* anything said about it belongs in a warning, never a gate.
|
|
76
|
+
*/
|
|
77
|
+
peekBaseValue(dataRef: string, featureName?: string): string | undefined {
|
|
78
|
+
if (this.capturedVars.has(dataRef)) return undefined;
|
|
79
|
+
try {
|
|
80
|
+
return this.resolveValue(dataRef, featureName);
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
67
86
|
private resolveValue(dataRef: string, featureName?: string): string {
|
|
68
87
|
const { fileName, valuePath } = this.parseDataRef(dataRef, featureName);
|
|
69
88
|
const data = this.loadDataFile(fileName);
|