From e638bdfa74b1038541d447074e96ecce8aee9791 Mon Sep 17 00:00:00 2001 From: ThatGuySam Date: Sat, 4 Apr 2026 13:05:03 -0500 Subject: [PATCH 1/2] test(playwright): cover browser flows with typed harness Lock the Apple Silicon upload flow behind a real browser regression and migrate the Pagefind browser regression onto a shared typed harness. This keeps the branch non-runtime and repairs the targeted pagefind script alias after moving the browser test to TypeScript. Constraint: Must avoid runtime code changes on this branch Rejected: Ship the app-test coverage without fixing script aliases | leaves a broken targeted check in package.json Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep browser-only support helpers under test/playwright/support until runtime code is typed separately Tested: pnpm run typecheck; pnpm run test:browser; pnpm run test:browser:pagefind Not-tested: pnpm run test:browser:pagefind:live --- package.json | 4 +- .../apple-silicon-app-test.playwright.ts | 165 +++++++++++ .../pagefind-native-filter.playwright.js | 273 ------------------ .../pagefind-native-filter.playwright.ts | 149 ++++++++++ .../playwright/support/app-archive-fixture.ts | 68 +++++ test/playwright/support/astro-browser-test.ts | 176 +++++++++++ vitest.playwright.config.mjs | 3 +- 7 files changed, 562 insertions(+), 276 deletions(-) create mode 100644 test/playwright/apple-silicon-app-test.playwright.ts delete mode 100644 test/playwright/pagefind-native-filter.playwright.js create mode 100644 test/playwright/pagefind-native-filter.playwright.ts create mode 100644 test/playwright/support/app-archive-fixture.ts create mode 100644 test/playwright/support/astro-browser-test.ts diff --git a/package.json b/package.json index fc6f0f6..50e3da1 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "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", + "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.ts", "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", diff --git a/test/playwright/apple-silicon-app-test.playwright.ts b/test/playwright/apple-silicon-app-test.playwright.ts new file mode 100644 index 0000000..9699e0f --- /dev/null +++ b/test/playwright/apple-silicon-app-test.playwright.ts @@ -0,0 +1,165 @@ +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' + +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 warmAppTestRoute( browser, devServer.baseUrl ) + } ) + + afterAll( async () => { + await browser?.close() + await stopChildProcess( devServer?.process || null ) + } ) + + it( 'uploads an app archive, scans it, and renders a native result', async () => { + const page = await browser.newPage() + const consoleErrors: string[] = [] + const pageErrors: string[] = [] + const submittedScans: Record[] = [] + + 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/`, { + 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[] ) { + await page.route( '**/api/test-results', async route => { + const postData = route.request().postDataJSON() + + if ( postData && typeof postData === 'object' ) { + submittedScans.push( postData as Record ) + } + + 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 ) { + const warmPage = await browser.newPage() + + try { + await warmPage.goto( `${ baseUrl }/apple-silicon-app-test/`, { + waitUntil: 'load' + } ) + await warmPage.waitForTimeout( 5000 ) + } finally { + await warmPage.close() + } +} diff --git a/test/playwright/pagefind-native-filter.playwright.js b/test/playwright/pagefind-native-filter.playwright.js deleted file mode 100644 index 5de7712..0000000 --- a/test/playwright/pagefind-native-filter.playwright.js +++ /dev/null @@ -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() - } ) -} ) diff --git a/test/playwright/pagefind-native-filter.playwright.ts b/test/playwright/pagefind-native-filter.playwright.ts new file mode 100644 index 0000000..face037 --- /dev/null +++ b/test/playwright/pagefind-native-filter.playwright.ts @@ -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() + } ) +} ) diff --git a/test/playwright/support/app-archive-fixture.ts b/test/playwright/support/app-archive-fixture.ts new file mode 100644 index 0000000..933730c --- /dev/null +++ b/test/playwright/support/app-archive-fixture.ts @@ -0,0 +1,68 @@ +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 { + buffer: Buffer + mimeType: string + name: string +} + +function makeInfoPlist ( appName: string ) { + return [ + '', + '', + '', + '', + ' CFBundleDisplayName', + ` ${ appName }`, + ' CFBundleExecutable', + ` ${ appName }`, + ' CFBundleIdentifier', + ` com.doesitarm.${ appName.toLowerCase().replaceAll( ' ', '-' ) }`, + ' CFBundleName', + ` ${ appName }`, + ' CFBundleShortVersionString', + ' 1.0.0', + '', + '', + '' + ].join( '\n' ) +} + +export async function createNativeAppArchive ( appName = 'Playwright Native App' ): Promise { + 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 ) + + return { + buffer: await readFile( archivePath ), + mimeType: 'application/zip', + name: `${ appName }.app.zip` + } + } finally { + await rm( tempRoot, { + force: true, + recursive: true + } ) + } +} diff --git a/test/playwright/support/astro-browser-test.ts b/test/playwright/support/astro-browser-test.ts new file mode 100644 index 0000000..af3b2d1 --- /dev/null +++ b/test/playwright/support/astro-browser-test.ts @@ -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( ( 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 + preferConfiguredBaseUrl?: boolean +} = {} ): Promise { + 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( 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 { + return chromium.launch({ + executablePath: getBrowserExecutablePath(), + headless: true + }) +} diff --git a/vitest.playwright.config.mjs b/vitest.playwright.config.mjs index 987a9cc..57fb266 100644 --- a/vitest.playwright.config.mjs +++ b/vitest.playwright.config.mjs @@ -9,7 +9,8 @@ const vitestConfig = { test: { setupFiles: 'tsconfig-paths/register', include: [ - 'test/playwright/**/*.playwright.js' + 'test/playwright/**/*.playwright.js', + 'test/playwright/**/*.playwright.ts' ], exclude: [ 'test/_disabled/**' From d4c8082453c7c1105fd770a9697b5b0581ede8ec Mon Sep 17 00:00:00 2001 From: ThatGuySam Date: Sat, 4 Apr 2026 13:05:16 -0500 Subject: [PATCH 2/2] docs(plan): capture app-test TypeScript rollout Record the staged approach for locking the Apple Silicon scan flow with browser coverage before moving deeper into the TypeScript conversion. Keeping this plan separate from the test commit preserves a clean non-runtime history. Constraint: Plan should not change runtime behavior or blur code-review scope Rejected: Fold the plan into the browser-test commit | mixes delivery guidance with executable changes Confidence: high Scope-risk: narrow Reversibility: clean Directive: Revisit the worker-based scanner path only after the legacy app-test route has stable browser coverage Tested: Document reviewed against current staged test coverage and repo layout Not-tested: Plan execution beyond the committed browser-test slice --- docs/plans/app-test-typescript-refactor.md | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/plans/app-test-typescript-refactor.md diff --git a/docs/plans/app-test-typescript-refactor.md b/docs/plans/app-test-typescript-refactor.md new file mode 100644 index 0000000..71165d5 --- /dev/null +++ b/docs/plans/app-test-typescript-refactor.md @@ -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.