| ... | ... | @@ -1,279 +1,141 @@ |
| 1 | 1 | import "../index.css"; |
| 2 | 2 | |
| 3 | | import { type MessageId, type ThreadId } from "@t3tools/contracts"; |
| 4 | 3 | import { |
| 5 | | Outlet, |
| 6 | | RouterProvider, |
| 7 | | createMemoryHistory, |
| 8 | | createRootRoute, |
| 9 | | createRoute, |
| 10 | | createRouter, |
| 11 | | } from "@tanstack/react-router"; |
| 4 | ORCHESTRATION_WS_METHODS, |
| 5 | type MessageId, |
| 6 | type OrchestrationReadModel, |
| 7 | type ProjectId, |
| 8 | type ProviderSessionId, |
| 9 | type ServerConfig, |
| 10 | type ThreadId, |
| 11 | type WsWelcomePayload, |
| 12 | WS_CHANNELS, |
| 13 | WS_METHODS, |
| 14 | } from "@t3tools/contracts"; |
| 15 | import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; |
| 16 | import { HttpResponse, http, ws } from "msw"; |
| 17 | import { setupWorker } from "msw/browser"; |
| 12 | 18 | import { createRoot, type Root } from "react-dom/client"; |
| 13 | | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 19 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; |
| 14 | 20 | |
| 15 | | import type { ChatMessage, Thread } from "../types"; |
| 16 | | import ChatView from "./ChatView"; |
| 21 | import { getRouter } from "../router"; |
| 22 | import { useStore } from "../store"; |
| 17 | 23 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 18 | 24 | |
| 19 | | const mocks = vi.hoisted(() => { |
| 20 | | return { |
| 21 | | markThreadVisited: vi.fn(), |
| 22 | | setThreadError: vi.fn(), |
| 23 | | setRuntimeMode: vi.fn(), |
| 24 | | setThreadBranch: vi.fn(), |
| 25 | | storeState: { |
| 26 | | projects: [] as unknown[], |
| 27 | | threads: [] as Thread[], |
| 28 | | runtimeMode: "full-access", |
| 29 | | markThreadVisited: vi.fn(), |
| 30 | | setError: vi.fn(), |
| 31 | | setRuntimeMode: vi.fn(), |
| 32 | | setThreadBranch: vi.fn(), |
| 33 | | }, |
| 34 | | composerDraft: { |
| 35 | | prompt: "", |
| 36 | | images: [] as unknown[], |
| 37 | | nonPersistedImageIds: [] as string[], |
| 38 | | model: null, |
| 39 | | effort: null, |
| 40 | | }, |
| 41 | | composerStore: { |
| 42 | | draftsByThreadId: {}, |
| 43 | | draftThreadsByThreadId: {}, |
| 44 | | setPrompt: vi.fn(), |
| 45 | | setModel: vi.fn(), |
| 46 | | setEffort: vi.fn(), |
| 47 | | addImage: vi.fn(), |
| 48 | | addImages: vi.fn(), |
| 49 | | removeImage: vi.fn(), |
| 50 | | clearPersistedAttachments: vi.fn(), |
| 51 | | syncPersistedAttachments: vi.fn(), |
| 52 | | clearComposerContent: vi.fn(), |
| 53 | | clearDraftThread: vi.fn(), |
| 54 | | setDraftThreadContext: vi.fn(), |
| 55 | | }, |
| 56 | | terminalStore: { |
| 57 | | terminalStateByThreadId: {} as Record<string, unknown>, |
| 58 | | setTerminalOpen: vi.fn(), |
| 59 | | setTerminalHeight: vi.fn(), |
| 60 | | splitTerminal: vi.fn(), |
| 61 | | newTerminal: vi.fn(), |
| 62 | | setActiveTerminal: vi.fn(), |
| 63 | | closeTerminal: vi.fn(), |
| 64 | | }, |
| 65 | | terminalState: { |
| 66 | | terminalOpen: false, |
| 67 | | terminalHeight: 280, |
| 68 | | terminalIds: ["default"], |
| 69 | | runningTerminalIds: [], |
| 70 | | activeTerminalId: "default", |
| 71 | | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], |
| 72 | | activeTerminalGroupId: "group-default", |
| 73 | | }, |
| 74 | | }; |
| 75 | | }); |
| 76 | | |
| 77 | | vi.mock("@tanstack/react-query", async () => { |
| 78 | | const actual = await vi.importActual<typeof import("@tanstack/react-query")>( |
| 79 | | "@tanstack/react-query", |
| 80 | | ); |
| 81 | | return { |
| 82 | | ...actual, |
| 83 | | useQueryClient: () => ({}), |
| 84 | | useMutation: () => ({ |
| 85 | | mutateAsync: vi.fn(async () => ({})), |
| 86 | | isPending: false, |
| 87 | | }), |
| 88 | | useQuery: () => ({ |
| 89 | | data: undefined, |
| 90 | | isLoading: false, |
| 91 | | error: null, |
| 92 | | }), |
| 93 | | }; |
| 94 | | }); |
| 95 | | |
| 96 | | vi.mock("../store", () => ({ |
| 97 | | useStore: (selector: (store: typeof mocks.storeState) => unknown) => selector(mocks.storeState), |
| 98 | | })); |
| 99 | | |
| 100 | | vi.mock("../composerDraftStore", () => ({ |
| 101 | | useComposerThreadDraft: () => mocks.composerDraft, |
| 102 | | useComposerDraftStore: (selector: (store: typeof mocks.composerStore) => unknown) => |
| 103 | | selector(mocks.composerStore), |
| 104 | | })); |
| 105 | | |
| 106 | | vi.mock("../terminalStateStore", () => ({ |
| 107 | | useTerminalStateStore: (selector: (store: typeof mocks.terminalStore) => unknown) => |
| 108 | | selector(mocks.terminalStore), |
| 109 | | selectThreadTerminalState: () => mocks.terminalState, |
| 110 | | })); |
| 111 | | |
| 112 | | vi.mock("../hooks/useTurnDiffSummaries", () => ({ |
| 113 | | useTurnDiffSummaries: () => ({ |
| 114 | | turnDiffSummaries: [], |
| 115 | | inferredCheckpointTurnCountByTurnId: {}, |
| 116 | | }), |
| 117 | | })); |
| 118 | | |
| 119 | | vi.mock("../hooks/useTheme", () => ({ |
| 120 | | useTheme: () => ({ resolvedTheme: "light" as const }), |
| 121 | | })); |
| 122 | | |
| 123 | | vi.mock("../nativeApi", () => ({ |
| 124 | | readNativeApi: () => null, |
| 125 | | ensureNativeApi: () => { |
| 126 | | throw new Error("Native API unavailable in browser test"); |
| 127 | | }, |
| 128 | | })); |
| 129 | | |
| 130 | | vi.mock("./BranchToolbar", () => ({ |
| 131 | | default: () => null, |
| 132 | | })); |
| 133 | | |
| 134 | | vi.mock("./GitActionsControl", () => ({ |
| 135 | | default: () => null, |
| 136 | | })); |
| 137 | | |
| 138 | | vi.mock("./ProjectScriptsControl", () => ({ |
| 139 | | default: () => null, |
| 140 | | })); |
| 141 | | |
| 142 | | vi.mock("./ThreadTerminalDrawer", () => ({ |
| 143 | | default: () => null, |
| 144 | | })); |
| 145 | | |
| 146 | | vi.mock("./ComposerPromptEditor", () => ({ |
| 147 | | ComposerPromptEditor: () => null, |
| 148 | | })); |
| 149 | | |
| 150 | | vi.mock("./ui/sidebar", () => ({ |
| 151 | | SidebarTrigger: () => null, |
| 152 | | })); |
| 153 | | |
| 154 | 25 | const THREAD_ID = "thread-browser-test" as ThreadId; |
| 26 | const PROJECT_ID = "project-1" as ProjectId; |
| 155 | 27 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 156 | 28 | const BASE_TIME_MS = Date.parse(NOW_ISO); |
| 157 | | const TALL_IMAGE_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( |
| 158 | | "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>", |
| 159 | | )}`; |
| 29 | const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>"; |
| 160 | 30 | |
| 161 | | interface RenderMeasureOptions { |
| 162 | | timelineWidthPx: number; |
| 163 | | messages: ChatMessage[]; |
| 164 | | targetMessageId: MessageId; |
| 31 | interface WsRequestEnvelope { |
| 32 | id: string; |
| 33 | body: { |
| 34 | _tag: string; |
| 35 | [key: string]: unknown; |
| 36 | }; |
| 165 | 37 | } |
| 166 | 38 | |
| 167 | | function createTestRouter(threadId: ThreadId) { |
| 168 | | const rootRoute = createRootRoute({ |
| 169 | | component: () => <Outlet />, |
| 170 | | }); |
| 171 | | const threadRoute = createRoute({ |
| 172 | | getParentRoute: () => rootRoute, |
| 173 | | path: "/$threadId", |
| 174 | | component: () => <ChatView threadId={threadId} />, |
| 175 | | }); |
| 176 | | return createRouter({ |
| 177 | | routeTree: rootRoute.addChildren([threadRoute]), |
| 178 | | history: createMemoryHistory({ |
| 179 | | initialEntries: [`/${threadId}`], |
| 180 | | }), |
| 181 | | context: {}, |
| 182 | | }); |
| 39 | interface TestFixture { |
| 40 | snapshot: OrchestrationReadModel; |
| 41 | serverConfig: ServerConfig; |
| 42 | welcome: WsWelcomePayload; |
| 183 | 43 | } |
| 184 | 44 | |
| 185 | | function createThread(messages: ChatMessage[]): Thread { |
| 186 | | return { |
| 187 | | id: THREAD_ID, |
| 188 | | codexThreadId: null, |
| 189 | | projectId: "project-1" as Thread["projectId"], |
| 190 | | title: "Browser test thread", |
| 191 | | model: "gpt-5", |
| 192 | | session: null, |
| 193 | | messages, |
| 194 | | error: null, |
| 195 | | createdAt: NOW_ISO, |
| 196 | | latestTurn: null, |
| 197 | | lastVisitedAt: NOW_ISO, |
| 198 | | branch: null, |
| 199 | | worktreePath: null, |
| 200 | | turnDiffSummaries: [], |
| 201 | | activities: [], |
| 202 | | }; |
| 203 | | } |
| 45 | let fixture: TestFixture; |
| 46 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 204 | 47 | |
| 205 | 48 | function isoAt(offsetSeconds: number): string { |
| 206 | 49 | return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); |
| 207 | 50 | } |
| 208 | 51 | |
| 209 | | function createUserMessage({ |
| 210 | | id, |
| 211 | | text, |
| 212 | | offsetSeconds, |
| 213 | | attachments, |
| 214 | | }: { |
| 52 | function createBaseServerConfig(): ServerConfig { |
| 53 | return { |
| 54 | cwd: "/repo/project", |
| 55 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 56 | keybindings: [], |
| 57 | issues: [], |
| 58 | providers: [ |
| 59 | { |
| 60 | provider: "codex", |
| 61 | status: "ready", |
| 62 | available: true, |
| 63 | authStatus: "authenticated", |
| 64 | checkedAt: NOW_ISO, |
| 65 | }, |
| 66 | ], |
| 67 | availableEditors: [], |
| 68 | }; |
| 69 | } |
| 70 | |
| 71 | function createUserMessage(options: { |
| 215 | 72 | id: MessageId; |
| 216 | 73 | text: string; |
| 217 | 74 | offsetSeconds: number; |
| 218 | | attachments?: ChatMessage["attachments"]; |
| 219 | | }): ChatMessage { |
| 75 | attachments?: Array<{ |
| 76 | type: "image"; |
| 77 | id: string; |
| 78 | name: string; |
| 79 | mimeType: string; |
| 80 | sizeBytes: number; |
| 81 | }>; |
| 82 | }) { |
| 220 | 83 | return { |
| 221 | | id, |
| 222 | | role: "user", |
| 223 | | text, |
| 224 | | ...(attachments && attachments.length > 0 ? { attachments } : {}), |
| 225 | | createdAt: isoAt(offsetSeconds), |
| 226 | | completedAt: isoAt(offsetSeconds + 1), |
| 84 | id: options.id, |
| 85 | role: "user" as const, |
| 86 | text: options.text, |
| 87 | ...(options.attachments ? { attachments: options.attachments } : {}), |
| 88 | turnId: null, |
| 227 | 89 | streaming: false, |
| 90 | createdAt: isoAt(options.offsetSeconds), |
| 91 | updatedAt: isoAt(options.offsetSeconds + 1), |
| 228 | 92 | }; |
| 229 | 93 | } |
| 230 | 94 | |
| 231 | | function createAssistantMessage({ |
| 232 | | id, |
| 233 | | text, |
| 234 | | offsetSeconds, |
| 235 | | }: { |
| 95 | function createAssistantMessage(options: { |
| 236 | 96 | id: MessageId; |
| 237 | 97 | text: string; |
| 238 | 98 | offsetSeconds: number; |
| 239 | | }): ChatMessage { |
| 99 | }) { |
| 240 | 100 | return { |
| 241 | | id, |
| 242 | | role: "assistant", |
| 243 | | text, |
| 244 | | createdAt: isoAt(offsetSeconds), |
| 245 | | completedAt: isoAt(offsetSeconds + 1), |
| 101 | id: options.id, |
| 102 | role: "assistant" as const, |
| 103 | text: options.text, |
| 104 | turnId: null, |
| 246 | 105 | streaming: false, |
| 106 | createdAt: isoAt(options.offsetSeconds), |
| 107 | updatedAt: isoAt(options.offsetSeconds + 1), |
| 247 | 108 | }; |
| 248 | 109 | } |
| 249 | 110 | |
| 250 | | function createImageAttachments(count: number): NonNullable<ChatMessage["attachments"]> { |
| 251 | | return Array.from({ length: count }, (_, index) => ({ |
| 252 | | type: "image" as const, |
| 253 | | id: `attachment-${index + 1}`, |
| 254 | | name: `attachment-${index + 1}.svg`, |
| 255 | | mimeType: "image/svg+xml", |
| 256 | | sizeBytes: 128, |
| 257 | | previewUrl: TALL_IMAGE_DATA_URI, |
| 258 | | })); |
| 259 | | } |
| 260 | | |
| 261 | | function createConversationWithTargetUser(options: { |
| 111 | function createSnapshotForTargetUser(options: { |
| 262 | 112 | targetMessageId: MessageId; |
| 263 | 113 | targetText: string; |
| 264 | | targetAttachments?: ChatMessage["attachments"]; |
| 265 | | }): ChatMessage[] { |
| 266 | | const messages: ChatMessage[] = []; |
| 114 | targetAttachmentCount?: number; |
| 115 | }): OrchestrationReadModel { |
| 116 | const messages: Array<OrchestrationReadModel["threads"][number]["messages"][number]> = []; |
| 117 | |
| 267 | 118 | for (let index = 0; index < 22; index += 1) { |
| 268 | | const userId = (`msg-user-${index}` as MessageId); |
| 269 | | const assistantId = (`msg-assistant-${index}` as MessageId); |
| 270 | 119 | const isTarget = index === 3; |
| 120 | const userId = `msg-user-${index}` as MessageId; |
| 121 | const assistantId = `msg-assistant-${index}` as MessageId; |
| 122 | const attachments = |
| 123 | isTarget && (options.targetAttachmentCount ?? 0) > 0 |
| 124 | ? Array.from({ length: options.targetAttachmentCount ?? 0 }, (_, attachmentIndex) => ({ |
| 125 | type: "image" as const, |
| 126 | id: `attachment-${attachmentIndex + 1}`, |
| 127 | name: `attachment-${attachmentIndex + 1}.png`, |
| 128 | mimeType: "image/png", |
| 129 | sizeBytes: 128, |
| 130 | })) |
| 131 | : undefined; |
| 132 | |
| 271 | 133 | messages.push( |
| 272 | 134 | createUserMessage({ |
| 273 | 135 | id: isTarget ? options.targetMessageId : userId, |
| 274 | 136 | text: isTarget ? options.targetText : `filler user message ${index}`, |
| 275 | 137 | offsetSeconds: messages.length * 3, |
| 276 | | attachments: isTarget ? options.targetAttachments : undefined, |
| 138 | ...(attachments ? { attachments } : {}), |
| 277 | 139 | }), |
| 278 | 140 | ); |
| 279 | 141 | messages.push( |
| ... | ... | @@ -284,9 +146,149 @@ function createConversationWithTargetUser(options: { |
| 284 | 146 | }), |
| 285 | 147 | ); |
| 286 | 148 | } |
| 287 | | return messages; |
| 149 | |
| 150 | return { |
| 151 | snapshotSequence: 1, |
| 152 | projects: [ |
| 153 | { |
| 154 | id: PROJECT_ID, |
| 155 | title: "Project", |
| 156 | workspaceRoot: "/repo/project", |
| 157 | defaultModel: "gpt-5", |
| 158 | scripts: [], |
| 159 | createdAt: NOW_ISO, |
| 160 | updatedAt: NOW_ISO, |
| 161 | deletedAt: null, |
| 162 | }, |
| 163 | ], |
| 164 | threads: [ |
| 165 | { |
| 166 | id: THREAD_ID, |
| 167 | projectId: PROJECT_ID, |
| 168 | title: "Browser test thread", |
| 169 | model: "gpt-5", |
| 170 | branch: "main", |
| 171 | worktreePath: null, |
| 172 | latestTurn: null, |
| 173 | createdAt: NOW_ISO, |
| 174 | updatedAt: NOW_ISO, |
| 175 | deletedAt: null, |
| 176 | messages, |
| 177 | activities: [], |
| 178 | checkpoints: [], |
| 179 | session: { |
| 180 | threadId: THREAD_ID, |
| 181 | status: "ready", |
| 182 | providerName: "codex", |
| 183 | providerSessionId: "session-1" as ProviderSessionId, |
| 184 | providerThreadId: null, |
| 185 | approvalPolicy: "on-failure", |
| 186 | sandboxMode: "workspace-write", |
| 187 | activeTurnId: null, |
| 188 | lastError: null, |
| 189 | updatedAt: NOW_ISO, |
| 190 | }, |
| 191 | }, |
| 192 | ], |
| 193 | updatedAt: NOW_ISO, |
| 194 | }; |
| 195 | } |
| 196 | |
| 197 | function buildFixture(snapshot: OrchestrationReadModel): TestFixture { |
| 198 | return { |
| 199 | snapshot, |
| 200 | serverConfig: createBaseServerConfig(), |
| 201 | welcome: { |
| 202 | cwd: "/repo/project", |
| 203 | projectName: "Project", |
| 204 | bootstrapProjectId: PROJECT_ID, |
| 205 | bootstrapThreadId: THREAD_ID, |
| 206 | }, |
| 207 | }; |
| 208 | } |
| 209 | |
| 210 | function resolveWsRpc(tag: string): unknown { |
| 211 | if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { |
| 212 | return fixture.snapshot; |
| 213 | } |
| 214 | if (tag === WS_METHODS.serverGetConfig) { |
| 215 | return fixture.serverConfig; |
| 216 | } |
| 217 | if (tag === WS_METHODS.gitListBranches) { |
| 218 | return { |
| 219 | isRepo: true, |
| 220 | branches: [ |
| 221 | { |
| 222 | name: "main", |
| 223 | current: true, |
| 224 | isDefault: true, |
| 225 | worktreePath: null, |
| 226 | }, |
| 227 | ], |
| 228 | }; |
| 229 | } |
| 230 | if (tag === WS_METHODS.gitStatus) { |
| 231 | return { |
| 232 | branch: "main", |
| 233 | hasWorkingTreeChanges: false, |
| 234 | workingTree: { |
| 235 | files: [], |
| 236 | insertions: 0, |
| 237 | deletions: 0, |
| 238 | }, |
| 239 | hasUpstream: true, |
| 240 | aheadCount: 0, |
| 241 | behindCount: 0, |
| 242 | pr: null, |
| 243 | }; |
| 244 | } |
| 245 | if (tag === WS_METHODS.projectsSearchEntries) { |
| 246 | return { |
| 247 | entries: [], |
| 248 | truncated: false, |
| 249 | }; |
| 250 | } |
| 251 | return {}; |
| 288 | 252 | } |
| 289 | 253 | |
| 254 | const worker = setupWorker( |
| 255 | wsLink.addEventListener("connection", ({ client }) => { |
| 256 | client.send( |
| 257 | JSON.stringify({ |
| 258 | type: "push", |
| 259 | channel: WS_CHANNELS.serverWelcome, |
| 260 | data: fixture.welcome, |
| 261 | }), |
| 262 | ); |
| 263 | client.addEventListener("message", (event) => { |
| 264 | const rawData = event.data; |
| 265 | if (typeof rawData !== "string") return; |
| 266 | let request: WsRequestEnvelope; |
| 267 | try { |
| 268 | request = JSON.parse(rawData) as WsRequestEnvelope; |
| 269 | } catch { |
| 270 | return; |
| 271 | } |
| 272 | const method = request.body?._tag; |
| 273 | if (typeof method !== "string") return; |
| 274 | client.send( |
| 275 | JSON.stringify({ |
| 276 | id: request.id, |
| 277 | result: resolveWsRpc(method), |
| 278 | }), |
| 279 | ); |
| 280 | }); |
| 281 | }), |
| 282 | http.get("*/attachments/:attachmentId", () => |
| 283 | HttpResponse.text(ATTACHMENT_SVG, { |
| 284 | headers: { |
| 285 | "Content-Type": "image/svg+xml", |
| 286 | }, |
| 287 | }), |
| 288 | ), |
| 289 | http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), |
| 290 | ); |
| 291 | |
| 290 | 292 | async function nextFrame(): Promise<void> { |
| 291 | 293 | await new Promise<void>((resolve) => { |
| 292 | 294 | window.requestAnimationFrame(() => resolve()); |
| ... | ... | @@ -299,8 +301,24 @@ async function waitForLayout(): Promise<void> { |
| 299 | 301 | await nextFrame(); |
| 300 | 302 | } |
| 301 | 303 | |
| 304 | async function waitForElement<T extends Element>( |
| 305 | query: () => T | null, |
| 306 | errorMessage: string, |
| 307 | ): Promise<T> { |
| 308 | const timeoutAt = performance.now() + 8_000; |
| 309 | while (performance.now() < timeoutAt) { |
| 310 | const element = query(); |
| 311 | if (element) return element; |
| 312 | await nextFrame(); |
| 313 | } |
| 314 | throw new Error(errorMessage); |
| 315 | } |
| 316 | |
| 302 | 317 | async function waitForImagesToLoad(scope: ParentNode): Promise<void> { |
| 303 | 318 | const images = Array.from(scope.querySelectorAll("img")); |
| 319 | if (images.length === 0) { |
| 320 | return; |
| 321 | } |
| 304 | 322 | await Promise.all( |
| 305 | 323 | images.map( |
| 306 | 324 | (image) => |
| ... | ... | @@ -317,74 +335,130 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> { |
| 317 | 335 | await waitForLayout(); |
| 318 | 336 | } |
| 319 | 337 | |
| 320 | | async function renderAndMeasureUserRow({ |
| 321 | | timelineWidthPx, |
| 322 | | messages, |
| 323 | | targetMessageId, |
| 324 | | }: RenderMeasureOptions): Promise<{ |
| 338 | async function renderAndMeasureUserRow(options: { |
| 339 | timelineWidthPx: number; |
| 340 | targetMessageId: MessageId; |
| 341 | snapshot: OrchestrationReadModel; |
| 342 | }): Promise<{ |
| 325 | 343 | measuredRowHeightPx: number; |
| 326 | 344 | timelineWidthMeasuredPx: number; |
| 327 | 345 | renderedInVirtualizedRegion: boolean; |
| 328 | 346 | }> { |
| 347 | fixture = buildFixture(options.snapshot); |
| 348 | |
| 329 | 349 | const host = document.createElement("div"); |
| 330 | | host.style.width = `${timelineWidthPx}px`; |
| 350 | host.style.width = `${options.timelineWidthPx}px`; |
| 331 | 351 | host.style.height = "920px"; |
| 332 | 352 | host.style.display = "flex"; |
| 333 | 353 | host.style.overflow = "hidden"; |
| 334 | 354 | document.body.append(host); |
| 335 | 355 | |
| 336 | | mocks.storeState.threads = [createThread(messages)]; |
| 337 | | const router = createTestRouter(THREAD_ID); |
| 356 | const router = getRouter( |
| 357 | createMemoryHistory({ |
| 358 | initialEntries: [`/${THREAD_ID}`], |
| 359 | }), |
| 360 | ); |
| 338 | 361 | |
| 339 | 362 | const root: Root = createRoot(host); |
| 340 | 363 | root.render(<RouterProvider router={router} />); |
| 341 | | await waitForLayout(); |
| 342 | 364 | |
| 343 | | const scrollContainer = host.querySelector("div.overflow-y-auto.overscroll-y-contain"); |
| 344 | | if (!(scrollContainer instanceof HTMLDivElement)) { |
| 345 | | root.unmount(); |
| 346 | | throw new Error("Unable to find ChatView message scroll container."); |
| 347 | | } |
| 348 | | scrollContainer.scrollTop = 0; |
| 349 | | scrollContainer.dispatchEvent(new Event("scroll")); |
| 350 | | await waitForLayout(); |
| 365 | try { |
| 366 | await waitForLayout(); |
| 351 | 367 | |
| 352 | | const row = host.querySelector<HTMLElement>( |
| 353 | | `[data-message-id="${targetMessageId}"][data-message-role="user"]`, |
| 354 | | ); |
| 355 | | if (!(row instanceof HTMLElement)) { |
| 356 | | root.unmount(); |
| 357 | | throw new Error("Unable to locate targeted user message row."); |
| 358 | | } |
| 359 | | await waitForImagesToLoad(row); |
| 368 | const scrollContainer = await waitForElement( |
| 369 | () => host.querySelector<HTMLDivElement>("div.overflow-y-auto.overscroll-y-contain"), |
| 370 | "Unable to find ChatView message scroll container.", |
| 371 | ); |
| 360 | 372 | |
| 361 | | const timelineRoot = row.closest("div.max-w-3xl"); |
| 362 | | if (!(timelineRoot instanceof HTMLElement)) { |
| 373 | let row: HTMLElement | null = null; |
| 374 | const timeoutAt = performance.now() + 8_000; |
| 375 | while (performance.now() < timeoutAt) { |
| 376 | scrollContainer.scrollTop = 0; |
| 377 | scrollContainer.dispatchEvent(new Event("scroll")); |
| 378 | await waitForLayout(); |
| 379 | row = host.querySelector<HTMLElement>( |
| 380 | `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`, |
| 381 | ); |
| 382 | if (row) { |
| 383 | break; |
| 384 | } |
| 385 | } |
| 386 | if (!row) { |
| 387 | throw new Error("Unable to locate targeted user message row."); |
| 388 | } |
| 389 | |
| 390 | await waitForImagesToLoad(row); |
| 391 | scrollContainer.scrollTop = 0; |
| 392 | scrollContainer.dispatchEvent(new Event("scroll")); |
| 393 | await nextFrame(); |
| 394 | |
| 395 | const timelineRoot = |
| 396 | row.closest<HTMLElement>('[data-timeline-root="true"]') ?? |
| 397 | host.querySelector<HTMLElement>('[data-timeline-root="true"]'); |
| 398 | if (!(timelineRoot instanceof HTMLElement)) { |
| 399 | throw new Error("Unable to locate timeline root container."); |
| 400 | } |
| 401 | |
| 402 | const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; |
| 403 | let measuredRowHeightPx = 0; |
| 404 | let renderedInVirtualizedRegion = false; |
| 405 | const rowSelector = `[data-message-id="${options.targetMessageId}"][data-message-role="user"]`; |
| 406 | const measureTimeoutAt = performance.now() + 4_000; |
| 407 | while (performance.now() < measureTimeoutAt) { |
| 408 | scrollContainer.scrollTop = 0; |
| 409 | scrollContainer.dispatchEvent(new Event("scroll")); |
| 410 | await nextFrame(); |
| 411 | const measuredRow = host.querySelector<HTMLElement>(rowSelector); |
| 412 | if (!measuredRow) { |
| 413 | continue; |
| 414 | } |
| 415 | measuredRowHeightPx = measuredRow.getBoundingClientRect().height; |
| 416 | renderedInVirtualizedRegion = measuredRow.closest("[data-index]") instanceof HTMLElement; |
| 417 | if (measuredRowHeightPx > 0) { |
| 418 | break; |
| 419 | } |
| 420 | } |
| 421 | if (measuredRowHeightPx <= 0) { |
| 422 | throw new Error("Unable to measure targeted user row height."); |
| 423 | } |
| 424 | |
| 425 | return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; |
| 426 | } finally { |
| 363 | 427 | root.unmount(); |
| 364 | | throw new Error("Unable to locate timeline root container."); |
| 428 | host.remove(); |
| 365 | 429 | } |
| 430 | } |
| 366 | 431 | |
| 367 | | const measuredRowHeightPx = row.getBoundingClientRect().height; |
| 368 | | const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; |
| 369 | | const renderedInVirtualizedRegion = row.closest("[data-index]") instanceof HTMLElement; |
| 370 | | |
| 371 | | root.unmount(); |
| 372 | | host.remove(); |
| 432 | describe("ChatView timeline estimator parity (full app)", () => { |
| 433 | beforeAll(async () => { |
| 434 | fixture = buildFixture( |
| 435 | createSnapshotForTargetUser({ |
| 436 | targetMessageId: "msg-user-bootstrap" as MessageId, |
| 437 | targetText: "bootstrap", |
| 438 | }), |
| 439 | ); |
| 440 | await worker.start({ |
| 441 | onUnhandledRequest: "bypass", |
| 442 | quiet: true, |
| 443 | serviceWorker: { |
| 444 | url: "/mockServiceWorker.js", |
| 445 | }, |
| 446 | }); |
| 447 | }); |
| 373 | 448 | |
| 374 | | return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; |
| 375 | | } |
| 449 | afterAll(async () => { |
| 450 | await worker.stop(); |
| 451 | }); |
| 376 | 452 | |
| 377 | | describe("ChatView timeline estimator parity", () => { |
| 378 | 453 | beforeEach(() => { |
| 454 | localStorage.clear(); |
| 379 | 455 | document.body.innerHTML = ""; |
| 380 | | mocks.storeState.projects = []; |
| 381 | | mocks.storeState.threads = []; |
| 382 | | mocks.storeState.runtimeMode = "full-access"; |
| 383 | | mocks.composerDraft.prompt = ""; |
| 384 | | mocks.composerDraft.images = []; |
| 385 | | mocks.composerDraft.nonPersistedImageIds = []; |
| 386 | | mocks.composerDraft.model = null; |
| 387 | | mocks.composerDraft.effort = null; |
| 456 | useStore.setState({ |
| 457 | projects: [], |
| 458 | threads: [], |
| 459 | threadsHydrated: false, |
| 460 | runtimeMode: "full-access", |
| 461 | }); |
| 388 | 462 | }); |
| 389 | 463 | |
| 390 | 464 | afterEach(() => { |
| ... | ... | @@ -398,7 +472,7 @@ describe("ChatView timeline estimator parity", () => { |
| 398 | 472 | await renderAndMeasureUserRow({ |
| 399 | 473 | timelineWidthPx: 960, |
| 400 | 474 | targetMessageId, |
| 401 | | messages: createConversationWithTargetUser({ |
| 475 | snapshot: createSnapshotForTargetUser({ |
| 402 | 476 | targetMessageId, |
| 403 | 477 | targetText: userText, |
| 404 | 478 | }), |
| ... | ... | @@ -417,19 +491,19 @@ describe("ChatView timeline estimator parity", () => { |
| 417 | 491 | it("tracks additional rendered wrapping when ChatView width narrows", async () => { |
| 418 | 492 | const userText = "x".repeat(2_400); |
| 419 | 493 | const targetMessageId = "msg-user-target-wrap" as MessageId; |
| 420 | | const messages = createConversationWithTargetUser({ |
| 494 | const snapshot = createSnapshotForTargetUser({ |
| 421 | 495 | targetMessageId, |
| 422 | 496 | targetText: userText, |
| 423 | 497 | }); |
| 424 | 498 | const desktop = await renderAndMeasureUserRow({ |
| 425 | 499 | timelineWidthPx: 960, |
| 426 | 500 | targetMessageId, |
| 427 | | messages, |
| 501 | snapshot, |
| 428 | 502 | }); |
| 429 | 503 | const mobile = await renderAndMeasureUserRow({ |
| 430 | 504 | timelineWidthPx: 360, |
| 431 | 505 | targetMessageId, |
| 432 | | messages, |
| 506 | snapshot, |
| 433 | 507 | }); |
| 434 | 508 | |
| 435 | 509 | const estimatedDesktopPx = estimateTimelineMessageHeight( |
| ... | ... | @@ -452,16 +526,15 @@ describe("ChatView timeline estimator parity", () => { |
| 452 | 526 | |
| 453 | 527 | it("keeps user attachment estimate close to actual rendered ChatView row height", async () => { |
| 454 | 528 | const targetMessageId = "msg-user-target-attachments" as MessageId; |
| 455 | | const attachments = createImageAttachments(3); |
| 456 | 529 | const userText = "message with image attachments"; |
| 457 | 530 | const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = |
| 458 | 531 | await renderAndMeasureUserRow({ |
| 459 | 532 | timelineWidthPx: 960, |
| 460 | 533 | targetMessageId, |
| 461 | | messages: createConversationWithTargetUser({ |
| 534 | snapshot: createSnapshotForTargetUser({ |
| 462 | 535 | targetMessageId, |
| 463 | 536 | targetText: userText, |
| 464 | | targetAttachments: attachments, |
| 537 | targetAttachmentCount: 3, |
| 465 | 538 | }), |
| 466 | 539 | }); |
| 467 | 540 | |
| ... | ... | @@ -471,7 +544,7 @@ describe("ChatView timeline estimator parity", () => { |
| 471 | 544 | { |
| 472 | 545 | role: "user", |
| 473 | 546 | text: userText, |
| 474 | | attachments: attachments.map((attachment) => ({ id: attachment.id })), |
| 547 | attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], |
| 475 | 548 | }, |
| 476 | 549 | { timelineWidthPx: timelineWidthMeasuredPx }, |
| 477 | 550 | ); |