diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 2172c2bb267..472231c7831 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -124,6 +124,47 @@ jobs:
path: e2e/next-sandbox/playwright-report
retention-days: 14
+ e2e-ai-tests:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [22.4.1]
+
+ timeout-minutes: 60
+
+ env:
+ NEXT_PUBLIC_GITHUB_SHA: ${{ github.sha }}
+ NEXT_PUBLIC_LIVEBLOCKS_BASE_URL: ${{ vars.E2E_TEST_LIVEBLOCKS_BASE_URL }}
+ LIVEBLOCKS_SECRET_KEY: ${{ secrets.E2E_TEST_LIVEBLOCKS_SECRET_KEY }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Use Node ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: npm
+ cache-dependency-path: "**/package-lock.json"
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Install Playwright
+ run: npx playwright install chromium
+
+ - name: Run AI Playwright tests
+ run: npm run test -- --filter @liveblocks/next-ai-kitchen-sink
+
+ - uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: ai-playwright-report
+ path: e2e/next-ai-kitchen-sink/playwright-report
+ retention-days: 14
+
e2e-client-specs-tests:
runs-on: ubuntu-latest
diff --git a/CLAUDE.md b/CLAUDE.md
index 3190e23b624..1abe8afde7f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,7 +18,8 @@ _private_ package. Customers should not use it directly.
# Bash commands
-- To run scripts, use `npx turbo`, not `npm`
+When running scripts, use `npx turbo`, not `npm`.
+
- npx turbo run build: Build the project
- npx turbo run build && tsc: Run typechecks
- npx turbo run test:types: Run the type-level tests
@@ -32,5 +33,15 @@ _private_ package. Customers should not use it directly.
# Workflow
-- Be sure to typecheck when you’re done making a series of code changes
+- Be sure to typecheck when you're done making a series of code changes
- Prefer running single tests, and not the whole test suite, for performance
+
+# Testing
+
+- End-to-end applications are located in e2e/
+- For Storage, Presence, Inbox Notifications, Comments & Threads the app is
+ located in e2e/next-sandbox
+- For AI the app is in e2e/next-ai-kitchen-sink
+
+Run e2e tests headlessly using Playwright:
+npx turbo build && env HEADLESS=1 playwright test --retries=5 --
diff --git a/assets/account-management/dsync-setup.png b/assets/account-management/dsync-setup.png
new file mode 100644
index 00000000000..eccb9208de8
Binary files /dev/null and b/assets/account-management/dsync-setup.png differ
diff --git a/assets/account-management/sso-setup.png b/assets/account-management/sso-setup.png
new file mode 100644
index 00000000000..aa2f1320f4d
Binary files /dev/null and b/assets/account-management/sso-setup.png differ
diff --git a/assets/account-management/team-security-settings.png b/assets/account-management/team-security-settings.png
new file mode 100644
index 00000000000..6a10cd26959
Binary files /dev/null and b/assets/account-management/team-security-settings.png differ
diff --git a/docs/pages/platform/account-management.mdx b/docs/pages/platform/account-management.mdx
index ec751c9747d..7711e5861fb 100644
--- a/docs/pages/platform/account-management.mdx
+++ b/docs/pages/platform/account-management.mdx
@@ -10,3 +10,5 @@ accounts and teams.
- [Create an account](/docs/platform/account-management/create-an-account)
- [Manage team members](/docs/platform/account-management/manage-team-members)
+- [Configure SSO](/docs/platform/account-management/saml)
+- [Configure Directory sync](/docs/platform/account-management/directory-sync)
diff --git a/docs/pages/platform/account-management/directory-sync.mdx b/docs/pages/platform/account-management/directory-sync.mdx
new file mode 100644
index 00000000000..ad72b4668da
--- /dev/null
+++ b/docs/pages/platform/account-management/directory-sync.mdx
@@ -0,0 +1,66 @@
+---
+meta:
+ title: "Directory sync"
+ parentTitle: "Platform"
+ description: "Learn how to set up directory sync on your Liveblocks account."
+---
+
+
+ This feature is available as an add-on to Enterprise customers. Reach out to
+ [sales@liveblocks.io](mailto:sales@liveblocks.io) to enable it on your
+ account.
+
+
+_Directory sync_ helps teams manage their organization membership from a
+third-party identity provider like Google Directory or Okta. To use directory
+sync, [SSO must be configured first](/docs/platform/account-management/saml) as
+it requires an active SSO connection.
+
+## How it works
+
+When directory sync is configured, changes to your directory provider will
+automatically be synced with your team members. The previously existing
+permissions/roles will be overwritten by directory sync, including current user
+performing the sync.
+
+
+
+Make sure you’re a member of the groups from the directory provider that you’re
+syncing with Liveblocks. Otherwise, you may lock yourself out.
+
+
+
+You can configure a mapping between your directory provider’s groups and a
+Liveblocks team role. For example, your _engineers_ group on Okta can be
+configured with the _member_ role on Liveblocks, and your _admin_ group can use
+the _owner_ role.
+
+
+
+
+
+### Configuring directory sync
+
+1. To configure directory sync for your team, you must be an owner of the team
+ (see
+ [how to manage team members](/docs/platform/account-management/manage-team-members)).
+2. From your dashboard, ensure your team is selected in the scope selector.
+3. Navigate to the “Settings” tab and select “Security & Privacy”.
+4. Navigate to the “Directory sync” section.
+5. Click “Configure” and follow the walkthrough to configure directory sync for
+ your team with your directory provider of choice.
+6. Map your directory groups to appropriate Liveblocks team roles.
+
+
+
+
diff --git a/docs/pages/platform/account-management/saml.mdx b/docs/pages/platform/account-management/saml.mdx
new file mode 100644
index 00000000000..aa32c2bde6a
--- /dev/null
+++ b/docs/pages/platform/account-management/saml.mdx
@@ -0,0 +1,111 @@
+---
+meta:
+ title: "SAML Single Sign-on"
+ parentTitle: "Platform"
+ description: "Learn how to set up SSO on your Liveblocks account."
+---
+
+
+ This feature is available as an add-on to Enterprise customers. Reach out to
+ [sales@liveblocks.io](mailto:sales@liveblocks.io) to enable it on your
+ account.
+
+
+SSO enables teams to enforce authentication via identity providers like Okta,
+Azure AD, Google Workspace, or OneLogin using SAML. This simplifies account
+management, and helps organizations meet internal security requirements. Paired
+with Directory Sync, teams can manage organization membership directly from
+their identity provider, reducing manual overhead and aligning with standard
+enterprise access control practices.
+
+Once enabled, all team members will be able to log in to the dashboard using
+your selected identity provider. You’ll still need to invite users to your team
+for them to have access. If you would like all users signing up with SSO to be
+automatically added to your team, please contact us.
+
+You can also use
+[Directory Sync](/docs/platform/account-management/directory-sync) to
+automatically assign users to your team with a specific role.
+
+
+
+
+
+### Configuring SSO
+
+1. To configure SSO for your team, you must be an owner of the team (see
+ [how to manage team members](/docs/platform/account-management/manage-team-members)).
+2. From your dashboard, ensure your team is selected in the scope selector.
+3. Navigate to the “Settings” tab and select “Security & Privacy”.
+4. Navigate to the “SSO” section.
+5. Click “Configure” and follow the walkthrough to configure SSO for your team
+ with the identity provider of your choice.
+
+
+
+
+
+### Enforcing SSO
+
+For additional security, SSO can be enforced for a team so that all team members
+cannot access any team information unless their current session is authenticated
+with SSO. If you want to activate this setting, please contact us at
+[support@liveblocks.io](mailto:support@liveblocks.io).
+
+### Automatic provisioning
+
+By default, you still need to manually invite users to your team for them to
+have access to it. If you would like all users signing up with SSO to be
+automatically added to your team, please contact us at
+[support@liveblocks.io](mailto:support@liveblocks.io).
+
+### Authenticating with SSO
+
+Once you have configured SSO, your team members can use it to log in or sign up
+to Liveblocks. They only have to enter their email, and they will be redirected
+to the third-party authentication provider that you configured.
+
+### Supported providers
+
+Liveblocks supports the following third-party identity providers:
+
+- Okta
+- Entra ID (Azure AD)
+- Google SAML
+- ADP OpenID Connect
+- Auth0 SAML
+- CAS SAML
+- ClassLink SAML
+- Cloudflare SAML
+- CyberArk SAML
+- Duo SAML
+- Entra ID OpenID Connect
+- Google OpenID Connect
+- JumpCloud SAML
+- Keycloak SAML
+- LastPass SAML
+- Microsoft AD FS
+- NetIQ SAML
+- Okta OpenID Connect
+- OneLogin
+- Oracle
+- PingFederate
+- PingOne
+- Rippling
+- Salesforce
+- SimpleSAMLphp SAML
+- VMware Workspace One
+
+If your identity provider is not listed or if you'd like to use an OIDC (OpenID
+Connect) SSO provider, please reach out to us at
+[support@liveblocks.io](mailto:support@liveblocks.io).
diff --git a/docs/routes.json b/docs/routes.json
index 4fecfb5584c..9f4ea32d320 100644
--- a/docs/routes.json
+++ b/docs/routes.json
@@ -654,6 +654,14 @@
{
"title": "Manage team members",
"path": "/platform/account-management/manage-team-members"
+ },
+ {
+ "title": "SAML Single Sign-on",
+ "path": "/platform/account-management/saml"
+ },
+ {
+ "title": "Directory sync",
+ "path": "/platform/account-management/directory-sync"
}
]
},
diff --git a/e2e/next-ai-kitchen-sink/.gitignore b/e2e/next-ai-kitchen-sink/.gitignore
index 5ef6a520780..a281a739dbe 100644
--- a/e2e/next-ai-kitchen-sink/.gitignore
+++ b/e2e/next-ai-kitchen-sink/.gitignore
@@ -39,3 +39,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# playwright
+test-results/
+playwright-report/
diff --git a/e2e/next-ai-kitchen-sink/package.json b/e2e/next-ai-kitchen-sink/package.json
index bf7c52721cf..faa07a4254d 100644
--- a/e2e/next-ai-kitchen-sink/package.json
+++ b/e2e/next-ai-kitchen-sink/package.json
@@ -3,10 +3,13 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev",
+ "dev": "next dev --port 3008",
"build": "next build",
- "start": "next start",
- "lint": "next lint"
+ "start": "next start --port 3008",
+ "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"
},
"dependencies": {
"@liveblocks/client": "*",
@@ -20,6 +23,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
+ "@playwright/test": "^1.49.1",
"@tailwindcss/postcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.3.1",
diff --git a/e2e/next-ai-kitchen-sink/playwright.config.ts b/e2e/next-ai-kitchen-sink/playwright.config.ts
new file mode 100644
index 00000000000..0a678a3d082
--- /dev/null
+++ b/e2e/next-ai-kitchen-sink/playwright.config.ts
@@ -0,0 +1,71 @@
+import type { PlaywrightTestConfig } from "@playwright/test";
+import { devices } from "@playwright/test";
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// require('dotenv').config();
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+const config: PlaywrightTestConfig = {
+ testDir: "./test",
+ /* Maximum time one test can run for. */
+ timeout: 60 * 1000,
+ expect: {
+ /**
+ * Maximum time expect() should wait for the condition to be met.
+ * For example in `await expect(locator).toHaveText();`
+ */
+ timeout: 10000,
+ },
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Opt out of parallel tests on CI. */
+ workers: process.env.CI ? 4 : 6,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: "html",
+ /* 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. Defaults to 0 (no limit). */
+ actionTimeout: 0,
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ // baseURL: 'http://localhost:3000',
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: "retain-on-failure",
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ // {
+ // name: "firefox",
+ // use: { ...devices["Desktop Firefox"] },
+ // },
+ ],
+
+ /* Folder for test artifacts such as screenshots, videos, traces, etc. */
+ // outputDir: 'test-results/',
+
+ /* Run your local dev server before starting the tests */
+ webServer: {
+ command: process.env.CI
+ ? "npm run start" // Test production builds on CI
+ : "npm run dev", // Test dev builds on CI (with React StrictMode enabled)
+ port: 3008, // AI kitchen sink port
+ reuseExistingServer: !process.env.CI,
+ },
+};
+
+export default config;
diff --git a/e2e/next-ai-kitchen-sink/test/concurrent-operations.test.ts b/e2e/next-ai-kitchen-sink/test/concurrent-operations.test.ts
new file mode 100644
index 00000000000..77a7bef09e7
--- /dev/null
+++ b/e2e/next-ai-kitchen-sink/test/concurrent-operations.test.ts
@@ -0,0 +1,219 @@
+import { test, expect } from "@playwright/test";
+import type { Page } from "@playwright/test";
+
+async function setupChatComposer(page: Page) {
+ // Wait for the chat interface to load
+ await expect(page.locator('[role="textbox"]')).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Find the text input and send button
+ const textInput = page.locator('[role="textbox"]');
+ const sendButton = page.locator('button[aria-label="Send"]');
+
+ // If there's an ongoing operation (abort button is enabled), click it first to clear state
+ const abortButton = page.locator('button[aria-label="Abort"]');
+ if (await abortButton.isVisible()) {
+ await abortButton.click();
+ // Wait for it to return to send state
+ await expect(sendButton).toBeVisible();
+ }
+
+ // Clear any existing text
+ await textInput.clear();
+
+ return { textInput, sendButton };
+}
+
+async function sendMessage(page: Page, message: string) {
+ const { textInput, sendButton } = await setupChatComposer(page);
+
+ await textInput.fill(message);
+ await expect(sendButton).toBeEnabled({ timeout: 5000 });
+ await sendButton.click();
+
+ // Verify the message was sent (appears in the chat)
+ await expect(page.locator(`text=${message}`)).toBeVisible();
+}
+
+test.describe("Concurrent Operations", () => {
+ test("should handle rapid message sending without conflicts", async ({
+ page,
+ }) => {
+ // Go to chats page and create a new chat
+ await page.goto("/chats");
+ await expect(page.locator("h1")).toHaveText("List of all chats");
+
+ // Create a new chat
+ await page.click('button:has-text("Start a new AI chat")');
+ await expect(page).toHaveURL(/\/chats\/[a-zA-Z0-9_-]+/);
+
+ const chatUrl = page.url();
+ const chatId = chatUrl.split("/").pop();
+
+ // Send first message
+ await sendMessage(page, "What is 2+2?");
+
+ // Immediately try to send a second message while first is processing
+ // This should either be queued or the first should be aborted
+ await sendMessage(page, "What is 3+3?");
+
+ // Try to send a third message rapidly
+ await sendMessage(page, "What is 4+4?");
+
+ // Wait for the interface to stabilize
+ await page.waitForTimeout(2000);
+
+ // Verify all user messages are present
+ await expect(page.locator("text=What is 2+2?")).toBeVisible();
+ await expect(page.locator("text=What is 3+3?")).toBeVisible();
+ await expect(page.locator("text=What is 4+4?")).toBeVisible();
+
+ // Wait for at least one assistant response (the system should handle the concurrent requests gracefully)
+ await page.waitForFunction(
+ () => {
+ const messages = document.querySelectorAll("p");
+ // Should have at least 4 messages: 3 user + 1 assistant response
+ return messages.length >= 4;
+ },
+ { timeout: 30000 }
+ );
+
+ // Verify that the final state is stable (no ongoing operations)
+ const sendButton = page.locator('button[aria-label="Send"]');
+ const abortButton = page.locator('button[aria-label="Abort"]');
+
+ await expect(sendButton).toBeVisible();
+ await expect(abortButton).not.toBeVisible();
+
+ // Clean up: delete the test chat
+ await page.goto("/chats");
+ const deleteButton = page
+ .locator(`a[href="/chats/${chatId}"]`)
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+
+ // Verify the chat is deleted
+ await expect(page.locator(`a[href="/chats/${chatId}"]`)).not.toBeVisible();
+ });
+
+ test("should handle abort during rapid message sending", async ({ page }) => {
+ // Go to chats page and create a new chat
+ await page.goto("/chats");
+ await page.click('button:has-text("Start a new AI chat")');
+ await expect(page).toHaveURL(/\/chats\/[a-zA-Z0-9_-]+/);
+
+ const chatUrl = page.url();
+ const chatId = chatUrl.split("/").pop();
+
+ // Send a message that should take some time to process
+ await sendMessage(
+ page,
+ "Write a detailed explanation of quantum mechanics with examples."
+ );
+
+ // Wait for abort button to appear
+ const abortButton = page.locator('button[aria-label="Abort"]');
+ await expect(abortButton).toBeVisible({ timeout: 5000 });
+
+ // Click abort
+ await abortButton.click();
+
+ // Verify we're back to send state
+ const sendButton = page.locator('button[aria-label="Send"]');
+ await expect(sendButton).toBeVisible({ timeout: 5000 });
+
+ // Immediately send another message
+ await sendMessage(page, "What is the capital of France?");
+
+ // Wait for this new response
+ await page.waitForFunction(
+ () => {
+ const messages = document.querySelectorAll("p");
+ // Should have at least 3 messages: 2 user + 1 assistant response
+ return messages.length >= 3;
+ },
+ { timeout: 30000 }
+ );
+
+ // Verify both user messages are present
+ await expect(
+ page.locator("text=Write a detailed explanation")
+ ).toBeVisible();
+ await expect(
+ page.locator("text=What is the capital of France?")
+ ).toBeVisible();
+
+ // Verify the system is in a stable state
+ await expect(sendButton).toBeVisible();
+ await expect(abortButton).not.toBeVisible();
+
+ // Clean up: delete the test chat
+ await page.goto("/chats");
+ const deleteButton = page
+ .locator(`a[href="/chats/${chatId}"]`)
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(page.locator(`a[href="/chats/${chatId}"]`)).not.toBeVisible();
+ });
+
+ test("should handle empty messages and text input validation", async ({
+ page,
+ }) => {
+ // Go to chats page and create a new chat
+ await page.goto("/chats");
+ await page.click('button:has-text("Start a new AI chat")');
+ await expect(page).toHaveURL(/\/chats\/[a-zA-Z0-9_-]+/);
+
+ const chatUrl = page.url();
+ const chatId = chatUrl.split("/").pop();
+
+ const { textInput, sendButton } = await setupChatComposer(page);
+
+ // Initially the button might be enabled or disabled - let's check what happens when we try to send empty
+ const initialButtonState = await sendButton.isEnabled();
+
+ if (initialButtonState) {
+ // If button is enabled with empty text, clicking it should not send a message
+ await sendButton.click();
+
+ // Wait a moment and verify no message was added to the chat
+ await page.waitForTimeout(1000);
+ const messageCount = await page.locator("p").count();
+ expect(messageCount).toBe(0); // No messages should be present
+ }
+
+ // Add some text - button should be enabled
+ await textInput.fill("Hello world!");
+ await expect(sendButton).toBeEnabled();
+ await sendButton.click();
+
+ // Verify the message was sent
+ await expect(page.locator("text=Hello world!")).toBeVisible();
+
+ // Try sending whitespace-only message
+ await textInput.clear();
+ await textInput.fill(" ");
+
+ // Even if button is enabled, clicking shouldn't send whitespace-only message
+ if (await sendButton.isEnabled()) {
+ await sendButton.click();
+ await page.waitForTimeout(1000);
+
+ // Should still only have 1 user message (the "Hello world!" one)
+ const userMessages = await page.locator("p").count();
+ expect(userMessages).toBeLessThanOrEqual(2); // 1 user message + possibly 1 assistant response
+ }
+
+ // Clean up: delete the test chat
+ await page.goto("/chats");
+ const deleteButton = page
+ .locator(`a[href="/chats/${chatId}"]`)
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(page.locator(`a[href="/chats/${chatId}"]`)).not.toBeVisible();
+ });
+});
diff --git a/e2e/next-ai-kitchen-sink/test/knowledge-simple.test.ts b/e2e/next-ai-kitchen-sink/test/knowledge-simple.test.ts
new file mode 100644
index 00000000000..00065a0d069
--- /dev/null
+++ b/e2e/next-ai-kitchen-sink/test/knowledge-simple.test.ts
@@ -0,0 +1,76 @@
+import { test, expect } from "@playwright/test";
+
+test.describe("Knowledge Registration - Simple", () => {
+ test("should open knowledge page and interact with AI chat", async ({
+ page,
+ }) => {
+ // Clean up first - delete the knowledge chat if it exists
+ await page.goto("/chats");
+ await expect(page.locator("h1")).toHaveText("List of all chats");
+
+ const knowledgeChatLink = page.locator('a[href="/chats/todo125"]');
+ if (await knowledgeChatLink.isVisible()) {
+ const deleteButton = knowledgeChatLink
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(knowledgeChatLink).not.toBeVisible();
+ }
+
+ // Go to knowledge page
+ await page.goto("/knowledge");
+
+ // Wait for the page to load - verify default tab is "Todo app"
+ await expect(
+ page.locator('button.font-bold:has-text("Todo app")')
+ ).toBeVisible();
+
+ // 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();
+
+ // The knowledge page has the chat trigger in the bottom right corner
+ const triggerButton = page.locator(".fixed.bottom-8.right-8 button");
+ await expect(triggerButton).toBeVisible();
+
+ // Check if popover is already open
+ const dataState = await triggerButton.getAttribute("data-state");
+ if (dataState !== "open") {
+ await triggerButton.click();
+ await page.waitForTimeout(1000); // Give popover time to open
+ }
+
+ // Wait for the AI chat popover to be visible
+ await expect(page.locator(".lb-ai-chat-composer")).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Find the text input and send button
+ const textInput = page.locator(".lb-ai-chat-composer-editor");
+ const sendButton = page.locator(".lb-ai-chat-composer-action");
+
+ // If there's an ongoing operation (abort button is enabled), click it first to clear state
+ const buttonVariant = await sendButton.getAttribute("data-variant");
+ if (buttonVariant === "secondary") {
+ await sendButton.click();
+ // Wait for it to return to send state
+ await expect(sendButton).toHaveAttribute("data-variant", "primary");
+ }
+
+ // Clear any existing text and send a simple message
+ await textInput.clear();
+ await textInput.fill("What is the current view in the app?");
+ await sendButton.click({ timeout: 5000 });
+
+ // Verify the message was sent (appears in the chat) - use first() to handle duplicate messages
+ await expect(
+ page.locator("text=What is the current view in the app?").first()
+ ).toBeVisible();
+
+ // Wait for any AI response to appear
+ await page.waitForTimeout(10000);
+
+ console.log("Test completed successfully");
+ });
+});
diff --git a/e2e/next-ai-kitchen-sink/test/knowledge.test.ts b/e2e/next-ai-kitchen-sink/test/knowledge.test.ts
new file mode 100644
index 00000000000..963e8df7f1c
--- /dev/null
+++ b/e2e/next-ai-kitchen-sink/test/knowledge.test.ts
@@ -0,0 +1,294 @@
+import { test, expect } from "@playwright/test";
+import type { Page } from "@playwright/test";
+
+async function setupAiChat(page: Page) {
+ // The knowledge page has the chat trigger in the bottom right corner
+ const triggerButton = page.locator(".fixed.bottom-8.right-8 button");
+
+ // Check if popover is already open
+ const isOpen = (await triggerButton.getAttribute("data-state")) === "open";
+ if (!isOpen) {
+ await triggerButton.click();
+ }
+
+ // Wait for the AI chat popover to be visible
+ await expect(page.locator(".lb-ai-chat-composer")).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Find the text input and send button
+ const textInput = page.locator(".lb-ai-chat-composer-editor");
+ const sendButton = page.locator(".lb-ai-chat-composer-action");
+
+ // If there's an ongoing operation (abort button is enabled), click it first to clear state
+ if ((await sendButton.getAttribute("data-variant")) === "secondary") {
+ await sendButton.click();
+ // Wait for it to return to send state
+ await expect(sendButton).toHaveAttribute("data-variant", "primary");
+ }
+
+ // Clear any existing text
+ await textInput.clear();
+
+ return { textInput, sendButton };
+}
+
+async function sendAiMessage(page: Page, message: string) {
+ const { textInput, sendButton } = await setupAiChat(page);
+
+ await textInput.fill(message);
+ await sendButton.click({ timeout: 5000 });
+
+ // Verify the message was sent (appears in the chat)
+ await expect(page.locator(`text=${message}`)).toBeVisible();
+}
+
+test.describe("Knowledge Registration", () => {
+ test("should use registered knowledge about current view and todos", async ({
+ page,
+ }) => {
+ // Clean up - delete the knowledge chat to start fresh
+ await page.goto("/chats");
+ await expect(page.locator("h1")).toHaveText("List of all chats");
+
+ const knowledgeChatLink = page.locator('a[href="/chats/todo125"]');
+ if (await knowledgeChatLink.isVisible()) {
+ const deleteButton = knowledgeChatLink
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(knowledgeChatLink).not.toBeVisible();
+ }
+
+ // Go to knowledge page
+ await page.goto("/knowledge");
+
+ // Wait for the page to load - verify default tab is "Todo app"
+ await expect(
+ page.locator('button.font-bold:has-text("Todo app")')
+ ).toBeVisible();
+
+ // 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();
+
+ // 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?");
+
+ // Wait for AI response that should mention the todo view
+ await expect(
+ page.locator("text=Todo").or(page.locator("text=todo"))
+ ).toBeVisible({ timeout: 30000 });
+
+ // 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 }) => {
+ // Clean up first
+ await page.goto("/chats");
+ const knowledgeChatLink = page.locator('a[href="/chats/todo125"]');
+ if (await knowledgeChatLink.isVisible()) {
+ const deleteButton = knowledgeChatLink
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(knowledgeChatLink).not.toBeVisible();
+ }
+
+ await page.goto("/knowledge");
+
+ // Find the nickname checkbox specifically by its label text
+ const nicknameCheckbox = page.locator(
+ 'label:has-text("Share my nickname") input[type="checkbox"]'
+ );
+
+ // Check its initial state (might be checked from previous test)
+ const isChecked = await nicknameCheckbox.isChecked();
+ if (isChecked) {
+ await nicknameCheckbox.uncheck();
+ }
+ await expect(nicknameCheckbox).not.toBeChecked();
+
+ // Ask AI about my name - it shouldn't know it
+ await sendAiMessage(page, "What is my name or nickname?");
+ await page.waitForTimeout(10000);
+
+ // Now enable nickname sharing
+ await nicknameCheckbox.check();
+ await expect(nicknameCheckbox).toBeChecked();
+
+ // Ask AI again about my name - now it should know "nvie"
+ await sendAiMessage(page, "What is my nickname?");
+
+ // Wait for AI response that should include "nvie"
+ await expect(page.locator("text=nvie")).toBeVisible({ timeout: 30000 });
+ });
+
+ test("should use dark mode knowledge and tool", async ({ page }) => {
+ // Clean up first
+ await page.goto("/chats");
+ const knowledgeChatLink = page.locator('a[href="/chats/todo125"]');
+ if (await knowledgeChatLink.isVisible()) {
+ const deleteButton = knowledgeChatLink
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(knowledgeChatLink).not.toBeVisible();
+ }
+
+ await page.goto("/knowledge");
+
+ // Find the expose dark mode checkbox by its label text
+ const exposeCheckbox = page.locator(
+ 'label:has-text("Expose dark mode as knowledge & tool") input[type="checkbox"]'
+ );
+
+ // Ensure it's checked (default state according to code)
+ const isChecked = await exposeCheckbox.isChecked();
+ if (!isChecked) {
+ await exposeCheckbox.check();
+ }
+ await expect(exposeCheckbox).toBeChecked();
+
+ // Find the dark mode toggle checkbox (it has the sun/moon emoji in the label)
+ const darkModeCheckbox = page
+ .locator("label")
+ .filter({ hasText: /Dark mode [☀️🌙]/ })
+ .locator('input[type="checkbox"]');
+
+ // Ensure we start in light mode
+ const isDarkMode = await darkModeCheckbox.isChecked();
+ if (isDarkMode) {
+ await darkModeCheckbox.uncheck();
+ }
+ await expect(darkModeCheckbox).not.toBeChecked();
+ await expect(page.locator("text=☀️")).toBeVisible();
+
+ // Ask AI about current mode
+ await sendAiMessage(page, "What is the current dark mode setting?");
+
+ // Wait for AI response about light mode
+ await expect(
+ page.locator("text=light").or(page.locator("text=Light"))
+ ).toBeVisible({ timeout: 30000 });
+
+ // Ask AI to change to dark mode using the tool
+ await sendAiMessage(page, "Change the app to dark mode");
+
+ // Wait for the tool execution and verify dark mode is now enabled
+ await page.waitForTimeout(10000);
+ await expect(page.locator("text=🌙")).toBeVisible({ timeout: 15000 });
+ await expect(darkModeCheckbox).toBeChecked();
+ });
+
+ test("should update knowledge when switching between tabs", async ({
+ page,
+ }) => {
+ // Clean up first
+ await page.goto("/chats");
+ const knowledgeChatLink = page.locator('a[href="/chats/todo125"]');
+ if (await knowledgeChatLink.isVisible()) {
+ const deleteButton = knowledgeChatLink
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(knowledgeChatLink).not.toBeVisible();
+ }
+
+ await page.goto("/knowledge");
+
+ // Start on Todo app tab
+ await expect(
+ page.locator('button.font-bold:has-text("Todo app")')
+ ).toBeVisible();
+
+ // Ask about current view
+ await sendAiMessage(page, "What view am I currently in?");
+ await expect(
+ page.locator("text=Todo").or(page.locator("text=todo"))
+ ).toBeVisible({ timeout: 30000 });
+
+ // Switch to "Another app" tab
+ await page.click('button:has-text("Another app")');
+ await expect(
+ page.locator('button.font-bold:has-text("Another app")')
+ ).toBeVisible();
+ await expect(page.locator("text=Another part of the app")).toBeVisible();
+
+ // Ask about current view again - should now know it's "Another app"
+ await sendAiMessage(page, "What is my current view now?");
+ // Look for "Another" in the AI chat response, not in the UI
+ await expect(
+ page
+ .locator(".lb-ai-chat-messages")
+ .locator("text=Another")
+ .or(page.locator(".lb-ai-chat-messages").locator("text=another"))
+ ).toBeVisible({ timeout: 30000 });
+
+ // Switch to "Both" tab
+ await page.click('button:has-text("Both")');
+ await expect(
+ page.locator('button.font-bold:has-text("Both")')
+ ).toBeVisible();
+
+ // Ask about current view - should know it's "Both apps"
+ await sendAiMessage(page, "What view am I in now?");
+ // Look for "Both" in the AI chat response, not in the UI
+ await expect(
+ page
+ .locator(".lb-ai-chat-messages")
+ .locator("text=Both")
+ .or(page.locator(".lb-ai-chat-messages").locator("text=both"))
+ ).toBeVisible({ timeout: 30000 });
+ });
+
+ test("should handle knowledge being disabled", async ({ page }) => {
+ // Clean up first
+ await page.goto("/chats");
+ const knowledgeChatLink = page.locator('a[href="/chats/todo125"]');
+ if (await knowledgeChatLink.isVisible()) {
+ const deleteButton = knowledgeChatLink
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(knowledgeChatLink).not.toBeVisible();
+ }
+
+ await page.goto("/knowledge");
+
+ // Find and disable dark mode knowledge exposure
+ const exposeCheckbox = page.locator(
+ 'label:has-text("Expose dark mode as knowledge & tool") input[type="checkbox"]'
+ );
+ await exposeCheckbox.uncheck();
+ await expect(exposeCheckbox).not.toBeChecked();
+
+ // Ask AI about dark mode - it should not have access to this knowledge
+ await sendAiMessage(page, "What is the current dark mode setting?");
+
+ // Wait for response - AI should indicate it doesn't know or can't access this info
+ await page.waitForTimeout(10000);
+
+ // Try to use the dark mode tool - it should not be available
+ await sendAiMessage(page, "Change to dark mode");
+
+ // Wait for response - AI should indicate it can't perform this action
+ await page.waitForTimeout(10000);
+
+ // Verify dark mode checkbox is still unchecked (tool didn't work)
+ const darkModeCheckbox = page
+ .locator("label")
+ .filter({ hasText: /Dark mode [☀️🌙]/ })
+ .locator('input[type="checkbox"]');
+ await expect(darkModeCheckbox).not.toBeChecked();
+ });
+});
diff --git a/e2e/next-ai-kitchen-sink/test/multi-chat.test.ts b/e2e/next-ai-kitchen-sink/test/multi-chat.test.ts
new file mode 100644
index 00000000000..5ff3803e6b5
--- /dev/null
+++ b/e2e/next-ai-kitchen-sink/test/multi-chat.test.ts
@@ -0,0 +1,151 @@
+import { test, expect } from "@playwright/test";
+import type { Page } from "@playwright/test";
+
+async function setupChatComposer(page: Page) {
+ // Wait for the chat interface to load (based on the AiChatComposer structure)
+ await expect(page.locator('[role="textbox"]')).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Find the text input (contenteditable div) and send button
+ const textInput = page.locator('[role="textbox"]');
+ const sendButton = page.locator('button[aria-label="Send"]');
+
+ // If there's an ongoing operation (abort button is enabled), click it first to clear state
+ const abortButton = page.locator('button[aria-label="Abort"]');
+ if (await abortButton.isVisible()) {
+ await abortButton.click();
+ // Wait for it to return to send state
+ await expect(sendButton).toBeVisible();
+ }
+
+ // Clear any existing text
+ await textInput.clear();
+
+ return { textInput, sendButton };
+}
+
+test.describe("Multi Chat", () => {
+ test("should create multiple chats, get responses, auto-generate titles, and cleanup", async ({
+ page,
+ }) => {
+ // Start at the chats index page
+ await page.goto("/chats");
+
+ // Wait for the page to load
+ await expect(page.locator("h1")).toHaveText("List of all chats");
+
+ // Create first chat
+ await page.click('button:has-text("Start a new AI chat")');
+
+ // We should be redirected to a new chat page
+ await expect(page).toHaveURL(/\/chats\/[a-zA-Z0-9_-]+/);
+ const firstChatUrl = page.url();
+ const firstChatId = firstChatUrl.split("/").pop();
+
+ // Send a message in the first chat
+ const { textInput: firstTextInput, sendButton: firstSendButton } =
+ await setupChatComposer(page);
+ await firstTextInput.fill("What is machine learning?");
+ await firstSendButton.click();
+
+ // Verify the message was sent (optimistically created)
+ await expect(page.locator("text=What is machine learning?")).toBeVisible();
+
+ // Go back to chats index and create second chat
+ await page.goto("/chats");
+ await page.click('button:has-text("Start a new AI chat")');
+
+ // We should be redirected to another new chat page
+ await expect(page).toHaveURL(/\/chats\/[a-zA-Z0-9_-]+/);
+ const secondChatUrl = page.url();
+ const secondChatId = secondChatUrl.split("/").pop();
+
+ // Ensure it's a different chat
+ expect(secondChatId).not.toBe(firstChatId);
+
+ // Send a message in the second chat
+ const { textInput: secondTextInput, sendButton: secondSendButton } =
+ await setupChatComposer(page);
+ await secondTextInput.fill("Explain quantum computing.");
+ await secondSendButton.click();
+
+ // Verify the message was sent
+ await expect(page.locator("text=Explain quantum computing.")).toBeVisible();
+
+ // Go back to first chat and wait for assistant response
+ await page.goto(firstChatUrl);
+
+ // Wait for assistant response in first chat (should have at least 2 messages - user + assistant)
+ await expect(page.locator('[role="textbox"]')).toBeVisible(); // Wait for page to load
+ await page.waitForFunction(
+ () => {
+ const messages = document.querySelectorAll("p"); // Messages are in
tags
+ return messages.length >= 2; // At least user message + assistant response
+ },
+ { timeout: 30000 }
+ );
+
+ // Go back to second chat and wait for assistant response
+ await page.goto(secondChatUrl);
+
+ // Wait for assistant response in second chat
+ await expect(page.locator('[role="textbox"]')).toBeVisible(); // Wait for page to load
+ await page.waitForFunction(
+ () => {
+ const messages = document.querySelectorAll("p");
+ return messages.length >= 2; // At least user message + assistant response
+ },
+ { timeout: 30000 }
+ );
+
+ // Go back to index page to check auto-generated titles
+ await page.goto("/chats");
+
+ // Wait for titles to be generated (they should no longer be "Untitled")
+ // Look for links that are not "Untitled" and contain our chat IDs
+ await expect(
+ page
+ .locator(`a[href="/chats/${firstChatId}"]`)
+ .filter({ hasNotText: "Untitled" })
+ ).toBeVisible({ timeout: 30000 });
+ await expect(
+ page
+ .locator(`a[href="/chats/${secondChatId}"]`)
+ .filter({ hasNotText: "Untitled" })
+ ).toBeVisible({ timeout: 30000 });
+
+ // Verify the titles are related to the content
+ const firstChatLink = page.locator(`a[href="/chats/${firstChatId}"]`);
+ const secondChatLink = page.locator(`a[href="/chats/${secondChatId}"]`);
+
+ const firstTitle = await firstChatLink.textContent();
+ const secondTitle = await secondChatLink.textContent();
+
+ // Titles should be meaningful and related to the questions asked
+ expect(firstTitle?.toLowerCase()).toMatch(/machine|learning|ml/);
+ expect(secondTitle?.toLowerCase()).toMatch(/quantum|computing/);
+
+ // Clean up: delete both chats
+ // Find and click delete buttons for our chats
+ const firstDeleteButton = page
+ .locator(`a[href="/chats/${firstChatId}"]`)
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ const secondDeleteButton = page
+ .locator(`a[href="/chats/${secondChatId}"]`)
+ .locator("..")
+ .locator('button:has-text("Delete")');
+
+ await firstDeleteButton.click();
+ await secondDeleteButton.click();
+
+ // Verify the chats are no longer in the list
+ await expect(
+ page.locator(`a[href="/chats/${firstChatId}"]`)
+ ).not.toBeVisible();
+ await expect(
+ page.locator(`a[href="/chats/${secondChatId}"]`)
+ ).not.toBeVisible();
+ });
+});
diff --git a/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts b/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts
new file mode 100644
index 00000000000..501e8f8bca8
--- /dev/null
+++ b/e2e/next-ai-kitchen-sink/test/simple-chat.test.ts
@@ -0,0 +1,107 @@
+import { test, expect } from "@playwright/test";
+import type { Page } from "@playwright/test";
+
+async function setupSimpleChat(page: Page) {
+ await page.goto("/simple");
+
+ // Wait for the chat interface to load
+ await expect(page.locator(".lb-ai-chat-composer")).toBeVisible();
+
+ // Find the text input and send button
+ const textInput = page.locator(".lb-ai-chat-composer-editor");
+ const sendButton = page.locator(".lb-ai-chat-composer-action");
+
+ // If there's an ongoing operation (abort button is enabled), click it first to clear state
+ if (
+ (await sendButton.isEnabled()) &&
+ (await sendButton.getAttribute("data-variant")) === "secondary"
+ ) {
+ await sendButton.click();
+ // Wait for it to return to send state
+ await expect(sendButton).toHaveAttribute("data-variant", "primary");
+ }
+
+ // Clear any existing text
+ await textInput.clear();
+
+ return { textInput, sendButton };
+}
+
+test.describe("Simple Chat", () => {
+ test("should handle ping-pong interaction", async ({ page }) => {
+ const { textInput, sendButton } = await setupSimpleChat(page);
+
+ // 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 have StopIcon and different data-variant
+ await expect(sendButton).toHaveAttribute("data-variant", "secondary");
+
+ // Wait for the send button to become enabled again (back to primary variant)
+ await expect(sendButton).toHaveAttribute("data-variant", "primary", {
+ timeout: 5000, // Give it up to 5 seconds for the AI 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: 5000 });
+
+ // Check if it contains "pong"
+ await expect(assistantMessage).toContainText("pong", {
+ timeout: 5000,
+ });
+ });
+
+ test("should abort AI response when abort button is clicked", async ({
+ page,
+ }) => {
+ const { textInput, sendButton } = await setupSimpleChat(page);
+
+ // 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: 5000 });
+ await sendButton.click();
+
+ // Verify the button changes to abort state (secondary variant)
+ await expect(sendButton).toHaveAttribute("data-variant", "secondary");
+ await expect(sendButton).toHaveAttribute("aria-label", "Abort response");
+
+ // Click the abort button while the AI is generating
+ await sendButton.click();
+
+ // Verify the button goes back to send state (primary variant)
+ await expect(sendButton).toHaveAttribute("data-variant", "primary", {
+ timeout: 5000,
+ });
+ await expect(sendButton).toHaveAttribute("aria-label", "Send");
+
+ // 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 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();
+
+ // 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
new file mode 100644
index 00000000000..8aba9ff119a
--- /dev/null
+++ b/e2e/next-ai-kitchen-sink/test/tool-calling.test.ts
@@ -0,0 +1,227 @@
+import { test, expect } from "@playwright/test";
+import type { Page } from "@playwright/test";
+
+async function setupAiChat(page: Page) {
+ // Wait for the AI chat popover to be visible
+ await expect(page.locator(".lb-ai-chat-composer")).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Find the text input and send button in the AI chat
+ const textInput = page.locator(".lb-ai-chat-composer-editor");
+ const sendButton = page.locator(".lb-ai-chat-composer-action");
+
+ // If there's an ongoing operation (abort button is enabled), click it first to clear state
+ if ((await sendButton.getAttribute("data-variant")) === "secondary") {
+ await sendButton.click();
+ // Wait for it to return to send state
+ await expect(sendButton).toHaveAttribute("data-variant", "primary");
+ }
+
+ // Clear any existing text
+ await textInput.clear();
+
+ return { textInput, sendButton };
+}
+
+async function sendAiMessage(page: Page, message: string) {
+ const { textInput, sendButton } = await setupAiChat(page);
+
+ await textInput.fill(message);
+ await expect(sendButton).toBeEnabled({ timeout: 5000 });
+ await sendButton.click();
+
+ // Verify the message was sent (appears in the chat)
+ await expect(page.locator(`text=${message}`)).toBeVisible();
+}
+
+test.describe("Tool Calling", () => {
+ test("should perform todo operations via AI tool calls", async ({ page }) => {
+ // Step 1: Clean up - delete the "todo" chat to start fresh
+ await page.goto("/chats");
+ await expect(page.locator("h1")).toHaveText("List of all chats");
+
+ // Look for the "todo" chat and delete it if it exists
+ const todoChatLink = page.locator('a[href="/chats/todo"]');
+ if (await todoChatLink.isVisible()) {
+ const deleteButton = todoChatLink
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ // Wait for it to be deleted
+ await expect(todoChatLink).not.toBeVisible();
+ }
+
+ // Step 2: Go to the todo page
+ await page.goto("/todo");
+
+ // 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();
+
+ // 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();
+
+ // 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");
+
+ // 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 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'");
+
+ // 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"
+ );
+ }
+
+ // 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();
+ });
+
+ test("should handle tool call errors gracefully", async ({ page }) => {
+ // Clean up first
+ await page.goto("/chats");
+ const todoChatLink = page.locator('a[href="/chats/todo"]');
+ if (await todoChatLink.isVisible()) {
+ const deleteButton = todoChatLink
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(todoChatLink).not.toBeVisible();
+ }
+
+ await page.goto("/todo");
+
+ // Try to toggle a non-existent todo
+ await sendAiMessage(page, "Toggle the todo with ID 99999");
+
+ // The AI should handle this gracefully, either by explaining the todo doesn't exist
+ // or by attempting the operation and reporting no changes
+ await page.waitForTimeout(10000); // Wait for AI response
+
+ // Verify the existing todos are unchanged in the main list (use more specific selectors)
+ 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();
+ });
+
+ test("should handle cancellation of delete confirmation", async ({
+ page,
+ }) => {
+ // Clean up first
+ await page.goto("/chats");
+ const todoChatLink = page.locator('a[href="/chats/todo"]');
+ if (await todoChatLink.isVisible()) {
+ const deleteButton = todoChatLink
+ .locator("..")
+ .locator('button:has-text("Delete")');
+ await deleteButton.click();
+ await expect(todoChatLink).not.toBeVisible();
+ }
+
+ await page.goto("/todo");
+
+ // Add a test todo
+ const todoInput = page.locator('input[placeholder="Add a todo"]');
+ await todoInput.fill("Test cancellation item");
+ await todoInput.press("Enter");
+ await expect(
+ page.locator('ul li:has-text("Test cancellation item")')
+ ).toBeVisible();
+
+ // Ask AI to delete it
+ await sendAiMessage(page, "Delete the todo 'Test cancellation item'");
+
+ // Wait for confirmation dialog
+ await expect(page.locator("text=Okay to delete?")).toBeVisible({
+ timeout: 10000,
+ });
+
+ // Click Cancel/No instead of Confirm
+ const cancelButton = page
+ .locator('button:has-text("Cancel")')
+ .or(page.locator('button:has-text("No")'));
+ await cancelButton.click();
+
+ // Verify the item is still there
+ await expect(
+ page.locator('ul li:has-text("Test cancellation item")')
+ ).toBeVisible();
+
+ // Verify the AI shows the request was denied
+ await expect(
+ page.locator("text=denied").or(page.locator("text=cancelled"))
+ ).toBeVisible({ timeout: 10000 });
+ });
+});
diff --git a/e2e/next-ai-kitchen-sink/turbo.json b/e2e/next-ai-kitchen-sink/turbo.json
new file mode 100644
index 00000000000..d1edffbd5a5
--- /dev/null
+++ b/e2e/next-ai-kitchen-sink/turbo.json
@@ -0,0 +1,9 @@
+{
+ "extends": ["//"],
+ "tasks": {
+ "test": {
+ "dependsOn": ["build"],
+ "cache": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 1fbbe8b0687..fe92ce55177 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45,6 +45,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3",
+ "@playwright/test": "^1.49.1",
"@tailwindcss/postcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.3.1",