feat: complete auction menu parity

This commit is contained in:
2026-07-26 05:33:33 +00:00
parent 8fac491c9d
commit 5c19d24254
16 changed files with 1721 additions and 215 deletions
@@ -0,0 +1,257 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from '@playwright/test';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const targetRoot = process.env.REF_AUCTION_URL ?? 'https://dev-sam-ref.hided.net/sam/';
const secretRoot = process.env.REF_SECRET_ROOT;
const username = process.env.REF_USER_ID ?? 'refuser1';
const passwordFile = process.env.REF_PASSWORD_FILE ?? 'user1_password';
const allowGeneralCreate = process.env.REF_CREATE_GENERAL === '1';
const outputRoot = resolve(
process.env.REF_AUCTION_ARTIFACT_DIR ?? resolve(repositoryRoot, 'test-results/auction-reference')
);
if (!secretRoot) {
throw new Error('REF_SECRET_ROOT is required.');
}
const password = (await readFile(resolve(secretRoot, passwordFile), 'utf8')).trim();
const viewports = [
{ name: 'desktop', width: 1000, height: 800 },
{ name: 'mobile', width: 500, height: 800 },
];
const login = async (context) => {
const page = await context.newPage();
await page.goto(targetRoot, { waitUntil: 'networkidle', timeout: 60_000 });
const globalSalt = await page.locator('#global_salt').inputValue();
const clientPasswordHash = createHash('sha512')
.update(globalSalt + password + globalSalt)
.digest('hex');
const response = await context.request.post(new URL('api.php?path=Login/LoginByID', targetRoot).toString(), {
data: { username, password: clientPasswordHash },
timeout: 60_000,
});
const result = await response.json();
if (!response.ok() || result.result !== true) {
throw new Error('Reference login failed.');
}
if (allowGeneralCreate) {
const joinUrl = new URL('hwe/v_join.php', targetRoot).toString();
await page.goto(joinUrl, { waitUntil: 'networkidle', timeout: 60_000 });
if (page.url().includes('v_join.php')) {
const createButton = page.getByRole('button', { name: '장수 생성', exact: true });
try {
await createButton.waitFor({ state: 'visible', timeout: 30_000 });
} catch {
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
throw new Error(`Reference general form did not render: ${page.url()} | ${pageText}`);
}
page.on('dialog', async (dialog) => dialog.accept());
await createButton.click();
await page.waitForURL((url) => !url.pathname.endsWith('/v_join.php'), { timeout: 60_000 });
}
}
await page.close();
};
const roundedRect = (rect) => ({
x: Math.round(rect.x * 100) / 100,
y: Math.round(rect.y * 100) / 100,
width: Math.round(rect.width * 100) / 100,
height: Math.round(rect.height * 100) / 100,
});
const measurePage = async (page, type) => {
const diagnostics = [];
const onPageError = (error) => diagnostics.push(`pageerror: ${error.message}`);
const onConsole = (message) => {
if (message.type() === 'error') {
diagnostics.push(`console: ${message.text()}`);
}
};
const onResponse = (response) => {
if (response.status() >= 400) {
diagnostics.push(`http ${response.status()}: ${response.url()}`);
}
};
page.on('pageerror', onPageError);
page.on('console', onConsole);
page.on('response', onResponse);
const relative = type === 'unique' ? 'hwe/v_auction.php?type=unique' : 'hwe/v_auction.php';
await page.goto(new URL(relative, targetRoot).toString(), { waitUntil: 'networkidle', timeout: 60_000 });
try {
await page.locator('#container').waitFor({ state: 'visible', timeout: 30_000 });
} catch {
const pageText = (await page.locator('body').innerText()).replace(/\s+/g, ' ').slice(0, 300);
const scripts = await page.locator('script').evaluateAll((elements) =>
elements.map((element) => ({
src: element.getAttribute('src'),
type: element.getAttribute('type'),
length: element.textContent?.length ?? 0,
}))
);
throw new Error(
`Reference auction did not render: ${page.url()} | ${await page.title()} | ${pageText} | ${JSON.stringify(scripts)} | ${diagnostics.slice(0, 8).join(' | ')}`
);
} finally {
page.off('pageerror', onPageError);
page.off('console', onConsole);
page.off('response', onResponse);
}
await page.locator('button').first().waitFor({ state: 'visible' });
await page.evaluate(() => document.fonts.ready);
const measurement = await page.evaluate(() => {
const rect = (element) => {
if (!(element instanceof HTMLElement)) {
return null;
}
const value = element.getBoundingClientRect();
return { x: value.x, y: value.y, width: value.width, height: value.height };
};
const style = (element) => {
if (!(element instanceof HTMLElement)) {
return null;
}
const value = getComputedStyle(element);
return {
color: value.color,
backgroundColor: value.backgroundColor,
backgroundImage: value.backgroundImage,
borderColor: value.borderColor,
borderWidth: value.borderWidth,
borderRadius: value.borderRadius,
fontFamily: value.fontFamily,
fontSize: value.fontSize,
fontWeight: value.fontWeight,
lineHeight: value.lineHeight,
padding: value.padding,
cursor: value.cursor,
};
};
const container = document.querySelector('#container');
const topBar = container?.firstElementChild ?? null;
const topBarButtons = [...(topBar?.querySelectorAll('button') ?? [])].map((element) => ({
text: element.textContent?.trim() ?? '',
rect: rect(element),
style: style(element),
}));
const firstButton = document.querySelector('button');
const firstInput = [...document.querySelectorAll('input')].find(
(element) => element.getBoundingClientRect().width > 0
);
const firstAuctionRow = document.querySelector('.auctionItem');
const firstAuctionRowChildren = [...(firstAuctionRow?.children ?? [])].map((element) => ({
className: element.className,
rect: rect(element),
style: style(element),
}));
const firstSection = [...document.querySelectorAll('#container > div')].find((element) =>
['쌀 구매', '쌀 판매'].includes(element.textContent?.trim() ?? '')
);
const directChildren = [...(container?.children ?? [])].slice(0, 12).map((element) => ({
tag: element.tagName,
className: element.className,
text: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 80) ?? '',
rect: rect(element),
}));
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
document: {
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
},
body: { rect: rect(document.body), style: style(document.body) },
container: { rect: rect(container), style: style(container) },
topBar: { rect: rect(topBar), style: style(topBar) },
topBarButtons,
firstButton: { rect: rect(firstButton), style: style(firstButton) },
firstInput: { rect: rect(firstInput), style: style(firstInput) },
firstAuctionRow: { rect: rect(firstAuctionRow), style: style(firstAuctionRow) },
firstAuctionRowChildren,
firstSection: { rect: rect(firstSection), style: style(firstSection) },
directChildren,
};
});
return {
...measurement,
body: {
...measurement.body,
rect: measurement.body.rect ? roundedRect(measurement.body.rect) : null,
},
container: {
...measurement.container,
rect: measurement.container.rect ? roundedRect(measurement.container.rect) : null,
},
topBar: {
...measurement.topBar,
rect: measurement.topBar.rect ? roundedRect(measurement.topBar.rect) : null,
},
topBarButtons: measurement.topBarButtons.map((button) => ({
...button,
rect: button.rect ? roundedRect(button.rect) : null,
})),
firstButton: {
...measurement.firstButton,
rect: measurement.firstButton.rect ? roundedRect(measurement.firstButton.rect) : null,
},
firstInput: {
...measurement.firstInput,
rect: measurement.firstInput.rect ? roundedRect(measurement.firstInput.rect) : null,
},
firstAuctionRow: {
...measurement.firstAuctionRow,
rect: measurement.firstAuctionRow.rect ? roundedRect(measurement.firstAuctionRow.rect) : null,
},
firstAuctionRowChildren: measurement.firstAuctionRowChildren.map((child) => ({
...child,
rect: child.rect ? roundedRect(child.rect) : null,
})),
firstSection: {
...measurement.firstSection,
rect: measurement.firstSection.rect ? roundedRect(measurement.firstSection.rect) : null,
},
directChildren: measurement.directChildren.map((child) => ({
...child,
rect: child.rect ? roundedRect(child.rect) : null,
})),
};
};
await mkdir(outputRoot, { recursive: true });
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const results = {};
try {
for (const viewport of viewports) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
colorScheme: 'dark',
});
try {
await login(context);
const page = await context.newPage();
for (const type of ['resource', 'unique']) {
results[`${viewport.name}-${type}`] = await measurePage(page, type);
await page.screenshot({
path: resolve(outputRoot, `${viewport.name}-${type}.png`),
fullPage: true,
});
}
} finally {
await context.close();
}
}
} finally {
await browser.close();
}
const outputPath = resolve(outputRoot, 'computed-dom.json');
await writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`);
console.log(JSON.stringify({ outputPath, views: Object.keys(results) }));
@@ -451,6 +451,10 @@ describe('auction integration flow', () => {
const initialScore = await redis.zScore(keys.timerKey, String(auction.id));
expect(Number(initialScore)).toBe(initialCloseAt.getTime());
await expect(hostClient.auction.bidBuyRice.mutate({ auctionId: auction.id, amount: 300 })).rejects.toThrow(
'자신이 연 경매에 입찰할 수 없습니다.'
);
const bidder1Client = createGameClient(gameUrl, gameServer.config.trpcPath, {
value: bidder1.accessToken,
});