diff --git a/src/client/editor.css b/src/client/editor.css index c9b59859..79992adc 100644 --- a/src/client/editor.css +++ b/src/client/editor.css @@ -839,6 +839,9 @@ font-weight: 500; color: #5b6c4e; } +.document-chat-panel .live-chat { + width: 100%; +} .document-chat-panel .chat-persona { padding: 18px; } diff --git a/tests/page-chat-layout.test.tsx b/tests/page-chat-layout.test.tsx new file mode 100644 index 00000000..a1c75e42 --- /dev/null +++ b/tests/page-chat-layout.test.tsx @@ -0,0 +1,199 @@ +import { readFileSync } from 'node:fs'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { chromium } from 'playwright'; +import { expect, it, vi } from 'vitest'; +import { Chat } from '../src/client/Chat'; +import { PageReviewCard } from '../src/client/PageReviewCard'; + +vi.mock('@copilotkit/react-core/v2', () => ({ + useAgent: () => ({ + isReady: true, + agent: { + messages: [ + { id: 'user', role: 'user', content: 'Please review these notes.' }, + { + id: 'assistant', + role: 'assistant', + content: 'I prepared a review draft with the key points.', + toolCalls: [ + { + id: 'review', + type: 'function', + function: { name: 'review_space_page', arguments: '{}' }, + }, + ], + }, + ], + }, + }), + useCopilotKit: () => ({ copilotkit: {} }), + useHumanInTheLoop: () => {}, + useRenderTool: () => {}, + CopilotChatToolCallsView: () => ( + {}} + /> + ), +})); +vi.mock('../src/client/api', () => ({ api: vi.fn() })); +vi.mock('../src/client/useVoice', () => ({ + useVoice: () => ({ status: 'idle' }), +})); +vi.mock('../src/client/CallView', () => ({ CallView: () => null })); + +async function launchChatBrowser() { + try { + return await chromium.launch({ + executablePath: process.env.CHROMIUM_EXECUTABLE_PATH, + }); + } catch (error) { + if ( + process.env.CHROMIUM_EXECUTABLE_PATH || + !(error instanceof Error) || + !error.message.includes("Executable doesn't exist at") + ) { + throw error; + } + // Hosted CI images include Chrome even when Playwright's cache is empty. + return chromium.launch({ channel: 'chrome' }); + } +} + +it('falls back to Chrome only when bundled Chromium is missing', async () => { + vi.stubEnv('CHROMIUM_EXECUTABLE_PATH', undefined); + const browser = {} as Awaited>; + const launch = vi + .spyOn(chromium, 'launch') + .mockRejectedValueOnce( + new Error("Executable doesn't exist at /cache/chromium"), + ) + .mockResolvedValueOnce(browser); + try { + expect(await launchChatBrowser()).toBe(browser); + expect(launch.mock.calls).toEqual([ + [{ executablePath: undefined }], + [{ channel: 'chrome' }], + ]); + } finally { + launch.mockRestore(); + vi.unstubAllEnvs(); + } +}); + +it('does not hide other browser launch failures', async () => { + vi.stubEnv('CHROMIUM_EXECUTABLE_PATH', undefined); + const launch = vi + .spyOn(chromium, 'launch') + .mockRejectedValue(new Error('browser sandbox failed')); + try { + await expect(launchChatBrowser()).rejects.toThrow('browser sandbox failed'); + expect(launch).toHaveBeenCalledTimes(1); + } finally { + launch.mockRestore(); + vi.unstubAllEnvs(); + } +}); + +it('does not replace an explicitly configured executable', async () => { + vi.stubEnv('CHROMIUM_EXECUTABLE_PATH', '/custom/chrome'); + const launch = vi + .spyOn(chromium, 'launch') + .mockRejectedValue(new Error("Executable doesn't exist at /custom/chrome")); + try { + await expect(launchChatBrowser()).rejects.toThrow( + "Executable doesn't exist at /custom/chrome", + ); + expect(launch.mock.calls).toEqual([[{ executablePath: '/custom/chrome' }]]); + } finally { + launch.mockRestore(); + vi.unstubAllEnvs(); + } +}); + +it('keeps page chat inside its sidebar across desktop and stacked widths', async () => { + const browser = await launchChatBrowser(); + try { + const page = await browser.newPage(); + const css = ['style.css', 'editor.css'] + .map((file) => + readFileSync(new URL(`../src/client/${file}`, import.meta.url), 'utf8'), + ) + .join('\n'); + const html = renderToStaticMarkup( +
+
Project notes
+
+ +
+
, + ); + await page.setContent(`${html}`); + for (const width of [1320, 1150, 901, 900, 700, 390]) { + await page.setViewportSize({ width, height: 900 }); + const bounds = await page + .locator('.document-assistant') + .evaluate((panel) => { + const chat = panel.querySelector('.live-chat')!; + return { + panelWidth: panel.clientWidth, + panelScroll: panel.scrollWidth, + chatRight: chat.getBoundingClientRect().right, + panelRight: panel.getBoundingClientRect().right, + }; + }); + expect( + bounds.panelScroll, + `panel overflow at ${width}px`, + ).toBeLessThanOrEqual(bounds.panelWidth + 1); + expect( + bounds.chatRight, + `chat extends outside panel at ${width}px`, + ).toBeLessThanOrEqual(bounds.panelRight + 1); + } + } finally { + await browser.close(); + } +}, 30000);