diff --git a/playwright.config.ts b/playwright.config.ts index 371154a..ff133fc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,10 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests/browser', + workers: 4, + use: { + serviceWorkers: 'block', + }, projects: [ { name: 'desktop-chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'mobile-chromium', use: { ...devices['Pixel 5'] } }, diff --git a/src/components/PortfolioSection.vue b/src/components/PortfolioSection.vue index 8ac7d5f..166cd2e 100644 --- a/src/components/PortfolioSection.vue +++ b/src/components/PortfolioSection.vue @@ -10,7 +10,7 @@ defineProps<{ {{ String(index).padStart(2, '0') }} / {{ eyebrow }} - {{ heading }} + {{ heading }} diff --git a/src/components/SiteNavigation.vue b/src/components/SiteNavigation.vue index dbeb8fe..981d1a0 100644 --- a/src/components/SiteNavigation.vue +++ b/src/components/SiteNavigation.vue @@ -2,13 +2,42 @@ import type { SectionId } from '@/content/types'; defineProps<{ items: ReadonlyArray<{ id: SectionId; label: string }> }>(); + +const focusActivatedSection = (event: MouseEvent): void => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + !(event.currentTarget instanceof HTMLAnchorElement) + ) { + return; + } + + let sectionId: string; + try { + sectionId = decodeURIComponent(event.currentTarget.hash.slice(1)); + } catch { + return; + } + + const section = document.getElementById(sectionId); + const heading = section?.querySelector( + ':scope > h2[tabindex="-1"]', + ); + if (heading === undefined || heading === null) return; + + requestAnimationFrame(() => heading.focus({ preventScroll: true })); +}; - {{ item.label }} + {{ item.label }} diff --git a/tests/browser/hydration.spec.ts b/tests/browser/hydration.spec.ts new file mode 100644 index 0000000..2bc0615 --- /dev/null +++ b/tests/browser/hydration.spec.ts @@ -0,0 +1,133 @@ +import { expect, test } from '../helpers/serve-dist'; + +interface DocumentSnapshot { + text: string; + sectionIds: string[]; + sectionHeadings: string[]; + h1: string; + role: string; + landmarks: { + headers: number; + mains: number; + navigations: number; + footers: number; + }; +} + +const captureDocument = async ( + page: import('@playwright/test').Page, +): Promise => + page.evaluate(() => ({ + text: (document.querySelector('.portfolio-shell')?.textContent ?? '') + .replace(/\s+/gu, ' ') + .trim(), + sectionIds: [ + ...document.querySelectorAll('main > section'), + ].map((section) => section.id), + sectionHeadings: [ + ...document.querySelectorAll('main > section > h2'), + ].map((heading) => heading.innerText.trim()), + h1: document.querySelector('h1')?.textContent?.trim() ?? '', + role: document.querySelector('.site-header > p')?.textContent?.trim() ?? '', + landmarks: { + headers: document.querySelectorAll('header').length, + mains: document.querySelectorAll('main').length, + navigations: document.querySelectorAll('nav').length, + footers: document.querySelectorAll('footer').length, + }, + })); + +test('hydration preserves the built subject matter and landmark structure without errors or external fetches', async ({ + browser, + page, + distUrl, +}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-chromium', + 'Hydration identity is covered once; semantic and no-script behavior run on mobile.', + ); + + const beforePage = await browser.newPage({ + javaScriptEnabled: false, + viewport: { width: 1440, height: 900 }, + }); + await beforePage.goto(distUrl, { waitUntil: 'load' }); + const before = await captureDocument(beforePage); + await beforePage.close(); + + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + const failedRequests: string[] = []; + const requests: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('request', (request) => requests.push(request.url())); + page.on('requestfailed', (request) => + failedRequests.push( + `${request.url()}: ${request.failure()?.errorText ?? 'failed'}`, + ), + ); + await page.addInitScript(() => { + const unhandled: string[] = []; + Reflect.set(globalThis, '__portfolioUnhandledRejections', unhandled); + addEventListener('unhandledrejection', (event) => + unhandled.push(String(event.reason)), + ); + }); + + await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => { + const app = document.querySelector('#app'); + return app !== null && Reflect.has(app, '__vue_app__'); + }); + await page.waitForFunction(() => document.readyState === 'complete'); + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + + const after = await captureDocument(page); + const unhandledRejections = await page.evaluate( + () => + (Reflect.get(globalThis, '__portfolioUnhandledRejections') as + string[] | undefined) ?? [], + ); + + expect(after).toEqual(before); + expect(after.h1).toBe('Luca Pîslaru'); + expect(after.role).toBe('System Administrator & Full-Stack Developer'); + expect(after.sectionIds).toEqual([ + 'start', + 'profile', + 'experience', + 'infrastructure', + 'selected-builds', + 'additional-work', + 'toolkit', + 'contact', + ]); + expect(after.landmarks).toEqual({ + headers: 1, + mains: 1, + navigations: 1, + footers: 1, + }); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); + expect(unhandledRejections).toEqual([]); + expect(failedRequests).toEqual([]); + expect(requests.length).toBeGreaterThan(1); + expect( + requests.every((requestUrl) => { + const url = new URL(requestUrl); + return ( + url.origin === distUrl && + (url.pathname === '/' || url.pathname.startsWith('/assets/')) + ); + }), + ).toBe(true); +}); diff --git a/tests/browser/keyboard.spec.ts b/tests/browser/keyboard.spec.ts new file mode 100644 index 0000000..c475823 --- /dev/null +++ b/tests/browser/keyboard.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from '../helpers/serve-dist'; + +const navHrefs = [ + '#start', + '#profile', + '#experience', + '#infrastructure', + '#selected-builds', + '#additional-work', + '#toolkit', + '#contact', +] as const; + +const assertVisibleUnobscuredFocus = async ( + page: import('@playwright/test').Page, +): Promise => { + await expect + .poll(() => + page.evaluate(() => { + const active = document.activeElement; + if (!(active instanceof HTMLElement)) return false; + const rect = active.getBoundingClientRect(); + const left = Math.max(0, rect.left); + const right = Math.min(window.innerWidth, rect.right); + const top = Math.max(0, rect.top); + const bottom = Math.min(window.innerHeight, rect.bottom); + if (right <= left || bottom <= top) return false; + const x = left + (right - left) / 2; + const y = top + (bottom - top) / 2; + const topElement = document.elementFromPoint(x, y); + const style = getComputedStyle(active); + return ( + topElement !== null && + (active === topElement || + active.contains(topElement) || + topElement.contains(active)) && + style.outlineStyle !== 'none' && + Number.parseFloat(style.outlineWidth) >= 2 + ); + }), + ) + .toBe(true); +}; + +test('tabs from the skip link through navigation and every contact action', async ({ + page, + distUrl, +}) => { + await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); + + const expectedTargets = [ + { selector: '.skip-link', href: '#portfolio-content' }, + ...navHrefs.map((href) => ({ selector: `nav a[href="${href}"]`, href })), + { + selector: '#contact a[href^="mailto:"]', + href: 'mailto:luca.pislaru@gmail.com', + }, + { + selector: '#contact a[href="https://github.com/LucaPisl"]', + href: 'https://github.com/LucaPisl', + }, + { + selector: '#contact a[href="https://gitlab.com/luca_pisl"]', + href: 'https://gitlab.com/luca_pisl', + }, + { + selector: '#contact a[href="https://linkedin.com/in/luca-pisl"]', + href: 'https://linkedin.com/in/luca-pisl', + }, + ]; + + for (const target of expectedTargets) { + await page.keyboard.press('Tab'); + const locator = page.locator(target.selector); + await expect(locator).toBeFocused(); + await expect(locator).toHaveAttribute('href', target.href); + await assertVisibleUnobscuredFocus(page); + } +}); + +test('explicit section activation scrolls to and focuses its heading', async ({ + page, + distUrl, +}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-chromium', + 'The full keyboard path already covers the mobile project.', + ); + await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); + + for (let index = 0; index < 5; index += 1) await page.keyboard.press('Tab'); + await expect(page.locator('nav a[href="#infrastructure"]')).toBeFocused(); + await page.keyboard.press('Enter'); + + await expect(page).toHaveURL(`${distUrl}/#infrastructure`); + await expect(page.locator('#infrastructure')).toBeInViewport(); + await expect(page.locator('#infrastructure-heading')).toHaveAttribute( + 'tabindex', + '-1', + ); + await expect(page.locator('#infrastructure-heading')).toBeFocused(); + await assertVisibleUnobscuredFocus(page); +}); + +test('manual wheel and PageDown scrolling never move focus', async ({ + page, + distUrl, +}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-chromium', + 'The focus invariant is engine-level rather than viewport-specific.', + ); + await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + const startLink = page.locator('nav a[href="#start"]'); + await expect(startLink).toBeFocused(); + + await page.mouse.wheel(0, 900); + await expect(startLink).toBeFocused(); + await page.keyboard.press('PageDown'); + await expect(startLink).toBeFocused(); +}); + +test('direct fragments do not steal focus while reduced-motion navigation remains accessible', async ({ + page, + distUrl, +}, testInfo) => { + test.skip( + testInfo.project.name === 'mobile-chromium', + 'The full keyboard path already covers the mobile project.', + ); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(`${distUrl}/#toolkit`, { waitUntil: 'domcontentloaded' }); + await expect(page.locator('#toolkit-heading')).not.toBeFocused(); + + await page.locator('nav a[href="#contact"]').click(); + await expect(page).toHaveURL(`${distUrl}/#contact`); + await expect(page.locator('#contact-heading')).toBeFocused(); + await expect(page.locator('#contact')).toBeInViewport(); +}); diff --git a/tests/browser/no-javascript.spec.ts b/tests/browser/no-javascript.spec.ts new file mode 100644 index 0000000..792c79f --- /dev/null +++ b/tests/browser/no-javascript.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '../helpers/serve-dist'; + +test.use({ javaScriptEnabled: false }); + +const sectionIds = [ + 'start', + 'profile', + 'experience', + 'infrastructure', + 'selected-builds', + 'additional-work', + 'toolkit', + 'contact', +] as const; + +const sectionHeadings = [ + 'START', + 'PROFILE', + 'EXPERIENCE', + 'INFRASTRUCTURE', + 'SELECTED BUILDS', + 'ADDITIONAL WORK', + 'TOOLKIT', + 'CONTACT', +] as const; + +test('keeps the complete hiring-manager path readable without JavaScript', async ({ + page, + distUrl, +}) => { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + const failedRequests: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('requestfailed', (request) => { + if (request.resourceType() !== 'script') { + failedRequests.push( + `${request.url()}: ${request.failure()?.errorText ?? 'failed'}`, + ); + } + }); + + await page.goto(distUrl, { waitUntil: 'load' }); + + await expect(page.locator('h1:visible')).toHaveCount(1); + await expect(page.locator('h1')).toHaveText('Luca Pîslaru'); + await expect(page.locator('.site-header > p').first()).toHaveText( + 'System Administrator & Full-Stack Developer', + ); + await expect(page.locator('main')).toBeVisible(); + await expect( + page.locator('nav[aria-label="Portfolio sections"]'), + ).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + expect( + await page + .locator('main > section') + .evaluateAll((sections) => sections.map((section) => section.id)), + ).toEqual(sectionIds); + expect(await page.locator('main > section > h2').allTextContents()).toEqual( + sectionHeadings, + ); + + for (const evidence of [ + 'Professional systems operations', + 'Secure self-hosted platform and lifecycle', + 'Private self-hosted medication tracker', + 'Footy Tracker', + 'Linux', + 'Docker', + 'Python', + 'Vue.js', + ]) { + await expect( + page.getByText(evidence, { exact: true }).first(), + ).toBeVisible(); + } + + const email = page.locator('a[href="mailto:luca.pislaru@gmail.com"]'); + await expect(email).toBeVisible(); + await expect(email).toHaveText('luca.pislaru@gmail.com'); + await expect( + page.getByText( + 'For a current CV or role-specific information, contact me directly.', + { exact: true }, + ), + ).toBeVisible(); + + for (const title of [ + 'Private self-hosted medication tracker', + 'Private healthcare platform', + 'Private game prototype', + ]) { + await expect( + page.locator('article').filter({ hasText: title }).locator('a'), + ).toHaveCount(0); + } + await expect(page.locator('a[download]')).toHaveCount(0); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); + expect(failedRequests).toEqual([]); +}); + +test('retains native section navigation when scripts are disabled', async ({ + page, + distUrl, +}) => { + await page.goto(distUrl, { waitUntil: 'load' }); + await page.locator('nav a[href="#contact"]').click(); + + await expect(page).toHaveURL(`${distUrl}/#contact`); + await expect(page.locator('#contact')).toBeInViewport(); + await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible(); +}); diff --git a/tests/browser/semantic.spec.ts b/tests/browser/semantic.spec.ts new file mode 100644 index 0000000..a202ddf --- /dev/null +++ b/tests/browser/semantic.spec.ts @@ -0,0 +1,179 @@ +import { request as httpRequest } from 'node:http'; +import { symlink, unlink } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { expect, test } from '../helpers/serve-dist'; + +const sectionIds = [ + 'start', + 'profile', + 'experience', + 'infrastructure', + 'selected-builds', + 'additional-work', + 'toolkit', + 'contact', +] as const; + +const sectionHeadings = [ + 'START', + 'PROFILE', + 'EXPERIENCE', + 'INFRASTRUCTURE', + 'SELECTED BUILDS', + 'ADDITIONAL WORK', + 'TOOLKIT', + 'CONTACT', +] as const; + +const rawStatus = async ( + origin: string, + path: string, + method = 'GET', +): Promise => + new Promise((resolve, reject) => { + const url = new URL(origin); + const request = httpRequest( + { + host: url.hostname, + method, + path, + port: url.port, + }, + (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode)); + }, + ); + request.once('error', reject); + request.end(); + }); + +test('serves only the owned generated preview surface', async ({ + distUrl, + request, +}, testInfo) => { + const document = await request.get(`${distUrl}/`); + expect(document.status()).toBe(200); + expect(document.headers()['content-type']).toBe('text/html; charset=utf-8'); + + const unsafeMethod = await request.post(`${distUrl}/index.html`); + expect(unsafeMethod.status()).toBe(405); + expect(unsafeMethod.headers().allow).toBe('GET, HEAD'); + expect(await rawStatus(distUrl, '/%2e%2e/package.json')).toBe(403); + expect( + (await request.get(`${distUrl}/not-a-preview-resource`)).status(), + ).toBe(404); + + const linkName = `.task-7-escape-${process.pid}-${testInfo.workerIndex}`; + const linkPath = resolve(process.cwd(), 'dist', linkName); + await symlink(resolve(process.cwd(), 'package.json'), linkPath); + try { + expect((await request.get(`${distUrl}/${linkName}`)).status()).toBe(403); + } finally { + await unlink(linkPath); + } +}); + +test('exposes the complete semantic portfolio from the generated document', async ({ + page, + distUrl, +}) => { + await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); + + await expect(page.locator('h1:visible')).toHaveCount(1); + await expect(page.locator('h1')).toHaveText('Luca Pîslaru'); + await expect(page.locator('.site-header > p').first()).toHaveText( + 'System Administrator & Full-Stack Developer', + ); + await expect(page.locator('main')).toHaveCount(1); + await expect( + page.locator('nav[aria-label="Portfolio sections"]'), + ).toHaveCount(1); + await expect(page.locator('footer')).toHaveCount(1); + + expect( + await page + .locator('main > section') + .evaluateAll((sections) => sections.map((section) => section.id)), + ).toEqual(sectionIds); + expect(await page.locator('main > section > h2').allTextContents()).toEqual( + sectionHeadings, + ); + expect( + await page.locator('h1, h2, h3').evaluateAll((headings) => { + const levels = headings.map((heading) => + Number(heading.tagName.slice(1)), + ); + return levels.every( + (level, index) => index === 0 || level <= levels[index - 1]! + 1, + ); + }), + ).toBe(true); +}); + +test('keeps canonical native anchors and safe external destinations', async ({ + page, + distUrl, +}) => { + await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); + + const navigation = page.locator('nav[aria-label="Portfolio sections"] a'); + expect( + await navigation.evaluateAll((links) => + links.map((link) => link.getAttribute('href')), + ), + ).toEqual(sectionIds.map((id) => `#${id}`)); + for (const id of sectionIds) { + await expect(page.locator(`#${id}`)).toHaveCount(1); + await expect(page.locator(`#${id}-heading`)).toHaveCount(1); + } + + const email = page.locator('a[href^="mailto:"]'); + await expect(email).toHaveCount(1); + await expect(email).toHaveText('luca.pislaru@gmail.com'); + + const externalLinks = page.locator('#contact a[target="_blank"]'); + await expect(externalLinks).toHaveCount(3); + expect( + await externalLinks.evaluateAll((links) => + links.every((link) => { + const rel = new Set((link.getAttribute('rel') ?? '').split(/\s+/u)); + return ( + link instanceof HTMLAnchorElement && + link.protocol === 'https:' && + rel.has('noopener') && + rel.has('noreferrer') + ); + }), + ), + ).toBe(true); +}); + +test('never links private projects or packages a résumé download', async ({ + page, + distUrl, +}) => { + await page.goto(distUrl, { waitUntil: 'domcontentloaded' }); + + for (const title of [ + 'Private self-hosted medication tracker', + 'Private healthcare platform', + 'Private game prototype', + ]) { + await expect( + page.locator('article').filter({ hasText: title }).locator('a'), + ).toHaveCount(0); + } + await expect(page.locator('a[download]')).toHaveCount(0); + expect( + await page + .locator('a') + .evaluateAll((links) => + links.some( + (link) => + /(?:résumé|resume|\bcv\b)/iu.test(link.textContent ?? '') || + /\.(?:pdf|docx?)(?:$|[?#])/iu.test(link.getAttribute('href') ?? ''), + ), + ), + ).toBe(false); +}); diff --git a/tests/helpers/serve-dist.ts b/tests/helpers/serve-dist.ts new file mode 100644 index 0000000..f560f09 --- /dev/null +++ b/tests/helpers/serve-dist.ts @@ -0,0 +1,206 @@ +import { readFile, realpath, stat } from 'node:fs/promises'; +import { createServer, type ServerResponse } from 'node:http'; +import { extname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { expect, test as base } from '@playwright/test'; + +const LOOPBACK_HOST = '127.0.0.1'; + +const contentTypes: Readonly> = { + '.avif': 'image/avif', + '.br': 'application/octet-stream', + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.xml': 'application/xml; charset=utf-8', +}; + +const sendText = ( + response: ServerResponse, + status: number, + body: string, +): void => { + response.writeHead(status, { + 'Cache-Control': 'no-store', + 'Content-Length': Buffer.byteLength(body), + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }); + response.end(body); +}; + +const isInside = (root: string, candidate: string): boolean => { + const pathFromRoot = relative(root, candidate); + return ( + pathFromRoot === '' || + (!isAbsolute(pathFromRoot) && + pathFromRoot !== '..' && + !pathFromRoot.startsWith(`..${sep}`)) + ); +}; + +const decodedRequestPath = (requestTarget: string): string | null => { + const rawPath = requestTarget.split(/[?#]/u, 1)[0] ?? '/'; + let decoded: string; + try { + decoded = decodeURIComponent(rawPath); + } catch { + return null; + } + + if ( + !decoded.startsWith('/') || + decoded.includes('\0') || + decoded.includes('\\') + ) { + return null; + } + + const segments = decoded.split('/'); + if (segments.some((segment) => segment === '.' || segment === '..')) { + return null; + } + + return decoded === '/' || decoded.endsWith('/') + ? `${decoded}index.html` + : decoded; +}; + +export interface DistPreviewServer { + readonly origin: string; + close(): Promise; +} + +export async function startDistServer( + distRoot = resolve(process.cwd(), 'dist'), +): Promise { + const root = await realpath(distRoot); + if (!(await stat(root)).isDirectory()) { + throw new Error(`The preview output is not a directory: ${distRoot}`); + } + + const server = createServer((request, response) => { + void (async () => { + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.setHeader('Allow', 'GET, HEAD'); + sendText(response, 405, 'Method not allowed.'); + return; + } + + const requestPath = decodedRequestPath(request.url ?? '/'); + if (requestPath === null) { + sendText(response, 403, 'Forbidden.'); + return; + } + + const candidate = resolve(root, `.${requestPath}`); + if (!isInside(root, candidate)) { + sendText(response, 403, 'Forbidden.'); + return; + } + + let filePath: string; + try { + filePath = await realpath(candidate); + } catch { + sendText(response, 404, 'Not found.'); + return; + } + + if (!isInside(root, filePath)) { + sendText(response, 403, 'Forbidden.'); + return; + } + + const file = await stat(filePath); + if (!file.isFile()) { + sendText(response, 404, 'Not found.'); + return; + } + + const body = await readFile(filePath); + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Length': body.byteLength, + 'Content-Type': + contentTypes[extname(filePath).toLowerCase()] ?? + 'application/octet-stream', + 'X-Content-Type-Options': 'nosniff', + }); + response.end(request.method === 'HEAD' ? undefined : body); + })().catch(() => { + if (!response.headersSent) { + sendText(response, 500, 'Internal server error.'); + } else { + response.destroy(); + } + }); + }); + + await new Promise((resolveListening, reject) => { + const onError = (error: Error): void => reject(error); + server.once('error', onError); + server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => { + server.off('error', onError); + resolveListening(); + }); + }); + + const address = server.address(); + if ( + address === null || + typeof address === 'string' || + address.address !== LOOPBACK_HOST + ) { + server.closeAllConnections(); + await new Promise((resolveClosed) => + server.close(() => resolveClosed()), + ); + throw new Error( + 'The preview server did not bind the owned loopback listener.', + ); + } + + let closed = false; + return { + origin: `http://${LOOPBACK_HOST}:${address.port}`, + async close(): Promise { + if (closed) return; + closed = true; + await new Promise((resolveClosed, reject) => { + server.close((error) => + error === undefined ? resolveClosed() : reject(error), + ); + server.closeIdleConnections(); + server.closeAllConnections(); + }); + }, + }; +} + +export const test = base.extend<{}, { distUrl: string }>({ + distUrl: [ + async ({}, use) => { + const preview = await startDistServer(); + try { + await use(preview.origin); + } finally { + await preview.close(); + } + }, + { scope: 'worker' }, + ], +}); + +export { expect };
{{ String(index).padStart(2, '0') }} / {{ eyebrow }}