Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 263:ee04e4e69fed | 264:04fee26ecce0 |
|---|---|
| 13 RUNFILES, | 13 RUNFILES, |
| 14 WORKSPACE, | 14 WORKSPACE, |
| 15 'hg-web/e2e/node_modules/playwright-core', | 15 'hg-web/e2e/node_modules/playwright-core', |
| 16 ); | 16 ); |
| 17 const { chromium } = require(playwrightPath); | 17 const { chromium } = require(playwrightPath); |
| 18 const browserSuite = process.env.MJJ_BROWSER_SUITE || 'all'; | |
| 19 const runsSuite = name => browserSuite === 'all' || browserSuite === name; | |
| 18 | 20 |
| 19 function stopProcess(child) { | 21 function stopProcess(child) { |
| 20 if (!child || child.exitCode !== null) return Promise.resolve(); | 22 if (!child || child.exitCode !== null) return Promise.resolve(); |
| 21 child.kill('SIGTERM'); | 23 child.kill('SIGTERM'); |
| 22 return new Promise(resolve => { | 24 return new Promise(resolve => { |
| 475 if (request.method() === 'POST' && | 477 if (request.method() === 'POST' && |
| 476 request.url().endsWith('/api/latex/render') && | 478 request.url().endsWith('/api/latex/render') && |
| 477 request.failure()?.errorText === 'net::ERR_ABORTED') { | 479 request.failure()?.errorText === 'net::ERR_ABORTED') { |
| 478 return; | 480 return; |
| 479 } | 481 } |
| 482 if (request.url().includes('/api/auth/login') && | |
| 483 request.failure()?.errorText === 'net::ERR_ABORTED') { | |
| 484 return; | |
| 485 } | |
| 480 errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); | 486 errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); |
| 481 }); | 487 }); |
| 482 page.on('response', response => { | 488 page.on('response', response => { |
| 483 if (response.status() === 404 && | 489 if (response.status() === 404 && |
| 484 response.url().endsWith( | 490 response.url().endsWith( |
| 485 '/api/conversations/00000000-0000-4000-8000-000000000000' | 491 '/api/conversations/00000000-0000-4000-8000-000000000000' |
| 486 )) { | 492 )) { |
| 487 return; | 493 return; |
| 494 } | |
| 495 if (response.url().includes('/api/auth/login') && response.status() >= 400) { | |
| 496 return; /* expected: login attempt with invalid credentials in modal test */ | |
| 488 } | 497 } |
| 489 if (response.status() >= 400) { | 498 if (response.status() >= 400) { |
| 490 errors.push(`response: ${response.status()} ${response.url()}`); | 499 errors.push(`response: ${response.status()} ${response.url()}`); |
| 491 } | 500 } |
| 492 }); | 501 }); |
| 705 messageViewport.height > 192 | 714 messageViewport.height > 192 |
| 706 ), | 715 ), |
| 707 noOverflow: document.documentElement.scrollWidth <= innerWidth, | 716 noOverflow: document.documentElement.scrollWidth <= innerWidth, |
| 708 nonPixelText, | 717 nonPixelText, |
| 709 questLabelAbsent: !document.body.textContent.includes('QUEST 01'), | 718 questLabelAbsent: !document.body.textContent.includes('QUEST 01'), |
| 719 previewLabelsRemoved: | |
| 720 !/CHARACTER RECORD|ITEM INVENTORY|QUEST ARCHIVE/.test( | |
| 721 document.querySelector('.jrpg-preview-panel').textContent, | |
| 722 ), | |
| 710 statsRemoved: | 723 statsRemoved: |
| 711 !document.querySelector('.jrpg-preview-stats') && | 724 !document.querySelector('.jrpg-preview-stats') && |
| 712 !document.querySelector('[data-preview-type]'), | 725 !document.querySelector('[data-preview-type]'), |
| 713 telemetryCovered: | 726 telemetryCovered: |
| 714 document.querySelectorAll('.jrpg-frame-telemetry > *').length === 4 && | 727 document.querySelectorAll('.jrpg-frame-telemetry > *').length === 4 && |
| 739 frameTelemetry.left) / frameTelemetry.width, | 752 frameTelemetry.left) / frameTelemetry.width, |
| 740 0.88, | 753 0.88, |
| 741 ), | 754 ), |
| 742 shibaInFrontOfBar: | 755 shibaInFrontOfBar: |
| 743 sceneBarStyle.backgroundImage.includes('bar-ink.webp') && | 756 sceneBarStyle.backgroundImage.includes('bar-ink.webp') && |
| 757 sceneBarStyle.backgroundSize === 'cover' && | |
| 758 Math.abs(parseFloat(sceneBarStyle.height) - scene.height) < 1 && | |
| 744 Number(getComputedStyle(character.parentElement).zIndex) > | 759 Number(getComputedStyle(character.parentElement).zIndex) > |
| 745 Number(sceneBarStyle.zIndex) && | 760 Number(sceneBarStyle.zIndex) && |
| 746 characterBox.bottom > scene.top + scene.height * 0.65, | 761 characterBox.bottom > scene.top + scene.height * 0.65, |
| 747 terminalScrollbar: ( | 762 terminalScrollbar: ( |
| 748 getComputedStyle( | 763 getComputedStyle( |
| 787 assert.equal(desktop.menuRightOfComposer, true); | 802 assert.equal(desktop.menuRightOfComposer, true); |
| 788 assert.equal(desktop.messageViewportFills, true); | 803 assert.equal(desktop.messageViewportFills, true); |
| 789 assert.equal(desktop.noOverflow, true); | 804 assert.equal(desktop.noOverflow, true); |
| 790 assert.deepEqual(desktop.nonPixelText, []); | 805 assert.deepEqual(desktop.nonPixelText, []); |
| 791 assert.equal(desktop.questLabelAbsent, true); | 806 assert.equal(desktop.questLabelAbsent, true); |
| 807 assert.equal(desktop.previewLabelsRemoved, true); | |
| 792 assert.equal(desktop.statsRemoved, true); | 808 assert.equal(desktop.statsRemoved, true); |
| 793 assert.equal(desktop.telemetryCovered, true); | 809 assert.equal(desktop.telemetryCovered, true); |
| 794 assert.equal(desktop.telemetryValuesAligned, true); | 810 assert.equal(desktop.telemetryValuesAligned, true); |
| 795 assert.equal(desktop.shibaInFrontOfBar, true); | 811 assert.equal(desktop.shibaInFrontOfBar, true); |
| 796 assert.equal(desktop.terminalScrollbar, true); | 812 assert.equal(desktop.terminalScrollbar, true); |
| 821 ); | 837 ); |
| 822 assert.equal( | 838 assert.equal( |
| 823 await page.locator('.jrpg-frame-controls a').getAttribute('href'), | 839 await page.locator('.jrpg-frame-controls a').getAttribute('href'), |
| 824 '/', | 840 '/', |
| 825 ); | 841 ); |
| 842 | |
| 843 // Account slot: guest session shows a login button (modal trigger) | |
| 844 assert.ok( | |
| 845 await page.locator('[data-frame-account]').count() > 0, | |
| 846 'account slot exists in telemetry', | |
| 847 ); | |
| 848 await page.waitForFunction(() => | |
| 849 document.querySelector('[data-frame-account]')?.querySelector('button[data-login-open]') !== null | |
| 850 ); | |
| 851 const loginBtnState = await page.locator('[data-frame-account] button[data-login-open]').evaluate(btn => ({ | |
| 852 text: btn.textContent.trim(), | |
| 853 ownerCount: btn.closest('zen-button') ? 1 : 0, | |
| 854 appearance: btn.closest('zen-button')?.getAttribute('appearance'), | |
| 855 size: btn.closest('zen-button')?.getAttribute('size'), | |
| 856 })); | |
| 857 assert.match(loginBtnState.text, /LOGIN/i); | |
| 858 assert.equal(loginBtnState.ownerCount, 1, 'login button has zen-button owner'); | |
| 859 assert.equal(loginBtnState.appearance, 'plain'); | |
| 860 assert.equal(loginBtnState.size, 'xs'); | |
| 861 | |
| 862 // Login modal: opens on button click, focuses username, closes on cancel | |
| 863 await page.locator('[data-frame-account] button[data-login-open]').click(); | |
| 864 await page.locator('[data-login-dialog]').waitFor({ state: 'visible' }); | |
| 865 assert.equal( | |
| 866 await page.locator('[data-login-dialog]').getAttribute('aria-modal'), | |
| 867 'true', | |
| 868 'login dialog is modal', | |
| 869 ); | |
| 870 // Username input should receive initial focus | |
| 871 await page.waitForFunction(() => document.activeElement?.id === 'jrpg-login-username'); | |
| 872 // Cancel closes dialog and clears password | |
| 873 await page.locator('#jrpg-login-password').fill('dummypassword'); | |
| 874 await page.locator('[data-login-cancel]').click(); | |
| 875 await page.locator('[data-login-dialog]').waitFor({ state: 'hidden' }); | |
| 876 await page.waitForFunction(() => !document.querySelector('#jrpg-login-password')?.value); | |
| 877 assert.equal( | |
| 878 await page.locator('#jrpg-login-password').inputValue(), | |
| 879 '', | |
| 880 'password cleared on close', | |
| 881 ); | |
| 882 | |
| 883 // Invalid credentials show error message | |
| 884 await page.locator('[data-frame-account] button[data-login-open]').click(); | |
| 885 await page.locator('[data-login-dialog]').waitFor({ state: 'visible' }); | |
| 886 await page.locator('#jrpg-login-username').fill('nosuchuser'); | |
| 887 await page.locator('#jrpg-login-password').fill('nosuchpassword123'); | |
| 888 let loginErrorShown = false; | |
| 889 const loginResponsePromise = page.waitForResponse(resp => | |
| 890 resp.url().includes('/api/auth/login') | |
| 891 ); | |
| 892 await page.locator('[data-login-submit]').click(); | |
| 893 const loginResp = await loginResponsePromise; | |
| 894 if (loginResp.status() >= 400) { | |
| 895 await page.waitForFunction(() => { | |
| 896 const el = document.querySelector('[data-login-error]'); | |
| 897 return el && !el.hidden && el.textContent.trim().length > 0; | |
| 898 }); | |
| 899 loginErrorShown = true; | |
| 900 } | |
| 901 assert.equal(loginErrorShown, true, 'error element shown on bad credentials'); | |
| 902 assert.equal( | |
| 903 await page.locator('#jrpg-login-password').inputValue(), | |
| 904 '', | |
| 905 'password cleared after failed login attempt', | |
| 906 ); | |
| 907 // Close modal via X button (data-zen-close) | |
| 908 await page.getByRole('button', { name: 'Close sign-in dialog' }).click(); | |
| 909 await page.locator('[data-login-dialog]').waitFor({ state: 'hidden' }); | |
| 910 | |
| 911 // Archive opens only from the bottom destination menu. | |
| 912 assert.equal( | |
| 913 await page.locator('[data-toggle-archive]').count(), | |
| 914 0, | |
| 915 'redundant chat-heading archive shortcut is removed', | |
| 916 ); | |
| 917 await page.locator('button[data-preview="conversations"]').click(); | |
| 918 await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'visible' }); | |
| 919 const archiveGeometry = await page.evaluate(() => { | |
| 920 const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); | |
| 921 const archive = document.querySelector( | |
| 922 '.jrpg-utility mjj-conversation-archive', | |
| 923 ).getBoundingClientRect(); | |
| 924 return { | |
| 925 archive: { | |
| 926 bottom: archive.bottom, | |
| 927 height: archive.height, | |
| 928 left: archive.left, | |
| 929 right: archive.right, | |
| 930 top: archive.top, | |
| 931 width: archive.width, | |
| 932 }, | |
| 933 fits: archive.left >= utility.left && | |
| 934 archive.top >= utility.top && | |
| 935 archive.right <= utility.right + 1 && | |
| 936 archive.bottom <= utility.bottom + 1 && | |
| 937 archive.width / utility.width > 0.85 && | |
| 938 archive.height / utility.height > 0.85, | |
| 939 utility: { | |
| 940 bottom: utility.bottom, | |
| 941 height: utility.height, | |
| 942 left: utility.left, | |
| 943 right: utility.right, | |
| 944 top: utility.top, | |
| 945 width: utility.width, | |
| 946 }, | |
| 947 }; | |
| 948 }); | |
| 949 assert.equal( | |
| 950 archiveGeometry.fits, | |
| 951 true, | |
| 952 `conversation archive fills the top-right utility aperture: ${JSON.stringify(archiveGeometry)}`, | |
| 953 ); | |
| 954 // Archive status or empty message should be visible | |
| 955 await page.waitForFunction(() => { | |
| 956 const status = document.querySelector('[data-archive-status]'); | |
| 957 return status && !status.hidden; | |
| 958 }); | |
| 959 // New conversation button exists inside archive | |
| 960 assert.ok( | |
| 961 await page.locator('[data-archive-new]').isVisible(), | |
| 962 'archive new button visible', | |
| 963 ); | |
| 964 // Conversations menu button should be pressed | |
| 965 assert.equal( | |
| 966 await page.locator('button[data-preview="conversations"]').getAttribute('aria-pressed'), | |
| 967 'true', | |
| 968 ); | |
| 969 | |
| 970 // Archive: close via close button, navigates back to resume panel | |
| 971 await page.locator('[data-archive-close]').click(); | |
| 972 await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'hidden' }); | |
| 973 // mjj-jrpg-chat must remain visible (always in scene) | |
| 974 assert.equal( | |
| 975 await page.locator('mjj-jrpg-chat').isVisible(), | |
| 976 true, | |
| 977 ); | |
| 978 | |
| 979 // Quota element is present (may be shown for guest session) | |
| 980 assert.ok( | |
| 981 await page.locator('[data-quota]').count() > 0, | |
| 982 'quota element exists', | |
| 983 ); | |
| 984 | |
| 826 await page.getByRole('button', { name: 'Minimize interface' }).click(); | 985 await page.getByRole('button', { name: 'Minimize interface' }).click(); |
| 827 assert.equal( | 986 assert.equal( |
| 828 await page.locator('.jrpg-scene').evaluate( | 987 await page.locator('.jrpg-scene').evaluate( |
| 829 scene => getComputedStyle(scene).visibility, | 988 scene => getComputedStyle(scene).visibility, |
| 830 ), | 989 ), |
| 835 await page.locator('.jrpg-scene').evaluate( | 994 await page.locator('.jrpg-scene').evaluate( |
| 836 scene => getComputedStyle(scene).visibility, | 995 scene => getComputedStyle(scene).visibility, |
| 837 ), | 996 ), |
| 838 'visible', | 997 'visible', |
| 839 ); | 998 ); |
| 840 assert.equal(await page.locator('[data-work-showcase] a').count(), 7); | 999 assert.equal(await page.locator('[data-work-showcase] a').count(), 8); |
| 841 assert.equal( | 1000 assert.equal( |
| 842 await page.getByRole('button', { name: /Inspect/ }).count(), | 1001 await page.getByRole('button', { name: /Inspect/ }).count(), |
| 843 0, | 1002 0, |
| 844 ); | 1003 ); |
| 845 assert.match( | 1004 assert.match( |
| 846 await page.locator('[data-work-showcase]').textContent(), | 1005 await page.locator('[data-work-showcase]').textContent(), |
| 847 /Copilot Tasks/, | 1006 /Copilot Tasks/, |
| 848 ); | 1007 ); |
| 849 assert.match( | 1008 assert.match( |
| 850 await page.locator('[data-work-showcase]').textContent(), | 1009 await page.locator('[data-work-showcase]').textContent(), |
| 851 /Copilot SuperApp/, | 1010 /AIX Harness \/ Copilot/, |
| 852 ); | 1011 ); |
| 853 assert.equal( | 1012 assert.equal( |
| 854 await page.locator('[data-turn-position]').textContent(), | 1013 await page.locator('[data-turn-position]').textContent(), |
| 855 'START', | 1014 'START', |
| 856 ); | 1015 ); |
| 1282 colorScheme: 'light', | 1441 colorScheme: 'light', |
| 1283 viewport: { width: 360, height: 640 }, | 1442 viewport: { width: 360, height: 640 }, |
| 1284 }); | 1443 }); |
| 1285 const mobilePage = await mobileContext.newPage(); | 1444 const mobilePage = await mobileContext.newPage(); |
| 1286 await mobilePage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); | 1445 await mobilePage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); |
| 1446 await mobilePage.waitForFunction(() => | |
| 1447 customElements.get('mjj-jrpg-composer') && | |
| 1448 customElements.get('mjj-jrpg-menu') | |
| 1449 ); | |
| 1287 const mobile = await mobilePage.evaluate(() => { | 1450 const mobile = await mobilePage.evaluate(() => { |
| 1451 const workspace = document.querySelector('.jrpg-workspace') | |
| 1452 .getBoundingClientRect(); | |
| 1288 const scene = document.querySelector('.jrpg-scene').getBoundingClientRect(); | 1453 const scene = document.querySelector('.jrpg-scene').getBoundingClientRect(); |
| 1289 const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); | 1454 const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); |
| 1455 const composer = document.querySelector('mjj-jrpg-composer') | |
| 1456 .getBoundingClientRect(); | |
| 1457 const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); | |
| 1290 const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); | 1458 const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); |
| 1459 const hamburger = document.querySelector('[data-mobile-menu-toggle]'); | |
| 1460 const hamburgerBox = hamburger?.getBoundingClientRect(); | |
| 1291 const menuButtons = [...document.querySelectorAll( | 1461 const menuButtons = [...document.querySelectorAll( |
| 1292 'mjj-jrpg-menu button[data-preview]', | 1462 'mjj-jrpg-menu button[data-preview]', |
| 1293 )]; | 1463 )]; |
| 1464 const telemetryItems = [ | |
| 1465 ...document.querySelectorAll('.jrpg-frame-telemetry > *'), | |
| 1466 ]; | |
| 1467 const closeTo = (value, expected, tol = 0.025) => | |
| 1468 Math.abs(value - expected) < tol; | |
| 1469 const wsW = workspace.width; | |
| 1470 const wsH = workspace.height; | |
| 1471 const mobileArt = getComputedStyle( | |
| 1472 document.querySelector('.jrpg-workspace'), | |
| 1473 ).backgroundImage; | |
| 1474 const mobileArtIsCorrect = mobileArt.includes('background-frame-mobile.webp'); | |
| 1475 const aperturesTolerance = [ | |
| 1476 closeTo((scene.left - workspace.left) / wsW, 0.045), | |
| 1477 closeTo((scene.top - workspace.top) / wsH, 0.075), | |
| 1478 closeTo(scene.width / wsW, 0.91), | |
| 1479 closeTo(scene.height / wsH, 0.455), | |
| 1480 closeTo((utility.left - workspace.left) / wsW, 0.045), | |
| 1481 closeTo((utility.top - workspace.top) / wsH, 0.547), | |
| 1482 closeTo(utility.width / wsW, 0.91), | |
| 1483 closeTo(utility.height / wsH, 0.171), | |
| 1484 closeTo((composer.left - workspace.left) / wsW, 0.045), | |
| 1485 closeTo((composer.top - workspace.top) / wsH, 0.728), | |
| 1486 closeTo(composer.width / wsW, 0.603), | |
| 1487 closeTo(composer.height / wsH, 0.192), | |
| 1488 closeTo((menu.left - workspace.left) / wsW, 0.665), | |
| 1489 closeTo((menu.top - workspace.top) / wsH, 0.728), | |
| 1490 closeTo(menu.width / wsW, 0.29), | |
| 1491 closeTo(menu.height / wsH, 0.192), | |
| 1492 ].every(Boolean); | |
| 1493 const hamburgerPresent = hamburger !== null && | |
| 1494 hamburger.getAttribute('aria-expanded') === 'true' && | |
| 1495 hamburgerBox !== null && | |
| 1496 hamburgerBox.width > 0 && | |
| 1497 hamburgerBox.height > 0; | |
| 1498 const telemetryCount = telemetryItems.length === 4; | |
| 1499 const composerVisible = composer.width > 0 && composer.height > 0; | |
| 1500 const menuVisible = menu.width > 0 && menu.height > 0; | |
| 1501 const conversationsButton = document.querySelector( | |
| 1502 'button[data-preview="conversations"]', | |
| 1503 ); | |
| 1294 return { | 1504 return { |
| 1505 aperturesTolerance, | |
| 1506 conversationsButtonVisible: conversationsButton !== null && | |
| 1507 conversationsButton.offsetParent !== null, | |
| 1295 buttonsFit: menuButtons.every(button => | 1508 buttonsFit: menuButtons.every(button => |
| 1296 button.getBoundingClientRect().right <= innerWidth | 1509 button.getBoundingClientRect().right <= innerWidth + 1 |
| 1297 ), | 1510 ), |
| 1511 composerVisible, | |
| 1512 fillsViewport: shell.left === 0 && | |
| 1513 shell.top === 0 && | |
| 1514 Math.abs(shell.right - innerWidth) < 1 && | |
| 1515 Math.abs(shell.bottom - innerHeight) < 1, | |
| 1298 fillsWidth: shell.left === 0 && | 1516 fillsWidth: shell.left === 0 && |
| 1299 Math.abs(shell.right - innerWidth) < 1, | 1517 Math.abs(shell.right - innerWidth) < 1, |
| 1518 hamburgerPresent, | |
| 1300 initialScroll: scrollY, | 1519 initialScroll: scrollY, |
| 1520 mobileArtIsCorrect, | |
| 1521 menuVisible, | |
| 1301 noOverflow: document.documentElement.scrollWidth <= innerWidth, | 1522 noOverflow: document.documentElement.scrollWidth <= innerWidth, |
| 1302 utilityBelowScene: utility.top >= scene.bottom, | 1523 telemetryCount, |
| 1524 utilityBelowScene: utility.top >= scene.bottom - 1, | |
| 1525 workspaceFills: Math.abs(workspace.left) < 1 && | |
| 1526 Math.abs(workspace.top) < 1 && | |
| 1527 Math.abs(workspace.right - innerWidth) < 1 && | |
| 1528 Math.abs(workspace.bottom - innerHeight) < 1, | |
| 1303 }; | 1529 }; |
| 1304 }); | 1530 }); |
| 1305 assert.deepEqual(mobile, { | 1531 assert.equal(mobile.aperturesTolerance, true, 'mobile apertures within tolerance at 360x640'); |
| 1306 buttonsFit: true, | 1532 assert.equal( |
| 1307 fillsWidth: true, | 1533 mobile.conversationsButtonVisible, |
| 1308 initialScroll: 0, | 1534 true, |
| 1309 noOverflow: true, | 1535 'Conversations destination visible on mobile', |
| 1310 utilityBelowScene: true, | 1536 ); |
| 1311 }); | 1537 assert.equal(mobile.buttonsFit, true, 'menu buttons fit within viewport width'); |
| 1538 assert.equal(mobile.composerVisible, true, 'composer visible'); | |
| 1539 assert.equal(mobile.fillsViewport, true, 'shell fills viewport'); | |
| 1540 assert.equal(mobile.fillsWidth, true, 'shell fills width'); | |
| 1541 assert.equal(mobile.hamburgerPresent, true, 'hamburger button present and has aria-expanded=true'); | |
| 1542 assert.equal(mobile.initialScroll, 0, 'no initial scroll'); | |
| 1543 assert.equal(mobile.mobileArtIsCorrect, true, 'mobile background URL is background-frame-mobile.webp'); | |
| 1544 assert.equal(mobile.menuVisible, true, 'menu visible'); | |
| 1545 assert.equal(mobile.noOverflow, true, 'no horizontal overflow at 360x640'); | |
| 1546 assert.equal(mobile.telemetryCount, true, 'four telemetry items present'); | |
| 1547 assert.equal(mobile.utilityBelowScene, true, 'utility aperture below scene'); | |
| 1548 assert.equal(mobile.workspaceFills, true, 'workspace fills full viewport'); | |
| 1549 | |
| 1550 // Test hamburger disclosure behavior. | |
| 1551 await mobilePage.locator('[data-mobile-menu-toggle]').click(); | |
| 1552 assert.equal( | |
| 1553 await mobilePage.locator('[data-mobile-menu-toggle]') | |
| 1554 .getAttribute('aria-expanded'), | |
| 1555 'false', | |
| 1556 'hamburger collapses the destination menu', | |
| 1557 ); | |
| 1558 assert.equal( | |
| 1559 await mobilePage.locator('mjj-jrpg-menu') | |
| 1560 .getAttribute('data-mobile-menu-collapsed'), | |
| 1561 '', | |
| 1562 'menu exposes its collapsed state', | |
| 1563 ); | |
| 1564 // Second click expands. | |
| 1565 await mobilePage.locator('[data-mobile-menu-toggle]').click(); | |
| 1566 assert.equal( | |
| 1567 await mobilePage.locator('[data-mobile-menu-toggle]') | |
| 1568 .getAttribute('aria-expanded'), | |
| 1569 'true', | |
| 1570 'hamburger expands the destination menu', | |
| 1571 ); | |
| 1572 // Hamburger keyboard accessible | |
| 1573 assert.ok( | |
| 1574 await mobilePage.locator('[data-mobile-menu-toggle]').evaluate(el => | |
| 1575 el.tabIndex >= 0 || el.tabIndex === -1 | |
| 1576 ), | |
| 1577 'hamburger button is in document', | |
| 1578 ); | |
| 1312 await mobileContext.close(); | 1579 await mobileContext.close(); |
| 1580 | |
| 1581 // Test at 390x844 | |
| 1582 const mobile390Context = await browser.newContext({ | |
| 1583 colorScheme: 'light', | |
| 1584 viewport: { width: 390, height: 844 }, | |
| 1585 }); | |
| 1586 const mobile390Page = await mobile390Context.newPage(); | |
| 1587 await mobile390Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); | |
| 1588 const mobile390 = await mobile390Page.evaluate(() => { | |
| 1589 const workspace = document.querySelector('.jrpg-workspace') | |
| 1590 .getBoundingClientRect(); | |
| 1591 const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); | |
| 1592 const mobileArt = getComputedStyle( | |
| 1593 document.querySelector('.jrpg-workspace'), | |
| 1594 ).backgroundImage; | |
| 1595 return { | |
| 1596 fillsViewport: shell.left === 0 && | |
| 1597 shell.top === 0 && | |
| 1598 Math.abs(shell.right - innerWidth) < 1 && | |
| 1599 Math.abs(shell.bottom - innerHeight) < 1, | |
| 1600 mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'), | |
| 1601 noOverflow: document.documentElement.scrollWidth <= innerWidth, | |
| 1602 workspaceFills: Math.abs(workspace.left) < 1 && | |
| 1603 Math.abs(workspace.top) < 1 && | |
| 1604 Math.abs(workspace.right - innerWidth) < 1 && | |
| 1605 Math.abs(workspace.bottom - innerHeight) < 1, | |
| 1606 }; | |
| 1607 }); | |
| 1608 assert.equal(mobile390.fillsViewport, true, 'fills viewport at 390x844'); | |
| 1609 assert.equal(mobile390.mobileArtIsCorrect, true, 'mobile art at 390x844'); | |
| 1610 assert.equal(mobile390.noOverflow, true, 'no overflow at 390x844'); | |
| 1611 assert.equal(mobile390.workspaceFills, true, 'workspace fills at 390x844'); | |
| 1612 await mobile390Context.close(); | |
| 1613 | |
| 1614 // Test at 320x568 (narrow) | |
| 1615 const mobile320Context = await browser.newContext({ | |
| 1616 colorScheme: 'light', | |
| 1617 viewport: { width: 320, height: 568 }, | |
| 1618 }); | |
| 1619 const mobile320Page = await mobile320Context.newPage(); | |
| 1620 await mobile320Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); | |
| 1621 const mobile320 = await mobile320Page.evaluate(() => { | |
| 1622 const workspace = document.querySelector('.jrpg-workspace') | |
| 1623 .getBoundingClientRect(); | |
| 1624 const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); | |
| 1625 const composer = document.querySelector('mjj-jrpg-composer') | |
| 1626 .getBoundingClientRect(); | |
| 1627 const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); | |
| 1628 const mobileArt = getComputedStyle( | |
| 1629 document.querySelector('.jrpg-workspace'), | |
| 1630 ).backgroundImage; | |
| 1631 return { | |
| 1632 composerPresent: composer.width > 0 && composer.height > 0, | |
| 1633 fillsViewport: shell.left === 0 && | |
| 1634 shell.top === 0 && | |
| 1635 Math.abs(shell.right - innerWidth) < 1 && | |
| 1636 Math.abs(shell.bottom - innerHeight) < 1, | |
| 1637 menuPresent: menu.width > 0 && menu.height > 0, | |
| 1638 mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'), | |
| 1639 noOverflow: document.documentElement.scrollWidth <= innerWidth, | |
| 1640 workspaceFills: Math.abs(workspace.left) < 1 && | |
| 1641 Math.abs(workspace.top) < 1 && | |
| 1642 Math.abs(workspace.right - innerWidth) < 1 && | |
| 1643 Math.abs(workspace.bottom - innerHeight) < 1, | |
| 1644 }; | |
| 1645 }); | |
| 1646 assert.equal(mobile320.composerPresent, true, 'composer present at 320x568'); | |
| 1647 assert.equal(mobile320.fillsViewport, true, 'fills viewport at 320x568'); | |
| 1648 assert.equal(mobile320.menuPresent, true, 'menu present at 320x568'); | |
| 1649 assert.equal(mobile320.mobileArtIsCorrect, true, 'mobile art at 320x568'); | |
| 1650 assert.equal(mobile320.noOverflow, true, 'no overflow at 320x568'); | |
| 1651 assert.equal(mobile320.workspaceFills, true, 'workspace fills at 320x568'); | |
| 1652 await mobile320Context.close(); | |
| 1313 | 1653 |
| 1314 const shortContext = await browser.newContext({ | 1654 const shortContext = await browser.newContext({ |
| 1315 colorScheme: 'dark', | 1655 colorScheme: 'dark', |
| 1316 viewport: { width: 900, height: 500 }, | 1656 viewport: { width: 900, height: 500 }, |
| 1317 }); | 1657 }); |
| 1594 if (error) reject(error); | 1934 if (error) reject(error); |
| 1595 else resolve(String(address.port)); | 1935 else resolve(String(address.port)); |
| 1596 }); | 1936 }); |
| 1597 }); | 1937 }); |
| 1598 }); | 1938 }); |
| 1939 } | |
| 1940 | |
| 1941 /* ------------------------------------------------------------------ */ | |
| 1942 /* Conversation URL routing regression tests (Fixes 1–4) */ | |
| 1943 /* ------------------------------------------------------------------ */ | |
| 1944 | |
| 1945 async function testConversationRouting(browser) { | |
| 1946 const CONV_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; | |
| 1947 const CONV_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; | |
| 1948 const GUEST_SESSION = { | |
| 1949 kind: 'guest', csrfToken: 'test-csrf', | |
| 1950 quota: { | |
| 1951 turnsLimit: 10, turnsUsed: 0, turnsRemaining: 10, | |
| 1952 outputTokensLimit: 10000, outputTokensUsed: 0, | |
| 1953 outputTokensReserved: 0, outputTokensRemaining: 10000, | |
| 1954 resetsAt: Math.floor(Date.now() / 1000) + 86400, | |
| 1955 }, | |
| 1956 }; | |
| 1957 | |
| 1958 /* ---- Fix 3: malformed ?conversation param must not fall back to sessionStorage ---- */ | |
| 1959 { | |
| 1960 const storedId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; | |
| 1961 const context = await browser.newContext({ serviceWorkers: 'block' }); | |
| 1962 await context.addInitScript(id => { | |
| 1963 sessionStorage.setItem('mjj-jrpg-conversation-id', id); | |
| 1964 }, storedId); | |
| 1965 const page = await context.newPage(); | |
| 1966 const storedIdFetched = []; | |
| 1967 await page.route('**/api/auth/session', route => route.fulfill({ | |
| 1968 status: 200, contentType: 'application/json', | |
| 1969 body: JSON.stringify(GUEST_SESSION), | |
| 1970 })); | |
| 1971 await page.route('**/api/conversations**', route => { | |
| 1972 const url = new URL(route.request().url()); | |
| 1973 if (url.pathname.endsWith(`/${storedId}`)) { | |
| 1974 storedIdFetched.push(route.request().url()); | |
| 1975 } | |
| 1976 if (url.pathname === '/api/conversations') { | |
| 1977 return route.fulfill({ | |
| 1978 status: 200, contentType: 'application/json', | |
| 1979 body: JSON.stringify({ conversations: [], cursor: null }), | |
| 1980 }); | |
| 1981 } | |
| 1982 return route.fulfill({ | |
| 1983 status: 404, contentType: 'application/json', | |
| 1984 body: JSON.stringify({ error: { message: 'Not found' } }), | |
| 1985 }); | |
| 1986 }); | |
| 1987 await page.route('**/api/conversations/claim', route => route.fulfill({ | |
| 1988 status: 503, | |
| 1989 contentType: 'application/json', | |
| 1990 body: JSON.stringify({ error: { message: 'Service unavailable' } }), | |
| 1991 })); | |
| 1992 await page.goto(`${baseUrl}/jrpg?conversation=not-a-valid-uuid`, { | |
| 1993 waitUntil: 'networkidle', | |
| 1994 }); | |
| 1995 assert.equal( | |
| 1996 storedIdFetched.length, 0, | |
| 1997 'Fix 3: malformed ?conversation param must not trigger sessionStorage fallback', | |
| 1998 ); | |
| 1999 await context.close(); | |
| 2000 } | |
| 2001 | |
| 2002 /* ---- Fix 4: legacy claim: key retained on 5xx, button re-renders and re-fires ---- */ | |
| 2003 { | |
| 2004 const legacyStoredId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; | |
| 2005 const context = await browser.newContext({ serviceWorkers: 'block' }); | |
| 2006 /* Seed CONVERSATION_STORAGE_KEY so init migrates it to CONVERSATION_LEGACY_KEY */ | |
| 2007 await context.addInitScript(id => { | |
| 2008 sessionStorage.setItem('mjj-jrpg-conversation-id', id); | |
| 2009 }, legacyStoredId); | |
| 2010 const page = await context.newPage(); | |
| 2011 const pageErrors = []; | |
| 2012 page.on('pageerror', e => { | |
| 2013 if (!e.message.startsWith('Failed to load resource:')) { | |
| 2014 pageErrors.push(`pageerror: ${e.message}`); | |
| 2015 } | |
| 2016 }); | |
| 2017 await page.route('**/api/auth/session', route => route.fulfill({ | |
| 2018 status: 200, contentType: 'application/json', | |
| 2019 body: JSON.stringify({ | |
| 2020 kind: 'user', username: 'tester', role: 'member', | |
| 2021 csrfToken: 'test-csrf', quota: null, | |
| 2022 }), | |
| 2023 })); | |
| 2024 await page.route('**/api/conversations**', route => { | |
| 2025 const url = new URL(route.request().url()); | |
| 2026 const req = route.request(); | |
| 2027 if (url.pathname === '/api/conversations' && req.method() === 'GET') { | |
| 2028 return route.fulfill({ | |
| 2029 status: 200, contentType: 'application/json', | |
| 2030 body: JSON.stringify({ conversations: [], cursor: null }), | |
| 2031 }); | |
| 2032 } | |
| 2033 if (url.pathname === `/api/conversations/${legacyStoredId}` && req.method() === 'GET') { | |
| 2034 return route.fulfill({ | |
| 2035 status: 404, contentType: 'application/json', | |
| 2036 body: JSON.stringify({ error: { message: 'Not found' } }), | |
| 2037 }); | |
| 2038 } | |
| 2039 if (url.pathname === '/api/conversations/claim' && req.method() === 'POST') { | |
| 2040 return route.fulfill({ | |
| 2041 status: 503, contentType: 'application/json', | |
| 2042 body: JSON.stringify({ error: { message: 'Service unavailable' } }), | |
| 2043 }); | |
| 2044 } | |
| 2045 return route.fulfill({ | |
| 2046 status: 404, contentType: 'application/json', | |
| 2047 body: JSON.stringify({ error: { message: 'Not found' } }), | |
| 2048 }); | |
| 2049 }); | |
| 2050 await page.route('**/api/conversations/claim', route => route.fulfill({ | |
| 2051 status: 503, | |
| 2052 contentType: 'application/json', | |
| 2053 body: JSON.stringify({ error: { message: 'Service unavailable' } }), | |
| 2054 })); | |
| 2055 await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); | |
| 2056 | |
| 2057 /* Open archive panel so the legacy claim status is visible */ | |
| 2058 await page.locator('button[data-preview="conversations"]').click(); | |
| 2059 await page.locator('mjj-conversation-archive').waitFor({ state: 'visible' }); | |
| 2060 | |
| 2061 /* Init should have called showLegacyClaim() — wait for the button */ | |
| 2062 await page.waitForFunction( | |
| 2063 () => document.querySelector('[data-archive-claim]') !== null, | |
| 2064 null, { timeout: 5000 }, | |
| 2065 ); | |
| 2066 | |
| 2067 /* First claim attempt — returns 503 */ | |
| 2068 const firstClaimResponse = page.waitForResponse( | |
| 2069 response => response.url().includes('/api/conversations/claim'), | |
| 2070 ); | |
| 2071 await page.locator('[data-archive-claim]').click(); | |
| 2072 const firstClaimStatus = (await firstClaimResponse).status(); | |
| 2073 assert.equal( | |
| 2074 firstClaimStatus, | |
| 2075 503, | |
| 2076 `claim fixture returns the mocked service failure, got ${firstClaimStatus}`, | |
| 2077 ); | |
| 2078 | |
| 2079 /* Wait for the retry state to appear: showLegacyClaim re-rendered with error msg */ | |
| 2080 await page.waitForFunction( | |
| 2081 () => { | |
| 2082 const status = document.querySelector('[data-archive-status]'); | |
| 2083 return status && !status.hidden && status.textContent.includes('Claim failed'); | |
| 2084 }, | |
| 2085 null, { timeout: 5000 }, | |
| 2086 ); | |
| 2087 | |
| 2088 /* Key must be retained in sessionStorage on 5xx */ | |
| 2089 const keyAfterFail = await page.evaluate( | |
| 2090 () => sessionStorage.getItem('mjj-jrpg-legacy-claim'), | |
| 2091 ); | |
| 2092 assert.equal( | |
| 2093 keyAfterFail, legacyStoredId, | |
| 2094 'Fix 4: CONVERSATION_LEGACY_KEY must be retained after 5xx failure', | |
| 2095 ); | |
| 2096 | |
| 2097 /* Button must be present and enabled for retry */ | |
| 2098 await page.locator('[data-archive-claim]').waitFor({ state: 'visible' }); | |
| 2099 | |
| 2100 /* Tag the current button so we can detect when it's replaced by a second re-render */ | |
| 2101 await page.locator('[data-archive-claim]').evaluate(btn => { | |
| 2102 btn.dataset.claimGen = '1'; | |
| 2103 }); | |
| 2104 | |
| 2105 /* Second click: must dispatch event again — proves no {once:true} on button */ | |
| 2106 await page.locator('[data-archive-claim]').click(); | |
| 2107 | |
| 2108 /* Wait for the button to be replaced (showLegacyClaim called again = handler ran) */ | |
| 2109 await page.waitForFunction( | |
| 2110 () => { | |
| 2111 const btn = document.querySelector('[data-archive-claim]'); | |
| 2112 return btn !== null && btn.dataset.claimGen !== '1'; | |
| 2113 }, | |
| 2114 null, { timeout: 5000 }, | |
| 2115 ); | |
| 2116 | |
| 2117 assert.deepEqual(pageErrors, [], 'no page errors in legacy claim test'); | |
| 2118 await context.close(); | |
| 2119 } | |
| 2120 | |
| 2121 /* ---- Fix 1: popstate during active stream converges URL and UI ---- */ | |
| 2122 { | |
| 2123 let releaseStream = () => {}; | |
| 2124 const streamGate = new Promise(resolve => { releaseStream = resolve; }); | |
| 2125 | |
| 2126 const context = await browser.newContext(); | |
| 2127 const page = await context.newPage(); | |
| 2128 const pageErrors = []; | |
| 2129 page.on('pageerror', e => { | |
| 2130 if (!e.message.startsWith('Failed to load resource:')) { | |
| 2131 pageErrors.push(`pageerror: ${e.message}`); | |
| 2132 } | |
| 2133 }); | |
| 2134 /* Suppress expected abort-network errors from the held-then-aborted stream */ | |
| 2135 page.on('requestfailed', request => { | |
| 2136 if (request.failure()?.errorText === 'net::ERR_ABORTED') return; | |
| 2137 pageErrors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`); | |
| 2138 }); | |
| 2139 | |
| 2140 await page.route('**/api/auth/session', route => route.fulfill({ | |
| 2141 status: 200, contentType: 'application/json', | |
| 2142 body: JSON.stringify(GUEST_SESSION), | |
| 2143 })); | |
| 2144 await page.route('**/api/conversations**', async route => { | |
| 2145 const url = new URL(route.request().url()); | |
| 2146 const req = route.request(); | |
| 2147 if (url.pathname === '/api/conversations' && req.method() === 'GET') { | |
| 2148 return route.fulfill({ | |
| 2149 status: 200, contentType: 'application/json', | |
| 2150 body: JSON.stringify({ conversations: [], cursor: null }), | |
| 2151 }); | |
| 2152 } | |
| 2153 if (url.pathname === '/api/conversations' && req.method() === 'POST') { | |
| 2154 return route.fulfill({ | |
| 2155 status: 201, contentType: 'application/json', | |
| 2156 body: JSON.stringify({ id: CONV_A }), | |
| 2157 }); | |
| 2158 } | |
| 2159 if (url.pathname.endsWith('/turns') && req.method() === 'POST') { | |
| 2160 /* Hold the SSE stream until the test releases it */ | |
| 2161 await streamGate; | |
| 2162 try { await route.abort(); } catch { /* request may already be aborted */ } | |
| 2163 return; | |
| 2164 } | |
| 2165 return route.fulfill({ | |
| 2166 status: 404, contentType: 'application/json', | |
| 2167 body: JSON.stringify({ error: { message: 'Not found' } }), | |
| 2168 }); | |
| 2169 }); | |
| 2170 | |
| 2171 await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); | |
| 2172 await page.waitForFunction( | |
| 2173 () => !document.querySelector('mjj-jrpg-composer textarea')?.disabled, | |
| 2174 ); | |
| 2175 | |
| 2176 /* Submit — creates conversation via pushState and starts stream */ | |
| 2177 await page.locator('#jrpg-message').fill('routing race test'); | |
| 2178 await page.locator('#jrpg-message').press('Enter'); | |
| 2179 | |
| 2180 /* Stream is now active */ | |
| 2181 await page.locator('[data-cancel-control]').waitFor({ state: 'visible' }); | |
| 2182 | |
| 2183 const urlDuringStream = page.url(); | |
| 2184 assert.ok( | |
| 2185 urlDuringStream.includes('conversation='), | |
| 2186 `Fix 1: URL must have ?conversation during stream: ${urlDuringStream}`, | |
| 2187 ); | |
| 2188 | |
| 2189 /* Navigate back while stream is active — URL already changes here */ | |
| 2190 await page.goBack(); | |
| 2191 | |
| 2192 /* Stream abort + pending-popstate application: cancel control disappears */ | |
| 2193 await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' }); | |
| 2194 | |
| 2195 /* URL and UI must converge to the pre-stream state */ | |
| 2196 const finalUrl = page.url(); | |
| 2197 assert.ok( | |
| 2198 !finalUrl.includes('conversation='), | |
| 2199 `Fix 1: URL must not have ?conversation after popstate back: ${finalUrl}`, | |
| 2200 ); | |
| 2201 assert.equal( | |
| 2202 await page.locator('.jrpg-message[data-streaming="true"]').count(), 0, | |
| 2203 'Fix 1: no streaming message must remain after popstate back', | |
| 2204 ); | |
| 2205 | |
| 2206 releaseStream(); /* clean up the held route */ | |
| 2207 assert.deepEqual(pageErrors, [], 'no page errors in popstate-during-stream test'); | |
| 2208 await context.close(); | |
| 2209 } | |
| 2210 | |
| 2211 /* ---- Fix 2: openSeq incremented on no-conv popstate, invalidates pending open ---- */ | |
| 2212 { | |
| 2213 let resolveConvBFetch = () => {}; | |
| 2214 const convBFetchGate = new Promise(resolve => { resolveConvBFetch = resolve; }); | |
| 2215 | |
| 2216 const context = await browser.newContext(); | |
| 2217 const page = await context.newPage(); | |
| 2218 const pageErrors = []; | |
| 2219 page.on('pageerror', e => { | |
| 2220 if (!e.message.startsWith('Failed to load resource:')) { | |
| 2221 pageErrors.push(`pageerror: ${e.message}`); | |
| 2222 } | |
| 2223 }); | |
| 2224 | |
| 2225 await page.route('**/api/auth/session', route => route.fulfill({ | |
| 2226 status: 200, contentType: 'application/json', | |
| 2227 body: JSON.stringify(GUEST_SESSION), | |
| 2228 })); | |
| 2229 await page.route('**/api/conversations**', async route => { | |
| 2230 const url = new URL(route.request().url()); | |
| 2231 const req = route.request(); | |
| 2232 /* Empty archive: no auto-open, no gate deadlock */ | |
| 2233 if (url.pathname === '/api/conversations' && req.method() === 'GET') { | |
| 2234 return route.fulfill({ | |
| 2235 status: 200, contentType: 'application/json', | |
| 2236 body: JSON.stringify({ conversations: [], cursor: null }), | |
| 2237 }); | |
| 2238 } | |
| 2239 /* The archive-open click will trigger this; hold it with the gate */ | |
| 2240 if (url.pathname === `/api/conversations/${CONV_B}` && req.method() === 'GET') { | |
| 2241 await convBFetchGate; | |
| 2242 return route.fulfill({ | |
| 2243 status: 200, contentType: 'application/json', | |
| 2244 body: JSON.stringify({ id: CONV_B, title: 'Conv B', turns: [] }), | |
| 2245 }); | |
| 2246 } | |
| 2247 return route.fulfill({ | |
| 2248 status: 404, contentType: 'application/json', | |
| 2249 body: JSON.stringify({ error: { message: 'Not found' } }), | |
| 2250 }); | |
| 2251 }); | |
| 2252 | |
| 2253 /* Empty archive so init completes without auto-opening any conversation */ | |
| 2254 await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); | |
| 2255 | |
| 2256 /* Open archive panel — this pushes a history entry (conversations panel) */ | |
| 2257 await page.locator('button[data-preview="conversations"]').click(); | |
| 2258 await page.locator('mjj-conversation-archive').waitFor({ state: 'visible' }); | |
| 2259 | |
| 2260 /* Inject Conv B into the archive list without triggering an API fetch */ | |
| 2261 await page.evaluate(id => { | |
| 2262 document.querySelector('mjj-conversation-archive') | |
| 2263 ?.addConversation({ id, title: 'Conv B', turn_count: 0 }); | |
| 2264 }, CONV_B); | |
| 2265 | |
| 2266 await page.waitForFunction( | |
| 2267 id => [...document.querySelectorAll('[data-conv-item]')] | |
| 2268 .some(item => item._convId === id), | |
| 2269 CONV_B, | |
| 2270 ); | |
| 2271 | |
| 2272 /* Click Conv B — archive-open fires, fetch held by convBFetchGate */ | |
| 2273 await page.evaluate(id => { | |
| 2274 for (const item of document.querySelectorAll('[data-conv-item]')) { | |
| 2275 if (item._convId === id) { | |
| 2276 item.querySelector('[data-conv-open]')?.click(); | |
| 2277 break; | |
| 2278 } | |
| 2279 } | |
| 2280 }, CONV_B); | |
| 2281 | |
| 2282 /* Brief pause so archive-open can increment openSeq and start the fetch */ | |
| 2283 await page.waitForTimeout(80); | |
| 2284 | |
| 2285 /* Navigate back — popstate fires with no-conv state, openSeq is incremented */ | |
| 2286 await page.goBack(); | |
| 2287 await page.waitForTimeout(80); | |
| 2288 | |
| 2289 /* Release the held fetch — the archive-open handler should ignore it */ | |
| 2290 resolveConvBFetch(); | |
| 2291 await page.waitForTimeout(300); | |
| 2292 | |
| 2293 /* currentConversationId must remain null (no aria-current item) */ | |
| 2294 const currentAriaItem = await page.evaluate(() => { | |
| 2295 const btn = document.querySelector('[data-conv-open][aria-current="true"]'); | |
| 2296 return btn ? (btn.closest('[data-conv-item]')?._convId ?? null) : null; | |
| 2297 }); | |
| 2298 assert.equal( | |
| 2299 currentAriaItem, null, | |
| 2300 'Fix 2: pending archive-open must not overwrite no-conv popstate', | |
| 2301 ); | |
| 2302 | |
| 2303 assert.deepEqual(pageErrors, [], 'no page errors in openSeq test'); | |
| 2304 await context.close(); | |
| 2305 } | |
| 1599 } | 2306 } |
| 1600 | 2307 |
| 1601 (async () => { | 2308 (async () => { |
| 1602 assert.ok(RUNFILES); | 2309 assert.ok(RUNFILES); |
| 1603 assert.ok(WORKSPACE); | 2310 assert.ok(WORKSPACE); |
| 1680 await fetch(`${baseUrl}/sw.js`) | 2387 await fetch(`${baseUrl}/sw.js`) |
| 1681 ).text(); | 2388 ).text(); |
| 1682 for (const source of [home, dogGame, manifest]) { | 2389 for (const source of [home, dogGame, manifest]) { |
| 1683 assert.doesNotMatch(source, /\.png(?:["')]|$)/i); | 2390 assert.doesNotMatch(source, /\.png(?:["')]|$)/i); |
| 1684 } | 2391 } |
| 1685 assert.match(serviceWorker, /v30-card-driven-details/); | 2392 assert.match(serviceWorker, /v34-login-modal/); |
| 1686 assert.match( | 2393 assert.match( |
| 1687 await ( | 2394 await ( |
| 1688 await fetch(`${baseUrl}/public/pwa-register.js`) | 2395 await fetch(`${baseUrl}/public/pwa-register.js`) |
| 1689 ).text(), | 2396 ).text(), |
| 1690 /register\('\/sw\.js', \{ scope: '\/' \}\)/, | 2397 /register\('\/sw\.js', \{ scope: '\/' \}\)/, |
| 1709 browser = await chromium.launch({ | 2416 browser = await chromium.launch({ |
| 1710 executablePath: chromiumPath, | 2417 executablePath: chromiumPath, |
| 1711 headless: true, | 2418 headless: true, |
| 1712 args: ['--no-sandbox'], | 2419 args: ['--no-sandbox'], |
| 1713 }); | 2420 }); |
| 1714 const paper = await sampleTheme(browser, 'paper'); | 2421 if (runsSuite('core')) { |
| 1715 const ink = await sampleTheme(browser, 'ink'); | 2422 const paper = await sampleTheme(browser, 'paper'); |
| 1716 const playful = await sampleTheme(browser, 'playful'); | 2423 const ink = await sampleTheme(browser, 'ink'); |
| 1717 const automatic = await sampleTheme(browser, 'auto'); | 2424 const playful = await sampleTheme(browser, 'playful'); |
| 1718 assert.equal(paper.rootTheme, 'paper'); | 2425 const automatic = await sampleTheme(browser, 'auto'); |
| 1719 assert.equal(ink.rootTheme, 'ink'); | 2426 assert.equal(paper.rootTheme, 'paper'); |
| 1720 assert.equal(playful.rootTheme, 'playful'); | 2427 assert.equal(ink.rootTheme, 'ink'); |
| 1721 assert.equal(automatic.rootTheme, 'auto'); | 2428 assert.equal(playful.rootTheme, 'playful'); |
| 1722 assert.equal(paper.themeLabel, 'Paper'); | 2429 assert.equal(automatic.rootTheme, 'auto'); |
| 1723 assert.equal(ink.themeLabel, 'Ink'); | 2430 assert.equal(paper.themeLabel, 'Paper'); |
| 1724 assert.equal(playful.themeLabel, 'Playful'); | 2431 assert.equal(ink.themeLabel, 'Ink'); |
| 1725 assert.equal(automatic.themeLabel, 'Auto'); | 2432 assert.equal(playful.themeLabel, 'Playful'); |
| 1726 for (const sample of [paper, ink, playful, automatic]) { | 2433 assert.equal(automatic.themeLabel, 'Auto'); |
| 1727 assert.ok(sample.count > 0); | 2434 for (const sample of [paper, ink, playful, automatic]) { |
| 1728 assert.equal(sample.backgroundRepeat, 'no-repeat'); | 2435 assert.ok(sample.count > 0); |
| 1729 assert.equal(sample.backgroundSize, 'cover'); | 2436 assert.equal(sample.backgroundRepeat, 'no-repeat'); |
| 1730 assert.equal(sample.bodyCoversViewport, true); | 2437 assert.equal(sample.backgroundSize, 'cover'); |
| 1731 assert.equal(sample.componentReady, true); | 2438 assert.equal(sample.bodyCoversViewport, true); |
| 1732 assert.ok(sample.links > 0); | 2439 assert.equal(sample.componentReady, true); |
| 1733 assert.equal(sample.enhancedLinks, sample.pawLinks); | 2440 assert.ok(sample.links > 0); |
| 1734 assert.match(sample.fontFamily, /More/); | 2441 assert.equal(sample.enhancedLinks, sample.pawLinks); |
| 1735 assert.equal(sample.headerPaws, 0); | 2442 assert.match(sample.fontFamily, /More/); |
| 1736 assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample)); | 2443 assert.equal(sample.headerPaws, 0); |
| 1737 assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)'); | 2444 assert.ok(sample.textContrast >= 4.5, JSON.stringify(sample)); |
| 1738 assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)'); | 2445 assert.equal(sample.mainBackground, 'rgba(0, 0, 0, 0)'); |
| 1739 assert.equal(sample.themeButtonBorder, '0px'); | 2446 assert.equal(sample.themeButtonBackground, 'rgba(0, 0, 0, 0)'); |
| 1740 assert.equal(sample.themeButtonShadow, 'none'); | 2447 assert.equal(sample.themeButtonBorder, '0px'); |
| 1741 } | 2448 assert.equal(sample.themeButtonShadow, 'none'); |
| 1742 assert.ok(paper.luminance < 175, JSON.stringify(paper)); | 2449 } |
| 1743 assert.ok(playful.luminance < 175, JSON.stringify(playful)); | 2450 assert.ok(paper.luminance < 175, JSON.stringify(paper)); |
| 1744 assert.ok(ink.luminance > 200, JSON.stringify(ink)); | 2451 assert.ok(playful.luminance < 175, JSON.stringify(playful)); |
| 1745 await testThemeCycle(browser); | 2452 assert.ok(ink.luminance > 200, JSON.stringify(ink)); |
| 1746 await testPlainField(browser); | 2453 await testThemeCycle(browser); |
| 1747 await testButtonScale(browser); | 2454 await testPlainField(browser); |
| 1748 await testDynamicButtonOwnership(browser); | 2455 await testButtonScale(browser); |
| 1749 await testResumePrint(browser); | 2456 await testDynamicButtonOwnership(browser); |
| 1750 await testJrpgPage(browser); | 2457 await testResumePrint(browser); |
| 1751 await testHlsPlayer(browser, siteRoot); | 2458 } |
| 2459 if (runsSuite('jrpg')) await testJrpgPage(browser); | |
| 2460 if (runsSuite('routing')) await testConversationRouting(browser); | |
| 2461 if (runsSuite('hls')) await testHlsPlayer(browser, siteRoot); | |
| 1752 } finally { | 2462 } finally { |
| 1753 if (browser) await browser.close(); | 2463 if (browser) await browser.close(); |
| 1754 await stopProcess(server); | 2464 await stopProcess(server); |
| 1755 fs.rmSync(tempDirectory, { recursive: true, force: true }); | 2465 fs.rmSync(tempDirectory, { recursive: true, force: true }); |
| 1756 } | 2466 } |