| ... | @@ -1,279 +1,141 @@ | ... | @@ -1,279 +1,141 @@ |
| 1 | import "../index.css"; | 1 | import "../index.css"; |
| 2 | | 2 | |
| 3 | import { type MessageId, type ThreadId } from "@t3tools/contracts"; | | |
| 4 | import { | 3 | import { |
| 5 | Outlet, | 4 | ORCHESTRATION_WS_METHODS, |
| 6 | RouterProvider, | 5 | type MessageId, |
| 7 | createMemoryHistory, | 6 | type OrchestrationReadModel, |
| 8 | createRootRoute, | 7 | type ProjectId, |
| 9 | createRoute, | 8 | type ProviderSessionId, |
| 10 | createRouter, | 9 | type ServerConfig, |
| 11 | } from "@tanstack/react-router"; | 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 | import { createRoot, type Root } from "react-dom/client"; | 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"; | 21 | import { getRouter } from "../router"; |
| 16 | import ChatView from "./ChatView"; | 22 | import { useStore } from "../store"; |
| 17 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | 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 | const THREAD_ID = "thread-browser-test" as ThreadId; | 25 | const THREAD_ID = "thread-browser-test" as ThreadId; |
| | 26 | const PROJECT_ID = "project-1" as ProjectId; |
| 155 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; | 27 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 156 | const BASE_TIME_MS = Date.parse(NOW_ISO); | 28 | const BASE_TIME_MS = Date.parse(NOW_ISO); |
| 157 | const TALL_IMAGE_DATA_URI = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( | 29 | const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>"; |
| 158 | "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>", | | |
| 159 | )}`; | | |
| 160 | | 30 | |
| 161 | interface RenderMeasureOptions { | 31 | interface WsRequestEnvelope { |
| 162 | timelineWidthPx: number; | 32 | id: string; |
| 163 | messages: ChatMessage[]; | 33 | body: { |
| 164 | targetMessageId: MessageId; | 34 | _tag: string; |
| | 35 | [key: string]: unknown; |
| | 36 | }; |
| 165 | } | 37 | } |
| 166 | | 38 | |
| 167 | function createTestRouter(threadId: ThreadId) { | 39 | interface TestFixture { |
| 168 | const rootRoute = createRootRoute({ | 40 | snapshot: OrchestrationReadModel; |
| 169 | component: () => <Outlet />, | 41 | serverConfig: ServerConfig; |
| 170 | }); | 42 | welcome: WsWelcomePayload; |
| 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 | }); | | |
| 183 | } | 43 | } |
| 184 | | 44 | |
| 185 | function createThread(messages: ChatMessage[]): Thread { | 45 | let fixture: TestFixture; |
| 186 | return { | 46 | const wsLink = ws.link(/ws(s)?:\/\/.*/); |
| 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 | } | | |
| 204 | | 47 | |
| 205 | function isoAt(offsetSeconds: number): string { | 48 | function isoAt(offsetSeconds: number): string { |
| 206 | return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); | 49 | return new Date(BASE_TIME_MS + offsetSeconds * 1_000).toISOString(); |
| 207 | } | 50 | } |
| 208 | | 51 | |
| 209 | function createUserMessage({ | 52 | function createBaseServerConfig(): ServerConfig { |
| 210 | id, | 53 | return { |
| 211 | text, | 54 | cwd: "/repo/project", |
| 212 | offsetSeconds, | 55 | keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", |
| 213 | attachments, | 56 | keybindings: [], |
| 214 | }: { | 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 | id: MessageId; | 72 | id: MessageId; |
| 216 | text: string; | 73 | text: string; |
| 217 | offsetSeconds: number; | 74 | offsetSeconds: number; |
| 218 | attachments?: ChatMessage["attachments"]; | 75 | attachments?: Array<{ |
| 219 | }): ChatMessage { | 76 | type: "image"; |
| | 77 | id: string; |
| | 78 | name: string; |
| | 79 | mimeType: string; |
| | 80 | sizeBytes: number; |
| | 81 | }>; |
| | 82 | }) { |
| 220 | return { | 83 | return { |
| 221 | id, | 84 | id: options.id, |
| 222 | role: "user", | 85 | role: "user" as const, |
| 223 | text, | 86 | text: options.text, |
| 224 | ...(attachments && attachments.length > 0 ? { attachments } : {}), | 87 | ...(options.attachments ? { attachments: options.attachments } : {}), |
| 225 | createdAt: isoAt(offsetSeconds), | 88 | turnId: null, |
| 226 | completedAt: isoAt(offsetSeconds + 1), | | |
| 227 | streaming: false, | 89 | streaming: false, |
| | 90 | createdAt: isoAt(options.offsetSeconds), |
| | 91 | updatedAt: isoAt(options.offsetSeconds + 1), |
| 228 | }; | 92 | }; |
| 229 | } | 93 | } |
| 230 | | 94 | |
| 231 | function createAssistantMessage({ | 95 | function createAssistantMessage(options: { |
| 232 | id, | | |
| 233 | text, | | |
| 234 | offsetSeconds, | | |
| 235 | }: { | | |
| 236 | id: MessageId; | 96 | id: MessageId; |
| 237 | text: string; | 97 | text: string; |
| 238 | offsetSeconds: number; | 98 | offsetSeconds: number; |
| 239 | }): ChatMessage { | 99 | }) { |
| 240 | return { | 100 | return { |
| 241 | id, | 101 | id: options.id, |
| 242 | role: "assistant", | 102 | role: "assistant" as const, |
| 243 | text, | 103 | text: options.text, |
| 244 | createdAt: isoAt(offsetSeconds), | 104 | turnId: null, |
| 245 | completedAt: isoAt(offsetSeconds + 1), | | |
| 246 | streaming: false, | 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"]> { | 111 | function createSnapshotForTargetUser(options: { |
| 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: { | | |
| 262 | targetMessageId: MessageId; | 112 | targetMessageId: MessageId; |
| 263 | targetText: string; | 113 | targetText: string; |
| 264 | targetAttachments?: ChatMessage["attachments"]; | 114 | targetAttachmentCount?: number; |
| 265 | }): ChatMessage[] { | 115 | }): OrchestrationReadModel { |
| 266 | const messages: ChatMessage[] = []; | 116 | const messages: Array<OrchestrationReadModel["threads"][number]["messages"][number]> = []; |
| | 117 | |
| 267 | for (let index = 0; index < 22; index += 1) { | 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 | const isTarget = index === 3; | 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 | messages.push( | 133 | messages.push( |
| 272 | createUserMessage({ | 134 | createUserMessage({ |
| 273 | id: isTarget ? options.targetMessageId : userId, | 135 | id: isTarget ? options.targetMessageId : userId, |
| 274 | text: isTarget ? options.targetText : `filler user message ${index}`, | 136 | text: isTarget ? options.targetText : `filler user message ${index}`, |
| 275 | offsetSeconds: messages.length * 3, | 137 | offsetSeconds: messages.length * 3, |
| 276 | attachments: isTarget ? options.targetAttachments : undefined, | 138 | ...(attachments ? { attachments } : {}), |
| 277 | }), | 139 | }), |
| 278 | ); | 140 | ); |
| 279 | messages.push( | 141 | messages.push( |
| ... | @@ -284,9 +146,149 @@ function createConversationWithTargetUser(options: { | ... | @@ -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 | async function nextFrame(): Promise<void> { | 292 | async function nextFrame(): Promise<void> { |
| 291 | await new Promise<void>((resolve) => { | 293 | await new Promise<void>((resolve) => { |
| 292 | window.requestAnimationFrame(() => resolve()); | 294 | window.requestAnimationFrame(() => resolve()); |
| ... | @@ -299,8 +301,24 @@ async function waitForLayout(): Promise<void> { | ... | @@ -299,8 +301,24 @@ async function waitForLayout(): Promise<void> { |
| 299 | await nextFrame(); | 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 | async function waitForImagesToLoad(scope: ParentNode): Promise<void> { | 317 | async function waitForImagesToLoad(scope: ParentNode): Promise<void> { |
| 303 | const images = Array.from(scope.querySelectorAll("img")); | 318 | const images = Array.from(scope.querySelectorAll("img")); |
| | 319 | if (images.length === 0) { |
| | 320 | return; |
| | 321 | } |
| 304 | await Promise.all( | 322 | await Promise.all( |
| 305 | images.map( | 323 | images.map( |
| 306 | (image) => | 324 | (image) => |
| ... | @@ -317,74 +335,130 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> { | ... | @@ -317,74 +335,130 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> { |
| 317 | await waitForLayout(); | 335 | await waitForLayout(); |
| 318 | } | 336 | } |
| 319 | | 337 | |
| 320 | async function renderAndMeasureUserRow({ | 338 | async function renderAndMeasureUserRow(options: { |
| 321 | timelineWidthPx, | 339 | timelineWidthPx: number; |
| 322 | messages, | 340 | targetMessageId: MessageId; |
| 323 | targetMessageId, | 341 | snapshot: OrchestrationReadModel; |
| 324 | }: RenderMeasureOptions): Promise<{ | 342 | }): Promise<{ |
| 325 | measuredRowHeightPx: number; | 343 | measuredRowHeightPx: number; |
| 326 | timelineWidthMeasuredPx: number; | 344 | timelineWidthMeasuredPx: number; |
| 327 | renderedInVirtualizedRegion: boolean; | 345 | renderedInVirtualizedRegion: boolean; |
| 328 | }> { | 346 | }> { |
| | 347 | fixture = buildFixture(options.snapshot); |
| | 348 | |
| 329 | const host = document.createElement("div"); | 349 | const host = document.createElement("div"); |
| 330 | host.style.width = `${timelineWidthPx}px`; | 350 | host.style.width = `${options.timelineWidthPx}px`; |
| 331 | host.style.height = "920px"; | 351 | host.style.height = "920px"; |
| 332 | host.style.display = "flex"; | 352 | host.style.display = "flex"; |
| 333 | host.style.overflow = "hidden"; | 353 | host.style.overflow = "hidden"; |
| 334 | document.body.append(host); | 354 | document.body.append(host); |
| 335 | | 355 | |
| 336 | mocks.storeState.threads = [createThread(messages)]; | 356 | const router = getRouter( |
| 337 | const router = createTestRouter(THREAD_ID); | 357 | createMemoryHistory({ |
| | 358 | initialEntries: [`/${THREAD_ID}`], |
| | 359 | }), |
| | 360 | ); |
| 338 | | 361 | |
| 339 | const root: Root = createRoot(host); | 362 | const root: Root = createRoot(host); |
| 340 | root.render(<RouterProvider router={router} />); | 363 | root.render(<RouterProvider router={router} />); |
| 341 | await waitForLayout(); | | |
| 342 | | 364 | |
| 343 | const scrollContainer = host.querySelector("div.overflow-y-auto.overscroll-y-contain"); | 365 | try { |
| 344 | if (!(scrollContainer instanceof HTMLDivElement)) { | 366 | await waitForLayout(); |
| 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(); | | |
| 351 | | 367 | |
| 352 | const row = host.querySelector<HTMLElement>( | 368 | const scrollContainer = await waitForElement( |
| 353 | `[data-message-id="${targetMessageId}"][data-message-role="user"]`, | 369 | () => host.querySelector<HTMLDivElement>("div.overflow-y-auto.overscroll-y-contain"), |
| 354 | ); | 370 | "Unable to find ChatView message scroll container.", |
| 355 | if (!(row instanceof HTMLElement)) { | 371 | ); |
| 356 | root.unmount(); | | |
| 357 | throw new Error("Unable to locate targeted user message row."); | | |
| 358 | } | | |
| 359 | await waitForImagesToLoad(row); | | |
| 360 | | 372 | |
| 361 | const timelineRoot = row.closest("div.max-w-3xl"); | 373 | let row: HTMLElement | null = null; |
| 362 | if (!(timelineRoot instanceof HTMLElement)) { | 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 | root.unmount(); | 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; | 432 | describe("ChatView timeline estimator parity (full app)", () => { |
| 368 | const timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width; | 433 | beforeAll(async () => { |
| 369 | const renderedInVirtualizedRegion = row.closest("[data-index]") instanceof HTMLElement; | 434 | fixture = buildFixture( |
| 370 | | 435 | createSnapshotForTargetUser({ |
| 371 | root.unmount(); | 436 | targetMessageId: "msg-user-bootstrap" as MessageId, |
| 372 | host.remove(); | 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 }; | 449 | afterAll(async () => { |
| 375 | } | 450 | await worker.stop(); |
| | 451 | }); |
| 376 | | 452 | |
| 377 | describe("ChatView timeline estimator parity", () => { | | |
| 378 | beforeEach(() => { | 453 | beforeEach(() => { |
| | 454 | localStorage.clear(); |
| 379 | document.body.innerHTML = ""; | 455 | document.body.innerHTML = ""; |
| 380 | mocks.storeState.projects = []; | 456 | useStore.setState({ |
| 381 | mocks.storeState.threads = []; | 457 | projects: [], |
| 382 | mocks.storeState.runtimeMode = "full-access"; | 458 | threads: [], |
| 383 | mocks.composerDraft.prompt = ""; | 459 | threadsHydrated: false, |
| 384 | mocks.composerDraft.images = []; | 460 | runtimeMode: "full-access", |
| 385 | mocks.composerDraft.nonPersistedImageIds = []; | 461 | }); |
| 386 | mocks.composerDraft.model = null; | | |
| 387 | mocks.composerDraft.effort = null; | | |
| 388 | }); | 462 | }); |
| 389 | | 463 | |
| 390 | afterEach(() => { | 464 | afterEach(() => { |
| ... | @@ -398,7 +472,7 @@ describe("ChatView timeline estimator parity", () => { | ... | @@ -398,7 +472,7 @@ describe("ChatView timeline estimator parity", () => { |
| 398 | await renderAndMeasureUserRow({ | 472 | await renderAndMeasureUserRow({ |
| 399 | timelineWidthPx: 960, | 473 | timelineWidthPx: 960, |
| 400 | targetMessageId, | 474 | targetMessageId, |
| 401 | messages: createConversationWithTargetUser({ | 475 | snapshot: createSnapshotForTargetUser({ |
| 402 | targetMessageId, | 476 | targetMessageId, |
| 403 | targetText: userText, | 477 | targetText: userText, |
| 404 | }), | 478 | }), |
| ... | @@ -417,19 +491,19 @@ describe("ChatView timeline estimator parity", () => { | ... | @@ -417,19 +491,19 @@ describe("ChatView timeline estimator parity", () => { |
| 417 | it("tracks additional rendered wrapping when ChatView width narrows", async () => { | 491 | it("tracks additional rendered wrapping when ChatView width narrows", async () => { |
| 418 | const userText = "x".repeat(2_400); | 492 | const userText = "x".repeat(2_400); |
| 419 | const targetMessageId = "msg-user-target-wrap" as MessageId; | 493 | const targetMessageId = "msg-user-target-wrap" as MessageId; |
| 420 | const messages = createConversationWithTargetUser({ | 494 | const snapshot = createSnapshotForTargetUser({ |
| 421 | targetMessageId, | 495 | targetMessageId, |
| 422 | targetText: userText, | 496 | targetText: userText, |
| 423 | }); | 497 | }); |
| 424 | const desktop = await renderAndMeasureUserRow({ | 498 | const desktop = await renderAndMeasureUserRow({ |
| 425 | timelineWidthPx: 960, | 499 | timelineWidthPx: 960, |
| 426 | targetMessageId, | 500 | targetMessageId, |
| 427 | messages, | 501 | snapshot, |
| 428 | }); | 502 | }); |
| 429 | const mobile = await renderAndMeasureUserRow({ | 503 | const mobile = await renderAndMeasureUserRow({ |
| 430 | timelineWidthPx: 360, | 504 | timelineWidthPx: 360, |
| 431 | targetMessageId, | 505 | targetMessageId, |
| 432 | messages, | 506 | snapshot, |
| 433 | }); | 507 | }); |
| 434 | | 508 | |
| 435 | const estimatedDesktopPx = estimateTimelineMessageHeight( | 509 | const estimatedDesktopPx = estimateTimelineMessageHeight( |
| ... | @@ -452,16 +526,15 @@ describe("ChatView timeline estimator parity", () => { | ... | @@ -452,16 +526,15 @@ describe("ChatView timeline estimator parity", () => { |
| 452 | | 526 | |
| 453 | it("keeps user attachment estimate close to actual rendered ChatView row height", async () => { | 527 | it("keeps user attachment estimate close to actual rendered ChatView row height", async () => { |
| 454 | const targetMessageId = "msg-user-target-attachments" as MessageId; | 528 | const targetMessageId = "msg-user-target-attachments" as MessageId; |
| 455 | const attachments = createImageAttachments(3); | | |
| 456 | const userText = "message with image attachments"; | 529 | const userText = "message with image attachments"; |
| 457 | const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = | 530 | const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } = |
| 458 | await renderAndMeasureUserRow({ | 531 | await renderAndMeasureUserRow({ |
| 459 | timelineWidthPx: 960, | 532 | timelineWidthPx: 960, |
| 460 | targetMessageId, | 533 | targetMessageId, |
| 461 | messages: createConversationWithTargetUser({ | 534 | snapshot: createSnapshotForTargetUser({ |
| 462 | targetMessageId, | 535 | targetMessageId, |
| 463 | targetText: userText, | 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,7 +544,7 @@ describe("ChatView timeline estimator parity", () => { |
| 471 | { | 544 | { |
| 472 | role: "user", | 545 | role: "user", |
| 473 | text: userText, | 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 | { timelineWidthPx: timelineWidthMeasuredPx }, | 549 | { timelineWidthPx: timelineWidthMeasuredPx }, |
| 477 | ); | 550 | ); |