@sun-asterisk/sungen 3.2.22-beta.1 → 3.2.22-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/generators/test-generator/adapters/appium/templates/imports.hbs +9 -0
  2. package/dist/generators/test-generator/adapters/appium/templates/scenario.hbs +23 -1
  3. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/capture-row-column.hbs +2 -0
  4. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/capture-variable.hbs +10 -0
  5. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/click-with-alert-action.hbs +7 -0
  6. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/drag-action.hbs +14 -2
  7. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/hover-element-with-text.hbs +3 -0
  8. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/table-action-in-row-nth.hbs +2 -0
  9. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-assertion.hbs +17 -0
  10. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-element.hbs +13 -0
  11. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-filter-assertion.hbs +26 -0
  12. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +24 -0
  13. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-variable-assertion.hbs +9 -0
  14. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-dialog-heading-assertion.hbs +10 -0
  15. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-filter-assertion.hbs +14 -0
  16. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +21 -0
  17. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-variable-assertion.hbs +10 -0
  18. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/row-scoped-column-assertion.hbs +2 -0
  19. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +23 -0
  20. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/storage-key-assertion.hbs +2 -0
  21. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/tab-order-assertion.hbs +3 -0
  22. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-dialog-heading-assertion.hbs +10 -0
  23. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +17 -0
  24. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-with-role-variable-assertion.hbs +13 -0
  25. package/dist/generators/test-generator/adapters/appium/templates/steps/navigation/wait-table-refresh.hbs +13 -0
  26. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/drag-action.hbs +1 -1
  27. package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/frame-enter-action.hbs +1 -1
  28. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/all-contain-element.hbs +5 -5
  29. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/row-scoped-column-assertion.hbs +1 -0
  30. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  31. package/dist/generators/test-generator/code-generator.js +29 -8
  32. package/dist/generators/test-generator/code-generator.js.map +1 -1
  33. package/dist/generators/test-generator/diagnostics.d.ts +20 -1
  34. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  35. package/dist/generators/test-generator/diagnostics.js +19 -0
  36. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  37. package/dist/generators/test-generator/patterns/index.d.ts +45 -0
  38. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  39. package/dist/generators/test-generator/patterns/index.js +150 -19
  40. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  41. package/dist/generators/test-generator/patterns/types.d.ts +36 -0
  42. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  43. package/dist/generators/test-generator/step-mapper.d.ts +33 -0
  44. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  45. package/dist/generators/test-generator/step-mapper.js +78 -24
  46. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  47. package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +2 -2
  48. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
  49. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +61 -12
  50. package/dist/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +22 -8
  51. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +5 -1
  52. package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
  53. package/dist/orchestrator/templates/specs-api.js +104 -29
  54. package/dist/orchestrator/templates/specs-api.js.map +1 -1
  55. package/dist/orchestrator/templates/specs-api.ts +104 -26
  56. package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
  57. package/dist/orchestrator/templates/specs-db.js +18 -5
  58. package/dist/orchestrator/templates/specs-db.js.map +1 -1
  59. package/dist/orchestrator/templates/specs-db.ts +19 -5
  60. package/package.json +3 -3
  61. package/src/generators/test-generator/adapters/appium/templates/imports.hbs +9 -0
  62. package/src/generators/test-generator/adapters/appium/templates/scenario.hbs +23 -1
  63. package/src/generators/test-generator/adapters/appium/templates/steps/actions/capture-row-column.hbs +2 -0
  64. package/src/generators/test-generator/adapters/appium/templates/steps/actions/capture-variable.hbs +10 -0
  65. package/src/generators/test-generator/adapters/appium/templates/steps/actions/click-with-alert-action.hbs +7 -0
  66. package/src/generators/test-generator/adapters/appium/templates/steps/actions/drag-action.hbs +14 -2
  67. package/src/generators/test-generator/adapters/appium/templates/steps/actions/hover-element-with-text.hbs +3 -0
  68. package/src/generators/test-generator/adapters/appium/templates/steps/actions/table-action-in-row-nth.hbs +2 -0
  69. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-assertion.hbs +17 -0
  70. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-element.hbs +13 -0
  71. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-filter-assertion.hbs +26 -0
  72. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +24 -0
  73. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-variable-assertion.hbs +9 -0
  74. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-dialog-heading-assertion.hbs +10 -0
  75. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-filter-assertion.hbs +14 -0
  76. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +21 -0
  77. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-variable-assertion.hbs +10 -0
  78. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/row-scoped-column-assertion.hbs +2 -0
  79. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +23 -0
  80. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/storage-key-assertion.hbs +2 -0
  81. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/tab-order-assertion.hbs +3 -0
  82. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-dialog-heading-assertion.hbs +10 -0
  83. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +17 -0
  84. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-with-role-variable-assertion.hbs +13 -0
  85. package/src/generators/test-generator/adapters/appium/templates/steps/navigation/wait-table-refresh.hbs +13 -0
  86. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/drag-action.hbs +1 -1
  87. package/src/generators/test-generator/adapters/playwright/templates/steps/actions/frame-enter-action.hbs +1 -1
  88. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/all-contain-element.hbs +5 -5
  89. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/row-scoped-column-assertion.hbs +1 -0
  90. package/src/generators/test-generator/code-generator.ts +34 -9
  91. package/src/generators/test-generator/diagnostics.ts +20 -1
  92. package/src/generators/test-generator/patterns/index.ts +165 -24
  93. package/src/generators/test-generator/patterns/types.ts +35 -0
  94. package/src/generators/test-generator/step-mapper.ts +84 -23
  95. package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +2 -2
  96. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
  97. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +61 -12
  98. package/src/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +22 -8
  99. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +5 -1
  100. package/src/orchestrator/templates/specs-api.ts +104 -26
  101. package/src/orchestrator/templates/specs-db.ts +19 -5
@@ -27,21 +27,26 @@ AND → inherits from preceding keyword
27
27
 
28
28
  ## Step Patterns (70 patterns)
29
29
 
30
+ > **Platform legend:** unmarked = `[both]` (compiles on Playwright AND Appium). `[web]` = compiles
31
+ > only on Playwright — the Appium template throws, naming the reason. `[mobile]` = mobile-only
32
+ > vocabulary with no web counterpart. Every marking is checked against a shipped `.hbs` — see
33
+ > **Platform Support** at the end of this section for the full web-only/mobile-only/divergence list.
34
+
30
35
  ### Setup / Form / Interaction
31
36
 
32
37
  ```
33
38
  User is on [T] page | page with {{v}} | dialog
34
39
  User fill [T] field | textarea | search | slider | date-picker with {{v}}
35
- User fill [T] uploader with {{f}}
40
+ User fill [T] uploader with {{f}} [web]
36
41
  User clear [T] field
37
42
  User check [T] checkbox | toggle | radio
38
43
  User uncheck [T] checkbox | toggle
39
44
  User select [T] dropdown with {{v}}
40
45
  User click [T] button | tab | column | breadcrumb
41
46
  User click [T] row with {{v}}
42
- User try to click [T] button | link # DISABLED element only — see rule below (v3.3)
47
+ User try to click [T] button | link # DISABLED element only — see rule below (v3.3) [web]
43
48
  User double click [T] element
44
- User hover [T] icon | row
49
+ User hover [T] icon | row # no-op on mobile (see Platform Support)
45
50
  User drag [T] to [T2]
46
51
  User expand | collapse [T] row
47
52
  ```
@@ -60,15 +65,15 @@ NEVER use it for a click that is supposed to work — it deletes the actionabili
60
65
  User click [T] button and accept [OK] alert # PREFERRED (v3.3): natural order,
61
66
  User click [T] button and dismiss [Cancel] alert # compiler registers the listener first
62
67
  User click [OK | Cancel] alert # two-step form: must come BEFORE the trigger
63
- User fill [T] alert with {{v}}
68
+ User fill [T] alert with {{v}} # no-op on mobile — native prompt fill is app-specific
64
69
  User see [message text] alert
65
70
  User press Escape key | [Enter] key | Tab key 5 times | Enter on [T] field
66
- User wait for N seconds | [T] page
71
+ User wait for N seconds | [T] page # [T] page: web waits for the URL; mobile pauses (settle) — see Platform Support
67
72
  User wait for [T] TYPE is visible | hidden | enabled | disabled # ANY reference (v3.3)
68
73
  User wait for [T] TYPE with {{v}} # until it shows the value
69
74
  User wait for [T] table to refresh # filter/search/pagination round-trip (v3.3)
70
75
  User scroll to [T] section
71
- User switch to [T] frame | [main] frame
76
+ User switch to [T] frame | [main] frame # web: iframe; mobile: hybrid-app WebView context (no-op if the screen has no WebView)
72
77
  ```
73
78
 
74
79
  > **Browser alerts (native `window.confirm/alert/prompt` only):** prefer the compound form —
@@ -81,7 +86,7 @@ User switch to [T] frame | [main] frame
81
86
  > `wait for N seconds` stays a last resort. `table to refresh` watches the app's loading
82
87
  > indicator (`qa/app.yaml` `feedback.loading.indicator`, default `[aria-busy="true"]`).
83
88
 
84
- ### Positional table rows (v3.3)
89
+ ### Positional table rows (v3.3) `[web]`
85
90
 
86
91
  ```
87
92
  User remember [Col] column in [T] table row {{n}} as {{var}} # read a cell by POSITION
@@ -135,6 +140,11 @@ Two asymmetries worth knowing rather than discovering:
135
140
  2. **A repeated param matches as a subset per key**, which is the same rule as "extra params are
136
141
  tolerated": every value you declare must be present, and the URL may carry more.
137
142
 
143
+ > **Mobile:** `Then User is on [T] page` **throws** (no URL/address bar on native). `Then User see
144
+ > [T] page` \| `page with {{v}}` **silently no-ops** — the compiled step asserts nothing and the
145
+ > scenario passes regardless. Never use Pattern 8 to prove "landed on screen X" on mobile — assert
146
+ > a screen-marker element instead (`Then User see [X] header`).
147
+
138
148
  The predicate itself lives in `specs/url-assert.ts` (auto-generated, `DO NOT EDIT`). If `[T]` has no
139
149
  `type: page` selector entry — or its key collides with a non-page entry, so `value` is something like
140
150
  `button` rather than a URL — the step falls back to another path and cannot match the real URL. The
@@ -152,7 +162,7 @@ User see all [Product Card] contain [Add To Cart] button
152
162
 
153
163
  Use the all-card form whenever a title claims *every / each* card/row exposes something — a single `User see [Add To Cart] button` does NOT prove "each card" and the harness Claim-Proof gate will flag it.
154
164
 
155
- ### Table
165
+ ### Table `[web]`
156
166
 
157
167
  ```
158
168
  User see [Col] column in [Table] table
@@ -176,7 +186,7 @@ first contact row:
176
186
  ```
177
187
  → compiles to `expect(table.locator('tbody tr:first-child')).toContainText(v)` — the exact row must hold the value — and still enters row scope for `[Col] column` checks.
178
188
 
179
- ### Browser storage (web)
189
+ ### Browser storage `[web]`
180
190
 
181
191
  ```
182
192
  Then User see [KEY] in local storage exists
@@ -189,7 +199,7 @@ Then User see key matching "PATTERN" in local storage # regex over key name
189
199
 
190
200
  `[KEY]` is a **storage key, not a selector** — never add it to selectors.yaml. It may embed `{{vars}}`: `[{{exclusive_code}}_ACCESS_TOKEN]`. The check runs inside the browser and returns only a boolean, so a failure message never contains the stored value (safe for tokens). There is deliberately no `equals {{expected}}` form. ⚠️ `expect [KEY] in local storage …` is NOT valid (`expect` reads `{{response}}` refs only) — the compiler warns SG-W011.
191
201
 
192
- ### Tab order (web only)
202
+ ### Tab order `[web]`
193
203
 
194
204
  ```
195
205
  Then User see tab order:
@@ -201,7 +211,7 @@ Then User see tab order:
201
211
 
202
212
  Focuses row 1 (the pinned origin — there is no separate `focus [X]` step) and asserts it actually HOLDS focus (a non-focusable ref fails loudly), then presses Tab per following row and asserts it receives focus. Cell refs ARE selector references (resolved via selectors.yaml; optional element type after the ref). Requires the `| Ref |` header and ≥2 element rows — a missing header or single row is a compile error, never an empty pass. A mismatch reports the expected ref + the actual focused element (shadow-DOM-aware tag/role/name). Limitations: web only (no Appium); declare tab order OUTSIDE `use dialog`/frame scope (cell locators render page-rooted — put `scope: dialog` on the selector ENTRIES if the elements live in a dialog); focus traps / dynamic comboboxes / `tabindex=-1` reordering / WebKit differences may need `@manual` keyboard-only audits — those stay legitimate manuals.
203
213
 
204
- ### Network mocking (optional Mock Driver — `sungen capability add mock`)
214
+ ### Network mocking `[web]` (optional Mock Driver — `sungen capability add mock`)
205
215
 
206
216
  ```gherkin
207
217
  @mock
@@ -294,6 +304,42 @@ Full-shape contract check: `expect {{name.body}} matches schema [Ref]` validates
294
304
  - **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.
295
305
  - **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.
296
306
 
307
+ ### Platform Support — web-only / mobile-only / divergences
308
+
309
+ Every claim below is checked against a shipped `.hbs` under
310
+ `adapters/{playwright,appium}/templates/steps/` or a shipped diagnostic — see
311
+ `tests/codegen/adapter-template-parity.run.ts` `DIVERGENCES` for the authoritative one-sided list.
312
+
313
+ **Web-only `[web]`** — the Appium template throws, naming the reason: `fill [T] uploader with
314
+ {{f}}`, Positional table rows, the whole Table section, Browser storage, Tab order, `@mock`,
315
+ `Then User is on [T] page` (route-assertion). Each targets something native has no equivalent for
316
+ (HTML `<table>`, `<input type=file>`, DOM storage, keyboard-focus traversal, `page.route`, a URL bar).
317
+
318
+ **Mobile-only `[mobile]`** — the gesture catalog (swipe, long-press, pinch-zoom, pull-to-refresh,
319
+ rotate, background/foreground, notifications, grant-permission, clipboard set, set-geolocation,
320
+ hide-keyboard, tap-top-of) has no web counterpart. Full syntax → `sungen-mobile-gestures`.
321
+
322
+ **Divergences — compiles on both, means something different:**
323
+
324
+ | Step | Web | Mobile |
325
+ |---|---|---|
326
+ | `see [T] page` \| `page with {{v}}` | asserts path+query | **silent no-op** — asserts nothing; the scenario passes regardless. Assert a screen-marker element instead |
327
+ | `is on [T] page` \| `open [T] page` (Given/When) | navigates via URL | no-op — the app is already launched; use tap/gesture steps for mobile screen changes |
328
+ | `wait for [T] page` | waits for the URL | fixed `driver.pause(500)` settle — not a real wait condition |
329
+ | `hover [T] icon \| row` | real hover | no-op — hover-revealed content is normally already visible on mobile; use `tap` |
330
+ | `fill [T] alert with {{v}}` | fills native `prompt()` | no-op (comment only) — app-specific, handle manually |
331
+ | `switch to [T] frame` | enters an `<iframe>` | switches a hybrid app's WebView context; no-op on a pure-native screen (no WebView found) |
332
+ | `see [X] with {{v}}` (filtered visibility forms) | CSS `hasText` filter | hand-rolled substring match over `getText()`/`content-desc` (Android) or `label`/`value` (iOS) — same substring semantics, different attribute set |
333
+ | `… is sorted …` / `… is loading` inside a filtered row/state check | reads `aria-sort`/`aria-busy` | **throws** — no native analog for these two states specifically (the plain, unfiltered `is loading` on a spinner still works on both) |
334
+ | `scope: dialog` selector option | resolves inside the dialog | no effect (`SG-W021`) — steps resolve against the whole screen |
335
+ | `open notification panel` (mobile gesture) | n/a | Android-only — throws on iOS |
336
+
337
+ **Partial support, not full unsupported** — read the `.hbs` before marking anything `[web]`:
338
+ `all-contain-*`, `check`/`uncheck`/`toggle-action`, `contain-text`/`have-text-assertion`,
339
+ `visible-filtered-assertion`, `disabled`/`hidden-with-filter-assertion` are fully supported on
340
+ mobile even though a grep shows a `throw` somewhere in their file — the throw is a normal
341
+ zero-match assertion failure, not a platform gap.
342
+
297
343
  ### States
298
344
 
299
345
  `hidden` `visible` `disabled` `enabled` `checked` `unchecked` `focused` `empty` `loading` `selected` `sorted ascending` `sorted descending`
@@ -366,6 +412,9 @@ award:
366
412
 
367
413
  Options: `nth` `exact` `scope` `match` `variant` `frame` `contenteditable` `columns`
368
414
 
415
+ `scope` (e.g. `scope: dialog`) is `[web]`-effective only — no Appium template reads `inDialog`, so
416
+ on mobile a dialog-scoped ref still resolves against the whole screen (`SG-W021`).
417
+
369
418
  ## Tags
370
419
 
371
420
  ### Functional tags (affect code generation)
@@ -383,7 +432,7 @@ Options: `nth` `exact` `scope` `match` `variant` `frame` `contenteditable` `colu
383
432
  | `@cleanup:scroll` | Auto-cleanup: scroll to top after each test (cleanupPage) |
384
433
  | `@cleanup:storage` | Auto-cleanup: clear sessionStorage after each test (cleanupPage) |
385
434
  | `@screenshot:on-failure` | Auto-capture screenshot when test fails (base.ts fixture) |
386
- | `@parallel` | Opt-out: fresh page per test instead of serial default (for independent scenarios) |
435
+ | `@parallel` | Opt-out: fresh page per test instead of serial default (for independent scenarios). Compiles on mobile too, but a single device/emulator gets no session-isolation benefit from it |
387
436
  | `@beforeAll` | Hook: runs once before all tests → `test.beforeAll()` |
388
437
  | `@afterEach` | Hook: runs after each test → `test.afterEach()` (custom cleanup) |
389
438
  | `@afterAll` | Hook: runs once after all tests → `test.afterAll()` |
@@ -10,7 +10,8 @@ Document the **mobile-only interactions** that have no web equivalent, so the AI
10
10
  during exploration via Appium MCP and (b) write Gherkin steps for them. These are gestures the web
11
11
  patterns (`click`, `hover`, `fill`) don't cover.
12
12
 
13
- > Codegen status (Phase 3): the Appium adapter now **compiles** these gesture steps to WebdriverIO:
13
+ > Codegen status: the Appium adapter **compiles** all of the following (verified against the shipped
14
+ > `.hbs` under `adapters/appium/templates/steps/{gestures,actions}/`):
14
15
  > - **`tap` / `taps`** — synonym for `click` (→ `.click()`); **`double-tap`** → double-click.
15
16
  > - **`scroll to [X]`** → `.scrollIntoView()` (shared `scroll-action` template).
16
17
  > - **`swipe <dir> on [X]`** → `mobile: swipeGesture`.
@@ -24,9 +25,19 @@ patterns (`click`, `hover`, `fill`) don't cover.
24
25
  > - **`tap top of [X]`** / **`tap [X] at top`** → tap the element's **visible top edge** (`mobile: clickGesture`
25
26
  > at top-centre from the element bounds) instead of its centre — use when the centre is occluded by a
26
27
  > floating bottom bar so a normal centre tap would hit the bar.
27
- >
28
- > Still exploration-only (no codegen yet): grant/deny permissions, clipboard set/get, dismiss system
29
- > dialog author with the `appium_*` tool calls below; templates land in a later phase.
28
+ > - **`dismiss [X]`** (any non-alert element) → best-effort tap-if-shown-within-2.5s, never fails the
29
+ > step (`dismiss-action`) for launch interstitials / promo overlays, not the native alert dialog
30
+ > (that's the existing `accept/dismiss [OK] alert` form).
31
+ > - **`hide the keyboard`** → `driver.hideKeyboard()`. **Android-only** — XCUITest cannot dismiss the
32
+ > keyboard generically (WDA throws + retries, ~12s wasted); on iOS the step is a silent best-effort
33
+ > no-op (never fails, just does nothing).
34
+ > - **`grant [X] permission`** → Android `mobile: changePermissions`; iOS (Simulator only) `mobile:
35
+ > setPermission` — needs the `applesimutils` binary on iOS or the driver fails loud with the install hint.
36
+ > - **`set the clipboard to {{v}}`** → `driver.setClipboard(...)`. **Write-only**: the appium adapter
37
+ > ships a `clipboard-text-assertion` template that reads the clipboard back, but no Gherkin pattern
38
+ > requests it yet — there is currently no phrasing that compiles to an actual clipboard-read
39
+ > assertion. Don't author "see clipboard contains X" expecting it to compile; tag it `@manual` instead.
40
+ > - **`set location to {{lat}}, {{lng}}`** → `driver.setGeoLocation(...)`.
30
41
  >
31
42
  > 📜 **`scroll to [X]` — two failure modes seen, with the real cause (measured):**
32
43
  > 1. *"Default scrollable element '//android.widget.ScrollView' not found"* — wdio's mobile scroll runs
@@ -69,12 +80,14 @@ from `appium_find_element`; screen gestures pass `direction` or coordinates.
69
80
  | System back | `User go back` | `action=back` |
70
81
  | Drag & drop | `User drag [A] onto [B]` | `appium_drag_and_drop` (separate tool) |
71
82
 
72
- Other device-level actions (separate MCP tools, future Gherkin):
83
+ Other device-level actions (separate MCP tools for exploration; Gherkin now compiles — see codegen
84
+ status above):
73
85
  - Rotate: `appium_orientation` — `User rotate to landscape`
74
86
  - Permission dialog: `appium_mobile_permissions` / `appium_alert` — `User grant [Location] permission`
75
87
  - Background/foreground: `appium_app_lifecycle` — `User send app to background for 5 seconds`
76
- - Clipboard: `appium_mobile_clipboard` — `User paste into [Field]`
77
- - Notifications: open panel via `appium_mobile_device_control`
88
+ - Clipboard write: `appium_mobile_clipboard` — `User set the clipboard to {{value}}` (read-back has
89
+ no Gherkin pattern yet see codegen status above)
90
+ - Notifications: open panel via `appium_mobile_device_control` — `User open notification panel`
78
91
 
79
92
  ---
80
93
 
@@ -104,6 +117,7 @@ them stable (accessibility-id) so the scroll terminates reliably.
104
117
 
105
118
  ## What this skill does NOT do
106
119
 
107
- - Does not implement gesture codegen (templates land in a later phase).
108
120
  - Does not replace `sungen-gherkin-syntax` — it supplements it with the mobile-only step vocabulary.
109
121
  - Does not cover tap/set-value/assertions (those are the shared Tier-1 patterns already supported).
122
+ - Does not yet support a clipboard-READ assertion via Gherkin (see "Write-only" note above) — the
123
+ template exists but no pattern requests it.
@@ -479,7 +479,11 @@ Add cleanup tags per the `sungen-gherkin-syntax` Cleanup table. Key rules:
479
479
 
480
480
  **Files:** `qa/screens/<screen>/features/<screen>.feature` + `qa/screens/<screen>/test-data/<screen>.yaml`
481
481
 
482
- Use step patterns and element types from `sungen-gherkin-syntax`.
482
+ Use step patterns and element types from `sungen-gherkin-syntax`. For a `@platform:mobile/android/ios`
483
+ screen or flow, first check that skill's **Platform Support** section — never author a step marked
484
+ `[web]` there (tables, browser storage, tab order, `@mock`, uploader, `Then User is on [T] page`) for
485
+ a mobile unit, and read the divergence table before relying on `see [T] page` or `hover` to prove
486
+ anything on mobile. Reach for `sungen-mobile-gestures` for the gesture vocabulary instead.
483
487
  **Naming**: reuse the **project's `test-viewpoint.md` IDs** when it declares them (e.g. `VP0`, `MS-HP-001`); otherwise `VP-<CATEGORY>-<NNN>`. Scenario name must use the **same element type** as the steps.
484
488
 
485
489
  **Test data** — grouped by section, loaded at runtime:
@@ -7,12 +7,16 @@
7
7
  * `{{name.body.<path>}}`. Base URL + auth come from a `kind: api` datasource in datasources.yaml,
8
8
  * with `${VAR}` resolved from .env.qa / process.env — never inline.
9
9
  *
10
+ * Runs on Node's global `fetch` (Node 18+) — no `@playwright/test` import, so this driver loads in
11
+ * mobile (Appium/Mocha) specs, which carry no Playwright dependency. Previously built on
12
+ * `APIRequestContext`; ported method-by-method (see `ApiClient.call`) to keep behaviour — including
13
+ * the `@hybrid` storageState cookie reuse — equivalent on web.
14
+ *
10
15
  * Safety: a datasource flagged `env: production` is refused unless SUNGEN_ALLOW_PROD=1.
11
16
  * DO NOT EDIT — regenerated by `sungen generate`.
12
17
  */
13
18
  import * as fs from 'fs';
14
19
  import * as path from 'path';
15
- import { request, type APIRequestContext } from '@playwright/test';
16
20
 
17
21
  interface ApiDataSource {
18
22
  kind?: string;
@@ -56,11 +60,53 @@ function substituteRaw(text: string, params: Record<string, any>): string {
56
60
  }
57
61
  /** Delete any case-insensitive variant of a header (HTTP header names are case-insensitive, but a
58
62
  * plain object keys on exact case — so a datasource `Content-Type` and our `content-type` would
59
- * both be sent). Call before setting, or to let Playwright own the header (multipart boundary). */
63
+ * both be sent). Call before setting, or to let fetch own the header (multipart boundary). */
60
64
  function deleteHeader(headers: Record<string, string>, name: string): void {
61
65
  const lower = name.toLowerCase();
62
66
  for (const k of Object.keys(headers)) if (k.toLowerCase() === lower) delete headers[k];
63
67
  }
68
+ /** True when `headers` already carries a case-insensitive match for `name` — used to respect an
69
+ * explicit catalog header instead of overriding it with our own content-type default. */
70
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
71
+ const lower = name.toLowerCase();
72
+ return Object.keys(headers).some((k) => k.toLowerCase() === lower);
73
+ }
74
+
75
+ /**
76
+ * Build a `Cookie:` header value from a Playwright storageState JSON file (written by
77
+ * `sungen makeauth` / `context.storageState()` — `{ cookies: [...], origins: [...] }`), scoped to
78
+ * the cookies that apply to `url` the way a browser would send them (domain + path + not expired).
79
+ * Powers `@hybrid`: the API call reuses the UI session's cookies without a browser/Playwright
80
+ * context. Returns undefined when the file has no cookie that matches `url`.
81
+ */
82
+ function loadStorageStateCookieHeader(storageStatePath: string, url: string): string | undefined {
83
+ const abs = path.isAbsolute(storageStatePath) ? storageStatePath : path.join(process.cwd(), storageStatePath);
84
+ if (!fs.existsSync(abs)) {
85
+ throw new Error(`API Driver: storageState "${storageStatePath}" not found — run \`sungen makeauth\` first.`);
86
+ }
87
+ const state = JSON.parse(fs.readFileSync(abs, 'utf-8')) as {
88
+ cookies?: Array<{ name: string; value: string; domain?: string; path?: string; expires?: number; secure?: boolean }>;
89
+ };
90
+ const cookies = state.cookies || [];
91
+ if (!cookies.length) return undefined;
92
+ const target = new URL(url);
93
+ const nowSec = Date.now() / 1000;
94
+ const targetIsSecure = target.protocol === 'https:';
95
+ const matches = cookies.filter((c) => {
96
+ const domain = (c.domain || '').replace(/^\./, '');
97
+ const domainOk = !domain || target.hostname === domain || target.hostname.endsWith(`.${domain}`);
98
+ const pathOk = target.pathname.startsWith(c.path || '/');
99
+ const notExpired = c.expires === undefined || c.expires < 0 || c.expires > nowSec;
100
+ // A Secure cookie must never leave over plaintext. Playwright's own cookie jar enforces this,
101
+ // and this function exists to be EQUIVALENT to it — omitting the check would make a mixed
102
+ // @hybrid setup (https browser session, http API datasource) leak the session cookie in clear,
103
+ // a downgrade introduced by us rather than by anything the author wrote.
104
+ const secureOk = !c.secure || targetIsSecure;
105
+ return domainOk && pathOk && notExpired && secureOk;
106
+ });
107
+ if (!matches.length) return undefined;
108
+ return matches.map((c) => `${c.name}=${c.value}`).join('; ');
109
+ }
64
110
 
65
111
  /** A multipart file field embedded by the compiler (string = shorthand for { path }). */
66
112
  type FileSpec = string | { path: string; mimeType?: string; filename?: string; name?: string };
@@ -173,11 +219,13 @@ class ApiClient {
173
219
  if (req.body !== undefined && req.body !== null) {
174
220
  body = JSON.parse(JSON.stringify(req.body).replace(/":([A-Za-z_][A-Za-z0-9_]*)"/g, (_m, p) => JSON.stringify(params[p] ?? null)));
175
221
  }
176
- // Map the wire format to the right Playwright option (#345): json → data (application/json,
177
- // default), form → form (application/x-www-form-urlencoded), multipart → multipart (form-data).
178
- // A `files` block is a real upload: read each fixture into a { name, mimeType, buffer } part and
179
- // send multipart, with any `body` values riding along as text fields.
180
- const bodyOpt: Record<string, unknown> = {};
222
+ // Map the wire format to a fetch request body (#345): json → JSON string (application/json,
223
+ // default), form → URL-encoded string, multipart → FormData. A `files` block is a real upload:
224
+ // read each fixture into a Blob part and send multipart, with any `body` values riding along
225
+ // as text fields. (Typed as `any`, not fetch's DOM-only `BodyInit`: this package's tsconfig has
226
+ // no "dom" lib, so that alias isn't in scope even though the global runtime values are.)
227
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see comment above
228
+ let fetchBody: any;
181
229
  if (req.bodyFile) {
182
230
  // Raw-binary body: the whole request body is the fixture's bytes. Content-Type comes from the
183
231
  // spec's mimeType (default application/octet-stream) — this is what octet-stream upload
@@ -186,40 +234,70 @@ class ApiClient {
186
234
  const fpath = substituteRaw(String(spec.path), params);
187
235
  if (fpath) {
188
236
  const resolved = resolveFixture(fpath, label);
189
- bodyOpt.data = fs.readFileSync(resolved);
237
+ fetchBody = fs.readFileSync(resolved);
190
238
  // Own the Content-Type: drop any datasource default (case-insensitive) so we don't send both.
191
239
  deleteHeader(headers, 'content-type');
192
240
  headers['content-type'] = spec.mimeType ?? 'application/octet-stream';
193
241
  }
194
242
  } else if (req.files && Object.keys(req.files).length) {
195
- // Playwright generates the multipart/form-data Content-Type (with boundary); drop any datasource
196
- // default Content-Type so it doesn't conflict with / duplicate the generated one.
243
+ // fetch derives the multipart/form-data Content-Type (with boundary) from the FormData body;
244
+ // drop any datasource default Content-Type so it doesn't conflict with / duplicate it.
197
245
  deleteHeader(headers, 'content-type');
198
- bodyOpt.multipart = buildMultipart(req.files, body, params, label);
246
+ fetchBody = buildMultipart(req.files, body, params, label);
199
247
  } else if (body !== undefined) {
200
248
  const enc = req.encoding ?? 'json';
201
- if (enc === 'form') bodyOpt.form = body;
202
- else if (enc === 'multipart') bodyOpt.multipart = body;
203
- else bodyOpt.data = body;
249
+ if (enc === 'form') {
250
+ if (!hasHeader(headers, 'content-type')) headers['content-type'] = 'application/x-www-form-urlencoded';
251
+ fetchBody = new URLSearchParams(body as Record<string, string>).toString();
252
+ } else if (enc === 'multipart') {
253
+ deleteHeader(headers, 'content-type');
254
+ fetchBody = buildMultipart({}, body, params, label); // no file fields — text fields only
255
+ } else {
256
+ if (!hasHeader(headers, 'content-type')) headers['content-type'] = 'application/json';
257
+ fetchBody = JSON.stringify(body);
258
+ }
259
+ }
260
+
261
+ // GET/HEAD requests may not carry a body under fetch (undici throws). Refuse LOUDLY rather than
262
+ // send a request quietly shorter than the catalog says it is — a dropped body reads as a server
263
+ // bug, not an authoring one. `validateApiEntry` catches this at generate time; this is the
264
+ // backstop for a catalog that reached the runtime some other way.
265
+ if (fetchBody !== undefined && /^(GET|HEAD)$/i.test(req.method)) {
266
+ throw new Error(`API Driver: ${label} — a ${req.method.toUpperCase()} request cannot carry a body. Remove body:/files:/bodyFile: from the catalog entry, or change its method to POST/PUT/PATCH.`);
267
+ }
268
+
269
+ // Full URL (not a baseURL-relative path) so a base path component like /api/v3 is preserved.
270
+ const fullUrl = joinApiUrl(base, urlPath);
271
+
272
+ // @hybrid: `storageState` is the @auth role's saved session (Playwright's `context.storageState()`
273
+ // JSON) — reuse its cookies (scoped to `fullUrl`'s domain/path) so the request shares the
274
+ // browser's authenticated session without a browser/Playwright context.
275
+ if (opts.storageState) {
276
+ const cookieHeader = loadStorageStateCookieHeader(opts.storageState, fullUrl);
277
+ if (cookieHeader) {
278
+ const existingKey = Object.keys(headers).find((k) => k.toLowerCase() === 'cookie');
279
+ headers[existingKey || 'cookie'] = existingKey ? `${headers[existingKey]}; ${cookieHeader}` : cookieHeader;
280
+ }
204
281
  }
205
282
 
206
- // Playwright APIRequestContext: same runner/report/retries as UI tests. @hybrid passes
207
- // `storageState` (the @auth role's saved session) so the request shares the browser's
208
- // authenticated cookies. Disposed per call so no request context lingers and hangs the process.
209
- const ctx: APIRequestContext = await request.newContext({
210
- extraHTTPHeaders: headers,
211
- timeout: req.timeout ?? conf.timeout_ms ?? 15000, // per-endpoint override → datasource → 15s
212
- ...(opts.storageState ? { storageState: opts.storageState } : {}),
213
- });
283
+ // Timeout: fetch has no built-in per-request timeout, so abort it manually — mirrors the prior
284
+ // per-endpoint-override datasource 15s default precedence.
285
+ const timeoutMs = req.timeout ?? conf.timeout_ms ?? 15000;
286
+ const controller = new AbortController();
287
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
214
288
  try {
215
- // Full URL (not a baseURL-relative path) so a base path component like /api/v3 is preserved.
216
- const res = await ctx.fetch(joinApiUrl(base, urlPath), { method: req.method, ...bodyOpt });
289
+ const res = await fetch(fullUrl, {
290
+ method: req.method,
291
+ headers,
292
+ ...(fetchBody !== undefined ? { body: fetchBody } : {}),
293
+ signal: controller.signal,
294
+ });
217
295
  const text = await res.text();
218
296
  let parsed: any = text;
219
297
  try { parsed = text ? JSON.parse(text) : null; } catch { /* non-JSON → keep text */ }
220
- return { status: res.status(), ok: res.ok(), body: parsed, headers: res.headers() };
298
+ return { status: res.status, ok: res.ok, body: parsed, headers: Object.fromEntries(res.headers.entries()) };
221
299
  } finally {
222
- await ctx.dispose();
300
+ clearTimeout(timer);
223
301
  }
224
302
  }
225
303
 
@@ -9,9 +9,12 @@
9
9
  * Engines: PostgreSQL (`pg`), MySQL (`mysql2`), and SQLite (`better-sqlite3`), lazy-loaded on first use.
10
10
  * Config: a `datasources.yaml` at the project root (or qa/), with ${VAR} resolved from env.
11
11
  *
12
+ * Assertions are plain thrown Errors — no `@playwright/test` import — so this helper loads in
13
+ * mobile (Appium/Mocha) specs too, which carry no Playwright dependency (same reason
14
+ * `specs/api.ts` runs on Node's global `fetch`).
15
+ *
12
16
  * DO NOT EDIT — regenerated by `sungen generate`.
13
17
  */
14
- import { expect } from '@playwright/test';
15
18
  import * as fs from 'fs';
16
19
  import * as path from 'path';
17
20
 
@@ -647,12 +650,13 @@ class DataSource {
647
650
  async assertRow(table: string, filter: Record<string, any>, expected?: Record<string, any>, datasource?: string): Promise<void> {
648
651
  const { engine } = await this.engine(datasource);
649
652
  const rows = await engine.findRows(table, filter, 50);
650
- expect(rows.length, `Expected a row in "${table}" where ${desc(filter)} — found ${rows.length}`).toBeGreaterThanOrEqual(1);
653
+ assert(rows.length >= 1, `Expected a row in "${table}" where ${desc(filter)} — found ${rows.length}`);
651
654
  if (expected) {
652
655
  const row = rows[0];
653
656
  for (const [col, val] of Object.entries(expected)) {
654
657
  ident(col);
655
- expect(String(row[col]), `Column "${col}" of "${table}" where ${desc(filter)}`).toBe(String(val));
658
+ assert(String(row[col]) === String(val),
659
+ `Column "${col}" of "${table}" where ${desc(filter)} — expected ${JSON.stringify(String(val))}, found ${JSON.stringify(String(row[col]))}`);
656
660
  }
657
661
  }
658
662
  }
@@ -661,7 +665,7 @@ class DataSource {
661
665
  async assertNoRow(table: string, filter: Record<string, any>, datasource?: string): Promise<void> {
662
666
  const { engine } = await this.engine(datasource);
663
667
  const rows = await engine.findRows(table, filter, 50);
664
- expect(rows.length, `Expected NO row in "${table}" where ${desc(filter)} — found ${rows.length}`).toBe(0);
668
+ assert(rows.length === 0, `Expected NO row in "${table}" where ${desc(filter)} — found ${rows.length}`);
665
669
  }
666
670
 
667
671
  /** Exactly `count` rows must match `filter`. */
@@ -669,7 +673,7 @@ class DataSource {
669
673
  const { engine } = await this.engine(datasource);
670
674
  const n = await engine.countRows(table, filter);
671
675
  const cols = Object.keys(filter);
672
- expect(n, `Expected ${count} row(s) in "${table}"${cols.length ? ' where ' + desc(filter) : ''} — found ${n}`).toBe(Number(count));
676
+ assert(n === Number(count), `Expected ${count} row(s) in "${table}"${cols.length ? ' where ' + desc(filter) : ''} — found ${n}`);
673
677
  }
674
678
 
675
679
  /**
@@ -694,6 +698,16 @@ class DataSource {
694
698
  }
695
699
  }
696
700
 
701
+ /**
702
+ * Fail a DB assertion. A thrown Error is exactly what BOTH runners report as a test failure
703
+ * (Playwright and Mocha/WDIO), so this replaces Playwright's `expect(...)` without importing it —
704
+ * the coupling that kept `specs/db.ts` out of mobile specs. Messages carry expected AND found,
705
+ * since there is no matcher to print them.
706
+ */
707
+ function assert(cond: boolean, message: string): void {
708
+ if (!cond) throw new Error(`[sungen] ${message}`);
709
+ }
710
+
697
711
  function desc(filter: Record<string, any>): string {
698
712
  return Object.entries(filter).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ');
699
713
  }