feat: 한국어 우선 쇼케이스 정리
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB |
|
|
@ -13,6 +13,24 @@ const root = path.resolve(here, "..");
|
|||
const shots = path.join(root, "src", "assets", "shots");
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function requestedSlugs(argv) {
|
||||
const slugs = new Set();
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--slug") {
|
||||
const slug = argv[index + 1];
|
||||
if (!slug) throw new Error("--slug requires a showcase slug");
|
||||
slugs.add(slug);
|
||||
index += 1;
|
||||
} else if (argument.startsWith("--slug=")) {
|
||||
slugs.add(argument.slice("--slug=".length));
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
return slugs;
|
||||
}
|
||||
|
||||
const CHROME = [
|
||||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||
|
|
@ -44,6 +62,12 @@ const JOBS = [
|
|||
},
|
||||
];
|
||||
|
||||
const requested = requestedSlugs(process.argv);
|
||||
const known = new Set(JOBS.map((job) => job.slug));
|
||||
const unknown = [...requested].filter((slug) => !known.has(slug));
|
||||
if (unknown.length) throw new Error(`Unknown showcase slug(s): ${unknown.join(", ")}`);
|
||||
const selectedJobs = requested.size ? JOBS.filter((job) => requested.has(job.slug)) : JOBS;
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
headless: "new",
|
||||
|
|
@ -56,7 +80,7 @@ const browser = await puppeteer.launch({
|
|||
defaultViewport: { width: 1440, height: 900 },
|
||||
});
|
||||
|
||||
for (const job of JOBS) {
|
||||
for (const job of selectedJobs) {
|
||||
const page = await browser.newPage();
|
||||
await page.setCacheEnabled(false);
|
||||
const file = path.join(root, "public/work", job.slug, "index.html");
|
||||
|
|
|
|||
|
|
@ -8,6 +8,24 @@ const rootDir = path.resolve(__dirname, "..");
|
|||
const chromePath = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
||||
const shotsDir = path.join(rootDir, "src", "assets", "shots");
|
||||
|
||||
function requestedSlugs(argv) {
|
||||
const slugs = new Set();
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--slug") {
|
||||
const slug = argv[index + 1];
|
||||
if (!slug) throw new Error("--slug requires a showcase slug");
|
||||
slugs.add(slug);
|
||||
index += 1;
|
||||
} else if (argument.startsWith("--slug=")) {
|
||||
slugs.add(argument.slice("--slug=".length));
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
return slugs;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(shotsDir)) {
|
||||
fs.mkdirSync(shotsDir, { recursive: true });
|
||||
}
|
||||
|
|
@ -94,6 +112,13 @@ const targets = [
|
|||
},
|
||||
];
|
||||
|
||||
const requested = requestedSlugs(process.argv);
|
||||
const slugOf = (target) => path.basename(path.dirname(target.url));
|
||||
const known = new Set(targets.map(slugOf));
|
||||
const unknown = [...requested].filter((slug) => !known.has(slug));
|
||||
if (unknown.length) throw new Error(`Unknown showcase slug(s): ${unknown.join(", ")}`);
|
||||
const selectedTargets = requested.size ? targets.filter((target) => requested.has(slugOf(target))) : targets;
|
||||
|
||||
console.log("📸 Capturing high-resolution screenshots...");
|
||||
|
||||
const shot = (t, url, out) => {
|
||||
|
|
@ -111,7 +136,7 @@ const shot = (t, url, out) => {
|
|||
]);
|
||||
};
|
||||
|
||||
for (const t of targets) {
|
||||
for (const t of selectedTargets) {
|
||||
console.log(`- Capturing ${path.basename(t.out)}`);
|
||||
try {
|
||||
shot(t, t.url, t.out);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,30 @@ const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe";
|
|||
const shots = path.join(root, "src", "assets", "shots");
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function requestedSlugs(argv) {
|
||||
const slugs = new Set();
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === "--slug") {
|
||||
const slug = argv[index + 1];
|
||||
if (!slug) throw new Error("--slug requires an app slug");
|
||||
slugs.add(slug);
|
||||
index += 1;
|
||||
} else if (argument.startsWith("--slug=")) {
|
||||
slugs.add(argument.slice("--slug=".length));
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
return slugs;
|
||||
}
|
||||
|
||||
const requested = requestedSlugs(process.argv);
|
||||
const known = new Set(["gaon-lms", "dure-enrollment"]);
|
||||
const unknown = [...requested].filter((slug) => !known.has(slug));
|
||||
if (unknown.length) throw new Error(`Unknown app slug(s): ${unknown.join(", ")}`);
|
||||
const selected = requested.size ? requested : known;
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME,
|
||||
headless: "new",
|
||||
|
|
@ -24,24 +48,28 @@ const open = async (app) => {
|
|||
await sleep(250);
|
||||
};
|
||||
|
||||
// 가온: 대시보드 본컷 + 학적 카드(수강생 모달) -2컷
|
||||
await open("gaon-lms");
|
||||
await page.screenshot({ path: path.join(shots, "work-gaon-lms.png") });
|
||||
await page.screenshot({ path: path.join(root, "public/work/gaon-lms/og.png") });
|
||||
await page.evaluate(() => document.querySelector('[data-view="students"]').click());
|
||||
await page.evaluate(() => document.querySelector("#stu-table [data-stu]").click());
|
||||
await page.waitForSelector("#stu-dialog[open]");
|
||||
await sleep(350); // 모달 등장 모션 200ms + 여유
|
||||
await page.screenshot({ path: path.join(shots, "work-gaon-lms-2.png") });
|
||||
if (selected.has("gaon-lms")) {
|
||||
// 가온: 대시보드 본컷 + 학적 카드(수강생 모달) -2컷
|
||||
await open("gaon-lms");
|
||||
await page.screenshot({ path: path.join(shots, "work-gaon-lms.png") });
|
||||
await page.screenshot({ path: path.join(root, "public/work/gaon-lms/og.png") });
|
||||
await page.evaluate(() => document.querySelector('[data-view="students"]').click());
|
||||
await page.evaluate(() => document.querySelector("#stu-table [data-stu]").click());
|
||||
await page.waitForSelector("#stu-dialog[open]");
|
||||
await sleep(350); // 모달 등장 모션 200ms + 여유
|
||||
await page.screenshot({ path: path.join(shots, "work-gaon-lms-2.png") });
|
||||
}
|
||||
|
||||
// 두레: 과목 찾기 본컷 + 강의계획(과목 모달) -2컷
|
||||
await open("dure-enrollment");
|
||||
await page.screenshot({ path: path.join(shots, "work-dure-enrollment.png") });
|
||||
await page.screenshot({ path: path.join(root, "public/work/dure-enrollment/og.png") });
|
||||
await page.evaluate(() => document.querySelector(".course-row").click());
|
||||
await page.waitForSelector("#course-dialog[open]");
|
||||
await sleep(350);
|
||||
await page.screenshot({ path: path.join(shots, "work-dure-enrollment-2.png") });
|
||||
if (selected.has("dure-enrollment")) {
|
||||
// 두레: 과목 찾기 본컷 + 강의계획(과목 모달) -2컷
|
||||
await open("dure-enrollment");
|
||||
await page.screenshot({ path: path.join(shots, "work-dure-enrollment.png") });
|
||||
await page.screenshot({ path: path.join(root, "public/work/dure-enrollment/og.png") });
|
||||
await page.evaluate(() => document.querySelector(".course-row").click());
|
||||
await page.waitForSelector("#course-dialog[open]");
|
||||
await sleep(350);
|
||||
await page.screenshot({ path: path.join(shots, "work-dure-enrollment-2.png") });
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("work shots + og.png 갱신 완료");
|
||||
|
|
|
|||
|
|
@ -170,6 +170,12 @@ const UTILS = `
|
|||
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
|
||||
return ls.length;
|
||||
};
|
||||
window.__textLines = (sel) => {
|
||||
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
|
||||
if (!el) return -1;
|
||||
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
|
||||
return window.__lines(text || el);
|
||||
};
|
||||
`;
|
||||
|
||||
const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
|
||||
|
|
@ -386,7 +392,7 @@ for (const p of CFG.pages) {
|
|||
const bl = await page.evaluate((sel) => {
|
||||
const els = [...document.querySelectorAll(sel)];
|
||||
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
|
||||
return window.__lines(el);
|
||||
return window.__textLines(el);
|
||||
}, p.brand);
|
||||
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# E2E 시나리오 게이트 — 2026-08-24 19:47
|
||||
# E2E 시나리오 게이트 — 2026-08-26 09:12
|
||||
|
||||
단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과**
|
||||
단언 1799 · 통과 1799 · 실패 0 — **GREEN — 전 단언 통과**
|
||||
|
||||
| 스위트 | 통과 | 실패 |
|
||||
|---|---|---|
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
| M320 매트릭스 | 90 | 0 |
|
||||
| M390 매트릭스 | 105 | 0 |
|
||||
| M1 터치타깃 | 231 | 0 |
|
||||
| M2 대비 | 1093 | 0 |
|
||||
| M2 대비 | 1087 | 0 |
|
||||
| M768 매트릭스 | 90 | 0 |
|
||||
| S7 뒤로 가기 | 6 | 0 |
|
||||
| S7 뒤로 가기·모달 | 5 | 0 |
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ const UTILS = `
|
|||
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
|
||||
return ls.length;
|
||||
};
|
||||
window.__textLines = (sel) => {
|
||||
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
|
||||
if (!el) return -1;
|
||||
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
|
||||
return window.__lines(text || el);
|
||||
};
|
||||
window.__tapSweep = () => {
|
||||
const EXEMPT_STRICT = ".undo-btn, .row-open";
|
||||
const out = [];
|
||||
|
|
@ -265,7 +271,7 @@ for (const [app, cfg] of Object.entries(APPS)) {
|
|||
const h1 = await page.evaluate(() => window.__lines(document.querySelector(".view.is-on h2.v-h, h1")));
|
||||
ok(h1 <= 1, S, `${app}/${v}@${w} 제목 1줄`, h1 + "줄");
|
||||
if (w === 390) {
|
||||
const brand = await page.evaluate(() => window.__lines(document.querySelector(".brand, .appbar .brand")));
|
||||
const brand = await page.evaluate(() => window.__textLines(document.querySelector(".brand, .appbar .brand")));
|
||||
ok(brand <= 1, S, `${app}/${v}@390 브랜드 1줄`, brand + "줄");
|
||||
}
|
||||
const nav = await page.evaluate(() => {
|
||||
|
|
|
|||
|
|
@ -170,7 +170,8 @@ for (const [app, views] of [["gaon-lms", GAON_VIEWS], ["dure-enrollment", DURE_V
|
|||
|
||||
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
|
||||
await openApp(page, app);
|
||||
const brandLines = await page.evaluate("window.__lines(document.querySelector('.brand, .appbar .brand'))");
|
||||
// 브랜드 마크의 세로 중앙 정렬 차이는 줄바꿈이 아니다. 실제 브랜드 문구만 센다.
|
||||
const brandLines = await page.evaluate("window.__textLines(document.querySelector('.brand, .appbar .brand'))");
|
||||
if (brandLines > 1) fail(`${app} 브랜드 세로 쌓임`, brandLines + "줄");
|
||||
else pass(`${app} 브랜드 1줄`);
|
||||
}
|
||||
|
|
|
|||
59
apps/site/tools/no-hanja.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// 배포 텍스트에는 한자를 두지 않는다. 이미지처럼 텍스트가 아닌 자산은 이 검사의 대상이 아니다.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(here, "..");
|
||||
const TEXT_EXTENSIONS = new Set([".astro", ".css", ".html", ".js", ".json", ".mjs", ".svg", ".ts", ".tsx", ".txt", ".webmanifest", ".xml"]);
|
||||
const HANJA = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/u;
|
||||
const HANJA_GLOBAL = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/gu;
|
||||
|
||||
function scanRoots(argv) {
|
||||
const roots = [];
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
const value = argument === "--root" ? argv[++index] : argument.startsWith("--root=") ? argument.slice("--root=".length) : null;
|
||||
if (!value) throw new Error(`Unknown or incomplete argument: ${argument}`);
|
||||
const candidate = path.resolve(root, value);
|
||||
if (candidate !== root && !candidate.startsWith(root + path.sep)) throw new Error(`Root must stay inside apps/site: ${value}`);
|
||||
roots.push(candidate);
|
||||
}
|
||||
return roots.length ? roots : [path.join(root, "src"), path.join(root, "public")];
|
||||
}
|
||||
|
||||
function textFiles(directory) {
|
||||
const files = [];
|
||||
const walk = (current) => {
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const candidate = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) walk(candidate);
|
||||
else if (entry.isFile() && TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(candidate);
|
||||
}
|
||||
};
|
||||
walk(directory);
|
||||
return files;
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
let scanned = 0;
|
||||
for (const scanRoot of scanRoots(process.argv)) {
|
||||
if (!fs.existsSync(scanRoot)) throw new Error(`Scan root does not exist: ${path.relative(root, scanRoot) || "."}`);
|
||||
for (const file of textFiles(scanRoot)) {
|
||||
scanned += 1;
|
||||
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
|
||||
lines.forEach((line, index) => {
|
||||
if (!HANJA.test(line)) return;
|
||||
const characters = [...new Set(line.match(HANJA_GLOBAL) || [])].join("");
|
||||
failures.push(`${path.relative(root, file).replaceAll("\\", "/")}:${index + 1} contains ${characters}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`FAIL: ${failures.length} Hanja occurrence(s) across ${scanned} shipped text files.`);
|
||||
for (const failure of failures) console.error(` ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`PASS: ${scanned} shipped text files contain no Hanja.`);
|
||||
|
|
@ -257,7 +257,7 @@ try {
|
|||
// 4. 새벽청과 uses the current Korean market date and turns every price row into a readable lot.
|
||||
await goto(page, urls.market);
|
||||
const stamp = expectedKoreanStamp();
|
||||
const date = await page.$eval(".hero-stamp time", (time) => ({ text: time.textContent.trim(), datetime: time.dateTime }));
|
||||
const date = await page.$eval(".hero-meta time", (time) => ({ text: time.textContent.trim(), datetime: time.dateTime }));
|
||||
assert(date.text === stamp.text && date.datetime === stamp.datetime, "새벽청과 date", "market stamp is current in Korea", `${date.text}/${date.datetime}`);
|
||||
assert((await page.$$eval("[data-market-row]", (items) => items.length)) === 6, "새벽청과 lot board", "six selectable lots");
|
||||
await page.click('[data-market-row][data-name="무화과"]');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# TWENTYOZ E2E — 2026-08-24 19:48
|
||||
# TWENTYOZ E2E — 2026-08-26 09:13
|
||||
|
||||
**148 assertions passed · GREEN**
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,22 @@
|
|||
# 검증 리포트 — 2026-08-24 19:48
|
||||
# 검증 리포트 — 2026-08-26 09:13
|
||||
|
||||
게이트: **전 계층 통과 — 배포 가능** · 총 167.0초
|
||||
게이트: **전 계층 통과 — 배포 가능** · 총 195.3초
|
||||
|
||||
| 계층 | 결과 | 항목 | 소요 |
|
||||
|---|---|---|---|
|
||||
| L0-자산/트웬티온스 3D | PASS | PASS — | 0.2s |
|
||||
| L0-정적/stylelint | PASS | PASS — | 1.7s |
|
||||
| L0-정식/html-validate | PASS | PASS — | 1.6s |
|
||||
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.2s |
|
||||
| L0-자산/트웬티온스 3D | PASS | PASS — | 0.3s |
|
||||
| L0-한자 금지/배포 텍스트 | PASS | PASS 1 | 0.1s |
|
||||
| L0-정적/stylelint | PASS | PASS — | 2.4s |
|
||||
| L0-정식/html-validate | PASS | PASS — | 1.9s |
|
||||
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.1s |
|
||||
| L1-단위/계산로직 | PASS | PASS 40 | 0.1s |
|
||||
| L2-기능/감사 | PASS | PASS 72 | 9.7s |
|
||||
| L2-불변식/렌더 | PASS | PASS 10 | 16.6s |
|
||||
| L2-디자인 게이트/메인 | PASS | PASS 130 | 32.6s |
|
||||
| L2-디자인 게이트/제품 | PASS | PASS 15 | 6.9s |
|
||||
| L3-시각회귀/diff | PASS | PASS 30 | 11.5s |
|
||||
| L4-WebKit | PASS | PASS 8 | 3.6s |
|
||||
| L5-탐색/키보드 | PASS | PASS 14 | 6.1s |
|
||||
| L6-시나리오/E2E | PASS | PASS — | 42.6s |
|
||||
| L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 11.0s |
|
||||
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 22.7s |
|
||||
| L2-기능/감사 | PASS | PASS 72 | 10.0s |
|
||||
| L2-불변식/렌더 | PASS | PASS 10 | 36.9s |
|
||||
| L2-디자인 게이트/메인 | PASS | PASS 130 | 37.9s |
|
||||
| L2-디자인 게이트/제품 | PASS | PASS 15 | 7.2s |
|
||||
| L3-시각회귀/diff | PASS | PASS 30 | 11.8s |
|
||||
| L4-WebKit | PASS | PASS 8 | 3.8s |
|
||||
| L5-탐색/키보드 | PASS | PASS 14 | 6.3s |
|
||||
| L6-시나리오/E2E | PASS | PASS — | 43.6s |
|
||||
| L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 10.9s |
|
||||
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 22.0s |
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ const steps = [
|
|||
layer: "L0-자산/트웬티온스 3D",
|
||||
cmd: () => spawnSync(process.execPath, ["tools/build-showcase-assets.mjs", "--check"], { cwd: root, encoding: "utf8" }),
|
||||
},
|
||||
{
|
||||
layer: "L0-한자 금지/배포 텍스트",
|
||||
cmd: () => spawnSync(process.execPath, ["tools/no-hanja.mjs"], { cwd: root, encoding: "utf8" }),
|
||||
},
|
||||
{
|
||||
layer: "L0-정적/stylelint",
|
||||
cmd: () => spawnSync(npx, ["stylelint", "public/work/*/styles/*.css"], { cwd: root, encoding: "utf8", shell: true }),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,19 @@ const UTILS = `
|
|||
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
|
||||
return ls.length;
|
||||
};
|
||||
window.__textLines = (sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return -1;
|
||||
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
|
||||
if (!text) return window.__lines(sel);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4);
|
||||
const tops = rects.map((r) => r.top).sort((a, b) => a - b);
|
||||
const ls = [];
|
||||
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
|
||||
return ls.length;
|
||||
};
|
||||
`;
|
||||
|
||||
const browser = await webkit.launch();
|
||||
|
|
@ -40,7 +53,7 @@ for (const [app, views] of Object.entries(APPS)) {
|
|||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
// 브랜드 1줄 + h1 1줄 + 페이지 오버플로
|
||||
const brand = await page.evaluate(`__lines('.brand')`);
|
||||
const brand = await page.evaluate(`__textLines('.brand')`);
|
||||
if (brand > 1) fail(`${app} WebKit 브랜드 세로`, brand + "줄"); else pass(`${app} WebKit 브랜드`);
|
||||
const h1 = await page.evaluate(`__lines('h1')`);
|
||||
if (h1 > 1) fail(`${app} WebKit h1 여러 줄`, h1 + "줄"); else pass(`${app} WebKit h1`);
|
||||
|
|
|
|||