@sun-asterisk/sungen 3.2.24-beta.6 → 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.
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/biometric-action.hbs +9 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +11 -6
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/deep-link-action.hbs +8 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/location-services-action.hbs +11 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/reinstall-app-action.hbs +21 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/restart-app-action.hbs +16 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/set-connectivity-action.hbs +11 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +9 -5
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/checked-assertion.hbs +10 -4
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/device-log-assertion.hbs +16 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/disappears-within-assertion.hbs +5 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/not-checked-assertion.hbs +6 -4
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +4 -3
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/still-visible-after-assertion.hbs +4 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/pull-to-refresh-action.hbs +3 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/gestures/swipe-action.hbs +3 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/partials/appium-selector.hbs +7 -1
- package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +129 -15
- package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
- package/dist/generators/test-generator/template-engine.js +3 -0
- package/dist/generators/test-generator/template-engine.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +4 -0
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +3 -1
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/capability-plan.d.ts.map +1 -1
- package/dist/harness/capability-plan.js +6 -1
- package/dist/harness/capability-plan.js.map +1 -1
- package/dist/harness/parse.d.ts +1 -0
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +1 -0
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/script-check.d.ts.map +1 -1
- package/dist/harness/script-check.js +6 -2
- package/dist/harness/script-check.js.map +1 -1
- package/dist/harness/sensors.d.ts.map +1 -1
- package/dist/harness/sensors.js +5 -1
- package/dist/harness/sensors.js.map +1 -1
- package/dist/orchestrator/mobile-runtime-scaffolder.d.ts.map +1 -1
- package/dist/orchestrator/mobile-runtime-scaffolder.js +5 -1
- package/dist/orchestrator/mobile-runtime-scaffolder.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +10 -4
- package/dist/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +23 -0
- package/dist/orchestrator/templates/env.appium.example +11 -0
- package/dist/orchestrator/templates/specs-db.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-db.js +79 -6
- package/dist/orchestrator/templates/specs-db.js.map +1 -1
- package/dist/orchestrator/templates/specs-db.ts +70 -7
- package/dist/orchestrator/templates/wdio.conf.ts +100 -3
- package/dist/utils/selector-types.d.ts +1 -1
- package/dist/utils/selector-types.d.ts.map +1 -1
- package/dist/utils/selector-types.js +1 -0
- package/dist/utils/selector-types.js.map +1 -1
- package/package.json +3 -3
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/biometric-action.hbs +9 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/check-action.hbs +11 -6
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/deep-link-action.hbs +8 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/location-services-action.hbs +11 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/reinstall-app-action.hbs +21 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/restart-app-action.hbs +16 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/set-connectivity-action.hbs +11 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/uncheck-action.hbs +9 -5
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/checked-assertion.hbs +10 -4
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/device-log-assertion.hbs +16 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/disappears-within-assertion.hbs +5 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/not-checked-assertion.hbs +6 -4
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/state-with-filter-assertion.hbs +4 -3
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/still-visible-after-assertion.hbs +4 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/gestures/pull-to-refresh-action.hbs +3 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/gestures/swipe-action.hbs +3 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/partials/appium-selector.hbs +7 -1
- package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +129 -15
- package/src/generators/test-generator/template-engine.ts +4 -0
- package/src/generators/test-generator/utils/selector-resolver.ts +4 -0
- package/src/harness/audit.ts +3 -1
- package/src/harness/capability-plan.ts +3 -1
- package/src/harness/parse.ts +2 -0
- package/src/harness/script-check.ts +6 -2
- package/src/harness/sensors.ts +5 -1
- package/src/orchestrator/mobile-runtime-scaffolder.ts +5 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +10 -4
- package/src/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +23 -0
- package/src/orchestrator/templates/env.appium.example +11 -0
- package/src/orchestrator/templates/specs-db.ts +70 -7
- package/src/orchestrator/templates/wdio.conf.ts +100 -3
- package/src/utils/selector-types.ts +1 -0
|
@@ -72,20 +72,38 @@ async function __ensureVisible(selector: string): Promise<WebdriverIO.Element> {
|
|
|
72
72
|
}
|
|
73
73
|
const safeBottom = Math.round(winH * 0.82); // above the floating bottom-nav band
|
|
74
74
|
let lastY: number | null = null;
|
|
75
|
+
let lastEl: WebdriverIO.Element | null = null;
|
|
75
76
|
for (let __i = 0; __i < 12; __i++) {
|
|
76
77
|
let y: number | null = null;
|
|
78
|
+
let existsUndisplayed = false;
|
|
77
79
|
try {
|
|
78
80
|
const el = await $(selector);
|
|
79
81
|
if (await el.isDisplayed()) {
|
|
80
82
|
const r = await driver.getElementRect(el.elementId);
|
|
81
|
-
|
|
83
|
+
// Done when the element sits fully above the floating bottom-nav band — OR is simply too
|
|
84
|
+
// tall to ever fit there (a scroll container / full-screen list): demanding "fully above"
|
|
85
|
+
// from such an element makes the loop scroll a page that was already correct, dragging
|
|
86
|
+
// list-hosted siblings (e.g. the top bar as first list item) out of the viewport.
|
|
87
|
+
if (r.y + r.height <= safeBottom || r.height >= safeBottom) return el;
|
|
82
88
|
y = r.y;
|
|
89
|
+
lastEl = el;
|
|
90
|
+
} else if (el.elementId) {
|
|
91
|
+
existsUndisplayed = true; // in the tree, just not visible right now
|
|
83
92
|
}
|
|
84
93
|
} catch { /* off-screen / not in tree yet → keep scrolling */ }
|
|
94
|
+
// Overlay grace (Android mirror of the iOS render grace above): the element is IN the tree but
|
|
95
|
+
// reports not-displayed — typically a launch popup/overlay covering it that auto-dismisses
|
|
96
|
+
// within seconds. Scrolling now would carry a list-hosted element (a Compose top bar is often
|
|
97
|
+
// the first list item) clean out of the viewport, where a down-only loop can never recover it —
|
|
98
|
+
// so wait the overlay out before the first scroll.
|
|
99
|
+
if (existsUndisplayed && __i < 4) { await driver.pause(1200); continue; }
|
|
85
100
|
// No-progress break: the element is on screen but a prior scroll didn't move it — it's a FIXED
|
|
86
101
|
// element (e.g. a bottom-nav tab, intentionally in the nav band) or we've hit the end of the
|
|
87
102
|
// scrollable. Stop rather than burn the whole budget; the caller acts on it where it is.
|
|
88
|
-
|
|
103
|
+
// Return the RESOLVED element, never a fresh $() stub: on an animation-churned tree (a looping
|
|
104
|
+
// banner is enough) a from-scratch re-find takes seconds and outlasts the caller's assertion
|
|
105
|
+
// budget — the element we just measured is the one the caller should act on.
|
|
106
|
+
if (y !== null && lastY !== null && Math.abs(y - lastY) < 2) return lastEl ?? $(selector);
|
|
89
107
|
lastY = y;
|
|
90
108
|
await driver.execute('mobile: scrollGesture', { left: 60, top: 420, width: 960, height: 1320, direction: 'down', percent: 0.5 });
|
|
91
109
|
await driver.pause(300);
|
|
@@ -116,6 +134,74 @@ async function __assertVisible(selector: string): Promise<void> {
|
|
|
116
134
|
}
|
|
117
135
|
throw new Error(`Expect ${JSON.stringify(selector)} to be displayed — iOS geometry oracle: rect=${JSON.stringify(r)} is not on screen (0x0 = not rendered / scrolled out)`);
|
|
118
136
|
}
|
|
137
|
+
|
|
138
|
+
// Checkbox / toggle / radio state oracle. There is NO single authoritative source for it on
|
|
139
|
+
// mobile, and the standard one lies in a way that cannot be detected from the read itself:
|
|
140
|
+
// UiAutomator2 puts `checked` on EVERY Android node and defaults it to "false" when the widget
|
|
141
|
+
// publishes no checked semantics. So a custom-rendered control — a Flutter widget without
|
|
142
|
+
// `Semantics(checked: …)`, an RN `Pressable` without `accessibilityState={ checked: … }` — is not
|
|
143
|
+
// ABSENT from the tree, it reports a confident false while sitting visibly ON, and a strict
|
|
144
|
+
// `toHaveAttribute('checked','true')` fails with no way to see why.
|
|
145
|
+
// Hence: probe every signal both runtimes expose, and let the DIRECTION of the assertion decide
|
|
146
|
+
// how to combine them (__assertCheckedState below).
|
|
147
|
+
// Android: `checked`, `selected`, isSelected() iOS: `value` ("1"/"0"), `selected`, isSelected()
|
|
148
|
+
// Descendants are probed too, bounded: on a composite row (label + control merged into one a11y
|
|
149
|
+
// node) the state routinely sits on an inner node while the queried outer node reports the default.
|
|
150
|
+
// A child xpath must start with `.//` — a leading `//` is ABSOLUTE in Appium even when chained.
|
|
151
|
+
async function __probeCheckedSignals(el: WebdriverIO.Element): Promise<{ source: string; value: boolean }[]> {
|
|
152
|
+
const out: { source: string; value: boolean }[] = [];
|
|
153
|
+
const read = async (node: WebdriverIO.Element, prefix: string): Promise<void> => {
|
|
154
|
+
for (const attr of driver.isIOS ? ['value', 'selected'] : ['checked', 'selected']) {
|
|
155
|
+
let raw: string | null = null;
|
|
156
|
+
try { raw = await node.getAttribute(attr); } catch { continue; } // attribute unsupported on this node
|
|
157
|
+
const v = String(raw ?? '').trim().toLowerCase();
|
|
158
|
+
if (v === 'true' || v === '1') out.push({ source: `${prefix}${attr}="${raw}"`, value: true });
|
|
159
|
+
else if (v === 'false' || v === '0') out.push({ source: `${prefix}${attr}="${raw}"`, value: false });
|
|
160
|
+
// anything else (empty, a real iOS `value` string like "Off") carries no parseable state → skip
|
|
161
|
+
}
|
|
162
|
+
try { out.push({ source: `${prefix}isSelected()`, value: await node.isSelected() }); } catch { /* not supported here */ }
|
|
163
|
+
};
|
|
164
|
+
await read(el, '');
|
|
165
|
+
// Only descend when the node itself yielded nothing positive — otherwise we already have the
|
|
166
|
+
// answer and a subtree walk would turn one assertion into dozens of round-trips for nothing.
|
|
167
|
+
if (!out.some(s => s.value)) {
|
|
168
|
+
let kids: WebdriverIO.Element[] = [];
|
|
169
|
+
try { kids = await el.$$('.//*'); } catch { /* no child query on this node */ }
|
|
170
|
+
for (const k of kids.slice(0, 8)) await read(k, 'descendant.');
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Assert checked/unchecked from the probed signals. The two directions are deliberately NOT
|
|
176
|
+
// symmetric, because the evidence isn't:
|
|
177
|
+
// checked → ANY credible positive signal passes. One runtime exposing the state is enough;
|
|
178
|
+
// demanding the standard attribute is what breaks custom-rendered controls.
|
|
179
|
+
// unchecked → NO signal anywhere may be positive. Strictly stronger than reading one attribute,
|
|
180
|
+
// so a visibly-ON custom toggle can't slip through as a false PASS.
|
|
181
|
+
// Polls like WDIO's own matchers do — a tap-then-assert reads the tree mid-animation.
|
|
182
|
+
// KNOWN LIMIT, stated rather than hidden: on Android a UiAutomator2-defaulted `checked="false"` is
|
|
183
|
+
// indistinguishable from a genuine false — that information is not in the tree. So `unchecked`
|
|
184
|
+
// passes on "no positive signal anywhere", which is the strongest available evidence, not proof.
|
|
185
|
+
async function __assertCheckedState(el: WebdriverIO.Element, expected: boolean, label: string): Promise<void> {
|
|
186
|
+
// Fail loud with the REAL cause when the element never resolved (mirrors check-action, #430).
|
|
187
|
+
if (!el.elementId) throw new Error((el as any).error?.message || `Element not found: ${el.selector}`);
|
|
188
|
+
let signals: { source: string; value: boolean }[] = [];
|
|
189
|
+
for (let __i = 0; __i < 10; __i++) {
|
|
190
|
+
signals = await __probeCheckedSignals(el);
|
|
191
|
+
if (signals.some(s => s.value) === expected) return;
|
|
192
|
+
await driver.pause(500);
|
|
193
|
+
}
|
|
194
|
+
const want = expected ? 'checked' : 'unchecked';
|
|
195
|
+
if (signals.length === 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Expect ${label} to be ${want} — the control publishes NO checked/selected state, on itself or any child. ` +
|
|
198
|
+
`A custom-rendered control has to publish it for any tool to read it: Flutter → wrap it in Semantics(checked: …); ` +
|
|
199
|
+
`React Native → accessibilityState={ checked: … }. Until then assert a visible consequence instead ` +
|
|
200
|
+
`(a label/text change), or tag the scenario @manual.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Expect ${label} to be ${want} — read [${signals.map(s => `${s.source} → ${s.value}`).join(', ')}]`);
|
|
204
|
+
}
|
|
119
205
|
{{#if singleAuthRole}}
|
|
120
206
|
import * as fs from 'node:fs';
|
|
121
207
|
import * as path from 'node:path';
|
|
@@ -133,7 +219,9 @@ function __authSelector(s: { type: string; value: string }): string {
|
|
|
133
219
|
case 'accessibility-id': case 'testid': case 'label': case 'placeholder': case 'text': return '~' + s.value;
|
|
134
220
|
case 'android-uiautomator': return 'android=' + s.value;
|
|
135
221
|
case 'ios-predicate': return '-ios predicate string:' + s.value;
|
|
136
|
-
|
|
222
|
+
// Appium's id strategy prepends `<appPackage>:id/` to colon-less values, which breaks bare
|
|
223
|
+
// Compose testTagsAsResourceId ids — match those exactly via UiSelector on Android instead.
|
|
224
|
+
case 'id': return (!driver.isIOS && !s.value.includes(':')) ? 'android=new UiSelector().resourceId("' + s.value + '")' : 'id=' + s.value;
|
|
137
225
|
case 'xpath': case 'locator': default: return s.value;
|
|
138
226
|
}
|
|
139
227
|
}
|
|
@@ -205,10 +293,29 @@ async function __ensureAuth(role: string): Promise<void> {
|
|
|
205
293
|
}
|
|
206
294
|
|
|
207
295
|
// ── Mode B: legacy authedMarker (present on the landing screen ⟺ logged in) ──
|
|
208
|
-
|
|
209
|
-
|
|
296
|
+
// markerTimeoutMs: contract override for slow-booting apps — a cold start that needs 10-15s+ to
|
|
297
|
+
// render the landing screen false-negatives the 6s default into "logged out" and then blocks the
|
|
298
|
+
// spec when the login form never appears.
|
|
299
|
+
const __budget = cfg.markerTimeoutMs || 6000;
|
|
300
|
+
// When the contract also names a loggedOutMarker (an element unique to the login screen), the
|
|
301
|
+
// state is decided by WHICHEVER marker renders first (alternating short probes): a slow-booting
|
|
302
|
+
// landing screen costs only its real boot time instead of gambling on one fixed timeout, and a
|
|
303
|
+
// genuine logged-out state is detected without waiting out the full authed budget first.
|
|
304
|
+
if (cfg.loggedOutMarker) {
|
|
305
|
+
let loggedOut = false;
|
|
306
|
+
const __t0 = Date.now();
|
|
307
|
+
while (Date.now() - __t0 < __budget) {
|
|
308
|
+
if (await present(cfg.authedMarker, 3000)) return; // logged in — nothing to do
|
|
309
|
+
if (await present(cfg.loggedOutMarker, 3000)) { loggedOut = true; break; }
|
|
310
|
+
}
|
|
311
|
+
if (!loggedOut) {
|
|
312
|
+
throw new Error(`@auth:${role} could not decide the login state within ${__budget}ms — neither authedMarker nor loggedOutMarker rendered. Check the markers in ${rel}.`);
|
|
313
|
+
}
|
|
314
|
+
} else if (await present(cfg.authedMarker, __budget)) {
|
|
315
|
+
return; // idempotent — already authenticated (session kept by noReset)
|
|
316
|
+
}
|
|
210
317
|
await runSteps(cfg.loginSteps);
|
|
211
|
-
if (!(await
|
|
318
|
+
if (!(await present(cfg.authedMarker, __budget))) {
|
|
212
319
|
throw new Error(`@auth:${role} login failed — verify credentials / loginSteps / authedMarker in ${rel}.`);
|
|
213
320
|
}
|
|
214
321
|
}
|
|
@@ -267,25 +374,32 @@ describe('{{featureName}}', () => {
|
|
|
267
374
|
// of cold-starting, which strands top-of-screen elements above the viewport.
|
|
268
375
|
await driver.pause(500);
|
|
269
376
|
await driver.activateApp(__APP_PKG__);
|
|
377
|
+
// Restore the NATIVE context — a prior scenario that entered a WebView (`switch to [X] frame`)
|
|
378
|
+
// without switching back would otherwise poison every following native find.
|
|
379
|
+
try { await driver.switchContext('NATIVE_APP'); } catch { /* single-context session */ }
|
|
270
380
|
// Restore portrait so a prior scenario's `rotate to landscape` can't leak into this one — the
|
|
271
381
|
// app relaunch above resets in-app state, but device orientation is OS-level and persists.
|
|
272
382
|
try { await driver.setOrientation('PORTRAIT'); } catch { /* orientation control not supported */ }
|
|
273
383
|
// Settle after a cold (re)launch: a Flutter app first renders a BLANK accessibility skeleton
|
|
274
384
|
// (nested FrameLayout/View, no content-desc) for several seconds before the real tree appears.
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
|
|
385
|
+
// So require rendered content — at least one non-empty content-desc/text — on two consecutive
|
|
386
|
+
// reads before proceeding. A generous timeout covers slow cold renders on a stressed emulator.
|
|
387
|
+
// Best-effort: a timeout never fails the scenario, it just falls through to the step's own
|
|
388
|
+
// auto-wait.
|
|
389
|
+
// "Settled" = rendered content present on TWO consecutive reads. Deliberately NOT full source
|
|
390
|
+
// equality: a UI with any looping animation (a rotating promo carousel is enough) never yields
|
|
391
|
+
// two identical snapshots, so an equality oracle starves and burns the whole timeout on every
|
|
392
|
+
// scenario. Two content-bearing reads still reject the blank first-frame skeleton (no
|
|
393
|
+
// content-desc/text at all) that a single read could false-positive on.
|
|
394
|
+
let __prevHadContent = false;
|
|
281
395
|
await driver
|
|
282
396
|
.waitUntil(
|
|
283
397
|
async () => {
|
|
284
398
|
const src = await driver.getPageSource();
|
|
285
399
|
const hasContent = /content-desc="[^"]+"|\btext="[^"]+"/.test(src);
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
return
|
|
400
|
+
const settled = hasContent && __prevHadContent;
|
|
401
|
+
__prevHadContent = hasContent;
|
|
402
|
+
return settled;
|
|
289
403
|
},
|
|
290
404
|
{ timeout: 30000, interval: 400, timeoutMsg: 'UI did not settle (no rendered content)' },
|
|
291
405
|
)
|
|
@@ -43,6 +43,10 @@ export class TemplateEngine {
|
|
|
43
43
|
return String(text).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
Handlebars.registerHelper('includes', function(text: string, search: string) {
|
|
47
|
+
return typeof text === 'string' && text.includes(search);
|
|
48
|
+
});
|
|
49
|
+
|
|
46
50
|
// Comparison helpers for conditional logic
|
|
47
51
|
Handlebars.registerHelper('eq', function(a: any, b: any) {
|
|
48
52
|
return a === b;
|
|
@@ -677,6 +677,10 @@ export class SelectorResolver {
|
|
|
677
677
|
case 'android-uiautomator':
|
|
678
678
|
case 'ios-predicate':
|
|
679
679
|
case 'id':
|
|
680
|
+
// css: only meaningful INSIDE a WebView context (hybrid screens, after `switch to [X]
|
|
681
|
+
// frame`) — the appium adapter's partial already renders it raw; without this pass-through
|
|
682
|
+
// the type fell to the placeholder strategy and compiled to an undefined selector.
|
|
683
|
+
case 'css':
|
|
680
684
|
return withExtras({
|
|
681
685
|
strategy: type,
|
|
682
686
|
value,
|
package/src/harness/audit.ts
CHANGED
|
@@ -236,7 +236,9 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
236
236
|
const ledger = viewpointLedger(viewpointPath, scenarios, featureText);
|
|
237
237
|
const negSideEffect = negativeSideEffect(scenarios);
|
|
238
238
|
const ownership = crossArtifactOwnership(screenDir, scenarios);
|
|
239
|
-
|
|
239
|
+
// @exploration scenarios are probes outside the official suite — the finding's own fix
|
|
240
|
+
// ("tag it @exploration") must actually exempt them from source tracing.
|
|
241
|
+
const unsourced = sourceBacked(scenarios.filter((s) => !s.exploration), parseSpecClauses(specPath).frs.map((f) => f.id), parseViewpointItems(viewpointPath).map((i) => i.text), viewpoints.map((v) => v.id), featureText);
|
|
240
242
|
|
|
241
243
|
// H3 — stateful-flow regression depth. For a UI flow whose scenarios mutate a cart/checkout
|
|
242
244
|
// collection, the regression dimensions (count/quantity proof · teardown · multi-source) cap the
|
|
@@ -153,7 +153,9 @@ export function manualReasonMismatches(featurePath: string): ReasonMismatch[] {
|
|
|
153
153
|
for (let k = i + 1; k < lines.length && k <= i + 16; k++) {
|
|
154
154
|
const l = lines[k].trim();
|
|
155
155
|
if (/^#/.test(l)) parts.push(l.replace(/^#+\s*/, ''));
|
|
156
|
-
|
|
156
|
+
// A blank line after collected comments ends the body block — scanning past it would
|
|
157
|
+
// swallow the NEXT section's banner comment and mis-infer the reason code from it.
|
|
158
|
+
else if (l === '') { if (parts.length) break; continue; }
|
|
157
159
|
else break;
|
|
158
160
|
}
|
|
159
161
|
// The block scanned here includes the tester PROCEDURE, so several codes can
|
package/src/harness/parse.ts
CHANGED
|
@@ -36,6 +36,7 @@ export interface ScenarioInfo {
|
|
|
36
36
|
queryRefs?: string[]; // named queries referenced by this scenario (inline `query [name]` + @query: tags)
|
|
37
37
|
apiRefs?: string[]; // named API endpoints referenced by this scenario (@api: tags)
|
|
38
38
|
requiresCaps?: string[]; // @requires:<cap> — automation-ready but needs an opt-in driver (TQ-11)
|
|
39
|
+
exploration?: boolean; // @exploration — a probe outside the official suite, exempt from source tracing
|
|
39
40
|
deferredToFlow?: boolean; // @deferred:flow — owned by a flow, not automated on this screen (H6)
|
|
40
41
|
ownedByFlow?: string; // @owned-by:<flow> — the flow that owns this deferred scenario (H6)
|
|
41
42
|
/** Ordered steps with their resolved bucket (And/But inherit) — flow handoff analysis (#569). */
|
|
@@ -253,6 +254,7 @@ function classifyScenario(sc: ParsedScenario): ScenarioInfo {
|
|
|
253
254
|
queryRefs: queryRefs.size ? [...queryRefs] : undefined,
|
|
254
255
|
apiRefs: apiRefs.size ? [...apiRefs] : undefined,
|
|
255
256
|
requiresCaps: requiresCaps.length ? requiresCaps : undefined,
|
|
257
|
+
exploration: tags.some((t) => /^@exploration\b/i.test(t)) || undefined,
|
|
256
258
|
deferredToFlow: deferredToFlow || undefined,
|
|
257
259
|
ownedByFlow,
|
|
258
260
|
};
|
|
@@ -197,8 +197,12 @@ export async function runScriptCheck(screenDir: string, screenName: string, kind
|
|
|
197
197
|
// ONLY the template form, so on the current codegen every @cases scenario was
|
|
198
198
|
// reported MISSING (+ its test EXTRA) despite compiling and running fine.
|
|
199
199
|
// Canonicalize the extracted title to the bare scenario name so both shapes
|
|
200
|
-
// (and plain titles) compare on the same footing.
|
|
201
|
-
|
|
200
|
+
// (and plain titles) compare on the same footing. Mobile/mocha specs append the
|
|
201
|
+
// tags to the title (`it('<name> [@high @spec:FR-01]')` — mocha has no tag option),
|
|
202
|
+
// so strip that suffix too or every mobile scenario reads MISSING + EXTRA (#429).
|
|
203
|
+
const canonSpecTitle = (t: string) => t
|
|
204
|
+
.replace(/\s*\[@[^\]]*\]$/, '')
|
|
205
|
+
.replace(/\s*—(\s*\$\{__row\.__label\})?$/, '');
|
|
202
206
|
const specTitleSet = new Set(specTitles.map(canonSpecTitle));
|
|
203
207
|
const expectedSet = new Set(automated.map((s) => s.name));
|
|
204
208
|
const missingInSpec = automated.filter((s) => !specTitleSet.has(s.name)).map((s) => s.name);
|
package/src/harness/sensors.ts
CHANGED
|
@@ -17,6 +17,8 @@ import { readTextFile } from './read-text';
|
|
|
17
17
|
const BUSINESS_CRITICAL_CATS = [
|
|
18
18
|
// UI commerce cores
|
|
19
19
|
'LIST', 'CART', 'PRODUCT', 'FILTER', 'CHECKOUT', 'ORDER', 'DETAIL', 'DISCOVERY', 'CATEGORY', 'BRAND', 'DUPLICATE', 'CONSISTENCY',
|
|
20
|
+
// Payment surfaces are business-core in any commerce/fintech app (JALPAY, PAYMENT, WALLET …)
|
|
21
|
+
'PAY', 'WALLET',
|
|
20
22
|
// API / DB capability cores — for an api/db suite the operation IS the business core
|
|
21
23
|
'API', 'ENDPOINT', 'CRUD', 'QUERY', 'CONTRACT', 'RESOURCE',
|
|
22
24
|
];
|
|
@@ -27,7 +29,9 @@ const BUSINESS_CRITICAL_CATS = [
|
|
|
27
29
|
const BUCKET_ORDER: Array<[string, string[]]> = [
|
|
28
30
|
['business-core', BUSINESS_CRITICAL_CATS],
|
|
29
31
|
['behavior', ['LOGIC', 'TRANSITION', 'WORKFLOW']],
|
|
30
|
-
|
|
32
|
+
// ERROR/EMPTY: error handling and empty-state guards are the same defensive family as
|
|
33
|
+
// validation — every non-trivial suite declares them, so `other` would swallow them.
|
|
34
|
+
['validation-security', ['VAL', 'SEC', 'SUB', 'AUTH', 'LOGIN', 'ERROR', 'EMPTY']],
|
|
31
35
|
['navigation', ['NAV']],
|
|
32
36
|
['presentation', ['UI', 'LAYOUT', 'RESPONSIVE', 'DISPLAY', 'SEO', 'ACCESSIBILITY', 'USABILITY', 'VISUAL']],
|
|
33
37
|
];
|
|
@@ -61,7 +61,11 @@ export function emitMobileRuntime(cwd: string, opts: MobileInitOptions): Scaffol
|
|
|
61
61
|
// appActivity is optional — leave empty when not provided so UiAutomator2
|
|
62
62
|
// auto-resolves the launcher activity from appPackage. Only pin when explicitly passed.
|
|
63
63
|
.replace('__APP_ACTIVITY__', opts.appActivity || '')
|
|
64
|
-
|
|
64
|
+
// GLOBAL replace: the template holds this placeholder twice (the connectivity self-heal
|
|
65
|
+
// helper + the 'appium:udid' capability, #615). String-form .replace would fill only the
|
|
66
|
+
// first and ship a literal '__ANDROID_UDID__' as the session udid. Do NOT globalize the
|
|
67
|
+
// others: __APP_ACTIVITY__/__IOS_VERSION__ keep a second occurrence as sentinel comparisons.
|
|
68
|
+
.replace(/__ANDROID_UDID__/g, opts.androidUdid || 'emulator-5554')
|
|
65
69
|
.replace('__IOS_BUNDLE_ID__', opts.iosBundleId || 'com.example.app')
|
|
66
70
|
.replace('__IOS_DEVICE__', opts.iosDevice || 'iPhone 16')
|
|
67
71
|
.replace('__IOS_VERSION__', opts.iosVersion || '18.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
|
|
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 (
|
|
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 (
|
|
522
|
-
//
|
|
523
|
-
|
|
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');
|