@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,283 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appiumConnection,
|
|
3
|
+
getIosSession,
|
|
4
|
+
iosCapabilities,
|
|
5
|
+
resetIosSession
|
|
6
|
+
} from "./chunk-S6GHOP4B.js";
|
|
7
|
+
import {
|
|
8
|
+
installAndLaunch,
|
|
9
|
+
installedAppVersion,
|
|
10
|
+
launchArgs,
|
|
11
|
+
listDevices,
|
|
12
|
+
versionMatches,
|
|
13
|
+
waitForInstalledVersion
|
|
14
|
+
} from "./chunk-JZDYF3HL.js";
|
|
15
|
+
import {
|
|
16
|
+
openAndroidDriver,
|
|
17
|
+
openIosDriver
|
|
18
|
+
} from "./chunk-AXQNUPGC.js";
|
|
19
|
+
import {
|
|
20
|
+
webOrigin
|
|
21
|
+
} from "./chunk-NLX3V7ZJ.js";
|
|
22
|
+
import {
|
|
23
|
+
appIdFor,
|
|
24
|
+
kindOfAppId
|
|
25
|
+
} from "./chunk-NJNTEWXE.js";
|
|
26
|
+
|
|
27
|
+
// packages/devices/src/testflight.ts
|
|
28
|
+
import { setTimeout as wait } from "timers/promises";
|
|
29
|
+
import { remote } from "webdriverio";
|
|
30
|
+
var testflightBundleId = "com.apple.TestFlight";
|
|
31
|
+
var testflightUi = { settleMs: 2500, maxSwipes: 6 };
|
|
32
|
+
function versionGroupPrefix(version) {
|
|
33
|
+
return `${version.split("+")[0]},`;
|
|
34
|
+
}
|
|
35
|
+
function buildRowLabel(version) {
|
|
36
|
+
const [display, build] = version.split("+");
|
|
37
|
+
return build ? `${display}(${build})` : null;
|
|
38
|
+
}
|
|
39
|
+
function nearestByY(target, items) {
|
|
40
|
+
return items.reduce((best, item) => !best || Math.abs(item.y - target) < Math.abs(best.y - target) ? item : best, null);
|
|
41
|
+
}
|
|
42
|
+
var byLabel = (browser, predicate) => browser.$(`-ios predicate string:${predicate}`);
|
|
43
|
+
async function findScrolling(browser, predicate) {
|
|
44
|
+
for (let swipe = 0; swipe <= testflightUi.maxSwipes; swipe += 1) {
|
|
45
|
+
const element = byLabel(browser, predicate);
|
|
46
|
+
if (await element.isExisting() && await element.isDisplayed()) return element;
|
|
47
|
+
await browser.execute("mobile: swipe", { direction: "up" });
|
|
48
|
+
await wait(1e3);
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`TestFlight\uC5D0\uC11C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${predicate}`);
|
|
51
|
+
}
|
|
52
|
+
async function tap(browser, predicate) {
|
|
53
|
+
await (await findScrolling(browser, predicate)).click();
|
|
54
|
+
await wait(testflightUi.settleMs);
|
|
55
|
+
}
|
|
56
|
+
async function tapInstallOnRow(browser, version) {
|
|
57
|
+
const label = buildRowLabel(version);
|
|
58
|
+
const display = version.split("+")[0];
|
|
59
|
+
const row = await findScrolling(browser, label ? `type == "XCUIElementTypeStaticText" AND label == "${label}"` : `type == "XCUIElementTypeStaticText" AND label BEGINSWITH "${display}("`);
|
|
60
|
+
const rowY = (await row.getLocation()).y;
|
|
61
|
+
const placed = [];
|
|
62
|
+
for (const button of await browser.$$(`-ios predicate string:type == "XCUIElementTypeButton" AND label == "\uC124\uCE58"`).getElements()) {
|
|
63
|
+
placed.push({ button, y: (await button.getLocation()).y });
|
|
64
|
+
}
|
|
65
|
+
const target = nearestByY(rowY, placed);
|
|
66
|
+
if (!target) throw new Error(`TestFlight ${label ?? display} \uC904\uC5D0 \u300C\uC124\uCE58\u300D \uBC84\uD2BC\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uC774\uBBF8 \uAE54\uB824 \uC788\uC744 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`);
|
|
67
|
+
await target.button.click();
|
|
68
|
+
await wait(testflightUi.settleMs);
|
|
69
|
+
await browser.acceptAlert().catch(() => void 0);
|
|
70
|
+
}
|
|
71
|
+
async function installFromTestflight(udid, version, devAppName, signing = {}) {
|
|
72
|
+
if (!devAppName) throw new Error("\uC571 \uC124\uC815 install.testflightDevAppName\uC774 \uC5C6\uC5B4 TestFlight\uC5D0\uC11C \uACE0\uB97C \uC571\uC744 \uBAA8\uB985\uB2C8\uB2E4.");
|
|
73
|
+
const browser = await remote({ ...appiumConnection(), logLevel: "silent", capabilities: iosCapabilities(udid, testflightBundleId, signing) });
|
|
74
|
+
try {
|
|
75
|
+
await browser.execute("mobile: terminateApp", { bundleId: testflightBundleId });
|
|
76
|
+
await browser.execute("mobile: activateApp", { bundleId: testflightBundleId });
|
|
77
|
+
await wait(testflightUi.settleMs);
|
|
78
|
+
await tap(browser, `label CONTAINS "${devAppName}"`);
|
|
79
|
+
await tap(browser, `type == "XCUIElementTypeButton" AND label BEGINSWITH "\uC774\uC804 \uBE4C\uB4DC"`);
|
|
80
|
+
await tap(browser, `type == "XCUIElementTypeButton" AND label BEGINSWITH "${versionGroupPrefix(version)}"`);
|
|
81
|
+
await tapInstallOnRow(browser, version);
|
|
82
|
+
} finally {
|
|
83
|
+
await browser.deleteSession().catch(() => void 0);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// packages/server/src/build/firebase.ts
|
|
88
|
+
import { createWriteStream } from "fs";
|
|
89
|
+
import { access, mkdir, rename, rm } from "fs/promises";
|
|
90
|
+
import path from "path";
|
|
91
|
+
import { Readable } from "stream";
|
|
92
|
+
import { pipeline } from "stream/promises";
|
|
93
|
+
import { GoogleAuth } from "google-auth-library";
|
|
94
|
+
var buildCacheDir = "test-results/builds";
|
|
95
|
+
var releasesBaseUrl = "https://firebaseappdistribution.googleapis.com/v1";
|
|
96
|
+
var cloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform";
|
|
97
|
+
var binaryExtensions = { android: "apk", ios: "ipa" };
|
|
98
|
+
function appIdEnvName(platform, kind) {
|
|
99
|
+
return `FIREBASE_APP_${platform.toUpperCase()}_${kind.toUpperCase()}`;
|
|
100
|
+
}
|
|
101
|
+
function firebaseAppId(platform, kind, install) {
|
|
102
|
+
const envName = appIdEnvName(platform, kind);
|
|
103
|
+
const value = (process.env[envName]?.trim() || install?.firebase?.[platform]?.[kind] || "").trim();
|
|
104
|
+
if (!value) throw new Error(`${envName}\uC774 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.`);
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
function projectNumber(appId) {
|
|
108
|
+
const number = appId.split(":")[1];
|
|
109
|
+
if (!number) throw new Error(`Firebase \uC571 ID \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4: ${appId}`);
|
|
110
|
+
return number;
|
|
111
|
+
}
|
|
112
|
+
function isRecord(value) {
|
|
113
|
+
return typeof value === "object" && value !== null;
|
|
114
|
+
}
|
|
115
|
+
function toRelease(value) {
|
|
116
|
+
const fields = ["name", "displayVersion", "buildVersion", "createTime", "binaryDownloadUri"];
|
|
117
|
+
if (!isRecord(value) || fields.some((field) => typeof value[field] !== "string")) {
|
|
118
|
+
throw new Error("Firebase \uB9B4\uB9AC\uC2A4 \uC751\uB2F5 \uD615\uC2DD\uC774 \uB2E4\uB985\uB2C8\uB2E4.");
|
|
119
|
+
}
|
|
120
|
+
const [name, displayVersion, buildVersion, createTime, binaryDownloadUri] = fields.map((field) => String(value[field]));
|
|
121
|
+
return { name, displayVersion, buildVersion, createTime, binaryDownloadUri };
|
|
122
|
+
}
|
|
123
|
+
function parsePage(body) {
|
|
124
|
+
if (!isRecord(body)) throw new Error("Firebase \uB9B4\uB9AC\uC2A4 \uBAA9\uB85D \uC751\uB2F5 \uD615\uC2DD\uC774 \uB2E4\uB985\uB2C8\uB2E4.");
|
|
125
|
+
const raw = body.releases ?? [];
|
|
126
|
+
if (!Array.isArray(raw)) throw new Error("Firebase \uB9B4\uB9AC\uC2A4 \uBAA9\uB85D \uC751\uB2F5 \uD615\uC2DD\uC774 \uB2E4\uB985\uB2C8\uB2E4.");
|
|
127
|
+
const token = typeof body.nextPageToken === "string" && body.nextPageToken ? body.nextPageToken : null;
|
|
128
|
+
return { releases: raw.map(toRelease), nextPageToken: token };
|
|
129
|
+
}
|
|
130
|
+
async function listReleases(appId, fetchJson) {
|
|
131
|
+
const baseUrl = `${releasesBaseUrl}/projects/${projectNumber(appId)}/apps/${appId}/releases`;
|
|
132
|
+
const releases = [];
|
|
133
|
+
let pageToken = null;
|
|
134
|
+
do {
|
|
135
|
+
const url = pageToken ? `${baseUrl}?pageToken=${encodeURIComponent(pageToken)}` : baseUrl;
|
|
136
|
+
const page = parsePage(await fetchJson(url));
|
|
137
|
+
releases.push(...page.releases);
|
|
138
|
+
pageToken = page.nextPageToken;
|
|
139
|
+
} while (pageToken);
|
|
140
|
+
return releases;
|
|
141
|
+
}
|
|
142
|
+
function pickRelease(releases, version) {
|
|
143
|
+
const [displayVersion, buildVersion] = version.split("+");
|
|
144
|
+
const matches = releases.filter(
|
|
145
|
+
(release) => release.displayVersion === displayVersion && (!buildVersion || release.buildVersion === buildVersion)
|
|
146
|
+
);
|
|
147
|
+
const byNewest = (a, b) => Date.parse(b.createTime) - Date.parse(a.createTime);
|
|
148
|
+
return [...matches].sort(byNewest)[0] ?? null;
|
|
149
|
+
}
|
|
150
|
+
function cachePath(input, release, root) {
|
|
151
|
+
const fileName = `${input.platform}-${input.kind}-${release.displayVersion}-${release.buildVersion}.${binaryExtensions[input.platform]}`;
|
|
152
|
+
return path.join(root, buildCacheDir, fileName);
|
|
153
|
+
}
|
|
154
|
+
async function exists(filePath) {
|
|
155
|
+
try {
|
|
156
|
+
await access(filePath);
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function downloadAtomically(url, dest, deps) {
|
|
163
|
+
const partPath = `${dest}.part`;
|
|
164
|
+
try {
|
|
165
|
+
await deps.download(url, partPath);
|
|
166
|
+
await rename(partPath, dest);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
await rm(partPath, { force: true });
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function fetchBuild(input, deps) {
|
|
173
|
+
const releases = await listReleases(firebaseAppId(input.platform, input.kind, deps.install), deps.fetchJson);
|
|
174
|
+
const release = pickRelease(releases, input.version);
|
|
175
|
+
if (!release) throw new Error(`${input.platform} ${input.kind} ${input.version} \uBE4C\uB4DC\uAC00 Firebase\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4.`);
|
|
176
|
+
const dest = cachePath(input, release, deps.root ?? process.cwd());
|
|
177
|
+
if (await exists(dest)) return dest;
|
|
178
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
179
|
+
try {
|
|
180
|
+
await downloadAtomically(release.binaryDownloadUri, dest, deps);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
183
|
+
throw new Error(`${input.platform} ${input.kind} ${input.version} \uBE4C\uB4DC\uB97C \uBC1B\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${reason}`, { cause: error });
|
|
184
|
+
}
|
|
185
|
+
return dest;
|
|
186
|
+
}
|
|
187
|
+
async function downloadToFile(url, dest) {
|
|
188
|
+
const response = await fetch(url);
|
|
189
|
+
if (!response.ok || !response.body) throw new Error(`\uB2E4\uC6B4\uB85C\uB4DC \uC2E4\uD328 HTTP ${response.status}`);
|
|
190
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(dest));
|
|
191
|
+
}
|
|
192
|
+
function defaultBuildDeps(install, root) {
|
|
193
|
+
let auth = null;
|
|
194
|
+
const fetchJson = async (url) => {
|
|
195
|
+
const keyFile = process.env.FIREBASE_SERVICE_ACCOUNT?.trim();
|
|
196
|
+
if (!keyFile) throw new Error("FIREBASE_SERVICE_ACCOUNT\uC774 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.");
|
|
197
|
+
auth ??= new GoogleAuth({ keyFile, scopes: [cloudPlatformScope] });
|
|
198
|
+
const client = await auth.getClient();
|
|
199
|
+
const response = await client.request({ url });
|
|
200
|
+
return response.data;
|
|
201
|
+
};
|
|
202
|
+
return { fetchJson, download: downloadToFile, root, install };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// packages/server/src/devices.ts
|
|
206
|
+
var deviceListCacheMs = 2e3;
|
|
207
|
+
async function openDriver(app, device, appId) {
|
|
208
|
+
if (device.platform === "android") return openAndroidDriver(device.id, appId, webOrigin(app, kindOfAppId(app, appId)), app);
|
|
209
|
+
return openIosDriver(await getIosSession(device.id, appId, app.ios), appId, app);
|
|
210
|
+
}
|
|
211
|
+
function createDeviceDeps(app, exec, hub) {
|
|
212
|
+
const buildDeps = defaultBuildDeps(app.install);
|
|
213
|
+
return {
|
|
214
|
+
app,
|
|
215
|
+
// ponytail: appVersion은 dev 앱 기준이다(탐색의 dev 판정과 격자 표시가 같은 값). 운영 앱 버전은 안 보인다.
|
|
216
|
+
listDevices: () => listDevices(exec, app.appIds.dev, app.iosBundleIds?.dev),
|
|
217
|
+
fetchBuild: (input) => fetchBuild(input, buildDeps),
|
|
218
|
+
installAndLaunch: async (device, file, appId, run) => {
|
|
219
|
+
const result = await installAndLaunch(device, file, appId, run);
|
|
220
|
+
if (result.ok && device.platform === "ios") {
|
|
221
|
+
await resetIosSession(device.id);
|
|
222
|
+
hub.forgetIos(device.id);
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
},
|
|
226
|
+
waitForTestflight: async (device, kind, version) => {
|
|
227
|
+
const appId = appIdFor(app, kind, device.platform);
|
|
228
|
+
const readInstalled = () => installedAppVersion(exec, device, appId);
|
|
229
|
+
if (!versionMatches(version, await readInstalled().catch(() => null))) {
|
|
230
|
+
await resetIosSession(device.id);
|
|
231
|
+
hub.forgetIos(device.id);
|
|
232
|
+
await installFromTestflight(device.id, version, app.install.testflightDevAppName, app.ios).catch((error) => console.log(`[farm] TestFlight \uC124\uCE58 \uC2E4\uD328, \uC790\uB3D9 \uC5C5\uB370\uC774\uD2B8\uB97C \uAE30\uB2E4\uB9BD\uB2C8\uB2E4: ${error instanceof Error ? error.message : String(error)}`));
|
|
233
|
+
}
|
|
234
|
+
const result = await waitForInstalledVersion({ requested: version, readInstalled });
|
|
235
|
+
if (!result.ok) return result;
|
|
236
|
+
await resetIosSession(device.id);
|
|
237
|
+
hub.forgetIos(device.id);
|
|
238
|
+
const [command, args] = launchArgs(device, appId);
|
|
239
|
+
await exec(command, args);
|
|
240
|
+
return result;
|
|
241
|
+
},
|
|
242
|
+
openDriver: (device, appId) => openDriver(app, device, appId)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function stateOf(locks, id) {
|
|
246
|
+
const owner = locks.ownerOf(id);
|
|
247
|
+
if (owner?.kind === "bot") return "checking";
|
|
248
|
+
if (owner?.kind === "user") return "controlling";
|
|
249
|
+
return "idle";
|
|
250
|
+
}
|
|
251
|
+
function createDeviceList(list, locks, now = Date.now) {
|
|
252
|
+
const seen = /* @__PURE__ */ new Map();
|
|
253
|
+
let inFlight = null;
|
|
254
|
+
let cached = null;
|
|
255
|
+
async function connected() {
|
|
256
|
+
if (cached && now() - cached.at < deviceListCacheMs) return cached.devices;
|
|
257
|
+
inFlight ??= list().finally(() => {
|
|
258
|
+
inFlight = null;
|
|
259
|
+
});
|
|
260
|
+
const devices = await inFlight;
|
|
261
|
+
cached = { at: now(), devices };
|
|
262
|
+
for (const device of devices) seen.set(device.id, device);
|
|
263
|
+
return devices;
|
|
264
|
+
}
|
|
265
|
+
async function withState() {
|
|
266
|
+
const devices = await connected();
|
|
267
|
+
const ids = new Set(devices.map((device) => device.id));
|
|
268
|
+
const online = devices.map((device) => ({ ...device, state: stateOf(locks, device.id) }));
|
|
269
|
+
const offline = [...seen.values()].filter((device) => !ids.has(device.id)).map((device) => ({ ...device, state: "offline" }));
|
|
270
|
+
return [...online, ...offline];
|
|
271
|
+
}
|
|
272
|
+
return { connected, withState };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export {
|
|
276
|
+
firebaseAppId,
|
|
277
|
+
listReleases,
|
|
278
|
+
pickRelease,
|
|
279
|
+
defaultBuildDeps,
|
|
280
|
+
openDriver,
|
|
281
|
+
createDeviceDeps,
|
|
282
|
+
createDeviceList
|
|
283
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// packages/core/src/explore/policy.ts
|
|
2
|
+
var mutatingMethods = ["POST", "PUT", "PATCH", "DELETE"];
|
|
3
|
+
var readMethods = ["GET", "HEAD", "OPTIONS"];
|
|
4
|
+
var queryOrHash = /[?#].*$/;
|
|
5
|
+
function stripQuery(path2) {
|
|
6
|
+
return path2.replace(queryOrHash, "");
|
|
7
|
+
}
|
|
8
|
+
function safeDecode(path2) {
|
|
9
|
+
try {
|
|
10
|
+
return decodeURIComponent(path2);
|
|
11
|
+
} catch {
|
|
12
|
+
return path2;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function isForbiddenPath(path2, patterns) {
|
|
16
|
+
const candidates = [path2, safeDecode(path2)];
|
|
17
|
+
return patterns.some((pattern) => candidates.some((candidate) => pattern.test(candidate)));
|
|
18
|
+
}
|
|
19
|
+
function escapeRegExp(value) {
|
|
20
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
21
|
+
}
|
|
22
|
+
var templateParam = /\{[^/{}]+\}/g;
|
|
23
|
+
function matchTemplate(template, path2) {
|
|
24
|
+
const literals = template.split(templateParam).map(escapeRegExp);
|
|
25
|
+
const source = literals.join("[^/]+");
|
|
26
|
+
return new RegExp(`^${source}$`).test(path2);
|
|
27
|
+
}
|
|
28
|
+
function isReadMethod(method) {
|
|
29
|
+
return readMethods.includes(method);
|
|
30
|
+
}
|
|
31
|
+
function findEntry(allowlist, method, path2, status) {
|
|
32
|
+
return allowlist.entries.some(
|
|
33
|
+
(entry) => entry.status === status && entry.method.toUpperCase() === method && matchTemplate(entry.path, path2)
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
var isPushTokenRequest = (rules, method, path2) => rules.pushTokenRequests.some((item) => item.method === method && matchTemplate(item.path, path2));
|
|
37
|
+
function decideRequest(method, path2, allowlist, rules) {
|
|
38
|
+
const upper = method.toUpperCase();
|
|
39
|
+
if (isReadMethod(upper)) return { allow: true };
|
|
40
|
+
const pathOnly = stripQuery(path2);
|
|
41
|
+
if (isPushTokenRequest(rules, upper, pathOnly)) return { allow: true };
|
|
42
|
+
if (isForbiddenPath(pathOnly, rules.forbiddenApiPatterns)) return { allow: false, reason: "forbidden" };
|
|
43
|
+
if (findEntry(allowlist, upper, pathOnly, "allow")) return { allow: true };
|
|
44
|
+
if (findEntry(allowlist, upper, pathOnly, "deny")) return { allow: false, reason: "not-allowed" };
|
|
45
|
+
return { allow: false, reason: "unknown" };
|
|
46
|
+
}
|
|
47
|
+
function isMutatingMethod(method) {
|
|
48
|
+
return mutatingMethods.includes(method);
|
|
49
|
+
}
|
|
50
|
+
function isRecord(value) {
|
|
51
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
function parseEntry(value, index) {
|
|
54
|
+
const where = `allowlist.entries[${index}]`;
|
|
55
|
+
if (!isRecord(value)) throw new Error(`${where}: \uAC1D\uCCB4\uAC00 \uC544\uB2D9\uB2C8\uB2E4.`);
|
|
56
|
+
const { method, path: path2, status, note } = value;
|
|
57
|
+
if (typeof method !== "string" || !isMutatingMethod(method)) throw new Error(`${where}.method: ${mutatingMethods.join("|")} \uC911 \uD558\uB098\uC5EC\uC57C \uD569\uB2C8\uB2E4.`);
|
|
58
|
+
if (typeof path2 !== "string" || !path2.startsWith("/")) throw new Error(`${where}.path: "/"\uB85C \uC2DC\uC791\uD558\uB294 \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.`);
|
|
59
|
+
if (status !== "allow" && status !== "deny") throw new Error(`${where}.status: allow|deny \uC911 \uD558\uB098\uC5EC\uC57C \uD569\uB2C8\uB2E4.`);
|
|
60
|
+
if (note !== void 0 && typeof note !== "string") throw new Error(`${where}.note: \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.`);
|
|
61
|
+
return { method, path: path2, status, note: note ?? "" };
|
|
62
|
+
}
|
|
63
|
+
function parseAllowlist(value) {
|
|
64
|
+
if (!isRecord(value) || !Array.isArray(value.entries)) throw new Error("allowlist: { entries: [] } \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4.");
|
|
65
|
+
return { entries: value.entries.map(parseEntry) };
|
|
66
|
+
}
|
|
67
|
+
function entryKey(method, path2) {
|
|
68
|
+
return `${method.toUpperCase()} ${path2}`;
|
|
69
|
+
}
|
|
70
|
+
function addMissingAsDeny(allowlist, operations) {
|
|
71
|
+
const known = new Set(allowlist.entries.map((entry) => entryKey(entry.method, entry.path)));
|
|
72
|
+
const added = [];
|
|
73
|
+
for (const operation of operations) {
|
|
74
|
+
const method = operation.method.toUpperCase();
|
|
75
|
+
const key = entryKey(method, operation.path);
|
|
76
|
+
if (!isMutatingMethod(method) || known.has(key)) continue;
|
|
77
|
+
known.add(key);
|
|
78
|
+
added.push({ method, path: operation.path, status: "deny", note: "" });
|
|
79
|
+
}
|
|
80
|
+
return { allowlist: { entries: [...allowlist.entries, ...added] }, added: added.length };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// packages/engine/src/explore/allowlist-file.ts
|
|
84
|
+
import { existsSync } from "fs";
|
|
85
|
+
import { readFile } from "fs/promises";
|
|
86
|
+
import path from "path";
|
|
87
|
+
var defaultAllowlistFile = path.resolve("explore/allowlist.json");
|
|
88
|
+
async function loadAllowlist(file = defaultAllowlistFile) {
|
|
89
|
+
if (!existsSync(file)) return { entries: [] };
|
|
90
|
+
const text = await readFile(file, "utf8");
|
|
91
|
+
return parseAllowlist(JSON.parse(text));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
readMethods,
|
|
96
|
+
decideRequest,
|
|
97
|
+
isMutatingMethod,
|
|
98
|
+
parseAllowlist,
|
|
99
|
+
addMissingAsDeny,
|
|
100
|
+
defaultAllowlistFile,
|
|
101
|
+
loadAllowlist
|
|
102
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// packages/devices/src/ios-session.ts
|
|
2
|
+
import { remote } from "webdriverio";
|
|
3
|
+
var defaultAppiumUrl = "http://127.0.0.1:4723";
|
|
4
|
+
var iosMjpegPort = 9100;
|
|
5
|
+
var iosNewCommandTimeoutSec = 600;
|
|
6
|
+
var iosMjpegSettings = {
|
|
7
|
+
mjpegServerFramerate: 30,
|
|
8
|
+
mjpegScalingFactor: 50,
|
|
9
|
+
mjpegServerScreenshotQuality: 35
|
|
10
|
+
};
|
|
11
|
+
var defaultWdaBundleId = "com.example.WebDriverAgentRunner";
|
|
12
|
+
function iosCapabilities(udid, appId, signing = {}) {
|
|
13
|
+
const teamId = signing.teamId || process.env.IOS_TEAM_ID;
|
|
14
|
+
const wdaBundleId = signing.wdaBundleId || process.env.IOS_WDA_BUNDLE_ID || defaultWdaBundleId;
|
|
15
|
+
return {
|
|
16
|
+
platformName: "iOS",
|
|
17
|
+
"appium:automationName": "XCUITest",
|
|
18
|
+
"appium:udid": udid,
|
|
19
|
+
"appium:bundleId": appId,
|
|
20
|
+
"appium:noReset": true,
|
|
21
|
+
// 기본 60초면 화면 하나가 오래 걸릴 때 세션이 끊기고 남은 화면이 모두 「세션 종료」로 실패한다.
|
|
22
|
+
"appium:newCommandTimeout": iosNewCommandTimeoutSec,
|
|
23
|
+
"appium:showSafariConsoleLog": true,
|
|
24
|
+
"appium:showSafariNetworkLog": true,
|
|
25
|
+
"appium:mjpegServerPort": iosMjpegPort,
|
|
26
|
+
...teamId ? {
|
|
27
|
+
"appium:xcodeOrgId": teamId,
|
|
28
|
+
"appium:xcodeSigningId": "Apple Development",
|
|
29
|
+
"appium:updatedWDABundleId": wdaBundleId,
|
|
30
|
+
"appium:allowProvisioningDeviceRegistration": true
|
|
31
|
+
} : {}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function appiumConnection() {
|
|
35
|
+
const url = new URL(process.env.APPIUM_URL || defaultAppiumUrl);
|
|
36
|
+
return {
|
|
37
|
+
protocol: url.protocol.replace(":", ""),
|
|
38
|
+
hostname: url.hostname,
|
|
39
|
+
port: Number(url.port || defaultPortOf(url.protocol)),
|
|
40
|
+
path: url.pathname
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function defaultPortOf(protocol) {
|
|
44
|
+
return protocol === "https:" ? 443 : 80;
|
|
45
|
+
}
|
|
46
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
47
|
+
var lastAliveAt = /* @__PURE__ */ new Map();
|
|
48
|
+
var sessionCheckIntervalMs = 5e3;
|
|
49
|
+
var deadSessionPattern = /invalid session id|session is either terminated|session .* (not found|does not exist)|ECONNREFUSED|socket hang up|Could not proxy command/i;
|
|
50
|
+
function isDeadSessionError(error) {
|
|
51
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
52
|
+
return deadSessionPattern.test(message);
|
|
53
|
+
}
|
|
54
|
+
async function getIosSession(udid, appId, signing = {}) {
|
|
55
|
+
const existing = sessions.get(udid);
|
|
56
|
+
if (!existing) return openIosSession(udid, appId, signing);
|
|
57
|
+
const session = await existing;
|
|
58
|
+
if (Date.now() - (lastAliveAt.get(udid) ?? 0) < sessionCheckIntervalMs) return session;
|
|
59
|
+
try {
|
|
60
|
+
await session.getWindowSize();
|
|
61
|
+
lastAliveAt.set(udid, Date.now());
|
|
62
|
+
return session;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!isDeadSessionError(error)) throw error;
|
|
65
|
+
if (sessions.get(udid) === existing) sessions.delete(udid);
|
|
66
|
+
return openIosSession(udid, appId, signing);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function openIosSession(udid, appId, signing) {
|
|
70
|
+
const pending = sessions.get(udid);
|
|
71
|
+
if (pending) return pending;
|
|
72
|
+
const opening = remote({ ...appiumConnection(), logLevel: "warn", capabilities: iosCapabilities(udid, appId, signing) });
|
|
73
|
+
sessions.set(udid, opening);
|
|
74
|
+
opening.then(
|
|
75
|
+
(session) => {
|
|
76
|
+
lastAliveAt.set(udid, Date.now());
|
|
77
|
+
session.updateSettings(iosMjpegSettings).catch(() => void 0);
|
|
78
|
+
},
|
|
79
|
+
() => {
|
|
80
|
+
if (sessions.get(udid) === opening) sessions.delete(udid);
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
return opening;
|
|
84
|
+
}
|
|
85
|
+
async function deleteQuietly(opening) {
|
|
86
|
+
try {
|
|
87
|
+
const session = await opening;
|
|
88
|
+
await session.deleteSession();
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function resetIosSession(udid) {
|
|
93
|
+
const opening = sessions.get(udid);
|
|
94
|
+
sessions.delete(udid);
|
|
95
|
+
if (opening) await deleteQuietly(opening);
|
|
96
|
+
}
|
|
97
|
+
async function closeAllIosSessions() {
|
|
98
|
+
const openings = [...sessions.values()];
|
|
99
|
+
sessions.clear();
|
|
100
|
+
await Promise.all(openings.map(deleteQuietly));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export {
|
|
104
|
+
iosMjpegPort,
|
|
105
|
+
iosCapabilities,
|
|
106
|
+
appiumConnection,
|
|
107
|
+
isDeadSessionError,
|
|
108
|
+
getIosSession,
|
|
109
|
+
resetIosSession,
|
|
110
|
+
closeAllIosSessions
|
|
111
|
+
};
|