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:
ThatGuySam 2026-04-04 14:55:13 -05:00
parent c5ec942de0
commit 0480c47bbb
8 changed files with 633 additions and 276 deletions

View 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
} )
}
}

View 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
})
}