feat: strengthen design skill and refresh all showcases
Some checks failed
ci / build (push) Has been cancelled
Some checks failed
ci / build (push) Has been cancelled
This commit is contained in:
parent
69e5cc1efd
commit
e09d4efb1c
257 changed files with 8196 additions and 2374 deletions
|
|
@ -1,25 +1,27 @@
|
|||
// 쇼케이스 실렌더 감사 — 전 폭 × 전 페이지에서 하드 게이트를 기계로 판정한다.
|
||||
// 쇼케이스 실렌더 감사 — 전 폭 × 전 HTML 경로에서 규범·기능 하드 게이트를 판정한다.
|
||||
//
|
||||
// 검사 항목
|
||||
// 1. 가로 스크롤 (하드 게이트 5)
|
||||
// 2. 뷰포트를 넘는 요소 (오버플로)
|
||||
// 3. 버튼·내비 라벨 2줄 접힘 (하드 게이트 6)
|
||||
// 3. 버튼·내비 라벨 2줄 접힘 (시각 관찰)
|
||||
// 4. 텍스트 대비 AA (하드 게이트 7) — 조상 배경을 걷어 올려 실제 배경으로 잰다
|
||||
// 5. 왼쪽 인셋 최소값 (가장자리에 붙는 제목)
|
||||
// 6. h1 개수 · 히어로 헤드라인 줄 수
|
||||
// 5. 왼쪽 인셋 최소값 (시각 관찰)
|
||||
// 6. 프로젝트 oneH1 계약 · 히어로 헤드라인 줄 수(시각 관찰)
|
||||
// 7. 콘솔 에러 · 깨진 이미지
|
||||
//
|
||||
// 사용: node tools/showcase-audit.mjs [slug ...]
|
||||
// 인자가 없으면 public/work 아래 전부를 돈다.
|
||||
// 인자가 없으면 public 아래 라이선스 자산을 제외한 모든 HTML을 돈다.
|
||||
|
||||
import puppeteer from "puppeteer-core";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
import { startPublicServer, routeForFile } from "./capture-stable.mjs";
|
||||
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, "..");
|
||||
const workRoot = path.join(root, "public", "work");
|
||||
const publicRoot = path.resolve(process.env.SHOWCASE_AUDIT_PUBLIC_ROOT || path.join(root, "public"));
|
||||
const reportPath = path.resolve(process.env.SHOWCASE_AUDIT_REPORT || path.join(root, "showcase-audit-report.json"));
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
|
|
@ -33,37 +35,67 @@ if (!chrome) {
|
|||
process.exit(2);
|
||||
}
|
||||
|
||||
const WIDTHS = [320, 390, 768, 1440];
|
||||
// 넓은 모니터에서 최대 폭이 풀려 카드·카피가 늘어지는 결함은 1440px 검사만으로
|
||||
// 발견할 수 없다. 기본 감사에는 1920/2560까지 포함하고, 긴급 진단은 환경변수로
|
||||
// 필요한 폭만 좁혀 실행할 수 있게 둔다.
|
||||
const DEFAULT_WIDTHS = [320, 390, 768, 1440, 1920, 2560];
|
||||
const WIDTHS = process.env.SHOWCASE_AUDIT_WIDTHS
|
||||
? process.env.SHOWCASE_AUDIT_WIDTHS.split(",")
|
||||
.map((value) => Number.parseInt(value.trim(), 10))
|
||||
.filter(Number.isFinite)
|
||||
: DEFAULT_WIDTHS;
|
||||
|
||||
if (!WIDTHS.length || WIDTHS.some((width) => width < 1)) {
|
||||
console.error("SHOWCASE_AUDIT_WIDTHS는 쉼표로 구분한 양의 정수 폭이어야 한다.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const slugs = args.length
|
||||
? args
|
||||
: fs
|
||||
.readdirSync(workRoot, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
.sort();
|
||||
|
||||
/** 한 쇼케이스가 가진 모든 index.html (하위 상세 페이지 포함) */
|
||||
function pagesOf(slug) {
|
||||
/** assets 아래 라이선스 HTML은 쇼케이스 경로가 아니므로 제외하고, legacy 상세까지 모두 수집한다. */
|
||||
function htmlPagesOf(directory) {
|
||||
const out = [];
|
||||
const walk = (dir) => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.name === "index.html") out.push(p);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name !== "assets") walk(p);
|
||||
} else if (e.name.endsWith(".html")) out.push(p);
|
||||
}
|
||||
};
|
||||
walk(path.join(workRoot, slug));
|
||||
walk(directory);
|
||||
return out;
|
||||
}
|
||||
|
||||
function selectedPages() {
|
||||
if (!args.length) return htmlPagesOf(publicRoot);
|
||||
return args.flatMap((slug) => {
|
||||
const workCandidate = path.join(publicRoot, "work", slug);
|
||||
const directCandidate = path.resolve(publicRoot, slug);
|
||||
const selected = fs.existsSync(workCandidate) ? workCandidate : directCandidate;
|
||||
if (!fs.existsSync(selected) || !fs.statSync(selected).isDirectory()) throw new Error(`감사 경로를 찾지 못했다: ${slug}`);
|
||||
return htmlPagesOf(selected);
|
||||
});
|
||||
}
|
||||
|
||||
/* 페이지 안에서 도는 판정기. 여기서 나온 수치가 판정의 근거다. */
|
||||
const PROBE = `() => {
|
||||
// getComputedStyle는 color-mix()를 rgb()가 아닌 CSS Color 4 color(srgb …)
|
||||
// 문자열로 돌려줄 수 있다. 그 0~1 채널을 0~255 rgb로 오독하면 대비 게이트가
|
||||
// 거짓 RED를 낸다. 모든 색 합성·휘도 계산이 같은 파서를 공유해야 한다.
|
||||
const parseColor = (c) => {
|
||||
const srgb = String(c || '').match(/^color\\(srgb\\s+([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)(?:\\s*\\/\\s*([\\d.]+))?\\)$/i);
|
||||
if (srgb) {
|
||||
return { rgb: srgb.slice(1, 4).map((v) => Number(v) * 255), a: srgb[4] === undefined ? 1 : Number(srgb[4]) };
|
||||
}
|
||||
const m = String(c || '').match(/[\\d.]+/g);
|
||||
if (!m) return null;
|
||||
return { rgb: m.slice(0, 3).map(Number), a: m.length > 3 ? Number(m[3]) : 1 };
|
||||
};
|
||||
const lum = (c) => {
|
||||
const m = (c || '').match(/[\\d.]+/g);
|
||||
if (!m) return 1;
|
||||
const [r, g, b] = m.slice(0, 3).map(Number).map((v) => {
|
||||
const parsed = parseColor(c);
|
||||
if (!parsed) return 1;
|
||||
const [r, g, b] = parsed.rgb.map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
|
|
@ -75,12 +107,21 @@ const PROBE = `() => {
|
|||
let n = el, acc = null;
|
||||
while (n && n.nodeType === 1) {
|
||||
const c = getComputedStyle(n).backgroundColor;
|
||||
const m = (c || '').match(/[\\d.]+/g);
|
||||
if (m) {
|
||||
const a = m.length > 3 ? Number(m[3]) : 1;
|
||||
const parsed = parseColor(c);
|
||||
if (parsed) {
|
||||
const { rgb, a } = parsed;
|
||||
if (a > 0.001) {
|
||||
const rgb = m.slice(0, 3).map(Number);
|
||||
acc = acc === null ? { rgb, a } : { rgb: acc.rgb.map((v, i) => v * acc.a + rgb[i] * (1 - acc.a)), a: 1 };
|
||||
if (acc === null) {
|
||||
acc = { rgb, a };
|
||||
} else {
|
||||
// 현재 acc(자식)를 새 부모 배경 위에 합성한다. 이전 구현은 부모 alpha를
|
||||
// (1-a)에 써서 반투명 color-mix 층의 실제 색을 왜곡했다.
|
||||
const outA = acc.a + a * (1 - acc.a);
|
||||
acc = {
|
||||
rgb: acc.rgb.map((v, i) => (v * acc.a + rgb[i] * a * (1 - acc.a)) / outA),
|
||||
a: outA,
|
||||
};
|
||||
}
|
||||
if (acc.a >= 0.999) return 'rgb(' + acc.rgb.map(Math.round).join(',') + ')';
|
||||
}
|
||||
}
|
||||
|
|
@ -140,15 +181,114 @@ const PROBE = `() => {
|
|||
if (r.right > window.innerWidth + 1.5) overflow.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), right: Math.round(r.right) });
|
||||
});
|
||||
|
||||
// 기능은 정상이어도 글자가 테두리에 닿으면 조작감이 거칠어 보인다. 실제 텍스트
|
||||
// 렌더 범위를 컨트롤의 테두리 상자와 비교한다. 아이콘만 있는 컨트롤은 텍스트
|
||||
// 여백 검사의 대상이 아니며, 입력·선택 상자는 계산 스타일의 inline padding을 잰다.
|
||||
const textRect = (el) => {
|
||||
// selectNodeContents는 sr-only 안내, aria-hidden 장식, 빈 행 오버레이의
|
||||
// element box까지 모두 묶는다. 실제 보이는 text node만 합쳐야 셀과 아이콘
|
||||
// 컨트롤의 시각 인셋을 측정할 수 있다.
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
let textNodes = 0;
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (!node.nodeValue.trim()) continue;
|
||||
const owner = node.parentElement;
|
||||
if (!owner || owner.closest('.sr-only, [aria-hidden="true"], [hidden]')) continue;
|
||||
const ownerStyle = getComputedStyle(owner);
|
||||
if (ownerStyle.display === 'none' || ownerStyle.visibility === 'hidden') continue;
|
||||
const range = document.createRange();
|
||||
range.selectNode(node);
|
||||
const nodeRects = [...range.getClientRects()].filter((r) => r.width > 0.5 && r.height > 0.5);
|
||||
if (nodeRects.length) textNodes += 1;
|
||||
rects.push(...nodeRects);
|
||||
}
|
||||
if (!rects.length) return null;
|
||||
return {
|
||||
left: Math.min(...rects.map((r) => r.left)),
|
||||
right: Math.max(...rects.map((r) => r.right)),
|
||||
top: Math.min(...rects.map((r) => r.top)),
|
||||
bottom: Math.max(...rects.map((r) => r.bottom)),
|
||||
nodes: textNodes,
|
||||
};
|
||||
};
|
||||
|
||||
const wrapped = [];
|
||||
document.querySelectorAll('nav a, .btn, button, [role=button]').forEach((el) => {
|
||||
if (!visible(el) || !el.textContent.trim()) return;
|
||||
const cs = getComputedStyle(el);
|
||||
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.25;
|
||||
const textRange = document.createRange();
|
||||
textRange.selectNodeContents(el);
|
||||
const textHeight = textRange.getBoundingClientRect().height;
|
||||
if (textHeight > lh * 1.6) wrapped.push(el.textContent.trim().slice(0, 26));
|
||||
const content = textRect(el);
|
||||
// 이름·기한·상태처럼 여러 정보를 수직으로 쌓은 행은 두 줄 버튼이 아니라
|
||||
// 작은 정보 카드다. 한 개의 라벨 텍스트가 의도치 않게 접힌 경우만 실패로
|
||||
// 잡아 내비와 일반 CTA의 좁은 폭 결함은 유지한다.
|
||||
if (content && content.nodes === 1 && content.bottom - content.top > lh * 1.6) {
|
||||
wrapped.push(el.textContent.trim().slice(0, 26));
|
||||
}
|
||||
});
|
||||
|
||||
const controlInsets = [];
|
||||
const controls = new Set(document.querySelectorAll('button, a.btn, .btn, [role=button], input:not([type=hidden]), select, textarea'));
|
||||
controls.forEach((el) => {
|
||||
if (!visible(el) || el.disabled || el.getBoundingClientRect().width < 28) return;
|
||||
// range/checkbox/radio는 텍스트 입력 상자가 아니라 고유 트랙·표식이라
|
||||
// padding 0이 정상이다. 해당 값은 인접 label에서 별도로 읽힌다.
|
||||
if (el.matches('input[type=range], input[type=checkbox], input[type=radio], input[type=color], input[type=file]')) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
const isField = el.matches('input, select, textarea');
|
||||
const left = isField ? parseFloat(cs.paddingInlineStart) || 0 : textRect(el)?.left - rect.left;
|
||||
const right = isField ? parseFloat(cs.paddingInlineEnd) || 0 : rect.right - textRect(el)?.right;
|
||||
if (!Number.isFinite(left) || !Number.isFinite(right)) return;
|
||||
const inset = Math.min(left, right);
|
||||
if (inset < 8) {
|
||||
controlInsets.push({
|
||||
tag: el.tagName,
|
||||
cls: String(el.className || '').slice(0, 30),
|
||||
inset: +inset.toFixed(1),
|
||||
text: (el.getAttribute('aria-label') || el.textContent || el.value || '').trim().slice(0, 30),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 입력 상자는 서로 포개지면 클릭 대상과 값의 관계가 사라진다. 다른 뷰에 숨은
|
||||
// 요소는 visible()에서 제외하고, 실제로 면적을 공유할 때만 실패로 낸다.
|
||||
const inputOverlaps = [];
|
||||
const fields = [...document.querySelectorAll('input:not([type=hidden]), select, textarea')].filter(visible);
|
||||
for (let i = 0; i < fields.length; i += 1) {
|
||||
const a = fields[i].getBoundingClientRect();
|
||||
for (let j = i + 1; j < fields.length; j += 1) {
|
||||
const b = fields[j].getBoundingClientRect();
|
||||
const width = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||||
const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||||
if (width > 4 && height > 4) {
|
||||
inputOverlaps.push({
|
||||
first: fields[i].name || fields[i].id || fields[i].type,
|
||||
second: fields[j].name || fields[j].id || fields[j].type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 표의 셀은 flex/grid로 그렸는지와 무관하게 읽히는 텍스트가 양 옆 경계에
|
||||
// 붙지 않아야 한다. 역할을 알 수 있는 native/ARIA table 셀만 검사해 그리드
|
||||
// 레이아웃 자체를 테이블로 오인하는 오탐을 피한다.
|
||||
const crowdedCells = [];
|
||||
document.querySelectorAll('th, td, [role=cell], [role=gridcell]').forEach((el) => {
|
||||
if (!visible(el) || !el.textContent.trim()) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const content = textRect(el);
|
||||
if (!content || rect.width < 32) return;
|
||||
const inset = Math.min(content.left - rect.left, rect.right - content.right);
|
||||
if (inset < 8) {
|
||||
crowdedCells.push({
|
||||
tag: el.tagName,
|
||||
cls: String(el.className || '').slice(0, 30),
|
||||
inset: +inset.toFixed(1),
|
||||
text: el.textContent.trim().slice(0, 30),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const insets = [...document.querySelectorAll('h1,h2,h3,p,li')]
|
||||
|
|
@ -170,7 +310,7 @@ const PROBE = `() => {
|
|||
docWidth: document.documentElement.scrollWidth,
|
||||
winWidth: window.innerWidth,
|
||||
horizScroll: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
contrast, overflow, wrapped,
|
||||
contrast, overflow, wrapped, controlInsets, inputOverlaps, crowdedCells,
|
||||
minInset: insets.length ? Math.min(...insets) : null,
|
||||
h1Count: h1.length,
|
||||
h1Lines,
|
||||
|
|
@ -178,73 +318,114 @@ const PROBE = `() => {
|
|||
};
|
||||
}`;
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
headless: "new",
|
||||
// WebGL 을 쓰는 페이지가 있으므로 --disable-gpu 를 쓰지 않는다.
|
||||
// swiftshader 로 소프트웨어 렌더를 강제해 헤드리스에서도 캔버스가 뜬다
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--allow-file-access-from-files",
|
||||
"--use-angle=swiftshader",
|
||||
"--enable-unsafe-swiftshader",
|
||||
"--force-device-scale-factor=1",
|
||||
],
|
||||
});
|
||||
|
||||
let totalFail = 0;
|
||||
const lines = [];
|
||||
|
||||
for (const slug of slugs) {
|
||||
for (const file of pagesOf(slug)) {
|
||||
const rel = path.relative(workRoot, file).replace(/\\/g, "/");
|
||||
for (const width of WIDTHS) {
|
||||
console.log(`RUN ${rel} @${width}`);
|
||||
const page = await browser.newPage();
|
||||
const consoleErrors = [];
|
||||
page.on("pageerror", (e) => consoleErrors.push(String(e).slice(0, 120)));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") consoleErrors.push(m.text().slice(0, 120));
|
||||
});
|
||||
await page.setViewport({ width, height: 900 });
|
||||
await page.goto(url.pathToFileURL(file).href, { waitUntil: "networkidle0" });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await new Promise((r) => setTimeout(r, 260));
|
||||
|
||||
// 문자열을 넘기면 puppeteer 는 그것을 '식'으로 평가한다.
|
||||
// 화살표 함수만 넘기면 함수 객체가 그대로 직렬화돼 빈 객체가 돌아온다 — 즉시호출로 감싼다
|
||||
const r = await page.evaluate(`(${PROBE})()`);
|
||||
await page.close();
|
||||
|
||||
const problems = [];
|
||||
if (r.horizScroll) problems.push(`가로스크롤 ${r.docWidth}>${r.winWidth}`);
|
||||
if (r.overflow.length) problems.push(`오버플로 ${r.overflow.length} (${r.overflow.slice(0, 2).map((o) => o.tag + "." + o.cls).join(", ")})`);
|
||||
if (r.wrapped.length) problems.push(`컨트롤 줄바꿈 ${r.wrapped.length} (${r.wrapped.slice(0, 2).join(", ")})`);
|
||||
if (r.contrast.length) problems.push(`대비미달 ${r.contrast.length} (최악 ${Math.min(...r.contrast.map((c) => c.ratio))}:1 — ${r.contrast[0].cls || r.contrast[0].tag})`);
|
||||
if (r.minInset !== null && r.minInset < 12) problems.push(`왼쪽 인셋 ${r.minInset}px`);
|
||||
if (r.h1Count !== 1) problems.push(`h1 ${r.h1Count}개`);
|
||||
if (r.h1Lines > 2) problems.push(`히어로 헤드라인 ${r.h1Lines}줄`);
|
||||
if (r.brokenImages.length) problems.push(`깨진 이미지 ${r.brokenImages.length}`);
|
||||
if (consoleErrors.length) problems.push(`콘솔에러 ${consoleErrors.length} (${consoleErrors[0]})`);
|
||||
|
||||
if (problems.length) {
|
||||
totalFail++;
|
||||
lines.push(`FAIL ${rel} @${width} — ${problems.join(" · ")}`);
|
||||
if (r.contrast.length) {
|
||||
for (const c of r.contrast.slice(0, 4)) {
|
||||
lines.push(` ↳ ${c.ratio}:1 (${c.need} 필요) ${c.size}px ${c.tag}.${c.cls} "${c.text}"`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(`PASS ${rel} @${width}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function hardFailuresFor(rawProbe, browserFailures) {
|
||||
const failures = [];
|
||||
// 이 목록만 WCAG·기능·프로젝트 계약의 하드 실패다. 줄 수·여백 등은 아래 observations에 남겨
|
||||
// 실제 렌더의 과업·언어·브리프와 함께 판정한다.
|
||||
if (rawProbe.horizScroll) failures.push(`document horizontal overflow ${rawProbe.docWidth}>${rawProbe.winWidth}`);
|
||||
if (rawProbe.overflow.length) failures.push(`visible element overflow ${rawProbe.overflow.length}`);
|
||||
if (rawProbe.contrast.length) failures.push(`contrast below required ratio ${rawProbe.contrast.length}`);
|
||||
if (rawProbe.inputOverlaps.length) failures.push(`input overlap ${rawProbe.inputOverlaps.length}`);
|
||||
if (rawProbe.brokenImages.length) failures.push(`broken images ${rawProbe.brokenImages.length}`);
|
||||
if (rawProbe.h1Count !== 1) failures.push(`project oneH1 contract: ${rawProbe.h1Count}`);
|
||||
if (browserFailures.consoleErrors.length) failures.push(`browser console errors ${browserFailures.consoleErrors.length}`);
|
||||
if (browserFailures.pageErrors.length) failures.push(`browser page errors ${browserFailures.pageErrors.length}`);
|
||||
if (browserFailures.requestFailures.length) failures.push(`network failures ${browserFailures.requestFailures.length}`);
|
||||
if (browserFailures.httpErrors.length) failures.push(`HTTP responses >=400 ${browserFailures.httpErrors.length}`);
|
||||
return failures;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
function observationsFor(rawProbe) {
|
||||
// 스타일 휴리스틱은 기계의 합격/불합격이 아니라 시각 검토자가 확인할 관찰값이다.
|
||||
return {
|
||||
h1Lines: rawProbe.h1Lines,
|
||||
wrappedControls: rawProbe.wrapped,
|
||||
minInset: rawProbe.minInset,
|
||||
controlInsets: rawProbe.controlInsets,
|
||||
crowdedCells: rawProbe.crowdedCells,
|
||||
};
|
||||
}
|
||||
|
||||
for (const l of lines) console.log(l);
|
||||
const passCount = lines.filter((l) => l.startsWith("PASS")).length;
|
||||
console.log(`\n${totalFail ? "✗" : "✓"} 실렌더 감사 — PASS ${passCount} / FAIL ${totalFail}`);
|
||||
process.exit(totalFail ? 1 : 0);
|
||||
function writePartialReport(report) {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
function attachBrowserFailureCollection(page) {
|
||||
const result = { consoleErrors: [], pageErrors: [], requestFailures: [], httpErrors: [] };
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") result.consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("pageerror", (error) => result.pageErrors.push(error.message));
|
||||
page.on("requestfailed", (request) => result.requestFailures.push(`${request.url()} ${request.failure()?.errorText || ""}`));
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) result.httpErrors.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
publicRoot,
|
||||
widths: WIDTHS,
|
||||
rows: [],
|
||||
runnerErrors: [],
|
||||
};
|
||||
let browser;
|
||||
let server;
|
||||
|
||||
try {
|
||||
const pages = selectedPages().sort();
|
||||
if (!pages.length) throw new Error(`감사할 HTML이 없다: ${publicRoot}`);
|
||||
server = await startPublicServer(publicRoot);
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: chrome,
|
||||
headless: "new",
|
||||
// WebGL 페이지도 HTTP 환경에서 실제 렌더한다. capture-stable의 freeze helper는 사용하지 않는다.
|
||||
args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1"],
|
||||
});
|
||||
|
||||
for (const file of pages) {
|
||||
const route = routeForFile(publicRoot, file);
|
||||
for (const width of WIDTHS) {
|
||||
console.log(`RUN ${route} @${width}`);
|
||||
const row = { route, width, rawProbe: null, hardFailures: [], observations: null, consoleErrors: [], pageErrors: [], networkErrors: [] };
|
||||
const page = await browser.newPage();
|
||||
const browserFailures = attachBrowserFailureCollection(page);
|
||||
try {
|
||||
await page.setViewport({ width, height: 900, isMobile: width < 500, hasTouch: width < 500 });
|
||||
await page.goto(`${server.origin}${route}`, { waitUntil: "networkidle0", timeout: 30000 });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await new Promise((resolve) => setTimeout(resolve, 260));
|
||||
// PROBE는 기존 수집 범위를 보존한다. 분류는 hardFailuresFor/observationsFor에서만 바꾼다.
|
||||
row.rawProbe = await page.evaluate(`(${PROBE})()`);
|
||||
} catch (error) {
|
||||
browserFailures.pageErrors.push(`audit runner: ${error.stack || error.message}`);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
row.consoleErrors = browserFailures.consoleErrors;
|
||||
row.pageErrors = browserFailures.pageErrors;
|
||||
row.networkErrors = [...browserFailures.requestFailures, ...browserFailures.httpErrors];
|
||||
if (row.rawProbe) {
|
||||
row.hardFailures = hardFailuresFor(row.rawProbe, browserFailures);
|
||||
row.observations = observationsFor(row.rawProbe);
|
||||
} else {
|
||||
row.hardFailures = ["no probe result"];
|
||||
row.observations = {};
|
||||
}
|
||||
report.rows.push(row);
|
||||
writePartialReport(report);
|
||||
console.log(`${row.hardFailures.length ? "FAIL" : "PASS"} ${route} @${width}${row.hardFailures.length ? ` — ${row.hardFailures.join(" · ")}` : ""}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
report.runnerErrors.push(error.stack || error.message);
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.close();
|
||||
writePartialReport(report);
|
||||
const failedRows = report.rows.filter((row) => row.hardFailures.length);
|
||||
console.log(`\n${failedRows.length || report.runnerErrors.length ? "✗" : "✓"} 실렌더 감사 — PASS ${report.rows.length - failedRows.length} / FAIL ${failedRows.length}`);
|
||||
process.exitCode = failedRows.length || report.runnerErrors.length ? 1 : 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue