@aarwitz/tapp 0.15.0
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/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Bounded deterministic Android exploration. It intentionally mirrors the
|
|
2
|
+
// XCUITest harness's marker contract so report.js, baselines, and the CI gate do
|
|
3
|
+
// not know or care which platform produced the evidence.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { AndroidDriver, androidElementKey, isAndroidAppSnapshot } from "./android-driver.js";
|
|
7
|
+
import { semanticUiKey } from "./ui-map.js";
|
|
8
|
+
|
|
9
|
+
const ERROR_RE = /\b(something went wrong|internal server error|an error occurred|failed to load|unhandled exception|has stopped)\b/i;
|
|
10
|
+
const DESTRUCTIVE_RE = /\b(delete|remove|purchase|buy now|pay now|reset|erase|unsubscribe|sign out|log out|logout)\b/i;
|
|
11
|
+
|
|
12
|
+
function stateHash(snap) {
|
|
13
|
+
return snap.elements.map((e) => `${androidElementKey(e)}:${e.text}:${e.x},${e.y}`).join("|");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function inputDescriptors(elements) {
|
|
17
|
+
return elements.filter((e) => /EditText/i.test(e.type)).map((e) => ({
|
|
18
|
+
key: androidElementKey(e), label: e.label || e.id, secure: e.secure,
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function androidMapControls(elements) {
|
|
23
|
+
return elements.filter((element) => /EditText/i.test(element.type) || element.clickable || /Button|Tab|Switch|CheckBox/i.test(element.type))
|
|
24
|
+
.map((element) => ({
|
|
25
|
+
kind: /EditText/i.test(element.type) ? (element.secure ? "secureField" : "field")
|
|
26
|
+
: /Tab/i.test(element.type) ? "tab" : /Switch|CheckBox/i.test(element.type) ? "toggle" : "button",
|
|
27
|
+
type: element.type,
|
|
28
|
+
id: element.id || "",
|
|
29
|
+
resourceId: element.id || "",
|
|
30
|
+
accessibilityId: element.description || "",
|
|
31
|
+
label: /EditText/i.test(element.type) ? (element.label || element.description || element.id) : controlLabel(element),
|
|
32
|
+
secure: !!element.secure,
|
|
33
|
+
enabled: element.enabled !== false,
|
|
34
|
+
hittable: !!element.hittable,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function controlLabel(e) { return e.label || e.text || e.description || e.id; }
|
|
39
|
+
function isDestructive(e) {
|
|
40
|
+
return DESTRUCTIVE_RE.test(`${e.label || ""} ${e.text || ""} ${e.description || ""} ${e.id || ""}`.replace(/[_-]+/g, " "));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40, timeoutSec = 300, outDir, testEmail = "", testPassword = "", clearData = true, seedTargets = [], onProgress = () => {}, driver }) {
|
|
44
|
+
const d = driver || new AndroidDriver({ appId, serial });
|
|
45
|
+
d.appId = appId;
|
|
46
|
+
await d.ensureDevice();
|
|
47
|
+
if (apkPath) await d.install(apkPath);
|
|
48
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
49
|
+
const screenshots = path.join(outDir, "screenshots");
|
|
50
|
+
fs.mkdirSync(screenshots, { recursive: true });
|
|
51
|
+
const markersPath = path.join(outDir, "ocqa-markers.txt");
|
|
52
|
+
fs.rmSync(markersPath, { force: true });
|
|
53
|
+
const emit = (kind, payload) => fs.appendFileSync(markersPath, `OCQA_${kind}:${JSON.stringify(payload)}\n`);
|
|
54
|
+
const deadline = Date.now() + timeoutSec * 1000;
|
|
55
|
+
const tried = new Set();
|
|
56
|
+
const visited = new Map();
|
|
57
|
+
let issues = 0;
|
|
58
|
+
let actions = 0;
|
|
59
|
+
let snap = await d.launch({ clearData });
|
|
60
|
+
|
|
61
|
+
const normalizedTargets = (Array.isArray(seedTargets) ? seedTargets : []).filter((target) =>
|
|
62
|
+
target?.platform === "android" && target?.status === "planned" && target?.navigation?.status === "replayable" &&
|
|
63
|
+
target.navigation.mode === "ui-map-path" && Array.isArray(target.navigation.steps) && target.navigation.steps.length <= 8
|
|
64
|
+
).slice(0, 1);
|
|
65
|
+
|
|
66
|
+
const recordState = async (snapshot) => {
|
|
67
|
+
if (!isAndroidAppSnapshot(snapshot, appId)) return null;
|
|
68
|
+
const hash = stateHash(snapshot);
|
|
69
|
+
const screen = snapshot.screenTitle;
|
|
70
|
+
if (!visited.has(hash)) {
|
|
71
|
+
visited.set(hash, screen);
|
|
72
|
+
await d.screenshot(path.join(screenshots, `${String(visited.size).padStart(2, "0")}-${screen.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}.png`)).catch(() => {});
|
|
73
|
+
}
|
|
74
|
+
const inputs = inputDescriptors(snapshot.elements);
|
|
75
|
+
emit("STATE", { screen, hash, elements: snapshot.elements.length, action: actions, role: inputs.some((input) => input.secure) ? "login" : "app", inputs, controls: androidMapControls(snapshot.elements) });
|
|
76
|
+
return { hash, screen, inputs };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Replay at most one bounded PR target from the controlled launch state before
|
|
80
|
+
// broad exploration. Every action and resulting state uses the ordinary marker
|
|
81
|
+
// contract, and a miss remains a failed/inconclusive gate target.
|
|
82
|
+
for (const target of normalizedTargets) {
|
|
83
|
+
let failure = "";
|
|
84
|
+
let state = await recordState(snap);
|
|
85
|
+
for (const step of target.navigation.steps) {
|
|
86
|
+
if (!state || actions >= maxActions || Date.now() >= deadline) { failure = "Target path exceeded the exploration budget"; break; }
|
|
87
|
+
const action = step.action;
|
|
88
|
+
const selector = (action.selectors || []).find((item) => ["resourceId", "accessibilityId", "label"].includes(item.kind) && item.value)?.value || action.target;
|
|
89
|
+
let result;
|
|
90
|
+
if (action.type === "back") result = await d.back();
|
|
91
|
+
else result = await d.tap(selector, snap);
|
|
92
|
+
actions += 1;
|
|
93
|
+
emit("ACTION", { type: action.type, target: selector, reason: "pr_ui_map_path", step: actions, screen: state.screen, status: result.status });
|
|
94
|
+
if (result.status !== "ok") { failure = result.detail || `Observed control was not available: ${selector}`; break; }
|
|
95
|
+
const before = state;
|
|
96
|
+
snap = await d.settle();
|
|
97
|
+
state = await recordState(snap);
|
|
98
|
+
if (!state) { failure = "The UI Map path left the application foreground"; break; }
|
|
99
|
+
emit("TRANSITION", { from: before.screen, to: state.screen, action: selector, changed: before.hash !== state.hash });
|
|
100
|
+
onProgress({ action: actions, max: maxActions, states: visited.size });
|
|
101
|
+
}
|
|
102
|
+
if (!failure && semanticUiKey(snap.screenTitle) !== semanticUiKey(target.node.semanticKey || target.node.name)) {
|
|
103
|
+
failure = `Reached ${snap.screenTitle}, expected ${target.node.name}`;
|
|
104
|
+
}
|
|
105
|
+
if (failure) {
|
|
106
|
+
emit("PR_TARGET", { targetId: target.id, status: "failed", screen: snap.screenTitle, error: failure.slice(0, 160) });
|
|
107
|
+
emit("ISSUE", { type: "pr_target_unreachable", severity: "high", title: failure, screen: snap.screenTitle, step: actions });
|
|
108
|
+
issues += 1;
|
|
109
|
+
} else emit("PR_TARGET", { targetId: target.id, status: "observed", screen: snap.screenTitle });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
while (actions < maxActions && Date.now() < deadline) {
|
|
113
|
+
// UIAutomator can still describe the launcher or a system surface after
|
|
114
|
+
// Back/external navigation. Those are exploration boundaries, never nodes
|
|
115
|
+
// in the application-owned UI Map.
|
|
116
|
+
if (!isAndroidAppSnapshot(snap, appId)) break;
|
|
117
|
+
const recorded = await recordState(snap);
|
|
118
|
+
if (!recorded) break;
|
|
119
|
+
const { hash, screen, inputs } = recorded;
|
|
120
|
+
onProgress({ action: actions, max: maxActions, states: visited.size });
|
|
121
|
+
|
|
122
|
+
const visibleText = snap.elements.map((e) => e.text || e.label).filter(Boolean).join(" ");
|
|
123
|
+
if (ERROR_RE.test(visibleText)) {
|
|
124
|
+
emit("ISSUE", { type: "error_surface", severity: "high", title: "Visible error surface", screen, step: actions });
|
|
125
|
+
issues += 1;
|
|
126
|
+
}
|
|
127
|
+
if (snap.elements.filter((e) => e.hittable).length === 0) {
|
|
128
|
+
emit("ISSUE", { type: "blank_screen", severity: "high", title: "No usable controls or content", screen, step: actions });
|
|
129
|
+
issues += 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Form values change the state hash. Key field attempts by semantic screen + id,
|
|
133
|
+
// otherwise the explorer repeatedly refills the first field and never submits.
|
|
134
|
+
const unfilled = snap.elements.find((e) => /EditText/i.test(e.type)
|
|
135
|
+
&& !tried.has(`${screen}|input|${androidElementKey(e)}`));
|
|
136
|
+
if (unfilled) {
|
|
137
|
+
const key = androidElementKey(unfilled);
|
|
138
|
+
const lower = `${key} ${unfilled.label}`.toLowerCase();
|
|
139
|
+
const value = unfilled.secure || lower.includes("password") ? (testPassword || "TestPass123!")
|
|
140
|
+
: lower.includes("email") ? (testEmail || "test@example.com") : "Tapp test";
|
|
141
|
+
tried.add(`${screen}|input|${key}`);
|
|
142
|
+
const r = await d.type(key, value, snap);
|
|
143
|
+
actions += 1;
|
|
144
|
+
const valueSource = unfilled.secure || lower.includes("password") ? "test-password" : lower.includes("email") ? "test-email" : "generated-text";
|
|
145
|
+
emit("ACTION", { type: "type", target: key, valueSource, reason: "complete_form", step: actions, screen, status: r.status });
|
|
146
|
+
snap = await d.settle();
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const candidate = snap.elements.find((e) => {
|
|
151
|
+
const label = controlLabel(e);
|
|
152
|
+
return e.hittable && !/EditText/i.test(e.type) && (e.clickable || /Button|Tab/i.test(e.type))
|
|
153
|
+
&& label && !isDestructive(e) && !tried.has(`${hash}|tap|${label}`);
|
|
154
|
+
});
|
|
155
|
+
if (candidate) {
|
|
156
|
+
const target = controlLabel(candidate);
|
|
157
|
+
tried.add(`${hash}|tap|${target}`);
|
|
158
|
+
const before = hash;
|
|
159
|
+
const r = await d.tap(target, snap);
|
|
160
|
+
actions += 1;
|
|
161
|
+
snap = await d.settle();
|
|
162
|
+
const after = stateHash(snap);
|
|
163
|
+
emit("ACTION", { type: "tap", target, reason: "untried_control", step: actions, screen, status: r.status });
|
|
164
|
+
if (!isAndroidAppSnapshot(snap, appId)) break;
|
|
165
|
+
emit("TRANSITION", { from: screen, to: snap.screenTitle, action: target, changed: before !== after });
|
|
166
|
+
if (r.status === "ok" && before === after && candidate.clickable) {
|
|
167
|
+
emit("ISSUE", { type: "unresponsive_element", severity: "medium", title: `Control did not respond: ${target}`, screen, target, step: actions });
|
|
168
|
+
issues += 1;
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Exhausted this state: go back once to expose sibling paths. Stop if back
|
|
174
|
+
// cannot change the state; this is an honest coverage boundary, not a pass.
|
|
175
|
+
const backKey = `${hash}|back`;
|
|
176
|
+
if (!tried.has(backKey)) {
|
|
177
|
+
tried.add(backKey);
|
|
178
|
+
await d.back();
|
|
179
|
+
actions += 1;
|
|
180
|
+
const next = await d.settle();
|
|
181
|
+
emit("ACTION", { type: "back", target: "system back", reason: "state_exhausted", step: actions, screen });
|
|
182
|
+
if (!isAndroidAppSnapshot(next, appId)) break;
|
|
183
|
+
emit("TRANSITION", { from: screen, to: next.screenTitle, action: "back", changed: stateHash(next) !== hash });
|
|
184
|
+
if (stateHash(next) !== hash) { snap = next; continue; }
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const timedOut = Date.now() >= deadline;
|
|
190
|
+
if (timedOut) {
|
|
191
|
+
emit("ISSUE", { type: "explore_timeout", severity: "medium", title: `Exploration timed out after ${timeoutSec}s`, screen: snap.screenTitle, step: actions });
|
|
192
|
+
issues += 1;
|
|
193
|
+
}
|
|
194
|
+
emit("COMPLETE", { actions, states: visited.size, issues, screens: [...new Set(visited.values())].join(","), outcome: timedOut ? "timeout" : "complete" });
|
|
195
|
+
onProgress({ action: actions, max: maxActions, states: visited.size });
|
|
196
|
+
return { markersPath, outDir, actions, states: visited.size, issues, timedOut, seedTargets: normalizedTargets };
|
|
197
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AndroidDriver, findAndroidElement, isAndroidAppSnapshot } from "./android-driver.js";
|
|
4
|
+
import { FlowLog, flowVariables, normalizeFlowStep, substituteFlowValue } from "./flow-runtime.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT = 6000;
|
|
7
|
+
|
|
8
|
+
async function waitForExactScreen(driver, wanted, timeout) {
|
|
9
|
+
const deadline = Date.now() + timeout;
|
|
10
|
+
let latest;
|
|
11
|
+
while (Date.now() < deadline) {
|
|
12
|
+
latest = await driver.snapshot();
|
|
13
|
+
if (latest.screenTitle.toLowerCase() === wanted.toLowerCase()) return latest;
|
|
14
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
15
|
+
}
|
|
16
|
+
return latest;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function runAndroidFlow({ flow, appId, apkPath, serial, logPath, screenshotDir, driver }) {
|
|
20
|
+
const targetApp = appId || flow.app;
|
|
21
|
+
if (!targetApp) throw new Error("Android Flow needs `app:` with the application id");
|
|
22
|
+
if (logPath) fs.rmSync(logPath, { force: true });
|
|
23
|
+
const d = driver || new AndroidDriver({ appId: targetApp, serial });
|
|
24
|
+
d.appId = targetApp;
|
|
25
|
+
await d.ensureDevice();
|
|
26
|
+
if (apkPath) await d.install(apkPath);
|
|
27
|
+
let snap = await d.launch({ clearData: flow.reset === "clear" });
|
|
28
|
+
const log = new FlowLog({ logPath, flow });
|
|
29
|
+
const vars = flowVariables(flow);
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < flow.steps.length; i += 1) {
|
|
32
|
+
const raw = normalizeFlowStep(flow.steps[i]);
|
|
33
|
+
const action = raw.action;
|
|
34
|
+
const target = substituteFlowValue(raw.target, vars);
|
|
35
|
+
const value = substituteFlowValue(raw.value, vars);
|
|
36
|
+
const timeout = Number(raw.params.timeoutMs) || DEFAULT_TIMEOUT;
|
|
37
|
+
let status = "pass";
|
|
38
|
+
let detail = "";
|
|
39
|
+
try {
|
|
40
|
+
if (action === "tap") {
|
|
41
|
+
const r = await d.tap(target, snap);
|
|
42
|
+
if (r.status !== "ok") throw new Error(r.detail || `could not tap ‘${target}’`);
|
|
43
|
+
} else if (action === "type") {
|
|
44
|
+
const r = await d.type(target, value, snap);
|
|
45
|
+
if (r.status !== "ok") throw new Error(r.detail || `no field ‘${target}’ to type into`);
|
|
46
|
+
} else if (action === "swipe") {
|
|
47
|
+
await d.swipe(target || "up");
|
|
48
|
+
} else if (action === "back") {
|
|
49
|
+
await d.back();
|
|
50
|
+
} else if (action === "wait_for") {
|
|
51
|
+
const waited = await d.waitFor(target, timeout);
|
|
52
|
+
if (!waited) throw new Error(`‘${target}’ never appeared within ${timeout}ms`);
|
|
53
|
+
snap = waited;
|
|
54
|
+
} else if (action === "wait") {
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, timeout));
|
|
56
|
+
} else if (action === "assert_screen") {
|
|
57
|
+
const wanted = value || target;
|
|
58
|
+
snap = await waitForExactScreen(d, wanted, timeout);
|
|
59
|
+
if (snap.screenTitle.toLowerCase() !== wanted.toLowerCase()) throw new Error(`expected screen ‘${wanted}’, saw ‘${snap.screenTitle}’`);
|
|
60
|
+
} else if (action === "assert_exists") {
|
|
61
|
+
if (!await d.waitFor(target, timeout)) throw new Error(`‘${target}’ not found`);
|
|
62
|
+
} else if (action === "assert_absent") {
|
|
63
|
+
snap = await d.settle(1500);
|
|
64
|
+
if (findAndroidElement(snap.elements, target)) throw new Error(`‘${target}’ was present but should be absent`);
|
|
65
|
+
} else if (action === "assert_text") {
|
|
66
|
+
const of = substituteFlowValue(raw.params.of || target, vars);
|
|
67
|
+
const needle = substituteFlowValue(raw.params.contains || value, vars);
|
|
68
|
+
snap = await d.snapshot();
|
|
69
|
+
const el = findAndroidElement(snap.elements, of);
|
|
70
|
+
if (!el || !String(el.text || el.label).toLowerCase().includes(needle.toLowerCase())) throw new Error(`‘${of}’ did not contain ‘${needle}’`);
|
|
71
|
+
} else if (action === "assert_ai") {
|
|
72
|
+
status = "skip";
|
|
73
|
+
detail = "AI assertions are advisory and are not part of deterministic Android replay";
|
|
74
|
+
} else {
|
|
75
|
+
throw new Error(`unknown action ‘${action}’`);
|
|
76
|
+
}
|
|
77
|
+
if (!["wait_for", "assert_screen", "assert_exists", "assert_absent", "assert_text", "assert_ai"].includes(action)) snap = await d.settle();
|
|
78
|
+
if (action !== "assert_ai" && !isAndroidAppSnapshot(snap, targetApp)) throw new Error(`app left the foreground during ‘${action}’`);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
status = "fail";
|
|
81
|
+
detail = error.message || String(error);
|
|
82
|
+
if (screenshotDir) await d.screenshot(path.join(screenshotDir, `flow-failure-${i + 1}.png`)).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
log.step({ index: i + 1, action, target: target || value, status, detail, task: raw.task });
|
|
85
|
+
if (status === "fail" && !flow.continueOnFailure) break;
|
|
86
|
+
}
|
|
87
|
+
if (screenshotDir) await d.screenshot(path.join(screenshotDir, "flow-final.png")).catch(() => {});
|
|
88
|
+
return log.finish();
|
|
89
|
+
}
|