From 3dcf7da6387e243b5ca5451bfbe0517393491e8a Mon Sep 17 00:00:00 2001 From: ThatGuySam Date: Sun, 15 Mar 2026 19:27:25 -0500 Subject: [PATCH] test(search): add url-targetable pagefind browser regression Cover the Native Support filter with a Playwright-backed Vitest case that can boot the local dev server or attach to a deployed URL so the same regression can gate post-deploy verification. --- package.json | 4 + pnpm-lock.yaml | 10 + .../pagefind-native-filter.playwright.js | 273 ++++++++++++++++++ vitest.playwright.config.mjs | 23 ++ 4 files changed, 310 insertions(+) create mode 100644 test/playwright/pagefind-native-filter.playwright.js create mode 100644 vitest.playwright.config.mjs diff --git a/package.json b/package.json index 51c50f6..fc6f0f6 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "test-postbuild-api": "pnpm test-listings", "test-vitest": "vitest", "test": "vitest run", + "test:browser": "vitest run --config vitest.playwright.config.mjs", + "test:browser:pagefind": "vitest run --config vitest.playwright.config.mjs test/playwright/pagefind-native-filter.playwright.js", + "test:browser:pagefind:live": "PLAYWRIGHT_BASE_URL=https://doesitarm.com vitest run --config vitest.playwright.config.mjs test/playwright/pagefind-native-filter.playwright.js", "dev": "pnpm run dev-astro", "build": "pnpm run generate-astro", "build-api": "pnpm run clone-readme && pnpm exec vite-node build-lists.js -- --with-api --no-lists", @@ -142,6 +145,7 @@ "node-fetch": "^2.6.1", "nodemon": "^1.11.0", "npm-run-all": "^4.1.5", + "playwright-core": "^1.58.2", "postcss": "^8.2.4", "postcss-cli": "^8.3.1", "replace-css-url": "^1.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25804de..85244ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ importers: npm-run-all: specifier: ^4.1.5 version: 4.1.5 + playwright-core: + specifier: ^1.58.2 + version: 1.58.2 postcss: specifier: ^8.2.4 version: 8.2.4 @@ -6004,6 +6007,11 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + plist@3.0.1: resolution: {integrity: sha512-GpgvHHocGRyQm74b6FWEZZVRroHKE1I0/BTjAmySaohK+cUn+hZpbqXkc3KWgW3gQYkqcQej35FohcT0FRlkRQ==} engines: {node: '>=6'} @@ -14716,6 +14724,8 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + playwright-core@1.58.2: {} + plist@3.0.1: dependencies: base64-js: 1.5.1 diff --git a/test/playwright/pagefind-native-filter.playwright.js b/test/playwright/pagefind-native-filter.playwright.js new file mode 100644 index 0000000..5de7712 --- /dev/null +++ b/test/playwright/pagefind-native-filter.playwright.js @@ -0,0 +1,273 @@ +import { accessSync, constants } from 'node:fs' +import { spawn } from 'node:child_process' +import net from 'node:net' + +import { chromium } from 'playwright-core' +import { + afterAll, + beforeAll, + describe, + expect, + it +} from 'vitest' + + +const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const host = '127.0.0.1' +const configuredBaseUrl = process.env.PLAYWRIGHT_BASE_URL || '' + +function canAccessPath ( filePath ) { + try { + accessSync( filePath, constants.X_OK ) + return true + } catch { + return false + } +} + +function getBrowserExecutablePath () { + const candidatePaths = [ + process.env.PLAYWRIGHT_BROWSER_PATH, + process.env.CHROME_BIN, + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/opt/homebrew/bin/chromium', + ].filter( Boolean ) + + const executablePath = candidatePaths.find( canAccessPath ) + + if ( !executablePath ) { + throw new Error(`No browser executable found. Set PLAYWRIGHT_BROWSER_PATH or CHROME_BIN.`) + } + + return executablePath +} + +function getAvailablePort () { + return new Promise( ( resolve, reject ) => { + const server = net.createServer() + + server.unref() + server.on( 'error', reject ) + server.listen( 0, host, () => { + const { port } = server.address() + server.close( err => { + if ( err ) { + reject( err ) + return + } + + resolve( port ) + } ) + } ) + } ) +} + +async function waitForServer ( url, { + intervalMs = 250, + timeoutMs = 60 * 1000 +} = {} ) { + const startedAt = Date.now() + + while ( Date.now() - startedAt < timeoutMs ) { + try { + const response = await fetch( url ) + + if ( response.ok ) { + return + } + } catch {} + + await new Promise( resolve => setTimeout( resolve, intervalMs ) ) + } + + throw new Error(`Timed out waiting for dev server at ${ url }`) +} + +function stopProcess ( childProcess ) { + return new Promise( resolve => { + if ( !childProcess ) { + resolve() + return + } + + if ( childProcess.killed || childProcess.exitCode !== null ) { + resolve() + return + } + + childProcess.once( 'exit', () => resolve() ) + childProcess.kill( 'SIGTERM' ) + + setTimeout( () => { + if ( childProcess.exitCode === null ) { + childProcess.kill( 'SIGKILL' ) + } + }, 5 * 1000 ).unref() + } ) +} + +describe('Pagefind dev search', () => { + let browser + let devServer + let devServerOutput = '' + let baseUrl = '' + + beforeAll( async () => { + const executablePath = getBrowserExecutablePath() + if ( configuredBaseUrl.length > 0 ) { + baseUrl = configuredBaseUrl + } else { + const port = await getAvailablePort() + + baseUrl = `http://${ host }:${ port }` + + devServer = spawn( command, [ + 'exec', + 'astro', + 'dev', + '--host', + host, + '--port', + String( port ) + ], { + cwd: process.cwd(), + env: { + ...process.env, + PUBLIC_SEARCH_PROVIDER: 'pagefind' + }, + stdio: [ 'ignore', 'pipe', 'pipe' ] + } ) + + devServer.stdout.on( 'data', chunk => { + devServerOutput += chunk.toString() + } ) + devServer.stderr.on( 'data', chunk => { + devServerOutput += chunk.toString() + } ) + } + + await waitForServer( baseUrl ) + + browser = await chromium.launch({ + executablePath, + headless: true + } ) + } ) + + afterAll( async () => { + await browser?.close() + await stopProcess( devServer ) + } ) + + it('renders visible Pagefind results when Native Support is clicked', async () => { + const page = await browser.newPage() + const consoleErrors = [] + const pageErrors = [] + const pagefindResponses = [] + const failedRequests = [] + let fragmentRequests = 0 + let failedFragmentRequests = 0 + + page.on( 'console', message => { + if ( message.type() === 'error' ) { + consoleErrors.push( message.text() ) + } + } ) + + page.on( 'pageerror', error => { + pageErrors.push( error.message ) + } ) + + page.on( 'response', response => { + if ( response.url().includes( '/pagefind/pagefind.js' ) ) { + pagefindResponses.push({ + status: response.status(), + url: response.url() + }) + } + } ) + + page.on( 'request', request => { + if ( request.url().includes( '/pagefind/' ) && request.url().includes( 'pf_fragment' ) ) { + fragmentRequests++ + } + } ) + + page.on( 'requestfailed', request => { + if ( request.url().includes( '/pagefind/pagefind.js' ) ) { + failedRequests.push({ + errorText: request.failure()?.errorText || 'unknown', + url: request.url() + }) + } + + if ( request.url().includes( '/pagefind/' ) && request.url().includes( 'pf_fragment' ) ) { + failedFragmentRequests++ + } + } ) + + await page.goto( baseUrl, { + waitUntil: 'domcontentloaded' + } ) + + await page.waitForTimeout( 3000 ) + + await Promise.all([ + page.waitForResponse( response => { + return response.url().includes( '/pagefind/pagefind.js' ) + }, { + timeout: 10 * 1000 + } ), + page.getByRole( 'button', { + name: /native support/i + } ).click() + ]) + + await page.waitForFunction( () => { + return [ ...document.querySelectorAll( 'li[data-app-slug] h3' ) ].some( node => { + const text = node.textContent || '' + return text.trim().length > 0 && !/loading/i.test( text ) + } ) + }, { + timeout: 15 * 1000 + } ) + + const bodyText = await page.locator( 'body' ).textContent() + const renderedResults = await page.evaluate( () => { + const headings = [ ...document.querySelectorAll( 'li[data-app-slug] h3' ) ].map( node => { + return ( node.textContent || '' ).trim() + } ) + + return { + loadingRows: headings.filter( text => /loading/i.test( text ) ).length, + rows: document.querySelectorAll( 'li[data-app-slug]' ).length, + visibleHeadings: headings.slice( 0, 5 ) + } + } ) + + expect( pagefindResponses.some( response => response.status === 200 ), devServerOutput ).toBe( true ) + expect( + pagefindResponses.some( response => response.status >= 400 ), + [ + pagefindResponses.map( response => `${ response.status } ${ response.url }` ).join( '\n' ), + failedRequests.map( request => `${ request.errorText } ${ request.url }` ).join( '\n' ), + pageErrors.join( '\n' ), + consoleErrors.join( '\n' ) + ].join( '\n\n' ) + ).toBe( false ) + expect( fragmentRequests, JSON.stringify( renderedResults ) ).toBeGreaterThan( 0 ) + expect( fragmentRequests, JSON.stringify( renderedResults ) ).toBeLessThan( 100 ) + expect( failedFragmentRequests, JSON.stringify( renderedResults ) ).toBe( 0 ) + expect( renderedResults.rows, JSON.stringify( renderedResults ) ).toBeGreaterThan( 0 ) + expect( renderedResults.loadingRows, JSON.stringify( renderedResults ) ).toBe( 0 ) + expect( bodyText ).not.toContain( 'Failed to load url /pagefind/pagefind.js' ) + expect( bodyText ).not.toContain( 'No apps found for' ) + expect( pageErrors.join( '\n' ) ).not.toMatch( /pagefind\/pagefind\.js/ ) + expect( pageErrors.join( '\n' ) ).not.toMatch( /Failed to fetch/ ) + expect( consoleErrors.join( '\n' ) ).not.toMatch( /pagefind\/pagefind\.js/ ) + expect( consoleErrors.join( '\n' ) ).not.toMatch( /ERR_INSUFFICIENT_RESOURCES/ ) + + await page.close() + } ) +} ) diff --git a/vitest.playwright.config.mjs b/vitest.playwright.config.mjs new file mode 100644 index 0000000..987a9cc --- /dev/null +++ b/vitest.playwright.config.mjs @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config' + +import astroConfig from './astro.config.mjs' + + +const vitestConfig = { + ...astroConfig, + ...astroConfig.vite, + test: { + setupFiles: 'tsconfig-paths/register', + include: [ + 'test/playwright/**/*.playwright.js' + ], + exclude: [ + 'test/_disabled/**' + ], + fileParallelism: false, + hookTimeout: 120 * 1000, + testTimeout: 120 * 1000 + } +} + +export default defineConfig( vitestConfig )