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 4d0cf5305f..0008efa9ee 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -9,6 +9,7 @@ import { CustomizationSidebarListStyle, CustomizationThemeMode, } from '@gitbook/api'; +import type { GitBookStandalone } from '@gitbook/embed'; import { expect } from '@playwright/test'; import jwt from 'jsonwebtoken'; @@ -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'; } @@ -1946,6 +1947,324 @@ const testCases: TestsCase[] = [ ]), ], }, + { + name: 'Docs Embed - Basic', + contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/', + tests: [ + { + name: 'Standalone UX', + 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(); + await button.click(); // Toggle the window off for the screenshot + await expect(page.locator('#gitbook-widget-window')).not.toBeVisible(); + }, + }, + { + name: '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', + }, + }); + }); + const button = page.locator('#gitbook-widget-button'); + await expect(button).toBeVisible(); + button.click(); + await expect(page.locator('#gitbook-widget-window')).not.toBeVisible(); + await expect(page.locator('#gitbook-widget-button-label')).toHaveText('Docs'); + await expect(page.locator('#gitbook-widget-button-icon')).toHaveAttribute( + 'data-icon', + 'book' + ); + }, + }, + ], + }, + { + name: 'Docs Embed - Assistant + Docs', + contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/', + skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel', + tests: [ + { + 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 expect(iframe.getByTestId('embed-docs-page')).toBeVisible({ + timeout: 20000, + }); + + await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab + await expect(iframe.getByTestId('ai-chat')).toBeVisible(); + + 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 expect(iframe.getByTestId('embed-docs-page')).toBeVisible({ + timeout: 20000, + }); + 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 expect(iframe.getByTestId('ai-chat')).toBeVisible(); + await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText( + AI_PROMPT + ); + await iframe.owner().evaluate(overrideAIResponse); + }, + }, + { + 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'); + await expect( + iframe.getByTestId('ai-chat-suggested-question').nth(0) + ).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 expect(iframe.getByTestId('embed-action')).toHaveCount(4); + const actions = iframe.getByTestId('embed-action'); + + await expect(actions.nth(0)).toHaveAccessibleName('Open internal link'); + await actions.nth(0).click(); + await expect(iframe.getByTestId('embed-docs-page')).toBeVisible(); + 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 expect(toolConfirmation).toBeVisible({ + timeout: 30000, + }); + await iframe.owner().evaluate(overrideAIResponse); + }, + }, + ], + }, + { + name: 'Docs Embed - Docs Only', + contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/', + skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel', + tests: [ + { + name: 'Docs only', + url: '', + run: async (page) => { + await expect(page.locator('#gitbook-widget-window')).toBeVisible(); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({ + timeout: 20000, + }); + }, + }, + { + name: 'Table of contents', + url: '', + run: async (page) => { + await expect(page.locator('#gitbook-widget-window')).toBeVisible(); + const iframe = page.frameLocator('#gitbook-widget-iframe'); + await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({ + timeout: 20000, + }); + 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 expect(iframe.getByTestId('embed-docs-page')).toBeVisible({ + timeout: 20000, + }); + 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 0957d83d09..344eaf5763 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -455,7 +455,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 is 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 ( -
+
- + @@ -239,13 +238,14 @@ export function AIChatBody(props: {
{greeting?.title || timeGreeting}

{greeting?.subtitle || 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 c4dca65dbf..45d368ff4e 100644 --- a/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx +++ b/packages/gitbook/src/components/AIChat/AIChatSuggestedQuestions.tsx @@ -19,9 +19,13 @@ export default function AIChatSuggestedQuestions(props: { ]; return ( -

+
{suggestions.map((question, index) => (