@sun-asterisk/sungen 3.2.9 → 3.2.10-beta.11
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/capabilities/discover.d.ts.map +1 -1
- package/dist/capabilities/discover.js +5 -2
- package/dist/capabilities/discover.js.map +1 -1
- package/dist/capabilities/registry.d.ts +9 -0
- package/dist/capabilities/registry.d.ts.map +1 -1
- package/dist/capabilities/registry.js +4 -0
- package/dist/capabilities/registry.js.map +1 -1
- package/dist/cli/commands/capability.d.ts.map +1 -1
- package/dist/cli/commands/capability.js +44 -2
- package/dist/cli/commands/capability.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +15 -5
- 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 +16 -0
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/exporters/spec-parser.d.ts.map +1 -1
- package/dist/exporters/spec-parser.js +16 -5
- package/dist/exporters/spec-parser.js.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/alert-accept-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/alert-dismiss-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/alert-fill-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/capture-row-column.hbs +2 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/click-element-with-text.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/click-with-alert-action.hbs +2 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/recipe-sequence.hbs +3 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row-nth.hbs +2 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/unknown-element-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +3 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-table-refresh.hbs +3 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/has-text-value.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-blur.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-click-text.hbs +3 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-click.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-value.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-for-each.hbs +5 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-hover.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-press.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-scroll-into-view.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-type.hbs +1 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-wait-for.hbs +1 -0
- package/dist/generators/test-generator/app-profile.d.ts +89 -0
- package/dist/generators/test-generator/app-profile.d.ts.map +1 -0
- package/dist/generators/test-generator/app-profile.js +157 -0
- package/dist/generators/test-generator/app-profile.js.map +1 -0
- package/dist/generators/test-generator/code-generator.d.ts +4 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +7 -0
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +36 -0
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -0
- package/dist/generators/test-generator/diagnostics.js +44 -0
- package/dist/generators/test-generator/diagnostics.js.map +1 -0
- package/dist/generators/test-generator/patterns/types.d.ts +12 -0
- package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
- package/dist/generators/test-generator/recipe-expander.d.ts +32 -0
- package/dist/generators/test-generator/recipe-expander.d.ts.map +1 -0
- package/dist/generators/test-generator/recipe-expander.js +118 -0
- package/dist/generators/test-generator/recipe-expander.js.map +1 -0
- package/dist/generators/test-generator/step-mapper.d.ts +17 -0
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +70 -0
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/generators/test-generator/template-engine.d.ts +5 -1
- package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
- package/dist/generators/test-generator/template-engine.js +27 -3
- package/dist/generators/test-generator/template-engine.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts +10 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +32 -0
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/harness/capability.d.ts +1 -0
- package/dist/harness/capability.d.ts.map +1 -1
- package/dist/harness/capability.js.map +1 -1
- package/dist/harness/catalog/drivers.yaml +2 -1
- package/dist/harness/query-catalog.d.ts +32 -2
- package/dist/harness/query-catalog.d.ts.map +1 -1
- package/dist/harness/query-catalog.js +0 -0
- package/dist/harness/query-catalog.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-data-test.md +99 -0
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +3 -0
- package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -0
- package/dist/orchestrator/templates/ai-src/config/claude.md +3 -1
- package/dist/orchestrator/templates/ai-src/config/copilot.md +3 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +116 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +12 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +43 -8
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +22 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +14 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +1 -0
- package/dist/orchestrator/templates/specs-db.d.ts +201 -11
- package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-db.js +440 -91
- package/dist/orchestrator/templates/specs-db.js.map +1 -1
- package/dist/orchestrator/templates/specs-db.ts +463 -79
- package/dist/orchestrator/templates/specs-test-data.ts +39 -3
- package/package.json +8 -4
- package/src/capabilities/discover.ts +5 -2
- package/src/capabilities/registry.ts +13 -0
- package/src/cli/commands/capability.ts +40 -2
- package/src/cli/commands/delivery.ts +13 -5
- package/src/cli/commands/generate.ts +15 -0
- package/src/exporters/spec-parser.ts +17 -5
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/alert-accept-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/alert-dismiss-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/alert-fill-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/capture-row-column.hbs +2 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/click-element-with-text.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/click-with-alert-action.hbs +2 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/recipe-sequence.hbs +3 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/table-action-in-row-nth.hbs +2 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/unknown-element-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +3 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-table-refresh.hbs +3 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/has-text-value.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator-base.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/locator.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-blur.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-click-text.hbs +3 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-click.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-value.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-for-each.hbs +5 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-hover.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-press.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-scroll-into-view.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-type.hbs +1 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-wait-for.hbs +1 -0
- package/src/generators/test-generator/app-profile.ts +185 -0
- package/src/generators/test-generator/code-generator.ts +9 -0
- package/src/generators/test-generator/diagnostics.ts +51 -0
- package/src/generators/test-generator/patterns/types.ts +12 -0
- package/src/generators/test-generator/recipe-expander.ts +129 -0
- package/src/generators/test-generator/step-mapper.ts +73 -0
- package/src/generators/test-generator/template-engine.ts +29 -3
- package/src/generators/test-generator/utils/selector-resolver.ts +49 -2
- package/src/harness/capability.ts +1 -0
- package/src/harness/catalog/drivers.yaml +2 -1
- package/src/harness/query-catalog.ts +0 -0
- package/src/index.ts +5 -2
- package/src/orchestrator/templates/ai-src/commands/create-data-test.md +99 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +3 -0
- package/src/orchestrator/templates/ai-src/commands/run-test.md +1 -0
- package/src/orchestrator/templates/ai-src/config/claude.md +3 -1
- package/src/orchestrator/templates/ai-src/config/copilot.md +3 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-data-factory/SKILL.md +116 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +12 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +43 -8
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +22 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +14 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +1 -0
- package/src/orchestrator/templates/specs-db.ts +463 -79
- package/src/orchestrator/templates/specs-test-data.ts +39 -3
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import yaml from 'yaml';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* App Profile (`qa/app.yaml`) — the per-project BEHAVIOR layer (spec:
|
|
7
|
+
* docs/spec/sungen_app_profile_spec.md). Three kinds of knowledge live in three files:
|
|
8
|
+
* INTENT → .feature + test-data (portable across projects)
|
|
9
|
+
* IDENTITY → selectors.yaml (per-screen: WHERE an element is)
|
|
10
|
+
* BEHAVIOR → qa/app.yaml (per-project: HOW this app's UI behaves)
|
|
11
|
+
*
|
|
12
|
+
* The file describes the app under test, never the tool (no timeouts/retries/sleeps —
|
|
13
|
+
* those belong to sungen.config.yaml). One file per project; presets merge in via
|
|
14
|
+
* `extends:`; per-element `variant:`/`scope:` in selectors.yaml wins over the profile.
|
|
15
|
+
* No file → the built-in defaults below → pre-profile behavior stays intact.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface AppProfile {
|
|
19
|
+
version?: number;
|
|
20
|
+
extends?: string | string[];
|
|
21
|
+
app?: { ui_library?: string; framework?: string; locales?: string[]; base_locale?: string };
|
|
22
|
+
widgets?: Record<string, string | { recipe?: string; variant?: string }>;
|
|
23
|
+
recipes?: Record<string, unknown>;
|
|
24
|
+
layers?: { portal_roles?: string[] };
|
|
25
|
+
feedback?: {
|
|
26
|
+
toast?: { role?: string; named_by?: 'content' | 'label'; stacking?: string };
|
|
27
|
+
loading?: { indicator?: string };
|
|
28
|
+
field_error?: { locate?: string; indicator?: string };
|
|
29
|
+
confirm?: string;
|
|
30
|
+
};
|
|
31
|
+
conventions?: Record<string, unknown>;
|
|
32
|
+
detection?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProfileWarning {
|
|
36
|
+
code: 'SG-W005' | 'SG-W006';
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ResolvedAppProfile {
|
|
41
|
+
/** ARIA roles rendered via portal — never wrapped in dialog/landmark scope (issue #465). */
|
|
42
|
+
portalRoles: string[];
|
|
43
|
+
/** Roles whose accessible name derives from CONTENT — defaulting name from the Gherkin
|
|
44
|
+
* reference can never match (issue #467). */
|
|
45
|
+
contentNamedRoles: string[];
|
|
46
|
+
/** widget kind → variant/recipe name, normalized (`select: radix-select`). Per-element
|
|
47
|
+
* `variant:` in selectors.yaml overrides this. */
|
|
48
|
+
widgetVariants: Record<string, string>;
|
|
49
|
+
/** recipe name → { intent → primitive list } — built-in variant recipes merged under
|
|
50
|
+
* project-defined ones (project wins). */
|
|
51
|
+
recipes: Record<string, Record<string, unknown[]>>;
|
|
52
|
+
/** Refresh/loading signal for `wait for [T] table to refresh` (feedback.loading.indicator). */
|
|
53
|
+
loadingIndicator: string;
|
|
54
|
+
/** Positional-row conventions (`row {{n}}` steps): first row number + whether the header
|
|
55
|
+
* counts as a row in getByRole('row') indexing. */
|
|
56
|
+
tableRowBase: number;
|
|
57
|
+
tableHeaderIsRow: boolean;
|
|
58
|
+
/** The merged profile (presets ← project), or null when qa/app.yaml does not exist. */
|
|
59
|
+
raw: AppProfile | null;
|
|
60
|
+
warnings: ProfileWarning[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Portal-rendered option layers (Radix/MUI/Headless UI/antd portal to document.body). */
|
|
64
|
+
export const DEFAULT_PORTAL_ROLES = ['option', 'listbox', 'menu', 'menuitem', 'tooltip'];
|
|
65
|
+
|
|
66
|
+
/** ARIA name-from-content roles (per spec): a `{ name }` filter invented from the Gherkin
|
|
67
|
+
* reference text can never match these. */
|
|
68
|
+
export const DEFAULT_CONTENT_NAMED_ROLES = ['status', 'alert', 'log', 'marquee', 'timer'];
|
|
69
|
+
|
|
70
|
+
/** Default refresh signal — apps that set aria-busy while loading need zero config. */
|
|
71
|
+
export const DEFAULT_LOADING_INDICATOR = '[aria-busy="true"]';
|
|
72
|
+
|
|
73
|
+
const KNOWN_TOP_KEYS = ['version', 'extends', 'app', 'widgets', 'recipes', 'layers', 'feedback', 'conventions', 'detection'];
|
|
74
|
+
|
|
75
|
+
/** Reserved preset key: always merged as the base layer (built-in widget recipes ship here). */
|
|
76
|
+
const BUILTIN_PRESET_KEY = 'builtin';
|
|
77
|
+
|
|
78
|
+
export function defaultAppProfile(presets: Record<string, unknown> = {}): ResolvedAppProfile {
|
|
79
|
+
return finalizeProfile((presets[BUILTIN_PRESET_KEY] as AppProfile) ?? {}, null, []);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Derive the compiler-consumed slices from a merged profile document. */
|
|
83
|
+
function finalizeProfile(merged: AppProfile, raw: AppProfile | null, warnings: ProfileWarning[]): ResolvedAppProfile {
|
|
84
|
+
const portalRoles = merged.layers?.portal_roles ?? DEFAULT_PORTAL_ROLES;
|
|
85
|
+
const contentNamed = new Set(DEFAULT_CONTENT_NAMED_ROLES);
|
|
86
|
+
if (merged.feedback?.toast?.named_by === 'content' && merged.feedback.toast.role) {
|
|
87
|
+
contentNamed.add(merged.feedback.toast.role);
|
|
88
|
+
}
|
|
89
|
+
const widgetVariants: Record<string, string> = {};
|
|
90
|
+
for (const [kind, v] of Object.entries(merged.widgets ?? {})) {
|
|
91
|
+
const name = typeof v === 'string' ? v : (v?.recipe ?? v?.variant);
|
|
92
|
+
if (name) widgetVariants[kind] = name;
|
|
93
|
+
}
|
|
94
|
+
const conventions = merged.conventions as { table?: { row_base?: number; header_is_row?: boolean } } | undefined;
|
|
95
|
+
return {
|
|
96
|
+
portalRoles,
|
|
97
|
+
contentNamedRoles: [...contentNamed],
|
|
98
|
+
widgetVariants,
|
|
99
|
+
recipes: (merged.recipes ?? {}) as Record<string, Record<string, unknown[]>>,
|
|
100
|
+
loadingIndicator: merged.feedback?.loading?.indicator ?? DEFAULT_LOADING_INDICATOR,
|
|
101
|
+
tableRowBase: conventions?.table?.row_base ?? 1,
|
|
102
|
+
tableHeaderIsRow: conventions?.table?.header_is_row ?? false,
|
|
103
|
+
raw,
|
|
104
|
+
warnings,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Load and resolve `qa/app.yaml`: merge the `extends` preset chain (project wins),
|
|
110
|
+
* validate top-level keys, and derive the compiler-consumed slices. Never throws on
|
|
111
|
+
* a bad profile — problems surface as SG-W005/W006 diagnostics, and each slice falls
|
|
112
|
+
* back to its built-in default.
|
|
113
|
+
*/
|
|
114
|
+
export function loadAppProfile(cwd: string, presets: Record<string, unknown> = {}): ResolvedAppProfile {
|
|
115
|
+
const warnings: ProfileWarning[] = [];
|
|
116
|
+
const qaDir = path.join(cwd, 'qa');
|
|
117
|
+
|
|
118
|
+
// Per-environment profiles are unsupported BY DESIGN — widget behavior must not differ
|
|
119
|
+
// between environments (test-data/locale overlays already cover what legitimately does).
|
|
120
|
+
if (fs.existsSync(qaDir)) {
|
|
121
|
+
for (const name of fs.readdirSync(qaDir)) {
|
|
122
|
+
if (/^app\.[^.]+\.ya?ml$/.test(name)) {
|
|
123
|
+
warnings.push({
|
|
124
|
+
code: 'SG-W006',
|
|
125
|
+
message: `qa/${name}: per-environment app profiles are unsupported by design — describe the app once in qa/app.yaml`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const file = path.join(qaDir, 'app.yaml');
|
|
132
|
+
if (!fs.existsSync(file)) {
|
|
133
|
+
return { ...defaultAppProfile(presets), warnings };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let doc: AppProfile;
|
|
137
|
+
try {
|
|
138
|
+
doc = (yaml.parse(fs.readFileSync(file, 'utf-8')) ?? {}) as AppProfile;
|
|
139
|
+
} catch (e) {
|
|
140
|
+
warnings.push({ code: 'SG-W005', message: `qa/app.yaml: unparseable YAML (${e instanceof Error ? e.message : e}) — using built-in defaults` });
|
|
141
|
+
return { ...defaultAppProfile(presets), warnings };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const key of Object.keys(doc)) {
|
|
145
|
+
if (!KNOWN_TOP_KEYS.includes(key)) {
|
|
146
|
+
warnings.push({ code: 'SG-W005', message: `qa/app.yaml: unknown key "${key}" (known: ${KNOWN_TOP_KEYS.join(', ')})` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// extends: preset chain, merged in order, project file last (narrow beats broad).
|
|
151
|
+
// The reserved `builtin` preset (built-in widget recipes from driver-ui) is always
|
|
152
|
+
// the base layer, so per-element `variant: radix-select` works without any extends.
|
|
153
|
+
const extendNames = doc.extends ? (Array.isArray(doc.extends) ? doc.extends : [doc.extends]) : [];
|
|
154
|
+
const availableNames = Object.keys(presets).filter((k) => k !== BUILTIN_PRESET_KEY);
|
|
155
|
+
const chain: AppProfile[] = presets[BUILTIN_PRESET_KEY] ? [presets[BUILTIN_PRESET_KEY] as AppProfile] : [];
|
|
156
|
+
for (const name of extendNames) {
|
|
157
|
+
const key = String(name).replace(/^@sungen\/app-/, '');
|
|
158
|
+
const preset = presets[key] as AppProfile | undefined;
|
|
159
|
+
if (!preset || key === BUILTIN_PRESET_KEY) {
|
|
160
|
+
warnings.push({
|
|
161
|
+
code: 'SG-W005',
|
|
162
|
+
message: `qa/app.yaml: unknown preset "${name}" in extends (available: ${availableNames.join(', ') || 'none — is the driver that ships it installed?'})`,
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
chain.push(preset);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const merged = [...chain, doc].reduce<AppProfile>((acc, layer) => deepMerge(acc, layer) as AppProfile, {});
|
|
169
|
+
|
|
170
|
+
return finalizeProfile(merged, merged, warnings);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Plain-object deep merge; arrays and scalars replace (a preset's list is a whole answer). */
|
|
174
|
+
function deepMerge(base: unknown, layer: unknown): unknown {
|
|
175
|
+
if (!isPlainObject(base) || !isPlainObject(layer)) return layer === undefined ? base : layer;
|
|
176
|
+
const out: Record<string, unknown> = { ...base };
|
|
177
|
+
for (const [k, v] of Object.entries(layer)) {
|
|
178
|
+
out[k] = k in out ? deepMerge(out[k], v) : v;
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
184
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
185
|
+
}
|
|
@@ -4,6 +4,7 @@ import { ParsedFeature, ParsedScenario, ParsedStep } from '../gherkin-parser';
|
|
|
4
4
|
import { StepMapper } from './step-mapper';
|
|
5
5
|
import { TestGeneratorAdapter, adapterRegistry } from './adapters';
|
|
6
6
|
import { transformToRuntimeData } from './utils/runtime-data-transformer';
|
|
7
|
+
import { DiagnosticsCollector } from './diagnostics';
|
|
7
8
|
import { capabilityRegistry } from '../../capabilities/registry';
|
|
8
9
|
import { discoverAndRegisterCapabilities } from '../../capabilities/discover';
|
|
9
10
|
import { readCapabilities } from '../../harness/capability';
|
|
@@ -227,10 +228,13 @@ export class CodeGenerator {
|
|
|
227
228
|
// Steps registry built per feature during generateTestCode(); used by countSteps()
|
|
228
229
|
private stepsRegistry = new Map<string, ParsedScenario>();
|
|
229
230
|
|
|
231
|
+
private diagnostics = new DiagnosticsCollector();
|
|
232
|
+
|
|
230
233
|
constructor(options: { useAI?: boolean; verbose?: boolean; framework?: string; baseURL?: string; screenName?: string; runtimeData?: boolean; flowMode?: boolean } = {}) {
|
|
231
234
|
this.options = options;
|
|
232
235
|
this.screenName = options.screenName;
|
|
233
236
|
this.stepMapper = new StepMapper(options);
|
|
237
|
+
this.stepMapper.setDiagnostics(this.diagnostics);
|
|
234
238
|
// Get adapter from registry. Platform comes from the caller (resolved from
|
|
235
239
|
// qa/capabilities.yaml); default `web` = the bundled Playwright adapter
|
|
236
240
|
// (back-compat baseline). External platforms (e.g. mobile) are loaded by the
|
|
@@ -243,6 +247,11 @@ export class CodeGenerator {
|
|
|
243
247
|
}
|
|
244
248
|
}
|
|
245
249
|
|
|
250
|
+
/** Compile diagnostics collected across all generateTest calls (SG-W001/W002...) */
|
|
251
|
+
getDiagnostics(): DiagnosticsCollector {
|
|
252
|
+
return this.diagnostics;
|
|
253
|
+
}
|
|
254
|
+
|
|
246
255
|
/**
|
|
247
256
|
* Generate test file from parsed feature
|
|
248
257
|
*/
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-time diagnostics — the warning channel for "the compiler knows something
|
|
3
|
+
* and would otherwise silently discard or invent it" (spec: sungen_app_profile_spec.md §4).
|
|
4
|
+
*
|
|
5
|
+
* Codes:
|
|
6
|
+
* SG-W001 — step matched no pattern (compiles to a no-op `// TODO` comment)
|
|
7
|
+
* SG-W002 — step parsed a {{dataRef}} value but the generated code never uses it
|
|
8
|
+
* SG-W003 — `name` filter defaulted from the Gherkin reference on a name-from-content
|
|
9
|
+
* role (status/alert/…) — such a filter can never match; opt out with `name: null`
|
|
10
|
+
* SG-W005 — qa/app.yaml problem (unknown key, unknown preset, unparseable)
|
|
11
|
+
* SG-W006 — qa/app.<env>.yaml found (per-environment profiles are unsupported by design)
|
|
12
|
+
* SG-W007 — browser-alert handler step written AFTER an action step: the listener
|
|
13
|
+
* registers too late, an earlier dialog auto-dismisses with no error
|
|
14
|
+
*
|
|
15
|
+
* Diagnostics never block generation by themselves; `sungen generate --strict`
|
|
16
|
+
* turns any collected diagnostic into a non-zero exit.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007';
|
|
20
|
+
|
|
21
|
+
export interface Diagnostic {
|
|
22
|
+
code: DiagnosticCode;
|
|
23
|
+
message: string;
|
|
24
|
+
feature?: string;
|
|
25
|
+
step?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DiagnosticsCollector {
|
|
29
|
+
private items: Diagnostic[] = [];
|
|
30
|
+
|
|
31
|
+
add(code: DiagnosticCode, message: string, context: { feature?: string; step?: string } = {}): void {
|
|
32
|
+
this.items.push({ code, message, ...context });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
all(): Diagnostic[] {
|
|
36
|
+
return this.items;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get count(): number {
|
|
40
|
+
return this.items.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One line per diagnostic, ready for CLI output. */
|
|
44
|
+
formatLines(): string[] {
|
|
45
|
+
return this.items.map((d) => {
|
|
46
|
+
const where = d.feature ? `${d.feature}: ` : '';
|
|
47
|
+
const step = d.step ? ` — "${d.step}"` : '';
|
|
48
|
+
return `[${d.code}] ${where}${d.message}${step}`;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -44,6 +44,18 @@ export interface PatternContext {
|
|
|
44
44
|
scenarioSteps?: ParsedStep[];
|
|
45
45
|
/** Effective Gherkin keyword (Given/When/Then), resolved from And/But */
|
|
46
46
|
effectiveKeyword?: string;
|
|
47
|
+
/** App profile (qa/app.yaml merged over presets/defaults) — widget variants, recipes,
|
|
48
|
+
* loading indicator, table row conventions. Typed loosely to keep the SPI decoupled;
|
|
49
|
+
* the concrete shape is ResolvedAppProfile. */
|
|
50
|
+
appProfile?: {
|
|
51
|
+
widgetVariants: Record<string, string>;
|
|
52
|
+
recipes: Record<string, Record<string, unknown[]>>;
|
|
53
|
+
loadingIndicator: string;
|
|
54
|
+
tableRowBase: number;
|
|
55
|
+
tableHeaderIsRow: boolean;
|
|
56
|
+
portalRoles: string[];
|
|
57
|
+
contentNamedRoles: string[];
|
|
58
|
+
};
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
/**
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget-recipe expander (spec: sungen_app_profile_spec.md §3.1 [recipes]).
|
|
3
|
+
*
|
|
4
|
+
* A recipe is a LINEAR sequence of closed primitives that describes one widget
|
|
5
|
+
* intent ("select", "fill", …). Recipes come from `qa/app.yaml` `recipes:` or from
|
|
6
|
+
* driver-shipped built-ins (the reserved `builtin` preset), selected by the widget's
|
|
7
|
+
* variant name (per-element `variant:` > profile `widgets.<kind>`).
|
|
8
|
+
*
|
|
9
|
+
* This expander holds ZERO framework syntax. It resolves each primitive to a
|
|
10
|
+
* `{ partial, ...data }` NODE; the actual Playwright code lives in the per-primitive
|
|
11
|
+
* `recipe-*.hbs` partials, rendered by the template engine via `recipe-sequence.hbs`
|
|
12
|
+
* (dynamic partial lookup). The only strings it builds are locator/value EXPRESSIONS
|
|
13
|
+
* (data — the same split every resolver uses when it hands the locator template a value).
|
|
14
|
+
*
|
|
15
|
+
* Placeholders: `$trigger` = the resolved widget element; `$value` = the step's data
|
|
16
|
+
* value (a runtime marker in runtime mode, transformed to testData.get later); `$item`
|
|
17
|
+
* = the current forEach item. Throws on an unknown primitive/shape — callers fall back
|
|
18
|
+
* to the generic template.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface RecipeExpandContext {
|
|
22
|
+
/** Pre-rendered locator code for the widget element (dialog/frame scoping included). */
|
|
23
|
+
triggerCode: string;
|
|
24
|
+
/** The step's value — a literal or a __SUNGEN_TD__ runtime marker. */
|
|
25
|
+
value: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A render node: the partial name plus the data it interpolates. */
|
|
29
|
+
export interface RecipeNode {
|
|
30
|
+
partial: string;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const escape = (s: string): string => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
35
|
+
|
|
36
|
+
export function expandRecipe(primitives: unknown[], ctx: RecipeExpandContext): RecipeNode[] {
|
|
37
|
+
if (!Array.isArray(primitives) || primitives.length === 0) {
|
|
38
|
+
throw new Error('recipe intent is not a primitive list');
|
|
39
|
+
}
|
|
40
|
+
return primitives.map((p) => expandPrimitive(p, ctx, null));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `itemVar` is non-null inside a forEach body — `$item` renders as that identifier. */
|
|
44
|
+
function expandPrimitive(prim: unknown, ctx: RecipeExpandContext, itemVar: string | null): RecipeNode {
|
|
45
|
+
if (typeof prim !== 'object' || prim === null || Array.isArray(prim)) {
|
|
46
|
+
throw new Error(`invalid primitive: ${JSON.stringify(prim)}`);
|
|
47
|
+
}
|
|
48
|
+
const entries = Object.entries(prim as Record<string, unknown>);
|
|
49
|
+
if (entries.length !== 1) throw new Error(`a primitive must have exactly one key: ${JSON.stringify(prim)}`);
|
|
50
|
+
const [kind, arg] = entries[0];
|
|
51
|
+
|
|
52
|
+
// Resolve a `$trigger`/css target into a locator EXPRESSION (data for the partial).
|
|
53
|
+
const targetExpr = (v: unknown): string =>
|
|
54
|
+
v === '$trigger' ? ctx.triggerCode : `page.locator('${escape(String(v))}')`;
|
|
55
|
+
// Resolve a `$value`/`$item`/literal into a JS value EXPRESSION (quoted unless $item;
|
|
56
|
+
// a quoted __SUNGEN_TD__ marker is rewritten to testData.get(...) by the runtime transform).
|
|
57
|
+
const valueExpr = (v: unknown): string => {
|
|
58
|
+
if (v === '$item') {
|
|
59
|
+
if (!itemVar) throw new Error('$item is only valid inside forEach');
|
|
60
|
+
return itemVar;
|
|
61
|
+
}
|
|
62
|
+
const s = v === '$value' ? ctx.value : String(v);
|
|
63
|
+
return `'${escape(s)}'`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
switch (kind) {
|
|
67
|
+
case 'click':
|
|
68
|
+
return { partial: 'recipe-click', targetExpr: targetExpr(arg) };
|
|
69
|
+
case 'hover':
|
|
70
|
+
return { partial: 'recipe-hover', targetExpr: targetExpr(arg) };
|
|
71
|
+
case 'type': {
|
|
72
|
+
const v = typeof arg === 'object' && arg !== null ? (arg as any).value : arg;
|
|
73
|
+
return { partial: 'recipe-type', valueExpr: valueExpr(v ?? '$value') };
|
|
74
|
+
}
|
|
75
|
+
case 'press':
|
|
76
|
+
return { partial: 'recipe-press', key: escape(String(arg)) };
|
|
77
|
+
case 'waitFor': {
|
|
78
|
+
// `waitFor: '<css>'` → wait visible. `waitFor: { target, state }` allows an explicit
|
|
79
|
+
// state ('attached' | 'visible' | 'hidden' | 'detached'); `attached` covers the AntD
|
|
80
|
+
// case where a selected chip collapses the input to ~0px (node exists before actionable).
|
|
81
|
+
const o = (typeof arg === 'object' && arg !== null ? arg : { target: arg }) as Record<string, unknown>;
|
|
82
|
+
const state = ['attached', 'visible', 'hidden', 'detached'].includes(String(o.state)) ? String(o.state) : 'visible';
|
|
83
|
+
return { partial: 'recipe-wait-for', targetExpr: targetExpr(o.target), state };
|
|
84
|
+
}
|
|
85
|
+
case 'waitForHidden':
|
|
86
|
+
return { partial: 'recipe-wait-for', targetExpr: targetExpr(arg), state: 'hidden' };
|
|
87
|
+
case 'scrollIntoView':
|
|
88
|
+
// Playwright's own actionability scroll (not arbitrary evaluate) — nudges a lazy list
|
|
89
|
+
// to render+reveal the target before a click. Deterministic, no loop.
|
|
90
|
+
return { partial: 'recipe-scroll-into-view', targetExpr: targetExpr(arg) };
|
|
91
|
+
case 'blur':
|
|
92
|
+
// One bounded el.blur() to dismiss a widget's own overlay (AntD DatePicker panel keeps
|
|
93
|
+
// pointer-blocking after fill; Escape/force-click are unreliable). No retry loop.
|
|
94
|
+
return { partial: 'recipe-blur', targetExpr: targetExpr(arg) };
|
|
95
|
+
case 'clickText': {
|
|
96
|
+
const o = (typeof arg === 'object' && arg !== null ? arg : { text: arg }) as Record<string, unknown>;
|
|
97
|
+
const text = valueExpr(o.text ?? '$value');
|
|
98
|
+
const exact = o.exact === true;
|
|
99
|
+
// Build the option-locator expression; the partial scrolls it into view then clicks.
|
|
100
|
+
const expr = o.role
|
|
101
|
+
? `page.getByRole('${escape(String(o.role))}', { name: ${text}${exact ? ', exact: true' : ''} })`
|
|
102
|
+
: `${o.within ? `page.locator('${escape(String(o.within))}')` : 'page'}.getByText(${text}${exact ? ', { exact: true }' : ''}).first()`;
|
|
103
|
+
return { partial: 'recipe-click-text', targetExpr: expr };
|
|
104
|
+
}
|
|
105
|
+
case 'expectText': {
|
|
106
|
+
const o = (typeof arg === 'object' && arg !== null ? arg : {}) as Record<string, unknown>;
|
|
107
|
+
return { partial: 'recipe-expect-text', targetExpr: targetExpr(o.locator ?? '$trigger'), valueExpr: valueExpr(o.value ?? '$value') };
|
|
108
|
+
}
|
|
109
|
+
case 'expectValue': {
|
|
110
|
+
const o = (typeof arg === 'object' && arg !== null ? arg : {}) as Record<string, unknown>;
|
|
111
|
+
return { partial: 'recipe-expect-value', targetExpr: targetExpr(o.locator ?? '$trigger'), valueExpr: valueExpr(o.value ?? '$value') };
|
|
112
|
+
}
|
|
113
|
+
case 'forEach': {
|
|
114
|
+
const o = (typeof arg === 'object' && arg !== null ? arg : {}) as Record<string, unknown>;
|
|
115
|
+
if (itemVar) throw new Error('nested forEach is not supported');
|
|
116
|
+
const inner = o.do;
|
|
117
|
+
if (!Array.isArray(inner) || inner.length === 0) throw new Error('forEach.do must be a primitive list');
|
|
118
|
+
const source = o.in === '$values' || o.in === undefined ? '$value' : o.in;
|
|
119
|
+
return {
|
|
120
|
+
partial: 'recipe-for-each',
|
|
121
|
+
sourceExpr: valueExpr(source),
|
|
122
|
+
split: escape(String(o.split ?? '>')),
|
|
123
|
+
body: inner.map((p) => expandPrimitive(p, ctx, 'item')),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
default:
|
|
127
|
+
throw new Error(`unknown recipe primitive "${kind}"`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -4,6 +4,9 @@ import { adapterRegistry } from './adapters';
|
|
|
4
4
|
import { PatternRegistry, PatternContext } from './patterns';
|
|
5
5
|
import { SelectorResolver } from './utils/selector-resolver';
|
|
6
6
|
import { DataResolver } from './utils/data-resolver';
|
|
7
|
+
import { DiagnosticsCollector } from './diagnostics';
|
|
8
|
+
import { loadAppProfile, ResolvedAppProfile } from './app-profile';
|
|
9
|
+
import { capabilityRegistry } from '../../capabilities/registry';
|
|
7
10
|
|
|
8
11
|
export interface MappedStep {
|
|
9
12
|
code: string; // Generated Playwright code
|
|
@@ -34,6 +37,12 @@ export class StepMapper {
|
|
|
34
37
|
/** Row scope: when active, column assertions are scoped to this row */
|
|
35
38
|
private inRowScope: boolean = false;
|
|
36
39
|
private rowScopeTable: string = '';
|
|
40
|
+
/** SG-W007: an action step already ran in this scenario (alert handlers registered after it are too late) */
|
|
41
|
+
private sawActionStep: boolean = false;
|
|
42
|
+
/** Compile-time warning channel (SG-W001/W002); optional — absent means silent legacy behavior */
|
|
43
|
+
private diagnostics?: DiagnosticsCollector;
|
|
44
|
+
/** App profile (qa/app.yaml merged over presets/defaults) — the per-project BEHAVIOR layer */
|
|
45
|
+
private appProfile: ResolvedAppProfile;
|
|
37
46
|
|
|
38
47
|
constructor(options: { verbose?: boolean; baseURL?: string; featureName?: string; screenName?: string; featurePath?: string; runtimeData?: boolean; framework?: string } = {}) {
|
|
39
48
|
this.verbose = options.verbose ?? false;
|
|
@@ -51,11 +60,26 @@ export class StepMapper {
|
|
|
51
60
|
this.selectorResolver = new SelectorResolver(undefined, options.screenName);
|
|
52
61
|
this.dataResolver = new DataResolver(undefined, options.screenName, options.runtimeData);
|
|
53
62
|
|
|
63
|
+
// App profile (qa/app.yaml) — loaded after PatternRegistry so capability discovery has
|
|
64
|
+
// populated the preset registry (driver-ui ships native/radix/antd). Missing file →
|
|
65
|
+
// built-in defaults → pre-profile behavior.
|
|
66
|
+
this.appProfile = loadAppProfile(process.cwd(), capabilityRegistry.appProfilePresets());
|
|
67
|
+
this.templateEngine.setPersistentContext({ portalRoles: this.appProfile.portalRoles });
|
|
68
|
+
this.selectorResolver.setContentNamedRoles(this.appProfile.contentNamedRoles);
|
|
69
|
+
|
|
54
70
|
if (this.verbose) {
|
|
55
71
|
console.log(` [StepMapper] ${this.patternRegistry.getPatternCount()} patterns loaded`);
|
|
56
72
|
}
|
|
57
73
|
}
|
|
58
74
|
|
|
75
|
+
/** Attach the compile-diagnostics collector (owned by CodeGenerator) */
|
|
76
|
+
setDiagnostics(collector: DiagnosticsCollector): void {
|
|
77
|
+
this.diagnostics = collector;
|
|
78
|
+
this.selectorResolver.setDiagnostics(collector);
|
|
79
|
+
// Flush profile-load warnings (SG-W005/W006) gathered before the collector existed.
|
|
80
|
+
for (const w of this.appProfile.warnings) collector.add(w.code, w.message);
|
|
81
|
+
}
|
|
82
|
+
|
|
59
83
|
/**
|
|
60
84
|
* Set feature context for data and selector resolution
|
|
61
85
|
*/
|
|
@@ -92,6 +116,7 @@ export class StepMapper {
|
|
|
92
116
|
this.inFrameScope = false;
|
|
93
117
|
this.inRowScope = false;
|
|
94
118
|
this.rowScopeTable = '';
|
|
119
|
+
this.sawActionStep = false;
|
|
95
120
|
this.lastPrimaryKeyword = 'Given';
|
|
96
121
|
this.dataResolver.clearCaptured(); // captured vars are scenario-scoped
|
|
97
122
|
this.templateEngine.resetBaseContext();
|
|
@@ -119,6 +144,23 @@ export class StepMapper {
|
|
|
119
144
|
this.lastPrimaryKeyword = keyword;
|
|
120
145
|
}
|
|
121
146
|
|
|
147
|
+
// SG-W007 — a two-step-form browser-alert handler (`click [OK] alert`) written AFTER
|
|
148
|
+
// an action step registers its page.once('dialog') listener too late: a dialog
|
|
149
|
+
// triggered by the earlier action auto-dismisses with no error (issue #472).
|
|
150
|
+
// Fix: move the alert step BEFORE the trigger, or use the compound form
|
|
151
|
+
// `click [X] button and accept [OK] alert`.
|
|
152
|
+
const isAlertHandlerStep = step.elementType === 'alert' &&
|
|
153
|
+
/\b(click|clicks|fill|fills)\b/.test(step.text) &&
|
|
154
|
+
!/\band\s+(accept|accepts|dismiss|dismisses)\b/i.test(step.text);
|
|
155
|
+
if (isAlertHandlerStep && this.sawActionStep) {
|
|
156
|
+
this.diagnostics?.add('SG-W007',
|
|
157
|
+
'browser-alert handler registered AFTER an action step — a dialog triggered earlier auto-dismisses silently. Move the alert step before the trigger, or use `click [X] button and accept [OK] alert`',
|
|
158
|
+
{ feature: this.featureName, step: step.text });
|
|
159
|
+
} else if (!isAlertHandlerStep &&
|
|
160
|
+
/\b(click|clicks|fill|fills|select|selects|press|presses|check|checks|uncheck|unchecks|drag|drags)\b/.test(step.text)) {
|
|
161
|
+
this.sawActionStep = true;
|
|
162
|
+
}
|
|
163
|
+
|
|
122
164
|
// Frame scope directives — intercept before pattern matching
|
|
123
165
|
if (/\bswitch(?:es)?\s+to\b/i.test(step.text) &&
|
|
124
166
|
(step.elementType === 'frame' || step.elementType === 'iframe')) {
|
|
@@ -215,16 +257,46 @@ export class StepMapper {
|
|
|
215
257
|
if (this.verbose) {
|
|
216
258
|
console.log(` ✓ Pattern matched: ${step.text.substring(0, 50)}...`);
|
|
217
259
|
}
|
|
260
|
+
this.checkDataRefConsumed(step, mappedStep);
|
|
218
261
|
return mappedStep;
|
|
219
262
|
}
|
|
220
263
|
|
|
221
264
|
// No pattern matched - return TODO comment
|
|
265
|
+
this.diagnostics?.add('SG-W001', 'step matched no pattern — compiled to a no-op // TODO comment', {
|
|
266
|
+
feature: this.featureName,
|
|
267
|
+
step: step.text,
|
|
268
|
+
});
|
|
222
269
|
return {
|
|
223
270
|
code: `// TODO: Implement step: ${step.text}`,
|
|
224
271
|
comment: `Unrecognized step pattern: ${step.text}`,
|
|
225
272
|
};
|
|
226
273
|
}
|
|
227
274
|
|
|
275
|
+
/**
|
|
276
|
+
* SG-W002 — a step carried a parsed {{dataRef}} but the matched pattern's generated
|
|
277
|
+
* code never uses it (neither the runtime marker, the ${ref} fallback, nor the
|
|
278
|
+
* inline-resolved value). This is the class of bug where `with {{v}}` silently
|
|
279
|
+
* disappears from the output (e.g. issue #464). Warning-only: loose containment
|
|
280
|
+
* checks err on the side of NOT flagging.
|
|
281
|
+
*/
|
|
282
|
+
private checkDataRefConsumed(step: ParsedStep, mapped: MappedStep): void {
|
|
283
|
+
if (!step.dataRef || !this.diagnostics) return;
|
|
284
|
+
const code = mapped.code || '';
|
|
285
|
+
if (code.includes(DataResolver.encodeMarker(step.dataRef))) return; // runtime marker
|
|
286
|
+
if (code.includes(`\${${step.dataRef}}`)) return; // unresolved fallback
|
|
287
|
+
if (code.includes(step.dataRef)) return; // testData.set('ref'), comments
|
|
288
|
+
try {
|
|
289
|
+
const value = this.dataResolver.resolveData(step.dataRef, this.featureName);
|
|
290
|
+
if (value && (code.includes(value) || code.includes(value.replace(/'/g, "\\'")))) return; // inline mode
|
|
291
|
+
} catch {
|
|
292
|
+
return; // unresolvable ref → other errors surface elsewhere; don't double-report
|
|
293
|
+
}
|
|
294
|
+
this.diagnostics.add('SG-W002', `parsed {{${step.dataRef}}} but the generated code does not use it`, {
|
|
295
|
+
feature: this.featureName,
|
|
296
|
+
step: step.text,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
228
300
|
/**
|
|
229
301
|
* Generate column assertion scoped to the current row.
|
|
230
302
|
* Uses columns config for exact cell targeting (nth), or filter fallback.
|
|
@@ -296,6 +368,7 @@ export class StepMapper {
|
|
|
296
368
|
selectorResolver: this.selectorResolver,
|
|
297
369
|
dataResolver: this.dataResolver,
|
|
298
370
|
renderLocator: (resolved) => this.templateEngine.renderStep('locator', resolved),
|
|
371
|
+
appProfile: this.appProfile,
|
|
299
372
|
baseURL: this.baseURL,
|
|
300
373
|
featureName: this.featureName,
|
|
301
374
|
screenName: this.screenName,
|
|
@@ -7,6 +7,8 @@ export class TemplateEngine {
|
|
|
7
7
|
private templatesDir: string;
|
|
8
8
|
private stepsTemplatesDir: string;
|
|
9
9
|
private baseContext: Record<string, any> = {};
|
|
10
|
+
/** Per-project context that survives scenario resets (e.g. app-profile portalRoles) */
|
|
11
|
+
private persistentContext: Record<string, any> = {};
|
|
10
12
|
|
|
11
13
|
constructor(templatesDir: string) {
|
|
12
14
|
this.templatesDir = templatesDir;
|
|
@@ -127,7 +129,17 @@ export class TemplateEngine {
|
|
|
127
129
|
Handlebars.registerHelper('pageRoot', function(this: any) {
|
|
128
130
|
// Frame scope takes precedence (frame > dialog > page)
|
|
129
131
|
if (this.inFrame) return 'frame';
|
|
130
|
-
|
|
132
|
+
// `scope: dialog` on the selector entry is the explicit opt-IN sentinel — the
|
|
133
|
+
// element is dialog-rooted even if its role is portal-exempt (issue #465).
|
|
134
|
+
const explicitDialog = this.scope === 'dialog';
|
|
135
|
+
if (!this.inDialog && !explicitDialog) return 'page';
|
|
136
|
+
// Portal-rendered roles (Radix/MUI/Headless UI/antd render option/menu/tooltip
|
|
137
|
+
// layers onto document.body) live OUTSIDE the dialog's DOM subtree — wrapping
|
|
138
|
+
// them in the dialog root guarantees a timeout. List comes from the app profile
|
|
139
|
+
// (qa/app.yaml layers.portal_roles), default DEFAULT_PORTAL_ROLES.
|
|
140
|
+
if (!explicitDialog && this.role && Array.isArray(this.portalRoles) && this.portalRoles.includes(this.role)) {
|
|
141
|
+
return 'page';
|
|
142
|
+
}
|
|
131
143
|
const partial = Handlebars.partials['dialog-root'];
|
|
132
144
|
if (partial) {
|
|
133
145
|
const fn = typeof partial === 'string' ? Handlebars.compile(partial) : partial;
|
|
@@ -152,6 +164,14 @@ export class TemplateEngine {
|
|
|
152
164
|
Handlebars.registerPartial('locator-base', locatorBaseContent);
|
|
153
165
|
}
|
|
154
166
|
|
|
167
|
+
// Register has-text-value partial — the ONE place that renders a .filter({ hasText })
|
|
168
|
+
// value, so action templates cannot drift from the exact/anchored-regex standard the
|
|
169
|
+
// assertion templates follow (issue #466).
|
|
170
|
+
const hasTextValuePath = path.join(this.stepsTemplatesDir, 'partials', 'has-text-value.hbs');
|
|
171
|
+
if (fs.existsSync(hasTextValuePath)) {
|
|
172
|
+
Handlebars.registerPartial('has-text-value', fs.readFileSync(hasTextValuePath, 'utf-8'));
|
|
173
|
+
}
|
|
174
|
+
|
|
155
175
|
// Register locator strategy partials
|
|
156
176
|
const strategiesDir = path.join(this.stepsTemplatesDir, 'partials', 'locator-strategies');
|
|
157
177
|
if (fs.existsSync(strategiesDir)) {
|
|
@@ -246,9 +266,15 @@ export class TemplateEngine {
|
|
|
246
266
|
this.baseContext = { ...this.baseContext, ...ctx };
|
|
247
267
|
}
|
|
248
268
|
|
|
249
|
-
/**
|
|
269
|
+
/** Context that survives resetBaseContext (per-project facts, e.g. app-profile portalRoles) */
|
|
270
|
+
setPersistentContext(ctx: Record<string, any>): void {
|
|
271
|
+
this.persistentContext = { ...this.persistentContext, ...ctx };
|
|
272
|
+
this.baseContext = { ...this.baseContext, ...ctx };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Reset base context (e.g., on new scenario) — persistent (per-project) context stays */
|
|
250
276
|
resetBaseContext(): void {
|
|
251
|
-
this.baseContext = {};
|
|
277
|
+
this.baseContext = { ...this.persistentContext };
|
|
252
278
|
}
|
|
253
279
|
|
|
254
280
|
renderImports(options?: { runtimeData?: boolean; basePath?: string; isParallel?: boolean; needsCleanupImport?: boolean ; needsDb?: boolean; needsApi?: boolean }): string {
|