@sun-asterisk/sungen 3.2.12 → 3.2.14

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.
Files changed (54) hide show
  1. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/hover-element-with-text.hbs +1 -0
  2. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +11 -0
  3. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-filtered-assertion.hbs +1 -0
  4. package/dist/generators/test-generator/code-generator.d.ts +10 -0
  5. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  6. package/dist/generators/test-generator/code-generator.js +40 -3
  7. package/dist/generators/test-generator/code-generator.js.map +1 -1
  8. package/dist/generators/test-generator/diagnostics.d.ts +3 -1
  9. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  10. package/dist/generators/test-generator/diagnostics.js +2 -0
  11. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  12. package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
  13. package/dist/generators/test-generator/utils/runtime-data-transformer.js +9 -3
  14. package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
  15. package/dist/orchestrator/flow-manager.d.ts.map +1 -1
  16. package/dist/orchestrator/flow-manager.js +4 -3
  17. package/dist/orchestrator/flow-manager.js.map +1 -1
  18. package/dist/orchestrator/screen-manager.d.ts.map +1 -1
  19. package/dist/orchestrator/screen-manager.js +4 -7
  20. package/dist/orchestrator/screen-manager.js.map +1 -1
  21. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +1 -1
  22. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +6 -0
  23. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +9 -2
  24. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +1 -0
  25. package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
  26. package/dist/orchestrator/templates/specs-api.js +3 -1
  27. package/dist/orchestrator/templates/specs-api.js.map +1 -1
  28. package/dist/orchestrator/templates/specs-api.ts +3 -1
  29. package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
  30. package/dist/orchestrator/templates/specs-db.js +3 -1
  31. package/dist/orchestrator/templates/specs-db.js.map +1 -1
  32. package/dist/orchestrator/templates/specs-db.ts +3 -1
  33. package/dist/orchestrator/templates/specs-test-data.ts +65 -2
  34. package/dist/orchestrator/test-data-guide.d.ts +8 -0
  35. package/dist/orchestrator/test-data-guide.d.ts.map +1 -0
  36. package/dist/orchestrator/test-data-guide.js +193 -0
  37. package/dist/orchestrator/test-data-guide.js.map +1 -0
  38. package/package.json +4 -4
  39. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/hover-element-with-text.hbs +1 -0
  40. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +11 -0
  41. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-filtered-assertion.hbs +1 -0
  42. package/src/generators/test-generator/code-generator.ts +35 -0
  43. package/src/generators/test-generator/diagnostics.ts +3 -1
  44. package/src/generators/test-generator/utils/runtime-data-transformer.ts +10 -4
  45. package/src/orchestrator/flow-manager.ts +4 -3
  46. package/src/orchestrator/screen-manager.ts +4 -7
  47. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +1 -1
  48. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +6 -0
  49. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +9 -2
  50. package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +1 -0
  51. package/src/orchestrator/templates/specs-api.ts +3 -1
  52. package/src/orchestrator/templates/specs-db.ts +3 -1
  53. package/src/orchestrator/templates/specs-test-data.ts +65 -2
  54. package/src/orchestrator/test-data-guide.ts +158 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.2.12",
3
+ "version": "3.2.14",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -12,7 +12,7 @@
12
12
  "copy-templates": "mkdir -p dist/generators/test-generator/adapters/playwright/templates/steps && mkdir -p dist/generators/test-generator/templates && mkdir -p dist/orchestrator/templates && mkdir -p dist/dashboard/templates && cp -r src/generators/test-generator/adapters/playwright/templates/*.hbs dist/generators/test-generator/adapters/playwright/templates/ 2>/dev/null || true && cp -r src/generators/test-generator/adapters/playwright/templates/steps dist/generators/test-generator/adapters/playwright/templates/ && mkdir -p dist/generators/test-generator/adapters/appium/templates/steps && cp -r src/generators/test-generator/adapters/appium/templates/*.hbs dist/generators/test-generator/adapters/appium/templates/ 2>/dev/null || true && cp -r src/generators/test-generator/adapters/appium/templates/steps dist/generators/test-generator/adapters/appium/templates/ && cp src/generators/test-generator/templates/*.hbs dist/generators/test-generator/templates/ 2>/dev/null || true && cp -r src/orchestrator/templates/* dist/orchestrator/templates/ && cp src/dashboard/templates/index.html dist/dashboard/templates/index.html && mkdir -p dist/harness/catalog && cp src/harness/catalog/*.yaml dist/harness/catalog/",
13
13
  "build:dashboard": "cd ../../dashboard && npm install --silent && npm run build && cd - && cp ../../dashboard/dist/index.html src/dashboard/templates/index.html",
14
14
  "dev": "tsx src/cli/index.ts",
15
- "test": "tsx tests/golden/run.ts && tsx tests/audit/run.ts && tsx tests/ingest/run.ts && tsx tests/eval/run.ts && tsx tests/exporter/run.ts && tsx tests/exporter/feature-parser-category.run.ts && tsx tests/exporter/api-detail-sheet.run.ts && tsx tests/exporter/overview-sheet.run.ts && tsx tests/exporter/delivery-divider-tcid.run.ts && tsx tests/exporter/note-expected-actual.run.ts && tsx tests/exporter/test-data-vars.run.ts && tsx tests/exporter/mobile-app-id.run.ts && tsx tests/exporter/api-testcase-cells.run.ts && tsx tests/exporter/delivery-cases-result.run.ts && tsx tests/exporter/precondition-auth-role.run.ts && tsx tests/exporter/report-builder-parity.run.ts && tsx tests/exporter/delivery-preflight-testdata.run.ts && tsx tests/exporter/delivery-preflight-selectors.run.ts && tsx tests/exporter/delivery-prerun-overview.run.ts && tsx tests/exporter/manual-testcase-testdata.run.ts && tsx tests/dashboard/api-flows-discovery.run.ts && tsx tests/dashboard/status-results-fallback.run.ts && tsx tests/codegen/verb-ref-masking.run.ts && tsx tests/codegen/table-aria-hidden-locators.run.ts && tsx tests/codegen/steps-base-not-compiled.run.ts && tsx tests/codegen/manual-oracle-gate.run.ts && tsx tests/api-runtime/base-path-url-join.run.ts && tsx tests/api-runtime/file-upload-multipart.run.ts && tsx tests/api-runtime/schema-assertion.run.ts && tsx tests/runtime/test-data-cross-ref.run.ts && tsx tests/runtime/test-data-array-projection.run.ts &&tsx tests/capabilities/run.ts && tsx tests/openapi/run.ts && tsx tests/api-field-coverage/run.ts && tsx tests/packaging/run.ts && tsx tests/generate-hint/run.ts && tsx tests/template-assertion/run.ts && tsx tests/journey/run.ts && tsx tests/harness/serial-cascade.run.ts && tsx tests/ai-skills/cross-assistant-orphan.run.ts && tsx tests/init/codex-mcp-config.run.ts && tsx src/orchestrator/ai-skills/golden-skills.test.ts && tsx tests/db-runtime/sql-placeholder-rewrite.run.ts && tsx tests/db-runtime/fallback-flow-codified.run.ts && tsx tests/db-runtime/cosmos-engine.run.ts && tsx tests/db-runtime/mongodb-engine.run.ts && tsx tests/db-runtime/dynamodb-engine.run.ts && tsx tests/db-runtime/mysql-integration.opt-in.run.ts && tsx tests/db-runtime/mongodb-integration.opt-in.run.ts && tsx tests/db-runtime/dynamodb-integration.opt-in.run.ts",
15
+ "test": "tsx tests/golden/run.ts && tsx tests/audit/run.ts && tsx tests/ingest/run.ts && tsx tests/eval/run.ts && tsx tests/exporter/run.ts && tsx tests/exporter/feature-parser-category.run.ts && tsx tests/exporter/api-detail-sheet.run.ts && tsx tests/exporter/overview-sheet.run.ts && tsx tests/exporter/delivery-divider-tcid.run.ts && tsx tests/exporter/note-expected-actual.run.ts && tsx tests/exporter/test-data-vars.run.ts && tsx tests/exporter/mobile-app-id.run.ts && tsx tests/exporter/api-testcase-cells.run.ts && tsx tests/exporter/delivery-cases-result.run.ts && tsx tests/exporter/precondition-auth-role.run.ts && tsx tests/exporter/report-builder-parity.run.ts && tsx tests/exporter/delivery-preflight-testdata.run.ts && tsx tests/exporter/delivery-preflight-selectors.run.ts && tsx tests/exporter/delivery-prerun-overview.run.ts && tsx tests/exporter/manual-testcase-testdata.run.ts && tsx tests/dashboard/api-flows-discovery.run.ts && tsx tests/dashboard/status-results-fallback.run.ts && tsx tests/codegen/verb-ref-masking.run.ts && tsx tests/codegen/table-aria-hidden-locators.run.ts && tsx tests/codegen/steps-base-not-compiled.run.ts && tsx tests/codegen/manual-oracle-gate.run.ts && tsx tests/codegen/locator-value-qualified-assertions.run.ts && tsx tests/api-runtime/base-path-url-join.run.ts && tsx tests/api-runtime/file-upload-multipart.run.ts && tsx tests/api-runtime/schema-assertion.run.ts && tsx tests/runtime/test-data-cross-ref.run.ts && tsx tests/runtime/test-data-array-projection.run.ts && tsx tests/runtime/env-secret-interpolation.run.ts && tsx tests/capabilities/run.ts && tsx tests/openapi/run.ts && tsx tests/api-field-coverage/run.ts && tsx tests/packaging/run.ts && tsx tests/generate-hint/run.ts && tsx tests/template-assertion/run.ts && tsx tests/journey/run.ts && tsx tests/harness/serial-cascade.run.ts && tsx tests/ai-skills/cross-assistant-orphan.run.ts && tsx tests/init/codex-mcp-config.run.ts && tsx src/orchestrator/ai-skills/golden-skills.test.ts && tsx tests/db-runtime/sql-placeholder-rewrite.run.ts && tsx tests/db-runtime/fallback-flow-codified.run.ts && tsx tests/db-runtime/cosmos-engine.run.ts && tsx tests/db-runtime/mongodb-engine.run.ts && tsx tests/db-runtime/dynamodb-engine.run.ts && tsx tests/db-runtime/mysql-integration.opt-in.run.ts && tsx tests/db-runtime/mongodb-integration.opt-in.run.ts && tsx tests/db-runtime/dynamodb-integration.opt-in.run.ts",
16
16
  "test:update": "tsx tests/golden/run.ts --update && tsx tests/audit/run.ts --update && tsx tests/ingest/run.ts --update",
17
17
  "prepublishOnly": "npm run build:dashboard && npm run build"
18
18
  },
@@ -39,8 +39,8 @@
39
39
  "@babel/types": "^7.28.5",
40
40
  "@cucumber/gherkin": "^37.0.0",
41
41
  "@cucumber/messages": "^31.0.0",
42
- "@sungen/driver-data-factory": "3.2.12",
43
- "@sungen/driver-ui": "3.2.12",
42
+ "@sungen/driver-data-factory": "3.2.14",
43
+ "@sungen/driver-ui": "3.2.14",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.2",
46
46
  "dotenv": "^17.2.3",
@@ -0,0 +1 @@
1
+ await {{> locator-base}}.filter({ hasText: {{> has-text-value dataValue=dataValue exact=exact legacyTail=(and (eq strategy "text") (eq value ""))}} }){{> locator-nth}}.hover();
@@ -0,0 +1,11 @@
1
+ {{#if selectedProbe}}
2
+ { const el = {{> locator-base}}.filter({ hasText: /^{{escapeRegex dataValue}}$/ }){{> locator-nth}};
3
+ if ((await el.getAttribute('aria-selected')) !== null) {
4
+ await expect(el).toHaveAttribute('aria-selected', 'true');
5
+ } else {
6
+ await expect(el).toBeChecked();
7
+ }
8
+ }
9
+ {{else}}
10
+ await expect({{> locator-base}}.filter({ hasText: /^{{escapeRegex dataValue}}$/ }){{> locator-nth}}).{{{stateAssertion}}};
11
+ {{/if}}
@@ -0,0 +1 @@
1
+ await expect({{> locator-base}}.filter({ hasText: {{> has-text-value dataValue=dataValue exact=exact legacyTail=(and (eq strategy "text") (eq value ""))}} }){{> locator-nth}}).toBeVisible();
@@ -937,6 +937,40 @@ export class CodeGenerator {
937
937
  /**
938
938
  * Generate tests for all features in a directory
939
939
  */
940
+ /**
941
+ * SG-W009 — every `${QA_*}` secret reference in the unit's test-data yaml must be
942
+ * DECLARED: as a real value in .env.qa (or an overlay .env.qa.<env>), or at least as
943
+ * a name in the committed .env.qa.example. An undeclared ref means the test throws
944
+ * "environment variable not set" the moment the key is used — surface it at compile
945
+ * time instead. Only the `${QA_*}` shape is checked: any other `${...}` text is
946
+ * ordinary literal data by design (never resolved, never warned about).
947
+ */
948
+ private reportedEnvRefs = new Set<string>();
949
+ private checkEnvSecretRefs(featureFile: string): void {
950
+ const testDataDir = path.join(path.dirname(featureFile), '..', 'test-data');
951
+ if (!fs.existsSync(testDataDir)) return;
952
+ const referenced = new Set<string>();
953
+ for (const f of fs.readdirSync(testDataDir).filter((x) => x.endsWith('.yaml') || x.endsWith('.yml'))) {
954
+ const text = fs.readFileSync(path.join(testDataDir, f), 'utf-8');
955
+ for (const m of text.matchAll(/\$\{(QA_[A-Z0-9_]+)\}/g)) referenced.add(m[1]);
956
+ }
957
+ if (!referenced.size) return;
958
+ const declared = new Set<string>();
959
+ for (const f of fs.readdirSync(process.cwd()).filter((x) => x === '.env.qa' || x.startsWith('.env.qa.'))) {
960
+ for (const line of fs.readFileSync(path.join(process.cwd(), f), 'utf-8').split('\n')) {
961
+ const m = line.match(/^\s*#?\s*(QA_[A-Z0-9_]+)\s*=/);
962
+ if (m) declared.add(m[1]);
963
+ }
964
+ }
965
+ for (const name of referenced) {
966
+ if (declared.has(name) || this.reportedEnvRefs.has(name)) continue;
967
+ this.reportedEnvRefs.add(name);
968
+ this.diagnostics.add('SG-W009',
969
+ `test-data references \${${name}} but ${name} is declared in neither .env.qa nor .env.qa.example — the test will throw "environment variable not set" when the key is used. Add ${name}=<value> to .env.qa (gitignored), and list the name in .env.qa.example`,
970
+ { feature: path.basename(featureFile, '.feature'), step: name });
971
+ }
972
+ }
973
+
940
974
  async generateAllTests(
941
975
  featuresDir: string,
942
976
  outputDir: string,
@@ -956,6 +990,7 @@ export class CodeGenerator {
956
990
  try {
957
991
  const feature = parser.parseFeatureFile(featureFile);
958
992
  features.push(feature);
993
+ this.checkEnvSecretRefs(featureFile);
959
994
  } catch (error) {
960
995
  console.error(`✗ Failed to parse ${featureFile}:`, error);
961
996
  parseErrors.push({ file: featureFile, error });
@@ -15,12 +15,14 @@
15
15
  * Then (invalid Gherkin; comments cannot substitute), or a documentation-style
16
16
  * manual (no steps) whose comments lack the extractable procedure — ≥1 numbered
17
17
  * action line + ≥1 labeled Observable:/Oracle:/Expected: line
18
+ * SG-W009 — test-data references a `${QA_*}` secret that is declared in neither .env.qa
19
+ * nor .env.qa.example — the test will throw "not set" the moment the key is used
18
20
  *
19
21
  * Diagnostics never block generation by themselves; `sungen generate --strict`
20
22
  * turns any collected diagnostic into a non-zero exit.
21
23
  */
22
24
 
23
- export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008';
25
+ export type DiagnosticCode = 'SG-W001' | 'SG-W002' | 'SG-W003' | 'SG-W005' | 'SG-W006' | 'SG-W007' | 'SG-W008' | 'SG-W009';
24
26
 
25
27
  export interface Diagnostic {
26
28
  code: DiagnosticCode;
@@ -5,11 +5,17 @@ const MARKER_PATTERN = /__SUNGEN_TD_([A-Za-z0-9_]+)__/;
5
5
  * Three passes: comments, string literals, then regex literals.
6
6
  */
7
7
  export function transformToRuntimeData(code: string, accessor: string = 'testData'): string {
8
- // Pass 0: Comments — replace markers in // comments with decoded key name
9
- // Prevents Pass 2 from misinterpreting // comment markers as regex delimiters
8
+ // Pass 0: Comments — replace markers in // comments with decoded key name.
9
+ // Prevents Pass 2 from misinterpreting // comment markers as regex delimiters.
10
+ // ANCHORED to line start (^\s*//) so a `//` INSIDE a string literal on a code line
11
+ // — e.g. a URL `https://careerforum.aki-inc.jp/` in a selector name — is NOT mistaken
12
+ // for a comment. Before this anchor, such a URL preceding a marker on the same line
13
+ // made Pass 0 strip the marker to its bare key, so Pass 1 never wrapped it in
14
+ // testData.get() (silent: `toHaveAttribute('href', 'official_site_url')`). Generated
15
+ // comments are always full-line, so anchoring loses nothing.
10
16
  code = code.replace(
11
- /\/\/(.*)__SUNGEN_TD_([A-Za-z0-9_]+)__(.*)/g,
12
- (_, before, enc, after) => `//${before}${decodeKey(enc)}${after}`
17
+ /^(\s*)\/\/(.*)__SUNGEN_TD_([A-Za-z0-9_]+)__(.*)$/gm,
18
+ (_, indent, before, enc, after) => `${indent}//${before}${decodeKey(enc)}${after}`
13
19
  );
14
20
 
15
21
  // Pass 1: String literal context — handles both whole-string and embedded markers
@@ -7,6 +7,7 @@
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import { readCapabilities } from '../harness/capability';
10
+ import { testDataHeader, ensureTestDataGuideFiles } from './test-data-guide';
10
11
 
11
12
  export interface FlowOptions {
12
13
  name: string;
@@ -114,11 +115,11 @@ export class FlowManager {
114
115
  fs.writeFileSync(selectorPath, selectorLines.join('\n'), 'utf-8');
115
116
 
116
117
  fs.writeFileSync(testDataPath, [
117
- `# ${options.name} Flow Test Data`,
118
- `# Namespace by phase: login.email, submission.nominee`,
119
- `# Reference in features using {{variable}} syntax`,
118
+ testDataHeader(`${options.name} Flow`).trimEnd(),
119
+ `# Namespace keys by phase: login.email, submission.nominee`,
120
120
  ``,
121
121
  ].join('\n'), 'utf-8');
122
+ ensureTestDataGuideFiles(process.cwd());
122
123
 
123
124
  const specPath = path.join(requirementsDir, 'spec.md');
124
125
  fs.writeFileSync(specPath, this.generateSpecTemplate(options, flowName, isMobile), 'utf-8');
@@ -7,6 +7,7 @@ import * as fs from 'fs';
7
7
  import * as path from 'path';
8
8
  import { chromium } from '@playwright/test';
9
9
  import { readCapabilities } from '../harness/capability';
10
+ import { testDataHeader, ensureTestDataGuideFiles } from './test-data-guide';
10
11
 
11
12
  export interface ScreenOptions {
12
13
  name: string;
@@ -144,15 +145,11 @@ export class ScreenManager {
144
145
  fs.writeFileSync(selectorPath, selectorLines.join('\n'), 'utf-8');
145
146
  }
146
147
 
147
- // Generate empty test-data YAML
148
+ // Generate empty test-data YAML (header = the 3-kinds authoring guide)
148
149
  if (!fs.existsSync(testDataPath)) {
149
- fs.writeFileSync(testDataPath, [
150
- `# ${options.name} Screen Test Data`,
151
- `# Fill via AI or manually`,
152
- `# Reference in features using {{variable}} syntax`,
153
- ``,
154
- ].join('\n'), 'utf-8');
150
+ fs.writeFileSync(testDataPath, testDataHeader(`${options.name} Screen`), 'utf-8');
155
151
  }
152
+ ensureTestDataGuideFiles(process.cwd());
156
153
 
157
154
  // Generate requirements spec.md (only on first screen creation)
158
155
  const specPath = path.join(requirementsDir, 'spec.md');
@@ -65,7 +65,7 @@ The CLI reads the **per-target result file first** (co-located with `.spec.ts`),
65
65
  | Category 3 | Feature name (first line of `.feature`) |
66
66
  | Category 4 | Screen name |
67
67
  | Pre-condition | Auth tag → "Logged in as X" / "Not authenticated" + Given steps (natural language) |
68
- | Test Data | `{{vars}}` from scenario resolved via test-data.yaml → `key: value; key2: value2` |
68
+ | Test Data | `{{vars}}` from scenario resolved via test-data.yaml → `key: value; key2: value2`. SECRET refs are printed **verbatim** as `${QA_*}` — the exporter never resolves them, so credentials never enter CSV/XLSX/dashboard |
69
69
  | Steps | `.spec.ts` code comments for interactions (numbered) |
70
70
  | Expected results | `.spec.ts` `expect(...)` comments (numbered) |
71
71
  | Priority | Tag: `@high`/`@normal`/`@low` (default: Normal) |
@@ -76,6 +76,7 @@ needs any of these, it is a **finding for QA** — surface it in the run summary
76
76
  | Error | Diagnosis | Fix |
77
77
  |---|---|---|
78
78
  | strict mode violation | Multiple elements match | Add `nth: 0`, `exact: true`, or more specific `name` |
79
+ | strict mode violation on `see`/`hover [X] item\|option\|cell with {{v}}` (dynamic list) | Older builds didn't filter the locator by the value | Since 3.2.13 `see`/`hover ... with {{v}}` on list-member roles filters by `hasText` like `click` (existence/hover the matching item) — keep the step data-driven, don't add per-item selectors. Update sungen if still unfiltered |
79
80
  | Timeout / not found | Element doesn't exist or name wrong | Re-snapshot → copy exact accessible name. Check iframe/dialog scope |
80
81
  | Element is not an input | Wrong element type targeted | Change `type` or `value` to match actual element |
81
82
  | not a select | Custom dropdown, not native `<select>` | Set the widget variant: `variant: radix-select`/`antd-select` (built-in recipes), a custom recipe name from `qa/app.yaml`, or project-wide `widgets.select` in `qa/app.yaml` |
@@ -92,6 +93,11 @@ needs any of these, it is a **finding for QA** — surface it in the run summary
92
93
  | `SG-W006` | `qa/app.<env>.yaml` found — unsupported by design | Describe the app once in `qa/app.yaml`; env differences belong in test-data overlays |
93
94
  | `SG-W007` | Browser-alert handler step written AFTER an action — listener registers too late, the dialog auto-dismisses silently | Use the compound form `click [X] button and accept [OK] alert` (preferred), or move the alert step before the trigger |
94
95
  | `SG-W008` | `@manual` scenario incomplete in either direction: (a) HAS Gherkin steps but NO `Then` — a dangling `When` is invalid Gherkin, comments can NOT substitute; (b) documentation-style (NO steps) but its comments lack numbered action lines and/or a labeled oracle line — prose paragraphs / rationale-only comments export EMPTY Steps + Expected cells | Two valid shapes only: complete the Gherkin with a `Then`, or write the FULL procedure in the `# Tester verifies:` block as NUMBERED lines — action steps first (`1. <action>`), then labeled `Observable:`/`Oracle:`/`Expected:` lines (the number is what fills Steps, the label is what fills Expected results) |
96
+ | `SG-W009` | test-data references `${QA_<NAME>}` (a SECRET env ref) but the name is declared in neither `.env.qa` nor `.env.qa.example` — the test throws "environment variable not set" the moment the key is used. (Only the `${QA_*}` shape is checked; any other `${...}` is literal data and never flagged.) | Add `QA_<NAME>=<value>` to `.env.qa` (gitignored) and list the NAME in `.env.qa.example` (the committed checklist). See `qa/TEST-DATA-GUIDE.md` |
97
+
98
+ ### Runtime error → `Test data "<key>" references ${QA_*} but the environment variable is not set`
99
+
100
+ The scenario uses a SECRET test-data key whose `QA_*` variable is missing in this environment. Fix: copy `.env.qa.example` → `.env.qa` and fill the value (or export it in the shell/CI). Never "fix" it by writing the real secret into test-data.yaml.
95
101
 
96
102
  ### Assertion errors → apply the Source-of-truth gate above FIRST
97
103
 
@@ -29,6 +29,11 @@ user-invocable: false
29
29
  - Hardcode exact error messages in test-data.yaml — never leave `{{error}}` vague
30
30
  → Assertions are string-equal; vague placeholders produce false-positive passes.
31
31
 
32
+ - **Data classification — decide per value which of 3 kinds it is** (the scaffolded test-data header + `qa/TEST-DATA-GUIDE.md` explain this to QA; PRESERVE that header when rewriting the file):
33
+ 1. **INVENTED** (backend need not accept it): validation inputs, boundaries, XSS payloads, expected messages → hardcode in the yaml.
34
+ 2. **ENV-BOUND** (must exist in the test environment; harmless if leaked): seeded ids, registered emails, valid tokens → keep in yaml with a `# ENV-BOUND: <what to seed>` marker; per-environment values go in `<unit>.<env>.yaml`.
35
+ 3. **SECRET** (real login / harmful if leaked): NEVER write the real value — reference `${QA_<NAME>}` (uppercase, `QA_` prefix mandatory; only that shape resolves from `.env.qa` at runtime, any other `${...}` stays literal). Whenever you emit a new `${QA_*}` ref, ALSO append the name to `.env.qa.example`. Note: "password" is not automatically SECRET — a made-up password probing validation is INVENTED; only a real account's password is SECRET (and `@auth:role` via `sungen makeauth` usually removes the need entirely).
36
+
32
37
  - `@parallel` required when mixing `@auth:X` + `@no-auth` in the same feature
33
38
  → Playwright shares browser context per worker; auth state leaks across scenarios without isolation.
34
39
 
@@ -321,7 +326,9 @@ Security: [S1 – admin only]
321
326
  - **Shape A — documentation-style** (NO Gherkin steps at all): the whole procedure lives in comments — numbered **action steps first**, then labeled `Observable:`/`Oracle:` lines. Use whenever the expected is unconfirmed (SPEC-GAP) or any part of the procedure is outside the DSL (password manager, DevTools, browser zoom/viewport, multi-tab, DB seeding). Do NOT keep a stray Gherkin fragment (`Given … page` + one fill) — a `When` without `Then` is invalid Gherkin, and a partial fragment can INVERT the chronology (e.g. "save a credential first" rendering AFTER "open the page").
322
327
  - **Shape B — complete Gherkin**: `Given/When/Then`, all steps present. Use when the expected IS known and only execution is manual.
323
328
  Gate (`SG-W008`, both directions): a `@manual` with Gherkin steps but no `Then` fires — comments (even labeled `Oracle:` lines) can NOT substitute for the missing `Then`. A `@manual` with NO steps ALSO fires when its comments lack ≥1 numbered action line + ≥1 labeled `Observable:`/`Oracle:`/`Expected:` line — prose paragraphs and rationale-only comments export EMPTY Steps/Expected cells.
324
- In shape A the observable/oracle lines MUST carry their **label** (`Observable:` / `Oracle:` / `Expected:`) — the delivery parser extracts Expected results **by label**; unlabeled prose all lands in the Steps column and the Expected cell exports EMPTY. And the numbered action steps MUST precede them — without the actions the tester has an oracle but no procedure. Use this comment shape:
329
+ In shape A the observable/oracle lines MUST carry their **label** (`Observable:` / `Oracle:` / `Expected:`) — the delivery parser extracts Expected results **by label**; unlabeled prose all lands in the Steps column and the Expected cell exports EMPTY. And the numbered action steps MUST precede them — without the actions the tester has an oracle but no procedure.
330
+ **Oracle layer must match the unit** — a screen-unit manual defaults to a **UI oracle**: phrase the expected as what the tester SEES (on-screen message, list/table state after reload). If the business effect IS UI-observable, do NOT cite API responses or DB state as the oracle — "server rejects the request" is verified by *the error message on screen + no new row after reload*, not by reading the API response. Only when the effect has NO UI observation (mail content, stored hash, request count, response-body leak) may the oracle use another layer — then: (a) put the **layer label inside the line**: `Oracle: UI — …` / `Oracle: API — …` / `Oracle: DB — …` so QA knows the tooling needed from the deliverable alone; (b) tag `@manual:M2` so the capability planner recommends the api/db driver; (c) prefer the automation-ready `@requires:api`/`@query` form when the steps are expressible. A deep engineering check may ride along as `(optional deep check: …)` appended to the UI oracle — it must never REPLACE a UI-observable oracle.
331
+ Use this comment shape:
325
332
  ```gherkin
326
333
  @high @manual
327
334
  Scenario: VP-… <claim>
@@ -330,7 +337,7 @@ Security: [S1 – admin only]
330
337
  # 1. <setup> e.g. seed a registered email; throttle the network
331
338
  # 2. <action> e.g. click [Submit] with the request in flight
332
339
  # 3. Observable: <what> e.g. Observable: only ONE POST is dispatched
333
- # 4. Oracle: <tool> e.g. Oracle: DevTools Network panel / mail-catcher / NVDA
340
+ # 4. Oracle: <layer — tool> e.g. Oracle: API — DevTools Network panel · Oracle: UI — visual inspection · Oracle: DB — read the row via DB client
334
341
  ```
335
342
 
336
343
  #### Tier 1 guard — minimum before writing scenarios
@@ -188,6 +188,7 @@ Build a mapping table: for each applicable group, does the feature have a matchi
188
188
  | Check | Pts |
189
189
  |---|---|
190
190
  | `@manual` has a valid **technical** reason (not "don't know how to automate" — document as `# TODO: automate when...` comment instead) | 3 |
191
+ | **Manual oracle speaks the unit's layer** — in a screen unit the oracle is UI-observable (on-screen message, list state after reload), NOT "API response/DB state" when the effect is visible in the UI; a genuinely non-UI oracle carries its layer label (`Oracle: API — …` / `Oracle: DB — …`) + `@manual:M2` | 2 |
191
192
  | Stable selectors — per `sungen-selector-keys`: keys use spaces, standard types, `locator` only as last resort | 3 |
192
193
  | Each step names a concrete element by name/label/role (enough to implement without asking) | 2 |
193
194
  | Idempotent + no fixed waits (`wait for [T] dialog` instead of `wait N seconds`) | 2 |
@@ -24,7 +24,9 @@ interface ApiDataSource {
24
24
  }
25
25
 
26
26
  function loadEnvQa(): void {
27
- for (const name of ['.env.qa', `.env.qa.${process.env.SUNGEN_ENV || ''}`]) {
27
+ // Precedence (first set wins, real env highest): shell/CI env > .env.qa.<SUNGEN_ENV>
28
+ // > .env.qa — the env-specific file OVERLAYS the base, mirroring test-data overlays.
29
+ for (const name of [`.env.qa.${process.env.SUNGEN_ENV || ''}`, '.env.qa']) {
28
30
  const p = path.join(process.cwd(), name);
29
31
  if (!name.endsWith('.') && fs.existsSync(p)) {
30
32
  for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
@@ -110,7 +110,9 @@ async function openSshTunnel(ssh: SshConfig, dstHost: string, dstPort: number):
110
110
  }
111
111
 
112
112
  function loadEnvQa(): void {
113
- for (const name of ['.env.qa', `.env.qa.${process.env.SUNGEN_ENV || ''}`]) {
113
+ // Precedence (first set wins, real env highest): shell/CI env > .env.qa.<SUNGEN_ENV>
114
+ // > .env.qa — the env-specific file OVERLAYS the base, mirroring test-data overlays.
115
+ for (const name of [`.env.qa.${process.env.SUNGEN_ENV || ''}`, '.env.qa']) {
114
116
  const p = path.join(process.cwd(), name);
115
117
  if (!name.endsWith('.') && fs.existsSync(p)) {
116
118
  for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
@@ -39,6 +39,8 @@ export class TestDataLoader {
39
39
  }
40
40
 
41
41
  data = resolveDynamicVars(data);
42
+ loadEnvQa();
43
+ data = resolveEnvRefs(data);
42
44
 
43
45
  return new TestDataLoader(data);
44
46
  }
@@ -51,8 +53,11 @@ export class TestDataLoader {
51
53
  // A projected array (e.g. `{{q.rows[*].name}}`) serializes as a JSON list so the full
52
54
  // ordered set is preserved for the assert-compare — String([...]) would flatten it to a
53
55
  // lossy comma-join that can't be distinguished from a scalar containing commas.
54
- if (Array.isArray(value)) return this.interpolate(JSON.stringify(value));
55
- return this.interpolate(String(value));
56
+ const result = Array.isArray(value)
57
+ ? this.interpolate(JSON.stringify(value))
58
+ : this.interpolate(String(value));
59
+ assertNoUnresolvedEnvRef(key, result);
60
+ return result;
56
61
  }
57
62
 
58
63
  /**
@@ -66,6 +71,7 @@ export class TestDataLoader {
66
71
  if (value === undefined || value === null) {
67
72
  throw new Error(`Test data key not found: ${key}`);
68
73
  }
74
+ if (typeof value === 'string') assertNoUnresolvedEnvRef(key, value);
69
75
  return value;
70
76
  }
71
77
 
@@ -206,6 +212,63 @@ function deepMerge(base: Record<string, any>, override: Record<string, any>): Re
206
212
  return result;
207
213
  }
208
214
 
215
+ /**
216
+ * SECRET test data: only `${QA_*}` (uppercase, QA_ prefix) is an env reference — resolved
217
+ * from .env.qa / process.env so real credentials never live in the committed yaml. The
218
+ * prefix doubles as the resolution ALLOWLIST: any other `${...}` text is ordinary literal
219
+ * data (XSS payloads, template strings, passwords containing `${`) — never touched, and a
220
+ * yaml can never exfiltrate arbitrary environment variables like ${AWS_SECRET_ACCESS_KEY}.
221
+ */
222
+ const QA_ENV_REF = /\$\{(QA_[A-Z0-9_]+)\}/g;
223
+
224
+ /**
225
+ * Load .env.qa files into process.env. Precedence (first set wins, real env highest):
226
+ * shell/CI env > .env.qa.<SUNGEN_ENV> > .env.qa — the env-specific file OVERLAYS the base,
227
+ * mirroring how <feature>.<env>.yaml overlays <feature>.yaml.
228
+ */
229
+ function loadEnvQa(): void {
230
+ const env = process.env.SUNGEN_ENV;
231
+ for (const name of [env ? `.env.qa.${env}` : '', '.env.qa']) {
232
+ if (!name) continue;
233
+ const p = path.join(process.cwd(), name);
234
+ if (!fs.existsSync(p)) continue;
235
+ for (const line of fs.readFileSync(p, 'utf-8').split('\n')) {
236
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
237
+ if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
238
+ }
239
+ }
240
+ }
241
+
242
+ /** Substitute `${QA_*}` refs in every string value. Unset vars stay literal — the guard in
243
+ * get()/raw() throws with the var name the moment such a key is actually used. */
244
+ function resolveEnvRefs(data: Record<string, any>): Record<string, any> {
245
+ function resolveValue(value: any): any {
246
+ if (typeof value === 'string') {
247
+ return value.replace(QA_ENV_REF, (match, name) =>
248
+ process.env[name] !== undefined ? process.env[name]! : match);
249
+ }
250
+ if (Array.isArray(value)) return value.map(resolveValue);
251
+ if (value && typeof value === 'object') {
252
+ const resolved: Record<string, any> = {};
253
+ for (const [k, v] of Object.entries(value)) resolved[k] = resolveValue(v);
254
+ return resolved;
255
+ }
256
+ return value;
257
+ }
258
+ return resolveValue(data);
259
+ }
260
+
261
+ /** Fail fast — and only for the test that actually uses the key — when a `${QA_*}` secret
262
+ * was referenced but never provided. */
263
+ function assertNoUnresolvedEnvRef(key: string, value: string): void {
264
+ const m = value.match(/\$\{(QA_[A-Z0-9_]+)\}/);
265
+ if (m) {
266
+ throw new Error(
267
+ `Test data "${key}" references \${${m[1]}} but the environment variable is not set — ` +
268
+ `add ${m[1]}=... to .env.qa (gitignored; see .env.qa.example), or export it in the shell/CI.`);
269
+ }
270
+ }
271
+
209
272
  function resolveDynamicVars(data: Record<string, any>): Record<string, any> {
210
273
  const ts = String(Date.now());
211
274
  const uid = crypto.randomUUID();
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Test-data authoring guide — the QA-facing explanation of the three data classes
3
+ * (INVENTED / ENV-BOUND / SECRET `${QA_*}`) and the secret-handling convention.
4
+ *
5
+ * Two layers, both generated here so they can never drift:
6
+ * - a compact header embedded at the top of every scaffolded test-data yaml
7
+ * (create-test's skill instructs the AI to preserve it when rewriting the file);
8
+ * - the full guide at qa/TEST-DATA-GUIDE.md + the .env.qa.example starter, created
9
+ * once per project by add-screen/add-flow.
10
+ */
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+
14
+ /** Compact per-file header — examples first, one line per class. */
15
+ export function testDataHeader(unitName: string): string {
16
+ return [
17
+ `# ${unitName} Test Data — reference in features using {{variable}} syntax.`,
18
+ `# ─── HOW TO FILL VALUES (full guide: qa/TEST-DATA-GUIDE.md) ────────────────`,
19
+ `# 3 kinds of values:`,
20
+ `# 1) INVENTED — only exists to probe the screen; make it up from the spec`,
21
+ `# wrong_email: "abc@" ← write the value right here`,
22
+ `# 2) ENV-BOUND — must EXIST in the test environment (seeded id/email),`,
23
+ `# but leaking it is harmless`,
24
+ `# active_user_id: "d4f8-001" # ENV-BOUND: seeded on staging`,
25
+ `# 3) SECRET — a real login / harmful if leaked (real password, API key)`,
26
+ `# admin_password: \${QA_ADMIN_PASSWORD} ← never write the real value;`,
27
+ `# put it in .env.qa (gitignored), variable name MUST start with QA_`,
28
+ `# ───────────────────────────────────────────────────────────────────────────`,
29
+ ``,
30
+ ].join('\n');
31
+ }
32
+
33
+ const GUIDE_MD = `# Test Data Guide
34
+
35
+ Every \`{{variable}}\` in a \`.feature\` file maps to a key in that unit's
36
+ \`test-data/*.yaml\`. When you create or edit a value, decide which of the
37
+ **three kinds** it is — each kind is handled differently.
38
+
39
+ ## 1) INVENTED — made-up values that only probe the screen
40
+
41
+ **What it is:** the value exists only to trigger a reaction (an error message, a
42
+ boundary, a rendering) — the backend does not need to accept it as real data.
43
+
44
+ **Examples:** a wrong-format email \`"abc@"\`; a 6-char password to trigger the
45
+ "minimum 7 characters" error; a 256-char string to test a 255 limit; an XSS
46
+ payload \`<script>alert(1)</script>\`; the exact expected error message from the spec.
47
+
48
+ **What to do:** write the value directly in the yaml. Nothing else.
49
+
50
+ **Self-check:** *"If I swapped this for another value of the same kind, would the
51
+ test still mean the same thing?"* → yes = INVENTED.
52
+
53
+ ## 2) ENV-BOUND — real records of the test environment (not secret)
54
+
55
+ **What it is:** the value must EXIST in the environment for the test to work — a
56
+ seeded record id, a registered email, a valid invite token — but leaking it
57
+ causes no harm.
58
+
59
+ **Examples:**
60
+ \`\`\`yaml
61
+ active_user_id: "d4f8-001" # ENV-BOUND: seeded user on staging
62
+ existing_email: "tester01@example.com" # ENV-BOUND: pre-registered account
63
+ \`\`\`
64
+
65
+ **What to do:** keep it in the yaml and mark the line with \`# ENV-BOUND: ...\` so
66
+ the next person knows it must be re-seeded/replaced per environment. When the
67
+ value differs per environment, override it in \`<unit>.<env>.yaml\`
68
+ (e.g. \`login.staging.yaml\`) and run with \`SUNGEN_ENV=staging\`.
69
+
70
+ **Self-check:** *"If someone deleted this record from the DB, would the test
71
+ fail?"* → yes = ENV-BOUND.
72
+
73
+ ## 3) SECRET — real credentials; harmful if leaked
74
+
75
+ **What it is:** values that grant real access or cause harm when exposed — a
76
+ real account password, an API key, a system token.
77
+
78
+ \`\`\`yaml
79
+ # ❌ WRONG — the real password lives in git; anyone who clones the repo reads it:
80
+ admin_password: "Sun@2026"
81
+
82
+ # ✅ RIGHT — the yaml holds only the variable name; the real value stays in
83
+ # .env.qa, which is gitignored:
84
+ admin_password: \${QA_ADMIN_PASSWORD}
85
+ \`\`\`
86
+
87
+ and in \`.env.qa\` (NOT committed — copy \`.env.qa.example\` and fill it):
88
+
89
+ \`\`\`bash
90
+ QA_ADMIN_PASSWORD=Sun@2026
91
+ \`\`\`
92
+
93
+ **Rules that matter:**
94
+ - The variable name MUST start with \`QA_\` and be UPPERCASE — only \`\${QA_...}\`
95
+ is replaced at runtime. Any other \`\${...}\` text is treated as ordinary
96
+ characters (so XSS payloads / template strings containing \`\${\` are safe).
97
+ - Whenever you add a new \`\${QA_*}\` reference, also add the NAME to
98
+ \`.env.qa.example\` — that file is the committed checklist of variables each
99
+ environment must provide.
100
+ - Delivery reports (CSV/Excel) and the dashboard always print \`\${QA_ADMIN_PASSWORD}\`
101
+ literally — secrets never enter deliverables.
102
+ - Per environment: \`.env.qa.<env>\` overlays \`.env.qa\` (run with \`SUNGEN_ENV=<env>\`);
103
+ a variable exported in the shell/CI beats both files.
104
+ - If a referenced \`QA_*\` variable is missing at run time, the test that uses it
105
+ fails immediately with the variable name — check \`.env.qa\`.
106
+
107
+ **Self-check:** *"Could sharing this file outside the team cause harm?"* → yes = SECRET.
108
+
109
+ ## Easy to confuse
110
+
111
+ - **"password" is not automatically SECRET.** \`password_below_min: "Aa1bcd"\` used
112
+ to trigger a validation error is INVENTED — make it up. Only a password that
113
+ actually logs in to a real account is SECRET.
114
+ - **Testing screens behind login usually needs NO password at all** — run
115
+ \`sungen makeauth\` once: it opens a browser, you log in by hand, and the saved
116
+ session (storageState) is reused by \`@auth:<role>\` scenarios.
117
+
118
+ ## Quick reference
119
+
120
+ | Kind | Recognize by | Where the value lives |
121
+ |---|---|---|
122
+ | INVENTED | backend need not accept it | the yaml, as-is |
123
+ | ENV-BOUND | must exist in the environment; harmless if leaked | the yaml + \`# ENV-BOUND:\` marker (+ \`<unit>.<env>.yaml\` overlay) |
124
+ | SECRET | real access / harmful if leaked | \`\${QA_*}\` in the yaml → real value in \`.env.qa\` (gitignored), name listed in \`.env.qa.example\` |
125
+ `;
126
+
127
+ const ENV_QA_EXAMPLE = `# Secret test data referenced as \${QA_*} in qa/**/test-data/*.yaml.
128
+ # Copy this file to .env.qa (gitignored) and fill the real values per environment.
129
+ # Per-environment overrides: .env.qa.<env> + run with SUNGEN_ENV=<env>.
130
+ #
131
+ # QA_ADMIN_PASSWORD=
132
+ `;
133
+
134
+ /**
135
+ * Idempotently create qa/TEST-DATA-GUIDE.md and .env.qa.example, and make sure
136
+ * .gitignore covers .env.qa (incl. .env.qa.<env> overlays, but NOT .env.qa.example).
137
+ */
138
+ export function ensureTestDataGuideFiles(cwd: string): void {
139
+ const guidePath = path.join(cwd, 'qa', 'TEST-DATA-GUIDE.md');
140
+ if (fs.existsSync(path.join(cwd, 'qa')) && !fs.existsSync(guidePath)) {
141
+ fs.writeFileSync(guidePath, GUIDE_MD, 'utf-8');
142
+ }
143
+ const examplePath = path.join(cwd, '.env.qa.example');
144
+ if (!fs.existsSync(examplePath)) {
145
+ fs.writeFileSync(examplePath, ENV_QA_EXAMPLE, 'utf-8');
146
+ }
147
+ const gitignorePath = path.join(cwd, '.gitignore');
148
+ if (fs.existsSync(gitignorePath)) {
149
+ const lines = fs.readFileSync(gitignorePath, 'utf-8').split('\n').map((l) => l.trim());
150
+ // `.env.qa` alone does not cover `.env.qa.staging` — ignore the whole family
151
+ // except the committed example (negation must come after the glob).
152
+ if (!lines.includes('.env.qa') && !lines.includes('.env.qa*') && !lines.includes('.env*')) {
153
+ fs.appendFileSync(gitignorePath, `\n# sungen secret test data (see .env.qa.example)\n.env.qa*\n!.env.qa.example\n`);
154
+ }
155
+ } else {
156
+ fs.writeFileSync(gitignorePath, `# sungen secret test data (see .env.qa.example)\n.env.qa*\n!.env.qa.example\n`, 'utf-8');
157
+ }
158
+ }