@ahnseungchan/device-farm 0.0.0-stage → 0.1.1
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/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +297 -2
- package/dist/bin.js +79 -0
- package/dist/chaos-CIB7G5X5.js +453 -0
- package/dist/chunk-3GCH6TN3.js +86 -0
- package/dist/chunk-4UOXXMXL.js +74 -0
- package/dist/chunk-7OJDY276.js +16 -0
- package/dist/chunk-AVECSGEH.js +1462 -0
- package/dist/chunk-AXQNUPGC.js +416 -0
- package/dist/chunk-F4TMB4WY.js +54 -0
- package/dist/chunk-F6PZOBU6.js +167 -0
- package/dist/chunk-JD2L6T7E.js +56 -0
- package/dist/chunk-JZDYF3HL.js +169 -0
- package/dist/chunk-NJNTEWXE.js +134 -0
- package/dist/chunk-NLX3V7ZJ.js +134 -0
- package/dist/chunk-OUNLQI2N.js +34 -0
- package/dist/chunk-P2M55S53.js +110 -0
- package/dist/chunk-PLMSQ5KF.js +173 -0
- package/dist/chunk-PYZWMVJ6.js +302 -0
- package/dist/chunk-PZZQWRSU.js +283 -0
- package/dist/chunk-Q6CXF3NJ.js +102 -0
- package/dist/chunk-S6GHOP4B.js +111 -0
- package/dist/chunk-SWW42KTA.js +223 -0
- package/dist/chunk-UAHWEUDI.js +68 -0
- package/dist/chunk-VGPCTMUA.js +258 -0
- package/dist/chunk-WTTCWFOX.js +161 -0
- package/dist/chunk-ZE2GGXFO.js +169 -0
- package/dist/device-explore-PHS6SVEJ.js +64 -0
- package/dist/device-run-XPNRYXEL.js +60 -0
- package/dist/explore-allowlist-F44YQDVK.js +44 -0
- package/dist/index.d.ts +192 -0
- package/dist/index.js +24 -0
- package/dist/init-QFFEOJWY.js +29 -0
- package/dist/policy-check-AUWGFNHZ.js +75 -0
- package/dist/policy-watch-QPHI2P7Z.js +66 -0
- package/dist/qa-run-5LLQMLAM.js +59 -0
- package/dist/record-EGHVQFR7.js +152 -0
- package/dist/serve-XT32MAEN.js +1687 -0
- package/dist/server-3NM7ODUG.js +251 -0
- package/dist/templates/env.example +48 -0
- package/dist/templates/farm.config.ts +104 -0
- package/dist/web/assets/index-BCuSKs27.js +17 -0
- package/dist/web/assets/index-C3is0x-E.css +1 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +95 -4
|
@@ -0,0 +1,1462 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hasBugCandidate
|
|
3
|
+
} from "./chunk-UAHWEUDI.js";
|
|
4
|
+
import {
|
|
5
|
+
decideRequest,
|
|
6
|
+
defaultAllowlistFile,
|
|
7
|
+
isMutatingMethod,
|
|
8
|
+
parseAllowlist,
|
|
9
|
+
readMethods
|
|
10
|
+
} from "./chunk-Q6CXF3NJ.js";
|
|
11
|
+
import {
|
|
12
|
+
checkButtons,
|
|
13
|
+
fillScript,
|
|
14
|
+
hasForbiddenWord,
|
|
15
|
+
linkClickScript,
|
|
16
|
+
listActionsScript,
|
|
17
|
+
stepLabels,
|
|
18
|
+
stepTarget,
|
|
19
|
+
summarizeButtons
|
|
20
|
+
} from "./chunk-SWW42KTA.js";
|
|
21
|
+
import {
|
|
22
|
+
installAndLaunch,
|
|
23
|
+
installSourceOf
|
|
24
|
+
} from "./chunk-JZDYF3HL.js";
|
|
25
|
+
import {
|
|
26
|
+
execFileText
|
|
27
|
+
} from "./chunk-AXQNUPGC.js";
|
|
28
|
+
import {
|
|
29
|
+
isLoginPath,
|
|
30
|
+
sleep,
|
|
31
|
+
waitForScreen
|
|
32
|
+
} from "./chunk-NLX3V7ZJ.js";
|
|
33
|
+
import {
|
|
34
|
+
appIdFor
|
|
35
|
+
} from "./chunk-NJNTEWXE.js";
|
|
36
|
+
|
|
37
|
+
// packages/engine/src/explore/run.ts
|
|
38
|
+
import path6 from "path";
|
|
39
|
+
|
|
40
|
+
// packages/core/src/device/types.ts
|
|
41
|
+
var deviceConcurrency = 2;
|
|
42
|
+
|
|
43
|
+
// packages/core/src/explore/guard.ts
|
|
44
|
+
var allRequests = "**/*";
|
|
45
|
+
var blockedErrorCode = "blockedbyclient";
|
|
46
|
+
function originOf(url) {
|
|
47
|
+
if (!url) return null;
|
|
48
|
+
try {
|
|
49
|
+
return new URL(url).origin;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function isExternal(url, webOrigin, apiOrigin) {
|
|
55
|
+
const target = originOf(url);
|
|
56
|
+
if (target === null || target === "null") return true;
|
|
57
|
+
const internal = [originOf(webOrigin), originOf(apiOrigin)].filter((origin) => origin !== null);
|
|
58
|
+
return !internal.includes(target);
|
|
59
|
+
}
|
|
60
|
+
function guardPath(url, webOrigin, apiOrigin) {
|
|
61
|
+
const parsed = new URL(url);
|
|
62
|
+
if (isExternal(url, webOrigin, apiOrigin)) return `${parsed.origin}${parsed.pathname}`;
|
|
63
|
+
return parsed.pathname;
|
|
64
|
+
}
|
|
65
|
+
function guardTargetOf(app, webOrigin) {
|
|
66
|
+
return { webOrigin, apiOrigin: app.apiOrigin || void 0, forbiddenApiPatterns: app.guard.forbiddenApiPatterns, pushTokenRequests: app.guard.pushTokenRequests };
|
|
67
|
+
}
|
|
68
|
+
function decide(route, target, allowlist) {
|
|
69
|
+
const request = route.request();
|
|
70
|
+
const method = request.method().toUpperCase();
|
|
71
|
+
try {
|
|
72
|
+
const path8 = guardPath(request.url(), target.webOrigin, target.apiOrigin);
|
|
73
|
+
const decision = decideRequest(method, path8, allowlist, target);
|
|
74
|
+
return decision.allow ? null : { method, path: path8, reason: decision.reason, at: Date.now() };
|
|
75
|
+
} catch {
|
|
76
|
+
if (readMethods.includes(method)) return null;
|
|
77
|
+
return { method, path: request.url(), reason: "unknown", at: Date.now() };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function guardAndroidPage(page, target, allowlist, onBlocked) {
|
|
81
|
+
const handler = async (route) => {
|
|
82
|
+
const blocked = decide(route, target, allowlist);
|
|
83
|
+
if (!blocked) {
|
|
84
|
+
await route.continue();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
await route.abort(blockedErrorCode);
|
|
88
|
+
onBlocked(blocked);
|
|
89
|
+
};
|
|
90
|
+
await page.route(allRequests, handler);
|
|
91
|
+
return () => page.unroute(allRequests, handler);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// packages/core/src/logs/network.ts
|
|
95
|
+
function formatNetworkLog(method, status, url, reason = "") {
|
|
96
|
+
return [method.toUpperCase(), String(status), url, reason].filter(Boolean).join(" ").trim();
|
|
97
|
+
}
|
|
98
|
+
var networkLogLine = /^([A-Za-z]+) (\d{1,3}) (\S+)(?: .*)?$/;
|
|
99
|
+
function parseNetworkLog(text) {
|
|
100
|
+
const match = networkLogLine.exec(text.trim());
|
|
101
|
+
if (!match) return null;
|
|
102
|
+
return { method: match[1].toUpperCase(), status: Number(match[2]), url: match[3] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// packages/core/src/explore/probe.ts
|
|
106
|
+
var maxOffscreen = 20;
|
|
107
|
+
var layoutSnapshotScript = `(() => {
|
|
108
|
+
const vw = document.documentElement.clientWidth;
|
|
109
|
+
const vh = window.innerHeight;
|
|
110
|
+
const root = document.documentElement;
|
|
111
|
+
const body = document.body;
|
|
112
|
+
const isVisible = (el, rect) => {
|
|
113
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
114
|
+
const style = getComputedStyle(el);
|
|
115
|
+
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
|
|
116
|
+
};
|
|
117
|
+
const isClipped = (el) => {
|
|
118
|
+
for (let node = el.parentElement; node && node !== body && node !== root; node = node.parentElement) {
|
|
119
|
+
if (getComputedStyle(node).overflowX !== "visible") return true;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
};
|
|
123
|
+
const labelOf = (el) => {
|
|
124
|
+
const name = el.getAttribute("aria-label") || (el.innerText || "").trim().slice(0, 40);
|
|
125
|
+
const id = el.id ? "#" + el.id : "";
|
|
126
|
+
return el.tagName.toLowerCase() + id + (name ? " " + JSON.stringify(name) : "");
|
|
127
|
+
};
|
|
128
|
+
const probe = document.createElement("div");
|
|
129
|
+
probe.style.cssText = "position:fixed;left:0;bottom:0;width:0;height:0;visibility:hidden;padding-bottom:env(safe-area-inset-bottom);";
|
|
130
|
+
body.appendChild(probe);
|
|
131
|
+
const safeAreaBottom = parseFloat(getComputedStyle(probe).paddingBottom) || 0;
|
|
132
|
+
probe.remove();
|
|
133
|
+
const offscreen = [];
|
|
134
|
+
const bottomFixed = [];
|
|
135
|
+
for (const el of Array.from(body.querySelectorAll("*"))) {
|
|
136
|
+
const rect = el.getBoundingClientRect();
|
|
137
|
+
if (!isVisible(el, rect)) continue;
|
|
138
|
+
if (offscreen.length < ${maxOffscreen} && (rect.right > vw + 1 || rect.left < -1) && !isClipped(el)) {
|
|
139
|
+
offscreen.push({ label: labelOf(el), left: rect.left, right: rect.right });
|
|
140
|
+
}
|
|
141
|
+
const position = getComputedStyle(el).position;
|
|
142
|
+
if ((position === "fixed" || position === "sticky") && rect.top > vh / 2) {
|
|
143
|
+
bottomFixed.push({ label: labelOf(el), bottom: rect.bottom });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
viewportWidth: vw,
|
|
148
|
+
viewportHeight: vh,
|
|
149
|
+
scrollWidth: root.scrollWidth,
|
|
150
|
+
bodyTextLength: (body.innerText || "").trim().length,
|
|
151
|
+
imageCount: body.querySelectorAll("img, svg, canvas, video, picture").length,
|
|
152
|
+
offscreen,
|
|
153
|
+
bottomFixed,
|
|
154
|
+
safeAreaBottom,
|
|
155
|
+
};
|
|
156
|
+
})()`;
|
|
157
|
+
function layoutIssues(snapshot) {
|
|
158
|
+
const issues = [];
|
|
159
|
+
if (snapshot.scrollWidth > snapshot.viewportWidth) {
|
|
160
|
+
issues.push({ kind: "overflow", message: `\uAC00\uB85C \uB118\uCE68: scrollWidth ${snapshot.scrollWidth} > ${snapshot.viewportWidth}` });
|
|
161
|
+
}
|
|
162
|
+
for (const item of snapshot.offscreen.slice(0, maxOffscreen)) {
|
|
163
|
+
issues.push({ kind: "offscreen", message: `\uD654\uBA74 \uBC16 \uC694\uC18C ${item.label} (left ${item.left}, right ${item.right})` });
|
|
164
|
+
}
|
|
165
|
+
if (snapshot.bodyTextLength === 0 && snapshot.imageCount === 0) {
|
|
166
|
+
issues.push({ kind: "blank", message: "\uBE48 \uD654\uBA74: \uAE00\uC790\uC640 \uC774\uBBF8\uC9C0\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." });
|
|
167
|
+
}
|
|
168
|
+
const limit = snapshot.viewportHeight - snapshot.safeAreaBottom;
|
|
169
|
+
for (const item of snapshot.bottomFixed) {
|
|
170
|
+
if (item.bottom > limit) {
|
|
171
|
+
issues.push({ kind: "bottom-covered", message: `\uD558\uB2E8 \uAC00\uB9BC ${item.label} (bottom ${item.bottom} > ${limit})` });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return issues;
|
|
175
|
+
}
|
|
176
|
+
var alwaysBadStatuses = [401, 403, 404, 405];
|
|
177
|
+
var writeValidationStatuses = [400, 409, 422];
|
|
178
|
+
var serverErrorMin = 500;
|
|
179
|
+
function includesStatus(list, status) {
|
|
180
|
+
return list.includes(status);
|
|
181
|
+
}
|
|
182
|
+
function networkIssue(entry) {
|
|
183
|
+
const message = `${entry.method.toUpperCase()} ${entry.status} ${entry.url}`;
|
|
184
|
+
if (entry.status >= serverErrorMin || includesStatus(alwaysBadStatuses, entry.status)) return { kind: "network", message };
|
|
185
|
+
if (!includesStatus(writeValidationStatuses, entry.status)) return null;
|
|
186
|
+
return entry.allowedWrite ? null : { kind: "network", message };
|
|
187
|
+
}
|
|
188
|
+
function pathnameOf(url) {
|
|
189
|
+
try {
|
|
190
|
+
return new URL(url).pathname;
|
|
191
|
+
} catch {
|
|
192
|
+
return url;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function isAllowedWrite(method, url, allowlist, rules) {
|
|
196
|
+
if (!allowlist || !isMutatingMethod(method)) return false;
|
|
197
|
+
return decideRequest(method, pathnameOf(url), allowlist, rules).allow;
|
|
198
|
+
}
|
|
199
|
+
function logIssue(entry, allowlist, rules) {
|
|
200
|
+
if (entry.source === "console") return { kind: "console", message: entry.text };
|
|
201
|
+
if (entry.source === "crash") return { kind: "crash", message: entry.text };
|
|
202
|
+
if (entry.source !== "network") return null;
|
|
203
|
+
const parsed = parseNetworkLog(entry.text);
|
|
204
|
+
if (!parsed) return null;
|
|
205
|
+
return networkIssue({ ...parsed, allowedWrite: isAllowedWrite(parsed.method, parsed.url, allowlist, rules) });
|
|
206
|
+
}
|
|
207
|
+
async function pageIssues(driver) {
|
|
208
|
+
const issues = [];
|
|
209
|
+
const path8 = await driver.currentPath();
|
|
210
|
+
const { loginPaths, appErrorText } = driver.app;
|
|
211
|
+
if (isLoginPath(path8, loginPaths)) issues.push({ kind: "login-redirect", message: `\uB85C\uADF8\uC778 \uD654\uBA74\uC73C\uB85C \uC774\uB3D9: ${path8}` });
|
|
212
|
+
if (await driver.hasText(appErrorText)) issues.push({ kind: "app-error", message: appErrorText });
|
|
213
|
+
const snapshot = await driver.evaluate(layoutSnapshotScript);
|
|
214
|
+
return [...issues, ...layoutIssues(snapshot)];
|
|
215
|
+
}
|
|
216
|
+
async function probeScreen(driver, ring, since, allowlist) {
|
|
217
|
+
const screen = await pageIssues(driver);
|
|
218
|
+
const logs = ring.since(since).map((entry) => logIssue(entry, allowlist, driver.app.guard));
|
|
219
|
+
return [...screen, ...logs.filter((issue) => issue !== null)];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// packages/core/src/logs/redact.ts
|
|
223
|
+
var patterns = [
|
|
224
|
+
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi,
|
|
225
|
+
/010[-.\s]?\d{3,4}[-.\s]?\d{4}/g,
|
|
226
|
+
/Bearer\s+\S+/gi,
|
|
227
|
+
/token=[^&\s]+/gi,
|
|
228
|
+
/cookie\s*[:=]\s*\S+/gi
|
|
229
|
+
];
|
|
230
|
+
function redactText(value) {
|
|
231
|
+
return patterns.reduce((text, pattern) => text.replace(pattern, "\uAC00\uB9BC"), value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// packages/core/src/logs/ring.ts
|
|
235
|
+
var logWindowMs = 3e4;
|
|
236
|
+
var LogRing = class {
|
|
237
|
+
constructor(windowMs = logWindowMs, now = Date.now) {
|
|
238
|
+
this.windowMs = windowMs;
|
|
239
|
+
this.now = now;
|
|
240
|
+
}
|
|
241
|
+
windowMs;
|
|
242
|
+
now;
|
|
243
|
+
entries = [];
|
|
244
|
+
push(entry) {
|
|
245
|
+
const at = this.now();
|
|
246
|
+
this.entries.push({ ...entry, at, text: redactText(entry.text) });
|
|
247
|
+
this.entries = this.entries.filter((item) => item.at >= at - this.windowMs);
|
|
248
|
+
}
|
|
249
|
+
around(at) {
|
|
250
|
+
return this.entries.filter((item) => item.at >= at - this.windowMs && item.at <= at + this.windowMs);
|
|
251
|
+
}
|
|
252
|
+
since(at) {
|
|
253
|
+
return this.entries.filter((item) => item.at >= at);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
var FrameRing = class {
|
|
257
|
+
constructor(windowMs = logWindowMs) {
|
|
258
|
+
this.windowMs = windowMs;
|
|
259
|
+
}
|
|
260
|
+
windowMs;
|
|
261
|
+
items = [];
|
|
262
|
+
push(frame, at, key = true) {
|
|
263
|
+
this.items.push({ at, frame, key });
|
|
264
|
+
const kept = this.items.filter((item) => item.at >= at - this.windowMs);
|
|
265
|
+
const firstKey = kept.findIndex((item) => item.key);
|
|
266
|
+
this.items = firstKey < 0 ? [] : kept.slice(firstKey);
|
|
267
|
+
}
|
|
268
|
+
frames() {
|
|
269
|
+
return [...this.items];
|
|
270
|
+
}
|
|
271
|
+
clear() {
|
|
272
|
+
this.items = [];
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// packages/engine/src/results.ts
|
|
277
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
|
|
278
|
+
import { tmpdir } from "os";
|
|
279
|
+
import path from "path";
|
|
280
|
+
var devicesRoot = path.resolve("test-results/devices");
|
|
281
|
+
var retentionDays = 30;
|
|
282
|
+
var restartMessage = "\uC11C\uBC84 \uC7AC\uC2DC\uC791\uC73C\uB85C \uC911\uB2E8\uB428";
|
|
283
|
+
var dayMs = 24 * 60 * 6e4;
|
|
284
|
+
var runFile = "run.json";
|
|
285
|
+
var fallbackFps = 10;
|
|
286
|
+
function runPath(root, runId) {
|
|
287
|
+
return path.join(root, runId, runFile);
|
|
288
|
+
}
|
|
289
|
+
function writeRun(run, root = devicesRoot) {
|
|
290
|
+
const file = runPath(root, run.runId);
|
|
291
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
292
|
+
const temp = `${file}.part`;
|
|
293
|
+
writeFileSync(temp, JSON.stringify(run, null, 2));
|
|
294
|
+
renameSync(temp, file);
|
|
295
|
+
}
|
|
296
|
+
function isDeviceRun(value) {
|
|
297
|
+
if (typeof value !== "object" || value === null) return false;
|
|
298
|
+
const run = value;
|
|
299
|
+
return typeof run.runId === "string" && typeof run.startedAt === "string" && Array.isArray(run.outcomes);
|
|
300
|
+
}
|
|
301
|
+
function readRun(file) {
|
|
302
|
+
try {
|
|
303
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
304
|
+
return isDeviceRun(parsed) ? parsed : null;
|
|
305
|
+
} catch {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function runDirs(root) {
|
|
310
|
+
if (!existsSync(root)) return [];
|
|
311
|
+
return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => path.join(root, entry.name));
|
|
312
|
+
}
|
|
313
|
+
function readRuns(root) {
|
|
314
|
+
return runDirs(root).flatMap((dir) => readRun(path.join(dir, runFile)) ?? []);
|
|
315
|
+
}
|
|
316
|
+
function readLatestRun(root = devicesRoot) {
|
|
317
|
+
const runs = readRuns(root).sort((left, right) => left.startedAt.localeCompare(right.startedAt));
|
|
318
|
+
return runs.at(-1) ?? null;
|
|
319
|
+
}
|
|
320
|
+
function closeUnfinishedRun(root = devicesRoot, now = Date.now) {
|
|
321
|
+
const unfinished = readRuns(root).filter((run) => run.finishedAt === null);
|
|
322
|
+
for (const run of unfinished) {
|
|
323
|
+
for (const outcome of run.outcomes) {
|
|
324
|
+
if (outcome.status !== "running") continue;
|
|
325
|
+
outcome.status = "skipped";
|
|
326
|
+
outcome.message = restartMessage;
|
|
327
|
+
}
|
|
328
|
+
run.finishedAt = new Date(now()).toISOString();
|
|
329
|
+
writeRun(run, root);
|
|
330
|
+
}
|
|
331
|
+
return unfinished.length > 0;
|
|
332
|
+
}
|
|
333
|
+
function isJpeg(frame) {
|
|
334
|
+
return frame[0] === 255 && frame[1] === 216;
|
|
335
|
+
}
|
|
336
|
+
function framesPerSecond(frames) {
|
|
337
|
+
const first = frames[0]?.at ?? 0;
|
|
338
|
+
const last = frames.at(-1)?.at ?? 0;
|
|
339
|
+
const seconds = (last - first) / 1e3;
|
|
340
|
+
return seconds > 0 ? Math.max(1, Math.round(frames.length / seconds)) : fallbackFps;
|
|
341
|
+
}
|
|
342
|
+
function ffmpegEncodeVideo(exec = execFileText) {
|
|
343
|
+
return async (frames, outFile) => {
|
|
344
|
+
const dir = mkdtempSync(path.join(tmpdir(), "bundle-video-"));
|
|
345
|
+
const format = isJpeg(frames[0].frame) ? "mjpeg" : "h264";
|
|
346
|
+
const input = path.join(dir, `frames.${format}`);
|
|
347
|
+
try {
|
|
348
|
+
writeFileSync(input, Buffer.concat(frames.map((item) => item.frame)));
|
|
349
|
+
const fps = String(framesPerSecond(frames));
|
|
350
|
+
await exec("ffmpeg", ["-y", "-loglevel", "error", "-f", format, "-framerate", fps, "-i", input, "-c:v", "libx264", "-pix_fmt", "yuv420p", outFile]);
|
|
351
|
+
} finally {
|
|
352
|
+
rmSync(dir, { recursive: true, force: true });
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
var defaultBundleDeps = { encodeVideo: ffmpegEncodeVideo() };
|
|
357
|
+
async function writeFailureBundle(dir, input, deps = defaultBundleDeps) {
|
|
358
|
+
mkdirSync(dir, { recursive: true });
|
|
359
|
+
writeFileSync(path.join(dir, "logs.jsonl"), input.logs.map((entry) => JSON.stringify(entry)).join("\n") + (input.logs.length > 0 ? "\n" : ""));
|
|
360
|
+
writeFileSync(path.join(dir, "screenshot.png"), input.screenshot);
|
|
361
|
+
if (input.baseline) {
|
|
362
|
+
writeFileSync(path.join(dir, "baseline.png"), input.baseline.baseline);
|
|
363
|
+
writeFileSync(path.join(dir, "diff.png"), input.baseline.diff);
|
|
364
|
+
}
|
|
365
|
+
if (input.frames.length > 0) await deps.encodeVideo(input.frames, path.join(dir, "video.mp4"));
|
|
366
|
+
}
|
|
367
|
+
function runStartedAt(dir) {
|
|
368
|
+
const run = readRun(path.join(dir, runFile));
|
|
369
|
+
const started = run ? Date.parse(run.startedAt) : Number.NaN;
|
|
370
|
+
return Number.isNaN(started) ? statSync(dir).mtimeMs : started;
|
|
371
|
+
}
|
|
372
|
+
function pruneRuns(root, now, days = retentionDays) {
|
|
373
|
+
const cutoff = now - days * dayMs;
|
|
374
|
+
const expired = runDirs(root).filter((dir) => runStartedAt(dir) < cutoff);
|
|
375
|
+
for (const dir of expired) rmSync(dir, { recursive: true, force: true });
|
|
376
|
+
return expired.length;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// packages/engine/src/run.ts
|
|
380
|
+
import { randomBytes } from "crypto";
|
|
381
|
+
import path4 from "path";
|
|
382
|
+
|
|
383
|
+
// packages/core/src/flows/run.ts
|
|
384
|
+
var flowStepWaitMs = 1e4;
|
|
385
|
+
var clickSettleMs = 2e3;
|
|
386
|
+
async function runStep(driver, step, waitMs, waitForScreen2) {
|
|
387
|
+
if (step.type === "home") {
|
|
388
|
+
if (!driver.goHome) throw new Error("\uC774 \uB4DC\uB77C\uC774\uBC84\uB294 \uD648 \uD0ED\uC73C\uB85C \uAC08 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
389
|
+
await driver.goHome();
|
|
390
|
+
const ready = await waitForScreen2(driver, driver.app.homePath);
|
|
391
|
+
if (!ready.ok) throw new Error(ready.message);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (step.type === "click") {
|
|
395
|
+
await driver.currentPath().then((path8) => waitForScreen2(driver, path8)).catch(() => void 0);
|
|
396
|
+
await driver.clickButton(step.target.name, step.target.role, step.target.match);
|
|
397
|
+
await sleep(clickSettleMs);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
return driver.expectText(step.text, waitMs);
|
|
401
|
+
}
|
|
402
|
+
function withHome(flow) {
|
|
403
|
+
return [{ type: "home" }, ...flow.steps];
|
|
404
|
+
}
|
|
405
|
+
async function runFlow(driver, flow, options = {}) {
|
|
406
|
+
const { now = Date.now, waitMs = flowStepWaitMs, waitForScreen: waitForScreen2 = (target, path8) => waitForScreen(target, path8), onStep } = options;
|
|
407
|
+
const steps = [];
|
|
408
|
+
for (const [index, step] of withHome(flow).entries()) {
|
|
409
|
+
onStep?.(index);
|
|
410
|
+
const started = now();
|
|
411
|
+
const base = { index, type: step.type, target: stepTarget(step, driver.app.homeTabName) };
|
|
412
|
+
try {
|
|
413
|
+
await runStep(driver, step, waitMs, waitForScreen2);
|
|
414
|
+
steps.push({ ...base, status: "passed", durationMs: now() - started });
|
|
415
|
+
} catch (error) {
|
|
416
|
+
steps.push({ ...base, status: "failed", durationMs: now() - started });
|
|
417
|
+
const reason = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
418
|
+
return { status: "failed", steps, message: `${index + 1}\uB2E8\uACC4 ${stepLabels[step.type]} \u300C${base.target}\u300D \uC2E4\uD328: ${reason}` };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return { status: "passed", steps };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// packages/engine/src/check.ts
|
|
425
|
+
import path3 from "path";
|
|
426
|
+
import { PNG as PNG2 } from "pngjs";
|
|
427
|
+
|
|
428
|
+
// packages/engine/src/baseline/store.ts
|
|
429
|
+
import { createHash } from "crypto";
|
|
430
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
431
|
+
import path2 from "path";
|
|
432
|
+
import { PNG } from "pngjs";
|
|
433
|
+
|
|
434
|
+
// packages/core/src/baseline/diff.ts
|
|
435
|
+
var channelTolerance = 16;
|
|
436
|
+
var failRatio = 0.02;
|
|
437
|
+
var diffMessage = "\uAE30\uC900 \uD654\uBA74\uACFC \uB2E4\uB985\uB2C8\uB2E4.";
|
|
438
|
+
var sizeMessage = "\uAE30\uC900 \uD654\uBA74\uACFC \uD06C\uAE30\uAC00 \uB2E4\uB985\uB2C8\uB2E4.";
|
|
439
|
+
function channelsDiffer(left, right, offset) {
|
|
440
|
+
const red = Math.abs(left[offset] - right[offset]);
|
|
441
|
+
const green = Math.abs(left[offset + 1] - right[offset + 1]);
|
|
442
|
+
const blue = Math.abs(left[offset + 2] - right[offset + 2]);
|
|
443
|
+
return Math.max(red, green, blue) > channelTolerance;
|
|
444
|
+
}
|
|
445
|
+
function buildMask(images) {
|
|
446
|
+
const first = images[0];
|
|
447
|
+
if (!first) return new Uint8Array();
|
|
448
|
+
const usable = images.filter((image) => image.width === first.width && image.height === first.height);
|
|
449
|
+
const count = first.width * first.height;
|
|
450
|
+
const mask = new Uint8Array(count);
|
|
451
|
+
mask.fill(255);
|
|
452
|
+
if (usable.length < 2) return mask;
|
|
453
|
+
for (let index = 0; index < count; index += 1) {
|
|
454
|
+
const offset = index * 4;
|
|
455
|
+
for (let left = 0; left < usable.length; left += 1) {
|
|
456
|
+
let differed = false;
|
|
457
|
+
for (let right = left + 1; right < usable.length; right += 1) {
|
|
458
|
+
if (!channelsDiffer(usable[left].data, usable[right].data, offset)) continue;
|
|
459
|
+
mask[index] = 0;
|
|
460
|
+
differed = true;
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
if (differed) break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return mask;
|
|
467
|
+
}
|
|
468
|
+
function compareImages(current, baseline, mask) {
|
|
469
|
+
if (current.width !== baseline.width || current.height !== baseline.height) {
|
|
470
|
+
return { pass: false, message: sizeMessage };
|
|
471
|
+
}
|
|
472
|
+
const count = current.width * current.height;
|
|
473
|
+
const useMask = mask != null && mask.length === count;
|
|
474
|
+
let compared = 0;
|
|
475
|
+
let changed = 0;
|
|
476
|
+
for (let index = 0; index < count; index += 1) {
|
|
477
|
+
if (useMask && mask[index] === 0) continue;
|
|
478
|
+
compared += 1;
|
|
479
|
+
if (channelsDiffer(current.data, baseline.data, index * 4)) changed += 1;
|
|
480
|
+
}
|
|
481
|
+
const ratio = compared === 0 ? 0 : changed / compared;
|
|
482
|
+
if (ratio > failRatio) return { pass: false, message: diffMessage };
|
|
483
|
+
return { pass: true, ratio };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// packages/engine/src/baseline/store.ts
|
|
487
|
+
var baselinesRoot = path2.resolve("test-results/baselines");
|
|
488
|
+
var archiveLimit = 5;
|
|
489
|
+
function scenarioDir(root, name) {
|
|
490
|
+
const id = createHash("sha256").update(name, "utf8").digest("hex").slice(0, 16);
|
|
491
|
+
return path2.join(root, id);
|
|
492
|
+
}
|
|
493
|
+
function pendingPath(root) {
|
|
494
|
+
return path2.join(root, "pending.json");
|
|
495
|
+
}
|
|
496
|
+
function readPng(file) {
|
|
497
|
+
const png = PNG.sync.read(readFileSync2(file));
|
|
498
|
+
return { width: png.width, height: png.height, data: png.data };
|
|
499
|
+
}
|
|
500
|
+
function writeGrayMask(file, width, height, mask) {
|
|
501
|
+
const png = new PNG({ width, height, colorType: 0 });
|
|
502
|
+
for (let index = 0; index < mask.length; index += 1) {
|
|
503
|
+
const value = mask[index] === 0 ? 0 : 255;
|
|
504
|
+
const offset = index * 4;
|
|
505
|
+
png.data[offset] = value;
|
|
506
|
+
png.data[offset + 1] = value;
|
|
507
|
+
png.data[offset + 2] = value;
|
|
508
|
+
png.data[offset + 3] = 255;
|
|
509
|
+
}
|
|
510
|
+
writeFileSync2(file, PNG.sync.write(png));
|
|
511
|
+
}
|
|
512
|
+
function readMask(file, width, height) {
|
|
513
|
+
if (!existsSync2(file)) return null;
|
|
514
|
+
const png = PNG.sync.read(readFileSync2(file));
|
|
515
|
+
if (png.width !== width || png.height !== height) return null;
|
|
516
|
+
const mask = new Uint8Array(width * height);
|
|
517
|
+
for (let index = 0; index < mask.length; index += 1) {
|
|
518
|
+
mask[index] = png.data[index * 4] === 0 ? 0 : 255;
|
|
519
|
+
}
|
|
520
|
+
return mask;
|
|
521
|
+
}
|
|
522
|
+
function archiveFiles(dir) {
|
|
523
|
+
if (!existsSync2(dir)) return [];
|
|
524
|
+
return readdirSync2(dir).flatMap((name) => {
|
|
525
|
+
const match = /^(\d+)\.png$/.exec(name);
|
|
526
|
+
if (!match) return [];
|
|
527
|
+
return [{ index: Number(match[1]), file: path2.join(dir, name) }];
|
|
528
|
+
}).sort((left, right) => left.index - right.index);
|
|
529
|
+
}
|
|
530
|
+
function readPending(root = baselinesRoot) {
|
|
531
|
+
try {
|
|
532
|
+
const parsed = JSON.parse(readFileSync2(pendingPath(root), "utf8"));
|
|
533
|
+
if (!Array.isArray(parsed.names)) return [];
|
|
534
|
+
return parsed.names.filter((name) => typeof name === "string");
|
|
535
|
+
} catch {
|
|
536
|
+
return [];
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function writePending(root, names) {
|
|
540
|
+
mkdirSync2(root, { recursive: true });
|
|
541
|
+
writeFileSync2(pendingPath(root), JSON.stringify({ names }, null, 2));
|
|
542
|
+
}
|
|
543
|
+
function addPending(root, name) {
|
|
544
|
+
const names = readPending(root);
|
|
545
|
+
if (!names.includes(name)) names.push(name);
|
|
546
|
+
writePending(root, names);
|
|
547
|
+
}
|
|
548
|
+
function hasBaseline(name, root = baselinesRoot) {
|
|
549
|
+
return existsSync2(path2.join(scenarioDir(root, name), "baseline.png"));
|
|
550
|
+
}
|
|
551
|
+
function saveCandidate(name, png, root = baselinesRoot) {
|
|
552
|
+
const dir = scenarioDir(root, name);
|
|
553
|
+
mkdirSync2(dir, { recursive: true });
|
|
554
|
+
writeFileSync2(path2.join(dir, "candidate.png"), png);
|
|
555
|
+
addPending(root, name);
|
|
556
|
+
}
|
|
557
|
+
function compareToBaseline(name, png, root = baselinesRoot) {
|
|
558
|
+
const dir = scenarioDir(root, name);
|
|
559
|
+
const current = PNG.sync.read(png);
|
|
560
|
+
const baseline = readPng(path2.join(dir, "baseline.png"));
|
|
561
|
+
const mask = readMask(path2.join(dir, "mask.png"), baseline.width, baseline.height);
|
|
562
|
+
return compareImages({ width: current.width, height: current.height, data: current.data }, baseline, mask);
|
|
563
|
+
}
|
|
564
|
+
function rebuildMask(dir) {
|
|
565
|
+
const files = archiveFiles(path2.join(dir, "archive"));
|
|
566
|
+
const baseline = readPng(path2.join(dir, "baseline.png"));
|
|
567
|
+
const images = files.map((file) => readPng(file.file)).filter((image) => image.width === baseline.width && image.height === baseline.height);
|
|
568
|
+
const mask = buildMask(images.length > 0 ? images : [baseline]);
|
|
569
|
+
writeGrayMask(path2.join(dir, "mask.png"), baseline.width, baseline.height, mask);
|
|
570
|
+
}
|
|
571
|
+
function confirmScenario(name, root = baselinesRoot) {
|
|
572
|
+
if (!readPending(root).includes(name)) return false;
|
|
573
|
+
const dir = scenarioDir(root, name);
|
|
574
|
+
const candidate = path2.join(dir, "candidate.png");
|
|
575
|
+
if (!existsSync2(candidate)) return false;
|
|
576
|
+
const archive = path2.join(dir, "archive");
|
|
577
|
+
mkdirSync2(archive, { recursive: true });
|
|
578
|
+
const existing = archiveFiles(archive);
|
|
579
|
+
const next = (existing.at(-1)?.index ?? 0) + 1;
|
|
580
|
+
const archived = path2.join(archive, `${String(next).padStart(4, "0")}.png`);
|
|
581
|
+
writeFileSync2(archived, readFileSync2(candidate));
|
|
582
|
+
const files = archiveFiles(archive);
|
|
583
|
+
while (files.length > archiveLimit) {
|
|
584
|
+
const oldest = files.shift();
|
|
585
|
+
if (oldest) unlinkSync(oldest.file);
|
|
586
|
+
}
|
|
587
|
+
writeFileSync2(path2.join(dir, "baseline.png"), readFileSync2(archived));
|
|
588
|
+
rebuildMask(dir);
|
|
589
|
+
unlinkSync(candidate);
|
|
590
|
+
writePending(
|
|
591
|
+
root,
|
|
592
|
+
readPending(root).filter((item) => item !== name)
|
|
593
|
+
);
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
function confirmPending(root = baselinesRoot) {
|
|
597
|
+
const names = readPending(root);
|
|
598
|
+
let confirmed = 0;
|
|
599
|
+
for (const name of names) {
|
|
600
|
+
if (confirmScenario(name, root)) confirmed += 1;
|
|
601
|
+
}
|
|
602
|
+
return confirmed;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// packages/engine/src/check.ts
|
|
606
|
+
var deviceBaselinesRoot = path3.resolve("test-results/device-baselines");
|
|
607
|
+
var statusBarFallbackRatio = 0.08;
|
|
608
|
+
var statusBarHeights = {
|
|
609
|
+
"SM-A325N": 80,
|
|
610
|
+
// 갤럭시 A32, Android 13, 1080x2400 (2026-10-26 실측)
|
|
611
|
+
"iPhone 16": 177
|
|
612
|
+
// 안전 영역 위 59pt × 3배. 캡처 1178x2556 (2026-10-26 확인)
|
|
613
|
+
};
|
|
614
|
+
function baselineKey(device, scenario) {
|
|
615
|
+
return `${device.model}|${device.osVersion}|${scenario}`;
|
|
616
|
+
}
|
|
617
|
+
function statusBarIgnore(device, width, height) {
|
|
618
|
+
const barHeight = statusBarHeights[device.model] ?? Math.round(height * statusBarFallbackRatio);
|
|
619
|
+
return { x: 0, y: 0, width, height: Math.min(barHeight, height) };
|
|
620
|
+
}
|
|
621
|
+
function blankStatusBar(png, device) {
|
|
622
|
+
const image = PNG2.sync.read(png);
|
|
623
|
+
const box = statusBarIgnore(device, image.width, image.height);
|
|
624
|
+
const end = box.height * image.width * 4;
|
|
625
|
+
for (let offset = 0; offset < end; offset += 4) {
|
|
626
|
+
image.data[offset] = 0;
|
|
627
|
+
image.data[offset + 1] = 0;
|
|
628
|
+
image.data[offset + 2] = 0;
|
|
629
|
+
image.data[offset + 3] = 255;
|
|
630
|
+
}
|
|
631
|
+
return PNG2.sync.write(image);
|
|
632
|
+
}
|
|
633
|
+
async function checkScreen(driver, device, scenario, baselineRoot = deviceBaselinesRoot, waitFor = (target, path8) => waitForScreen(target, path8)) {
|
|
634
|
+
await driver.navigate(scenario.path);
|
|
635
|
+
const ready = await waitFor(driver, scenario.path);
|
|
636
|
+
if (!ready.ok) return { status: "failed", message: ready.message };
|
|
637
|
+
const note = ready.redirectedTo ? `${ready.redirectedTo}\uB85C \uC774\uB3D9\uB428` : void 0;
|
|
638
|
+
const { appErrorText, loginPaths } = driver.app;
|
|
639
|
+
if (await driver.hasText(appErrorText)) {
|
|
640
|
+
return { status: "failed", message: `${scenario.path} \uC5D0\uC11C \uC571 \uC624\uB958\uAC00 \uB0AC\uC2B5\uB2C8\uB2E4.` };
|
|
641
|
+
}
|
|
642
|
+
if (isLoginPath(await driver.currentPath(), loginPaths)) return { status: "login_required" };
|
|
643
|
+
const screenshot = blankStatusBar(await driver.screenshot(), device);
|
|
644
|
+
const key = baselineKey(device, scenario.name);
|
|
645
|
+
if (!hasBaseline(key, baselineRoot)) {
|
|
646
|
+
saveCandidate(key, screenshot, baselineRoot);
|
|
647
|
+
return { status: "candidate", message: note, screenshot };
|
|
648
|
+
}
|
|
649
|
+
const result = compareToBaseline(key, screenshot, baselineRoot);
|
|
650
|
+
if (result.pass) return { status: "passed", message: note, screenshot };
|
|
651
|
+
return { status: "failed", message: result.message, screenshot };
|
|
652
|
+
}
|
|
653
|
+
function pendingDeviceBaselines(root = deviceBaselinesRoot) {
|
|
654
|
+
return readPending(root);
|
|
655
|
+
}
|
|
656
|
+
function confirmDeviceBaselines(names, root = deviceBaselinesRoot) {
|
|
657
|
+
if (!names) return confirmPending(root);
|
|
658
|
+
return names.filter((name) => confirmScenario(name, root)).length;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// packages/engine/src/run.ts
|
|
662
|
+
var humanControlMessage = "\uC9C1\uC811 \uC870\uC791 \uC911\uC774\uB77C \uAC74\uB108\uB700";
|
|
663
|
+
var stoppedMessage = "\uC810\uAC80\uC744 \uBA48\uCDC4\uC2B5\uB2C8\uB2E4.";
|
|
664
|
+
var testResultsRoot = path4.resolve("test-results");
|
|
665
|
+
var offlinePattern = /device not found|disconnected|offline/i;
|
|
666
|
+
var installedVersionLabel = "\uC124\uCE58\uB41C \uC571";
|
|
667
|
+
function errorMessage(error) {
|
|
668
|
+
return error instanceof Error ? error.message : String(error);
|
|
669
|
+
}
|
|
670
|
+
function isOffline(error) {
|
|
671
|
+
return offlinePattern.test(errorMessage(error));
|
|
672
|
+
}
|
|
673
|
+
function createRunId(now) {
|
|
674
|
+
const stamp = new Date(now).toISOString().replace(/[-:.]/g, "").replace("Z", "");
|
|
675
|
+
return `${stamp}-${randomBytes(2).toString("hex")}`;
|
|
676
|
+
}
|
|
677
|
+
function settle(job, status, message) {
|
|
678
|
+
delete job.outcome.phase;
|
|
679
|
+
delete job.outcome.current;
|
|
680
|
+
delete job.outcome.flowSteps;
|
|
681
|
+
job.outcome.status = status;
|
|
682
|
+
if (message === void 0) delete job.outcome.message;
|
|
683
|
+
else job.outcome.message = message;
|
|
684
|
+
job.deps.onUpdate(job.run);
|
|
685
|
+
}
|
|
686
|
+
function isStopped(job) {
|
|
687
|
+
return job.deps.signal?.aborted === true;
|
|
688
|
+
}
|
|
689
|
+
function stoppedOutcome(outcome) {
|
|
690
|
+
return outcome.screens.some((screen) => screen.status === "failed") ? "failed" : "skipped";
|
|
691
|
+
}
|
|
692
|
+
function screensOutcome(outcome) {
|
|
693
|
+
if (outcome.screens.some((screen) => screen.status === "failed")) return "failed";
|
|
694
|
+
if (outcome.screens.some((screen) => screen.status === "login_required")) return "login_required";
|
|
695
|
+
return "passed";
|
|
696
|
+
}
|
|
697
|
+
function pathSegment(name) {
|
|
698
|
+
return name.replace(/[\\/]/g, "_").replace(/^\.+/, "_");
|
|
699
|
+
}
|
|
700
|
+
function logOf(deps) {
|
|
701
|
+
return deps.log ?? ((message) => console.warn(`[run] ${message}`));
|
|
702
|
+
}
|
|
703
|
+
async function failureScreenshot(job, driver, result) {
|
|
704
|
+
if (result.screenshot) return result.screenshot;
|
|
705
|
+
try {
|
|
706
|
+
return await driver.screenshot();
|
|
707
|
+
} catch (error) {
|
|
708
|
+
logOf(job.deps)(`${job.device.id} \uC2E4\uD328 \uD654\uBA74 \uC2A4\uD06C\uB9B0\uC0F7\uC744 \uBABB \uCC0D\uC5C8\uC2B5\uB2C8\uB2E4: ${errorMessage(error)}`);
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function baselineImages(job, scenario, screenshot) {
|
|
713
|
+
try {
|
|
714
|
+
return job.deps.baselineFor?.(job.device, scenario, screenshot);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
logOf(job.deps)(`${scenario.name} \uAE30\uC900\xB7\uCC28\uC774 \uC774\uBBF8\uC9C0\uB97C \uBABB \uB9CC\uB4E4\uC5C8\uC2B5\uB2C8\uB2E4: ${errorMessage(error)}`);
|
|
717
|
+
return void 0;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
async function bundleFailure(job, driver, capture, name, result, scenario) {
|
|
721
|
+
const screenshot = await failureScreenshot(job, driver, result);
|
|
722
|
+
if (!screenshot) return {};
|
|
723
|
+
const root = job.deps.resultsRoot ?? testResultsRoot;
|
|
724
|
+
const dir = path4.join(root, "devices", job.run.runId, pathSegment(job.device.id), pathSegment(name));
|
|
725
|
+
const relative = path4.relative(root, dir).split(path4.sep).join("/");
|
|
726
|
+
const input = {
|
|
727
|
+
logs: capture.ring.around((job.deps.now ?? Date.now)()),
|
|
728
|
+
frames: capture.frames(),
|
|
729
|
+
screenshot,
|
|
730
|
+
// checkScreen이 스크린샷을 돌려준 실패 = 기준 비교 실패. 앱 오류·예외는 기준·차이를 붙이지 않는다.
|
|
731
|
+
baseline: result.screenshot && scenario ? baselineImages(job, scenario, screenshot) : void 0
|
|
732
|
+
};
|
|
733
|
+
try {
|
|
734
|
+
await (job.deps.writeBundle ?? writeFailureBundle)(dir, input);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
logOf(job.deps)(`${relative} \uC2E4\uD328 \uBB36\uC74C \uC77C\uBD80\uB97C \uBABB \uC37C\uC2B5\uB2C8\uB2E4: ${errorMessage(error)}`);
|
|
737
|
+
}
|
|
738
|
+
return { screenshot: `${relative}/screenshot.png`, logDir: relative };
|
|
739
|
+
}
|
|
740
|
+
async function checkOne(job, driver, scenario) {
|
|
741
|
+
const check = job.deps.checkScreen ?? checkScreen;
|
|
742
|
+
try {
|
|
743
|
+
return await check(driver, job.device, scenario);
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (isOffline(error)) throw error;
|
|
746
|
+
return { status: "failed", message: errorMessage(error) };
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async function pressButtons(job, driver, capture, scenario, result) {
|
|
750
|
+
const setup = job.deps.buttonCheck;
|
|
751
|
+
if (!job.request.buttonCheck || !setup || result.status !== "passed" && result.status !== "candidate") return { result };
|
|
752
|
+
job.outcome.current = `${scenario.name} \xB7 \uBC84\uD2BC \uB20C\uB7EC\uBCF4\uAE30`;
|
|
753
|
+
job.deps.onUpdate(job.run);
|
|
754
|
+
const ring = capture?.ring ?? new LogRing();
|
|
755
|
+
const buttons = await (setup.checkButtons ?? checkButtons)(driver, {
|
|
756
|
+
probe: (since) => setup.probe(driver, ring, since),
|
|
757
|
+
screenPath: scenario.path,
|
|
758
|
+
restore: async () => {
|
|
759
|
+
await driver.goHome?.();
|
|
760
|
+
await driver.navigate(scenario.path);
|
|
761
|
+
await waitForScreen(driver, scenario.path);
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
try {
|
|
765
|
+
setup.recordMap?.(scenario.path, buttons);
|
|
766
|
+
} catch (error) {
|
|
767
|
+
logOf(job.deps)(`\uD654\uBA74 \uC9C0\uB3C4 \uAE30\uB85D \uC2E4\uD328: ${errorMessage(error)}`);
|
|
768
|
+
}
|
|
769
|
+
const summary = summarizeButtons(buttons);
|
|
770
|
+
const message = result.message ? `${result.message} \xB7 ${summary}` : summary;
|
|
771
|
+
if (buttons.some((button) => button.status === "error")) return { result: { status: "failed", message }, buttons };
|
|
772
|
+
return { result: { ...result, message }, buttons };
|
|
773
|
+
}
|
|
774
|
+
async function runScenarios(job, driver, capture) {
|
|
775
|
+
for (const scenario of job.request.flowsOnly ? [] : job.deps.scenarios) {
|
|
776
|
+
if (isStopped(job)) return;
|
|
777
|
+
job.outcome.phase = "checking";
|
|
778
|
+
job.outcome.current = scenario.name;
|
|
779
|
+
job.deps.onUpdate(job.run);
|
|
780
|
+
const { result, buttons } = await pressButtons(job, driver, capture, scenario, await checkOne(job, driver, scenario));
|
|
781
|
+
const bundle = result.status === "failed" && capture ? await bundleFailure(job, driver, capture, scenario.name, result, scenario) : {};
|
|
782
|
+
job.outcome.screens.push({ scenario: scenario.name, status: result.status, message: result.message, buttons, ...bundle });
|
|
783
|
+
job.deps.onUpdate(job.run);
|
|
784
|
+
if (result.status === "login_required") return;
|
|
785
|
+
}
|
|
786
|
+
await runFlows(job, driver, capture);
|
|
787
|
+
await runQaScreens(job, driver, capture);
|
|
788
|
+
await runPolicyScreens(job, driver, capture);
|
|
789
|
+
}
|
|
790
|
+
var flowScenarioPrefix = "\uD50C\uB85C\uC6B0 \xB7 ";
|
|
791
|
+
var qaScenarioPrefix = "\uB370\uC774\uD130 \xB7 ";
|
|
792
|
+
var policyScenarioPrefix = "\uC815\uCC45 \xB7 ";
|
|
793
|
+
var qaScreensOf = (deps) => deps.runQa ? deps.qaScreens ?? [] : [];
|
|
794
|
+
async function qaOne(runQa, driver, screen) {
|
|
795
|
+
try {
|
|
796
|
+
return await runQa(driver, screen);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
if (isOffline(error)) throw error;
|
|
799
|
+
return { path: screen.path, name: screen.name, apis: [], checks: [{ id: "run", title: "\uB370\uC774\uD130 QA \uC2E4\uD589", ok: false, message: errorMessage(error) }] };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
async function runQaScreens(job, driver, capture) {
|
|
803
|
+
const { runQa } = job.deps;
|
|
804
|
+
if (!runQa) return;
|
|
805
|
+
for (const screen of qaScreensOf(job.deps)) {
|
|
806
|
+
if (isStopped(job)) return;
|
|
807
|
+
const name = `${qaScenarioPrefix}${screen.name}`;
|
|
808
|
+
job.outcome.phase = "checking";
|
|
809
|
+
job.outcome.current = name;
|
|
810
|
+
job.deps.onUpdate(job.run);
|
|
811
|
+
const report = await qaOne(runQa, driver, screen);
|
|
812
|
+
const failed = report.checks.filter((check) => !check.ok);
|
|
813
|
+
const message = failed.length > 0 ? `${failed.length}\uAC1C \uC2E4\uD328 \xB7 ${failed[0].title}: ${failed[0].message ?? ""}` : void 0;
|
|
814
|
+
const status = failed.length > 0 ? "failed" : "passed";
|
|
815
|
+
const bundle = status === "failed" && capture ? await bundleFailure(job, driver, capture, name, { status: "failed", message }) : {};
|
|
816
|
+
job.outcome.screens.push({ scenario: name, status, message, checks: report.checks, ...bundle });
|
|
817
|
+
job.deps.onUpdate(job.run);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
function policyMessage(report) {
|
|
821
|
+
const open = report.checks.filter((check) => !check.ok && !check.ignored && check.finding);
|
|
822
|
+
const notes = [
|
|
823
|
+
open.length > 0 ? `\uC5B4\uAE0B\uB0A8 ${open.length}\uAC1C \xB7 ${open[0].title}: ${open[0].message ?? ""}` : null,
|
|
824
|
+
report.situational > 0 ? `\uC0C1\uD669 \uD544\uC694 \uADDC\uCE59 ${report.situational}\uAC1C\uB294 \uD655\uC778 \uC548 \uD568` : null,
|
|
825
|
+
report.judgeError ?? null
|
|
826
|
+
].filter(Boolean);
|
|
827
|
+
return notes.length > 0 ? notes.join(" \xB7 ") : void 0;
|
|
828
|
+
}
|
|
829
|
+
async function runPolicyScreens(job, driver, capture) {
|
|
830
|
+
const { runPolicy } = job.deps;
|
|
831
|
+
if (!runPolicy) return;
|
|
832
|
+
for (const screen of job.deps.policyScreens ?? []) {
|
|
833
|
+
if (isStopped(job)) return;
|
|
834
|
+
const name = `${policyScenarioPrefix}${screen.path}`;
|
|
835
|
+
job.outcome.phase = "checking";
|
|
836
|
+
job.outcome.current = name;
|
|
837
|
+
job.deps.onUpdate(job.run);
|
|
838
|
+
let report;
|
|
839
|
+
try {
|
|
840
|
+
report = await runPolicy(driver, screen);
|
|
841
|
+
} catch (error) {
|
|
842
|
+
if (isOffline(error)) throw error;
|
|
843
|
+
report = { path: screen.path, checks: [], situational: 0, cached: false, judgeError: errorMessage(error) };
|
|
844
|
+
}
|
|
845
|
+
const status = hasBugCandidate(report.checks) ? "failed" : "passed";
|
|
846
|
+
const message = policyMessage(report);
|
|
847
|
+
const bundle = status === "failed" && capture ? await bundleFailure(job, driver, capture, name, { status: "failed", message }) : {};
|
|
848
|
+
job.outcome.screens.push({ scenario: name, status, message, checks: report.checks, ...bundle });
|
|
849
|
+
job.deps.onUpdate(job.run);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
function flowStepViews(flow, running) {
|
|
853
|
+
return withHome(flow).map((step, index) => ({
|
|
854
|
+
label: `${stepLabels[step.type]} \u300C${stepTarget(step)}\u300D`,
|
|
855
|
+
status: index < running ? "passed" : index === running ? "running" : "pending"
|
|
856
|
+
}));
|
|
857
|
+
}
|
|
858
|
+
async function flowOne(job, driver, flow) {
|
|
859
|
+
const onStep = (index) => {
|
|
860
|
+
job.outcome.flowSteps = flowStepViews(flow, index);
|
|
861
|
+
job.deps.onUpdate(job.run);
|
|
862
|
+
};
|
|
863
|
+
try {
|
|
864
|
+
return await (job.deps.runFlow ?? ((target, item, step) => runFlow(target, item, { onStep: step })))(driver, flow, onStep);
|
|
865
|
+
} catch (error) {
|
|
866
|
+
if (isOffline(error)) throw error;
|
|
867
|
+
return { status: "failed", steps: [], message: errorMessage(error) };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
async function runFlows(job, driver, capture) {
|
|
871
|
+
for (const flow of job.deps.flows ?? []) {
|
|
872
|
+
if (isStopped(job)) return;
|
|
873
|
+
const name = `${flowScenarioPrefix}${flow.name}`;
|
|
874
|
+
job.outcome.phase = "checking";
|
|
875
|
+
job.outcome.current = name;
|
|
876
|
+
job.deps.onUpdate(job.run);
|
|
877
|
+
const result = await flowOne(job, driver, flow);
|
|
878
|
+
delete job.outcome.flowSteps;
|
|
879
|
+
const bundle = result.status === "failed" && capture ? await bundleFailure(job, driver, capture, name, { status: "failed" }) : {};
|
|
880
|
+
job.outcome.screens.push({ scenario: name, status: result.status, message: result.message, steps: result.steps, ...bundle });
|
|
881
|
+
job.deps.onUpdate(job.run);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function emptyCapture() {
|
|
885
|
+
return { ring: new LogRing(), frames: () => [], stop: async () => void 0 };
|
|
886
|
+
}
|
|
887
|
+
async function startCapture(job, driver, appId) {
|
|
888
|
+
if (!job.deps.capture) return null;
|
|
889
|
+
try {
|
|
890
|
+
return await job.deps.capture(job.device, driver, appId);
|
|
891
|
+
} catch (error) {
|
|
892
|
+
logOf(job.deps)(`${job.device.id} \uB85C\uADF8 \uC218\uC9D1\uC744 \uBABB \uB744\uC6E0\uC2B5\uB2C8\uB2E4: ${errorMessage(error)}`);
|
|
893
|
+
return emptyCapture();
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
async function stopCapture(job, capture) {
|
|
897
|
+
try {
|
|
898
|
+
await capture?.stop();
|
|
899
|
+
} catch (error) {
|
|
900
|
+
logOf(job.deps)(`${job.device.id} \uB85C\uADF8 \uC218\uC9D1 \uC815\uC9C0 \uC2E4\uD328: ${errorMessage(error)}`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
var testflightUnsupportedMessage = "TestFlight \uC124\uCE58\uB97C \uAE30\uB2E4\uB9B4 \uBC29\uBC95\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
904
|
+
async function installBuild(job) {
|
|
905
|
+
const { device, request, deps } = job;
|
|
906
|
+
try {
|
|
907
|
+
if (installSourceOf(deps.app.install, device.platform, request.kind) === "testflight") {
|
|
908
|
+
if (!deps.waitForTestflight) return { ok: false, message: testflightUnsupportedMessage };
|
|
909
|
+
return await deps.waitForTestflight(device, request.kind, request.version);
|
|
910
|
+
}
|
|
911
|
+
const file = await deps.fetchBuild({ platform: device.platform, kind: request.kind, version: request.version });
|
|
912
|
+
return await (deps.installAndLaunch ?? installAndLaunch)(device, file, appIdFor(deps.app, request.kind, device.platform), deps.exec);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
return { ok: false, message: errorMessage(error) };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function markPhase(job, phase) {
|
|
918
|
+
job.outcome.phase = phase;
|
|
919
|
+
delete job.outcome.current;
|
|
920
|
+
job.deps.onUpdate(job.run);
|
|
921
|
+
}
|
|
922
|
+
async function guardButtons(job, driver) {
|
|
923
|
+
if (!job.request.buttonCheck || !job.deps.buttonCheck) return null;
|
|
924
|
+
try {
|
|
925
|
+
return await job.deps.buttonCheck.guard(driver);
|
|
926
|
+
} catch (error) {
|
|
927
|
+
job.request = { ...job.request, buttonCheck: false };
|
|
928
|
+
job.outcome.message = `\uBC84\uD2BC \uB20C\uB7EC\uBCF4\uAE30\uB97C \uAC74\uB108\uB700: ${errorMessage(error)}`;
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
async function testDevice(job) {
|
|
933
|
+
if (isStopped(job)) {
|
|
934
|
+
settle(job, "skipped", stoppedMessage);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
if (!job.request.flowsOnly) {
|
|
938
|
+
const source = installSourceOf(job.deps.app.install, job.device.platform, job.request.kind);
|
|
939
|
+
markPhase(job, source === "testflight" ? "waiting_testflight" : "installing");
|
|
940
|
+
const installed = await installBuild(job);
|
|
941
|
+
if (!installed.ok) {
|
|
942
|
+
settle(job, "install_failed", installed.message);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (isStopped(job)) {
|
|
946
|
+
settle(job, "skipped", stoppedMessage);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const appId = appIdFor(job.deps.app, job.request.kind, job.device.platform);
|
|
951
|
+
const driver = await job.deps.openDriver(job.device, appId);
|
|
952
|
+
const capture = await startCapture(job, driver, appId);
|
|
953
|
+
const unguard = await guardButtons(job, driver);
|
|
954
|
+
try {
|
|
955
|
+
await runScenarios(job, driver, capture);
|
|
956
|
+
} finally {
|
|
957
|
+
await unguard?.().catch((error) => logOf(job.deps)(`${job.device.id} \uBC84\uD2BC \uAC00\uB4DC \uD574\uC81C \uC2E4\uD328: ${errorMessage(error)}`));
|
|
958
|
+
await stopCapture(job, capture);
|
|
959
|
+
await driver.close().catch(() => void 0);
|
|
960
|
+
}
|
|
961
|
+
if (isStopped(job)) {
|
|
962
|
+
settle(job, stoppedOutcome(job.outcome), stoppedMessage);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
settle(job, screensOutcome(job.outcome), job.outcome.message);
|
|
966
|
+
}
|
|
967
|
+
async function runDevice(job) {
|
|
968
|
+
const owner = { kind: "bot", runId: job.run.runId };
|
|
969
|
+
if (!job.deps.locks.acquire(job.device.id, owner)) {
|
|
970
|
+
settle(job, "skipped", humanControlMessage);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
try {
|
|
974
|
+
await testDevice(job);
|
|
975
|
+
} catch (error) {
|
|
976
|
+
settle(job, isOffline(error) ? "offline" : "failed", errorMessage(error));
|
|
977
|
+
} finally {
|
|
978
|
+
job.deps.locks.release(job.device.id, owner);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
async function eachLimited(items, limit, work) {
|
|
982
|
+
let next = 0;
|
|
983
|
+
const worker = async () => {
|
|
984
|
+
while (next < items.length) {
|
|
985
|
+
const item = items[next];
|
|
986
|
+
next += 1;
|
|
987
|
+
await work(item);
|
|
988
|
+
}
|
|
989
|
+
};
|
|
990
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
991
|
+
}
|
|
992
|
+
function pickDevices(devices, deviceIds) {
|
|
993
|
+
if (deviceIds === "all") return [...devices];
|
|
994
|
+
return devices.filter((device) => deviceIds.includes(device.id));
|
|
995
|
+
}
|
|
996
|
+
async function runDevices(request, deps) {
|
|
997
|
+
const now = deps.now ?? Date.now;
|
|
998
|
+
const startedAt = now();
|
|
999
|
+
const devices = pickDevices(await deps.listDevices(), request.deviceIds);
|
|
1000
|
+
const run = {
|
|
1001
|
+
runId: request.runId ?? createRunId(startedAt),
|
|
1002
|
+
kind: request.kind,
|
|
1003
|
+
version: request.version,
|
|
1004
|
+
trigger: request.trigger ?? "manual",
|
|
1005
|
+
scenarioCount: (request.flowsOnly ? 0 : deps.scenarios.length) + (deps.flows?.length ?? 0) + qaScreensOf(deps).length,
|
|
1006
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
1007
|
+
finishedAt: null,
|
|
1008
|
+
outcomes: devices.map((device) => ({ device, status: "running", screens: [] }))
|
|
1009
|
+
};
|
|
1010
|
+
deps.onUpdate(run);
|
|
1011
|
+
const jobs = run.outcomes.map((outcome) => ({ device: outcome.device, outcome, request, run, deps }));
|
|
1012
|
+
await eachLimited(jobs, deviceConcurrency, runDevice);
|
|
1013
|
+
run.finishedAt = new Date(now()).toISOString();
|
|
1014
|
+
deps.onUpdate(run);
|
|
1015
|
+
return run;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// packages/engine/src/explore/crawler.ts
|
|
1019
|
+
import { createHash as createHash2 } from "crypto";
|
|
1020
|
+
import { setTimeout as wait } from "timers/promises";
|
|
1021
|
+
var exploreLimits = { maxDepth: 6, maxActionsPerScreen: 20, maxMinutes: 30 };
|
|
1022
|
+
var actionSettleMs = 500;
|
|
1023
|
+
var minuteMs = 6e4;
|
|
1024
|
+
var hashLength = 16;
|
|
1025
|
+
function toAction(candidate, formValues, forbiddenWords) {
|
|
1026
|
+
if (candidate.role === "input") {
|
|
1027
|
+
const value = formValues[candidate.name];
|
|
1028
|
+
return value ? { kind: "fill", label: candidate.name, value } : null;
|
|
1029
|
+
}
|
|
1030
|
+
if (hasForbiddenWord(candidate.name, forbiddenWords)) return null;
|
|
1031
|
+
return { kind: "click", role: candidate.role, name: candidate.name };
|
|
1032
|
+
}
|
|
1033
|
+
function actionSortKey(action) {
|
|
1034
|
+
return action.kind === "fill" ? `${action.label}\0fill` : `${action.name}\0${action.role}`;
|
|
1035
|
+
}
|
|
1036
|
+
function compareActions(a, b) {
|
|
1037
|
+
const left = actionSortKey(a);
|
|
1038
|
+
const right = actionSortKey(b);
|
|
1039
|
+
if (left === right) return 0;
|
|
1040
|
+
return left < right ? -1 : 1;
|
|
1041
|
+
}
|
|
1042
|
+
function selectActions(candidates, formValues, forbiddenWords) {
|
|
1043
|
+
const unique = /* @__PURE__ */ new Map();
|
|
1044
|
+
for (const candidate of candidates) {
|
|
1045
|
+
const action = toAction(candidate, formValues, forbiddenWords);
|
|
1046
|
+
if (action) unique.set(actionSortKey(action), action);
|
|
1047
|
+
}
|
|
1048
|
+
return [...unique.values()].sort(compareActions).slice(0, exploreLimits.maxActionsPerScreen);
|
|
1049
|
+
}
|
|
1050
|
+
async function listActions(driver, formValues) {
|
|
1051
|
+
const candidates = await driver.evaluate(listActionsScript);
|
|
1052
|
+
return selectActions(candidates, formValues, driver.app.guard.forbiddenButtonWords);
|
|
1053
|
+
}
|
|
1054
|
+
function shortHash(value) {
|
|
1055
|
+
return createHash2("sha256").update(value).digest("hex").slice(0, hashLength);
|
|
1056
|
+
}
|
|
1057
|
+
function screenKey(path8, actions) {
|
|
1058
|
+
return shortHash(JSON.stringify([path8, actions]));
|
|
1059
|
+
}
|
|
1060
|
+
function pathId(actions) {
|
|
1061
|
+
return shortHash(JSON.stringify(actions));
|
|
1062
|
+
}
|
|
1063
|
+
async function runAction(driver, action) {
|
|
1064
|
+
if (action.kind === "fill") {
|
|
1065
|
+
await driver.evaluate(fillScript(action.label, action.value));
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (action.role === "button") {
|
|
1069
|
+
await driver.clickButton(action.name);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
await driver.evaluate(linkClickScript(action.name));
|
|
1073
|
+
}
|
|
1074
|
+
async function replay(driver, path8, settleMs = actionSettleMs) {
|
|
1075
|
+
await driver.navigate("/");
|
|
1076
|
+
for (const action of path8.actions) {
|
|
1077
|
+
await runAction(driver, action);
|
|
1078
|
+
if (settleMs > 0) await wait(settleMs);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function clickDepth(actions) {
|
|
1082
|
+
return actions.filter((action) => action.kind === "click").length;
|
|
1083
|
+
}
|
|
1084
|
+
function childPaths(path8, actions) {
|
|
1085
|
+
if (clickDepth(path8.actions) >= exploreLimits.maxDepth) return [];
|
|
1086
|
+
const fills = actions.filter((action) => action.kind === "fill");
|
|
1087
|
+
return actions.filter((action) => action.kind === "click").map((click) => {
|
|
1088
|
+
const next = [...path8.actions, ...fills, click];
|
|
1089
|
+
return { id: pathId(next), actions: next };
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
function errorMessage2(error) {
|
|
1093
|
+
return error instanceof Error ? error.message : String(error);
|
|
1094
|
+
}
|
|
1095
|
+
async function visit(input, run, path8, seen) {
|
|
1096
|
+
const { driver } = input;
|
|
1097
|
+
try {
|
|
1098
|
+
await replay(driver, path8, input.settleMs);
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
run.replayFailures.push({ pathId: path8.id, message: errorMessage2(error) });
|
|
1101
|
+
return { clean: false, children: [] };
|
|
1102
|
+
}
|
|
1103
|
+
if (await input.isExternal()) return { clean: true, children: [] };
|
|
1104
|
+
const actions = await listActions(driver, input.formValues);
|
|
1105
|
+
const key = screenKey(await driver.currentPath(), actions);
|
|
1106
|
+
if (seen.has(key)) return { clean: false, children: [] };
|
|
1107
|
+
seen.add(key);
|
|
1108
|
+
const issues = await input.probe();
|
|
1109
|
+
run.issues.push(...issues.map((issue) => ({ ...issue, pathId: path8.id, screenshot: "", logDir: "" })));
|
|
1110
|
+
return { clean: true, children: childPaths(path8, actions) };
|
|
1111
|
+
}
|
|
1112
|
+
function takeBlocked(input) {
|
|
1113
|
+
return input.blockedSince ? input.blockedSince() : [];
|
|
1114
|
+
}
|
|
1115
|
+
async function crawl(input) {
|
|
1116
|
+
const now = input.now ?? Date.now;
|
|
1117
|
+
const startedAt = now();
|
|
1118
|
+
const deadline = startedAt + exploreLimits.maxMinutes * minuteMs;
|
|
1119
|
+
const run = {
|
|
1120
|
+
runId: input.runId ?? "",
|
|
1121
|
+
deviceId: input.deviceId ?? "",
|
|
1122
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
1123
|
+
finishedAt: null,
|
|
1124
|
+
paths: [],
|
|
1125
|
+
issues: [],
|
|
1126
|
+
blocked: [],
|
|
1127
|
+
cleanPathIds: [],
|
|
1128
|
+
replayFailures: []
|
|
1129
|
+
};
|
|
1130
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1131
|
+
const queue = [{ id: pathId([]), actions: [] }];
|
|
1132
|
+
takeBlocked(input);
|
|
1133
|
+
while (queue.length > 0 && now() <= deadline) {
|
|
1134
|
+
const path8 = queue.shift();
|
|
1135
|
+
if (!path8) break;
|
|
1136
|
+
const result = await visit(input, run, path8, seen);
|
|
1137
|
+
const blocked = takeBlocked(input);
|
|
1138
|
+
run.paths.push(path8);
|
|
1139
|
+
run.blocked.push(...blocked.map((request) => ({ ...request, pathId: path8.id })));
|
|
1140
|
+
if (result.clean && blocked.length === 0) run.cleanPathIds.push(path8.id);
|
|
1141
|
+
queue.push(...result.children);
|
|
1142
|
+
input.onStep(run);
|
|
1143
|
+
}
|
|
1144
|
+
run.finishedAt = new Date(now()).toISOString();
|
|
1145
|
+
input.onStep(run);
|
|
1146
|
+
return run;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// packages/engine/src/explore/results.ts
|
|
1150
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
1151
|
+
import path5 from "path";
|
|
1152
|
+
var exploreDir = "explore";
|
|
1153
|
+
var jsonExtension = ".json";
|
|
1154
|
+
function exploreRunDir(runId, root = testResultsRoot) {
|
|
1155
|
+
return path5.join(root, exploreDir, pathSegment(runId));
|
|
1156
|
+
}
|
|
1157
|
+
function writeExploreResult(result, root = testResultsRoot) {
|
|
1158
|
+
const file = path5.join(exploreRunDir(result.runId, root), `${pathSegment(result.deviceId)}${jsonExtension}`);
|
|
1159
|
+
mkdirSync3(path5.dirname(file), { recursive: true });
|
|
1160
|
+
writeFileSync3(`${file}.part`, JSON.stringify(result, null, 2));
|
|
1161
|
+
renameSync2(`${file}.part`, file);
|
|
1162
|
+
}
|
|
1163
|
+
function isExploreResult(value) {
|
|
1164
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1165
|
+
const run = value;
|
|
1166
|
+
return typeof run.runId === "string" && typeof run.platform === "string" && typeof run.deviceId === "string" && Array.isArray(run.paths) && Array.isArray(run.cleanPathIds);
|
|
1167
|
+
}
|
|
1168
|
+
function readResult(file) {
|
|
1169
|
+
try {
|
|
1170
|
+
const parsed = JSON.parse(readFileSync3(file, "utf8"));
|
|
1171
|
+
return isExploreResult(parsed) ? parsed : null;
|
|
1172
|
+
} catch {
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
function readExploreRun(runId, root = testResultsRoot) {
|
|
1177
|
+
const dir = exploreRunDir(runId, root);
|
|
1178
|
+
if (!existsSync3(dir)) return [];
|
|
1179
|
+
return readdirSync3(dir).filter((name) => name.endsWith(jsonExtension)).sort().flatMap((name) => readResult(path5.join(dir, name)) ?? []);
|
|
1180
|
+
}
|
|
1181
|
+
function readLatestExplore(root = testResultsRoot) {
|
|
1182
|
+
const base = path5.join(root, exploreDir);
|
|
1183
|
+
if (!existsSync3(base)) return [];
|
|
1184
|
+
const latest = readdirSync3(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().at(-1);
|
|
1185
|
+
return latest ? readExploreRun(latest, root) : [];
|
|
1186
|
+
}
|
|
1187
|
+
function cleanAndroidPaths(results) {
|
|
1188
|
+
const crawled = results.filter((result) => result.platform === "android" && result.finishedAt !== null && result.message === void 0);
|
|
1189
|
+
if (crawled.length === 0) return null;
|
|
1190
|
+
const paths = /* @__PURE__ */ new Map();
|
|
1191
|
+
for (const result of crawled) {
|
|
1192
|
+
const clean = new Set(result.cleanPathIds);
|
|
1193
|
+
for (const item of result.paths) if (clean.has(item.id) && !paths.has(item.id)) paths.set(item.id, item);
|
|
1194
|
+
}
|
|
1195
|
+
return [...paths.values()];
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// packages/engine/src/explore/run.ts
|
|
1199
|
+
var devOnlyMessage = "dev \uBE4C\uB4DC\uC5D0\uC11C\uB9CC \uD0D0\uC0C9\uD569\uB2C8\uB2E4.";
|
|
1200
|
+
var noAndroidMessage = "Android \uD0D0\uC0C9 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
1201
|
+
var iosCleanOnlyMessage = "iOS\uB294 Android\uC5D0\uC11C \uCC28\uB2E8 \uC5C6\uC774 \uB05D\uB09C \uACBD\uB85C\uB9CC \uB2E4\uC2DC \uB3D5\uB2C8\uB2E4.";
|
|
1202
|
+
var ExploreRequestError = class extends Error {
|
|
1203
|
+
constructor(status, message) {
|
|
1204
|
+
super(message);
|
|
1205
|
+
this.status = status;
|
|
1206
|
+
}
|
|
1207
|
+
status;
|
|
1208
|
+
};
|
|
1209
|
+
var emptyBundle = { screenshot: "", logDir: "" };
|
|
1210
|
+
function isAndroidDriver(driver) {
|
|
1211
|
+
return "context" in driver;
|
|
1212
|
+
}
|
|
1213
|
+
var defaultGuard = async (driver, webOrigin, allowlist, onBlocked) => {
|
|
1214
|
+
if (!isAndroidDriver(driver)) throw new Error("Android \uB4DC\uB77C\uC774\uBC84\uAC00 \uC544\uB2C8\uB77C \uC694\uCCAD \uAC00\uB4DC\uB97C \uCF24 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
1215
|
+
return guardAndroidPage(driver.context, guardTargetOf(driver.app, webOrigin), allowlist, onBlocked);
|
|
1216
|
+
};
|
|
1217
|
+
var nowOf = (deps) => (deps.now ?? Date.now)();
|
|
1218
|
+
var logOf2 = (deps) => deps.log ?? ((message) => console.warn(`[explore] ${message}`));
|
|
1219
|
+
async function bundleScreen(session) {
|
|
1220
|
+
const { deps } = session;
|
|
1221
|
+
const root = deps.resultsRoot ?? testResultsRoot;
|
|
1222
|
+
session.bundles += 1;
|
|
1223
|
+
const dir = path6.join(root, "explore", pathSegment(session.runId), pathSegment(session.device.id), `screen-${session.bundles}`);
|
|
1224
|
+
const relative = path6.relative(root, dir).split(path6.sep).join("/");
|
|
1225
|
+
let screenshot;
|
|
1226
|
+
try {
|
|
1227
|
+
screenshot = await session.driver.screenshot();
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
logOf2(deps)(`${relative} \uC2A4\uD06C\uB9B0\uC0F7\uC744 \uBABB \uCC0D\uC5B4 \uBB36\uC74C\uC744 \uAC74\uB108\uB701\uB2C8\uB2E4: ${errorMessage(error)}`);
|
|
1230
|
+
return emptyBundle;
|
|
1231
|
+
}
|
|
1232
|
+
const input = { logs: session.capture.ring.around(nowOf(deps)), frames: session.capture.frames(), screenshot };
|
|
1233
|
+
try {
|
|
1234
|
+
await (deps.writeBundle ?? writeFailureBundle)(dir, input);
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
logOf2(deps)(`${relative} \uC2E4\uD328 \uBB36\uC74C \uC77C\uBD80\uB97C \uBABB \uC37C\uC2B5\uB2C8\uB2E4: ${errorMessage(error)}`);
|
|
1237
|
+
}
|
|
1238
|
+
return { screenshot: `${relative}/screenshot.png`, logDir: relative };
|
|
1239
|
+
}
|
|
1240
|
+
function newResult(runId, device, now) {
|
|
1241
|
+
return {
|
|
1242
|
+
runId,
|
|
1243
|
+
deviceId: device.id,
|
|
1244
|
+
platform: device.platform,
|
|
1245
|
+
startedAt: new Date(now).toISOString(),
|
|
1246
|
+
finishedAt: null,
|
|
1247
|
+
paths: [],
|
|
1248
|
+
issues: [],
|
|
1249
|
+
blocked: [],
|
|
1250
|
+
cleanPathIds: [],
|
|
1251
|
+
replayFailures: []
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
function emptyCapture2() {
|
|
1255
|
+
return { ring: new LogRing(), frames: () => [], stop: async () => void 0 };
|
|
1256
|
+
}
|
|
1257
|
+
async function startCapture2(deps, device, driver) {
|
|
1258
|
+
if (!deps.capture) return emptyCapture2();
|
|
1259
|
+
try {
|
|
1260
|
+
return await deps.capture(device, driver, appIdFor(deps.app, "dev", device.platform));
|
|
1261
|
+
} catch (error) {
|
|
1262
|
+
logOf2(deps)(`${device.id} \uB85C\uADF8 \uC218\uC9D1\uC744 \uBABB \uB744\uC6E0\uC2B5\uB2C8\uB2E4: ${errorMessage(error)}`);
|
|
1263
|
+
return emptyCapture2();
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
async function withDevice(deps, device, runId, body) {
|
|
1267
|
+
const owner = { kind: "bot", runId };
|
|
1268
|
+
if (!deps.locks.acquire(device.id, owner)) return false;
|
|
1269
|
+
try {
|
|
1270
|
+
const driver = await deps.openDriver(device, appIdFor(deps.app, "dev", device.platform));
|
|
1271
|
+
const capture = await startCapture2(deps, device, driver);
|
|
1272
|
+
try {
|
|
1273
|
+
await body({ deps, device, driver, capture, runId, bundles: 0 });
|
|
1274
|
+
} finally {
|
|
1275
|
+
await capture.stop().catch((error) => logOf2(deps)(`${device.id} \uB85C\uADF8 \uC218\uC9D1 \uC815\uC9C0 \uC2E4\uD328: ${errorMessage(error)}`));
|
|
1276
|
+
await driver.close().catch(() => void 0);
|
|
1277
|
+
}
|
|
1278
|
+
} finally {
|
|
1279
|
+
deps.locks.release(device.id, owner);
|
|
1280
|
+
}
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
function fillBundles(result, bundles) {
|
|
1284
|
+
result.issues.forEach((issue, index) => {
|
|
1285
|
+
const bundle = bundles[index];
|
|
1286
|
+
if (bundle) Object.assign(issue, bundle);
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
async function crawlAndroid(session, result, publish) {
|
|
1290
|
+
const { deps, driver, capture } = session;
|
|
1291
|
+
const blocked = [];
|
|
1292
|
+
const unguard = await (deps.guard ?? defaultGuard)(driver, deps.webOrigin, deps.allowlist, (request) => blocked.push(request));
|
|
1293
|
+
const bundles = [];
|
|
1294
|
+
let since = nowOf(deps);
|
|
1295
|
+
const probe = async () => {
|
|
1296
|
+
const issues = await (deps.probe ?? probeScreen)(driver, capture.ring, since, deps.allowlist);
|
|
1297
|
+
const bundle = issues.length > 0 ? await bundleScreen(session) : emptyBundle;
|
|
1298
|
+
bundles.push(...issues.map(() => bundle));
|
|
1299
|
+
return issues;
|
|
1300
|
+
};
|
|
1301
|
+
try {
|
|
1302
|
+
await (deps.crawl ?? crawl)({
|
|
1303
|
+
driver,
|
|
1304
|
+
probe,
|
|
1305
|
+
formValues: deps.formValues,
|
|
1306
|
+
now: deps.now,
|
|
1307
|
+
runId: session.runId,
|
|
1308
|
+
deviceId: session.device.id,
|
|
1309
|
+
isExternal: async () => isExternal(await driver.evaluate("location.href"), deps.webOrigin, deps.app.apiOrigin || void 0),
|
|
1310
|
+
blockedSince: () => {
|
|
1311
|
+
since = nowOf(deps);
|
|
1312
|
+
return blocked.splice(0);
|
|
1313
|
+
},
|
|
1314
|
+
onStep: (step) => {
|
|
1315
|
+
Object.assign(result, step, { platform: session.device.platform });
|
|
1316
|
+
fillBundles(result, bundles);
|
|
1317
|
+
publish();
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
1320
|
+
} finally {
|
|
1321
|
+
await unguard();
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
async function replayIos(session, result, paths, publish) {
|
|
1325
|
+
const { deps, driver, capture } = session;
|
|
1326
|
+
for (const item of paths) {
|
|
1327
|
+
const since = nowOf(deps);
|
|
1328
|
+
result.paths.push(item);
|
|
1329
|
+
try {
|
|
1330
|
+
await (deps.replay ?? replay)(driver, item);
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
result.replayFailures.push({ pathId: item.id, message: errorMessage(error) });
|
|
1333
|
+
publish();
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
const issues = await (deps.probe ?? probeScreen)(driver, capture.ring, since, deps.allowlist);
|
|
1337
|
+
const bundle = issues.length > 0 ? await bundleScreen(session) : emptyBundle;
|
|
1338
|
+
result.issues.push(...issues.map((issue) => ({ ...issue, pathId: item.id, ...bundle })));
|
|
1339
|
+
if (issues.length === 0) result.cleanPathIds.push(item.id);
|
|
1340
|
+
publish();
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
function publisher(context, result) {
|
|
1344
|
+
return () => {
|
|
1345
|
+
writeExploreResult(result, context.deps.resultsRoot);
|
|
1346
|
+
context.deps.onUpdate?.(context.results);
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
function skipReason(device, androidPaths) {
|
|
1350
|
+
if (device.appVersion === null) return devOnlyMessage;
|
|
1351
|
+
if (device.platform === "ios" && androidPaths === null) return noAndroidMessage;
|
|
1352
|
+
return null;
|
|
1353
|
+
}
|
|
1354
|
+
async function exploreDevice(context, device, androidPaths) {
|
|
1355
|
+
const { deps } = context;
|
|
1356
|
+
const result = newResult(context.runId, device, nowOf(deps));
|
|
1357
|
+
context.results.push(result);
|
|
1358
|
+
const publish = publisher(context, result);
|
|
1359
|
+
const skip = skipReason(device, androidPaths);
|
|
1360
|
+
try {
|
|
1361
|
+
if (skip !== null) result.message = skip;
|
|
1362
|
+
else {
|
|
1363
|
+
const ran = await withDevice(
|
|
1364
|
+
deps,
|
|
1365
|
+
device,
|
|
1366
|
+
context.runId,
|
|
1367
|
+
(session) => device.platform === "android" ? crawlAndroid(session, result, publish) : replayIos(session, result, androidPaths ?? [], publish)
|
|
1368
|
+
);
|
|
1369
|
+
if (!ran) result.message = humanControlMessage;
|
|
1370
|
+
}
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
result.message = errorMessage(error);
|
|
1373
|
+
} finally {
|
|
1374
|
+
result.finishedAt ??= new Date(nowOf(deps)).toISOString();
|
|
1375
|
+
publish();
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
async function runExplore(request, deps) {
|
|
1379
|
+
const context = { runId: request.runId ?? createRunId(nowOf(deps)), deps, results: [] };
|
|
1380
|
+
const devices = pickDevices(await deps.listDevices(), request.deviceIds);
|
|
1381
|
+
const androids = devices.filter((device) => device.platform === "android");
|
|
1382
|
+
await eachLimited(androids, deviceConcurrency, (device) => exploreDevice(context, device, null));
|
|
1383
|
+
const androidPaths = cleanAndroidPaths(context.results);
|
|
1384
|
+
const iosDevices = devices.filter((device) => device.platform === "ios");
|
|
1385
|
+
await eachLimited(iosDevices, deviceConcurrency, (device) => exploreDevice(context, device, androidPaths));
|
|
1386
|
+
return context.results;
|
|
1387
|
+
}
|
|
1388
|
+
async function replayAndProbe(session, item) {
|
|
1389
|
+
const { deps, driver, capture } = session;
|
|
1390
|
+
const blocked = [];
|
|
1391
|
+
const unguard = session.device.platform === "android" ? await (deps.guard ?? defaultGuard)(driver, deps.webOrigin, deps.allowlist, (request) => blocked.push(request)) : async () => void 0;
|
|
1392
|
+
try {
|
|
1393
|
+
const since = nowOf(deps);
|
|
1394
|
+
await (deps.replay ?? replay)(driver, item);
|
|
1395
|
+
const issues = await (deps.probe ?? probeScreen)(driver, capture.ring, since, deps.allowlist);
|
|
1396
|
+
return [...issues, ...blocked.map((request) => ({ kind: "network", message: `\uCC28\uB2E8\uB428 ${request.method} ${request.path} (${request.reason})` }))];
|
|
1397
|
+
} finally {
|
|
1398
|
+
await unguard();
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
async function replayOne(deviceId, runId, pathId2, deps) {
|
|
1402
|
+
const results = readExploreRun(runId, deps.resultsRoot);
|
|
1403
|
+
const item = results.flatMap((result) => result.paths).find((candidate) => candidate.id === pathId2);
|
|
1404
|
+
if (!item) throw new ExploreRequestError(404, "\uB2E4\uC2DC \uB3CC\uB9B4 \uACBD\uB85C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
1405
|
+
const device = (await deps.listDevices()).find((candidate) => candidate.id === deviceId);
|
|
1406
|
+
if (!device) throw new ExploreRequestError(404, "\uC5F0\uACB0\uB41C \uAE30\uAE30\uAC00 \uC544\uB2D9\uB2C8\uB2E4.");
|
|
1407
|
+
if (device.appVersion === null) throw new ExploreRequestError(400, devOnlyMessage);
|
|
1408
|
+
const clean = cleanAndroidPaths(results) ?? [];
|
|
1409
|
+
if (device.platform === "ios" && !clean.some((candidate) => candidate.id === pathId2)) throw new ExploreRequestError(400, iosCleanOnlyMessage);
|
|
1410
|
+
let issues = [];
|
|
1411
|
+
const ran = await withDevice(deps, device, `replay-${runId}`, async (session) => {
|
|
1412
|
+
issues = await replayAndProbe(session, item);
|
|
1413
|
+
});
|
|
1414
|
+
if (!ran) throw new ExploreRequestError(409, humanControlMessage);
|
|
1415
|
+
return issues;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// packages/server/src/explore.ts
|
|
1419
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
1420
|
+
import path7 from "path";
|
|
1421
|
+
var formValuesFile = path7.resolve("explore/form-values.json");
|
|
1422
|
+
var readJson = (file, fallback) => existsSync4(file) ? JSON.parse(readFileSync4(file, "utf8")) : fallback;
|
|
1423
|
+
function parseFormValues(value) {
|
|
1424
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("form-values: { \uB77C\uBCA8: \uAC12 } \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4.");
|
|
1425
|
+
const entries = Object.entries(value);
|
|
1426
|
+
if (entries.some(([, item]) => typeof item !== "string")) throw new Error("form-values: \uAC12\uC740 \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.");
|
|
1427
|
+
return Object.fromEntries(entries);
|
|
1428
|
+
}
|
|
1429
|
+
function loadExploreInputs(allowlistFile = defaultAllowlistFile, valuesFile = formValuesFile) {
|
|
1430
|
+
return {
|
|
1431
|
+
allowlist: parseAllowlist(readJson(allowlistFile, { entries: [] })),
|
|
1432
|
+
formValues: parseFormValues(readJson(valuesFile, {}))
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
export {
|
|
1437
|
+
formatNetworkLog,
|
|
1438
|
+
probeScreen,
|
|
1439
|
+
LogRing,
|
|
1440
|
+
FrameRing,
|
|
1441
|
+
devicesRoot,
|
|
1442
|
+
writeRun,
|
|
1443
|
+
readLatestRun,
|
|
1444
|
+
closeUnfinishedRun,
|
|
1445
|
+
pruneRuns,
|
|
1446
|
+
channelTolerance,
|
|
1447
|
+
deviceBaselinesRoot,
|
|
1448
|
+
baselineKey,
|
|
1449
|
+
pendingDeviceBaselines,
|
|
1450
|
+
confirmDeviceBaselines,
|
|
1451
|
+
testResultsRoot,
|
|
1452
|
+
installedVersionLabel,
|
|
1453
|
+
errorMessage,
|
|
1454
|
+
createRunId,
|
|
1455
|
+
runDevices,
|
|
1456
|
+
readLatestExplore,
|
|
1457
|
+
ExploreRequestError,
|
|
1458
|
+
defaultGuard,
|
|
1459
|
+
runExplore,
|
|
1460
|
+
replayOne,
|
|
1461
|
+
loadExploreInputs
|
|
1462
|
+
};
|