mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-20 20:30:24 +00:00
3027 lines
123 KiB
TypeScript
3027 lines
123 KiB
TypeScript
import { type Page, expect } from '@playwright/test';
|
||
import jwt from 'jsonwebtoken';
|
||
|
||
import {
|
||
CustomizationAIMode,
|
||
CustomizationBackground,
|
||
CustomizationCorners,
|
||
CustomizationDefaultMonospaceFont,
|
||
CustomizationDefaultThemeMode,
|
||
CustomizationDepth,
|
||
CustomizationHeaderPreset,
|
||
CustomizationIconsStyle,
|
||
CustomizationPageActionType,
|
||
CustomizationSidebarListStyle,
|
||
SiteSocialAccountPlatform,
|
||
} from '@gitbook/api';
|
||
import type { GitBookStandalone } from '@gitbook/embed';
|
||
|
||
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
|
||
import {
|
||
type Test,
|
||
type TestsCase,
|
||
allDeprecatedThemePresets,
|
||
allLocales,
|
||
allSearchStyles,
|
||
allSidebarBackgroundStyles,
|
||
allThemeModes,
|
||
allThemes,
|
||
allTintColors,
|
||
getCustomizationURL,
|
||
headerLinks,
|
||
runTestCases,
|
||
setTimeToMorning,
|
||
waitForAIChatResponse,
|
||
waitForAdminToolbar,
|
||
waitForCookiesDialog,
|
||
waitForCoverImages,
|
||
waitForHydration,
|
||
waitForNotFound,
|
||
} from './util';
|
||
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
|
||
|
||
// Kept as deterministic as possible to reduce visual flakiness: no preamble, a
|
||
// single fixed search, a concise answer, and a fixed number of follow-ups. The
|
||
// model is never perfectly deterministic, so `overrideAIResponse` still
|
||
// normalizes the rendered content below — this prompt just narrows the variance.
|
||
const AI_PROMPT = [
|
||
"You're being invoked by the GitBook CI/CD pipeline for automated visual testing.",
|
||
'Follow these instructions exactly and do not deviate:',
|
||
'1. Do not write any preamble, commentary, or reasoning before acting.',
|
||
'2. Perform a single search for exactly "Lorem ipsum".',
|
||
'3. Reply with only the first sentence of the first page you find, and nothing else.',
|
||
'4. Always end by proposing exactly 3 follow-up suggestions.',
|
||
].join('\n');
|
||
|
||
// `InsightsProvider` debounces its flushes by 1.5s.
|
||
const INSIGHTS_FLUSH_TIMEOUT = 3000;
|
||
|
||
/**
|
||
* Collect the insights events of a given type sent by the page and its frames.
|
||
*/
|
||
function trackInsightsEvents(page: Page, type: string) {
|
||
const collected: { type: string }[] = [];
|
||
|
||
page.on('request', (request) => {
|
||
if (request.method() !== 'POST' || !request.url().includes('/~gitbook/__evt')) {
|
||
return;
|
||
}
|
||
|
||
const body = request.postDataJSON() as { events?: { type: string }[] } | null;
|
||
collected.push(...(body?.events ?? []).filter((event) => event.type === type));
|
||
});
|
||
|
||
return collected;
|
||
}
|
||
|
||
const overrideAIInitialState = () => {
|
||
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
|
||
if (greeting) {
|
||
greeting.textContent = 'Good morning';
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Normalize the non-deterministic content of an AI response before screenshotting,
|
||
* while preserving the surrounding structure (message bubbles, tool/activity
|
||
* summary, response container, suggestion buttons) so visual regressions in the
|
||
* chat chrome are still caught. The actual answer formatting is covered separately
|
||
* by the deterministic page tests, since the AI response renders through the same
|
||
* `DocumentView`.
|
||
*
|
||
* Must run only once the chat is no longer `aria-busy` (the response has fully
|
||
* settled), otherwise React re-renders from late stream events will clobber these
|
||
* mutations. See `waitForAIChatResponse`.
|
||
*/
|
||
const overrideAIResponse = () => {
|
||
// The user's prompt varies in length; pin it to a fixed string.
|
||
document.querySelectorAll('[data-testid="ai-chat-message-user"]').forEach((userMessage) => {
|
||
userMessage.textContent = '[Replaced message] Chat message sent by the user';
|
||
});
|
||
|
||
// The assistant's answer text is non-deterministic; replace the rendered
|
||
// document body while keeping the `.ai-response-document` container.
|
||
document
|
||
.querySelectorAll('[data-testid="ai-chat-message-assistant"] .ai-response-document')
|
||
.forEach((message) => {
|
||
message.innerHTML = '<p>[Replaced message] AI chat response</p>';
|
||
});
|
||
|
||
// The "Explored with N tools" activity label varies with the number of tool
|
||
// calls; pin it (the chevron sibling is left intact).
|
||
document.querySelectorAll('[data-testid="ai-chat-activity-summary"]').forEach((summary) => {
|
||
summary.textContent = 'Explored';
|
||
});
|
||
|
||
// Follow-up suggestion text varies; pin each label.
|
||
document
|
||
.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]')
|
||
.forEach((suggestion) => {
|
||
suggestion.textContent = 'Follow-up suggestion';
|
||
});
|
||
};
|
||
|
||
const searchTestCases: Test[] = [
|
||
{
|
||
name: 'Search - AI Mode: None - Complete flow',
|
||
url: getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.None,
|
||
},
|
||
}),
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
const searchInput = page.getByTestId('search-input');
|
||
await searchInput.focus();
|
||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||
|
||
// Fill search input, expecting search results
|
||
await searchInput.fill('gitbook');
|
||
await expect(page.getByTestId('search-results')).toBeVisible({
|
||
timeout: 10_000,
|
||
});
|
||
const pageResults = await page.getByTestId('search-page-result').all();
|
||
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||
await expect(page.getByTestId('search-ask-question')).toHaveCount(0); // No AI search results with aiMode=None.
|
||
},
|
||
},
|
||
{
|
||
name: 'Search - AI Mode: None - Keyboard shortcut',
|
||
url: getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.None,
|
||
},
|
||
}),
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await page.keyboard.press('ControlOrMeta+K');
|
||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||
},
|
||
},
|
||
{
|
||
name: 'Search - Keyboard focus exits to the next page control',
|
||
url: getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.Search,
|
||
},
|
||
}),
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
const searchInput = page.getByTestId('search-input');
|
||
await searchInput.focus();
|
||
await searchInput.fill('gitbook');
|
||
|
||
const searchPopup = page.getByTestId('search-popover');
|
||
await expect(searchPopup).toBeVisible({ timeout: 10_000 });
|
||
const finalPopupControl = searchPopup
|
||
.locator(
|
||
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||
)
|
||
.filter({ visible: true })
|
||
.last();
|
||
const nextPageControl = page.getByTestId('table-of-contents').getByRole('link').first();
|
||
await expect(finalPopupControl).toBeVisible();
|
||
await expect(nextPageControl).toBeVisible();
|
||
await finalPopupControl.focus();
|
||
await page.keyboard.press('Tab');
|
||
await expect(searchPopup).toBeHidden();
|
||
await expect(nextPageControl).toBeFocused();
|
||
|
||
await page.keyboard.press('Shift+Tab');
|
||
await expect(searchInput).toBeFocused();
|
||
},
|
||
},
|
||
{
|
||
// `fill()` bypasses key events, so it can't catch a swallowed key. RND-12484.
|
||
name: 'Search - AI Mode: None - Typing multi-word queries',
|
||
url: getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.None,
|
||
},
|
||
}),
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
const searchInput = page.getByTestId('search-input');
|
||
await searchInput.focus();
|
||
await searchInput.pressSequentially('getting started');
|
||
await expect(searchInput).toHaveValue('getting started');
|
||
},
|
||
},
|
||
{
|
||
name: 'Search - AI Mode: None - URL query (Initial)',
|
||
url: `${getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.None,
|
||
},
|
||
})}&q=`,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await expect(page.getByTestId('search-results')).toHaveCount(0); // No pop-up yet because there's no recommended questions.
|
||
},
|
||
},
|
||
{
|
||
name: 'Search - AI Mode: None - URL query (Results)',
|
||
url: `${getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.None,
|
||
},
|
||
})}&q=gitbook`,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Search - AI Mode: Search - URL query (Results)',
|
||
url: `${getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.Search,
|
||
},
|
||
})}&q=gitbook`,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await expect(page.getByTestId('search-input')).toBeFocused();
|
||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||
await expect(page.getByTestId('search-results')).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
// RND-12844: the popover's focus manager re-focused the closing popup and
|
||
// scrolled the page back to the top right after landing on the section.
|
||
name: 'Search - Section result scrolls to the section',
|
||
url: getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.None,
|
||
},
|
||
}),
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
const searchInput = page.getByTestId('search-input');
|
||
await searchInput.focus();
|
||
// Type like a visitor: `fill()` doesn't trigger the remote search.
|
||
await searchInput.pressSequentially('tasks');
|
||
|
||
const sectionResult = page.locator(
|
||
'[data-testid="search-page-result"][href$="/blocks/lists#tasks"]'
|
||
);
|
||
// Section results come from the remote index, which can be slow to answer.
|
||
await expect(sectionResult).toBeVisible({ timeout: 30_000 });
|
||
await sectionResult.click();
|
||
await page.waitForURL(/\/blocks\/lists#tasks$/);
|
||
|
||
// The regression scrolled back to the top shortly after landing, so let
|
||
// that happen before asserting.
|
||
await page.waitForTimeout(1000);
|
||
|
||
// The heading is parked under the header, within its scroll margin.
|
||
const top = await page
|
||
.locator('#tasks')
|
||
.evaluate((heading) => heading.getBoundingClientRect().top);
|
||
expect(top).toBeGreaterThanOrEqual(0);
|
||
expect(top).toBeLessThanOrEqual(150);
|
||
},
|
||
},
|
||
{
|
||
name: 'Ask - AI Mode: Assistant - Complete flow',
|
||
url: getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.Assistant,
|
||
},
|
||
}),
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||
|
||
// Fill search input, expecting AI search option
|
||
await searchInput.focus();
|
||
await searchInput.fill(AI_PROMPT);
|
||
const aiSearchResult = page.getByTestId('search-ask-question');
|
||
await expect(aiSearchResult).toBeVisible();
|
||
await aiSearchResult.click();
|
||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||
// Wait for the full response (incl. follow-up suggestions) to settle before
|
||
// asserting/screenshotting, rather than racing a fixed suggestion count.
|
||
await waitForAIChatResponse(page);
|
||
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
|
||
},
|
||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
|
||
},
|
||
{
|
||
name: 'Ask - AI Mode: Assistant - Keyboard shortcut',
|
||
url: getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.Assistant,
|
||
},
|
||
}),
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await page.keyboard.press('ControlOrMeta+I');
|
||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||
},
|
||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||
},
|
||
{
|
||
name: 'Ask - AI Mode: Assistant - Button',
|
||
url: getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.Assistant,
|
||
},
|
||
}),
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await page.getByTestId('ai-chat-button').click();
|
||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||
},
|
||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||
},
|
||
{
|
||
name: 'Ask - AI Mode: Assistant - URL query (Initial)',
|
||
url: `${getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.Assistant,
|
||
},
|
||
})}&ask=`,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||
await expect(page.getByTestId('search-input')).toBeEmpty();
|
||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||
},
|
||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIInitialState),
|
||
},
|
||
{
|
||
name: 'Ask - AI Mode: Assistant - URL query (Results)',
|
||
url: `${getCustomizationURL({
|
||
ai: {
|
||
mode: CustomizationAIMode.Assistant,
|
||
},
|
||
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||
// Wait for the full response (incl. follow-up suggestions) to settle before
|
||
// asserting/screenshotting, rather than racing a fixed suggestion count.
|
||
await waitForAIChatResponse(page);
|
||
await expect(page.getByTestId('ai-chat-followup-suggestion').first()).toBeVisible();
|
||
},
|
||
// Re-applied per viewport so the replacement survives resize-driven re-renders.
|
||
normalizeBeforeScreenshot: (page) => page.evaluate(overrideAIResponse),
|
||
},
|
||
];
|
||
|
||
const testCases: TestsCase[] = [
|
||
{
|
||
name: 'GitBook Site (Single Variant)',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||
tests: [
|
||
{
|
||
name: 'Home',
|
||
url: '',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'No variants dropdown',
|
||
url: '',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await expect(page.locator('[data-testid="space-dropdown-button"]')).toHaveCount(
|
||
0
|
||
);
|
||
},
|
||
},
|
||
{
|
||
name: 'Expandable TOC navigation',
|
||
url: '',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
|
||
// Verify "Navigation" link is not visible initially
|
||
const navigationLink = page.getByRole('link', { name: 'Navigation' });
|
||
await expect(navigationLink).not.toBeVisible();
|
||
|
||
// Find and click the chevron element that is next to "Editor" in the TOC
|
||
// It is a button inside the link
|
||
const editorChevron = page
|
||
.getByRole('link', { name: 'Editor' })
|
||
.locator('button');
|
||
await editorChevron.click();
|
||
|
||
// Verify "Navigation" link becomes visible after expansion
|
||
await expect(navigationLink).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Expandable nested TOC navigation',
|
||
url: '',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
|
||
// Verify "Spaces" link is not visible initially
|
||
const navigationLink = page.getByRole('link', { name: 'Spaces' });
|
||
await expect(navigationLink).not.toBeVisible();
|
||
|
||
// Find and click the chevron element that is next to "Editor" in the TOC
|
||
// It is a button inside the link
|
||
const editorChevron = page
|
||
.getByRole('link', { name: 'Editor' })
|
||
.locator('button');
|
||
await editorChevron.click();
|
||
|
||
// At this stage the link should still not be visible
|
||
await expect(navigationLink).not.toBeVisible();
|
||
|
||
// Then we click 'Content Structure' chevron to expand further
|
||
const contentStructureChevron = page
|
||
.getByRole('link', { name: 'Content Structure' })
|
||
.locator('button');
|
||
await contentStructureChevron.click();
|
||
|
||
// Verify "Spaces" link becomes visible after expansion
|
||
await expect(navigationLink).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Not found',
|
||
url: 'content-not-found',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'GitBook Site (Multi Variants)',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/multi-variants/',
|
||
tests: [
|
||
{
|
||
name: 'Variants dropdown',
|
||
url: '',
|
||
run: async (page) => {
|
||
const spaceDrowpdown = page
|
||
.locator('[data-testid="space-dropdown-button"]')
|
||
.locator('visible=true');
|
||
await spaceDrowpdown.waitFor();
|
||
},
|
||
},
|
||
{
|
||
name: 'Default variant',
|
||
url: '',
|
||
},
|
||
{
|
||
name: 'RFC variant',
|
||
url: 'rfcs',
|
||
},
|
||
{
|
||
name: 'Customized variant titles are displayed',
|
||
url: '',
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const spaceDropdown = page
|
||
.locator('[data-testid="space-dropdown-button"]')
|
||
.locator('visible=true');
|
||
await spaceDropdown.click();
|
||
|
||
const variantSelectionDropdown = page.locator(
|
||
'css=[data-testid="dropdown-menu"]'
|
||
);
|
||
// the customized space title
|
||
await expect(
|
||
variantSelectionDropdown.getByRole('menuitem', {
|
||
name: 'Multi-Variants',
|
||
})
|
||
).toBeVisible();
|
||
|
||
// the NON-customized space title
|
||
await expect(
|
||
variantSelectionDropdown.getByRole('menuitem', {
|
||
name: 'RFCs',
|
||
})
|
||
).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Switch variant with alternate link in metadata',
|
||
url: 'rfcs',
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const spaceDropdown = page
|
||
.locator('[data-testid="space-dropdown-button"]')
|
||
.locator('visible=true');
|
||
await spaceDropdown.click();
|
||
|
||
const variantSelectionDropdown = page.locator(
|
||
'css=[data-testid="dropdown-menu"]'
|
||
);
|
||
|
||
// Click the variant space called 'Multi-Variants' for which
|
||
// there is an alternate link in the current (RFC variant) page metadata
|
||
await variantSelectionDropdown
|
||
.getByRole('menuitem', {
|
||
name: 'Multi-Variants',
|
||
})
|
||
.click();
|
||
|
||
// It should navigate to the alternate link defined in the metadata (a completely different page)
|
||
await page.waitForURL((url) =>
|
||
url.pathname.includes('multi-variants/reference/api-reference/pets')
|
||
);
|
||
// Verify we are on the correct page by checking the h1
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'Pets' })
|
||
).toBeVisible();
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'GitBook Site (Navigation when switching variant)',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/',
|
||
tests: [
|
||
{
|
||
name: 'Strip fallback after loading a page without adding history',
|
||
url: 'api-multi-versions/reference/api-reference/pets',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const previousURL = page.url();
|
||
const targetURL = new URL(previousURL);
|
||
targetURL.searchParams.set('fallback', 'true');
|
||
targetURL.searchParams.set('ref', 'variant');
|
||
targetURL.hash = 'pets';
|
||
await page.goto(targetURL.toString());
|
||
targetURL.searchParams.delete('fallback');
|
||
await expect(page).toHaveURL(targetURL.toString());
|
||
await page.goBack();
|
||
await expect(page).toHaveURL(previousURL);
|
||
},
|
||
},
|
||
{
|
||
name: 'Keep navigation path/route when switching variant (Public)',
|
||
url: 'api-multi-versions/reference/api-reference/pets',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const spaceDropdown = await page
|
||
.locator('[data-testid="space-dropdown-button"]')
|
||
.locator('visible=true');
|
||
await spaceDropdown.click();
|
||
|
||
const variantSelectionDropdown = page.locator(
|
||
'css=[data-testid="dropdown-menu"]'
|
||
);
|
||
// Click the second variant in the dropdown
|
||
await variantSelectionDropdown
|
||
.getByRole('menuitem', {
|
||
name: '2.0',
|
||
})
|
||
.click();
|
||
|
||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||
await page.waitForURL(
|
||
(url) =>
|
||
url.pathname.includes(
|
||
'api-multi-versions/2.0/reference/api-reference/pets'
|
||
) && !url.searchParams.has('fallback')
|
||
);
|
||
},
|
||
},
|
||
{
|
||
name: 'Keep navigation path/route when switching variant (Share link)',
|
||
url: 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/reference/api-reference/pets',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const spaceDropdown = await page
|
||
.locator('[data-testid="space-dropdown-button"]')
|
||
.locator('visible=true');
|
||
await spaceDropdown.click();
|
||
|
||
const variantSelectionDropdown = page.locator(
|
||
'css=[data-testid="dropdown-menu"]'
|
||
);
|
||
|
||
// Click the second variant in the dropdown
|
||
await variantSelectionDropdown
|
||
.getByRole('menuitem', {
|
||
name: '2.0',
|
||
})
|
||
.click();
|
||
|
||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||
await page.waitForURL((url) =>
|
||
url.pathname.includes(
|
||
'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/reference/api-reference/pets'
|
||
)
|
||
);
|
||
},
|
||
},
|
||
{
|
||
name: 'Keep navigation path/route when switching variant (VA)',
|
||
screenshot: false,
|
||
url: () => {
|
||
const privateKey = 'c26190fc-74b2-4b54-9fc7-df9941104953';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `api-multi-versions-va/reference/api-reference/pets?jwt_token=${token}`;
|
||
},
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const spaceDropdown = await page
|
||
.locator('[data-testid="space-dropdown-button"]')
|
||
.locator('visible=true');
|
||
await spaceDropdown.click();
|
||
|
||
const variantSelectionDropdown = page.locator(
|
||
'css=[data-testid="dropdown-menu"]'
|
||
);
|
||
|
||
// Click the second variant in the dropdown
|
||
await variantSelectionDropdown
|
||
.getByRole('menuitem', {
|
||
name: '2.0',
|
||
})
|
||
.click();
|
||
|
||
// It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant
|
||
await page.waitForURL((url) =>
|
||
url.pathname.includes(
|
||
'api-multi-versions-va/2.0/reference/api-reference/pets'
|
||
)
|
||
);
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Language Site (Navigation when switching language variant)',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/',
|
||
tests: [
|
||
{
|
||
name: 'Should resolve to the same page in the new language variant when switching language variant (Source English -> Target Dutch)',
|
||
url: 'ecosystem/connection-provider',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const spaceDropdown = page
|
||
.locator('[data-testid="space-dropdown-button"]')
|
||
.locator('visible=true');
|
||
await spaceDropdown.click();
|
||
|
||
const variantSelectionDropdown = page.locator(
|
||
'css=[data-testid="dropdown-menu"]'
|
||
);
|
||
// Click the dutch language variant in the dropdown
|
||
await variantSelectionDropdown
|
||
.getByRole('menuitem', {
|
||
name: 'Yjs (NL)',
|
||
})
|
||
.click();
|
||
|
||
// It should keep the current page path, i.e "ecosysteem/connection-provider" when navigating to the NL variant
|
||
await page.waitForURL((url) =>
|
||
url.pathname.includes('nl/ecosysteem/connection-provider')
|
||
);
|
||
// Verify we are on the correct page by checking the h1
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'Connectieprovider' })
|
||
).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Should resolve to the same page in the new language variant when switching language variant (Source Dutch -> Target Finnish)',
|
||
url: 'nl/ecosysteem/connection-provider',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const spaceDropdown = page
|
||
.locator('[data-testid="space-dropdown-button"]')
|
||
.locator('visible=true');
|
||
await spaceDropdown.click();
|
||
|
||
const variantSelectionDropdown = page.locator(
|
||
'css=[data-testid="dropdown-menu"]'
|
||
);
|
||
// Click the finnish language variant in the dropdown
|
||
await variantSelectionDropdown
|
||
.getByRole('menuitem', {
|
||
name: 'Yjs (FI)',
|
||
})
|
||
.click();
|
||
|
||
// It should keep the current page path, i.e "ecosysteem/connection-provider" when navigating to the FI variant
|
||
await page.waitForURL((url) =>
|
||
url.pathname.includes('fi/ekosysteemi/connection-provider')
|
||
);
|
||
// Verify we are on the correct page by checking the h1
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'Yhteysvälittäjä' })
|
||
).toBeVisible();
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'GitBook Site (Sections and Section Groups)',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||
tests: [
|
||
{
|
||
name: 'Site with sections and section groups',
|
||
url: '',
|
||
},
|
||
{
|
||
name: 'Section group link',
|
||
url: '',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
// The menu only opens on `mouseenter`, which cannot fire again once the pointer
|
||
// is inside: a hover landing before hydration is lost for good.
|
||
await waitForHydration(page);
|
||
const trigger = page
|
||
.locator('[data-gb-sections]')
|
||
.getByRole('button', { name: 'Test Section Group 1' });
|
||
await trigger.hover();
|
||
await expect(page.getByText('Section B')).toBeVisible();
|
||
await page.getByText('Section B').click();
|
||
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
|
||
},
|
||
},
|
||
{
|
||
name: 'Root external link renders in the configured position',
|
||
url: '',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const rootSections = page.locator('[data-gb-sections]');
|
||
const rootItems = rootSections.locator(':scope > li');
|
||
|
||
await expect(rootItems).toHaveCount(4);
|
||
await expect(rootItems.nth(0)).toContainText('Home');
|
||
await expect(rootItems.nth(1)).toContainText('Test Section Group 1');
|
||
await expect(rootItems.nth(2)).toContainText('Test Section Group 2');
|
||
await expect(rootItems.last()).toContainText('Gitbook Docs');
|
||
await expect(
|
||
rootSections.getByRole('link', { name: 'Gitbook Docs' })
|
||
).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Root external link has the configured contract',
|
||
url: '',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
const rootLink = page
|
||
.locator('[data-gb-sections]')
|
||
.getByRole('link', { name: 'Gitbook Docs' });
|
||
|
||
await expect(rootLink).toBeVisible();
|
||
await expect(rootLink).toHaveAttribute('href', 'https://gitbook.com/docs');
|
||
await expect(rootLink).not.toHaveAttribute('target');
|
||
await expect(rootLink).not.toHaveAttribute('rel');
|
||
await expect(rootLink).toHaveAttribute('data-active', 'false');
|
||
await expect(rootLink).not.toHaveAttribute('aria-current');
|
||
},
|
||
},
|
||
{
|
||
name: 'Nested external link renders in the configured position',
|
||
url: '',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
await page
|
||
.locator('[data-gb-sections]')
|
||
.getByRole('button', { name: 'Test Section Group 2' })
|
||
.hover();
|
||
|
||
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
|
||
await expect(nestedLink).toBeVisible();
|
||
|
||
const nestedItems = nestedLink
|
||
.locator('xpath=ancestor::ul[1]')
|
||
.locator(':scope > li');
|
||
await expect(nestedItems).toHaveCount(3);
|
||
await expect(nestedItems.nth(0)).toContainText('Section C');
|
||
await expect(nestedItems.nth(1)).toContainText('Section with longer title');
|
||
await expect(nestedItems.last()).toContainText('Gitbook Site');
|
||
},
|
||
},
|
||
{
|
||
name: 'Nested external link has the configured contract',
|
||
url: '',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
await page
|
||
.locator('[data-gb-sections]')
|
||
.getByRole('button', { name: 'Test Section Group 2' })
|
||
.hover();
|
||
|
||
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
|
||
await expect(nestedLink).toBeVisible();
|
||
await expect(nestedLink).toHaveAttribute('href', 'https://gitbook.com');
|
||
await expect(nestedLink).not.toHaveAttribute('target');
|
||
await expect(nestedLink).not.toHaveAttribute('rel');
|
||
await expect(nestedLink).not.toHaveAttribute('aria-current');
|
||
},
|
||
},
|
||
{
|
||
name: 'External links use the configured window open behavior',
|
||
url: '',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
|
||
const windowOpenCalls: {
|
||
url: string;
|
||
target: string;
|
||
features: string | undefined;
|
||
}[] = [];
|
||
await page.exposeFunction(
|
||
'recordExternalWindowOpen',
|
||
(url: string, target: string, features?: string) => {
|
||
windowOpenCalls.push({ url, target, features });
|
||
}
|
||
);
|
||
await page.evaluate(() => {
|
||
const recordExternalWindowOpen = (
|
||
window as unknown as {
|
||
recordExternalWindowOpen: (
|
||
url: string,
|
||
target: string,
|
||
features?: string
|
||
) => void;
|
||
}
|
||
).recordExternalWindowOpen;
|
||
window.open = ((url, target, features) => {
|
||
void recordExternalWindowOpen(
|
||
url?.toString() ?? '',
|
||
target ?? '',
|
||
features
|
||
);
|
||
return null;
|
||
}) as typeof window.open;
|
||
});
|
||
|
||
const initialURL = page.url();
|
||
await page
|
||
.locator('[data-gb-sections]')
|
||
.getByRole('link', { name: 'Gitbook Docs' })
|
||
.click();
|
||
await expect.poll(() => windowOpenCalls.length).toBe(1);
|
||
expect(windowOpenCalls[0]).toEqual({
|
||
url: 'https://gitbook.com/docs',
|
||
target: '_self',
|
||
features: undefined,
|
||
});
|
||
await expect(page).toHaveURL(initialURL);
|
||
|
||
await page
|
||
.locator('[data-gb-sections]')
|
||
.getByRole('button', { name: 'Test Section Group 2' })
|
||
.hover();
|
||
await page.getByRole('link', { name: 'Gitbook Site' }).click();
|
||
await expect.poll(() => windowOpenCalls.length).toBe(2);
|
||
expect(windowOpenCalls[1]).toEqual({
|
||
url: 'https://gitbook.com',
|
||
target: '_self',
|
||
features: undefined,
|
||
});
|
||
await expect(page).toHaveURL(initialURL);
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'GitBook',
|
||
contentBaseURL: 'https://gitbook.com/docs/',
|
||
tests: [
|
||
{
|
||
name: 'Home',
|
||
url: '',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Not found',
|
||
url: 'content-not-found',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Versioning',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: [
|
||
{
|
||
name: 'Revision',
|
||
url: '~/revisions/S55pwsEr5UVoroaOiWnP/blocks/headings',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
// Viewing a past revision shows the admin toolbar; assert it is
|
||
// present (it is hidden from the screenshot as it animates open).
|
||
await waitForAdminToolbar(page);
|
||
},
|
||
},
|
||
{
|
||
name: 'Invalid revision',
|
||
url: '~/revisions/idnotfound/blocks/headings',
|
||
run: waitForNotFound,
|
||
screenshot: false,
|
||
},
|
||
{
|
||
name: 'Invalid change request',
|
||
url: '~/changes/idnotfound/blocks/headings',
|
||
run: waitForNotFound,
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'PDF',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: [
|
||
{
|
||
name: 'PDF',
|
||
url: '~gitbook/pdf?limit=10',
|
||
screenshot: {
|
||
waitForTOCScrolling: false,
|
||
},
|
||
run: async (page) => {
|
||
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Space PDF',
|
||
tests: [
|
||
{
|
||
name: 'Main content',
|
||
url: async () => {
|
||
const data = await getSiteAPIToken(
|
||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||
);
|
||
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.set('limit', '10');
|
||
searchParams.set('token', data.apiToken);
|
||
|
||
return `~space/${data.space}/~gitbook/pdf?${searchParams.toString()}`;
|
||
},
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Change request',
|
||
url: async () => {
|
||
const data = await getSiteAPIToken(
|
||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||
);
|
||
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.set('limit', '10');
|
||
searchParams.set('token', data.apiToken);
|
||
|
||
return `~space/${data.space}/~/changes/HrtgUd5MlFusCMv1elA7/~gitbook/pdf?${searchParams.toString()}`;
|
||
},
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await expect(page.locator('[data-testid="print-button"]')).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Show error when missing token',
|
||
url: async () => {
|
||
const data = await getSiteAPIToken(
|
||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||
);
|
||
|
||
// Intentionally not setting the token to test error handling when the token is missing
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.set('limit', '10');
|
||
|
||
return `~space/${data.space}/~gitbook/pdf?${searchParams.toString()}`;
|
||
},
|
||
screenshot: false,
|
||
run: async (page, response) => {
|
||
expect(response).not.toBeNull();
|
||
expect(response?.status()).toBe(400);
|
||
await expect(page.getByText('Missing API token')).toBeVisible();
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Site Previews',
|
||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||
tests: [
|
||
{
|
||
name: 'Main content',
|
||
url: async () => {
|
||
const data = await getSiteAPIToken(
|
||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||
);
|
||
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.set('token', data.apiToken);
|
||
|
||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||
},
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'With sections',
|
||
url: async () => {
|
||
const data = await getSiteAPIToken('https://gitbook.com/docs');
|
||
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.set('token', data.apiToken);
|
||
|
||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||
},
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
const sectionTabs = page.getByLabel('Sections');
|
||
await expect(sectionTabs).toBeVisible();
|
||
|
||
const sectionTabLinks = sectionTabs.getByRole('link');
|
||
for (const link of await sectionTabLinks.all()) {
|
||
const href = await link.getAttribute('href');
|
||
expect(href?.includes('/preview/site_p4Xo4')).toBeTruthy();
|
||
}
|
||
},
|
||
},
|
||
{
|
||
name: 'With customization cookie',
|
||
url: async () => {
|
||
const data = await getSiteAPIToken(
|
||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||
);
|
||
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.set('token', data.apiToken);
|
||
|
||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||
},
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||
// Trademark exists by default
|
||
expect(await page.getByTestId('gb-trademark').count()).toBeGreaterThanOrEqual(
|
||
1
|
||
); // We output 2 trademark buttons that are quite different, and can only determine which one is appropriate based on screen size / CSS. So our check needs to be more lenient than "exactly 1".
|
||
|
||
// Go to another page with the customization query to disable the trademark
|
||
const pageBlocks = new URL(page.url());
|
||
pageBlocks.pathname = `${pageBlocks.pathname.replace(/\/$/, '')}/blocks`;
|
||
pageBlocks.search = getCustomizationURL({
|
||
trademark: {
|
||
enabled: false,
|
||
},
|
||
}).slice(1);
|
||
await page.goto(pageBlocks.toString());
|
||
// No trademark because customization is disabled
|
||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'Blocks' })
|
||
).toBeVisible();
|
||
|
||
const pageBlocksCode = new URL(page.url());
|
||
pageBlocksCode.pathname = `${pageBlocksCode.pathname.replace(/\/$/, '')}/code`;
|
||
pageBlocksCode.search = '';
|
||
await page.goto(pageBlocksCode.toString());
|
||
// The trademark should not be visible because the cookie is still set,
|
||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'Code' })
|
||
).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Markdown page with theme cookie',
|
||
url: async () => {
|
||
const data = await getSiteAPIToken(
|
||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||
);
|
||
|
||
const searchParams = new URLSearchParams();
|
||
searchParams.set('token', data.apiToken);
|
||
searchParams.set('theme', 'dark');
|
||
|
||
return `url/${getGitBookPreviewURL(`${data.site}/blocks?${searchParams.toString()}`)}`;
|
||
},
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
// The app opens previews with `?theme=`, which the middleware persists in a cookie.
|
||
const markdownURL = new URL(page.url());
|
||
markdownURL.pathname = `${markdownURL.pathname.replace(/\/$/, '')}.md`;
|
||
markdownURL.search = '';
|
||
const response = await page.request.get(markdownURL.toString());
|
||
expect(response.status()).toBe(200);
|
||
expect(response.headers()['content-type']).toContain('text/markdown');
|
||
},
|
||
},
|
||
{
|
||
name: 'Redirect to app for authentication when missing token',
|
||
url: async () => {
|
||
const data = await getSiteAPIToken('https://gitbook.com/docs');
|
||
|
||
const searchParams = new URLSearchParams();
|
||
// Intentionally not setting the token to test redirection for authentication
|
||
|
||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||
},
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
await page.waitForURL(
|
||
(url) =>
|
||
url.host === 'app.gitbook.com' && url.pathname.includes('/preview/auth')
|
||
);
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Site subdirectory (proxy)',
|
||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||
contentBaseURL: 'https://nextjs-gbo-proxy.vercel.app/documentation/',
|
||
tests: [
|
||
{
|
||
name: 'Main',
|
||
url: '',
|
||
fullPage: true,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Site subdirectory (proxy) with VA',
|
||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||
contentBaseURL: 'https://nextjs-gbo-proxy-va.vercel.app/va/docs/',
|
||
tests: [
|
||
{
|
||
name: 'Main',
|
||
url: () => {
|
||
const privateKey =
|
||
'rqSfA6x7eAKx1qDRCDq9aCXwivpUvQ8YkXeDdFvCCUa9QchIcM7pF1iJ4o7AGOU49spmOWjKoIPtX0pVUVQ81w==';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `?jwt_token=${token}`;
|
||
},
|
||
fullPage: true,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Search & AI',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: searchTestCases,
|
||
},
|
||
{
|
||
name: 'Content tests',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
fullPage: true,
|
||
tests: [
|
||
{
|
||
name: 'Text',
|
||
url: 'text-page',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Long text',
|
||
url: 'text-page/long-text',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Images',
|
||
url: 'blocks/block-images',
|
||
run: waitForCookiesDialog,
|
||
fullPage: true,
|
||
screenshot: { threshold: 0.9 },
|
||
},
|
||
{
|
||
name: 'Images (with zoom)',
|
||
url: 'blocks/block-images',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
const zoomImage = page.getByTestId('zoom-image');
|
||
await zoomImage.first().click();
|
||
await expect(page.getByTestId('zoom-image-modal')).toBeVisible();
|
||
},
|
||
screenshot: { threshold: 0.8 },
|
||
},
|
||
{
|
||
name: 'Inline Images',
|
||
url: 'blocks/inline-images',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
// Make the text invisible to fix flakiness due to the text position.
|
||
await page.evaluate(() => {
|
||
for (const p of document.querySelectorAll('p')) {
|
||
if (
|
||
p.textContent?.includes(
|
||
'This image has intrinsic 400px width, but renders as 300px:'
|
||
)
|
||
) {
|
||
p.style.color = 'transparent';
|
||
}
|
||
}
|
||
});
|
||
},
|
||
},
|
||
{
|
||
name: 'Tabs',
|
||
url: 'blocks/tabs',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Hints',
|
||
url: 'blocks/hints',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Integration Blocks',
|
||
url: 'blocks/integrations',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
const mermaidIframe = page
|
||
.locator('iframe[title*="mermaid"]')
|
||
.first()
|
||
.contentFrame();
|
||
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
|
||
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
||
},
|
||
},
|
||
{
|
||
name: 'Tables',
|
||
url: 'blocks/tables',
|
||
run: waitForCookiesDialog,
|
||
fullPage: true,
|
||
},
|
||
{
|
||
name: 'Expandables',
|
||
url: 'blocks/expandables',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'API Blocks',
|
||
url: 'blocks/api-blocks',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Headings',
|
||
url: 'blocks/headings',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Marks',
|
||
url: 'blocks/marks',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Emojis',
|
||
url: 'blocks/emojis',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Icons',
|
||
url: 'blocks/icons',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Links',
|
||
url: 'blocks/links',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Buttons',
|
||
url: 'blocks/buttons',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Lists',
|
||
url: 'blocks/lists',
|
||
fullPage: true,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Code',
|
||
url: 'blocks/code',
|
||
fullPage: true,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Cards',
|
||
url: 'blocks/cards',
|
||
fullPage: true,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Updates',
|
||
url: 'blocks/updates',
|
||
fullPage: true,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Math',
|
||
url: 'blocks/math',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await page.waitForFunction(() => {
|
||
const fonts = Array.from(document.fonts.values());
|
||
const mjxFonts = fonts.filter(
|
||
(font) => font.family === 'MJXZERO' || font.family === 'MJXTEX'
|
||
);
|
||
return (
|
||
mjxFonts.length === 2 &&
|
||
mjxFonts.every((font) => font.status === 'loaded')
|
||
);
|
||
});
|
||
},
|
||
},
|
||
{
|
||
name: 'Files',
|
||
url: 'blocks/files',
|
||
fullPage: true,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Embeds',
|
||
url: 'blocks/embeds',
|
||
fullPage: true,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Page links',
|
||
url: 'blocks/page-links',
|
||
fullPage: true,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Annotations',
|
||
url: 'blocks/annotations',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await page.waitForSelector('[data-annotation]');
|
||
await page.click('[data-annotation]');
|
||
},
|
||
},
|
||
{
|
||
name: 'Stepper',
|
||
url: 'blocks/stepper',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Columns',
|
||
url: 'blocks/columns',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Mermaid',
|
||
url: 'blocks/mermaid',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Prompt',
|
||
url: 'blocks/prompt',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Page options',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: [
|
||
{
|
||
name: 'Hidden',
|
||
url: 'page-options/page-hidden',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'With cover',
|
||
url: 'page-options/page-with-cover',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await waitForCoverImages(page);
|
||
},
|
||
},
|
||
{
|
||
name: 'With cover for dark mode',
|
||
url: `page-options/page-with-dark-cover${getCustomizationURL({
|
||
themes: {
|
||
default: CustomizationDefaultThemeMode.Dark,
|
||
toggeable: false,
|
||
},
|
||
})}`,
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await waitForCoverImages(page, { darkMode: true });
|
||
},
|
||
},
|
||
{
|
||
name: 'With hero cover',
|
||
url: 'page-options/page-with-hero-cover',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await waitForCoverImages(page);
|
||
},
|
||
},
|
||
{
|
||
name: 'With cover and no TOC',
|
||
url: 'page-options/page-with-cover-and-no-toc',
|
||
run: async (page) => {
|
||
await waitForCookiesDialog(page);
|
||
await waitForCoverImages(page);
|
||
},
|
||
screenshot: {
|
||
waitForTOCScrolling: false,
|
||
},
|
||
},
|
||
{
|
||
name: 'With icon',
|
||
url: 'page-options/page-with-icon',
|
||
run: waitForCookiesDialog,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Customization',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: allThemeModes.flatMap((themeMode) => [
|
||
{
|
||
name: `Without header - Theme mode ${themeMode}`,
|
||
url: getCustomizationURL({
|
||
header: {
|
||
preset: CustomizationHeaderPreset.None,
|
||
links: [],
|
||
},
|
||
themes: {
|
||
default: themeMode,
|
||
toggeable: false,
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: `With duotone icons - Theme mode ${themeMode}`,
|
||
url: `page-options/page-with-icon${getCustomizationURL({
|
||
styling: {
|
||
icons: CustomizationIconsStyle.Duotone,
|
||
},
|
||
themes: {
|
||
default: themeMode,
|
||
toggeable: false,
|
||
},
|
||
})}`,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: `With header buttons - Theme mode ${themeMode}`,
|
||
url: getCustomizationURL({
|
||
header: {
|
||
preset: CustomizationHeaderPreset.Default,
|
||
links: headerLinks,
|
||
},
|
||
themes: {
|
||
default: themeMode,
|
||
toggeable: false,
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: `Without tint - Default preset - Theme mode ${themeMode}`,
|
||
url: getCustomizationURL({
|
||
header: {
|
||
preset: CustomizationHeaderPreset.Default,
|
||
links: headerLinks,
|
||
},
|
||
themes: {
|
||
default: themeMode,
|
||
toggeable: false,
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: `With custom monospace font - Theme mode ${themeMode}`,
|
||
url: `blocks/code${getCustomizationURL({
|
||
styling: {
|
||
monospaceFont: CustomizationDefaultMonospaceFont.SpaceMono,
|
||
},
|
||
})}`,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
// New site themes
|
||
...allThemes.flatMap((theme) => [
|
||
...allTintColors.flatMap((tint) =>
|
||
allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
|
||
name: `Theme ${theme} - Tint ${tint.label} - Sidebar ${sidebarStyle} - Mode ${themeMode}`,
|
||
url: getCustomizationURL({
|
||
styling: {
|
||
theme,
|
||
...(tint.value ? { tint: { color: tint.value } } : {}),
|
||
sidebar: {
|
||
background: sidebarStyle,
|
||
list: CustomizationSidebarListStyle.Default,
|
||
},
|
||
},
|
||
header: {
|
||
links: headerLinks,
|
||
},
|
||
themes: {
|
||
default: themeMode,
|
||
toggeable: false,
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
}))
|
||
),
|
||
...allSearchStyles.flatMap((searchStyle) => ({
|
||
name: `Theme ${theme} – Search ${searchStyle} – Mode ${themeMode}`,
|
||
url: getCustomizationURL({
|
||
styling: {
|
||
theme,
|
||
search: searchStyle,
|
||
},
|
||
header: {
|
||
links: headerLinks,
|
||
},
|
||
themes: {
|
||
default: themeMode,
|
||
toggeable: false,
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
})),
|
||
]),
|
||
// Deprecated header themes
|
||
...allDeprecatedThemePresets.flatMap((preset) =>
|
||
allSidebarBackgroundStyles.flatMap((sidebarStyle) => ({
|
||
name: `With tint - Legacy header preset ${preset} - Sidebar ${sidebarStyle} - Theme mode ${themeMode}`,
|
||
url: getCustomizationURL({
|
||
styling: {
|
||
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
|
||
sidebar: {
|
||
background: sidebarStyle,
|
||
list: CustomizationSidebarListStyle.Default,
|
||
},
|
||
},
|
||
header: {
|
||
preset,
|
||
...(preset === CustomizationHeaderPreset.Custom
|
||
? {
|
||
backgroundColor: { light: '#C62C68', dark: '#EF96B8' },
|
||
linkColor: { light: '#4DDE98', dark: '#0C693D' },
|
||
}
|
||
: {}),
|
||
links: headerLinks,
|
||
},
|
||
themes: {
|
||
default: themeMode,
|
||
toggeable: false,
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
}))
|
||
),
|
||
{
|
||
name: `With tint - Legacy background match - Theme mode ${themeMode}`,
|
||
url: getCustomizationURL({
|
||
styling: {
|
||
background: CustomizationBackground.Match,
|
||
},
|
||
header: {
|
||
preset: CustomizationHeaderPreset.Default,
|
||
links: headerLinks,
|
||
},
|
||
themes: {
|
||
default: themeMode,
|
||
toggeable: false,
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: `With flat and circular corners - Theme mode ${themeMode}`,
|
||
url: getCustomizationURL({
|
||
styling: {
|
||
depth: CustomizationDepth.Flat,
|
||
corners: CustomizationCorners.Circular,
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
},
|
||
]),
|
||
},
|
||
{
|
||
name: 'Reusable contents',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/reusable-contents/',
|
||
tests: [
|
||
{
|
||
name: 'All cases',
|
||
url: '',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Edit on Git page actions',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/yjs/',
|
||
tests: [
|
||
{
|
||
name: 'With Edit on Git as the default action',
|
||
url: getCustomizationURL({
|
||
pageActions: {
|
||
items: [CustomizationPageActionType.Git],
|
||
},
|
||
}),
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
await expect(
|
||
page.getByRole('link', { name: 'Edit', exact: true })
|
||
).toHaveAttribute(
|
||
'href',
|
||
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
|
||
);
|
||
},
|
||
screenshot: false,
|
||
},
|
||
{
|
||
name: 'With Edit on Git in the dropdown',
|
||
url: getCustomizationURL({
|
||
pageActions: {
|
||
items: [
|
||
CustomizationPageActionType.Markdown,
|
||
CustomizationPageActionType.Git,
|
||
],
|
||
},
|
||
}),
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
await page.getByRole('button', { name: 'More' }).click();
|
||
await expect(page.getByRole('menu')).toBeVisible();
|
||
await expect(
|
||
page.getByRole('menuitem', { name: 'Edit on GitHub' })
|
||
).toHaveAttribute(
|
||
'href',
|
||
'https://github.com/taranvohra/yjs-docs/tree/main/README.md'
|
||
);
|
||
},
|
||
screenshot: false,
|
||
},
|
||
{
|
||
name: 'Without Edit on Git',
|
||
url: getCustomizationURL({
|
||
pageActions: {
|
||
items: [CustomizationPageActionType.Markdown],
|
||
},
|
||
}),
|
||
run: async (page) => {
|
||
await waitForHydration(page);
|
||
await page.getByRole('button', { name: 'More' }).click();
|
||
await expect(page.getByRole('menu')).toBeVisible();
|
||
await expect(
|
||
page.getByRole('menuitem', { name: 'Edit on GitHub' })
|
||
).toHaveCount(0);
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Page actions',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: [
|
||
{
|
||
name: 'Without page actions',
|
||
url: getCustomizationURL({
|
||
pageActions: {
|
||
items: [],
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Social links',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: [
|
||
{
|
||
name: 'With social links',
|
||
url: getCustomizationURL({
|
||
socialAccounts: [
|
||
{
|
||
platform: SiteSocialAccountPlatform.Github,
|
||
display: { footer: true },
|
||
handle: 'GitbookIO',
|
||
},
|
||
{
|
||
platform: SiteSocialAccountPlatform.Linkedin,
|
||
display: { footer: true },
|
||
handle: 'gitbook',
|
||
},
|
||
{
|
||
platform: SiteSocialAccountPlatform.Twitter,
|
||
display: { footer: false },
|
||
handle: 'GitBookIO',
|
||
},
|
||
],
|
||
}),
|
||
fullPage: true,
|
||
run: waitForCookiesDialog,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Ads',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: [
|
||
{
|
||
name: 'Without previewed ads',
|
||
url: 'text-page?ads_preview=1',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Shared space navigation (first site)',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-uno/',
|
||
tests: [
|
||
{
|
||
name: 'Navigation to shared space',
|
||
url: '',
|
||
run: async (page) => {
|
||
const sharedSpaceLink = page.locator('a.underline');
|
||
await sharedSpaceLink.click();
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'shared' })
|
||
).toBeVisible();
|
||
const url = page.url();
|
||
expect(url.includes('shared-space-uno')).toBeTruthy(); // same uno site
|
||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Shared space navigation (second site)',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/shared-space-dos/',
|
||
tests: [
|
||
{
|
||
name: 'Navigation to shared space',
|
||
url: '',
|
||
run: async (page) => {
|
||
await page.locator('a.underline').click();
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'shared' })
|
||
).toBeVisible();
|
||
const url = page.url();
|
||
expect(url.includes('shared-space-dos')).toBeTruthy(); // same dos site
|
||
expect(url.endsWith('/shared')).toBeTruthy(); // correct page
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Site Redirects',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||
tests: [
|
||
{
|
||
name: 'Basic redirect',
|
||
url: 'a/redirect/to/sso',
|
||
run: async (page) => {
|
||
await expect(page.locator('h1')).toHaveText('SSO');
|
||
},
|
||
screenshot: false,
|
||
},
|
||
{
|
||
name: 'Complex wildcard with special characters',
|
||
url: 'foo/bar/baz/123456789-welcome-to-gitbook-%22%20target=%22_blank',
|
||
run: async (page) => {
|
||
await expect(page.locator('h1')).toHaveText('SEO');
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Content Redirects',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/gitbook-doc/',
|
||
tests: [
|
||
{
|
||
name: 'Redirect to new location',
|
||
url: '/content-editor/editing-content/inline/redirect-test',
|
||
run: async (page) => {
|
||
await expect(page.locator('h1')).toHaveText('Redirect test');
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Site Redirects with sections',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/sections/',
|
||
tests: [
|
||
{
|
||
// This test that a redirect that incudes a section path works
|
||
name: 'Redirect to Quickstart page',
|
||
url: 'sections-2/redirect-test',
|
||
run: async (page) => {
|
||
await expect(page.locator('h1')).toContainText('Quickstart');
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Share links',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-tests-share-links/',
|
||
tests: [
|
||
{
|
||
name: 'Valid link',
|
||
url: 'thDznyWXCeEoT55WB7HC/',
|
||
},
|
||
{
|
||
name: 'Invalid link',
|
||
url: 'invalid/',
|
||
run: async (page) => {
|
||
await expect(
|
||
page.getByText('Authentication missing to access this content')
|
||
).toBeVisible();
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Navigation of links on a share links site',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/',
|
||
tests: [
|
||
{
|
||
name: 'Link in same site has the share link token preserved',
|
||
url: '8tNo6MeXg7CkFMzSSz81/link-in-same-site',
|
||
run: async (page) => {
|
||
const linkToDifferentPage = page.getByRole('link', { name: 'Other Page' });
|
||
await linkToDifferentPage.click();
|
||
await page.waitForURL((url) =>
|
||
url.pathname.includes(
|
||
'/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/3.0/other-page'
|
||
)
|
||
);
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'Other Page' })
|
||
).toBeVisible();
|
||
},
|
||
screenshot: false,
|
||
},
|
||
{
|
||
name: 'Link to different site should not have the share link token preserved',
|
||
url: '8tNo6MeXg7CkFMzSSz81/link-in-different-site',
|
||
run: async (page) => {
|
||
const linkToDifferentSite = page.getByRole('link', { name: 'Basics' });
|
||
await linkToDifferentSite.click();
|
||
await page.waitForURL(
|
||
(url) =>
|
||
url.toString() ===
|
||
'https://gitbook-open-e2e-sites.gitbook.io/sections/sections-4/basics/editor'
|
||
);
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'Editor' })
|
||
).toBeVisible();
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Visitor Auth - Space',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
|
||
tests: [
|
||
{
|
||
name: 'First',
|
||
url: () => {
|
||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `first?jwt_token=${token}`;
|
||
},
|
||
run: async (page) => {
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'first' })
|
||
).toBeVisible();
|
||
},
|
||
screenshot: false,
|
||
},
|
||
{
|
||
name: 'Second',
|
||
url: () => {
|
||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `second?jwt_token=${token}`;
|
||
},
|
||
run: async (page) => {
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'second' })
|
||
).toBeVisible();
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Visitor Auth - Space (oversized token)',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
|
||
tests: [
|
||
{
|
||
name: 'Oversized token is chunked into cookies and survives navigation',
|
||
url: () => {
|
||
const privateKey = '70b844d0-c519-4532-8586-5970ce48c537';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
// Inflate the token above the ~4KB browser cookie limit,
|
||
// like an IdP issuing many group claims would.
|
||
groups: Array.from(
|
||
{ length: 60 },
|
||
(_, index) => `group-${index}-${'x'.repeat(80)}`
|
||
),
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `first?jwt_token=${token}`;
|
||
},
|
||
run: async (page) => {
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'first' })
|
||
).toBeVisible();
|
||
|
||
// The token must be persisted as a chunk-count marker plus chunk cookies.
|
||
const cookies = await page.context().cookies();
|
||
// Next.js percent-encodes cookie values, so the raw value is `chunks%3A2`.
|
||
const marker = cookies.find(
|
||
(cookie) =>
|
||
cookie.name.startsWith(VISITOR_TOKEN_COOKIE) &&
|
||
/^chunks(:|%3A)\d+$/.test(cookie.value)
|
||
);
|
||
expect(marker).toBeDefined();
|
||
const chunks = cookies.filter((cookie) =>
|
||
cookie.name.startsWith(`${marker?.name}-`)
|
||
);
|
||
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
||
|
||
// Navigating without the token must authenticate from the chunked cookie.
|
||
// `first` is the space's default page, so the post-sign-in redirect
|
||
// canonicalizes to the space root: derive `second` from that base.
|
||
const secondURL = new URL(page.url());
|
||
const basePathname = secondURL.pathname
|
||
.replace(/\/first\/?$/, '')
|
||
.replace(/\/$/, '');
|
||
secondURL.pathname = `${basePathname}/second`;
|
||
secondURL.search = '';
|
||
// Same reason as the harness navigation: third-party subresources on
|
||
// this site can hang and never fire `load`.
|
||
await page.goto(secondURL.toString(), { waitUntil: 'domcontentloaded' });
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'second' })
|
||
).toBeVisible();
|
||
},
|
||
screenshot: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Visitor Auth - Collection',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-collection/',
|
||
tests: [
|
||
{
|
||
name: 'Root',
|
||
url: () => {
|
||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `?jwt_token=${token}`;
|
||
},
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Primary (Space A)',
|
||
url: () => {
|
||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
|
||
// Test that when accessing the non-canonical URL, we are redirected to the canonical URL
|
||
// with the jwt token in the query string
|
||
return `spacea?jwt_token=${token}`;
|
||
},
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Space B',
|
||
url: () => {
|
||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `spaceb?jwt_token=${token}`;
|
||
},
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Space C',
|
||
url: () => {
|
||
const privateKey = 'af5688dc-f0b6-4146-9b1d-6d834c62c980';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `spacec?jwt_token=${token}`;
|
||
},
|
||
run: waitForCookiesDialog,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Visitor Auth - Space (custom domain)',
|
||
contentBaseURL: 'https://test.gitbook.community/',
|
||
tests: [
|
||
{
|
||
name: 'Root',
|
||
url: () => {
|
||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `?jwt_token=${token}`;
|
||
},
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'First',
|
||
url: () => {
|
||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `first?jwt_token=${token}`;
|
||
},
|
||
run: async (page) => {
|
||
await expect(
|
||
page.getByRole('heading', { level: 1, name: 'first' })
|
||
).toBeVisible();
|
||
},
|
||
screenshot: false,
|
||
},
|
||
{
|
||
name: 'Custom page',
|
||
url: () => {
|
||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `custom-page?jwt_token=${token}`;
|
||
},
|
||
run: waitForCookiesDialog,
|
||
},
|
||
{
|
||
name: 'Inner page',
|
||
url: () => {
|
||
const privateKey = '19c8166f-c436-4ed1-a24e-60954b804021';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `custom-page/inner-page?jwt_token=${token}`;
|
||
},
|
||
run: waitForCookiesDialog,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Visitor Auth - Site (redirects to fallback/auth URL)',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/va-site-redirects-fallback/',
|
||
tests: [
|
||
// This test does not work on Playwright
|
||
// Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
|
||
// @see https://github.com/microsoft/playwright/issues/34889
|
||
// {
|
||
// name: 'Redirect to fallback on invalid token pulled from cookie',
|
||
// url: '',
|
||
// screenshot: false,
|
||
// cookies: (() => {
|
||
// const basePath = '/va-site-redirects-fallback/';
|
||
// const invalidToken = jwt.sign(
|
||
// {
|
||
// name: 'gitbook-open-tests',
|
||
// },
|
||
// 'invalidKey',
|
||
// {
|
||
// expiresIn: '24h',
|
||
// }
|
||
// );
|
||
// return [
|
||
// {
|
||
// name: getVisitorAuthCookieName(basePath),
|
||
// value: getVisitorAuthCookieValue(basePath, invalidToken),
|
||
// httpOnly: true,
|
||
// },
|
||
// ];
|
||
// })(),
|
||
// run: async (page) => {
|
||
// await expect(page).toHaveURL(/https:\/\/www.google.com/);
|
||
// },
|
||
// },
|
||
{
|
||
name: 'Show error message when invalid token is passed to url',
|
||
screenshot: false,
|
||
url: () => {
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
},
|
||
'invalidKey',
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `?jwt_token=${token}`;
|
||
},
|
||
run: async (page) => {
|
||
await expect(page.locator('pre')).toContainText(
|
||
'Error while validating the JWT token. Reason: The token signature is invalid.'
|
||
);
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Languages',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: allLocales.map((locale) => ({
|
||
name: locale,
|
||
url: getCustomizationURL({
|
||
internationalization: {
|
||
locale,
|
||
},
|
||
}),
|
||
run: waitForCookiesDialog,
|
||
})),
|
||
},
|
||
{
|
||
name: 'SEO',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: [
|
||
{
|
||
name: 'Index by default',
|
||
url: '?x-gitbook-search-indexation=true',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
const metaRobots = page.locator('meta[name="robots"]');
|
||
await expect(metaRobots).toHaveAttribute('content', 'index, follow');
|
||
},
|
||
},
|
||
{
|
||
name: `Don't index noIndex`,
|
||
url: 'page-options/page-no-index?x-gitbook-search-indexation=true',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
const metaRobots = page.locator('meta[name="robots"]');
|
||
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
|
||
},
|
||
},
|
||
{
|
||
name: `Don't index descendant of noIndex`,
|
||
url: 'page-options/page-no-index/descendant-of-page-no-index?x-gitbook-search-indexation=true',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
const metaRobots = page.locator('meta[name="robots"]');
|
||
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
|
||
},
|
||
},
|
||
{
|
||
name: `Don't index noRobotsIndex`,
|
||
url: 'page-options/page-no-robots-index?x-gitbook-search-indexation=true',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
const metaRobots = page.locator('meta[name="robots"]');
|
||
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
|
||
},
|
||
},
|
||
{
|
||
name: `Don't index descendant of noRobotsIndex`,
|
||
url: 'page-options/page-no-robots-index/descendant-of-page-no-robots-index?x-gitbook-search-indexation=true',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
const metaRobots = page.locator('meta[name="robots"]');
|
||
await expect(metaRobots).toHaveAttribute('content', 'noindex, nofollow');
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Adaptive Content - VA',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-va/',
|
||
tests: [
|
||
{
|
||
name: 'isAlphaUser',
|
||
url: () => {
|
||
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
isAlphaUser: true,
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `?jwt_token=${token}`;
|
||
},
|
||
run: async (page) => {
|
||
const alphaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Alpha users' });
|
||
const betaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Beta users' });
|
||
await expect(alphaUserPage).toBeVisible();
|
||
await expect(betaUserPage).toHaveCount(0);
|
||
},
|
||
},
|
||
{
|
||
name: 'isBetaUser',
|
||
url: () => {
|
||
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
isBetaUser: true,
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `?jwt_token=${token}`;
|
||
},
|
||
run: async (page) => {
|
||
const alphaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Alpha users' });
|
||
const betaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Beta users' });
|
||
await expect(betaUserPage).toBeVisible();
|
||
await expect(alphaUserPage).toHaveCount(0);
|
||
},
|
||
},
|
||
{
|
||
name: 'isAlphaUser & isBetaUser',
|
||
url: () => {
|
||
const privateKey = 'afe09cdf-0f43-480a-b54c-8b1f62f174f9';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
isAlphaUser: true,
|
||
isBetaUser: true,
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return `?jwt_token=${token}`;
|
||
},
|
||
run: async (page) => {
|
||
const alphaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Alpha users' });
|
||
const betaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Beta users' });
|
||
await expect(alphaUserPage).toBeVisible();
|
||
await expect(betaUserPage).toBeVisible();
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Adaptive Content - Public',
|
||
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/adaptive-content-public/',
|
||
tests: [
|
||
{
|
||
name: 'No custom cookie',
|
||
url: '',
|
||
run: async (page) => {
|
||
const welcomePage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Welcome Page' });
|
||
const alphaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Alpha User' });
|
||
const betaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Beta User' });
|
||
|
||
await expect(welcomePage).toBeVisible();
|
||
await expect(alphaUserPage).toHaveCount(0);
|
||
await expect(betaUserPage).toHaveCount(0);
|
||
},
|
||
},
|
||
{
|
||
name: 'Custom cookie with isAlphaUser claim',
|
||
cookies: (() => {
|
||
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
isAlphaUser: true,
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return [
|
||
{
|
||
name: VISITOR_TOKEN_COOKIE,
|
||
value: token,
|
||
httpOnly: true,
|
||
},
|
||
];
|
||
})(),
|
||
url: '',
|
||
run: async (page) => {
|
||
const welcomePage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Welcome Page' });
|
||
const alphaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Alpha User' });
|
||
const betaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Beta User' });
|
||
|
||
await expect(welcomePage).toBeVisible();
|
||
await expect(alphaUserPage).toBeVisible();
|
||
await expect(betaUserPage).toHaveCount(0);
|
||
},
|
||
},
|
||
{
|
||
name: 'Custom cookie with isBetaUser claim',
|
||
cookies: (() => {
|
||
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
isBetaUser: true,
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return [
|
||
{
|
||
name: VISITOR_TOKEN_COOKIE,
|
||
value: token,
|
||
httpOnly: true,
|
||
},
|
||
];
|
||
})(),
|
||
url: '',
|
||
run: async (page) => {
|
||
const welcomePage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Welcome Page' });
|
||
const alphaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Alpha User' });
|
||
const betaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Beta User' });
|
||
|
||
await expect(welcomePage).toBeVisible();
|
||
await expect(betaUserPage).toBeVisible();
|
||
await expect(alphaUserPage).toHaveCount(0);
|
||
},
|
||
},
|
||
{
|
||
name: 'Custom cookie with isAlphaUser & isBetaUser claims',
|
||
cookies: (() => {
|
||
const privateKey = '4ddd3c2f-e4b7-4e73-840b-526c3be19746';
|
||
const token = jwt.sign(
|
||
{
|
||
name: 'gitbook-open-tests',
|
||
isAlphaUser: true,
|
||
isBetaUser: true,
|
||
},
|
||
privateKey,
|
||
{
|
||
expiresIn: '24h',
|
||
}
|
||
);
|
||
return [
|
||
{
|
||
name: VISITOR_TOKEN_COOKIE,
|
||
value: token,
|
||
httpOnly: true,
|
||
},
|
||
];
|
||
})(),
|
||
url: '',
|
||
run: async (page) => {
|
||
const welcomePage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Welcome Page' });
|
||
const alphaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Alpha User' });
|
||
const betaUserPage = page
|
||
.locator('a[class*="group\\/toclink"]')
|
||
.filter({ hasText: 'Beta User' });
|
||
|
||
await expect(welcomePage).toBeVisible();
|
||
await expect(betaUserPage).toBeVisible();
|
||
await expect(alphaUserPage).toBeVisible();
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Tables',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||
tests: [
|
||
{
|
||
name: 'Default table',
|
||
url: 'blocks/tables',
|
||
run: waitForCookiesDialog,
|
||
fullPage: true,
|
||
},
|
||
{
|
||
name: 'Table with straight corners',
|
||
url: `blocks/tables${getCustomizationURL({
|
||
styling: {
|
||
corners: CustomizationCorners.Straight,
|
||
},
|
||
})}`,
|
||
run: waitForCookiesDialog,
|
||
fullPage: true,
|
||
},
|
||
{
|
||
name: 'Table with primary color',
|
||
url: `blocks/tables${getCustomizationURL({
|
||
styling: {
|
||
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
|
||
},
|
||
})}`,
|
||
run: waitForCookiesDialog,
|
||
fullPage: true,
|
||
},
|
||
// Test dark mode for each variant
|
||
...allThemeModes.flatMap((theme) => [
|
||
{
|
||
name: `Table in ${theme} mode`,
|
||
url: `blocks/tables${getCustomizationURL({
|
||
themes: {
|
||
default: theme,
|
||
toggeable: false,
|
||
},
|
||
})}`,
|
||
run: waitForCookiesDialog,
|
||
fullPage: true,
|
||
},
|
||
{
|
||
name: `Table with straight corners in ${theme} mode`,
|
||
url: `blocks/tables${getCustomizationURL({
|
||
styling: {
|
||
corners: CustomizationCorners.Straight,
|
||
},
|
||
themes: {
|
||
default: theme,
|
||
toggeable: false,
|
||
},
|
||
})}`,
|
||
run: waitForCookiesDialog,
|
||
fullPage: true,
|
||
},
|
||
{
|
||
name: `Table with primary color in ${theme} mode`,
|
||
url: `blocks/tables${getCustomizationURL({
|
||
styling: {
|
||
tint: { color: { light: '#346DDB', dark: '#346DDB' } },
|
||
},
|
||
themes: {
|
||
default: theme,
|
||
toggeable: false,
|
||
},
|
||
})}`,
|
||
run: waitForCookiesDialog,
|
||
fullPage: true,
|
||
},
|
||
]),
|
||
],
|
||
},
|
||
{
|
||
name: 'Docs Embed - Basic',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~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: 'Only tracks ask_view once the widget is opened',
|
||
// `trigger=custom` loads the frame but leaves the window closed.
|
||
url: '?trigger=custom',
|
||
screenshot: false,
|
||
run: async (page) => {
|
||
const askViews = trackInsightsEvents(page, 'ask_view');
|
||
const chat = page.frameLocator('#gitbook-widget-iframe').getByTestId('ai-chat');
|
||
|
||
// The assistant renders inside the hidden frame, but nobody has seen it.
|
||
await expect(chat).toBeAttached({ timeout: 20000 });
|
||
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
|
||
expect(askViews).toHaveLength(0);
|
||
|
||
await page.getByRole('button', { name: 'Open' }).click();
|
||
await expect(chat).toBeVisible();
|
||
await expect.poll(() => askViews.length, { timeout: 20000 }).toBe(1);
|
||
|
||
// Hiding and showing the same frame again is not a second view.
|
||
await page.getByRole('button', { name: 'Close' }).click();
|
||
await expect(chat).toBeHidden();
|
||
await page.getByRole('button', { name: 'Open' }).click();
|
||
await expect(chat).toBeVisible();
|
||
await page.waitForTimeout(INSIGHTS_FLUSH_TIMEOUT);
|
||
expect(askViews).toHaveLength(1);
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
name: 'Docs Embed - Assistant + Docs',
|
||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
|
||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||
tests: [
|
||
{
|
||
name: 'Switch between tabs',
|
||
url: '',
|
||
run: async (page) => {
|
||
await setTimeToMorning(page);
|
||
await page.reload();
|
||
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();
|
||
},
|
||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||
normalizeBeforeScreenshot: (page) =>
|
||
page
|
||
.frameLocator('#gitbook-widget-iframe')
|
||
.locator('body')
|
||
.evaluate(overrideAIInitialState),
|
||
},
|
||
{
|
||
name: 'API - navigateToPage',
|
||
url: '',
|
||
run: async (page) => {
|
||
await page.evaluate(() => {
|
||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||
GitBook('navigateToPage', '/text-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,
|
||
});
|
||
await expect(iframe.owner()).toHaveAttribute(
|
||
'src',
|
||
expect.stringContaining('text-page')
|
||
);
|
||
},
|
||
},
|
||
{
|
||
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
|
||
);
|
||
// Wait for the full response to settle before normalizing.
|
||
await waitForAIChatResponse(iframe);
|
||
},
|
||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||
normalizeBeforeScreenshot: (page) =>
|
||
page
|
||
.frameLocator('#gitbook-widget-iframe')
|
||
.locator('body')
|
||
.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?');
|
||
},
|
||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||
normalizeBeforeScreenshot: (page) =>
|
||
page
|
||
.frameLocator('#gitbook-widget-iframe')
|
||
.locator('body')
|
||
.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', '/text-page');
|
||
},
|
||
},
|
||
{
|
||
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('text-page')
|
||
);
|
||
|
||
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
|
||
await expect(newPage).toHaveURL(/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();
|
||
// Wait for the response posted above to settle before normalizing.
|
||
await waitForAIChatResponse(iframe);
|
||
},
|
||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||
normalizeBeforeScreenshot: (page) =>
|
||
page
|
||
.frameLocator('#gitbook-widget-iframe')
|
||
.locator('body')
|
||
.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-confirm-accept')
|
||
.first();
|
||
await expect(toolConfirmation).toBeVisible({
|
||
timeout: 30000,
|
||
});
|
||
// The turn settles (aria-busy clears) once the stream pauses on the
|
||
// confirmation control; wait for that before normalizing.
|
||
await waitForAIChatResponse(iframe);
|
||
},
|
||
// Runs inside the iframe (not the parent doc) and per viewport.
|
||
normalizeBeforeScreenshot: (page) =>
|
||
page
|
||
.frameLocator('#gitbook-widget-iframe')
|
||
.locator('body')
|
||
.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 page.evaluate(() => {
|
||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||
GitBook('configure', {
|
||
tabs: ['docs'],
|
||
});
|
||
});
|
||
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 page.evaluate(() => {
|
||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||
GitBook('configure', {
|
||
tabs: ['docs'],
|
||
});
|
||
});
|
||
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 page.evaluate(() => {
|
||
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||
GitBook('configure', {
|
||
tabs: ['docs'],
|
||
});
|
||
});
|
||
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
|
||
await expect(newPage).toHaveURL(/gitbook\.gitbook\.io/);
|
||
// Close it immediately to avoid navigation
|
||
await newPage.close();
|
||
},
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
runTestCases(testCases);
|