@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,1687 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startPolicyWatch
|
|
3
|
+
} from "./chunk-PYZWMVJ6.js";
|
|
4
|
+
import {
|
|
5
|
+
checkPolicyScreen,
|
|
6
|
+
policyScreens
|
|
7
|
+
} from "./chunk-WTTCWFOX.js";
|
|
8
|
+
import {
|
|
9
|
+
addIgnore,
|
|
10
|
+
askClaude,
|
|
11
|
+
farmScenarios,
|
|
12
|
+
readAllPageRules,
|
|
13
|
+
readFigmaCache,
|
|
14
|
+
readFrontFigmaMap,
|
|
15
|
+
readIgnores,
|
|
16
|
+
removeIgnore
|
|
17
|
+
} from "./chunk-VGPCTMUA.js";
|
|
18
|
+
import {
|
|
19
|
+
startCollectors
|
|
20
|
+
} from "./chunk-ZE2GGXFO.js";
|
|
21
|
+
import {
|
|
22
|
+
ExploreRequestError,
|
|
23
|
+
FrameRing,
|
|
24
|
+
LogRing,
|
|
25
|
+
baselineKey,
|
|
26
|
+
channelTolerance,
|
|
27
|
+
closeUnfinishedRun,
|
|
28
|
+
confirmDeviceBaselines,
|
|
29
|
+
createRunId,
|
|
30
|
+
defaultGuard,
|
|
31
|
+
deviceBaselinesRoot,
|
|
32
|
+
devicesRoot,
|
|
33
|
+
errorMessage,
|
|
34
|
+
installedVersionLabel,
|
|
35
|
+
loadExploreInputs,
|
|
36
|
+
pendingDeviceBaselines,
|
|
37
|
+
probeScreen,
|
|
38
|
+
pruneRuns,
|
|
39
|
+
readLatestExplore,
|
|
40
|
+
readLatestRun,
|
|
41
|
+
replayOne,
|
|
42
|
+
runDevices,
|
|
43
|
+
runExplore,
|
|
44
|
+
testResultsRoot,
|
|
45
|
+
writeRun
|
|
46
|
+
} from "./chunk-AVECSGEH.js";
|
|
47
|
+
import "./chunk-UAHWEUDI.js";
|
|
48
|
+
import "./chunk-F6PZOBU6.js";
|
|
49
|
+
import "./chunk-Q6CXF3NJ.js";
|
|
50
|
+
import {
|
|
51
|
+
acceptGenerated,
|
|
52
|
+
addCandidate,
|
|
53
|
+
confirmFlows,
|
|
54
|
+
flowDirs,
|
|
55
|
+
listPrds,
|
|
56
|
+
mapFlowError,
|
|
57
|
+
mapFlowPrd,
|
|
58
|
+
mapRulesOf,
|
|
59
|
+
prdHash,
|
|
60
|
+
prdRoot,
|
|
61
|
+
readCandidates,
|
|
62
|
+
readHashes,
|
|
63
|
+
readPrd,
|
|
64
|
+
readReady,
|
|
65
|
+
writeCandidates,
|
|
66
|
+
writeHash
|
|
67
|
+
} from "./chunk-PLMSQ5KF.js";
|
|
68
|
+
import {
|
|
69
|
+
readScreenMap,
|
|
70
|
+
recordScreenMap
|
|
71
|
+
} from "./chunk-F4TMB4WY.js";
|
|
72
|
+
import {
|
|
73
|
+
stepsOf
|
|
74
|
+
} from "./chunk-SWW42KTA.js";
|
|
75
|
+
import {
|
|
76
|
+
runScreenQa
|
|
77
|
+
} from "./chunk-3GCH6TN3.js";
|
|
78
|
+
import {
|
|
79
|
+
createDeviceDeps,
|
|
80
|
+
createDeviceList,
|
|
81
|
+
defaultBuildDeps,
|
|
82
|
+
firebaseAppId,
|
|
83
|
+
listReleases,
|
|
84
|
+
pickRelease
|
|
85
|
+
} from "./chunk-PZZQWRSU.js";
|
|
86
|
+
import "./chunk-OUNLQI2N.js";
|
|
87
|
+
import {
|
|
88
|
+
closeAllIosSessions,
|
|
89
|
+
getIosSession,
|
|
90
|
+
iosMjpegPort,
|
|
91
|
+
isDeadSessionError,
|
|
92
|
+
resetIosSession
|
|
93
|
+
} from "./chunk-S6GHOP4B.js";
|
|
94
|
+
import "./chunk-JZDYF3HL.js";
|
|
95
|
+
import {
|
|
96
|
+
cliApp
|
|
97
|
+
} from "./chunk-7OJDY276.js";
|
|
98
|
+
import {
|
|
99
|
+
execFileText,
|
|
100
|
+
nativeContext
|
|
101
|
+
} from "./chunk-AXQNUPGC.js";
|
|
102
|
+
import {
|
|
103
|
+
apiOrigin,
|
|
104
|
+
loadDotEnv,
|
|
105
|
+
webOrigin
|
|
106
|
+
} from "./chunk-NLX3V7ZJ.js";
|
|
107
|
+
import {
|
|
108
|
+
appIdFor,
|
|
109
|
+
publicAppConfig,
|
|
110
|
+
tabNamesOf
|
|
111
|
+
} from "./chunk-NJNTEWXE.js";
|
|
112
|
+
|
|
113
|
+
// apps/cli/src/serve.ts
|
|
114
|
+
import { existsSync as existsSync3 } from "fs";
|
|
115
|
+
import path6 from "path";
|
|
116
|
+
import { fileURLToPath } from "url";
|
|
117
|
+
import { preview } from "vite";
|
|
118
|
+
|
|
119
|
+
// packages/server/src/http/device-api.ts
|
|
120
|
+
import { readFileSync as readFileSync4, realpathSync, statSync } from "fs";
|
|
121
|
+
import { get as httpGet } from "http";
|
|
122
|
+
import path5 from "path";
|
|
123
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
124
|
+
|
|
125
|
+
// packages/core/src/device/input.ts
|
|
126
|
+
var inputKeys = ["back", "home"];
|
|
127
|
+
var maxSwipeMs = 1e4;
|
|
128
|
+
var maxTextLength = 1e3;
|
|
129
|
+
function clamp(value, max) {
|
|
130
|
+
return Math.min(Math.max(value, 0), max);
|
|
131
|
+
}
|
|
132
|
+
function toDevicePoint(point, size) {
|
|
133
|
+
return {
|
|
134
|
+
x: clamp(Math.round(point.x * size.width), size.width - 1),
|
|
135
|
+
y: clamp(Math.round(point.y * size.height), size.height - 1)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function isRecord(value) {
|
|
139
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
function isFiniteNumber(value) {
|
|
142
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
143
|
+
}
|
|
144
|
+
function parsePoint(value) {
|
|
145
|
+
if (!isRecord(value) || !isFiniteNumber(value.x) || !isFiniteNumber(value.y)) return null;
|
|
146
|
+
return { x: value.x, y: value.y };
|
|
147
|
+
}
|
|
148
|
+
function parseSwipe(value) {
|
|
149
|
+
const from = parsePoint(value.from);
|
|
150
|
+
const to = parsePoint(value.to);
|
|
151
|
+
const { durationMs } = value;
|
|
152
|
+
if (!from || !to || !isFiniteNumber(durationMs) || durationMs < 0 || durationMs > maxSwipeMs) return null;
|
|
153
|
+
return { type: "swipe", from, to, durationMs };
|
|
154
|
+
}
|
|
155
|
+
function parseKey(value) {
|
|
156
|
+
return inputKeys.find((key) => key === value) ?? null;
|
|
157
|
+
}
|
|
158
|
+
function parseInputEvent(value) {
|
|
159
|
+
if (!isRecord(value)) return null;
|
|
160
|
+
if (value.type === "tap") {
|
|
161
|
+
const point = parsePoint(value);
|
|
162
|
+
return point ? { type: "tap", ...point } : null;
|
|
163
|
+
}
|
|
164
|
+
if (value.type === "swipe") return parseSwipe(value);
|
|
165
|
+
if (value.type === "text") {
|
|
166
|
+
const { text } = value;
|
|
167
|
+
return typeof text === "string" && text.length > 0 && text.length <= maxTextLength ? { type: "text", text } : null;
|
|
168
|
+
}
|
|
169
|
+
if (value.type === "key") {
|
|
170
|
+
const key = parseKey(value.key);
|
|
171
|
+
return key ? { type: "key", key } : null;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// packages/devices/src/stream/ios.ts
|
|
177
|
+
var iosMjpegUrl = `http://127.0.0.1:${iosMjpegPort}`;
|
|
178
|
+
var iosBackUnsupported = "iOS\uB294 \uB4A4\uB85C \uD0A4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
179
|
+
var tapHoldMs = 50;
|
|
180
|
+
var fingerId = "finger1";
|
|
181
|
+
var moveTo = (point, duration) => ({ type: "pointerMove", duration, origin: "viewport", ...point });
|
|
182
|
+
function iosTapArgs(event, size) {
|
|
183
|
+
return event.type === "tap" ? toDevicePoint(event, size) : null;
|
|
184
|
+
}
|
|
185
|
+
function iosPointerActions(event, size) {
|
|
186
|
+
const touch2 = (steps) => [
|
|
187
|
+
{ type: "pointer", id: fingerId, parameters: { pointerType: "touch" }, actions: steps }
|
|
188
|
+
];
|
|
189
|
+
if (event.type === "tap") {
|
|
190
|
+
const point = toDevicePoint(event, size);
|
|
191
|
+
return touch2([moveTo(point, 0), { type: "pointerDown", button: 0 }, { type: "pause", duration: tapHoldMs }, { type: "pointerUp", button: 0 }]);
|
|
192
|
+
}
|
|
193
|
+
if (event.type === "swipe") {
|
|
194
|
+
const from = toDevicePoint(event.from, size);
|
|
195
|
+
const to = toDevicePoint(event.to, size);
|
|
196
|
+
return touch2([moveTo(from, 0), { type: "pointerDown", button: 0 }, moveTo(to, Math.round(event.durationMs)), { type: "pointerUp", button: 0 }]);
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
async function sendKey(session, key) {
|
|
201
|
+
if (key === "back") throw new Error(iosBackUnsupported);
|
|
202
|
+
await session.execute("mobile: pressButton", { name: "home" });
|
|
203
|
+
}
|
|
204
|
+
async function openIosStream(udid, session) {
|
|
205
|
+
await session.switchContext(nativeContext);
|
|
206
|
+
const window = await session.getWindowSize();
|
|
207
|
+
const size = { width: window.width, height: window.height };
|
|
208
|
+
const send = async (event) => {
|
|
209
|
+
if (event.type === "key") return sendKey(session, event.key);
|
|
210
|
+
const tap = iosTapArgs(event, size);
|
|
211
|
+
if (tap) return void await session.execute("mobile: tap", tap);
|
|
212
|
+
await session.switchContext(nativeContext);
|
|
213
|
+
if (event.type === "text") return session.keys(event.text);
|
|
214
|
+
const actions = iosPointerActions(event, size);
|
|
215
|
+
if (!actions) throw new Error(`${udid}: \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uC785\uB825\uC785\uB2C8\uB2E4.`);
|
|
216
|
+
await session.performActions(actions);
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
mjpegUrl: iosMjpegUrl,
|
|
220
|
+
send,
|
|
221
|
+
size: () => size,
|
|
222
|
+
// 세션은 상시 유지한다. 닫을 것은 없다.
|
|
223
|
+
close: async () => void 0
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// packages/engine/src/chaos/results.ts
|
|
228
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
229
|
+
import path from "path";
|
|
230
|
+
var chaosRoot = path.resolve("test-results/chaos");
|
|
231
|
+
var runIdPattern = /^[0-9A-Za-z-]+$/;
|
|
232
|
+
function readChaosRun(runId, root = chaosRoot) {
|
|
233
|
+
if (!runIdPattern.test(runId)) return null;
|
|
234
|
+
const file = path.join(root, runId, "run.json");
|
|
235
|
+
if (!existsSync(file)) return null;
|
|
236
|
+
try {
|
|
237
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function listChaosRuns(root = chaosRoot) {
|
|
243
|
+
if (!existsSync(root)) return [];
|
|
244
|
+
return readdirSync(root).flatMap((runId) => {
|
|
245
|
+
const run = readChaosRun(runId, root);
|
|
246
|
+
if (!run) return [];
|
|
247
|
+
const summary = {
|
|
248
|
+
runId: run.runId,
|
|
249
|
+
deviceId: run.deviceId,
|
|
250
|
+
seed: run.seed,
|
|
251
|
+
startedAt: run.startedAt,
|
|
252
|
+
finishedAt: run.finishedAt,
|
|
253
|
+
steps: run.steps.length,
|
|
254
|
+
states: run.states,
|
|
255
|
+
errorSteps: run.steps.filter((step) => step.errors.length > 0).length,
|
|
256
|
+
blocked: run.blocked.length
|
|
257
|
+
};
|
|
258
|
+
return [summary];
|
|
259
|
+
}).sort((a, b) => a.startedAt < b.startedAt ? 1 : -1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// packages/core/src/flows/prompt.ts
|
|
263
|
+
function parseModelJson(text) {
|
|
264
|
+
const trimmed = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
265
|
+
try {
|
|
266
|
+
const parsed = JSON.parse(trimmed);
|
|
267
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function generationPrompt(input2) {
|
|
273
|
+
const tabNames = tabNamesOf(input2.app);
|
|
274
|
+
const forbiddenClickNames = input2.app.guard.forbiddenClickNames;
|
|
275
|
+
return [
|
|
276
|
+
`PRD\uC5D0\uC11C ${input2.app.name} \uC571 \uC2E4\uAE30\uAE30 \uD50C\uB85C\uC6B0 \uC810\uAC80 \uD6C4\uBCF4\uB97C JSON \uBC30\uC5F4\uB85C\uB9CC \uB2F5\uD55C\uB2E4.`,
|
|
277
|
+
"\uB3D9\uC791\uC740 click, expectText\uB9CC \uC4F4\uB2E4. \uC8FC\uC18C\uB85C \uD654\uBA74\uC744 \uC5EC\uB294 \uB3D9\uC791\uC740 \uC5C6\uB2E4.",
|
|
278
|
+
`\uD50C\uB85C\uC6B0\uB294 \uC571 \uD648(${input2.app.homeTabName} \uD0ED)\uC5D0\uC11C \uC2DC\uC791\uD55C\uB2E4. \uC810\uAC80\uAE30\uAC00 \uC2DC\uC791 \uC804\uC5D0 \uD648\uC73C\uB85C \uBCF4\uB0B8\uB2E4. \uB2E4\uB978 \uD654\uBA74\uC73C\uB85C\uB294 \uBC84\uD2BC\uACFC \uB9C1\uD06C\uB97C \uB20C\uB7EC\uC11C\uB9CC \uAC04\uB2E4.`,
|
|
279
|
+
`\uD558\uB2E8 \uD0ED\uC740 click.target.role link\uB85C \uB204\uB978\uB2E4. \uD0ED \uC774\uB984: ${tabNames.join(", ")}.`,
|
|
280
|
+
"click.target.role\uC740 button \uB610\uB294 link\uC774\uACE0 name\uC740 \uD654\uBA74\uC5D0 \uBCF4\uC774\uB294 \uBB38\uAD6C\uC640 \uC815\uD655\uD788 \uAC19\uC544\uC57C \uD55C\uB2E4.",
|
|
281
|
+
"click \uB4A4\uC5D0\uB294 \uBC14\uB010 \uD654\uBA74\uC758 \uBB38\uAD6C\uB97C expectText\uB85C \uD655\uC778\uD55C\uB2E4.",
|
|
282
|
+
`${forbiddenClickNames.join(", ")} \uBC84\uD2BC\uC740 \uC4F0\uC9C0 \uC54A\uB294\uB2E4.`,
|
|
283
|
+
"\uC785\uB825, \uC8FC\uBB38, \uACB0\uC81C, \uD310\uB9E4, \uCD9C\uAE08, \uD0C8\uD1F4, \uBCF8\uC778\uC778\uC99D \uD50C\uB85C\uC6B0\uB294 \uB9CC\uB4E4\uC9C0 \uC54A\uB294\uB2E4.",
|
|
284
|
+
"expectText\uAC00 \uD558\uB098 \uC774\uC0C1 \uC788\uC5B4\uC57C \uD55C\uB2E4.",
|
|
285
|
+
"requirement\uB294 PRD\uC5D0 \uC788\uB294 \uBB38\uC7A5\uC744 \uADF8\uB300\uB85C \uC4F4\uB2E4.",
|
|
286
|
+
"proposal\uC740 keep, update, create, delete \uC911 \uD558\uB098\uB2E4.",
|
|
287
|
+
"id\uB294 \uC601\uBB38 \uC18C\uBB38\uC790\uC640 \uC22B\uC790, \uD558\uC774\uD508\uB9CC \uC4F4\uB2E4.",
|
|
288
|
+
"source\uB294 ai_generated\uB2E4.",
|
|
289
|
+
`PRD \uD30C\uC77C: ${input2.prdName}`,
|
|
290
|
+
"\uD655\uC815\uB41C \uC2DC\uB098\uB9AC\uC624:",
|
|
291
|
+
JSON.stringify(input2.ready.map((item) => ({ id: item.id, name: item.name, requirement: item.requirement, steps: item.steps }))),
|
|
292
|
+
"PRD \uBCF8\uBB38:",
|
|
293
|
+
input2.prdBody
|
|
294
|
+
].join("\n");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// packages/engine/src/flows/llm.ts
|
|
298
|
+
var llmTimeoutMs = 2e4;
|
|
299
|
+
function llmConfigured() {
|
|
300
|
+
return Boolean(process.env.FLOW_LLM_API_KEY && process.env.FLOW_LLM_BASE_URL && process.env.FLOW_LLM_MODEL);
|
|
301
|
+
}
|
|
302
|
+
async function complete(prompt) {
|
|
303
|
+
const key = process.env.FLOW_LLM_API_KEY ?? "";
|
|
304
|
+
const base = process.env.FLOW_LLM_BASE_URL ?? "";
|
|
305
|
+
const model = process.env.FLOW_LLM_MODEL ?? "";
|
|
306
|
+
if (!key || !base || !model) return { ok: false, skipped: true };
|
|
307
|
+
const controller = new AbortController();
|
|
308
|
+
const timer = setTimeout(() => controller.abort(), llmTimeoutMs);
|
|
309
|
+
try {
|
|
310
|
+
const response = await fetch(new URL("chat/completions", base.endsWith("/") ? base : `${base}/`), {
|
|
311
|
+
method: "POST",
|
|
312
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
|
|
313
|
+
body: JSON.stringify({
|
|
314
|
+
model,
|
|
315
|
+
messages: [{ role: "user", content: prompt }]
|
|
316
|
+
}),
|
|
317
|
+
signal: controller.signal
|
|
318
|
+
});
|
|
319
|
+
if (!response.ok) return { ok: false, skipped: false };
|
|
320
|
+
const body = await response.json();
|
|
321
|
+
const text = body.choices?.[0]?.message?.content;
|
|
322
|
+
if (typeof text !== "string" || !text) return { ok: false, skipped: false };
|
|
323
|
+
return { ok: true, text };
|
|
324
|
+
} catch {
|
|
325
|
+
return { ok: false, skipped: false };
|
|
326
|
+
} finally {
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// packages/engine/src/flows/generate.ts
|
|
332
|
+
var llmMissingReason = "FLOW_LLM_API_KEY\xB7FLOW_LLM_BASE_URL\xB7FLOW_LLM_MODEL\uC774 \uC5C6\uC5B4 \uC0DD\uC131\uC744 \uAC74\uB108\uB701\uB2C8\uB2E4.";
|
|
333
|
+
var sameHashReason = "PRD\uAC00 \uC9C0\uB09C \uC0DD\uC131 \uB4A4 \uBC14\uB00C\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.";
|
|
334
|
+
async function generateFromPrd(app, name, dirs = flowDirs, llm = complete) {
|
|
335
|
+
const body = readPrd(name);
|
|
336
|
+
if (body == null) return { ok: false, status: 400, error: `PRD\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${name}` };
|
|
337
|
+
const hash = prdHash(body);
|
|
338
|
+
if (readHashes(dirs)[name] === hash) return { ok: true, generated: 0, skipped: true, reason: sameHashReason };
|
|
339
|
+
if (llm === complete && !llmConfigured()) return { ok: true, generated: 0, skipped: true, reason: llmMissingReason };
|
|
340
|
+
const result = await llm(generationPrompt({ app, prdName: name, prdBody: body, ready: readReady(dirs) }));
|
|
341
|
+
if (!result.ok) return { ok: false, status: 502, error: "LLM\uC774 \uB2F5\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4." };
|
|
342
|
+
const items = parseModelJson(result.text);
|
|
343
|
+
if (!items) return { ok: false, status: 502, error: "LLM \uB2F5\uC774 JSON \uBC30\uC5F4\uC774 \uC544\uB2D9\uB2C8\uB2E4." };
|
|
344
|
+
const accepted = acceptGenerated(items, name, body, app.guard.forbiddenClickNames, dirs);
|
|
345
|
+
writeCandidates(name, accepted, dirs);
|
|
346
|
+
writeHash(name, hash, dirs);
|
|
347
|
+
return { ok: true, generated: accepted.length, skipped: false };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// packages/server/src/access.ts
|
|
351
|
+
var localUser = "local";
|
|
352
|
+
var userHeader = "tailscale-user-login";
|
|
353
|
+
var proxyHeaders = ["x-forwarded-for", "x-forwarded-host", "forwarded", "tailscale-user-name"];
|
|
354
|
+
var loopbackAddresses = ["127.0.0.1", "::1", "::ffff:127.0.0.1"];
|
|
355
|
+
function allowedDomains(value = process.env.FARM_ALLOWED_DOMAINS, fallback = []) {
|
|
356
|
+
const normalize = (list) => list.map((domain) => domain.trim().toLowerCase().replace(/^@/, "")).filter(Boolean);
|
|
357
|
+
const domains = normalize((value ?? "").split(","));
|
|
358
|
+
return domains.length > 0 ? domains : normalize(fallback);
|
|
359
|
+
}
|
|
360
|
+
function headerValue(headers, name) {
|
|
361
|
+
const value = headers[name];
|
|
362
|
+
return (Array.isArray(value) ? value[0] : value)?.trim() || void 0;
|
|
363
|
+
}
|
|
364
|
+
function domainOf(login) {
|
|
365
|
+
return (login.split("@").at(-1) ?? "").toLowerCase();
|
|
366
|
+
}
|
|
367
|
+
function decideAccess(input2, domains) {
|
|
368
|
+
const login = headerValue(input2.headers, userHeader);
|
|
369
|
+
if (login) {
|
|
370
|
+
if (login.includes("@") && domains.includes(domainOf(login))) return { ok: true, user: login };
|
|
371
|
+
const allowed = domains.length > 0 ? `${domains.join(", ")} \uACC4\uC815\uC73C\uB85C \uC811\uC18D\uD574 \uC8FC\uC138\uC694.` : "\uD5C8\uC6A9\uD55C \uACC4\uC815 \uB3C4\uBA54\uC778\uC774 \uC5C6\uC2B5\uB2C8\uB2E4(FARM_ALLOWED_DOMAINS\xB7access.allowedDomains).";
|
|
372
|
+
return { ok: false, status: 403, message: `${login} \uACC4\uC815\uC740 \uB514\uBC14\uC774\uC2A4\uD31C\uC744 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. ${allowed}` };
|
|
373
|
+
}
|
|
374
|
+
const proxied = proxyHeaders.some((name) => headerValue(input2.headers, name));
|
|
375
|
+
const loopback = loopbackAddresses.some((address) => address === input2.remoteAddress);
|
|
376
|
+
if (loopback && !proxied) return { ok: true, user: localUser };
|
|
377
|
+
return { ok: false, status: 401, message: "Tailscale \uACC4\uC815\uC744 \uD655\uC778\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. tailnet \uC548\uC5D0\uC11C tailscale serve \uC8FC\uC18C\uB85C \uC811\uC18D\uD574 \uC8FC\uC138\uC694." };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// packages/core/src/device/lock.ts
|
|
381
|
+
var controlIdleMs = 5 * 6e4;
|
|
382
|
+
function sameOwner(left, right) {
|
|
383
|
+
if (left.kind === "bot" && right.kind === "bot") return left.runId === right.runId;
|
|
384
|
+
if (left.kind === "user" && right.kind === "user") return left.user === right.user;
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
var DeviceLocks = class {
|
|
388
|
+
constructor(now = Date.now) {
|
|
389
|
+
this.now = now;
|
|
390
|
+
}
|
|
391
|
+
now;
|
|
392
|
+
entries = /* @__PURE__ */ new Map();
|
|
393
|
+
acquire(id, owner) {
|
|
394
|
+
const current = this.ownerOf(id);
|
|
395
|
+
if (current && !sameOwner(current, owner)) return false;
|
|
396
|
+
this.entries.set(id, { owner, touchedAt: this.now() });
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
release(id, owner) {
|
|
400
|
+
const current = this.ownerOf(id);
|
|
401
|
+
if (current && sameOwner(current, owner)) this.entries.delete(id);
|
|
402
|
+
}
|
|
403
|
+
touch(id) {
|
|
404
|
+
const entry = this.entries.get(id);
|
|
405
|
+
if (entry) entry.touchedAt = this.now();
|
|
406
|
+
}
|
|
407
|
+
ownerOf(id) {
|
|
408
|
+
const entry = this.entries.get(id);
|
|
409
|
+
if (!entry) return null;
|
|
410
|
+
const idle = entry.owner.kind === "user" && this.now() - entry.touchedAt > controlIdleMs;
|
|
411
|
+
if (!idle) return entry.owner;
|
|
412
|
+
this.entries.delete(id);
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// packages/server/src/baselines.ts
|
|
418
|
+
import { createHash } from "crypto";
|
|
419
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
420
|
+
import path2 from "path";
|
|
421
|
+
import { PNG } from "pngjs";
|
|
422
|
+
var keyHashLength = 16;
|
|
423
|
+
var fadeRatio = 0.3;
|
|
424
|
+
var channelMax = 255;
|
|
425
|
+
function baselineDir(key, root) {
|
|
426
|
+
const id = createHash("sha256").update(key, "utf8").digest("hex").slice(0, keyHashLength);
|
|
427
|
+
return path2.join(root, id);
|
|
428
|
+
}
|
|
429
|
+
function candidatePng(key, root = deviceBaselinesRoot) {
|
|
430
|
+
if (!pendingDeviceBaselines(root).includes(key)) return null;
|
|
431
|
+
const file = path2.join(baselineDir(key, root), "candidate.png");
|
|
432
|
+
return existsSync2(file) ? readFileSync2(file) : null;
|
|
433
|
+
}
|
|
434
|
+
function differs(left, right, offset) {
|
|
435
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
436
|
+
if (Math.abs(left[offset + channel] - right[offset + channel]) > channelTolerance) return true;
|
|
437
|
+
}
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
function diffPng(current, baseline) {
|
|
441
|
+
const now = PNG.sync.read(current);
|
|
442
|
+
const base = PNG.sync.read(baseline);
|
|
443
|
+
if (now.width !== base.width || now.height !== base.height) return null;
|
|
444
|
+
const out = new PNG({ width: now.width, height: now.height });
|
|
445
|
+
for (let offset = 0; offset < now.data.length; offset += 4) {
|
|
446
|
+
const red = differs(now.data, base.data, offset);
|
|
447
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
448
|
+
const faded = channelMax - (channelMax - now.data[offset + channel]) * fadeRatio;
|
|
449
|
+
out.data[offset + channel] = red ? channel === 0 ? channelMax : 0 : Math.round(faded);
|
|
450
|
+
}
|
|
451
|
+
out.data[offset + 3] = channelMax;
|
|
452
|
+
}
|
|
453
|
+
return PNG.sync.write(out);
|
|
454
|
+
}
|
|
455
|
+
function baselineFor(device, scenario, screenshot, root = deviceBaselinesRoot) {
|
|
456
|
+
const file = path2.join(baselineDir(baselineKey(device, scenario.name), root), "baseline.png");
|
|
457
|
+
if (!existsSync2(file)) return void 0;
|
|
458
|
+
const baseline = readFileSync2(file);
|
|
459
|
+
const diff = diffPng(screenshot, baseline);
|
|
460
|
+
return diff ? { baseline, diff } : void 0;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// packages/server/src/auto.ts
|
|
464
|
+
function createAutoRuns(deps) {
|
|
465
|
+
async function platformDevices(signal) {
|
|
466
|
+
return (await deps.connected()).filter((device) => device.platform === signal.target.platform);
|
|
467
|
+
}
|
|
468
|
+
async function runAuto(signal) {
|
|
469
|
+
const { platform, kind } = signal.target;
|
|
470
|
+
const ids = (await platformDevices(signal)).map((device) => device.id);
|
|
471
|
+
if (ids.length === 0) {
|
|
472
|
+
deps.log(`${platform}-${kind} ${signal.version} \uC790\uB3D9 \uC810\uAC80: \uC5F0\uACB0\uB41C \uAE30\uAE30\uAC00 \uC5C6\uC5B4 \uAC74\uB108\uB701\uB2C8\uB2E4.`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
await deps.gate.startWhenIdle("run", () => runDevices({ kind, version: signal.version, deviceIds: ids, trigger: "auto" }, deps.runDeps()));
|
|
476
|
+
}
|
|
477
|
+
async function failIos(signal, message) {
|
|
478
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
479
|
+
const ios = await platformDevices(signal);
|
|
480
|
+
deps.record({
|
|
481
|
+
runId: createRunId(Date.now()),
|
|
482
|
+
kind: signal.target.kind,
|
|
483
|
+
version: signal.version,
|
|
484
|
+
trigger: "auto",
|
|
485
|
+
startedAt: at,
|
|
486
|
+
finishedAt: at,
|
|
487
|
+
outcomes: ios.map((device) => ({ device, status: "install_failed", message, screens: [] }))
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return { runAuto, failIos };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// packages/server/src/capture.ts
|
|
494
|
+
function createCapture(hub) {
|
|
495
|
+
return async (device, driver, appId) => {
|
|
496
|
+
const ring = new LogRing();
|
|
497
|
+
const frames = new FrameRing();
|
|
498
|
+
const stopCollectors = await startCollectors(device, appId, driver, ring);
|
|
499
|
+
let unsubscribe = () => {
|
|
500
|
+
};
|
|
501
|
+
if (device.platform === "android") {
|
|
502
|
+
try {
|
|
503
|
+
const subscription = await hub.subscribe(device.id, (packet, key) => frames.push(Buffer.from(packet), Date.now(), key));
|
|
504
|
+
unsubscribe = subscription.unsubscribe;
|
|
505
|
+
} catch (error) {
|
|
506
|
+
ring.push({ source: "system", text: `\uC601\uC0C1 \uC218\uC9D1 \uC2E4\uD328: ${errorMessage(error)}` });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
ring,
|
|
511
|
+
frames: () => frames.frames(),
|
|
512
|
+
stop: async () => {
|
|
513
|
+
unsubscribe();
|
|
514
|
+
frames.clear();
|
|
515
|
+
await stopCollectors();
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// packages/server/src/control.ts
|
|
522
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
523
|
+
import path3 from "path";
|
|
524
|
+
var controlFile = path3.join(devicesRoot, "control.jsonl");
|
|
525
|
+
var expireCheckMs = 3e4;
|
|
526
|
+
function createControlLog(locks, file = controlFile, now = Date.now) {
|
|
527
|
+
const held = /* @__PURE__ */ new Map();
|
|
528
|
+
function append(entry) {
|
|
529
|
+
mkdirSync(path3.dirname(file), { recursive: true });
|
|
530
|
+
appendFileSync(file, `${JSON.stringify(entry)}
|
|
531
|
+
`);
|
|
532
|
+
}
|
|
533
|
+
function record(entry) {
|
|
534
|
+
if (entry.action === "lock") held.set(entry.deviceId, entry.user);
|
|
535
|
+
else held.delete(entry.deviceId);
|
|
536
|
+
append({ at: now(), ...entry });
|
|
537
|
+
}
|
|
538
|
+
function checkExpired() {
|
|
539
|
+
for (const [deviceId, user] of held) {
|
|
540
|
+
const owner = locks.ownerOf(deviceId);
|
|
541
|
+
if (owner?.kind === "user" && owner.user === user) continue;
|
|
542
|
+
record({ user, deviceId, action: "expire" });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const timer = setInterval(checkExpired, expireCheckMs);
|
|
546
|
+
timer.unref();
|
|
547
|
+
return { record, checkExpired, stop: () => clearInterval(timer) };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// packages/server/src/jobs.ts
|
|
551
|
+
import { setTimeout as wait } from "timers/promises";
|
|
552
|
+
var busyMessages = {
|
|
553
|
+
run: "\uC774\uBBF8 \uAE30\uAE30 \uC810\uAC80 \uC911\uC785\uB2C8\uB2E4.",
|
|
554
|
+
explore: "\uC774\uBBF8 \uD0D0\uC0C9 \uC911\uC785\uB2C8\uB2E4.",
|
|
555
|
+
replay: "\uB2E4\uC2DC \uB3CC\uB9AC\uAE30\uAC00 \uC9C4\uD589 \uC911\uC785\uB2C8\uB2E4."
|
|
556
|
+
};
|
|
557
|
+
var idlePollMs = 1e3;
|
|
558
|
+
function createJobGate(onIdle) {
|
|
559
|
+
let current = null;
|
|
560
|
+
function start(kind, work) {
|
|
561
|
+
if (current !== null) return null;
|
|
562
|
+
current = kind;
|
|
563
|
+
return (async () => {
|
|
564
|
+
try {
|
|
565
|
+
await work();
|
|
566
|
+
} finally {
|
|
567
|
+
current = null;
|
|
568
|
+
onIdle();
|
|
569
|
+
}
|
|
570
|
+
})();
|
|
571
|
+
}
|
|
572
|
+
async function startWhenIdle(kind, work) {
|
|
573
|
+
for (; ; ) {
|
|
574
|
+
const started = start(kind, work);
|
|
575
|
+
if (started) return started;
|
|
576
|
+
await wait(idlePollMs);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
start,
|
|
581
|
+
startWhenIdle,
|
|
582
|
+
isBusy: () => current !== null,
|
|
583
|
+
busyMessage: () => current === null ? "" : busyMessages[current]
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// packages/devices/src/stream/android.ts
|
|
588
|
+
import { spawn } from "child_process";
|
|
589
|
+
import { setTimeout as wait2 } from "timers/promises";
|
|
590
|
+
var screenrecordBitRate = 4e6;
|
|
591
|
+
var screenrecordTimeLimitSec = 180;
|
|
592
|
+
var restartDelayMs = 1e3;
|
|
593
|
+
var nalTypeMask = 31;
|
|
594
|
+
var nalTypes = { idr: 5, sps: 7, pps: 8 };
|
|
595
|
+
var firstVclType = 1;
|
|
596
|
+
var lastVclType = 5;
|
|
597
|
+
var startCode = Uint8Array.of(0, 0, 0, 1);
|
|
598
|
+
var startCodeLength = startCode.length;
|
|
599
|
+
var androidKeyCodes = { back: 4, home: 3 };
|
|
600
|
+
function concatBytes(parts) {
|
|
601
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
602
|
+
const out = new Uint8Array(total);
|
|
603
|
+
let offset = 0;
|
|
604
|
+
for (const part of parts) {
|
|
605
|
+
out.set(part, offset);
|
|
606
|
+
offset += part.length;
|
|
607
|
+
}
|
|
608
|
+
return out;
|
|
609
|
+
}
|
|
610
|
+
function parseWmSize(stdout) {
|
|
611
|
+
const pick = (label) => stdout.match(new RegExp(`${label} size:\\s*(\\d+)x(\\d+)`));
|
|
612
|
+
const match = pick("Override") ?? pick("Physical");
|
|
613
|
+
if (!match) return null;
|
|
614
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
615
|
+
}
|
|
616
|
+
function avcCodecString(sps) {
|
|
617
|
+
const hex = (byte) => byte.toString(16).toUpperCase().padStart(2, "0");
|
|
618
|
+
const header = startCodeLength;
|
|
619
|
+
return `avc1.${hex(sps[header + 1])}${hex(sps[header + 2])}${hex(sps[header + 3])}`;
|
|
620
|
+
}
|
|
621
|
+
function nalType(nal) {
|
|
622
|
+
return nal[startCodeLength] & nalTypeMask;
|
|
623
|
+
}
|
|
624
|
+
function findNalStarts(buffer) {
|
|
625
|
+
const starts = [];
|
|
626
|
+
for (let index = 0; index + 2 < buffer.length; index += 1) {
|
|
627
|
+
if (buffer[index] === 0 && buffer[index + 1] === 0 && buffer[index + 2] === 1) {
|
|
628
|
+
starts.push(index + 3);
|
|
629
|
+
index += 2;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return starts;
|
|
633
|
+
}
|
|
634
|
+
function withStartCode(payload) {
|
|
635
|
+
let end = payload.length;
|
|
636
|
+
while (end > 0 && payload[end - 1] === 0) end -= 1;
|
|
637
|
+
return concatBytes([startCode, payload.subarray(0, end)]);
|
|
638
|
+
}
|
|
639
|
+
var AnnexBSplitter = class {
|
|
640
|
+
buffer = new Uint8Array(0);
|
|
641
|
+
push(chunk) {
|
|
642
|
+
this.buffer = concatBytes([this.buffer, chunk]);
|
|
643
|
+
const starts = findNalStarts(this.buffer);
|
|
644
|
+
if (starts.length < 2) return [];
|
|
645
|
+
const nals = starts.slice(0, -1).map((start, index) => withStartCode(this.buffer.subarray(start, starts[index + 1] - 3)));
|
|
646
|
+
this.buffer = this.buffer.slice(starts[starts.length - 1] - 3);
|
|
647
|
+
return nals;
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
var AccessUnitAssembler = class {
|
|
651
|
+
pending = [];
|
|
652
|
+
sps = null;
|
|
653
|
+
pps = null;
|
|
654
|
+
latestSps() {
|
|
655
|
+
return this.sps;
|
|
656
|
+
}
|
|
657
|
+
push(nal) {
|
|
658
|
+
const type = nalType(nal);
|
|
659
|
+
if (type === nalTypes.sps) this.sps = nal;
|
|
660
|
+
if (type === nalTypes.pps) this.pps = nal;
|
|
661
|
+
this.pending.push(nal);
|
|
662
|
+
if (type < firstVclType || type > lastVclType) return null;
|
|
663
|
+
const parts = this.pending;
|
|
664
|
+
this.pending = [];
|
|
665
|
+
const key = type === nalTypes.idr;
|
|
666
|
+
return { packet: concatBytes(key ? this.withParameterSets(parts) : parts), key };
|
|
667
|
+
}
|
|
668
|
+
withParameterSets(parts) {
|
|
669
|
+
const has = (wanted) => parts.some((part) => nalType(part) === wanted);
|
|
670
|
+
const prefix = [!has(nalTypes.sps) ? this.sps : null, !has(nalTypes.pps) ? this.pps : null].filter(
|
|
671
|
+
(part) => part !== null
|
|
672
|
+
);
|
|
673
|
+
return [...prefix, ...parts];
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
var printableAscii = /^[\x20-\x7e]+$/;
|
|
677
|
+
function escapeInputText(text) {
|
|
678
|
+
if (!printableAscii.test(text)) throw new Error("Android \uC9C1\uC811 \uC785\uB825\uC740 \uC601\uBB38\xB7\uC22B\uC790\xB7\uAE30\uD638\uB9CC \uB429\uB2C8\uB2E4.");
|
|
679
|
+
const spaced = text.replace(/ /g, "%s");
|
|
680
|
+
return `'${spaced.replace(/'/g, "'\\''")}'`;
|
|
681
|
+
}
|
|
682
|
+
function androidInputArgs(serial, event, size) {
|
|
683
|
+
const shell = ["-s", serial, "shell", "input"];
|
|
684
|
+
if (event.type === "tap") {
|
|
685
|
+
const point = toDevicePoint(event, size);
|
|
686
|
+
return [...shell, "tap", String(point.x), String(point.y)];
|
|
687
|
+
}
|
|
688
|
+
if (event.type === "swipe") {
|
|
689
|
+
const from = toDevicePoint(event.from, size);
|
|
690
|
+
const to = toDevicePoint(event.to, size);
|
|
691
|
+
return [...shell, "swipe", ...[from.x, from.y, to.x, to.y, Math.round(event.durationMs)].map(String)];
|
|
692
|
+
}
|
|
693
|
+
if (event.type === "text") return [...shell, "text", escapeInputText(event.text)];
|
|
694
|
+
return [...shell, "keyevent", String(androidKeyCodes[event.key])];
|
|
695
|
+
}
|
|
696
|
+
function screenrecordArgs(serial) {
|
|
697
|
+
return [
|
|
698
|
+
"-s",
|
|
699
|
+
serial,
|
|
700
|
+
"exec-out",
|
|
701
|
+
"screenrecord",
|
|
702
|
+
"--output-format=h264",
|
|
703
|
+
"--bit-rate",
|
|
704
|
+
String(screenrecordBitRate),
|
|
705
|
+
"--time-limit",
|
|
706
|
+
String(screenrecordTimeLimitSec),
|
|
707
|
+
"-"
|
|
708
|
+
];
|
|
709
|
+
}
|
|
710
|
+
async function readScreenSize(serial, exec) {
|
|
711
|
+
const stdout = await exec("adb", ["-s", serial, "shell", "wm", "size"]);
|
|
712
|
+
const size = parseWmSize(stdout);
|
|
713
|
+
if (!size) throw new Error(`${serial} \uD654\uBA74 \uD06C\uAE30\uB97C \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.`);
|
|
714
|
+
return size;
|
|
715
|
+
}
|
|
716
|
+
var defaultSpawnRecorder = (args) => spawn("adb", args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
717
|
+
async function openAndroidStream(serial, deps = {}) {
|
|
718
|
+
const exec = deps.exec ?? execFileText;
|
|
719
|
+
const spawnRecorder = deps.spawnRecorder ?? defaultSpawnRecorder;
|
|
720
|
+
const size = await readScreenSize(serial, exec);
|
|
721
|
+
const listeners = [];
|
|
722
|
+
const assembler = new AccessUnitAssembler();
|
|
723
|
+
let recorder = null;
|
|
724
|
+
let closed = false;
|
|
725
|
+
const emit = (nal) => {
|
|
726
|
+
const video = assembler.push(nal);
|
|
727
|
+
if (video) for (const listener of listeners) listener(video.packet, video.key);
|
|
728
|
+
};
|
|
729
|
+
const start = () => {
|
|
730
|
+
const splitter = new AnnexBSplitter();
|
|
731
|
+
const child = spawnRecorder(screenrecordArgs(serial));
|
|
732
|
+
recorder = child;
|
|
733
|
+
child.stdout.on("data", (chunk) => splitter.push(chunk).forEach(emit));
|
|
734
|
+
let ended = false;
|
|
735
|
+
const restart = async () => {
|
|
736
|
+
if (ended || closed) return;
|
|
737
|
+
ended = true;
|
|
738
|
+
await wait2(restartDelayMs);
|
|
739
|
+
if (!closed) start();
|
|
740
|
+
};
|
|
741
|
+
child.on("error", restart);
|
|
742
|
+
child.on("close", restart);
|
|
743
|
+
};
|
|
744
|
+
start();
|
|
745
|
+
return {
|
|
746
|
+
onVideo: (cb) => {
|
|
747
|
+
listeners.push(cb);
|
|
748
|
+
},
|
|
749
|
+
send: async (event) => {
|
|
750
|
+
await exec("adb", androidInputArgs(serial, event, size));
|
|
751
|
+
},
|
|
752
|
+
size: () => size,
|
|
753
|
+
codec: () => {
|
|
754
|
+
const sps = assembler.latestSps();
|
|
755
|
+
return sps ? avcCodecString(sps) : null;
|
|
756
|
+
},
|
|
757
|
+
close: async () => {
|
|
758
|
+
closed = true;
|
|
759
|
+
recorder?.kill("SIGTERM");
|
|
760
|
+
recorder = null;
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// packages/server/src/streams.ts
|
|
766
|
+
var defaultOpenIos = (app) => async (udid) => {
|
|
767
|
+
if (!app) throw new Error("\uC571 \uC124\uC815 \uC5C6\uC774 iOS \uC2A4\uD2B8\uB9BC\uC744 \uC5F4 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
768
|
+
return openIosStream(udid, await getIosSession(udid, appIdFor(app, "dev", "ios"), app.ios));
|
|
769
|
+
};
|
|
770
|
+
function createStreamHub(deps = {}) {
|
|
771
|
+
const openAndroid = deps.openAndroid ?? ((serial) => openAndroidStream(serial));
|
|
772
|
+
const openIos = deps.openIos ?? defaultOpenIos(deps.app);
|
|
773
|
+
const android = /* @__PURE__ */ new Map();
|
|
774
|
+
const ios = /* @__PURE__ */ new Map();
|
|
775
|
+
function entryOf(serial) {
|
|
776
|
+
const existing = android.get(serial);
|
|
777
|
+
if (existing) return existing;
|
|
778
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
779
|
+
const stream = openAndroid(serial).then((opened) => {
|
|
780
|
+
opened.onVideo((packet, key) => listeners.forEach((listener) => listener(packet, key)));
|
|
781
|
+
return opened;
|
|
782
|
+
});
|
|
783
|
+
const entry = { stream, listeners };
|
|
784
|
+
android.set(serial, entry);
|
|
785
|
+
stream.catch(() => {
|
|
786
|
+
if (android.get(serial) === entry) android.delete(serial);
|
|
787
|
+
});
|
|
788
|
+
return entry;
|
|
789
|
+
}
|
|
790
|
+
async function subscribe(serial, listener) {
|
|
791
|
+
const entry = entryOf(serial);
|
|
792
|
+
entry.listeners.add(listener);
|
|
793
|
+
let stream;
|
|
794
|
+
try {
|
|
795
|
+
stream = await entry.stream;
|
|
796
|
+
} catch (error) {
|
|
797
|
+
entry.listeners.delete(listener);
|
|
798
|
+
throw error;
|
|
799
|
+
}
|
|
800
|
+
const unsubscribe = () => {
|
|
801
|
+
if (!entry.listeners.delete(listener) || entry.listeners.size > 0) return;
|
|
802
|
+
if (android.get(serial) === entry) android.delete(serial);
|
|
803
|
+
void stream.close();
|
|
804
|
+
};
|
|
805
|
+
return { stream, unsubscribe };
|
|
806
|
+
}
|
|
807
|
+
function iosStream(udid) {
|
|
808
|
+
const existing = ios.get(udid);
|
|
809
|
+
if (existing) return existing;
|
|
810
|
+
const opening = openIos(udid);
|
|
811
|
+
ios.set(udid, opening);
|
|
812
|
+
opening.catch(() => {
|
|
813
|
+
if (ios.get(udid) === opening) ios.delete(udid);
|
|
814
|
+
});
|
|
815
|
+
return opening;
|
|
816
|
+
}
|
|
817
|
+
async function sendAndroid(serial, event) {
|
|
818
|
+
const { stream, unsubscribe } = await subscribe(serial, () => void 0);
|
|
819
|
+
try {
|
|
820
|
+
await stream.send(event);
|
|
821
|
+
} finally {
|
|
822
|
+
unsubscribe();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
subscribe,
|
|
827
|
+
sendAndroid,
|
|
828
|
+
sendIos: async (udid, event) => {
|
|
829
|
+
try {
|
|
830
|
+
await (await iosStream(udid)).send(event);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
if (!isDeadSessionError(error)) throw error;
|
|
833
|
+
ios.delete(udid);
|
|
834
|
+
await resetIosSession(udid);
|
|
835
|
+
await (await iosStream(udid)).send(event);
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
/** 앱 재설치로 Appium 세션을 새로 열었을 때. 다음 입력이 새 세션으로 스트림을 연다. */
|
|
839
|
+
forgetIos: (udid) => {
|
|
840
|
+
ios.delete(udid);
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
async function watchVideo(hub, serial, sink) {
|
|
845
|
+
let stream = null;
|
|
846
|
+
let sentHeader = false;
|
|
847
|
+
const subscription = await hub.subscribe(serial, (packet, key) => {
|
|
848
|
+
if (!sentHeader) {
|
|
849
|
+
const codec = stream?.codec();
|
|
850
|
+
if (!key || !stream || !codec) return;
|
|
851
|
+
sink.header({ codec, ...stream.size() });
|
|
852
|
+
sentHeader = true;
|
|
853
|
+
}
|
|
854
|
+
sink.packet(packet);
|
|
855
|
+
});
|
|
856
|
+
stream = subscription.stream;
|
|
857
|
+
return subscription.unsubscribe;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// packages/server/src/build/testflight.ts
|
|
861
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
862
|
+
import { sign } from "crypto";
|
|
863
|
+
var ascTokenTtlSeconds = 20 * 60;
|
|
864
|
+
var ascAudience = "appstoreconnect-v1";
|
|
865
|
+
var ascBuildsUrl = "https://api.appstoreconnect.apple.com/v1/builds";
|
|
866
|
+
var ascBuildsLimit = 20;
|
|
867
|
+
function requireEnv(name) {
|
|
868
|
+
const value = process.env[name]?.trim();
|
|
869
|
+
if (!value) throw new Error(`${name}\uC774 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.`);
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
872
|
+
function ascKeyFromEnv() {
|
|
873
|
+
return {
|
|
874
|
+
keyId: requireEnv("ASC_KEY_ID"),
|
|
875
|
+
issuerId: requireEnv("ASC_ISSUER_ID"),
|
|
876
|
+
privateKey: readFileSync3(requireEnv("ASC_PRIVATE_KEY_PATH"), "utf8")
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
function ascAppId(kind = "prod") {
|
|
880
|
+
return requireEnv(kind === "dev" ? "ASC_APP_ID_DEV" : "ASC_APP_ID");
|
|
881
|
+
}
|
|
882
|
+
function base64UrlJson(value) {
|
|
883
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
884
|
+
}
|
|
885
|
+
function ascToken(now, key = ascKeyFromEnv()) {
|
|
886
|
+
const iat = Math.floor(now / 1e3);
|
|
887
|
+
const header = base64UrlJson({ alg: "ES256", kid: key.keyId, typ: "JWT" });
|
|
888
|
+
const payload = base64UrlJson({ iss: key.issuerId, iat, exp: iat + ascTokenTtlSeconds, aud: ascAudience });
|
|
889
|
+
const signingInput = `${header}.${payload}`;
|
|
890
|
+
const signature = sign("sha256", Buffer.from(signingInput), { key: key.privateKey, dsaEncoding: "ieee-p1363" });
|
|
891
|
+
return `${signingInput}.${signature.toString("base64url")}`;
|
|
892
|
+
}
|
|
893
|
+
function ascFetchJson(now = Date.now) {
|
|
894
|
+
return async (url) => {
|
|
895
|
+
const response = await fetch(url, { headers: { Authorization: `Bearer ${ascToken(now())}` } });
|
|
896
|
+
if (!response.ok) throw new Error(`App Store Connect HTTP ${response.status}`);
|
|
897
|
+
return response.json();
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
function isRecord2(value) {
|
|
901
|
+
return typeof value === "object" && value !== null;
|
|
902
|
+
}
|
|
903
|
+
function field(value, ...keys) {
|
|
904
|
+
return keys.reduce((current, key) => isRecord2(current) ? current[key] : void 0, value);
|
|
905
|
+
}
|
|
906
|
+
function preReleaseVersions(included) {
|
|
907
|
+
const versions = /* @__PURE__ */ new Map();
|
|
908
|
+
if (!Array.isArray(included)) return versions;
|
|
909
|
+
for (const item of included) {
|
|
910
|
+
const id = field(item, "id");
|
|
911
|
+
const version = field(item, "attributes", "version");
|
|
912
|
+
if (field(item, "type") === "preReleaseVersions" && typeof id === "string" && typeof version === "string") {
|
|
913
|
+
versions.set(id, version);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return versions;
|
|
917
|
+
}
|
|
918
|
+
function toBuild(item, versions) {
|
|
919
|
+
const id = field(item, "id");
|
|
920
|
+
const build = field(item, "attributes", "version");
|
|
921
|
+
const uploadedDate = field(item, "attributes", "uploadedDate");
|
|
922
|
+
const preReleaseId = field(item, "relationships", "preReleaseVersion", "data", "id");
|
|
923
|
+
const version = typeof preReleaseId === "string" ? versions.get(preReleaseId) : void 0;
|
|
924
|
+
if (typeof id !== "string" || typeof build !== "string" || typeof uploadedDate !== "string" || !version) return null;
|
|
925
|
+
return { id, version, build, uploadedDate };
|
|
926
|
+
}
|
|
927
|
+
async function listTestflightBuilds(fetchJson, appId) {
|
|
928
|
+
const query = `filter[app]=${encodeURIComponent(appId)}&sort=-uploadedDate&limit=${ascBuildsLimit}&include=preReleaseVersion`;
|
|
929
|
+
const body = await fetchJson(`${ascBuildsUrl}?${query}`);
|
|
930
|
+
const data = field(body, "data");
|
|
931
|
+
if (!Array.isArray(data)) throw new Error("App Store Connect \uBE4C\uB4DC \uBAA9\uB85D \uC751\uB2F5 \uD615\uC2DD\uC774 \uB2E4\uB985\uB2C8\uB2E4.");
|
|
932
|
+
const versions = preReleaseVersions(field(body, "included"));
|
|
933
|
+
return data.map((item) => toBuild(item, versions)).filter((build) => build !== null);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// packages/server/src/build/watch.ts
|
|
937
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
938
|
+
import path4 from "path";
|
|
939
|
+
var releasePollMs = 5 * 6e4;
|
|
940
|
+
var adhocWaitMs = 60 * 6e4;
|
|
941
|
+
var seenFile = "test-results/devices/seen-releases.json";
|
|
942
|
+
var emptySeen = "";
|
|
943
|
+
var watchTargets = [
|
|
944
|
+
{ platform: "android", kind: "dev" },
|
|
945
|
+
{ platform: "android", kind: "prod" },
|
|
946
|
+
{ platform: "ios", kind: "dev" },
|
|
947
|
+
{ platform: "ios", kind: "prod" }
|
|
948
|
+
];
|
|
949
|
+
function targetKey(target) {
|
|
950
|
+
return `${target.platform}-${target.kind}`;
|
|
951
|
+
}
|
|
952
|
+
function newestUnseen(items, seen) {
|
|
953
|
+
const newest = items[0];
|
|
954
|
+
return newest && newest.id !== seen ? newest : null;
|
|
955
|
+
}
|
|
956
|
+
function releaseSignals(target, releases) {
|
|
957
|
+
return [...releases].sort((a, b) => Date.parse(b.createTime) - Date.parse(a.createTime)).map((release) => ({ target, id: release.name, version: `${release.displayVersion}+${release.buildVersion}` }));
|
|
958
|
+
}
|
|
959
|
+
function testflightSignals(target, builds) {
|
|
960
|
+
return builds.map((build) => ({ target, id: build.id, version: `${build.version}+${build.build}` }));
|
|
961
|
+
}
|
|
962
|
+
function adhocMissingMessage(version) {
|
|
963
|
+
return `${version} TestFlight \uBE4C\uB4DC\uC640 \uAC19\uC740 Ad-hoc\uC774 Firebase\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4.`;
|
|
964
|
+
}
|
|
965
|
+
function isIosProd(target) {
|
|
966
|
+
return target.platform === "ios" && target.kind === "prod";
|
|
967
|
+
}
|
|
968
|
+
function errorText(error) {
|
|
969
|
+
return error instanceof Error ? error.message : String(error);
|
|
970
|
+
}
|
|
971
|
+
function parseSeen(text) {
|
|
972
|
+
const value = JSON.parse(text);
|
|
973
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
974
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
|
|
975
|
+
}
|
|
976
|
+
async function readSeen(filePath, log) {
|
|
977
|
+
try {
|
|
978
|
+
return parseSeen(await readFile(filePath, "utf8"));
|
|
979
|
+
} catch (error) {
|
|
980
|
+
const missing = error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
981
|
+
if (!missing) log(`\uBCF8 \uAE30\uB85D\uC744 \uC77D\uC9C0 \uBABB\uD574 \uC0C8\uB85C \uC801\uC2B5\uB2C8\uB2E4: ${errorText(error)}`);
|
|
982
|
+
return {};
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
async function writeSeen(filePath, seen) {
|
|
986
|
+
await mkdir(path4.dirname(filePath), { recursive: true });
|
|
987
|
+
await writeFile(filePath, `${JSON.stringify(seen, null, 2)}
|
|
988
|
+
`);
|
|
989
|
+
}
|
|
990
|
+
function createReleaseWatcher(deps) {
|
|
991
|
+
const now = deps.now ?? Date.now;
|
|
992
|
+
const log = deps.log ?? ((message) => console.log(`[watch] ${message}`));
|
|
993
|
+
const seenPath = path4.join(deps.root ?? process.cwd(), seenFile);
|
|
994
|
+
const pending = /* @__PURE__ */ new Map();
|
|
995
|
+
let polling = false;
|
|
996
|
+
let draining = false;
|
|
997
|
+
async function listAll() {
|
|
998
|
+
return Promise.allSettled(watchTargets.map((target) => deps.listSignals(target)));
|
|
999
|
+
}
|
|
1000
|
+
function observe(record, target, signals) {
|
|
1001
|
+
const key = targetKey(target);
|
|
1002
|
+
const known = record[key];
|
|
1003
|
+
const newest = newestUnseen(signals, known ?? null);
|
|
1004
|
+
if (!newest) {
|
|
1005
|
+
if (known !== void 0) return false;
|
|
1006
|
+
record[key] = emptySeen;
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
record[key] = newest.id;
|
|
1010
|
+
if (known !== void 0) pending.set(key, { signal: newest, since: now() });
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
async function poll() {
|
|
1014
|
+
const current = await readSeen(seenPath, log);
|
|
1015
|
+
const results = await listAll();
|
|
1016
|
+
let changed = false;
|
|
1017
|
+
results.forEach((result, index) => {
|
|
1018
|
+
const target = watchTargets[index];
|
|
1019
|
+
if (result.status === "rejected") {
|
|
1020
|
+
log(`${targetKey(target)} \uBAA9\uB85D\uC744 \uC77D\uC9C0 \uBABB\uD574 \uC774\uBC88 \uD68C\uCC28\uB97C \uAC74\uB108\uB701\uB2C8\uB2E4: ${errorText(result.reason)}`);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
changed = observe(current, target, result.value) || changed;
|
|
1024
|
+
});
|
|
1025
|
+
if (changed) await writeSeen(seenPath, current);
|
|
1026
|
+
}
|
|
1027
|
+
async function adhocReady(key, entry) {
|
|
1028
|
+
if (!isIosProd(entry.signal.target)) return true;
|
|
1029
|
+
try {
|
|
1030
|
+
if (await deps.findAdhoc(entry.signal)) return true;
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
log(`${entry.signal.version} Ad-hoc \uD655\uC778 \uC2E4\uD328: ${errorText(error)}`);
|
|
1033
|
+
}
|
|
1034
|
+
if (now() - entry.since <= adhocWaitMs) return false;
|
|
1035
|
+
pending.delete(key);
|
|
1036
|
+
await deps.failIos(entry.signal, adhocMissingMessage(entry.signal.version)).catch((error) => {
|
|
1037
|
+
log(`${entry.signal.version} install_failed \uAE30\uB85D \uC2E4\uD328: ${errorText(error)}`);
|
|
1038
|
+
});
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
async function runNext() {
|
|
1042
|
+
for (const [key, entry] of pending) {
|
|
1043
|
+
if (deps.isBusy()) return false;
|
|
1044
|
+
if (!await adhocReady(key, entry)) continue;
|
|
1045
|
+
if (pending.get(key) !== entry) return true;
|
|
1046
|
+
pending.delete(key);
|
|
1047
|
+
await deps.startRun(entry.signal).catch((error) => {
|
|
1048
|
+
log(`${key} ${entry.signal.version} \uC790\uB3D9 \uC810\uAC80 \uC2E4\uD328: ${errorText(error)}`);
|
|
1049
|
+
});
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
async function drain() {
|
|
1055
|
+
if (draining) return;
|
|
1056
|
+
draining = true;
|
|
1057
|
+
try {
|
|
1058
|
+
while (await runNext()) ;
|
|
1059
|
+
} finally {
|
|
1060
|
+
draining = false;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
async function tick() {
|
|
1064
|
+
if (!polling) {
|
|
1065
|
+
polling = true;
|
|
1066
|
+
try {
|
|
1067
|
+
await poll();
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
log(`\uAC10\uC2DC \uD68C\uCC28 \uC2E4\uD328: ${errorText(error)}`);
|
|
1070
|
+
} finally {
|
|
1071
|
+
polling = false;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
await drain();
|
|
1075
|
+
}
|
|
1076
|
+
return { tick };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// packages/server/src/watch.ts
|
|
1080
|
+
function signalLister(fetchJson, install) {
|
|
1081
|
+
const ascFetch = ascFetchJson();
|
|
1082
|
+
return async (target) => {
|
|
1083
|
+
if (target.platform === "ios") return testflightSignals(target, await listTestflightBuilds(ascFetch, ascAppId(target.kind)));
|
|
1084
|
+
return releaseSignals(target, await listReleases(firebaseAppId(target.platform, target.kind, install), fetchJson));
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
function adhocFinder(fetchJson, install) {
|
|
1088
|
+
return async (signal) => {
|
|
1089
|
+
const releases = await listReleases(firebaseAppId("ios", "prod", install), fetchJson);
|
|
1090
|
+
return pickRelease(releases, signal.version) !== null;
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
function startAutoWatch(deps) {
|
|
1094
|
+
const fetchJson = deps.fetchJson ?? defaultBuildDeps(deps.install).fetchJson;
|
|
1095
|
+
const watcher = createReleaseWatcher({
|
|
1096
|
+
listSignals: signalLister(fetchJson, deps.install),
|
|
1097
|
+
isBusy: deps.isBusy,
|
|
1098
|
+
startRun: deps.runAuto,
|
|
1099
|
+
findAdhoc: adhocFinder(fetchJson, deps.install),
|
|
1100
|
+
failIos: deps.failIos,
|
|
1101
|
+
log: deps.log
|
|
1102
|
+
});
|
|
1103
|
+
void watcher.tick();
|
|
1104
|
+
const timer = setInterval(() => void watcher.tick(), releasePollMs);
|
|
1105
|
+
timer.unref();
|
|
1106
|
+
return { tick: () => watcher.tick(), stop: () => clearInterval(timer) };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// packages/server/src/farm.ts
|
|
1110
|
+
function createFarm(options) {
|
|
1111
|
+
loadDotEnv();
|
|
1112
|
+
const { app } = options;
|
|
1113
|
+
const scenarios = farmScenarios(app);
|
|
1114
|
+
const exec = options.exec ?? execFileText;
|
|
1115
|
+
const log = options.log ?? ((message) => console.log(`[farm] ${message}`));
|
|
1116
|
+
const locks = new DeviceLocks();
|
|
1117
|
+
const hub = createStreamHub({ app });
|
|
1118
|
+
const deviceDeps = createDeviceDeps(app, exec, hub);
|
|
1119
|
+
const devices = createDeviceList(deviceDeps.listDevices, locks);
|
|
1120
|
+
const control = createControlLog(locks);
|
|
1121
|
+
const capture = createCapture(hub);
|
|
1122
|
+
let latest = null;
|
|
1123
|
+
let runAbort = null;
|
|
1124
|
+
let exploring = null;
|
|
1125
|
+
let watch = null;
|
|
1126
|
+
let policyWatch = null;
|
|
1127
|
+
const gate = createJobGate(() => {
|
|
1128
|
+
try {
|
|
1129
|
+
pruneRuns(devicesRoot, Date.now());
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
log(`\uBCF4\uAD00 \uC815\uB9AC \uC2E4\uD328: ${errorMessage(error)}`);
|
|
1132
|
+
}
|
|
1133
|
+
if (watch) void watch.tick();
|
|
1134
|
+
});
|
|
1135
|
+
const buttonCheck = () => {
|
|
1136
|
+
let allowlist = null;
|
|
1137
|
+
const loadAllowlist = () => allowlist ??= loadExploreInputs().allowlist;
|
|
1138
|
+
return {
|
|
1139
|
+
guard: (driver) => defaultGuard(driver, webOrigin(app, "dev"), loadAllowlist(), (request) => log(`\uBC84\uD2BC \uB20C\uB7EC\uBCF4\uAE30 \uC911 \uB9C9\uC740 \uC694\uCCAD: ${request.method} ${request.path}`)),
|
|
1140
|
+
probe: (driver, ring, since) => probeScreen(driver, ring, since, loadAllowlist()),
|
|
1141
|
+
recordMap: (from, buttons) => recordScreenMap(from, buttons)
|
|
1142
|
+
};
|
|
1143
|
+
};
|
|
1144
|
+
const dataQa = () => {
|
|
1145
|
+
const origin = apiOrigin(app);
|
|
1146
|
+
if (!origin || app.qa.screens.length === 0) return {};
|
|
1147
|
+
return { qaScreens: app.qa.screens, runQa: (driver, screen) => runScreenQa(driver, screen, { apiOrigin: origin }) };
|
|
1148
|
+
};
|
|
1149
|
+
const knownScreens = () => {
|
|
1150
|
+
const edges = readScreenMap().edges;
|
|
1151
|
+
return [.../* @__PURE__ */ new Set([...scenarios.map((scenario) => scenario.path), ...edges.map((edge) => edge.from), ...edges.flatMap((edge) => edge.to ? [edge.to] : [])])].sort();
|
|
1152
|
+
};
|
|
1153
|
+
const policyCheck = () => {
|
|
1154
|
+
const screens = policyScreens(readAllPageRules(), knownScreens(), readFigmaCache(), readFrontFigmaMap()?.routes);
|
|
1155
|
+
if (screens.length === 0) return {};
|
|
1156
|
+
const ignores = readIgnores();
|
|
1157
|
+
return { policyScreens: screens, runPolicy: (driver, screen) => checkPolicyScreen(driver, screen, { ask: askClaude, ignores }) };
|
|
1158
|
+
};
|
|
1159
|
+
const runDeps = () => ({
|
|
1160
|
+
...deviceDeps,
|
|
1161
|
+
exec,
|
|
1162
|
+
locks,
|
|
1163
|
+
listDevices: devices.connected,
|
|
1164
|
+
scenarios,
|
|
1165
|
+
flows: readReady(),
|
|
1166
|
+
buttonCheck: buttonCheck(),
|
|
1167
|
+
...dataQa(),
|
|
1168
|
+
...policyCheck(),
|
|
1169
|
+
onUpdate: (run) => {
|
|
1170
|
+
latest = run;
|
|
1171
|
+
writeRun(run);
|
|
1172
|
+
},
|
|
1173
|
+
capture,
|
|
1174
|
+
baselineFor: (device, scenario, screenshot) => baselineFor(device, scenario, screenshot),
|
|
1175
|
+
log
|
|
1176
|
+
});
|
|
1177
|
+
const exploreDeps = () => ({
|
|
1178
|
+
...loadExploreInputs(),
|
|
1179
|
+
app,
|
|
1180
|
+
webOrigin: webOrigin(app, "dev"),
|
|
1181
|
+
locks,
|
|
1182
|
+
listDevices: devices.connected,
|
|
1183
|
+
openDriver: deviceDeps.openDriver,
|
|
1184
|
+
capture,
|
|
1185
|
+
onUpdate: (results) => {
|
|
1186
|
+
exploring = results;
|
|
1187
|
+
},
|
|
1188
|
+
log
|
|
1189
|
+
});
|
|
1190
|
+
function background(kind, started) {
|
|
1191
|
+
started.catch((error) => log(`${kind} \uC2E4\uD328: ${errorMessage(error)}`));
|
|
1192
|
+
}
|
|
1193
|
+
const auto = createAutoRuns({
|
|
1194
|
+
connected: devices.connected,
|
|
1195
|
+
gate,
|
|
1196
|
+
runDeps,
|
|
1197
|
+
record: (run) => {
|
|
1198
|
+
writeRun(run);
|
|
1199
|
+
latest = run;
|
|
1200
|
+
},
|
|
1201
|
+
log
|
|
1202
|
+
});
|
|
1203
|
+
async function openIosSessions() {
|
|
1204
|
+
const ios = (await devices.connected()).filter((device) => device.platform === "ios");
|
|
1205
|
+
await Promise.all(ios.map((device) => getIosSession(device.id, appIdFor(app, "dev", "ios"), app.ios).catch((error) => log(`${device.id} Appium \uC138\uC158\uC744 \uBABB \uC5F4\uC5C8\uC2B5\uB2C8\uB2E4: ${errorMessage(error)}`))));
|
|
1206
|
+
}
|
|
1207
|
+
return {
|
|
1208
|
+
app,
|
|
1209
|
+
resultsRoot: testResultsRoot,
|
|
1210
|
+
locks,
|
|
1211
|
+
mjpegUrl: iosMjpegUrl,
|
|
1212
|
+
ensureIosSession: async (id) => {
|
|
1213
|
+
await getIosSession(id, appIdFor(app, "dev", "ios"), app.ios);
|
|
1214
|
+
},
|
|
1215
|
+
isBusy: gate.isBusy,
|
|
1216
|
+
listDevices: devices.withState,
|
|
1217
|
+
platformOf: async (id) => (await devices.withState()).find((device) => device.id === id)?.platform ?? null,
|
|
1218
|
+
startRun: (request) => {
|
|
1219
|
+
const runId = createRunId(Date.now());
|
|
1220
|
+
const abort = new AbortController();
|
|
1221
|
+
const started = gate.start("run", () => runDevices({ ...request, runId }, { ...runDeps(), signal: abort.signal }));
|
|
1222
|
+
if (!started) return { ok: false, error: gate.busyMessage() };
|
|
1223
|
+
runAbort = abort;
|
|
1224
|
+
background(
|
|
1225
|
+
"\uAE30\uAE30 \uC810\uAC80",
|
|
1226
|
+
started.finally(() => {
|
|
1227
|
+
if (runAbort === abort) runAbort = null;
|
|
1228
|
+
})
|
|
1229
|
+
);
|
|
1230
|
+
return { ok: true, runId };
|
|
1231
|
+
},
|
|
1232
|
+
stopRun: () => {
|
|
1233
|
+
if (!runAbort || runAbort.signal.aborted) return false;
|
|
1234
|
+
runAbort.abort();
|
|
1235
|
+
log("\uC810\uAC80 \uBA48\uCD94\uAE30 \uC694\uCCAD");
|
|
1236
|
+
return true;
|
|
1237
|
+
},
|
|
1238
|
+
latestRun: () => latest,
|
|
1239
|
+
pendingBaselines: () => pendingDeviceBaselines(),
|
|
1240
|
+
candidatePng: (name) => candidatePng(name),
|
|
1241
|
+
confirmBaselines: (names) => confirmDeviceBaselines(names),
|
|
1242
|
+
startExplore: (request) => {
|
|
1243
|
+
if (gate.isBusy()) return { ok: false, error: gate.busyMessage() };
|
|
1244
|
+
const deps = exploreDeps();
|
|
1245
|
+
const runId = createRunId(Date.now());
|
|
1246
|
+
const started = gate.start("explore", () => runExplore({ deviceIds: request.deviceIds, runId }, deps).finally(() => {
|
|
1247
|
+
exploring = null;
|
|
1248
|
+
}));
|
|
1249
|
+
if (!started) return { ok: false, error: gate.busyMessage() };
|
|
1250
|
+
background("\uD0D0\uC0C9", started);
|
|
1251
|
+
return { ok: true, runId };
|
|
1252
|
+
},
|
|
1253
|
+
latestExplore: () => exploring ?? readLatestExplore(),
|
|
1254
|
+
replay: async ({ runId, deviceId, pathId }) => {
|
|
1255
|
+
let issues = [];
|
|
1256
|
+
const deps = exploreDeps();
|
|
1257
|
+
const started = gate.start("replay", async () => {
|
|
1258
|
+
issues = await replayOne(deviceId, runId, pathId, deps);
|
|
1259
|
+
});
|
|
1260
|
+
if (!started) throw new ExploreRequestError(409, gate.busyMessage());
|
|
1261
|
+
await started;
|
|
1262
|
+
return issues;
|
|
1263
|
+
},
|
|
1264
|
+
sendInput: (id, platform, event) => platform === "android" ? hub.sendAndroid(id, event) : hub.sendIos(id, event),
|
|
1265
|
+
watchVideo: (id, sink) => watchVideo(hub, id, sink),
|
|
1266
|
+
recordControl: control.record,
|
|
1267
|
+
policyState: () => policyWatch?.state() ?? null,
|
|
1268
|
+
checkPolicy: () => policyWatch?.checkNow() ?? false,
|
|
1269
|
+
start: async () => {
|
|
1270
|
+
if (closeUnfinishedRun(devicesRoot)) log("\uB05D\uB098\uC9C0 \uC54A\uC740 \uC2E4\uD589\uC744 \uC11C\uBC84 \uC7AC\uC2DC\uC791\uC73C\uB85C \uB2EB\uC558\uC2B5\uB2C8\uB2E4.");
|
|
1271
|
+
log(`\uBCF4\uAD00 \uAE30\uAC04\uC774 \uC9C0\uB09C \uC2E4\uD589 ${pruneRuns(devicesRoot, Date.now())}\uAC1C\uB97C \uC9C0\uC6E0\uC2B5\uB2C8\uB2E4.`);
|
|
1272
|
+
latest = readLatestRun(devicesRoot);
|
|
1273
|
+
void openIosSessions().catch((error) => log(`iOS \uC138\uC158 \uC900\uBE44 \uC2E4\uD328: ${errorMessage(error)}`));
|
|
1274
|
+
watch = startAutoWatch({ isBusy: gate.isBusy, runAuto: auto.runAuto, failIos: auto.failIos, log, install: app.install });
|
|
1275
|
+
policyWatch = startPolicyWatch({ app, log, knownScreens });
|
|
1276
|
+
},
|
|
1277
|
+
stop: async () => {
|
|
1278
|
+
watch?.stop();
|
|
1279
|
+
policyWatch?.stop();
|
|
1280
|
+
control.stop();
|
|
1281
|
+
await closeAllIosSessions();
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// packages/server/src/http/device-api.ts
|
|
1287
|
+
var prodExploreMessage = "\uC6B4\uC601 \uBE4C\uB4DC\uB294 \uD0D0\uC0C9\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.";
|
|
1288
|
+
var noLockMessage = "\uC870\uC791 \uC7A0\uAE08\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 \uC870\uC791\uC744 \uC2DC\uC791\uD574 \uC8FC\uC138\uC694.";
|
|
1289
|
+
var botLockMessage = "\uBD07\uC774 \uC810\uAC80 \uC911\uC778 \uAE30\uAE30\uC785\uB2C8\uB2E4.";
|
|
1290
|
+
var baselineBusyMessage = "\uAE30\uAE30 \uC810\uAC80 \uC911\uC5D0\uB294 \uAE30\uC900\uC744 \uD655\uC815\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
1291
|
+
var flowBusyMessage = "\uAE30\uAE30 \uC810\uAC80 \uC911\uC5D0\uB294 \uD50C\uB85C\uC6B0\uB97C \uD655\uC815\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
1292
|
+
var apiPrefix = "/api/devices";
|
|
1293
|
+
var userHeader2 = "tailscale-user-login";
|
|
1294
|
+
var maxBodyBytes = 1024 * 1024;
|
|
1295
|
+
var mapFlowIdHashLength = 8;
|
|
1296
|
+
var buildKinds = ["dev", "prod"];
|
|
1297
|
+
var resultFileTypes = {
|
|
1298
|
+
".png": "image/png",
|
|
1299
|
+
".mp4": "video/mp4",
|
|
1300
|
+
".jsonl": "application/x-ndjson; charset=utf-8",
|
|
1301
|
+
".json": "application/json; charset=utf-8"
|
|
1302
|
+
};
|
|
1303
|
+
var closeReasonLength = 60;
|
|
1304
|
+
var HttpError = class extends Error {
|
|
1305
|
+
constructor(status, message) {
|
|
1306
|
+
super(message);
|
|
1307
|
+
this.status = status;
|
|
1308
|
+
}
|
|
1309
|
+
status;
|
|
1310
|
+
};
|
|
1311
|
+
function sendJson(res, status, body) {
|
|
1312
|
+
res.statusCode = status;
|
|
1313
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
1314
|
+
res.setHeader("cache-control", "no-store");
|
|
1315
|
+
res.end(JSON.stringify(body));
|
|
1316
|
+
}
|
|
1317
|
+
function sendFile(res, type, data) {
|
|
1318
|
+
res.statusCode = 200;
|
|
1319
|
+
res.setHeader("content-type", type);
|
|
1320
|
+
res.setHeader("cache-control", "no-store");
|
|
1321
|
+
res.end(data);
|
|
1322
|
+
}
|
|
1323
|
+
async function readBody(req) {
|
|
1324
|
+
const chunks = [];
|
|
1325
|
+
let size = 0;
|
|
1326
|
+
for await (const chunk of req) {
|
|
1327
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
1328
|
+
size += buffer.length;
|
|
1329
|
+
if (size > maxBodyBytes) throw new HttpError(413, "\uC694\uCCAD \uBCF8\uBB38\uC774 \uB108\uBB34 \uD07D\uB2C8\uB2E4.");
|
|
1330
|
+
chunks.push(buffer);
|
|
1331
|
+
}
|
|
1332
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
1333
|
+
if (!text) return {};
|
|
1334
|
+
let parsed;
|
|
1335
|
+
try {
|
|
1336
|
+
parsed = JSON.parse(text);
|
|
1337
|
+
} catch {
|
|
1338
|
+
throw new HttpError(400, "\uC694\uCCAD \uBCF8\uBB38\uC774 JSON\uC774 \uC544\uB2D9\uB2C8\uB2E4.");
|
|
1339
|
+
}
|
|
1340
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new HttpError(400, "\uC694\uCCAD \uBCF8\uBB38\uC740 JSON \uAC1D\uCCB4\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
|
|
1341
|
+
return parsed;
|
|
1342
|
+
}
|
|
1343
|
+
function nonEmptyString(value, field2) {
|
|
1344
|
+
if (typeof value !== "string" || !value.trim()) throw new HttpError(400, `${field2}\uB294 \uBE48 \uBB38\uC790\uC5F4\uC774 \uC544\uB2C8\uC5B4\uC57C \uD569\uB2C8\uB2E4.`);
|
|
1345
|
+
return value.trim();
|
|
1346
|
+
}
|
|
1347
|
+
function parseDevices(value) {
|
|
1348
|
+
if (value === "all") return "all";
|
|
1349
|
+
if (Array.isArray(value) && value.length > 0 && value.every((id) => typeof id === "string" && id.trim())) return value.map((id) => id.trim());
|
|
1350
|
+
throw new HttpError(400, 'devices\uB294 "all" \uB610\uB294 \uAE30\uAE30 id \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.');
|
|
1351
|
+
}
|
|
1352
|
+
function parseKind(value) {
|
|
1353
|
+
const kind = buildKinds.find((item) => item === value);
|
|
1354
|
+
if (!kind) throw new HttpError(400, "kind\uB294 dev \uB610\uB294 prod\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
|
|
1355
|
+
return kind;
|
|
1356
|
+
}
|
|
1357
|
+
function parseNames(value) {
|
|
1358
|
+
if (value === void 0) return void 0;
|
|
1359
|
+
if (Array.isArray(value) && value.every((name) => typeof name === "string")) return value;
|
|
1360
|
+
throw new HttpError(400, "names\uB294 \uBB38\uC790\uC5F4 \uBC30\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4.");
|
|
1361
|
+
}
|
|
1362
|
+
function createAccessMiddleware(domains) {
|
|
1363
|
+
return (req, res, next) => accessGate(req, res, next, domains);
|
|
1364
|
+
}
|
|
1365
|
+
function accessGate(req, res, next, domains) {
|
|
1366
|
+
const access = decideAccess({ remoteAddress: req.socket.remoteAddress, headers: req.headers }, domains);
|
|
1367
|
+
if (access.ok) return next();
|
|
1368
|
+
res.statusCode = access.status;
|
|
1369
|
+
res.setHeader("cache-control", "no-store");
|
|
1370
|
+
if ((req.url ?? "").startsWith("/api/")) {
|
|
1371
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
1372
|
+
res.end(JSON.stringify({ error: access.message }));
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
1376
|
+
res.end(`\uB514\uBC14\uC774\uC2A4\uD31C: ${access.message}`);
|
|
1377
|
+
}
|
|
1378
|
+
function userOf(req) {
|
|
1379
|
+
const header = req.headers[userHeader2];
|
|
1380
|
+
const value = (Array.isArray(header) ? header[0] : header)?.trim();
|
|
1381
|
+
return value || localUser;
|
|
1382
|
+
}
|
|
1383
|
+
function resolveResultFile(root, relative) {
|
|
1384
|
+
if (!relative || path5.isAbsolute(relative) || relative.includes("\0")) return null;
|
|
1385
|
+
const type = resultFileTypes[path5.extname(relative).toLowerCase()];
|
|
1386
|
+
if (!type) return null;
|
|
1387
|
+
try {
|
|
1388
|
+
const base = realpathSync(root);
|
|
1389
|
+
const inside = (target2) => target2.startsWith(base + path5.sep);
|
|
1390
|
+
const target = path5.resolve(base, relative);
|
|
1391
|
+
if (!inside(target)) return null;
|
|
1392
|
+
const real = realpathSync(target);
|
|
1393
|
+
return inside(real) && statSync(real).isFile() ? { file: real, type } : null;
|
|
1394
|
+
} catch {
|
|
1395
|
+
return null;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
var noRunToStopMessage = "\uBA48\uCD9C \uC810\uAC80\uC774 \uC5C6\uC5B4\uC694.";
|
|
1399
|
+
async function stopRun({ farm, res }) {
|
|
1400
|
+
if (!farm.stopRun()) return sendJson(res, 409, { error: noRunToStopMessage });
|
|
1401
|
+
sendJson(res, 200, { stopping: true });
|
|
1402
|
+
}
|
|
1403
|
+
async function startRun({ farm, req, res }) {
|
|
1404
|
+
const body = await readBody(req);
|
|
1405
|
+
if (body.flowsOnly !== void 0 && typeof body.flowsOnly !== "boolean") throw new HttpError(400, "flowsOnly\uB294 true \uB610\uB294 false\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
|
|
1406
|
+
if (body.buttonCheck !== void 0 && typeof body.buttonCheck !== "boolean") throw new HttpError(400, "buttonCheck\uB294 true \uB610\uB294 false\uC5EC\uC57C \uD569\uB2C8\uB2E4.");
|
|
1407
|
+
const flowsOnly = body.flowsOnly === true;
|
|
1408
|
+
const version = flowsOnly ? installedVersionLabel : nonEmptyString(body.version, "version");
|
|
1409
|
+
const request = { kind: parseKind(body.kind), version, deviceIds: parseDevices(body.devices), flowsOnly, buttonCheck: body.buttonCheck === true };
|
|
1410
|
+
const started = farm.startRun(request);
|
|
1411
|
+
if (!started.ok) return sendJson(res, 409, { error: started.error });
|
|
1412
|
+
sendJson(res, 202, { runId: started.runId });
|
|
1413
|
+
}
|
|
1414
|
+
async function startExplore({ farm, req, res }) {
|
|
1415
|
+
const body = await readBody(req);
|
|
1416
|
+
if (body.build === "prod") return sendJson(res, 400, { error: prodExploreMessage });
|
|
1417
|
+
if (body.build !== void 0 && body.build !== "dev") throw new HttpError(400, "build\uB294 dev\uB9CC \uBC1B\uC2B5\uB2C8\uB2E4.");
|
|
1418
|
+
const started = farm.startExplore({ deviceIds: parseDevices(body.devices) });
|
|
1419
|
+
if (!started.ok) return sendJson(res, 409, { error: started.error });
|
|
1420
|
+
sendJson(res, 202, { runId: started.runId });
|
|
1421
|
+
}
|
|
1422
|
+
async function replay({ farm, req, res }) {
|
|
1423
|
+
const body = await readBody(req);
|
|
1424
|
+
const input2 = { runId: nonEmptyString(body.runId, "runId"), deviceId: nonEmptyString(body.deviceId, "deviceId"), pathId: nonEmptyString(body.pathId, "pathId") };
|
|
1425
|
+
sendJson(res, 200, await farm.replay(input2));
|
|
1426
|
+
}
|
|
1427
|
+
async function confirmBaselines({ farm, req, res }) {
|
|
1428
|
+
const names = parseNames((await readBody(req)).names);
|
|
1429
|
+
if (farm.isBusy()) return sendJson(res, 409, { error: baselineBusyMessage });
|
|
1430
|
+
sendJson(res, 200, { confirmed: farm.confirmBaselines(names) });
|
|
1431
|
+
}
|
|
1432
|
+
function flowsState() {
|
|
1433
|
+
return { candidates: readCandidates(), ready: readReady(), prds: listPrds(), prdRootSet: prdRoot() !== null, llmConfigured: llmConfigured() };
|
|
1434
|
+
}
|
|
1435
|
+
async function generateFlows({ farm, req, res }) {
|
|
1436
|
+
const result = await generateFromPrd(farm.app, nonEmptyString((await readBody(req)).prd, "prd"));
|
|
1437
|
+
if (!result.ok) return sendJson(res, result.status, { error: result.error });
|
|
1438
|
+
sendJson(res, 200, result);
|
|
1439
|
+
}
|
|
1440
|
+
async function confirmFlowCandidates({ farm, req, res }) {
|
|
1441
|
+
const ids = parseNames((await readBody(req)).ids);
|
|
1442
|
+
if (farm.isBusy()) return sendJson(res, 409, { error: flowBusyMessage });
|
|
1443
|
+
sendJson(res, 200, { confirmed: confirmFlows(ids) });
|
|
1444
|
+
}
|
|
1445
|
+
async function mapFlowCandidate({ farm, res, req }) {
|
|
1446
|
+
const body = await readBody(req);
|
|
1447
|
+
const name = nonEmptyString(body.name, "name");
|
|
1448
|
+
const steps = stepsOf(body.steps, farm.app.guard.forbiddenClickNames);
|
|
1449
|
+
if (typeof steps === "string") throw new HttpError(400, steps);
|
|
1450
|
+
const error = mapFlowError(readScreenMap().edges, steps, mapRulesOf(farm.app));
|
|
1451
|
+
if (error) throw new HttpError(400, error);
|
|
1452
|
+
const id = `map-${prdHash(JSON.stringify(steps)).slice(0, mapFlowIdHashLength)}`;
|
|
1453
|
+
const proposal = readReady().some((flow2) => flow2.id === id) ? "update" : "create";
|
|
1454
|
+
const requirement = `\uD654\uBA74 \uC9C0\uB3C4\uC5D0\uC11C \uBE14\uB85D ${steps.length}\uAC1C\uB97C \uC774\uC5B4 \uBD99\uC784`;
|
|
1455
|
+
const flow = { id, name, source: "map_built", status: "generated", prd: mapFlowPrd, requirement, proposal, steps };
|
|
1456
|
+
addCandidate(flow);
|
|
1457
|
+
sendJson(res, 200, { id });
|
|
1458
|
+
}
|
|
1459
|
+
async function candidate({ farm, res, url }) {
|
|
1460
|
+
const png = farm.candidatePng(url.searchParams.get("name") ?? "");
|
|
1461
|
+
if (!png) return sendJson(res, 404, { error: "\uD6C4\uBCF4 \uD654\uBA74\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." });
|
|
1462
|
+
sendFile(res, "image/png", png);
|
|
1463
|
+
}
|
|
1464
|
+
async function checkPolicy({ farm, res }) {
|
|
1465
|
+
const state = farm.policyState();
|
|
1466
|
+
if (!state?.configured) return sendJson(res, 409, { error: "CONFLUENCE_EMAIL\xB7CONFLUENCE_API_TOKEN\uC774 \uC5C6\uC5B4 \uC815\uCC45\uC744 \uD655\uC778\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4." });
|
|
1467
|
+
if (!farm.checkPolicy()) return sendJson(res, 409, { error: "\uC774\uBBF8 \uC815\uCC45\uC744 \uD655\uC778\uD558\uB294 \uC911\uC785\uB2C8\uB2E4." });
|
|
1468
|
+
sendJson(res, 202, { started: true });
|
|
1469
|
+
}
|
|
1470
|
+
async function ignorePolicy({ req, res }) {
|
|
1471
|
+
const body = await readBody(req);
|
|
1472
|
+
const key = nonEmptyString(body.key, "key");
|
|
1473
|
+
const hash = typeof body.hash === "string" ? body.hash : "";
|
|
1474
|
+
addIgnore({ key, hash, reason: nonEmptyString(body.reason, "reason"), at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1475
|
+
sendJson(res, 200, { items: readIgnores() });
|
|
1476
|
+
}
|
|
1477
|
+
async function unignorePolicy({ req, res }) {
|
|
1478
|
+
const body = await readBody(req);
|
|
1479
|
+
removeIgnore(nonEmptyString(body.key, "key"), typeof body.hash === "string" ? body.hash : "");
|
|
1480
|
+
sendJson(res, 200, { items: readIgnores() });
|
|
1481
|
+
}
|
|
1482
|
+
async function chaosRun({ res, url }) {
|
|
1483
|
+
const run = readChaosRun(url.searchParams.get("id") ?? "");
|
|
1484
|
+
if (!run) return sendJson(res, 404, { error: "\uCE74\uC624\uC2A4 \uC2E4\uD589\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." });
|
|
1485
|
+
sendJson(res, 200, run);
|
|
1486
|
+
}
|
|
1487
|
+
async function resultFile({ farm, res, url }) {
|
|
1488
|
+
const found = resolveResultFile(farm.resultsRoot, url.searchParams.get("path") ?? "");
|
|
1489
|
+
if (!found) return sendJson(res, 404, { error: "\uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." });
|
|
1490
|
+
sendFile(res, found.type, readFileSync4(found.file));
|
|
1491
|
+
}
|
|
1492
|
+
function ownsLock(farm, id, user) {
|
|
1493
|
+
const owner = farm.locks.ownerOf(id);
|
|
1494
|
+
return owner?.kind === "user" && owner.user === user;
|
|
1495
|
+
}
|
|
1496
|
+
async function input({ farm, req, res, id }) {
|
|
1497
|
+
if (!ownsLock(farm, id, userOf(req))) return sendJson(res, 423, { error: noLockMessage });
|
|
1498
|
+
const event = parseInputEvent(await readBody(req));
|
|
1499
|
+
if (!event) throw new HttpError(400, "\uC785\uB825 \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
|
|
1500
|
+
const platform = await farm.platformOf(id);
|
|
1501
|
+
if (!platform) throw new HttpError(404, "\uC5F0\uACB0\uB41C \uAE30\uAE30\uAC00 \uC544\uB2D9\uB2C8\uB2E4.");
|
|
1502
|
+
if (platform === "ios" && event.type === "key" && event.key === "back") throw new HttpError(400, iosBackUnsupported);
|
|
1503
|
+
farm.locks.touch(id);
|
|
1504
|
+
await farm.sendInput(id, platform, event);
|
|
1505
|
+
sendJson(res, 200, { ok: true });
|
|
1506
|
+
}
|
|
1507
|
+
async function lock({ farm, req, res, id }) {
|
|
1508
|
+
const user = userOf(req);
|
|
1509
|
+
const already = ownsLock(farm, id, user);
|
|
1510
|
+
if (!farm.locks.acquire(id, { kind: "user", user })) {
|
|
1511
|
+
const owner = farm.locks.ownerOf(id);
|
|
1512
|
+
return sendJson(res, 409, { error: owner?.kind === "user" ? `${owner.user}\uB2D8\uC774 \uC870\uC791 \uC911\uC785\uB2C8\uB2E4.` : botLockMessage });
|
|
1513
|
+
}
|
|
1514
|
+
if (!already) farm.recordControl({ user, deviceId: id, action: "lock" });
|
|
1515
|
+
sendJson(res, 200, { ok: true });
|
|
1516
|
+
}
|
|
1517
|
+
async function unlock({ farm, req, res, id }) {
|
|
1518
|
+
const user = userOf(req);
|
|
1519
|
+
if (ownsLock(farm, id, user)) {
|
|
1520
|
+
farm.locks.release(id, { kind: "user", user });
|
|
1521
|
+
farm.recordControl({ user, deviceId: id, action: "release" });
|
|
1522
|
+
}
|
|
1523
|
+
sendJson(res, 200, { ok: true });
|
|
1524
|
+
}
|
|
1525
|
+
async function touch({ farm, req, res, id }) {
|
|
1526
|
+
const locked = ownsLock(farm, id, userOf(req));
|
|
1527
|
+
if (locked) farm.locks.touch(id);
|
|
1528
|
+
sendJson(res, 200, { ok: true, locked });
|
|
1529
|
+
}
|
|
1530
|
+
async function mjpeg({ farm, req, res, id }) {
|
|
1531
|
+
if (await farm.platformOf(id) !== "ios") throw new HttpError(404, "\uC5F0\uACB0\uB41C iOS \uAE30\uAE30\uAC00 \uC544\uB2D9\uB2C8\uB2E4.");
|
|
1532
|
+
await farm.ensureIosSession?.(id);
|
|
1533
|
+
const upstream = httpGet(farm.mjpegUrl, (source) => {
|
|
1534
|
+
res.statusCode = source.statusCode ?? 502;
|
|
1535
|
+
res.setHeader("content-type", source.headers["content-type"] ?? "multipart/x-mixed-replace");
|
|
1536
|
+
res.setHeader("cache-control", "no-store");
|
|
1537
|
+
source.pipe(res);
|
|
1538
|
+
});
|
|
1539
|
+
upstream.on("error", (error) => {
|
|
1540
|
+
if (!res.headersSent) sendJson(res, 502, { error: `iOS \uD654\uBA74\uC744 \uBABB \uBC1B\uC558\uC2B5\uB2C8\uB2E4: ${error.message}` });
|
|
1541
|
+
else res.destroy();
|
|
1542
|
+
});
|
|
1543
|
+
req.on("close", () => upstream.destroy());
|
|
1544
|
+
}
|
|
1545
|
+
async function streamOverHttp({ res }) {
|
|
1546
|
+
sendJson(res, 426, { error: "WebSocket\uC73C\uB85C \uC5F0\uACB0\uD574 \uC8FC\uC138\uC694." });
|
|
1547
|
+
}
|
|
1548
|
+
var json = (load) => async ({ farm, res }) => sendJson(res, 200, await load(farm));
|
|
1549
|
+
var fixedRoutes = {
|
|
1550
|
+
"GET /": json((farm) => farm.listDevices()),
|
|
1551
|
+
"GET /app": json((farm) => publicAppConfig(farm.app)),
|
|
1552
|
+
"POST /run": startRun,
|
|
1553
|
+
"POST /run/stop": stopRun,
|
|
1554
|
+
"GET /runs/latest": json((farm) => farm.latestRun()),
|
|
1555
|
+
"GET /baselines/pending": json((farm) => farm.pendingBaselines()),
|
|
1556
|
+
"GET /baselines/candidate": candidate,
|
|
1557
|
+
"POST /baselines/confirm": confirmBaselines,
|
|
1558
|
+
"POST /explore": startExplore,
|
|
1559
|
+
"GET /explore/latest": json((farm) => farm.latestExplore()),
|
|
1560
|
+
"POST /explore/replay": replay,
|
|
1561
|
+
"GET /files": resultFile,
|
|
1562
|
+
"GET /flows": json(() => flowsState()),
|
|
1563
|
+
"POST /flows/generate": generateFlows,
|
|
1564
|
+
"POST /flows/confirm": confirmFlowCandidates,
|
|
1565
|
+
"POST /flows/from-map": mapFlowCandidate,
|
|
1566
|
+
"GET /screen-map": json(() => readScreenMap()),
|
|
1567
|
+
"GET /chaos": json(() => listChaosRuns()),
|
|
1568
|
+
"GET /policy": json((farm) => farm.policyState()),
|
|
1569
|
+
"POST /policy/check": checkPolicy,
|
|
1570
|
+
"GET /policy/ignored": json(() => ({ items: readIgnores() })),
|
|
1571
|
+
"POST /policy/ignore": ignorePolicy,
|
|
1572
|
+
"POST /policy/unignore": unignorePolicy,
|
|
1573
|
+
"GET /chaos/run": chaosRun
|
|
1574
|
+
};
|
|
1575
|
+
var deviceRoutes = {
|
|
1576
|
+
"POST input": input,
|
|
1577
|
+
"POST lock": lock,
|
|
1578
|
+
"DELETE lock": unlock,
|
|
1579
|
+
"POST lock/touch": touch,
|
|
1580
|
+
"GET mjpeg": mjpeg,
|
|
1581
|
+
"GET stream": streamOverHttp
|
|
1582
|
+
};
|
|
1583
|
+
var devicePathPattern = /^\/([^/]+)\/(input|lock|lock\/touch|mjpeg|stream)$/;
|
|
1584
|
+
function findRoute(method, rest) {
|
|
1585
|
+
const fixed = fixedRoutes[`${method} ${rest || "/"}`];
|
|
1586
|
+
if (fixed) return { handler: fixed, id: "" };
|
|
1587
|
+
const match = devicePathPattern.exec(rest);
|
|
1588
|
+
const handler = match ? deviceRoutes[`${method} ${match[2]}`] : void 0;
|
|
1589
|
+
return match && handler ? { handler, id: decodeURIComponent(match[1]) } : null;
|
|
1590
|
+
}
|
|
1591
|
+
function statusOf(error) {
|
|
1592
|
+
if (error instanceof HttpError || error instanceof ExploreRequestError) return error.status;
|
|
1593
|
+
return 500;
|
|
1594
|
+
}
|
|
1595
|
+
function deviceMiddleware(farm) {
|
|
1596
|
+
return (req, res, next) => {
|
|
1597
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1598
|
+
if (url.pathname !== apiPrefix && !url.pathname.startsWith(`${apiPrefix}/`)) return next();
|
|
1599
|
+
const route = findRoute(req.method ?? "GET", url.pathname.slice(apiPrefix.length).replace(/\/$/, ""));
|
|
1600
|
+
if (!route) return sendJson(res, 404, { error: "\uC5C6\uB294 API\uC785\uB2C8\uB2E4." });
|
|
1601
|
+
route.handler({ farm, req, res, url, id: route.id }).catch((error) => {
|
|
1602
|
+
if (!res.headersSent) sendJson(res, statusOf(error), { error: errorMessage(error) });
|
|
1603
|
+
});
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
var streamPathPattern = /^\/api\/devices\/([^/]+)\/stream$/;
|
|
1607
|
+
function streamIdOf(rawUrl) {
|
|
1608
|
+
const match = streamPathPattern.exec(new URL(rawUrl ?? "/", "http://127.0.0.1").pathname);
|
|
1609
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
1610
|
+
}
|
|
1611
|
+
async function attachStream(farm, id, socket) {
|
|
1612
|
+
let unsubscribe = null;
|
|
1613
|
+
socket.on("close", () => unsubscribe?.());
|
|
1614
|
+
try {
|
|
1615
|
+
if (await farm.platformOf(id) !== "android") throw new Error("Android \uAE30\uAE30\uB9CC \uC2A4\uD2B8\uB9BC\uC744 \uBCF4\uB0C5\uB2C8\uB2E4. iOS\uB294 mjpeg\uB97C \uC4F0\uC138\uC694.");
|
|
1616
|
+
const stop = await farm.watchVideo(id, {
|
|
1617
|
+
header: (header) => socket.send(JSON.stringify(header)),
|
|
1618
|
+
packet: (packet) => {
|
|
1619
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(packet);
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
if (socket.readyState === WebSocket.OPEN) unsubscribe = stop;
|
|
1623
|
+
else stop();
|
|
1624
|
+
} catch (error) {
|
|
1625
|
+
socket.close(1011, errorMessage(error).slice(0, closeReasonLength));
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
function devicePlugin(options) {
|
|
1629
|
+
const { app, farm } = options;
|
|
1630
|
+
return {
|
|
1631
|
+
name: "device-farm-devices",
|
|
1632
|
+
configureServer: attach,
|
|
1633
|
+
// `npx device-farm dev`는 미리 빌드한 화면을 vite preview로 연다. API·스트림은 dev와 똑같이 붙인다.
|
|
1634
|
+
configurePreviewServer: attach
|
|
1635
|
+
};
|
|
1636
|
+
function attach(server) {
|
|
1637
|
+
const running = farm ? null : createFarm({ app });
|
|
1638
|
+
const active = farm ?? running;
|
|
1639
|
+
if (!active) return;
|
|
1640
|
+
void running?.start().catch((error) => console.error(`[farm] \uC2DC\uC791 \uC2E4\uD328: ${errorMessage(error)}`));
|
|
1641
|
+
const domains = allowedDomains(void 0, app.access.allowedDomains);
|
|
1642
|
+
server.middlewares.use(createAccessMiddleware(domains));
|
|
1643
|
+
server.middlewares.use(deviceMiddleware(active));
|
|
1644
|
+
const sockets = new WebSocketServer({ noServer: true });
|
|
1645
|
+
server.httpServer?.on("upgrade", (req, socket, head) => {
|
|
1646
|
+
const id = streamIdOf(req.url);
|
|
1647
|
+
if (id === null) return;
|
|
1648
|
+
const access = decideAccess({ remoteAddress: req.socket.remoteAddress, headers: req.headers }, domains);
|
|
1649
|
+
if (!access.ok) {
|
|
1650
|
+
socket.end(`HTTP/1.1 ${access.status} ${access.status === 401 ? "Unauthorized" : "Forbidden"}\r
|
|
1651
|
+
Connection: close\r
|
|
1652
|
+
\r
|
|
1653
|
+
`);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
sockets.handleUpgrade(req, socket, head, (ws) => void attachStream(active, id, ws));
|
|
1657
|
+
});
|
|
1658
|
+
server.httpServer?.on("close", () => {
|
|
1659
|
+
sockets.close();
|
|
1660
|
+
void running?.stop();
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// apps/cli/src/serve.ts
|
|
1666
|
+
var webDist = fileURLToPath(new URL("./web", import.meta.url));
|
|
1667
|
+
async function serve(port) {
|
|
1668
|
+
if (!existsSync3(path6.join(webDist, "index.html"))) throw new Error(`\uD654\uBA74 \uBE4C\uB4DC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4: ${webDist} (\uC800\uC7A5\uC18C\uC5D0\uC11C\uB294 npm run build \uB610\uB294 npm run dev)`);
|
|
1669
|
+
const app = await cliApp();
|
|
1670
|
+
const server = await preview({
|
|
1671
|
+
configFile: false,
|
|
1672
|
+
// root를 web 바로 위(dist)로 둬야 vite가 outDir=root 경고를 내지 않는다
|
|
1673
|
+
root: path6.dirname(webDist),
|
|
1674
|
+
build: { outDir: webDist },
|
|
1675
|
+
preview: { host: "127.0.0.1", port, strictPort: true, allowedHosts: [".ts.net"] },
|
|
1676
|
+
plugins: [devicePlugin({ app })]
|
|
1677
|
+
});
|
|
1678
|
+
server.printUrls();
|
|
1679
|
+
const close = () => {
|
|
1680
|
+
server.close().finally(() => process.exit(0));
|
|
1681
|
+
};
|
|
1682
|
+
process.once("SIGINT", close);
|
|
1683
|
+
process.once("SIGTERM", close);
|
|
1684
|
+
}
|
|
1685
|
+
export {
|
|
1686
|
+
serve
|
|
1687
|
+
};
|