@sun-asterisk/sungen 3.2.18 → 3.2.20
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 +4 -1
- package/dist/capabilities/discover.js.map +1 -1
- package/dist/cli/commands/add.d.ts.map +1 -1
- package/dist/cli/commands/add.js +4 -2
- package/dist/cli/commands/add.js.map +1 -1
- package/dist/cli/commands/dashboard.d.ts.map +1 -1
- package/dist/cli/commands/dashboard.js +10 -1
- package/dist/cli/commands/dashboard.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +19 -1
- 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 +10 -1
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/dashboard/html-renderer.d.ts.map +1 -1
- package/dist/dashboard/html-renderer.js +7 -2
- package/dist/dashboard/html-renderer.js.map +1 -1
- package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
- package/dist/dashboard/snapshot-builder.js +3 -0
- package/dist/dashboard/snapshot-builder.js.map +1 -1
- package/dist/dashboard/types.d.ts +4 -0
- package/dist/dashboard/types.d.ts.map +1 -1
- package/dist/exporters/api-environment.d.ts.map +1 -1
- package/dist/exporters/api-environment.js +4 -24
- package/dist/exporters/api-environment.js.map +1 -1
- package/dist/exporters/api-testcase-formatter.d.ts +6 -0
- package/dist/exporters/api-testcase-formatter.d.ts.map +1 -1
- package/dist/exporters/api-testcase-formatter.js +11 -4
- package/dist/exporters/api-testcase-formatter.js.map +1 -1
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +40 -9
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/env-qa.d.ts +13 -0
- package/dist/exporters/env-qa.d.ts.map +1 -0
- package/dist/exporters/env-qa.js +82 -0
- package/dist/exporters/env-qa.js.map +1 -0
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +16 -33
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/json-exporter.d.ts.map +1 -1
- package/dist/exporters/json-exporter.js +57 -20
- package/dist/exporters/json-exporter.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +15 -6
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/gates.d.ts.map +1 -1
- package/dist/exporters/matrix/gates.js +3 -2
- package/dist/exporters/matrix/gates.js.map +1 -1
- package/dist/exporters/matrix/step-buckets.d.ts +13 -0
- package/dist/exporters/matrix/step-buckets.d.ts.map +1 -0
- package/dist/exporters/matrix/step-buckets.js +7 -0
- package/dist/exporters/matrix/step-buckets.js.map +1 -0
- package/dist/exporters/qa-env-secret-denylist.d.ts +43 -0
- package/dist/exporters/qa-env-secret-denylist.d.ts.map +1 -0
- package/dist/exporters/qa-env-secret-denylist.js +143 -0
- package/dist/exporters/qa-env-secret-denylist.js.map +1 -0
- package/dist/exporters/scenario-merger.d.ts +20 -13
- package/dist/exporters/scenario-merger.d.ts.map +1 -1
- package/dist/exporters/scenario-merger.js +55 -30
- package/dist/exporters/scenario-merger.js.map +1 -1
- package/dist/exporters/step-formatter.d.ts +42 -33
- package/dist/exporters/step-formatter.d.ts.map +1 -1
- package/dist/exporters/step-formatter.js +68 -68
- package/dist/exporters/step-formatter.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 +9 -8
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts +2 -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 +2 -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/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +2 -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 +6 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/navigation.hbs +4 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +15 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +78 -7
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +16 -1
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +15 -0
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/generators/test-generator/recipe-expander.d.ts +1 -1
- package/dist/generators/test-generator/recipe-expander.d.ts.map +1 -1
- package/dist/generators/test-generator/recipe-expander.js +1 -1
- package/dist/generators/test-generator/recipe-expander.js.map +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts +41 -0
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +161 -17
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/generators/test-generator/template-engine.d.ts +2 -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 +18 -1
- package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/data-resolver.js +28 -4
- package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
- package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +2 -2
- package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/runtime-data-transformer.js +56 -11
- package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts +18 -2
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +29 -6
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +8 -7
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/capability-plan.d.ts.map +1 -1
- package/dist/harness/capability-plan.js +2 -3
- package/dist/harness/capability-plan.js.map +1 -1
- package/dist/harness/catalog/drivers.yaml +1 -1
- package/dist/harness/challenge.d.ts.map +1 -1
- package/dist/harness/challenge.js +1 -55
- package/dist/harness/challenge.js.map +1 -1
- package/dist/harness/data-driven-lint.d.ts.map +1 -1
- package/dist/harness/data-driven-lint.js +14 -9
- package/dist/harness/data-driven-lint.js.map +1 -1
- package/dist/harness/depth-lint.d.ts.map +1 -1
- package/dist/harness/depth-lint.js +1 -3
- package/dist/harness/depth-lint.js.map +1 -1
- package/dist/harness/flow-check.d.ts.map +1 -1
- package/dist/harness/flow-check.js +9 -7
- package/dist/harness/flow-check.js.map +1 -1
- package/dist/harness/flow-plan.d.ts.map +1 -1
- package/dist/harness/flow-plan.js +4 -3
- package/dist/harness/flow-plan.js.map +1 -1
- package/dist/harness/manifest.js +2 -2
- package/dist/harness/manifest.js.map +1 -1
- package/dist/harness/parse.d.ts +12 -0
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +28 -1
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/script-check.d.ts.map +1 -1
- package/dist/harness/script-check.js +61 -31
- package/dist/harness/script-check.js.map +1 -1
- package/dist/harness/secret-scan.d.ts +3 -1
- package/dist/harness/secret-scan.d.ts.map +1 -1
- package/dist/harness/secret-scan.js +27 -11
- package/dist/harness/secret-scan.js.map +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +15 -4
- package/dist/harness/sensors.js.map +1 -1
- package/dist/harness/trace.js +1 -1
- package/dist/harness/trace.js.map +1 -1
- package/dist/harness/unit-paths.d.ts +16 -0
- package/dist/harness/unit-paths.d.ts.map +1 -1
- package/dist/harness/unit-paths.js +39 -2
- package/dist/harness/unit-paths.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/screen-manager.d.ts.map +1 -1
- package/dist/orchestrator/screen-manager.js +8 -2
- package/dist/orchestrator/screen-manager.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
- package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +8 -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 +79 -2
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +7 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +12 -3
- package/dist/orchestrator/templates/playwright.config.ts +33 -2
- package/dist/orchestrator/templates/specs-mock.d.ts +66 -0
- package/dist/orchestrator/templates/specs-mock.d.ts.map +1 -0
- package/dist/orchestrator/templates/specs-mock.js +137 -0
- package/dist/orchestrator/templates/specs-mock.js.map +1 -0
- package/dist/orchestrator/templates/specs-mock.ts +179 -0
- 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/dist/orchestrator/test-data-guide.d.ts.map +1 -1
- package/dist/orchestrator/test-data-guide.js +4 -0
- package/dist/orchestrator/test-data-guide.js.map +1 -1
- package/package.json +3 -3
- package/src/capabilities/discover.ts +4 -1
- package/src/cli/commands/add.ts +4 -2
- package/src/cli/commands/dashboard.ts +10 -1
- package/src/cli/commands/delivery.ts +19 -1
- package/src/cli/commands/generate.ts +9 -1
- package/src/dashboard/html-renderer.ts +8 -2
- package/src/dashboard/snapshot-builder.ts +3 -0
- package/src/dashboard/types.ts +4 -0
- package/src/exporters/api-environment.ts +3 -20
- package/src/exporters/api-testcase-formatter.ts +8 -2
- package/src/exporters/csv-exporter.ts +47 -8
- package/src/exporters/env-qa.ts +41 -0
- package/src/exporters/feature-parser.ts +17 -38
- package/src/exporters/json-exporter.ts +71 -24
- package/src/exporters/matrix/build.ts +15 -6
- package/src/exporters/matrix/gates.ts +3 -2
- package/src/exporters/matrix/step-buckets.ts +15 -0
- package/src/exporters/qa-env-secret-denylist.ts +118 -0
- package/src/exporters/scenario-merger.ts +80 -42
- package/src/exporters/step-formatter.ts +67 -66
- package/src/exporters/test-data-resolver.ts +34 -1
- package/src/exporters/types.ts +9 -8
- 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/appium/templates/steps/actions/keyboard-global-action.hbs +3 -1
- package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +6 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/keyboard-global-action.hbs +3 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/page-assertion.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/storage-key-assertion.hbs +4 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/tab-order-assertion.hbs +30 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/navigation.hbs +4 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/route-assertion.hbs +1 -4
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/dialog-root.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +15 -0
- package/src/generators/test-generator/code-generator.ts +91 -8
- package/src/generators/test-generator/diagnostics.ts +16 -1
- package/src/generators/test-generator/recipe-expander.ts +2 -2
- package/src/generators/test-generator/step-mapper.ts +160 -15
- package/src/generators/test-generator/template-engine.ts +2 -2
- package/src/generators/test-generator/utils/data-resolver.ts +27 -5
- package/src/generators/test-generator/utils/runtime-data-transformer.ts +57 -11
- package/src/generators/test-generator/utils/selector-resolver.ts +29 -5
- package/src/harness/audit.ts +10 -9
- package/src/harness/capability-plan.ts +2 -2
- package/src/harness/catalog/drivers.yaml +1 -1
- package/src/harness/challenge.ts +2 -3
- package/src/harness/data-driven-lint.ts +13 -9
- package/src/harness/depth-lint.ts +2 -4
- package/src/harness/flow-check.ts +9 -7
- package/src/harness/flow-plan.ts +4 -3
- package/src/harness/manifest.ts +3 -3
- package/src/harness/parse.ts +28 -1
- package/src/harness/script-check.ts +61 -33
- package/src/harness/secret-scan.ts +23 -8
- package/src/harness/sensors.ts +15 -4
- package/src/harness/trace.ts +2 -2
- package/src/harness/unit-paths.ts +39 -2
- package/src/index.ts +1 -0
- package/src/orchestrator/screen-manager.ts +7 -2
- package/src/orchestrator/templates/ai-src/commands/dashboard.md +2 -1
- package/src/orchestrator/templates/ai-src/commands/run-test.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +8 -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 +79 -2
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +7 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +12 -3
- package/src/orchestrator/templates/playwright.config.ts +33 -2
- package/src/orchestrator/templates/specs-mock.ts +179 -0
- package/src/orchestrator/templates/specs-url-assert.ts +134 -0
- package/src/orchestrator/test-data-guide.ts +4 -0
|
@@ -311,20 +311,29 @@ export class CodeGenerator {
|
|
|
311
311
|
const activeCapabilityIds = this.activeCapabilityIds(feature);
|
|
312
312
|
const needsDb = activeCapabilityIds.includes('db');
|
|
313
313
|
const needsApi = activeCapabilityIds.includes('api');
|
|
314
|
+
const needsMock = activeCapabilityIds.includes('mock');
|
|
314
315
|
for (const id of activeCapabilityIds) {
|
|
315
316
|
for (const h of capabilityRegistry.get(id)?.runtimeHelpers ?? []) this.syncGeneratedHelper(outputDir, h.file, h.template);
|
|
316
317
|
}
|
|
317
318
|
|
|
318
|
-
const imports = this.adapter.renderImports({ runtimeData: this.options.runtimeData, basePath, needsCleanupImport, needsDb, needsApi });
|
|
319
|
-
|
|
320
319
|
// Generate test code (async now to support AI mapping)
|
|
321
320
|
const testCode = await this.generateTestCode(feature);
|
|
322
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
|
+
|
|
323
332
|
// Combine and collapse any runs of 3+ newlines down to 2 (one blank line max)
|
|
324
333
|
const raw = `${imports}\n\n${testCode}`;
|
|
325
334
|
let code = raw.replace(/\n{3,}/g, '\n\n');
|
|
326
335
|
|
|
327
|
-
// Runtime data: replace
|
|
336
|
+
// Runtime data: replace __SUNGEN_TD$…$__ markers with testData.get() calls
|
|
328
337
|
if (this.options.runtimeData) {
|
|
329
338
|
code = transformToRuntimeData(code);
|
|
330
339
|
}
|
|
@@ -382,15 +391,40 @@ export class CodeGenerator {
|
|
|
382
391
|
const steps: ParsedStep[] = [];
|
|
383
392
|
if (feature.background?.steps) steps.push(...feature.background.steps);
|
|
384
393
|
for (const sc of feature.scenarios || []) if (sc.steps) steps.push(...sc.steps);
|
|
385
|
-
|
|
394
|
+
// FEATURE-level tags count too: a feature-wide `@mock` must sync the helper and
|
|
395
|
+
// emit the import exactly like a per-scenario tag — reading scenario tags only
|
|
396
|
+
// left patterns matching while the helper/import never arrived, generating a
|
|
397
|
+
// spec that does not compile.
|
|
398
|
+
const tagPool = [
|
|
399
|
+
...(feature.tags || []),
|
|
400
|
+
...(feature.scenarios || []).flatMap((sc) => sc.tags || []),
|
|
401
|
+
];
|
|
386
402
|
const ids = new Set<string>();
|
|
387
403
|
const def = capabilityRegistry.defaultCapabilityId();
|
|
388
404
|
if (def) ids.add(def);
|
|
389
405
|
for (const cap of capabilityRegistry.all()) {
|
|
390
|
-
const annoMatch = (cap.annotations ?? []).some((a) =>
|
|
406
|
+
const annoMatch = (cap.annotations ?? []).some((a) => tagPool.some((t) => t === a || t.startsWith(a + ':')));
|
|
391
407
|
const stepMatch = cap.detectsStep ? steps.some((s) => s && typeof s.text === 'string' && cap.detectsStep!(s.text)) : false;
|
|
392
408
|
if (annoMatch || stepMatch) ids.add(cap.id);
|
|
393
409
|
}
|
|
410
|
+
|
|
411
|
+
// Opt-in driver ABSENT while the feature clearly wants it = hard error. Discovery
|
|
412
|
+
// swallows a missing driver silently and unmatched steps become warn-only TODOs —
|
|
413
|
+
// a "mocked" scenario would then run green against the REAL backend.
|
|
414
|
+
const wantsMock =
|
|
415
|
+
tagPool.some((t) => t === '@mock' || t.startsWith('@mock:') || t === '@network') ||
|
|
416
|
+
steps.some((s) => s && typeof s.text === 'string' && /\[[^\]]+\]\s+mock\s+(?:is\s+active|called)\b/i.test(s.text));
|
|
417
|
+
if (wantsMock && !capabilityRegistry.get('mock')) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`feature "${feature.name}" uses network mocking (@mock / mock steps) but the mock capability is not installed — run: sungen capability add mock`);
|
|
420
|
+
}
|
|
421
|
+
// Web-only: the runtime rides Playwright's page.route. Failing here beats a
|
|
422
|
+
// confusing "Cannot find name 'mock'" tsc error in the generated appium spec.
|
|
423
|
+
if (wantsMock && this.adapter.frameworkName === 'appium') {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`feature "${feature.name}" uses network mocking (@mock / mock steps), which is web-only — mobile drivers have no request-interception seam. Keep these scenarios @manual on mobile.`);
|
|
426
|
+
}
|
|
427
|
+
|
|
394
428
|
return [...ids];
|
|
395
429
|
}
|
|
396
430
|
|
|
@@ -525,10 +559,33 @@ export class CodeGenerator {
|
|
|
525
559
|
// tab`) is silently dropped and every scenario runs on the launcher. So branch on isParallel only
|
|
526
560
|
// for web; for appium always use generateBackgroundSteps().
|
|
527
561
|
const isMobile = this.adapter.frameworkName === 'appium';
|
|
562
|
+
const mockActive = !isMobile && this.activeCapabilityIds(feature).includes('mock');
|
|
528
563
|
if (isParallel && !isMobile) {
|
|
529
|
-
background = await this.generateBeforeEach(
|
|
564
|
+
background = await this.generateBeforeEach(
|
|
565
|
+
feature.background,
|
|
566
|
+
// The passthrough flag must be armed BEFORE the Background installs + goto —
|
|
567
|
+
// a scenario-body statement runs too late: boot-time requests of an opted-out
|
|
568
|
+
// scenario would be aborted and recorded as violations. beforeEach reads the
|
|
569
|
+
// scenario's own tags via test.info().
|
|
570
|
+
mockActive
|
|
571
|
+
? [{
|
|
572
|
+
comment: '@mock:passthrough scenarios opt out before any mock installs',
|
|
573
|
+
code: this.indentCode("if (test.info().tags.includes('@mock:passthrough')) mock.setPassthrough(page);", 4),
|
|
574
|
+
}]
|
|
575
|
+
: [],
|
|
576
|
+
);
|
|
530
577
|
} else {
|
|
531
578
|
backgroundSteps = await this.generateBackgroundSteps(feature.background);
|
|
579
|
+
// @serial: Background (beforeAll) mocks + the guard must survive each scenario's
|
|
580
|
+
// scoped reset — snapshot them so reset() removes only scenario-added routes.
|
|
581
|
+
// Without this, scenario 1's reset kills the Background mocks and scenarios 2+
|
|
582
|
+
// run against the REAL backend with no guard.
|
|
583
|
+
if (mockActive && !isMobile) {
|
|
584
|
+
backgroundSteps.push({
|
|
585
|
+
comment: 'Background mocks persist across the @serial scenarios',
|
|
586
|
+
code: this.indentCode('await mock.snapshotBackground(page);', 4),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
532
589
|
}
|
|
533
590
|
}
|
|
534
591
|
|
|
@@ -736,9 +793,12 @@ export class CodeGenerator {
|
|
|
736
793
|
return steps;
|
|
737
794
|
}
|
|
738
795
|
|
|
739
|
-
private async generateBeforeEach(
|
|
796
|
+
private async generateBeforeEach(
|
|
797
|
+
background: ParsedScenario,
|
|
798
|
+
preambleSteps: Array<{ comment?: string; code: string }> = [],
|
|
799
|
+
): Promise<string> {
|
|
740
800
|
// Map all steps
|
|
741
|
-
const steps: Array<{ comment?: string; code: string }> = [];
|
|
801
|
+
const steps: Array<{ comment?: string; code: string }> = [...preambleSteps];
|
|
742
802
|
for (const step of background.steps) {
|
|
743
803
|
const mapped = await Promise.resolve(this.stepMapper.mapStep(step));
|
|
744
804
|
steps.push({
|
|
@@ -885,6 +945,29 @@ export class CodeGenerator {
|
|
|
885
945
|
steps.unshift(...preconditions.map((p) => ({ comment: p.comment, code: this.indentCode(p.code, 4) })));
|
|
886
946
|
}
|
|
887
947
|
|
|
948
|
+
// Mock scenario epilogue. (a) EVERY path: the fail-on-unmocked verdict — the guard
|
|
949
|
+
// records+aborts unmatched requests (it must not throw inside a route handler:
|
|
950
|
+
// that surfaces as an unhandled rejection), so the scenario ends by asserting the
|
|
951
|
+
// violation list is empty. (b) @serial shared page only: a scoped reset so this
|
|
952
|
+
// scenario's routes/counters don't leak into the next (the router unroutes only
|
|
953
|
+
// what IT installed — never page.unrouteAll(), which would also kill Background
|
|
954
|
+
// mocks and third-party routes; the per-test path gets that free at page close).
|
|
955
|
+
const usesMock =
|
|
956
|
+
[...(featureTags || []), ...(scenario.tags || [])].some((t) => t === '@mock' || t.startsWith('@mock:') || t === '@network') ||
|
|
957
|
+
stepsToMap.some((s) => /\[[^\]]+\]\s+mock\s+is\s+active\b/i.test(s.text || ''));
|
|
958
|
+
if (usesMock && this.adapter.frameworkName !== 'appium' && capabilityRegistry.get('mock')) {
|
|
959
|
+
steps.push({
|
|
960
|
+
comment: 'Fail-on-unmocked verdict: any unmatched request aborted by the guard fails here',
|
|
961
|
+
code: this.indentCode('await mock.assertNoUnmocked(page);', 4),
|
|
962
|
+
});
|
|
963
|
+
if (!isParallel) {
|
|
964
|
+
steps.push({
|
|
965
|
+
comment: 'Reset mock routes and counters (shared @serial page)',
|
|
966
|
+
code: this.indentCode('await mock.reset(page);', 4),
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
888
971
|
// Extract pass-through tags (feature + scenario, excluding functional tags)
|
|
889
972
|
const tags = extractPassThroughTags(scenario.tags, featureTags);
|
|
890
973
|
|
|
@@ -20,12 +20,27 @@
|
|
|
20
20
|
* SG-W010 — a row step's [Ref] looks POSITIONAL (First/Last/Top/…) but has no selectors.yaml
|
|
21
21
|
* entry: the label is display-only, so the step checks existence anywhere in the
|
|
22
22
|
* table — an ordering rule would pass even when the row moved
|
|
23
|
+
* SG-W011 — a storage assertion written in `expect … in local/session storage` form:
|
|
24
|
+
* `expect` steps only read `{{response}}` refs, so this compiles to a silent
|
|
25
|
+
* no-op — the supported grammar is `User see [KEY] in local storage exists/…`
|
|
26
|
+
* SG-W012 — a mock-install step AFTER a navigation step in the same block: page.route()
|
|
27
|
+
* registered after goto() misses every request fired during page load — move
|
|
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
|
|
23
38
|
*
|
|
24
39
|
* Diagnostics never block generation by themselves; `sungen generate --strict`
|
|
25
40
|
* turns any collected diagnostic into a non-zero exit.
|
|
26
41
|
*/
|
|
27
42
|
|
|
28
|
-
export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009' | 'SG-W010';
|
|
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';
|
|
29
44
|
|
|
30
45
|
export interface Diagnostic {
|
|
31
46
|
code: DiagnosticCode;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
export interface RecipeExpandContext {
|
|
22
22
|
/** Pre-rendered locator code for the widget element (dialog/frame scoping included). */
|
|
23
23
|
triggerCode: string;
|
|
24
|
-
/** The step's value — a literal or a
|
|
24
|
+
/** The step's value — a literal or a __SUNGEN_TD$…$__ runtime marker. */
|
|
25
25
|
value: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -53,7 +53,7 @@ function expandPrimitive(prim: unknown, ctx: RecipeExpandContext, itemVar: strin
|
|
|
53
53
|
const targetExpr = (v: unknown): string =>
|
|
54
54
|
v === '$trigger' ? ctx.triggerCode : `page.locator('${escape(String(v))}')`;
|
|
55
55
|
// Resolve a `$value`/`$item`/literal into a JS value EXPRESSION (quoted unless $item;
|
|
56
|
-
// a quoted
|
|
56
|
+
// a quoted __SUNGEN_TD$…$__ marker is rewritten to testData.get(...) by the runtime transform).
|
|
57
57
|
const valueExpr = (v: unknown): string => {
|
|
58
58
|
if (v === '$item') {
|
|
59
59
|
if (!itemVar) throw new Error('$item is only valid inside forEach');
|
|
@@ -43,6 +43,8 @@ export class StepMapper {
|
|
|
43
43
|
private tableRowDeclared: boolean = false;
|
|
44
44
|
/** SG-W007: an action step already ran in this scenario (alert handlers registered after it are too late) */
|
|
45
45
|
private sawActionStep: boolean = false;
|
|
46
|
+
/** SG-W012: a navigation already ran in this scenario (mocks installed after it miss page-load requests) */
|
|
47
|
+
private sawNavigationStep: boolean = false;
|
|
46
48
|
/** Compile-time warning channel (SG-W001/W002); optional — absent means silent legacy behavior */
|
|
47
49
|
private diagnostics?: DiagnosticsCollector;
|
|
48
50
|
/** App profile (qa/app.yaml merged over presets/defaults) — the per-project BEHAVIOR layer */
|
|
@@ -91,6 +93,14 @@ export class StepMapper {
|
|
|
91
93
|
this.featureName = featureName;
|
|
92
94
|
this.featurePath = featurePath;
|
|
93
95
|
this.selectorResolver.setFeatureContext(featureName);
|
|
96
|
+
// One StepMapper serves every feature in a run, so the step counter that names the
|
|
97
|
+
// generated temporaries (`__t_<n>`) used to carry over between files: a screen's spec
|
|
98
|
+
// then depended on how many steps the PRECEDING files happened to contain — i.e. on
|
|
99
|
+
// readdir order and on whether the run was `--all` or `--screen`. Same input, different
|
|
100
|
+
// output, which is exactly what a deterministic compiler must not do (and what made a
|
|
101
|
+
// multi-feature screen report permanent false DRIFT in script-check). The counter only
|
|
102
|
+
// needs to be unique WITHIN one spec file, so it restarts with each feature.
|
|
103
|
+
this.stepCounter = 0;
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
/**
|
|
@@ -137,6 +147,7 @@ export class StepMapper {
|
|
|
137
147
|
this.tableRowScopeSteps = steps.filter((s) => this.isRowScopeEntryStep(s)).length;
|
|
138
148
|
this.tableRowDeclared = false;
|
|
139
149
|
this.sawActionStep = false;
|
|
150
|
+
this.sawNavigationStep = false;
|
|
140
151
|
this.lastPrimaryKeyword = 'Given';
|
|
141
152
|
this.dataResolver.clearCaptured(); // captured vars are scenario-scoped
|
|
142
153
|
this.templateEngine.resetBaseContext();
|
|
@@ -181,6 +192,20 @@ export class StepMapper {
|
|
|
181
192
|
this.sawActionStep = true;
|
|
182
193
|
}
|
|
183
194
|
|
|
195
|
+
// A mock must be routed BEFORE the navigation whose requests it should intercept —
|
|
196
|
+
// page.route() registered after goto() misses everything fired during page load
|
|
197
|
+
// (mirror of the late-alert-listener check above). Background installs compile
|
|
198
|
+
// into beforeEach, before any scenario step, so they never trip this.
|
|
199
|
+
const isMockInstallStep = /\[[^\]]+\]\s+mock\s+is\s+active\b/i.test(step.text);
|
|
200
|
+
if (isMockInstallStep && this.sawNavigationStep) {
|
|
201
|
+
this.diagnostics?.add('SG-W012',
|
|
202
|
+
'mock installed AFTER a navigation step — requests fired during page load were not intercepted. Move the mock step before the navigation, or declare it in Background',
|
|
203
|
+
{ feature: this.featureName, step: step.text });
|
|
204
|
+
} else if (!isMockInstallStep && step.elementType === 'page' &&
|
|
205
|
+
/\b(?:is on|navigates?\s+to|opens?)\b/i.test(step.text)) {
|
|
206
|
+
this.sawNavigationStep = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
184
209
|
// Frame scope directives — intercept before pattern matching
|
|
185
210
|
if (/\bswitch(?:es)?\s+to\b/i.test(step.text) &&
|
|
186
211
|
(step.elementType === 'frame' || step.elementType === 'iframe')) {
|
|
@@ -221,7 +246,14 @@ export class StepMapper {
|
|
|
221
246
|
// Given User is on [...] dialog — set dialog scope (3 cases)
|
|
222
247
|
if (step.text.includes('is on') && step.elementType === 'dialog') {
|
|
223
248
|
this.inDialogScope = true;
|
|
224
|
-
|
|
249
|
+
// setBaseContext MERGES — clear every root key so a previous dialog scope in the
|
|
250
|
+
// same scenario cannot leak its root into this one.
|
|
251
|
+
const contextVars: Record<string, any> = {
|
|
252
|
+
inDialog: true,
|
|
253
|
+
dialogRootExpr: undefined,
|
|
254
|
+
dialogRoleName: undefined,
|
|
255
|
+
dialogFilterText: undefined,
|
|
256
|
+
};
|
|
225
257
|
|
|
226
258
|
if (step.selectorRef && step.dataRef) {
|
|
227
259
|
// Case 3: [panel] dialog with {{value}}
|
|
@@ -234,16 +266,18 @@ export class StepMapper {
|
|
|
234
266
|
filterText = `\${${step.dataRef}}`;
|
|
235
267
|
}
|
|
236
268
|
contextVars.dialogFilterText = filterText;
|
|
269
|
+
// The declared entry still owns the ROOT the filter applies to — but only when it
|
|
270
|
+
// is a non-role strategy (locator/testid/css). A role entry keeps the bare
|
|
271
|
+
// getByRole('dialog') base on purpose here: the name is dynamic, hence the filter.
|
|
272
|
+
const rootExpr = this.renderDialogRoot(step.selectorRef, { nonRoleOnly: true });
|
|
273
|
+
if (rootExpr) contextVars.dialogRootExpr = rootExpr;
|
|
237
274
|
} else if (step.selectorRef) {
|
|
238
|
-
// Case 2: [Title] dialog →
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
roleName = step.selectorRef;
|
|
245
|
-
}
|
|
246
|
-
contextVars.dialogRoleName = roleName;
|
|
275
|
+
// Case 2: [Title] dialog → the root the DECLARED selectors.yaml entry asks for
|
|
276
|
+
// (issue: an explicit `type: locator` / `value: '[role="dialog"]'` was being
|
|
277
|
+
// discarded in favour of getByRole('dialog', { name: <reference text> })).
|
|
278
|
+
const rootExpr = this.renderDialogRoot(step.selectorRef);
|
|
279
|
+
if (rootExpr) contextVars.dialogRootExpr = rootExpr;
|
|
280
|
+
else contextVars.dialogRoleName = step.selectorRef;
|
|
247
281
|
}
|
|
248
282
|
// Case 1: no selectorRef → plain page.getByRole('dialog')
|
|
249
283
|
|
|
@@ -299,20 +333,127 @@ export class StepMapper {
|
|
|
299
333
|
console.log(` ✓ Pattern matched: ${step.text.substring(0, 50)}...`);
|
|
300
334
|
}
|
|
301
335
|
this.checkDataRefConsumed(step, mappedStep);
|
|
336
|
+
this.checkPageAssertionFallback(step, mappedStep);
|
|
337
|
+
this.checkDeclaredUrlHasNoQuery(step, mappedStep);
|
|
302
338
|
return mappedStep;
|
|
303
339
|
}
|
|
304
340
|
|
|
305
341
|
// No pattern matched - return TODO comment
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
342
|
+
if (/\bexpect\b.*\b(?:local|session)\s+storage\b/i.test(step.text)) {
|
|
343
|
+
// The natural first guess for a storage check — but `expect` steps only read
|
|
344
|
+
// {{response}} refs, so this shape silently compiles to nothing. Point at the
|
|
345
|
+
// supported grammar instead of emitting the generic no-pattern warning.
|
|
346
|
+
this.diagnostics?.add('SG-W011',
|
|
347
|
+
'storage assertions use `User see [KEY] in local storage exists` (also: does not exist / is not empty / has length {{n}} / key matching "PATTERN") — the `expect …` form compiles to a no-op', {
|
|
348
|
+
feature: this.featureName,
|
|
349
|
+
step: step.text,
|
|
350
|
+
});
|
|
351
|
+
} else {
|
|
352
|
+
this.diagnostics?.add('SG-W001', 'step matched no pattern — compiled to a no-op // TODO comment', {
|
|
353
|
+
feature: this.featureName,
|
|
354
|
+
step: step.text,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
310
357
|
return {
|
|
311
358
|
code: `// TODO: Implement step: ${step.text}`,
|
|
312
359
|
comment: `Unrecognized step pattern: ${step.text}`,
|
|
313
360
|
};
|
|
314
361
|
}
|
|
315
362
|
|
|
363
|
+
/** Strategies the `locator` partial renders into a usable ROOT expression. Anything else
|
|
364
|
+
* (mobile native strategies) has no meaning as a Playwright dialog root. */
|
|
365
|
+
private static readonly DIALOG_ROOT_STRATEGIES = ['locator', 'testid', 'role', 'id', 'label', 'text', 'placeholder'];
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Render the Playwright root expression for `Given User is on [X] dialog` from the entry
|
|
369
|
+
* DECLARED in selectors.yaml, through the same `locator` partial every other step renders
|
|
370
|
+
* with — so `type: locator`, `type: testid`, a non-`dialog` role, `name: null`, `exact:`
|
|
371
|
+
* and `nth:` mean here exactly what they mean everywhere else. Before this, the entry was
|
|
372
|
+
* read for its `name` only and the root was hardcoded to getByRole('dialog', { name }),
|
|
373
|
+
* silently discarding an explicit `type: locator` / `value: '[role="dialog"]'`.
|
|
374
|
+
*
|
|
375
|
+
* Returns undefined when the reference does not resolve to a usable root — the caller then
|
|
376
|
+
* falls back to the legacy named-dialog root, so behavior is unchanged for those entries.
|
|
377
|
+
*/
|
|
378
|
+
private renderDialogRoot(selectorRef: string, opts: { nonRoleOnly?: boolean } = {}): string | undefined {
|
|
379
|
+
let resolved: any;
|
|
380
|
+
try {
|
|
381
|
+
resolved = this.selectorResolver.resolveSelector(selectorRef, this.featureName, 'dialog', 0);
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
if (!resolved) return undefined;
|
|
386
|
+
|
|
387
|
+
// `type: role` + a `locator:` field collapses to strategy 'css', which the locator partial
|
|
388
|
+
// has no case for — normalize it to the raw-locator strategy it actually means.
|
|
389
|
+
if (resolved.strategy === 'css' && resolved.locator) {
|
|
390
|
+
resolved = { ...resolved, strategy: 'locator', value: resolved.locator };
|
|
391
|
+
}
|
|
392
|
+
if (!StepMapper.DIALOG_ROOT_STRATEGIES.includes(resolved.strategy)) return undefined;
|
|
393
|
+
if (opts.nonRoleOnly && resolved.strategy === 'role') return undefined;
|
|
394
|
+
|
|
395
|
+
// Render the root PAGE-rooted: leaving inDialog set (or `scope: dialog` on the entry
|
|
396
|
+
// itself) would send the partial back through dialog-root and nest the dialog in itself.
|
|
397
|
+
const expr = this.templateEngine.renderStep('locator', {
|
|
398
|
+
...resolved,
|
|
399
|
+
scope: resolved.scope === 'dialog' ? undefined : resolved.scope,
|
|
400
|
+
inDialog: false,
|
|
401
|
+
}).trim();
|
|
402
|
+
return expr || undefined;
|
|
403
|
+
}
|
|
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
|
+
|
|
316
457
|
/**
|
|
317
458
|
* SG-W002 — a step carried a parsed {{dataRef}} but the matched pattern's generated
|
|
318
459
|
* code never uses it (neither the runtime marker, the ${ref} fallback, nor the
|
|
@@ -323,7 +464,11 @@ export class StepMapper {
|
|
|
323
464
|
private checkDataRefConsumed(step: ParsedStep, mapped: MappedStep): void {
|
|
324
465
|
if (!step.dataRef || !this.diagnostics) return;
|
|
325
466
|
const code = mapped.code || '';
|
|
326
|
-
|
|
467
|
+
// Runtime marker — in both spellings: templates that embed the value into a regex
|
|
468
|
+
// literal run it through the escapeRegex helper, which escapes the marker's `$`
|
|
469
|
+
// delimiters, so the code may carry `__SUNGEN_TD\$key\$__` instead of the plain form.
|
|
470
|
+
const marker = DataResolver.encodeMarker(step.dataRef);
|
|
471
|
+
if (code.includes(marker) || code.includes(marker.replace(/\$/g, '\\$'))) return;
|
|
327
472
|
if (code.includes(`\${${step.dataRef}}`)) return; // unresolved fallback
|
|
328
473
|
if (code.includes(step.dataRef)) return; // testData.set('ref'), comments
|
|
329
474
|
try {
|
|
@@ -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 }): string {
|
|
281
|
-
return this.render('imports', { runtimeData: options?.runtimeData, basePath: options?.basePath || '..', isParallel: options?.isParallel, needsCleanupImport: options?.needsCleanupImport, needsDb: options?.needsDb, needsApi: options?.needsApi });
|
|
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);
|
|
@@ -112,12 +131,15 @@ export class DataResolver {
|
|
|
112
131
|
return { fileName: parts[0], valuePath: parts.slice(1) };
|
|
113
132
|
}
|
|
114
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Encode a test-data ref as an opaque wire marker: `__SUNGEN_TD$<key>$__`,
|
|
136
|
+
* dots escaped to `_D_`. `$` delimiters keep the key boundary unambiguous
|
|
137
|
+
* when the surrounding text continues with underscores (`{{code}}_SUFFIX`)
|
|
138
|
+
* while the marker stays a single JS-identifier-safe token. This is the ONLY
|
|
139
|
+
* producer; the only decoder lives in runtime-data-transformer.ts.
|
|
140
|
+
*/
|
|
115
141
|
static encodeMarker(ref: string): string {
|
|
116
|
-
return `
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
static decodeMarker(encoded: string): string {
|
|
120
|
-
return encoded.replace(/^__SUNGEN_TD_/, '').replace(/__$/, '').replace(/_D_/g, '.');
|
|
142
|
+
return `__SUNGEN_TD$${ref.replace(/\./g, '_D_')}$__`;
|
|
121
143
|
}
|
|
122
144
|
|
|
123
145
|
/**
|
|
@@ -1,8 +1,54 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Wire format of a runtime test-data marker: `__SUNGEN_TD$<key>$__`.
|
|
3
|
+
*
|
|
4
|
+
* `$` delimits the encoded key on both sides. The key alphabet is [A-Za-z0-9_]
|
|
5
|
+
* (dots in refs are escaped to `_D_` by DataResolver.encodeMarker), so a `$`
|
|
6
|
+
* can never occur INSIDE a key — the `$__` terminator stays unambiguous even
|
|
7
|
+
* when the surrounding literal continues with underscore text. The previous
|
|
8
|
+
* `__SUNGEN_TD_<key>__` format could not tell its `__` terminator apart from a
|
|
9
|
+
* `_`-leading suffix: in `'{{code}}_ACCESS_TOKEN'` the greedy key class
|
|
10
|
+
* swallowed one `_` of the `___` run and decoding produced `get('code_')` plus
|
|
11
|
+
* a detached `ACCESS_TOKEN`. `$` is also identifier-safe — the whole marker
|
|
12
|
+
* still parses as ONE bare JS token, so it survives the bare-identifier
|
|
13
|
+
* (numeric) positions Pass 3 handles.
|
|
14
|
+
*
|
|
15
|
+
* Single source of truth: every pass and every whole-literal probe below is
|
|
16
|
+
* built from these constants. Invariant: every marker producer goes through
|
|
17
|
+
* DataResolver.encodeMarker and every consumer through this module — a
|
|
18
|
+
* drifting hand-copied char class is what let the suffix bug hide.
|
|
19
|
+
*/
|
|
20
|
+
const MARKER_KEY = '[A-Za-z0-9_]+';
|
|
21
|
+
// Templates that embed a value into a regex literal run it through the escapeRegex
|
|
22
|
+
// Handlebars helper first (template-engine.ts), which escapes `$` — so a marker can
|
|
23
|
+
// arrive here spelled `__SUNGEN_TD\$key\$__`. `\\\\?` accepts that optional backslash
|
|
24
|
+
// before each delimiter; the replacement consumes it, so no escape leaks to output.
|
|
25
|
+
const DELIM = '\\\\?\\$';
|
|
26
|
+
/** Marker with no capture group — for "contains a marker" positions inside bigger regexes. */
|
|
27
|
+
const MARKER = `__SUNGEN_TD${DELIM}${MARKER_KEY}${DELIM}__`;
|
|
28
|
+
/** Marker capturing the encoded key. */
|
|
29
|
+
const MARKER_CAP = `__SUNGEN_TD${DELIM}(${MARKER_KEY})${DELIM}__`;
|
|
30
|
+
|
|
31
|
+
const MARKER_PATTERN = new RegExp(MARKER);
|
|
32
|
+
// Pass 0: full-line // comments carrying a marker (anchored — see pass comment).
|
|
33
|
+
const RE_COMMENT_LINE = new RegExp(`^(\\s*)\\/\\/(.*)${MARKER_CAP}(.*)$`, 'gm');
|
|
34
|
+
// Pass 1: a quoted string literal containing at least one marker. Covers ', " and
|
|
35
|
+
// backtick containers; `\\.` lets an ESCAPED quote (`'it\'s {{x}}'`) sit inside the
|
|
36
|
+
// body instead of terminating the literal early. The single-char branch excludes `\`
|
|
37
|
+
// so every backslash is consumed by exactly ONE branch — an ambiguous `(?!\1).`
|
|
38
|
+
// alternative enumerates 2^n segmentations when a marker-less escape-heavy literal
|
|
39
|
+
// (escapeQuotes output: multi-line Flutter content-desc) fails to match, hanging
|
|
40
|
+
// the generate run. `\n` exclusion just makes the dot's no-newline behavior explicit.
|
|
41
|
+
const RE_STRING_LITERAL = new RegExp('([\'"`])((?:\\\\.|(?!\\1)[^\\\\\\n])*?' + MARKER + '(?:\\\\.|(?!\\1)[^\\\\\\n])*)\\1', 'g');
|
|
42
|
+
// Pass 2: a single-line regex literal containing at least one marker.
|
|
43
|
+
const RE_REGEX_LITERAL = new RegExp(`\\/((?:[^/\\\\\\n]|\\\\.)*?${MARKER}(?:[^/\\\\\\n]|\\\\.)*)\\/([gimsuy]*)`, 'g');
|
|
44
|
+
// Whole-literal probe: the body is EXACTLY one marker (bare accessor call, no template).
|
|
45
|
+
const RE_WHOLE_MARKER = new RegExp(`^${MARKER_CAP}$`);
|
|
46
|
+
// Every marker occurrence, capturing the key (Pass 3 + interpolation).
|
|
47
|
+
const RE_MARKER_G = new RegExp(MARKER_CAP, 'g');
|
|
2
48
|
|
|
3
49
|
/**
|
|
4
|
-
* Replace
|
|
5
|
-
*
|
|
50
|
+
* Replace __SUNGEN_TD$…$__ markers with testData.get() calls in generated code.
|
|
51
|
+
* Four passes: comments, string literals, regex literals, then bare identifiers.
|
|
6
52
|
*/
|
|
7
53
|
export function transformToRuntimeData(code: string, accessor: string = 'testData'): string {
|
|
8
54
|
// Pass 0: Comments — replace markers in // comments with decoded key name.
|
|
@@ -14,7 +60,7 @@ export function transformToRuntimeData(code: string, accessor: string = 'testDat
|
|
|
14
60
|
// testData.get() (silent: `toHaveAttribute('href', 'official_site_url')`). Generated
|
|
15
61
|
// comments are always full-line, so anchoring loses nothing.
|
|
16
62
|
code = code.replace(
|
|
17
|
-
|
|
63
|
+
RE_COMMENT_LINE,
|
|
18
64
|
(_, indent, before, enc, after) => `${indent}//${before}${decodeKey(enc)}${after}`
|
|
19
65
|
);
|
|
20
66
|
|
|
@@ -30,22 +76,22 @@ export function transformToRuntimeData(code: string, accessor: string = 'testDat
|
|
|
30
76
|
// 'marker' → testData.get('key')
|
|
31
77
|
// 'a__m1__b__m2__c' → `a${testData.get('k1')}b${testData.get('k2')}c`
|
|
32
78
|
code = code.replace(
|
|
33
|
-
|
|
79
|
+
RE_STRING_LITERAL,
|
|
34
80
|
(_, _quote, body) => {
|
|
35
|
-
const whole =
|
|
81
|
+
const whole = RE_WHOLE_MARKER.exec(body);
|
|
36
82
|
if (whole) return `${accessor}.get('${decodeKey(whole[1])}')`;
|
|
37
83
|
return `\`${interpolateMarkers(body, accessor)}\``;
|
|
38
84
|
}
|
|
39
85
|
);
|
|
40
86
|
|
|
41
|
-
// Pass 2: Regex literal context —
|
|
87
|
+
// Pass 2: Regex literal context — /…$marker$…/ → new RegExp(`…`). Same
|
|
42
88
|
// several-markers-per-literal rule as Pass 1 (/{{min}}-{{max}} items/).
|
|
43
89
|
// Only matches within a single line (no newlines in the body).
|
|
44
90
|
code = code.replace(
|
|
45
|
-
|
|
91
|
+
RE_REGEX_LITERAL,
|
|
46
92
|
(_, body, flags) => {
|
|
47
93
|
const flagStr = flags ? `, '${flags}'` : '';
|
|
48
|
-
const whole =
|
|
94
|
+
const whole = RE_WHOLE_MARKER.exec(body);
|
|
49
95
|
if (whole) return `new RegExp(${accessor}.get('${decodeKey(whole[1])}')${flagStr})`;
|
|
50
96
|
return `new RegExp(\`${interpolateMarkers(body, accessor)}\`${flagStr})`;
|
|
51
97
|
}
|
|
@@ -55,7 +101,7 @@ export function transformToRuntimeData(code: string, accessor: string = 'testDat
|
|
|
55
101
|
// sits as a bare token in a numeric position (e.g. `toHaveCount(__marker__)` from the
|
|
56
102
|
// table/list count templates). testData.get() returns a string, so coerce with Number().
|
|
57
103
|
code = code.replace(
|
|
58
|
-
|
|
104
|
+
RE_MARKER_G,
|
|
59
105
|
(_, enc) => `Number(${accessor}.get('${decodeKey(enc)}'))`
|
|
60
106
|
);
|
|
61
107
|
|
|
@@ -72,5 +118,5 @@ function decodeKey(encoded: string): string {
|
|
|
72
118
|
|
|
73
119
|
/** Replace every marker in a literal's body with a `${accessor.get('key')}` interpolation. */
|
|
74
120
|
function interpolateMarkers(body: string, accessor: string): string {
|
|
75
|
-
return body.replace(
|
|
121
|
+
return body.replace(RE_MARKER_G, (_, enc) => `\${${accessor}.get('${decodeKey(enc)}')}`);
|
|
76
122
|
}
|