674 lines
34 KiB
JavaScript
674 lines
34 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { createRequire } from 'node:module';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { fixture } from './load-fixtures.mjs';
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const require = createRequire(path.join(root, 'app/game-frontend/package.json'));
|
|
const { chromium } = require('@playwright/test');
|
|
const out = process.env.TYPOGRAPHY_OUTPUT_DIR ?? path.join(root, 'test-results/typography-audit');
|
|
const phase = process.env.PHASE ?? 'capture';
|
|
const origin = process.env.CAPTURE_ORIGIN ?? 'http://127.0.0.1:15329';
|
|
const prefix = '/' + (process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '');
|
|
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
|
const fontRoot = process.env.TYPOGRAPHY_FONT_ROOT;
|
|
process.env.FRONTEND_PARITY_IMAGE_ROOT = path.resolve(
|
|
process.env.FRONTEND_PARITY_IMAGE_ROOT ?? path.join(root, '../image')
|
|
);
|
|
process.env.LEGACY_IMAGE_ROOT = path.resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT ?? path.join(root, '../image'));
|
|
const modules = {};
|
|
async function m(name) {
|
|
return (modules[name] ??= await fixture(name.includes('/') ? name : `app/game-frontend/e2e/${name}.spec.ts`));
|
|
}
|
|
const stateMain = () => ({
|
|
officerLevel: 12,
|
|
permission: 4,
|
|
nationLevel: 3,
|
|
stage: 5,
|
|
npcMode: 1,
|
|
generalMeCalls: 0,
|
|
operations: [],
|
|
validMapImages: true,
|
|
});
|
|
const stateMenus = () => ({ permission: 'head', myset: 0, settingMutations: [], accessPages: [], richMyInfo: true });
|
|
const setup = {
|
|
audit: async (p) => (await m('playAudit')).install(p),
|
|
login: async (p) => {
|
|
await p.route('**/gateway/', (r) => r.fulfill({ status: 204 }));
|
|
},
|
|
selection: async (p) => {
|
|
await p.addInitScript(() => {
|
|
localStorage.setItem('sammo-game-token', 'ga_selection_fixture');
|
|
localStorage.setItem('sammo-game-profile', 'che:default');
|
|
});
|
|
await p.route('**/che/api/trpc/**', async (r) => {
|
|
const ops = decodeURIComponent(new URL(r.request().url()).pathname.split('/trpc/')[1]).split(',');
|
|
const rows = ops.map((op) => ({
|
|
result: {
|
|
data:
|
|
{
|
|
'auth.status': { ok: true },
|
|
'lobby.info': { myGeneral: null },
|
|
'join.getConfig': {
|
|
nations: [{ id: 1, name: '촉', color: '#008000', scoutMessage: '임관 안내' }],
|
|
selectionPool: { enabled: true, hasGeneral: false, allowOptions: [] },
|
|
personalities: [],
|
|
user: { icons: [] },
|
|
iconChoices: [],
|
|
},
|
|
'join.getSelectionPool': {
|
|
reservationId: 'fixture',
|
|
hasGeneral: false,
|
|
validUntil: '2030-01-01T00:00:00Z',
|
|
candidates: [0, 1, 2].map((i) => ({
|
|
uniqueName: 'candidate' + i,
|
|
generalName: '장수이름',
|
|
leadership: 90,
|
|
strength: 80,
|
|
intel: 70,
|
|
dex: [1000, 2000, 3000, 4000, 5000],
|
|
picture: 'default.jpg',
|
|
imageServer: 0,
|
|
specialDomesticName: '인덕',
|
|
specialDomesticInfo: '내정 특기',
|
|
specialWarName: '귀모',
|
|
specialWarInfo: '전투 특기',
|
|
})),
|
|
},
|
|
}[op] ?? {},
|
|
},
|
|
}));
|
|
await r.fulfill({ contentType: 'application/json', body: JSON.stringify(rows) });
|
|
});
|
|
},
|
|
public: async (p) => {
|
|
const x = await m('tools/frontend-legacy-parity/fixtures/canonical.ts');
|
|
const f = x.canonicalFrontendFixture.game;
|
|
await p.route('**/che/api/trpc/**', async (r) => {
|
|
const ops = decodeURIComponent(new URL(r.request().url()).pathname.split('/trpc/')[1]).split(',');
|
|
const data = ops.map((op) => ({
|
|
result: {
|
|
data:
|
|
{
|
|
'auth.status': { ok: true },
|
|
'lobby.info': { myGeneral: null },
|
|
'public.getMapLayout': f.mapLayout,
|
|
'public.getCachedMap': {
|
|
...f.map,
|
|
nationList: f.map.nationList.map((row, i) => [
|
|
row[0],
|
|
row[1],
|
|
i ? '#800000' : '#008000',
|
|
row[3],
|
|
]),
|
|
},
|
|
'public.getWorldTrend': { year: 217, month: 7, turnTerm: 10, records: [], history: [] },
|
|
'public.getNationList': [
|
|
{ id: 1, name: '촉', color: '#008000', generalCount: 100, cityCount: 8 },
|
|
],
|
|
'public.getGeneralList': [
|
|
{
|
|
id: 1,
|
|
name: '유비',
|
|
npcState: 0,
|
|
nationName: '촉',
|
|
nationId: 1,
|
|
officerLevel: 12,
|
|
picture: 'default.jpg',
|
|
cityName: '낙양',
|
|
},
|
|
],
|
|
}[op] ?? {},
|
|
},
|
|
}));
|
|
await r.fulfill({ contentType: 'application/json', body: JSON.stringify(data) });
|
|
});
|
|
},
|
|
messages: async (p) => {
|
|
const x = await m('mainNavigation');
|
|
await x.installRealtimeHarness(p);
|
|
const state = stateMain();
|
|
state.messages = {
|
|
...x.emptyMessages(4),
|
|
public: [
|
|
{
|
|
id: 801,
|
|
text: '최대 길이 장수명과 국가명을 확인합니다.',
|
|
time: '2026-09-15 12:00:00',
|
|
msgType: 'public',
|
|
src: {
|
|
generalId: 7,
|
|
generalName: '작성장수',
|
|
nationId: 1,
|
|
nationName: '위',
|
|
color: '#008000',
|
|
icon: '/image/icons/default.jpg',
|
|
},
|
|
dest: null,
|
|
option: {},
|
|
},
|
|
],
|
|
private: [
|
|
{
|
|
id: 802,
|
|
text: '메시지 본문과 삭제 동작의 간격을 확인합니다.',
|
|
time: '2026-09-15 12:00:00',
|
|
msgType: 'private',
|
|
src: {
|
|
generalId: 7,
|
|
generalName: '작성장수',
|
|
nationId: 1,
|
|
nationName: '위',
|
|
color: '#008000',
|
|
icon: '/image/icons/default.jpg',
|
|
},
|
|
dest: { generalId: 8, generalName: '수신장수', nationId: 2, nationName: '촉', color: '#800000' },
|
|
option: {},
|
|
},
|
|
],
|
|
};
|
|
await x.installFixture(p, state);
|
|
},
|
|
main: async (p) => {
|
|
const x = await m('mainNavigation');
|
|
await x.installRealtimeHarness(p);
|
|
await x.installFixture(p, stateMain());
|
|
},
|
|
commands: async (p) => (await m('commandArguments')).install(p),
|
|
menus: async (p) => (await m('inGameMenus')).install(p, stateMenus()),
|
|
info: async (p) => (await m('inGameInfo')).install(p),
|
|
directories: async (p) => (await m('directoryLists')).install(p),
|
|
officers: async (p) => (await m('nationOffices')).installFixture(p, { role: 'leader', rate: 20 }),
|
|
tournament: async (p) => (await m('tournamentBracket')).installFixture(p),
|
|
nationBetting: async (p) => (await m('nationBetting')).installFixture(p),
|
|
archives: async (p) => (await m('legacyArchiveViews')).installArchiveViews(p),
|
|
secret: async (p) => (await m('nationGeneralSecret')).install(p, true, false, 4),
|
|
troop: async (p) => {
|
|
const x = await m('troop');
|
|
await x.installApiFixture(p, { me: { id: 1, troopId: 1 }, permission: 4, troops: x.baseTroops() });
|
|
},
|
|
board: async (p) => {
|
|
const x = await m('board');
|
|
await x.installApi(p, {
|
|
permission: 4,
|
|
canMeeting: true,
|
|
canSecret: true,
|
|
articles: x.initialArticles(),
|
|
requests: [],
|
|
});
|
|
},
|
|
auction: async (p) => (await m('auction')).installFixture(p, { resourceBidCount: 0, uniqueBidCount: 0 }),
|
|
diplomacy: async (p) => (await m('diplomacy')).installFixture(p, 4),
|
|
past: async (p) => (await m('pastPlays')).installArchive(p),
|
|
npc: async (p) => (await m('npcPolicy')).installFixture(p, { permissionLevel: 4, mutations: [] }),
|
|
possession: async (p) =>
|
|
(await m('npcPossession')).installFixture(p, {
|
|
reservationCalls: 0,
|
|
reservationInputs: [],
|
|
rawBodies: [],
|
|
possessInputs: [],
|
|
hasGeneral: false,
|
|
injectTimeout: false,
|
|
}),
|
|
join: async (p) => (await m('joinLayout')).installFixture(p, { mapRequests: 0, generalRequests: 0 }),
|
|
simulator: async (p) =>
|
|
(await m('battleSimulator')).installApi(p, {
|
|
hasGeneral: true,
|
|
requests: [],
|
|
preparedPayloads: [],
|
|
serverResults: [],
|
|
}),
|
|
ranking: async (p) =>
|
|
(await m('tools/frontend-legacy-parity/visual-parity.spec.ts')).installAuthenticatedGameFixture(p),
|
|
inherit: async (p) => (await m('tools/frontend-legacy-parity/inheritance-management.spec.ts')).installFixture(p),
|
|
};
|
|
const scenes = [
|
|
['audit', 'audit', 'play-audit', '#play-audit-container'],
|
|
[
|
|
'audit-nation',
|
|
'audit',
|
|
'play-audit?tab=nations&nation=2&fromYear=190&fromMonth=1&year=190&month=6',
|
|
'#play-audit-container',
|
|
],
|
|
['audit-generals', 'audit', 'play-audit?tab=generals&at=month&year=190&month=6', '#play-audit-container'],
|
|
['audit-cities', 'audit', 'play-audit?tab=cities&year=190&month=6', '#play-audit-container'],
|
|
[
|
|
'audit-diplomacy',
|
|
'audit',
|
|
'play-audit?tab=diplomacy&nation=2&otherNation=3&year=190&month=6',
|
|
'#play-audit-container',
|
|
],
|
|
['audit-policy', 'audit', 'play-audit?tab=policies&nation=2&year=190&month=6', '#play-audit-container'],
|
|
['board-secret', 'board', 'board/secret', 'main'],
|
|
['affairs-redirect', 'officers', 'nation/affairs', 'main'],
|
|
['recruit-redirect', 'officers', 'nation/recruit-message', 'main'],
|
|
['login', 'login', 'login', '.login-redirect'],
|
|
['messages', 'messages', '', '.msg-plate'],
|
|
['recruitment', 'commands', '', '.main-page'],
|
|
['personnel-picker', 'officers', 'nation/personnel', 'main'],
|
|
['unique-auction', 'auction', 'auction?type=unique', 'main'],
|
|
['main', 'main', '', '.main-page'],
|
|
['selection', 'selection', 'select-general', 'main'],
|
|
['notfound', 'public', 'typography-not-found', 'h1'],
|
|
['public', 'public', 'public', '.public-page'],
|
|
['my-page', 'menus', 'my-page', '.legacy-page'],
|
|
['settings', 'menus', 'my-settings', 'main'],
|
|
['traffic', 'menus', 'traffic', 'main'],
|
|
['battle-center', 'menus', 'battle-center', '.battle-page'],
|
|
['chief', 'commands', 'chief-center', '.chief-page'],
|
|
['nation-info', 'info', 'nation/info', 'main'],
|
|
['global', 'info', 'global-info', 'main'],
|
|
['current-city', 'info', 'current-city', 'main'],
|
|
['nation-cities', 'info', 'nation/cities', 'main'],
|
|
['nation-list', 'directories', 'nation-list', 'main'],
|
|
['general-list', 'directories', 'general-list', 'main'],
|
|
['npc-list', 'directories', 'npc-list', 'main'],
|
|
['personnel', 'officers', 'nation/personnel', 'main'],
|
|
['finance', 'officers', 'nation/finance', 'main'],
|
|
['tournament', 'tournament', 'tournament', 'main'],
|
|
['betting', 'tournament', 'betting', 'main'],
|
|
['nation-betting', 'nationBetting', 'nation-betting', 'main'],
|
|
['hall', 'archives', 'hall-of-fame', 'main'],
|
|
['dynasty', 'archives', 'dynasty', 'main'],
|
|
['dynasty-detail', 'archives', 'dynasty/1', 'main'],
|
|
['yearbook', 'archives', 'yearbook', 'main'],
|
|
['nation-generals', 'secret', 'nation/generals', 'main'],
|
|
['secret', 'secret', 'nation/secret', 'main'],
|
|
['troop', 'troop', 'troop', 'main'],
|
|
['board', 'board', 'board', 'main'],
|
|
['auction', 'auction', 'auction', 'main'],
|
|
['diplomacy', 'diplomacy', 'diplomacy', '.diplomacy-view'],
|
|
['past', 'past', 'past-plays', 'main'],
|
|
['npc-policy', 'npc', 'npc-control', 'main'],
|
|
['possession', 'possession', 'join', 'main'],
|
|
['join', 'join', 'join', 'main'],
|
|
['simulator', 'simulator', 'battle-simulator', 'main'],
|
|
['best', 'ranking', 'best-general', 'main'],
|
|
['survey', 'ranking', 'survey', 'main'],
|
|
['inherit', 'inherit', 'inherit', 'main'],
|
|
];
|
|
// Keep the inventory tied to the active router, including redirects and fallback.
|
|
const routerSource = await fs.readFile(path.join(root, 'app/game-frontend/src/router/index.ts'), 'utf8');
|
|
const routePaths = [...routerSource.matchAll(/\bpath: '([^']+)'/g)].map((match) => match[1]);
|
|
const normalizeRoute = (route) => {
|
|
const pathname = '/' + route.split('?')[0];
|
|
if (pathname === '/typography-not-found') return '/:pathMatch(.*)*';
|
|
return pathname.replace(/^\/dynasty\/[^/]+$/, '/dynasty/:id');
|
|
};
|
|
const sceneRoutes = new Set(scenes.map((scene) => normalizeRoute(scene[2])));
|
|
const missingRoutes = routePaths.filter((route) => !sceneRoutes.has(route));
|
|
if (missingRoutes.length) throw new Error('Routes without typography scenes: ' + missingRoutes.join(', '));
|
|
await fs.mkdir(out, { recursive: true });
|
|
await fs.writeFile(path.join(out, 'route-coverage.json'), JSON.stringify({ routePaths, scenes }, null, 2));
|
|
const filter = process.env.SCENES?.split(',');
|
|
const nameKind = process.env.NAME_KIND ?? 'cjk';
|
|
const generalName =
|
|
nameKind === 'ascii'
|
|
? 'W'.repeat(18)
|
|
: nameKind === 'short4'
|
|
? '가나다라'
|
|
: nameKind === 'mid7'
|
|
? '가나다라마바사'
|
|
: '가나다라마바사아자';
|
|
const nationName = nameKind === 'ascii' ? 'M'.repeat(18) : '대한민국천하통일국';
|
|
function transform(value, key = '', parent = '') {
|
|
if (Array.isArray(value)) {
|
|
if (key === 'nationList')
|
|
return value.map((row) =>
|
|
Array.isArray(row) ? row.map((v, i) => (i === 1 ? nationName : v)) : transform(row, key, parent)
|
|
);
|
|
return value.map((v) => transform(v, key, parent));
|
|
}
|
|
if (!value || typeof value !== 'object') return value;
|
|
const obj = {};
|
|
const isGeneral =
|
|
['npcState', 'officerLevel', 'picture', 'stats', 'leadership', 'crew', 'generalId'].some((k) => k in value) ||
|
|
/general|chief|officer|leader|participant|candidate|winner|loser|owner/i.test(key);
|
|
const isNation =
|
|
/nation/i.test(key) || ('color' in value && ('level' in value || 'capital' in value || 'power' in value));
|
|
for (const [k, v] of Object.entries(value)) {
|
|
if (typeof v === 'string' && /^(nationName|nation|국가)$/i.test(k)) obj[k] = nationName;
|
|
else if (
|
|
typeof v === 'string' &&
|
|
/^(generalName|authorName|sellerName|hostName|bidderName|leaderName|chiefName|officerName|kingName|l\d+name)$/i.test(
|
|
k
|
|
)
|
|
)
|
|
obj[k] = generalName;
|
|
else if (k === 'name' && typeof v === 'string' && (isGeneral || isNation))
|
|
obj[k] = isGeneral ? generalName : nationName;
|
|
else obj[k] = transform(v, k, key);
|
|
}
|
|
return obj;
|
|
}
|
|
const browser = await chromium.launch({ headless: true });
|
|
const manifest = [];
|
|
for (const [id, group, route, ready] of scenes.filter((s) => !filter || filter.includes(s[0]))) {
|
|
for (const width of (process.env.WIDTHS ?? '500,1000').split(',').map(Number)) {
|
|
const dir = path.join(out, phase, `${id}-${width}-${nameKind}`);
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const context = await browser.newContext({
|
|
baseURL: origin + prefix + '/',
|
|
viewport: { width: process.env.MOBILE ? 390 : width, height: process.env.MOBILE ? 844 : 900 },
|
|
isMobile: !!process.env.MOBILE,
|
|
deviceScaleFactor: 1,
|
|
locale: 'ko-KR',
|
|
timezoneId: 'UTC',
|
|
colorScheme: 'dark',
|
|
});
|
|
const page = await context.newPage();
|
|
await page.addInitScript(() => {
|
|
window.__typographyCanvasFonts = [];
|
|
const original = CanvasRenderingContext2D.prototype.fillText;
|
|
CanvasRenderingContext2D.prototype.fillText = function (...args) {
|
|
if (!window.__typographyCanvasFonts.includes(this.font)) window.__typographyCanvasFonts.push(this.font);
|
|
return original.apply(this, args);
|
|
};
|
|
});
|
|
const errors = [],
|
|
responses = [];
|
|
page.on('pageerror', (e) => errors.push(e.message));
|
|
const proxy = new Proxy(page, {
|
|
get(target, key) {
|
|
if (key === 'route')
|
|
return async (pattern, handler, options) =>
|
|
target.route(
|
|
typeof pattern === 'string' ? pattern.replaceAll('/che/', prefix + '/') : pattern,
|
|
async (route, request) => {
|
|
const rp = new Proxy(route, {
|
|
get(rt, rk) {
|
|
if (rk === 'fulfill')
|
|
return async (options) => {
|
|
let body = options.json;
|
|
if (
|
|
body === undefined &&
|
|
typeof options.body === 'string' &&
|
|
(options.body.startsWith('[') || options.body.startsWith('{'))
|
|
)
|
|
try {
|
|
body = JSON.parse(options.body);
|
|
} catch {
|
|
// Non-JSON fixture bodies are passed through unchanged.
|
|
}
|
|
if (body !== undefined) {
|
|
body = transform(body);
|
|
responses.push({ url: request.url(), body });
|
|
return rt.fulfill({
|
|
...options,
|
|
json: undefined,
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
return rt.fulfill(options);
|
|
};
|
|
const v = Reflect.get(rt, rk, rt);
|
|
return typeof v === 'function' ? v.bind(rt) : v;
|
|
},
|
|
});
|
|
try {
|
|
return await handler(rp, request);
|
|
} catch (e) {
|
|
errors.push('fixture: ' + e.message);
|
|
await route.fulfill({
|
|
status: 500,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: {
|
|
message: e.message,
|
|
code: -32603,
|
|
data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500 },
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
},
|
|
options
|
|
);
|
|
const v = Reflect.get(target, key, target);
|
|
return typeof v === 'function' ? v.bind(target) : v;
|
|
},
|
|
});
|
|
try {
|
|
await setup[group](proxy);
|
|
await page.addInitScript(
|
|
({ mode, profile }) => {
|
|
localStorage.setItem('sammo-game-profile', profile);
|
|
localStorage.setItem('sam.screenMode', mode);
|
|
localStorage.removeItem('sam_customCSS');
|
|
},
|
|
{ mode: process.env.MOBILE ? `${width}px` : 'auto', profile }
|
|
);
|
|
// Serve the exact installed source font faces and real workspace images.
|
|
if (fontRoot) {
|
|
await page.route('**/pretendard.css', (r) =>
|
|
r.fulfill({ contentType: 'text/css', path: path.join(fontRoot, 'source.css') })
|
|
);
|
|
await page.route('**/*.woff2', async (r) => {
|
|
const file = new URL(r.request().url()).pathname.split('/').at(-1);
|
|
try {
|
|
await r.fulfill({ contentType: 'font/woff2', path: path.join(fontRoot, file) });
|
|
} catch {
|
|
await r.abort();
|
|
}
|
|
});
|
|
}
|
|
await page.route(/\/(?:image\/|sam-image\.hided\.net\/)/, async (r) => {
|
|
const u = new URL(r.request().url());
|
|
let rel = u.pathname.replace(/^\/image\//, '');
|
|
if (u.hostname === 'sam-image.hided.net') rel = u.pathname.slice(1);
|
|
if (rel.includes('..')) return r.abort();
|
|
try {
|
|
const body = await fs.readFile(path.resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, rel));
|
|
await r.fulfill({
|
|
body,
|
|
contentType: rel.endsWith('.png')
|
|
? 'image/png'
|
|
: rel.endsWith('.gif')
|
|
? 'image/gif'
|
|
: 'image/jpeg',
|
|
});
|
|
} catch {
|
|
await r.fallback();
|
|
}
|
|
});
|
|
await page.goto(origin + prefix + '/' + route, { waitUntil: 'networkidle', timeout: 30000 });
|
|
await page.locator(ready).first().waitFor({ state: 'visible', timeout: 5000 });
|
|
const requestedPath = route.split('?')[0];
|
|
const expectedPath = ['nation/affairs', 'nation/recruit-message'].includes(requestedPath)
|
|
? 'nation/finance'
|
|
: requestedPath;
|
|
if (new URL(page.url()).pathname !== `${prefix}/${expectedPath}`)
|
|
throw new Error(`Unexpected route after guard: ${page.url()}`);
|
|
|
|
if (id === 'messages') await page.locator('.msg-plate:visible').first().scrollIntoViewIfNeeded();
|
|
if (id === 'recruitment') {
|
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
|
const picker = page.getByTestId('command-picker');
|
|
await picker.getByRole('button', { name: '내정', exact: true }).click();
|
|
await picker.getByRole('button', { name: '징병', exact: true }).click();
|
|
await page.getByTestId('recruitment-command-form').waitFor();
|
|
}
|
|
if (id === 'personnel-picker') {
|
|
await page.getByRole('button', { name: '주부 변경하기', exact: true }).click();
|
|
await page.getByTestId('personnel-selection-dialog').waitFor();
|
|
}
|
|
if (id === 'nation-betting') {
|
|
await page.getByRole('button', { name: /두 번째 천통국 베팅/u }).click();
|
|
await page.getByRole('spinbutton', { name: '베팅 금액' }).waitFor();
|
|
}
|
|
if (id === 'join') {
|
|
await page.locator('input[type=text]').first().fill(generalName);
|
|
}
|
|
if (id === 'simulator') {
|
|
for (const input of await page.getByLabel('이름', { exact: true }).all()) await input.fill(generalName);
|
|
}
|
|
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
await Promise.all([...document.images].map((i) => i.decode().catch(() => {})));
|
|
});
|
|
await page.waitForTimeout(150);
|
|
const data = await page.evaluate(
|
|
({ generalName, nationName }) => {
|
|
const nodes = [];
|
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
let n;
|
|
function selector(e) {
|
|
const parts = [];
|
|
for (let p = e; p && p !== document.body; p = p.parentElement) {
|
|
let s = p.tagName.toLowerCase();
|
|
if (p.id) s += '#' + p.id;
|
|
else if (p.classList.length) s += '.' + [...p.classList].join('.');
|
|
parts.unshift(s);
|
|
}
|
|
return parts.join(' > ');
|
|
}
|
|
while ((n = walker.nextNode())) {
|
|
const text = n.textContent.trim();
|
|
const e = n.parentElement;
|
|
if (!text || !e || ['SCRIPT', 'STYLE', 'OPTION'].includes(e.tagName)) continue;
|
|
const s = getComputedStyle(e);
|
|
if (s.display === 'none' || s.visibility === 'hidden' || !e.getClientRects().length) continue;
|
|
const range = document.createRange();
|
|
range.selectNodeContents(n);
|
|
const r = range.getBoundingClientRect();
|
|
if (!r.width || !r.height) continue;
|
|
const er = e.getBoundingClientRect();
|
|
const isName = text.includes(generalName) || text.includes(nationName);
|
|
const clipped = [];
|
|
for (let p = e; p && p !== document.body; p = p.parentElement) {
|
|
const ps = getComputedStyle(p),
|
|
pr = p.getBoundingClientRect();
|
|
if (
|
|
['hidden', 'clip', 'auto', 'scroll'].includes(ps.overflowX) &&
|
|
(r.left < pr.left - 1 || r.right > pr.right + 1)
|
|
)
|
|
clipped.push({ selector: selector(p), axis: 'x', overflow: ps.overflowX });
|
|
if (
|
|
['hidden', 'clip'].includes(ps.overflowY) &&
|
|
(r.top < pr.top - 1 || r.bottom > pr.bottom + 1)
|
|
)
|
|
clipped.push({ selector: selector(p), axis: 'y', overflow: ps.overflowY });
|
|
}
|
|
nodes.push({
|
|
text,
|
|
fitMax: e.dataset.fontFitMax ?? null,
|
|
isSubordinate: !!e.closest('small, sub, sup, .legacy-small, .sammo-text-smaller'),
|
|
selector: selector(e),
|
|
isName,
|
|
font: s.fontSize,
|
|
lineHeight: s.lineHeight,
|
|
weight: s.fontWeight,
|
|
whiteSpace: s.whiteSpace,
|
|
textOverflow: s.textOverflow,
|
|
rect: { x: r.x, y: r.y, width: r.width, height: r.height },
|
|
element: { x: er.x, y: er.y, width: er.width, height: er.height },
|
|
clipped,
|
|
});
|
|
}
|
|
return {
|
|
url: location.href,
|
|
canvasFonts: window.__typographyCanvasFonts,
|
|
smallSteps: [...document.querySelectorAll('small, sub, sup, .legacy-small')]
|
|
.filter(
|
|
(element) =>
|
|
element.getClientRects().length && getComputedStyle(element).visibility !== 'hidden'
|
|
)
|
|
.map((element) => ({
|
|
selector: selector(element),
|
|
font: getComputedStyle(element).fontSize,
|
|
parentFont: getComputedStyle(element.parentElement).fontSize,
|
|
})),
|
|
viewport: {
|
|
width: innerWidth,
|
|
height: innerHeight,
|
|
scale: visualViewport?.scale,
|
|
visualWidth: visualViewport?.width,
|
|
mode: localStorage.getItem('sam.screenMode'),
|
|
},
|
|
document: {
|
|
width: document.documentElement.scrollWidth,
|
|
height: document.documentElement.scrollHeight,
|
|
},
|
|
fontLoaded: document.fonts.check('14px Pretendard'),
|
|
fontFaces: [...document.fonts].map((f) => ({
|
|
family: f.family,
|
|
weight: f.weight,
|
|
status: f.status,
|
|
})),
|
|
nodes,
|
|
images: [...document.images].map((i) => ({
|
|
src: i.getAttribute('src'),
|
|
naturalWidth: i.naturalWidth,
|
|
naturalHeight: i.naturalHeight,
|
|
objectFit: getComputedStyle(i).objectFit,
|
|
})),
|
|
};
|
|
},
|
|
{ generalName, nationName }
|
|
);
|
|
if (!data.fontFaces.some((f) => f.family.includes('Pretendard') && f.status === 'loaded'))
|
|
throw new Error('Pretendard was not loaded');
|
|
const nextSize = { '24px': '16px', '16px': '14px', '14px': '12px', '12px': '10px', '10px': '10px' };
|
|
const wrongSteps = data.smallSteps.filter(
|
|
(node) => nextSize[node.parentFont] && node.font !== nextSize[node.parentFont]
|
|
);
|
|
if (wrongSteps.length) errors.push('Small step mismatch: ' + JSON.stringify(wrongSteps));
|
|
const offTier = data.nodes.filter(
|
|
(n) =>
|
|
!['0px', '12px', '14px', '16px', '24px'].includes(n.font) &&
|
|
!(n.font === '10px' && n.isSubordinate) &&
|
|
!(
|
|
n.fitMax &&
|
|
[12, 14, 16, 24].includes(Number(n.fitMax)) &&
|
|
parseFloat(n.font) <= Number(n.fitMax) &&
|
|
parseFloat(n.font) >= 10
|
|
)
|
|
);
|
|
if (offTier.length)
|
|
errors.push(
|
|
'Off-tier text: ' +
|
|
JSON.stringify(offTier.map((n) => ({ font: n.font, selector: n.selector })).slice(0, 20))
|
|
);
|
|
await page.screenshot({ path: path.join(dir, 'full.png'), fullPage: true });
|
|
await page.screenshot({ path: path.join(dir, 'viewport.png') });
|
|
await fs.writeFile(path.join(dir, 'dom.json'), JSON.stringify(data, null, 2));
|
|
await fs.writeFile(path.join(dir, 'dom.html'), await page.content());
|
|
await fs.writeFile(path.join(dir, 'responses.json'), JSON.stringify(responses, null, 2));
|
|
const entry = {
|
|
id,
|
|
width,
|
|
nameKind,
|
|
status: 'captured',
|
|
nodes: data.nodes.length,
|
|
names: data.nodes.filter((n) => n.isName).length,
|
|
clippedNames: data.nodes.filter((n) => n.isName && n.clipped.length).length,
|
|
fontLoaded: data.fontLoaded,
|
|
errors,
|
|
};
|
|
manifest.push(entry);
|
|
console.log(JSON.stringify(entry));
|
|
} catch (e) {
|
|
const entry = { id, width, nameKind, status: 'failed', error: e.message, errors };
|
|
manifest.push(entry);
|
|
console.log(JSON.stringify(entry));
|
|
await page.screenshot({ path: path.join(dir, 'failure.png'), fullPage: true }).catch(() => {});
|
|
await fs.writeFile(path.join(dir, 'failure.html'), await page.content());
|
|
}
|
|
await context.close();
|
|
await fs.writeFile(path.join(out, `${phase}-${nameKind}-manifest.json`), JSON.stringify(manifest, null, 2));
|
|
}
|
|
}
|
|
await browser.close();
|
|
const failures = manifest.filter((row) => row.status !== 'captured' || row.errors.length);
|
|
console.log(
|
|
JSON.stringify({
|
|
captures: manifest.length,
|
|
failures: failures.length,
|
|
profile,
|
|
prefix,
|
|
browser: browser.version(),
|
|
})
|
|
);
|
|
if (failures.length) process.exitCode = 1;
|