@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 +29 -0
- package/src/index.ts +87 -0
- package/src/patterns/gesture-patterns.ts +180 -0
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
|
+
];
|