@sun-asterisk/sungen 3.2.24-beta.5 → 3.2.24

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.
Files changed (100) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +9 -1
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/biometric-action.hbs +9 -0
  5. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +11 -6
  6. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/deep-link-action.hbs +8 -0
  7. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/location-services-action.hbs +11 -0
  8. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/reinstall-app-action.hbs +21 -0
  9. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/restart-app-action.hbs +16 -0
  10. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/set-connectivity-action.hbs +11 -0
  11. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +9 -5
  12. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/checked-assertion.hbs +10 -4
  13. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/device-log-assertion.hbs +16 -0
  14. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disappears-within-assertion.hbs +5 -0
  15. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/not-checked-assertion.hbs +6 -4
  16. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +4 -3
  17. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/still-visible-after-assertion.hbs +4 -0
  18. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/pull-to-refresh-action.hbs +3 -0
  19. package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/swipe-action.hbs +3 -0
  20. package/dist/generators/test-generator/adapters/appium/templates/steps/partials/appium-selector.hbs +7 -1
  21. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +129 -15
  22. package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
  23. package/dist/generators/test-generator/template-engine.js +3 -0
  24. package/dist/generators/test-generator/template-engine.js.map +1 -1
  25. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  26. package/dist/generators/test-generator/utils/selector-resolver.js +4 -0
  27. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  28. package/dist/harness/audit.d.ts.map +1 -1
  29. package/dist/harness/audit.js +38 -2
  30. package/dist/harness/audit.js.map +1 -1
  31. package/dist/harness/capability-plan.d.ts.map +1 -1
  32. package/dist/harness/capability-plan.js +6 -1
  33. package/dist/harness/capability-plan.js.map +1 -1
  34. package/dist/harness/parse.d.ts +1 -0
  35. package/dist/harness/parse.d.ts.map +1 -1
  36. package/dist/harness/parse.js +1 -0
  37. package/dist/harness/parse.js.map +1 -1
  38. package/dist/harness/script-check.d.ts.map +1 -1
  39. package/dist/harness/script-check.js +6 -2
  40. package/dist/harness/script-check.js.map +1 -1
  41. package/dist/harness/sensors.d.ts +31 -0
  42. package/dist/harness/sensors.d.ts.map +1 -1
  43. package/dist/harness/sensors.js +79 -1
  44. package/dist/harness/sensors.js.map +1 -1
  45. package/dist/harness/viewpoint-ledger.d.ts +21 -0
  46. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  47. package/dist/harness/viewpoint-ledger.js +57 -5
  48. package/dist/harness/viewpoint-ledger.js.map +1 -1
  49. package/dist/orchestrator/mobile-runtime-scaffolder.d.ts.map +1 -1
  50. package/dist/orchestrator/mobile-runtime-scaffolder.js +5 -1
  51. package/dist/orchestrator/mobile-runtime-scaffolder.js.map +1 -1
  52. package/dist/orchestrator/templates/ai-src/commands/create-test.md +23 -0
  53. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +10 -4
  54. package/dist/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +23 -0
  55. package/dist/orchestrator/templates/env.appium.example +11 -0
  56. package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
  57. package/dist/orchestrator/templates/specs-db.js +79 -6
  58. package/dist/orchestrator/templates/specs-db.js.map +1 -1
  59. package/dist/orchestrator/templates/specs-db.ts +70 -7
  60. package/dist/orchestrator/templates/wdio.conf.ts +100 -3
  61. package/dist/utils/selector-types.d.ts +1 -1
  62. package/dist/utils/selector-types.d.ts.map +1 -1
  63. package/dist/utils/selector-types.js +1 -0
  64. package/dist/utils/selector-types.js.map +1 -1
  65. package/package.json +3 -3
  66. package/src/cli/commands/audit.ts +9 -1
  67. package/src/generators/test-generator/adapters/appium/templates/steps/actions/biometric-action.hbs +9 -0
  68. package/src/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +11 -6
  69. package/src/generators/test-generator/adapters/appium/templates/steps/actions/deep-link-action.hbs +8 -0
  70. package/src/generators/test-generator/adapters/appium/templates/steps/actions/location-services-action.hbs +11 -0
  71. package/src/generators/test-generator/adapters/appium/templates/steps/actions/reinstall-app-action.hbs +21 -0
  72. package/src/generators/test-generator/adapters/appium/templates/steps/actions/restart-app-action.hbs +16 -0
  73. package/src/generators/test-generator/adapters/appium/templates/steps/actions/set-connectivity-action.hbs +11 -0
  74. package/src/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +9 -5
  75. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/checked-assertion.hbs +10 -4
  76. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/device-log-assertion.hbs +16 -0
  77. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disappears-within-assertion.hbs +5 -0
  78. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/not-checked-assertion.hbs +6 -4
  79. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +4 -3
  80. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/still-visible-after-assertion.hbs +4 -0
  81. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/pull-to-refresh-action.hbs +3 -0
  82. package/src/generators/test-generator/adapters/appium/templates/steps/gestures/swipe-action.hbs +3 -0
  83. package/src/generators/test-generator/adapters/appium/templates/steps/partials/appium-selector.hbs +7 -1
  84. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +129 -15
  85. package/src/generators/test-generator/template-engine.ts +4 -0
  86. package/src/generators/test-generator/utils/selector-resolver.ts +4 -0
  87. package/src/harness/audit.ts +39 -2
  88. package/src/harness/capability-plan.ts +3 -1
  89. package/src/harness/parse.ts +2 -0
  90. package/src/harness/script-check.ts +6 -2
  91. package/src/harness/sensors.ts +73 -1
  92. package/src/harness/viewpoint-ledger.ts +68 -5
  93. package/src/orchestrator/mobile-runtime-scaffolder.ts +5 -1
  94. package/src/orchestrator/templates/ai-src/commands/create-test.md +23 -0
  95. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +10 -4
  96. package/src/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +23 -0
  97. package/src/orchestrator/templates/env.appium.example +11 -0
  98. package/src/orchestrator/templates/specs-db.ts +70 -7
  99. package/src/orchestrator/templates/wdio.conf.ts +100 -3
  100. package/src/utils/selector-types.ts +1 -0
@@ -257,7 +257,7 @@ Full config + catalog examples → **Advanced → Database** doc.
257
257
 
258
258
  ### Unsupported DB engine — fallback (ask, don't improvise)
259
259
 
260
- Direct SQL verification (`@query`, the Data Driver) covers these SQL engines: **`{postgres, sqlite, mysql}`** — plus the three native NoSQL engines above (Cosmos, MongoDB, DynamoDB). When a datasource's `engine` (or the DB the user describes) is **outside all of those** — e.g. Cassandra, Oracle, Neo4j, a proprietary store:
260
+ Direct SQL verification (`@query`, the Data Driver) covers these SQL engines: **`{postgres, sqlite, device-sqlite, mysql}`** (`device-sqlite` = an ON-DEVICE app database on a mobile project pulled fresh per query through the Appium session via `package:` + `db_path:`; debuggable build or emulator required) — plus the three native NoSQL engines above (Cosmos, MongoDB, DynamoDB). When a datasource's `engine` (or the DB the user describes) is **outside all of those** — e.g. Cassandra, Oracle, Neo4j, a proprietary store:
261
261
 
262
262
  1. **Do not** attempt a direct connect. **Do not** improvise a verification method.
263
263
  2. Present a fixed `AskUserQuestion` with exactly these 3 branches (always these, no others invented):
@@ -275,7 +275,7 @@ Direct SQL verification (`@query`, the Data Driver) covers these SQL engines: **
275
275
  Example:
276
276
  ```
277
277
  AskUserQuestion:
278
- question: "This datasource's engine (cassandra) isn't a supported Data Driver engine ({postgres, sqlite, mysql} + Cosmos/MongoDB/DynamoDB) — direct connect isn't supported. How should DB state be verified?"
278
+ question: "This datasource's engine (cassandra) isn't a supported Data Driver engine ({postgres, sqlite, device-sqlite, mysql} + Cosmos/MongoDB/DynamoDB) — direct connect isn't supported. How should DB state be verified?"
279
279
  options:
280
280
  - "@api — verify via the project's internal API (Recommended: qa/api/ catalog found)"
281
281
  - "@manual — emit a @manual step + cqlsh check"
@@ -317,7 +317,13 @@ Every claim below is checked against a shipped `.hbs` under
317
317
 
318
318
  **Mobile-only `[mobile]`** — the gesture catalog (swipe, long-press, pinch-zoom, pull-to-refresh,
319
319
  rotate, background/foreground, notifications, grant-permission, clipboard set, set-geolocation,
320
- hide-keyboard, tap-top-of) has no web counterpart. Full syntax `sungen-mobile-gestures`.
320
+ hide-keyboard, tap-top-of) has no web counterpart, and neither do the lifecycle / device-state /
321
+ timing steps (`restart the app`, `reinstall the app`, `turn airplane mode|wifi|mobile data on|off`,
322
+ `turn device location on|off` — the latter two Android-only, throw on iOS — the window
323
+ assertions `see [X] disappear within N seconds` / `still visible after N seconds`, and the
324
+ device-oracle trio `open deep link {{url}}` · `see device log contains {{v}}` · `pass|fail
325
+ biometric authentication` (biometric = emulator/Simulator only).
326
+ Full syntax → `sungen-mobile-gestures`.
321
327
 
322
328
  **Divergences — compiles on both, means something different:**
323
329
 
@@ -328,7 +334,7 @@ hide-keyboard, tap-top-of) has no web counterpart. Full syntax → `sungen-mobil
328
334
  | `wait for [T] page` | waits for the URL | fixed `driver.pause(500)` settle — not a real wait condition |
329
335
  | `hover [T] icon \| row` | real hover | no-op — hover-revealed content is normally already visible on mobile; use `tap` |
330
336
  | `fill [T] alert with {{v}}` | fills native `prompt()` | no-op (comment only) — app-specific, handle manually |
331
- | `switch to [T] frame` | enters an `<iframe>` | switches a hybrid app's WebView context; no-op on a pure-native screen (no WebView found) |
337
+ | `switch to [T] frame` | enters an `<iframe>` | switches a hybrid app's WebView context; no-op on a pure-native screen. **Needs `APPIUM_CHROMEDRIVER_AUTODOWNLOAD=1`** (.env.appium) or the switch fails with "No Chromedriver found". INSIDE the webview, selectors are **css/xpath only** — UiSelector-based types and the text-value asserts (`label with {{v}}`) don't apply there; assert element presence via css/xpath refs, then `switch to [main] frame` back |
332
338
  | `see [X] with {{v}}` (filtered visibility forms) | CSS `hasText` filter | hand-rolled substring match over `getText()`/`content-desc` (Android) or `label`/`value` (iOS) — same substring semantics, different attribute set |
333
339
  | `… is sorted …` / `… is loading` inside a filtered row/state check | reads `aria-sort`/`aria-busy` | **throws** — no native analog for these two states specifically (the plain, unfiltered `is loading` on a spinner still works on both) |
334
340
  | `scope: dialog` selector option | resolves inside the dialog | no effect (`SG-W021`) — steps resolve against the whole screen |
@@ -38,6 +38,29 @@ patterns (`click`, `hover`, `fill`) don't cover.
38
38
  > requests it yet — there is currently no phrasing that compiles to an actual clipboard-read
39
39
  > assertion. Don't author "see clipboard contains X" expecting it to compile; tag it `@manual` instead.
40
40
  > - **`set location to {{lat}}, {{lng}}`** → `driver.setGeoLocation(...)`.
41
+ > - **`restart the app`** → kill → relaunch → content settle (same oracle as the per-scenario
42
+ > relaunch). THE step for persistence oracles: pinned card, dismissal survival, show-once tooltips.
43
+ > - **`reinstall the app`** → remove → install from the session's build file → launch. **Requires
44
+ > the session to have installed via `APP_APK` / `IOS_APP`** (the `appium:app` cap) — attaching by
45
+ > package only makes the step fail loud with that exact fix in the message.
46
+ > - **`turn airplane mode|wifi|mobile data on|off`** → `mobile: setConnectivity`. **Android-only** —
47
+ > XCUITest cannot toggle connectivity; on iOS the step throws loud. Keep such scenarios
48
+ > `@platform:android`. Do NOT author cleanup steps to restore connectivity "in case the test
49
+ > dies": the generated `wdio.conf.ts` restores the baseline (airplane off, wifi/data/location on)
50
+ > via adb in `onPrepare`/`onComplete`, healing even a run whose Appium session died mid-toggle (#615).
51
+ > - **`turn device location on|off`** → `mobile: isGpsEnabled` + `mobile: toggleGps` (the execute
52
+ > aliases — WDIO v9 dropped the legacy wrappers), idempotent. **Android-only**, throws loud on iOS.
53
+ > - **`see [X] disappear within N seconds`** → reverse `waitForDisplayed` with the step's deadline
54
+ > (+2s churn grace) — the auto-dismiss / expiry oracle (a 5s tooltip, a 60s barcode).
55
+ > - **`see [X] still visible after N seconds`** → wait out the window, then the platform-aware
56
+ > visibility oracle — the "must NOT auto-dismiss / must stay valid" counterpart.
57
+ > - **`open deep link {{url}}`** → `mobile: deepLink` (VIEW intent / XCUITest deepLink with the
58
+ > feature's app id) — THE entry for push-param / universal-link routing scenarios.
59
+ > - **`see device log contains {{v}}`** → logcat (Android) / syslog (iOS) poll-accumulate ≤10s —
60
+ > makes SILENT failure modes and analytics events observable (they leave only a log line).
61
+ > - **`pass|fail biometric authentication`** (face wording → iOS faceId) → **emulator/Simulator
62
+ > only**: Android `mobile: fingerprint` (finger 1 must be ENROLLED; fail = unenrolled id), iOS
63
+ > `mobile: sendBiometricMatch`. Real devices reject the command — the step fails loud.
41
64
  >
42
65
  > 📜 **`scroll to [X]` — two failure modes seen, with the real cause (measured):**
43
66
  > 1. *"Default scrollable element '//android.widget.ScrollView' not found"* — wdio's mobile scroll runs
@@ -23,3 +23,14 @@ JAVA_HOME=/opt/homebrew/opt/openjdk@21
23
23
  # IOS_UDID=<device-udid> # REQUIRED — physical device (`xcrun xctrace list devices`), not a sim
24
24
  # IOS_BUNDLE_ID=<ios bundle id> # REQUIRED — the iOS bundle (⚠ NOT the Android package)
25
25
  # IOS_TEAM_ID=<Apple Team ID> # REQUIRED — 10-char id (Xcode → Settings → Accounts)
26
+
27
+ # ════════════ Appium server security (opt-in) ════════════
28
+ # Allow `mobile: shell` (raw adb passthrough) for device-level oracles (e.g. pulling an app's
29
+ # SQLite DB via run-as). Starts the server with --allow-insecure=uiautomator2:adb_shell.
30
+ # SECURITY: gives the session shell access — enable only on trusted local/dev machines,
31
+ # never on shared CI runners you don't control.
32
+ # APPIUM_ALLOW_ADB_SHELL=1
33
+
34
+ # Hybrid WebView: auto-download the chromedriver matching the device's WebView (needed by
35
+ # `switch to [X] frame` on hybrid screens). Downloads a binary at session start — opt-in.
36
+ # APPIUM_CHROMEDRIVER_AUTODOWNLOAD=1
@@ -27,7 +27,7 @@ const ident = (s: string): string => {
27
27
  // SQL engines: verified via the direct guarded-SELECT path and needing the `$n`→`?` placeholder
28
28
  // rewrite. This literal is the single source of truth for that rewrite condition and is quoted, by
29
29
  // name, in the sungen-gherkin-syntax skill's "direct SQL verification" note — keep them in sync.
30
- const SUPPORTED_ENGINES = ['postgres', 'sqlite', 'mysql'] as const;
30
+ const SUPPORTED_ENGINES = ['postgres', 'sqlite', 'device-sqlite', 'mysql'] as const;
31
31
  // NoSQL engines: implement the Engine contract natively (no SQL rewrite). Tracked separately so the
32
32
  // SQL-rewrite guard and its skill-parity test stay pinned to the three SQL engines above.
33
33
  const NOSQL_ENGINES = ['cosmos', 'mongodb', 'dynamodb'] as const;
@@ -43,9 +43,15 @@ interface SshConfig {
43
43
  }
44
44
 
45
45
  interface DataSourceConfig {
46
- engine: 'postgres' | 'mysql' | 'sqlite' | 'cosmos' | 'mongodb' | 'dynamodb';
46
+ engine: 'postgres' | 'mysql' | 'sqlite' | 'device-sqlite' | 'cosmos' | 'mongodb' | 'dynamodb';
47
47
  url: string;
48
48
  readonly?: boolean;
49
+ // device-sqlite (mobile): an ON-DEVICE app database, pulled FRESH per query through the active
50
+ // Appium session (pullFile's `@<package>/<path>` app-container syntax — run-as under the hood,
51
+ // so it needs a debuggable build or an emulator). `package` = the app id, `db_path` = the
52
+ // path inside the app's data dir (e.g. databases/app.db). No `url`. Same SQLite dialect.
53
+ package?: string;
54
+ db_path?: string;
49
55
  statement_timeout_ms?: number;
50
56
  max_rows?: number;
51
57
  // Fallback path: tunnel the DB SOCKET through an SSH bastion. DB-only — the browser/E2E
@@ -160,7 +166,7 @@ function loadConfig(): Record<string, DataSourceConfig> {
160
166
  * reorder is essential there too (an author-written PartiQL `WHERE sk = $2 AND pk = $1`).
161
167
  */
162
168
  export function rewritePlaceholders(engine: string, sql: string, params: any[]): { sql: string; params: any[] } {
163
- if (engine !== 'sqlite' && engine !== 'mysql' && engine !== 'dynamodb') return { sql, params };
169
+ if (engine !== 'sqlite' && engine !== 'device-sqlite' && engine !== 'mysql' && engine !== 'dynamodb') return { sql, params };
164
170
  let out = '';
165
171
  let inStr = false;
166
172
  const newParams: any[] = [];
@@ -517,10 +523,11 @@ class DataSource {
517
523
  const { key, conf } = this.cfg(name);
518
524
  if (this.engines.has(key)) return { engine: this.engines.get(key)!, conf };
519
525
  let engine: Engine;
520
- if (conf.engine === 'postgres' || conf.engine === 'mysql' || conf.engine === 'sqlite') {
521
- // url is required for the SQL engines (host-based, or a sqlite file path). NoSQL engines that
522
- // have no connection URL (e.g. DynamoDB region+creds) supply their own config and skip this.
523
- if (!conf.url) throw new Error(`Data Driver: datasource "${key}" has no url (set it in .env.qa).`);
526
+ if (conf.engine === 'postgres' || conf.engine === 'mysql' || conf.engine === 'sqlite' || conf.engine === 'device-sqlite') {
527
+ // url is required for the HOST-based SQL engines (or a sqlite file path). device-sqlite has
528
+ // no url the file comes off the device (package + db_path, validated in its exec builder);
529
+ // NoSQL engines without a URL (e.g. DynamoDB region+creds) also supply their own config.
530
+ if (!conf.url && conf.engine !== 'device-sqlite') throw new Error(`Data Driver: datasource "${key}" has no url (set it in .env.qa).`);
524
531
  engine = new SqlEngine(conf.engine, await this.sqlExec(key, conf));
525
532
  } else if (conf.engine === 'cosmos') {
526
533
  engine = this.cosmosEngine(key, conf);
@@ -586,6 +593,62 @@ class DataSource {
586
593
  }
587
594
  };
588
595
  }
596
+ // device-sqlite — an ON-DEVICE app database. Every query pulls a FRESH copy through the
597
+ // active Appium session so the oracle always reads CURRENT device state (persistence checks
598
+ // assert AFTER actions). pullFile's `@<package>/<path>` app-container syntax uses run-as
599
+ // under the hood → needs a debuggable build or an emulator. SQLite WAL sidecars (-wal/-shm)
600
+ // are pulled best-effort alongside: Room defaults to WAL, where recent commits live in the
601
+ // -wal file — without it the main file silently misses the newest writes.
602
+ if (conf.engine === 'device-sqlite') {
603
+ if (!conf.package || !conf.db_path) {
604
+ throw new Error(`Data Driver: datasource "${key}" (device-sqlite) needs package: and db_path: (path inside the app data dir, e.g. databases/app.db).`);
605
+ }
606
+ const pkg = conf.package;
607
+ const rel = conf.db_path.replace(/^\/+/, '');
608
+ return async (sql, params) => {
609
+ const wd = (globalThis as any).driver;
610
+ if (!wd || typeof wd.pullFile !== 'function') {
611
+ throw new Error(`Data Driver: datasource "${key}" (device-sqlite) needs an active Appium session — device @query steps only run inside mobile specs.`);
612
+ }
613
+ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path');
614
+ const base = path.join(os.tmpdir(), `sungen-devdb-${process.pid}-${Date.now()}`);
615
+ const files: string[] = [];
616
+ const pull = async (suffix: string, required: boolean): Promise<void> => {
617
+ let b64: string | null = null;
618
+ let pullErr: unknown;
619
+ try {
620
+ b64 = await wd.pullFile(`@${pkg}/${rel}${suffix}`);
621
+ } catch (e) {
622
+ pullErr = e;
623
+ // pullFile's container route does `run-as cp → /data/local/tmp`, which newer Android
624
+ // blocks for the app UID. Fallback: stream base64 over run-as via `mobile: shell`
625
+ // (opt-in — APPIUM_ALLOW_ADB_SHELL=1, see the wdio.conf template).
626
+ try {
627
+ const out = await wd.execute('mobile: shell', { command: 'run-as', args: [pkg, 'base64', `${rel}${suffix}`] });
628
+ b64 = String(out).replace(/\s+/g, '');
629
+ } catch (e2) {
630
+ if (required) {
631
+ throw new Error(`Data Driver: could not pull @${pkg}/${rel} from the device. pullFile: ${pullErr} · shell fallback: ${e2} — needs a DEBUGGABLE build; if pullFile's container route fails on this Android version, set APPIUM_ALLOW_ADB_SHELL=1 (.env.appium).`);
632
+ }
633
+ return;
634
+ }
635
+ }
636
+ if (b64 == null) return;
637
+ fs.writeFileSync(base + suffix, Buffer.from(b64, 'base64'));
638
+ files.push(base + suffix);
639
+ };
640
+ await pull('', true);
641
+ await pull('-wal', false);
642
+ await pull('-shm', false);
643
+ try {
644
+ const Database = require('better-sqlite3');
645
+ const db = new Database(base, { readonly: false }); // WAL recovery needs write access to the TEMP copy
646
+ try { return db.prepare(sql).all(...params); } finally { db.close(); }
647
+ } finally {
648
+ for (const f of files) { try { fs.unlinkSync(f); } catch { /* best-effort */ } }
649
+ }
650
+ };
651
+ }
589
652
  // sqlite
590
653
  if (conf.ssh) console.warn(`Data Driver: datasource "${key}" sets ssh: but engine is sqlite (file-based) — ssh ignored.`);
591
654
  const Database = require('better-sqlite3');
@@ -178,6 +178,61 @@ function resolveLocaleCaps(): Record<string, unknown> {
178
178
  }
179
179
  const localeCaps = resolveLocaleCaps();
180
180
 
181
+ // ── Connectivity self-heal (#615) ───────────────────────────────────────────
182
+ // The connectivity/location steps mutate DEVICE state (airplane, wifi, data, GPS). A run that
183
+ // aborts mid-toggle — e.g. an Appium session timeout during an airplane round-trip — leaves the
184
+ // device offline as PERSISTENT state, so every later launch fails with the app's network error
185
+ // and the next run dies at cold start. Restore the baseline around the run via host-side adb:
186
+ // it works even when no Appium session survives (a spec-level after() cannot — the session is
187
+ // exactly what died). Android-only (the mutating steps are Android-only), idempotent (on/on/on
188
+ // is the baseline every suite assumes; offline scenarios set their own state mid-test), and
189
+ // best-effort per command (a device lacking one command just skips that line).
190
+ function restoreConnectivity(hook: string): void {
191
+ if (!RUN_ANDROID) return;
192
+ // Appium finds adb via ANDROID_HOME — PATH may not carry it (fresh CI runner), which would
193
+ // make every heal command fail silently. Resolve the same way Appium does, fall back to PATH.
194
+ const adbBin = process.env.ANDROID_HOME ? path.join(process.env.ANDROID_HOME, 'platform-tools', 'adb') : 'adb';
195
+ const adb = `"${adbBin}" -s ${process.env.ANDROID_UDID || '__ANDROID_UDID__'} shell`;
196
+ // stderr ignored: with a detached device the reads would otherwise spray raw adb errors on every run.
197
+ const sh = (cmd: string) => execSync(`${adb} ${cmd}`, { timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
198
+ // Detect-first: read each state and only touch what is actually wedged, so a healthy run
199
+ // costs four quick reads, mutates nothing, and logs nothing. Each fix lists every command
200
+ // variant (emulator `svc`, real-device `cmd`, API<30 fallbacks) — all best-effort, same
201
+ // direction, idempotent, so running the inapplicable ones is harmless.
202
+ let touched = false;
203
+ const heal = (read: string, wedged: string, fixes: string[]) => {
204
+ try {
205
+ if (sh(read) !== wedged) return;
206
+ for (const fix of fixes) { try { sh(fix); } catch { /* variant not on this API — fine */ } }
207
+ touched = true;
208
+ } catch { /* unreadable state (device gone / key absent) — leave untouched */ }
209
+ };
210
+ heal('settings get global airplane_mode_on', '1', [
211
+ 'cmd connectivity airplane-mode disable', // API 30+
212
+ 'settings put global airplane_mode_on 0', // API<30 wedge (settings+broadcast)
213
+ 'am broadcast -a android.intent.action.AIRPLANE_MODE --ez state false',
214
+ ]);
215
+ heal('settings get global wifi_on', '0', [
216
+ 'svc wifi enable', // emulator flavor
217
+ 'cmd -w wifi set-wifi-enabled enabled', // real-device flavor (svc can no-op silently)
218
+ ]);
219
+ heal('settings get global mobile_data', '0', [
220
+ 'svc data enable',
221
+ 'cmd phone data enable',
222
+ ]);
223
+ heal('cmd location is-location-enabled', 'false', ['cmd location set-location-enabled true']);
224
+ if (!touched) return;
225
+ console.log(`[sungen] connectivity self-heal (${hook}): device was offline — a previous run likely aborted mid-toggle. Baseline restored (airplane off, wifi/data/location on).`);
226
+ // Radios re-associate asynchronously (emulator wifi can take 10-30s) — wait for REAL IP
227
+ // connectivity before the session starts, or the app's first cold start still lands on its
228
+ // network-error dialog and the auth/first assert fails despite the heal.
229
+ for (let i = 0; i < 10; i++) {
230
+ try { execSync(`${adb} ping -c 1 -W 2 8.8.8.8`, { stdio: 'ignore', timeout: 8000 }); return; } catch { /* not yet */ }
231
+ try { execSync(`${adb} sleep 2`, { stdio: 'ignore', timeout: 8000 }); } catch { /* device gone — stop waiting */ return; }
232
+ }
233
+ console.log('[sungen] connectivity self-heal: no ICMP reply within ~30s — continuing (fine if this network blocks ping; otherwise the device may need a cold boot).');
234
+ }
235
+
181
236
  export const config: WebdriverIO.Config = {
182
237
  runner: 'local',
183
238
  tsConfigPath: './tsconfig.json',
@@ -192,7 +247,10 @@ export const config: WebdriverIO.Config = {
192
247
  // Global auto-wait for $/$$ matchers (toBeDisplayed, click, …). Default is 3s, too short for a
193
248
  // heavy app's cold (re)launch — each scenario terminates+activates its app, so the first
194
249
  // find/click must wait out the splash/first-frame. 20s keeps slow native/Flutter apps green.
195
- waitforTimeout: 20000,
250
+ // 30s (not the classic 20s): on a Compose app with looping in-app animations the accessibility
251
+ // manager can starve for 10s+ per query ("Timed out waiting for the root AccessibilityNodeInfo",
252
+ // observed live) — a 20s budget turns that latency into flaky not-found failures.
253
+ waitforTimeout: 30000,
196
254
 
197
255
  // Capability set(s) selected by MOBILE_PLATFORM (android | ios | both). Each cap carries its OWN
198
256
  // `specs` (per-OS routing): the Android cap runs android + mobile specs, the iOS cap runs ios +
@@ -217,6 +275,14 @@ export const config: WebdriverIO.Config = {
217
275
  'appium:noReset': true,
218
276
  'appium:autoGrantPermissions': true,
219
277
  'appium:newCommandTimeout': 300,
278
+ // Compose/animated UIs (a looping promo animation is enough) never report accessibility-idle,
279
+ // so every find first waits out UiAutomator2's full idle timeout — 10000ms by default. 2s keeps
280
+ // interactions responsive while still absorbing screen transitions. NOTE: milliseconds — the
281
+ // same-named XCUITest cap below is in seconds; the two drivers define the knob differently.
282
+ 'appium:settings[waitForIdleTimeout]': 2000,
283
+ // Disable SYSTEM animation scales for the session — trims accessibility-tree churn so
284
+ // queries stall less on animation-heavy apps (in-app Compose animations still run).
285
+ 'appium:disableWindowAnimation': true,
220
286
  // i18n: when SUNGEN_ENV/SUNGEN_LOCALE is a locale code, this overrides noReset→false and
221
287
  // adds appium:language/appium:locale so the app boots in that locale. Empty otherwise.
222
288
  ...localeCaps,
@@ -275,7 +341,23 @@ export const config: WebdriverIO.Config = {
275
341
  reporters: ['spec', [PwShapeReporter, {}]],
276
342
 
277
343
  // Auto-start a local Appium server on :4723 (base path '/')
278
- services: ['appium'],
344
+ // Appium server options — security-gated features, each opt-in via .env.appium:
345
+ // APPIUM_ALLOW_ADB_SHELL=1 → --allow-insecure uiautomator2:adb_shell
346
+ // (raw shell access — device-level oracles like device-sqlite's run-as fallback)
347
+ // APPIUM_CHROMEDRIVER_AUTODOWNLOAD=1 → --allow-insecure uiautomator2:chromedriver_autodownload
348
+ // (hybrid WebView: downloads the chromedriver matching the device's WebView at session start —
349
+ // without it, `switch to [X] frame` fails with "No Chromedriver found that can automate Chrome N")
350
+ // Both stay OFF by default (see docs/spec/security-and-telemetry-roadmap.md).
351
+ services: [
352
+ ['appium', (() => {
353
+ const feats: string[] = [];
354
+ if (process.env.APPIUM_ALLOW_ADB_SHELL) feats.push('uiautomator2:adb_shell');
355
+ if (process.env.APPIUM_CHROMEDRIVER_AUTODOWNLOAD) feats.push('uiautomator2:chromedriver_autodownload');
356
+ // NOTE: pass a comma-joined STRING — @wdio/appium-service JSON.stringifies arrays, which the
357
+ // appium server then reads as a JSON literal and the features silently never register.
358
+ return feats.length ? { args: { allowInsecure: feats.join(',') } } : {};
359
+ })()],
360
+ ],
279
361
  port: 4723,
280
362
  path: '/',
281
363
 
@@ -291,12 +373,27 @@ export const config: WebdriverIO.Config = {
291
373
  retries: 1,
292
374
  },
293
375
 
376
+ // expect-webdriverio's default assertion budget (~2s wait / 100ms interval) is tuned for
377
+ // sub-second finds. On an accessibility tree churned by looping in-app animations (a rotating
378
+ // promo carousel is enough) a single server-side find can take 5-15s, so every toBeDisplayed()
379
+ // would expire before its first query even returns — align the budget with waitforTimeout.
380
+ before: async () => {
381
+ const { setOptions } = await import('expect-webdriverio');
382
+ setOptions({ wait: 20000, interval: 500 });
383
+ },
384
+
385
+ // Connectivity self-heal (#615): onPrepare heals a device wedged by a previous KILLED run
386
+ // (its onComplete never fired) before the session starts; onComplete restores after this run,
387
+ // pass or fail. Both run in the launcher process, so they fire even when the worker/session died.
388
+ onPrepare: () => restoreConnectivity('onPrepare'),
389
+ onComplete: () => restoreConnectivity('onComplete'),
390
+
294
391
  // iOS + locale: XCUITest's `noReset:false` does NOT actually clear the app's data container
295
392
  // (verified live 2026-06-11) — a Flutter app that persisted its language on a previous launch keeps
296
393
  // it and ignores the new device locale, so the i18n run asserts the wrong language. Wipe the app
297
394
  // data via simctl BEFORE the session so first-launch locale detection re-runs. Android needs no
298
395
  // hook (its noReset:false reset works natively).
299
- beforeSession: async (_config, capabilities) => {
396
+ beforeSession: async (_config, capabilities, specs) => {
300
397
  if (Object.keys(localeCaps).length === 0) return; // not a locale run
301
398
  const caps = capabilities as Record<string, unknown>;
302
399
  if (String(caps['platformName'] || '').toLowerCase() !== 'ios') return;
@@ -20,6 +20,7 @@ export const VALID_SELECTOR_TYPES = [
20
20
  'xpath', // cross-platform, last resort
21
21
  'android-uiautomator', // Android only — UiSelector expressions
22
22
  'ios-predicate', // iOS only — NSPredicate strings
23
+ 'css', // hybrid WebView only — CSS inside a WEBVIEW context (after `switch to [X] frame`)
23
24
  ] as const;
24
25
 
25
26
  export type SelectorType = (typeof VALID_SELECTOR_TYPES)[number];