diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f300605cc4..787e07bce7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: jobs: quality: - name: Lint, Typecheck, Test, Build + name: Lint, Typecheck, Test, Browser Test, Build runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout @@ -34,6 +34,14 @@ jobs: restore-keys: | ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}- + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install dependencies run: bun install --frozen-lockfile @@ -46,6 +54,14 @@ jobs: - name: Test run: bun run test + - name: Install browser test runtime + run: | + cd apps/web + bunx playwright install --with-deps chromium + + - name: Browser test + run: bun run --cwd apps/web test:browser + - name: Build desktop pipeline run: bun run build:desktop diff --git a/.gitignore b/.gitignore index 09bcb945a0..ac08d39161 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,7 @@ packages/*/dist build/ .logs/ release/ -.t3 \ No newline at end of file +.t3 +apps/web/.playwright +apps/web/playwright-report +apps/web/src/components/__screenshots__ diff --git a/apps/web/package.json b/apps/web/package.json index 97985eb853..aa934cf575 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,9 @@ "prepare": "effect-language-service patch", "preview": "vite preview", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "test:browser": "vitest run --config vitest.browser.config.ts", + "test:browser:install": "playwright install --with-deps chromium" }, "dependencies": { "@base-ui/react": "^1.2.0", @@ -41,10 +43,14 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.1.4", + "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", + "msw": "^2.12.10", + "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", "vite": "^8.0.0-beta.12", - "vitest": "catalog:" + "vitest": "catalog:", + "vitest-browser-react": "^2.0.5" } } diff --git a/apps/web/public/mockServiceWorker.js b/apps/web/public/mockServiceWorker.js new file mode 100644 index 0000000000..daa58d0f12 --- /dev/null +++ b/apps/web/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.12.10' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx new file mode 100644 index 0000000000..d3f3ede966 --- /dev/null +++ b/apps/web/src/components/ChatView.browser.tsx @@ -0,0 +1,713 @@ +// Production CSS is part of the behavior under test because row height depends on it. +import "../index.css"; + +import { + ORCHESTRATION_WS_METHODS, + type MessageId, + type OrchestrationReadModel, + type ProjectId, + type ProviderSessionId, + type ServerConfig, + type ThreadId, + type WsWelcomePayload, + WS_CHANNELS, + WS_METHODS, +} from "@t3tools/contracts"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { HttpResponse, http, ws } from "msw"; +import { setupWorker } from "msw/browser"; +import { page } from "vitest/browser"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { getRouter } from "../router"; +import { useStore } from "../store"; +import { estimateTimelineMessageHeight } from "./timelineHeight"; + +const THREAD_ID = "thread-browser-test" as ThreadId; +const PROJECT_ID = "project-1" as ProjectId; +const NOW_ISO = "2026-03-04T12:00:00.000Z"; +const BASE_TIME_MS = Date.parse(NOW_ISO); +const ATTACHMENT_SVG = ""; + +interface WsRequestEnvelope { + id: string; + body: { + _tag: string; + [key: string]: unknown; + }; +} + +interface TestFixture { + snapshot: OrchestrationReadModel; + serverConfig: ServerConfig; + welcome: WsWelcomePayload; +} + +let fixture: TestFixture; +const wsLink = ws.link(/ws(s)?:\/\/.*/); + +interface ViewportSpec { + name: string; + width: number; + height: number; + textTolerancePx: number; + attachmentTolerancePx: number; +} + +const DEFAULT_VIEWPORT: ViewportSpec = { + name: "desktop", + width: 960, + height: 1_100, + textTolerancePx: 44, + attachmentTolerancePx: 56, +}; +const TEXT_VIEWPORT_MATRIX = [ + DEFAULT_VIEWPORT, + { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, + { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, + { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, +] as const satisfies readonly ViewportSpec[]; +const ATTACHMENT_VIEWPORT_MATRIX = [ + DEFAULT_VIEWPORT, + { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, + { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, +] as const satisfies readonly ViewportSpec[]; + +interface UserRowMeasurement { + measuredRowHeightPx: number; + timelineWidthMeasuredPx: number; + renderedInVirtualizedRegion: boolean; +} + +interface MountedChatView { + cleanup: () => Promise; + measureUserRow: (targetMessageId: MessageId) => Promise; + setViewport: (viewport: ViewportSpec) => Promise; +} + +function isoAt(offsetSeconds: number): string { + return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); +} + +function createBaseServerConfig(): ServerConfig { + return { + cwd: "/repo/project", + keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", + keybindings: [], + issues: [], + providers: [ + { + provider: "codex", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: NOW_ISO, + }, + ], + availableEditors: [], + }; +} + +function createUserMessage(options: { + id: MessageId; + text: string; + offsetSeconds: number; + attachments?: Array<{ + type: "image"; + id: string; + name: string; + mimeType: string; + sizeBytes: number; + }>; +}) { + return { + id: options.id, + role: "user" as const, + text: options.text, + ...(options.attachments ? { attachments: options.attachments } : {}), + turnId: null, + streaming: false, + createdAt: isoAt(options.offsetSeconds), + updatedAt: isoAt(options.offsetSeconds + 1), + }; +} + +function createAssistantMessage(options: { id: MessageId; text: string; offsetSeconds: number }) { + return { + id: options.id, + role: "assistant" as const, + text: options.text, + turnId: null, + streaming: false, + createdAt: isoAt(options.offsetSeconds), + updatedAt: isoAt(options.offsetSeconds + 1), + }; +} + +function createSnapshotForTargetUser(options: { + targetMessageId: MessageId; + targetText: string; + targetAttachmentCount?: number; +}): OrchestrationReadModel { + const messages: Array = []; + + for (let index = 0; index < 22; index += 1) { + const isTarget = index === 3; + const userId = `msg-user-${index}` as MessageId; + const assistantId = `msg-assistant-${index}` as MessageId; + const attachments = + isTarget && (options.targetAttachmentCount ?? 0) > 0 + ? Array.from({ length: options.targetAttachmentCount ?? 0 }, (_, attachmentIndex) => ({ + type: "image" as const, + id: `attachment-${attachmentIndex + 1}`, + name: `attachment-${attachmentIndex + 1}.png`, + mimeType: "image/png", + sizeBytes: 128, + })) + : undefined; + + messages.push( + createUserMessage({ + id: isTarget ? options.targetMessageId : userId, + text: isTarget ? options.targetText : `filler user message ${index}`, + offsetSeconds: messages.length * 3, + ...(attachments ? { attachments } : {}), + }), + ); + messages.push( + createAssistantMessage({ + id: assistantId, + text: `assistant filler ${index}`, + offsetSeconds: messages.length * 3, + }), + ); + } + + return { + snapshotSequence: 1, + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo/project", + defaultModel: "gpt-5", + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Browser test thread", + model: "gpt-5", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + messages, + activities: [], + checkpoints: [], + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + providerSessionId: "session-1" as ProviderSessionId, + providerThreadId: null, + approvalPolicy: "on-failure", + sandboxMode: "workspace-write", + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + ], + updatedAt: NOW_ISO, + }; +} + +function buildFixture(snapshot: OrchestrationReadModel): TestFixture { + return { + snapshot, + serverConfig: createBaseServerConfig(), + welcome: { + cwd: "/repo/project", + projectName: "Project", + bootstrapProjectId: PROJECT_ID, + bootstrapThreadId: THREAD_ID, + }, + }; +} + +function resolveWsRpc(tag: string): unknown { + if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { + return fixture.snapshot; + } + if (tag === WS_METHODS.serverGetConfig) { + return fixture.serverConfig; + } + if (tag === WS_METHODS.gitListBranches) { + return { + isRepo: true, + branches: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: null, + }, + ], + }; + } + if (tag === WS_METHODS.gitStatus) { + return { + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + } + if (tag === WS_METHODS.projectsSearchEntries) { + return { + entries: [], + truncated: false, + }; + } + return {}; +} + +const worker = setupWorker( + wsLink.addEventListener("connection", ({ client }) => { + client.send( + JSON.stringify({ + type: "push", + channel: WS_CHANNELS.serverWelcome, + data: fixture.welcome, + }), + ); + client.addEventListener("message", (event) => { + const rawData = event.data; + if (typeof rawData !== "string") return; + let request: WsRequestEnvelope; + try { + request = JSON.parse(rawData) as WsRequestEnvelope; + } catch { + return; + } + const method = request.body?._tag; + if (typeof method !== "string") return; + client.send( + JSON.stringify({ + id: request.id, + result: resolveWsRpc(method), + }), + ); + }); + }), + http.get("*/attachments/:attachmentId", () => + HttpResponse.text(ATTACHMENT_SVG, { + headers: { + "Content-Type": "image/svg+xml", + }, + }), + ), + http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), +); + +async function nextFrame(): Promise { + await new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); +} + +async function waitForLayout(): Promise { + await nextFrame(); + await nextFrame(); + await nextFrame(); +} + +async function setViewport(viewport: ViewportSpec): Promise { + await page.viewport(viewport.width, viewport.height); + await waitForLayout(); +} + +async function waitForProductionStyles(): Promise { + await vi.waitFor( + () => { + expect(getComputedStyle(document.documentElement).getPropertyValue("--background").trim()).not.toBe( + "", + ); + expect(getComputedStyle(document.body).marginTop).toBe("0px"); + }, + { + timeout: 4_000, + interval: 16, + }, + ); +} + +async function waitForElement( + query: () => T | null, + errorMessage: string, +): Promise { + let element: T | null = null; + await vi.waitFor( + () => { + element = query(); + expect(element, errorMessage).toBeTruthy(); + }, + { + timeout: 8_000, + interval: 16, + }, + ); + if (!element) { + throw new Error(errorMessage); + } + return element; +} + +async function waitForImagesToLoad(scope: ParentNode): Promise { + const images = Array.from(scope.querySelectorAll("img")); + if (images.length === 0) { + return; + } + await Promise.all( + images.map( + (image) => + new Promise((resolve) => { + if (image.complete) { + resolve(); + return; + } + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => resolve(), { once: true }); + }), + ), + ); + await waitForLayout(); +} + +async function measureUserRow(options: { + host: HTMLElement; + targetMessageId: MessageId; +}): Promise { + const { host, targetMessageId } = options; + const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`; + + const scrollContainer = await waitForElement( + () => host.querySelector("div.overflow-y-auto.overscroll-y-contain"), + "Unable to find ChatView message scroll container.", + ); + + let row: HTMLElement | null = null; + await vi.waitFor( + async () => { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await waitForLayout(); + row = host.querySelector(rowSelector); + expect(row, "Unable to locate targeted user message row.").toBeTruthy(); + }, + { + timeout: 8_000, + interval: 16, + }, + ); + + await waitForImagesToLoad(row!); + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await nextFrame(); + + const timelineRoot = + row!.closest('[data-timeline-root="true"]') ?? + host.querySelector('[data-timeline-root="true"]'); + if (!(timelineRoot instanceof HTMLElement)) { + throw new Error("Unable to locate timeline root container."); + } + + let timelineWidthMeasuredPx = 0; + let measuredRowHeightPx = 0; + let renderedInVirtualizedRegion = false; + await vi.waitFor( + async () => { + scrollContainer.scrollTop = 0; + scrollContainer.dispatchEvent(new Event("scroll")); + await nextFrame(); + const measuredRow = host.querySelector(rowSelector); + expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy(); + timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; + measuredRowHeightPx = measuredRow!.getBoundingClientRect().height; + renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement; + expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0); + expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0); + }, + { + timeout: 4_000, + interval: 16, + }, + ); + + return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; +} + +async function mountChatView(options: { + viewport: ViewportSpec; + snapshot: OrchestrationReadModel; +}): Promise { + fixture = buildFixture(options.snapshot); + await setViewport(options.viewport); + await waitForProductionStyles(); + + const host = document.createElement("div"); + host.style.position = "fixed"; + host.style.inset = "0"; + host.style.width = "100vw"; + host.style.height = "100vh"; + host.style.display = "grid"; + host.style.overflow = "hidden"; + document.body.append(host); + + const router = getRouter( + createMemoryHistory({ + initialEntries: [`/${THREAD_ID}`], + }), + ); + + const screen = await render(, { + container: host, + }); + + await waitForLayout(); + + return { + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), + setViewport: async (viewport: ViewportSpec) => { + await setViewport(viewport); + await waitForProductionStyles(); + }, + }; +} + +async function measureUserRowAtViewport(options: { + snapshot: OrchestrationReadModel; + targetMessageId: MessageId; + viewport: ViewportSpec; +}): Promise { + const mounted = await mountChatView({ + viewport: options.viewport, + snapshot: options.snapshot, + }); + + try { + return await mounted.measureUserRow(options.targetMessageId); + } finally { + await mounted.cleanup(); + } +} + +describe("ChatView timeline estimator parity (full app)", () => { + beforeAll(async () => { + fixture = buildFixture( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-bootstrap" as MessageId, + targetText: "bootstrap", + }), + ); + await worker.start({ + onUnhandledRequest: "bypass", + quiet: true, + serviceWorker: { + url: "/mockServiceWorker.js", + }, + }); + }); + + afterAll(async () => { + await worker.stop(); + }); + + beforeEach(async () => { + await setViewport(DEFAULT_VIEWPORT); + localStorage.clear(); + document.body.innerHTML = ""; + useStore.setState({ + projects: [], + threads: [], + threadsHydrated: false, + runtimeMode: "full-access", + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it.each(TEXT_VIEWPORT_MATRIX)( + "keeps long user message estimate close at the $name viewport", + async (viewport) => { + const userText = "x".repeat(3_200); + const targetMessageId = `msg-user-target-long-${viewport.name}` as MessageId; + const mounted = await mountChatView({ + viewport, + snapshot: createSnapshotForTargetUser({ + targetMessageId, + targetText: userText, + }), + }); + + try { + const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + await mounted.measureUserRow(targetMessageId); + + expect(renderedInVirtualizedRegion).toBe(true); + + const estimatedHeightPx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: timelineWidthMeasuredPx }, + ); + + expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( + viewport.textTolerancePx, + ); + } finally { + await mounted.cleanup(); + } + }, + ); + + it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { + const userText = "x".repeat(3_200); + const targetMessageId = "msg-user-target-resize" as MessageId; + const mounted = await mountChatView({ + viewport: TEXT_VIEWPORT_MATRIX[0], + snapshot: createSnapshotForTargetUser({ + targetMessageId, + targetText: userText, + }), + }); + + try { + const measurements: Array = []; + + for (const viewport of TEXT_VIEWPORT_MATRIX) { + await mounted.setViewport(viewport); + const measurement = await mounted.measureUserRow(targetMessageId); + const estimatedHeightPx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: measurement.timelineWidthMeasuredPx }, + ); + + expect(measurement.renderedInVirtualizedRegion).toBe(true); + expect(Math.abs(measurement.measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( + viewport.textTolerancePx, + ); + measurements.push({ ...measurement, viewport, estimatedHeightPx }); + } + + expect(new Set(measurements.map((measurement) => Math.round(measurement.timelineWidthMeasuredPx))).size).toBeGreaterThanOrEqual(3); + + const byMeasuredWidth = measurements.toSorted( + (left, right) => left.timelineWidthMeasuredPx - right.timelineWidthMeasuredPx, + ); + const narrowest = byMeasuredWidth[0]!; + const widest = byMeasuredWidth.at(-1)!; + expect(narrowest.timelineWidthMeasuredPx).toBeLessThan(widest.timelineWidthMeasuredPx); + expect(narrowest.measuredRowHeightPx).toBeGreaterThan(widest.measuredRowHeightPx); + expect(narrowest.estimatedHeightPx).toBeGreaterThan(widest.estimatedHeightPx); + } finally { + await mounted.cleanup(); + } + }); + + it("tracks additional rendered wrapping when ChatView width narrows between desktop and mobile viewports", async () => { + const userText = "x".repeat(2_400); + const targetMessageId = "msg-user-target-wrap" as MessageId; + const snapshot = createSnapshotForTargetUser({ + targetMessageId, + targetText: userText, + }); + const desktopMeasurement = await measureUserRowAtViewport({ + viewport: TEXT_VIEWPORT_MATRIX[0], + snapshot, + targetMessageId, + }); + const mobileMeasurement = await measureUserRowAtViewport({ + viewport: TEXT_VIEWPORT_MATRIX[2], + snapshot, + targetMessageId, + }); + + const estimatedDesktopPx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: desktopMeasurement.timelineWidthMeasuredPx }, + ); + const estimatedMobilePx = estimateTimelineMessageHeight( + { role: "user", text: userText, attachments: [] }, + { timelineWidthPx: mobileMeasurement.timelineWidthMeasuredPx }, + ); + + const measuredDeltaPx = mobileMeasurement.measuredRowHeightPx - desktopMeasurement.measuredRowHeightPx; + const estimatedDeltaPx = estimatedMobilePx - estimatedDesktopPx; + expect(measuredDeltaPx).toBeGreaterThan(0); + expect(estimatedDeltaPx).toBeGreaterThan(0); + const ratio = estimatedDeltaPx / measuredDeltaPx; + expect(ratio).toBeGreaterThan(0.65); + expect(ratio).toBeLessThan(1.35); + }); + + it.each(ATTACHMENT_VIEWPORT_MATRIX)( + "keeps user attachment estimate close at the $name viewport", + async (viewport) => { + const targetMessageId = `msg-user-target-attachments-${viewport.name}` as MessageId; + const userText = "message with image attachments"; + const mounted = await mountChatView({ + viewport, + snapshot: createSnapshotForTargetUser({ + targetMessageId, + targetText: userText, + targetAttachmentCount: 3, + }), + }); + + try { + const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = + await mounted.measureUserRow(targetMessageId); + + expect(renderedInVirtualizedRegion).toBe(true); + + const estimatedHeightPx = estimateTimelineMessageHeight( + { + role: "user", + text: userText, + attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], + }, + { timelineWidthPx: timelineWidthMeasuredPx }, + ); + + expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual( + viewport.attachmentTolerancePx, + ); + } finally { + await mounted.cleanup(); + } + }, + ); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9c24d883b5..ff8900d1d0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -131,6 +131,7 @@ import { import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; +import { estimateTimelineMessageHeight } from "./timelineHeight"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -3166,20 +3167,6 @@ type TimelineRow = } | { kind: "working"; id: string; createdAt: string | null }; -function estimateTimelineMessageHeight(message: TimelineMessage): number { - const textLength = message.text.length; - if (message.role === "assistant") { - const estimatedLines = Math.max(1, Math.ceil(textLength / 72)); - return 78 + Math.min(estimatedLines * 22, 820); - } - - const estimatedLines = Math.max(1, Math.ceil(textLength / 56)); - const attachmentCount = message.attachments?.length ?? 0; - const attachmentRows = Math.ceil(attachmentCount / 2); - const attachmentHeight = attachmentRows * 124; - return 96 + Math.min(estimatedLines * 22, 620) + attachmentHeight; -} - const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, @@ -3200,6 +3187,34 @@ const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, }: MessagesTimelineProps) { + const timelineRootRef = useRef(null); + const [timelineWidthPx, setTimelineWidthPx] = useState(null); + + useLayoutEffect(() => { + const timelineRoot = timelineRootRef.current; + if (!timelineRoot) return; + + const updateWidth = (nextWidth: number) => { + setTimelineWidthPx((previousWidth) => { + if (previousWidth !== null && Math.abs(previousWidth - nextWidth) < 0.5) { + return previousWidth; + } + return nextWidth; + }); + }; + + updateWidth(timelineRoot.getBoundingClientRect().width); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + updateWidth(timelineRoot.getBoundingClientRect().width); + }); + observer.observe(timelineRoot); + return () => { + observer.disconnect(); + }; + }, [hasMessages, isWorking]); + const rows = useMemo(() => { const nextRows: TimelineRow[] = []; @@ -3303,12 +3318,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ if (!row) return 96; if (row.kind === "work") return 112; if (row.kind === "working") return 40; - return estimateTimelineMessageHeight(row.message); + return estimateTimelineMessageHeight(row.message, { timelineWidthPx }); }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, overscan: 8, }); + useEffect(() => { + if (timelineWidthPx === null) return; + rowVirtualizer.measure(); + }, [rowVirtualizer, timelineWidthPx]); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; @@ -3341,7 +3360,12 @@ const MessagesTimeline = memo(function MessagesTimeline({ const nonVirtualizedRows = rows.slice(virtualizedRowCount); const renderRowContent = (row: TimelineRow) => ( -
+
{row.kind === "work" && (() => { const groupId = row.id; @@ -3624,7 +3648,11 @@ const MessagesTimeline = memo(function MessagesTimeline({ } return ( -
+
{virtualizedRowCount > 0 && (
{virtualRows.map((virtualRow: VirtualItem) => { diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts new file mode 100644 index 0000000000..73a21cd08d --- /dev/null +++ b/apps/web/src/components/timelineHeight.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { estimateTimelineMessageHeight } from "./timelineHeight"; + +describe("estimateTimelineMessageHeight", () => { + it("uses assistant sizing rules for assistant messages", () => { + expect( + estimateTimelineMessageHeight({ + role: "assistant", + text: "a".repeat(144), + }), + ).toBe(122); + }); + + it("uses assistant sizing rules for system messages", () => { + expect( + estimateTimelineMessageHeight({ + role: "system", + text: "a".repeat(144), + }), + ).toBe(122); + }); + + it("adds one attachment row for one or two user attachments", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }], + }), + ).toBe(346); + + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }, { id: "2" }], + }), + ).toBe(346); + }); + + it("adds a second attachment row for three or four user attachments", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], + }), + ).toBe(574); + + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "hello", + attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], + }), + ).toBe(574); + }); + + it("does not cap long user message estimates", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "a".repeat(56 * 120), + }), + ).toBe(2736); + }); + + it("counts explicit newlines for user message estimates", () => { + expect( + estimateTimelineMessageHeight({ + role: "user", + text: "first\nsecond\nthird", + }), + ).toBe(162); + }); + + it("uses narrower width to increase user line wrapping", () => { + const message = { + role: "user" as const, + text: "a".repeat(52), + }; + + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); + }); + + it("does not clamp user wrapping too aggressively on very narrow layouts", () => { + const message = { + role: "user" as const, + text: "a".repeat(20), + }; + + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118); + }); + + it("uses narrower width to increase assistant line wrapping", () => { + const message = { + role: "assistant" as const, + text: "a".repeat(200), + }; + + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(188); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(122); + }); +}); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts new file mode 100644 index 0000000000..78a5f6539b --- /dev/null +++ b/apps/web/src/components/timelineHeight.ts @@ -0,0 +1,90 @@ +const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; +const USER_CHARS_PER_LINE_FALLBACK = 56; +const LINE_HEIGHT_PX = 22; +const ASSISTANT_BASE_HEIGHT_PX = 78; +const USER_BASE_HEIGHT_PX = 96; +const ATTACHMENTS_PER_ROW = 2; +// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. +const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; +const USER_BUBBLE_WIDTH_RATIO = 0.8; +const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; +const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; +const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; +const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; +const MIN_USER_CHARS_PER_LINE = 4; +const MIN_ASSISTANT_CHARS_PER_LINE = 20; + +interface TimelineMessageHeightInput { + role: "user" | "assistant" | "system"; + text: string; + attachments?: ReadonlyArray<{ id: string }>; +} + +interface TimelineHeightEstimateLayout { + timelineWidthPx: number | null; +} + +function estimateWrappedLineCount(text: string, charsPerLine: number): number { + if (text.length === 0) return 1; + + // Avoid allocating via split for long logs; iterate once and count wrapped lines. + let lines = 0; + let currentLineLength = 0; + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) === 10) { + lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); + currentLineLength = 0; + continue; + } + currentLineLength += 1; + } + + lines += Math.max(1, Math.ceil(currentLineLength / charsPerLine)); + return lines; +} + +function isFinitePositiveNumber(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { + if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; + const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; + const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); + return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); +} + +function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { + if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; + const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); + return Math.max( + MIN_ASSISTANT_CHARS_PER_LINE, + Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), + ); +} + +export function estimateTimelineMessageHeight( + message: TimelineMessageHeightInput, + layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, +): number { + if (message.role === "assistant") { + const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; + } + + if (message.role === "user") { + const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + const attachmentCount = message.attachments?.length ?? 0; + const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); + const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; + return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight; + } + + // `system` messages are not rendered in the chat timeline, but keep a stable + // explicit branch in case they are present in timeline data. + const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b735484b2b..e4fe3eda58 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,40 +1,20 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; -import { createHashHistory, createRouter, createBrowserHistory } from "@tanstack/react-router"; -import { StoreProvider } from "./store"; +import { createHashHistory, createBrowserHistory } from "@tanstack/react-router"; import "@xterm/xterm/css/xterm.css"; import "./index.css"; -import { APP_DISPLAY_NAME } from "./branding"; import { isElectron } from "./env"; -import { routeTree } from "./routeTree.gen"; +import { getRouter } from "./router"; +import { APP_DISPLAY_NAME } from "./branding"; const history = isElectron ? createHashHistory() : createBrowserHistory(); -const queryClient = new QueryClient(); -document.title = APP_DISPLAY_NAME; +const router = getRouter(history); -const router = createRouter({ - routeTree, - history, - context: { - queryClient, - }, - Wrap: ({ children }) => ( - - {children} - - ), -}); - -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} +document.title = APP_DISPLAY_NAME; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts new file mode 100644 index 0000000000..0192ee0c6c --- /dev/null +++ b/apps/web/src/router.ts @@ -0,0 +1,34 @@ +import { createElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createRouter } from "@tanstack/react-router"; + +import { routeTree } from "./routeTree.gen"; +import { StoreProvider } from "./store"; + +type RouterHistory = NonNullable[0]["history"]>; + +export function getRouter(history: RouterHistory) { + const queryClient = new QueryClient(); + + return createRouter({ + routeTree, + history, + context: { + queryClient, + }, + Wrap: ({ children }) => + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(StoreProvider, null, children), + ), + }); +} + +export type AppRouter = ReturnType; + +declare module "@tanstack/react-router" { + interface Register { + router: AppRouter; + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4616543d55..89037c218b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,6 +26,9 @@ export const Route = createRootRouteWithContext<{ }>()({ component: RootRouteView, errorComponent: RootRouteErrorView, + head: () => ({ + meta: [{ name: "title", content: APP_DISPLAY_NAME }], + }), }); function RootRouteView() { @@ -152,9 +155,7 @@ function EventRouter() { latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); syncServerReadModel(snapshot); const activeThreadIds = new Set( - snapshot.threads - .filter((t) => t.deletedAt === null) - .map((t) => t.id), + snapshot.threads.filter((t) => t.deletedAt === null).map((t) => t.id), ); removeOrphanedTerminalStates(activeThreadIds); if (pending) { @@ -195,11 +196,13 @@ function EventRouter() { if (hasRunningSubprocess === null) { return; } - useTerminalStateStore.getState().setTerminalActivity( - ThreadId.makeUnsafe(event.threadId), - event.terminalId, - hasRunningSubprocess, - ); + useTerminalStateStore + .getState() + .setTerminalActivity( + ThreadId.makeUnsafe(event.threadId), + event.terminalId, + hasRunningSubprocess, + ); }); const unsubWelcome = onServerWelcome((payload) => { void (async () => { @@ -276,7 +279,13 @@ function EventRouter() { unsubWelcome(); unsubServerConfigUpdated(); }; - }, [navigate, queryClient, removeOrphanedTerminalStates, setProjectExpanded, syncServerReadModel]); + }, [ + navigate, + queryClient, + removeOrphanedTerminalStates, + setProjectExpanded, + syncServerReadModel, + ]); return null; } diff --git a/apps/web/vitest.browser.config.ts b/apps/web/vitest.browser.config.ts new file mode 100644 index 0000000000..6083d6735e --- /dev/null +++ b/apps/web/vitest.browser.config.ts @@ -0,0 +1,29 @@ +import { fileURLToPath } from "node:url"; +import { playwright } from "@vitest/browser-playwright"; +import { defineConfig, mergeConfig } from "vitest/config"; + +import viteConfig from "./vite.config"; + +const srcPath = fileURLToPath(new URL("./src", import.meta.url)); + +export default mergeConfig( + viteConfig, + defineConfig({ + resolve: { + alias: { + "~": srcPath, + }, + }, + test: { + include: ["src/components/ChatView.browser.tsx"], + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + headless: true, + }, + testTimeout: 30_000, + hookTimeout: 30_000, + }, + }), +); diff --git a/bun.lock b/bun.lock index 2c717ea7db..a5ed3cf690 100644 --- a/bun.lock +++ b/bun.lock @@ -92,11 +92,15 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.1.4", + "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", + "msw": "^2.12.10", + "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", "vite": "^8.0.0-beta.12", "vitest": "catalog:", + "vitest-browser-react": "^2.0.5", }, }, "packages/contracts": { @@ -467,6 +471,8 @@ "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -699,6 +705,10 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], + "@vitest/browser": ["@vitest/browser@4.0.18", "", { "dependencies": { "@vitest/mocker": "4.0.18", "@vitest/utils": "4.0.18", "magic-string": "^0.30.21", "pixelmatch": "7.1.0", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.0.3", "ws": "^8.18.3" }, "peerDependencies": { "vitest": "4.0.18" } }, "sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng=="], + + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.0.18", "", { "dependencies": { "@vitest/browser": "4.0.18", "@vitest/mocker": "4.0.18", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "playwright": "*", "vitest": "4.0.18" } }, "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g=="], + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], @@ -919,7 +929,7 @@ "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1191,6 +1201,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], @@ -1249,6 +1261,14 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pixelmatch": ["pixelmatch@7.1.0", "", { "dependencies": { "pngjs": "^7.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng=="], + + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], @@ -1345,6 +1365,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1411,6 +1433,8 @@ "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -1483,6 +1507,8 @@ "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "vitest-browser-react": ["vitest-browser-react@2.0.5", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vitest": "^4.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YODQX8mHTJCyKNVYTWJrLEYrUtw+QfLl78owgvuE7C5ydgmGBq6v5s4jK2w6wdPhIZsN9PpV1rQbmAevWJjO9g=="], + "wait-on": ["wait-on@8.0.5", "", { "dependencies": { "axios": "^1.12.1", "joi": "^18.0.1", "lodash": "^4.17.21", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], @@ -1587,6 +1613,8 @@ "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], @@ -1601,8 +1629,14 @@ "rolldown-plugin-dts/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], @@ -1635,6 +1669,8 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "@tailwindcss/vite/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@types/cacheable-request/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@types/keyv/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -1645,6 +1681,10 @@ "@types/yauzl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@vitejs/plugin-react/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + + "vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], } } diff --git a/package.json b/package.json index f79062d7f4..907b6fde1e 100644 --- a/package.json +++ b/package.json @@ -56,5 +56,10 @@ "bun": "^1.3.9", "node": "^24.13.1" }, - "packageManager": "bun@1.3.9" -} + "packageManager": "bun@1.3.9", + "msw": { + "workerDirectory": [ + "apps/web/public" + ] + } +} \ No newline at end of file