@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,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
samePath,
|
|
3
|
+
sleep
|
|
4
|
+
} from "./chunk-NLX3V7ZJ.js";
|
|
5
|
+
|
|
6
|
+
// packages/core/src/flows/schema.ts
|
|
7
|
+
var idPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
8
|
+
var prdPattern = /^[A-Za-z0-9._()[\]/-]+\.md$/;
|
|
9
|
+
var stepLabels = { home: "\uD648\uC5D0\uC11C \uC2DC\uC791", click: "\uB204\uB974\uAE30", expectText: "\uBB38\uAD6C \uD655\uC778" };
|
|
10
|
+
function stepTarget(step, homeTabName = "\uD648") {
|
|
11
|
+
if (step.type === "home") return homeTabName;
|
|
12
|
+
if (step.type === "click") return step.target.match === "prefix" ? `${step.target.name}\u2026` : step.target.name;
|
|
13
|
+
return step.text;
|
|
14
|
+
}
|
|
15
|
+
function isPrdName(name) {
|
|
16
|
+
return prdPattern.test(name) && !name.split("/").some((part) => part === ".." || part === "" || part === ".");
|
|
17
|
+
}
|
|
18
|
+
function failed(id, name, prd, requirement, reason) {
|
|
19
|
+
return { id, name, source: "ai_generated", status: "generation_failed", prd, requirement, reason, steps: [] };
|
|
20
|
+
}
|
|
21
|
+
function stepOf(raw, forbiddenClickNames) {
|
|
22
|
+
if (!raw || typeof raw !== "object") return "\uC2A4\uD15D \uD615\uC2DD\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.";
|
|
23
|
+
const step = raw;
|
|
24
|
+
if (step.type === "home") return { type: "home" };
|
|
25
|
+
if (step.type === "navigate") return "\uD50C\uB85C\uC6B0\uB294 \uD648\uC5D0\uC11C \uBC84\uD2BC\xB7\uB9C1\uD06C\uB85C\uB9CC \uC774\uB3D9\uD569\uB2C8\uB2E4. navigate\uB294 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
26
|
+
if (step.type === "click") {
|
|
27
|
+
const role = step.target?.role;
|
|
28
|
+
if (role !== "button" && role !== "link" || typeof step.target?.name !== "string" || !step.target.name) {
|
|
29
|
+
return "\uBC84\uD2BC\xB7\uB9C1\uD06C \uC774\uB984\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.";
|
|
30
|
+
}
|
|
31
|
+
if (forbiddenClickNames.includes(step.target.name)) {
|
|
32
|
+
return `\u300C${step.target.name}\u300D \uBC84\uD2BC\uC740 \uC4F8 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4(\uAE08\uC9C0 \uBC84\uD2BC).`;
|
|
33
|
+
}
|
|
34
|
+
if (step.target.match !== void 0 && step.target.match !== "prefix") return "match\uB294 prefix\uB9CC \uC4F8 \uC218 \uC788\uC2B5\uB2C8\uB2E4.";
|
|
35
|
+
return { type: "click", target: { role, name: step.target.name, ...step.target.match === "prefix" ? { match: "prefix" } : {} } };
|
|
36
|
+
}
|
|
37
|
+
if (step.type === "expectText") {
|
|
38
|
+
if (typeof step.text !== "string" || !step.text) return "\uAE30\uB300 \uBB38\uAD6C\uAC00 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.";
|
|
39
|
+
return { type: "expectText", text: step.text };
|
|
40
|
+
}
|
|
41
|
+
return "\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uB3D9\uC791\uC785\uB2C8\uB2E4.";
|
|
42
|
+
}
|
|
43
|
+
function proposalError(proposal, id, readyIds) {
|
|
44
|
+
if (proposal !== "keep" && proposal !== "update" && proposal !== "create" && proposal !== "delete") return "proposal\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.";
|
|
45
|
+
if (proposal !== "create" && !readyIds.has(id)) return "\uD655\uC815\uB41C \uC2DC\uB098\uB9AC\uC624\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
46
|
+
if (proposal === "create" && readyIds.has(id)) return "\uC774\uBBF8 \uD655\uC815\uB41C \uC2DC\uB098\uB9AC\uC624\uC785\uB2C8\uB2E4.";
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function stepsOf(raw, forbiddenClickNames) {
|
|
50
|
+
if (!Array.isArray(raw) || raw.length === 0) return "\uC2A4\uD15D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
51
|
+
const steps = [];
|
|
52
|
+
for (const entry of raw) {
|
|
53
|
+
const step = stepOf(entry, forbiddenClickNames);
|
|
54
|
+
if (typeof step === "string") return step;
|
|
55
|
+
steps.push(step);
|
|
56
|
+
}
|
|
57
|
+
if (!steps.some((step) => step.type === "click")) return "\uB204\uB974\uB294 \uC2A4\uD15D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
58
|
+
if (!steps.some((step) => step.type === "expectText")) return "\uAE30\uB300 \uBB38\uAD6C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
59
|
+
return steps;
|
|
60
|
+
}
|
|
61
|
+
function validateGenerated(raw, index, readyIds, prd, prdBody, forbiddenClickNames) {
|
|
62
|
+
const record = raw && typeof raw === "object" ? raw : {};
|
|
63
|
+
const id = typeof record.id === "string" && idPattern.test(record.id) ? record.id : `invalid-${index + 1}`;
|
|
64
|
+
const name = typeof record.name === "string" && record.name ? record.name : "\uC774\uB984 \uC5C6\uB294 \uD50C\uB85C\uC6B0";
|
|
65
|
+
const requirement = typeof record.requirement === "string" ? record.requirement : "";
|
|
66
|
+
if (!requirement || !prdBody.includes(requirement)) return failed(id, name, prd, requirement || "\uC694\uAD6C \uBB38\uC7A5", "\uC694\uAD6C \uBB38\uC7A5\uC774 PRD\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
67
|
+
const proposal = record.proposal;
|
|
68
|
+
const wrongProposal = proposalError(proposal, id, readyIds);
|
|
69
|
+
if (wrongProposal) return failed(id, name, prd, requirement, wrongProposal);
|
|
70
|
+
const steps = stepsOf(record.steps, forbiddenClickNames);
|
|
71
|
+
if (typeof steps === "string") return failed(id, name, prd, requirement, steps);
|
|
72
|
+
return { id, name, source: "ai_generated", status: "generated", prd, requirement, proposal, steps };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// packages/core/src/explore/actions.ts
|
|
76
|
+
var domHelpers = `
|
|
77
|
+
const nameOf = (el) => (el.getAttribute("aria-label") || el.innerText || el.value || el.title || el.getAttribute("alt") || "").replace(/\\s+/g, " ").trim();
|
|
78
|
+
const isVisible = (el) => {
|
|
79
|
+
const rect = el.getBoundingClientRect();
|
|
80
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
81
|
+
const style = getComputedStyle(el);
|
|
82
|
+
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
|
|
83
|
+
};
|
|
84
|
+
const isEnabled = (el) => !el.disabled && el.getAttribute("aria-disabled") !== "true" && !el.closest("[inert]");
|
|
85
|
+
const isUsable = (el) => isVisible(el) && isEnabled(el);
|
|
86
|
+
const buttons = () => Array.from(document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]')).filter(isUsable);
|
|
87
|
+
const isSafeLink = (el) => {
|
|
88
|
+
if (el.getAttribute("target") === "_blank") return false;
|
|
89
|
+
if (!el.href) return true;
|
|
90
|
+
return el.href.startsWith("http://") || el.href.startsWith("https://");
|
|
91
|
+
};
|
|
92
|
+
const links = () => Array.from(document.querySelectorAll('a[href], [role="link"]')).filter((el) => isUsable(el) && isSafeLink(el));
|
|
93
|
+
const skippedInputTypes = ["hidden", "password", "file", "checkbox", "radio", "submit", "button", "image", "reset"];
|
|
94
|
+
const labelOf = (el) => (el.getAttribute("aria-label") || (el.labels && el.labels[0] ? el.labels[0].innerText : "") || el.getAttribute("placeholder") || "").replace(/\\s+/g, " ").trim();
|
|
95
|
+
const inputs = () => Array.from(document.querySelectorAll("input, textarea")).filter((el) => isUsable(el) && !el.readOnly && !skippedInputTypes.includes((el.getAttribute("type") || "").toLowerCase()));
|
|
96
|
+
`;
|
|
97
|
+
var listActionsScript = `(() => {${domHelpers}
|
|
98
|
+
return [
|
|
99
|
+
...buttons().map((el) => ({ role: "button", name: nameOf(el) })),
|
|
100
|
+
...links().map((el) => ({ role: "link", name: nameOf(el) })),
|
|
101
|
+
...inputs().map((el) => ({ role: "input", name: labelOf(el) })),
|
|
102
|
+
].filter((item) => item.name.length > 0);
|
|
103
|
+
})()`;
|
|
104
|
+
function linkClickScript(name) {
|
|
105
|
+
return `(() => {${domHelpers}
|
|
106
|
+
const target = links().find((el) => nameOf(el) === ${JSON.stringify(name)});
|
|
107
|
+
if (!target) throw new Error("\uB9C1\uD06C \uC5C6\uC74C: " + ${JSON.stringify(name)});
|
|
108
|
+
target.click();
|
|
109
|
+
return true;
|
|
110
|
+
})()`;
|
|
111
|
+
}
|
|
112
|
+
function fillScript(label, value) {
|
|
113
|
+
return `(() => {${domHelpers}
|
|
114
|
+
const target = inputs().find((el) => labelOf(el) === ${JSON.stringify(label)});
|
|
115
|
+
if (!target) throw new Error("\uC785\uB825\uCE78 \uC5C6\uC74C: " + ${JSON.stringify(label)});
|
|
116
|
+
const proto = target instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
117
|
+
Object.getOwnPropertyDescriptor(proto, "value").set.call(target, ${JSON.stringify(value)});
|
|
118
|
+
target.dispatchEvent(new Event("input", { bubbles: true }));
|
|
119
|
+
target.dispatchEvent(new Event("change", { bubbles: true }));
|
|
120
|
+
return true;
|
|
121
|
+
})()`;
|
|
122
|
+
}
|
|
123
|
+
function hasForbiddenWord(name, words) {
|
|
124
|
+
const compact = name.replace(/\s+/g, "");
|
|
125
|
+
return words.some((word) => compact.includes(word.replace(/\s+/g, "")));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// packages/core/src/device/button-check.ts
|
|
129
|
+
var buttonCheckLimits = { maxButtonsPerScreen: 12, settleMs: 1500, maxTexts: 5 };
|
|
130
|
+
var buttonErrorKinds = ["app-error", "crash", "network", "login-redirect"];
|
|
131
|
+
function skippedButtonNames(app) {
|
|
132
|
+
return [...app.inPageBackNames, ...Object.keys(app.tabPaths)];
|
|
133
|
+
}
|
|
134
|
+
var fingerprintScript = `(() => {
|
|
135
|
+
const text = (document.body ? document.body.innerText : "").replace(/\\s+/g, " ").trim();
|
|
136
|
+
const dialogs = document.querySelectorAll('[role="dialog"], [aria-modal="true"]').length;
|
|
137
|
+
return text.length + "|" + dialogs + "|" + text.slice(0, 200) + "|" + text.slice(-200);
|
|
138
|
+
})()`;
|
|
139
|
+
async function fingerprint(driver) {
|
|
140
|
+
return { path: await driver.currentPath(), content: await driver.evaluate(fingerprintScript) };
|
|
141
|
+
}
|
|
142
|
+
var screenTextsScript = `(() => {
|
|
143
|
+
const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [aria-modal="true"]'));
|
|
144
|
+
const root = dialogs.at(-1) || document;
|
|
145
|
+
const visible = (el) => { const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; };
|
|
146
|
+
const nodes = Array.from(root.querySelectorAll('h1, h2, h3, [role="heading"], header p, [role="dialog"] p')).filter(visible);
|
|
147
|
+
const texts = nodes.map((el) => el.innerText.replace(/\\s+/g, " ").trim()).filter((text) => text.length > 0 && text.length <= 40);
|
|
148
|
+
return [...new Set(texts)].slice(0, ${buttonCheckLimits.maxTexts});
|
|
149
|
+
})()`;
|
|
150
|
+
function reaction(before, after) {
|
|
151
|
+
if (before.path !== after.path) return `${after.path}\uB85C \uC774\uB3D9`;
|
|
152
|
+
if (before.content !== after.content) return "\uD654\uBA74 \uB0B4\uC6A9 \uBC14\uB01C";
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
async function listButtons(driver, max = buttonCheckLimits.maxButtonsPerScreen) {
|
|
156
|
+
const candidates = await driver.evaluate(listActionsScript);
|
|
157
|
+
const unique = /* @__PURE__ */ new Map();
|
|
158
|
+
const skipped = skippedButtonNames(driver.app);
|
|
159
|
+
for (const { role, name } of candidates) {
|
|
160
|
+
if (role === "input" || hasForbiddenWord(name, driver.app.guard.forbiddenButtonWords) || skipped.includes(name)) continue;
|
|
161
|
+
unique.set(`${role}:${name}`, { role, name });
|
|
162
|
+
}
|
|
163
|
+
return [...unique.values()].slice(0, max);
|
|
164
|
+
}
|
|
165
|
+
async function pressOne(driver, target, deps) {
|
|
166
|
+
const now = deps.now ?? Date.now;
|
|
167
|
+
const before = await fingerprint(driver);
|
|
168
|
+
const since = now();
|
|
169
|
+
try {
|
|
170
|
+
await driver.clickButton(target.name, target.role);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return { ...target, status: "skipped", message: error instanceof Error ? error.message : String(error) };
|
|
173
|
+
}
|
|
174
|
+
await sleep(deps.settleMs ?? buttonCheckLimits.settleMs);
|
|
175
|
+
const errors = (await deps.probe(since)).filter((issue) => buttonErrorKinds.includes(issue.kind));
|
|
176
|
+
const after = await fingerprint(driver).catch(() => ({ path: "", content: "" }));
|
|
177
|
+
const changed = reaction(before, after);
|
|
178
|
+
const texts = changed ? await driver.evaluate(screenTextsScript).catch(() => []) : [];
|
|
179
|
+
if (changed) await deps.restore();
|
|
180
|
+
if (errors.length > 0) return { ...target, status: "error", message: errors.map((issue) => issue.message).join(" \xB7 ") };
|
|
181
|
+
if (!changed) return { ...target, status: "dead" };
|
|
182
|
+
return { ...target, status: "reacted", message: changed, texts, ...before.path !== after.path ? { to: after.path } : {} };
|
|
183
|
+
}
|
|
184
|
+
async function checkButtons(driver, deps) {
|
|
185
|
+
const results = [];
|
|
186
|
+
const targets = await listButtons(driver, deps.maxButtons);
|
|
187
|
+
for (const [index, target] of targets.entries()) {
|
|
188
|
+
results.push(await pressOne(driver, target, deps));
|
|
189
|
+
const path = await driver.currentPath().catch(() => "");
|
|
190
|
+
if (!samePath(deps.screenPath, path)) {
|
|
191
|
+
const rest = targets.slice(index + 1).map((skipped) => ({ ...skipped, status: "skipped", message: `\uD654\uBA74 \uBCF5\uADC0 \uC2E4\uD328: ${path}\uC5D0 \uBA38\uBB3E` }));
|
|
192
|
+
return [...results, ...rest];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
function summarizeButtons(results) {
|
|
198
|
+
const named = (status) => results.filter((item) => item.status === status).map((item) => item.name);
|
|
199
|
+
const dead = named("dead");
|
|
200
|
+
const errors = named("error");
|
|
201
|
+
const skipped = named("skipped");
|
|
202
|
+
const parts = [`\uBC84\uD2BC ${results.length}\uAC1C`];
|
|
203
|
+
if (dead.length > 0) parts.push(`\uBC18\uC751 \uC5C6\uC74C ${dead.length}(${dead.join(", ")})`);
|
|
204
|
+
if (errors.length > 0) parts.push(`\uC5D0\uB7EC ${errors.length}(${errors.join(", ")})`);
|
|
205
|
+
if (skipped.length > 0) parts.push(`\uAC74\uB108\uB700 ${skipped.length}`);
|
|
206
|
+
return parts.join(" \xB7 ");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export {
|
|
210
|
+
listActionsScript,
|
|
211
|
+
linkClickScript,
|
|
212
|
+
fillScript,
|
|
213
|
+
hasForbiddenWord,
|
|
214
|
+
buttonCheckLimits,
|
|
215
|
+
screenTextsScript,
|
|
216
|
+
checkButtons,
|
|
217
|
+
summarizeButtons,
|
|
218
|
+
stepLabels,
|
|
219
|
+
stepTarget,
|
|
220
|
+
isPrdName,
|
|
221
|
+
stepsOf,
|
|
222
|
+
validateGenerated
|
|
223
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifyWithFigma,
|
|
3
|
+
findingLabels,
|
|
4
|
+
screenMatches,
|
|
5
|
+
shortHash,
|
|
6
|
+
squash
|
|
7
|
+
} from "./chunk-F6PZOBU6.js";
|
|
8
|
+
|
|
9
|
+
// packages/core/src/policy/compare.ts
|
|
10
|
+
var isIgnored = (ignores, key, hash) => ignores.some((item) => item.key === key && item.hash === hash);
|
|
11
|
+
var rulesForScreen = (rules, path) => rules.filter((rule) => rule.screens.some((pattern) => screenMatches(pattern, path)));
|
|
12
|
+
var screenTextHash = (text) => shortHash(squash(text).replace(/\d[\d,.:]*/g, "#"));
|
|
13
|
+
var inFigma = (figmaTexts, expected) => figmaTexts && figmaTexts.length > 0 ? figmaTexts.map(squash).join("\0").includes(squash(expected)) : null;
|
|
14
|
+
var failed = (rule, message, context) => {
|
|
15
|
+
const figma = inFigma(context.figmaTexts, rule.expected ?? "");
|
|
16
|
+
const finding = classifyWithFigma(rule, figma, context.screenChanged, context.now);
|
|
17
|
+
const side = figma === null ? "" : figma ? " \uD53C\uADF8\uB9C8\uC5D0\uB294 \uC788\uC2B5\uB2C8\uB2E4." : " \uD53C\uADF8\uB9C8\uC5D0\uB3C4 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
18
|
+
message = `${message}${side}`;
|
|
19
|
+
const ignored = isIgnored(context.ignores, rule.id, rule.pageHash);
|
|
20
|
+
return { id: rule.id, title: rule.statement, ok: false, message: `[${findingLabels[finding]}] ${message} \xB7 \u300C${rule.pageTitle}\u300D`, finding, ignoreKey: rule.id, ignoreHash: rule.pageHash, ignored, pageId: rule.pageId };
|
|
21
|
+
};
|
|
22
|
+
function compareCopyRules(rules, context) {
|
|
23
|
+
const screenText = squash(context.text);
|
|
24
|
+
return rulesForScreen(rules, context.path).filter((rule) => rule.kind !== "behavior" && rule.visibleOnOpen && rule.expected).map(
|
|
25
|
+
(rule) => screenText.includes(squash(rule.expected ?? "")) ? { id: rule.id, title: rule.statement, ok: true, ignoreKey: rule.id, ignoreHash: rule.pageHash, pageId: rule.pageId } : failed(rule, `\uD654\uBA74\uC5D0 \u300C${rule.expected}\u300D\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`, context)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
var situationalRules = (rules, path) => rulesForScreen(rules, path).filter((rule) => rule.kind !== "behavior" && !rule.visibleOnOpen);
|
|
29
|
+
var maxCopyDiff = 30;
|
|
30
|
+
var minCopyLength = 2;
|
|
31
|
+
var isStaticCopy = (text) => squash(text).length >= minCopyLength && !/\d/.test(text);
|
|
32
|
+
function diffFigmaCopy(figmaTexts, screenText) {
|
|
33
|
+
const screen = squash(screenText);
|
|
34
|
+
const figma = figmaTexts.map(squash).join("\0");
|
|
35
|
+
const missingOnScreen = [...new Set(figmaTexts.map((text) => text.trim()).filter((text) => isStaticCopy(text) && !screen.includes(squash(text))))];
|
|
36
|
+
const notInFigma = [...new Set(screenText.split("\n").map((line) => line.trim()).filter((line) => isStaticCopy(line) && !figma.includes(squash(line))))];
|
|
37
|
+
return { missingOnScreen: missingOnScreen.slice(0, maxCopyDiff), notInFigma: notInFigma.slice(0, maxCopyDiff) };
|
|
38
|
+
}
|
|
39
|
+
function figmaChecks(diff, figmaHash, path, ignores) {
|
|
40
|
+
const toCheck = (text, side) => {
|
|
41
|
+
const key = `figma:${path}:${side}:${squash(text)}`;
|
|
42
|
+
const message = side === "missing" ? "\uD53C\uADF8\uB9C8\uC5D0 \uC788\uB294\uB370 \uD654\uBA74\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4." : "\uD654\uBA74\uC5D0 \uC788\uB294\uB370 \uD53C\uADF8\uB9C8\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
43
|
+
return { id: key, title: `\uD53C\uADF8\uB9C8 \uBB38\uAD6C \u300C${text}\u300D`, ok: false, message: `[\uD53C\uADF8\uB9C8 \uCC28\uC774] ${message}`, ignoreKey: key, ignoreHash: figmaHash, ignored: isIgnored(ignores, key, figmaHash) };
|
|
44
|
+
};
|
|
45
|
+
return diff.notInFigma.map((text) => toCheck(text, "extra"));
|
|
46
|
+
}
|
|
47
|
+
function docsMismatchChecks(rules, figmaTexts, screenText, ignores) {
|
|
48
|
+
if (figmaTexts.length === 0) return [];
|
|
49
|
+
const figma = figmaTexts.map(squash).join("\0");
|
|
50
|
+
const screen = squash(screenText);
|
|
51
|
+
return rules.filter((rule) => rule.kind === "copy" && rule.expected && screen.includes(squash(rule.expected)) && !figma.includes(squash(rule.expected))).map((rule) => {
|
|
52
|
+
const key = `docs:${rule.id}`;
|
|
53
|
+
return { id: key, title: rule.statement, ok: false, message: `[${findingLabels["docs-mismatch"]}] \uC815\uCC45\xB7\uC571 \uBB38\uAD6C \u300C${rule.expected}\u300D\uAC00 \uD53C\uADF8\uB9C8\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4. \xB7 \u300C${rule.pageTitle}\u300D`, finding: "docs-mismatch", ignoreKey: key, ignoreHash: rule.pageHash, ignored: isIgnored(ignores, key, rule.pageHash), pageId: rule.pageId };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
var hasBugCandidate = (checks) => checks.some((check) => !check.ok && !check.ignored && check.finding === "bug-candidate");
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
isIgnored,
|
|
60
|
+
rulesForScreen,
|
|
61
|
+
screenTextHash,
|
|
62
|
+
compareCopyRules,
|
|
63
|
+
situationalRules,
|
|
64
|
+
diffFigmaCopy,
|
|
65
|
+
figmaChecks,
|
|
66
|
+
docsMismatchChecks,
|
|
67
|
+
hasBugCandidate
|
|
68
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import {
|
|
2
|
+
diffSnapshots,
|
|
3
|
+
emptySnapshot
|
|
4
|
+
} from "./chunk-F6PZOBU6.js";
|
|
5
|
+
|
|
6
|
+
// packages/engine/src/policy/claude.ts
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
var claudeTimeoutMs = 5 * 6e4;
|
|
9
|
+
var ClaudeError = class extends Error {
|
|
10
|
+
};
|
|
11
|
+
var askClaude = (prompt, options = {}) => new Promise((resolve, reject) => {
|
|
12
|
+
const args = ["-p", "--output-format", "json", "--tools", options.allowRead ? "Read" : ""];
|
|
13
|
+
if (options.allowRead) args.push("--allowedTools", "Read");
|
|
14
|
+
const child = spawn("claude", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
15
|
+
let stdout = "";
|
|
16
|
+
let stderr = "";
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
child.kill("SIGTERM");
|
|
19
|
+
reject(new ClaudeError(`Claude \uC751\uB2F5\uC774 ${claudeTimeoutMs / 6e4}\uBD84 \uC548\uC5D0 \uC624\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.`));
|
|
20
|
+
}, claudeTimeoutMs);
|
|
21
|
+
child.stdout.on("data", (chunk) => stdout += chunk.toString("utf8"));
|
|
22
|
+
child.stderr.on("data", (chunk) => stderr += chunk.toString("utf8"));
|
|
23
|
+
child.on("error", (error) => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
reject(new ClaudeError(`claude \uC2E4\uD589 \uC2E4\uD328: ${error.message}`));
|
|
26
|
+
});
|
|
27
|
+
child.on("close", () => {
|
|
28
|
+
clearTimeout(timer);
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(stdout);
|
|
31
|
+
if (parsed.is_error || typeof parsed.result !== "string") throw new ClaudeError(parsed.result ?? "Claude \uC751\uB2F5\uC774 \uBE44\uC5C8\uC2B5\uB2C8\uB2E4.");
|
|
32
|
+
resolve(parsed.result);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
reject(error instanceof ClaudeError ? error : new ClaudeError(`Claude \uC751\uB2F5\uC744 \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4: ${(stderr || stdout).slice(0, 200)}`));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
child.stdin.end(prompt);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// packages/engine/src/policy/store.ts
|
|
41
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
42
|
+
import path from "path";
|
|
43
|
+
var sourcesFile = path.resolve("policy/sources.json");
|
|
44
|
+
var policyRoot = path.resolve("test-results/policy");
|
|
45
|
+
function readSources(file = sourcesFile) {
|
|
46
|
+
if (!existsSync(file)) return { pages: [] };
|
|
47
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
48
|
+
return { pages: Array.isArray(parsed.pages) ? parsed.pages : [] };
|
|
49
|
+
}
|
|
50
|
+
var snapshotFile = (root) => path.join(root, "snapshot.json");
|
|
51
|
+
var changesFile = (root) => path.join(root, "changes.jsonl");
|
|
52
|
+
function readSnapshot(root = policyRoot) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(readFileSync(snapshotFile(root), "utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
return emptySnapshot;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function recordSnapshot(next, root = policyRoot) {
|
|
60
|
+
const changes = diffSnapshots(readSnapshot(root), next);
|
|
61
|
+
mkdirSync(root, { recursive: true });
|
|
62
|
+
const temp = `${snapshotFile(root)}.tmp`;
|
|
63
|
+
writeFileSync(temp, `${JSON.stringify(next, null, 2)}
|
|
64
|
+
`);
|
|
65
|
+
renameSync(temp, snapshotFile(root));
|
|
66
|
+
const lines = changes.map((change) => {
|
|
67
|
+
const record = { at: next.checkedAt, kind: change.kind, id: change.page.id, title: change.page.title, version: change.page.version, fromVersion: change.before?.version, hash: change.page.hash };
|
|
68
|
+
return JSON.stringify(record);
|
|
69
|
+
});
|
|
70
|
+
if (lines.length > 0) appendFileSync(changesFile(root), `${lines.join("\n")}
|
|
71
|
+
`);
|
|
72
|
+
return changes;
|
|
73
|
+
}
|
|
74
|
+
function readChanges(limit, root = policyRoot) {
|
|
75
|
+
if (!existsSync(changesFile(root))) return [];
|
|
76
|
+
const records = readFileSync(changesFile(root), "utf8").split("\n").filter(Boolean).flatMap((line) => {
|
|
77
|
+
try {
|
|
78
|
+
return [JSON.parse(line)];
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return records.slice(-limit).reverse();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// packages/engine/src/policy/rules-store.ts
|
|
87
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
88
|
+
import path2 from "path";
|
|
89
|
+
var ignoredFile = path2.resolve("policy/ignored.json");
|
|
90
|
+
var rulesDir = (root) => path2.join(root, "rules");
|
|
91
|
+
var figmaFile = (root) => path2.join(root, "figma.json");
|
|
92
|
+
var judgeFile = (root) => path2.join(root, "judge-cache.json");
|
|
93
|
+
var screensFile = (root) => path2.join(root, "screens.json");
|
|
94
|
+
function readJson(file, fallback) {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(readFileSync2(file, "utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function writeJsonFile(file, value) {
|
|
102
|
+
mkdirSync2(path2.dirname(file), { recursive: true });
|
|
103
|
+
const temp = `${file}.tmp`;
|
|
104
|
+
writeFileSync2(temp, `${JSON.stringify(value, null, 2)}
|
|
105
|
+
`);
|
|
106
|
+
renameSync2(temp, file);
|
|
107
|
+
}
|
|
108
|
+
function readPageRules(pageId, root = policyRoot) {
|
|
109
|
+
return readJson(path2.join(rulesDir(root), `${pageId}.json`), null);
|
|
110
|
+
}
|
|
111
|
+
function writePageRules(rules, root = policyRoot) {
|
|
112
|
+
writeJsonFile(path2.join(rulesDir(root), `${rules.pageId}.json`), rules);
|
|
113
|
+
}
|
|
114
|
+
function removePageRules(pageId, root = policyRoot) {
|
|
115
|
+
rmSync(path2.join(rulesDir(root), `${pageId}.json`), { force: true });
|
|
116
|
+
}
|
|
117
|
+
function readAllPageRules(root = policyRoot) {
|
|
118
|
+
if (!existsSync2(rulesDir(root))) return [];
|
|
119
|
+
return readdirSync(rulesDir(root)).filter((name) => name.endsWith(".json")).flatMap((name) => {
|
|
120
|
+
const rules = readJson(path2.join(rulesDir(root), name), null);
|
|
121
|
+
return rules ? [rules] : [];
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function readIgnores(file = ignoredFile) {
|
|
125
|
+
const parsed = readJson(file, {});
|
|
126
|
+
return Array.isArray(parsed.items) ? parsed.items : [];
|
|
127
|
+
}
|
|
128
|
+
function addIgnore(item, file = ignoredFile) {
|
|
129
|
+
const items = readIgnores(file).filter((existing) => !(existing.key === item.key && existing.hash === item.hash));
|
|
130
|
+
writeJsonFile(file, { items: [...items, item] });
|
|
131
|
+
}
|
|
132
|
+
function removeIgnore(key, hash, file = ignoredFile) {
|
|
133
|
+
writeJsonFile(file, { items: readIgnores(file).filter((item) => !(item.key === key && item.hash === hash)) });
|
|
134
|
+
}
|
|
135
|
+
var readFigmaCache = (root = policyRoot) => readJson(figmaFile(root), {});
|
|
136
|
+
var writeFigmaCache = (cache, root = policyRoot) => writeJsonFile(figmaFile(root), cache);
|
|
137
|
+
var readJudgeCache = (root = policyRoot) => readJson(judgeFile(root), {});
|
|
138
|
+
var writeJudgeCache = (cache, root = policyRoot) => writeJsonFile(judgeFile(root), cache);
|
|
139
|
+
var readScreenHashes = (root = policyRoot) => readJson(screensFile(root), {});
|
|
140
|
+
var writeScreenHashes = (hashes, root = policyRoot) => writeJsonFile(screensFile(root), hashes);
|
|
141
|
+
|
|
142
|
+
// packages/engine/src/policy/front-figma.ts
|
|
143
|
+
import { execFileSync } from "child_process";
|
|
144
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
145
|
+
import path3 from "path";
|
|
146
|
+
|
|
147
|
+
// packages/core/src/policy/front-figma.ts
|
|
148
|
+
function routeOfFile(file, appDir = "app") {
|
|
149
|
+
const prefix = appDir.split("/").filter(Boolean);
|
|
150
|
+
const all = file.split("/");
|
|
151
|
+
if (prefix.some((part, index) => all[index] !== part)) return null;
|
|
152
|
+
const parts = ["app", ...all.slice(prefix.length)];
|
|
153
|
+
const route = [];
|
|
154
|
+
for (const part of parts.slice(1, -1)) {
|
|
155
|
+
if (part.startsWith("_") || part.startsWith("@")) break;
|
|
156
|
+
if (part.startsWith("(") && part.endsWith(")")) continue;
|
|
157
|
+
route.push(part.startsWith("[") ? "*" : part);
|
|
158
|
+
}
|
|
159
|
+
return `/${route.join("/")}`;
|
|
160
|
+
}
|
|
161
|
+
var linkPattern = /figma\.com\/(?:design|file|proto)\/([A-Za-z0-9]+)[^"'`\s<>]*?node-id=([0-9]+)(?:-|%3A|:)([0-9]+)/g;
|
|
162
|
+
var commentPattern = /Figma\s+([0-9]+):([0-9]+)/g;
|
|
163
|
+
function parseGrepLine(line, ref) {
|
|
164
|
+
const body = line.startsWith(`${ref}:`) ? line.slice(ref.length + 1) : line;
|
|
165
|
+
const match = /^([^:]+):\d+:(.*)$/.exec(body);
|
|
166
|
+
return match ? { file: match[1], text: match[2] } : null;
|
|
167
|
+
}
|
|
168
|
+
function buildFrontFigmaMap(lines, appDir = "app") {
|
|
169
|
+
const links = /* @__PURE__ */ new Map();
|
|
170
|
+
const comments = /* @__PURE__ */ new Map();
|
|
171
|
+
const keyCount = /* @__PURE__ */ new Map();
|
|
172
|
+
const add = (map, route, value) => map.set(route, (map.get(route) ?? /* @__PURE__ */ new Set()).add(value));
|
|
173
|
+
for (const { file, text } of lines) {
|
|
174
|
+
const route = routeOfFile(file, appDir);
|
|
175
|
+
if (!route) continue;
|
|
176
|
+
for (const match of text.matchAll(linkPattern)) {
|
|
177
|
+
add(links, route, `${match[1]}:${match[2]}:${match[3]}`);
|
|
178
|
+
const counts = keyCount.get(route) ?? /* @__PURE__ */ new Map();
|
|
179
|
+
keyCount.set(route, counts.set(match[1], (counts.get(match[1]) ?? 0) + 1));
|
|
180
|
+
}
|
|
181
|
+
for (const match of text.matchAll(commentPattern)) add(comments, route, `${match[1]}:${match[2]}`);
|
|
182
|
+
}
|
|
183
|
+
const topKey = (counts) => [...(counts ?? /* @__PURE__ */ new Map()).entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
184
|
+
const allCounts = /* @__PURE__ */ new Map();
|
|
185
|
+
for (const counts of keyCount.values()) for (const [key, count] of counts) allCounts.set(key, (allCounts.get(key) ?? 0) + count);
|
|
186
|
+
const fallbackKey = topKey(allCounts);
|
|
187
|
+
const result = {};
|
|
188
|
+
for (const route of /* @__PURE__ */ new Set([...links.keys(), ...comments.keys()])) {
|
|
189
|
+
const refs = new Set(links.get(route));
|
|
190
|
+
const key = topKey(keyCount.get(route)) ?? fallbackKey;
|
|
191
|
+
if (key) for (const node of comments.get(route) ?? []) refs.add(`${key}:${node}`);
|
|
192
|
+
if (refs.size > 0) result[route] = [...refs].sort();
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// packages/engine/src/policy/front-figma.ts
|
|
198
|
+
var defaultFrontRef = "origin/main";
|
|
199
|
+
var grepMaxBuffer = 32 * 1024 * 1024;
|
|
200
|
+
var mapFile = (root) => path3.join(root, "front-figma.json");
|
|
201
|
+
function readFrontFigmaMap(root = policyRoot) {
|
|
202
|
+
if (!existsSync3(mapFile(root))) return null;
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(readFileSync3(mapFile(root), "utf8"));
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function buildFrontFigma(frontRoot, ref = defaultFrontRef, now = /* @__PURE__ */ new Date(), root = policyRoot, appDir = "app") {
|
|
210
|
+
const git = (...args) => execFileSync("git", ["-C", frontRoot, ...args], { encoding: "utf8", maxBuffer: grepMaxBuffer });
|
|
211
|
+
const [remote, ...branch] = ref.split("/");
|
|
212
|
+
try {
|
|
213
|
+
git("fetch", "-q", remote, branch.join("/"));
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
const commit = git("rev-parse", "--short", ref).trim();
|
|
217
|
+
let output = "";
|
|
218
|
+
try {
|
|
219
|
+
output = git("grep", "-n", "-E", "figma\\.com/(design|file|proto)/|Figma [0-9]+:[0-9]+", ref, "--", appDir);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.status !== 1) throw error;
|
|
222
|
+
}
|
|
223
|
+
const lines = output.split("\n").map((line) => parseGrepLine(line, ref)).filter((line) => line !== null);
|
|
224
|
+
const map = { ref, commit, builtAt: now.toISOString(), routes: buildFrontFigmaMap(lines, appDir) };
|
|
225
|
+
writeJsonFile(mapFile(root), map);
|
|
226
|
+
return map;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// packages/server/src/scenarios.ts
|
|
230
|
+
function farmScenarios(app) {
|
|
231
|
+
const excluded = new Set(app.excludedScenarioPaths);
|
|
232
|
+
return app.scenarios.filter((scenario) => !excluded.has(scenario.path));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export {
|
|
236
|
+
askClaude,
|
|
237
|
+
policyRoot,
|
|
238
|
+
readSources,
|
|
239
|
+
readSnapshot,
|
|
240
|
+
recordSnapshot,
|
|
241
|
+
readChanges,
|
|
242
|
+
readPageRules,
|
|
243
|
+
writePageRules,
|
|
244
|
+
removePageRules,
|
|
245
|
+
readAllPageRules,
|
|
246
|
+
readIgnores,
|
|
247
|
+
addIgnore,
|
|
248
|
+
removeIgnore,
|
|
249
|
+
readFigmaCache,
|
|
250
|
+
writeFigmaCache,
|
|
251
|
+
readJudgeCache,
|
|
252
|
+
writeJudgeCache,
|
|
253
|
+
readScreenHashes,
|
|
254
|
+
writeScreenHashes,
|
|
255
|
+
readFrontFigmaMap,
|
|
256
|
+
buildFrontFigma,
|
|
257
|
+
farmScenarios
|
|
258
|
+
};
|