From 3d1eb53708c71b2673598cdfef7216f8a7336fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 14 Feb 2025 17:00:48 +0100 Subject: [PATCH] Run visual testing on customers docs (#2833) Co-authored-by: Valentino Hudhra Co-authored-by: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com> --- .github/workflows/ci.yaml | 25 ++ package.json | 1 + packages/gitbook/e2e/customers.spec.ts | 240 +++++++++++++ .../e2e/{pages.spec.ts => internal.spec.ts} | 334 +----------------- packages/gitbook/e2e/util.ts | 332 +++++++++++++++++ packages/gitbook/package.json | 3 +- turbo.json | 5 + 7 files changed, 620 insertions(+), 320 deletions(-) create mode 100644 packages/gitbook/e2e/customers.spec.ts rename packages/gitbook/e2e/{pages.spec.ts => internal.spec.ts} (82%) create mode 100644 packages/gitbook/e2e/util.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 23154b59f..1548a6ba4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -253,6 +253,31 @@ jobs: name: playwright-test-results path: packages/gitbook/test-results/ retention-days: 3 + visual-testing-customers: + runs-on: ubuntu-latest + name: Visual Testing Customers + needs: deploy + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Bun + uses: ./.github/composite/setup-bun + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Setup Playwright + uses: ./.github/actions/setup-playwright + - name: Run Playwright tests + run: bun e2e-customers + env: + BASE_URL: ${{needs.deploy.outputs.deployment-url}} + ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} + ARGOS_BUILD_NAME: 'customers' + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-test-results-customers + path: packages/gitbook/test-results/ + retention-days: 3 pagespeed-testing: runs-on: ubuntu-latest name: PageSpeed Testing diff --git a/package.json b/package.json index 7f70cd3fa..2dcb41b7d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "format:check": "prettier ./ --ignore-unknown --list-different", "unit": "turbo run unit", "e2e": "turbo run e2e", + "e2e-customers": "turbo run e2e-customers", "changeset": "changeset", "release": "turbo run release && changeset publish", "release:preview": "turbo run release:preview", diff --git a/packages/gitbook/e2e/customers.spec.ts b/packages/gitbook/e2e/customers.spec.ts new file mode 100644 index 000000000..30d5aef6b --- /dev/null +++ b/packages/gitbook/e2e/customers.spec.ts @@ -0,0 +1,240 @@ +import { runTestCases, TestsCase, waitForCookiesDialog } from './util'; + +/** A list of test cases to run on the customers' docs sites. */ +const testCases: TestsCase[] = [ + { + name: 'Snyk', + baseUrl: 'https://docs.snyk.io', + tests: [ + { name: 'Home', url: '/' }, + { name: 'OpenAPI', url: '/snyk-api/reference/apps' }, + ], + }, + { + name: 'Nexthink', + baseUrl: 'https://docs.nexthink.com', + tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }], + }, + { + name: 'asiksupport-stg.dto.kemkes.go.id', + baseUrl: 'https://asiksupport-stg.dto.kemkes.go.id', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'jasons-tutorials.gitbook.io', + baseUrl: 'https://jasons-tutorials.gitbook.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'faq.deltaemulator.com', + baseUrl: 'https://faq.deltaemulator.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.dify.ai', + baseUrl: 'https://docs.dify.ai', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'seeddao.gitbook.io', + baseUrl: 'https://seeddao.gitbook.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'faq.altstore.io', + baseUrl: 'https://faq.altstore.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'support.audacityteam.org', + baseUrl: 'https://support.audacityteam.org', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.gmgn.ai', + baseUrl: 'https://docs.gmgn.ai', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.spicychat.ai', + baseUrl: 'https://docs.spicychat.ai', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.portainer.io', + baseUrl: 'https://docs.portainer.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.chirptoken.io', + baseUrl: 'https://docs.chirptoken.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.dexscreener.com', + baseUrl: 'https://docs.dexscreener.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.pancakeswap.finance', + baseUrl: 'https://docs.pancakeswap.finance', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'book.character.ai', + baseUrl: 'https://book.character.ai', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.tradeonnova.io', + baseUrl: 'https://docs.tradeonnova.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'azcoiner.gitbook.io', + baseUrl: 'https://azcoiner.gitbook.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.midas.app', + baseUrl: 'https://docs.midas.app', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.keeper.io', + baseUrl: 'https://docs.keeper.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'adiblar.gitbook.io', + baseUrl: 'https://adiblar.gitbook.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.gradient.network', + baseUrl: 'https://docs.gradient.network', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'mygate-network.gitbook.io', + baseUrl: 'https://mygate-network.gitbook.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'treasurenft.gitbook.io', + baseUrl: 'https://treasurenft.gitbook.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'browndust2.gitbook.io', + baseUrl: 'https://browndust2.gitbook.io', + tests: [{ name: 'Home', url: '/', screenshot: { waitForTOCScrolling: false } }], + }, + { + name: 'junookyo.gitbook.io', + baseUrl: 'https://junookyo.gitbook.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'meshnet.nordvpn.com', + baseUrl: 'https://meshnet.nordvpn.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'manual.bubble.io', + baseUrl: 'https://manual.bubble.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.tickettool.xyz', + baseUrl: 'https://docs.tickettool.xyz', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'wiki.redmodding.org', + baseUrl: 'https://wiki.redmodding.org', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.cherry-ai.com', + baseUrl: 'https://docs.cherry-ai.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.snyk.io', + baseUrl: 'https://docs.snyk.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.realapp.link', + baseUrl: 'https://docs.realapp.link', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.plaza.finance', + baseUrl: 'https://docs.plaza.finance', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.publicai.io', + baseUrl: 'https://docs.publicai.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'hyperliquid.gitbook.io', + baseUrl: 'https://hyperliquid.gitbook.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.umbraco.com', + baseUrl: 'https://docs.umbraco.com', + tests: [{ name: 'Home', url: '/welcome', screenshot: { waitForTOCScrolling: false } }], + }, + { + name: 'sosovalue-white-paper.gitbook.io', + baseUrl: 'https://sosovalue-white-paper.gitbook.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.revrobotics.com', + baseUrl: 'https://docs.revrobotics.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'chartschool.stockcharts.com', + baseUrl: 'https://chartschool.stockcharts.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.soniclabs.com', + baseUrl: 'https://docs.soniclabs.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.meshchain.ai', + baseUrl: 'https://docs.meshchain.ai', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.thousandeyes.com', + baseUrl: 'https://docs.thousandeyes.com', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.raydium.io', + baseUrl: 'https://docs.raydium.io', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.solv.finance', + baseUrl: 'https://docs.solv.finance', + tests: [{ name: 'Home', url: '/' }], + }, + { + name: 'docs.fluentbit.io', + baseUrl: 'https://docs.fluentbit.io', + tests: [{ name: 'Home', url: '/' }], + }, +]; + +runTestCases(testCases); diff --git a/packages/gitbook/e2e/pages.spec.ts b/packages/gitbook/e2e/internal.spec.ts similarity index 82% rename from packages/gitbook/e2e/pages.spec.ts rename to packages/gitbook/e2e/internal.spec.ts index b4356ae8c..a12b35ad1 100644 --- a/packages/gitbook/e2e/pages.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -1,25 +1,12 @@ -import { argosScreenshot } from '@argos-ci/playwright'; import { CustomizationBackground, - CustomizationContentLink, CustomizationCorners, - CustomizationFont, - CustomizationHeaderItem, CustomizationHeaderPreset, CustomizationIconsStyle, - CustomizationLocale, - CustomizationSidebarBackgroundStyle, CustomizationSidebarListStyle, - CustomizationTheme, - CustomizationThemedColor, - CustomizationThemeMode, - SiteCustomizationSettings, } from '@gitbook/api'; -import { test, expect, Page, BrowserContext } from '@playwright/test'; -import deepMerge from 'deepmerge'; +import { expect } from '@playwright/test'; import jwt from 'jsonwebtoken'; -import rison from 'rison'; -import { DeepPartial } from 'ts-essentials'; import { getVisitorAuthCookieName, @@ -27,119 +14,19 @@ import { VISITOR_TOKEN_COOKIE, } from '@/lib/visitor-token'; -import { getContentTestURL } from '../tests/utils'; - -interface Test { - name: string; - /** - * URL to visit for testing. - */ - url: string; - cookies?: Parameters[0]; - /** - * Test to run - */ - run?: (page: Page) => Promise; - /** - * Whether the test should be fullscreened during testing. - */ - fullPage?: boolean; - /** - * Whether to take a screenshot of the test or set a threshold for the screenshot. - */ - screenshot?: - | false - | { - /** - * Screenshot threshold. - * From 0 to 1, where 0 is the most strict and 1 is the most permissive. - * @default 0.5 - */ - threshold?: number; - /** - * Whether to wait for the table of contents to finish scrolling before taking the screenshot. - */ - waitForTOCScrolling?: boolean; - }; - /** - * Whether to only run this test. - */ - only?: boolean; -} - -interface TestsCase { - name: string; - baseUrl: string; - tests: Array; -} - -const allLocales: CustomizationLocale[] = [ - CustomizationLocale.Fr, - CustomizationLocale.Es, - CustomizationLocale.Ja, - CustomizationLocale.Zh, -]; - -const allThemeModes: CustomizationThemeMode[] = [ - CustomizationThemeMode.Light, - CustomizationThemeMode.Dark, -]; - -const allTintColors: Array<{ - label: string; - value: CustomizationThemedColor | undefined; -}> = [ - { - label: 'Off', - value: undefined, - }, - { label: 'Primary', value: { light: '#346DDB', dark: '#346DDB' } }, - { label: 'Custom', value: { light: '#C62C68', dark: '#EF96B8' } }, -]; - -const allThemes: CustomizationTheme[] = [ - CustomizationTheme.Clean, - CustomizationTheme.Muted, - CustomizationTheme.Bold, - CustomizationTheme.Gradient, -]; - -const allDeprecatedThemePresets: CustomizationHeaderPreset[] = [ - CustomizationHeaderPreset.Default, - CustomizationHeaderPreset.Bold, - CustomizationHeaderPreset.Contrast, - CustomizationHeaderPreset.Custom, -]; - -const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] = [ - CustomizationSidebarBackgroundStyle.Default, - CustomizationSidebarBackgroundStyle.Filled, -]; - -// Common customization settings - -const headerLinks: CustomizationHeaderItem[] = [ - { - title: 'Secondary button', - to: { kind: 'url', url: 'https://www.gitbook.com' }, - style: 'button-secondary', - links: [], - }, - { - title: 'Primary button', - to: { kind: 'url', url: 'https://www.gitbook.com' }, - style: 'button-primary', - links: [], - }, -]; - -async function waitForCookiesDialog(page: Page) { - const dialog = page.getByRole('dialog', { name: 'Cookies' }); - const accept = dialog.getByRole('button', { name: 'Accept' }); - const reject = dialog.getByRole('button', { name: 'Reject' }); - await expect(accept).toBeVisible(); - await expect(reject).toBeVisible(); -} +import { + allDeprecatedThemePresets, + allLocales, + allSidebarBackgroundStyles, + allThemeModes, + allThemes, + allTintColors, + getCustomizationURL, + headerLinks, + runTestCases, + TestsCase, + waitForCookiesDialog, +} from './util'; const testCases: TestsCase[] = [ { @@ -1461,195 +1348,4 @@ const testCases: TestsCase[] = [ }, ]; -for (const testCase of testCases) { - test.describe(testCase.name, () => { - for (const testEntry of testCase.tests) { - const testFn = testEntry.only ? test.only : test; - testFn(testEntry.name, async ({ page, baseURL, context }) => { - const contentUrl = new URL(testEntry.url, testCase.baseUrl); - const url = getContentTestURL(contentUrl.toString(), baseURL); - if (testEntry.cookies) { - await context.addCookies( - testEntry.cookies.map((cookie) => ({ - ...cookie, - domain: new URL(url).host, - path: '/', - })), - ); - } - await page.goto(url); - if (testEntry.run) { - await testEntry.run(page); - } - const screenshotOptions = testEntry.screenshot; - if (screenshotOptions !== false) { - await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, { - viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'], - argosCSS: ` - /* Hide Intercom */ - .intercom-lightweight-app { - display: none !important; - } - `, - threshold: screenshotOptions?.threshold ?? undefined, - fullPage: testEntry.fullPage ?? false, - beforeScreenshot: async () => { - await waitForIcons(page); - if (screenshotOptions?.waitForTOCScrolling !== false) { - await waitForTOCScrolling(page); - } - }, - }); - } - }); - } - }); -} - -/** - * Create a URL with customization settings. - */ -function getCustomizationURL(partial: DeepPartial): string { - // We replicate the theme migration logic from the API to the tests, because the don't get these settings from the API. - // We can remove this once the migration to the new themes have been completed and the new theme styles are verified - // Map the theme preset (+ tint) to one of the new themes - const newTheme = (() => { - if (partial.styling?.theme) { - return partial.styling.theme; - } - - switch (partial.header?.preset) { - case CustomizationHeaderPreset.Bold: - case CustomizationHeaderPreset.Contrast: - case CustomizationHeaderPreset.Custom: - return CustomizationTheme.Bold; - - case CustomizationHeaderPreset.None: - case CustomizationHeaderPreset.Default: - if (partial.styling?.tint) { - return CustomizationTheme.Muted; - } - - return CustomizationTheme.Clean; - default: - return CustomizationTheme.Clean; - } - })(); - - /** - * Default customization settings. - * - * The customization object passed to the URL should be a valid API settings object. Hence we extend the test with necessary defaults. - */ - const DEFAULT_CUSTOMIZATION: SiteCustomizationSettings = { - styling: { - theme: newTheme, - primaryColor: { light: '#346DDB', dark: '#346DDB' }, - corners: CustomizationCorners.Rounded, - font: CustomizationFont.Inter, - background: CustomizationBackground.Plain, - icons: CustomizationIconsStyle.Regular, - sidebar: { - background: CustomizationSidebarBackgroundStyle.Default, - list: CustomizationSidebarListStyle.Default, - }, - }, - internationalization: { - locale: CustomizationLocale.En, - }, - favicon: {}, - header: { - preset: CustomizationHeaderPreset.Default, - links: [], - }, - footer: { - groups: [], - }, - themes: { - default: CustomizationThemeMode.Light, - toggeable: true, - }, - pdf: { - enabled: true, - }, - feedback: { - enabled: false, - }, - aiSearch: { - enabled: true, - }, - advancedCustomization: { - enabled: true, - }, - git: { - showEditLink: false, - }, - pagination: { - enabled: true, - }, - trademark: { - enabled: true, - }, - privacyPolicy: { - url: 'https://www.gitbook.com/privacy', - }, - socialPreview: {}, - }; - - const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial)); - - const searchParams = new URLSearchParams(); - searchParams.set('customization', encoded); - - return `?${searchParams.toString()}`; -} - -/** - * Wait for all icons present on the page to be loaded. - */ -async function waitForIcons(page: Page) { - await page.waitForFunction(async () => { - function loadImage(src: string) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = (error) => reject(new Error(`Failed to load image: ${src}`)); - img.src = src; - }); - } - - const icons = Array.from(document.querySelectorAll('svg.gb-icon')); - await Promise.all( - icons.map(async (icon) => { - // url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93") - const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image'); - const urlMatch = maskImage.match(/url\("([^"]+)"\)/); - const url = urlMatch ? urlMatch[1] : null; - if (!url) { - throw new Error('No mask-image'); - } - await loadImage(url); - }), - ); - }); -} - -/** - * Wait for TOC to be correctly scrolled into view. - */ -async function waitForTOCScrolling(page: Page) { - const viewport = await page.viewportSize(); - if (viewport && viewport.width >= 1024) { - const toc = page.getByTestId('table-of-contents'); - await expect(toc).toBeVisible(); - await page.evaluate(() => { - const tocScrollContainer = document.querySelector( - '[data-testid="table-of-contents"] [data-testid="toc-scroll-container"]', - ); - if (!tocScrollContainer) { - throw new Error('TOC scroll container not found'); - } - tocScrollContainer.scrollTo(0, 0); - }); - } -} +runTestCases(testCases); diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts new file mode 100644 index 000000000..8ddd2ddbb --- /dev/null +++ b/packages/gitbook/e2e/util.ts @@ -0,0 +1,332 @@ +import { argosScreenshot } from '@argos-ci/playwright'; +import { + CustomizationBackground, + CustomizationCorners, + CustomizationFont, + CustomizationHeaderItem, + CustomizationHeaderPreset, + CustomizationIconsStyle, + CustomizationLocale, + CustomizationSidebarBackgroundStyle, + CustomizationSidebarListStyle, + CustomizationTheme, + CustomizationThemedColor, + CustomizationThemeMode, + SiteCustomizationSettings, +} from '@gitbook/api'; +import { test, expect, Page, BrowserContext } from '@playwright/test'; +import deepMerge from 'deepmerge'; +import rison from 'rison'; +import { DeepPartial } from 'ts-essentials'; + +import { getContentTestURL } from '../tests/utils'; + +export interface Test { + name: string; + /** + * URL to visit for testing. + */ + url: string; + cookies?: Parameters[0]; + /** + * Test to run + */ + run?: (page: Page) => Promise; + /** + * Whether the test should be fullscreened during testing. + */ + fullPage?: boolean; + /** + * Whether to take a screenshot of the test or set a threshold for the screenshot. + */ + screenshot?: + | false + | { + /** + * Screenshot threshold. + * From 0 to 1, where 0 is the most strict and 1 is the most permissive. + * @default 0.5 + */ + threshold?: number; + /** + * Whether to wait for the table of contents to finish scrolling before taking the screenshot. + */ + waitForTOCScrolling?: boolean; + }; + /** + * Whether to only run this test. + */ + only?: boolean; +} + +export interface TestsCase { + name: string; + baseUrl: string; + tests: Array; +} + +export const allLocales: CustomizationLocale[] = [ + CustomizationLocale.Fr, + CustomizationLocale.Es, + CustomizationLocale.Ja, + CustomizationLocale.Zh, +]; + +export const allThemeModes: CustomizationThemeMode[] = [ + CustomizationThemeMode.Light, + CustomizationThemeMode.Dark, +]; + +export const allTintColors: Array<{ + label: string; + value: CustomizationThemedColor | undefined; +}> = [ + { + label: 'Off', + value: undefined, + }, + { label: 'Primary', value: { light: '#346DDB', dark: '#346DDB' } }, + { label: 'Custom', value: { light: '#C62C68', dark: '#EF96B8' } }, +]; + +export const allThemes: CustomizationTheme[] = [ + CustomizationTheme.Clean, + CustomizationTheme.Muted, + CustomizationTheme.Bold, + CustomizationTheme.Gradient, +]; + +export const allDeprecatedThemePresets: CustomizationHeaderPreset[] = [ + CustomizationHeaderPreset.Default, + CustomizationHeaderPreset.Bold, + CustomizationHeaderPreset.Contrast, + CustomizationHeaderPreset.Custom, +]; + +export const allSidebarBackgroundStyles: CustomizationSidebarBackgroundStyle[] = [ + CustomizationSidebarBackgroundStyle.Default, + CustomizationSidebarBackgroundStyle.Filled, +]; + +// Common customization settings + +export const headerLinks: CustomizationHeaderItem[] = [ + { + title: 'Secondary button', + to: { kind: 'url', url: 'https://www.gitbook.com' }, + style: 'button-secondary', + links: [], + }, + { + title: 'Primary button', + to: { kind: 'url', url: 'https://www.gitbook.com' }, + style: 'button-primary', + links: [], + }, +]; + +export async function waitForCookiesDialog(page: Page) { + const dialog = page.getByRole('dialog', { name: 'Cookies' }); + const accept = dialog.getByRole('button', { name: 'Accept' }); + const reject = dialog.getByRole('button', { name: 'Reject' }); + await expect(accept).toBeVisible(); + await expect(reject).toBeVisible(); +} + +/** + * Transform test cases into Playwright tests and run it. + */ +export function runTestCases(testCases: TestsCase[]) { + for (const testCase of testCases) { + test.describe(testCase.name, () => { + for (const testEntry of testCase.tests) { + const testFn = testEntry.only ? test.only : test; + testFn(testEntry.name, async ({ page, baseURL, context }) => { + const contentUrl = new URL(testEntry.url, testCase.baseUrl); + const url = getContentTestURL(contentUrl.toString(), baseURL); + if (testEntry.cookies) { + await context.addCookies( + testEntry.cookies.map((cookie) => ({ + ...cookie, + domain: new URL(url).host, + path: '/', + })), + ); + } + await page.goto(url); + if (testEntry.run) { + await testEntry.run(page); + } + const screenshotOptions = testEntry.screenshot; + if (screenshotOptions !== false) { + await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, { + viewports: ['macbook-16', 'macbook-13', 'ipad-2', 'iphone-x'], + argosCSS: ` + /* Hide Intercom */ + .intercom-lightweight-app { + display: none !important; + } + `, + threshold: screenshotOptions?.threshold ?? undefined, + fullPage: testEntry.fullPage ?? false, + beforeScreenshot: async () => { + await waitForIcons(page); + if (screenshotOptions?.waitForTOCScrolling !== false) { + await waitForTOCScrolling(page); + } + }, + }); + } + }); + } + }); + } +} + +/** + * Create a URL with customization settings. + */ +export function getCustomizationURL(partial: DeepPartial): string { + // We replicate the theme migration logic from the API to the tests, because the don't get these settings from the API. + // We can remove this once the migration to the new themes have been completed and the new theme styles are verified + // Map the theme preset (+ tint) to one of the new themes + const newTheme = (() => { + if (partial.styling?.theme) { + return partial.styling.theme; + } + + switch (partial.header?.preset) { + case CustomizationHeaderPreset.Bold: + case CustomizationHeaderPreset.Contrast: + case CustomizationHeaderPreset.Custom: + return CustomizationTheme.Bold; + + case CustomizationHeaderPreset.None: + case CustomizationHeaderPreset.Default: + if (partial.styling?.tint) { + return CustomizationTheme.Muted; + } + + return CustomizationTheme.Clean; + default: + return CustomizationTheme.Clean; + } + })(); + + /** + * Default customization settings. + * + * The customization object passed to the URL should be a valid API settings object. Hence we extend the test with necessary defaults. + */ + const DEFAULT_CUSTOMIZATION: SiteCustomizationSettings = { + styling: { + theme: newTheme, + primaryColor: { light: '#346DDB', dark: '#346DDB' }, + corners: CustomizationCorners.Rounded, + font: CustomizationFont.Inter, + background: CustomizationBackground.Plain, + icons: CustomizationIconsStyle.Regular, + sidebar: { + background: CustomizationSidebarBackgroundStyle.Default, + list: CustomizationSidebarListStyle.Default, + }, + }, + internationalization: { + locale: CustomizationLocale.En, + }, + favicon: {}, + header: { + preset: CustomizationHeaderPreset.Default, + links: [], + }, + footer: { + groups: [], + }, + themes: { + default: CustomizationThemeMode.Light, + toggeable: true, + }, + pdf: { + enabled: true, + }, + feedback: { + enabled: false, + }, + aiSearch: { + enabled: true, + }, + advancedCustomization: { + enabled: true, + }, + git: { + showEditLink: false, + }, + pagination: { + enabled: true, + }, + trademark: { + enabled: true, + }, + privacyPolicy: { + url: 'https://www.gitbook.com/privacy', + }, + socialPreview: {}, + }; + + const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial)); + + const searchParams = new URLSearchParams(); + searchParams.set('customization', encoded); + + return `?${searchParams.toString()}`; +} + +/** + * Wait for all icons present on the page to be loaded. + */ +async function waitForIcons(page: Page) { + await page.waitForFunction(async () => { + function loadImage(src: string) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (error) => reject(new Error(`Failed to load image: ${src}`)); + img.src = src; + }); + } + + const icons = Array.from(document.querySelectorAll('svg.gb-icon')); + await Promise.all( + icons.map(async (icon) => { + // url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93") + const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image'); + const urlMatch = maskImage.match(/url\("([^"]+)"\)/); + const url = urlMatch ? urlMatch[1] : null; + if (!url) { + throw new Error('No mask-image'); + } + await loadImage(url); + }), + ); + }); +} + +/** + * Wait for TOC to be correctly scrolled into view. + */ +async function waitForTOCScrolling(page: Page) { + const viewport = await page.viewportSize(); + if (viewport && viewport.width >= 1024) { + const toc = page.getByTestId('table-of-contents'); + await expect(toc).toBeVisible(); + await page.evaluate(() => { + const tocScrollContainer = document.querySelector( + '[data-testid="table-of-contents"] [data-testid="toc-scroll-container"]', + ); + if (!tocScrollContainer) { + throw new Error('TOC scroll container not found'); + } + tocScrollContainer.scrollTo(0, 0); + }); + } +} diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index a9bf0e372..e2e0354fe 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -9,7 +9,8 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "e2e": "playwright test", + "e2e": "playwright test e2e/internal.spec.ts", + "e2e-customers": "playwright test e2e/customers.spec.ts", "unit": "bun test {src,packages}", "generate": "gitbook-icons ./public/~gitbook/static/icons custom-icons && gitbook-math ./public/~gitbook/static/math", "copy:icons": "gitbook-icons ./public/~gitbook/static/icons", diff --git a/turbo.json b/turbo.json index e3b62ffa8..a4616afc1 100644 --- a/turbo.json +++ b/turbo.json @@ -49,6 +49,11 @@ "dependsOn": ["^e2e"], "env": ["BASE_URL"] }, + // Run end-to-end tests for customers + "e2e-customers": { + "dependsOn": ["^e2e-customers"], + "env": ["BASE_URL"] + }, // Start the package in development mode "dev": { "persistent": true,