@sungen/driver-mobile 3.2.28 → 3.2.29

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-mobile",
3
- "version": "3.2.28",
3
+ "version": "3.2.29",
4
4
  "description": "Sungen mobile platform driver (Appium / WebdriverIO) — gesture vocabulary, mobile selector strategies, viewpoint gate, and the WDIO runner. Plugs into @sun-asterisk/sungen via the capability SPI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -13,11 +13,12 @@
13
13
  "author": "eqe team (engineer & quality) — Sun Asterisk",
14
14
  "license": "MIT",
15
15
  "dependencies": {
16
- "@sun-asterisk/sungen": "3.2.28",
16
+ "@sun-asterisk/sungen": "3.2.29",
17
17
  "@wdio/appium-service": "^9.0.0",
18
18
  "@wdio/cli": "^9.0.0",
19
19
  "@wdio/local-runner": "^9.0.0",
20
20
  "@wdio/mocha-framework": "^9.0.0",
21
+ "@wdio/reporter": "^9.0.0",
21
22
  "@wdio/spec-reporter": "^9.0.0",
22
23
  "appium": "^3.0.0",
23
24
  "webdriverio": "^9.0.0"
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import { lifecyclePatterns } from './patterns/lifecycle-patterns';
41
41
  import { devicePatterns } from './patterns/device-patterns';
42
42
  import { timingPatterns } from './patterns/timing-patterns';
43
43
  import { logPatterns } from './patterns/log-patterns';
44
+ import { mobileRepair } from './mobile-repair';
44
45
 
45
46
  /** Surfaces universal-viewpoint theme gaps (low-priority reminder; does not move the score). */
46
47
  const mobileViewpointGateSensor: Sensor<GateInput> = {
@@ -88,6 +89,10 @@ export function register(registry: CapabilityRegistry): void {
88
89
  // resolved @sun-asterisk/sungen copy, which npm can nest at an older published version whose
89
90
  // catalog predates `mobile-home` → stale-catalog version-skew. See the module header.
90
91
  sensors: [mobileViewpointGateSensor],
92
+ // MOB-4 harness parity: the fix catalog `sungen repair` consults for a mobile unit. Its rules
93
+ // key on what the audit sensors and the Appium runtime actually emit — never on the SG-W02x
94
+ // codes, which are console-only and never reach either channel. See mobile-repair.ts.
95
+ repair: mobileRepair,
91
96
  });
92
97
  }
93
98
 
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Mobile repair catalog (MOB-4 / CG-9) — the `mobile` capability's deterministic fix rules.
3
+ *
4
+ * `sungen repair --screen <unit>` on a `platform: mobile` project matches each audit finding and
5
+ * each Playwright-reported failure against these rules and proposes the concrete fix. Same shape as
6
+ * `driver-api`'s `apiRepair` (#343): capability-owned DATA, so audit + runtime signals compose into
7
+ * one plan. Type-only dependency on core.
8
+ *
9
+ * WHAT THESE RULES MATCH, AND WHY IT IS NOT `SG-W02x`.
10
+ * The mobile compile diagnostics are the obvious thing to key on, and they cannot be used:
11
+ * · they are console-only — `sungen generate` prints them and keeps nothing, so they never enter
12
+ * `.sungen/reports/<unit>-audit.json`, which is the only audit input `planRepair` reads;
13
+ * · the loud ones DO reach a test-result, but through `unsupportedStepCode(reason)`, which
14
+ * prefixes `[sungen]` and embeds the diagnostic's REASON PROSE — never the code itself.
15
+ * So a `/SG-W023/` rule would be a rule that can never fire, which is worse than no rule: it
16
+ * inflates `rulesAvailable` while the finding still sits in `unmatched`. The audit half below keys
17
+ * on what the audit sensors emit; the runtime half keys on the reason text that actually arrives.
18
+ * The purely silent diagnostics (SG-W021/W022/W023/W026 emit no code and no runtime failure) have
19
+ * no signal to match at all — they are review-time concerns, and `sungen-tc-review`'s A8 gate owns
20
+ * them.
21
+ *
22
+ * EVERY RULE CARRIES A VERBATIM `example`, and `tests/harness/mobile-repair-plan.run.ts` asserts
23
+ * that each rule matches its own example and that no earlier rule steals it. That is what keeps a
24
+ * reworded emitter from silently retiring a rule, and a broad regex from swallowing its neighbours.
25
+ * Ids are stable — reports quote them.
26
+ */
27
+ import type { RepairProvider, RepairRule } from '@sun-asterisk/sungen';
28
+
29
+ /** A rule plus the real message it was written against (the test's reachability oracle). */
30
+ export interface MobileRepairRule extends RepairRule {
31
+ /** Verbatim from the emitter — an audit sensor, a diagnostic reason, or an appium template. */
32
+ example: string;
33
+ }
34
+
35
+ /**
36
+ * Order is significant: `planRepair` takes the FIRST matching rule. Compile-time causes come before
37
+ * runtime infrastructure, which comes before element-level symptoms — a step that could not be
38
+ * compiled honestly is a better explanation than the missing element it also produces.
39
+ */
40
+ export const mobileRepairRules: MobileRepairRule[] = [
41
+ // ── Audit findings (`.sungen/reports/<unit>-audit.json`) ────────────────────────────────────
42
+ { id: 'mobile-universal-theme', match: /^UNIVERSAL: missing theme/,
43
+ example: 'UNIVERSAL: missing theme(s): app-lifecycle-resume, permission-prompt (low priority reminder).',
44
+ fix: 'Unscored mobile-universal theme(s) have no scenario. Each has a compiling step: app-lifecycle-resume → `User background the app for 5 seconds` then assert the screen state survived; orientation-stability → `User rotate the device to landscape`; connectivity-degraded → `User turn on airplane mode` (Android — tag `@platform:android`); permission-prompt → `User deny [Camera] permission`; gesture-reachability → `User scroll to [X]` / `User tap top of [X]`; deep-link-entry → `User open the deep link {{url}}`. Pick from the Group F tables in `sungen-viewpoint`.' },
45
+
46
+ { id: 'mobile-gate-theme-uncovered', match: /^GATE: critical theme .* has NO covering scenario/,
47
+ example: 'GATE: critical theme "keyboard-overlap" for page-type "mobile-form" has NO covering scenario → generate it (often cross-screen → consider add-flow).',
48
+ fix: 'A critical theme of this `mobile-*` page-type is unclaimed. Write the scenario from the matching Group F pattern in `sungen-viewpoint` (mobile-form → 21 Native Picker / 30 Soft-Keyboard Overlap; mobile-list → 20 Swipe-to-Delete Row / 29 Pull-to-Refresh List; mobile-settings → 31 Settings Toggle List), then re-run `sungen audit`. Do NOT answer it with `Then User see [X] page` — that emits nothing on Appium (SG-W022) and would pass having proven nothing.' },
49
+
50
+ { id: 'mobile-gate-theme-shallow', match: /^GATE: critical theme .* covered only by SHALLOW/,
51
+ example: 'GATE: critical theme "list-data" is covered only by SHALLOW scenarios (no data assertion) → deepen with `... with {{value}}` / `table ... with {{value}}` (count @manual cross-screen too).',
52
+ fix: 'The theme is named but nothing reads a value. Deepen the scenario with a data oracle: `Then User see [Row Title] contains {{expected}}` / `... has text {{expected}}`, or a survival oracle across a lifecycle step (`User restart the app`, then assert the value). The one exception is `keyboard-overlap`, whose oracle is a SIGHTING (`User see [Submit] button` while the keyboard is up) — a data assertion there proves nothing extra.' },
53
+
54
+ { id: 'mobile-gesture-substituted', match: /^VIEWPOINT-GESTURE-SUBSTITUTED/,
55
+ example: 'VIEWPOINT-GESTURE-SUBSTITUTED: the viewpoint asks for "the OS/hardware back gesture" ("Returning by hardware back keeps the draft…") but the suite exercises an in-app control instead.',
56
+ fix: 'On mobile all three substituted gestures now have real steps, so `@manual` is no longer the honest answer: the OS/hardware back → `When User go back` (Android KEYCODE_BACK, iOS WDA navigation-back — NOT `tap [Back]`, which runs the screen\'s own handler); backgrounding and resuming → `When User background the app for 5 seconds`; rotation → `When User rotate the device to landscape`. Swap the in-app control for the gesture the viewpoint asked for.' },
57
+
58
+ { id: 'mobile-manual-uncoded', match: /^MANUAL-CODE-MISSING/,
59
+ example: 'MANUAL-CODE-MISSING: 7 @manual tag(s) carry no reason code — tag @manual:M1-M11 (M5 = external dependency e.g. inbox, M3 = mock/network, M7 = environment, M10 = device/OS capability, M11 = real-device only) so the capability planner can route what a driver would automate.',
60
+ fix: 'Code each `@manual` so the capability planner can route it. On mobile the two that matter are `@manual:M10` (device/OS capability — process death, a real push notification, the iOS permission dialog) and `@manual:M11` (real-device only — biometrics and fingerprint, which emulator-only commands cannot stand in for). Before tagging, confirm no step already exists: airplane mode, GPS toggle, deep links, clipboard, `go back`, rotate and background all compile now.' },
61
+
62
+ { id: 'mobile-page-type-undetermined', match: /^PAGE-TYPE-UNDETERMINED/,
63
+ example: 'PAGE-TYPE-UNDETERMINED: no page type fits this screen with enough confidence (best 0 keyword hit(s)), so NO critical themes were demanded and the coverage axis is excluded from the score — declare it in test-viewpoint.md (`page-type: <id>`) to have the theme checklist applied.',
64
+ fix: 'Declare the page type in `requirements/test-viewpoint.md` as `page-type: <id>`, choosing a MOBILE id — `mobile-home`, `mobile-list`, `mobile-detail`, `mobile-form`, `mobile-auth` or `mobile-settings`. Until it is declared the coverage axis is excluded, so the unit is unmeasured rather than passing; a web id (`form`, `list`) would demand the wrong themes.' },
65
+
66
+ // ── Runtime: a step sungen refused to compile honestly, surfacing as a loud throw ────────────
67
+ { id: 'mobile-web-only-step', match: /\[sungen\][^\n]*?(?:\bweb-only\b|not supported on the appium adapter|no native-mobile equivalent|adapter cannot express it)/,
68
+ example: '[sungen] table/column steps target HTML table roles and are not supported on the appium adapter. Use list / accessibility-id assertions, or tag this scenario @manual.',
69
+ fix: 'A web-only step form reached the Appium adapter (reported at compile time as SG-W020/SG-E020) and throws by design rather than passing silently. Swap in the native equivalent: a table/column assertion → per-element `[Row N Title]` accessibility-id assertions; a URL/route assertion → a screen marker (`Then User see [Header Title] is visible`); tab-order or `sorted` → read the element texts and compare, or `@manual:M10`.' },
70
+
71
+ { id: 'mobile-step-unmatched', match: /\[sungen\] step "[^"]*" matched no pattern/,
72
+ example: '[sungen] step "User pinches the map twice" matched no pattern — sungen has no step form for it. Rephrase using a supported step form (see the sungen-gherkin-syntax reference), or tag the scenario @manual.',
73
+ fix: 'The sentence is outside the vocabulary (SG-W001), so it throws instead of quietly asserting nothing. Rephrase from the mobile catalogue in `sungen-mobile-gestures` (tap / double tap / long press / swipe / pinch / scroll to / tap top of / pull to refresh / go back / rotate / background / restart / reinstall / deep link / clipboard / permission / airplane mode), or tag the scenario `@manual:M10` with the reason.' },
74
+
75
+ { id: 'mobile-lifecycle-no-app-id', match: /needs the app id: the mobile lifecycle steps resolve the target app from the feature/,
76
+ example: "restart the app needs the app id: the mobile lifecycle steps resolve the target app from the feature's Path, and this feature declares none, so the generated helper would be missing and the whole spec would fail at runtime.",
77
+ fix: 'Declare the app id on the Feature line so `__resolveAppId()` is emitted: `Path: com.example.app/.MainActivity | com.example.app`. For a mobile unit `--path` carries the APP ID and `--reach` carries the navigation recipe — a unit scaffolded with the route in `--path` produces exactly this (SG-E024), and without the line the whole spec dies in a before hook, not just the step.' },
78
+
79
+ { id: 'mobile-ios-unsupported-step', match: /This feature declares @platform:ios/,
80
+ example: 'step "User turn on airplane mode" compiles to `connectivity-action`, which does nothing on iOS — it is guarded out with no error, so the step would run and the scenario would PASS having done nothing. This feature declares @platform:ios.',
81
+ fix: 'The step has no iOS implementation and the feature pins iOS (SG-E025). Either move the scenario to an `@platform:android` feature — connectivity, the GPS toggle and the offline banner are Android-only at runtime — or drop the step and tag the scenario `@manual:M10`. The per-OS columns in `docs/spec/step-support-matrix.md` say which OS serves each template.' },
82
+
83
+ // ── Runtime: the Appium session / driver layer ───────────────────────────────────────────────
84
+ { id: 'mobile-session-not-created', match: /A new session could not be created/,
85
+ example: 'A new session could not be created. Details: The requested application does not appear to be installed on the device (com.example.app)',
86
+ fix: 'The Appium session never started, so nothing in the suite ran. Reconcile the capabilities in `.env.appium` / `wdio.conf.ts` with the device: `appium:appPackage` + `appium:appActivity` (or `appium:app` pointing at an installed build), `appium:platformVersion` against `adb shell getprop ro.build.version.release`, and `appium:deviceName`/`appium:udid` against `adb devices`. Install the build first if the details name a missing package.' },
87
+
88
+ { id: 'mobile-driver-proxy-lost', match: /Could not proxy command to (?:the )?remote server|socket hang up|ECONNRESET/,
89
+ example: 'Could not proxy command to the remote server. Original error: socket hang up',
90
+ fix: 'The device-side driver (UiAutomator2 server / WebDriverAgent) died mid-run, so every later step in the file fails for one cause. Raise `appium:newCommandTimeout` (the 60s default kills the session during a long pause), keep the device awake and unlocked, and read the Appium server log for the crash. If it recurs at the same step, that step is crashing the app — capture the failure evidence for it instead of retrying the suite.' },
91
+
92
+ { id: 'mobile-instrumentation-failed', match: /instrumentation process (?:cannot|could not) be (?:initialized|started)|Cannot start the instrumentation process/,
93
+ example: "Cannot start the 'com.example.app' application. Original error: The instrumentation process cannot be initialized",
94
+ fix: 'The UiAutomator2 instrumentation could not attach. Uninstall its two helper packages and let Appium reinstall them: `adb uninstall io.appium.uiautomator2.server` and `adb uninstall io.appium.uiautomator2.server.test`. If it persists, the build under test is signed with a different key than the installed copy — uninstall the app itself and reinstall the exact APK the run points at.' },
95
+
96
+ { id: 'mobile-chromedriver-mismatch', match: /No Chromedriver found|chromedriver.*(?:version|mismatch)|Chrome version must be/i,
97
+ example: "No Chromedriver found that can automate Chrome '112.0.5615'. See the appium chromedriver documentation.",
98
+ fix: 'A WebView step needs a Chromedriver matching the device\'s System WebView. Set `appium:chromedriverAutodownload: true` in the capabilities, or pin `appium:chromedriverExecutableDir` to a directory holding the right build. If this unit is a native screen that only crossed a WebView by accident, assert the native markers instead so the run never enters WEBVIEW context.' },
99
+
100
+ // ── Runtime: element-level symptoms (both measured on a real device) ─────────────────────────
101
+ { id: 'mobile-scroll-no-scrollable', match: /Default scrollable element .* not found/,
102
+ example: "Default scrollable element '//android.widget.ScrollView' not found",
103
+ fix: 'MEASURED: wdio\'s mobile scroll runs `$$(\'//android.widget.ScrollView\')` and throws when 0 match — that node exists on a list/Home screen but not on every screen, so this is STATE-dependent, not "the screen cannot scroll". Assert the app is on the scrollable screen before scrolling; on Flutter/Compose (no native ScrollView) or a screen whose content fits, drop the scroll entirely. For an element merely hidden behind a floating bottom nav use `When User tap top of [X]` — scroll cannot fix occlusion, because `.scrollIntoView()` is a NO-OP once the target is inside the viewport.' },
104
+
105
+ { id: 'mobile-tap-intercepted', match: /element click intercepted|is not clickable at point|element is obscured/i,
106
+ example: 'element click intercepted: Element <button id="submit"> is not clickable at point (540, 1830). Other element would receive the click: <div class="bottom-nav">',
107
+ fix: 'Another element owns that point — on mobile almost always a floating bottom nav or a sticky footer sitting over it. Use `When User tap top of [X]`, which taps a visible point of the element rather than its centre. Do NOT reach for `scroll to [X]`: it was measured to be a NO-OP here (element bounds identical before and after), because scrollIntoView has no concept of z-order occlusion.' },
108
+
109
+ { id: 'mobile-element-absent', match: /no such element|Element not found:|An element could not be located/,
110
+ example: 'Element not found: ~submit-button',
111
+ fix: 'The locator resolved to nothing. Regenerate the key from the live screen with `/sungen:run-test` (the `sungen-selector-fix` skill reads the page source), preferring `accessibility-id` — an XPath over the native tree breaks on the first layout change. If the element only appears after a gesture, add the step that reveals it (`scroll to` / `tap`), since `__ensureVisible` scrolls but does not navigate.' },
112
+
113
+ { id: 'mobile-ios-offscreen', match: /iOS geometry oracle: rect=/,
114
+ example: 'Expect "~photo-view" to be displayed — iOS geometry oracle: rect={"x":0,"y":0,"width":0,"height":0} is not on screen (0x0 = not rendered / scrolled out)',
115
+ fix: 'On iOS the element EXISTS in the tree but occupies no on-screen rectangle — 0x0 means not rendered yet, and non-zero-but-off-screen means scrolled out. Add the step that brings it on screen (`When User scroll to [X]`) or an explicit settle before the assertion; if the screen renders it lazily behind a fetch, assert a loading marker disappears first so the oracle runs against the settled screen.' },
116
+
117
+ { id: 'mobile-auth-session', match: /@auth:\w+ (?:requires|login rejected|login did not complete|could not decide the login state|login failed)/,
118
+ example: '@auth:admin login did not complete (still on the login screen) — check loginSteps in qa/screens/login/test-data/login.yaml.',
119
+ fix: 'The mobile `@auth:<role>` bootstrap could not reach a logged-in state, so the scenario never got to its own subject. Complete the four parts it needs in the role\'s test-data: `loginSteps`, `credentials.email` / `credentials.password`, an `authedMarker` that only the signed-in screen renders, and a `loggedOutMarker`. A marker both screens render is the usual cause of "could not decide the login state".' },
120
+
121
+ { id: 'mobile-device-log-silent', match: /device log \([^)]+\) did not contain/,
122
+ example: 'device log (logcat) did not contain "checkout_completed" within 10s',
123
+ fix: 'The log oracle polled for 10s and never saw the line — a real finding when the step is the oracle for a SILENT behaviour (an analytics event, a swallowed error). Confirm the needle against a manual run\'s `adb logcat` output before treating it as a test defect: a wrong log type (`logcat` vs `syslog`) or a substring the app formats differently is the other half of the cases, and both look identical from here.' },
124
+ ];
125
+
126
+ export const mobileRepair: RepairProvider = { rules: mobileRepairRules };
@@ -20,6 +20,9 @@ export const devicePatterns: StepPattern[] = [
20
20
  // Keyed on the device noun + turn/enable/disable so it never collides with UI toggles
21
21
  // (`tap [Wifi Switch]` carries a selectorRef and a tap verb).
22
22
  name: 'set-connectivity',
23
+ platforms: ['mobile'],
24
+ platformAlternative:
25
+ 'a browser cannot toggle the device radio — intercept the network in the Playwright config (or `@mock`) to test offline behaviour, or tag the scenario @manual',
23
26
  matcher: (step) =>
24
27
  /\b(airplane(?:\s+mode)?|wi-?fi|mobile\s+data)\b/i.test(step.text) &&
25
28
  /\b(turn(s)?|enable(s)?|disable(s)?|switch(es)?)\b/i.test(step.text) &&
@@ -42,6 +45,9 @@ export const devicePatterns: StepPattern[] = [
42
45
  // `User turn device location on|off` / `User disable location services`. Distinct from
43
46
  // `set-geolocation` (set/mock/simulate coordinates) — this toggles the SERVICE.
44
47
  name: 'location-services',
48
+ platforms: ['mobile'],
49
+ platformAlternative:
50
+ 'a browser has no GPS switch — set/clear geolocation on the Playwright context, or tag the scenario @manual',
45
51
  matcher: (step) =>
46
52
  /\b(device\s+location|location\s+services?)\b/i.test(step.text) &&
47
53
  /\b(turn(s)?|enable(s)?|disable(s)?|switch(es)?)\b/i.test(step.text) &&
@@ -56,6 +62,9 @@ export const devicePatterns: StepPattern[] = [
56
62
  // `User pass|fail biometric authentication` — simulated biometric input on emulator/simulator
57
63
  // (#603). Face wording (`face id`) selects the iOS faceId type; Android is fingerprint-only.
58
64
  name: 'biometric-auth',
65
+ platforms: ['mobile'],
66
+ platformAlternative:
67
+ 'a browser has no biometric prompt (WebAuthn is a different mechanism) — tag the scenario @manual',
59
68
  matcher: (step) =>
60
69
  /\b(biometric|fingerprint|face\s?id|touch\s?id)\b/i.test(step.text) &&
61
70
  /\b(pass(es)?|fail(s)?|match(es)?|reject(s)?)\b/i.test(step.text) &&
@@ -68,6 +68,9 @@ export const gesturePatterns: StepPattern[] = [
68
68
  // --- gestures ---
69
69
  {
70
70
  name: 'swipe-element',
71
+ platforms: ['mobile'],
72
+ platformAlternative:
73
+ 'a browser has no swipe — drive the control the swipe reveals (`User click [X]`), or tag the scenario @manual',
71
74
  matcher: (step) => /\bswipe(s)?\b/i.test(step.text) && !!step.selectorRef,
72
75
  resolver: (step, context) => {
73
76
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
@@ -78,6 +81,9 @@ export const gesturePatterns: StepPattern[] = [
78
81
  },
79
82
  {
80
83
  name: 'long-press-element',
84
+ platforms: ['mobile'],
85
+ platformAlternative:
86
+ 'a browser has no long press — drive the control the long press reveals, or tag the scenario @manual',
81
87
  matcher: (step) => /\blong[-\s]?press(es)?\b/i.test(step.text) && !!step.selectorRef,
82
88
  resolver: (step, context) => {
83
89
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
@@ -89,6 +95,9 @@ export const gesturePatterns: StepPattern[] = [
89
95
  },
90
96
  {
91
97
  name: 'pull-to-refresh',
98
+ platforms: ['mobile'],
99
+ platformAlternative:
100
+ 'a browser has no pull-to-refresh — re-enter the page with `User is on [X] page`, or tag the scenario @manual',
92
101
  matcher: (step) => /\bpull[-\s]?to[-\s]?refresh\b/i.test(step.text) && !!step.selectorRef,
93
102
  resolver: (step, context) => {
94
103
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
@@ -98,6 +107,9 @@ export const gesturePatterns: StepPattern[] = [
98
107
  },
99
108
  {
100
109
  name: 'pinch-zoom',
110
+ platforms: ['mobile'],
111
+ platformAlternative:
112
+ 'a browser has no pinch — set the viewport/zoom in the Playwright config, or tag the scenario @manual',
101
113
  matcher: (step) => /\bpinch[-\s]?zoom\b/i.test(step.text) && !!step.selectorRef,
102
114
  resolver: (step, context) => {
103
115
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
@@ -108,6 +120,9 @@ export const gesturePatterns: StepPattern[] = [
108
120
  },
109
121
  {
110
122
  name: 'tap-top-of-element',
123
+ platforms: ['mobile'],
124
+ platformAlternative:
125
+ 'a browser click already lands on a visible point — use `User click [X]`',
111
126
  matcher: (step) => /\btap(s)?\b/i.test(step.text) && /\b(top of|at the top|at top)\b/i.test(step.text) && !!step.selectorRef,
112
127
  resolver: (step, context) => {
113
128
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
@@ -117,6 +132,9 @@ export const gesturePatterns: StepPattern[] = [
117
132
  },
118
133
  {
119
134
  name: 'dismiss-if-present',
135
+ platforms: ['mobile'],
136
+ platformAlternative:
137
+ 'there is no OS dialog on web — close the in-app dialog with its own control (`User click [Close] button`)',
120
138
  matcher: (step) => /\bdismiss(es)?\b/i.test(step.text) && !!step.selectorRef && step.elementType !== 'alert',
121
139
  resolver: (step, context) => {
122
140
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
@@ -126,6 +144,9 @@ export const gesturePatterns: StepPattern[] = [
126
144
  },
127
145
  {
128
146
  name: 'rotate-device',
147
+ platforms: ['mobile'],
148
+ platformAlternative:
149
+ 'a browser has no orientation — set the viewport size in the Playwright config for the responsive check',
129
150
  matcher: (step) => /\brotate\b/i.test(step.text) && /\b(landscape|portrait)\b/i.test(step.text) && !step.selectorRef,
130
151
  resolver: (step) => {
131
152
  const orientation = /\blandscape\b/i.test(step.text) ? 'LANDSCAPE' : 'PORTRAIT';
@@ -133,8 +154,42 @@ export const gesturePatterns: StepPattern[] = [
133
154
  },
134
155
  priority: 16,
135
156
  },
157
+ {
158
+ // The OS/hardware back, deliberately distinct from tapping an in-app Back control. The two are
159
+ // different behaviours — Android's system back skips the screen's own handler unless the app
160
+ // wires it — and `viewpoint-ledger` already refuses the second as an answer to the first. Before
161
+ // this pattern the ledger asked a question the vocabulary could not answer, so the only reply
162
+ // was @manual.
163
+ //
164
+ // What separates `tap [Back] button` from this pattern is REF MASKING, not `!selectorRef`:
165
+ // matchers see `[~]`, so the word "back" is not in the text they read. Mutation testing proved
166
+ // it — dropping `!selectorRef` broke nothing, because masking had already done that job. The
167
+ // guard's real and only job is `User go back to [List] screen`: verb AND ref, where a match
168
+ // would silently DISCARD the named screen. That is the case the test now covers.
169
+ //
170
+ // Every phrasing the ledger's theme regex recognises is accepted here on purpose; a phrasing it
171
+ // matches but the vocabulary rejects would re-open the same dead end one word over. `tap the back
172
+ // button` is NOT accepted: with no ref it is far more likely an in-app control the author forgot
173
+ // to bracket, and reading it as the OS gesture would change the meaning of their test silently.
174
+ name: 'os-back',
175
+ platforms: ['mobile'],
176
+ platformAlternative: 'a browser has no OS back — use `User is on [X] page` to re-enter the screen, or tag the scenario @manual',
177
+ matcher: (step) =>
178
+ !step.selectorRef && (
179
+ /\bgo(es)?\s+back\b/i.test(step.text) ||
180
+ /\bpress(es)?\s+(?:the\s+)?back\s+button\b/i.test(step.text) ||
181
+ /\b(?:hardware|system|os|device|android|physical)[-\s]?back\b/i.test(step.text) ||
182
+ /\bback\s+gesture\b/i.test(step.text) ||
183
+ /\bswipe[-\s]?back\b/i.test(step.text)
184
+ ),
185
+ resolver: () => ({ templateName: 'go-back-action', data: {}, comment: 'OS/hardware back' }),
186
+ priority: 16,
187
+ },
136
188
  {
137
189
  name: 'background-app',
190
+ platforms: ['mobile'],
191
+ platformAlternative:
192
+ 'a browser tab has no background/resume — re-enter with `User is on [X] page` to test re-hydration, or tag the scenario @manual',
138
193
  matcher: (step) => /\bbackground\b/i.test(step.text) && /\bapp\b/i.test(step.text) && !step.selectorRef,
139
194
  resolver: (step) => {
140
195
  const secs = step.text.match(/(\d+)\s*second/i)?.[1];
@@ -145,18 +200,27 @@ export const gesturePatterns: StepPattern[] = [
145
200
  },
146
201
  {
147
202
  name: 'open-notifications',
203
+ platforms: ['mobile'],
204
+ platformAlternative:
205
+ 'a browser has no notification shade — tag the scenario @manual',
148
206
  matcher: (step) => /\bnotification(s)?\s+(panel|shade|tray)\b/i.test(step.text) || /\bopen(s)?\s+notifications\b/i.test(step.text),
149
207
  resolver: () => ({ templateName: 'open-notifications-action', data: {}, comment: 'Open notification panel' }),
150
208
  priority: 16,
151
209
  },
152
210
  {
153
211
  name: 'hide-keyboard',
212
+ platforms: ['mobile'],
213
+ platformAlternative:
214
+ 'a browser has no soft keyboard, so nothing overlaps and the step has no web meaning — remove it',
154
215
  matcher: (step) => /\bkeyboard\b/i.test(step.text) && /\b(hide|hides|dismiss|dismisses|close|closes)\b/i.test(step.text) && !step.selectorRef,
155
216
  resolver: () => ({ templateName: 'hide-keyboard-action', data: {}, comment: 'Hide the soft keyboard' }),
156
217
  priority: 16,
157
218
  },
158
219
  {
159
220
  name: 'grant-permission',
221
+ platforms: ['mobile'],
222
+ platformAlternative:
223
+ 'browser permissions are granted on the Playwright context, not by a step — configure them there, or tag the scenario @manual',
160
224
  matcher: (step) => /\bgrant(s)?\b/i.test(step.text) && /\bpermission(s)?\b/i.test(step.text),
161
225
  resolver: (step) => {
162
226
  const raw = step.selectorRef || step.text.match(/grant(?:s)?\s+(?:the\s+)?([\w.]+)\s+permission/i)?.[1] || '';
@@ -168,6 +232,9 @@ export const gesturePatterns: StepPattern[] = [
168
232
  },
169
233
  {
170
234
  name: 'set-clipboard',
235
+ platforms: ['mobile'],
236
+ platformAlternative:
237
+ 'the browser clipboard is permission-gated and not a step — seed the value through the UI instead, or tag the scenario @manual',
171
238
  matcher: (step) => /\bclipboard\b/i.test(step.text) && /\b(set|sets|copy|copies|put|puts)\b/i.test(step.text) && (!!step.dataRef || step.value != null),
172
239
  resolver: (step, context) => {
173
240
  let value: string;
@@ -180,14 +247,42 @@ export const gesturePatterns: StepPattern[] = [
180
247
  },
181
248
  {
182
249
  name: 'set-geolocation',
250
+ platforms: ['mobile'],
251
+ platformAlternative:
252
+ 'browser geolocation is set on the Playwright context, not by a step — configure it there',
183
253
  matcher: (step) => /\b(location|geolocation|gps|coordinates)\b/i.test(step.text) && /\b(set|sets|mock|mocks|simulate|simulates|move|moves)\b/i.test(step.text),
184
- resolver: (step, context) => {
185
- let raw = '';
186
- if (step.dataRef) { try { raw = context.dataResolver.resolveData(step.dataRef, context.featureName); } catch { raw = ''; } }
187
- const nums = (raw || step.text).match(/-?\d+\.\d+|-?\d+/g) || [];
188
- const latitude = nums[0] ?? '0';
189
- const longitude = nums[1] ?? '0';
190
- return { templateName: 'set-geolocation-action', data: { latitude, longitude }, comment: `Set location to ${latitude}, ${longitude}` };
254
+ // Resolves nothing itself — it CLASSIFIES and forwards, like `set-clipboard` above and
255
+ // `deep-link` in lifecycle-patterns.ts. Reading the coordinates here is what caused S1: under the
256
+ // default runtime-data mode `resolveData` throws, the catch fell back to scanning the step TEXT
257
+ // for digits (there are none in a `{{ref}}`), and `?? '0'` shipped `0, 0` — Null Island, a real
258
+ // place off the coast of Africa — as a valid coordinate. A geofence test passed there, silently.
259
+ resolver: (step) => {
260
+ // A ref goes to the template untouched: the TEST reads test-data, so one compiled spec works
261
+ // across environments (`SUNGEN_ENV` swaps the overlay, never the code).
262
+ if (step.dataRef) {
263
+ return {
264
+ templateName: 'set-geolocation-action',
265
+ data: { dataRef: step.dataRef },
266
+ comment: `Set location from ${step.dataRef}`,
267
+ };
268
+ }
269
+ const nums = step.text.match(/-?\d+\.\d+|-?\d+/g) || [];
270
+ // No ref and fewer than two numbers: there is nothing to set. `PatternResolver` cannot return
271
+ // "no" (types.ts:66 is non-nullable), so the refusal is the template's job — the treatment
272
+ // `alert-fill-action` got. Defaulting is the one thing that must not happen: an unanswered
273
+ // coordinate is still somewhere, and the test would pass there.
274
+ if (nums.length < 2) {
275
+ return {
276
+ templateName: 'set-geolocation-action',
277
+ data: { missing: step.text },
278
+ comment: `Cannot set location — no coordinates in "${step.text}"`,
279
+ };
280
+ }
281
+ return {
282
+ templateName: 'set-geolocation-action',
283
+ data: { latitude: nums[0], longitude: nums[1] },
284
+ comment: `Set location to ${nums[0]}, ${nums[1]}`,
285
+ };
191
286
  },
192
287
  priority: 16,
193
288
  },
@@ -46,6 +46,9 @@ export const lifecyclePatterns: StepPattern[] = [
46
46
  // `User restart the app` / `User restarts the app`. Must NOT match when a selectorRef is
47
47
  // present — `tap [Restart Button]` belongs to the tap pattern, not here.
48
48
  name: 'restart-app',
49
+ platforms: ['mobile'],
50
+ platformAlternative:
51
+ 'a browser has no app process — re-enter with `User is on [X] page` (and clear storage) to test persistence',
49
52
  matcher: (step) => /\brestart(s)?\b/i.test(step.text) && /\bapp\b/i.test(step.text) && !step.selectorRef,
50
53
  resolver: (step, context) => requiresAppId(step, context, 'restart the app')
51
54
  ?? { templateName: 'restart-app-action', data: {}, comment: 'Restart the app (kill → relaunch → settle)' },
@@ -55,6 +58,9 @@ export const lifecyclePatterns: StepPattern[] = [
55
58
  // `User reinstall the app` — needs the session to have installed from a build file
56
59
  // (appium:app); the template fails loud with the fix when the cap is absent.
57
60
  name: 'reinstall-app',
61
+ platforms: ['mobile'],
62
+ platformAlternative:
63
+ 'a browser has no install — clear storage/cookies to reach the first-run state',
58
64
  matcher: (step) => /\bre-?install(s)?\b/i.test(step.text) && /\bapp\b/i.test(step.text) && !step.selectorRef,
59
65
  resolver: (step, context) => requiresAppId(step, context, 'reinstall the app')
60
66
  ?? { templateName: 'reinstall-app-action', data: {}, comment: 'Reinstall the app (remove → install → launch)' },
@@ -64,6 +70,9 @@ export const lifecyclePatterns: StepPattern[] = [
64
70
  // `User open deep link {{url}}` (or a quoted literal) — routes the app via a VIEW intent /
65
71
  // XCUITest deepLink. THE entry for push-param / universal-link routing matrices (#603).
66
72
  name: 'open-deep-link',
73
+ platforms: ['mobile'],
74
+ platformAlternative:
75
+ 'a browser URL is not a deep link — use `User is on [X] page` with the route',
67
76
  matcher: (step) => /\bdeep\s?-?link\b/i.test(step.text) && (!!step.dataRef || step.value != null) && !step.selectorRef,
68
77
  resolver: (step) => ({
69
78
  templateName: 'deep-link-action',
@@ -12,6 +12,9 @@ export const logPatterns: StepPattern[] = [
12
12
  // `Then User see device log contains {{v}}` / `… contains "literal"`. No selectorRef — the
13
13
  // element-scoped `contains` assertions all carry one, so there is no collision.
14
14
  name: 'device-log-contains',
15
+ platforms: ['mobile'],
16
+ platformAlternative:
17
+ 'a browser has no device log — assert the behaviour on the UI, or tag the scenario @manual',
15
18
  matcher: (step) =>
16
19
  /\b(device\s+)?logs?\b/i.test(step.text) &&
17
20
  /\bcontains?\b/i.test(step.text) &&
@@ -13,6 +13,9 @@ export const timingPatterns: StepPattern[] = [
13
13
  {
14
14
  // `Then User see [X] disappear within 5 seconds` — the element must vanish inside the window.
15
15
  name: 'disappears-within',
16
+ platforms: ['mobile'],
17
+ platformAlternative:
18
+ 'the web adapter auto-waits — use `Then User see [X] is hidden`',
16
19
  matcher: (step) => /\bdisappears?\b/i.test(step.text) && /\bwithin\b/i.test(step.text) && SECONDS.test(step.text) && !!step.selectorRef,
17
20
  resolver: (step, context) => {
18
21
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
@@ -28,6 +31,9 @@ export const timingPatterns: StepPattern[] = [
28
31
  {
29
32
  // `Then User see [X] still visible after 3 seconds` — the element must survive the window.
30
33
  name: 'still-visible-after',
34
+ platforms: ['mobile'],
35
+ platformAlternative:
36
+ 'the web adapter auto-waits — assert `Then User see [X] is visible` after the action that might have removed it',
31
37
  matcher: (step) => /\bstill\s+visible\s+after\b/i.test(step.text) && SECONDS.test(step.text) && !!step.selectorRef,
32
38
  resolver: (step, context) => {
33
39
  const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);