@sun-asterisk/sungen 3.2.5 → 3.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/dashboard/templates/index.html +2 -2
  2. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  3. package/dist/exporters/csv-exporter.js +20 -26
  4. package/dist/exporters/csv-exporter.js.map +1 -1
  5. package/dist/exporters/feature-parser.d.ts +6 -0
  6. package/dist/exporters/feature-parser.d.ts.map +1 -1
  7. package/dist/exporters/feature-parser.js +9 -3
  8. package/dist/exporters/feature-parser.js.map +1 -1
  9. package/dist/exporters/json-exporter.d.ts.map +1 -1
  10. package/dist/exporters/json-exporter.js +58 -48
  11. package/dist/exporters/json-exporter.js.map +1 -1
  12. package/dist/exporters/result-variants.d.ts +29 -0
  13. package/dist/exporters/result-variants.d.ts.map +1 -0
  14. package/dist/exporters/result-variants.js +33 -0
  15. package/dist/exporters/result-variants.js.map +1 -0
  16. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  17. package/dist/exporters/xlsx-exporter.js +45 -13
  18. package/dist/exporters/xlsx-exporter.js.map +1 -1
  19. package/dist/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
  20. package/dist/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
  21. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
  22. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
  23. package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -1
  24. package/dist/generators/test-generator/patterns/expect-patterns.js +23 -0
  25. package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -1
  26. package/dist/generators/test-generator/utils/selector-resolver.d.ts +7 -0
  27. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  28. package/dist/generators/test-generator/utils/selector-resolver.js +13 -2
  29. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  30. package/dist/orchestrator/project-initializer.d.ts +9 -0
  31. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  32. package/dist/orchestrator/project-initializer.js +66 -9
  33. package/dist/orchestrator/project-initializer.js.map +1 -1
  34. package/dist/orchestrator/templates/ai-src/commands/add-api.md +76 -0
  35. package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  36. package/dist/orchestrator/templates/ai-src/config/codex.md +3 -2
  37. package/dist/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
  38. package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
  39. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
  40. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
  41. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
  42. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
  43. package/dist/orchestrator/templates/specs-api.d.ts +26 -0
  44. package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
  45. package/dist/orchestrator/templates/specs-api.js +131 -3
  46. package/dist/orchestrator/templates/specs-api.js.map +1 -1
  47. package/dist/orchestrator/templates/specs-api.ts +118 -5
  48. package/dist/orchestrator/templates/specs-test-data.ts +20 -1
  49. package/package.json +3 -3
  50. package/src/dashboard/templates/index.html +2 -2
  51. package/src/exporters/csv-exporter.ts +19 -24
  52. package/src/exporters/feature-parser.ts +8 -2
  53. package/src/exporters/json-exporter.ts +56 -47
  54. package/src/exporters/result-variants.ts +47 -0
  55. package/src/exporters/xlsx-exporter.ts +48 -13
  56. package/src/generators/test-generator/adapters/appium/templates/scenario.hbs +1 -1
  57. package/src/generators/test-generator/adapters/playwright/templates/scenario.hbs +6 -6
  58. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/expand-action.hbs +30 -8
  59. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/selected-assertion.hbs +16 -1
  60. package/src/generators/test-generator/patterns/expect-patterns.ts +23 -0
  61. package/src/generators/test-generator/utils/selector-resolver.ts +30 -2
  62. package/src/orchestrator/project-initializer.ts +67 -9
  63. package/src/orchestrator/templates/ai-src/commands/add-api.md +76 -0
  64. package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  65. package/src/orchestrator/templates/ai-src/config/codex.md +3 -2
  66. package/src/orchestrator/templates/ai-src/skills/sungen-api-coverage-model/SKILL.md +73 -0
  67. package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +42 -8
  68. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +21 -0
  69. package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +2 -0
  70. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +33 -0
  71. package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +6 -0
  72. package/src/orchestrator/templates/specs-api.ts +118 -5
  73. package/src/orchestrator/templates/specs-test-data.ts +20 -1
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: add-api
3
+ description: "Add a new Sungen API area or flow — scaffolds the datasource + qa/api/<area>/ directories and can import an OpenAPI/Swagger or CSV spec so the endpoint catalog (+ field metadata, auth, schemas) is populated for you"
4
+ argument-hint: "[area-name] [--base-url <url>] [--flow <name>] [--import <openapi|csv|sheet-url>] [--datasource <name>]"
5
+ order: 10
6
+ claude-tools: "Read, Grep, Bash, Glob, Edit, Write, AskUserQuestion"
7
+ copilot-tools: "[vscode, execute, read, agent, edit, search, todo]"
8
+ codex-trigger: "Run when the user asks to add or scaffold a NEW API area/resource or an API flow to test (REST/HTTP endpoints, not a UI screen). Step 1 of the API workflow. Do NOT use for UI screens, flows across screens, creating test cases, or running tests."
9
+ ---
10
+ {{^cap parallel-subagents}}
11
+ **Input**: an API area name (a resource/tag, e.g. `orders`, `users`) and the base URL:
12
+ `/sungen-add-api orders --base-url https://api.example.com`.
13
+ Import a spec so the catalog is filled in: `/sungen-add-api orders --base-url https://api.example.com --import ./openapi.yaml`.
14
+ Add an API flow (ordered CRUD/auth journey) instead of a single area: `/sungen-add-api user-lifecycle --flow`.
15
+ {{/cap}}
16
+ You are scaffolding an **API-first** unit for test generation — an area (one resource) or a flow (a cross-endpoint journey). There is **no visual capture and no selectors**: the contract is the named-endpoint catalog (`api/apis.yaml`), referenced by `@api:<name>`. Follow the `sungen-api-design` skill after scaffolding.
17
+
18
+ ## Parameters
19
+
20
+ {{#cap parallel-subagents}}
21
+ Parse from `$ARGUMENTS`:
22
+ - **name** — area or flow name (e.g. `orders`, `users`, `user-lifecycle`)
23
+ - **`--base-url <url>`** — the API base URL (stored in `.env.qa`, referenced as `${<DS>_URL}`). Needed the first time (project not yet API-initialized); skippable if `qa/datasources.yaml` already has a `kind: api` datasource.
24
+ - **`--flow <name>`** — scaffold an ordered `@api` chain (auth → CRUD) under `qa/api/flows/<name>/` instead of a single area.
25
+ - **`--import <source>`** — an OpenAPI/Swagger file/URL or a CSV / published Google-Sheet URL. When given, the catalog + `fields:` metadata + inferred auth headers + response `schemas.yaml` are generated from it.
26
+ - **`--datasource <name>`** — datasource name (default `app_api`).
27
+ {{/cap}}
28
+ {{^cap parallel-subagents}}
29
+ - **name** — ${input:name:API area or flow name (e.g. orders, users)}
30
+ - **`--base-url <url>`** — API base URL (first-time init; skip if already API-initialized)
31
+ - **`--flow <name>`** — scaffold an ordered @api chain instead of a single area
32
+ - **`--import <source>`** — OpenAPI/Swagger or CSV/Sheet URL → auto-populate the catalog
33
+ - **`--datasource <name>`** — datasource name (default app_api)
34
+ {{/cap}}
35
+
36
+ If **name** is missing, ask: "What is the API area (resource) name? (e.g., `orders`, `users`)".
37
+ If **`--base-url`** is missing AND `qa/datasources.yaml` has no `kind: api` datasource, ask for it (it's needed to run the tests later; you can proceed with scaffolding and add it after).
38
+
39
+ ## Steps
40
+
41
+ ### 1. Initialize the API project (first area only)
42
+
43
+ Check `qa/datasources.yaml` for a `kind: api` datasource. If none, run:
44
+ ```bash
45
+ sungen api init --base-url <base-url> --datasource <datasource>
46
+ ```
47
+ This creates the `kind: api` datasource, `qa/api/`, and the `.env.qa` URL key. Skip if already initialized (never clobbers).
48
+
49
+ ### 2. Scaffold the area (or flow)
50
+
51
+ ```bash
52
+ sungen api add --area <name> # a single resource → qa/api/<name>/
53
+ # or
54
+ sungen api add --flow <name> # an ordered CRUD/auth journey → qa/api/flows/<name>/
55
+ ```
56
+ This creates `features/`, `api/apis.yaml`, `test-data/`, and `requirements/spec.md`.
57
+
58
+ ### 3. Populate the catalog (recommended)
59
+
60
+ If `--import <source>` was given, run it — importing a spec is the single biggest quality lever (the catalog gets accurate paths, query/body params, `fields:` constraints, inferred auth headers, and response `schemas.yaml`, so `/sungen:create-test` can enumerate a complete case set instead of guessing):
61
+ ```bash
62
+ sungen api import <source> --datasource <datasource>
63
+ ```
64
+ No spec? Hand-author `qa/api/<name>/api/apis.yaml` (or fill `requirements/spec.md` and let `/sungen:create-test` draft it). Review the generated catalog + wire the datasource `base_url`/auth in `datasources.yaml` + `.env.qa`.
65
+
66
+ ### 4. Converge (next actions)
67
+
68
+ Use `AskUserQuestion` to offer the next step — do NOT just print text:
69
+ - **`/sungen:create-test <name>`** (Recommended) — generate `.feature` + `test-data` (auto-detects the API unit; enumerates the coverage model from `fields:`).
70
+ - **`/sungen:add-api <another> --base-url …`** — scaffold another area.
71
+ - Review `qa/api/<name>/api/apis.yaml` + wire the datasource, then create tests.
72
+
73
+ ## Rules
74
+ - **No visual capture, no selectors** — API units are catalog-driven; don't run Playwright/Figma capture.
75
+ - **Never clobber** — `init` merges datasources; `add` refuses an existing area; `import` needs `--merge` to combine.
76
+ - **Non-prod default** — a `production` datasource is refused at runtime unless `SUNGEN_ALLOW_PROD=1`.
@@ -62,10 +62,10 @@ Parse **name** from `$ARGUMENTS`. If missing, ask the user.
62
62
  ## API unit mode (driver-api)
63
63
 
64
64
  {{#cap parallel-subagents}}
65
- If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the design loop differs — **no visual capture, no selectors**; the contract is the named-endpoint catalog. **Follow the `sungen-api-design` skill end-to-end** instead of the screen/flow steps below: `sungen context --area <name>` (discover) → API viewpoint overview → generate `@api`/`@cases`/flow/`@concurrent`/`@query` scenarios → **`sungen audit --area <name>` gate + the `sungen-reviewer` sub-agent + repair loop to businessDepth ≥ 0.7** → record + trace. Then jump to the "Converge" next-step options (recommend `/sungen:run-test <name>`). The capture / viewpoint-group / selector steps do **not** apply.
65
+ If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the design loop differs — **no visual capture, no selectors**; the contract is the named-endpoint catalog. **Follow the `sungen-api-design` + `sungen-api-coverage-model` skills end-to-end** instead of the screen/flow steps below: `sungen context --area <name>` (discover endpoints + `fields:`) → **enumerate the Tier-1 case list per endpoint from the coverage model** (contract + not-found/required-matrix + auth + idempotency, expanded mechanically from `fields:`) → generate `@api`/`@cases`/flow/`@concurrent`/`@query` scenarios with **strict assertions** (prove the effect, never status-only) → **`sungen audit --area <name>` gate + the `sungen-reviewer` sub-agent + repair loop to businessDepth ≥ 0.7** → record + trace. Then jump to the "Converge" next-step options (recommend `/sungen:run-test <name>`). The capture / viewpoint-group / selector steps do **not** apply.
66
66
  {{/cap}}
67
67
  {{^cap parallel-subagents}}
68
- If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the design loop differs — **no visual capture, no selectors**; the contract is the named-endpoint catalog. **Follow the `sungen-api-design` skill end-to-end** instead of the screen/flow steps: `sungen context --area <name>` (discover) → API viewpoint overview → generate `@api`/`@cases`/flow/`@concurrent`/`@query` scenarios → **`sungen audit --area <name>` gate + reviewer + repair loop to businessDepth ≥ 0.7** → record + trace. Then recommend `/sungen-run-test <name>`. The capture / viewpoint-group / selector steps do **not** apply.
68
+ If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the design loop differs — **no visual capture, no selectors**; the contract is the named-endpoint catalog. **Follow the `sungen-api-design` + `sungen-api-coverage-model` skills end-to-end** instead of the screen/flow steps: `sungen context --area <name>` (discover endpoints + `fields:`) → **enumerate the Tier-1 case list per endpoint from the coverage model** (contract + not-found/required-matrix + auth + idempotency, expanded mechanically from `fields:`) → generate `@api`/`@cases`/flow/`@concurrent`/`@query` scenarios with **strict assertions** (prove the effect, never status-only) → **`sungen audit --area <name>` gate + reviewer + repair loop to businessDepth ≥ 0.7** → record + trace. Then recommend `/sungen-run-test <name>`. The capture / viewpoint-group / selector steps do **not** apply.
69
69
  {{/cap}}
70
70
 
71
71
  ## Steps
@@ -2,7 +2,8 @@
2
2
 
3
3
  This project uses **sungen** (Gherkin → Playwright test compiler). Its workflow and
4
4
  reference skills are installed under `.agents/skills/sungen-*`. Codex auto-discovers them;
5
- pick a workflow step with `/skills` or `$sungen-create-test`, or just describe the task
6
- (e.g. "create a test for the login screen") and the matching skill triggers by description.
5
+ invoke a workflow step with `$sungen-create-test <args>` (typing `$` opens the skill
6
+ autocomplete), or just describe the task (e.g. "create a test for the login screen") and
7
+ the matching skill triggers by description. `/skills` lists the installed skills.
7
8
 
8
9
  Workflow order: add-screen → create-test → review → run-test → delivery.
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: sungen-api-coverage-model
3
+ description: The per-endpoint API coverage model — the systematic case set to enumerate for each endpoint (read / mutating / flow), keyed to the catalog's fields: metadata, tiered so a first pass is complete (not "happy path + 1 error"). Load this in API unit mode alongside sungen-api-design; it turns endpoints × field constraints into a concrete case list, mechanically, instead of guessing.
4
+ ---
5
+
6
+ # API coverage model (what to test per endpoint)
7
+
8
+ The gap this closes: a bare `@api` generation defaults to **one happy path + maybe one error** per endpoint — 3 of 8 needed cases. This skill is the **enumeration rule**: for each endpoint, walk the checklist below and emit **every applicable case**. Cases are derived **mechanically** from the catalog `fields:` block (`required` / `type` / `format` / `min`/`max` / `minLength`/`maxLength` / `pattern` / enum `values`) surfaced by `sungen context --area <name>` — do NOT guess the matrix from prose when `fields:` exists.
9
+
10
+ **Tiers** — always emit **Tier 1** (the complete-first-pass minimum). Emit **Tier 2** when the user asks for full coverage or the spec flags the concern as business-critical. Don't stop at Tier 1's *count* if an endpoint has more required/constrained fields — the matrix scales with the fields.
11
+
12
+ ## Read endpoint (GET / HEAD)
13
+
14
+ | # | Case | Tier | Route | From |
15
+ |---|------|------|-------|------|
16
+ | R1 | **Contract** — 2xx + response body asserted (≥1 server-derived field value; when a `schemaRef` exists, add `expect {{name.body}} matches schema [Ref]` for the full shape) | 1 | `@api` | always |
17
+ | R2 | **Not found** — by-id read with a missing id → 404 (or the spec's code) | 1 | `@api` | path has an id param |
18
+ | R3 | **Unauthorized** — no / invalid token → 401 | 1 | `@api` + `@cases` | endpoint requires auth |
19
+ | R4 | **Empty state** — query that returns 0 rows → 2xx + empty array / count 0 | 2 | `@api` | list endpoint |
20
+ | R5 | **Forbidden** — valid token, wrong role → 403 | 2 | `@api` | role-scoped |
21
+ | R6 | **Per query param validation** — for each `fields:` query param: required→omit→400; type mismatch→400; enum→invalid value→400; `min/max`→BVA rows | 2 | `@cases` | each constrained query field |
22
+ | R7 | **Pagination / filter / sort** — offset/limit boundaries, sort direction, filter match | 2 | `@api`/`@cases` | paginated list |
23
+
24
+ ## Mutating endpoint (POST / PUT / PATCH / DELETE)
25
+
26
+ | # | Case | Tier | Route | From |
27
+ |---|------|------|-------|------|
28
+ | M1 | **Happy path** — 2xx + response body asserted (id/echoed fields) **and prove the effect** (`@query` DB read-back, or a follow-up GET) | 1 | `@api` (+`@query`) | always |
29
+ | M2 | **Required-field matrix** — one row per `required` field omitted → 400 + error message | 1 | `@cases` | each `required` field |
30
+ | M3 | **Unauthorized** — no / invalid token → 401 | 1 | `@api`/`@cases` | endpoint requires auth |
31
+ | M4 | **Idempotency / duplicate** — `@concurrent:N` + `@query` oracle → exactly one effect (or 409 on the 2nd) | 1 | `@concurrent`+`@query` | create / non-idempotent |
32
+ | M5 | **Type + boundary matrix** — per constrained field: wrong type; `min-1/min/max/max+1`; `minLength/maxLength` edges | 2 | `@cases` | each constrained field |
33
+ | M6 | **Format / enum matrix** — per `format`/`pattern`/enum field: an invalid value → 400 | 2 | `@cases` | each format/enum field |
34
+ | M7 | **Forbidden** — valid token, wrong role → 403 | 2 | `@api`/`@cases` | role-scoped |
35
+ | M8 | **Semantic (422)** — fields individually valid but violate a business rule (e.g. end < start) | 2 | `@cases` | cross-field rule |
36
+ | M9 | **Payload size / content-type** — oversized body → 413/400; missing/wrong `Content-Type` → 400 | 2 | `@cases` | as spec dictates |
37
+
38
+ ## Flow (multi-endpoint journey — qa/api/flows/<flow>)
39
+
40
+ | # | Case | Tier |
41
+ |---|------|------|
42
+ | F1 | **CRUD chain** — create → read-back → update → read-back → delete → 404 (each step asserts + self-cleans) | 1 |
43
+ | F2 | **Auth flow** — login → authed call 2xx → logout → same call 401 | 1 |
44
+ | F3 | **Cross-endpoint invariant** — create in A, verify in B (data consistency) | 2 |
45
+
46
+ ## Deriving the matrix from `fields:`
47
+
48
+ For an endpoint with `fields:` metadata, expand mechanically:
49
+
50
+ - `required: true` → **one M2/R6 missing-field row** (omit it, expect 400 + message).
51
+ - `type` (integer/number/boolean) → a **wrong-type row** (send a string).
52
+ - `min`/`max` (or `minLength`/`maxLength`) → **BVA rows**: `min-1` (invalid), `min` (valid edge), `max` (valid edge), `max+1` (invalid).
53
+ - `values` (enum) → a **not-in-enum row**.
54
+ - `format`/`pattern` → an **ill-formed row** (bad email, wrong pattern).
55
+
56
+ Collapse these into compact `@cases` datasets — one dataset per endpoint, one row per (field, technique), each row carrying its `expect_status`. Don't write one scenario per row.
57
+
58
+ ## Assertion depth (tie every case to a proof)
59
+
60
+ Never emit a status-only assertion for a success case. Three tiers — see also `sungen-tc-review` (API assertion rubric):
61
+
62
+ - **Shallow ❌** — `expect {{x.status}} is 201` alone. Proves the server didn't 500; not that anything happened.
63
+ - **Medium ⚠️** — status + a response body field (`{{x.body.id}}`). OK for a read contract.
64
+ - **Strict ✅ (required for mutating success + Tier-1 reads)** — status + body field **value that isn't just the input echoed back** + a **proof of effect**: a `@query` DB read-back, a follow-up GET, `matches schema [Ref]` (full-shape contract, when a `schemaRef` exists), or a `@concurrent` `ok_count` invariant.
65
+
66
+ > `expect {{name.body}} matches schema [Ref]` validates the whole response body against the named JSON Schema in `qa/api/schemas.yaml` (populated by `sungen api import`). Use it in addition to — not instead of — a couple of concrete field-value asserts on the critical fields.
67
+
68
+ Anti-pattern to avoid: re-asserting the value you just sent (`expect {{x.body.username}} is {{username}}` on the thing you created proves little) — assert a **server-derived** field (generated id, timestamp, computed status) or read it back independently.
69
+
70
+ ## Output discipline
71
+ - Enumerate the **full Tier-1 list per endpoint first**, then write incrementally (≈10–15 scenarios per Write call) — never the whole suite in one response (output-token cap).
72
+ - Name scenarios `VP-API-NNN <what it proves>`; keep the endpoint + technique legible.
73
+ - Enumerate the matrix **mechanically from `fields:`** — that is the coverage guarantee this phase; don't rely on a gate to backfill it. (A future audit gate will additionally flag a `required`/constrained field with no matching negative case — `VIEWPOINT-API-REQUIRED-MATRIX` / `-BOUNDARY` / `-FORMAT` — but until it ships, completeness comes from the enumeration above, not from repair.)
@@ -10,20 +10,30 @@ Use this when the unit is **api-first** — `qa/api/<area>/` or `qa/api/flows/<f
10
10
  ## The loop (mirror of /sungen:design, API-native)
11
11
 
12
12
  ### 1. Discover (no capture)
13
- Run `sungen context --area <name>` — it reads the catalog and prints the **endpoints** + the **generation units** (one `matrix` unit per endpoint, an `async` unit per mutating endpoint, a `flow` unit for an api flow). Read `qa/api/<name>/requirements/spec.md` if present. No `apis.yaml` yet? → `sungen api import <openapi|csv>` or `sungen api add --area <name>` first.
13
+ Run `sungen context --area <name>` — it reads the catalog and prints the **endpoints**, their **`fields:` metadata** (required/type/format/range/enum), any **`schemaRef`**, and the **generation units** (one `matrix` unit per endpoint, an `async` unit per mutating endpoint, a `flow` unit for an api flow). Read `qa/api/<name>/requirements/spec.md` if present. No `apis.yaml` yet? → `sungen api import <openapi|csv>` or `sungen api add --area <name>` first.
14
14
 
15
- ### 2. API viewpoint overview (by method-profile)
16
- For each endpoint, cover its viewpointsseverity-weighted by method:
15
+ ### 2. Enumerate the coverage model (don't guess the matrix)
16
+ **Load the `sungen-api-coverage-model` skill** and, for each endpoint, walk its checklist to produce the **concrete Tier-1 case list** (a read endpoint → contract + not-found + unauthorized; a mutating endpoint → happy+side-effect + required-field matrix + unauthorized + idempotency). Expand the validation matrix **mechanically from `fields:`** one `@cases` row per (required/constrained field × technique), each with its `expect_status`. This is the difference between 3-of-8 and a complete first pass.
17
17
 
18
- | Profile | Endpoints | Must cover | Then |
19
- |---|---|---|---|
20
- | read | GET, HEAD | `contract` (status + body shape) | `pagination`/`filter` (list), `not-found` (by-id) |
21
- | mutating | POST/PUT/PATCH/DELETE | `contract`, `error` (validation/4xx/auth) | `idempotency` (`@concurrent`), `side-effect` (`@query`) |
18
+ **Mapping Contract** endpoint profile cases route:
19
+
20
+ | Profile | Viewpoint | Cases (Tier 1) | Technique | Route |
21
+ |---|---|---|---|---|
22
+ | GET/HEAD | contract | 1 | happy + body-field assert (+`matches schema [Ref]` if `schemaRef`) | `@api` |
23
+ | GET/HEAD | not-found | 1 (by-id) | missing id → 404 | `@api` |
24
+ | GET/HEAD | auth | 1 | no/invalid token → 401 | `@api`/`@cases` |
25
+ | GET/HEAD | param validation | N (Tier 2) | EP/BVA per query field | `@cases` |
26
+ | POST/PUT/PATCH/DELETE | contract + side-effect | 1 | happy + `@query` read-back | `@api`+`@query` |
27
+ | POST/PUT/PATCH/DELETE | required matrix | N = #required fields | one row per omitted field → 400 | `@cases` |
28
+ | POST/PUT/PATCH/DELETE | auth | 1 | no/invalid token → 401 | `@api`/`@cases` |
29
+ | POST/PUT/PATCH/DELETE | idempotency | 1 | `@concurrent:N` + `@query` | `@concurrent`+`@query` |
30
+ | POST/PUT/PATCH/DELETE | type/boundary/format | N (Tier 2) | per constrained field | `@cases` |
22
31
 
23
32
  Bands: **~70%** success+failure matrix · **~20%** flows (auth/CRUD chains) · **~10%** async/idempotency.
24
33
 
25
34
  ### 3. Generate (incremental — never the whole suite in one Write)
26
- - **Contract**: `@api:<name>` + `expect {{name.status}} is …` **and a body assertion** (`{{name.body.<path>}}`).
35
+ Follow the coverage model's **assertion depth tiers** a mutating success case must **prove the effect** (body value + `@query`/read-back), never status-only, and must not merely re-assert the input it just sent.
36
+ - **Contract**: `@api:<name>` + `expect {{name.status}} is …` **and a body assertion** on a server-derived field (`{{name.body.<path>}}`); when the entry has a `schemaRef`, add `expect {{name.body}} matches schema [<Ref>]` for the full-shape contract (validates against `qa/api/schemas.yaml`).
27
37
  - **Error matrix**: `@api:<name>(p={{p}}) @cases:<dataset>` — one scenario, a dataset of `input → expected status`.
28
38
  - **Flow**: ordered `@api` tags threading a prior response (`token={{login.body.token}}` → the catalog `Bearer :token` header; `id={{create.body.id}}` → a path param). Self-clean (delete what you create).
29
39
  - **Idempotency**: `@api:<name> @concurrent:N` + `expect {{name.ok_count}} is 1`, cross-checked with `@query` (the DB is the oracle).
@@ -37,8 +47,13 @@ Run `sungen audit --area <name>`; read `gateStatus` + `findings`. Then the **sem
37
47
  | `VIEWPOINT-API-ERROR` | a mutating endpoint has no failure scenario → add a `@cases` error matrix (or an explicit 4xx) |
38
48
  | `VIEWPOINT-API-IDEMPOTENCY` | a mutating endpoint has no race check → add `@concurrent:N` + a `@query` DB cross-check |
39
49
  | `VIEWPOINT-API-MANUAL-AUTOMATABLE` | a `@manual` scenario whose endpoint resolves is automatable → drop `@manual`, use `@api` (+ `@cases`); reserve `@manual` for genuine judgment cases |
50
+ | `VIEWPOINT-API-REQUIRED-MATRIX` | a `required` field (from `fields:`) has no missing-field case → add a `@cases` row omitting it, asserting 4xx |
51
+ | `VIEWPOINT-API-BOUNDARY` | a field with a `min/max/minLength/maxLength` constraint has no boundary case → add BVA `@cases` rows (`min-1/min/max/max+1`) |
52
+ | `VIEWPOINT-API-FORMAT` | a `format`/`pattern`/enum field has no invalid-value case → add a `@cases` row with an ill-formed value → 4xx |
40
53
  | **`DEPTH-FAIL`** (businessDepth < 0.7) | a **mutating success** scenario asserts only `status` → make it **prove the effect**: assert a response **body** field, a **`@query`** side-effect, or a **`@concurrent` `ok_count`** invariant. (An error/`@cases` scenario proving the status is correct — it is *not* depth-required.) |
41
54
 
55
+ The `VIEWPOINT-API-REQUIRED-MATRIX`/`-BOUNDARY`/`-FORMAT` rows are the repair actions for those findings **once the field-driven gate sensors ship** (they will fire only when the endpoint has `fields:`, so a bare catalog raises none — no false demands). Until then, completeness comes from enumerating the matrix mechanically from `fields:` at generation time (see `sungen-api-coverage-model`), not from these gate findings.
56
+
42
57
  Stop when the gate PASSes + businessDepth ≥ 0.7, or the budget is exhausted → report residual gaps honestly (mark genuinely-unautomatable cases `@manual` with an oracle). Never fake a pass.
43
58
 
44
59
  ### 5. Record + converge
@@ -54,6 +69,25 @@ Stop when the gate PASSes + businessDepth ≥ 0.7, or the budget is exhausted
54
69
 
55
70
  A flow (`create → login → delete`) is a **Functional integration** test, **not** non-functional — don't file it under "Non-Functional". Reserve non-functional for perf/security/concurrency.
56
71
 
72
+ ## File upload (real files)
73
+ `body:` carries only scalar fields — to send a **real file**, declare it in the catalog entry; the runtime reads the fixture from disk. Two forms, by how the server accepts the file:
74
+ - **`files:`** → `multipart/form-data`, the file is a form part: `files: { file: { path: ":avatar", mimeType: image/png } }` (+ optional text `body:` fields alongside). Shorthand `files: { file: ":avatar" }`.
75
+ - **`bodyFile:`** → raw binary body (the whole body IS the file's bytes, e.g. `application/octet-stream`): `bodyFile: { path: ":image", mimeType: application/octet-stream }`.
76
+
77
+ Fixture path resolves cwd-relative/absolute first, else `qa/fixtures/<path>` (drop sample files there, reference by name from `test-data`). An empty resolved file param omits the part → use for missing-file `@cases` error rows. `files:`/`bodyFile:` are mutually exclusive. **Automate the upload success case with `@api`** — don't defer it to `@manual`.
78
+
79
+ ## Per-endpoint knobs & auth patterns
80
+ - **Timeout** — a slow endpoint can override the datasource default (15s) with `timeout_ms: 30000` on its catalog entry (else the datasource `timeout_ms` applies).
81
+ - **OAuth2 (pure API, no browser)** — no new grammar; it's `@api` chaining. Call the token endpoint, bind the token, thread it into later calls' `Bearer :token` header:
82
+ ```gherkin
83
+ @api:get_token(client_id={{client_id}},client_secret={{client_secret}}) @api:list_orders(token={{get_token.body.access_token}})
84
+ Scenario: VP-API-FLOW client-credentials token authorizes the call
85
+ Then expect {{get_token.status}} is 200
86
+ And expect {{list_orders.status}} is 200
87
+ ```
88
+ The token endpoint is a normal catalog entry (`POST /oauth/token`, form/JSON body); `list_orders` carries `headers: { authorization: "Bearer :token" }`. For a browser-authorized session instead, use `@hybrid` + `@auth:<role>`.
89
+ - **Pagination** — use query params (`?page=:page&limit=:limit`, imported into `params`) + assert the page shape with plain field access (`{{list.body.next_page_token}}`, `{{list.body.items.length}}`); no special primitive needed.
90
+
57
91
  ## Rules
58
92
  - **No HTTP, no selectors** — only `.feature` + the reviewed `apis.yaml` + `test-data`.
59
93
  - **Non-prod default** — a `production` datasource is refused unless `SUNGEN_ALLOW_PROD=1`.
@@ -118,6 +118,27 @@ Scenario: ...
118
118
 
119
119
  Path access on a bound result: `{{q.count}}`/`{{q.length}}`, `{{q.first.col}}`, `{{q.last.col}}`, `{{q[2].col}}`, `{{q.col}}` (= first row's col). `expect A is B` also supports `is at least` / `is at most` / `is not`. Tier-2 declarative (trivial inline, no catalog): `User see [<table>] row where [<col>] is {{v}} [has [<col2>] = "x"]`, `… no row where …`, `… count is {{n}}`. Full grammar + catalog/datasource/secret rules → **Advanced → Database** doc. Only emit DB steps when the project has a `database/` catalog / `datasources.yaml`.
120
120
 
121
+ ### API response assertions (optional API Driver)
122
+
123
+ An `@api:<name>` call binds the response to `{{name}}` = `{ status, ok, body, headers }` (+ `ok_count`/`status_counts` under `@concurrent`). Assert with the same `expect A is B` grammar + path access:
124
+
125
+ ```gherkin
126
+ @api:create_user(email={{email}}) @query:user_by_email(email={{email}})
127
+ Scenario: ...
128
+ Then expect {{create_user.status}} is 201 # status
129
+ And expect {{create_user.body.id}} is at least {{one}} # server-derived body field
130
+ And expect {{user_by_email.count}} is {{one}} # proof of effect (DB oracle)
131
+ ```
132
+
133
+ Path access: `{{name.status}}`, `{{name.body.<path>}}` (dot/`[i]` nav), `{{name.headers.x-request-id}}` (dot form — hyphens are fine, no quotes/brackets), `{{name.ok_count}}`.
134
+
135
+ Full-shape contract check: `expect {{name.body}} matches schema [Ref]` validates the response body against the named JSON Schema in `qa/api/schemas.yaml` (populated by `sungen api import`). Use alongside a couple of concrete field asserts, not instead of them.
136
+
137
+ **Depth rule** (see `sungen-tc-review` API rubric):
138
+ - **Success (2xx)** → assert status **and** ≥1 body field; a **mutating** success must also prove the effect (`@query` read-back, follow-up GET, or `@concurrent` `ok_count`). Never status-only.
139
+ - **Error (4xx/5xx)** → assert the status (via `@cases` `expect_status` or an explicit `is 4xx`); assert the error message field when the contract defines one.
140
+ - **Anti-pattern** — re-asserting the value you just sent (`{{x.body.email}} is {{email}}` on the thing you created) proves little; assert a **server-derived** field (id, timestamp, computed status) or read it back.
141
+
121
142
  ### States
122
143
 
123
144
  `hidden` `visible` `disabled` `enabled` `checked` `unchecked` `focused` `empty` `loading` `selected` `sorted ascending` `sorted descending`
@@ -84,6 +84,7 @@ When running Phase 0 for a **flow** (`qa/flows/<name>/`), check existing screen
84
84
  - **Typical mismatch cases**: Gherkin uses English label (`[Submit]`) but app displays Vietnamese (`"Gửi"`); placeholder is descriptive (`"Nhập email của bạn"`) not a bare field name (`"Email"`); button text includes an icon glyph before/after the word.
85
85
  7. **Substring ambiguity check**: for each `role` + `name` selector, check if any other element in the snapshot has a name that **contains** this name as a substring (e.g., `"Đăng ký"` vs `"Đăng ký bằng Google"`). If yes → add `exact: true` to prevent strict mode violation at runtime.
86
86
  8. **Merge, don't overwrite**: preserve the page selector and any user-authored entries in `selectors.yaml`. Only add missing keys.
87
+ - **Type-collision check** (see `sungen-selector-keys` § Collision rule): before writing a base key, check whether it already exists with a *different* `type`. If so, give **both** entries a `--type` suffix instead of clobbering or sharing the base key — a base-key entry of the wrong type gets picked up by any step referencing that label. Watch `page` especially: a `[X] page` step reads `value` as a URL, so if `X` already holds a button (`value: 'button'`) the assertion compiles to `toHaveURL(/button/)`. Write `X--page` (URL from the live page / feature `Path:`) alongside `X--button`.
87
88
  9. **Show summary + confirm**: list the keys that will be added, ask the user to approve, then write the file.
88
89
  10. **Compile**: **Screen**: `sungen generate --screen <screen>`. **Flow**: `sungen generate --flow <flow>`. Then proceed to Phase 1.
89
90
 
@@ -97,6 +98,7 @@ When running Phase 0 for a **flow** (`qa/flows/<name>/`), check existing screen
97
98
  - Writing XPath or class-based CSS selectors → breaks on DOM/style refactoring. Use role/testid/text/label/placeholder from the accessibility tree.
98
99
  - Falling back to `locator: 'div.some-class > span'` when the element IS visible in the accessibility snapshot with a role + name → the snapshot gives you `getByRole` for free; use it.
99
100
  - Overwriting user-authored selectors → always merge.
101
+ - Reusing a base key for two element types (e.g. a `[X] button` and a `[X] page`) → the wrong-type entry wins on lookup; disambiguate with `--type` suffixes (see step 8 type-collision check).
100
102
 
101
103
  ---
102
104
 
@@ -88,6 +88,39 @@ add campaign--text:
88
88
  value: 'Add Campaign'
89
89
  ```
90
90
 
91
+ ### Collision rule (MANDATORY)
92
+
93
+ **Before writing a base key, check whether it already exists with a *different* `type`.**
94
+ If it does, DO NOT overwrite it and DO NOT let the two share the base key — give **each** entry
95
+ its own `--type` suffix. A key match alone does not carry type: the resolver walks
96
+ `key--type` → `key` (see § Lookup Priority), so a base-key entry of the wrong type will be
97
+ picked up by any step referencing that label, regardless of the type the step expects.
98
+
99
+ The `page` type is the sharpest trap. A page step (`Then User see [X] page`) reads the entry's
100
+ `value` as a **URL**. If key `X` already holds a `role`/button entry, the page step resolves that
101
+ button and emits `toHaveURL(/button/)` — a literal button label treated as a URL path.
102
+
103
+ ```yaml
104
+ # Gherkin: `When User click [読取結果一覧] button` AND `Then User see [読取結果一覧] page`
105
+ # WRONG — one base key, page step inherits the button's value ('button') as a URL:
106
+ 読取結果一覧:
107
+ type: 'role'
108
+ value: 'button'
109
+ name: '読取結果一覧'
110
+
111
+ # RIGHT — disambiguate by type; the page entry carries the real URL:
112
+ 読取結果一覧--button:
113
+ type: 'role'
114
+ value: 'button'
115
+ name: '読取結果一覧'
116
+
117
+ 読取結果一覧--page:
118
+ type: 'page'
119
+ value: '/reading-results' # captured from the live page, or the feature Path:
120
+ ```
121
+
122
+ If the label is used by only ONE type, the base key (no suffix) is fine — suffix only on collision.
123
+
91
124
  Type aliases (normalized automatically):
92
125
  | Gherkin type | Normalized suffix |
93
126
  |---|---|
@@ -159,6 +159,12 @@ Build a mapping table: for each applicable group, does the feature have a matchi
159
159
  | **Syntax compliance** — steps match `sungen-gherkin-syntax` patterns, correct `[Ref] type with {{v}}`, no bare `is visible` | 4 |
160
160
  | Grouping — each case has 2–7 related assertions for the same action | 4 |
161
161
 
162
+ **API scenarios (`@api`) — depth rubric:** a success case scores on Assertion Quality only if it proves the effect, not just the status. Apply per scenario:
163
+ - **status-only mutating success = fail the "Specific" check** — `expect {{x.status}} is 201` alone proves nothing happened. Require a server-derived body field **and** a proof of effect (`@query` read-back / follow-up GET / `@concurrent` `ok_count`).
164
+ - **read contract** — status + ≥1 body field (medium is acceptable for reads).
165
+ - **error (4xx)** — the status IS the assertion; also check the error-message field when the contract defines one.
166
+ - **anti-pattern (counts against "Avoids anti-patterns")** — re-asserting the input just sent instead of a server-derived value.
167
+
162
168
  ### Test Data (10)
163
169
 
164
170
  | Check | Pts |
@@ -48,6 +48,37 @@ function loadConfig(): Record<string, ApiDataSource> {
48
48
  function substitute(text: string, params: Record<string, any>): string {
49
49
  return text.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, p) => encodeURIComponent(String(params[p] ?? '')));
50
50
  }
51
+ /** Bind `:param` tokens WITHOUT URL-encoding — for headers, body-file paths, and multipart file paths. */
52
+ function substituteRaw(text: string, params: Record<string, any>): string {
53
+ return text.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, p) => String(params[p] ?? ''));
54
+ }
55
+ /** Delete any case-insensitive variant of a header (HTTP header names are case-insensitive, but a
56
+ * plain object keys on exact case — so a datasource `Content-Type` and our `content-type` would
57
+ * both be sent). Call before setting, or to let Playwright own the header (multipart boundary). */
58
+ function deleteHeader(headers: Record<string, string>, name: string): void {
59
+ const lower = name.toLowerCase();
60
+ for (const k of Object.keys(headers)) if (k.toLowerCase() === lower) delete headers[k];
61
+ }
62
+
63
+ /** A multipart file field embedded by the compiler (string = shorthand for { path }). */
64
+ type FileSpec = string | { path: string; mimeType?: string; filename?: string; name?: string };
65
+
66
+ const MIME: Record<string, string> = {
67
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
68
+ svg: 'image/svg+xml', pdf: 'application/pdf', csv: 'text/csv', txt: 'text/plain',
69
+ json: 'application/json', xml: 'application/xml', zip: 'application/zip', mp4: 'video/mp4',
70
+ };
71
+ export function inferMime(name: string): string {
72
+ return MIME[(name.split('.').pop() || '').toLowerCase()] || 'application/octet-stream';
73
+ }
74
+ /** Resolve a fixture path: cwd-relative/absolute first, else qa/fixtures/<path>. */
75
+ export function resolveFixture(p: string, label: string): string {
76
+ const direct = path.isAbsolute(p) ? p : path.join(process.cwd(), p);
77
+ if (fs.existsSync(direct)) return direct;
78
+ const inFixtures = path.join(process.cwd(), 'qa', 'fixtures', p);
79
+ if (fs.existsSync(inFixtures)) return inFixtures;
80
+ throw new Error(`API Driver: ${label} — fixture "${p}" not found (looked in ${direct} and qa/fixtures/${p}).`);
81
+ }
51
82
 
52
83
  /**
53
84
  * Join a datasource base URL with a catalog path. Concatenate rather than rely on Playwright's
@@ -82,7 +113,7 @@ class ApiClient {
82
113
  */
83
114
  async call(
84
115
  label: string,
85
- req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart'; headers?: Record<string, string>; datasource?: string },
116
+ req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart'; files?: Record<string, FileSpec>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string },
86
117
  params: Record<string, any> = {},
87
118
  opts: { storageState?: string } = {},
88
119
  ): Promise<{ status: number; ok: boolean; body: any; headers: Record<string, string> }> {
@@ -94,7 +125,7 @@ class ApiClient {
94
125
  const headers: Record<string, string> = { ...(conf.headers || {}) };
95
126
  // catalog headers; :param tokens bind at runtime — raw (no URL-encoding, unlike the path)
96
127
  for (const [k, v] of Object.entries(req.headers || {}))
97
- headers[k] = String(v).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, p) => String(params[p] ?? ''));
128
+ headers[k] = substituteRaw(String(v), params);
98
129
  // Body: substitute `:param` into the body template (object values), then encode per `encoding`.
99
130
  let body: any;
100
131
  if (req.body !== undefined && req.body !== null) {
@@ -102,8 +133,39 @@ class ApiClient {
102
133
  }
103
134
  // Map the wire format to the right Playwright option (#345): json → data (application/json,
104
135
  // default), form → form (application/x-www-form-urlencoded), multipart → multipart (form-data).
136
+ // A `files` block is a real upload: read each fixture into a { name, mimeType, buffer } part and
137
+ // send multipart, with any `body` values riding along as text fields.
105
138
  const bodyOpt: Record<string, unknown> = {};
106
- if (body !== undefined) {
139
+ if (req.bodyFile) {
140
+ // Raw-binary body: the whole request body is the fixture's bytes. Content-Type comes from the
141
+ // spec's mimeType (default application/octet-stream) — this is what octet-stream upload
142
+ // endpoints expect (a multipart body would be rejected 415).
143
+ const spec = typeof req.bodyFile === 'string' ? { path: req.bodyFile } : req.bodyFile;
144
+ const fpath = substituteRaw(String(spec.path), params);
145
+ if (fpath) {
146
+ const resolved = resolveFixture(fpath, label);
147
+ bodyOpt.data = fs.readFileSync(resolved);
148
+ // Own the Content-Type: drop any datasource default (case-insensitive) so we don't send both.
149
+ deleteHeader(headers, 'content-type');
150
+ headers['content-type'] = spec.mimeType ?? 'application/octet-stream';
151
+ }
152
+ } else if (req.files && Object.keys(req.files).length) {
153
+ const mp: Record<string, any> = {};
154
+ if (body && typeof body === 'object')
155
+ for (const [k, v] of Object.entries(body as Record<string, unknown>)) mp[k] = typeof v === 'string' ? v : JSON.stringify(v);
156
+ for (const [field, raw] of Object.entries(req.files)) {
157
+ const spec = typeof raw === 'string' ? { path: raw } : raw;
158
+ const fpath = substituteRaw(String(spec.path), params);
159
+ if (!fpath) continue; // empty param → omit the part (a missing-file / wrong-type error row)
160
+ const resolved = resolveFixture(fpath, label);
161
+ const filename = spec.filename ?? path.basename(resolved);
162
+ mp[spec.name ?? field] = { name: filename, mimeType: spec.mimeType ?? inferMime(filename), buffer: fs.readFileSync(resolved) };
163
+ }
164
+ // Playwright generates the multipart/form-data Content-Type (with boundary); drop any datasource
165
+ // default Content-Type so it doesn't conflict with / duplicate the generated one.
166
+ deleteHeader(headers, 'content-type');
167
+ bodyOpt.multipart = mp;
168
+ } else if (body !== undefined) {
107
169
  const enc = req.encoding ?? 'json';
108
170
  if (enc === 'form') bodyOpt.form = body;
109
171
  else if (enc === 'multipart') bodyOpt.multipart = body;
@@ -115,7 +177,7 @@ class ApiClient {
115
177
  // authenticated cookies. Disposed per call so no request context lingers and hangs the process.
116
178
  const ctx: APIRequestContext = await request.newContext({
117
179
  extraHTTPHeaders: headers,
118
- timeout: conf.timeout_ms ?? 15000,
180
+ timeout: req.timeout ?? conf.timeout_ms ?? 15000, // per-endpoint override → datasource → 15s
119
181
  ...(opts.storageState ? { storageState: opts.storageState } : {}),
120
182
  });
121
183
  try {
@@ -139,7 +201,7 @@ class ApiClient {
139
201
  */
140
202
  async callN(
141
203
  label: string,
142
- req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart'; headers?: Record<string, string>; datasource?: string },
204
+ req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart'; files?: Record<string, FileSpec>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string },
143
205
  params: Record<string, any> = {},
144
206
  n = 1,
145
207
  opts: { storageState?: string } = {},
@@ -160,6 +222,57 @@ class ApiClient {
160
222
  statuses: responses.map((r) => r.status),
161
223
  };
162
224
  }
225
+
226
+ /**
227
+ * Validate a response body against a named JSON Schema from `qa/api/schemas.yaml` (populated by
228
+ * `sungen api import` from the OpenAPI `components.schemas`). Backs `expect {{name.body}} matches
229
+ * schema [Ref]` — a full-contract check beyond individual field asserts. Returns `{ ok, errors }`;
230
+ * the generated assertion feeds `errors` into the failure message. Uses Ajv (a QA-project dep).
231
+ */
232
+ assertSchema(value: unknown, schemaName: string): { ok: boolean; errors: string } {
233
+ return assertSchemaImpl(value, schemaName);
234
+ }
235
+ }
236
+
237
+ // ── Response-schema validation (Ajv) ─────────────────────────────────────────────────────────────
238
+ // Schemas live in qa/api/schemas.yaml (name → JSON Schema, incl. #/components/schemas/* $refs).
239
+ // Loaded + compiled lazily so a project that never asserts a schema needs no Ajv resolution.
240
+ let _apiSchemas: Record<string, any> | null | undefined;
241
+ function loadApiSchemas(): Record<string, any> {
242
+ if (_apiSchemas !== undefined) return _apiSchemas || {};
243
+ const file = [path.join(process.cwd(), 'qa', 'api', 'schemas.yaml'), path.join(process.cwd(), 'schemas.yaml')].find((f) => fs.existsSync(f));
244
+ if (!file) { _apiSchemas = null; return {}; }
245
+ const { parse } = require('yaml');
246
+ _apiSchemas = (parse(fs.readFileSync(file, 'utf8')) as Record<string, any>) || {};
247
+ return _apiSchemas || {};
248
+ }
249
+
250
+ let _ajv: any;
251
+ function getAjv(): any {
252
+ if (_ajv) return _ajv;
253
+ const Ajv = require('ajv');
254
+ _ajv = new Ajv({ allErrors: true, strict: false });
255
+ try { require('ajv-formats')(_ajv); } catch { /* formats optional */ }
256
+ // Register every named schema under its component ref so inter-schema $refs resolve.
257
+ for (const [name, schema] of Object.entries(loadApiSchemas())) {
258
+ try { _ajv.addSchema(schema, `#/components/schemas/${name}`); } catch { /* dup/invalid → skip */ }
259
+ }
260
+ return _ajv;
261
+ }
262
+
263
+ function assertSchemaImpl(value: unknown, schemaName: string): { ok: boolean; errors: string } {
264
+ const schema = loadApiSchemas()[schemaName];
265
+ if (!schema) return { ok: false, errors: `schema "${schemaName}" not found in qa/api/schemas.yaml` };
266
+ let validate: any;
267
+ try {
268
+ const ajv = getAjv();
269
+ validate = ajv.getSchema(`#/components/schemas/${schemaName}`) || ajv.compile(schema);
270
+ } catch (e: any) {
271
+ return { ok: false, errors: `schema "${schemaName}" failed to compile: ${e?.message || e}` };
272
+ }
273
+ const ok = validate(value) === true;
274
+ const errors = ok ? '' : (validate.errors || []).map((e: any) => `${e.instancePath || '(root)'} ${e.message}`).join('; ') || 'did not match schema';
275
+ return { ok, errors };
163
276
  }
164
277
 
165
278
  export const api = new ApiClient();
@@ -48,7 +48,26 @@ export class TestDataLoader {
48
48
  if (value === undefined || value === null) {
49
49
  throw new Error(`Test data key not found: ${key}`);
50
50
  }
51
- return String(value);
51
+ return this.interpolate(String(value));
52
+ }
53
+
54
+ /**
55
+ * Substitute embedded `{{ref}}` cross-references inside a resolved string value.
56
+ * Each ref is looked up via resolve() (flat key, @cases row column, or dotted path),
57
+ * so `expected_operator_id_label: 'ID : {{operator.id}}'` yields the real value at
58
+ * runtime — matching the delivery report's substituteTestDataVars. `$`-prefixed
59
+ * dynamic vars ({{$timestamp}} …) are already substituted at load time and never
60
+ * match `[\w.]+`, so they are left untouched. Resolution recurses (a referenced value
61
+ * may itself hold refs) up to a depth cap that guards against reference cycles; an
62
+ * unknown or empty ref is left as the literal `{{ref}}` — never blanked.
63
+ */
64
+ private interpolate(value: string, depth: number = 0): string {
65
+ if (depth > 10 || value.indexOf('{{') === -1) return value;
66
+ return value.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, ref: string) => {
67
+ const resolved = this.resolve(ref);
68
+ if (resolved === undefined || resolved === null || resolved === '') return match;
69
+ return this.interpolate(String(resolved), depth + 1);
70
+ });
52
71
  }
53
72
 
54
73
  /**