@aarwitz/tapp 0.17.0-rc.7 → 0.17.0-rc.8
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.
|
@@ -1518,6 +1518,8 @@ class ExplorerTests: XCTestCase {
|
|
|
1518
1518
|
if current.isEmpty || current == ph {
|
|
1519
1519
|
reportedPersistenceKeys.insert(memKey)
|
|
1520
1520
|
let t = "Entered value did not persist: '\(fieldKey)' on \(titleStr)"
|
|
1521
|
+
issues.append((type: "state_persistence", severity: "medium", title: t,
|
|
1522
|
+
desc: "Typed '\(typed)' into this field earlier in the run; after navigating away and returning, the field is empty — entered state was silently lost."))
|
|
1521
1523
|
print("OCQA_ISSUE:{\"type\":\"state_persistence\",\"severity\":\"medium\",\"title\":\"\(escapeJSON(t))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapeJSON(fieldKey))\",\"step\":\(actionCount),\"desc\":\"Typed '\(escapeJSON(typed))' into this field earlier in the run; after navigating away and returning, the field is empty — entered state was silently lost.\"}")
|
|
1522
1524
|
}
|
|
1523
1525
|
}
|
|
@@ -1679,7 +1681,13 @@ class ExplorerTests: XCTestCase {
|
|
|
1679
1681
|
|
|
1680
1682
|
// ---- Blank-screen detection ----
|
|
1681
1683
|
// Distinguish between "no a11y labels / custom UI" vs genuinely empty.
|
|
1682
|
-
|
|
1684
|
+
// A system Back/Close affordance is navigation chrome, not screen content. Treat a
|
|
1685
|
+
// destination whose only usable control is that chrome as blank too; otherwise an
|
|
1686
|
+
// EmptyView pushed by NavigationStack looks like a healthy one-control screen.
|
|
1687
|
+
let contentInteractables = interactable.filter {
|
|
1688
|
+
!isNavBackButton($0) && !isLikelyGlobalNavigation($0, screenBounds: screenBounds)
|
|
1689
|
+
}
|
|
1690
|
+
if visibleTextInventory.isEmpty && contentInteractables.isEmpty {
|
|
1683
1691
|
let blankKey = "blank:\(titleStr)"
|
|
1684
1692
|
let blankCount = (actionCounts[blankKey] ?? 0) + 1
|
|
1685
1693
|
actionCounts[blankKey] = blankCount
|
|
@@ -2273,12 +2281,21 @@ class ExplorerTests: XCTestCase {
|
|
|
2273
2281
|
// the crash itself goes unreported. app.state is non-throwing even when the app is dead.
|
|
2274
2282
|
// (Found on a real app: a login submit terminated the app; the run limped on but emitted
|
|
2275
2283
|
// no crash finding.) Try one relaunch to distinguish a hard crash from a transient exit.
|
|
2276
|
-
|
|
2277
|
-
|
|
2284
|
+
let stateAfterAction = app.state
|
|
2285
|
+
if stateAfterAction != .runningForeground {
|
|
2286
|
+
print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(stateAfterAction.rawValue)")
|
|
2287
|
+
let crashKey = "crash:\(titleStr)|\(key)"
|
|
2288
|
+
// A terminated process is already proof of an in-run crash. Relaunching it may
|
|
2289
|
+
// succeed, but that must not erase the user-visible failure that just happened.
|
|
2290
|
+
if stateAfterAction == .notRunning && !reportedIssueKeys.contains(crashKey) {
|
|
2291
|
+
reportedIssueKeys.insert(crashKey)
|
|
2292
|
+
issues.append((type: "crash", severity: "critical", title: "App crashed after \(actionType) on \(titleStr)",
|
|
2293
|
+
desc: "The app process terminated after \(actionType) '\(targetName)' on '\(titleStr)'."))
|
|
2294
|
+
print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"\(escapeJSON("App crashed after \(actionType) on \(titleStr)"))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
|
|
2295
|
+
}
|
|
2278
2296
|
app.activate()
|
|
2279
2297
|
Thread.sleep(forTimeInterval: 3.0)
|
|
2280
2298
|
if app.state != .runningForeground {
|
|
2281
|
-
let crashKey = "crash:\(titleStr)|\(key)"
|
|
2282
2299
|
if !reportedIssueKeys.contains(crashKey) {
|
|
2283
2300
|
reportedIssueKeys.insert(crashKey)
|
|
2284
2301
|
issues.append((type: "crash", severity: "critical", title: "App crashed after \(actionType) on \(titleStr)",
|
|
@@ -2382,16 +2399,28 @@ class ExplorerTests: XCTestCase {
|
|
|
2382
2399
|
|
|
2383
2400
|
// ---- App left foreground / crash detection ----
|
|
2384
2401
|
// Check both .exists and .state — external links may cause either to fail
|
|
2385
|
-
let
|
|
2402
|
+
let stateAfterDelayedChecks = app.state
|
|
2403
|
+
let appInForeground = stateAfterDelayedChecks == .runningForeground
|
|
2386
2404
|
if !appInForeground || !app.exists {
|
|
2387
|
-
print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(
|
|
2405
|
+
print("OCQA_STATE:app_left_foreground step=\(actionCount) state=\(stateAfterDelayedChecks.rawValue)")
|
|
2406
|
+
let crashKey = "crash:\(titleStr)|\(key)"
|
|
2407
|
+
if stateAfterDelayedChecks == .notRunning && !reportedIssueKeys.contains(crashKey) {
|
|
2408
|
+
reportedIssueKeys.insert(crashKey)
|
|
2409
|
+
issues.append((type: "crash", severity: "critical",
|
|
2410
|
+
title: "App crashed after \(actionType) on \(titleStr)",
|
|
2411
|
+
desc: "The app process terminated after: \(actionDesc)"))
|
|
2412
|
+
print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"\(escapeJSON("App crashed after \(actionType) on \(titleStr)"))\",\"screen\":\"\(escapedTitle)\",\"control\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
|
|
2413
|
+
}
|
|
2388
2414
|
app.activate()
|
|
2389
2415
|
Thread.sleep(forTimeInterval: 3.0)
|
|
2390
2416
|
if app.state != .runningForeground {
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2417
|
+
if !reportedIssueKeys.contains(crashKey) {
|
|
2418
|
+
reportedIssueKeys.insert(crashKey)
|
|
2419
|
+
issues.append((type: "crash", severity: "critical",
|
|
2420
|
+
title: "App not recoverable",
|
|
2421
|
+
desc: "App left foreground after: \(actionDesc)"))
|
|
2422
|
+
print("OCQA_ISSUE:{\"type\":\"crash\",\"severity\":\"critical\",\"title\":\"App not recoverable\",\"action\":\"\(escapedTarget)\",\"step\":\(actionCount)}")
|
|
2423
|
+
}
|
|
2395
2424
|
break
|
|
2396
2425
|
}
|
|
2397
2426
|
print("OCQA_STATE:app_reactivated step=\(actionCount)")
|
package/bin/tapp.js
CHANGED
|
@@ -235,6 +235,7 @@ function safeCommandUsage(verb) {
|
|
|
235
235
|
pr: "tapp pr plan [--base REF|--changed-files FILE] [--head REF] [--platform PLATFORM] [--out FILE]\ntapp pr gate PLAN [gate target/options]\ntapp pr adopt PLAN --item ID [--project-dir DIR]",
|
|
236
236
|
plan: "tapp plan show [FILE]\ntapp plan review [FILE] --approve NAME[,NAME] --reject NAME[,NAME] --defer NAME[,NAME]\ntapp plan generate|validate|promote [FILE] [options]",
|
|
237
237
|
baseline: "tapp baseline create [repo] [--platform PLATFORM] [--target NAME] [--from GATE.json] [--replace]",
|
|
238
|
+
ci: "tapp ci ...\ntapp ci install [repo] [--out FILE] [--manifest FILE] [--dry-run] [--replace]",
|
|
238
239
|
actor: "tapp actor set NAME --email-env ENV --password-env ENV [--project-dir DIR]\ntapp actor list [repo]",
|
|
239
240
|
app: "tapp app [repo] [--no-open] [--port PORT]",
|
|
240
241
|
report: "tapp report [captureId|latest]",
|
|
@@ -248,7 +249,10 @@ function safeCommandUsage(verb) {
|
|
|
248
249
|
// Safe help: `--help`/`-h` on ANY verb prints the command reference and does NOTHING else — never
|
|
249
250
|
// builds, launches, writes, or opens (ADR-0005 manual-testing requirement). `ci` keeps its own
|
|
250
251
|
// richer `--help` (a safe usage print in ci-gate.sh); help/version don't need interception.
|
|
251
|
-
|
|
252
|
+
const safeHelpRequested = (rest.includes("--help") || rest.includes("-h"))
|
|
253
|
+
&& !["help", "version", "--version", "-v"].includes(command)
|
|
254
|
+
&& (command !== "ci" || rest[0] === "install");
|
|
255
|
+
if (safeHelpRequested) {
|
|
252
256
|
console.log(`Usage:\n ${safeCommandUsage(command).replaceAll("\n", "\n ")}\n\nℹ️ --help never builds, launches, writes, or opens. Full command reference:\n`);
|
|
253
257
|
command = "help";
|
|
254
258
|
rest = [];
|
|
@@ -282,6 +282,19 @@ export class AndroidDriver {
|
|
|
282
282
|
return { screenTitle: detectAndroidScreen(elements, activity), elements, activity, xml };
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
async observeActivityTransition(beforeActivity, timeoutMs = 700) {
|
|
286
|
+
const initial = String(beforeActivity || "");
|
|
287
|
+
if (!initial) return false;
|
|
288
|
+
const deadline = Date.now() + timeoutMs;
|
|
289
|
+
do {
|
|
290
|
+
const current = await this.currentActivity();
|
|
291
|
+
if (current && current !== initial) return true;
|
|
292
|
+
if (Date.now() >= deadline) break;
|
|
293
|
+
await sleep(60);
|
|
294
|
+
} while (Date.now() < deadline);
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
285
298
|
async screenshot(filePath) {
|
|
286
299
|
const r = await this.adb(["exec-out", "screencap", "-p"], { encoding: "buffer", timeout: 30_000, maxBuffer: 32 * 1024 * 1024 });
|
|
287
300
|
if (r.code !== 0 || !r.stdout?.length) throw new Error("Android screenshot failed");
|
|
@@ -57,8 +57,9 @@ export function isAndroidBlankSnapshot(snapshot) {
|
|
|
57
57
|
return !meaningful && !interactive;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", clearData = true, seedTargets = [], onProgress = () => {}, driver }) {
|
|
60
|
+
export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", clearData = true, seedTargets = [], onProgress = () => {}, driver, screenshotDelayMs }) {
|
|
61
61
|
const d = driver || new AndroidDriver({ appId, serial });
|
|
62
|
+
const visualSettleMs = Number.isFinite(screenshotDelayMs) ? Math.max(0, screenshotDelayMs) : (driver ? 0 : 350);
|
|
62
63
|
d.appId = appId;
|
|
63
64
|
await d.ensureDevice();
|
|
64
65
|
if (apkPath) await d.install(apkPath);
|
|
@@ -112,6 +113,10 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
|
|
|
112
113
|
const screen = snapshot.screenTitle;
|
|
113
114
|
if (!visited.has(hash)) {
|
|
114
115
|
visited.set(hash, screen);
|
|
116
|
+
// UIAutomator can expose a fully populated hierarchy a fraction before
|
|
117
|
+
// SurfaceFlinger composites the first app frame. Give real devices one
|
|
118
|
+
// bounded draw interval so retained PNG evidence matches the tree.
|
|
119
|
+
if (visualSettleMs) await sleep(visualSettleMs);
|
|
115
120
|
await d.screenshot(path.join(screenshots, `${String(visited.size).padStart(2, "0")}-${screen.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.png`)).catch(() => {});
|
|
116
121
|
}
|
|
117
122
|
const inputs = inputDescriptors(snapshot.elements);
|
|
@@ -209,7 +214,14 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
|
|
|
209
214
|
const before = hash;
|
|
210
215
|
const r = await d.tap(target, snap);
|
|
211
216
|
actions += 1;
|
|
212
|
-
|
|
217
|
+
// UIAutomator can miss a short-lived Activity that opens and cleanly
|
|
218
|
+
// returns before its next hierarchy dump. Observe the cheaper Activity
|
|
219
|
+
// signal in parallel so a real transient response is not called dead.
|
|
220
|
+
const activityEffect = r.status === "ok" && typeof d.observeActivityTransition === "function"
|
|
221
|
+
? d.observeActivityTransition(snap.activity)
|
|
222
|
+
: Promise.resolve(false);
|
|
223
|
+
const [settledSnap, activityEffectObserved] = await Promise.all([d.settle(), activityEffect]);
|
|
224
|
+
snap = settledSnap;
|
|
213
225
|
const after = stateHash(snap);
|
|
214
226
|
emit("ACTION", { type: loginSubmit ? "login_submit" : "tap", target, reason: "untried_control", step: actions, screen, status: r.status });
|
|
215
227
|
if (!isAndroidAppSnapshot(snap, appId)) {
|
|
@@ -221,7 +233,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
|
|
|
221
233
|
if (r.status === "ok" && authFailed) {
|
|
222
234
|
emit("ISSUE", { type: "auth_failed", severity: "high", title: "Sign-in attempt remained on the login screen", screen, target, step: actions });
|
|
223
235
|
issues += 1;
|
|
224
|
-
} else if (r.status === "ok" && before === after && candidate.clickable) {
|
|
236
|
+
} else if (r.status === "ok" && before === after && candidate.clickable && !activityEffectObserved) {
|
|
225
237
|
emit("ISSUE", { type: "unresponsive_element", severity: "medium", title: `Control did not respond: ${target}`, screen, target, step: actions });
|
|
226
238
|
issues += 1;
|
|
227
239
|
}
|
package/package.json
CHANGED
package/scripts/flow_ai_judge.py
CHANGED
|
@@ -26,7 +26,7 @@ def _ctx():
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
SSL_CTX = _ctx()
|
|
29
|
-
MODEL = os.environ.get("TAPP_VISION_MODEL"
|
|
29
|
+
MODEL = os.environ.get("TAPP_VISION_MODEL", "claude-haiku-4-5-20251001")
|
|
30
30
|
SYSTEM = (
|
|
31
31
|
"You are a QA test oracle. You are shown one iOS app screenshot and a CLAIM the test asserts about "
|
|
32
32
|
"it. Decide if the claim is TRUE of what is actually visible. Be strict and literal — only pass if "
|