feat: synchronize account icons across game profiles
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const targetUrl = process.env.REF_ACCOUNT_URL;
|
||||
const passwordFile = process.env.REF_ACCOUNT_PASSWORD_FILE;
|
||||
const username = process.env.REF_ACCOUNT_USERNAME ?? 'refuser1';
|
||||
const outputPath = process.env.REF_ACCOUNT_MODAL_OUTPUT;
|
||||
const screenshotPath = process.env.REF_ACCOUNT_MODAL_SCREENSHOT;
|
||||
const viewportWidth = Number.parseInt(process.env.REF_VIEWPORT_WIDTH ?? '1440', 10);
|
||||
const viewportHeight = Number.parseInt(process.env.REF_VIEWPORT_HEIGHT ?? '900', 10);
|
||||
|
||||
if (!targetUrl || !passwordFile || process.env.REF_ALLOW_ICON_DELETE !== '1') {
|
||||
throw new Error(
|
||||
'REF_ACCOUNT_URL, REF_ACCOUNT_PASSWORD_FILE, and REF_ALLOW_ICON_DELETE=1 are required for the disposable Ref account.'
|
||||
);
|
||||
}
|
||||
|
||||
const password = (await readFile(passwordFile, 'utf8')).trim();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: viewportWidth, height: viewportHeight },
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(targetUrl, { waitUntil: 'networkidle' });
|
||||
const globalSalt = await page.locator('#global_salt').inputValue();
|
||||
const passwordHash = createHash('sha512')
|
||||
.update(globalSalt + password + globalSalt)
|
||||
.digest('hex');
|
||||
const loginResponse = await context.request.post(new URL('api.php?path=Login/LoginByID', targetUrl).toString(), {
|
||||
data: { username, password: passwordHash },
|
||||
});
|
||||
const loginResult = await loginResponse.json();
|
||||
if (!loginResponse.ok() || loginResult.result !== true) {
|
||||
throw new Error('Ref disposable account login failed.');
|
||||
}
|
||||
|
||||
await page.goto(new URL('i_entrance/user_info.php', targetUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
await page.locator('#slot_nickname').waitFor({ state: 'visible' });
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.locator('#btn_remove_icon').click();
|
||||
const modal = page.locator('#chooseServer');
|
||||
await modal.waitFor({ state: 'visible' });
|
||||
await page.locator('#chooseServerForm input').first().waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const measure = () =>
|
||||
page.evaluate(() => {
|
||||
const rect = (selector) => {
|
||||
const value = document.querySelector(selector).getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const style = (selector) => {
|
||||
const value = getComputedStyle(document.querySelector(selector));
|
||||
return {
|
||||
color: value.color,
|
||||
backgroundColor: value.backgroundColor,
|
||||
borderColor: value.borderColor,
|
||||
borderRadius: value.borderRadius,
|
||||
boxShadow: value.boxShadow,
|
||||
fontFamily: value.fontFamily,
|
||||
fontSize: value.fontSize,
|
||||
fontWeight: value.fontWeight,
|
||||
lineHeight: value.lineHeight,
|
||||
letterSpacing: value.letterSpacing,
|
||||
wordSpacing: value.wordSpacing,
|
||||
whiteSpace: value.whiteSpace,
|
||||
padding: value.padding,
|
||||
outline: value.outline,
|
||||
opacity: value.opacity,
|
||||
transitionDuration: value.transitionDuration,
|
||||
transitionProperty: value.transitionProperty,
|
||||
transitionTimingFunction: value.transitionTimingFunction,
|
||||
transform: value.transform,
|
||||
};
|
||||
};
|
||||
const textLines = (selector) => {
|
||||
const root = document.querySelector(selector);
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const lines = new Map();
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
for (let offset = 0; offset < node.textContent.length; offset += 1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset + 1);
|
||||
const box = range.getBoundingClientRect();
|
||||
if (box.width > 0) {
|
||||
const top = Math.round(box.top);
|
||||
lines.set(top, `${lines.get(top) ?? ''}${node.textContent[offset]}`);
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return [...lines.entries()].sort(([left], [right]) => left - right).map(([, text]) => text);
|
||||
};
|
||||
return {
|
||||
viewport: { width: innerWidth, height: innerHeight, deviceScaleFactor: devicePixelRatio },
|
||||
dialog: rect('#chooseServer .modal-dialog'),
|
||||
content: rect('#chooseServer .modal-content'),
|
||||
header: rect('#chooseServer .modal-header'),
|
||||
title: rect('#chooseServer .modal-title'),
|
||||
titleLines: textLines('#chooseServer .modal-title'),
|
||||
body: rect('#chooseServer .modal-body'),
|
||||
footer: rect('#chooseServer .modal-footer'),
|
||||
close: rect('#chooseServer .close'),
|
||||
secondary: rect('#chooseServer .btn-secondary'),
|
||||
apply: rect('#modal-apply'),
|
||||
dialogStyle: style('#chooseServer .modal-content'),
|
||||
motionStyle: style('#chooseServer .modal-dialog'),
|
||||
titleStyle: style('#chooseServer .modal-title'),
|
||||
closeStyle: style('#chooseServer .close'),
|
||||
secondaryStyle: style('#chooseServer .btn-secondary'),
|
||||
applyStyle: style('#modal-apply'),
|
||||
modalStyle: style('#chooseServer'),
|
||||
backdropStyle: style('.modal-backdrop'),
|
||||
checked: Array.from(document.querySelectorAll('#chooseServerForm input')).map((input) => input.checked),
|
||||
labels: Array.from(document.querySelectorAll('#chooseServerForm label')).map((label) =>
|
||||
label.textContent?.trim()
|
||||
),
|
||||
activeElement: document.activeElement?.id || document.activeElement?.tagName,
|
||||
titleFontMetrics: (() => {
|
||||
const title = document.querySelector('#chooseServer .modal-title');
|
||||
const titleStyle = getComputedStyle(title);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
context.font = titleStyle.font;
|
||||
return {
|
||||
font: context.font,
|
||||
textWidth: context.measureText('새 아이콘을 적용할 서버를 선택하세요.').width,
|
||||
selectionPrefixWidth: context.measureText('새 아이콘을 적용할 서버를 선택').width,
|
||||
pretendardLoaded: document.fonts.check('20px Pretendard'),
|
||||
};
|
||||
})(),
|
||||
overflow: {
|
||||
documentFits: document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
dialogFits:
|
||||
document.querySelector('#chooseServer .modal-dialog').scrollWidth <=
|
||||
document.querySelector('#chooseServer .modal-dialog').clientWidth,
|
||||
headerFits:
|
||||
document.querySelector('#chooseServer .modal-header').scrollWidth <=
|
||||
document.querySelector('#chooseServer .modal-header').clientWidth,
|
||||
},
|
||||
};
|
||||
});
|
||||
const measured = await measure();
|
||||
|
||||
if (screenshotPath) {
|
||||
await page.locator('#chooseServer .modal-content').screenshot({
|
||||
path: screenshotPath,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const apply = page.locator('#modal-apply');
|
||||
await apply.hover();
|
||||
measured.applyHoverStyle = (await measure()).applyStyle;
|
||||
await apply.focus();
|
||||
measured.applyFocusStyle = (await measure()).applyStyle;
|
||||
const applyBox = await apply.boundingBox();
|
||||
if (!applyBox) {
|
||||
throw new Error('Ref apply button has no rendered box.');
|
||||
}
|
||||
await page.mouse.move(applyBox.x + applyBox.width / 2, applyBox.y + applyBox.height / 2);
|
||||
await page.mouse.down();
|
||||
measured.applyActiveStyle = (await measure()).applyStyle;
|
||||
await page.mouse.move(5, 5);
|
||||
await page.mouse.up();
|
||||
await page.locator('#chooseServerForm input').first().focus();
|
||||
measured.checkboxFocusStyle = await page
|
||||
.locator('#chooseServerForm input')
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
const value = getComputedStyle(element);
|
||||
return { outline: value.outline, opacity: value.opacity };
|
||||
});
|
||||
await page.keyboard.press('Tab');
|
||||
measured.keyboardFocus = {
|
||||
activeElement: await page.evaluate(() => document.activeElement?.id || document.activeElement?.tagName),
|
||||
applyStyle: (await measure()).applyStyle,
|
||||
};
|
||||
|
||||
await page.mouse.click(5, 5);
|
||||
measured.backdropClickKeepsOpen = await modal.isVisible();
|
||||
await page.waitForTimeout(30);
|
||||
measured.staticBackdropMotionStyle = (await measure()).motionStyle;
|
||||
await page.waitForTimeout(350);
|
||||
measured.staticBackdropSettledMotionStyle = (await measure()).motionStyle;
|
||||
await page.keyboard.press('Escape');
|
||||
measured.escapeKeepsOpen = await modal.isVisible();
|
||||
|
||||
const output = `${JSON.stringify(measured, null, 2)}\n`;
|
||||
if (outputPath) {
|
||||
await writeFile(outputPath, output);
|
||||
} else {
|
||||
process.stdout.write(output);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
@@ -268,6 +268,7 @@ validate_marker_registry() {
|
||||
cd "$workspace_root"
|
||||
rg -o --no-filename \
|
||||
'process\.env\.[A-Z0-9_]+_DATABASE_URL' \
|
||||
app/gateway-api/test \
|
||||
app/game-api/test \
|
||||
app/game-engine/test \
|
||||
tools/integration-tests/test \
|
||||
@@ -453,6 +454,9 @@ pnpm --filter @sammo-ts/infra prisma:generate
|
||||
pnpm --filter @sammo-ts/common build
|
||||
pnpm --filter @sammo-ts/infra build
|
||||
|
||||
GATEWAY_MIGRATION_TEST_DATABASE_URL=$base_database_url \
|
||||
pnpm --filter @sammo-ts/infra verify:migration:account-icon
|
||||
|
||||
cleanup_resources_started=1
|
||||
create_owned_schema "$integration_schema"
|
||||
create_owned_schema "$npc_possession_schema"
|
||||
@@ -509,6 +513,9 @@ immediate_action_database_url=$(build_database_url "$immediate_action_schema")
|
||||
pnpm --filter @sammo-ts/infra prisma:db:push:game
|
||||
)
|
||||
export IMMEDIATE_ACTION_DATABASE_URL=$immediate_action_database_url
|
||||
run_marked_tests app/game-api \
|
||||
"$(markers_for_mode immediate_action)" \
|
||||
"immediate_action_api_postgresql"
|
||||
run_marked_tests app/game-engine \
|
||||
"$(markers_for_mode immediate_action)" \
|
||||
"immediate_action_postgresql"
|
||||
@@ -522,6 +529,10 @@ gateway_runtime_database_url=$(build_database_url "$gateway_runtime_schema")
|
||||
pnpm --filter @sammo-ts/infra prisma:db:push:gateway
|
||||
)
|
||||
export GATEWAY_RUNTIME_ACTION_DATABASE_URL=$gateway_runtime_database_url
|
||||
export GATEWAY_RUNTIME_INTEGRATION_SCHEMA=$gateway_runtime_schema
|
||||
run_marked_tests app/gateway-api \
|
||||
"$(markers_for_mode gateway_runtime)" \
|
||||
"gateway_runtime_gateway_api_postgresql"
|
||||
run_marked_tests app/game-engine \
|
||||
"$(markers_for_mode gateway_runtime)" \
|
||||
"gateway_runtime_postgresql"
|
||||
|
||||
Reference in New Issue
Block a user