@sun-asterisk/sungen 3.2.22-beta.1 → 3.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generators/test-generator/adapters/appium/templates/imports.hbs +9 -0
- package/dist/generators/test-generator/adapters/appium/templates/scenario.hbs +23 -1
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/capture-row-column.hbs +2 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/capture-variable.hbs +10 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/click-with-alert-action.hbs +7 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/drag-action.hbs +14 -2
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/hover-element-with-text.hbs +3 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/table-action-in-row-nth.hbs +2 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-assertion.hbs +17 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-element.hbs +13 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-filter-assertion.hbs +26 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +24 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-variable-assertion.hbs +9 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-dialog-heading-assertion.hbs +10 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-filter-assertion.hbs +14 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +21 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-variable-assertion.hbs +10 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/row-scoped-column-assertion.hbs +2 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +23 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/storage-key-assertion.hbs +2 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/tab-order-assertion.hbs +3 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-dialog-heading-assertion.hbs +10 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +17 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-with-role-variable-assertion.hbs +13 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/navigation/wait-table-refresh.hbs +13 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/drag-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/frame-enter-action.hbs +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/all-contain-element.hbs +5 -5
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/row-scoped-column-assertion.hbs +1 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +29 -8
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +20 -1
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +19 -0
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/generators/test-generator/patterns/index.d.ts +45 -0
- package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/index.js +150 -19
- package/dist/generators/test-generator/patterns/index.js.map +1 -1
- package/dist/generators/test-generator/patterns/types.d.ts +36 -0
- package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.d.ts +33 -0
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +78 -24
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +2 -2
- package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +61 -12
- package/dist/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +22 -8
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +5 -1
- package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-api.js +104 -29
- package/dist/orchestrator/templates/specs-api.js.map +1 -1
- package/dist/orchestrator/templates/specs-api.ts +104 -26
- package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-db.js +18 -5
- package/dist/orchestrator/templates/specs-db.js.map +1 -1
- package/dist/orchestrator/templates/specs-db.ts +19 -5
- package/package.json +3 -3
- package/src/generators/test-generator/adapters/appium/templates/imports.hbs +9 -0
- package/src/generators/test-generator/adapters/appium/templates/scenario.hbs +23 -1
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/capture-row-column.hbs +2 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/capture-variable.hbs +10 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/click-with-alert-action.hbs +7 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/drag-action.hbs +14 -2
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/hover-element-with-text.hbs +3 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/table-action-in-row-nth.hbs +2 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-assertion.hbs +17 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/all-contain-element.hbs +13 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-filter-assertion.hbs +26 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-role-variable-assertion.hbs +24 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disabled-with-variable-assertion.hbs +9 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-dialog-heading-assertion.hbs +10 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-filter-assertion.hbs +14 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-role-variable-assertion.hbs +21 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/hidden-with-variable-assertion.hbs +10 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/row-scoped-column-assertion.hbs +2 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +23 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/storage-key-assertion.hbs +2 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/tab-order-assertion.hbs +3 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-dialog-heading-assertion.hbs +10 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +17 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-with-role-variable-assertion.hbs +13 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/navigation/wait-table-refresh.hbs +13 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/drag-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/frame-enter-action.hbs +1 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/all-contain-element.hbs +5 -5
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/row-scoped-column-assertion.hbs +1 -0
- package/src/generators/test-generator/code-generator.ts +34 -9
- package/src/generators/test-generator/diagnostics.ts +20 -1
- package/src/generators/test-generator/patterns/index.ts +165 -24
- package/src/generators/test-generator/patterns/types.ts +35 -0
- package/src/generators/test-generator/step-mapper.ts +84 -23
- package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +2 -2
- package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +4 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +61 -12
- package/src/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +22 -8
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +5 -1
- package/src/orchestrator/templates/specs-api.ts +104 -26
- 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
|
|
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
|
|
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
|
|
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
|
-
>
|
|
29
|
-
>
|
|
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
|
|
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
|
|
77
|
-
|
|
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
|
|
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
|
|
177
|
-
// default), form →
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
196
|
-
// default Content-Type so it doesn't conflict with / duplicate
|
|
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
|
-
|
|
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')
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
298
|
+
return { status: res.status, ok: res.ok, body: parsed, headers: Object.fromEntries(res.headers.entries()) };
|
|
221
299
|
} finally {
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|