diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml new file mode 100644 index 000000000..5796e8d63 --- /dev/null +++ b/.github/actions/setup-playwright/action.yml @@ -0,0 +1,35 @@ +name: 'Setup Playwright' +description: 'Install Playwright and dependencies' +runs: + using: 'composite' + steps: + # Run npm ci and get Playwright version + - name: 🏗 Prepare Playwright env + shell: bash + run: | + PLAYWRIGHT_VERSION=$(npm ls --json @playwright/test | jq --raw-output '.dependencies["@playwright/test"].version') + echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV + + # Cache browser binaries, cache key is based on Playwright version and OS + - name: 🧰 Cache Playwright browser binaries + uses: actions/cache@v3 + id: playwright-cache + with: + path: '~/.cache/ms-playwright' + key: '${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}' + restore-keys: | + ${{ runner.os }}-playwright- + + # Install browser binaries & OS dependencies if cache missed + - name: 🏗 Install Playwright browser binaries & OS dependencies + if: steps.playwright-cache.outputs.cache-hit != 'true' + shell: bash + run: | + bun x playwright install --with-deps chromium + + # Install only the OS dependencies if cache hit + - name: 🏗 Install Playwright OS dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + shell: bash + run: | + bun x playwright install-deps diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 65bf4f73e..a94f5d9b0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,7 +12,7 @@ jobs: deployment_url: ${{ steps.cloudflare.outputs.url }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup bun uses: oven-sh/setup-bun@v1 - name: Install dependencies @@ -72,39 +72,32 @@ jobs: needs: deploy steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup bun uses: oven-sh/setup-bun@v1 - name: Install dependencies run: bun install --frozen-lockfile - - name: Run visual tests - run: bun ./tests/visual-testing.ts $DEPLOYMENT_URL - env: - DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}} - - name: Upload to Argos - run: bun x argos upload --token $ARGOS_TOKEN ./screenshots + - name: Setup Playwright + uses: ./.github/actions/setup-playwright + - name: Run Playwright tests + run: bun e2e env: + BASE_URL: ${{needs.deploy.outputs.deployment_url}} ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }} - - name: Upload failed tests as artifacts - uses: actions/upload-artifact@v3 - if: ${{ failure() }} - with: - name: error-screenshots - path: screenshots/errors/ pagespeed-testing: runs-on: ubuntu-latest name: PageSpeed Testing needs: deploy steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup bun uses: oven-sh/setup-bun@v1 - name: Install dependencies run: bun install --frozen-lockfile env: PUPPETEER_SKIP_DOWNLOAD: 1 - - name: Run visual tests + - name: Run pagespeed tests run: bun ./tests/pagespeed-testing.ts $DEPLOYMENT_URL env: DEPLOYMENT_URL: ${{needs.deploy.outputs.deployment_url}} @@ -114,7 +107,7 @@ jobs: name: Format steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup bun uses: oven-sh/setup-bun@v1 - name: Install dependencies @@ -127,7 +120,7 @@ jobs: name: Lint steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup bun uses: oven-sh/setup-bun@v1 - name: Install dependencies @@ -140,20 +133,20 @@ jobs: name: Test steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup bun uses: oven-sh/setup-bun@v1 - name: Install dependencies run: bun install --frozen-lockfile env: PUPPETEER_SKIP_DOWNLOAD: 1 - - run: bun test + - run: bun unit typecheck: runs-on: ubuntu-latest name: Typecheck steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup bun uses: oven-sh/setup-bun@v1 - name: Install dependencies diff --git a/.gitignore b/.gitignore index f870a8504..22db9a4fb 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ screenshots/ # Sentry Config File .sentryclirc +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.prettierignore b/.prettierignore index a680367ef..d0d878e40 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1 @@ -.next +.next \ No newline at end of file diff --git a/README.md b/README.md index 5217d229f..8be0fd903 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Next.js application to render GitBook published content. ## Development -#### Start the local server +### Start the local server Clone the repository and use [Bun](https://bun.sh/) to install dependencies and run the local development server. @@ -20,12 +20,15 @@ bun dev Then open the space in your web browser, using `http://localhost:3000//` (example: `http://localhost:3000/docs.gitbook.com`). -#### Other development commands +### Other development commands - `bun format`: format the code - `bun lint`: lint the code -#### CI and testing +### Testing and CI + +- Run unit tests with `bun unit`. +- Run E2E tests with `bun e2e` (be sure to have your dev server running locally). All pull-requests will be tested against both visual and performances testing to prevent regressions. diff --git a/bun.lockb b/bun.lockb index 1325759e0..5e833de58 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/tests/visual-testing.ts b/e2e/pages.spec.ts similarity index 79% rename from tests/visual-testing.ts rename to e2e/pages.spec.ts index b016f9ef9..c0d0fc4ff 100644 --- a/tests/visual-testing.ts +++ b/e2e/pages.spec.ts @@ -1,18 +1,18 @@ -import fs from 'node:fs/promises'; -import rison from 'rison'; -import puppeteer, { Page } from 'puppeteer'; -import { argosScreenshot } from '@argos-ci/puppeteer'; -import { getContentTestURL, getTargetURL } from './utils'; +import { argosScreenshot } from '@argos-ci/playwright'; import { CustomizationHeaderPreset, CustomizationLocale, CustomizationSettings, } from '@gitbook/api'; +import { test, Page } from '@playwright/test'; +import rison from 'rison'; + +import { getContentTestURL } from '../tests/utils'; interface Test { name: string; url: string; - wait?: (page: Page) => Promise; + run?: (page: Page) => Promise; } interface TestsCase { @@ -44,12 +44,12 @@ const testCases: TestsCase[] = [ { name: 'Search Results', url: '?q=gitbook', - wait: (page) => page.waitForSelector('[data-test="search-results"]'), + run: (page) => page.waitForSelector('[data-test="search-results"]'), }, { name: 'AI Search', url: '?q=What+is+GitBook%3F&ask=true', - wait: (page) => page.waitForSelector('[data-test="search-ask-answer"]'), + run: (page) => page.waitForSelector('[data-test="search-ask-answer"]'), }, { name: 'Not found', @@ -293,50 +293,23 @@ const testCases: TestsCase[] = [ }, ]; -console.log(`Starting visual testing with ${getTargetURL()}...`); - -const browser = await puppeteer.launch({ - headless: 'new', -}); - for (const testCase of testCases) { - for (const test of testCase.tests) { - const page = await browser.newPage(); - const contentUrl = new URL(test.url, testCase.baseUrl); - const url = getContentTestURL(contentUrl.toString()); - const start = Date.now(); - - console.log(`Testing ${testCase.name} - ${test.name} (${url})...`); - - const screenshotName = `${testCase.name} - ${test.name}.png`; - - try { - await page.goto(url, { waitUntil: test.wait ? 'load' : 'networkidle2' }); - - if (test.wait) { - await test.wait(page); - } - } catch (error) { - await fs.mkdir('screenshots/errors', { - recursive: true, + test.describe(testCase.name, () => { + for (const testEntry of testCase.tests) { + test(testEntry.name, async ({ page, baseURL }) => { + const contentUrl = new URL(testEntry.url, testCase.baseUrl); + const url = getContentTestURL(contentUrl.toString(), baseURL); + await page.goto(url); + if (testEntry.run) { + await testEntry.run(page); + } + await argosScreenshot(page, `${testCase.name} - ${testEntry.name}`, { + viewports: ['macbook-13', 'iphone-x', 'ipad-2'], + }); }); - - await page.screenshot({ path: `screenshots/errors/${screenshotName}` }); - console.log(`❌ Failed in ${((Date.now() - start) / 1000).toFixed(2)}s`); - throw error; } - - await argosScreenshot(page, screenshotName, { - viewports: ['macbook-13', 'iphone-x', 'ipad-2'], - }); - console.log(`✅ Done in ${((Date.now() - start) / 1000).toFixed(2)}s`); - console.log(''); - await page.close(); - } + }); } -await browser.close(); - -console.log('All done!'); /** * Create a URL with customization settings. diff --git a/package.json b/package.json index a62372f93..d654a27a6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "lint": "next lint", "format": "prettier ./ --ignore-unknown --write", "format:check": "prettier ./ --ignore-unknown --list-different", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "unit": "bun test {src,packages}/**/*.test.ts", + "e2e": "playwright test" }, "workspaces": [ "packages/*" @@ -61,9 +63,10 @@ }, "devDependencies": { "@argos-ci/cli": "^1.0.4", - "@argos-ci/puppeteer": "^1.2.1", + "@argos-ci/playwright": "^1.9.3", "@cloudflare/next-on-pages": "^1.9.0", "@cloudflare/workers-types": "^4.20231218.0", + "@playwright/test": "^1.42.1", "@types/js-cookie": "^3.0.6", "@types/js-yaml": "^4.0.9", "@types/jsontoxml": "^1.0.5", @@ -83,7 +86,6 @@ "postcss": "^8", "prettier": "^3.0.3", "psi": "^4.1.0", - "puppeteer": "^21.6.1", "tailwindcss": "^3.4.0", "typescript": "^5" } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..2e7bddf00 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: [ + process.env.CI ? ['dot'] : ['list'], + ['@argos-ci/playwright/reporter', { uploadToArgos: !!process.env.CI }], + ], + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + use: { + baseURL: process.env.BASE_URL || 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, +}); diff --git a/tests/pagespeed-testing.ts b/tests/pagespeed-testing.ts index e30f86559..c88a04053 100644 --- a/tests/pagespeed-testing.ts +++ b/tests/pagespeed-testing.ts @@ -1,4 +1,5 @@ import psi from 'psi'; + import { getContentTestURL, getTargetURL } from './utils'; interface Test { diff --git a/tests/utils.ts b/tests/utils.ts index dd877e944..20e2e13b6 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -11,11 +11,16 @@ export function getTargetURL() { return targetUrl; } +export function getContentPathName(input: string): string { + const contentUrl = new URL(input); + return `${contentUrl.host}${contentUrl.pathname}`; +} + /** * Get the URL to load for a content */ -export function getContentTestURL(input: string): string { - const url = new URL(getTargetURL()); +export function getContentTestURL(input: string, baseUrl: string = getTargetURL()): string { + const url = new URL(baseUrl); const contentUrl = new URL(input); url.pathname = `${contentUrl.host}${contentUrl.pathname}`;