From 0480c47bbbb654b042b6cb308f5030324499025a Mon Sep 17 00:00:00 2001 From: ThatGuySam Date: Sat, 4 Apr 2026 14:55:13 -0500 Subject: [PATCH] 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 --- docs/plans/app-test-typescript-refactor.md | 49 ++++ package.json | 4 +- .../apple-silicon-app-test.playwright.ts | 178 ++++++++++++ .../pagefind-native-filter.playwright.js | 273 ------------------ .../pagefind-native-filter.playwright.ts | 149 ++++++++++ .../playwright/support/app-archive-fixture.ts | 77 +++++ test/playwright/support/astro-browser-test.ts | 176 +++++++++++ vitest.playwright.config.mjs | 3 +- 8 files changed, 633 insertions(+), 276 deletions(-) create mode 100644 docs/plans/app-test-typescript-refactor.md 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/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. 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..a542359 --- /dev/null +++ b/test/playwright/apple-silicon-app-test.playwright.ts @@ -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[] = [] + + 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[] ) { + 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, 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() + } +} 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..be0ff47 --- /dev/null +++ b/test/playwright/support/app-archive-fixture.ts @@ -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 [ + '', + '', + '', + '', + ' 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 ) + + 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 + } ) + } +} 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/**'