forked from phenom/healer-man
250 lines
17 KiB
JavaScript
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; });
|