import AxeBuilder from '@axe-core/playwright'; import { readFile } from 'node:fs/promises'; import { expect, test } from '@playwright/test'; type BrowserAuth = { session: string; csrf: string; }; const authPath = process.env.NUTHATCH_BROWSER_AUTH_FILE; if (authPath === undefined) { throw new Error('NUTHATCH_BROWSER_AUTH_FILE is required'); } const auth = JSON.parse(await readFile(authPath, 'utf8')) as BrowserAuth; test.beforeEach(async ({ context, baseURL }) => { if (baseURL === undefined) throw new Error('NUTHATCH_BROWSER_BASE_URL is required'); const hostname = new URL(baseURL).hostname; await context.addCookies([ { name: 'nuthatch_session', value: auth.session, domain: hostname, path: '/', secure: true, httpOnly: true, sameSite: 'Lax' }, { name: 'nuthatch_csrf', value: auth.csrf, domain: hostname, path: '/', secure: true, httpOnly: false, sameSite: 'Lax' } ]); }); test('compiled application completes the critical browser journey', async ({ page, context }, testInfo) => { const browserErrors: string[] = []; const assets = new Set(); page.on('console', (message) => { if (message.type() === 'error') browserErrors.push(message.text()); }); page.on('pageerror', (error) => browserErrors.push(error.message)); page.on('response', (response) => { if (new URL(response.url()).pathname.startsWith('/assets/')) { assets.add(response.url()); } }); await page.goto('/app'); await expect(page.getByRole('heading', { name: 'Your files' })).toBeVisible(); await expect(page.getByText('Browser Fixture')).toBeVisible(); expect(assets.size).toBeGreaterThan(0); await expectAccessible(page); const shared = page.getByRole('button', { name: 'Shared', exact: true }); await shared.focus(); await page.keyboard.press('Enter'); await expect(shared).toHaveAttribute('aria-pressed', 'true'); await expect(page.getByText('Shared root', { exact: true })).toBeVisible(); await expectAccessible(page); await page.getByRole('button', { name: 'Personal', exact: true }).click(); const fileInput = page.getByLabel('Choose file'); await expect(fileInput).toBeEnabled(); let failUpload = true; await page.route('**/api/upload?**', async (route) => { if (failUpload) { failUpload = false; await route.abort('failed'); } else { await route.continue(); } }); await fileInput.setInputFiles({ name: 'upload-retry.txt', mimeType: 'text/plain', buffer: Buffer.from('browser upload') }); await expect(page.getByRole('alert')).toBeVisible(); await expect(page.getByRole('button', { name: 'Retry same upload' })).toBeVisible(); await expectAccessible(page); expect(browserErrors).toEqual(['Failed to load resource: net::ERR_FAILED']); browserErrors.length = 0; await page.unroute('**/api/upload?**'); await page.getByRole('button', { name: 'Retry same upload' }).click(); await expect(page.getByText('Uploaded upload-retry.txt', { exact: true })).toBeVisible(); const [download] = await Promise.all([ page.waitForEvent('download'), page.getByRole('button', { name: /^↓ upload-retry\.txt/ }).click() ]); expect(download.suggestedFilename()).toBe('upload-retry.txt'); for (const path of [ 'search/a.txt', 'search/b.txt', 'search/c.txt', 'search/d.txt', '00-journey.txt' ]) { await uploadFixture(page, path, path); } await page.getByRole('button', { name: 'Refresh' }).click(); const search = page.getByRole('textbox', { name: 'Search this root' }); await search.fill('search'); await page.getByRole('button', { name: 'Search', exact: true }).click(); await expect(page.getByText(/Partial search results/)).toBeVisible(); await expectAccessible(page); await search.fill('00-journey'); await page.getByRole('button', { name: 'Search', exact: true }).click(); page.once('dialog', (dialog) => dialog.accept()); await page.getByRole('button', { name: 'Delete 00-journey.txt' }).click(); await expect(page.getByText('Deleted 00-journey.txt', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Refresh' })).toBeFocused(); await uploadFixture(page, '00-tree/a.txt', 'a'); await uploadFixture(page, '00-tree/b.txt', 'b'); await search.fill(''); await page.getByRole('button', { name: 'Search', exact: true }).click(); await expect(page.getByRole('button', { name: /00-tree\// })).toBeVisible(); let failBatch = true; await page.route('**/api/delete-batch', async (route) => { if (failBatch) { failBatch = false; await route.abort('failed'); } else { await route.continue(); } }); page.once('dialog', (dialog) => dialog.accept()); await page.getByRole('button', { name: 'Delete 00-tree' }).click(); await expect(page.getByRole('alert')).toBeVisible(); await expect(page.getByRole('button', { name: 'Resume deletion' })).toBeVisible(); expect( await page.evaluate(() => localStorage.getItem('nuthatch-recursive-delete')) ).not.toBeNull(); await expectAccessible(page); expect(browserErrors).toEqual(['Failed to load resource: net::ERR_FAILED']); browserErrors.length = 0; await page.unroute('**/api/delete-batch'); await page.reload(); await expect(page.getByRole('button', { name: 'Resume deletion' })).toBeVisible(); await page.getByRole('button', { name: 'Resume deletion' }).click(); await expect(page.getByText(/Deleted 2 objects from 00-tree/)).toBeVisible(); await expectAccessible(page); const logout = page.waitForResponse( (response) => new URL(response.url()).pathname === '/logout' && response.request().method() === 'POST' ); await page.getByRole('button', { name: 'Log out' }).click(); expect((await logout).status()).toBe(200); const cookies = await context.cookies(); expect(cookies.some(({ name }) => name === 'nuthatch_session')).toBe(false); await testInfo.attach('browser-errors', { body: browserErrors.join('\n'), contentType: 'text/plain' }); expect(browserErrors).toEqual([]); }); async function uploadFixture( page: import('@playwright/test').Page, path: string, body: string ): Promise { const response = await page.evaluate( async ({ path, body, csrf }) => { const bytes = new TextEncoder().encode(body); const digest = await crypto.subtle.digest('SHA-256', bytes); const contentSha256 = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0') ).join(''); const query = new URLSearchParams({ root: 'personal', path, operation_id: crypto.randomUUID() }); const result = await fetch(`/api/upload?${query}`, { method: 'POST', credentials: 'same-origin', headers: { 'X-Declared-Size': String(bytes.byteLength), 'X-Content-SHA256': contentSha256, 'X-CSRF-Token': csrf }, body: bytes }); return { status: result.status, body: await result.text() }; }, { path, body, csrf: auth.csrf } ); expect(response.status, response.body).toBe(200); } async function expectAccessible( page: import('@playwright/test').Page ): Promise { const results = await new AxeBuilder({ page }).analyze(); expect( results.violations.filter(({ impact }) => impact === 'serious' || impact === 'critical' ) ).toEqual([]); }