From ee21d4362b6868da3cc4bbc656abe5c875c79e08 Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Tue, 2 Sep 2025 11:18:45 +0200 Subject: [PATCH 1/2] Throttle incoming deltas during streaming (#2638) --- CHANGELOG.md | 4 +- packages/liveblocks-core/src/ai.ts | 140 ++++++++++++++++++----------- 2 files changed, 92 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd59091f752..30ed3300417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,10 @@ ### `@liveblocks/core` +- Throttle incoming AI delta updates to prevent excessive re-renders during fast + streaming. - Optimized partial JSON parser for improved tool invocation streaming - performance + performance. ## v3.5.1 diff --git a/packages/liveblocks-core/src/ai.ts b/packages/liveblocks-core/src/ai.ts index f2e86588a3c..39d0ad9b9b3 100644 --- a/packages/liveblocks-core/src/ai.ts +++ b/packages/liveblocks-core/src/ai.ts @@ -952,6 +952,39 @@ export function createAi(config: AiConfig): Ai { knowledge: new KnowledgeStack(), }; + // Delta batch processing system to throttle incoming delta updates. Incoming + // deltas are buffered and only let through every every 25ms. This creates + // a ceiling of max 40 rerenders/second during streaming. + const DELTA_THROTTLE = 25; + let pendingDeltas: { id: MessageId; delta: AiAssistantDeltaUpdate }[] = []; + let deltaBatchTimer: ReturnType | null = null; + + function flushPendingDeltas() { + const currentQueue = pendingDeltas; + + pendingDeltas = []; + if (deltaBatchTimer !== null) { + clearTimeout(deltaBatchTimer); + deltaBatchTimer = null; + } + + // Process all pending deltas in a single batch + batch(() => { + for (const { id, delta } of currentQueue) { + context.messagesStore.addDelta(id, delta); + } + }); + } + + function enqueueDelta(id: MessageId, delta: AiAssistantDeltaUpdate) { + pendingDeltas.push({ id, delta }); + + // If no timer is running, start one to process the batch + if (deltaBatchTimer === null) { + deltaBatchTimer = setTimeout(flushPendingDeltas, DELTA_THROTTLE); + } + } + let lastTokenKey: string | undefined; function onStatusDidChange(_newStatus: Status) { const authValue = managedSocket.authValue; @@ -998,7 +1031,10 @@ export function createAi(config: AiConfig): Ai { // NoOp for now, but we should maybe fetch messages or something? } - function onDidDisconnect() {} + function onDidDisconnect() { + // Flush any pending deltas before disconnect to prevent data loss + flushPendingDeltas(); + } function handleServerMessage(event: IWebSocketMessageEvent) { if (typeof event.data !== "string") @@ -1027,60 +1063,62 @@ export function createAi(config: AiConfig): Ai { } if ("event" in msg) { - switch (msg.event) { - case "cmd-failed": - pendingCmd?.reject(new Error(msg.error)); - break; - - case "delta": { - const { id, delta } = msg; - context.messagesStore.addDelta(id, delta); - break; - } - - case "settle": { - context.messagesStore.upsert(msg.message); - break; - } - - case "warning": - console.warn(msg.message); - break; - - case "error": - console.error(msg.error); - break; - - case "rebooted": - context.messagesStore.failAllPending(); - break; + // Delta's are handled separately + if (msg.event === "delta") { + const { id, delta } = msg; + enqueueDelta(id, delta); + } else { + batch(() => { + flushPendingDeltas(); - case "sync": - batch(() => { - // Delete any resources? - for (const m of msg["-messages"] ?? []) { - context.messagesStore.remove(m.chatId, m.id); - } - for (const chatId of msg["-chats"] ?? []) { - context.chatsStore.markDeleted(chatId); - context.messagesStore.removeByChatId(chatId); - } - for (const chatId of msg.clear ?? []) { - context.messagesStore.removeByChatId(chatId); - } + switch (msg.event) { + case "cmd-failed": + pendingCmd?.reject(new Error(msg.error)); + break; - // Add any new resources? - if (msg.chats) { - context.chatsStore.upsertMany(msg.chats); - } - if (msg.messages) { - context.messagesStore.upsertMany(msg.messages); + case "settle": { + context.messagesStore.upsert(msg.message); + break; } - }); - break; - default: - return assertNever(msg, "Unhandled case"); + case "warning": + console.warn(msg.message); + break; + + case "error": + console.error(msg.error); + break; + + case "rebooted": + context.messagesStore.failAllPending(); + break; + + case "sync": + // Delete any resources? + for (const m of msg["-messages"] ?? []) { + context.messagesStore.remove(m.chatId, m.id); + } + for (const chatId of msg["-chats"] ?? []) { + context.chatsStore.markDeleted(chatId); + context.messagesStore.removeByChatId(chatId); + } + for (const chatId of msg.clear ?? []) { + context.messagesStore.removeByChatId(chatId); + } + + // Add any new resources? + if (msg.chats) { + context.chatsStore.upsertMany(msg.chats); + } + if (msg.messages) { + context.messagesStore.upsertMany(msg.messages); + } + break; + + default: + return assertNever(msg, "Unhandled case"); + } + }); } } else { switch (msg.cmd) { From 98241f85bab7ddc915bffa30f5656a491f79335e Mon Sep 17 00:00:00 2001 From: Vincent Driessen Date: Tue, 2 Sep 2025 11:49:56 +0200 Subject: [PATCH 2/2] Upgrade Playwright to latest version (#2637) --- e2e/next-ai-kitchen-sink/package.json | 7 +- e2e/next-ai-kitchen-sink/playwright.config.ts | 1 - .../test/knowledge.test.ts | 65 ++++--- .../test/simple-chat.test.ts | 112 +++++++----- .../test/tool-calling.test.ts | 170 ++++++++++-------- e2e/next-sandbox/package.json | 7 +- e2e/next-sandbox/playwright.config.ts | 1 - e2e/next-sandbox/test/client.test.ts | 132 ++++++++------ e2e/next-sandbox/test/storage/list.test.ts | 17 +- package-lock.json | 64 ++----- 10 files changed, 303 insertions(+), 273 deletions(-) diff --git a/e2e/next-ai-kitchen-sink/package.json b/e2e/next-ai-kitchen-sink/package.json index 9352fc7e090..67200e7c921 100644 --- a/e2e/next-ai-kitchen-sink/package.json +++ b/e2e/next-ai-kitchen-sink/package.json @@ -9,7 +9,8 @@ "lint": "next lint", "format": "(eslint --fix app/ test/ || true) && prettier --write app/ test/", "test": "playwright test --max-failures=1", - "test:ui": "playwright test --ui --workers=1 --max-failures=1" + "test:headed": "playwright --max-failures=1 test --headed", + "test:ui": "playwright test --max-failures=1 --workers=1 --ui" }, "dependencies": { "@liveblocks/client": "*", @@ -23,11 +24,11 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.55.0", "@tailwindcss/postcss": "^4", "eslint": "^9", "eslint-config-next": "15.3.1", - "playwright": "^1.54.2", + "playwright": "^1.55.0", "tailwindcss": "^4", "typescript": "^5" } diff --git a/e2e/next-ai-kitchen-sink/playwright.config.ts b/e2e/next-ai-kitchen-sink/playwright.config.ts index 2b6e4bfd44a..77d36235502 100644 --- a/e2e/next-ai-kitchen-sink/playwright.config.ts +++ b/e2e/next-ai-kitchen-sink/playwright.config.ts @@ -35,7 +35,6 @@ export default defineConfig({ ], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - headless: process.env.CI || process.env.HEADLESS ? true : false, viewport: { width: 640, height: 800 }, permissions: ["clipboard-write", "clipboard-read"], /* Maximum time each action such as `click()` can take. 10s local, 15s CI. */ diff --git a/e2e/next-ai-kitchen-sink/test/knowledge.test.ts b/e2e/next-ai-kitchen-sink/test/knowledge.test.ts index 3c2f7d2b6eb..5b16a7f19cb 100644 --- a/e2e/next-ai-kitchen-sink/test/knowledge.test.ts +++ b/e2e/next-ai-kitchen-sink/test/knowledge.test.ts @@ -70,37 +70,46 @@ test.describe("Knowledge Registration", () => { test("should use registered knowledge about current view and todos", async ({ page, }) => { - // Create unique test chat and go to knowledge page const chatId = createRandomChat(page); - await page.goto(`/knowledge/${chatId}`); - // Wait for the page to load - verify default tab is "Todo app" - await expect( - page.locator('button.font-bold:has-text("Todo app")') - ).toBeVisible(); + await test.step("Setup and verify todo app view", async () => { + await page.goto(`/knowledge/${chatId}`, { waitUntil: "networkidle" }); - // Verify the default todos are visible - await expect(page.locator('li:has-text("Get groceries")')).toBeVisible(); - await expect(page.locator('li:has-text("Go to the gym")')).toBeVisible(); - await expect(page.locator('li:has-text("Cook dinner")')).toBeVisible(); + // Wait for the page to load - verify default tab is "Todo app" + await expect( + page.locator('button.font-bold:has-text("Todo app")') + ).toBeVisible(); - // Ask AI about the current view - it should know we're on the Todo list - await sendAiMessage(page, "What is the current view in the app?"); + // Verify the default todos are visible + await expect(page.locator('li:has-text("Get groceries")')).toBeVisible(); + await expect(page.locator('li:has-text("Go to the gym")')).toBeVisible(); + await expect(page.locator('li:has-text("Cook dinner")')).toBeVisible(); + }); - // Wait for AI response that should mention the todo view - await expect( - page.locator("text=Todo").or(page.locator("text=todo")) - ).toBeVisible({ timeout: 30000 }); + await test.step("Test AI knowledge of current view", async () => { + // Ask AI about the current view - it should know we're on the Todo list + await sendAiMessage(page, "What is the current view in the app?"); - // Ask AI about the todos - it should know the specific items - await sendAiMessage(page, "What todos do I have?"); + // Wait for AI response that should mention the todo view + await expect( + page.locator("text=Todo").or(page.locator("text=todo")) + ).toBeVisible({ timeout: 30000 }); + }); - // Wait for AI response that should include the todo items - await page.waitForTimeout(10000); - // The AI should mention the specific todos in its response - use first() to handle duplicates - await expect( - page.locator("text=groceries").or(page.locator("text=Groceries")).first() - ).toBeVisible({ timeout: 20000 }); + await test.step("Test AI knowledge of specific todos", async () => { + // Ask AI about the todos - it should know the specific items + await sendAiMessage(page, "What todos do I have?"); + + // Wait for AI response that should include the todo items + await page.waitForTimeout(10000); + // The AI should mention the specific todos in its response - use first() to handle duplicates + await expect( + page + .locator("text=groceries") + .or(page.locator("text=Groceries")) + .first() + ).toBeVisible({ timeout: 20000 }); + }); }); test("should use nickname knowledge when enabled", async ({ page }) => { @@ -191,7 +200,7 @@ test.describe("Knowledge Registration", () => { await page.goto(`/knowledge/${chatId}`); // Start on Todo app tab - await expect(page.getByTestId("tab-todo-app")).toHaveClass(/font-bold/); + await expect(page.getByTestId("tab-todo-app")).toContainClass("font-bold"); // Ask about current view await sendAiMessage(page, "What view am I currently in?"); @@ -201,7 +210,9 @@ test.describe("Knowledge Registration", () => { // Switch to "Another app" tab await page.getByTestId("tab-another-app").click(); - await expect(page.getByTestId("tab-another-app")).toHaveClass(/font-bold/); + await expect(page.getByTestId("tab-another-app")).toContainClass( + "font-bold" + ); await expect(page.locator("text=Another part of the app")).toBeVisible(); // Ask about current view again - should now know it's "Another app" @@ -214,7 +225,7 @@ test.describe("Knowledge Registration", () => { // Switch to "Both" tab await page.getByTestId("tab-both").click(); - await expect(page.getByTestId("tab-both")).toHaveClass(/font-bold/); + await expect(page.getByTestId("tab-both")).toContainClass("font-bold"); // Ask about current view - should know it's "Both apps" await sendAiMessage(page, "What view am I in now?"); diff --git a/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts b/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts index 0c7cbd80de0..95f448f2754 100644 --- a/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts +++ b/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts @@ -39,29 +39,37 @@ test.describe("Simple Chat", () => { const chatId = createRandomChat(page); const { textInput, sendButton } = await setupSimpleChat(page, chatId); - // Perform the ping-pong interaction - await textInput.fill("ping"); - await sendButton.click(); - - // Ensure the send button turns into an abort button (should now show StopIcon) - // The button should change to show "Abort response" aria-label - await expect(sendButton).toHaveAttribute("aria-label", "Abort response"); + await test.step("Send ping message", async () => { + // Perform the ping-pong interaction + await textInput.fill("ping"); + await sendButton.click(); + }); - // Wait for the send button to become enabled again (back to send state) - await expect(sendButton).toHaveAttribute("aria-label", "Send", { - timeout: 15000, // Give it up to 15 seconds for the AI response + await test.step("Verify AI is processing request", async () => { + // Ensure the send button turns into an abort button (should now show StopIcon) + // The button should change to show "Abort response" aria-label + await expect(sendButton).toHaveAttribute("aria-label", "Abort response"); }); - // Check that a response message containing "pong" is received - // Look for assistant messages using the correct class - const assistantMessage = page - .locator(".lb-ai-chat-assistant-message") - .last(); - await expect(assistantMessage).toBeVisible({ timeout: 15000 }); + await test.step("Wait for AI response completion", async () => { + // Wait for the send button to become enabled again (back to send state) + await expect(sendButton).toHaveAttribute("aria-label", "Send", { + timeout: 15000, // Give it up to 15 seconds for the AI response + }); + }); - // Check if it contains "pong" - await expect(assistantMessage).toContainText("pong", { - timeout: 15000, + await test.step("Verify pong response received", async () => { + // Check that a response message containing "pong" is received + // Look for assistant messages using the correct class + const assistantMessage = page + .locator(".lb-ai-chat-assistant-message") + .last(); + await expect(assistantMessage).toBeVisible({ timeout: 15000 }); + + // Check if it contains "pong" + await expect(assistantMessage).toContainText("pong", { + timeout: 15000, + }); }); }); @@ -71,44 +79,52 @@ test.describe("Simple Chat", () => { const chatId = createRandomChat(page); const { textInput, sendButton } = await setupSimpleChat(page, chatId); - // Ask a question that should generate a long response - await textInput.fill( - "Write a detailed 500-word essay about the history of artificial intelligence, covering major milestones from the 1950s to today." - ); + await test.step("Send long-form request", async () => { + // Ask a question that should generate a long response + await textInput.fill( + "Write a detailed 500-word essay about the history of artificial intelligence, covering major milestones from the 1950s to today." + ); - // The button should be enabled once we have text - await expect(sendButton).toBeEnabled({ timeout: 15000 }); - await sendButton.click(); + // The button should be enabled once we have text + await expect(sendButton).toBeEnabled({ timeout: 15000 }); + await sendButton.click(); + }); - // Verify the button changes to abort state - await expect(sendButton).toHaveAttribute("aria-label", "Abort response"); + await test.step("Verify AI starts processing and abort", async () => { + // Verify the button changes to abort state + await expect(sendButton).toHaveAttribute("aria-label", "Abort response"); - // Click the abort button while the AI is generating - await sendButton.click(); + // Click the abort button while the AI is generating + await sendButton.click(); - // Verify the button goes back to send state - await expect(sendButton).toHaveAttribute("aria-label", "Send", { - timeout: 15000, + // Verify the button goes back to send state + await expect(sendButton).toHaveAttribute("aria-label", "Send", { + timeout: 15000, + }); }); - // Verify the user message exists - const userMessage = page.locator(".lb-ai-chat-user-message").last(); - await expect(userMessage).toBeVisible(); - await expect(userMessage).toContainText("Write a detailed 500-word essay"); + await test.step("Verify messages and abort behavior", async () => { + // Verify the user message exists + const userMessage = page.locator(".lb-ai-chat-user-message").last(); + await expect(userMessage).toBeVisible(); + await expect(userMessage).toContainText( + "Write a detailed 500-word essay" + ); - // The assistant message should exist (created optimistically) but may be hidden when aborted - const assistantMessage = page - .locator(".lb-ai-chat-assistant-message") - .last(); + // The assistant message should exist (created optimistically) but may be hidden when aborted + const assistantMessage = page + .locator(".lb-ai-chat-assistant-message") + .last(); - // The assistant message must exist in the DOM (created optimistically) - await expect(assistantMessage).toHaveCount(1); + // The assistant message must exist in the DOM (created optimistically) + await expect(assistantMessage).toHaveCount(1); - // Get the message text - even if hidden, we can still read the content - const messageText = await assistantMessage.textContent(); + // Get the message text - even if hidden, we can still read the content + const messageText = await assistantMessage.textContent(); - // The message should be incomplete due to abort - much shorter than a full 500-word essay - // A full essay would be significantly longer than 500 characters - expect(messageText?.length || 0).toBeLessThan(500); + // The message should be incomplete due to abort - much shorter than a full 500-word essay + // A full essay would be significantly longer than 500 characters + expect(messageText?.length || 0).toBeLessThan(500); + }); }); }); diff --git a/e2e/next-ai-kitchen-sink/test/tool-calling.test.ts b/e2e/next-ai-kitchen-sink/test/tool-calling.test.ts index b7d558e4e01..9ab2169c628 100644 --- a/e2e/next-ai-kitchen-sink/test/tool-calling.test.ts +++ b/e2e/next-ai-kitchen-sink/test/tool-calling.test.ts @@ -46,96 +46,108 @@ test.describe("Tool Calling", () => { }); test("should perform todo operations via AI tool calls", async ({ page }) => { - // Create unique test chat and go to todo page const chatId = createRandomChat(page); - await page.goto(`/todo/${chatId}`); + await test.step("Setup todo page with default items", async () => { + // Create unique test chat and go to todo page + await page.goto(`/todo/${chatId}`, { waitUntil: "networkidle" }); + + // Wait for the page to load and show default todos + await expect(page.locator('li:has-text("Get groceries")')).toBeVisible(); + await expect(page.locator('li:has-text("Go to the gym")')).toBeVisible(); + await expect(page.locator('li:has-text("Cook dinner")')).toBeVisible(); + }); - // Wait for the page to load and show default todos - await expect(page.locator('li:has-text("Get groceries")')).toBeVisible(); - await expect(page.locator('li:has-text("Go to the gym")')).toBeVisible(); - await expect(page.locator('li:has-text("Cook dinner")')).toBeVisible(); + await test.step("Add test todo item manually", async () => { + // Add a new todo item manually first + const todoInput = page.locator('input[placeholder="Add a todo"]'); + await todoInput.fill("Buy test item for AI"); + await todoInput.press("Enter"); - // Step 3: Add a new todo item manually first - const todoInput = page.locator('input[placeholder="Add a todo"]'); - await todoInput.fill("Buy test item for AI"); - await todoInput.press("Enter"); + // Verify the new todo appears in the list + await expect( + page.locator('li:has-text("Buy test item for AI")') + ).toBeVisible(); + }); - // Verify the new todo appears in the list - await expect( - page.locator('li:has-text("Buy test item for AI")') - ).toBeVisible(); + await test.step("Test AI tool call to list todos", async () => { + // Open the AI chat and ask it to list all current todos + // The chat should be open by default (Popover.Root open={true}) + await sendAiMessage(page, "List all current todos"); + + // Wait for the AI response with tool call results + // The response should show all todos including our new one + await expect(page.locator("text=Buy test item for AI")).toBeVisible({ + timeout: 30000, + }); + await expect(page.locator("text=Get groceries")).toBeVisible(); + await expect(page.locator("text=Go to the gym")).toBeVisible(); + await expect(page.locator("text=Cook dinner")).toBeVisible(); + }); - // Step 4: Open the AI chat and ask it to list all current todos - // The chat should be open by default (Popover.Root open={true}) - await sendAiMessage(page, "List all current todos"); + await test.step("Test AI tool call to toggle todo completion", async () => { + // Ask AI to toggle the new item we just added + await sendAiMessage( + page, + "Toggle the completion status of 'Buy test item for AI'" + ); - // Wait for the AI response with tool call results - // The response should show all todos including our new one - await expect(page.locator("text=Buy test item for AI")).toBeVisible({ - timeout: 30000, + // Wait for the tool call to execute and verify the todo is now completed (crossed out) + await page.waitForTimeout(5000); // Wait for tool execution + // Check in the main todo list (not in the AI chat) for the completed item + const completedTodo = page.locator( + 'ul.flex li.line-through:has-text("Buy test item for AI")' + ); + await expect(completedTodo).toBeVisible({ timeout: 10000 }); }); - await expect(page.locator("text=Get groceries")).toBeVisible(); - await expect(page.locator("text=Go to the gym")).toBeVisible(); - await expect(page.locator("text=Cook dinner")).toBeVisible(); - - // Step 5: Ask AI to toggle the new item we just added - await sendAiMessage( - page, - "Toggle the completion status of 'Buy test item for AI'" - ); - // Wait for the tool call to execute and verify the todo is now completed (crossed out) - await page.waitForTimeout(5000); // Wait for tool execution - // Check in the main todo list (not in the AI chat) for the completed item - const completedTodo = page.locator( - 'ul.flex li.line-through:has-text("Buy test item for AI")' - ); - await expect(completedTodo).toBeVisible({ timeout: 10000 }); - - // Step 6: Ask AI to delete the newly added item - await sendAiMessage(page, "Delete the todo item 'Buy test item for AI'"); + await test.step("Test AI tool call to delete todo", async () => { + // Ask AI to delete the newly added item + await sendAiMessage(page, "Delete the todo item 'Buy test item for AI'"); - // Wait for the confirmation dialog to appear - await expect(page.locator("text=Okay to delete?")).toBeVisible({ - timeout: 10000, + // Wait for the confirmation dialog to appear + await expect(page.locator("text=Okay to delete?")).toBeVisible({ + timeout: 10000, + }); + + // Click the Confirm button (look for various possible button texts) + const confirmButton = page + .locator("button") + .filter({ hasText: /Confirm|Yes|OK|Delete/ }) + .first(); + await confirmButton.click(); + + // Wait for the deletion to complete + await page.waitForTimeout(5000); + + // Check if the AI chat shows the deletion was successful first + const deletedText = page + .locator("text=Deleted") + .or(page.locator("text=deleted")); + if (await deletedText.isVisible()) { + // If deletion was successful, the item should be gone from the main list + await expect( + page.locator('ul.flex li:has-text("Buy test item for AI")') + ).not.toBeVisible(); + } else { + // If deletion failed or was cancelled, just verify the AI handled it gracefully + console.log( + "Deletion may have failed or been cancelled - checking AI response" + ); + } }); - // Click the Confirm button (look for various possible button texts) - const confirmButton = page - .locator("button") - .filter({ hasText: /Confirm|Yes|OK|Delete/ }) - .first(); - await confirmButton.click(); - - // Wait for the deletion to complete - await page.waitForTimeout(5000); - - // Check if the AI chat shows the deletion was successful first - const deletedText = page - .locator("text=Deleted") - .or(page.locator("text=deleted")); - if (await deletedText.isVisible()) { - // If deletion was successful, the item should be gone from the main list + await test.step("Verify original todos remain intact", async () => { + // Verify the remaining todos are still there in the main list await expect( - page.locator('ul.flex li:has-text("Buy test item for AI")') - ).not.toBeVisible(); - } else { - // If deletion failed or was cancelled, just verify the AI handled it gracefully - console.log( - "Deletion may have failed or been cancelled - checking AI response" - ); - } - - // Step 7: Verify the remaining todos are still there in the main list - await expect( - page.locator('ul.flex li:has-text("Get groceries")') - ).toBeVisible(); - await expect( - page.locator('ul.flex li:has-text("Go to the gym")') - ).toBeVisible(); - await expect( - page.locator('ul.flex li:has-text("Cook dinner")') - ).toBeVisible(); + page.locator('ul.flex li:has-text("Get groceries")') + ).toBeVisible(); + await expect( + page.locator('ul.flex li:has-text("Go to the gym")') + ).toBeVisible(); + await expect( + page.locator('ul.flex li:has-text("Cook dinner")') + ).toBeVisible(); + }); }); test("should handle tool call errors gracefully", async ({ page }) => { @@ -167,7 +179,7 @@ test.describe("Tool Calling", () => { }) => { // Create unique test chat const chatId = createRandomChat(page); - + await page.goto(`/todo/${chatId}`); // Add a test todo diff --git a/e2e/next-sandbox/package.json b/e2e/next-sandbox/package.json index cb218fecdef..2a224abc3c1 100644 --- a/e2e/next-sandbox/package.json +++ b/e2e/next-sandbox/package.json @@ -7,7 +7,8 @@ "lint": "eslint pages/ utils/ test/", "format": "(eslint --fix pages/ utils/ test/ || true) && prettier --write pages/ utils/ test/", "test": "playwright test --max-failures=1", - "test:ui": "playwright test --ui --workers=1 --max-failures=1", + "test:headed": "playwright test --max-failures=1 --headed", + "test:ui": "playwright test --max-failures=1 --workers=1 --ui", "dev": "next dev --port 3007", "start": "next start --port 3007" }, @@ -33,11 +34,11 @@ "devDependencies": { "@liveblocks/eslint-config": "*", "@liveblocks/jest-config": "*", - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.55.0", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.13", "eslint-config-next": "^14.2.20", "lodash": "^4.17.21", - "playwright": "^1.54.2" + "playwright": "^1.55.0" } } diff --git a/e2e/next-sandbox/playwright.config.ts b/e2e/next-sandbox/playwright.config.ts index baf53da513c..fc7be57d541 100644 --- a/e2e/next-sandbox/playwright.config.ts +++ b/e2e/next-sandbox/playwright.config.ts @@ -35,7 +35,6 @@ export default defineConfig({ ], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - headless: process.env.CI || process.env.HEADLESS ? true : false, viewport: { width: 640, height: 800 }, permissions: ["clipboard-write", "clipboard-read"], /* Maximum time each action such as `click()` can take. 10s local, 15s CI. */ diff --git a/e2e/next-sandbox/test/client.test.ts b/e2e/next-sandbox/test/client.test.ts index 2ca258a91aa..f249d9a2fd2 100644 --- a/e2e/next-sandbox/test/client.test.ts +++ b/e2e/next-sandbox/test/client.test.ts @@ -38,69 +38,83 @@ test.describe("Client logout", () => { test("client.logout() will reconnect currently connected rooms", async () => { const page = pages[0]; - // Connect to two different rooms - await page.click("#add-column"); - await page.click("#add-column"); - await page.fill("#input_1", "e2e:logout-A"); - await page.fill("#input_2", "e2e:logout-B"); - await page.fill("#input_3", "e2e:logout-B"); // Same room as instance 2 - await page.click("#mount_1"); - await page.click("#mount_2"); - await page.click("#mount_3"); - - await waitForJson(page, "#socketStatus_1", "connected"); - await waitForJson(page, "#socketStatus_2", "connected"); - await waitForJson(page, "#socketStatus_3", "connected"); - const connId1 = (await getJson(page, "#connectionId_1")) as number; - const connId2 = (await getJson(page, "#connectionId_2")) as number; - const connId3 = (await getJson(page, "#connectionId_3")) as number; - - await page.click("#logout"); - await waitForJson(page, "#socketStatus_1", "connected"); - await waitForJson(page, "#socketStatus_2", "connected"); - await waitForJson(page, "#socketStatus_3", "connected"); - - // All three rooms get re-connected (and thus increment their connection ID) - await waitForJson(page, "#connectionId_1", connId1 + 1); - await waitForJson(page, "#connectionId_2", connId2 + 1); - await waitForJson(page, "#connectionId_3", connId3 + 1); + await test.step("Setup three room connections", async () => { + // Connect to two different rooms + await page.click("#add-column"); + await page.click("#add-column"); + await page.fill("#input_1", "e2e:logout-A"); + await page.fill("#input_2", "e2e:logout-B"); + await page.fill("#input_3", "e2e:logout-B"); // Same room as instance 2 + await page.click("#mount_1"); + await page.click("#mount_2"); + await page.click("#mount_3"); + }); + + let connId1: number, connId2: number, connId3: number; + await test.step("Wait for all connections to be established", async () => { + await waitForJson(page, "#socketStatus_1", "connected"); + await waitForJson(page, "#socketStatus_2", "connected"); + await waitForJson(page, "#socketStatus_3", "connected"); + connId1 = (await getJson(page, "#connectionId_1")) as number; + connId2 = (await getJson(page, "#connectionId_2")) as number; + connId3 = (await getJson(page, "#connectionId_3")) as number; + }); + + await test.step("Logout and verify reconnection", async () => { + await page.click("#logout"); + await waitForJson(page, "#socketStatus_1", "connected"); + await waitForJson(page, "#socketStatus_2", "connected"); + await waitForJson(page, "#socketStatus_3", "connected"); + + // All three rooms get re-connected (and thus increment their connection ID) + await waitForJson(page, "#connectionId_1", connId1 + 1); + await waitForJson(page, "#connectionId_2", connId2 + 1); + await waitForJson(page, "#connectionId_3", connId3 + 1); + }); }); test("client.logout() will not reconnect idle rooms", async () => { const page = pages[0]; - // Connect to two different rooms - await page.click("#add-column"); - await page.click("#add-column"); - await page.fill("#input_1", "e2e:logout-P"); - await page.fill("#input_2", "e2e:logout-Q"); - await page.fill("#input_3", "e2e:logout-R"); - await page.click("#mount_1"); - await page.click("#mount_2"); - await page.click("#disconnect_2"); // Immediately disconnect 2nd room - await page.click("#mount_3"); - - await waitForJson(page, "#socketStatus_1", "connected"); - await waitForJson(page, "#socketStatus_2", "initial"); - - // Also disconnect room 3, but only after it has first established - // a connection (so in contrast with room 2 it will have a connection ID) - await waitForJson(page, "#socketStatus_3", "connected"); - await page.click("#disconnect_3"); - await waitForJson(page, "#socketStatus_3", "initial"); - - const connId1 = (await getJson(page, "#connectionId_1")) as number; - await expectJson(page, "#connectionId_2", undefined); // Room 2 has no connection ID - const connId3 = (await getJson(page, "#connectionId_3")) as number; - - await page.click("#logout"); - await waitForJson(page, "#socketStatus_1", "connected"); - await waitForJson(page, "#socketStatus_2", "initial"); // Remains in initial - await waitForJson(page, "#socketStatus_3", "initial"); // Remains in initial - - // Only room 1 got reconnected (and increased its connection ID) - await waitForJson(page, "#connectionId_1", connId1 + 1); - await waitForJson(page, "#connectionId_2", undefined); - await waitForJson(page, "#connectionId_3", connId3); + await test.step("Setup rooms with mixed connection states", async () => { + // Connect to two different rooms + await page.click("#add-column"); + await page.click("#add-column"); + await page.fill("#input_1", "e2e:logout-P"); + await page.fill("#input_2", "e2e:logout-Q"); + await page.fill("#input_3", "e2e:logout-R"); + await page.click("#mount_1"); + await page.click("#mount_2"); + await page.click("#disconnect_2"); // Immediately disconnect 2nd room + await page.click("#mount_3"); + }); + + let connId1: number, connId3: number; + await test.step("Create idle rooms by disconnecting after connection", async () => { + await waitForJson(page, "#socketStatus_1", "connected"); + await waitForJson(page, "#socketStatus_2", "initial"); + + // Also disconnect room 3, but only after it has first established + // a connection (so in contrast with room 2 it will have a connection ID) + await waitForJson(page, "#socketStatus_3", "connected"); + await page.click("#disconnect_3"); + await waitForJson(page, "#socketStatus_3", "initial"); + + connId1 = (await getJson(page, "#connectionId_1")) as number; + await expectJson(page, "#connectionId_2", undefined); // Room 2 has no connection ID + connId3 = (await getJson(page, "#connectionId_3")) as number; + }); + + await test.step("Verify logout only reconnects active rooms", async () => { + await page.click("#logout"); + await waitForJson(page, "#socketStatus_1", "connected"); + await waitForJson(page, "#socketStatus_2", "initial"); // Remains in initial + await waitForJson(page, "#socketStatus_3", "initial"); // Remains in initial + + // Only room 1 got reconnected (and increased its connection ID) + await waitForJson(page, "#connectionId_1", connId1 + 1); + await waitForJson(page, "#connectionId_2", undefined); + await waitForJson(page, "#connectionId_3", connId3); + }); }); }); diff --git a/e2e/next-sandbox/test/storage/list.test.ts b/e2e/next-sandbox/test/storage/list.test.ts index ef6c47d3ed0..fa388cf057f 100644 --- a/e2e/next-sandbox/test/storage/list.test.ts +++ b/e2e/next-sandbox/test/storage/list.test.ts @@ -31,13 +31,20 @@ test.describe("Storage - LiveList", () => { test("list push basic", async () => { const [page1] = pages; - await page1.click("#clear"); - await waitForJson(pages, "#numItems", 0); - await page1.click("#push"); - await waitForJson(pages, "#numItems", 1); + await test.step("Clear initial list", async () => { + await page1.click("#clear"); + await waitForJson(pages, "#numItems", 0); + }); - await waitUntilEqualOnAllPages(pages, "#items"); + await test.step("Push item to list", async () => { + await page1.click("#push"); + await waitForJson(pages, "#numItems", 1); + }); + + await test.step("Verify synchronization across pages", async () => { + await waitUntilEqualOnAllPages(pages, "#items"); + }); await page1.click("#push"); await waitForJson(pages, "#numItems", 2); diff --git a/package-lock.json b/package-lock.json index 6b6da9d1e06..00584b8c8c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,11 +48,11 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.55.0", "@tailwindcss/postcss": "^4", "eslint": "^9", "eslint-config-next": "15.3.1", - "playwright": "^1.54.2", + "playwright": "^1.55.0", "tailwindcss": "^4", "typescript": "^5" } @@ -1284,12 +1284,12 @@ "devDependencies": { "@liveblocks/eslint-config": "*", "@liveblocks/jest-config": "*", - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.55.0", "@types/jest": "^29.5.14", "@types/lodash": "^4.17.13", "eslint-config-next": "^14.2.20", "lodash": "^4.17.21", - "playwright": "^1.54.2" + "playwright": "^1.55.0" } }, "e2e/next-sandbox/node_modules/@reduxjs/toolkit": { @@ -8557,11 +8557,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.49.1", + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.49.1" + "playwright": "1.55.0" }, "bin": { "playwright": "cli.js" @@ -8570,38 +8572,6 @@ "node": ">=18" } }, - "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", - "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.49.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", - "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "license": "MIT", @@ -29346,13 +29316,13 @@ } }, "node_modules/playwright": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", - "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", - "dev": true, + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.54.2" + "playwright-core": "1.55.0" }, "bin": { "playwright": "cli.js" @@ -29365,10 +29335,10 @@ } }, "node_modules/playwright-core": { - "version": "1.54.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", - "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", - "dev": true, + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js"