218 lines
7.3 KiB
TypeScript
218 lines
7.3 KiB
TypeScript
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<string>();
|
|
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<void> {
|
|
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<void> {
|
|
const results = await new AxeBuilder({ page }).analyze();
|
|
expect(
|
|
results.violations.filter(({ impact }) =>
|
|
impact === 'serious' || impact === 'critical'
|
|
)
|
|
).toEqual([]);
|
|
}
|