@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,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizePath
|
|
3
|
+
} from "./chunk-NLX3V7ZJ.js";
|
|
4
|
+
|
|
5
|
+
// packages/core/src/record/recording.ts
|
|
6
|
+
function buttonOf(event) {
|
|
7
|
+
const moved = normalizePath(event.from) !== normalizePath(event.to);
|
|
8
|
+
if (!moved && event.texts.length === 0) return null;
|
|
9
|
+
return {
|
|
10
|
+
role: event.role,
|
|
11
|
+
name: event.name,
|
|
12
|
+
status: "reacted",
|
|
13
|
+
message: moved ? `${event.to}\uB85C \uC774\uB3D9` : "\uD654\uBA74 \uB0B4\uC6A9 \uBC14\uB01C",
|
|
14
|
+
...moved ? { to: event.to } : {},
|
|
15
|
+
...event.texts.length > 0 ? { texts: event.texts } : {}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function screensOf(events) {
|
|
19
|
+
const byFrom = /* @__PURE__ */ new Map();
|
|
20
|
+
for (const event of events) {
|
|
21
|
+
if (event.type !== "click") continue;
|
|
22
|
+
const button = buttonOf(event);
|
|
23
|
+
if (!button) continue;
|
|
24
|
+
const from = normalizePath(event.from);
|
|
25
|
+
byFrom.set(from, [...byFrom.get(from) ?? [], button]);
|
|
26
|
+
}
|
|
27
|
+
return [...byFrom.entries()].map(([from, buttons]) => ({ from, buttons }));
|
|
28
|
+
}
|
|
29
|
+
function stepsOf(events, startPath, { homePath, forbiddenClickNames }) {
|
|
30
|
+
if (normalizePath(startPath) !== homePath) return { steps: [], cut: `\uAE30\uB85D\uC744 ${startPath}\uC5D0\uC11C \uC2DC\uC791\uD574 \uD648\uBD80\uD130 \uB2E4\uC2DC \uB3CC \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`, lastTexts: [] };
|
|
31
|
+
const steps = [];
|
|
32
|
+
let lastTexts = [];
|
|
33
|
+
for (const event of events) {
|
|
34
|
+
if (event.type === "jump") {
|
|
35
|
+
if (normalizePath(event.to) === homePath) {
|
|
36
|
+
steps.push({ type: "home" });
|
|
37
|
+
lastTexts = [];
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
return { steps, cut: `${event.from}\uC5D0\uC11C \uD074\uB9AD \uC5C6\uC774 ${event.to}\uB85C \uBC14\uB00C\uC5C8\uC2B5\uB2C8\uB2E4.`, lastTexts };
|
|
41
|
+
}
|
|
42
|
+
if (forbiddenClickNames.includes(event.name)) return { steps, cut: `\uAE08\uC9C0 \uBC84\uD2BC \u300C${event.name}\u300D\uC744 \uB20C\uB800\uC2B5\uB2C8\uB2E4.`, lastTexts };
|
|
43
|
+
steps.push({ type: "click", target: { role: event.role, name: event.name } });
|
|
44
|
+
lastTexts = event.texts;
|
|
45
|
+
}
|
|
46
|
+
return { steps, cut: null, lastTexts };
|
|
47
|
+
}
|
|
48
|
+
function buildRecording(events, startPath, rules) {
|
|
49
|
+
const { steps, cut, lastTexts } = stepsOf(events, startPath, rules);
|
|
50
|
+
const withExpect = steps.some((step) => step.type === "click") && lastTexts.length > 0 ? [...steps, { type: "expectText", text: lastTexts[0] }] : steps;
|
|
51
|
+
return { steps: withExpect, screens: screensOf(events), cut };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
buildRecording
|
|
56
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// packages/devices/src/registry.ts
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
function parseAdbDevices(stdout) {
|
|
6
|
+
return stdout.split("\n").slice(1).map((line) => line.trim().split(/\s+/)).filter((cells) => cells.length >= 2 && cells[1] === "device").map((cells) => cells[0]);
|
|
7
|
+
}
|
|
8
|
+
function isConnectedIphone(device) {
|
|
9
|
+
if (device.hardwareProperties?.platform !== "iOS") return false;
|
|
10
|
+
const connection = device.connectionProperties;
|
|
11
|
+
return connection?.transportType === "wired" || connection?.tunnelState === "connected";
|
|
12
|
+
}
|
|
13
|
+
function parseDevicectlList(json) {
|
|
14
|
+
const parsed = JSON.parse(json);
|
|
15
|
+
return (parsed.result?.devices ?? []).filter(isConnectedIphone).map((device) => ({
|
|
16
|
+
id: device.hardwareProperties?.udid ?? "",
|
|
17
|
+
model: device.hardwareProperties?.marketingName ?? "",
|
|
18
|
+
osVersion: device.deviceProperties?.osVersionNumber ?? ""
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
async function devicectlJson(exec, args) {
|
|
22
|
+
const dir = mkdtempSync(path.join(tmpdir(), "devicectl-"));
|
|
23
|
+
const file = path.join(dir, "out.json");
|
|
24
|
+
try {
|
|
25
|
+
await exec("xcrun", ["devicectl", ...args, "--json-output", file]);
|
|
26
|
+
return readFileSync(file, "utf8");
|
|
27
|
+
} finally {
|
|
28
|
+
rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function parseVersionName(stdout) {
|
|
32
|
+
return /versionName=(\S+)/.exec(stdout)?.[1] ?? null;
|
|
33
|
+
}
|
|
34
|
+
function parseAndroidAppVersion(stdout) {
|
|
35
|
+
const name = parseVersionName(stdout);
|
|
36
|
+
const code = /versionCode=(\d+)/.exec(stdout)?.[1];
|
|
37
|
+
return name && code ? `${name}+${code}` : name;
|
|
38
|
+
}
|
|
39
|
+
async function adbShell(exec, serial, args) {
|
|
40
|
+
return (await exec("adb", ["-s", serial, "shell", ...args])).trim();
|
|
41
|
+
}
|
|
42
|
+
async function androidDevice(exec, serial, appId) {
|
|
43
|
+
const model = await adbShell(exec, serial, ["getprop", "ro.product.model"]);
|
|
44
|
+
const osVersion = await adbShell(exec, serial, ["getprop", "ro.build.version.release"]);
|
|
45
|
+
const appVersion = parseAndroidAppVersion(await adbShell(exec, serial, ["dumpsys", "package", appId]));
|
|
46
|
+
return { id: serial, platform: "android", model, osVersion, appVersion };
|
|
47
|
+
}
|
|
48
|
+
function parseDevicectlAppVersion(json, appId) {
|
|
49
|
+
const parsed = JSON.parse(json);
|
|
50
|
+
const app = (parsed.result?.apps ?? []).find((item) => item.bundleIdentifier === appId);
|
|
51
|
+
if (app?.version && app.bundleVersion) return `${app.version}+${app.bundleVersion}`;
|
|
52
|
+
return app?.version ?? app?.bundleVersion ?? null;
|
|
53
|
+
}
|
|
54
|
+
async function iosAppVersion(exec, udid, appId) {
|
|
55
|
+
const json = await devicectlJson(exec, [
|
|
56
|
+
"device",
|
|
57
|
+
"info",
|
|
58
|
+
"apps",
|
|
59
|
+
"--device",
|
|
60
|
+
udid,
|
|
61
|
+
"--bundle-id",
|
|
62
|
+
appId,
|
|
63
|
+
"--include-all-apps"
|
|
64
|
+
]);
|
|
65
|
+
return parseDevicectlAppVersion(json, appId);
|
|
66
|
+
}
|
|
67
|
+
async function listAndroid(exec, appId) {
|
|
68
|
+
const serials = parseAdbDevices(await exec("adb", ["devices"]));
|
|
69
|
+
return Promise.all(serials.map((serial) => androidDevice(exec, serial, appId)));
|
|
70
|
+
}
|
|
71
|
+
async function listIos(exec, appId) {
|
|
72
|
+
const listings = parseDevicectlList(await devicectlJson(exec, ["list", "devices"]));
|
|
73
|
+
return Promise.all(
|
|
74
|
+
listings.map(async (listing) => ({
|
|
75
|
+
...listing,
|
|
76
|
+
platform: "ios",
|
|
77
|
+
appVersion: await iosAppVersion(exec, listing.id, appId)
|
|
78
|
+
}))
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
async function listDevices(exec, appId, iosAppId = appId) {
|
|
82
|
+
const [android, ios] = await Promise.all([listAndroid(exec, appId), listIos(exec, iosAppId)]);
|
|
83
|
+
return [...android, ...ios];
|
|
84
|
+
}
|
|
85
|
+
async function installedAppVersion(exec, device, appId) {
|
|
86
|
+
if (device.platform === "ios") return iosAppVersion(exec, device.id, appId);
|
|
87
|
+
return parseAndroidAppVersion(await adbShell(exec, device.id, ["dumpsys", "package", appId]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// packages/devices/src/install.ts
|
|
91
|
+
import { readdirSync } from "fs";
|
|
92
|
+
import path2 from "path";
|
|
93
|
+
function unpackedIpaDir(ipa) {
|
|
94
|
+
return `${ipa}.d`;
|
|
95
|
+
}
|
|
96
|
+
function ipaAppPath(ipa) {
|
|
97
|
+
const payload = path2.join(unpackedIpaDir(ipa), "Payload");
|
|
98
|
+
const app = readdirSync(payload).find((name) => name.endsWith(".app"));
|
|
99
|
+
if (!app) throw new Error(`${payload} \uC5D0 .app\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.`);
|
|
100
|
+
return path2.join(payload, app);
|
|
101
|
+
}
|
|
102
|
+
function isIpa(file) {
|
|
103
|
+
return file.toLowerCase().endsWith(".ipa");
|
|
104
|
+
}
|
|
105
|
+
function installArgs(device, file) {
|
|
106
|
+
if (device.platform === "android") return ["adb", ["-s", device.id, "install", "-r", file]];
|
|
107
|
+
const bundle = isIpa(file) ? ipaAppPath(file) : file;
|
|
108
|
+
return ["xcrun", ["devicectl", "device", "install", "app", "--device", device.id, bundle]];
|
|
109
|
+
}
|
|
110
|
+
function launchArgs(device, appId) {
|
|
111
|
+
if (device.platform === "android") return ["adb", ["-s", device.id, "shell", "monkey", "-p", appId, "1"]];
|
|
112
|
+
return ["xcrun", ["devicectl", "device", "process", "launch", "--device", device.id, appId]];
|
|
113
|
+
}
|
|
114
|
+
async function unpackIpa(file, exec) {
|
|
115
|
+
await exec("unzip", ["-q", "-o", file, "-d", unpackedIpaDir(file)]);
|
|
116
|
+
}
|
|
117
|
+
function errorMessage(error) {
|
|
118
|
+
return error instanceof Error ? error.message : String(error);
|
|
119
|
+
}
|
|
120
|
+
async function installAndLaunch(device, file, appId, exec) {
|
|
121
|
+
try {
|
|
122
|
+
if (device.platform === "ios" && isIpa(file)) await unpackIpa(file, exec);
|
|
123
|
+
await exec(...installArgs(device, file));
|
|
124
|
+
await exec(...launchArgs(device, appId));
|
|
125
|
+
return { ok: true };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { ok: false, message: errorMessage(error) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// packages/devices/src/install-source.ts
|
|
132
|
+
import { setTimeout as wait } from "timers/promises";
|
|
133
|
+
function installSourceOf(install, platform, kind) {
|
|
134
|
+
return install.sources?.[platform]?.[kind] ?? "firebase";
|
|
135
|
+
}
|
|
136
|
+
var testflightWaitMs = 30 * 6e4;
|
|
137
|
+
var testflightPollMs = 15e3;
|
|
138
|
+
function versionMatches(requested, installed) {
|
|
139
|
+
if (!installed) return false;
|
|
140
|
+
if (requested.includes("+")) return installed === requested;
|
|
141
|
+
return installed.split("+")[0] === requested;
|
|
142
|
+
}
|
|
143
|
+
function testflightTimeoutMessage(requested, installed, waitMs = testflightWaitMs) {
|
|
144
|
+
const minutes = Math.round(waitMs / 6e4);
|
|
145
|
+
return `TestFlight\uC5D0\uC11C ${requested}\uC774 ${minutes}\uBD84 \uC548\uC5D0 \uC124\uCE58\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. TestFlight \uC571\uC758 \uC790\uB3D9 \uC5C5\uB370\uC774\uD2B8\uB97C \uCF1C \uC8FC\uC138\uC694. (\uC124\uCE58\uB41C \uBC84\uC804: ${installed ?? "\uC5C6\uC74C"})`;
|
|
146
|
+
}
|
|
147
|
+
async function waitForInstalledVersion(input) {
|
|
148
|
+
const now = input.now ?? Date.now;
|
|
149
|
+
const sleep = input.sleep ?? ((ms) => wait(ms).then(() => void 0));
|
|
150
|
+
const waitMs = input.waitMs ?? testflightWaitMs;
|
|
151
|
+
const deadline = now() + waitMs;
|
|
152
|
+
let installed = null;
|
|
153
|
+
for (; ; ) {
|
|
154
|
+
installed = await input.readInstalled().catch(() => installed);
|
|
155
|
+
if (versionMatches(input.requested, installed)) return { ok: true };
|
|
156
|
+
if (now() >= deadline) return { ok: false, message: testflightTimeoutMessage(input.requested, installed, waitMs) };
|
|
157
|
+
await sleep(input.pollMs ?? testflightPollMs);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export {
|
|
162
|
+
listDevices,
|
|
163
|
+
installedAppVersion,
|
|
164
|
+
launchArgs,
|
|
165
|
+
installAndLaunch,
|
|
166
|
+
installSourceOf,
|
|
167
|
+
versionMatches,
|
|
168
|
+
waitForInstalledVersion
|
|
169
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// packages/core/src/config/app-config.ts
|
|
2
|
+
var defaultForbiddenButtonWords = [
|
|
3
|
+
"\uAD6C\uB9E4",
|
|
4
|
+
"\uACB0\uC81C",
|
|
5
|
+
"\uB9E4\uB3C4",
|
|
6
|
+
"\uB9E4\uC218",
|
|
7
|
+
"\uC1A1\uAE08",
|
|
8
|
+
"\uCD9C\uAE08",
|
|
9
|
+
"\uD0C8\uD1F4",
|
|
10
|
+
"\uBC30\uC1A1 \uC2E0\uCCAD",
|
|
11
|
+
"\uC8FC\uBB38",
|
|
12
|
+
"\uD658\uBD88",
|
|
13
|
+
"\uD574\uC9C0",
|
|
14
|
+
"\uC0AD\uC81C",
|
|
15
|
+
"\uC778\uCD9C",
|
|
16
|
+
"\uC120\uBB3C",
|
|
17
|
+
"\uCDA9\uC804",
|
|
18
|
+
"\uB85C\uADF8\uC544\uC6C3",
|
|
19
|
+
"\uACF5\uC720\uD558\uAE30",
|
|
20
|
+
"Buy",
|
|
21
|
+
"Pay",
|
|
22
|
+
"Checkout",
|
|
23
|
+
"Delete account",
|
|
24
|
+
"Log out",
|
|
25
|
+
"Sign out"
|
|
26
|
+
];
|
|
27
|
+
var defaultForbiddenClickNames = [
|
|
28
|
+
"\uC2E0\uCCAD",
|
|
29
|
+
"\uC2E0\uCCAD\uD558\uAE30",
|
|
30
|
+
"\uACB0\uC81C",
|
|
31
|
+
"\uACB0\uC81C\uD558\uAE30",
|
|
32
|
+
"\uAD6C\uB9E4\uD558\uAE30",
|
|
33
|
+
"\uD310\uB9E4\uD558\uAE30",
|
|
34
|
+
"\uCD9C\uAE08",
|
|
35
|
+
"\uD0C8\uD1F4",
|
|
36
|
+
"\uD0C8\uD1F4\uD558\uAE30",
|
|
37
|
+
"\uBCF8\uC778\uC778\uC99D",
|
|
38
|
+
"\uC1A1\uAE08",
|
|
39
|
+
"\uC774\uCCB4"
|
|
40
|
+
];
|
|
41
|
+
var defaultForbiddenApiPatterns = [
|
|
42
|
+
/order/i,
|
|
43
|
+
// 주문
|
|
44
|
+
/pay/i,
|
|
45
|
+
// payment, pay
|
|
46
|
+
/purchase|buy|checkout|billing/i,
|
|
47
|
+
// 구매·결제
|
|
48
|
+
/sell|sale/i,
|
|
49
|
+
// 매도·판매
|
|
50
|
+
/trade|exchange/i,
|
|
51
|
+
// 매매·교환
|
|
52
|
+
/transfer|remit|send-?money|gift/i,
|
|
53
|
+
// 송금·선물(자산 이전)
|
|
54
|
+
/withdraw|cash-?out|redeem|refund|deposit/i,
|
|
55
|
+
// 출금·환불·입금
|
|
56
|
+
/account|bank/i,
|
|
57
|
+
// 계좌·계정 삭제
|
|
58
|
+
/resign|leave|secession|unregister|deregister|quit/i,
|
|
59
|
+
// 탈퇴
|
|
60
|
+
/delivery|shipping|shipment/i,
|
|
61
|
+
// 배송 신청
|
|
62
|
+
/(^|\/)cards?(\/|$)|card-?regist/i
|
|
63
|
+
// 카드 등록
|
|
64
|
+
];
|
|
65
|
+
var defaultAvoidedNamePattern = /광고|Google Ads|AdMob|Dismiss|What is this|^설치$|^Install$/i;
|
|
66
|
+
var defaultNoPressPathPattern = /password|\/pin(\/|$)/i;
|
|
67
|
+
var defaultInPageBackNames = ["\uB4A4\uB85C\uAC00\uAE30", "Back"];
|
|
68
|
+
var defaultLoginPaths = ["/login"];
|
|
69
|
+
var defaultAppErrorText = "Application error";
|
|
70
|
+
function defineAppConfig(input) {
|
|
71
|
+
const guard = input.guard ?? {};
|
|
72
|
+
return {
|
|
73
|
+
name: input.name,
|
|
74
|
+
appIds: input.appIds,
|
|
75
|
+
iosBundleIds: input.iosBundleIds,
|
|
76
|
+
web: { dev: input.web?.dev ?? "", prod: input.web?.prod ?? "" },
|
|
77
|
+
apiOrigin: input.apiOrigin ?? "",
|
|
78
|
+
homePath: input.homePath,
|
|
79
|
+
homeTabName: input.homeTabName,
|
|
80
|
+
tabPaths: { [input.homeTabName]: input.homePath, ...input.tabPaths },
|
|
81
|
+
inPageBackNames: input.inPageBackNames ?? [...defaultInPageBackNames],
|
|
82
|
+
loginPaths: input.loginPaths ?? [...defaultLoginPaths],
|
|
83
|
+
appErrorText: input.appErrorText ?? defaultAppErrorText,
|
|
84
|
+
deepLink: { scheme: input.deepLink?.scheme ?? "", homeHost: input.deepLink?.homeHost ?? "home" },
|
|
85
|
+
android: { mainActivity: input.android?.mainActivity },
|
|
86
|
+
webview: { stackResetScript: input.webview?.stackResetScript, sheetCloseName: input.webview?.sheetCloseName ?? "\uB2EB\uAE30" },
|
|
87
|
+
guard: {
|
|
88
|
+
forbiddenButtonWords: [...defaultForbiddenButtonWords, ...guard.extraForbiddenButtonWords ?? []],
|
|
89
|
+
forbiddenClickNames: [...defaultForbiddenClickNames, ...guard.extraForbiddenClickNames ?? []],
|
|
90
|
+
forbiddenApiPatterns: [...defaultForbiddenApiPatterns, ...guard.extraForbiddenApiPatterns ?? []],
|
|
91
|
+
pushTokenRequests: guard.pushTokenRequests ?? [],
|
|
92
|
+
avoidedNamePattern: guard.avoidedNamePattern ?? defaultAvoidedNamePattern,
|
|
93
|
+
noPressPathPattern: guard.noPressPathPattern ?? defaultNoPressPathPattern
|
|
94
|
+
},
|
|
95
|
+
scenarios: input.scenarios ?? [],
|
|
96
|
+
excludedScenarioPaths: input.excludedScenarioPaths ?? [],
|
|
97
|
+
qa: { screens: input.qa?.screens ?? [], sessionPath: input.qa?.sessionPath ?? "/api/auth/session" },
|
|
98
|
+
policy: { confluence: input.policy?.confluence },
|
|
99
|
+
figma: { frontRepo: input.figma?.frontRepo, ref: input.figma?.ref ?? "origin/main", appDir: input.figma?.appDir ?? "app" },
|
|
100
|
+
install: { sources: input.install?.sources, firebase: input.install?.firebase, testflightDevAppName: input.install?.testflightDevAppName },
|
|
101
|
+
ios: { teamId: input.ios?.teamId, wdaBundleId: input.ios?.wdaBundleId },
|
|
102
|
+
access: { allowedDomains: input.access?.allowedDomains ?? [] }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function appIdFor(app, kind, platform = "android") {
|
|
106
|
+
return (platform === "ios" ? app.iosBundleIds?.[kind] : void 0) ?? app.appIds[kind];
|
|
107
|
+
}
|
|
108
|
+
function kindOfAppId(app, appId) {
|
|
109
|
+
return appId === app.appIds.prod || appId === app.iosBundleIds?.prod ? "prod" : "dev";
|
|
110
|
+
}
|
|
111
|
+
function publicAppConfig(app) {
|
|
112
|
+
return {
|
|
113
|
+
name: app.name,
|
|
114
|
+
homePath: app.homePath,
|
|
115
|
+
homeTabName: app.homeTabName,
|
|
116
|
+
tabPaths: app.tabPaths,
|
|
117
|
+
inPageBackNames: app.inPageBackNames,
|
|
118
|
+
forbiddenClickNames: app.guard.forbiddenClickNames
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function tabNamesOf(nav) {
|
|
122
|
+
return [nav.homeTabName, ...Object.keys(nav.tabPaths).filter((name) => name !== nav.homeTabName)];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export {
|
|
126
|
+
defaultForbiddenButtonWords,
|
|
127
|
+
defaultForbiddenClickNames,
|
|
128
|
+
defaultForbiddenApiPatterns,
|
|
129
|
+
defineAppConfig,
|
|
130
|
+
appIdFor,
|
|
131
|
+
kindOfAppId,
|
|
132
|
+
publicAppConfig,
|
|
133
|
+
tabNamesOf
|
|
134
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineAppConfig
|
|
3
|
+
} from "./chunk-NJNTEWXE.js";
|
|
4
|
+
|
|
5
|
+
// packages/server/src/env.ts
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
var webOriginEnv = { dev: "FARM_WEB_ORIGIN_DEV", prod: "FARM_WEB_ORIGIN_PROD" };
|
|
9
|
+
function loadDotEnv(file = path.resolve(".env")) {
|
|
10
|
+
if (!existsSync(file)) return false;
|
|
11
|
+
process.loadEnvFile(file);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
function webOrigin(app, kind) {
|
|
15
|
+
const name = webOriginEnv[kind];
|
|
16
|
+
const value = process.env[name]?.trim() || app.web[kind];
|
|
17
|
+
if (!value) throw new Error(`${kind} \uC6F9 \uC8FC\uC18C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. farm.config.ts\uC758 web.${kind}\uB098 .env\uC758 ${name}\uC5D0 \uC571 WebView \uC6F9 \uC8FC\uC18C\uB97C \uB123\uC5B4 \uC8FC\uC138\uC694.`);
|
|
18
|
+
return value.replace(/\/+$/, "");
|
|
19
|
+
}
|
|
20
|
+
function apiOrigin(app) {
|
|
21
|
+
return (process.env.FARM_API_ORIGIN?.trim() || app.apiOrigin).replace(/\/+$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// packages/devices/src/app-config-file.ts
|
|
25
|
+
import { existsSync as existsSync2 } from "fs";
|
|
26
|
+
import path2 from "path";
|
|
27
|
+
import { pathToFileURL } from "url";
|
|
28
|
+
var appConfigFileNames = ["farm.config.ts", "farm.config.example.ts"];
|
|
29
|
+
function resolveAppConfigFile(env = process.env, cwd = process.cwd()) {
|
|
30
|
+
const fromEnv = env.FARM_CONFIG?.trim();
|
|
31
|
+
if (fromEnv) {
|
|
32
|
+
const file = path2.resolve(cwd, fromEnv);
|
|
33
|
+
if (!existsSync2(file)) throw new Error(`FARM_CONFIG \uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4: ${file}`);
|
|
34
|
+
return file;
|
|
35
|
+
}
|
|
36
|
+
const found = appConfigFileNames.map((name) => path2.join(cwd, name)).find((file) => existsSync2(file));
|
|
37
|
+
if (!found) throw new Error(`\uC571 \uC124\uC815\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. ${cwd}\uC5D0 farm.config.ts\uB97C \uB9CC\uB4E4\uAC70\uB098 FARM_CONFIG\uB85C \uACBD\uB85C\uB97C \uC54C\uB824 \uC8FC\uC138\uC694(farm.config.example.ts \uCC38\uACE0).`);
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
function isInput(value) {
|
|
41
|
+
if (!value || typeof value !== "object") return false;
|
|
42
|
+
const config = value;
|
|
43
|
+
return typeof config.name === "string" && typeof config.appIds?.dev === "string" && typeof config.homePath === "string" && typeof config.homeTabName === "string";
|
|
44
|
+
}
|
|
45
|
+
var isDefined = (value) => Array.isArray(value.guard?.forbiddenButtonWords);
|
|
46
|
+
function normalizeAppConfig(value, source = "\uC571 \uC124\uC815") {
|
|
47
|
+
if (!isInput(value)) throw new Error(`${source}\uC758 default export\uAC00 \uC571 \uC124\uC815\uC774 \uC544\uB2D9\uB2C8\uB2E4. name\xB7appIds\xB7homePath\xB7homeTabName\xB7tabPaths\uAC00 \uC788\uC5B4\uC57C \uD569\uB2C8\uB2E4.`);
|
|
48
|
+
return isDefined(value) ? value : defineAppConfig(value);
|
|
49
|
+
}
|
|
50
|
+
async function loadAppConfig(file = resolveAppConfigFile(), log = console.log) {
|
|
51
|
+
const loaded = await import(
|
|
52
|
+
/* @vite-ignore */
|
|
53
|
+
pathToFileURL(file).href
|
|
54
|
+
);
|
|
55
|
+
const config = normalizeAppConfig(loaded.default, file);
|
|
56
|
+
if (path2.basename(file) === "farm.config.example.ts") log(`[farm] farm.config.ts\uAC00 \uC5C6\uC5B4 \uC608\uC2DC \uC571 \uC124\uC815(${file})\uC73C\uB85C \uC2DC\uC791\uD569\uB2C8\uB2E4.`);
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// packages/core/src/device/driver.ts
|
|
61
|
+
var homeAttempts = 3;
|
|
62
|
+
function isLoginPath(path3, loginPaths) {
|
|
63
|
+
return loginPaths.some((login) => path3 === login || path3.startsWith(`${login}/`) || path3.startsWith(`${login}?`));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// packages/core/src/sleep.ts
|
|
67
|
+
function sleep(ms) {
|
|
68
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// packages/core/src/device/screen-ready.ts
|
|
72
|
+
var screenReadyTimeoutMs = 15e3;
|
|
73
|
+
var screenPollMs = 250;
|
|
74
|
+
var screenSettleMs = 600;
|
|
75
|
+
var staleMarkKey = "__deviceFarmStale";
|
|
76
|
+
var markStaleScript = `window.${staleMarkKey} = true`;
|
|
77
|
+
var screenStateScript = `({ ready: document.readyState, path: location.pathname, text: (document.body ? document.body.innerText : '').trim().length, images: document.images.length, stale: window.${staleMarkKey} === true })`;
|
|
78
|
+
function normalizePath(path3) {
|
|
79
|
+
const pathname = path3.split(/[?#]/)[0] || "/";
|
|
80
|
+
return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
|
81
|
+
}
|
|
82
|
+
function samePath(target, actual) {
|
|
83
|
+
return normalizePath(target) === normalizePath(actual);
|
|
84
|
+
}
|
|
85
|
+
function arrived(target, state) {
|
|
86
|
+
if (state.stale) return false;
|
|
87
|
+
return samePath(target, state.path) || state.stale === false;
|
|
88
|
+
}
|
|
89
|
+
function painted(state) {
|
|
90
|
+
return state.ready === "complete" && (state.text > 0 || state.images > 0);
|
|
91
|
+
}
|
|
92
|
+
async function waitForScreen(driver, target, options = {}) {
|
|
93
|
+
const now = options.now ?? Date.now;
|
|
94
|
+
const sleep2 = options.sleep ?? ((ms) => sleep(ms).then(() => void 0));
|
|
95
|
+
const deadline = now() + (options.timeoutMs ?? screenReadyTimeoutMs);
|
|
96
|
+
let last = null;
|
|
97
|
+
for (; ; ) {
|
|
98
|
+
const state = await driver.evaluate(screenStateScript).catch(() => null);
|
|
99
|
+
if (state && isLoginPath(state.path, driver.app.loginPaths)) return { ok: true };
|
|
100
|
+
if (state && arrived(target, state) && painted(state)) {
|
|
101
|
+
if (last && last.path === state.path && last.text === state.text && last.images === state.images) {
|
|
102
|
+
return samePath(target, state.path) ? { ok: true } : { ok: true, redirectedTo: normalizePath(state.path) };
|
|
103
|
+
}
|
|
104
|
+
last = state;
|
|
105
|
+
await sleep2(screenSettleMs);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
last = null;
|
|
109
|
+
if (now() >= deadline) return { ok: false, message: notReadyMessage(target, state, options.timeoutMs ?? screenReadyTimeoutMs) };
|
|
110
|
+
await sleep2(screenPollMs);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function notReadyMessage(target, state, timeoutMs) {
|
|
114
|
+
const seconds = Math.round(timeoutMs / 1e3);
|
|
115
|
+
if (state?.stale) return `\uC774\uB3D9 \uC2E4\uD328: \uC571\uC774 ${target}\uB97C \uC5F4\uC9C0 \uC54A\uC544 ${state.path}\uC5D0 \uBA38\uBB3C\uB800\uC2B5\uB2C8\uB2E4.`;
|
|
116
|
+
if (state && !samePath(target, state.path)) return `\uC774\uB3D9 \uC2E4\uD328: ${target} \uB300\uC2E0 ${state.path}\uC5D0 \uBA38\uBB3C\uB800\uC2B5\uB2C8\uB2E4.`;
|
|
117
|
+
return `${target} \uD654\uBA74\uC774 ${seconds}\uCD08 \uC548\uC5D0 \uADF8\uB824\uC9C0\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export {
|
|
121
|
+
homeAttempts,
|
|
122
|
+
isLoginPath,
|
|
123
|
+
sleep,
|
|
124
|
+
staleMarkKey,
|
|
125
|
+
markStaleScript,
|
|
126
|
+
normalizePath,
|
|
127
|
+
samePath,
|
|
128
|
+
waitForScreen,
|
|
129
|
+
loadDotEnv,
|
|
130
|
+
webOrigin,
|
|
131
|
+
apiOrigin,
|
|
132
|
+
resolveAppConfigFile,
|
|
133
|
+
loadAppConfig
|
|
134
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// packages/core/src/qa/rules.ts
|
|
2
|
+
function formatWon(value) {
|
|
3
|
+
return Math.round(value).toLocaleString("en-US");
|
|
4
|
+
}
|
|
5
|
+
var squash = (text) => text.replace(/\s+/g, "");
|
|
6
|
+
function textHas(text, expected) {
|
|
7
|
+
return squash(text).includes(squash(expected));
|
|
8
|
+
}
|
|
9
|
+
function pick(source, path) {
|
|
10
|
+
return path.split(".").reduce((current, key) => current && typeof current === "object" ? current[key] : void 0, source);
|
|
11
|
+
}
|
|
12
|
+
function runRule(rule, context) {
|
|
13
|
+
try {
|
|
14
|
+
const message = rule.check(context);
|
|
15
|
+
return message === null ? { id: rule.id, title: rule.title, ok: true } : { id: rule.id, title: rule.title, ok: false, message };
|
|
16
|
+
} catch (error) {
|
|
17
|
+
return { id: rule.id, title: rule.title, ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
var equalWon = (label, actual, expected) => actual === expected ? null : `${label}: ${formatWon(actual)}\uC6D0\uC778\uB370 ${formatWon(expected)}\uC6D0\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.`;
|
|
21
|
+
function numberAt(context, api, path) {
|
|
22
|
+
const value = pick(context.responses[api], path);
|
|
23
|
+
if (typeof value !== "number") throw new Error(`${api}.${path} \uAC12\uC774 \uC22B\uC790\uAC00 \uC544\uB2D9\uB2C8\uB2E4.`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
formatWon,
|
|
29
|
+
textHas,
|
|
30
|
+
pick,
|
|
31
|
+
runRule,
|
|
32
|
+
equalWon,
|
|
33
|
+
numberAt
|
|
34
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// apps/cli/src/http.ts
|
|
2
|
+
var CliError = class extends Error {
|
|
3
|
+
};
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null;
|
|
6
|
+
}
|
|
7
|
+
function isConnectionRefused(error) {
|
|
8
|
+
return error instanceof TypeError && isRecord(error.cause) && error.cause.code === "ECONNREFUSED";
|
|
9
|
+
}
|
|
10
|
+
async function requestJson(port, path, init) {
|
|
11
|
+
let response;
|
|
12
|
+
try {
|
|
13
|
+
response = await fetch(`http://127.0.0.1:${port}${path}`, init);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (isConnectionRefused(error)) throw new CliError(`\uB514\uBC14\uC774\uC2A4\uD31C \uC11C\uBC84(127.0.0.1:${port})\uC5D0 \uC5F0\uACB0\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`);
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
const body = await response.json().catch(() => null);
|
|
19
|
+
if (isRecord(body) && typeof body.error === "string") throw new CliError(body.error);
|
|
20
|
+
if (!response.ok) throw new CliError(`\uB514\uBC14\uC774\uC2A4\uD31C \uC11C\uBC84 \uC751\uB2F5 \uC624\uB958: ${response.status} ${path}`);
|
|
21
|
+
return body;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// apps/cli/src/format.ts
|
|
25
|
+
var platformLabels = { android: "Android", ios: "iOS" };
|
|
26
|
+
var phaseLabels = {
|
|
27
|
+
running: "\uC810\uAC80 \uC911",
|
|
28
|
+
passed: "\uC124\uCE58 \uC644\uB8CC",
|
|
29
|
+
failed: "\uC124\uCE58 \uC644\uB8CC",
|
|
30
|
+
skipped: "\uAC74\uB108\uB700",
|
|
31
|
+
install_failed: "\uC124\uCE58 \uC2E4\uD328",
|
|
32
|
+
login_required: "\uB85C\uADF8\uC778 \uD544\uC694",
|
|
33
|
+
offline: "\uC5F0\uACB0 \uB04A\uAE40"
|
|
34
|
+
};
|
|
35
|
+
var exitCodes = { ok: 0, failed: 1 };
|
|
36
|
+
function deviceHeader(outcome) {
|
|
37
|
+
const { device } = outcome;
|
|
38
|
+
const name = device.model || device.id;
|
|
39
|
+
const phase = outcome.message && outcome.status !== "passed" && outcome.status !== "failed" ? `${phaseLabels[outcome.status]} (${outcome.message})` : phaseLabels[outcome.status];
|
|
40
|
+
return `[${name}] ${platformLabels[device.platform]} ${device.osVersion} \xB7 ${phase}`;
|
|
41
|
+
}
|
|
42
|
+
function failLine(outcome) {
|
|
43
|
+
const screen = outcome.screens.find((item) => item.status === "failed");
|
|
44
|
+
if (screen) return `FAIL ${screen.scenario} \xB7 ${screen.message ?? "\uAE30\uC900 \uD654\uBA74\uACFC \uB2E4\uB985\uB2C8\uB2E4"}`;
|
|
45
|
+
return `FAIL ${outcome.message ?? "\uC2E4\uD328"}`;
|
|
46
|
+
}
|
|
47
|
+
function passLine(outcome) {
|
|
48
|
+
const passed = outcome.screens.filter((item) => item.status === "passed").length;
|
|
49
|
+
return `PASS ${passed}/${outcome.screens.length} \uD654\uBA74`;
|
|
50
|
+
}
|
|
51
|
+
function formatOutcome(outcome) {
|
|
52
|
+
const header = deviceHeader(outcome);
|
|
53
|
+
if (outcome.status === "passed") return [header, passLine(outcome)];
|
|
54
|
+
if (outcome.status === "failed") return [header, failLine(outcome)];
|
|
55
|
+
return [header];
|
|
56
|
+
}
|
|
57
|
+
function changedLines(run, printed) {
|
|
58
|
+
const lines = [];
|
|
59
|
+
for (const outcome of run.outcomes) {
|
|
60
|
+
const current = formatOutcome(outcome);
|
|
61
|
+
const key = current.join("\n");
|
|
62
|
+
if (printed.get(outcome.device.id) === key) continue;
|
|
63
|
+
printed.set(outcome.device.id, key);
|
|
64
|
+
lines.push(...current);
|
|
65
|
+
}
|
|
66
|
+
return lines;
|
|
67
|
+
}
|
|
68
|
+
var noDevicesMessage = "\uC810\uAC80\uD560 \uAE30\uAE30\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. \uC5F0\uACB0 \uC0C1\uD0DC\uB97C \uD655\uC778\uD574 \uC8FC\uC138\uC694.";
|
|
69
|
+
function runExitCode(run) {
|
|
70
|
+
if (run.outcomes.length === 0) return exitCodes.failed;
|
|
71
|
+
return run.outcomes.some((outcome) => outcome.status === "failed") ? exitCodes.failed : exitCodes.ok;
|
|
72
|
+
}
|
|
73
|
+
function isRunFinished(run) {
|
|
74
|
+
return run.finishedAt !== null;
|
|
75
|
+
}
|
|
76
|
+
function summarizeExplore(runs) {
|
|
77
|
+
return {
|
|
78
|
+
issues: runs.reduce((sum, run) => sum + run.issues.length, 0),
|
|
79
|
+
blocked: runs.reduce((sum, run) => sum + run.blocked.length, 0)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function formatExploreSummary(summary) {
|
|
83
|
+
return `\uC624\uB958 ${summary.issues}\uAC1C \xB7 \uCC28\uB2E8 ${summary.blocked}\uAC1C`;
|
|
84
|
+
}
|
|
85
|
+
function formatExploreRun(run) {
|
|
86
|
+
const state = run.finishedAt === null ? "\uD0D0\uC0C9 \uC911" : "\uD0D0\uC0C9 \uC644\uB8CC";
|
|
87
|
+
return `[${run.deviceId}] ${state} \xB7 ${formatExploreSummary(summarizeExplore([run]))} \xB7 \uACBD\uB85C ${run.paths.length}\uAC1C`;
|
|
88
|
+
}
|
|
89
|
+
function exploreExitCode(runs) {
|
|
90
|
+
return summarizeExplore(runs).issues > 0 ? exitCodes.failed : exitCodes.ok;
|
|
91
|
+
}
|
|
92
|
+
function isExploreFinished(runs) {
|
|
93
|
+
return runs.length > 0 && runs.every((run) => run.finishedAt !== null);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export {
|
|
97
|
+
CliError,
|
|
98
|
+
isRecord,
|
|
99
|
+
requestJson,
|
|
100
|
+
exitCodes,
|
|
101
|
+
changedLines,
|
|
102
|
+
noDevicesMessage,
|
|
103
|
+
runExitCode,
|
|
104
|
+
isRunFinished,
|
|
105
|
+
summarizeExplore,
|
|
106
|
+
formatExploreSummary,
|
|
107
|
+
formatExploreRun,
|
|
108
|
+
exploreExitCode,
|
|
109
|
+
isExploreFinished
|
|
110
|
+
};
|