Skip to content

Commit 3dcf7da

Browse files
committed
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.
1 parent a1ae595 commit 3dcf7da

4 files changed

Lines changed: 310 additions & 0 deletions

File tree

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
"test-postbuild-api": "pnpm test-listings",
3131
"test-vitest": "vitest",
3232
"test": "vitest run",
33+
"test:browser": "vitest run --config vitest.playwright.config.mjs",
34+
"test:browser:pagefind": "vitest run --config vitest.playwright.config.mjs test/playwright/pagefind-native-filter.playwright.js",
35+
"test:browser:pagefind:live": "PLAYWRIGHT_BASE_URL=https://doesitarm.com vitest run --config vitest.playwright.config.mjs test/playwright/pagefind-native-filter.playwright.js",
3336
"dev": "pnpm run dev-astro",
3437
"build": "pnpm run generate-astro",
3538
"build-api": "pnpm run clone-readme && pnpm exec vite-node build-lists.js -- --with-api --no-lists",
@@ -142,6 +145,7 @@
142145
"node-fetch": "^2.6.1",
143146
"nodemon": "^1.11.0",
144147
"npm-run-all": "^4.1.5",
148+
"playwright-core": "^1.58.2",
145149
"postcss": "^8.2.4",
146150
"postcss-cli": "^8.3.1",
147151
"replace-css-url": "^1.2.6",

pnpm-lock.yaml

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
import { accessSync, constants } from 'node:fs'
2+
import { spawn } from 'node:child_process'
3+
import net from 'node:net'
4+
5+
import { chromium } from 'playwright-core'
6+
import {
7+
afterAll,
8+
beforeAll,
9+
describe,
10+
expect,
11+
it
12+
} from 'vitest'
13+
14+
15+
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
16+
const host = '127.0.0.1'
17+
const configuredBaseUrl = process.env.PLAYWRIGHT_BASE_URL || ''
18+
19+
function canAccessPath ( filePath ) {
20+
try {
21+
accessSync( filePath, constants.X_OK )
22+
return true
23+
} catch {
24+
return false
25+
}
26+
}
27+
28+
function getBrowserExecutablePath () {
29+
const candidatePaths = [
30+
process.env.PLAYWRIGHT_BROWSER_PATH,
31+
process.env.CHROME_BIN,
32+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
33+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
34+
'/opt/homebrew/bin/chromium',
35+
].filter( Boolean )
36+
37+
const executablePath = candidatePaths.find( canAccessPath )
38+
39+
if ( !executablePath ) {
40+
throw new Error(`No browser executable found. Set PLAYWRIGHT_BROWSER_PATH or CHROME_BIN.`)
41+
}
42+
43+
return executablePath
44+
}
45+
46+
function getAvailablePort () {
47+
return new Promise( ( resolve, reject ) => {
48+
const server = net.createServer()
49+
50+
server.unref()
51+
server.on( 'error', reject )
52+
server.listen( 0, host, () => {
53+
const { port } = server.address()
54+
server.close( err => {
55+
if ( err ) {
56+
reject( err )
57+
return
58+
}
59+
60+
resolve( port )
61+
} )
62+
} )
63+
} )
64+
}
65+
66+
async function waitForServer ( url, {
67+
intervalMs = 250,
68+
timeoutMs = 60 * 1000
69+
} = {} ) {
70+
const startedAt = Date.now()
71+
72+
while ( Date.now() - startedAt < timeoutMs ) {
73+
try {
74+
const response = await fetch( url )
75+
76+
if ( response.ok ) {
77+
return
78+
}
79+
} catch {}
80+
81+
await new Promise( resolve => setTimeout( resolve, intervalMs ) )
82+
}
83+
84+
throw new Error(`Timed out waiting for dev server at ${ url }`)
85+
}
86+
87+
function stopProcess ( childProcess ) {
88+
return new Promise( resolve => {
89+
if ( !childProcess ) {
90+
resolve()
91+
return
92+
}
93+
94+
if ( childProcess.killed || childProcess.exitCode !== null ) {
95+
resolve()
96+
return
97+
}
98+
99+
childProcess.once( 'exit', () => resolve() )
100+
childProcess.kill( 'SIGTERM' )
101+
102+
setTimeout( () => {
103+
if ( childProcess.exitCode === null ) {
104+
childProcess.kill( 'SIGKILL' )
105+
}
106+
}, 5 * 1000 ).unref()
107+
} )
108+
}
109+
110+
describe('Pagefind dev search', () => {
111+
let browser
112+
let devServer
113+
let devServerOutput = ''
114+
let baseUrl = ''
115+
116+
beforeAll( async () => {
117+
const executablePath = getBrowserExecutablePath()
118+
if ( configuredBaseUrl.length > 0 ) {
119+
baseUrl = configuredBaseUrl
120+
} else {
121+
const port = await getAvailablePort()
122+
123+
baseUrl = `http://${ host }:${ port }`
124+
125+
devServer = spawn( command, [
126+
'exec',
127+
'astro',
128+
'dev',
129+
'--host',
130+
host,
131+
'--port',
132+
String( port )
133+
], {
134+
cwd: process.cwd(),
135+
env: {
136+
...process.env,
137+
PUBLIC_SEARCH_PROVIDER: 'pagefind'
138+
},
139+
stdio: [ 'ignore', 'pipe', 'pipe' ]
140+
} )
141+
142+
devServer.stdout.on( 'data', chunk => {
143+
devServerOutput += chunk.toString()
144+
} )
145+
devServer.stderr.on( 'data', chunk => {
146+
devServerOutput += chunk.toString()
147+
} )
148+
}
149+
150+
await waitForServer( baseUrl )
151+
152+
browser = await chromium.launch({
153+
executablePath,
154+
headless: true
155+
} )
156+
} )
157+
158+
afterAll( async () => {
159+
await browser?.close()
160+
await stopProcess( devServer )
161+
} )
162+
163+
it('renders visible Pagefind results when Native Support is clicked', async () => {
164+
const page = await browser.newPage()
165+
const consoleErrors = []
166+
const pageErrors = []
167+
const pagefindResponses = []
168+
const failedRequests = []
169+
let fragmentRequests = 0
170+
let failedFragmentRequests = 0
171+
172+
page.on( 'console', message => {
173+
if ( message.type() === 'error' ) {
174+
consoleErrors.push( message.text() )
175+
}
176+
} )
177+
178+
page.on( 'pageerror', error => {
179+
pageErrors.push( error.message )
180+
} )
181+
182+
page.on( 'response', response => {
183+
if ( response.url().includes( '/pagefind/pagefind.js' ) ) {
184+
pagefindResponses.push({
185+
status: response.status(),
186+
url: response.url()
187+
})
188+
}
189+
} )
190+
191+
page.on( 'request', request => {
192+
if ( request.url().includes( '/pagefind/' ) && request.url().includes( 'pf_fragment' ) ) {
193+
fragmentRequests++
194+
}
195+
} )
196+
197+
page.on( 'requestfailed', request => {
198+
if ( request.url().includes( '/pagefind/pagefind.js' ) ) {
199+
failedRequests.push({
200+
errorText: request.failure()?.errorText || 'unknown',
201+
url: request.url()
202+
})
203+
}
204+
205+
if ( request.url().includes( '/pagefind/' ) && request.url().includes( 'pf_fragment' ) ) {
206+
failedFragmentRequests++
207+
}
208+
} )
209+
210+
await page.goto( baseUrl, {
211+
waitUntil: 'domcontentloaded'
212+
} )
213+
214+
await page.waitForTimeout( 3000 )
215+
216+
await Promise.all([
217+
page.waitForResponse( response => {
218+
return response.url().includes( '/pagefind/pagefind.js' )
219+
}, {
220+
timeout: 10 * 1000
221+
} ),
222+
page.getByRole( 'button', {
223+
name: /native support/i
224+
} ).click()
225+
])
226+
227+
await page.waitForFunction( () => {
228+
return [ ...document.querySelectorAll( 'li[data-app-slug] h3' ) ].some( node => {
229+
const text = node.textContent || ''
230+
return text.trim().length > 0 && !/loading/i.test( text )
231+
} )
232+
}, {
233+
timeout: 15 * 1000
234+
} )
235+
236+
const bodyText = await page.locator( 'body' ).textContent()
237+
const renderedResults = await page.evaluate( () => {
238+
const headings = [ ...document.querySelectorAll( 'li[data-app-slug] h3' ) ].map( node => {
239+
return ( node.textContent || '' ).trim()
240+
} )
241+
242+
return {
243+
loadingRows: headings.filter( text => /loading/i.test( text ) ).length,
244+
rows: document.querySelectorAll( 'li[data-app-slug]' ).length,
245+
visibleHeadings: headings.slice( 0, 5 )
246+
}
247+
} )
248+
249+
expect( pagefindResponses.some( response => response.status === 200 ), devServerOutput ).toBe( true )
250+
expect(
251+
pagefindResponses.some( response => response.status >= 400 ),
252+
[
253+
pagefindResponses.map( response => `${ response.status } ${ response.url }` ).join( '\n' ),
254+
failedRequests.map( request => `${ request.errorText } ${ request.url }` ).join( '\n' ),
255+
pageErrors.join( '\n' ),
256+
consoleErrors.join( '\n' )
257+
].join( '\n\n' )
258+
).toBe( false )
259+
expect( fragmentRequests, JSON.stringify( renderedResults ) ).toBeGreaterThan( 0 )
260+
expect( fragmentRequests, JSON.stringify( renderedResults ) ).toBeLessThan( 100 )
261+
expect( failedFragmentRequests, JSON.stringify( renderedResults ) ).toBe( 0 )
262+
expect( renderedResults.rows, JSON.stringify( renderedResults ) ).toBeGreaterThan( 0 )
263+
expect( renderedResults.loadingRows, JSON.stringify( renderedResults ) ).toBe( 0 )
264+
expect( bodyText ).not.toContain( 'Failed to load url /pagefind/pagefind.js' )
265+
expect( bodyText ).not.toContain( 'No apps found for' )
266+
expect( pageErrors.join( '\n' ) ).not.toMatch( /pagefind\/pagefind\.js/ )
267+
expect( pageErrors.join( '\n' ) ).not.toMatch( /Failed to fetch/ )
268+
expect( consoleErrors.join( '\n' ) ).not.toMatch( /pagefind\/pagefind\.js/ )
269+
expect( consoleErrors.join( '\n' ) ).not.toMatch( /ERR_INSUFFICIENT_RESOURCES/ )
270+
271+
await page.close()
272+
} )
273+
} )

vitest.playwright.config.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
import astroConfig from './astro.config.mjs'
4+
5+
6+
const vitestConfig = {
7+
...astroConfig,
8+
...astroConfig.vite,
9+
test: {
10+
setupFiles: 'tsconfig-paths/register',
11+
include: [
12+
'test/playwright/**/*.playwright.js'
13+
],
14+
exclude: [
15+
'test/_disabled/**'
16+
],
17+
fileParallelism: false,
18+
hookTimeout: 120 * 1000,
19+
testTimeout: 120 * 1000
20+
}
21+
}
22+
23+
export default defineConfig( vitestConfig )

0 commit comments

Comments
 (0)