@inspector-agent/verifier 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +55 -2
- package/dist/chunk-7XQGXD7A.js +228 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +109 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +12 -0
- package/package.json +53 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HamHyunJun
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @inspector-agent/verifier
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
헤드리스 Chromium(Playwright)으로 페이지를 **여러 뷰포트에서 스캔**해 레이아웃 결함을 규칙으로 탐지하고, 패치 전후를 **같은 상태에서 재측정**하는 검증기입니다. CI 에서 쓰는 `inspector-scan` CLI 를 포함합니다. [Inspector-Agent](https://github.com/HamHyunJun/Inspector-Agent) 의 일부입니다.
|
|
4
|
+
|
|
5
|
+
탐지 종류: 가로·세로 오버플로우(연쇄 결함 묶음), 말줄임 없는 텍스트 잘림, 팝업 가려짐(stacking context 원인), **모달 포커스 이탈**(실제 Tab/Shift+Tab 입력).
|
|
6
|
+
|
|
7
|
+
## 설치
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -D @inspector-agent/verifier
|
|
11
|
+
npx playwright-core@1.56.1 install chromium # 브라우저가 없다면 한 번 (또는 INSPECTOR_CHROMIUM_PATH 로 경로 지정)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Node 20 이상.
|
|
15
|
+
|
|
16
|
+
## CLI
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx inspector-scan http://localhost:5173 --viewports 375,768,1280 --fail-on high
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| 옵션 | 설명 |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `--viewports 375,768x1024` | 뷰포트 너비(또는 `너비x높이`) 목록. 기본 375, 768, 1280 |
|
|
25
|
+
| `--fail-on high\|medium\|low` | 해당 심각도 이상 결함이 하나라도 있으면 종료 코드 1 |
|
|
26
|
+
| `--summary 파일` | 마크다운 요약을 파일에 덧붙임. GitHub Actions 에서는 `"$GITHUB_STEP_SUMMARY"` |
|
|
27
|
+
| `--scenario 파일.json` | 스캔 전에 시드(localStorage/sessionStorage/쿠키)를 심고 단계(click/fill/wait)를 실행 |
|
|
28
|
+
| `--report http://localhost:3000` | 결과를 Inspector-Agent 대시보드로 전송 |
|
|
29
|
+
|
|
30
|
+
종료 코드: `0` 통과 · `1` 기준 초과 · `2` 사용법 오류 또는 스캔 실패.
|
|
31
|
+
|
|
32
|
+
GitHub Actions 예시:
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
- run: npx inspector-scan http://localhost:3000 --fail-on high --summary "$GITHUB_STEP_SUMMARY"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
데이터가 있어야 그려지는 화면이나 클릭해야 열리는 UI 는 [시나리오](https://github.com/HamHyunJun/Inspector-Agent/blob/main/docs/scenarios/README.md)로 스캔합니다. 소스 위치(`파일:라인`)까지 보려면 앱의 개발 서버에 [`@inspector-agent/vite-plugin`](https://www.npmjs.com/package/@inspector-agent/vite-plugin) 을 켜세요.
|
|
39
|
+
|
|
40
|
+
## API
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { scanPage, measureDefect } from '@inspector-agent/verifier'
|
|
44
|
+
|
|
45
|
+
const [mobile] = await scanPage('http://localhost:5173', [{ width: 375, height: 812 }])
|
|
46
|
+
const target = mobile.defects[0]
|
|
47
|
+
// 패치를 적용한 뒤, 같은 URL·뷰포트·시나리오에서 다시 재서 통과 여부를 판정
|
|
48
|
+
const result = await measureDefect(target, mobile.defects)
|
|
49
|
+
console.log(result.passed, result.verdict)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
통과 조건은 "대상 결함이 사라지고 새 결함이 없을 때"입니다. 가려짐은 비율이 0% 가 되어야 하고, 포커스 이탈은 두 방향 모두 막혀야 합니다.
|
|
53
|
+
|
|
54
|
+
## 라이선스
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { chromium } from "playwright-core";
|
|
5
|
+
import { DEFAULT_MIN_COVERED_RATIO, defectMagnitude, qualifyDefectId, scenarioKey } from "@inspector-agent/core";
|
|
6
|
+
|
|
7
|
+
// src/focus.ts
|
|
8
|
+
import { createDefectId, createFingerprint, diagnoseFocus } from "@inspector-agent/core";
|
|
9
|
+
var buildFocusDefect = (modal, result, context) => {
|
|
10
|
+
const fingerprint = createFingerprint("focus-escape", modal.node.source, modal.node.selector, context.viewport.width);
|
|
11
|
+
const metrics = {
|
|
12
|
+
kind: "focus-escape",
|
|
13
|
+
trigger: modal.trigger,
|
|
14
|
+
focusableCount: modal.focusableCount,
|
|
15
|
+
escapesForward: result.forward !== void 0,
|
|
16
|
+
escapesBackward: result.backward !== void 0,
|
|
17
|
+
tabsToEscape: result.forward?.presses,
|
|
18
|
+
escapedTo: result.forward?.escapedTo ?? result.backward?.escapedTo
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
id: createDefectId(context.url, fingerprint),
|
|
22
|
+
fingerprint,
|
|
23
|
+
kind: "focus-escape",
|
|
24
|
+
// 키보드 사용자가 모달 뒤의 페이지를 조작하게 되므로 항상 높음
|
|
25
|
+
severity: "high",
|
|
26
|
+
url: context.url,
|
|
27
|
+
viewport: context.viewport,
|
|
28
|
+
node: modal.node,
|
|
29
|
+
parent: modal.parent,
|
|
30
|
+
metrics,
|
|
31
|
+
suggestions: diagnoseFocus(metrics),
|
|
32
|
+
detectedAt: context.detectedAt
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
var probeDirection = async (page, modal, key, maxPresses, sourceAttribute) => {
|
|
36
|
+
const focused = await page.evaluate((index) => window.__INSPECTOR_AGENT__?.focusFirstInModal(index) ?? false, modal.index);
|
|
37
|
+
if (!focused) return void 0;
|
|
38
|
+
for (let presses = 1; presses <= maxPresses; presses++) {
|
|
39
|
+
await page.keyboard.press(key);
|
|
40
|
+
const state = await page.evaluate(
|
|
41
|
+
({ index, attribute }) => window.__INSPECTOR_AGENT__?.modalFocusState(index, { sourceAttribute: attribute }) ?? { inside: true },
|
|
42
|
+
{ index: modal.index, attribute: sourceAttribute }
|
|
43
|
+
);
|
|
44
|
+
if (!state.inside) return { presses, state };
|
|
45
|
+
}
|
|
46
|
+
return void 0;
|
|
47
|
+
};
|
|
48
|
+
var scanFocusEscapes = async (page, url, viewport, detect = {}) => {
|
|
49
|
+
const modals = await page.evaluate(
|
|
50
|
+
(options) => window.__INSPECTOR_AGENT__?.findModals(options) ?? [],
|
|
51
|
+
{ sourceAttribute: detect.sourceAttribute }
|
|
52
|
+
);
|
|
53
|
+
const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
54
|
+
const defects = [];
|
|
55
|
+
for (const modal of modals) {
|
|
56
|
+
const forward = await probeDirection(page, modal, "Tab", modal.focusableCount + 1, detect.sourceAttribute);
|
|
57
|
+
const backward = await probeDirection(page, modal, "Shift+Tab", 1, detect.sourceAttribute);
|
|
58
|
+
if (!forward && !backward) continue;
|
|
59
|
+
defects.push(
|
|
60
|
+
buildFocusDefect(
|
|
61
|
+
modal,
|
|
62
|
+
{
|
|
63
|
+
forward: forward ? { presses: forward.presses, escapedTo: forward.state.active } : void 0,
|
|
64
|
+
backward: backward ? { escapedTo: backward.state.active } : void 0
|
|
65
|
+
},
|
|
66
|
+
{ url, viewport, detectedAt }
|
|
67
|
+
)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return defects;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// src/scenario.ts
|
|
74
|
+
import { toStorageString } from "@inspector-agent/core";
|
|
75
|
+
var STEP_TIMEOUT_MS = 5e3;
|
|
76
|
+
var seedContext = async (context, url, scenario) => {
|
|
77
|
+
const seed = scenario.seed;
|
|
78
|
+
if (!seed) return;
|
|
79
|
+
if (seed.cookies?.length) {
|
|
80
|
+
const { hostname } = new URL(url);
|
|
81
|
+
await context.addCookies(
|
|
82
|
+
seed.cookies.map((cookie) => cookie.path ? { name: cookie.name, value: cookie.value, domain: hostname, path: cookie.path } : { name: cookie.name, value: cookie.value, url })
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const toStrings = (values) => Object.fromEntries(Object.entries(values ?? {}).map(([key, value]) => [key, toStorageString(value)]));
|
|
86
|
+
const local = toStrings(seed.localStorage);
|
|
87
|
+
const session = toStrings(seed.sessionStorage);
|
|
88
|
+
if (Object.keys(local).length === 0 && Object.keys(session).length === 0) return;
|
|
89
|
+
await context.addInitScript(
|
|
90
|
+
({ local: localValues, session: sessionValues }) => {
|
|
91
|
+
const write = (storage, values) => {
|
|
92
|
+
for (const [key, value] of Object.entries(values)) storage.setItem(key, value);
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
write(window.localStorage, localValues);
|
|
96
|
+
write(window.sessionStorage, sessionValues);
|
|
97
|
+
} catch {
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
{ local, session }
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
var describeStep = (step) => {
|
|
104
|
+
if (step.action === "wait") return step.selector !== void 0 ? `wait ${step.selector}` : `wait ${step.ms}ms`;
|
|
105
|
+
return `${step.action} ${step.selector}`;
|
|
106
|
+
};
|
|
107
|
+
var runScenarioSteps = async (page, steps) => {
|
|
108
|
+
for (const [index, step] of steps.entries()) {
|
|
109
|
+
try {
|
|
110
|
+
if (step.action === "click") await page.click(step.selector, { timeout: STEP_TIMEOUT_MS });
|
|
111
|
+
else if (step.action === "fill") await page.fill(step.selector, step.value, { timeout: STEP_TIMEOUT_MS });
|
|
112
|
+
else if (step.selector !== void 0) await page.waitForSelector(step.selector, { state: "visible", timeout: STEP_TIMEOUT_MS });
|
|
113
|
+
else await page.waitForTimeout(step.ms ?? 0);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const reason = (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
116
|
+
throw new Error(`\uC2DC\uB098\uB9AC\uC624 steps[${index}] (${describeStep(step)}) \uC2E4\uD328: ${reason}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// src/index.ts
|
|
122
|
+
var DEFAULT_VIEWPORTS = [
|
|
123
|
+
{ width: 375, height: 812 },
|
|
124
|
+
{ width: 768, height: 1024 },
|
|
125
|
+
{ width: 1280, height: 800 }
|
|
126
|
+
];
|
|
127
|
+
var require2 = createRequire(import.meta.url);
|
|
128
|
+
var detectorSource;
|
|
129
|
+
var loadDetectorSource = async () => {
|
|
130
|
+
if (detectorSource) return detectorSource;
|
|
131
|
+
const path = require2.resolve("@inspector-agent/core/detector.global.js");
|
|
132
|
+
detectorSource = await readFile(path, "utf8");
|
|
133
|
+
return detectorSource;
|
|
134
|
+
};
|
|
135
|
+
var launch = async (options) => chromium.launch({
|
|
136
|
+
headless: true,
|
|
137
|
+
executablePath: options.executablePath ?? process.env.INSPECTOR_CHROMIUM_PATH ?? void 0
|
|
138
|
+
});
|
|
139
|
+
var scanWithBrowser = async (browser, url, viewport, options) => {
|
|
140
|
+
const context = await browser.newContext({ viewport });
|
|
141
|
+
try {
|
|
142
|
+
const { scenario } = options;
|
|
143
|
+
if (scenario) await seedContext(context, url, scenario);
|
|
144
|
+
const page = await context.newPage();
|
|
145
|
+
await page.goto(url, { waitUntil: "networkidle" });
|
|
146
|
+
await page.waitForTimeout(options.settleMs ?? 300);
|
|
147
|
+
if (scenario?.steps?.length) {
|
|
148
|
+
await runScenarioSteps(page, scenario.steps);
|
|
149
|
+
await page.waitForTimeout(options.settleMs ?? 300);
|
|
150
|
+
}
|
|
151
|
+
await page.addScriptTag({ content: await loadDetectorSource() });
|
|
152
|
+
const result = await page.evaluate((detectOptions) => {
|
|
153
|
+
const agent = window.__INSPECTOR_AGENT__;
|
|
154
|
+
if (!agent) throw new Error("Inspector-Agent \uD0D0\uC9C0\uAE30 \uC8FC\uC785\uC5D0 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4.");
|
|
155
|
+
return agent.detect(detectOptions);
|
|
156
|
+
}, options.detect ?? {});
|
|
157
|
+
const focusDefects = await scanFocusEscapes(page, result.url, viewport, options.detect);
|
|
158
|
+
const defects = [...result.defects, ...focusDefects];
|
|
159
|
+
if (!scenario) return { ...result, defects };
|
|
160
|
+
const key = scenarioKey(scenario);
|
|
161
|
+
return {
|
|
162
|
+
...result,
|
|
163
|
+
scenarioKey: key,
|
|
164
|
+
defects: defects.map((defect) => ({ ...defect, id: qualifyDefectId(defect.id, key), scenarioKey: key }))
|
|
165
|
+
};
|
|
166
|
+
} finally {
|
|
167
|
+
await context.close();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
var scanPage = async (url, viewports = DEFAULT_VIEWPORTS, options = {}) => {
|
|
171
|
+
const browser = await launch(options);
|
|
172
|
+
try {
|
|
173
|
+
const results = [];
|
|
174
|
+
for (const viewport of viewports) {
|
|
175
|
+
results.push(await scanWithBrowser(browser, url, viewport, options));
|
|
176
|
+
}
|
|
177
|
+
return results;
|
|
178
|
+
} finally {
|
|
179
|
+
await browser.close();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
var isSameDefect = (target, candidate) => candidate.fingerprint === target.fingerprint || candidate.node.selector === target.node.selector;
|
|
183
|
+
var MEASURE_UNIT = { "overflow-x": "px", "overflow-y": "px", "clipped-text": "px", occluded: "ratio", "focus-escape": "count" };
|
|
184
|
+
var SUBJECT = { "overflow-x": "\uC624\uBC84\uD50C\uB85C\uC6B0", "overflow-y": "\uC138\uB85C \uC624\uBC84\uD50C\uB85C\uC6B0", "clipped-text": "\uD14D\uC2A4\uD2B8 \uC798\uB9BC", occluded: "\uAC00\uB824\uC9D0", "focus-escape": "\uD3EC\uCEE4\uC2A4 \uC774\uD0C8" };
|
|
185
|
+
var describeMagnitude = (defect, value) => {
|
|
186
|
+
const unit = MEASURE_UNIT[defect.kind];
|
|
187
|
+
return unit === "ratio" ? `${Math.round(value * 100)}%` : unit === "count" ? `${value}\uBC29\uD5A5` : `${Math.round(value)}px`;
|
|
188
|
+
};
|
|
189
|
+
var measureDefect = async (target, baseline, options = {}) => {
|
|
190
|
+
const browser = await launch(options);
|
|
191
|
+
try {
|
|
192
|
+
const detect = target.kind === "occluded" ? { ...options.detect, minCoveredRatio: 0.01 } : options.detect;
|
|
193
|
+
const result = await scanWithBrowser(browser, target.url, target.viewport, { ...options, detect });
|
|
194
|
+
const remaining = result.defects.find((defect) => isSameDefect(target, defect));
|
|
195
|
+
const regressions = result.defects.filter(
|
|
196
|
+
(defect) => !isSameDefect(target, defect) && !baseline.some((known) => isSameDefect(known, defect)) && (defect.kind !== "occluded" || defect.metrics.coveredRatio >= DEFAULT_MIN_COVERED_RATIO)
|
|
197
|
+
);
|
|
198
|
+
const before = defectMagnitude(target);
|
|
199
|
+
const after = remaining ? defectMagnitude(remaining) : 0;
|
|
200
|
+
const passed = remaining === void 0 && regressions.length === 0;
|
|
201
|
+
const subject = SUBJECT[target.kind];
|
|
202
|
+
const verdict = passed ? `${subject} ${describeMagnitude(target, before)} \u2192 ${describeMagnitude(target, 0)}, \uC0C8 \uACB0\uD568 \uC5C6\uC74C` : remaining ? `${subject} ${describeMagnitude(target, before)} \u2192 ${describeMagnitude(target, after)}\uB85C \uB0A8\uC544 \uC788\uC2B5\uB2C8\uB2E4` : `\uB300\uC0C1 \uACB0\uD568\uC740 \uC0AC\uB77C\uC84C\uC9C0\uB9CC \uC0C8 \uACB0\uD568 ${regressions.length}\uAC74\uC774 \uC0DD\uACBC\uC2B5\uB2C8\uB2E4`;
|
|
203
|
+
return {
|
|
204
|
+
passed,
|
|
205
|
+
verdict,
|
|
206
|
+
stillPresent: remaining !== void 0,
|
|
207
|
+
unit: MEASURE_UNIT[target.kind],
|
|
208
|
+
beforePx: before,
|
|
209
|
+
afterPx: after,
|
|
210
|
+
regressions,
|
|
211
|
+
documentOverflowPx: result.documentOverflowPx,
|
|
212
|
+
measuredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
213
|
+
};
|
|
214
|
+
} finally {
|
|
215
|
+
await browser.close();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
var parseViewports = (value) => value.split(",").map((part) => part.trim()).filter((part) => part !== "").map((part) => {
|
|
219
|
+
const [width = "0", height] = part.split("x");
|
|
220
|
+
return { width: Number(width), height: height ? Number(height) : 900 };
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
export {
|
|
224
|
+
DEFAULT_VIEWPORTS,
|
|
225
|
+
scanPage,
|
|
226
|
+
measureDefect,
|
|
227
|
+
parseViewports
|
|
228
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_VIEWPORTS,
|
|
4
|
+
parseViewports,
|
|
5
|
+
scanPage
|
|
6
|
+
} from "./chunk-7XQGXD7A.js";
|
|
7
|
+
|
|
8
|
+
// src/cli.ts
|
|
9
|
+
import { appendFile, readFile } from "fs/promises";
|
|
10
|
+
import { formatMagnitude as formatMagnitude2, parseScenario } from "@inspector-agent/core";
|
|
11
|
+
|
|
12
|
+
// src/report.ts
|
|
13
|
+
import { formatMagnitude } from "@inspector-agent/core";
|
|
14
|
+
var SEVERITY_RANK = { low: 0, medium: 1, high: 2 };
|
|
15
|
+
var SEVERITY_LABEL = { high: "\u{1F534} high", medium: "\u{1F7E0} medium", low: "\u{1F7E1} low" };
|
|
16
|
+
var parseFailOn = (value) => {
|
|
17
|
+
if (value === void 0) return void 0;
|
|
18
|
+
if (value === "high" || value === "medium" || value === "low") return value;
|
|
19
|
+
throw new Error(`--fail-on \uC740 high, medium, low \uC911 \uD558\uB098\uC5EC\uC57C \uD569\uB2C8\uB2E4 (\uBC1B\uC740 \uAC12: ${value || "\uC5C6\uC74C"})`);
|
|
20
|
+
};
|
|
21
|
+
var defectsAtOrAbove = (results, threshold) => results.flatMap((result) => result.defects.filter((defect) => SEVERITY_RANK[defect.severity] >= SEVERITY_RANK[threshold]));
|
|
22
|
+
var cell = (text) => text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
23
|
+
var locationOf = (defect) => defect.node.source ? `${defect.node.source.file}:${defect.node.source.line}` : defect.node.selector;
|
|
24
|
+
var renderMarkdown = (url, results, failOn, scenarioName) => {
|
|
25
|
+
const total = results.reduce((sum, result) => sum + result.defects.length, 0);
|
|
26
|
+
const failing = failOn ? defectsAtOrAbove(results, failOn).length : 0;
|
|
27
|
+
const verdict = failOn ? failing > 0 ? `\u274C \uC2E4\uD328: \`${failOn}\` \uC774\uC0C1 ${failing}\uAC74` : `\u2705 \uD1B5\uACFC: \`${failOn}\` \uC774\uC0C1 \uC5C6\uC74C` : "(\uD1B5\uACFC/\uC2E4\uD328 \uAE30\uC900 \uC5C6\uC74C)";
|
|
28
|
+
const lines = ["## Inspector-Agent \uB808\uC774\uC544\uC6C3 \uC2A4\uCE94", "", `- \uB300\uC0C1: ${url}`, ...scenarioName ? [`- \uC2DC\uB098\uB9AC\uC624: ${scenarioName}`] : [], `- \uBDF0\uD3EC\uD2B8: ${results.map((result) => `${result.viewport.width}px`).join(", ")}`, `- \uACB0\uD568: ${total}\uAC74 \xB7 ${verdict}`];
|
|
29
|
+
if (total === 0) return `${lines.join("\n")}
|
|
30
|
+
|
|
31
|
+
\uACB0\uD568\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \u2705
|
|
32
|
+
`;
|
|
33
|
+
lines.push("", "| \uBDF0\uD3EC\uD2B8 | \uC2EC\uAC01\uB3C4 | \uC704\uCE58 | \uC885\uB958 | \uD06C\uAE30 | \uC9C4\uB2E8 |", "| --- | --- | --- | --- | --- | --- |");
|
|
34
|
+
for (const result of results) {
|
|
35
|
+
for (const defect of result.defects) {
|
|
36
|
+
const hints = defect.suggestions.map((suggestion) => suggestion.hint).join(", ") || "-";
|
|
37
|
+
lines.push(
|
|
38
|
+
`| ${result.viewport.width}px | ${SEVERITY_LABEL[defect.severity]} | \`${cell(locationOf(defect))}\` | ${defect.kind} | ${cell(formatMagnitude(defect))} | ${cell(hints)} |`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return `${lines.join("\n")}
|
|
43
|
+
`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/cli.ts
|
|
47
|
+
var readFlag = (args, name) => {
|
|
48
|
+
const index = args.indexOf(name);
|
|
49
|
+
return index === -1 ? void 0 : args[index + 1];
|
|
50
|
+
};
|
|
51
|
+
var main = async () => {
|
|
52
|
+
const args = process.argv.slice(2);
|
|
53
|
+
const url = args.find((arg) => /^https?:\/\//.test(arg));
|
|
54
|
+
if (!url) {
|
|
55
|
+
console.error("\uC0AC\uC6A9\uBC95: inspector-scan <url> [--viewports 375,768,1280] [--report <\uB300\uC2DC\uBCF4\uB4DC URL>] [--fail-on high|medium|low] [--summary <\uD30C\uC77C>] [--scenario <\uD30C\uC77C.json>]");
|
|
56
|
+
process.exitCode = 2;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const viewportFlag = readFlag(args, "--viewports");
|
|
60
|
+
const report = readFlag(args, "--report");
|
|
61
|
+
const summaryFile = readFlag(args, "--summary");
|
|
62
|
+
const scenarioFile = readFlag(args, "--scenario");
|
|
63
|
+
const failOn = parseFailOn(args.includes("--fail-on") ? readFlag(args, "--fail-on") ?? "" : void 0);
|
|
64
|
+
let scenario;
|
|
65
|
+
if (scenarioFile) {
|
|
66
|
+
try {
|
|
67
|
+
scenario = parseScenario(JSON.parse(await readFile(scenarioFile, "utf8")));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(`--scenario ${scenarioFile} \uB97C \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
console.log(`\uC2DC\uB098\uB9AC\uC624: ${scenario.name ?? scenarioFile}`);
|
|
72
|
+
}
|
|
73
|
+
const results = await scanPage(url, viewportFlag ? parseViewports(viewportFlag) : DEFAULT_VIEWPORTS, { scenario });
|
|
74
|
+
for (const result of results) {
|
|
75
|
+
console.log(`
|
|
76
|
+
[${result.viewport.width}px] \uACB0\uD568 ${result.defects.length}\uAC74, \uBB38\uC11C \uAC00\uB85C \uC2A4\uD06C\uB864 ${result.documentOverflowPx}px`);
|
|
77
|
+
for (const defect of result.defects) {
|
|
78
|
+
const source = defect.node.source ? `${defect.node.source.file}:${defect.node.source.line}` : defect.node.selector;
|
|
79
|
+
const hints = defect.suggestions.map((suggestion) => suggestion.hint).join(", ");
|
|
80
|
+
console.log(` - [${defect.severity}] ${source} ${formatMagnitude2(defect)} (${hints})`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (report) {
|
|
84
|
+
const response = await fetch(new URL("/api/scans", report), {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "content-type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ results, scenario })
|
|
88
|
+
});
|
|
89
|
+
console.log(`
|
|
90
|
+
\uB300\uC2DC\uBCF4\uB4DC \uC804\uC1A1: ${response.status}`);
|
|
91
|
+
}
|
|
92
|
+
if (summaryFile) await appendFile(summaryFile, `${renderMarkdown(url, results, failOn, scenario?.name)}
|
|
93
|
+
`, "utf8");
|
|
94
|
+
if (failOn) {
|
|
95
|
+
const failing = defectsAtOrAbove(results, failOn);
|
|
96
|
+
if (failing.length > 0) {
|
|
97
|
+
console.error(`
|
|
98
|
+
\uC2E4\uD328: --fail-on ${failOn} \uAE30\uC900 ${failing.length}\uAC74`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`
|
|
102
|
+
\uD1B5\uACFC: ${failOn} \uC774\uC0C1 \uACB0\uD568 \uC5C6\uC74C`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
main().catch((error) => {
|
|
107
|
+
console.error(error instanceof Error ? error.message : error);
|
|
108
|
+
process.exitCode = 2;
|
|
109
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Viewport, Defect, DetectOptions, Scenario, ScanResult } from '@inspector-agent/core';
|
|
2
|
+
|
|
3
|
+
interface VerifierOptions {
|
|
4
|
+
/** Chromium 실행 파일 경로. 미지정 시 INSPECTOR_CHROMIUM_PATH, 그다음 Playwright 기본 설치본 */
|
|
5
|
+
executablePath?: string;
|
|
6
|
+
detect?: DetectOptions;
|
|
7
|
+
/** 페이지 로드 후 레이아웃 안정화를 기다리는 시간(ms). 기본 300 */
|
|
8
|
+
settleMs?: number;
|
|
9
|
+
/** 스캔 전에 시드를 심고 단계를 실행할 시나리오. 스캔과 패치 후 재측정이 같은 값을 넘기면 같은 상태에서 잰다 */
|
|
10
|
+
scenario?: Scenario;
|
|
11
|
+
}
|
|
12
|
+
interface DefectMeasurement {
|
|
13
|
+
/** 같은 fingerprint(또는 셀렉터)의 결함이 아직 남아 있는지 */
|
|
14
|
+
stillPresent: boolean;
|
|
15
|
+
/** 패치 전/후 측정값. unit 이 px 이면 오버플로우 양, ratio 이면 가려진 비율(0~1), count 이면 포커스가 빠져나가는 방향 수(0~2) */
|
|
16
|
+
unit: 'px' | 'ratio' | 'count';
|
|
17
|
+
beforePx: number;
|
|
18
|
+
afterPx: number;
|
|
19
|
+
/** 패치 후 같은 뷰포트에 새로 생긴 결함 (회귀) */
|
|
20
|
+
regressions: Defect[];
|
|
21
|
+
documentOverflowPx: number;
|
|
22
|
+
}
|
|
23
|
+
interface VerificationResult extends DefectMeasurement {
|
|
24
|
+
passed: boolean;
|
|
25
|
+
verdict: string;
|
|
26
|
+
measuredAt: string;
|
|
27
|
+
}
|
|
28
|
+
declare const DEFAULT_VIEWPORTS: Viewport[];
|
|
29
|
+
/** 하나의 URL을 여러 뷰포트에서 스캔한다 */
|
|
30
|
+
declare const scanPage: (url: string, viewports?: Viewport[], options?: VerifierOptions) => Promise<ScanResult[]>;
|
|
31
|
+
/**
|
|
32
|
+
* 패치 적용 후 같은 URL·뷰포트에서 다시 측정한다.
|
|
33
|
+
* "LLM이 고쳤다고 주장"하는 대신 수치가 실제로 0이 되었는지, 새 결함이 생기지 않았는지로 판정한다.
|
|
34
|
+
* - overflow-x: 오버플로우 px 가 0
|
|
35
|
+
* - occluded: 가려진 비율이 0. 일부만 풀린 경우(클릭이 여전히 막힘)는 실패이므로 탐지 임계값을 낮춰 잔여 가려짐까지 본다
|
|
36
|
+
* baseline 은 패치 전 같은 뷰포트의 결함 목록이며, 여기에 없던 결함은 회귀로 본다.
|
|
37
|
+
*/
|
|
38
|
+
declare const measureDefect: (target: Defect, baseline: Defect[], options?: VerifierOptions) => Promise<VerificationResult>;
|
|
39
|
+
declare const parseViewports: (value: string) => Viewport[];
|
|
40
|
+
|
|
41
|
+
export { DEFAULT_VIEWPORTS, type DefectMeasurement, type VerificationResult, type VerifierOptions, measureDefect, parseViewports, scanPage };
|
package/dist/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inspector-agent/verifier",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Playwright 로 여러 뷰포트를 스캔하고(inspector-scan CLI) 패치 전후를 재측정하는 레이아웃 결함 검증기",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "HamHyunJun",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/HamHyunJun/Inspector-Agent.git",
|
|
10
|
+
"directory": "packages/verifier"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/HamHyunJun/Inspector-Agent/tree/main/packages/verifier#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/HamHyunJun/Inspector-Agent/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"playwright",
|
|
18
|
+
"layout",
|
|
19
|
+
"overflow",
|
|
20
|
+
"viewport",
|
|
21
|
+
"cli",
|
|
22
|
+
"ci",
|
|
23
|
+
"accessibility",
|
|
24
|
+
"focus-trap"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"type": "module",
|
|
33
|
+
"main": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"bin": {
|
|
36
|
+
"inspector-scan": "./dist/cli.js"
|
|
37
|
+
},
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"import": "./dist/index.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"dist"
|
|
46
|
+
],
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"playwright-core": "1.56.1",
|
|
49
|
+
"@inspector-agent/core": "^0.2.0"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"typecheck": "tsc --noEmit"
|
|
55
|
+
}
|
|
6
56
|
}
|