Mercurial
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); |