@sungen/driver-mobile 3.2.2-beta.13

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 ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@sungen/driver-mobile",
3
+ "version": "3.2.2-beta.13",
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
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "sungen": {
8
+ "capability": "mobile"
9
+ },
10
+ "scripts": {
11
+ "build": "rm -rf dist && tsc -p tsconfig.json"
12
+ },
13
+ "author": "eqe team (engineer & quality) — Sun Asterisk",
14
+ "license": "MIT",
15
+ "dependencies": {
16
+ "@sun-asterisk/sungen": "3.2.2-beta.13",
17
+ "@wdio/appium-service": "^9.0.0",
18
+ "@wdio/cli": "^9.0.0",
19
+ "@wdio/local-runner": "^9.0.0",
20
+ "@wdio/mocha-framework": "^9.0.0",
21
+ "@wdio/spec-reporter": "^9.0.0",
22
+ "appium": "^3.0.0",
23
+ "webdriverio": "^9.0.0"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ]
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @sungen/driver-mobile — the MOBILE platform driver (Appium / WebdriverIO).
3
+ *
4
+ * The mobile mirror of @sungen/driver-ui (web). Core loads this at runtime via
5
+ * `sungenDriver.register` when a project's `qa/capabilities.yaml` has `platform: mobile`
6
+ * (`sungen capability add mobile`); core never imports this package statically — the
7
+ * mobile runtime (webdriverio/appium) lives only in the emitted project + this driver's
8
+ * peerDependencies, never in core.
9
+ *
10
+ * Phased build — see docs/spec/sungen_driver_mobile_spec.md:
11
+ * MOB-1: capability registration + package scaffold.
12
+ * MOB-2: the Appium codegen adapter (core adapterRegistry, `mobile`). The runtime files
13
+ * (wdio.conf.ts, specs/reporters/pw-shape-reporter.ts, .env.appium) are emitted into
14
+ * the target project by mobile-runtime-scaffolder.ts (called by `sungen init --mobile`
15
+ * and `sungen capability add mobile`), NOT via the runtimeHelpers SPI. The reporter is
16
+ * project-level config, emitted once — not per-generate.
17
+ * MOB-3: mobile selector strategies + gesture step patterns.
18
+ * MOB-4 (here): harness parity — gesture patterns + gate sensor + discovery/contextMapper.
19
+ * MOB-5: mobile AI skills (gestures / capture / selector-fix).
20
+ *
21
+ * Mobile is UI-flavoured (screens, interactions, assertions), so it is scored by the SAME
22
+ * coverage + assertion-depth gate as web. That gate is deliberately NOT re-implemented or
23
+ * re-imported here. The audit engine runs its OWN in-core viewpointGate + assertionDepth over
24
+ * the project's catalog, passing isMobile=true for a `platform: mobile` project (so a launcher
25
+ * resolves to a `mobile-*` page-type, never a web one). NOT exposing `viewpoints`/`gateProvider`
26
+ * keeps the score-bearing gate + catalog sourced from the RUNNING core — never from a separately
27
+ * resolved `@sun-asterisk/sungen` copy nested under this package, which could be an older published
28
+ * build whose catalog predates `mobile-home` and would silently drift the score (version-skew).
29
+ * Only the runtime (Appium) differs, and that lives in the adapter (MOB-2), not the gate.
30
+ */
31
+ import type {
32
+ CapabilityRegistry,
33
+ Sensor,
34
+ GateInput,
35
+ DiscoveryProvider,
36
+ ContextMapper,
37
+ GenerationUnit,
38
+ } from '@sun-asterisk/sungen';
39
+ import { gesturePatterns } from './patterns/gesture-patterns';
40
+
41
+ /** Surfaces universal-viewpoint theme gaps (low-priority reminder; does not move the score). */
42
+ const mobileViewpointGateSensor: Sensor<GateInput> = {
43
+ id: 'mobile-viewpoint',
44
+ capability: 'mobile',
45
+ kind: 'gate',
46
+ run: ({ universalGaps }) =>
47
+ universalGaps && universalGaps.length
48
+ ? [{ sensorId: 'mobile-viewpoint', capability: 'mobile', message: `UNIVERSAL: missing theme(s): ${universalGaps.join(', ')} (low priority reminder).`, severity: 'info' }]
49
+ : [],
50
+ };
51
+
52
+ const mobileDiscovery: DiscoveryProvider = {
53
+ appliesTo: (t) => t.kind === 'screen' || t.kind === 'flow',
54
+ discover: async (target) => ({ target: { ...target, capability: 'mobile' }, sources: {}, facts: {} }),
55
+ };
56
+
57
+ const mobileContextMapper: ContextMapper = {
58
+ decompose: (ctx) => {
59
+ const f = (ctx.facts || {}) as { screens?: unknown[]; flows?: unknown[] };
60
+ const units: GenerationUnit[] = [];
61
+ for (const s of f.screens ?? []) units.push({ mode: 'screen', targetSlice: s });
62
+ for (const fl of f.flows ?? []) units.push({ mode: 'flow', targetSlice: fl });
63
+ if (!units.length) units.push({ mode: ctx.target.kind === 'flow' ? 'flow' : 'screen', targetSlice: ctx.target });
64
+ return units;
65
+ },
66
+ };
67
+
68
+ export function register(registry: CapabilityRegistry): void {
69
+ registry.register({
70
+ id: 'mobile',
71
+ annotations: [],
72
+ patterns: [
73
+ ...gesturePatterns, // MOB-3: tap/double-tap + swipe/long-press/pinch/pull-to-refresh/rotate/… → appium templates
74
+ ],
75
+ discovery: mobileDiscovery,
76
+ contextMapper: mobileContextMapper,
77
+ // Intentionally NO `viewpoints`/`gateProvider`: the score-bearing gate + viewpoint catalog come
78
+ // from the RUNNING core (the audit engine runs its in-core viewpointGate with isMobile=true for a
79
+ // `platform: mobile` project). Self-loading them here would bind the gate to THIS package's own
80
+ // resolved @sun-asterisk/sungen copy, which npm can nest at an older published version whose
81
+ // catalog predates `mobile-home` → stale-catalog version-skew. See the module header.
82
+ sensors: [mobileViewpointGateSensor],
83
+ });
84
+ }
85
+
86
+ /** Discovery entry point core looks for (`mod.sungenDriver.register`). */
87
+ export const sungenDriver = { capability: 'mobile', register } as const;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Mobile (Appium) step patterns — MOB-3.
3
+ *
4
+ * Maps mobile-natural Gherkin to the appium adapter's gesture/action templates
5
+ * (core/adapters/appium/templates/steps/{gestures,actions}). Registered by
6
+ * @sungen/driver-mobile; only meaningful when `platform: mobile` (the appium adapter is
7
+ * the only one that ships these templates). `tap`/`double-tap` are the mobile synonyms
8
+ * for click (driver-ui only knows `click`); `scroll to [X]` is already handled by the
9
+ * shared scroll pattern (both adapters ship scroll-action), so it is not repeated here.
10
+ */
11
+ import { StepPattern } from '@sun-asterisk/sungen';
12
+
13
+ const ANDROID_PERMISSIONS: Record<string, string> = {
14
+ location: 'android.permission.ACCESS_FINE_LOCATION',
15
+ camera: 'android.permission.CAMERA',
16
+ microphone: 'android.permission.RECORD_AUDIO',
17
+ mic: 'android.permission.RECORD_AUDIO',
18
+ contacts: 'android.permission.READ_CONTACTS',
19
+ storage: 'android.permission.READ_EXTERNAL_STORAGE',
20
+ notifications: 'android.permission.POST_NOTIFICATIONS',
21
+ calendar: 'android.permission.READ_CALENDAR',
22
+ };
23
+ const IOS_PERMISSIONS: Record<string, string> = {
24
+ location: 'location', camera: 'camera', microphone: 'microphone', mic: 'microphone',
25
+ contacts: 'contacts', storage: 'photos', photos: 'photos', notifications: 'notifications',
26
+ calendar: 'calendar', reminders: 'reminders', motion: 'motion', siri: 'siri',
27
+ };
28
+
29
+ export const gesturePatterns: StepPattern[] = [
30
+ // --- tap / double-tap (mobile synonyms for click; appium renders them as taps) ---
31
+ {
32
+ name: 'mobile-tap',
33
+ matcher: (step) =>
34
+ /\btap(s)?\b/i.test(step.text) &&
35
+ !/\bdouble[-\s]?tap\b/i.test(step.text) &&
36
+ !/\blong[-\s]?press\b/i.test(step.text) &&
37
+ !/\b(top of|at the top|at top)\b/i.test(step.text) &&
38
+ !!step.selectorRef,
39
+ resolver: (step, context) => {
40
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
41
+ return { templateName: 'click-action', data: { ...resolved, selectorRef: step.selectorRef }, comment: `Tap ${step.selectorRef}` };
42
+ },
43
+ priority: 15,
44
+ },
45
+ {
46
+ name: 'mobile-double-tap',
47
+ matcher: (step) => /\bdouble[-\s]?tap(s)?\b/i.test(step.text) && !!step.selectorRef,
48
+ resolver: (step, context) => {
49
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
50
+ return { templateName: 'double-click-action', data: { ...resolved, selectorRef: step.selectorRef }, comment: `Double-tap ${step.selectorRef}` };
51
+ },
52
+ priority: 17,
53
+ },
54
+ // --- gestures ---
55
+ {
56
+ name: 'swipe-element',
57
+ matcher: (step) => /\bswipe(s)?\b/i.test(step.text) && !!step.selectorRef,
58
+ resolver: (step, context) => {
59
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
60
+ const direction = (step.text.match(/\b(left|right|up|down)\b/i)?.[1] || 'up').toLowerCase();
61
+ return { templateName: 'swipe-action', data: { ...resolved, direction, selectorRef: step.selectorRef }, comment: `Swipe ${direction} on ${step.selectorRef}` };
62
+ },
63
+ priority: 16,
64
+ },
65
+ {
66
+ name: 'long-press-element',
67
+ matcher: (step) => /\blong[-\s]?press(es)?\b/i.test(step.text) && !!step.selectorRef,
68
+ resolver: (step, context) => {
69
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
70
+ const secs = step.text.match(/(\d+)\s*second/i)?.[1];
71
+ const durationMs = secs ? parseInt(secs, 10) * 1000 : 1000;
72
+ return { templateName: 'long-press-action', data: { ...resolved, durationMs, selectorRef: step.selectorRef }, comment: `Long-press ${step.selectorRef}` };
73
+ },
74
+ priority: 16,
75
+ },
76
+ {
77
+ name: 'pull-to-refresh',
78
+ matcher: (step) => /\bpull[-\s]?to[-\s]?refresh\b/i.test(step.text) && !!step.selectorRef,
79
+ resolver: (step, context) => {
80
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
81
+ return { templateName: 'pull-to-refresh-action', data: { ...resolved, selectorRef: step.selectorRef }, comment: `Pull to refresh ${step.selectorRef}` };
82
+ },
83
+ priority: 16,
84
+ },
85
+ {
86
+ name: 'pinch-zoom',
87
+ matcher: (step) => /\bpinch[-\s]?zoom\b/i.test(step.text) && !!step.selectorRef,
88
+ resolver: (step, context) => {
89
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
90
+ const zoomOut = /\bout\b/i.test(step.text);
91
+ return { templateName: 'pinch-zoom-action', data: { ...resolved, zoomOut, selectorRef: step.selectorRef }, comment: `Pinch-zoom ${zoomOut ? 'out' : 'in'} on ${step.selectorRef}` };
92
+ },
93
+ priority: 16,
94
+ },
95
+ {
96
+ name: 'tap-top-of-element',
97
+ matcher: (step) => /\btap(s)?\b/i.test(step.text) && /\b(top of|at the top|at top)\b/i.test(step.text) && !!step.selectorRef,
98
+ resolver: (step, context) => {
99
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
100
+ return { templateName: 'tap-top-action', data: { ...resolved, selectorRef: step.selectorRef }, comment: `Tap top of ${step.selectorRef}` };
101
+ },
102
+ priority: 18,
103
+ },
104
+ {
105
+ name: 'dismiss-if-present',
106
+ matcher: (step) => /\bdismiss(es)?\b/i.test(step.text) && !!step.selectorRef && step.elementType !== 'alert',
107
+ resolver: (step, context) => {
108
+ const resolved = context.selectorResolver.resolveSelector(step.selectorRef!, context.featureName, step.elementType, step.nth);
109
+ return { templateName: 'dismiss-action', data: { ...resolved, selectorRef: step.selectorRef }, comment: `Dismiss ${step.selectorRef} if present` };
110
+ },
111
+ priority: 14,
112
+ },
113
+ {
114
+ name: 'rotate-device',
115
+ matcher: (step) => /\brotate\b/i.test(step.text) && /\b(landscape|portrait)\b/i.test(step.text) && !step.selectorRef,
116
+ resolver: (step) => {
117
+ const orientation = /\blandscape\b/i.test(step.text) ? 'LANDSCAPE' : 'PORTRAIT';
118
+ return { templateName: 'rotate-action', data: { orientation }, comment: `Rotate to ${orientation.toLowerCase()}` };
119
+ },
120
+ priority: 16,
121
+ },
122
+ {
123
+ name: 'background-app',
124
+ matcher: (step) => /\bbackground\b/i.test(step.text) && /\bapp\b/i.test(step.text) && !step.selectorRef,
125
+ resolver: (step) => {
126
+ const secs = step.text.match(/(\d+)\s*second/i)?.[1];
127
+ const seconds = secs ? parseInt(secs, 10) : 3;
128
+ return { templateName: 'background-action', data: { seconds }, comment: `Send app to background for ${seconds}s` };
129
+ },
130
+ priority: 16,
131
+ },
132
+ {
133
+ name: 'open-notifications',
134
+ matcher: (step) => /\bnotification(s)?\s+(panel|shade|tray)\b/i.test(step.text) || /\bopen(s)?\s+notifications\b/i.test(step.text),
135
+ resolver: () => ({ templateName: 'open-notifications-action', data: {}, comment: 'Open notification panel' }),
136
+ priority: 16,
137
+ },
138
+ {
139
+ name: 'hide-keyboard',
140
+ matcher: (step) => /\bkeyboard\b/i.test(step.text) && /\b(hide|hides|dismiss|dismisses|close|closes)\b/i.test(step.text) && !step.selectorRef,
141
+ resolver: () => ({ templateName: 'hide-keyboard-action', data: {}, comment: 'Hide the soft keyboard' }),
142
+ priority: 16,
143
+ },
144
+ {
145
+ name: 'grant-permission',
146
+ matcher: (step) => /\bgrant(s)?\b/i.test(step.text) && /\bpermission(s)?\b/i.test(step.text),
147
+ resolver: (step) => {
148
+ const raw = step.selectorRef || step.text.match(/grant(?:s)?\s+(?:the\s+)?([\w.]+)\s+permission/i)?.[1] || '';
149
+ const permission = ANDROID_PERMISSIONS[raw.toLowerCase()] || raw;
150
+ const iosService = IOS_PERMISSIONS[raw.toLowerCase()] || raw.toLowerCase();
151
+ return { templateName: 'grant-permission-action', data: { permission, iosService }, comment: `Grant ${raw || 'app'} permission` };
152
+ },
153
+ priority: 16,
154
+ },
155
+ {
156
+ name: 'set-clipboard',
157
+ 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),
158
+ resolver: (step, context) => {
159
+ let value: string;
160
+ if (step.dataRef) {
161
+ try { value = context.dataResolver.resolveData(step.dataRef, context.featureName); } catch { value = `\${${step.dataRef}}`; }
162
+ } else { value = step.value!; }
163
+ return { templateName: 'set-clipboard-action', data: { clipboardValue: value, dataRef: step.dataRef }, comment: 'Set the device clipboard' };
164
+ },
165
+ priority: 16,
166
+ },
167
+ {
168
+ name: 'set-geolocation',
169
+ 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),
170
+ resolver: (step, context) => {
171
+ let raw = '';
172
+ if (step.dataRef) { try { raw = context.dataResolver.resolveData(step.dataRef, context.featureName); } catch { raw = ''; } }
173
+ const nums = (raw || step.text).match(/-?\d+\.\d+|-?\d+/g) || [];
174
+ const latitude = nums[0] ?? '0';
175
+ const longitude = nums[1] ?? '0';
176
+ return { templateName: 'set-geolocation-action', data: { latitude, longitude }, comment: `Set location to ${latitude}, ${longitude}` };
177
+ },
178
+ priority: 16,
179
+ },
180
+ ];