Files
healer-man/scripts/playtest-thor-ui.cjs
T
2026-09-14 13:20:20 -04:00

250 lines
17 KiB
JavaScript

const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const directory = path.resolve('playtest-artifacts/thor-ui');
fs.mkdirSync(directory, { recursive: true });
const base = process.env.THOR_TEST_URL || 'http://127.0.0.1:5176';
const results = [];
(async () => {
console.log('Launching Thor UI browser');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
console.log('Browser launched');
const context = await browser.newContext({ hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 13; AYN Thor) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' });
await context.addInitScript(() => performance.setResourceTimingBufferSize(20000));
const top = await context.newPage();
const bottom = await context.newPage();
console.log('Two pages ready');
const errors = [];
for (const [page, width, height, diagonal] of [[top, 1920, 1080, 6], [bottom, 1240, 1080, 3.92]]) {
const ppi = Math.hypot(width, height) / diagonal;
const viewport = { width: Math.round(width / ppi * 160), height: Math.round(height / ppi * 160) };
await page.setViewportSize(viewport);
const cdp = await context.newCDPSession(page);
await cdp.send('Emulation.setDeviceMetricsOverride', { ...viewport, deviceScaleFactor: ppi / 160, mobile: false });
console.log('Configured panel', viewport);
page.setDefaultTimeout(60000);
page.on('pageerror', (error) => errors.push(error.message));
}
const capture = async (name) => {
for (const [surface, page] of [['top', top], ['bottom', bottom]]) {
await page.screenshot({ path: path.join(directory, `${name}-${surface}.png`) });
const overflow = await page.evaluate(() => {
const root = document.querySelector('#root');
const buttons = [...document.querySelectorAll('button')].filter((node) => node.getBoundingClientRect().width && getComputedStyle(node).visibility !== 'hidden');
const clipped = buttons.filter((node) => {
const rect = node.getBoundingClientRect();
if (rect.right <= innerWidth + 1 && rect.left >= -1 && rect.bottom <= innerHeight + 1 && rect.top >= -1) return false;
for (let parent = node.parentElement; parent && parent !== root; parent = parent.parentElement) {
const css = getComputedStyle(parent);
if (/(auto|scroll)/.test(css.overflow + css.overflowY + css.overflowX)) return false;
}
return true;
});
return { width: innerWidth, height: innerHeight, clipped: clipped.map((node) => node.textContent.trim().slice(0, 60)) };
});
results.push({ name, surface, ...overflow });
}
console.log(`Captured ${name}`);
};
try {
console.log('Opening top');
await top.goto(`${base}/?display=top&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
console.log('Top DOM ready');
await top.getByLabel('Username', { exact: true }).waitFor();
await bottom.goto(`${base}/?display=bottom&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
await bottom.locator('.companion-panel').waitFor();
await capture('01-login');
await top.getByRole('button', { name: /Play offline/ }).click();
await top.locator('.character-select-screen').waitFor();
await capture('02-roster-empty');
await top.getByRole('button', { name: /Create character/ }).click();
await top.locator('.character-create-screen').waitFor();
await capture('03-character-create');
await top.getByRole('button', { name: /Customize & name/ }).tap();
await top.getByLabel('Character name').fill('Thortester');
await capture('03b-appearance');
await top.getByRole('button', { name: /Create character/ }).tap();
await top.locator('.character-select-screen').waitFor();
await capture('04-roster');
await top.bringToFront();
await top.waitForTimeout(2000);
await capture('04b-roster-rendered');
await top.getByRole('button', { name: /Continue|Enter world/ }).first().click();
await top.locator('.main-menu-screen').waitFor();
await capture('05-main-menu');
await top.getByRole('button', { name: /Class Hall/ }).tap();
await top.locator('.class-hall-screen').waitFor();
await capture('05b-class-hall');
await top.locator('.class-hall-header button').tap();
for (const name of ['Talents', 'Keybinds', 'Button assignments']) {
await top.getByRole('button', { name: new RegExp(name) }).tap();
await top.locator('.main-menu-character-editor').waitFor();
await top.locator('.main-menu-character-editor .game-panel').waitFor();
await capture(`05-editor-${name.toLowerCase().replaceAll(' ', '-')}`);
await top.getByRole('button', { name: 'Back to main menu', exact: true }).tap();
}
await top.getByRole('button', { name: /View Reports/ }).tap();
await top.getByRole('heading', { name: 'View Reports' }).waitFor();
await capture('05c-reports');
await top.getByRole('button', { name: /Back/ }).first().tap();
await top.getByRole('button', { name: /Downloads/ }).tap();
await top.locator('.content-manager-screen').waitFor();
await capture('05d-downloads');
await top.getByRole('button', { name: 'Back', exact: true }).tap();
await top.getByRole('button', { name: /^Dungeons/ }).click();
await top.locator('.dungeon-select-screen').waitFor();
await capture('06-dungeon-picker');
await top.getByRole('button', { name: /Choose role/ }).click();
await capture('07-role-picker');
await top.locator('.dungeon-role-option').first().click();
await top.locator('.game-shell').waitFor();
await bottom.locator('.companion-hud-shell').waitFor();
await top.waitForFunction(async () => { const { useGameStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/store.ts').name); return ['ready', 'proxy', 'error'].includes(useGameStore.getState().assetStatus); }, null, { timeout: 120000 });
await top.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await top.waitForTimeout(1500);
await capture('08-dungeon');
assert.equal(await bottom.locator('[data-hud-element="party"]').count(), 0);
assert.equal(await top.locator('[data-hud-element="actions"]').count(), 0);
assert.equal(await top.locator('[data-hud-element="player"]').count(), 0);
assert.equal(await bottom.locator('.action-bar').count(), 1);
assert.equal(await bottom.locator('.player-status-panel').count(), 1);
await bottom.locator('.aura--inspectable').first().tap();
await bottom.getByRole('tooltip').waitFor();
await capture('08b-buff-inspection');
await bottom.getByRole('button', { name: /^Fade,/ }).tap();
await top.waitForFunction(async () => {
const { useCombatStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/combatStore.ts').name);
return Object.values(useCombatStore.getState().cooldowns).some((endsAt) => endsAt > Date.now());
});
for (const overlay of ['pause', 'map', 'party', 'boss-guide', 'spellbook', 'talents', 'bindings', 'inventory', 'boss-cards', 'options', 'keybinds', 'report-issue']) {
await top.evaluate(async (overlay) => { const { useGameStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/store.ts').name); useGameStore.getState().openOverlay(overlay); }, overlay);
await top.waitForTimeout(500);
await capture(`09-${overlay}`);
}
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.locator('.ui-layout-editor').waitFor();
assert.equal(await top.getByLabel('Party frames screen').inputValue(), 'top');
assert.equal(await top.getByLabel('Buffs and debuffs screen').inputValue(), 'bottom');
await top.getByLabel('Party frames screen').selectOption('bottom');
await bottom.getByRole('button', { name: 'Move Party frames', exact: true }).waitFor();
assert.equal(await top.locator('[data-hud-element="party"]').count(), 0);
await capture('10-editor-party-bottom');
const handle = bottom.getByRole('button', { name: 'Move Party frames', exact: true });
const rect = await handle.boundingBox();
const cdp = await context.newCDPSession(bottom);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: rect.x + 10, y: rect.y + 10 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: rect.x + 90, y: rect.y + 60 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await top.waitForFunction(async () => {
const { useUiLayoutStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/uiLayoutStore.ts').name);
return useUiLayoutStore.getState().draft.elements.party.position !== null;
});
await top.getByLabel('Party frames screen').selectOption('hidden');
await bottom.waitForFunction(() => !document.querySelector('[data-hud-element="party"]'));
await top.getByLabel('Party frames screen').selectOption('bottom');
await top.getByRole('button', { name: 'Cancel edits', exact: true }).tap();
await top.locator('.ui-layout-editor').waitFor({ state: 'hidden' });
await bottom.waitForFunction(() => !document.querySelector('[data-hud-element="party"]'));
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.getByLabel('Party frames screen').selectOption('bottom');
await capture('11-editor');
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
await top.waitForFunction(() => !document.querySelector('.ui-layout-editor'));
await capture('12-saved');
await top.reload({ waitUntil: 'domcontentloaded' });
await top.locator('.character-select-screen').waitFor();
await top.getByRole('button', { name: /Continue/ }).tap();
await top.getByRole('button', { name: /^Dungeons/ }).tap();
await top.getByRole('button', { name: /Choose role/ }).tap();
await top.locator('.dungeon-role-option').first().tap();
await bottom.locator('[data-hud-element="party"] .party-frame').first().waitFor();
assert.equal(await top.locator('[data-hud-element="party"]').count(), 0);
await bottom.locator('.party-frame--player').tap();
await top.waitForFunction(async () => {
const { usePartyStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/partyStore.ts').name);
return usePartyStore.getState().selfSelected;
});
const partyBounds = await bottom.locator('.party-frames').boundingBox();
const menuBounds = await bottom.locator('.companion-hud-menus').boundingBox();
assert.ok(partyBounds.y + partyBounds.height <= menuBounds.y, 'Relocated party frames must fit above the bottom navigation');
await capture('13-persisted-party-bottom');
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.getByRole('button', { name: 'Reset all elements', exact: true }).tap();
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
await capture('14-final-defaults');
await top.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await top.evaluate(async () => {
const load = (pathname) => import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === pathname).name);
const { useGameStore } = await load('/src/game/store.ts');
const { useCombatStore } = await load('/src/game/combatStore.ts');
useGameStore.setState({ paused: true, overlay: null });
useCombatStore.setState({ health: 0 });
});
await top.locator('.defeated-prompt').waitFor();
assert.equal(await bottom.locator('.defeated-prompt').count(), 0);
await capture('14b-defeated');
await top.getByRole('button', { name: 'Respawn at entrance', exact: true }).tap();
await top.locator('.defeated-prompt').waitFor({ state: 'hidden' });
// Isolated offline presentation fixture: exercise the completed-run UI without a live boss clear.
await top.evaluate(async () => {
const load = (pathname) => import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === pathname).name);
const { useGameStore } = await load('/src/game/store.ts');
const { useDungeonRunStore } = await load('/src/game/dungeonRunStore.ts');
const { usePartyStore } = await load('/src/game/partyStore.ts');
const { useCombatStore } = await load('/src/game/combatStore.ts');
const progress = useDungeonRunStore.getState().progress;
const now = performance.now();
const party = [{ name: 'Thortester', classId: useCombatStore.getState().classId, level: 1, role: 'healer', npc: false },
...usePartyStore.getState().members.map((member) => ({ name: member.name, classId: member.classId, level: member.level, role: member.role, npc: true }))];
useGameStore.setState({ paused: true, overlay: null });
useDungeonRunStore.setState({ progress: { ...progress, startedAt: now - 60000, finishedAt: now, durationMs: 60000, party } });
});
await top.locator('.dungeon-complete-banner').waitFor();
assert.equal(await bottom.locator('.dungeon-complete-banner').count(), 0);
await capture('14c-complete');
await top.locator('.dungeon-results').waitFor();
await capture('14d-results');
await top.getByRole('button', { name: /Stay and loot/ }).tap();
await top.getByRole('button', { name: 'Run results', exact: true }).tap();
await top.locator('.dungeon-results').waitFor();
await top.getByRole('button', { name: /Stay and loot/ }).tap();
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.getByLabel('Dungeon completion screen').selectOption('bottom');
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
await bottom.locator('.dungeon-complete-banner').waitFor();
assert.equal(await top.locator('.dungeon-complete-banner').count(), 0);
await capture('14e-complete-bottom');
await bottom.getByRole('button', { name: 'Finish dungeon', exact: true }).tap();
await top.locator('.main-menu-screen').waitFor();
await top.evaluate(async () => {
const { useUiLayoutStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/uiLayoutStore.ts').name);
const state = useUiLayoutStore.getState(); state.begin(); state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, completion: { visible: true, position: null, width: null } } })); state.finish(true);
});
const previewCdp = await context.newCDPSession(top);
await top.setViewportSize({ width: 1200, height: 1100 });
await previewCdp.send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 1100, deviceScaleFactor: 1, mobile: false });
await top.goto(`${base}/?layout=thor-preview`, { waitUntil: 'domcontentloaded' });
await top.getByRole('button', { name: /Continue/ }).first().tap();
await top.locator('.thor-preview-top').getByRole('button', { name: /^Dungeons/ }).tap();
await top.locator('.thor-preview-top').getByRole('button', { name: /Choose role/ }).tap();
await top.locator('.thor-preview-top .dungeon-role-option').first().tap();
await top.locator('.thor-preview-top .loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await top.waitForTimeout(1000);
assert.equal((await top.locator('.thor-preview-top').boundingBox()).width, 837);
assert.equal((await top.locator('.thor-preview-bottom').boundingBox()).width, 473);
assert.equal(await top.locator('.thor-preview-bottom [data-hud-element="party"]').count(), 0);
assert.equal(await top.locator('.thor-preview-top .action-bar').count(), 0);
await top.screenshot({ path: path.join(directory, '15-combined-preview.png'), fullPage: true });
results.push({ name: '15-combined-preview', surface: 'both', clipped: [] });
assert.deepEqual(results.filter((result) => result.clipped.length), [], 'Controls must fit each screen or an intentional scroll region');
assert.equal(errors.length, 0, errors.join('\n'));
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ results, errors }, null, 2)}\n`);
} finally {
console.log('Browser errors', errors);
await top.screenshot({ path: path.join(directory, 'last-top.png') });
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ results, errors }, null, 2)}\n`);
await browser.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });