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 }