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. + +
+ Liveblocks team security settings +
+ +### 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. + +
+ Liveblocks team security settings +
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. + +
+ Liveblocks team security settings +
+ +### 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. + +
+ Liveblocks team security settings +
+ +### 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",