@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +195 -2
  4. package/dist/bin.js +79 -0
  5. package/dist/chaos-CIB7G5X5.js +453 -0
  6. package/dist/chunk-3GCH6TN3.js +86 -0
  7. package/dist/chunk-4UOXXMXL.js +74 -0
  8. package/dist/chunk-7OJDY276.js +16 -0
  9. package/dist/chunk-AVECSGEH.js +1462 -0
  10. package/dist/chunk-AXQNUPGC.js +416 -0
  11. package/dist/chunk-F4TMB4WY.js +54 -0
  12. package/dist/chunk-F6PZOBU6.js +167 -0
  13. package/dist/chunk-JD2L6T7E.js +56 -0
  14. package/dist/chunk-JZDYF3HL.js +169 -0
  15. package/dist/chunk-NJNTEWXE.js +134 -0
  16. package/dist/chunk-NLX3V7ZJ.js +134 -0
  17. package/dist/chunk-OUNLQI2N.js +34 -0
  18. package/dist/chunk-P2M55S53.js +110 -0
  19. package/dist/chunk-PLMSQ5KF.js +173 -0
  20. package/dist/chunk-PYZWMVJ6.js +302 -0
  21. package/dist/chunk-PZZQWRSU.js +283 -0
  22. package/dist/chunk-Q6CXF3NJ.js +102 -0
  23. package/dist/chunk-S6GHOP4B.js +111 -0
  24. package/dist/chunk-SWW42KTA.js +223 -0
  25. package/dist/chunk-UAHWEUDI.js +68 -0
  26. package/dist/chunk-VGPCTMUA.js +258 -0
  27. package/dist/chunk-WTTCWFOX.js +161 -0
  28. package/dist/chunk-ZE2GGXFO.js +169 -0
  29. package/dist/device-explore-PHS6SVEJ.js +64 -0
  30. package/dist/device-run-XPNRYXEL.js +60 -0
  31. package/dist/explore-allowlist-F44YQDVK.js +44 -0
  32. package/dist/index.d.ts +192 -0
  33. package/dist/index.js +24 -0
  34. package/dist/init-QFFEOJWY.js +29 -0
  35. package/dist/policy-check-AUWGFNHZ.js +75 -0
  36. package/dist/policy-watch-QPHI2P7Z.js +66 -0
  37. package/dist/qa-run-5LLQMLAM.js +59 -0
  38. package/dist/record-EGHVQFR7.js +152 -0
  39. package/dist/serve-XT32MAEN.js +1687 -0
  40. package/dist/server-3NM7ODUG.js +251 -0
  41. package/dist/templates/env.example +48 -0
  42. package/dist/templates/farm.config.ts +104 -0
  43. package/dist/web/assets/index-BCuSKs27.js +17 -0
  44. package/dist/web/assets/index-C3is0x-E.css +1 -0
  45. package/dist/web/favicon.svg +1 -0
  46. package/dist/web/index.html +14 -0
  47. package/package.json +95 -4
@@ -0,0 +1,192 @@
1
+ /**
2
+ * 화면 데이터 QA: 화면이 부르는 GET API 응답과 화면 글자로 "값이 맞게 조회·표시됐는가"를 본다.
3
+ * 사람 QA가 하던 확인(합계가 맞는지, 받은 값이 화면에 그대로 보이는지)을 규칙으로 적는다. 판정에 LLM을 쓰지 않는다.
4
+ * 화면·규칙 목록은 앱 설정(`AppConfig.qa.screens`)에서 받는다. 여기는 틀과 도우미만 둔다.
5
+ * 브라우저에서도 읽을 수 있게 node 모듈을 쓰지 않는다.
6
+ */
7
+ /** 화면 하나가 조회하는 API. `{userId}`는 로그인 세션의 사용자 id로 바꾼다. */
8
+ type ScreenApis = Record<string, string>;
9
+ interface QaContext {
10
+ /** API 이름 → 파싱한 응답 본문 */
11
+ responses: Record<string, unknown>;
12
+ /** 화면에 보이는 글자(document.body.innerText) */
13
+ text: string;
14
+ }
15
+ interface QaCheck {
16
+ id: string;
17
+ title: string;
18
+ ok: boolean;
19
+ message?: string;
20
+ }
21
+ interface QaRule {
22
+ id: string;
23
+ title: string;
24
+ /** 통과면 null, 아니면 사람이 읽을 사유 */
25
+ check(context: QaContext): string | null;
26
+ }
27
+ interface QaScreen {
28
+ path: string;
29
+ name: string;
30
+ apis: ScreenApis;
31
+ rules: QaRule[];
32
+ }
33
+ /** 원 단위 금액 표기: 989946088 → "989,946,088" */
34
+ declare function formatWon(value: number): string;
35
+ declare function textHas(text: string, expected: string): boolean;
36
+ /** 응답에서 점 경로로 값을 꺼낸다. 없으면 undefined. */
37
+ declare function pick(source: unknown, path: string): unknown;
38
+ /** 금액 비교 규칙에서 쓰는 사유 문장. 같으면 null */
39
+ declare const equalWon: (label: string, actual: number, expected: number) => string | null;
40
+ /** 응답 점 경로의 숫자. 숫자가 아니면 throw해 규칙 실패 사유가 된다 */
41
+ declare function numberAt(context: QaContext, api: string, path: string): number;
42
+
43
+ type BuildKind = "dev" | "prod";
44
+
45
+ /** 고정 주소 화면 하나. 목록은 앱 설정(`AppConfig.scenarios`)에서 받는다. */
46
+ type ScreenScenario = {
47
+ path: string;
48
+ name: string;
49
+ when: string;
50
+ then: string;
51
+ };
52
+
53
+ /**
54
+ * 점검 대상 앱 설정. 앱마다 다른 값(패키지 id·웹 주소·탭·금지어·시나리오…)은 전부 여기서만 받는다.
55
+ * 사용자 프로젝트 루트의 `farm.config.ts`(또는 `FARM_CONFIG` 경로)가 `defineAppConfig()`로 내보낸다.
56
+ * 브라우저(apps/web)도 읽으므로 node 모듈을 쓰지 않는다. 전역으로 들고 있지 않고 인자로 넘긴다.
57
+ */
58
+
59
+ type GuardMethod = "POST" | "PUT" | "PATCH" | "DELETE";
60
+ /** 가드가 금지어보다 먼저 통과시키는 쓰기 요청. `{x}`는 슬래시 없는 한 칸 */
61
+ interface AllowedRequest {
62
+ method: GuardMethod;
63
+ path: string;
64
+ }
65
+ /** 하단 탭·홈·뒤로처럼 화면 이동 규칙. 지도 빌더(브라우저)·드라이버·카오스가 같이 쓴다 */
66
+ interface AppNavigation {
67
+ /** 홈 탭을 누르면 도착하는 경로. 플로우의 시작점 */
68
+ homePath: string;
69
+ /** 하단 탭바(웹 링크)의 홈 탭 이름 */
70
+ homeTabName: string;
71
+ /** 하단 탭 이름 → 도착 경로. 홈 탭을 포함한다 */
72
+ tabPaths: Record<string, string>;
73
+ /** 앱 화면 상단 뒤로 버튼 이름(aria-label 등) */
74
+ inPageBackNames: string[];
75
+ }
76
+ interface GuardConfig {
77
+ /** 탐색·버튼 눌러보기·카오스가 누르지 않는 버튼 낱말(부분 일치, 공백 무시) */
78
+ forbiddenButtonWords: string[];
79
+ /** 플로우 스텝으로 쓸 수 없는 버튼 이름(정확히 일치) */
80
+ forbiddenClickNames: string[];
81
+ /** 허용 목록에 allow로 있어도 막는 쓰기 API 경로 */
82
+ forbiddenApiPatterns: RegExp[];
83
+ /** 앱이 켜질 때마다 보내는 요청(푸시 토큰 저장 등). 금지어보다 먼저 통과시킨다 */
84
+ pushTokenRequests: AllowedRequest[];
85
+ /** 카오스·탐색이 누르지 않는 광고·외부 링크 이름 */
86
+ avoidedNamePattern: RegExp;
87
+ /** 누르지 않고 나가기만 하는 경로(비밀번호·PIN) */
88
+ noPressPathPattern: RegExp;
89
+ }
90
+ interface ConfluencePolicySource {
91
+ /** api.atlassian.com/ex/confluence/<cloudId> 게이트웨이용 */
92
+ cloudId: string;
93
+ spaceKey: string;
94
+ /** 제목에 이 말이 든 페이지를 정책 정본으로 본다 */
95
+ titleQuery: string;
96
+ /** 사람이 열어 볼 위키 주소(`https://<site>.atlassian.net/wiki`) */
97
+ siteUrl: string;
98
+ }
99
+ interface FigmaConfig {
100
+ /** 화면 → 피그마 노드를 뽑을 프론트 체크아웃(Next.js App Router). 비면 `FLOW_PRD_ROOT` */
101
+ frontRepo?: string;
102
+ /** 앱이 실제로 배포되는 원격 브랜치 */
103
+ ref: string;
104
+ /** 라우트 폴더(App Router `app`) */
105
+ appDir: string;
106
+ }
107
+ type InstallSourceKind = "firebase" | "testflight";
108
+ interface InstallConfig {
109
+ /** 빌드를 어디서 받나. 비면 전부 firebase */
110
+ sources?: Partial<Record<"android" | "ios", Partial<Record<BuildKind, InstallSourceKind>>>>;
111
+ /** Firebase App Distribution 앱 id. 비면 `FIREBASE_APP_<PLATFORM>_<KIND>` 환경 변수 */
112
+ firebase?: Partial<Record<"android" | "ios", Partial<Record<BuildKind, string>>>>;
113
+ /** TestFlight 앱 목록에 보이는 dev 앱 이름(이전 빌드 설치에 쓴다) */
114
+ testflightDevAppName?: string;
115
+ }
116
+ interface AppConfig extends AppNavigation {
117
+ name: string;
118
+ /** Android 패키지 = iOS 번들 id. 플랫폼마다 다르면 `iosBundleIds`로 덮는다 */
119
+ appIds: Record<BuildKind, string>;
120
+ iosBundleIds?: Partial<Record<BuildKind, string>>;
121
+ /** 앱 WebView가 여는 웹 주소. 비우면 `FARM_WEB_ORIGIN_DEV`·`FARM_WEB_ORIGIN_PROD` */
122
+ web: Record<BuildKind, string>;
123
+ /** API 호스트가 웹과 다르면 넣는다. 탐색이 내부로 본다 */
124
+ apiOrigin: string;
125
+ /** 로그인 화면 경로. 여기로 튕기면 로그인이 풀린 것으로 본다 */
126
+ loginPaths: string[];
127
+ /** 화면에 보이면 앱 오류로 보는 문구 */
128
+ appErrorText: string;
129
+ /** 시나리오 경로 → `scheme://path` 딥링크. 경로가 `/`면 `scheme://<homeHost>` */
130
+ deepLink: {
131
+ scheme: string;
132
+ homeHost: string;
133
+ };
134
+ android: {
135
+ /** 앱 WebView가 사는 액티비티. 다른 액티비티(네이티브 화면)가 위면 뒤로 키로 닫는다. 비면 닫지 않는다 */
136
+ mainActivity?: string;
137
+ };
138
+ webview: {
139
+ /** 열린 스택 화면을 전부 닫는 페이지 스크립트(앱 브리지). 비면 닫지 않는다 */
140
+ stackResetScript?: string;
141
+ /** 탭 화면에 뜬 바텀시트·팝업 닫기 버튼 이름 */
142
+ sheetCloseName: string;
143
+ };
144
+ guard: GuardConfig;
145
+ /** 고정 주소 화면 점검 목록 */
146
+ scenarios: ScreenScenario[];
147
+ /** 앞 단계가 있어야 의미 있는 화면(완료·중간 단계). 점검에서 뺀다 */
148
+ excludedScenarioPaths: string[];
149
+ /** 화면 데이터 QA. `sessionPath`는 로그인 세션(`{ user: { id, accessToken } }`)을 주는 웹 경로 */
150
+ qa: {
151
+ screens: QaScreen[];
152
+ sessionPath: string;
153
+ };
154
+ policy: {
155
+ confluence?: ConfluencePolicySource;
156
+ };
157
+ figma: FigmaConfig;
158
+ install: InstallConfig;
159
+ ios: {
160
+ /** WDA 서명 팀. 비면 `IOS_TEAM_ID` */
161
+ teamId?: string;
162
+ /** WDA 번들 id. 비면 `IOS_WDA_BUNDLE_ID`, 그것도 비면 `com.example.WebDriverAgentRunner` */
163
+ wdaBundleId?: string;
164
+ };
165
+ /** 디바이스팜 화면에 들어올 수 있는 Tailscale 계정 도메인. 비면 로컬 접속만 */
166
+ access: {
167
+ allowedDomains: string[];
168
+ };
169
+ }
170
+ type DeepPartial<T> = {
171
+ [K in keyof T]?: T[K] extends RegExp | unknown[] ? T[K] : T[K] extends object ? DeepPartial<T[K]> : T[K];
172
+ };
173
+ /** `farm.config.ts`가 적는 모양. 필수는 이름·앱 id·홈·탭뿐이다 */
174
+ type AppConfigInput = Pick<AppConfig, "name" | "appIds" | "homePath" | "homeTabName" | "tabPaths"> & DeepPartial<Omit<AppConfig, "name" | "appIds" | "homePath" | "homeTabName" | "tabPaths" | "guard">> & {
175
+ /** 기본 금지어·금지 경로에 덧붙인다(기본을 지우지 않는다) */
176
+ guard?: Partial<Pick<GuardConfig, "pushTokenRequests" | "avoidedNamePattern" | "noPressPathPattern">> & {
177
+ extraForbiddenButtonWords?: string[];
178
+ extraForbiddenClickNames?: string[];
179
+ extraForbiddenApiPatterns?: RegExp[];
180
+ };
181
+ };
182
+ /** 되돌릴 수 없는 돈·계정 동작. 앱과 상관없이 누르지 않는다 (작성자 판단) */
183
+ declare const defaultForbiddenButtonWords: readonly ["구매", "결제", "매도", "매수", "송금", "출금", "탈퇴", "배송 신청", "주문", "환불", "해지", "삭제", "인출", "선물", "충전", "로그아웃", "공유하기", "Buy", "Pay", "Checkout", "Delete account", "Log out", "Sign out"];
184
+ declare const defaultForbiddenClickNames: readonly ["신청", "신청하기", "결제", "결제하기", "구매하기", "판매하기", "출금", "탈퇴", "탈퇴하기", "본인인증", "송금", "이체"];
185
+ /**
186
+ * 주문·결제·매도·송금·출금·계좌·탈퇴·배송 신청 경로. 부분 문자열, 대소문자 무시.
187
+ * 일부러 넓게 잡았다. 과하게 막히면 탐색이 덜 될 뿐 사고는 나지 않는다.
188
+ */
189
+ declare const defaultForbiddenApiPatterns: readonly RegExp[];
190
+ declare function defineAppConfig(input: AppConfigInput): AppConfig;
191
+
192
+ export { type AllowedRequest, type AppConfig, type AppConfigInput, type AppNavigation, type BuildKind, type ConfluencePolicySource, type FigmaConfig, type GuardConfig, type GuardMethod, type InstallConfig, type InstallSourceKind, type QaCheck, type QaContext, type QaRule, type QaScreen, type ScreenApis, type ScreenScenario, defaultForbiddenApiPatterns, defaultForbiddenButtonWords, defaultForbiddenClickNames, defineAppConfig, equalWon, formatWon, numberAt, pick, textHas };
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ import {
2
+ equalWon,
3
+ formatWon,
4
+ numberAt,
5
+ pick,
6
+ textHas
7
+ } from "./chunk-OUNLQI2N.js";
8
+ import {
9
+ defaultForbiddenApiPatterns,
10
+ defaultForbiddenButtonWords,
11
+ defaultForbiddenClickNames,
12
+ defineAppConfig
13
+ } from "./chunk-NJNTEWXE.js";
14
+ export {
15
+ defaultForbiddenApiPatterns,
16
+ defaultForbiddenButtonWords,
17
+ defaultForbiddenClickNames,
18
+ defineAppConfig,
19
+ equalWon,
20
+ formatWon,
21
+ numberAt,
22
+ pick,
23
+ textHas
24
+ };
@@ -0,0 +1,29 @@
1
+ // apps/cli/src/init.ts
2
+ import { copyFileSync, existsSync } from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ var templates = [
6
+ { from: "farm.config.ts", to: "farm.config.ts" },
7
+ { from: "env.example", to: ".env.example" }
8
+ ];
9
+ function init(cwd = process.cwd()) {
10
+ const dir = fileURLToPath(new URL("./templates", import.meta.url));
11
+ for (const template of templates) {
12
+ const target = path.join(cwd, template.to);
13
+ if (existsSync(target)) {
14
+ console.log(`\uAC74\uB108\uB700 ${template.to} (\uC774\uBBF8 \uC788\uC2B5\uB2C8\uB2E4)`);
15
+ continue;
16
+ }
17
+ copyFileSync(path.join(dir, template.from), target);
18
+ console.log(`\uB9CC\uB4E6 ${template.to}`);
19
+ }
20
+ console.log(`
21
+ \uB2E4\uC74C \uB2E8\uACC4
22
+ 1. farm.config.ts\uB97C \uB0B4 \uC571 \uAC12(appIds\xB7web\xB7homePath\xB7tabPaths\u2026)\uC73C\uB85C \uBC14\uAFC9\uB2C8\uB2E4.
23
+ 2. \uBE44\uBC00 \uAC12\uC774 \uD544\uC694\uD558\uBA74 .env.example\uC744 .env\uB85C \uBCF5\uC0AC\uD574 \uCC44\uC6C1\uB2C8\uB2E4.
24
+ 3. .gitignore\uC5D0 .env\xB7test-results/\uB97C \uB123\uC2B5\uB2C8\uB2E4.
25
+ 4. npx device-farm dev \uB85C \uD654\uBA74\uC744 \uC5FD\uB2C8\uB2E4.`);
26
+ }
27
+ export {
28
+ init
29
+ };
@@ -0,0 +1,75 @@
1
+ import {
2
+ checkPolicyScreen,
3
+ policyScreens
4
+ } from "./chunk-WTTCWFOX.js";
5
+ import {
6
+ askClaude,
7
+ farmScenarios,
8
+ readAllPageRules,
9
+ readFigmaCache,
10
+ readFrontFigmaMap,
11
+ readIgnores
12
+ } from "./chunk-VGPCTMUA.js";
13
+ import "./chunk-UAHWEUDI.js";
14
+ import {
15
+ findingLabels
16
+ } from "./chunk-F6PZOBU6.js";
17
+ import {
18
+ readScreenMap
19
+ } from "./chunk-F4TMB4WY.js";
20
+ import {
21
+ openDriver
22
+ } from "./chunk-PZZQWRSU.js";
23
+ import "./chunk-S6GHOP4B.js";
24
+ import {
25
+ listDevices
26
+ } from "./chunk-JZDYF3HL.js";
27
+ import {
28
+ cliApp
29
+ } from "./chunk-7OJDY276.js";
30
+ import {
31
+ execFileText
32
+ } from "./chunk-AXQNUPGC.js";
33
+ import "./chunk-NLX3V7ZJ.js";
34
+ import {
35
+ appIdFor
36
+ } from "./chunk-NJNTEWXE.js";
37
+
38
+ // apps/cli/src/policy-check.ts
39
+ function option(name) {
40
+ const index = process.argv.indexOf(`--${name}`);
41
+ return index >= 0 ? process.argv[index + 1] : void 0;
42
+ }
43
+ async function main() {
44
+ const app = await cliApp();
45
+ const deviceId = option("device");
46
+ const device = (await listDevices(execFileText, app.appIds.dev, app.iosBundleIds?.dev)).find((item) => item.id === deviceId);
47
+ if (!device) throw new Error(`\uC5F0\uACB0\uB41C \uAE30\uAE30\uAC00 \uC544\uB2D9\uB2C8\uB2E4: ${deviceId ?? "(--device \uC5C6\uC74C)"}`);
48
+ const edges = readScreenMap().edges;
49
+ const known = [.../* @__PURE__ */ new Set([...farmScenarios(app).map((scenario) => scenario.path), ...edges.map((edge) => edge.from)])];
50
+ const path = option("path");
51
+ const screens = policyScreens(readAllPageRules(), known, readFigmaCache(), readFrontFigmaMap()?.routes).filter((screen) => !path || screen.path === path);
52
+ if (screens.length === 0) throw new Error("\uC815\uCC45 \uADDC\uCE59\uC774 \uAC78\uB9B0 \uD654\uBA74\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
53
+ const driver = await openDriver(app, device, appIdFor(app, "dev", device.platform));
54
+ let bugs = 0;
55
+ try {
56
+ for (const screen of screens) {
57
+ const report = await checkPolicyScreen(driver, screen, { ask: askClaude, ignores: readIgnores() });
58
+ console.log(`
59
+ ${screen.path} \xB7 \uADDC\uCE59 ${screen.rules.length}\uAC1C \xB7 \uD53C\uADF8\uB9C8 \uBB38\uAD6C ${screen.figma?.texts.length ?? 0}\uAC1C${report.cached ? " \xB7 \uD310\uC815 \uCE90\uC2DC" : ""}`);
60
+ if (report.judgeError) console.log(` \uD310\uC815 \uC2E4\uD328: ${report.judgeError}`);
61
+ for (const check of report.checks.filter((item) => !item.ok)) {
62
+ console.log(` ${check.finding ? findingLabels[check.finding] : "\uD53C\uADF8\uB9C8 \uCC28\uC774"}${check.ignored ? "(\uBB34\uC2DC)" : ""} ${check.title} \u2014 ${check.message ?? ""}`);
63
+ if (check.finding === "bug-candidate" && !check.ignored) bugs += 1;
64
+ }
65
+ console.log(` \uD1B5\uACFC ${report.checks.filter((item) => item.ok && !item.message).length}\uAC1C \xB7 \uD310\uB2E8 \uBD88\uAC00 ${report.checks.filter((item) => item.ok && item.message).length}\uAC1C \xB7 \uC0C1\uD669 \uD544\uC694 ${report.situational}\uAC1C`);
66
+ }
67
+ } finally {
68
+ await driver.close();
69
+ }
70
+ process.exitCode = bugs > 0 ? 1 : 0;
71
+ }
72
+ main().catch((error) => {
73
+ console.error(error instanceof Error ? error.message : error);
74
+ process.exitCode = 1;
75
+ });
@@ -0,0 +1,66 @@
1
+ import {
2
+ confluenceConfigFromEnv,
3
+ extractPage,
4
+ fetchPolicySnapshot,
5
+ refreshFigma,
6
+ refreshFrontFigma
7
+ } from "./chunk-PYZWMVJ6.js";
8
+ import {
9
+ askClaude,
10
+ farmScenarios,
11
+ readPageRules,
12
+ readSources,
13
+ recordSnapshot
14
+ } from "./chunk-VGPCTMUA.js";
15
+ import {
16
+ isRecentlyChanged
17
+ } from "./chunk-F6PZOBU6.js";
18
+ import {
19
+ readScreenMap
20
+ } from "./chunk-F4TMB4WY.js";
21
+ import {
22
+ cliApp
23
+ } from "./chunk-7OJDY276.js";
24
+ import "./chunk-NLX3V7ZJ.js";
25
+ import "./chunk-NJNTEWXE.js";
26
+
27
+ // apps/cli/src/policy-watch.ts
28
+ var kindLabels = { added: "\uCD94\uAC00", changed: "\uBCC0\uACBD", removed: "\uC0AD\uC81C" };
29
+ function knownScreens(app) {
30
+ const edges = readScreenMap().edges;
31
+ return [.../* @__PURE__ */ new Set([...farmScenarios(app).map((scenario) => scenario.path), ...edges.map((edge) => edge.from), ...edges.flatMap((edge) => edge.to ? [edge.to] : [])])].sort();
32
+ }
33
+ async function extractOne(app, config, pageId) {
34
+ await extractPage(pageId, { config, ask: askClaude, fetchImpl: (url, init) => fetch(url, init), knownScreens: knownScreens(app), appName: app.name, log: console.log });
35
+ const rules = readPageRules(pageId);
36
+ for (const rule of rules?.rules ?? []) console.log(` ${rule.kind} ${rule.visibleOnOpen ? "\uC5F4\uBA74 \uBCF4\uC784" : "\uC0C1\uD669 \uD544\uC694"} ${rule.statement}${rule.expected ? ` \u300C${rule.expected}\u300D` : ""} \u2192 ${rule.screens.join(", ") || "\uD654\uBA74 \uC5C6\uC74C"}`);
37
+ for (const item of rules?.dropped ?? []) console.log(` \uBC84\uB9BC ${item.statement} \u2014 ${item.reason}`);
38
+ console.log(` \uD654\uBA74 ${rules?.screens.join(", ") || "\uC5C6\uC74C"} \xB7 \uD53C\uADF8\uB9C8 ${rules?.figma.join(", ") || "\uC5C6\uC74C"}`);
39
+ }
40
+ async function main() {
41
+ const app = await cliApp();
42
+ if (process.argv.includes("--figma")) {
43
+ refreshFrontFigma(app.figma, console.log);
44
+ return refreshFigma((url, init) => fetch(url, init), console.log);
45
+ }
46
+ const config = confluenceConfigFromEnv(app.policy.confluence);
47
+ if (!config) throw new Error("CONFLUENCE_EMAIL\xB7CONFLUENCE_API_TOKEN(.env)\uACFC policy.confluence(farm.config.ts)\uC758 cloudId\xB7spaceKey\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.");
48
+ const extractIndex = process.argv.indexOf("--extract");
49
+ if (extractIndex >= 0) return extractOne(app, config, process.argv[extractIndex + 1] ?? "");
50
+ const registered = readSources().pages.map((page) => page.pageId);
51
+ const { snapshot, failed } = await fetchPolicySnapshot(config, registered, (url, init) => fetch(url, init));
52
+ const changes = recordSnapshot(snapshot);
53
+ const pages = Object.values(snapshot.pages);
54
+ console.log(`\uC815\uCC45 ${pages.length}\uAC1C \uD655\uC778 (${snapshot.checkedAt}) \xB7 \uCD5C\uADFC 14\uC77C \uC548 \uC218\uC815 ${pages.filter((page) => isRecentlyChanged(page)).length}\uAC1C`);
55
+ if (changes.length === 0) console.log("\uBC14\uB010 \uC815\uCC45 \uC5C6\uC74C");
56
+ for (const change of changes) {
57
+ const version = change.before ? `v${change.before.version} \u2192 v${change.page.version}` : `v${change.page.version}`;
58
+ console.log(` ${kindLabels[change.kind]} ${change.page.title} (${version}, ${change.page.modifiedAt.slice(0, 10)}) ${change.page.webUrl}`);
59
+ }
60
+ for (const item of failed) console.log(` \uC77D\uAE30 \uC2E4\uD328 ${item.id}: ${item.message}`);
61
+ process.exitCode = failed.length > 0 ? 1 : 0;
62
+ }
63
+ main().catch((error) => {
64
+ console.error(error instanceof Error ? error.message : error);
65
+ process.exitCode = 1;
66
+ });
@@ -0,0 +1,59 @@
1
+ import {
2
+ runScreenQa
3
+ } from "./chunk-3GCH6TN3.js";
4
+ import {
5
+ openDriver
6
+ } from "./chunk-PZZQWRSU.js";
7
+ import "./chunk-OUNLQI2N.js";
8
+ import "./chunk-S6GHOP4B.js";
9
+ import {
10
+ listDevices
11
+ } from "./chunk-JZDYF3HL.js";
12
+ import {
13
+ cliApp
14
+ } from "./chunk-7OJDY276.js";
15
+ import {
16
+ execFileText
17
+ } from "./chunk-AXQNUPGC.js";
18
+ import {
19
+ apiOrigin
20
+ } from "./chunk-NLX3V7ZJ.js";
21
+ import {
22
+ appIdFor
23
+ } from "./chunk-NJNTEWXE.js";
24
+
25
+ // apps/cli/src/qa-run.ts
26
+ function option(name) {
27
+ const index = process.argv.indexOf(`--${name}`);
28
+ return index >= 0 ? process.argv[index + 1] : void 0;
29
+ }
30
+ async function main() {
31
+ const app = await cliApp();
32
+ const apiOrigin2 = apiOrigin(app);
33
+ if (!apiOrigin2) throw new Error("API \uC8FC\uC18C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. farm.config.ts\uC758 apiOrigin\uC774\uB098 FARM_API_ORIGIN\uC744 \uB123\uC5B4 \uC8FC\uC138\uC694.");
34
+ const deviceId = option("device");
35
+ const device = (await listDevices(execFileText, app.appIds.dev, app.iosBundleIds?.dev)).find((item) => item.id === deviceId);
36
+ if (!device) throw new Error(`\uC5F0\uACB0\uB41C \uAE30\uAE30\uAC00 \uC544\uB2D9\uB2C8\uB2E4: ${deviceId ?? "(--device \uC5C6\uC74C)"}`);
37
+ const path = option("path");
38
+ const screens = app.qa.screens.filter((screen) => !path || screen.path === path);
39
+ if (screens.length === 0) throw new Error(`QA \uADDC\uCE59\uC774 \uC5C6\uB294 \uD654\uBA74\uC785\uB2C8\uB2E4: ${path}`);
40
+ const driver = await openDriver(app, device, appIdFor(app, "dev", device.platform));
41
+ let failed = 0;
42
+ try {
43
+ for (const screen of screens) {
44
+ const report = await runScreenQa(driver, screen, { apiOrigin: apiOrigin2 });
45
+ console.log(`
46
+ ${report.name} (${report.path})`);
47
+ for (const api of report.apis) console.log(` API ${api.status} ${api.durationMs}ms ${api.path}${api.error ? ` \xB7 ${api.error}` : ""}`);
48
+ for (const check of report.checks) console.log(` ${check.ok ? "\uD1B5\uACFC" : "\uC2E4\uD328"} ${check.title}${check.message ? ` \u2014 ${check.message}` : ""}`);
49
+ failed += report.checks.filter((check) => !check.ok).length;
50
+ }
51
+ } finally {
52
+ await driver.close();
53
+ }
54
+ process.exitCode = failed > 0 ? 1 : 0;
55
+ }
56
+ main().catch((error) => {
57
+ console.error(error instanceof Error ? error.message : error);
58
+ process.exitCode = 1;
59
+ });
@@ -0,0 +1,152 @@
1
+ import {
2
+ buildRecording
3
+ } from "./chunk-JD2L6T7E.js";
4
+ import {
5
+ addCandidate,
6
+ mapRulesOf,
7
+ readReady
8
+ } from "./chunk-PLMSQ5KF.js";
9
+ import {
10
+ recordScreenMap
11
+ } from "./chunk-F4TMB4WY.js";
12
+ import {
13
+ screenTextsScript,
14
+ stepsOf
15
+ } from "./chunk-SWW42KTA.js";
16
+ import {
17
+ cliApp
18
+ } from "./chunk-7OJDY276.js";
19
+ import {
20
+ appPages,
21
+ clickTargetHelpers,
22
+ openAndroidDriver
23
+ } from "./chunk-AXQNUPGC.js";
24
+ import {
25
+ webOrigin
26
+ } from "./chunk-NLX3V7ZJ.js";
27
+ import "./chunk-NJNTEWXE.js";
28
+
29
+ // apps/cli/src/record.ts
30
+ import { createHash } from "crypto";
31
+
32
+ // packages/devices/src/recorder.ts
33
+ import { setTimeout as sleep } from "timers/promises";
34
+ var recordPollMs = 500;
35
+ var recordSettleMs = 1500;
36
+ var bufferKey = "__deviceFarmRecord";
37
+ var installRecorderScript = `(() => {
38
+ if (window.${bufferKey}) return true;
39
+ window.${bufferKey} = [];
40
+ ${clickTargetHelpers}
41
+ const squash = (value) => String(value || "").replace(/\\s+/g, " ").trim();
42
+ document.addEventListener("click", (event) => {
43
+ const el = event.target instanceof Element ? event.target.closest(selectors.button + ", " + selectors.link) : null;
44
+ if (!el) return;
45
+ const role = el.matches(selectors.link) ? "link" : "button";
46
+ const name = squash(el.tagName === "INPUT" ? el.value : nameOf(el));
47
+ if (name) window.${bufferKey}.push({ at: Date.now(), path: location.pathname, role, name });
48
+ }, true);
49
+ return true;
50
+ })()`;
51
+ var drainScript = `(window.${bufferKey} || []).splice(0)`;
52
+ async function startRecorder(driver, origin, now = Date.now) {
53
+ const startPath = await driver.currentPath();
54
+ const events = [];
55
+ const pending = [];
56
+ let lastPath = startPath;
57
+ let stopped = false;
58
+ const drainAll = async () => {
59
+ for (const page of appPages(driver.context, origin)) {
60
+ await page.evaluate(installRecorderScript).catch(() => void 0);
61
+ const clicks = await page.evaluate(drainScript).catch(() => []);
62
+ pending.push(...clicks);
63
+ }
64
+ pending.sort((a, b) => a.at - b.at);
65
+ };
66
+ const settle = async () => {
67
+ const click = pending[0];
68
+ if (!click || now() - click.at < recordSettleMs) return false;
69
+ pending.shift();
70
+ const to = await driver.currentPath().catch(() => lastPath);
71
+ const texts = await driver.evaluate(screenTextsScript).catch(() => []);
72
+ events.push({ type: "click", at: click.at, from: click.path, role: click.role, name: click.name, to, texts });
73
+ lastPath = to;
74
+ return true;
75
+ };
76
+ const tick = async () => {
77
+ await drainAll();
78
+ if (await settle()) return;
79
+ if (pending.length > 0) return;
80
+ const path = await driver.currentPath().catch(() => lastPath);
81
+ if (path !== lastPath) events.push({ type: "jump", at: now(), from: lastPath, to: path });
82
+ lastPath = path;
83
+ };
84
+ const loop = (async () => {
85
+ while (!stopped) {
86
+ await tick().catch(() => void 0);
87
+ await sleep(recordPollMs);
88
+ }
89
+ })();
90
+ return {
91
+ startPath,
92
+ count: () => events.length,
93
+ stop: async () => {
94
+ stopped = true;
95
+ await loop;
96
+ await drainAll().catch(() => void 0);
97
+ while (pending.length > 0) {
98
+ const wait = recordSettleMs - (now() - pending[0].at);
99
+ if (wait > 0) await sleep(wait);
100
+ await settle().catch(() => pending.shift());
101
+ }
102
+ return events;
103
+ }
104
+ };
105
+ }
106
+
107
+ // apps/cli/src/record.ts
108
+ var defaultSeconds = 300;
109
+ var recordIdHashLength = 8;
110
+ function option(name) {
111
+ const index = process.argv.indexOf(`--${name}`);
112
+ return index >= 0 ? process.argv[index + 1] : void 0;
113
+ }
114
+ async function main() {
115
+ const app = await cliApp();
116
+ const serial = option("device");
117
+ if (!serial) throw new Error("--device <Android serial>\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.");
118
+ const seconds = Number(option("seconds") ?? defaultSeconds);
119
+ const origin = webOrigin(app, "dev");
120
+ const driver = await openAndroidDriver(serial, app.appIds.dev, origin, app);
121
+ try {
122
+ if (!driver.goHome) throw new Error("\uD648\uC73C\uB85C \uAC08 \uC218 \uC5C6\uB294 \uB4DC\uB77C\uC774\uBC84\uC785\uB2C8\uB2E4.");
123
+ await driver.goHome();
124
+ const recorder = await startRecorder(driver, origin);
125
+ console.log(`\uAE30\uB85D \uC2DC\uC791 (${recorder.startPath}). ${seconds}\uCD08 \uB4A4 \uB610\uB294 Ctrl+C\uB85C \uB05D\uB0C5\uB2C8\uB2E4.`);
126
+ await new Promise((resolve) => {
127
+ const timer = setTimeout(resolve, seconds * 1e3);
128
+ process.once("SIGINT", () => (clearTimeout(timer), resolve()));
129
+ });
130
+ const events = await recorder.stop();
131
+ const result = buildRecording(events, recorder.startPath, mapRulesOf(app));
132
+ for (const screen of result.screens) recordScreenMap(screen.from, screen.buttons);
133
+ console.log(`
134
+ \uC774\uBCA4\uD2B8 ${events.length}\uAC1C \xB7 \uC9C0\uB3C4 \uD654\uBA74 ${result.screens.length}\uAC1C\uC5D0 \uD569\uCE68`);
135
+ for (const event of events) console.log(event.type === "click" ? ` \uB204\uB974\uAE30 ${event.from} \u300C${event.name}\u300D \u2192 ${event.to}${event.texts[0] ? ` (${event.texts[0]})` : ""}` : ` \uC774\uB3D9 ${event.from} \u2192 ${event.to}`);
136
+ if (result.cut) console.log(`\uC2A4\uD15D\uC744 \uC5EC\uAE30\uC11C \uB04A\uC74C: ${result.cut}`);
137
+ const steps = stepsOf(result.steps, app.guard.forbiddenClickNames);
138
+ if (typeof steps === "string") return console.log(`\uD50C\uB85C\uC6B0 \uD6C4\uBCF4\uB85C \uC62C\uB9AC\uC9C0 \uC54A\uC74C: ${steps}`);
139
+ const id = `rec-${createHash("sha256").update(JSON.stringify(steps)).digest("hex").slice(0, recordIdHashLength)}`;
140
+ const name = option("name") ?? `\uAE30\uB85D ${(/* @__PURE__ */ new Date()).toLocaleString("ko-KR")}`;
141
+ const proposal = readReady().some((flow2) => flow2.id === id) ? "update" : "create";
142
+ const flow = { id, name, source: "recorded", status: "generated", prd: "\uC870\uC791 \uAE30\uB85D", requirement: `\uAE30\uB85D\uD55C \uC2A4\uD15D ${steps.length}\uAC1C`, proposal, steps };
143
+ addCandidate(flow);
144
+ console.log(`\uD50C\uB85C\uC6B0 \uD6C4\uBCF4 \u300C${name}\u300D(${id})\uB97C \uC62C\uB838\uC2B5\uB2C8\uB2E4. \uB514\uBC14\uC774\uC2A4\uD31C \uD50C\uB85C\uC6B0 \uD0ED\uC5D0\uC11C \uD655\uC815\uD558\uC138\uC694.`);
145
+ } finally {
146
+ await driver.close();
147
+ }
148
+ }
149
+ main().catch((error) => {
150
+ console.error(error instanceof Error ? error.message : error);
151
+ process.exitCode = 1;
152
+ });