Mercurial
view mrjunejune/test/theme_and_webp_test.js @ 266:efaf4c63cc94
fix clean production bundle staging
Recreate deployment staging before copying the Bazel bundle and verify required inference runtime paths before promoting it.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 12:52:30 -0700 |
| parents | 056790c4fb0d |
| children | 41a49c29a28f |
line wrap: on
line source
const assert = require('node:assert/strict'); const fs = require('node:fs'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const RUNFILES = process.env.JS_BINARY__RUNFILES; const WORKSPACE = process.env.JS_BINARY__WORKSPACE; let port; let baseUrl; const playwrightPath = path.join( RUNFILES, WORKSPACE, 'hg-web/e2e/node_modules/playwright-core', ); const { chromium } = require(playwrightPath); const browserSuite = process.env.MJJ_BROWSER_SUITE || 'all'; const runsSuite = name => browserSuite === 'all' || browserSuite === name; function stopProcess(child) { if (!child || child.exitCode !== null) return Promise.resolve(); child.kill('SIGTERM'); return new Promise(resolve => { const timer = setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL'); }, 3000); child.once('exit', () => { clearTimeout(timer); resolve(); }); }); } async function waitForServer(child, logs) { const deadline = Date.now() + 15000; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`Server exited with ${child.exitCode}\n${logs.join('')}`); } try { const response = await fetch(baseUrl); if (response.ok) return; } catch { // Keep waiting for startup. } await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error(`Server startup timed out\n${logs.join('')}`); } function listFiles(root) { const files = []; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { const absolute = path.join(root, entry.name); if (entry.isDirectory()) files.push(...listFiles(absolute)); else files.push(absolute); } return files; } function primitiveOwnershipViolations(source) { const violations = []; const stack = []; const voidElements = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]); const ownerMap = { button: new Set(['zen-button']), select: new Set(['zen-field', 'zen-native-select', 'zen-select']), textarea: new Set(['zen-field', 'zen-textarea']), }; for (const token of source.matchAll( /<!--[\s\S]*?-->|<\/?([a-zA-Z][\w-]*)\b([^>]*)>/g, )) { if (!token[1]) continue; const name = token[1].toLowerCase(); if (token[0].startsWith('</')) { while (stack.length) { if (stack.pop() === name) break; } continue; } let owners = ownerMap[name]; if (name === 'input') { const type = /\btype\s*=\s*["']?([^"'\s>]+)/i.exec(token[2])?.[1] ?.toLowerCase() || 'text'; if (type === 'checkbox') { owners = new Set(['zen-checkbox', 'zen-field', 'zen-switch']); } else if (type === 'radio') { owners = new Set(['zen-field', 'zen-radio-group']); } else if (type === 'range') { owners = new Set(['zen-field', 'zen-slider']); } else if (type === 'date') { owners = new Set(['zen-calendar', 'zen-date-picker', 'zen-field']); } else { owners = new Set([ 'zen-combobox', 'zen-command', 'zen-field', 'zen-input', 'zen-input-group', 'zen-input-otp', ]); } } if (owners) { const matchingOwners = stack.filter(ancestor => owners.has(ancestor)); if (matchingOwners.length !== 1) { violations.push({ control: name, owners: matchingOwners, source: token[0], }); } } if (!voidElements.has(name) && !token[0].endsWith('/>')) { stack.push(name); } } return violations; } async function sampleTheme(browser, theme) { const context = await browser.newContext({ colorScheme: 'dark' }); await context.addInitScript(value => { localStorage.setItem('theme-preference', value); }, theme); const page = await context.newPage(); const errors = []; await page.route('**/api/conversations**', async route => { const request = route.request(); const url = new URL(request.url()); if (request.method() === 'POST' && url.pathname === '/api/conversations') { await route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: '11111111-1111-4111-8111-111111111111', }), }); return; } if (request.method() === 'POST' && url.pathname.endsWith('/turns')) { await route.fulfill({ status: 200, contentType: 'text/event-stream', body: [ 'event: turn.accepted', 'data: {"request_id":"request-1"}', '', 'event: assistant.delta', 'data: {"request_id":"request-1","delta":"Start with "}', '', 'event: assistant.delta', 'data: {"request_id":"request-1","delta":"the tools."}', '', 'event: assistant.completed', 'data: {"request_id":"request-1","content":"Start with the tools."}', '', 'event: turn.done', 'data: {"request_id":"request-1","failed":false,"aborted":false}', '', '', ].join('\n'), }); return; } await route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Not found' } }), }); }); page.on('pageerror', error => errors.push(`pageerror: ${error.message}`)); page.on('console', message => { if (message.type() === 'error' && !message.text().startsWith('Failed to load resource:')) { errors.push(`console: ${message.text()}`); } }); page.on('requestfailed', request => { if (request.url().startsWith('blob:') && request.failure()?.errorText === 'net::ERR_ABORTED') { return; } if (request.method() === 'DELETE' && request.url().includes('/api/download/') && request.failure()?.errorText === 'net::ERR_ABORTED') { return; } if (request.method() === 'POST' && request.url().endsWith('/api/latex/render') && request.failure()?.errorText === 'net::ERR_ABORTED') { return; } errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); }); page.on('response', response => { if (response.status() === 404 && response.url().endsWith( '/api/conversations/00000000-0000-4000-8000-000000000000' )) { return; } if (response.status() >= 400) { errors.push(`response: ${response.status()} ${response.url()}`); } }); await page.goto(baseUrl, { waitUntil: 'networkidle' }); await page.waitForFunction(() => { const canvas = document.querySelector('#background'); if (!canvas) return false; const data = canvas.getContext('2d').getImageData( 0, 0, canvas.width, canvas.height, ).data; for (let index = 3; index < data.length; index += 4) { if (data[index] >= 20) return true; } return false; }); const sample = await page.evaluate(() => { const canvas = document.querySelector('#background'); const data = canvas.getContext('2d').getImageData( 0, 0, canvas.width, canvas.height, ).data; let count = 0; let luminance = 0; for (let index = 0; index < data.length; index += 4) { if (data[index + 3] < 20) continue; count++; luminance += data[index] * 0.2126 + data[index + 1] * 0.7152 + data[index + 2] * 0.0722; } const channels = value => value.match(/\d+(?:\.\d+)?/g) .slice(0, 3) .map(Number); const colorLuminance = value => { const converted = channels(value).map(channel => { const normalized = channel / 255; return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; }); return converted[0] * 0.2126 + converted[1] * 0.7152 + converted[2] * 0.0722; }; const bodyStyle = getComputedStyle(document.body); const themeButtonStyle = getComputedStyle( document.querySelector('#themeToggle'), ); const foreground = colorLuminance(bodyStyle.color); const contrastProbe = document.createElement('span'); contrastProbe.style.background = 'var(--zen-color-surface)'; document.body.append(contrastProbe); const background = colorLuminance( getComputedStyle(contrastProbe).backgroundColor, ); contrastProbe.remove(); return { backgroundRepeat: bodyStyle.backgroundRepeat, backgroundSize: bodyStyle.backgroundSize, bodyCoversViewport: document.body.getBoundingClientRect().height >= innerHeight, componentReady: Boolean(customElements.get('zen-button')), count, fontFamily: getComputedStyle(document.body).fontFamily, headerPaws: document.querySelectorAll( 'header [data-zen-link-decoration]', ).length, luminance: count ? luminance / count : 0, textContrast: (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05), rootTheme: document.documentElement.dataset.zenTheme || 'auto', enhancedLinks: document.querySelectorAll( 'zen-link[effect="paw"] > a > zen-icon[name="paw"]', ).length, links: document.querySelectorAll('a').length, pawLinks: [...document.querySelectorAll('a')].filter(link => !link.closest('zen-button') && link.dataset.zenLinkEffect !== 'none' ).length, mainBackground: getComputedStyle( document.querySelector('main'), ).backgroundColor, themeButtonBackground: themeButtonStyle.backgroundColor, themeButtonBorder: themeButtonStyle.borderTopWidth, themeButtonShadow: themeButtonStyle.boxShadow, themeLabel: document.querySelector('#themeName')?.textContent, }; }); if (errors.length) throw new Error(`${theme}\n${errors.join('\n')}`); await context.close(); return sample; } async function testThemeCycle(browser) { const context = await browser.newContext({ colorScheme: 'light' }); const page = await context.newPage(); await page.goto(baseUrl, { waitUntil: 'networkidle' }); const expected = ['paper', 'ink', 'playful', 'auto']; for (const theme of expected) { await page.locator('#themeToggle').click(); assert.equal( await page.evaluate(() => document.documentElement.dataset.zenTheme || 'auto' ), theme, ); assert.equal( await page.evaluate(() => localStorage.getItem('theme-preference')), theme, ); } await context.close(); } async function testPlainField(browser) { const page = await browser.newPage(); await page.goto(`${baseUrl}/tools/markdown_to_html`, { waitUntil: 'networkidle', }); await page.waitForFunction(() => customElements.get('zen-field')); const state = await page.locator('zen-field[appearance="plain"]').evaluate( field => { const label = field.querySelector('label'); const textarea = field.querySelector('textarea'); const probe = document.createElement('span'); probe.style.border = '1px solid var(--accent)'; document.body.append(probe); const accent = getComputedStyle(probe).borderColor; probe.remove(); textarea.focus(); return { borderColor: getComputedStyle(textarea).borderColor, display: getComputedStyle(field).display, labelFor: label.htmlFor, accent, textareaId: textarea.id, }; }, ); assert.deepEqual(state, { accent: state.borderColor, borderColor: state.borderColor, display: 'contents', labelFor: 'input', textareaId: 'input', }); await page.close(); } async function testButtonScale(browser) { const page = await browser.newPage(); for (const route of [ '/talk', '/notes/login', '/tools/hls_player', '/tools/latex_editor', '/tools/markdown_to_html', '/tools/file_converter', '/offline.html', ]) { await page.goto(`${baseUrl}${route}`, { waitUntil: 'networkidle' }); await page.waitForFunction(() => customElements.get('zen-button')); const controls = await page.locator( 'zen-button[size] > :is(button, a):not(#themeToggle)', ).evaluateAll(elements => elements.map(control => { const host = control.parentElement; const style = getComputedStyle(control); const hostStyle = getComputedStyle(host); return { height: control.getBoundingClientRect().height, minimum: Number.parseFloat( hostStyle.getPropertyValue('--zenbu-control-height'), ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize), paddingBlock: style.paddingBlockStart, paddingVariable: Number.parseFloat( hostStyle.getPropertyValue('--zenbu-control-padding-block'), ) * Number.parseFloat(getComputedStyle(document.documentElement).fontSize), size: host.getAttribute('size'), }; })); assert.ok(controls.length > 0, route); for (const control of controls) { assert.match(control.size, /^(?:xs|sm|md|lg|xl)$/); assert.ok(control.height >= control.minimum - 1, JSON.stringify(control)); assert.ok( Math.abs(Number.parseFloat(control.paddingBlock) - control.paddingVariable) < 1, JSON.stringify(control), ); } } await page.close(); } async function testDynamicButtonOwnership(browser) { const page = await browser.newPage(); await page.goto(baseUrl, { waitUntil: 'networkidle' }); await page.evaluate(() => { window.dispatchEvent(new Event('beforeinstallprompt', { cancelable: true, })); }); const state = await page.locator('#pwa-install-btn').evaluate(button => ({ ownerCount: button.closest('zen-button') ? 1 : 0, appearance: button.closest('zen-button')?.getAttribute('appearance'), size: button.closest('zen-button')?.getAttribute('size'), })); assert.deepEqual(state, { appearance: 'plain', ownerCount: 1, size: 'md', }); await page.close(); } async function testResumePrint(browser) { const page = await browser.newPage(); await page.emulateMedia({ media: 'print' }); await page.goto(`${baseUrl}/resume`, { waitUntil: 'networkidle' }); assert.equal( await page.locator('.info > a[href="/public/resume.pdf"]').isVisible(), false, ); await page.close(); } async function testJrpgPage(browser) { const context = await browser.newContext({ colorScheme: 'light', viewport: { width: 1280, height: 900 }, }); await context.addInitScript(key => { sessionStorage.setItem( key, '00000000-0000-4000-8000-000000000000', ); }, 'mjj-jrpg-conversation-id'); const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(`pageerror: ${error.message}`)); page.on('console', message => { if (message.type() === 'error' && !message.text().startsWith('Failed to load resource:')) { errors.push(`console: ${message.text()}`); } }); page.on('requestfailed', request => { if (request.url().startsWith('blob:') && request.failure()?.errorText === 'net::ERR_ABORTED') { return; } if (request.method() === 'DELETE' && request.url().includes('/api/download/') && request.failure()?.errorText === 'net::ERR_ABORTED') { return; } if (request.method() === 'POST' && request.url().endsWith('/api/latex/render') && request.failure()?.errorText === 'net::ERR_ABORTED') { return; } if (request.url().includes('/api/auth/login') && request.failure()?.errorText === 'net::ERR_ABORTED') { return; } errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); }); page.on('response', response => { if (response.status() === 404 && response.url().endsWith( '/api/conversations/00000000-0000-4000-8000-000000000000' )) { return; } if (response.url().includes('/api/auth/login') && response.status() >= 400) { return; /* expected: login attempt with invalid credentials in modal test */ } if (response.status() >= 400) { errors.push(`response: ${response.status()} ${response.url()}`); } }); await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); await page.waitForFunction(() => customElements.get('mjj-jrpg-composer') && customElements.get('mjj-jrpg-menu') && customElements.get('mjj-jrpg-chat') && customElements.get('mjj-jrpg-preview') ); await page.evaluate(() => document.fonts.ready); const desktop = await page.evaluate(() => { const scene = document.querySelector('.jrpg-scene').getBoundingClientRect(); const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); const composer = document.querySelector('mjj-jrpg-composer') .getBoundingClientRect(); const composerField = document.querySelector('.jrpg-composer zen-field') .getBoundingClientRect(); const composerFieldStyle = getComputedStyle( document.querySelector('.jrpg-composer zen-field'), ); const composerLabel = document.querySelector('.jrpg-composer label') .getBoundingClientRect(); const composerTextarea = document.querySelector('#jrpg-message') .getBoundingClientRect(); const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); const character = document.querySelector('[data-character]'); const characterBox = character.getBoundingClientRect(); const sceneBarStyle = getComputedStyle( document.querySelector('.jrpg-scene'), '::before', ); const messageScroller = document.querySelector('zen-message-scroller') .getBoundingClientRect(); const messageViewport = document.querySelector('.jrpg-message-viewport') .getBoundingClientRect(); const workspace = document.querySelector('.jrpg-workspace') .getBoundingClientRect(); const brand = document.querySelector('.jrpg-brand').getBoundingClientRect(); const frameControls = document.querySelector('.jrpg-frame-controls') .getBoundingClientRect(); const frameTelemetry = document.querySelector('.jrpg-frame-telemetry') .getBoundingClientRect(); const telemetryValues = [ ...document.querySelectorAll('.jrpg-frame-telemetry > *'), ].map(value => value.getBoundingClientRect()); const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); const main = document.querySelector('.jrpg-main').getBoundingClientRect(); const colorProbe = document.createElement('span'); colorProbe.style.background = 'var(--zenbu-sys-color-surface-page)'; colorProbe.style.color = 'var(--zenbu-sys-color-action-primary-background)'; colorProbe.style.borderColor = 'var(--zenbu-sys-color-focus-outer)'; const surfaceProbe = document.createElement('span'); surfaceProbe.style.background = 'var(--zenbu-sys-color-surface-default)'; surfaceProbe.style.color = 'var(--zenbu-sys-color-text-primary)'; surfaceProbe.style.borderColor = 'var(--zenbu-sys-color-border-strong)'; const sunkenProbe = document.createElement('span'); sunkenProbe.style.background = 'var(--zenbu-sys-color-surface-sunken)'; const panelProbe = document.createElement('span'); panelProbe.style.background = 'var(--mjj-jrpg-panel-strong)'; document.body.append(colorProbe, surfaceProbe, sunkenProbe, panelProbe); const probeStyle = getComputedStyle(colorProbe); const surfaceProbeStyle = getComputedStyle(surfaceProbe); const sunkenProbeStyle = getComputedStyle(sunkenProbe); const panelProbeStyle = getComputedStyle(panelProbe); const computedFonts = [ document.body, document.querySelector('.jrpg-brand h1'), document.querySelector('.jrpg-message p'), document.querySelector('#jrpg-message'), document.querySelector('.jrpg-composer button[type="submit"]'), ].map(element => getComputedStyle(element).fontFamily); const nonPixelText = [document.body, ...document.body.querySelectorAll('*')] .filter(element => [...element.childNodes].some(node => node.nodeType === Node.TEXT_NODE && node.textContent.trim() )) .filter(element => getComputedStyle(element).fontFamily !== '"Pixel Mplus"' ) .map(element => element.localName); const pageStyle = getComputedStyle(document.body); const submitStyle = getComputedStyle( document.querySelector('.jrpg-composer button[type="submit"]'), ); const colors = { canvas: probeStyle.backgroundColor, conversation: getComputedStyle( document.querySelector('mjj-jrpg-chat'), ).backgroundColor, conversationBorder: getComputedStyle( document.querySelector('mjj-jrpg-chat'), ).borderTopColor, conversationPanel: panelProbeStyle.backgroundColor, conversationText: getComputedStyle( document.querySelector('.jrpg-message p'), ).color, focus: probeStyle.borderColor, frame: surfaceProbeStyle.borderColor, menu: getComputedStyle( document.querySelector('mjj-jrpg-menu'), ).backgroundColor, page: pageStyle.backgroundColor, primary: probeStyle.color, primaryText: getComputedStyle( document.querySelector('.jrpg-preview-panel h2'), ).color, sunken: sunkenProbeStyle.backgroundColor, surface: surfaceProbeStyle.backgroundColor, submit: submitStyle.backgroundColor, text: surfaceProbeStyle.color, utility: getComputedStyle( document.querySelector('.jrpg-utility'), ).backgroundColor, }; colorProbe.remove(); surfaceProbe.remove(); sunkenProbe.remove(); panelProbe.remove(); const closeTo = (value, expected) => Math.abs(value - expected) < 0.01; const frameAlignment = [ closeTo((scene.left - workspace.left) / workspace.width, 0.032), closeTo((scene.top - workspace.top) / workspace.height, 0.094), closeTo(scene.width / workspace.width, 0.548), closeTo(scene.height / workspace.height, 0.57), closeTo((utility.left - workspace.left) / workspace.width, 0.604), closeTo(utility.width / workspace.width, 0.365), closeTo((composer.top - workspace.top) / workspace.height, 0.72), closeTo(composer.width / workspace.width, 0.682), closeTo((menu.left - workspace.left) / workspace.width, 0.734), closeTo(menu.width / workspace.width, 0.234), ].every(Boolean); const borderless = [ '.jrpg-shell', '.jrpg-scene', '.jrpg-utility', 'mjj-jrpg-chat', 'mjj-jrpg-composer', 'mjj-jrpg-menu', '.jrpg-preview-panel', '.jrpg-message', '.jrpg-work-showcase a', '.jrpg-composer textarea', '.jrpg-composer button[type="submit"]', 'mjj-jrpg-menu button', ].every(selector => { const style = getComputedStyle(document.querySelector(selector)); return style.borderTopWidth === '0px' && style.borderRightWidth === '0px' && style.borderBottomWidth === '0px' && style.borderLeftWidth === '0px'; }); return { art: getComputedStyle(document.querySelector('.jrpg-workspace')) .backgroundImage, borderless, brandInSystemSlot: closeTo((brand.left - workspace.left) / workspace.width, 0.054) && closeTo((brand.top - workspace.top) / workspace.height, 0.022) && document.querySelector('.jrpg-brand').parentElement.classList .contains('jrpg-workspace'), brandCoversSystemLabel: brand.left <= workspace.left + workspace.width * 0.062 && brand.right >= workspace.left + workspace.width * 0.102 && brand.top <= workspace.top + workspace.height * 0.03 && brand.bottom >= workspace.top + workspace.height * 0.06, characterLoaded: character.complete && character.naturalWidth > 0, colors, composerBelowScene: composer.top >= scene.bottom, composerTextareaFills: ( Math.abs(composerTextarea.left - composerField.left) < 1 && Math.abs(composerTextarea.right - composerField.right) < 1 && Math.abs(composerTextarea.bottom - composerField.bottom) < 1 && composerTextarea.height > 60 ), composerTextareaGeometry: { fieldBottom: composerField.bottom, fieldDisplay: composerFieldStyle.display, fieldGridRows: composerFieldStyle.gridTemplateRows, fieldLeft: composerField.left, fieldRight: composerField.right, fieldTop: composerField.top, labelBottom: composerLabel.bottom, labelTop: composerLabel.top, textareaBottom: composerTextarea.bottom, textareaHeight: composerTextarea.height, textareaLeft: composerTextarea.left, textareaRight: composerTextarea.right, textareaTop: composerTextarea.top, }, fillsViewport: shell.left === 0 && shell.top === 0 && Math.abs(shell.right - innerWidth) < 1 && Math.abs(shell.bottom - innerHeight) < 1 && main.left === 0 && main.top === 0 && Math.abs(main.right - innerWidth) < 1 && Math.abs(main.bottom - innerHeight) < 1, frameAlignment, frameChromeAligned: closeTo((frameControls.left - workspace.left) / workspace.width, 0.866) && closeTo((frameTelemetry.left - workspace.left) / workspace.width, 0.085) && closeTo((frameTelemetry.top - workspace.top) / workspace.height, 0.928), fonts: computedFonts, fontLoaded: [...document.fonts].some(font => font.family.replaceAll('"', '') === 'Pixel Mplus' && font.status === 'loaded' ), menuRightOfComposer: menu.left >= composer.right, messageViewportFills: ( Math.abs(messageViewport.top - messageScroller.top) < 1 && Math.abs(messageViewport.bottom - messageScroller.bottom) < 1 && messageViewport.height > 192 ), noOverflow: document.documentElement.scrollWidth <= innerWidth, nonPixelText, questLabelAbsent: !document.body.textContent.includes('QUEST 01'), previewLabelsRemoved: !/CHARACTER RECORD|ITEM INVENTORY|QUEST ARCHIVE/.test( document.querySelector('.jrpg-preview-panel').textContent, ), statsRemoved: !document.querySelector('.jrpg-preview-stats') && !document.querySelector('[data-preview-type]'), telemetryCovered: document.querySelectorAll('.jrpg-frame-telemetry > *').length === 4 && document.querySelector('[data-frame-network]').textContent .includes('ONLINE') && document.querySelector('[data-frame-uptime]').textContent .match(/^\d{2}:\d{2}:\d{2}$/) && [...document.querySelectorAll('.jrpg-frame-telemetry > *')] .every(item => !/TEMP:|USER:|STATUS:|UPTIME:/.test(item.textContent)), telemetryValuesAligned: closeTo( (telemetryValues[0].left + telemetryValues[0].width / 2 - frameTelemetry.left) / frameTelemetry.width, 0.12, ) && closeTo( (telemetryValues[1].left + telemetryValues[1].width / 2 - frameTelemetry.left) / frameTelemetry.width, 0.325, ) && closeTo( (telemetryValues[2].left + telemetryValues[2].width / 2 - frameTelemetry.left) / frameTelemetry.width, 0.605, ) && closeTo( (telemetryValues[3].left + telemetryValues[3].width / 2 - frameTelemetry.left) / frameTelemetry.width, 0.88, ), shibaInFrontOfBar: sceneBarStyle.backgroundImage.includes('bar-ink.webp') && sceneBarStyle.backgroundSize === 'cover' && Math.abs(parseFloat(sceneBarStyle.height) - scene.height) < 1 && Number(getComputedStyle(character.parentElement).zIndex) > Number(sceneBarStyle.zIndex) && characterBox.bottom > scene.top + scene.height * 0.65, terminalScrollbar: ( getComputedStyle( document.querySelector('.jrpg-message-viewport'), ).scrollbarWidth === 'thin' && getComputedStyle( document.querySelector('.jrpg-message-viewport'), ).scrollbarColor !== 'auto' ), theme: document.documentElement.dataset.zenTheme, utilityRightOfScene: utility.left >= scene.right, workspaceFillsViewport: Math.abs(workspace.left) < 1 && Math.abs(workspace.top) < 1 && Math.abs(workspace.right - innerWidth) < 1 && Math.abs(workspace.bottom - innerHeight) < 1, }; }); assert.match(desktop.art, /background-frame-2\.webp/); assert.equal(desktop.borderless, true); assert.equal(desktop.brandInSystemSlot, true); assert.equal(desktop.brandCoversSystemLabel, true); assert.equal(desktop.characterLoaded, true); assert.equal(desktop.colors.page, desktop.colors.canvas); assert.equal(desktop.colors.conversation, 'rgba(0, 0, 0, 0)'); assert.equal(desktop.colors.conversationText, desktop.colors.text); assert.equal(desktop.colors.menu, 'rgba(0, 0, 0, 0)'); assert.equal(desktop.colors.primaryText, desktop.colors.primary); assert.equal(desktop.colors.submit, desktop.colors.primary); assert.equal(desktop.colors.utility, 'rgba(0, 0, 0, 0)'); assert.equal(desktop.composerBelowScene, true); assert.equal( desktop.composerTextareaFills, true, JSON.stringify(desktop.composerTextareaGeometry), ); assert.equal(desktop.fillsViewport, true); assert.equal(desktop.frameAlignment, true); assert.equal(desktop.frameChromeAligned, true); assert.ok(desktop.fonts.every(font => font === '"Pixel Mplus"')); assert.equal(desktop.fontLoaded, true); assert.equal(desktop.menuRightOfComposer, true); assert.equal(desktop.messageViewportFills, true); assert.equal(desktop.noOverflow, true); assert.deepEqual(desktop.nonPixelText, []); assert.equal(desktop.questLabelAbsent, true); assert.equal(desktop.previewLabelsRemoved, true); assert.equal(desktop.statsRemoved, true); assert.equal(desktop.telemetryCovered, true); assert.equal(desktop.telemetryValuesAligned, true); assert.equal(desktop.shibaInFrontOfBar, true); assert.equal(desktop.terminalScrollbar, true); assert.equal(desktop.theme, 'cyberpunk'); assert.equal(desktop.utilityRightOfScene, true); assert.equal(desktop.workspaceFillsViewport, true); await page.locator('.jrpg-brand a').focus(); assert.equal( await page.locator('.jrpg-brand a').evaluate(element => getComputedStyle(element).outlineColor ), desktop.colors.focus, ); await page.locator('.jrpg-composer button[type="submit"]').focus(); assert.equal( await page.locator('.jrpg-composer button[type="submit"]').evaluate(element => getComputedStyle(element).outlineColor ), desktop.colors.focus, ); assert.equal( await page.locator('meta[name="theme-color"]').first().getAttribute('content'), desktop.colors.canvas, ); assert.equal( await page.locator('.jrpg-frame-controls :is(button, a)').count(), 3, ); assert.equal( await page.locator('.jrpg-frame-controls a').getAttribute('href'), '/', ); // Account slot: guest session shows a login button (modal trigger) assert.ok( await page.locator('[data-frame-account]').count() > 0, 'account slot exists in telemetry', ); await page.waitForFunction(() => document.querySelector('[data-frame-account]')?.querySelector('button[data-login-open]') !== null ); const loginBtnState = await page.locator('[data-frame-account] button[data-login-open]').evaluate(btn => ({ text: btn.textContent.trim(), ownerCount: btn.closest('zen-button') ? 1 : 0, appearance: btn.closest('zen-button')?.getAttribute('appearance'), size: btn.closest('zen-button')?.getAttribute('size'), })); assert.match(loginBtnState.text, /LOGIN/i); assert.equal(loginBtnState.ownerCount, 1, 'login button has zen-button owner'); assert.equal(loginBtnState.appearance, 'plain'); assert.equal(loginBtnState.size, 'xs'); // Login modal: opens on button click, focuses username, closes on cancel await page.locator('[data-frame-account] button[data-login-open]').click(); await page.locator('[data-login-dialog]').waitFor({ state: 'visible' }); assert.equal( await page.locator('[data-login-dialog]').getAttribute('aria-modal'), 'true', 'login dialog is modal', ); // Username input should receive initial focus await page.waitForFunction(() => document.activeElement?.id === 'jrpg-login-username'); // Cancel closes dialog and clears password await page.locator('#jrpg-login-password').fill('dummypassword'); await page.locator('[data-login-cancel]').click(); await page.locator('[data-login-dialog]').waitFor({ state: 'hidden' }); await page.waitForFunction(() => !document.querySelector('#jrpg-login-password')?.value); assert.equal( await page.locator('#jrpg-login-password').inputValue(), '', 'password cleared on close', ); // Invalid credentials show error message await page.locator('[data-frame-account] button[data-login-open]').click(); await page.locator('[data-login-dialog]').waitFor({ state: 'visible' }); await page.locator('#jrpg-login-username').fill('nosuchuser'); await page.locator('#jrpg-login-password').fill('nosuchpassword123'); let loginErrorShown = false; const loginResponsePromise = page.waitForResponse(resp => resp.url().includes('/api/auth/login') ); await page.locator('[data-login-submit]').click(); const loginResp = await loginResponsePromise; if (loginResp.status() >= 400) { await page.waitForFunction(() => { const el = document.querySelector('[data-login-error]'); return el && !el.hidden && el.textContent.trim().length > 0; }); loginErrorShown = true; } assert.equal(loginErrorShown, true, 'error element shown on bad credentials'); assert.equal( await page.locator('#jrpg-login-password').inputValue(), '', 'password cleared after failed login attempt', ); // Close modal via X button (data-zen-close) await page.getByRole('button', { name: 'Close sign-in dialog' }).click(); await page.locator('[data-login-dialog]').waitFor({ state: 'hidden' }); // Archive opens only from the bottom destination menu. assert.equal( await page.locator('[data-toggle-archive]').count(), 0, 'redundant chat-heading archive shortcut is removed', ); await page.locator('button[data-preview="conversations"]').click(); await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'visible' }); const archiveGeometry = await page.evaluate(() => { const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); const archive = document.querySelector( '.jrpg-utility mjj-conversation-archive', ).getBoundingClientRect(); return { archive: { bottom: archive.bottom, height: archive.height, left: archive.left, right: archive.right, top: archive.top, width: archive.width, }, fits: archive.left >= utility.left && archive.top >= utility.top && archive.right <= utility.right + 1 && archive.bottom <= utility.bottom + 1 && archive.width / utility.width > 0.85 && archive.height / utility.height > 0.85, utility: { bottom: utility.bottom, height: utility.height, left: utility.left, right: utility.right, top: utility.top, width: utility.width, }, }; }); assert.equal( archiveGeometry.fits, true, `conversation archive fills the top-right utility aperture: ${JSON.stringify(archiveGeometry)}`, ); // Archive status or empty message should be visible await page.waitForFunction(() => { const status = document.querySelector('[data-archive-status]'); return status && !status.hidden; }); // New conversation button exists inside archive assert.ok( await page.locator('[data-archive-new]').isVisible(), 'archive new button visible', ); // Conversations menu button should be pressed assert.equal( await page.locator('button[data-preview="conversations"]').getAttribute('aria-pressed'), 'true', ); // Archive: close via close button, navigates back to resume panel await page.locator('[data-archive-close]').click(); await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'hidden' }); // mjj-jrpg-chat must remain visible (always in scene) assert.equal( await page.locator('mjj-jrpg-chat').isVisible(), true, ); // Quota element is present (may be shown for guest session) assert.ok( await page.locator('[data-quota]').count() > 0, 'quota element exists', ); await page.getByRole('button', { name: 'Minimize interface' }).click(); assert.equal( await page.locator('.jrpg-scene').evaluate( scene => getComputedStyle(scene).visibility, ), 'hidden', ); await page.getByRole('button', { name: 'Restore interface' }).click(); assert.equal( await page.locator('.jrpg-scene').evaluate( scene => getComputedStyle(scene).visibility, ), 'visible', ); assert.equal(await page.locator('[data-work-showcase] a').count(), 8); assert.equal( await page.getByRole('button', { name: /Inspect/ }).count(), 0, ); assert.match( await page.locator('[data-work-showcase]').textContent(), /Copilot Tasks/, ); assert.match( await page.locator('[data-work-showcase]').textContent(), /AIX Harness \/ Copilot/, ); assert.equal( await page.locator('[data-turn-position]').textContent(), 'START', ); assert.equal( await page.getByRole('button', { name: 'Previous turn' }).isDisabled(), true, ); assert.equal( await page.getByRole('button', { name: 'Next turn' }).isDisabled(), true, ); await page.getByRole('button', { name: 'Tools', exact: true }).click(); assert.equal( await page.locator('[data-preview-title]').textContent(), 'Tools', ); assert.equal( await page.locator('[data-preview-link]').getAttribute('href'), '/tools', ); assert.equal( await page.getByRole('button', { name: 'Tools', exact: true }) .getAttribute('aria-pressed'), 'true', ); await page.waitForFunction(() => document.querySelectorAll('[data-latest-tool]').length === 5 ); assert.match( await page.locator('[data-work-showcase]').textContent(), /Online LaTeX Editor/, ); assert.match( await page.locator('[data-work-showcase]').textContent(), /Personal Notes/, ); await page.locator('[data-latest-tool]').first().click(); await page.locator('[data-functional-tool="markdown"]').waitFor(); assert.equal( await page.locator('[data-dialog-title]').textContent(), 'MarkDown to HTML', ); assert.equal( await page.locator('[data-preview-link]').getAttribute('href'), '/tools/markdown_to_html', ); await page.locator('[data-markdown-source]').fill('# Modal Markdown'); await page.waitForFunction(() => document.querySelector('[data-markdown-output] h1')?.textContent === 'Modal Markdown' ); assert.equal(await page.locator('.jrpg-tool-pane').count(), 2); const toolWorkspace = await page.evaluate(() => { const dialogElement = document.querySelector( '.jrpg-detail-dialog[data-detail-mode="tools"]', ); const dialog = dialogElement.getBoundingClientRect(); const windows = [...document.querySelectorAll('.jrpg-tool-window')] .map(window => window.getBoundingClientRect()); const windowShadows = [...document.querySelectorAll('.jrpg-tool-window')] .map(window => getComputedStyle(window).boxShadow); const rail = getComputedStyle( document.querySelector('.jrpg-tool-panes'), '::before', ); return { dialogHeight: dialogElement.offsetHeight, dialogWidth: dialogElement.offsetWidth, fillsViewport: dialogElement.offsetWidth > innerWidth * 0.9 && dialogElement.offsetHeight > innerHeight * 0.9, viewportHeight: innerHeight, viewportWidth: innerWidth, sideBySide: windows.length === 2 && windows[0].right < windows[1].left && Math.abs(windows[0].top - windows[1].top) < 1, fullPageInHeader: Boolean( document.querySelector('[data-preview-link]') .closest('.jrpg-dialog-heading-actions'), ), footerHidden: document.querySelector('[data-dialog-actions]').hidden, metalRail: rail.content !== 'none' && rail.backgroundImage.includes('bar-ink.webp'), pixelIcons: document.querySelectorAll('.jrpg-tool-window zen-icon').length >= 2 && [...document.querySelectorAll('.jrpg-tool-window zen-icon svg')] .every(icon => getComputedStyle(icon).shapeRendering.toLowerCase() === 'crispedges' ), separateGlows: windowShadows.length === 2 && windowShadows.every(shadow => shadow !== 'none'), wideWindows: windows.every(window => window.width > dialog.width * 0.35), }; }); assert.equal( toolWorkspace.fillsViewport, true, JSON.stringify(toolWorkspace), ); assert.equal(toolWorkspace.sideBySide, true); assert.equal(toolWorkspace.fullPageInHeader, true); assert.equal(toolWorkspace.footerHidden, true); assert.equal(toolWorkspace.metalRail, true); assert.equal(toolWorkspace.pixelIcons, true); assert.equal(toolWorkspace.separateGlows, true); assert.equal(toolWorkspace.wideWindows, true); await page.getByRole('button', { name: 'Close destination details', }).click(); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.locator('[data-latest-tool]').first().click(); await page.locator('[data-functional-tool="markdown"]').waitFor(); await page.getByRole('button', { name: 'Close destination details', }).click(); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.locator('[data-latest-tool]').nth(1).click(); await page.locator('[data-functional-tool="converter"]').waitFor(); assert.equal( await page.locator('[data-converter-input]').count(), 2, ); const converterFixture = path.join( RUNFILES, WORKSPACE, 'mrjunejune/test/shiba.webp', ); assert.equal(fs.existsSync(converterFixture), true); await page.locator('[data-converter-input="image"]').setInputFiles( converterFixture, ); const conversionResponse = page.waitForResponse(response => response.url().endsWith('/api/convert/image-to-webp') && response.request().method() === 'POST' ); await page.locator('[data-convert-kind="image"]').click(); assert.equal((await conversionResponse).status(), 200); await page.locator('[data-converter-download="image"]').waitFor(); const cleanupResponse = page.waitForResponse(response => response.url().includes('/api/download/') && response.request().method() === 'DELETE' ); await page.getByRole('button', { name: 'Close destination details', }).click(); assert.equal((await cleanupResponse).status(), 204); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.locator('[data-latest-tool]').nth(1).click(); await page.locator('[data-functional-tool="converter"]').waitFor(); await page.locator('[data-converter-input="image"]').setInputFiles( converterFixture, ); const lateConversionResponse = page.waitForResponse(response => response.url().endsWith('/api/convert/image-to-webp') && response.request().method() === 'POST' ); const lateCleanupResponse = page.waitForResponse(response => response.url().includes('/api/download/') && response.request().method() === 'DELETE' ); await page.locator('[data-convert-kind="image"]').click(); await page.getByRole('button', { name: 'Close destination details', }).click(); assert.equal((await lateConversionResponse).status(), 200); assert.equal((await lateCleanupResponse).status(), 204); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.locator('[data-latest-tool]').nth(2).click(); await page.locator('[data-functional-tool="hls"]').waitFor(); assert.equal( await page.locator('[data-preview-link]').getAttribute('href'), '/tools/hls_player', ); await page.locator('[data-hls-sample]').click(); await page.waitForFunction(() => ['ready', 'error'].includes( document.querySelector('[data-hls-status]')?.dataset.state, ) ); assert.ok( ['ready', 'error'].includes( await page.locator('[data-hls-status]').getAttribute('data-state'), ), ); assert.match( await page.locator('[data-hls-status]').textContent(), /ready|unsupported|failed/i, ); await page.getByRole('button', { name: 'Close destination details', }).click(); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.locator('[data-latest-tool]').nth(3).click(); await page.locator('[data-functional-tool="latex"]').waitFor(); await page.locator('[data-latex-compile]').click(); await page.locator('[data-latex-source]').fill( '\\documentclass{article}\\begin{document}Updated modal PDF\\end{document}', ); assert.equal(await page.locator('[data-latex-download]').isHidden(), true); await page.locator('[data-latex-compile]').click(); await page.waitForFunction(() => document.querySelector('[data-latex-status]')?.dataset.state === 'ready' ); assert.match( await page.locator('[data-latex-preview]').getAttribute('src'), /^blob:/, ); await page.getByRole('button', { name: 'Close destination details', }).click(); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.locator('[data-latest-tool]').nth(4).click(); await page.waitForFunction(() => document.querySelector('[data-tool-content] h1')?.textContent === 'Personal Notes' ); assert.match( await page.locator('[data-tool-content]').textContent(), /private browser-based writing workspace/, ); assert.equal( await page.locator('[data-preview-link]').getAttribute('href'), '/notes', ); await page.getByRole('button', { name: 'Close destination details', }).click(); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.getByRole('button', { name: 'Resume', exact: true }).click(); await page.locator('[data-resume-modal]').click(); await page.locator('[data-resume-content] .sub-header').first().waitFor(); const resumeDossier = await page.locator('[data-resume-dossier]').evaluate( dossier => ({ fontFamily: getComputedStyle(dossier).fontFamily, text: dossier.textContent, }), ); assert.equal(resumeDossier.fontFamily, '"Pixel Mplus"'); assert.match(resumeDossier.text, /Copilot Tasks/); assert.match(resumeDossier.text, /Microsoft/); assert.match(resumeDossier.text, /Meta/); assert.equal( await page.locator('[data-resume-content] a[target="_blank"]').count() > 0, true, ); await page.getByRole('button', { name: 'Close destination details', }).click(); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.getByRole('button', { name: 'Blogs', exact: true }).click(); await page.waitForFunction(() => document.querySelectorAll('[data-latest-blog]').length === 4 ); assert.match( await page.locator('[data-work-showcase]').textContent(), /Websocket Demystified/, ); assert.match( await page.locator('[data-work-showcase]').textContent(), /Creating Network Library in C/, ); await page.locator('[data-latest-blog]').first().click(); await page.locator('[data-blog-content] h1').waitFor(); assert.equal( await page.locator('[data-blog-content] h1').textContent(), 'WebSocket Demystified', ); assert.equal( await page.locator('[data-preview-link]').getAttribute('href'), '/blog/websocket-demystified', ); assert.equal( await page.locator('[data-blog-content]').evaluate( article => getComputedStyle(article).fontFamily, ), '"Pixel Mplus"', ); await page.getByRole('button', { name: 'Close destination details', }).click(); await page.locator('.jrpg-preview-dialog dialog').waitFor({ state: 'hidden' }); await page.locator('[data-blog-archive]').click(); await page.waitForFunction(() => document.querySelectorAll('[data-blog-entry]').length === 9 ); assert.equal(await page.locator('[data-blog-entry]').count(), 9); await page.locator('[data-blog-entry]').nth(1).click(); await page.waitForFunction(() => document.querySelector('[data-blog-content] h1')?.textContent === 'Creating Network Library in C' ); assert.equal( await page.locator('[data-preview-link]').getAttribute('href'), '/blog/my-seobeo-journey', ); await page.getByRole('button', { name: 'Close destination details', }).click(); await page.evaluate(() => { window.__jrpgStreamEvents = []; document.querySelector('mjj-jrpg-shell').addEventListener( 'mjj-jrpg-stream-event', event => window.__jrpgStreamEvents.push(event.detail), ); }); await page.locator('#jrpg-message').fill('Where should I begin?'); await page.locator('#jrpg-message').press('Enter'); await page.locator('.jrpg-message[data-speaker="June"]').last().waitFor(); const streamingMessage = page.locator( '.jrpg-message[data-speaker="Epi"][data-streaming="true"]', ).last(); await streamingMessage.waitFor(); await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' }); await streamingMessage.waitFor(); assert.notEqual( await streamingMessage.locator('p').textContent(), 'hello traveler', ); await page.getByRole('button', { name: 'Blogs', exact: true }).click(); assert.equal( await page.locator('.jrpg-message[data-speaker="June"] p').last() .textContent(), 'Where should I begin?', ); await page.waitForFunction(() => document.querySelector('mjj-jrpg-character')?.getAttribute('state') === 'default' && document.querySelectorAll('.jrpg-message[data-speaker="Epi"]').length > 1 ); assert.equal( await page.locator('.jrpg-message[data-speaker="Epi"] p').last() .textContent(), 'hello traveler', ); assert.equal( await page.locator( '.jrpg-message[data-speaker="Epi"][data-streaming="true"]', ).count(), 0, ); assert.deepEqual( await page.evaluate(() => { const event = window.__jrpgStreamEvents.find( item => item.type === 'tool.started', ); return { name: event?.data.tool?.name, type: event?.type, }; }), { name: 'mock-search', type: 'tool.started', }, ); assert.equal( await page.locator('[data-turn-position]').textContent(), 'TURN 1 / 1', ); assert.equal( await page.getByRole('button', { name: 'Previous turn' }).isEnabled(), true, ); assert.equal( await page.getByRole('button', { name: 'Next turn' }).isDisabled(), true, ); await page.getByRole('button', { name: 'Previous turn' }).click(); assert.equal( await page.locator('[data-turn-position]').textContent(), 'START', ); assert.equal( await page.locator('.jrpg-message[data-speaker="June"]:visible').count(), 0, ); assert.equal( await page.locator('.jrpg-message[data-speaker="System"]:visible').count(), 1, ); await page.getByRole('button', { name: 'Next turn' }).click(); assert.equal( await page.locator('[data-turn-position]').textContent(), 'TURN 1 / 1', ); assert.equal( await page.locator('.jrpg-message[data-speaker="June"]:visible').count(), 1, ); assert.equal(await page.locator('#jrpg-message').isEnabled(), true); await page.waitForFunction(() => navigator.serviceWorker?.controller); await page.reload({ waitUntil: 'networkidle' }); await page.evaluate(() => document.fonts.ready); const controlledReload = await page.evaluate(() => { const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); return { art: getComputedStyle(document.querySelector('.jrpg-workspace')) .backgroundImage, fillsViewport: shell.left === 0 && shell.top === 0 && Math.abs(shell.right - innerWidth) < 1 && Math.abs(shell.bottom - innerHeight) < 1, fontLoaded: document.fonts.check('16px "Pixel Mplus"'), fontFamily: getComputedStyle( document.querySelector('.jrpg-brand h1'), ).fontFamily, theme: document.documentElement.dataset.zenTheme, }; }); assert.match(controlledReload.art, /background-frame-2\.webp/); assert.equal(controlledReload.fillsViewport, true); assert.equal(controlledReload.fontLoaded, true); assert.equal(controlledReload.fontFamily, '"Pixel Mplus"'); assert.equal(controlledReload.theme, 'cyberpunk'); assert.deepEqual(errors, []); await context.close(); const mobileContext = await browser.newContext({ colorScheme: 'light', viewport: { width: 360, height: 640 }, }); const mobilePage = await mobileContext.newPage(); await mobilePage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); await mobilePage.waitForFunction(() => customElements.get('mjj-jrpg-composer') && customElements.get('mjj-jrpg-menu') ); const mobile = await mobilePage.evaluate(() => { const workspace = document.querySelector('.jrpg-workspace') .getBoundingClientRect(); const scene = document.querySelector('.jrpg-scene').getBoundingClientRect(); const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); const composer = document.querySelector('mjj-jrpg-composer') .getBoundingClientRect(); const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); const hamburger = document.querySelector('[data-mobile-menu-toggle]'); const hamburgerBox = hamburger?.getBoundingClientRect(); const menuButtons = [...document.querySelectorAll( 'mjj-jrpg-menu button[data-preview]', )]; const telemetryItems = [ ...document.querySelectorAll('.jrpg-frame-telemetry > *'), ]; const closeTo = (value, expected, tol = 0.025) => Math.abs(value - expected) < tol; const wsW = workspace.width; const wsH = workspace.height; const mobileArt = getComputedStyle( document.querySelector('.jrpg-workspace'), ).backgroundImage; const mobileArtIsCorrect = mobileArt.includes('background-frame-mobile.webp'); const aperturesTolerance = [ closeTo((scene.left - workspace.left) / wsW, 0.045), closeTo((scene.top - workspace.top) / wsH, 0.075), closeTo(scene.width / wsW, 0.91), closeTo(scene.height / wsH, 0.455), closeTo((utility.left - workspace.left) / wsW, 0.045), closeTo((utility.top - workspace.top) / wsH, 0.547), closeTo(utility.width / wsW, 0.91), closeTo(utility.height / wsH, 0.171), closeTo((composer.left - workspace.left) / wsW, 0.045), closeTo((composer.top - workspace.top) / wsH, 0.728), closeTo(composer.width / wsW, 0.603), closeTo(composer.height / wsH, 0.192), closeTo((menu.left - workspace.left) / wsW, 0.665), closeTo((menu.top - workspace.top) / wsH, 0.728), closeTo(menu.width / wsW, 0.29), closeTo(menu.height / wsH, 0.192), ].every(Boolean); const hamburgerPresent = hamburger !== null && hamburger.getAttribute('aria-expanded') === 'true' && hamburgerBox !== null && hamburgerBox.width > 0 && hamburgerBox.height > 0; const telemetryCount = telemetryItems.length === 4; const composerVisible = composer.width > 0 && composer.height > 0; const menuVisible = menu.width > 0 && menu.height > 0; const conversationsButton = document.querySelector( 'button[data-preview="conversations"]', ); return { aperturesTolerance, conversationsButtonVisible: conversationsButton !== null && conversationsButton.offsetParent !== null, buttonsFit: menuButtons.every(button => button.getBoundingClientRect().right <= innerWidth + 1 ), composerVisible, fillsViewport: shell.left === 0 && shell.top === 0 && Math.abs(shell.right - innerWidth) < 1 && Math.abs(shell.bottom - innerHeight) < 1, fillsWidth: shell.left === 0 && Math.abs(shell.right - innerWidth) < 1, hamburgerPresent, initialScroll: scrollY, mobileArtIsCorrect, menuVisible, noOverflow: document.documentElement.scrollWidth <= innerWidth, telemetryCount, utilityBelowScene: utility.top >= scene.bottom - 1, workspaceFills: Math.abs(workspace.left) < 1 && Math.abs(workspace.top) < 1 && Math.abs(workspace.right - innerWidth) < 1 && Math.abs(workspace.bottom - innerHeight) < 1, }; }); assert.equal(mobile.aperturesTolerance, true, 'mobile apertures within tolerance at 360x640'); assert.equal( mobile.conversationsButtonVisible, true, 'Conversations destination visible on mobile', ); assert.equal(mobile.buttonsFit, true, 'menu buttons fit within viewport width'); assert.equal(mobile.composerVisible, true, 'composer visible'); assert.equal(mobile.fillsViewport, true, 'shell fills viewport'); assert.equal(mobile.fillsWidth, true, 'shell fills width'); assert.equal(mobile.hamburgerPresent, true, 'hamburger button present and has aria-expanded=true'); assert.equal(mobile.initialScroll, 0, 'no initial scroll'); assert.equal(mobile.mobileArtIsCorrect, true, 'mobile background URL is background-frame-mobile.webp'); assert.equal(mobile.menuVisible, true, 'menu visible'); assert.equal(mobile.noOverflow, true, 'no horizontal overflow at 360x640'); assert.equal(mobile.telemetryCount, true, 'four telemetry items present'); assert.equal(mobile.utilityBelowScene, true, 'utility aperture below scene'); assert.equal(mobile.workspaceFills, true, 'workspace fills full viewport'); // Test hamburger disclosure behavior. await mobilePage.locator('[data-mobile-menu-toggle]').click(); assert.equal( await mobilePage.locator('[data-mobile-menu-toggle]') .getAttribute('aria-expanded'), 'false', 'hamburger collapses the destination menu', ); assert.equal( await mobilePage.locator('mjj-jrpg-menu') .getAttribute('data-mobile-menu-collapsed'), '', 'menu exposes its collapsed state', ); // Second click expands. await mobilePage.locator('[data-mobile-menu-toggle]').click(); assert.equal( await mobilePage.locator('[data-mobile-menu-toggle]') .getAttribute('aria-expanded'), 'true', 'hamburger expands the destination menu', ); // Hamburger keyboard accessible assert.ok( await mobilePage.locator('[data-mobile-menu-toggle]').evaluate(el => el.tabIndex >= 0 || el.tabIndex === -1 ), 'hamburger button is in document', ); await mobileContext.close(); // Test at 390x844 const mobile390Context = await browser.newContext({ colorScheme: 'light', viewport: { width: 390, height: 844 }, }); const mobile390Page = await mobile390Context.newPage(); await mobile390Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); const mobile390 = await mobile390Page.evaluate(() => { const workspace = document.querySelector('.jrpg-workspace') .getBoundingClientRect(); const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); const mobileArt = getComputedStyle( document.querySelector('.jrpg-workspace'), ).backgroundImage; return { fillsViewport: shell.left === 0 && shell.top === 0 && Math.abs(shell.right - innerWidth) < 1 && Math.abs(shell.bottom - innerHeight) < 1, mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'), noOverflow: document.documentElement.scrollWidth <= innerWidth, workspaceFills: Math.abs(workspace.left) < 1 && Math.abs(workspace.top) < 1 && Math.abs(workspace.right - innerWidth) < 1 && Math.abs(workspace.bottom - innerHeight) < 1, }; }); assert.equal(mobile390.fillsViewport, true, 'fills viewport at 390x844'); assert.equal(mobile390.mobileArtIsCorrect, true, 'mobile art at 390x844'); assert.equal(mobile390.noOverflow, true, 'no overflow at 390x844'); assert.equal(mobile390.workspaceFills, true, 'workspace fills at 390x844'); await mobile390Context.close(); // Test at 320x568 (narrow) const mobile320Context = await browser.newContext({ colorScheme: 'light', viewport: { width: 320, height: 568 }, }); const mobile320Page = await mobile320Context.newPage(); await mobile320Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); const mobile320 = await mobile320Page.evaluate(() => { const workspace = document.querySelector('.jrpg-workspace') .getBoundingClientRect(); const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); const composer = document.querySelector('mjj-jrpg-composer') .getBoundingClientRect(); const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); const mobileArt = getComputedStyle( document.querySelector('.jrpg-workspace'), ).backgroundImage; return { composerPresent: composer.width > 0 && composer.height > 0, fillsViewport: shell.left === 0 && shell.top === 0 && Math.abs(shell.right - innerWidth) < 1 && Math.abs(shell.bottom - innerHeight) < 1, menuPresent: menu.width > 0 && menu.height > 0, mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'), noOverflow: document.documentElement.scrollWidth <= innerWidth, workspaceFills: Math.abs(workspace.left) < 1 && Math.abs(workspace.top) < 1 && Math.abs(workspace.right - innerWidth) < 1 && Math.abs(workspace.bottom - innerHeight) < 1, }; }); assert.equal(mobile320.composerPresent, true, 'composer present at 320x568'); assert.equal(mobile320.fillsViewport, true, 'fills viewport at 320x568'); assert.equal(mobile320.menuPresent, true, 'menu present at 320x568'); assert.equal(mobile320.mobileArtIsCorrect, true, 'mobile art at 320x568'); assert.equal(mobile320.noOverflow, true, 'no overflow at 320x568'); assert.equal(mobile320.workspaceFills, true, 'workspace fills at 320x568'); await mobile320Context.close(); const shortContext = await browser.newContext({ colorScheme: 'dark', viewport: { width: 900, height: 500 }, }); const shortPage = await shortContext.newPage(); await shortPage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); const shortLayout = await shortPage.evaluate(() => { const scene = document.querySelector('.jrpg-scene').getBoundingClientRect(); const character = document.querySelector('mjj-jrpg-character') .getBoundingClientRect(); const chat = document.querySelector('mjj-jrpg-chat') .getBoundingClientRect(); return { characterInside: character.left >= scene.left && character.right <= scene.right, chatInside: chat.left >= scene.left && chat.right <= scene.right, noSceneOverflow: document.querySelector('.jrpg-scene').scrollWidth <= document.querySelector('.jrpg-scene').clientWidth, }; }); assert.deepEqual(shortLayout, { characterInside: true, chatInside: true, noSceneOverflow: true, }); await shortContext.close(); const forcedColorsContext = await browser.newContext({ forcedColors: 'active', viewport: { width: 1280, height: 900 }, }); const forcedColorsPage = await forcedColorsContext.newPage(); await forcedColorsPage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); assert.deepEqual( await forcedColorsPage.evaluate(() => ({ sceneBar: getComputedStyle( document.querySelector('.jrpg-scene'), '::before', ).backgroundImage, workspace: getComputedStyle( document.querySelector('.jrpg-workspace'), ).backgroundImage, })), { sceneBar: 'none', workspace: 'none', }, ); await forcedColorsContext.close(); } async function testHlsPlayer(browser, siteRoot) { const page = await browser.newPage(); const errors = []; const mediaRequests = []; let testingExpectedFailure = false; let testingExpectedReload = false; page.on('pageerror', error => errors.push(`pageerror: ${error.message}`)); page.on('console', message => { if (message.type() !== 'error') return; if (testingExpectedFailure && message.text().startsWith('Failed to load resource:')) return; errors.push(`console: ${message.text()}`); }); page.on('requestfailed', request => { if (testingExpectedReload && request.failure()?.errorText === 'net::ERR_ABORTED') return; errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); }); page.on('request', request => { if (/\.(?:m3u8|m4s|mp4)(?:\?|$)/.test(request.url())) { mediaRequests.push(request.url()); } }); const playlistResponse = await fetch( `${baseUrl}/public/hls-sample/master.m3u8`, ); assert.equal(playlistResponse.status, 200); assert.match( playlistResponse.headers.get('content-type') || '', /^application\/vnd\.apple\.mpegurl/, ); assert.match( await playlistResponse.text(), /#EXT-X-STREAM-INF:.*CODECS="vp09\.00\.10\.08,opus"/, ); const variantResponse = await fetch( `${baseUrl}/public/hls-sample/vp9-stream.m3u8`, ); assert.equal(variantResponse.status, 200); assert.match( await variantResponse.text(), /#EXT-X-MAP:URI="vp9-init\.mp4"/, ); const segmentResponse = await fetch( `${baseUrl}/public/hls-sample/vp9-segment000.m4s`, ); assert.equal(segmentResponse.status, 200); assert.match( segmentResponse.headers.get('content-type') || '', /^video\/iso\.segment/, ); const transportStreamResponse = await fetch( `${baseUrl}/public/hls-sample/h264-ts-segment000.ts`, ); assert.equal(transportStreamResponse.status, 200); assert.match( transportStreamResponse.headers.get('content-type') || '', /^video\/mp2t/, ); assert.ok((await transportStreamResponse.arrayBuffer()).byteLength > 0); await page.goto( `${baseUrl}/tools/hls_player?url=${encodeURIComponent('/public/hls-sample/master.m3u8')}`, { waitUntil: 'networkidle', }, ); await page.getByRole('button', { name: 'Sample', exact: true }).waitFor(); assert.equal( await page.locator('#hlsUrl').evaluate(input => input.defaultValue), '/public/hls-sample/h264-ts-stream.m3u8', ); await page.waitForFunction(() => { const status = document.querySelector('#hlsStatus'); return status?.dataset.state === 'ready' || status?.dataset.state === 'error'; }, null, { timeout: 15000 }); const terminalState = await page.locator('#hlsStatus').getAttribute('data-state'); if (terminalState !== 'ready') { throw new Error( `HLS player failed: ${await page.locator('#hlsStatus').textContent()}\n${errors.join('\n')}`, ); } assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/); assert.equal( await page.locator('[data-detail="mode"]').textContent(), 'hls.js', ); assert.match( await page.locator('[data-detail="segments"]').textContent(), /Managed by hls\.js|adaptive levels/, ); await page.waitForFunction(() => { const video = document.querySelector('#hlsVideo'); return video.readyState >= HTMLMediaElement.HAVE_METADATA && Number.isFinite(video.duration) && video.duration >= 14; }); const firstFrame = await page.locator('#hlsVideo').evaluate(video => { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const context = canvas.getContext('2d'); context.drawImage(video, 0, 0); const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; let total = 0; for (let index = 0; index < pixels.length; index += 4) { total += pixels[index] + pixels[index + 1] + pixels[index + 2]; } return total / (canvas.width * canvas.height * 3); }); assert.ok(firstFrame > 10, `Initial HLS frame is black: ${firstFrame}`); await page.locator('#hlsVideo').evaluate(video => video.play()); await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.2); await page.locator('#hlsVideo').evaluate(video => video.pause()); assert.ok(mediaRequests.some(url => url.endsWith('/hls-sample/master.m3u8'))); assert.ok(mediaRequests.some(url => url.endsWith('-stream.m3u8'))); assert.ok(mediaRequests.some(url => url.endsWith('-init.mp4'))); assert.ok(mediaRequests.some(url => /-segment\d+\.m4s$/.test(url))); await page.evaluate(() => { window.hlsPlayer.hls.trigger(window.Hls.Events.ERROR, { fatal: true, type: window.Hls.ErrorTypes.MEDIA_ERROR, details: 'testRuntimeFailure', error: new Error('runtime segment failed'), }); }); await page.locator('#hlsStatus[data-state="error"]').waitFor(); assert.match( await page.locator('#hlsStatus').textContent(), /runtime segment failed/, ); await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8'); await page.getByRole('button', { name: 'Load', exact: true }).click(); await page.locator('#hlsStatus[data-state="ready"]').waitFor(); let delayedSegment = true; await page.route('**/hls-sample/vp9-segment000.m4s', async route => { if (delayedSegment) { delayedSegment = false; await new Promise(resolve => setTimeout(resolve, 250)); } await route.continue(); }); testingExpectedReload = true; await page.locator('#hlsUrl').fill('/public/hls-sample/master.m3u8'); await page.getByRole('button', { name: 'Load', exact: true }).click(); await page.waitForTimeout(25); await page.getByRole('button', { name: 'Load', exact: true }).click(); await page.locator('#hlsStatus[data-state="ready"]').waitFor(); testingExpectedReload = false; assert.match(await page.locator('#hlsStatus').textContent(), /Stream ready/); await page.evaluate(() => { window.__hlsObjectUrls = { created: [], revoked: [] }; const createObjectURL = URL.createObjectURL.bind(URL); const revokeObjectURL = URL.revokeObjectURL.bind(URL); URL.createObjectURL = value => { const url = createObjectURL(value); window.__hlsObjectUrls.created.push(url); return url; }; URL.revokeObjectURL = url => { window.__hlsObjectUrls.revoked.push(url); revokeObjectURL(url); }; }); const localFiles = listFiles(path.join(siteRoot, 'public/hls-sample')); await page.locator('#hlsFiles').setInputFiles(localFiles); await page.waitForFunction(() => document.querySelector('#hlsUrl')?.value.startsWith('Local: ') ); await page.locator('#hlsStatus[data-state="ready"]').waitFor(); assert.match(await page.locator('#hlsUrl').inputValue(), /^Local: /); assert.equal( await page.locator('[data-detail="mode"]').textContent(), 'hls.js', ); await page.locator('#hlsVideo').evaluate(video => video.play()); await page.waitForFunction(() => document.querySelector('#hlsVideo').currentTime > 0.3); await page.locator('#hlsVideo').evaluate(video => video.pause()); const incompleteLocalFiles = localFiles.filter(file => file.endsWith('master.m3u8') || file.endsWith('vp9-stream.m3u8') ); await page.locator('#hlsFiles').setInputFiles(incompleteLocalFiles); await page.locator('#hlsStatus[data-state="error"]').waitFor(); assert.match( await page.locator('#hlsStatus').textContent(), /Local HLS file is missing/, ); const objectUrlCounts = await page.evaluate(() => ({ created: window.__hlsObjectUrls.created.length, revoked: window.__hlsObjectUrls.revoked.length, })); assert.ok(objectUrlCounts.created > 10, JSON.stringify(objectUrlCounts)); assert.ok( objectUrlCounts.revoked >= objectUrlCounts.created, JSON.stringify(objectUrlCounts), ); await page.route('**/invalid.m3u8', route => route.fulfill({ status: 404, body: 'not found', })); testingExpectedFailure = true; await page.locator('#hlsUrl').fill(`${baseUrl}/invalid.m3u8`); await page.getByRole('button', { name: 'Load', exact: true }).click(); await page.locator('#hlsStatus[data-state="error"]').waitFor(); assert.match( await page.locator('#hlsStatus').textContent(), /404|manifestLoadError|Playlist request failed/, ); if (errors.length) throw new Error(errors.join('\n')); await page.close(); } function findFreePort() { return new Promise((resolve, reject) => { const socket = net.createServer(); socket.once('error', reject); socket.listen(0, '127.0.0.1', () => { const address = socket.address(); socket.close(error => { if (error) reject(error); else resolve(String(address.port)); }); }); }); } /* ------------------------------------------------------------------ */ /* Conversation URL routing regression tests (Fixes 1–4) */ /* ------------------------------------------------------------------ */ async function testRoleGreetings(browser) { const cases = [ { name: 'guest', session: { kind: 'guest', csrfToken: 'guest-greeting-csrf', quota: null, }, expected: 'Hi, my name is Epi. June usually calls me Epi-chan.', }, { name: 'invited', session: { kind: 'user', username: 'friend', role: 'member', mustChangePassword: false, csrfToken: 'friend-greeting-csrf', quota: null, }, expected: 'Since June invited you, I can be a little more casual.', }, { name: 'admin', session: { kind: 'user', username: 'june', role: 'admin', mustChangePassword: false, csrfToken: 'admin-greeting-csrf', quota: null, }, expected: 'Hi June! I’m Epi-chan.', }, ]; for (const testCase of cases) { const context = await browser.newContext({ serviceWorkers: 'block' }); const page = await context.newPage(); await page.route('**/api/auth/session', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(testCase.session), })); await page.route('**/api/conversations**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ conversations: [], cursor: null }), })); await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'domcontentloaded' }); const greeting = page.locator('[data-greeting]'); await greeting.waitFor({ state: 'visible' }); if (testCase.name === 'guest') { await page.locator('[data-greeting][data-streaming="true"]').waitFor({ state: 'visible', }); } await page.waitForFunction(() => { const item = document.querySelector('[data-greeting]'); return item && !item.hasAttribute('data-streaming'); }); assert.match( await greeting.locator('p').textContent(), new RegExp(testCase.expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), `${testCase.name} receives its role-specific Epi greeting`, ); await context.close(); } } async function testConversationRouting(browser) { const CONV_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const CONV_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const GUEST_SESSION = { kind: 'guest', csrfToken: 'test-csrf', quota: { turnsLimit: 10, turnsUsed: 0, turnsRemaining: 10, outputTokensLimit: 10000, outputTokensUsed: 0, outputTokensReserved: 0, outputTokensRemaining: 10000, resetsAt: Math.floor(Date.now() / 1000) + 86400, }, }; /* ---- Fix 3: malformed ?conversation param must not fall back to sessionStorage ---- */ { const storedId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const context = await browser.newContext({ serviceWorkers: 'block' }); await context.addInitScript(id => { sessionStorage.setItem('mjj-jrpg-conversation-id', id); }, storedId); const page = await context.newPage(); const storedIdFetched = []; await page.route('**/api/auth/session', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(GUEST_SESSION), })); await page.route('**/api/conversations**', route => { const url = new URL(route.request().url()); if (url.pathname.endsWith(`/${storedId}`)) { storedIdFetched.push(route.request().url()); } if (url.pathname === '/api/conversations') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ conversations: [], cursor: null }), }); } return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Not found' } }), }); }); await page.route('**/api/conversations/claim', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Service unavailable' } }), })); await page.goto(`${baseUrl}/jrpg?conversation=not-a-valid-uuid`, { waitUntil: 'networkidle', }); assert.equal( storedIdFetched.length, 0, 'Fix 3: malformed ?conversation param must not trigger sessionStorage fallback', ); await context.close(); } /* ---- Fix 4: legacy claim: key retained on 5xx, button re-renders and re-fires ---- */ { const legacyStoredId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const context = await browser.newContext({ serviceWorkers: 'block' }); /* Seed CONVERSATION_STORAGE_KEY so init migrates it to CONVERSATION_LEGACY_KEY */ await context.addInitScript(id => { sessionStorage.setItem('mjj-jrpg-conversation-id', id); }, legacyStoredId); const page = await context.newPage(); const pageErrors = []; page.on('pageerror', e => { if (!e.message.startsWith('Failed to load resource:')) { pageErrors.push(`pageerror: ${e.message}`); } }); await page.route('**/api/auth/session', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ kind: 'user', username: 'tester', role: 'member', csrfToken: 'test-csrf', quota: null, }), })); await page.route('**/api/conversations**', route => { const url = new URL(route.request().url()); const req = route.request(); if (url.pathname === '/api/conversations' && req.method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ conversations: [], cursor: null }), }); } if (url.pathname === `/api/conversations/${legacyStoredId}` && req.method() === 'GET') { return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Not found' } }), }); } if (url.pathname === '/api/conversations/claim' && req.method() === 'POST') { return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Service unavailable' } }), }); } return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Not found' } }), }); }); await page.route('**/api/conversations/claim', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Service unavailable' } }), })); await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); /* Open archive panel so the legacy claim status is visible */ await page.locator('button[data-preview="conversations"]').click(); await page.locator('mjj-conversation-archive').waitFor({ state: 'visible' }); /* Init should have called showLegacyClaim() — wait for the button */ await page.waitForFunction( () => document.querySelector('[data-archive-claim]') !== null, null, { timeout: 5000 }, ); /* First claim attempt — returns 503 */ const firstClaimResponse = page.waitForResponse( response => response.url().includes('/api/conversations/claim'), ); await page.locator('[data-archive-claim]').click(); const firstClaimStatus = (await firstClaimResponse).status(); assert.equal( firstClaimStatus, 503, `claim fixture returns the mocked service failure, got ${firstClaimStatus}`, ); /* Wait for the retry state to appear: showLegacyClaim re-rendered with error msg */ await page.waitForFunction( () => { const status = document.querySelector('[data-archive-status]'); return status && !status.hidden && status.textContent.includes('Claim failed'); }, null, { timeout: 5000 }, ); /* Key must be retained in sessionStorage on 5xx */ const keyAfterFail = await page.evaluate( () => sessionStorage.getItem('mjj-jrpg-legacy-claim'), ); assert.equal( keyAfterFail, legacyStoredId, 'Fix 4: CONVERSATION_LEGACY_KEY must be retained after 5xx failure', ); /* Button must be present and enabled for retry */ await page.locator('[data-archive-claim]').waitFor({ state: 'visible' }); /* Tag the current button so we can detect when it's replaced by a second re-render */ await page.locator('[data-archive-claim]').evaluate(btn => { btn.dataset.claimGen = '1'; }); /* Second click: must dispatch event again — proves no {once:true} on button */ await page.locator('[data-archive-claim]').click(); /* Wait for the button to be replaced (showLegacyClaim called again = handler ran) */ await page.waitForFunction( () => { const btn = document.querySelector('[data-archive-claim]'); return btn !== null && btn.dataset.claimGen !== '1'; }, null, { timeout: 5000 }, ); assert.deepEqual(pageErrors, [], 'no page errors in legacy claim test'); await context.close(); } /* ---- Fix 1: popstate during active stream converges URL and UI ---- */ { let releaseStream = () => {}; const streamGate = new Promise(resolve => { releaseStream = resolve; }); const context = await browser.newContext(); const page = await context.newPage(); const pageErrors = []; page.on('pageerror', e => { if (!e.message.startsWith('Failed to load resource:')) { pageErrors.push(`pageerror: ${e.message}`); } }); /* Suppress expected abort-network errors from the held-then-aborted stream */ page.on('requestfailed', request => { if (request.failure()?.errorText === 'net::ERR_ABORTED') return; pageErrors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); }); await page.route('**/api/auth/session', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(GUEST_SESSION), })); await page.route('**/api/conversations**', async route => { const url = new URL(route.request().url()); const req = route.request(); if (url.pathname === '/api/conversations' && req.method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ conversations: [], cursor: null }), }); } if (url.pathname === '/api/conversations' && req.method() === 'POST') { return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ id: CONV_A }), }); } if (url.pathname.endsWith('/turns') && req.method() === 'POST') { /* Hold the SSE stream until the test releases it */ await streamGate; try { await route.abort(); } catch { /* request may already be aborted */ } return; } return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Not found' } }), }); }); await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); await page.waitForFunction( () => !document.querySelector('mjj-jrpg-composer textarea')?.disabled, ); /* Submit — creates conversation via pushState and starts stream */ await page.locator('#jrpg-message').fill('routing race test'); await page.locator('#jrpg-message').press('Enter'); /* Stream is now active */ await page.locator('[data-cancel-control]').waitFor({ state: 'visible' }); const urlDuringStream = page.url(); assert.ok( urlDuringStream.includes('conversation='), `Fix 1: URL must have ?conversation during stream: ${urlDuringStream}`, ); /* Navigate back while stream is active — URL already changes here */ await page.goBack(); /* Stream abort + pending-popstate application: cancel control disappears */ await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' }); /* URL and UI must converge to the pre-stream state */ const finalUrl = page.url(); assert.ok( !finalUrl.includes('conversation='), `Fix 1: URL must not have ?conversation after popstate back: ${finalUrl}`, ); assert.equal( await page.locator( '.jrpg-message[data-streaming="true"]:not([data-greeting])', ).count(), 0, 'Fix 1: no streaming message must remain after popstate back', ); releaseStream(); /* clean up the held route */ assert.deepEqual(pageErrors, [], 'no page errors in popstate-during-stream test'); await context.close(); } /* ---- Fix 2: openSeq incremented on no-conv popstate, invalidates pending open ---- */ { let resolveConvBFetch = () => {}; const convBFetchGate = new Promise(resolve => { resolveConvBFetch = resolve; }); const context = await browser.newContext(); const page = await context.newPage(); const pageErrors = []; page.on('pageerror', e => { if (!e.message.startsWith('Failed to load resource:')) { pageErrors.push(`pageerror: ${e.message}`); } }); await page.route('**/api/auth/session', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(GUEST_SESSION), })); await page.route('**/api/conversations**', async route => { const url = new URL(route.request().url()); const req = route.request(); /* Empty archive: no auto-open, no gate deadlock */ if (url.pathname === '/api/conversations' && req.method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ conversations: [], cursor: null }), }); } /* The archive-open click will trigger this; hold it with the gate */ if (url.pathname === `/api/conversations/${CONV_B}` && req.method() === 'GET') { await convBFetchGate; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: CONV_B, title: 'Conv B', turns: [] }), }); } return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: { message: 'Not found' } }), }); }); /* Empty archive so init completes without auto-opening any conversation */ await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); /* Open archive panel — this pushes a history entry (conversations panel) */ await page.locator('button[data-preview="conversations"]').click(); await page.locator('mjj-conversation-archive').waitFor({ state: 'visible' }); /* Inject Conv B into the archive list without triggering an API fetch */ await page.evaluate(id => { document.querySelector('mjj-conversation-archive') ?.addConversation({ id, title: 'Conv B', turn_count: 0 }); }, CONV_B); await page.waitForFunction( id => [...document.querySelectorAll('[data-conv-item]')] .some(item => item._convId === id), CONV_B, ); /* Click Conv B — archive-open fires, fetch held by convBFetchGate */ await page.evaluate(id => { for (const item of document.querySelectorAll('[data-conv-item]')) { if (item._convId === id) { item.querySelector('[data-conv-open]')?.click(); break; } } }, CONV_B); /* Brief pause so archive-open can increment openSeq and start the fetch */ await page.waitForTimeout(80); /* Navigate back — popstate fires with no-conv state, openSeq is incremented */ await page.goBack(); await page.waitForTimeout(80); /* Release the held fetch — the archive-open handler should ignore it */ resolveConvBFetch(); await page.waitForTimeout(300); /* currentConversationId must remain null (no aria-current item) */ const currentAriaItem = await page.evaluate(() => { const btn = document.querySelector('[data-conv-open][aria-current="true"]'); return btn ? (btn.closest('[data-conv-item]')?._convId ?? null) : null; }); assert.equal( currentAriaItem, null, 'Fix 2: pending archive-open must not overwrite no-conv popstate', ); assert.deepEqual(pageErrors, [], 'no page errors in openSeq test'); await context.close(); } } (async () => { assert.ok(RUNFILES); assert.ok(WORKSPACE); const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); const serverBinary = path.join( runfilesWorkspace, 'mrjunejune/mrjunejune_server_debug', ); const siteRoot = path.join(runfilesWorkspace, 'mrjunejune/src'); const fakeSidecar = path.join( runfilesWorkspace, 'mrjunejune/test/inference_bridge_fake_sidecar', ); const tempDirectory = fs.mkdtempSync( path.join(os.tmpdir(), 'mrjunejune-theme-test-'), ); const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); const serverLogs = []; let server; let browser; try { port = await findFreePort(); baseUrl = `http://127.0.0.1:${port}`; const pngFiles = listFiles(siteRoot).filter(file => file.endsWith('.png')); assert.deepEqual(pngFiles, [], `PNG files leaked into runfiles: ${pngFiles}`); for (const file of listFiles(siteRoot).filter(file => file.endsWith('.html') )) { const source = fs.readFileSync(file, 'utf8'); for (const primitive of source.matchAll(/<zen-button\b([^>]*)>/g)) { assert.match( primitive[1], /\bsize=["'](?:xs|sm|md|lg|xl)["']/, `${path.relative(siteRoot, file)} has an unsized zen-button`, ); } assert.deepEqual( primitiveOwnershipViolations(source), [], `${path.relative(siteRoot, file)} violates primitive ownership`, ); } for (const file of listFiles(siteRoot).filter(file => /\.(?:css|html|js)$/.test(file) && !file.includes(`${path.sep}public${path.sep}design-system${path.sep}`) )) { const source = fs.readFileSync(file, 'utf8'); assert.doesNotMatch( source, /var\(\s*--zen-/, `${path.relative(siteRoot, file)} consumes a deprecated token`, ); } server = spawn(serverBinary, [], { cwd: runfilesWorkspace, env: { ...process.env, MRJUNEJUNE_PORT: port, MRJUNEJUNE_DB_PATH: path.join(tempDirectory, 'site.db'), MRJUNEJUNE_INFERENCE_SIDECAR_PATH: fakeSidecar, MRJUNEJUNE_COPILOT_CLI_PATH: fakeSidecar, MRJUNEJUNE_ALLOW_GUEST_INFERENCE: '1', }, stdio: ['ignore', 'pipe', 'pipe'], }); server.stdout.on('data', chunk => serverLogs.push(chunk.toString())); server.stderr.on('data', chunk => serverLogs.push(chunk.toString())); await waitForServer(server, serverLogs); const home = await (await fetch(baseUrl)).text(); const dogGame = await ( await fetch(`${baseUrl}/public/dog-game.js`) ).text(); const manifest = await ( await fetch(`${baseUrl}/public/manifest.json`) ).text(); const serviceWorker = await ( await fetch(`${baseUrl}/sw.js`) ).text(); for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } assert.match(serviceWorker, /v35-role-greeting/); assert.match( await ( await fetch(`${baseUrl}/public/pwa-register.js`) ).text(), /register\('\/sw\.js', \{ scope: '\/' \}\)/, ); assert.ok(serviceWorker.includes("startsWith('/tools/hls_player')")); assert.ok(serviceWorker.includes("url.hostname === 'localhost'")); assert.ok(serviceWorker.includes("url.hostname === '127.0.0.1'")); assert.ok(!serviceWorker.includes("startsWith('/jrpg')")); for (const asset of [ 'sprite_shiba0.webp', 'dog-treat.webp', 'start-large.webp', 'icon-192.webp', 'vscode.webp', ]) { const response = await fetch(`${baseUrl}/public/${asset}`); assert.equal(response.status, 200, asset); assert.ok((await response.arrayBuffer()).byteLength > 0, asset); } browser = await chromium.launch({ executablePath: chromiumPath, headless: true, args: ['--no-sandbox'], }); if (runsSuite('core')) { const paper = await sampleTheme(browser, 'paper'); const ink = await sampleTheme(browser, 'ink'); const playful = await sampleTheme(browser, 'playful'); const automatic = await sampleTheme(browser, 'auto'); assert.equal(paper.rootTheme, 'paper'); assert.equal(ink.rootTheme, 'ink'); assert.equal(playful.rootTheme, 'playful'); assert.equal(automatic.rootTheme, 'auto'); assert.equal(paper.themeLabel, 'Paper'); assert.equal(ink.themeLabel, 'Ink'); assert.equal(playful.themeLabel, 'Playful'); assert.equal(automatic.themeLabel, 'Auto'); for (const sample of [paper, ink, playful, automatic]) { assert.ok(sample.count > 0); assert.equal(sample.backgroundRepeat, 'no-repeat'); assert.equal(sample.backgroundSize, 'cover'); assert.equal(sample.bodyCoversViewport, true); assert.equal(sample.componentReady, true); assert.ok(sample.links > 0); assert.equal(sample.enhancedLinks, sample.pawLinks); assert.match(sample.fontFamily, /More/); assert.equal(sample.headerPaws, 0); assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample)); assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)'); assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)'); assert.equal(sample.themeButtonBorder, '0px'); assert.equal(sample.themeButtonShadow, 'none'); } assert.ok(paper.luminance < 175, JSON.stringify(paper)); assert.ok(playful.luminance < 175, JSON.stringify(playful)); assert.ok(ink.luminance > 200, JSON.stringify(ink)); await testThemeCycle(browser); await testPlainField(browser); await testButtonScale(browser); await testDynamicButtonOwnership(browser); await testResumePrint(browser); } if (runsSuite('jrpg')) await testJrpgPage(browser); if (runsSuite('routing')) await testConversationRouting(browser); if (runsSuite('greeting')) await testRoleGreetings(browser); if (runsSuite('hls')) await testHlsPlayer(browser, siteRoot); } finally { if (browser) await browser.close(); await stopProcess(server); fs.rmSync(tempDirectory, { recursive: true, force: true }); } })().catch(error => { console.error(error.stack || error); process.exitCode = 1; });