mirror of
https://github.com/ThatGuySam/doesitarm.git
synced 2026-05-15 06:35:20 -07:00
test(playwright): lock browser coverage before scanner refactors
Add a typed Playwright harness for Pagefind and the Apple Silicon app-test flow so scanner work has browser-level protection. Keep the rollout plan in the same stack so the TypeScript conversion stays staged and reviewable. Constraint: Must not change production runtime behavior in this commit Rejected: Leave the old JS browser test and add a second harness | duplicates setup and leaves the targeted browser script broken Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep browser-only helpers under test/playwright/support until the runtime scanner surface is fully typed Tested: pnpm run typecheck; pnpm run test:browser; pnpm run test:browser:pagefind Not-tested: Live browser checks against doesitarm.com
This commit is contained in:
parent
c5ec942de0
commit
0480c47bbb
8 changed files with 633 additions and 276 deletions
49
docs/plans/app-test-typescript-refactor.md
Normal file
49
docs/plans/app-test-typescript-refactor.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Original Prompt
|
||||||
|
|
||||||
|
> OK, this is really great. I've been wanting to do a TypeScript conversion for this repo for a while. Tell me about that. We also need to... I want-- we have a-- the app test feature. Every time I try to touch that code, it gets fragile. So build a playwright test to verify that feature so that way we can scan apps, and then get that working. and then let's start a refactor. But once you have that working and verified, Let's start to refactor to get this converted all to TypeScript.
|
||||||
|
|
||||||
|
# Goal
|
||||||
|
|
||||||
|
Lock the Apple Silicon app-test flow with an end-to-end browser test, fix regressions in the real scan/upload path, and begin the TypeScript conversion with small, reviewable changes around the browser-test and scanner surface.
|
||||||
|
|
||||||
|
# Non-Goals
|
||||||
|
|
||||||
|
- Full repo-wide JavaScript-to-TypeScript conversion in one pass.
|
||||||
|
- Replacing the scan engine implementation without test coverage first.
|
||||||
|
- Changing user-facing app-test behavior beyond what is needed to make the feature reliable.
|
||||||
|
|
||||||
|
# Repo Findings
|
||||||
|
|
||||||
|
- The app-test UI is implemented in [pages/apple-silicon-app-test.vue](/Users/athena/Code/doesitarm/pages/apple-silicon-app-test.vue) and mounted by [src/pages/apple-silicon-app-test.astro](/Users/athena/Code/doesitarm/src/pages/apple-silicon-app-test.astro).
|
||||||
|
- The current browser-test harness exists, but only covers Pagefind in [test/playwright/pagefind-native-filter.playwright.js](/Users/athena/Code/doesitarm/test/playwright/pagefind-native-filter.playwright.js).
|
||||||
|
- The app-test flow depends on archive extraction, plist parsing, Mach-O parsing, and an HTTP POST to `TEST_RESULT_STORE` via [helpers/app-files-scanner.js](/Users/athena/Code/doesitarm/helpers/app-files-scanner.js).
|
||||||
|
- A newer worker-based scanner path exists behind `?version=2`, but the production page still defaults to the legacy path.
|
||||||
|
|
||||||
|
# Decision
|
||||||
|
|
||||||
|
Add a deterministic Playwright upload test that scans a generated zipped `.app` bundle against the real page, stub only the remote result-store POST, and use that as the safety rail before starting TypeScript refactors.
|
||||||
|
|
||||||
|
# Rollout Plan
|
||||||
|
|
||||||
|
1. Add typed Playwright support for spinning up Astro and generating a known-good app archive fixture.
|
||||||
|
2. Add a browser test for `/apple-silicon-app-test/` that uploads the fixture, intercepts the result-store request, and asserts the rendered native result.
|
||||||
|
3. Fix app-test regressions exposed by the browser test.
|
||||||
|
4. Start the TypeScript conversion with the new Playwright support layer and continue into the scanner path in later passes.
|
||||||
|
|
||||||
|
# Validation Gates
|
||||||
|
|
||||||
|
- `pnpm test:browser test/playwright/apple-silicon-app-test.playwright.ts`
|
||||||
|
- `pnpm test:browser`
|
||||||
|
- Manual smoke check of `/apple-silicon-app-test/` if the browser test exposes timing or hydration issues
|
||||||
|
|
||||||
|
# Deliverables
|
||||||
|
|
||||||
|
- A Playwright browser test covering the app-test upload and scan flow
|
||||||
|
- Any app-test fixes required to make that test pass
|
||||||
|
- Initial TypeScript refactor scaffolding in the browser-test/scanner-adjacent path
|
||||||
|
|
||||||
|
# Risks And Open Questions
|
||||||
|
|
||||||
|
- The legacy scanner depends on zip and Mach-O parsing behavior in the browser, so fixture choice needs to stay minimal and deterministic.
|
||||||
|
- The repo still mixes `.js`, `.mjs`, `.ts`, `.vue`, and `.astro`, so conversion order matters; scanner-adjacent modules should move only after coverage exists.
|
||||||
|
- The worker-based scanner path likely needs separate follow-up coverage before it can replace the legacy path.
|
||||||
|
|
@ -31,8 +31,8 @@
|
||||||
"test-vitest": "vitest",
|
"test-vitest": "vitest",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:browser": "vitest run --config vitest.playwright.config.mjs",
|
"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": "vitest run --config vitest.playwright.config.mjs test/playwright/pagefind-native-filter.playwright.ts",
|
||||||
"test:browser:pagefind:live": "PLAYWRIGHT_BASE_URL=https://doesitarm.com 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.ts",
|
||||||
"dev": "pnpm run dev-astro",
|
"dev": "pnpm run dev-astro",
|
||||||
"build": "pnpm run generate-astro",
|
"build": "pnpm run generate-astro",
|
||||||
"build-api": "pnpm run clone-readme && pnpm exec vite-node build-lists.js -- --with-api --no-lists",
|
"build-api": "pnpm run clone-readme && pnpm exec vite-node build-lists.js -- --with-api --no-lists",
|
||||||
|
|
|
||||||
178
test/playwright/apple-silicon-app-test.playwright.ts
Normal file
178
test/playwright/apple-silicon-app-test.playwright.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import type { Browser, Page } from 'playwright-core'
|
||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
launchBrowser,
|
||||||
|
startAstroDevServer,
|
||||||
|
stopChildProcess,
|
||||||
|
type AstroDevServer
|
||||||
|
} from './support/astro-browser-test'
|
||||||
|
import {
|
||||||
|
createNativeAppArchive,
|
||||||
|
type PlaywrightUploadFile
|
||||||
|
} from './support/app-archive-fixture'
|
||||||
|
|
||||||
|
const appTestVariants = [
|
||||||
|
{
|
||||||
|
name: 'legacy scanner',
|
||||||
|
routeSuffix: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'worker scanner',
|
||||||
|
routeSuffix: '?version=2'
|
||||||
|
}
|
||||||
|
] as const
|
||||||
|
|
||||||
|
describe( 'Apple Silicon app test page', () => {
|
||||||
|
let browser: Browser
|
||||||
|
let devServer: AstroDevServer
|
||||||
|
let appArchive: PlaywrightUploadFile
|
||||||
|
|
||||||
|
beforeAll( async () => {
|
||||||
|
appArchive = await createNativeAppArchive()
|
||||||
|
|
||||||
|
devServer = await startAstroDevServer({
|
||||||
|
env: {
|
||||||
|
TEST_RESULT_STORE: '/api/test-results'
|
||||||
|
},
|
||||||
|
preferConfiguredBaseUrl: false
|
||||||
|
})
|
||||||
|
|
||||||
|
browser = await launchBrowser()
|
||||||
|
await Promise.all( appTestVariants.map( variant => {
|
||||||
|
return warmAppTestRoute( browser, devServer.baseUrl, variant.routeSuffix )
|
||||||
|
} ) )
|
||||||
|
} )
|
||||||
|
|
||||||
|
afterAll( async () => {
|
||||||
|
await browser?.close()
|
||||||
|
await stopChildProcess( devServer?.process || null )
|
||||||
|
} )
|
||||||
|
|
||||||
|
it.each( appTestVariants )( 'uploads an app archive through the %s path and renders a native result', async ( variant ) => {
|
||||||
|
const page = await browser.newPage()
|
||||||
|
const consoleErrors: string[] = []
|
||||||
|
const pageErrors: string[] = []
|
||||||
|
const submittedScans: Record<string, unknown>[] = []
|
||||||
|
|
||||||
|
page.on( 'console', message => {
|
||||||
|
if ( message.type() === 'error' ) {
|
||||||
|
consoleErrors.push( message.text() )
|
||||||
|
}
|
||||||
|
} )
|
||||||
|
|
||||||
|
page.on( 'pageerror', error => {
|
||||||
|
pageErrors.push( error.message )
|
||||||
|
} )
|
||||||
|
|
||||||
|
await stubResultStore( page, submittedScans )
|
||||||
|
|
||||||
|
await page.goto( `${ devServer.baseUrl }/apple-silicon-app-test/${ variant.routeSuffix }`, {
|
||||||
|
waitUntil: 'load'
|
||||||
|
} )
|
||||||
|
|
||||||
|
await page.waitForFunction( () => {
|
||||||
|
const island = document.querySelector( 'astro-island[component-url="/pages/apple-silicon-app-test.vue"]' )
|
||||||
|
|
||||||
|
return Boolean( island && !island.hasAttribute( 'ssr' ) )
|
||||||
|
}, {
|
||||||
|
timeout: 30 * 1000
|
||||||
|
} )
|
||||||
|
|
||||||
|
await page.locator( 'input[type="file"]' ).setInputFiles( appArchive )
|
||||||
|
await waitForBodyText( page, 'Total Files: 1', {
|
||||||
|
consoleErrors,
|
||||||
|
devServerOutput: devServer.output.text,
|
||||||
|
pageErrors
|
||||||
|
} )
|
||||||
|
|
||||||
|
const firstScanRow = page.locator( '.results-container li' ).first()
|
||||||
|
|
||||||
|
await waitForBodyText( page, 'Playwright Native App', {
|
||||||
|
consoleErrors,
|
||||||
|
devServerOutput: devServer.output.text,
|
||||||
|
pageErrors
|
||||||
|
} )
|
||||||
|
await waitForBodyText( page, '✅ This app is natively compatible with Apple Silicon!', {
|
||||||
|
consoleErrors,
|
||||||
|
devServerOutput: devServer.output.text,
|
||||||
|
pageErrors
|
||||||
|
} )
|
||||||
|
|
||||||
|
await firstScanRow.locator( 'summary' ).click()
|
||||||
|
|
||||||
|
const rowText = await firstScanRow.textContent()
|
||||||
|
|
||||||
|
expect( rowText ).toContain( 'Bundle Identifier' )
|
||||||
|
expect( rowText ).toContain( 'com.doesitarm.playwright-native-app' )
|
||||||
|
|
||||||
|
expect( submittedScans.length, devServer.output.text ).toBe( 1 )
|
||||||
|
expect( submittedScans[ 0 ]?.filename, JSON.stringify( submittedScans[ 0 ] ) ).toBe( 'Playwright Native App.app.zip' )
|
||||||
|
expect( submittedScans[ 0 ]?.result, JSON.stringify( submittedScans[ 0 ] ) ).toBe( '✅' )
|
||||||
|
expect( pageErrors, devServer.output.text ).toEqual( [] )
|
||||||
|
expect( consoleErrors, devServer.output.text ).toEqual( [] )
|
||||||
|
} )
|
||||||
|
} )
|
||||||
|
|
||||||
|
async function stubResultStore ( page: Page, submittedScans: Record<string, unknown>[] ) {
|
||||||
|
await page.route( '**/api/test-results', async route => {
|
||||||
|
const postData = route.request().postDataJSON()
|
||||||
|
|
||||||
|
if ( postData && typeof postData === 'object' ) {
|
||||||
|
submittedScans.push( postData as Record<string, unknown> )
|
||||||
|
}
|
||||||
|
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
supportedVersionNumber: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} )
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForBodyText ( page: Page, expectedText: string, debugContext: {
|
||||||
|
consoleErrors: string[]
|
||||||
|
devServerOutput: string
|
||||||
|
pageErrors: string[]
|
||||||
|
} ) {
|
||||||
|
try {
|
||||||
|
await page.waitForFunction( textToFind => {
|
||||||
|
return Boolean( document.body?.textContent?.includes( textToFind ) )
|
||||||
|
}, expectedText, {
|
||||||
|
timeout: 30 * 1000
|
||||||
|
} )
|
||||||
|
} catch ( error ) {
|
||||||
|
const bodyText = await page.locator( 'body' ).textContent()
|
||||||
|
|
||||||
|
throw new Error( [
|
||||||
|
`Timed out waiting for body text: ${ expectedText }`,
|
||||||
|
bodyText || '',
|
||||||
|
debugContext.pageErrors.join( '\n' ),
|
||||||
|
debugContext.consoleErrors.join( '\n' ),
|
||||||
|
debugContext.devServerOutput
|
||||||
|
].filter( Boolean ).join( '\n\n' ), {
|
||||||
|
cause: error
|
||||||
|
} )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warmAppTestRoute ( browser: Browser, baseUrl: string, routeSuffix = '' ) {
|
||||||
|
const warmPage = await browser.newPage()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await warmPage.goto( `${ baseUrl }/apple-silicon-app-test/${ routeSuffix }`, {
|
||||||
|
waitUntil: 'load'
|
||||||
|
} )
|
||||||
|
await warmPage.waitForTimeout( 5000 )
|
||||||
|
} finally {
|
||||||
|
await warmPage.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
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()
|
|
||||||
} )
|
|
||||||
} )
|
|
||||||
149
test/playwright/pagefind-native-filter.playwright.ts
Normal file
149
test/playwright/pagefind-native-filter.playwright.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import type { Browser } from 'playwright-core'
|
||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it
|
||||||
|
} from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
launchBrowser,
|
||||||
|
startAstroDevServer,
|
||||||
|
stopChildProcess,
|
||||||
|
type AstroDevServer
|
||||||
|
} from './support/astro-browser-test'
|
||||||
|
|
||||||
|
describe( 'Pagefind dev search', () => {
|
||||||
|
let browser: Browser
|
||||||
|
let devServer: AstroDevServer
|
||||||
|
|
||||||
|
beforeAll( async () => {
|
||||||
|
devServer = await startAstroDevServer({
|
||||||
|
env: {
|
||||||
|
PUBLIC_SEARCH_PROVIDER: 'pagefind'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
browser = await launchBrowser()
|
||||||
|
} )
|
||||||
|
|
||||||
|
afterAll( async () => {
|
||||||
|
await browser?.close()
|
||||||
|
await stopChildProcess( devServer?.process || null )
|
||||||
|
} )
|
||||||
|
|
||||||
|
it( 'renders visible Pagefind results when Native Support is clicked', async () => {
|
||||||
|
const page = await browser.newPage()
|
||||||
|
const consoleErrors: string[] = []
|
||||||
|
const pageErrors: string[] = []
|
||||||
|
const pagefindResponses: Array<{
|
||||||
|
status: number
|
||||||
|
url: string
|
||||||
|
}> = []
|
||||||
|
const failedRequests: Array<{
|
||||||
|
errorText: string
|
||||||
|
url: string
|
||||||
|
}> = []
|
||||||
|
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( devServer.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 ), devServer.output.text ).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, bodyText ).toBeGreaterThan( 0 )
|
||||||
|
expect( renderedResults.loadingRows, JSON.stringify( renderedResults ) ).toBe( 0 )
|
||||||
|
expect( bodyText?.includes( 'No results found' ) ?? false, JSON.stringify( renderedResults ) ).toBe( false )
|
||||||
|
expect( consoleErrors, devServer.output.text ).toEqual( [] )
|
||||||
|
expect( pageErrors, devServer.output.text ).toEqual( [] )
|
||||||
|
|
||||||
|
await page.close()
|
||||||
|
} )
|
||||||
|
} )
|
||||||
77
test/playwright/support/app-archive-fixture.ts
Normal file
77
test/playwright/support/app-archive-fixture.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
import { Zip } from 'zip-lib'
|
||||||
|
|
||||||
|
const machoObjectBase64 =
|
||||||
|
'z/rt/gwAAAEAAAAAAQAAAAQAAABoAQAAACAAAAAAAAAZAAAA6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAiAEAAAAAAAA4AAAAAAAAAAcAAAAHAAAAAgAAAAAAAABfX3RleHQAAAAAAAAAAAAAX19URVhUAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAACIAQAAAgAAAAAAAAAAAAAAAAQAgAAAAAAAAAAAAAAAAF9fY29tcGFjdF91bndpbmRfX0xEAAAAAAAAAAAAAAAAGAAAAAAAAAAgAAAAAAAAAKABAAADAAAAwAEAAAEAAAAAAAACAAAAAAAAAAAAAAAAMgAAABgAAAABAAAAAAALAAACGgAAAAAAAgAAABgAAADIAQAAAwAAAPgBAAAYAAAACwAAAFAAAAAAAAAAAgAAAAIAAAABAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/QwDRAACAUv8PALn/QwCRwANf1gAAAAAAAAAAAAAAABQAAAAAEAACAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAGDQAAAA4BAAAAAAAAAAAAAAcAAAAOAgAAGAAAAAAAAAABAAAADwEAAAAAAAAAAAAAAF9tYWluAGx0bXAxAGx0bXAwAAAAAAAA'
|
||||||
|
|
||||||
|
export interface PlaywrightUploadFile {
|
||||||
|
arrayBuffer: ArrayBuffer
|
||||||
|
buffer: Buffer
|
||||||
|
mimeType: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInfoPlist ( appName: string ) {
|
||||||
|
return [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
||||||
|
'<plist version="1.0">',
|
||||||
|
'<dict>',
|
||||||
|
' <key>CFBundleDisplayName</key>',
|
||||||
|
` <string>${ appName }</string>`,
|
||||||
|
' <key>CFBundleExecutable</key>',
|
||||||
|
` <string>${ appName }</string>`,
|
||||||
|
' <key>CFBundleIdentifier</key>',
|
||||||
|
` <string>com.doesitarm.${ appName.toLowerCase().replaceAll( ' ', '-' ) }</string>`,
|
||||||
|
' <key>CFBundleName</key>',
|
||||||
|
` <string>${ appName }</string>`,
|
||||||
|
' <key>CFBundleShortVersionString</key>',
|
||||||
|
' <string>1.0.0</string>',
|
||||||
|
'</dict>',
|
||||||
|
'</plist>',
|
||||||
|
''
|
||||||
|
].join( '\n' )
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNativeAppArchive ( appName = 'Playwright Native App' ): Promise<PlaywrightUploadFile> {
|
||||||
|
const tempRoot = await mkdtemp( join( tmpdir(), 'doesitarm-playwright-' ) )
|
||||||
|
const appBundlePath = join( tempRoot, `${ appName }.app` )
|
||||||
|
const contentsPath = join( appBundlePath, 'Contents' )
|
||||||
|
const executablePath = join( contentsPath, 'MacOS', appName )
|
||||||
|
const archivePath = join( tempRoot, `${ appName }.app.zip` )
|
||||||
|
|
||||||
|
try {
|
||||||
|
const executableBytes = new Uint8Array( Buffer.from( machoObjectBase64, 'base64' ) )
|
||||||
|
|
||||||
|
await mkdir( join( contentsPath, 'MacOS' ), { recursive: true } )
|
||||||
|
await writeFile( join( contentsPath, 'Info.plist' ), makeInfoPlist( appName ) )
|
||||||
|
await writeFile( executablePath, executableBytes, { mode: 0o755 } )
|
||||||
|
|
||||||
|
const zip = new Zip()
|
||||||
|
|
||||||
|
zip.addFolder( appBundlePath, `${ appName }.app` )
|
||||||
|
await zip.archive( archivePath )
|
||||||
|
|
||||||
|
const archiveBuffer = await readFile( archivePath )
|
||||||
|
|
||||||
|
return {
|
||||||
|
arrayBuffer: archiveBuffer.buffer.slice(
|
||||||
|
archiveBuffer.byteOffset,
|
||||||
|
archiveBuffer.byteOffset + archiveBuffer.byteLength
|
||||||
|
),
|
||||||
|
buffer: archiveBuffer,
|
||||||
|
mimeType: 'application/zip',
|
||||||
|
name: `${ appName }.app.zip`,
|
||||||
|
type: 'application/zip'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm( tempRoot, {
|
||||||
|
force: true,
|
||||||
|
recursive: true
|
||||||
|
} )
|
||||||
|
}
|
||||||
|
}
|
||||||
176
test/playwright/support/astro-browser-test.ts
Normal file
176
test/playwright/support/astro-browser-test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||||
|
import { accessSync, constants } from 'node:fs'
|
||||||
|
import net from 'node:net'
|
||||||
|
|
||||||
|
import { chromium, type Browser } from 'playwright-core'
|
||||||
|
|
||||||
|
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||||
|
const host = '127.0.0.1'
|
||||||
|
|
||||||
|
export interface AstroDevServer {
|
||||||
|
baseUrl: string
|
||||||
|
output: {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
process: ChildProcessWithoutNullStreams | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccessPath ( filePath: string ) {
|
||||||
|
try {
|
||||||
|
accessSync( filePath, constants.X_OK )
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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( ( value ): value is string => Boolean( value ) )
|
||||||
|
|
||||||
|
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<number>( ( resolve, reject ) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
|
||||||
|
server.unref()
|
||||||
|
server.on( 'error', reject )
|
||||||
|
server.listen( 0, host, () => {
|
||||||
|
const address = server.address()
|
||||||
|
|
||||||
|
if ( !address || typeof address === 'string' ) {
|
||||||
|
reject( new Error( 'Unable to determine a free port.' ) )
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.close( err => {
|
||||||
|
if ( err ) {
|
||||||
|
reject( err )
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve( address.port )
|
||||||
|
} )
|
||||||
|
} )
|
||||||
|
} )
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForServer ( url: string, {
|
||||||
|
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 }` )
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startAstroDevServer ( {
|
||||||
|
env = {},
|
||||||
|
preferConfiguredBaseUrl = true
|
||||||
|
}: {
|
||||||
|
env?: Record<string, string>
|
||||||
|
preferConfiguredBaseUrl?: boolean
|
||||||
|
} = {} ): Promise<AstroDevServer> {
|
||||||
|
const configuredBaseUrl = process.env.PLAYWRIGHT_BASE_URL || ''
|
||||||
|
|
||||||
|
if ( preferConfiguredBaseUrl && configuredBaseUrl.length > 0 ) {
|
||||||
|
await waitForServer( configuredBaseUrl )
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: configuredBaseUrl,
|
||||||
|
output: { text: '' },
|
||||||
|
process: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = await getAvailablePort()
|
||||||
|
const baseUrl = `http://${ host }:${ port }`
|
||||||
|
const output = { text: '' }
|
||||||
|
|
||||||
|
const childProcess = spawn( command, [
|
||||||
|
'exec',
|
||||||
|
'astro',
|
||||||
|
'dev',
|
||||||
|
'--host',
|
||||||
|
host,
|
||||||
|
'--port',
|
||||||
|
String( port )
|
||||||
|
], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...env
|
||||||
|
},
|
||||||
|
stdio: [ 'ignore', 'pipe', 'pipe' ]
|
||||||
|
} )
|
||||||
|
|
||||||
|
childProcess.stdout.on( 'data', chunk => {
|
||||||
|
output.text += chunk.toString()
|
||||||
|
} )
|
||||||
|
childProcess.stderr.on( 'data', chunk => {
|
||||||
|
output.text += chunk.toString()
|
||||||
|
} )
|
||||||
|
|
||||||
|
await waitForServer( baseUrl )
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
output,
|
||||||
|
process: childProcess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopChildProcess ( childProcess: ChildProcessWithoutNullStreams | null ) {
|
||||||
|
return new Promise<void>( 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()
|
||||||
|
} )
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchBrowser (): Promise<Browser> {
|
||||||
|
return chromium.launch({
|
||||||
|
executablePath: getBrowserExecutablePath(),
|
||||||
|
headless: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,8 @@ const vitestConfig = {
|
||||||
test: {
|
test: {
|
||||||
setupFiles: 'tsconfig-paths/register',
|
setupFiles: 'tsconfig-paths/register',
|
||||||
include: [
|
include: [
|
||||||
'test/playwright/**/*.playwright.js'
|
'test/playwright/**/*.playwright.js',
|
||||||
|
'test/playwright/**/*.playwright.ts'
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
'test/_disabled/**'
|
'test/_disabled/**'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue