Spaces:
Running
Running
Commit ·
c24efc8
1
Parent(s): b11153a
test(e2e): add Playwright smoke test covering the core flows
Browse files- playwright.config.ts +24 -0
- tests/e2e/smoke.spec.ts +173 -0
playwright.config.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from '@playwright/test';
|
| 2 |
+
|
| 3 |
+
export default defineConfig({
|
| 4 |
+
testDir: 'tests/e2e',
|
| 5 |
+
timeout: 60_000,
|
| 6 |
+
fullyParallel: false,
|
| 7 |
+
workers: 1,
|
| 8 |
+
use: { baseURL: 'http://localhost:4173', viewport: { width: 390, height: 844 } },
|
| 9 |
+
webServer: {
|
| 10 |
+
command: 'rm -rf .data-e2e && npm run build && node build',
|
| 11 |
+
port: 4173,
|
| 12 |
+
reuseExistingServer: false,
|
| 13 |
+
timeout: 180_000,
|
| 14 |
+
env: {
|
| 15 |
+
STORAGE: 'local',
|
| 16 |
+
LOCAL_DATA_DIR: '.data-e2e',
|
| 17 |
+
SESSION_SECRET: 'e2e-secret-e2e-secret-e2e-secret-e2e',
|
| 18 |
+
PORT: '4173',
|
| 19 |
+
ORIGIN: 'http://localhost:4173',
|
| 20 |
+
BODY_SIZE_LIMIT: '8M',
|
| 21 |
+
COMPACTION_INTERVAL_MS: '3600000'
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
});
|
tests/e2e/smoke.spec.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page } from '@playwright/test';
|
| 2 |
+
|
| 3 |
+
const ONE_PX_PNG = Buffer.from(
|
| 4 |
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
| 5 |
+
'base64'
|
| 6 |
+
);
|
| 7 |
+
|
| 8 |
+
/** The expense/settlement amount field; `Amount for <name>` inputs exist too in exact mode. */
|
| 9 |
+
const AMOUNT_LABEL = /^Amount \(/;
|
| 10 |
+
|
| 11 |
+
async function login(page: Page, username: string, password: string) {
|
| 12 |
+
await page.goto('/login');
|
| 13 |
+
await page.getByLabel('Username').fill(username);
|
| 14 |
+
await page.getByLabel('Password', { exact: true }).fill(password);
|
| 15 |
+
await page.getByRole('button', { name: 'Log in' }).click();
|
| 16 |
+
// The form is progressively enhanced: wait for the redirect before navigating away,
|
| 17 |
+
// otherwise a following goto() aborts the in-flight submission.
|
| 18 |
+
await expect(page).not.toHaveURL(/\/login/);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
async function changePassword(page: Page, current: string, next: string) {
|
| 22 |
+
await expect(page).toHaveURL(/\/me\/password/);
|
| 23 |
+
await page.getByLabel('Current password').fill(current);
|
| 24 |
+
await page.getByLabel('New password (8+ characters)').fill(next);
|
| 25 |
+
await page.getByLabel('Confirm new password').fill(next);
|
| 26 |
+
await page.getByRole('button', { name: 'Save password' }).click();
|
| 27 |
+
await expect(page).toHaveURL('/');
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
async function logout(page: Page) {
|
| 31 |
+
await page.goto('/me');
|
| 32 |
+
await page.getByRole('button', { name: 'Log out' }).click();
|
| 33 |
+
await expect(page).toHaveURL(/\/login/);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
test.describe.configure({ mode: 'serial' });
|
| 37 |
+
|
| 38 |
+
test('admin bootstraps, is forced to change password, and creates users', async ({ page }) => {
|
| 39 |
+
await login(page, 'admin', '1234');
|
| 40 |
+
await changePassword(page, '1234', 'adminpass123');
|
| 41 |
+
|
| 42 |
+
await page.goto('/admin');
|
| 43 |
+
for (const [username, name] of [
|
| 44 |
+
['alice', 'Alice'],
|
| 45 |
+
['bob', 'Bob']
|
| 46 |
+
]) {
|
| 47 |
+
await page.getByLabel('Username').fill(username);
|
| 48 |
+
await page.getByLabel('Display name', { exact: true }).fill(name);
|
| 49 |
+
await page.getByLabel(/Temporary password/).fill('temppass1');
|
| 50 |
+
await page.getByRole('button', { name: 'Create user' }).click();
|
| 51 |
+
await expect(page.getByText(`Created user ${username}`)).toBeVisible();
|
| 52 |
+
}
|
| 53 |
+
await expect(page.getByText('@alice')).toBeVisible();
|
| 54 |
+
await expect(page.getByText('@bob')).toBeVisible();
|
| 55 |
+
await logout(page);
|
| 56 |
+
});
|
| 57 |
+
|
| 58 |
+
test('member adds expenses with every split mode and a photo', async ({ page }) => {
|
| 59 |
+
await login(page, 'alice', 'temppass1');
|
| 60 |
+
await changePassword(page, 'temppass1', 'alicepass1');
|
| 61 |
+
|
| 62 |
+
// Equal split, with photo.
|
| 63 |
+
await page.goto('/expenses/new');
|
| 64 |
+
await page.getByLabel('Description').fill('Pizza');
|
| 65 |
+
await page.getByLabel(AMOUNT_LABEL).fill('30');
|
| 66 |
+
await page
|
| 67 |
+
.getByLabel('Receipt photo (optional)')
|
| 68 |
+
.setInputFiles({ name: 'r.png', mimeType: 'image/png', buffer: ONE_PX_PNG });
|
| 69 |
+
await page.getByRole('button', { name: 'Save expense' }).click();
|
| 70 |
+
await expect(page).toHaveURL(/\/expenses\/e_/);
|
| 71 |
+
await expect(page.getByRole('heading', { name: 'Pizza' })).toBeVisible();
|
| 72 |
+
const img = page.getByAltText('Receipt for Pizza');
|
| 73 |
+
await expect(img).toBeVisible();
|
| 74 |
+
const src = await img.getAttribute('src');
|
| 75 |
+
const res = await page.request.get(src!);
|
| 76 |
+
expect(res.status()).toBe(200);
|
| 77 |
+
expect(res.headers()['content-type']).toMatch(/^image\//);
|
| 78 |
+
|
| 79 |
+
// Percent split: Alice 25 / Bob 75 of 100.
|
| 80 |
+
await page.goto('/expenses/new');
|
| 81 |
+
await page.getByLabel('Description').fill('Concert');
|
| 82 |
+
await page.getByLabel(AMOUNT_LABEL).fill('100');
|
| 83 |
+
await page.getByText('By %').click();
|
| 84 |
+
await page.getByLabel('Percent for Alice').fill('25');
|
| 85 |
+
await page.getByLabel('Percent for Bob').fill('75');
|
| 86 |
+
await expect(page.getByText('Total 100.00%')).toBeVisible();
|
| 87 |
+
await page.getByRole('button', { name: 'Save expense' }).click();
|
| 88 |
+
await expect(page).toHaveURL(/\/expenses\/e_/);
|
| 89 |
+
|
| 90 |
+
// Exact split with a validation error first.
|
| 91 |
+
await page.goto('/expenses/new');
|
| 92 |
+
await page.getByLabel('Description').fill('Groceries');
|
| 93 |
+
await page.getByLabel(AMOUNT_LABEL).fill('40');
|
| 94 |
+
await page.getByText('Exact').click();
|
| 95 |
+
await page.getByLabel('Amount for Alice').fill('10');
|
| 96 |
+
await page.getByLabel('Amount for Bob').fill('20');
|
| 97 |
+
await page.getByRole('button', { name: 'Save expense' }).click();
|
| 98 |
+
await expect(page.getByRole('alert')).toContainText('add up to the total');
|
| 99 |
+
await page.getByLabel('Amount for Bob').fill('30');
|
| 100 |
+
await page.getByRole('button', { name: 'Save expense' }).click();
|
| 101 |
+
await expect(page).toHaveURL(/\/expenses\/e_/);
|
| 102 |
+
|
| 103 |
+
// Feed lists all three.
|
| 104 |
+
await page.goto('/');
|
| 105 |
+
for (const t of ['Pizza', 'Concert', 'Groceries'])
|
| 106 |
+
await expect(page.getByText(t, { exact: true })).toBeVisible();
|
| 107 |
+
|
| 108 |
+
// Balances: Bob owes Alice 15 + 75 + 30 = 120.
|
| 109 |
+
await page.goto('/balances');
|
| 110 |
+
await expect(page.getByText('You are owed')).toBeVisible();
|
| 111 |
+
await expect(page.getByText('$120.00').first()).toBeVisible();
|
| 112 |
+
await expect(page.getByText(/Bob\s+owes\s+Alice/)).toBeVisible();
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
test('concurrent edit is rejected with a conflict', async ({ browser }) => {
|
| 116 |
+
const ctx = await browser.newContext();
|
| 117 |
+
const a = await ctx.newPage();
|
| 118 |
+
await login(a, 'alice', 'alicepass1');
|
| 119 |
+
await a.goto('/');
|
| 120 |
+
await a.getByText('Pizza', { exact: true }).click();
|
| 121 |
+
await a.getByRole('link', { name: 'Edit' }).click();
|
| 122 |
+
await expect(a).toHaveURL(/\/expenses\/e_[^/]+\/edit$/);
|
| 123 |
+
const editUrl = a.url();
|
| 124 |
+
|
| 125 |
+
const b = await ctx.newPage();
|
| 126 |
+
await b.goto(editUrl);
|
| 127 |
+
await b.getByLabel('Description').fill('Pizza (edited by B)');
|
| 128 |
+
await b.getByRole('button', { name: 'Save changes' }).click();
|
| 129 |
+
await expect(b).toHaveURL(/\/expenses\/e_[^/]+$/);
|
| 130 |
+
|
| 131 |
+
await a.getByLabel('Description').fill('Pizza (edited by A)');
|
| 132 |
+
await a.getByRole('button', { name: 'Save changes' }).click();
|
| 133 |
+
await expect(a.getByRole('alert')).toContainText('changed by someone else');
|
| 134 |
+
await ctx.close();
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
test('settle up records a payment and clears the debt; delete works', async ({ page }) => {
|
| 138 |
+
await login(page, 'bob', 'temppass1');
|
| 139 |
+
await changePassword(page, 'temppass1', 'bobpass123');
|
| 140 |
+
|
| 141 |
+
await page.goto('/balances');
|
| 142 |
+
await expect(page.getByText('You owe', { exact: true })).toBeVisible();
|
| 143 |
+
await page.getByRole('link', { name: 'Settle up' }).click();
|
| 144 |
+
await expect(page).toHaveURL(/\/settlements\/new\?from=/);
|
| 145 |
+
await expect(page.getByLabel(AMOUNT_LABEL)).toHaveValue('120.00');
|
| 146 |
+
await page.getByRole('button', { name: 'Save payment' }).click();
|
| 147 |
+
await expect(page).toHaveURL(/\/settlements\/s_/);
|
| 148 |
+
|
| 149 |
+
await page.goto('/balances');
|
| 150 |
+
await expect(page.getByText("You're all settled up")).toBeVisible();
|
| 151 |
+
await expect(page.getByText('Nobody owes anything')).toBeVisible();
|
| 152 |
+
|
| 153 |
+
// Delete the Groceries expense via the two-step confirm.
|
| 154 |
+
await page.goto('/');
|
| 155 |
+
await page.getByText('Groceries', { exact: true }).click();
|
| 156 |
+
await page.getByRole('link', { name: 'Delete expense' }).click();
|
| 157 |
+
await page.getByRole('button', { name: 'Confirm delete' }).click();
|
| 158 |
+
await expect(page).toHaveURL('/');
|
| 159 |
+
await expect(page.getByText('Groceries', { exact: true })).toHaveCount(0);
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
test('admin compacts pending events into the ledger', async ({ page }) => {
|
| 163 |
+
await login(page, 'admin', 'adminpass123');
|
| 164 |
+
await page.goto('/admin/storage');
|
| 165 |
+
await expect(page.getByText('Pending event files')).toBeVisible();
|
| 166 |
+
await page.getByRole('button', { name: 'Compact now' }).click();
|
| 167 |
+
await expect(page.getByText(/Done: appended \d+, deleted \d+/)).toBeVisible();
|
| 168 |
+
await page.reload();
|
| 169 |
+
const pending = page
|
| 170 |
+
.locator('dt', { hasText: 'Pending event files' })
|
| 171 |
+
.locator('xpath=following-sibling::dd[1]');
|
| 172 |
+
await expect(pending).toHaveText('0');
|
| 173 |
+
});
|