@sungen/driver-mobile 3.2.27 → 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 +3 -2
- package/src/index.ts +5 -0
- package/src/mobile-repair.ts +126 -0
- package/src/patterns/device-patterns.ts +9 -0
- package/src/patterns/gesture-patterns.ts +102 -7
- package/src/patterns/lifecycle-patterns.ts +9 -0
- package/src/patterns/log-patterns.ts +3 -0
- package/src/patterns/timing-patterns.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-mobile",
|
|
3
|
-
"version": "3.2.
|
|
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.
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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);
|