feat: complete nation personnel and finance parity

This commit is contained in:
2026-07-26 04:30:12 +00:00
parent b27c529a3d
commit f4c09a9b02
16 changed files with 2834 additions and 1222 deletions
@@ -0,0 +1,166 @@
import { chromium } from '@playwright/test';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/';
const username = process.env.REF_PARITY_USER ?? 'refadmin';
const passwordFile = process.env.REF_PARITY_PASSWORD_FILE;
const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-nation-offices');
if (!passwordFile) {
throw new Error('REF_PARITY_PASSWORD_FILE is required.');
}
const password = (await readFile(passwordFile, 'utf8')).trim();
await mkdir(artifactRoot, { recursive: true });
const login = async (context, page) => {
await page.goto(baseUrl, { waitUntil: 'networkidle' });
const globalSalt = await page.locator('#global_salt').inputValue();
const passwordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), {
data: { username, password: passwordHash },
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed.');
}
};
const rect = (element) => {
const value = element.getBoundingClientRect();
return { x: value.x, y: value.y, width: value.width, height: value.height };
};
const style = (element) => {
const value = getComputedStyle(element);
return {
display: value.display,
gridTemplateColumns: value.gridTemplateColumns,
fontFamily: value.fontFamily,
fontSize: value.fontSize,
lineHeight: value.lineHeight,
color: value.color,
backgroundColor: value.backgroundColor,
backgroundImage: value.backgroundImage,
borderTopColor: value.borderTopColor,
padding: value.padding,
cursor: value.cursor,
};
};
const browser = await chromium.launch({ headless: true });
try {
const result = {};
for (const viewport of [
{ name: 'desktop', width: 1000, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
]) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
colorScheme: 'dark',
});
const page = await context.newPage();
const failedResources = [];
const consoleErrors = [];
page.on('response', (response) => {
if (response.status() >= 400) failedResources.push(`${response.status()} ${response.url()}`);
});
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
await login(context, page);
await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' });
await page.goto(new URL('hwe/b_myBossInfo.php', baseUrl).toString(), { waitUntil: 'networkidle' });
const personnel = await page.evaluate(
({ rectSource, styleSource }) => {
const rect = new Function(`return (${rectSource})`)();
const style = new Function(`return (${styleSource})`)();
const tables = [...document.querySelectorAll('table')];
const firstIcon = document.querySelector('.generalIcon');
const firstSelect = document.querySelector('select');
const firstButton = document.querySelector('button, input[type="button"]');
return {
body: { rect: rect(document.body), style: style(document.body) },
tables: tables.slice(0, 8).map((table) => ({ rect: rect(table), style: style(table) })),
icon: firstIcon ? { rect: rect(firstIcon), style: style(firstIcon) } : null,
select: firstSelect ? { rect: rect(firstSelect), style: style(firstSelect) } : null,
button: firstButton ? { rect: rect(firstButton), style: style(firstButton) } : null,
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
};
},
{ rectSource: rect.toString(), styleSource: style.toString() }
);
await page.screenshot({
path: resolve(artifactRoot, `ref-personnel-${viewport.name}.png`),
fullPage: true,
});
await page.goto(new URL('hwe/v_nationStratFinan.php', baseUrl).toString(), { waitUntil: 'networkidle' });
try {
await page.locator('#container').waitFor({ timeout: 10_000 });
} catch {
await page.screenshot({
path: resolve(artifactRoot, `ref-finance-${viewport.name}-error.png`),
fullPage: true,
});
throw new Error(
`Reference finance failed to mount: ${JSON.stringify({
url: page.url(),
text: (await page.locator('body').innerText()).slice(0, 500),
failedResources,
consoleErrors,
})}`
);
}
const finance = await page.evaluate(
({ rectSource, styleSource }) => {
const rect = new Function(`return (${rectSource})`)();
const style = new Function(`return (${styleSource})`)();
const pick = (selector) => {
const element = document.querySelector(selector);
return element ? { rect: rect(element), style: style(element) } : null;
};
return {
body: pick('body'),
container: pick('#container'),
topBar: pick('#container > :first-child'),
diplomacyTitle: pick('.diplomacyTitle'),
diplomacyRow: pick('.diplomacyTable .tRow'),
noticeTitle: pick('.noticeTitle'),
noticeForm: pick('#noticeForm'),
scoutForm: pick('#scoutMsgForm'),
financeTitle: pick('.financeTitle'),
financeGrid: pick('.financeTitle + .row'),
firstInput: pick('input[type="number"]'),
firstButton: pick('button'),
document: {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
},
};
},
{ rectSource: rect.toString(), styleSource: style.toString() }
);
await page.screenshot({
path: resolve(artifactRoot, `ref-finance-${viewport.name}.png`),
fullPage: true,
});
result[viewport.name] = { personnel, finance };
await context.close();
}
await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`);
} finally {
await browser.close();
}