@ahnseungchan/device-farm 0.0.0-stage → 0.1.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +195 -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,453 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startCollectors
|
|
3
|
+
} from "./chunk-ZE2GGXFO.js";
|
|
4
|
+
import {
|
|
5
|
+
LogRing,
|
|
6
|
+
defaultGuard,
|
|
7
|
+
loadExploreInputs,
|
|
8
|
+
probeScreen
|
|
9
|
+
} from "./chunk-AVECSGEH.js";
|
|
10
|
+
import "./chunk-UAHWEUDI.js";
|
|
11
|
+
import "./chunk-F6PZOBU6.js";
|
|
12
|
+
import "./chunk-Q6CXF3NJ.js";
|
|
13
|
+
import {
|
|
14
|
+
hasForbiddenWord,
|
|
15
|
+
listActionsScript
|
|
16
|
+
} from "./chunk-SWW42KTA.js";
|
|
17
|
+
import "./chunk-S6GHOP4B.js";
|
|
18
|
+
import {
|
|
19
|
+
listDevices
|
|
20
|
+
} from "./chunk-JZDYF3HL.js";
|
|
21
|
+
import {
|
|
22
|
+
cliApp,
|
|
23
|
+
farmPort
|
|
24
|
+
} from "./chunk-7OJDY276.js";
|
|
25
|
+
import {
|
|
26
|
+
execFileText,
|
|
27
|
+
openAndroidDriver
|
|
28
|
+
} from "./chunk-AXQNUPGC.js";
|
|
29
|
+
import {
|
|
30
|
+
sleep,
|
|
31
|
+
webOrigin
|
|
32
|
+
} from "./chunk-NLX3V7ZJ.js";
|
|
33
|
+
import "./chunk-NJNTEWXE.js";
|
|
34
|
+
|
|
35
|
+
// apps/cli/src/chaos.ts
|
|
36
|
+
import path2 from "path";
|
|
37
|
+
|
|
38
|
+
// packages/engine/src/chaos/run.ts
|
|
39
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
40
|
+
import path from "path";
|
|
41
|
+
|
|
42
|
+
// packages/core/src/chaos/policy.ts
|
|
43
|
+
var baseWeights = {
|
|
44
|
+
press: 1,
|
|
45
|
+
back: 0.3,
|
|
46
|
+
tab: 0.2,
|
|
47
|
+
doubleTap: 0.15,
|
|
48
|
+
backDuringLoad: 0.15,
|
|
49
|
+
background: 0.1,
|
|
50
|
+
restart: 0.05,
|
|
51
|
+
homeReset: 0.05
|
|
52
|
+
};
|
|
53
|
+
var multipliers = { newState: 2, nearError: 3, dead: 0.2 };
|
|
54
|
+
var deadAfter = 2;
|
|
55
|
+
var pressMassCap = 3;
|
|
56
|
+
var stayStepsPerBoost = 2;
|
|
57
|
+
var linkBonus = 1.5;
|
|
58
|
+
var frontierBonus = 3;
|
|
59
|
+
var deeperBonus = 1.5;
|
|
60
|
+
var pathDepth = (path3) => path3.split("/").filter(Boolean).length;
|
|
61
|
+
var leaveKinds = ["back", "tab", "homeReset"];
|
|
62
|
+
function isNoPressPath(path3, pattern = /password|\/pin(\/|$)/i) {
|
|
63
|
+
return pattern.test(path3);
|
|
64
|
+
}
|
|
65
|
+
var actionKey = (action) => [action.kind, action.role ?? "", action.name ?? ""].join(":");
|
|
66
|
+
function isAvoidedName(name, pattern) {
|
|
67
|
+
return pattern.test(name);
|
|
68
|
+
}
|
|
69
|
+
function canGoBack(screen) {
|
|
70
|
+
if (screen.targets.some((target) => screen.nav.inPageBackNames.includes(target.name))) return true;
|
|
71
|
+
return !Object.values(screen.nav.tabPaths).includes(screen.path);
|
|
72
|
+
}
|
|
73
|
+
function chaosCandidates(screen) {
|
|
74
|
+
const tabNames = Object.keys(screen.nav.tabPaths);
|
|
75
|
+
const press = isNoPressPath(screen.path, screen.nav.noPressPathPattern) ? [] : screen.targets.filter((target) => !tabNames.includes(target.name));
|
|
76
|
+
const tabs = screen.targets.filter((target) => target.role === "link" && tabNames.includes(target.name));
|
|
77
|
+
const last = screen.lastPressed;
|
|
78
|
+
const lastVisible = last && press.some((target) => target.role === last.role && target.name === last.name);
|
|
79
|
+
return [
|
|
80
|
+
...press.map((target) => ({ kind: "press", ...target })),
|
|
81
|
+
...canGoBack(screen) ? [{ kind: "back" }] : [],
|
|
82
|
+
...tabs.length > 0 ? [{ kind: "tab" }] : [],
|
|
83
|
+
...lastVisible ? [{ kind: "doubleTap", ...last }] : [],
|
|
84
|
+
...press.length > 0 ? [{ kind: "backDuringLoad" }] : [],
|
|
85
|
+
{ kind: "background" },
|
|
86
|
+
{ kind: "restart" },
|
|
87
|
+
{ kind: "homeReset" }
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
function baseScore(action, context) {
|
|
91
|
+
const stayFactor = 1 + context.stay / stayStepsPerBoost;
|
|
92
|
+
if (action.kind === "press") {
|
|
93
|
+
const share = baseWeights.press * Math.min(1, pressMassCap / Math.max(1, context.pressCount));
|
|
94
|
+
const frontier = action.to && context.visited && !context.visited(action.to) ? frontierBonus : 1;
|
|
95
|
+
const deeper = action.to && context.path !== void 0 && pathDepth(action.to) > pathDepth(context.path) ? deeperBonus : 1;
|
|
96
|
+
return share * (action.role === "link" ? linkBonus : 1) * frontier * deeper / stayFactor;
|
|
97
|
+
}
|
|
98
|
+
return leaveKinds.includes(action.kind) ? baseWeights[action.kind] * stayFactor : baseWeights[action.kind];
|
|
99
|
+
}
|
|
100
|
+
function scoreAction(action, stats, context = { pressCount: 1, stay: 0 }) {
|
|
101
|
+
const base = baseScore(action, context);
|
|
102
|
+
if (!stats) return base;
|
|
103
|
+
return base / (1 + stats.tries) * (stats.openedNew ? multipliers.newState : 1) * (stats.errors > 0 ? multipliers.nearError : 1) * (stats.dead >= deadAfter ? multipliers.dead : 1);
|
|
104
|
+
}
|
|
105
|
+
var statsKey = (path3, action) => `${path3}|${actionKey(action)}`;
|
|
106
|
+
function scoreAll(candidates, statsOf, path3, stay = 0, visited) {
|
|
107
|
+
const context = { pressCount: candidates.filter((action) => action.kind === "press").length, stay, visited, path: path3 };
|
|
108
|
+
const scored = candidates.map((action) => ({ action, score: scoreAction(action, statsOf(statsKey(path3, action)), context) }));
|
|
109
|
+
const total = scored.reduce((sum, item) => sum + item.score, 0);
|
|
110
|
+
return scored.map((item) => ({ ...item, probability: total > 0 ? item.score / total : 0 }));
|
|
111
|
+
}
|
|
112
|
+
function seededRandom(seed) {
|
|
113
|
+
let state = seed >>> 0;
|
|
114
|
+
return () => {
|
|
115
|
+
state = state + 1831565813 >>> 0;
|
|
116
|
+
let t = state;
|
|
117
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
118
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
119
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function pickWeighted(items, random) {
|
|
123
|
+
const total = items.reduce((sum, item) => sum + item.score, 0);
|
|
124
|
+
let point = random() * total;
|
|
125
|
+
for (const item of items) {
|
|
126
|
+
point -= item.score;
|
|
127
|
+
if (point < 0) return item;
|
|
128
|
+
}
|
|
129
|
+
return items[items.length - 1];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// packages/core/src/chaos/judge.ts
|
|
133
|
+
var errorKinds = ["app-error", "crash", "console", "network", "blank", "login-redirect", "stuck"];
|
|
134
|
+
var guardAbortText = "ERR_BLOCKED_BY_CLIENT";
|
|
135
|
+
var relaunchGraceMs = 5e3;
|
|
136
|
+
function causedByChaos(issue, context) {
|
|
137
|
+
if (issue.kind === "console" && issue.message.includes(guardAbortText)) return true;
|
|
138
|
+
if (context.guardBlocked && (issue.kind === "console" || issue.kind === "network")) return true;
|
|
139
|
+
return issue.kind === "blank" && context.sinceRelaunchMs < relaunchGraceMs;
|
|
140
|
+
}
|
|
141
|
+
function judgeIssues(issues, context) {
|
|
142
|
+
const errors = issues.filter((issue) => errorKinds.includes(issue.kind) && !causedByChaos(issue, context));
|
|
143
|
+
return { errors, warnings: issues.filter((issue) => !errors.includes(issue)) };
|
|
144
|
+
}
|
|
145
|
+
var dedupeMessageLength = 80;
|
|
146
|
+
function errorKey(issue) {
|
|
147
|
+
return `${issue.kind}|${issue.message.replace(/\d+/g, "N").slice(0, dedupeMessageLength)}`;
|
|
148
|
+
}
|
|
149
|
+
function dedupeErrors(judgement, seen) {
|
|
150
|
+
const fresh = [];
|
|
151
|
+
const repeated = [];
|
|
152
|
+
for (const issue of judgement.errors) {
|
|
153
|
+
const key = errorKey(issue);
|
|
154
|
+
if (seen.has(key)) {
|
|
155
|
+
repeated.push(issue);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
seen.add(key);
|
|
159
|
+
fresh.push(issue);
|
|
160
|
+
}
|
|
161
|
+
return { errors: fresh, warnings: [...judgement.warnings, ...repeated] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// packages/engine/src/chaos/run.ts
|
|
165
|
+
var settleMs = 1500;
|
|
166
|
+
var backDuringLoadMs = 300;
|
|
167
|
+
var backgroundMs = 3e3;
|
|
168
|
+
var restartWaitMs = 8e3;
|
|
169
|
+
var doubleTapCount = 3;
|
|
170
|
+
var stuckAfterFailures = 3;
|
|
171
|
+
var recordedCandidates = 8;
|
|
172
|
+
async function attach(deps, blocked) {
|
|
173
|
+
const driver = await deps.openDriver();
|
|
174
|
+
const stopGuard = await deps.guard(driver, (line) => blocked.push(line));
|
|
175
|
+
const ring = new LogRing();
|
|
176
|
+
const stopCollectors = await startCollectors(deps.device, deps.appId, driver, ring);
|
|
177
|
+
return { driver, stopGuard, stopCollectors, ring, blocked };
|
|
178
|
+
}
|
|
179
|
+
async function detach(session) {
|
|
180
|
+
await session.stopCollectors().catch(() => void 0);
|
|
181
|
+
await session.stopGuard().catch(() => void 0);
|
|
182
|
+
await session.driver.close().catch(() => void 0);
|
|
183
|
+
}
|
|
184
|
+
var adb = (session, ...args) => session.deps.exec("adb", ["-s", session.deps.device.id, "shell", ...args]);
|
|
185
|
+
async function launchApp(session) {
|
|
186
|
+
await adb(session, "monkey", "-p", session.deps.appId, "1");
|
|
187
|
+
}
|
|
188
|
+
function parseForegroundPackage(dumpsys) {
|
|
189
|
+
return /topResumedActivity=ActivityRecord\{\S+ \S+ ([^/\s]+)\//.exec(dumpsys)?.[1] ?? null;
|
|
190
|
+
}
|
|
191
|
+
async function foregroundPackage(session) {
|
|
192
|
+
return parseForegroundPackage(await adb(session, "dumpsys", "activity", "activities").catch(() => ""));
|
|
193
|
+
}
|
|
194
|
+
async function returnToApp(session) {
|
|
195
|
+
await launchApp(session);
|
|
196
|
+
await sleep(settleMs);
|
|
197
|
+
const readable = await session.driver.currentPath().then(() => true, () => false);
|
|
198
|
+
if (!readable) await restartApp(session);
|
|
199
|
+
}
|
|
200
|
+
var linkTargetsScript = `(() => {
|
|
201
|
+
const nameOf = (el) => (el.getAttribute("aria-label") || el.innerText || el.title || "").replace(/\\s+/g, " ").trim();
|
|
202
|
+
return Array.from(document.querySelectorAll("a[href]")).flatMap((el) => {
|
|
203
|
+
try {
|
|
204
|
+
const url = new URL(el.href);
|
|
205
|
+
return url.origin === location.origin ? [[nameOf(el), url.pathname]] : [];
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
})()`;
|
|
211
|
+
var dialogCountScript = `document.querySelectorAll('[role="dialog"], [aria-modal="true"]').length`;
|
|
212
|
+
async function readScreen(driver) {
|
|
213
|
+
const path3 = await driver.currentPath();
|
|
214
|
+
const all = await driver.evaluate(listActionsScript).catch(() => []);
|
|
215
|
+
const seen = /* @__PURE__ */ new Set();
|
|
216
|
+
const targets = [];
|
|
217
|
+
for (const item of all) {
|
|
218
|
+
if (item.role === "input" || hasForbiddenWord(item.name, driver.app.guard.forbiddenButtonWords) || isAvoidedName(item.name, driver.app.guard.avoidedNamePattern)) continue;
|
|
219
|
+
const key = `${item.role}:${item.name}`;
|
|
220
|
+
if (seen.has(key)) continue;
|
|
221
|
+
seen.add(key);
|
|
222
|
+
targets.push({ role: item.role, name: item.name });
|
|
223
|
+
}
|
|
224
|
+
const text = await driver.evaluate("(document.body ? document.body.innerText : '').replace(/\\s+/g, ' ')").catch(() => "");
|
|
225
|
+
const dialogs = await driver.evaluate(dialogCountScript).catch(() => 0);
|
|
226
|
+
const hrefs = new Map(await driver.evaluate(linkTargetsScript).catch(() => []));
|
|
227
|
+
for (const target of targets) if (target.role === "link" && hrefs.has(target.name)) target.to = hrefs.get(target.name);
|
|
228
|
+
return { path: path3, targets, state: `${path3}|\uD31D\uC5C5 ${dialogs}`, fingerprint: `${path3}|${text.length}|${text.slice(0, 200)}` };
|
|
229
|
+
}
|
|
230
|
+
async function pressBack(session, targets) {
|
|
231
|
+
const inPage = targets.find((target) => session.driver.app.inPageBackNames.includes(target.name));
|
|
232
|
+
if (inPage) return session.driver.clickButton(inPage.name, inPage.role);
|
|
233
|
+
await adb(session, "input", "keyevent", "KEYCODE_BACK");
|
|
234
|
+
}
|
|
235
|
+
async function perform(session, action, targets, random) {
|
|
236
|
+
const { driver } = session;
|
|
237
|
+
if (action.kind === "press") return driver.clickButton(action.name ?? "", action.role);
|
|
238
|
+
if (action.kind === "tab") {
|
|
239
|
+
const tabNames = Object.keys(driver.app.tabPaths);
|
|
240
|
+
const tabs = targets.filter((target) => target.role === "link" && tabNames.includes(target.name));
|
|
241
|
+
const tab = tabs[Math.floor(random() * tabs.length)];
|
|
242
|
+
action.role = tab.role;
|
|
243
|
+
action.name = tab.name;
|
|
244
|
+
return driver.clickButton(tab.name, tab.role);
|
|
245
|
+
}
|
|
246
|
+
if (action.kind === "back") return pressBack(session, targets);
|
|
247
|
+
if (action.kind === "homeReset") return driver.goHome?.();
|
|
248
|
+
if (action.kind === "doubleTap") {
|
|
249
|
+
for (let i = 0; i < doubleTapCount; i += 1) await driver.clickButton(action.name ?? "", action.role);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (action.kind === "backDuringLoad") {
|
|
253
|
+
const pressable = targets.filter((target2) => !driver.app.inPageBackNames.includes(target2.name));
|
|
254
|
+
const target = pressable[Math.floor(random() * pressable.length)];
|
|
255
|
+
action.role = target.role;
|
|
256
|
+
action.name = target.name;
|
|
257
|
+
await driver.clickButton(target.name, target.role);
|
|
258
|
+
await sleep(backDuringLoadMs);
|
|
259
|
+
return pressBack(session, []);
|
|
260
|
+
}
|
|
261
|
+
if (action.kind === "background") {
|
|
262
|
+
await adb(session, "input", "keyevent", "KEYCODE_HOME");
|
|
263
|
+
await sleep(backgroundMs);
|
|
264
|
+
await launchApp(session);
|
|
265
|
+
session.relaunchedAt = Date.now();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
return restartApp(session);
|
|
269
|
+
}
|
|
270
|
+
async function restartApp(session) {
|
|
271
|
+
await adb(session, "am", "force-stop", session.deps.appId);
|
|
272
|
+
await detach(session);
|
|
273
|
+
await launchApp(session);
|
|
274
|
+
await sleep(restartWaitMs);
|
|
275
|
+
Object.assign(session, await attach(session.deps, session.blocked));
|
|
276
|
+
session.relaunchedAt = Date.now();
|
|
277
|
+
}
|
|
278
|
+
async function startEpisode(session, log) {
|
|
279
|
+
try {
|
|
280
|
+
await session.driver.goHome?.();
|
|
281
|
+
return;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
log(`\uD648 \uC774\uB3D9 \uC2E4\uD328, \uC571 \uC7AC\uC2DC\uC791: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`);
|
|
284
|
+
}
|
|
285
|
+
await restartApp(session);
|
|
286
|
+
await session.driver.goHome?.().catch((error) => log(`\uC7AC\uC2DC\uC791 \uB4A4\uC5D0\uB3C4 \uD648 \uC774\uB3D9 \uC2E4\uD328: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`));
|
|
287
|
+
}
|
|
288
|
+
function describe(action) {
|
|
289
|
+
return action.name ? `${action.kind} \u300C${action.name}\u300D` : action.kind;
|
|
290
|
+
}
|
|
291
|
+
function bumpStats(stats, key, outcome) {
|
|
292
|
+
const current = stats.get(key) ?? { tries: 0, openedNew: false, errors: 0, dead: 0 };
|
|
293
|
+
stats.set(key, {
|
|
294
|
+
tries: current.tries + 1,
|
|
295
|
+
openedNew: current.openedNew || outcome.newState,
|
|
296
|
+
errors: current.errors + outcome.errors,
|
|
297
|
+
dead: current.dead + (outcome.dead ? 1 : 0)
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async function saveScreenshot(session, dir, name) {
|
|
301
|
+
const png = await session.driver.screenshot().catch(() => null);
|
|
302
|
+
if (!png) return void 0;
|
|
303
|
+
writeFileSync(path.join(dir, name), png);
|
|
304
|
+
return name;
|
|
305
|
+
}
|
|
306
|
+
async function runChaos(deps) {
|
|
307
|
+
const log = deps.log ?? (() => void 0);
|
|
308
|
+
const random = seededRandom(deps.seed);
|
|
309
|
+
const runId = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-s${deps.seed}`;
|
|
310
|
+
const dir = path.join(deps.resultsDir, runId);
|
|
311
|
+
mkdirSync(dir, { recursive: true });
|
|
312
|
+
const run = { runId, deviceId: deps.device.id, seed: deps.seed, startedAt: (/* @__PURE__ */ new Date()).toISOString(), finishedAt: null, steps: [], states: 0, blocked: [] };
|
|
313
|
+
const save = () => writeFileSync(path.join(dir, "run.json"), `${JSON.stringify(run, null, 2)}
|
|
314
|
+
`);
|
|
315
|
+
const blocked = [];
|
|
316
|
+
const session = { deps, relaunchedAt: 0, ...await attach(deps, blocked) };
|
|
317
|
+
const stats = /* @__PURE__ */ new Map();
|
|
318
|
+
const seenStates = /* @__PURE__ */ new Set();
|
|
319
|
+
const visitedPaths = /* @__PURE__ */ new Set();
|
|
320
|
+
const seenErrors = /* @__PURE__ */ new Set();
|
|
321
|
+
let failuresInRow = 0;
|
|
322
|
+
let blockedAtPreviousStep = 0;
|
|
323
|
+
const deadline = Date.now() + deps.minutes * 6e4;
|
|
324
|
+
try {
|
|
325
|
+
for (let episode = 1; episode <= deps.episodes && Date.now() < deadline; episode += 1) {
|
|
326
|
+
await startEpisode(session, log);
|
|
327
|
+
let lastPressed;
|
|
328
|
+
let stay = 0;
|
|
329
|
+
let lastPath = "";
|
|
330
|
+
for (let step = 1; step <= deps.stepsPerEpisode && Date.now() < deadline; step += 1) {
|
|
331
|
+
const before = await readScreen(session.driver).catch(async (error) => {
|
|
332
|
+
log(`\uD654\uBA74 \uC77D\uAE30 \uC2E4\uD328, \uC571\uC73C\uB85C \uBCF5\uADC0: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`);
|
|
333
|
+
await returnToApp(session);
|
|
334
|
+
return readScreen(session.driver);
|
|
335
|
+
});
|
|
336
|
+
seenStates.add(before.state);
|
|
337
|
+
visitedPaths.add(before.path);
|
|
338
|
+
stay = before.path === lastPath ? stay + 1 : 0;
|
|
339
|
+
lastPath = before.path;
|
|
340
|
+
const scored = scoreAll(chaosCandidates({ path: before.path, targets: before.targets, lastPressed, nav: { ...session.driver.app, noPressPathPattern: session.driver.app.guard.noPressPathPattern } }), (key2) => stats.get(key2), before.path, stay, (path3) => visitedPaths.has(path3));
|
|
341
|
+
const chosen = pickWeighted(scored, random);
|
|
342
|
+
const key = actionKey(chosen.action);
|
|
343
|
+
const recordKey = statsKey(before.path, chosen.action);
|
|
344
|
+
const action = { ...chosen.action };
|
|
345
|
+
const since = Date.now();
|
|
346
|
+
const blockedBefore = blockedAtPreviousStep;
|
|
347
|
+
blockedAtPreviousStep = blocked.length;
|
|
348
|
+
let failure;
|
|
349
|
+
try {
|
|
350
|
+
await perform(session, action, before.targets, random);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
failure = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
353
|
+
}
|
|
354
|
+
await sleep(settleMs);
|
|
355
|
+
const foreground = await foregroundPackage(session);
|
|
356
|
+
const leftApp = foreground && foreground !== deps.appId ? foreground : void 0;
|
|
357
|
+
const after = leftApp ? { ...before, path: `(\uB2E4\uB978 \uC571 ${leftApp})`, state: `(\uB2E4\uB978 \uC571 ${leftApp})`, fingerprint: "" } : await readScreen(session.driver).catch(() => ({ ...before, path: "(\uC77D\uAE30 \uC2E4\uD328)", state: "(\uC77D\uAE30 \uC2E4\uD328)", fingerprint: "" }));
|
|
358
|
+
const newState = !seenStates.has(after.state);
|
|
359
|
+
seenStates.add(after.state);
|
|
360
|
+
const dead = action.kind === "press" && !failure && after.fingerprint === before.fingerprint;
|
|
361
|
+
const issues = await probeScreen(session.driver, session.ring, since, deps.allowlist).catch(() => []);
|
|
362
|
+
const { errors, warnings } = dedupeErrors(judgeIssues(issues, { sinceRelaunchMs: Date.now() - session.relaunchedAt, guardBlocked: blocked.length > blockedBefore }), seenErrors);
|
|
363
|
+
bumpStats(stats, recordKey, { newState, dead, errors: errors.length });
|
|
364
|
+
if (action.kind === "press") lastPressed = { role: action.role ?? "button", name: action.name ?? "" };
|
|
365
|
+
const record = {
|
|
366
|
+
episode,
|
|
367
|
+
step,
|
|
368
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
369
|
+
from: before.path,
|
|
370
|
+
state: before.state,
|
|
371
|
+
candidates: [...scored].sort((a, b) => b.score - a.score).slice(0, recordedCandidates).map((item) => ({ key: actionKey(item.action), score: Number(item.score.toFixed(3)), probability: Number(item.probability.toFixed(3)) })),
|
|
372
|
+
chosen: action.name ? `${key}${chosen.action.name ? "" : action.name}` : key,
|
|
373
|
+
chosenProbability: Number(chosen.probability.toFixed(3)),
|
|
374
|
+
to: after.path,
|
|
375
|
+
newState,
|
|
376
|
+
dead,
|
|
377
|
+
errors,
|
|
378
|
+
warnings,
|
|
379
|
+
...failure ? { failure } : {},
|
|
380
|
+
...leftApp ? { leftApp } : {}
|
|
381
|
+
};
|
|
382
|
+
failuresInRow = failure ? failuresInRow + 1 : 0;
|
|
383
|
+
if (failuresInRow >= stuckAfterFailures) {
|
|
384
|
+
record.errors.push({ kind: "stuck", message: `${before.path}\uC5D0\uC11C \uD589\uB3D9 ${failuresInRow}\uBC88\uC774 \uC5F0\uB2EC\uC544 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4(\uB9C8\uC9C0\uB9C9: ${failure}). \uC571\uC744 \uC7AC\uC2DC\uC791\uD569\uB2C8\uB2E4.` });
|
|
385
|
+
}
|
|
386
|
+
if (record.errors.length > 0) record.screenshot = await saveScreenshot(session, dir, `e${episode}-s${step}.png`);
|
|
387
|
+
run.steps.push(record);
|
|
388
|
+
run.states = seenStates.size;
|
|
389
|
+
run.blocked = [...blocked];
|
|
390
|
+
save();
|
|
391
|
+
if (failuresInRow >= stuckAfterFailures) {
|
|
392
|
+
failuresInRow = 0;
|
|
393
|
+
await restartApp(session);
|
|
394
|
+
await startEpisode(session, log);
|
|
395
|
+
} else if (leftApp) {
|
|
396
|
+
await returnToApp(session);
|
|
397
|
+
}
|
|
398
|
+
log(`[${episode}-${step}] ${before.path} \u2192 ${describe(action)} (${(chosen.probability * 100).toFixed(0)}%) \u2192 ${after.path}${newState ? " \uC0C8 \uC0C1\uD0DC" : ""}${dead ? " \uBC18\uC751 \uC5C6\uC74C" : ""}${failure ? ` \uC2E4\uD328: ${failure}` : ""}${errors.length ? ` \uC624\uB958: ${errors.map((e) => e.kind).join(",")}` : ""}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} finally {
|
|
402
|
+
run.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
403
|
+
save();
|
|
404
|
+
await detach(session);
|
|
405
|
+
}
|
|
406
|
+
return run;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// apps/cli/src/chaos.ts
|
|
410
|
+
function option(name, fallback) {
|
|
411
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
412
|
+
return index >= 0 ? process.argv[index + 1] : fallback;
|
|
413
|
+
}
|
|
414
|
+
async function farmBusy() {
|
|
415
|
+
const response = await fetch(`http://127.0.0.1:${farmPort()}/api/devices/runs/latest`).catch(() => null);
|
|
416
|
+
if (!response?.ok) return false;
|
|
417
|
+
const run = await response.json().catch(() => null);
|
|
418
|
+
return Boolean(run && run.finishedAt === null);
|
|
419
|
+
}
|
|
420
|
+
async function main() {
|
|
421
|
+
const app = await cliApp();
|
|
422
|
+
const serial = option("device");
|
|
423
|
+
if (!serial) throw new Error("--device <Android serial>\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.");
|
|
424
|
+
if (await farmBusy()) throw new Error("\uB514\uBC14\uC774\uC2A4\uD31C \uC810\uAC80\uC774 \uB3C4\uB294 \uC911\uC774\uB77C \uAE30\uAE30\uB97C \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
425
|
+
const device = (await listDevices(execFileText, app.appIds.dev)).find((item) => item.id === serial && item.platform === "android");
|
|
426
|
+
if (!device) throw new Error(`\uC5F0\uACB0\uB41C Android \uAE30\uAE30\uAC00 \uC544\uB2D9\uB2C8\uB2E4: ${serial}`);
|
|
427
|
+
const origin = webOrigin(app, "dev");
|
|
428
|
+
const { allowlist } = loadExploreInputs();
|
|
429
|
+
const noGuard = process.argv.includes("--no-guard");
|
|
430
|
+
if (noGuard) console.log(`\uC4F0\uAE30 \uC694\uCCAD\uC744 \uB9C9\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4(dev ${origin}).`);
|
|
431
|
+
const run = await runChaos({
|
|
432
|
+
device,
|
|
433
|
+
appId: app.appIds.dev,
|
|
434
|
+
exec: execFileText,
|
|
435
|
+
openDriver: () => openAndroidDriver(device.id, app.appIds.dev, origin, app),
|
|
436
|
+
guard: noGuard ? async () => async () => void 0 : (driver, onBlocked) => defaultGuard(driver, origin, allowlist, (request) => onBlocked(`${request.method} ${request.path}`)),
|
|
437
|
+
allowlist,
|
|
438
|
+
seed: Number(option("seed", String(Date.now() % 1e5))),
|
|
439
|
+
episodes: Number(option("episodes", "3")),
|
|
440
|
+
stepsPerEpisode: Number(option("steps", "50")),
|
|
441
|
+
minutes: Number(option("minutes", "60")),
|
|
442
|
+
resultsDir: path2.resolve("test-results/chaos"),
|
|
443
|
+
log: (line) => console.log(line)
|
|
444
|
+
});
|
|
445
|
+
const errors = run.steps.filter((step) => step.errors.length > 0);
|
|
446
|
+
console.log(`
|
|
447
|
+
\uB05D: \uC2A4\uD15D ${run.steps.length} \xB7 \uC0C1\uD0DC ${run.states} \xB7 \uC624\uB958 \uC2A4\uD15D ${errors.length} \xB7 \uB9C9\uC740 \uC4F0\uAE30 ${run.blocked.length}`);
|
|
448
|
+
console.log(`\uAE30\uB85D: test-results/chaos/${run.runId}/run.json`);
|
|
449
|
+
}
|
|
450
|
+
main().catch((error) => {
|
|
451
|
+
console.error(error instanceof Error ? error.message : error);
|
|
452
|
+
process.exitCode = 1;
|
|
453
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runRule
|
|
3
|
+
} from "./chunk-OUNLQI2N.js";
|
|
4
|
+
import {
|
|
5
|
+
samePath,
|
|
6
|
+
sleep
|
|
7
|
+
} from "./chunk-NLX3V7ZJ.js";
|
|
8
|
+
|
|
9
|
+
// packages/core/src/qa/run.ts
|
|
10
|
+
var qaSettleMs = 4e3;
|
|
11
|
+
var qaFetchWaitMs = 15e3;
|
|
12
|
+
var qaPollMs = 500;
|
|
13
|
+
var qaAttempts = 2;
|
|
14
|
+
var resultKey = "__deviceFarmQa";
|
|
15
|
+
function fetchScript(apiOrigin, apis, sessionPath = "/api/auth/session") {
|
|
16
|
+
return `(function () {
|
|
17
|
+
window.${resultKey} = null;
|
|
18
|
+
var apis = ${JSON.stringify(Object.entries(apis))};
|
|
19
|
+
fetch(${JSON.stringify(sessionPath)}).then(function (r) { return r.json(); }).then(function (session) {
|
|
20
|
+
var user = (session && session.user) || {};
|
|
21
|
+
if (!user.id || !user.accessToken) throw new Error("\uB85C\uADF8\uC778 \uC138\uC158\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
22
|
+
return Promise.all(apis.map(function (entry) {
|
|
23
|
+
var path = entry[1].replace("{userId}", encodeURIComponent(String(user.id)));
|
|
24
|
+
var started = Date.now();
|
|
25
|
+
return fetch(${JSON.stringify(apiOrigin)} + path, { method: "GET", headers: { Authorization: "Bearer " + user.accessToken }, credentials: "include" })
|
|
26
|
+
.then(function (r) { return r.text().then(function (body) { return { name: entry[0], path: path.replace(String(user.id), "{userId}"), status: r.status, durationMs: Date.now() - started, body: body }; }); });
|
|
27
|
+
}));
|
|
28
|
+
}).then(function (list) { window.${resultKey} = { ok: true, list: list }; }, function (error) { window.${resultKey} = { ok: false, error: String(error && error.message || error) }; });
|
|
29
|
+
return true;
|
|
30
|
+
})()`;
|
|
31
|
+
}
|
|
32
|
+
async function fetchInPage(driver, apiOrigin, apis, waitMs) {
|
|
33
|
+
await driver.evaluate(fetchScript(apiOrigin, apis, driver.app.qa.sessionPath));
|
|
34
|
+
const deadline = Date.now() + waitMs;
|
|
35
|
+
while (Date.now() < deadline) {
|
|
36
|
+
await sleep(qaPollMs);
|
|
37
|
+
const result = await driver.evaluate(`window.${resultKey}`);
|
|
38
|
+
if (result) return result;
|
|
39
|
+
}
|
|
40
|
+
return { ok: false, error: `${waitMs / 1e3}\uCD08 \uC548\uC5D0 API \uC751\uB2F5\uC744 \uBC1B\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.` };
|
|
41
|
+
}
|
|
42
|
+
function parseBody(body) {
|
|
43
|
+
try {
|
|
44
|
+
return { value: JSON.parse(body) };
|
|
45
|
+
} catch {
|
|
46
|
+
return { error: "JSON\uC774 \uC544\uB2D9\uB2C8\uB2E4." };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function openScreen(driver, path) {
|
|
50
|
+
const { tabPaths, homeTabName } = driver.app;
|
|
51
|
+
const tab = Object.entries(tabPaths).find(([, tabPath]) => samePath(tabPath, path));
|
|
52
|
+
if (!tab || !driver.goHome) return driver.navigate(path);
|
|
53
|
+
await driver.goHome();
|
|
54
|
+
if (tab[0] !== homeTabName) await driver.clickButton(tab[0], "link");
|
|
55
|
+
}
|
|
56
|
+
var failedReport = (screen, check) => ({ path: screen.path, name: screen.name, apis: [], checks: [check] });
|
|
57
|
+
async function checkOpenScreen(driver, screen, apiOrigin, fetchWaitMs) {
|
|
58
|
+
const raw = await fetchInPage(driver, apiOrigin, screen.apis, fetchWaitMs);
|
|
59
|
+
if (!raw.ok) return failedReport(screen, { id: "fetch", title: "API \uC870\uD68C", ok: false, message: raw.error });
|
|
60
|
+
const responses = {};
|
|
61
|
+
const apis = raw.list.map(({ name, path, status, durationMs, body }) => {
|
|
62
|
+
const parsed = parseBody(body);
|
|
63
|
+
if (parsed.value !== void 0 && status === 200) responses[name] = parsed.value;
|
|
64
|
+
return { name, path, status, durationMs, ...parsed.error ? { error: parsed.error } : {} };
|
|
65
|
+
});
|
|
66
|
+
const apiChecks = apis.filter((api) => api.status !== 200 || api.error).map((api) => ({ id: `api-${api.name}`, title: `API ${api.path}`, ok: false, message: api.error ?? `\uC0C1\uD0DC ${api.status}` }));
|
|
67
|
+
const text = await driver.evaluate("document.body.innerText");
|
|
68
|
+
return { path: screen.path, name: screen.name, apis, checks: [...apiChecks, ...screen.rules.map((rule) => runRule(rule, { responses, text }))] };
|
|
69
|
+
}
|
|
70
|
+
async function openAndSettle(driver, screen, settleMs) {
|
|
71
|
+
await openScreen(driver, screen.path);
|
|
72
|
+
await sleep(settleMs);
|
|
73
|
+
const opened = await driver.currentPath();
|
|
74
|
+
return samePath(screen.path, opened) ? null : failedReport(screen, { id: "open", title: "\uD654\uBA74 \uC5F4\uAE30", ok: false, message: `${screen.path} \uB300\uC2E0 ${opened} \uD654\uBA74\uC774 \uC5F4\uB824 \uC788\uC2B5\uB2C8\uB2E4.` });
|
|
75
|
+
}
|
|
76
|
+
async function runScreenQa(driver, screen, options) {
|
|
77
|
+
const { apiOrigin, settleMs = qaSettleMs, fetchWaitMs = qaFetchWaitMs } = options;
|
|
78
|
+
const attempt = async () => await openAndSettle(driver, screen, settleMs) ?? await checkOpenScreen(driver, screen, apiOrigin, fetchWaitMs);
|
|
79
|
+
let report = await attempt();
|
|
80
|
+
for (let tried = 1; tried < qaAttempts && report.checks.some((check) => !check.ok); tried += 1) report = await attempt();
|
|
81
|
+
return report;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export {
|
|
85
|
+
runScreenQa
|
|
86
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// apps/cli/src/args.ts
|
|
2
|
+
var defaultPort = 5173;
|
|
3
|
+
var versionPattern = /^\d+\.\d+\.\d+(\+\d+)?$/;
|
|
4
|
+
var maxPort = 65535;
|
|
5
|
+
var runOptions = ["build", "version", "devices", "port"];
|
|
6
|
+
var exploreOptions = ["build", "devices", "port"];
|
|
7
|
+
function readOptions(argv, allowed) {
|
|
8
|
+
const options = /* @__PURE__ */ new Map();
|
|
9
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
10
|
+
const token = argv[index];
|
|
11
|
+
if (!token.startsWith("--")) return { ok: false, error: `\uC54C \uC218 \uC5C6\uB294 \uC778\uC790\uC785\uB2C8\uB2E4: ${token}` };
|
|
12
|
+
const [rawKey, inline] = token.slice(2).split(/=(.*)/s, 2);
|
|
13
|
+
if (!allowed.includes(rawKey)) return { ok: false, error: `\uC54C \uC218 \uC5C6\uB294 \uC635\uC158\uC785\uB2C8\uB2E4: --${rawKey}` };
|
|
14
|
+
const value = inline ?? argv[index + 1];
|
|
15
|
+
if (value === void 0 || value.startsWith("--")) return { ok: false, error: `--${rawKey} \uAC12\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.` };
|
|
16
|
+
if (inline === void 0) index += 1;
|
|
17
|
+
options.set(rawKey, value);
|
|
18
|
+
}
|
|
19
|
+
return { ok: true, value: options };
|
|
20
|
+
}
|
|
21
|
+
function parseBuild(value) {
|
|
22
|
+
if (value === void 0) return { ok: false, error: "--build\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4. (dev | prod)" };
|
|
23
|
+
if (value === "dev" || value === "prod") return { ok: true, value };
|
|
24
|
+
return { ok: false, error: `--build\uB294 dev \uB610\uB294 prod\uC5EC\uC57C \uD569\uB2C8\uB2E4: ${value}` };
|
|
25
|
+
}
|
|
26
|
+
function parseVersion(value) {
|
|
27
|
+
if (value === void 0) return { ok: false, error: "--version\uC774 \uD544\uC694\uD569\uB2C8\uB2E4. (\uC608: 2.1.5 \uB610\uB294 2.1.5+412)" };
|
|
28
|
+
if (versionPattern.test(value)) return { ok: true, value };
|
|
29
|
+
return { ok: false, error: `--version \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4: ${value} (\uC608: 2.1.5 \uB610\uB294 2.1.5+412)` };
|
|
30
|
+
}
|
|
31
|
+
function parseDevices(value) {
|
|
32
|
+
if (value === void 0 || value === "all") return { ok: true, value: "all" };
|
|
33
|
+
const ids = value.split(",").map((id) => id.trim());
|
|
34
|
+
if (ids.some((id) => id === "")) return { ok: false, error: `--devices \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4: ${value} (all \uB610\uB294 id,id)` };
|
|
35
|
+
return { ok: true, value: ids };
|
|
36
|
+
}
|
|
37
|
+
function parsePort(value) {
|
|
38
|
+
if (value === void 0) return { ok: true, value: defaultPort };
|
|
39
|
+
const port = Number(value);
|
|
40
|
+
if (/^\d+$/.test(value) && port >= 1 && port <= maxPort) return { ok: true, value: port };
|
|
41
|
+
return { ok: false, error: `--port\uB294 1~${maxPort} \uC815\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4: ${value}` };
|
|
42
|
+
}
|
|
43
|
+
function parseRunArgs(argv) {
|
|
44
|
+
const options = readOptions(argv, runOptions);
|
|
45
|
+
if (!options.ok) return options;
|
|
46
|
+
const kind = parseBuild(options.value.get("build"));
|
|
47
|
+
if (!kind.ok) return kind;
|
|
48
|
+
const version = parseVersion(options.value.get("version"));
|
|
49
|
+
if (!version.ok) return version;
|
|
50
|
+
const devices = parseDevices(options.value.get("devices"));
|
|
51
|
+
if (!devices.ok) return devices;
|
|
52
|
+
const port = parsePort(options.value.get("port"));
|
|
53
|
+
if (!port.ok) return port;
|
|
54
|
+
return { ok: true, value: { kind: kind.value, version: version.value, devices: devices.value, port: port.value } };
|
|
55
|
+
}
|
|
56
|
+
function parseExploreArgs(argv) {
|
|
57
|
+
const options = readOptions(argv, exploreOptions);
|
|
58
|
+
if (!options.ok) return options;
|
|
59
|
+
const devices = parseDevices(options.value.get("devices"));
|
|
60
|
+
if (!devices.ok) return devices;
|
|
61
|
+
const port = parsePort(options.value.get("port"));
|
|
62
|
+
if (!port.ok) return port;
|
|
63
|
+
const rawBuild = options.value.get("build");
|
|
64
|
+
if (rawBuild === void 0) return { ok: true, value: { devices: devices.value, port: port.value } };
|
|
65
|
+
const build = parseBuild(rawBuild);
|
|
66
|
+
if (!build.ok) return build;
|
|
67
|
+
return { ok: true, value: { devices: devices.value, port: port.value, build: build.value } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
defaultPort,
|
|
72
|
+
parseRunArgs,
|
|
73
|
+
parseExploreArgs
|
|
74
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadAppConfig,
|
|
3
|
+
loadDotEnv
|
|
4
|
+
} from "./chunk-NLX3V7ZJ.js";
|
|
5
|
+
|
|
6
|
+
// apps/cli/src/app.ts
|
|
7
|
+
async function cliApp() {
|
|
8
|
+
loadDotEnv();
|
|
9
|
+
return loadAppConfig();
|
|
10
|
+
}
|
|
11
|
+
var farmPort = () => Number(process.env.FARM_PORT) || 5173;
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
cliApp,
|
|
15
|
+
farmPort
|
|
16
|
+
};
|