diff --git a/packages/embed/src/index.ts b/packages/embed/src/index.ts index 4f1cce44fa..cbfb347dcd 100644 --- a/packages/embed/src/index.ts +++ b/packages/embed/src/index.ts @@ -1 +1,2 @@ export * from './client'; +export * from './standalone'; diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 5bbc27561c..72ee4f9184 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -9,7 +9,8 @@ import { CustomizationSidebarListStyle, CustomizationThemeMode, } from '@gitbook/api'; -import { expect } from '@playwright/test'; +import type { GitBookStandalone } from '@gitbook/embed'; +import { type FrameLocator, type Locator, expect, test } from '@playwright/test'; import jwt from 'jsonwebtoken'; import { @@ -40,7 +41,7 @@ import { const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`; const overrideAIInitialState = () => { - const greeting = document.querySelector('[data-testid="ai-chat-time-greeting"]'); + const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]'); if (greeting) { greeting.textContent = 'Good morning'; } @@ -62,6 +63,43 @@ const overrideAIResponse = () => { }); }; +const CLOUDFLARE_TEXTS = [ + 'Checking if the site connection is secure', + 'Please stand by, while we are checking your browser', + 'DDoS protection by Cloudflare', + 'Just a moment...', +]; + +async function isCloudflareChallenge(iframe: FrameLocator) { + for (const text of CLOUDFLARE_TEXTS) { + const visible = await iframe + .getByText(text, { exact: false }) + .first() + .isVisible({ timeout: 500 }) + .catch(() => false); + if (visible) { + return true; + } + } + return false; +} + +async function waitForEmbedVisible(iframe: FrameLocator, locator: Locator, timeout = 30_000) { + if (await isCloudflareChallenge(iframe)) { + test.skip(true, 'Cloudflare challenge detected in embed iframe'); + } + try { + await locator.waitFor({ state: 'visible', timeout }); + } catch (error) { + if (await isCloudflareChallenge(iframe)) { + test.skip(true, 'Cloudflare challenge detected in embed iframe'); + } + throw error; + } + await expect(locator).toBeVisible({ timeout }); + return locator; +} + const searchTestCases: Test[] = [ { name: 'Search - AI Mode: None - Complete flow', @@ -1946,6 +1984,320 @@ const testCases: TestsCase[] = [ ]), ], }, + { + name: 'Docs Embed - GitBook (Assistant + Docs)', + contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/', + tests: [ + { + name: 'Basic functionality', + url: '', + run: async (page) => { + const button = page.locator('#gitbook-widget-button'); + await expect(button).toBeVisible(); + await expect(button.locator('#gitbook-widget-button-label')).toHaveText('Ask'); + await expect(button.locator('#gitbook-widget-button-icon')).toHaveAttribute( + 'data-icon', + 'assistant' + ); + + await expect(page.locator('#gitbook-widget-window')).toBeVisible(); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await expect(iframe.getByTestId('embed-tab-assistant')).toBeVisible(); + await expect(iframe.getByTestId('embed-tab-docs')).toBeVisible(); + + await button.click(); // Toggle the window off + await expect(page.locator('#gitbook-widget-window')).not.toBeVisible(); + await button.click(); // Toggle the window on + await expect(page.locator('#gitbook-widget-window')).toBeVisible(); + + await iframe.owner().evaluate(overrideAIInitialState); + }, + }, + { + name: 'Switch between tabs', + url: '', + run: async (page) => { + await expect(page.locator('#gitbook-widget-window')).toBeVisible(); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await iframe.getByTestId('embed-tab-docs').click(); // Switch to docs tab + await waitForEmbedVisible(iframe, iframe.getByTestId('embed-docs-page')); + + await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab + await waitForEmbedVisible(iframe, iframe.getByTestId('ai-chat')); + + await iframe.owner().evaluate(overrideAIInitialState); + }, + }, + { + name: 'API - navigateToPage', + url: '', + run: async (page) => { + await page.evaluate(() => { + const GitBook = window.GitBook as unknown as GitBookStandalone; + GitBook('navigateToPage', '/getting-started/quickstart'); + }); + await expect(page.locator('#gitbook-widget-window')).toBeVisible(); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await waitForEmbedVisible(iframe, iframe.getByTestId('embed-docs-page')); + await expect(iframe.owner()).toHaveAttribute( + 'src', + expect.stringContaining('getting-started/quickstart') + ); + }, + }, + { + name: 'API - postUserMessage', + url: '', + run: async (page) => { + await page.evaluate((aiPrompt) => { + const GitBook = window.GitBook as unknown as GitBookStandalone; + GitBook('postUserMessage', aiPrompt); + }, AI_PROMPT); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await waitForEmbedVisible(iframe, iframe.getByTestId('ai-chat')); + await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText( + AI_PROMPT + ); + await iframe.owner().evaluate(overrideAIResponse); + }, + }, + { + name: 'Configuration - Change standalone button label and icon', + url: '', + run: async (page) => { + await page.evaluate(() => { + const GitBook = window.GitBook as unknown as GitBookStandalone; + GitBook('configure', { + button: { + label: 'Docs', + icon: 'book', + }, + }); + }); + await expect(page.locator('#gitbook-widget-button-label')).toHaveText('Docs'); + await expect(page.locator('#gitbook-widget-button-icon')).toHaveAttribute( + 'data-icon', + 'book' + ); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await iframe.owner().evaluate(overrideAIInitialState); + }, + }, + { + name: 'Configuration - Suggested questions', + url: '', + run: async (page) => { + await page.evaluate(() => { + const GitBook = window.GitBook as unknown as GitBookStandalone; + GitBook('configure', { + suggestions: [ + 'What is GitBook?', + 'How do I get started?', + 'What can you do?', + ], + }); + }); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + const firstSuggestion = await waitForEmbedVisible( + iframe, + iframe.getByTestId('ai-chat-suggested-question').first() + ); + await expect(firstSuggestion).toHaveText('What is GitBook?'); + await expect( + iframe.getByTestId('ai-chat-suggested-question').nth(1) + ).toHaveText('How do I get started?'); + await expect( + iframe.getByTestId('ai-chat-suggested-question').nth(2) + ).toHaveText('What can you do?'); + await iframe.owner().evaluate(overrideAIInitialState); + }, + }, + { + name: 'Configuration - Custom action buttons', + url: '', + run: async (page) => { + await page.evaluate((aiPrompt) => { + const GitBook = window.GitBook as unknown as GitBookStandalone; + GitBook('configure', { + actions: [ + { + label: 'Open internal link', + icon: 'bolt', + onClick: () => { + const GitBook = + window.GitBook as unknown as GitBookStandalone; + GitBook('navigateToPage', '/getting-started/quickstart'); + }, + }, + { + label: 'Open external link', + icon: 'sparkle', + onClick: () => { + window.open('https://gitbook.com', '_blank'); + }, + }, + { + label: 'Post message', + icon: 'message', + onClick: () => { + const GitBook = + window.GitBook as unknown as GitBookStandalone; + GitBook('postUserMessage', aiPrompt); + GitBook('navigateToAssistant'); + }, + }, + { + label: 'Close', + icon: 'xmark', + onClick: () => { + const GitBook = + window.GitBook as unknown as GitBookStandalone; + GitBook('close'); + }, + }, + ], + }); + }, AI_PROMPT); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await waitForEmbedVisible( + iframe, + iframe.getByTestId('embed-action').first(), + 30_000 + ); + await expect(iframe.getByTestId('embed-action')).toHaveCount(4, { + timeout: 30_000, + }); + const actions = iframe.getByTestId('embed-action'); + + await expect(actions.nth(0)).toHaveAccessibleName('Open internal link'); + await actions.nth(0).click(); + await waitForEmbedVisible(iframe, iframe.getByTestId('embed-docs-page')); + await expect(iframe.owner()).toHaveAttribute( + 'src', + expect.stringContaining('getting-started/quickstart') + ); + + await expect(actions.nth(1)).toHaveAccessibleName('Open external link'); + // Intercept the new page event without navigating + const [newPage] = await Promise.all([ + page.context().waitForEvent('page', { timeout: 5000 }), + actions.nth(1).click(), + ]); + // Verify the new page would have opened with the expected URL + expect(newPage.url()).toContain('gitbook.com'); + // Close it immediately to avoid navigation + await newPage.close(); + + await expect(actions.nth(2)).toHaveAccessibleName('Post message'); + await actions.nth(2).click(); + await expect(iframe.getByTestId('ai-chat')).toBeVisible(); + await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText( + AI_PROMPT + ); + + await expect(actions.nth(3)).toHaveAccessibleName('Close'); + await actions.nth(3).click(); + await expect(page.locator('#gitbook-widget-window')).not.toBeVisible(); + await page.locator('#gitbook-widget-button').click(); + await iframe.owner().evaluate(overrideAIResponse); + }, + }, + { + name: 'Configuration - Custom tools', + url: '', + run: async (page) => { + await page.evaluate(() => { + const GitBook = window.GitBook as unknown as GitBookStandalone; + GitBook('configure', { + tools: [ + { + name: 'contact_support', + description: 'Contact support on behalf of the user', + execute: async () => { + return { + output: { message: 'Support message' }, + summary: { + text: 'Contacted support', + icon: 'circle-question', + }, + }; + }, + confirmation: { + icon: 'circle-question', + label: 'Contact support', + }, + }, + ], + }); + GitBook( + 'postUserMessage', + 'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.' + ); + }); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText( + 'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.' + ); + const toolConfirmation = iframe + .getByTestId('ai-chat-tool-confirmation') + .first(); + await waitForEmbedVisible(iframe, toolConfirmation, 30_000); + await page.waitForTimeout(10000); + await iframe.owner().evaluate(overrideAIResponse); + }, + }, + ], + }, + { + name: 'Docs Embed - Docs Only', + contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/', + tests: [ + { + name: 'Docs only', + url: '', + run: async (page) => { + await expect(page.locator('#gitbook-widget-window')).toBeVisible(); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await waitForEmbedVisible(iframe, iframe.getByTestId('embed-docs-page')); + }, + }, + { + name: 'Table of contents', + url: '', + run: async (page) => { + await expect(page.locator('#gitbook-widget-window')).toBeVisible(); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await waitForEmbedVisible(iframe, iframe.getByTestId('embed-docs-page')); + const tocButton = iframe.getByTestId('toc-button'); + await expect(tocButton).toBeVisible(); + await tocButton.click(); + await expect(iframe.getByTestId('table-of-contents')).toBeVisible(); + }, + }, + { + name: 'Open in new tab', + url: '', + run: async (page) => { + await expect(page.locator('#gitbook-widget-window')).toBeVisible(); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await waitForEmbedVisible(iframe, iframe.getByTestId('embed-docs-page')); + const openInNewTabButton = iframe.getByTestId( + 'embed-docs-page-open-in-new-tab' + ); + await expect(openInNewTabButton).toBeVisible(); + // Intercept the new page event without navigating + const [newPage] = await Promise.all([ + page.context().waitForEvent('page', { timeout: 5000 }), + openInNewTabButton.click(), + ]); + // Verify the new page would have opened with the expected URL + expect(newPage.url()).toContain('gitbook.gitbook.io'); + // Close it immediately to avoid navigation + await newPage.close(); + }, + }, + ], + }, ]; runTestCases(testCases); diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index 6b62dea8c0..706b66c19f 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -454,7 +454,8 @@ export async function waitForIcons(page: Page) { */ async function waitForTOCScrolling(page: Page) { const viewport = await page.viewportSize(); - if (viewport && viewport.width >= 1024) { + if (viewport && viewport.width >= 1024 && !page.url().includes('~gitbook/embed/demo')) { + // The embed demo is an iframe, which means the viewport only a fraction of the main document. So there is no open TOC to scroll to. const toc = page.getByTestId('table-of-contents'); await expect(toc).toBeVisible(); await page.evaluate(() => { diff --git a/packages/gitbook/src/components/AI/server-actions/AIToolCallsSummary.tsx b/packages/gitbook/src/components/AI/server-actions/AIToolCallsSummary.tsx index 731e6480ff..b91a421817 100644 --- a/packages/gitbook/src/components/AI/server-actions/AIToolCallsSummary.tsx +++ b/packages/gitbook/src/components/AI/server-actions/AIToolCallsSummary.tsx @@ -39,7 +39,10 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont const { toolCall, context } = props; return ( -
{t(language, 'ai_chat_assistant_description')}
diff --git a/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx b/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx index cbc31bff64..afd603b18e 100644 --- a/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx @@ -17,9 +17,13 @@ export default function AIChatSuggestedQuestions(props: { } = props; return ( -