Mercurial
diff mrjunejune/test/theme_and_webp_test.js @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | ee04e4e69fed |
| children | 056790c4fb0d |
line wrap: on
line diff
--- a/mrjunejune/test/theme_and_webp_test.js Thu Aug 06 11:31:30 2026 -0700 +++ b/mrjunejune/test/theme_and_webp_test.js Fri Aug 07 07:34:12 2026 -0700 @@ -15,6 +15,8 @@ '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(); @@ -477,6 +479,10 @@ 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 => { @@ -486,6 +492,9 @@ )) { 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()}`); } @@ -707,6 +716,10 @@ 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]'), @@ -741,6 +754,8 @@ ), 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, @@ -789,6 +804,7 @@ 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); @@ -823,6 +839,149 @@ 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( @@ -837,7 +996,7 @@ ), 'visible', ); - assert.equal(await page.locator('[data-work-showcase] a').count(), 7); + assert.equal(await page.locator('[data-work-showcase] a').count(), 8); assert.equal( await page.getByRole('button', { name: /Inspect/ }).count(), 0, @@ -848,7 +1007,7 @@ ); assert.match( await page.locator('[data-work-showcase]').textContent(), - /Copilot SuperApp/, + /AIX Harness \/ Copilot/, ); assert.equal( await page.locator('[data-turn-position]').textContent(), @@ -1284,32 +1443,213 @@ }); 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 + 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, - utilityBelowScene: utility.top >= scene.bottom, + 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.deepEqual(mobile, { - buttonsFit: true, - fillsWidth: true, - initialScroll: 0, - noOverflow: true, - utilityBelowScene: true, + 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 }, }); - await mobileContext.close(); + 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', @@ -1598,6 +1938,373 @@ }); } +/* ------------------------------------------------------------------ */ +/* Conversation URL routing regression tests (Fixes 1–4) */ +/* ------------------------------------------------------------------ */ + +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"]').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); @@ -1682,7 +2389,7 @@ for (const source of [home, dogGame, manifest]) { assert.doesNotMatch(source, /\.png(?:["')]|$)/i); } - assert.match(serviceWorker, /v30-card-driven-details/); + assert.match(serviceWorker, /v34-login-modal/); assert.match( await ( await fetch(`${baseUrl}/public/pwa-register.js`) @@ -1711,44 +2418,47 @@ headless: true, args: ['--no-sandbox'], }); - 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'); + 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); } - 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); - await testJrpgPage(browser); - await testHlsPlayer(browser, siteRoot); + if (runsSuite('jrpg')) await testJrpgPage(browser); + if (runsSuite('routing')) await testConversationRouting(browser); + if (runsSuite('hls')) await testHlsPlayer(browser, siteRoot); } finally { if (browser) await browser.close(); await stopProcess(server);