comparison mrjunejune/test/theme_and_webp_test.js @ 272:41a49c29a28f

polish JRPG conversation experience Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 16:05:29 -0700
parents 056790c4fb0d
children e02e2036ef84
comparison
equal deleted inserted replaced
271:13d61401c57d 272:41a49c29a28f
442 false, 442 false,
443 ); 443 );
444 await page.close(); 444 await page.close();
445 } 445 }
446 446
447 async function testComposerLab(browser) {
448 const context = await browser.newContext({
449 serviceWorkers: 'block',
450 viewport: { width: 390, height: 844 },
451 });
452 const page = await context.newPage();
453 await page.goto(`${baseUrl}/public/composer-lab.html`, {
454 waitUntil: 'networkidle',
455 });
456 await page.waitForFunction(() =>
457 customElements.get('mjj-composer') &&
458 customElements.get('zen-field') &&
459 customElements.get('zen-button')
460 );
461
462 const compact = page.locator('mjj-composer[compact]');
463 const textarea = compact.locator('textarea');
464 const submit = compact.locator('button[type="submit"]');
465 await textarea.fill('Composer lab message');
466 await textarea.press('Enter');
467 await page.waitForFunction(() =>
468 [...document.querySelectorAll('[data-output]')]
469 .some(output => output.textContent.includes('Composer lab message'))
470 );
471
472 const geometry = await compact.evaluate(element => {
473 const field = element.querySelector('zen-field');
474 const button = element.querySelector('zen-button');
475 const nativeButton = button.querySelector('button');
476 const buttonBox = nativeButton.getBoundingClientRect();
477 return {
478 compactButton: Math.abs(buttonBox.width - buttonBox.height) < 1,
479 fieldStyled: !field.hasAttribute('appearance'),
480 oneButtonOwner: nativeButton.closest('zen-button') === button,
481 textareaOwned: element.querySelector('textarea').closest('zen-field') === field,
482 };
483 });
484 assert.deepEqual(geometry, {
485 compactButton: true,
486 fieldStyled: true,
487 oneButtonOwner: true,
488 textareaOwned: true,
489 });
490 assert.equal(await submit.getAttribute('aria-label'), 'Send message');
491 await context.close();
492 }
493
447 async function testJrpgPage(browser) { 494 async function testJrpgPage(browser) {
448 const context = await browser.newContext({ 495 const context = await browser.newContext({
449 colorScheme: 'light', 496 colorScheme: 'light',
450 viewport: { width: 1280, height: 900 }, 497 viewport: { width: 1280, height: 900 },
451 }); 498 });
500 } 547 }
501 }); 548 });
502 549
503 await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); 550 await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
504 await page.waitForFunction(() => 551 await page.waitForFunction(() =>
505 customElements.get('mjj-jrpg-composer') && 552 customElements.get('mjj-composer') &&
506 customElements.get('mjj-jrpg-menu') && 553 customElements.get('mjj-jrpg-menu') &&
507 customElements.get('mjj-jrpg-chat') && 554 customElements.get('mjj-jrpg-chat') &&
508 customElements.get('mjj-jrpg-preview') 555 customElements.get('mjj-jrpg-preview')
509 ); 556 );
510 await page.evaluate(() => document.fonts.ready); 557 await page.evaluate(() => document.fonts.ready);
511 const desktop = await page.evaluate(() => { 558 const desktop = await page.evaluate(() => {
512 const scene = document.querySelector('.jrpg-scene').getBoundingClientRect(); 559 const scene = document.querySelector('.jrpg-scene').getBoundingClientRect();
513 const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); 560 const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
514 const composer = document.querySelector('mjj-jrpg-composer') 561 const composer = document.querySelector('mjj-composer')
515 .getBoundingClientRect(); 562 .getBoundingClientRect();
516 const composerField = document.querySelector('.jrpg-composer zen-field') 563 const composerField = document.querySelector('.jrpg-composer zen-field')
517 .getBoundingClientRect(); 564 .getBoundingClientRect();
518 const composerFieldStyle = getComputedStyle( 565 const composerFieldStyle = getComputedStyle(
519 document.querySelector('.jrpg-composer zen-field'), 566 document.querySelector('.jrpg-composer zen-field'),
520 ); 567 );
521 const composerLabel = document.querySelector('.jrpg-composer label') 568 const composerLabel = document.querySelector('.jrpg-composer label')
522 .getBoundingClientRect(); 569 .getBoundingClientRect();
523 const composerTextarea = document.querySelector('#jrpg-message') 570 const composerTextarea = document.querySelector('#jrpg-message')
524 .getBoundingClientRect(); 571 .getBoundingClientRect();
525 const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); 572 const menuElement = document.querySelector('mjj-jrpg-menu');
573 const menu = menuElement.getBoundingClientRect();
526 const character = document.querySelector('[data-character]'); 574 const character = document.querySelector('[data-character]');
575 const epiAvatar = document.querySelector(
576 '.jrpg-message[data-speaker="Epi"] .jrpg-message-avatar',
577 );
527 const characterBox = character.getBoundingClientRect(); 578 const characterBox = character.getBoundingClientRect();
528 const sceneBarStyle = getComputedStyle( 579 const sceneBarStyle = getComputedStyle(
529 document.querySelector('.jrpg-scene'), 580 document.querySelector('.jrpg-scene'),
530 '::before', 581 '::before',
531 ); 582 );
634 const borderless = [ 685 const borderless = [
635 '.jrpg-shell', 686 '.jrpg-shell',
636 '.jrpg-scene', 687 '.jrpg-scene',
637 '.jrpg-utility', 688 '.jrpg-utility',
638 'mjj-jrpg-chat', 689 'mjj-jrpg-chat',
639 'mjj-jrpg-composer', 690 'mjj-composer',
640 'mjj-jrpg-menu', 691 'mjj-jrpg-menu',
641 '.jrpg-preview-panel', 692 '.jrpg-preview-panel',
642 '.jrpg-message', 693 '.jrpg-message',
643 '.jrpg-work-showcase a', 694 '.jrpg-work-showcase a',
644 '.jrpg-composer textarea',
645 '.jrpg-composer button[type="submit"]',
646 'mjj-jrpg-menu button', 695 'mjj-jrpg-menu button',
647 ].every(selector => { 696 ].every(selector => {
648 const style = getComputedStyle(document.querySelector(selector)); 697 const style = getComputedStyle(document.querySelector(selector));
649 return style.borderTopWidth === '0px' && 698 return style.borderTopWidth === '0px' &&
650 style.borderRightWidth === '0px' && 699 style.borderRightWidth === '0px' &&
664 brand.left <= workspace.left + workspace.width * 0.062 && 713 brand.left <= workspace.left + workspace.width * 0.062 &&
665 brand.right >= workspace.left + workspace.width * 0.102 && 714 brand.right >= workspace.left + workspace.width * 0.102 &&
666 brand.top <= workspace.top + workspace.height * 0.03 && 715 brand.top <= workspace.top + workspace.height * 0.03 &&
667 brand.bottom >= workspace.top + workspace.height * 0.06, 716 brand.bottom >= workspace.top + workspace.height * 0.06,
668 characterLoaded: character.complete && character.naturalWidth > 0, 717 characterLoaded: character.complete && character.naturalWidth > 0,
718 epiAvatarLoaded:
719 epiAvatar?.complete &&
720 epiAvatar.naturalWidth > 0 &&
721 epiAvatar.getAttribute('aria-hidden') === 'true',
669 colors, 722 colors,
670 composerBelowScene: composer.top >= scene.bottom, 723 composerBelowScene: composer.top >= scene.bottom,
671 composerTextareaFills: ( 724 composerTextareaFills: (
672 Math.abs(composerTextarea.left - composerField.left) < 1 && 725 Math.abs(composerTextarea.left - composerField.left) < 1 &&
673 Math.abs(composerTextarea.right - composerField.right) < 1 && 726 Math.abs(composerTextarea.right - composerField.right) < 1 &&
674 Math.abs(composerTextarea.bottom - composerField.bottom) < 1 && 727 Math.abs(composerTextarea.bottom - composerField.bottom) < 1 &&
675 composerTextarea.height > 60 728 composerTextarea.height > 60
676 ), 729 ),
730 composerUsesZenStyles:
731 getComputedStyle(document.querySelector('.jrpg-composer textarea'))
732 .borderTopWidth !== '0px' &&
733 submitStyle.borderTopWidth !== '0px',
677 composerTextareaGeometry: { 734 composerTextareaGeometry: {
678 fieldBottom: composerField.bottom, 735 fieldBottom: composerField.bottom,
679 fieldDisplay: composerFieldStyle.display, 736 fieldDisplay: composerFieldStyle.display,
680 fieldGridRows: composerFieldStyle.gridTemplateRows, 737 fieldGridRows: composerFieldStyle.gridTemplateRows,
681 fieldLeft: composerField.left, 738 fieldLeft: composerField.left,
779 assert.match(desktop.art, /background-frame-2\.webp/); 836 assert.match(desktop.art, /background-frame-2\.webp/);
780 assert.equal(desktop.borderless, true); 837 assert.equal(desktop.borderless, true);
781 assert.equal(desktop.brandInSystemSlot, true); 838 assert.equal(desktop.brandInSystemSlot, true);
782 assert.equal(desktop.brandCoversSystemLabel, true); 839 assert.equal(desktop.brandCoversSystemLabel, true);
783 assert.equal(desktop.characterLoaded, true); 840 assert.equal(desktop.characterLoaded, true);
841 assert.equal(desktop.epiAvatarLoaded, true);
784 assert.equal(desktop.colors.page, desktop.colors.canvas); 842 assert.equal(desktop.colors.page, desktop.colors.canvas);
785 assert.equal(desktop.colors.conversation, 'rgba(0, 0, 0, 0)'); 843 assert.equal(desktop.colors.conversation, 'rgba(0, 0, 0, 0)');
786 assert.equal(desktop.colors.conversationText, desktop.colors.text); 844 assert.equal(desktop.colors.conversationText, desktop.colors.text);
787 assert.equal(desktop.colors.menu, 'rgba(0, 0, 0, 0)'); 845 assert.equal(desktop.colors.menu, 'rgba(0, 0, 0, 0)');
788 assert.equal(desktop.colors.primaryText, desktop.colors.primary); 846 assert.equal(desktop.colors.primaryText, desktop.colors.primary);
789 assert.equal(desktop.colors.submit, desktop.colors.primary); 847 assert.equal(desktop.colors.submit, desktop.colors.primary);
848 assert.equal(desktop.composerUsesZenStyles, true);
790 assert.equal(desktop.colors.utility, 'rgba(0, 0, 0, 0)'); 849 assert.equal(desktop.colors.utility, 'rgba(0, 0, 0, 0)');
791 assert.equal(desktop.composerBelowScene, true); 850 assert.equal(desktop.composerBelowScene, true);
792 assert.equal( 851 assert.equal(
793 desktop.composerTextareaFills, 852 desktop.composerTextareaFills,
794 true, 853 true,
912 assert.equal( 971 assert.equal(
913 await page.locator('[data-toggle-archive]').count(), 972 await page.locator('[data-toggle-archive]').count(),
914 0, 973 0,
915 'redundant chat-heading archive shortcut is removed', 974 'redundant chat-heading archive shortcut is removed',
916 ); 975 );
976 assert.equal(
977 await page.locator('.jrpg-panel-heading').count(),
978 0,
979 'visible conversation heading is removed',
980 );
917 await page.locator('button[data-preview="conversations"]').click(); 981 await page.locator('button[data-preview="conversations"]').click();
918 await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'visible' }); 982 await page.locator('.jrpg-utility mjj-conversation-archive').waitFor({ state: 'visible' });
919 const archiveGeometry = await page.evaluate(() => { 983 const archiveGeometry = await page.evaluate(() => {
920 const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); 984 const utilityElement = document.querySelector('.jrpg-utility');
921 const archive = document.querySelector( 985 const archiveElement = utilityElement.querySelector('mjj-conversation-archive');
922 '.jrpg-utility mjj-conversation-archive', 986 const utility = utilityElement.getBoundingClientRect();
923 ).getBoundingClientRect(); 987 const archive = archiveElement.getBoundingClientRect();
988 const archiveStyle = getComputedStyle(archiveElement);
989 const previewPanel = utilityElement.querySelector('.jrpg-preview-panel');
990 const panel = previewPanel.getBoundingClientRect();
991 const previewPanelStyle = getComputedStyle(previewPanel);
992 const panelPadding = {
993 bottom: parseFloat(previewPanelStyle.paddingBottom),
994 left: parseFloat(previewPanelStyle.paddingLeft),
995 right: parseFloat(previewPanelStyle.paddingRight),
996 top: parseFloat(previewPanelStyle.paddingTop),
997 };
998 const previewContent = previewPanel.querySelector('[data-preview-content]');
999 const renderedPanelChildren = Array.from(previewPanel.children)
1000 .filter(element => getComputedStyle(element).display !== 'none')
1001 .map(element => element.localName);
924 return { 1002 return {
925 archive: { 1003 archive: {
926 bottom: archive.bottom, 1004 bottom: archive.bottom,
927 height: archive.height, 1005 height: archive.height,
928 left: archive.left, 1006 left: archive.left,
929 right: archive.right, 1007 right: archive.right,
930 top: archive.top, 1008 top: archive.top,
931 width: archive.width, 1009 width: archive.width,
932 }, 1010 },
933 fits: archive.left >= utility.left && 1011 fits: Math.abs(archive.left - panel.left - panelPadding.left) < 1 &&
934 archive.top >= utility.top && 1012 Math.abs(archive.top - panel.top - panelPadding.top) < 1 &&
935 archive.right <= utility.right + 1 && 1013 Math.abs(panel.right - panelPadding.right - archive.right) < 1 &&
936 archive.bottom <= utility.bottom + 1 && 1014 Math.abs(panel.bottom - panelPadding.bottom - archive.bottom) < 1,
937 archive.width / utility.width > 0.85 && 1015 integrated: archiveStyle.position === 'static' &&
938 archive.height / utility.height > 0.85, 1016 archiveStyle.zIndex === 'auto' &&
1017 archiveStyle.backgroundColor === 'rgba(0, 0, 0, 0)' &&
1018 archiveStyle.backgroundImage === 'none' &&
1019 archiveStyle.boxShadow === 'none' &&
1020 previewPanelStyle.display === 'block' &&
1021 archiveElement.parentElement === previewPanel &&
1022 previewContent.hidden &&
1023 renderedPanelChildren.length === 1 &&
1024 renderedPanelChildren[0] === 'mjj-conversation-archive',
1025 renderedPanelChildren,
1026 panel: {
1027 bottom: panel.bottom,
1028 height: panel.height,
1029 left: panel.left,
1030 right: panel.right,
1031 top: panel.top,
1032 width: panel.width,
1033 },
1034 panelPadding,
939 utility: { 1035 utility: {
940 bottom: utility.bottom, 1036 bottom: utility.bottom,
941 height: utility.height, 1037 height: utility.height,
942 left: utility.left, 1038 left: utility.left,
943 right: utility.right, 1039 right: utility.right,
947 }; 1043 };
948 }); 1044 });
949 assert.equal( 1045 assert.equal(
950 archiveGeometry.fits, 1046 archiveGeometry.fits,
951 true, 1047 true,
952 `conversation archive fills the top-right utility aperture: ${JSON.stringify(archiveGeometry)}`, 1048 `conversation archive fills the existing utility content panel: ${JSON.stringify(archiveGeometry)}`,
1049 );
1050 assert.equal(
1051 archiveGeometry.integrated,
1052 true,
1053 `conversation archive renders as utility content instead of an overlay: ${JSON.stringify(archiveGeometry)}`,
953 ); 1054 );
954 // Archive status or empty message should be visible 1055 // Archive status or empty message should be visible
955 await page.waitForFunction(() => { 1056 await page.waitForFunction(() => {
956 const status = document.querySelector('[data-archive-status]'); 1057 const status = document.querySelector('[data-archive-status]');
957 return status && !status.hidden; 1058 return status && !status.hidden;
1329 await page.locator('.jrpg-message[data-speaker="June"]').last().waitFor(); 1430 await page.locator('.jrpg-message[data-speaker="June"]').last().waitFor();
1330 const streamingMessage = page.locator( 1431 const streamingMessage = page.locator(
1331 '.jrpg-message[data-speaker="Epi"][data-streaming="true"]', 1432 '.jrpg-message[data-speaker="Epi"][data-streaming="true"]',
1332 ).last(); 1433 ).last();
1333 await streamingMessage.waitFor(); 1434 await streamingMessage.waitFor();
1334 await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' }); 1435 await page.locator('[data-composer-cancel-owner]').waitFor({ state: 'hidden' });
1335 await streamingMessage.waitFor(); 1436 await streamingMessage.waitFor();
1336 assert.notEqual( 1437 assert.notEqual(
1337 await streamingMessage.locator('p').textContent(), 1438 await streamingMessage.locator('p').textContent(),
1338 'hello traveler', 1439 'hello traveler',
1339 ); 1440 );
1394 assert.equal( 1495 assert.equal(
1395 await page.locator('.jrpg-message[data-speaker="June"]:visible').count(), 1496 await page.locator('.jrpg-message[data-speaker="June"]:visible').count(),
1396 0, 1497 0,
1397 ); 1498 );
1398 assert.equal( 1499 assert.equal(
1399 await page.locator('.jrpg-message[data-speaker="System"]:visible').count(), 1500 await page.locator('.jrpg-message[data-speaker="Epi"]:visible').count(),
1400 1, 1501 1,
1401 ); 1502 );
1402 await page.getByRole('button', { name: 'Next turn' }).click(); 1503 await page.getByRole('button', { name: 'Next turn' }).click();
1403 assert.equal( 1504 assert.equal(
1404 await page.locator('[data-turn-position]').textContent(), 1505 await page.locator('[data-turn-position]').textContent(),
1442 viewport: { width: 360, height: 640 }, 1543 viewport: { width: 360, height: 640 },
1443 }); 1544 });
1444 const mobilePage = await mobileContext.newPage(); 1545 const mobilePage = await mobileContext.newPage();
1445 await mobilePage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); 1546 await mobilePage.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
1446 await mobilePage.waitForFunction(() => 1547 await mobilePage.waitForFunction(() =>
1447 customElements.get('mjj-jrpg-composer') && 1548 customElements.get('mjj-composer') &&
1448 customElements.get('mjj-jrpg-menu') 1549 customElements.get('mjj-jrpg-menu')
1449 ); 1550 );
1450 const mobile = await mobilePage.evaluate(() => { 1551 const mobile = await mobilePage.evaluate(() => {
1451 const workspace = document.querySelector('.jrpg-workspace') 1552 const workspace = document.querySelector('.jrpg-workspace')
1452 .getBoundingClientRect(); 1553 .getBoundingClientRect();
1453 const scene = document.querySelector('.jrpg-scene').getBoundingClientRect(); 1554 const scene = document.querySelector('.jrpg-scene').getBoundingClientRect();
1454 const utility = document.querySelector('.jrpg-utility').getBoundingClientRect(); 1555 const utility = document.querySelector('.jrpg-utility').getBoundingClientRect();
1455 const composer = document.querySelector('mjj-jrpg-composer') 1556 const composer = document.querySelector('mjj-composer')
1456 .getBoundingClientRect(); 1557 .getBoundingClientRect();
1457 const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); 1558 const brand = document.querySelector('.jrpg-brand').getBoundingClientRect();
1559 const brandLink = document.querySelector('.jrpg-brand a').getBoundingClientRect();
1560 const turnNavigation = document.querySelector('.jrpg-turn-navigation');
1561 const previousTurn = turnNavigation.querySelector('[data-turn-previous]')
1562 .getBoundingClientRect();
1563 const turnPosition = turnNavigation.querySelector('[data-turn-position]')
1564 .getBoundingClientRect();
1565 const nextTurn = turnNavigation.querySelector('[data-turn-next]')
1566 .getBoundingClientRect();
1567 const composerForm = document.querySelector('.jrpg-composer')
1568 .getBoundingClientRect();
1569 const composerTextarea = document.querySelector('#jrpg-message')
1570 .getBoundingClientRect();
1571 const sendButton = document.querySelector(
1572 '.jrpg-composer button[type="submit"]',
1573 ).getBoundingClientRect();
1574 const sendOwner = document.querySelector(
1575 '.jrpg-composer zen-button:has(button[type="submit"])',
1576 );
1577 const composerField = document.querySelector(
1578 '.jrpg-composer [data-composer-control] > zen-field',
1579 );
1580 const composerLabel = composerField.querySelector('label');
1581 const sendLabel = document.querySelector('[data-composer-action-label]');
1582 const menuElement = document.querySelector('mjj-jrpg-menu');
1583 const menu = menuElement.getBoundingClientRect();
1458 const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); 1584 const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
1459 const hamburger = document.querySelector('[data-mobile-menu-toggle]'); 1585 const hamburger = document.querySelector('[data-mobile-menu-toggle]');
1460 const hamburgerBox = hamburger?.getBoundingClientRect(); 1586 const hamburgerBox = hamburger?.getBoundingClientRect();
1461 const menuButtons = [...document.querySelectorAll( 1587 const dialog = document.querySelector('[data-mobile-destination-dialog]');
1462 'mjj-jrpg-menu button[data-preview]', 1588 const destinationContent = document.querySelector(
1463 )]; 1589 '[data-mobile-destination-content]',
1464 const telemetryItems = [ 1590 );
1465 ...document.querySelectorAll('.jrpg-frame-telemetry > *'),
1466 ];
1467 const closeTo = (value, expected, tol = 0.025) => 1591 const closeTo = (value, expected, tol = 0.025) =>
1468 Math.abs(value - expected) < tol; 1592 Math.abs(value - expected) < tol;
1469 const wsW = workspace.width; 1593 const wsW = workspace.width;
1470 const wsH = workspace.height; 1594 const wsH = workspace.height;
1471 const mobileArt = getComputedStyle( 1595 const mobileArt = getComputedStyle(
1472 document.querySelector('.jrpg-workspace'), 1596 document.querySelector('.jrpg-workspace'),
1473 ).backgroundImage; 1597 ).backgroundImage;
1474 const mobileArtIsCorrect = mobileArt.includes('background-frame-mobile.webp'); 1598 const mobileArtIsCorrect = mobileArt.includes('background-frame-mobile.webp');
1475 const aperturesTolerance = [ 1599 const aperturesTolerance = [
1476 closeTo((scene.left - workspace.left) / wsW, 0.045), 1600 closeTo((scene.left - workspace.left) / wsW, 0.048),
1477 closeTo((scene.top - workspace.top) / wsH, 0.075), 1601 closeTo((scene.top - workspace.top) / wsH, 0.073),
1478 closeTo(scene.width / wsW, 0.91), 1602 closeTo(scene.width / wsW, 0.904),
1479 closeTo(scene.height / wsH, 0.455), 1603 closeTo(scene.height / wsH, 0.712),
1480 closeTo((utility.left - workspace.left) / wsW, 0.045), 1604 closeTo((composer.left - workspace.left) / wsW, 0.048),
1481 closeTo((utility.top - workspace.top) / wsH, 0.547), 1605 closeTo((composer.top - workspace.top) / wsH, 0.796),
1482 closeTo(utility.width / wsW, 0.91), 1606 closeTo(composer.width / wsW, 0.904),
1483 closeTo(utility.height / wsH, 0.171), 1607 closeTo(composer.height / wsH, 0.177),
1484 closeTo((composer.left - workspace.left) / wsW, 0.045), 1608 closeTo((hamburgerBox.left - workspace.left) / wsW, 0.872),
1485 closeTo((composer.top - workspace.top) / wsH, 0.728), 1609 closeTo((hamburgerBox.top - workspace.top) / wsH, 0.013),
1486 closeTo(composer.width / wsW, 0.603), 1610 closeTo(hamburgerBox.width / wsW, 0.094),
1487 closeTo(composer.height / wsH, 0.192), 1611 closeTo(hamburgerBox.height / wsH, 0.053),
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); 1612 ].every(Boolean);
1493 const hamburgerPresent = hamburger !== null && 1613 const hamburgerPresent = hamburger !== null &&
1494 hamburger.getAttribute('aria-expanded') === 'true' && 1614 hamburger.getAttribute('aria-expanded') === 'false' &&
1495 hamburgerBox !== null && 1615 hamburgerBox !== null &&
1496 hamburgerBox.width > 0 && 1616 hamburgerBox.width > 0 &&
1497 hamburgerBox.height > 0; 1617 hamburgerBox.height > 0;
1498 const telemetryCount = telemetryItems.length === 4;
1499 const composerVisible = composer.width > 0 && composer.height > 0; 1618 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 );
1504 return { 1619 return {
1505 aperturesTolerance, 1620 aperturesTolerance,
1506 conversationsButtonVisible: conversationsButton !== null && 1621 brandAligned:
1507 conversationsButton.offsetParent !== null, 1622 closeTo((brand.top - workspace.top) / wsH, 0.013) &&
1508 buttonsFit: menuButtons.every(button => 1623 closeTo(brand.height / wsH, 0.053) &&
1509 button.getBoundingClientRect().right <= innerWidth + 1 1624 Math.abs(
1510 ), 1625 brandLink.left + brandLink.width / 2 -
1626 (workspace.left + workspace.width / 2)
1627 ) < 2,
1628 brandVisible:
1629 getComputedStyle(document.querySelector('.jrpg-brand')).display ===
1630 'grid' &&
1631 document.querySelector('.jrpg-brand').textContent.includes('MrJuneJune'),
1632 composerContentAligned:
1633 composerForm.left >= composer.left &&
1634 composerForm.top >= composer.top &&
1635 composer.right - composerForm.right <= 8 &&
1636 composer.bottom - composerForm.bottom <= 8 &&
1637 composerForm.left - composer.left <= 8 &&
1638 composerForm.top - composer.top <= 8 &&
1639 sendButton.bottom < composerTextarea.bottom &&
1640 composerTextarea.bottom - sendButton.bottom <= 16,
1641 composerGeometry: {
1642 composer: {
1643 bottom: composer.bottom,
1644 left: composer.left,
1645 right: composer.right,
1646 top: composer.top,
1647 },
1648 form: {
1649 bottom: composerForm.bottom,
1650 left: composerForm.left,
1651 right: composerForm.right,
1652 top: composerForm.top,
1653 },
1654 send: {
1655 bottom: sendButton.bottom,
1656 left: sendButton.left,
1657 right: sendButton.right,
1658 top: sendButton.top,
1659 },
1660 textarea: {
1661 bottom: composerTextarea.bottom,
1662 left: composerTextarea.left,
1663 right: composerTextarea.right,
1664 top: composerTextarea.top,
1665 },
1666 },
1667 composerFieldUsesSpace:
1668 composerTextarea.left === composerForm.left &&
1669 composerTextarea.right === composerForm.right &&
1670 sendButton.left > composerTextarea.left &&
1671 sendButton.right < composerTextarea.right &&
1672 sendButton.top > composerTextarea.top &&
1673 sendButton.bottom < composerTextarea.bottom,
1674 composerLabelHidden:
1675 composerLabel.getBoundingClientRect().width <= 1 &&
1676 composerLabel.getBoundingClientRect().height <= 1,
1511 composerVisible, 1677 composerVisible,
1678 dialogClosed: !dialog.open,
1512 fillsViewport: shell.left === 0 && 1679 fillsViewport: shell.left === 0 &&
1513 shell.top === 0 && 1680 shell.top === 0 &&
1514 Math.abs(shell.right - innerWidth) < 1 && 1681 Math.abs(shell.right - innerWidth) < 1 &&
1515 Math.abs(shell.bottom - innerHeight) < 1, 1682 Math.abs(shell.bottom - innerHeight) < 1,
1516 fillsWidth: shell.left === 0 && 1683 fillsWidth: shell.left === 0 &&
1517 Math.abs(shell.right - innerWidth) < 1, 1684 Math.abs(shell.right - innerWidth) < 1,
1518 hamburgerPresent, 1685 hamburgerPresent,
1519 initialScroll: scrollY, 1686 initialScroll: scrollY,
1687 mediaMatches: matchMedia(
1688 '(max-width: 52rem) and (orientation: portrait)',
1689 ).matches,
1690 menuInModal: menuElement.parentElement === destinationContent,
1691 menuHidden: menu.width === 0 && menu.height === 0,
1520 mobileArtIsCorrect, 1692 mobileArtIsCorrect,
1521 menuVisible,
1522 noOverflow: document.documentElement.scrollWidth <= innerWidth, 1693 noOverflow: document.documentElement.scrollWidth <= innerWidth,
1523 telemetryCount, 1694 sendButtonCompact:
1524 utilityBelowScene: utility.top >= scene.bottom - 1, 1695 Math.abs(sendButton.width - sendButton.height) < 1 &&
1696 sendButton.width <= 44 &&
1697 getComputedStyle(sendLabel).display === 'none',
1698 telemetryHidden: getComputedStyle(
1699 document.querySelector('.jrpg-frame-telemetry'),
1700 ).display === 'none',
1701 turnNavigationAligned:
1702 Math.abs(
1703 turnPosition.left + turnPosition.width / 2 -
1704 (workspace.left + workspace.width / 2)
1705 ) < 2 &&
1706 Math.abs(
1707 previousTurn.top + previousTurn.height / 2 -
1708 (nextTurn.top + nextTurn.height / 2)
1709 ) < 1 &&
1710 previousTurn.left < turnPosition.left &&
1711 nextTurn.right > turnPosition.right,
1712 turnNavigationGeometry: {
1713 nextCenter: nextTurn.left + nextTurn.width / 2,
1714 positionCenter: turnPosition.left + turnPosition.width / 2,
1715 previousCenter: previousTurn.left + previousTurn.width / 2,
1716 workspaceCenter: workspace.left + workspace.width / 2,
1717 },
1718 topLeftClear: document.elementFromPoint(10, 10)?.classList
1719 .contains('jrpg-workspace'),
1720 utilityHidden: utility.width === 0 && utility.height === 0,
1721 utilityInModal: document.querySelector('.jrpg-utility').parentElement ===
1722 destinationContent,
1723 usesDesignSystemControls:
1724 !composerField.hasAttribute('appearance') &&
1725 !sendOwner.hasAttribute('appearance'),
1525 workspaceFills: Math.abs(workspace.left) < 1 && 1726 workspaceFills: Math.abs(workspace.left) < 1 &&
1526 Math.abs(workspace.top) < 1 && 1727 Math.abs(workspace.top) < 1 &&
1527 Math.abs(workspace.right - innerWidth) < 1 && 1728 Math.abs(workspace.right - innerWidth) < 1 &&
1528 Math.abs(workspace.bottom - innerHeight) < 1, 1729 Math.abs(workspace.bottom - innerHeight) < 1,
1529 }; 1730 };
1530 }); 1731 });
1531 assert.equal(mobile.aperturesTolerance, true, 'mobile apertures within tolerance at 360x640'); 1732 assert.equal(mobile.aperturesTolerance, true, 'mobile apertures within tolerance at 360x640');
1532 assert.equal( 1733 assert.equal(mobile.brandAligned, true, 'MrJuneJune aligns in the mobile top bar');
1533 mobile.conversationsButtonVisible, 1734 assert.equal(mobile.brandVisible, true, 'MrJuneJune is visible on mobile');
1735 assert.equal(
1736 mobile.composerContentAligned,
1534 true, 1737 true,
1535 'Conversations destination visible on mobile', 1738 `composer content aligns inside frame aperture: ${JSON.stringify(mobile.composerGeometry)}`,
1536 ); 1739 );
1537 assert.equal(mobile.buttonsFit, true, 'menu buttons fit within viewport width'); 1740 assert.equal(mobile.composerFieldUsesSpace, true, 'composer field fills space beside send control');
1741 assert.equal(mobile.composerLabelHidden, true, 'Message label remains accessible but not visible');
1538 assert.equal(mobile.composerVisible, true, 'composer visible'); 1742 assert.equal(mobile.composerVisible, true, 'composer visible');
1743 assert.equal(mobile.dialogClosed, true, 'destination modal starts closed');
1539 assert.equal(mobile.fillsViewport, true, 'shell fills viewport'); 1744 assert.equal(mobile.fillsViewport, true, 'shell fills viewport');
1540 assert.equal(mobile.fillsWidth, true, 'shell fills width'); 1745 assert.equal(mobile.fillsWidth, true, 'shell fills width');
1541 assert.equal(mobile.hamburgerPresent, true, 'hamburger button present and has aria-expanded=true'); 1746 assert.equal(mobile.hamburgerPresent, true, 'top-right menu trigger starts collapsed');
1542 assert.equal(mobile.initialScroll, 0, 'no initial scroll'); 1747 assert.equal(mobile.initialScroll, 0, 'no initial scroll');
1543 assert.equal(mobile.mobileArtIsCorrect, true, 'mobile background URL is background-frame-mobile.webp'); 1748 assert.equal(mobile.mobileArtIsCorrect, true, 'mobile background URL is background-frame-mobile.webp');
1544 assert.equal(mobile.menuVisible, true, 'menu visible'); 1749 assert.equal(mobile.menuHidden, true, 'menu stays out of the two-aperture frame');
1750 assert.equal(
1751 mobile.menuInModal,
1752 true,
1753 `menu is hosted by the mobile modal: ${JSON.stringify(mobile)}`,
1754 );
1545 assert.equal(mobile.noOverflow, true, 'no horizontal overflow at 360x640'); 1755 assert.equal(mobile.noOverflow, true, 'no horizontal overflow at 360x640');
1546 assert.equal(mobile.telemetryCount, true, 'four telemetry items present'); 1756 assert.equal(mobile.sendButtonCompact, true, 'mobile send control is a compact square icon button');
1547 assert.equal(mobile.utilityBelowScene, true, 'utility aperture below scene'); 1757 assert.equal(mobile.telemetryHidden, true, 'mobile frame omits desktop telemetry');
1758 assert.equal(
1759 mobile.turnNavigationAligned,
1760 true,
1761 `turn navigation is centered and level: ${JSON.stringify(mobile.turnNavigationGeometry)}`,
1762 );
1763 assert.equal(mobile.utilityHidden, true, 'destination view stays out of the frame');
1764 assert.equal(mobile.utilityInModal, true, 'destination view is hosted by the mobile modal');
1765 assert.equal(mobile.usesDesignSystemControls, true, 'composer uses styled Zenbu controls');
1548 assert.equal(mobile.workspaceFills, true, 'workspace fills full viewport'); 1766 assert.equal(mobile.workspaceFills, true, 'workspace fills full viewport');
1549 1767 assert.equal(mobile.topLeftClear, true, 'hidden dialog triggers do not cover frame art');
1550 // Test hamburger disclosure behavior. 1768
1769 // The top-right trigger opens the modal menu.
1551 await mobilePage.locator('[data-mobile-menu-toggle]').click(); 1770 await mobilePage.locator('[data-mobile-menu-toggle]').click();
1552 assert.equal( 1771 await mobilePage.locator('[data-mobile-destination-dialog]').waitFor({
1553 await mobilePage.locator('[data-mobile-menu-toggle]') 1772 state: 'visible',
1554 .getAttribute('aria-expanded'), 1773 });
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( 1774 assert.equal(
1567 await mobilePage.locator('[data-mobile-menu-toggle]') 1775 await mobilePage.locator('[data-mobile-menu-toggle]')
1568 .getAttribute('aria-expanded'), 1776 .getAttribute('aria-expanded'),
1569 'true', 1777 'true',
1570 'hamburger expands the destination menu', 1778 'menu trigger reflects the open modal',
1571 ); 1779 );
1572 // Hamburger keyboard accessible 1780 assert.equal(
1781 await mobilePage.locator('mjj-jrpg-menu').isVisible(),
1782 true,
1783 'destination menu is visible inside the modal',
1784 );
1785
1786 // Every destination replaces the menu inside the same modal.
1787 await mobilePage.locator('button[data-preview="conversations"]').click();
1788 await mobilePage.locator('mjj-conversation-archive').waitFor({
1789 state: 'visible',
1790 });
1791 assert.equal(
1792 await mobilePage.locator('[data-mobile-destination-title]').textContent(),
1793 'Conversations',
1794 'conversation archive is a named modal destination',
1795 );
1796 assert.equal(
1797 await mobilePage.locator('[data-mobile-destination-dialog]').isVisible(),
1798 true,
1799 'conversation archive remains in the modal',
1800 );
1801 assert.equal(
1802 await mobilePage.locator('.jrpg-mobile-destination-header').evaluate(
1803 header => {
1804 const items = [...header.children].filter(
1805 item => getComputedStyle(item).display !== 'none',
1806 );
1807 const centers = items.map(item => {
1808 const rect = item.getBoundingClientRect();
1809 return rect.top + rect.height / 2;
1810 });
1811 return Math.max(...centers) - Math.min(...centers) < 2;
1812 },
1813 ),
1814 true,
1815 'mobile modal header controls share one row',
1816 );
1817
1818 await mobilePage.locator('[data-mobile-destination-back]').click();
1819 assert.equal(
1820 await mobilePage.locator('mjj-jrpg-menu').isVisible(),
1821 true,
1822 'modal back action returns to destinations',
1823 );
1824 await mobilePage.locator('button[data-preview="resume"]').click();
1825 assert.equal(
1826 await mobilePage.locator('[data-preview-content]').isVisible(),
1827 true,
1828 'resume view renders in the mobile modal',
1829 );
1830 assert.equal(
1831 await mobilePage.locator('[data-mobile-destination-title]').textContent(),
1832 'Resume',
1833 'modal title follows the selected destination',
1834 );
1835 await mobilePage.getByRole('button', {
1836 name: 'Close destination menu',
1837 }).click();
1838 await mobilePage.locator('[data-mobile-destination-dialog]').waitFor({
1839 state: 'hidden',
1840 });
1573 assert.ok( 1841 assert.ok(
1574 await mobilePage.locator('[data-mobile-menu-toggle]').evaluate(el => 1842 await mobilePage.locator('[data-mobile-menu-toggle]').evaluate(el =>
1575 el.tabIndex >= 0 || el.tabIndex === -1 1843 el.tabIndex >= 0
1576 ), 1844 ),
1577 'hamburger button is in document', 1845 'mobile menu trigger remains keyboard accessible',
1578 ); 1846 );
1579 await mobileContext.close(); 1847 await mobileContext.close();
1580 1848
1581 // Test at 390x844 1849 // Test at 390x844
1582 const mobile390Context = await browser.newContext({ 1850 const mobile390Context = await browser.newContext({
1620 await mobile320Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); 1888 await mobile320Page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
1621 const mobile320 = await mobile320Page.evaluate(() => { 1889 const mobile320 = await mobile320Page.evaluate(() => {
1622 const workspace = document.querySelector('.jrpg-workspace') 1890 const workspace = document.querySelector('.jrpg-workspace')
1623 .getBoundingClientRect(); 1891 .getBoundingClientRect();
1624 const shell = document.querySelector('.jrpg-shell').getBoundingClientRect(); 1892 const shell = document.querySelector('.jrpg-shell').getBoundingClientRect();
1625 const composer = document.querySelector('mjj-jrpg-composer') 1893 const composer = document.querySelector('mjj-composer')
1626 .getBoundingClientRect(); 1894 .getBoundingClientRect();
1627 const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect(); 1895 const menu = document.querySelector('mjj-jrpg-menu').getBoundingClientRect();
1628 const mobileArt = getComputedStyle( 1896 const mobileArt = getComputedStyle(
1629 document.querySelector('.jrpg-workspace'), 1897 document.querySelector('.jrpg-workspace'),
1630 ).backgroundImage; 1898 ).backgroundImage;
1632 composerPresent: composer.width > 0 && composer.height > 0, 1900 composerPresent: composer.width > 0 && composer.height > 0,
1633 fillsViewport: shell.left === 0 && 1901 fillsViewport: shell.left === 0 &&
1634 shell.top === 0 && 1902 shell.top === 0 &&
1635 Math.abs(shell.right - innerWidth) < 1 && 1903 Math.abs(shell.right - innerWidth) < 1 &&
1636 Math.abs(shell.bottom - innerHeight) < 1, 1904 Math.abs(shell.bottom - innerHeight) < 1,
1637 menuPresent: menu.width > 0 && menu.height > 0, 1905 menuHidden: menu.width === 0 && menu.height === 0,
1638 mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'), 1906 mobileArtIsCorrect: mobileArt.includes('background-frame-mobile.webp'),
1639 noOverflow: document.documentElement.scrollWidth <= innerWidth, 1907 noOverflow: document.documentElement.scrollWidth <= innerWidth,
1640 workspaceFills: Math.abs(workspace.left) < 1 && 1908 workspaceFills: Math.abs(workspace.left) < 1 &&
1641 Math.abs(workspace.top) < 1 && 1909 Math.abs(workspace.top) < 1 &&
1642 Math.abs(workspace.right - innerWidth) < 1 && 1910 Math.abs(workspace.right - innerWidth) < 1 &&
1643 Math.abs(workspace.bottom - innerHeight) < 1, 1911 Math.abs(workspace.bottom - innerHeight) < 1,
1644 }; 1912 };
1645 }); 1913 });
1646 assert.equal(mobile320.composerPresent, true, 'composer present at 320x568'); 1914 assert.equal(mobile320.composerPresent, true, 'composer present at 320x568');
1647 assert.equal(mobile320.fillsViewport, true, 'fills viewport at 320x568'); 1915 assert.equal(mobile320.fillsViewport, true, 'fills viewport at 320x568');
1648 assert.equal(mobile320.menuPresent, true, 'menu present at 320x568'); 1916 assert.equal(mobile320.menuHidden, true, 'menu remains modal-only at 320x568');
1649 assert.equal(mobile320.mobileArtIsCorrect, true, 'mobile art at 320x568'); 1917 assert.equal(mobile320.mobileArtIsCorrect, true, 'mobile art at 320x568');
1650 assert.equal(mobile320.noOverflow, true, 'no overflow at 320x568'); 1918 assert.equal(mobile320.noOverflow, true, 'no overflow at 320x568');
1651 assert.equal(mobile320.workspaceFills, true, 'workspace fills at 320x568'); 1919 assert.equal(mobile320.workspaceFills, true, 'workspace fills at 320x568');
1652 await mobile320Context.close(); 1920 await mobile320Context.close();
1653 1921
2023 outputTokensLimit: 10000, outputTokensUsed: 0, 2291 outputTokensLimit: 10000, outputTokensUsed: 0,
2024 outputTokensReserved: 0, outputTokensRemaining: 10000, 2292 outputTokensReserved: 0, outputTokensRemaining: 10000,
2025 resetsAt: Math.floor(Date.now() / 1000) + 86400, 2293 resetsAt: Math.floor(Date.now() / 1000) + 86400,
2026 }, 2294 },
2027 }; 2295 };
2296
2297 /* Returning without a conversation URL stays fresh until the user chooses one. */
2298 {
2299 const context = await browser.newContext({ serviceWorkers: 'block' });
2300 await context.addInitScript(id => {
2301 sessionStorage.setItem('mjj-jrpg-conversation-id', id);
2302 }, CONV_A);
2303 const page = await context.newPage();
2304 let detailRequestCount = 0;
2305 await page.route('**/api/auth/session', route => route.fulfill({
2306 status: 200, contentType: 'application/json',
2307 body: JSON.stringify(GUEST_SESSION),
2308 }));
2309 await page.route('**/api/conversations**', route => {
2310 const url = new URL(route.request().url());
2311 if (url.pathname === '/api/conversations') {
2312 return route.fulfill({
2313 status: 200, contentType: 'application/json',
2314 body: JSON.stringify({
2315 conversations: [{
2316 id: CONV_A,
2317 title: 'Earlier quest',
2318 turn_count: 2,
2319 }],
2320 cursor: null,
2321 }),
2322 });
2323 }
2324 return route.fulfill({
2325 status: 404, contentType: 'application/json',
2326 body: JSON.stringify({ error: { message: 'Not found' } }),
2327 });
2328 });
2329 await page.route(`**/api/conversations/${CONV_A}`, route => {
2330 detailRequestCount += 1;
2331 return route.fulfill({
2332 status: 200, contentType: 'application/json',
2333 body: JSON.stringify({
2334 id: CONV_A,
2335 title: 'Earlier quest',
2336 turns: [{ role: 'assistant', content: 'Welcome back.' }],
2337 }),
2338 });
2339 });
2340
2341 await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
2342 assert.equal(detailRequestCount, 0, 'latest conversation is not opened automatically');
2343 assert.equal(
2344 new URL(page.url()).searchParams.has('conversation'),
2345 false,
2346 'returning visit remains a fresh conversation URL',
2347 );
2348 await page.waitForFunction(() => {
2349 const greeting = document.querySelector('[data-greeting] p');
2350 return greeting?.textContent.includes('Open Conversations');
2351 });
2352 assert.equal(
2353 await page.evaluate(() =>
2354 sessionStorage.getItem('mjj-jrpg-conversation-id')),
2355 null,
2356 'stale active conversation state is cleared',
2357 );
2358
2359 await page.locator('button[data-preview="conversations"]').click();
2360 const selectedConversation = page.waitForResponse(response =>
2361 new URL(response.url()).pathname === `/api/conversations/${CONV_A}`);
2362 await page.locator('[data-conv-open]').click();
2363 assert.equal((await selectedConversation).status(), 200);
2364 assert.ok(detailRequestCount >= 1, 'conversation opens after explicit selection');
2365 await page.waitForFunction(id =>
2366 new URL(window.location.href).searchParams.get('conversation') === id,
2367 CONV_A);
2368 assert.equal(
2369 new URL(page.url()).searchParams.get('conversation'),
2370 CONV_A,
2371 'explicit selection writes the conversation URL',
2372 );
2373 await context.close();
2374 }
2028 2375
2029 /* ---- Fix 3: malformed ?conversation param must not fall back to sessionStorage ---- */ 2376 /* ---- Fix 3: malformed ?conversation param must not fall back to sessionStorage ---- */
2030 { 2377 {
2031 const storedId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; 2378 const storedId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
2032 const context = await browser.newContext({ serviceWorkers: 'block' }); 2379 const context = await browser.newContext({ serviceWorkers: 'block' });
2239 }); 2586 });
2240 }); 2587 });
2241 2588
2242 await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' }); 2589 await page.goto(`${baseUrl}/jrpg`, { waitUntil: 'networkidle' });
2243 await page.waitForFunction( 2590 await page.waitForFunction(
2244 () => !document.querySelector('mjj-jrpg-composer textarea')?.disabled, 2591 () => !document.querySelector('mjj-composer textarea')?.disabled,
2245 ); 2592 );
2246 2593
2247 /* Submit — creates conversation via pushState and starts stream */ 2594 /* Submit — creates conversation via pushState and starts stream */
2248 await page.locator('#jrpg-message').fill('routing race test'); 2595 await page.locator('#jrpg-message').fill('routing race test');
2249 await page.locator('#jrpg-message').press('Enter'); 2596 await page.locator('#jrpg-message').press('Enter');
2250 2597
2251 /* Stream is now active */ 2598 /* Stream is now active */
2252 await page.locator('[data-cancel-control]').waitFor({ state: 'visible' }); 2599 await page.locator('[data-composer-cancel-owner]').waitFor({ state: 'visible' });
2253 2600
2254 const urlDuringStream = page.url(); 2601 const urlDuringStream = page.url();
2255 assert.ok( 2602 assert.ok(
2256 urlDuringStream.includes('conversation='), 2603 urlDuringStream.includes('conversation='),
2257 `Fix 1: URL must have ?conversation during stream: ${urlDuringStream}`, 2604 `Fix 1: URL must have ?conversation during stream: ${urlDuringStream}`,
2259 2606
2260 /* Navigate back while stream is active — URL already changes here */ 2607 /* Navigate back while stream is active — URL already changes here */
2261 await page.goBack(); 2608 await page.goBack();
2262 2609
2263 /* Stream abort + pending-popstate application: cancel control disappears */ 2610 /* Stream abort + pending-popstate application: cancel control disappears */
2264 await page.locator('[data-cancel-control]').waitFor({ state: 'hidden' }); 2611 await page.locator('[data-composer-cancel-owner]').waitFor({ state: 'hidden' });
2265 2612
2266 /* URL and UI must converge to the pre-stream state */ 2613 /* URL and UI must converge to the pre-stream state */
2267 const finalUrl = page.url(); 2614 const finalUrl = page.url();
2268 assert.ok( 2615 assert.ok(
2269 !finalUrl.includes('conversation='), 2616 !finalUrl.includes('conversation='),
2527 await testThemeCycle(browser); 2874 await testThemeCycle(browser);
2528 await testPlainField(browser); 2875 await testPlainField(browser);
2529 await testButtonScale(browser); 2876 await testButtonScale(browser);
2530 await testDynamicButtonOwnership(browser); 2877 await testDynamicButtonOwnership(browser);
2531 await testResumePrint(browser); 2878 await testResumePrint(browser);
2879 await testComposerLab(browser);
2532 } 2880 }
2533 if (runsSuite('jrpg')) await testJrpgPage(browser); 2881 if (runsSuite('jrpg')) await testJrpgPage(browser);
2534 if (runsSuite('routing')) await testConversationRouting(browser); 2882 if (runsSuite('routing')) await testConversationRouting(browser);
2535 if (runsSuite('greeting')) await testRoleGreetings(browser); 2883 if (runsSuite('greeting')) await testRoleGreetings(browser);
2536 if (runsSuite('hls')) await testHlsPlayer(browser, siteRoot); 2884 if (runsSuite('hls')) await testHlsPlayer(browser, siteRoot);