@ahnseungchan/device-farm 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/dist/bin.js +79 -0
- package/dist/chaos-CIB7G5X5.js +453 -0
- package/dist/chunk-3GCH6TN3.js +86 -0
- package/dist/chunk-4UOXXMXL.js +74 -0
- package/dist/chunk-7OJDY276.js +16 -0
- package/dist/chunk-AVECSGEH.js +1462 -0
- package/dist/chunk-AXQNUPGC.js +416 -0
- package/dist/chunk-F4TMB4WY.js +54 -0
- package/dist/chunk-F6PZOBU6.js +167 -0
- package/dist/chunk-JD2L6T7E.js +56 -0
- package/dist/chunk-JZDYF3HL.js +169 -0
- package/dist/chunk-NJNTEWXE.js +134 -0
- package/dist/chunk-NLX3V7ZJ.js +134 -0
- package/dist/chunk-OUNLQI2N.js +34 -0
- package/dist/chunk-P2M55S53.js +110 -0
- package/dist/chunk-PLMSQ5KF.js +173 -0
- package/dist/chunk-PYZWMVJ6.js +302 -0
- package/dist/chunk-PZZQWRSU.js +283 -0
- package/dist/chunk-Q6CXF3NJ.js +102 -0
- package/dist/chunk-S6GHOP4B.js +111 -0
- package/dist/chunk-SWW42KTA.js +223 -0
- package/dist/chunk-UAHWEUDI.js +68 -0
- package/dist/chunk-VGPCTMUA.js +258 -0
- package/dist/chunk-WTTCWFOX.js +161 -0
- package/dist/chunk-ZE2GGXFO.js +169 -0
- package/dist/device-explore-PHS6SVEJ.js +64 -0
- package/dist/device-run-XPNRYXEL.js +60 -0
- package/dist/explore-allowlist-F44YQDVK.js +44 -0
- package/dist/index.d.ts +192 -0
- package/dist/index.js +24 -0
- package/dist/init-QFFEOJWY.js +29 -0
- package/dist/policy-check-AUWGFNHZ.js +75 -0
- package/dist/policy-watch-QPHI2P7Z.js +66 -0
- package/dist/qa-run-5LLQMLAM.js +59 -0
- package/dist/record-EGHVQFR7.js +152 -0
- package/dist/serve-XT32MAEN.js +1687 -0
- package/dist/server-3NM7ODUG.js +251 -0
- package/dist/templates/env.example +48 -0
- package/dist/templates/farm.config.ts +104 -0
- package/dist/web/assets/index-BCuSKs27.js +17 -0
- package/dist/web/assets/index-C3is0x-E.css +1 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +95 -4
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultGuard,
|
|
3
|
+
loadExploreInputs
|
|
4
|
+
} from "./chunk-AVECSGEH.js";
|
|
5
|
+
import "./chunk-UAHWEUDI.js";
|
|
6
|
+
import "./chunk-F6PZOBU6.js";
|
|
7
|
+
import "./chunk-Q6CXF3NJ.js";
|
|
8
|
+
import {
|
|
9
|
+
buildRecording
|
|
10
|
+
} from "./chunk-JD2L6T7E.js";
|
|
11
|
+
import {
|
|
12
|
+
addCandidate,
|
|
13
|
+
mapRulesOf,
|
|
14
|
+
readReady
|
|
15
|
+
} from "./chunk-PLMSQ5KF.js";
|
|
16
|
+
import {
|
|
17
|
+
recordScreenMap
|
|
18
|
+
} from "./chunk-F4TMB4WY.js";
|
|
19
|
+
import {
|
|
20
|
+
buttonCheckLimits,
|
|
21
|
+
hasForbiddenWord,
|
|
22
|
+
listActionsScript,
|
|
23
|
+
screenTextsScript,
|
|
24
|
+
stepsOf
|
|
25
|
+
} from "./chunk-SWW42KTA.js";
|
|
26
|
+
import {
|
|
27
|
+
listDevices
|
|
28
|
+
} from "./chunk-JZDYF3HL.js";
|
|
29
|
+
import {
|
|
30
|
+
execFileText,
|
|
31
|
+
openAndroidDriver
|
|
32
|
+
} from "./chunk-AXQNUPGC.js";
|
|
33
|
+
import {
|
|
34
|
+
isLoginPath,
|
|
35
|
+
loadAppConfig,
|
|
36
|
+
loadDotEnv,
|
|
37
|
+
resolveAppConfigFile,
|
|
38
|
+
sleep,
|
|
39
|
+
webOrigin
|
|
40
|
+
} from "./chunk-NLX3V7ZJ.js";
|
|
41
|
+
import "./chunk-NJNTEWXE.js";
|
|
42
|
+
|
|
43
|
+
// apps/mcp/src/server.ts
|
|
44
|
+
import { createHash } from "crypto";
|
|
45
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
46
|
+
import path from "path";
|
|
47
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
48
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
49
|
+
import { z } from "zod";
|
|
50
|
+
|
|
51
|
+
// packages/core/src/agent/device-session.ts
|
|
52
|
+
var defaultMaxActions = 40;
|
|
53
|
+
var maxListedActions = 40;
|
|
54
|
+
function createDeviceSession(driver, options = {}) {
|
|
55
|
+
const maxActions = options.maxActions ?? defaultMaxActions;
|
|
56
|
+
const now = options.now ?? Date.now;
|
|
57
|
+
const events = [];
|
|
58
|
+
const findings = [];
|
|
59
|
+
let actions = 0;
|
|
60
|
+
const { homePath, loginPaths, appErrorText, guard } = driver.app;
|
|
61
|
+
const forbidden = (name) => hasForbiddenWord(name, guard.forbiddenButtonWords);
|
|
62
|
+
let lastPath = homePath;
|
|
63
|
+
let lastActions = [];
|
|
64
|
+
const look = async () => {
|
|
65
|
+
const path2 = await driver.currentPath();
|
|
66
|
+
const all = await driver.evaluate(listActionsScript).catch(() => []);
|
|
67
|
+
const unique = /* @__PURE__ */ new Map();
|
|
68
|
+
for (const item of all) if (item.role !== "input" && !forbidden(item.name)) unique.set(`${item.role}:${item.name}`, { role: item.role, name: item.name });
|
|
69
|
+
const texts = await driver.evaluate(screenTextsScript).catch(() => []);
|
|
70
|
+
const blocked = options.takeBlocked?.() ?? [];
|
|
71
|
+
const flags = [
|
|
72
|
+
isLoginPath(path2, loginPaths) ? "\uB85C\uADF8\uC778 \uD654\uBA74\uC73C\uB85C \uD295\uAE40" : null,
|
|
73
|
+
await driver.hasText(appErrorText).catch(() => false) ? `\uC571 \uC624\uB958 \uD45C\uC2DC(${appErrorText})` : null,
|
|
74
|
+
blocked.length > 0 ? `\uB9C9\uC740 \uC4F0\uAE30 \uC694\uCCAD: ${blocked.join(", ")}` : null
|
|
75
|
+
].filter((flag) => flag !== null);
|
|
76
|
+
lastPath = path2;
|
|
77
|
+
lastActions = [...unique.values()].slice(0, maxListedActions);
|
|
78
|
+
return { path: path2, texts, flags, actions: lastActions, remainingActions: Math.max(0, maxActions - actions) };
|
|
79
|
+
};
|
|
80
|
+
const spend = () => {
|
|
81
|
+
if (actions >= maxActions) throw new Error("\uB204\uB97C \uC218 \uC788\uB294 \uD69F\uC218\uB97C \uB2E4 \uC37C\uC2B5\uB2C8\uB2E4. \uD0D0\uC0C9\uC744 \uB05D\uB0B4\uC138\uC694.");
|
|
82
|
+
actions += 1;
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
events,
|
|
86
|
+
findings,
|
|
87
|
+
actions: () => actions,
|
|
88
|
+
look,
|
|
89
|
+
press: async (role, name) => {
|
|
90
|
+
if (forbidden(name)) throw new Error(`\u300C${name}\u300D\uC740 \uB204\uB97C \uC218 \uC5C6\uB294 \uBC84\uD2BC\uC785\uB2C8\uB2E4.`);
|
|
91
|
+
if (!lastActions.some((item) => item.role === role && item.name === name)) {
|
|
92
|
+
throw new Error(`\u300C${name}\u300D(${role})\uC740 \uB9C8\uC9C0\uB9C9 \uBCF4\uAE30 \uBAA9\uB85D\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4. \uD654\uBA74\uC744 \uB2E4\uC2DC \uBCF4\uC138\uC694.`);
|
|
93
|
+
}
|
|
94
|
+
spend();
|
|
95
|
+
const from = lastPath;
|
|
96
|
+
await driver.clickButton(name, role);
|
|
97
|
+
await sleep(buttonCheckLimits.settleMs);
|
|
98
|
+
const view = await look();
|
|
99
|
+
events.push({ type: "click", at: now(), from, role, name, to: view.path, texts: view.texts });
|
|
100
|
+
return view;
|
|
101
|
+
},
|
|
102
|
+
goHome: async () => {
|
|
103
|
+
if (!driver.goHome) throw new Error("\uC774 \uAE30\uAE30\uB294 \uD648\uC73C\uB85C \uAC08 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
104
|
+
spend();
|
|
105
|
+
const from = lastPath;
|
|
106
|
+
await driver.goHome();
|
|
107
|
+
const view = await look();
|
|
108
|
+
events.push({ type: "jump", at: now(), from, to: view.path });
|
|
109
|
+
return view;
|
|
110
|
+
},
|
|
111
|
+
reportIssue: async (description) => {
|
|
112
|
+
const screenshot = await driver.screenshot().catch(() => void 0);
|
|
113
|
+
findings.push({ path: lastPath, description, screenshot });
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// apps/mcp/src/server.ts
|
|
119
|
+
var agentIdHashLength = 8;
|
|
120
|
+
var userProject = process.cwd();
|
|
121
|
+
var farmRoot = process.env.FARM_ROOT ?? path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../..");
|
|
122
|
+
var userConfig = path.join(userProject, "farm.config.ts");
|
|
123
|
+
var configFile = process.env.FARM_CONFIG ? resolveAppConfigFile(process.env, userProject) : existsSync(userConfig) ? userConfig : resolveAppConfigFile(process.env, farmRoot);
|
|
124
|
+
process.chdir(farmRoot);
|
|
125
|
+
loadDotEnv();
|
|
126
|
+
var app = await loadAppConfig(configFile, (line) => console.error(line));
|
|
127
|
+
var farmRunsUrl = `http://127.0.0.1:${Number(process.env.FARM_PORT) || 5173}/api/devices/runs/latest`;
|
|
128
|
+
var active = null;
|
|
129
|
+
var text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 1) }] });
|
|
130
|
+
var failure = (error) => ({ ...text(error instanceof Error ? error.message : String(error)), isError: true });
|
|
131
|
+
function requireActive() {
|
|
132
|
+
if (!active) throw new Error("\uD0D0\uC0C9\uC774 \uC2DC\uC791\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. device_start\uBD80\uD130 \uBD80\uB974\uC138\uC694.");
|
|
133
|
+
return active;
|
|
134
|
+
}
|
|
135
|
+
async function guarded(work) {
|
|
136
|
+
try {
|
|
137
|
+
return text(await work());
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return failure(error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function farmBusy() {
|
|
143
|
+
const response = await fetch(farmRunsUrl).catch(() => null);
|
|
144
|
+
if (!response?.ok) return false;
|
|
145
|
+
const run = await response.json().catch(() => null);
|
|
146
|
+
return Boolean(run && run.finishedAt === null);
|
|
147
|
+
}
|
|
148
|
+
async function start(task, deviceId, maxActions) {
|
|
149
|
+
if (active) throw new Error(`\uC774\uBBF8 \uD0D0\uC0C9 \uC911\uC785\uB2C8\uB2E4: ${active.task}. device_finish\uB85C \uB05D\uB0B8 \uB4A4 \uC2DC\uC791\uD558\uC138\uC694.`);
|
|
150
|
+
if (await farmBusy()) throw new Error("\uB514\uBC14\uC774\uC2A4\uD31C \uC810\uAC80\uC774 \uB3C4\uB294 \uC911\uC774\uB77C \uAE30\uAE30\uB97C \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
151
|
+
const devices = (await listDevices(execFileText, app.appIds.dev)).filter((device2) => device2.platform === "android");
|
|
152
|
+
const device = deviceId ? devices.find((item) => item.id === deviceId) : devices[0];
|
|
153
|
+
if (!device) throw new Error(deviceId ? `\uC5F0\uACB0\uB41C Android \uAE30\uAE30\uAC00 \uC544\uB2D9\uB2C8\uB2E4: ${deviceId}` : "\uC5F0\uACB0\uB41C Android \uAE30\uAE30\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. iOS\uB294 \uC4F0\uAE30 \uC694\uCCAD \uCC28\uB2E8\uC774 \uC5C6\uC5B4 \uC544\uC9C1 \uD0D0\uC0C9\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.");
|
|
154
|
+
const origin = webOrigin(app, "dev");
|
|
155
|
+
const driver = await openAndroidDriver(device.id, app.appIds.dev, origin, app);
|
|
156
|
+
const blocked = [];
|
|
157
|
+
const unguard = await defaultGuard(driver, origin, loadExploreInputs().allowlist, (request) => blocked.push(`${request.method} ${request.path}`)).catch(async (error) => {
|
|
158
|
+
await driver.close();
|
|
159
|
+
throw error;
|
|
160
|
+
});
|
|
161
|
+
const session = createDeviceSession(driver, { maxActions, takeBlocked: () => blocked.splice(0) });
|
|
162
|
+
active = { task, driver, session, unguard };
|
|
163
|
+
if (!driver.goHome) throw new Error("\uD648\uC73C\uB85C \uAC08 \uC218 \uC5C6\uB294 \uB4DC\uB77C\uC774\uBC84\uC785\uB2C8\uB2E4.");
|
|
164
|
+
await driver.goHome();
|
|
165
|
+
return { device: `${device.model} (${device.id})`, task, maxActions, screen: await session.look() };
|
|
166
|
+
}
|
|
167
|
+
async function finish(summary) {
|
|
168
|
+
const { task, driver, session, unguard } = requireActive();
|
|
169
|
+
active = null;
|
|
170
|
+
try {
|
|
171
|
+
const recording = buildRecording(session.events, app.homePath, mapRulesOf(app));
|
|
172
|
+
for (const screen of recording.screens) recordScreenMap(screen.from, screen.buttons);
|
|
173
|
+
const dir = path.resolve("test-results/agent", (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"));
|
|
174
|
+
mkdirSync(dir, { recursive: true });
|
|
175
|
+
const findings = session.findings.map((finding, index) => {
|
|
176
|
+
const screenshot = finding.screenshot ? `finding-${index + 1}.png` : void 0;
|
|
177
|
+
if (finding.screenshot && screenshot) writeFileSync(path.join(dir, screenshot), finding.screenshot);
|
|
178
|
+
return { path: finding.path, description: finding.description, screenshot };
|
|
179
|
+
});
|
|
180
|
+
writeFileSync(path.join(dir, "report.json"), `${JSON.stringify({ task, summary, actions: session.actions(), findings, events: session.events, cut: recording.cut }, null, 2)}
|
|
181
|
+
`);
|
|
182
|
+
const steps = stepsOf(recording.steps, app.guard.forbiddenClickNames);
|
|
183
|
+
let candidate;
|
|
184
|
+
if (typeof steps === "string") {
|
|
185
|
+
candidate = `\uD50C\uB85C\uC6B0 \uD6C4\uBCF4\uB85C \uC62C\uB9AC\uC9C0 \uC54A\uC74C: ${recording.cut ?? steps}`;
|
|
186
|
+
} else {
|
|
187
|
+
const id = `agent-${createHash("sha256").update(JSON.stringify(steps)).digest("hex").slice(0, agentIdHashLength)}`;
|
|
188
|
+
const proposal = readReady().some((flow2) => flow2.id === id) ? "update" : "create";
|
|
189
|
+
const flow = { id, name: `\uD0D0\uC0C9: ${task}`, source: "recorded", status: "generated", prd: "\uC5D0\uC774\uC804\uD2B8 \uD0D0\uC0C9", requirement: task, proposal, steps };
|
|
190
|
+
addCandidate(flow);
|
|
191
|
+
candidate = `\uD50C\uB85C\uC6B0 \uD6C4\uBCF4 \u300C${flow.name}\u300D(${id})\uB97C \uC62C\uB838\uC2B5\uB2C8\uB2E4. \uB514\uBC14\uC774\uC2A4\uD31C \uD50C\uB85C\uC6B0 \uD0ED\uC5D0\uC11C \uD655\uC815\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`;
|
|
192
|
+
}
|
|
193
|
+
return { report: path.relative(process.cwd(), path.join(dir, "report.json")), actions: session.actions(), findings, mapScreens: recording.screens.length, candidate };
|
|
194
|
+
} finally {
|
|
195
|
+
await unguard().catch(() => void 0);
|
|
196
|
+
await driver.close().catch(() => void 0);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
var server = new McpServer({ name: "device-farm", version: "0.1.0" });
|
|
200
|
+
var roleSchema = z.enum(["button", "link"]);
|
|
201
|
+
server.registerTool(
|
|
202
|
+
"device_start",
|
|
203
|
+
{
|
|
204
|
+
description: `${app.name} Android dev \uC571 \uD0D0\uC0C9\uC744 \uC2DC\uC791\uD55C\uB2E4. \uC4F0\uAE30 \uC694\uCCAD \uCC28\uB2E8\uC744 \uCF1C\uACE0 \uD648 \uD0ED(${app.homeTabName})\uC73C\uB85C \uAC04 \uB4A4 \uD654\uBA74\uC744 \uB3CC\uB824\uC900\uB2E4. \uAD6C\uB9E4\xB7\uACB0\uC81C\xB7\uCD9C\uAE08\xB7\uC2E0\uCCAD\xB7\uD0C8\uD1F4 \uAC19\uC740 \uBC84\uD2BC\uC740 \uBAA9\uB85D\uC5D0\uC11C \uBE60\uC9C0\uACE0 \uB20C\uB7EC\uB3C4 \uAC70\uC808\uB41C\uB2E4. \uB05D\uB098\uBA74 \uBC18\uB4DC\uC2DC device_finish\uB97C \uBD80\uB978\uB2E4.`,
|
|
205
|
+
inputSchema: {
|
|
206
|
+
task: z.string().describe("\uD0D0\uC0C9 \uBAA9\uD45C. \uC608: \uD034\uC988 \uD654\uBA74\uC744 \uB098\uAC14\uB2E4 \uB2E4\uC2DC \uB4E4\uC5B4\uC62C \uB54C \uC0C1\uD0DC\uAC00 \uC720\uC9C0\uB418\uB294\uC9C0"),
|
|
207
|
+
device: z.string().optional().describe("Android serial. \uC5C6\uC73C\uBA74 \uC5F0\uACB0\uB41C \uCCAB \uAE30\uAE30"),
|
|
208
|
+
maxActions: z.number().int().positive().optional().describe(`\uB204\uB974\uAE30\xB7\uD648 \uC774\uB3D9 \uCD5C\uB300 \uD69F\uC218 (\uAE30\uBCF8 ${defaultMaxActions})`)
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async ({ task, device, maxActions }) => guarded(() => start(task, device, maxActions ?? defaultMaxActions))
|
|
212
|
+
);
|
|
213
|
+
server.registerTool(
|
|
214
|
+
"device_look",
|
|
215
|
+
{ description: "\uC9C0\uAE08 \uD654\uBA74\uC758 \uACBD\uB85C, \uC81C\uBAA9\xB7\uD31D\uC5C5 \uBB38\uAD6C, \uACBD\uACE0(\uC571 \uC624\uB958\xB7\uB85C\uADF8\uC778 \uD295\uAE40\xB7\uB9C9\uC740 \uC4F0\uAE30 \uC694\uCCAD), \uB204\uB97C \uC218 \uC788\uB294 \uBC84\uD2BC\xB7\uB9C1\uD06C \uBAA9\uB85D, \uB0A8\uC740 \uD69F\uC218\uB97C \uBCF8\uB2E4." },
|
|
216
|
+
async () => guarded(() => requireActive().session.look())
|
|
217
|
+
);
|
|
218
|
+
server.registerTool(
|
|
219
|
+
"device_press",
|
|
220
|
+
{
|
|
221
|
+
description: "\uB9C8\uC9C0\uB9C9 device_look \uBAA9\uB85D\uC5D0 \uC788\uB294 \uBC84\uD2BC\xB7\uB9C1\uD06C\uB97C \uC774\uB984 \uADF8\uB300\uB85C \uB204\uB978\uB2E4. \uB204\uB978 \uB4A4 \uD654\uBA74\uC744 \uB3CC\uB824\uC900\uB2E4.",
|
|
222
|
+
inputSchema: { role: roleSchema, name: z.string().describe("\uBAA9\uB85D\uC758 name\uC744 \uAE00\uC790 \uADF8\uB300\uB85C") }
|
|
223
|
+
},
|
|
224
|
+
async ({ role, name }) => guarded(() => requireActive().session.press(role, name))
|
|
225
|
+
);
|
|
226
|
+
server.registerTool(
|
|
227
|
+
"device_go_home",
|
|
228
|
+
{ description: `\uC5F4\uB9B0 \uD654\uBA74\uC744 \uBAA8\uB450 \uB2EB\uACE0 \uD648(${app.homeTabName} \uD0ED)\uC73C\uB85C \uAC04\uB2E4. \uB9C9\uD614\uAC70\uB098 \uCC98\uC74C\uBD80\uD130 \uB2E4\uC2DC \uD574 \uBCFC \uB54C \uC4F4\uB2E4.` },
|
|
229
|
+
async () => guarded(() => requireActive().session.goHome())
|
|
230
|
+
);
|
|
231
|
+
server.registerTool(
|
|
232
|
+
"device_report_issue",
|
|
233
|
+
{
|
|
234
|
+
description: "\uC9C0\uAE08 \uD654\uBA74\uC5D0\uC11C \uBC84\uADF8\uB85C \uBCF4\uC774\uB294 \uAC83\uC744 \uC2A4\uD06C\uB9B0\uC0F7\uACFC \uAC19\uC774 \uB0A8\uAE34\uB2E4. \uBB34\uC5C7\uC744 \uD588\uB354\uB2C8 \uBB34\uC5C7\uC774 \uC774\uC0C1\uD588\uB294\uC9C0 \uAD6C\uCCB4\uC801\uC73C\uB85C \uC4F4\uB2E4. \uB514\uC790\uC778 \uCDE8\uD5A5\uC740 \uB0A8\uAE30\uC9C0 \uC54A\uB294\uB2E4.",
|
|
235
|
+
inputSchema: { description: z.string() }
|
|
236
|
+
},
|
|
237
|
+
async ({ description }) => guarded(async () => (await requireActive().session.reportIssue(description), "\uAE30\uB85D\uD588\uC2B5\uB2C8\uB2E4."))
|
|
238
|
+
);
|
|
239
|
+
server.registerTool(
|
|
240
|
+
"device_finish",
|
|
241
|
+
{
|
|
242
|
+
description: "\uD0D0\uC0C9\uC744 \uB05D\uB0B8\uB2E4. \uD654\uBA74 \uC9C0\uB3C4\uC5D0 \uD569\uCE58\uACE0, \uD648\uBD80\uD130 \uB2E4\uC2DC \uB3CC \uC218 \uC788\uC73C\uBA74 \uD50C\uB85C\uC6B0 \uD6C4\uBCF4\uB85C \uC62C\uB9AC\uACE0, \uC774\uC0C1 \uD6C4\uBCF4\xB7\uC2A4\uD06C\uB9B0\uC0F7\uC744 \uBCF4\uACE0\uC11C\uB85C \uB0A8\uAE34\uB2E4.",
|
|
243
|
+
inputSchema: { summary: z.string().describe("\uD55C \uC77C\uACFC \uCC3E\uC740 \uAC83 \uC694\uC57D") }
|
|
244
|
+
},
|
|
245
|
+
async ({ summary }) => guarded(() => finish(summary))
|
|
246
|
+
);
|
|
247
|
+
process.stdin.on("close", () => {
|
|
248
|
+
if (active) void finish("\uC5F0\uACB0\uC774 \uB04A\uACA8 \uB05D\uB0C4").catch((error) => console.error(error));
|
|
249
|
+
});
|
|
250
|
+
await server.connect(new StdioServerTransport());
|
|
251
|
+
console.error("[device-farm] MCP \uC11C\uBC84 \uC900\uBE44\uB428");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# 비밀 값과 이 맥에만 해당하는 값. 앱마다 다른 값은 farm.config.ts에 둔다.
|
|
2
|
+
|
|
3
|
+
# 앱 설정 파일 경로(비우면 작업 폴더의 farm.config.ts → farm.config.example.ts)
|
|
4
|
+
FARM_CONFIG=
|
|
5
|
+
# 화면 서버 포트(비우면 5173)
|
|
6
|
+
FARM_PORT=
|
|
7
|
+
# farm.config.ts의 web·apiOrigin을 이 맥에서만 덮을 때
|
|
8
|
+
FARM_WEB_ORIGIN_DEV=
|
|
9
|
+
FARM_WEB_ORIGIN_PROD=
|
|
10
|
+
FARM_API_ORIGIN=
|
|
11
|
+
# 탐색 허용 목록 후보를 뽑을 OpenAPI 스펙 주소(npm run explore-allowlist)
|
|
12
|
+
FARM_OPENAPI_URL=
|
|
13
|
+
# 디바이스팜 화면에 들어올 수 있는 Tailscale 계정 도메인(쉼표). 비우면 farm.config.ts의 access.allowedDomains
|
|
14
|
+
FARM_ALLOWED_DOMAINS=
|
|
15
|
+
|
|
16
|
+
# Firebase App Distribution(빌드 받기·새 빌드 감시). 앱 id는 farm.config.ts install.firebase로도 줄 수 있다
|
|
17
|
+
FIREBASE_SERVICE_ACCOUNT=
|
|
18
|
+
FIREBASE_APP_ANDROID_DEV=
|
|
19
|
+
FIREBASE_APP_ANDROID_PROD=
|
|
20
|
+
FIREBASE_APP_IOS_DEV=
|
|
21
|
+
FIREBASE_APP_IOS_PROD=
|
|
22
|
+
|
|
23
|
+
# App Store Connect(TestFlight 새 빌드 감시, 읽기만)
|
|
24
|
+
ASC_KEY_ID=
|
|
25
|
+
ASC_ISSUER_ID=
|
|
26
|
+
ASC_PRIVATE_KEY_PATH=
|
|
27
|
+
ASC_APP_ID=
|
|
28
|
+
ASC_APP_ID_DEV=
|
|
29
|
+
|
|
30
|
+
# iOS Appium·WebDriverAgent. 팀 id가 비면 서명 설정 없이 세션을 연다
|
|
31
|
+
APPIUM_URL=
|
|
32
|
+
IOS_TEAM_ID=
|
|
33
|
+
IOS_WDA_BUNDLE_ID=
|
|
34
|
+
|
|
35
|
+
# 플로우 생성: PRD를 읽을 프론트 체크아웃, OpenAI 호환 LLM
|
|
36
|
+
FLOW_PRD_ROOT=
|
|
37
|
+
FLOW_LLM_API_KEY=
|
|
38
|
+
FLOW_LLM_BASE_URL=
|
|
39
|
+
FLOW_LLM_MODEL=
|
|
40
|
+
|
|
41
|
+
# 정책·피그마 대조(읽기 전용). cloudId·스페이스는 farm.config.ts policy.confluence로도 줄 수 있다
|
|
42
|
+
CONFLUENCE_EMAIL=
|
|
43
|
+
CONFLUENCE_API_TOKEN=
|
|
44
|
+
CONFLUENCE_CLOUD_ID=
|
|
45
|
+
CONFLUENCE_SPACE_KEY=
|
|
46
|
+
FIGMA_TOKEN=
|
|
47
|
+
# 프론트 배포 브랜치(비우면 farm.config.ts figma.ref)
|
|
48
|
+
FARM_FRONT_REF=
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 디바이스팜 앱 설정(`npx device-farm init`이 만든 예시: 가상의 쇼핑 앱 「Sample Shop」).
|
|
3
|
+
* 값을 내 앱으로 바꾼다. 필수는 name·appIds·homePath·homeTabName·tabPaths뿐이고 나머지는 지워도 된다.
|
|
4
|
+
* 비밀 값(토큰·키)은 여기 말고 .env에 둔다.
|
|
5
|
+
*/
|
|
6
|
+
import { defineAppConfig, equalWon, formatWon, numberAt, textHas } from "@ahnseungchan/device-farm";
|
|
7
|
+
|
|
8
|
+
export default defineAppConfig({
|
|
9
|
+
name: "Sample Shop",
|
|
10
|
+
// Android 패키지 = iOS 번들 id. 다르면 iosBundleIds로 덮는다.
|
|
11
|
+
appIds: { dev: "com.example.shop.dev", prod: "com.example.shop" },
|
|
12
|
+
// 앱 WebView가 여는 웹 주소. 비우면 .env의 FARM_WEB_ORIGIN_DEV·FARM_WEB_ORIGIN_PROD
|
|
13
|
+
web: { dev: "https://dev.shop.example.com", prod: "https://shop.example.com" },
|
|
14
|
+
// API 호스트가 웹과 다를 때. 탐색 가드가 내부로 보고, 데이터 QA가 이 주소로 다시 조회한다.
|
|
15
|
+
apiOrigin: "https://api-dev.shop.example.com",
|
|
16
|
+
|
|
17
|
+
// 하단 탭바(웹 링크). 홈 탭을 누르면 homePath로 간다. 플로우는 홈에서 시작한다.
|
|
18
|
+
homePath: "/home",
|
|
19
|
+
homeTabName: "홈",
|
|
20
|
+
tabPaths: { 카테고리: "/category", 혜택: "/benefit", 마이: "/my" },
|
|
21
|
+
// 화면 상단 뒤로 버튼 이름(aria-label)
|
|
22
|
+
inPageBackNames: ["뒤로가기"],
|
|
23
|
+
// 여기로 튕기면 로그인이 풀린 것으로 본다
|
|
24
|
+
loginPaths: ["/login"],
|
|
25
|
+
appErrorText: "Application error",
|
|
26
|
+
|
|
27
|
+
// 시나리오 경로 → sampleshop://benefit/attendance. 앱이 같은 웹 경로를 WebView 스택으로 연다고 본다.
|
|
28
|
+
deepLink: { scheme: "sampleshop", homeHost: "home" },
|
|
29
|
+
android: {
|
|
30
|
+
// 이 액티비티가 아닌 앱 화면(네이티브 오퍼월 등)이 위에 있으면 뒤로 키로 닫는다. 비우면 닫지 않는다.
|
|
31
|
+
mainActivity: "com.example.shop.MainActivity",
|
|
32
|
+
},
|
|
33
|
+
webview: {
|
|
34
|
+
// 열린 WebView 스택을 모두 닫는 앱 브리지(예: flutter_inappwebview 핸들러). 비우면 닫지 않는다.
|
|
35
|
+
stackResetScript: "window.appBridge && window.appBridge.callHandler && window.appBridge.callHandler('stackReset')",
|
|
36
|
+
sheetCloseName: "닫기",
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
guard: {
|
|
40
|
+
// 기본 금지어(구매·결제·송금·출금·탈퇴…)에 더한다
|
|
41
|
+
extraForbiddenButtonWords: ["쿠폰 사용"],
|
|
42
|
+
extraForbiddenApiPatterns: [/coupon\/use/i],
|
|
43
|
+
// 앱이 켜질 때마다 보내 막으면 안 되는 쓰기 요청(푸시 토큰 저장 등)
|
|
44
|
+
pushTokenRequests: [{ method: "POST", path: "/v1/users/{userId}/push-token" }],
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
// 고정 주소 화면 점검. excludedScenarioPaths는 앞 단계가 있어야 열리는 완료·중간 화면
|
|
48
|
+
scenarios: [
|
|
49
|
+
{ path: "/home", name: "로그인 후 홈 화면이 열린다", when: "홈 화면을 연다", then: "홈 화면이 보인다" },
|
|
50
|
+
{ path: "/category", name: "로그인 후 카테고리 화면이 열린다", when: "카테고리 화면을 연다", then: "카테고리 화면이 보인다" },
|
|
51
|
+
{ path: "/benefit/attendance", name: "로그인 후 출석체크 화면이 열린다", when: "출석체크를 연다", then: "출석 화면이 보인다" },
|
|
52
|
+
{ path: "/my", name: "로그인 후 마이 화면이 열린다", when: "마이 화면을 연다", then: "마이 화면이 보인다" },
|
|
53
|
+
{ path: "/order/complete", name: "로그인 후 주문 완료 화면이 열린다", when: "주문 완료 화면을 연다", then: "주문 완료 화면이 보인다" },
|
|
54
|
+
],
|
|
55
|
+
excludedScenarioPaths: ["/order/complete"],
|
|
56
|
+
|
|
57
|
+
// 데이터 QA(선택): 화면이 부르는 GET API를 다시 불러 화면 글자와 맞춰 본다. LLM 없이 규칙으로만 판정한다.
|
|
58
|
+
qa: {
|
|
59
|
+
sessionPath: "/api/auth/session",
|
|
60
|
+
screens: [
|
|
61
|
+
{
|
|
62
|
+
path: "/cart",
|
|
63
|
+
name: "장바구니",
|
|
64
|
+
apis: { cart: "/v1/users/{userId}/cart" },
|
|
65
|
+
rules: [
|
|
66
|
+
{
|
|
67
|
+
id: "total-equals-items",
|
|
68
|
+
title: "합계 = 항목 금액 합",
|
|
69
|
+
check: (context) => {
|
|
70
|
+
const items = (context.responses.cart as { items?: { priceWon: number }[] } | undefined)?.items;
|
|
71
|
+
if (!Array.isArray(items)) throw new Error("cart.items가 배열이 아닙니다.");
|
|
72
|
+
return equalWon("합계", numberAt(context, "cart", "totalWon"), items.reduce((sum, item) => sum + item.priceWon, 0));
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "total-shown",
|
|
77
|
+
title: "받은 합계가 화면에 그대로 보인다",
|
|
78
|
+
check: (context) => {
|
|
79
|
+
const shown = `${formatWon(numberAt(context, "cart", "totalWon"))}원`;
|
|
80
|
+
return textHas(context.text, shown) ? null : `화면에 없음: 합계 「${shown}」`;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// 정책 대조(선택): Confluence 스페이스에서 제목에 titleQuery가 든 페이지를 정책 정본으로 본다. 토큰은 .env
|
|
89
|
+
policy: {
|
|
90
|
+
confluence: { cloudId: "00000000-0000-0000-0000-000000000000", spaceKey: "SHOP", titleQuery: "정책", siteUrl: "https://example.atlassian.net/wiki" },
|
|
91
|
+
},
|
|
92
|
+
// 피그마 대조(선택): 프론트(Next.js App Router) 체크아웃에서 화면 → 피그마 노드를 뽑는다. 토큰은 .env의 FIGMA_TOKEN
|
|
93
|
+
figma: { frontRepo: "", ref: "origin/main", appDir: "app" },
|
|
94
|
+
|
|
95
|
+
// 빌드 받기(선택): Firebase App Distribution 앱 id(비우면 .env), iOS dev는 TestFlight에서 깔기
|
|
96
|
+
install: {
|
|
97
|
+
sources: { ios: { dev: "testflight" } },
|
|
98
|
+
testflightDevAppName: "Sample Shop DEV",
|
|
99
|
+
},
|
|
100
|
+
// WDA 서명 팀·번들 id(비우면 .env의 IOS_TEAM_ID·IOS_WDA_BUNDLE_ID)
|
|
101
|
+
ios: {},
|
|
102
|
+
// 디바이스팜 화면에 들어올 수 있는 Tailscale 계정 도메인(비우면 이 맥에서만)
|
|
103
|
+
access: { allowedDomains: ["example.com"] },
|
|
104
|
+
});
|