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; });