assafvayner HF Staff commited on
Commit
c24efc8
·
1 Parent(s): b11153a

test(e2e): add Playwright smoke test covering the core flows

Browse files
Files changed (2) hide show
  1. playwright.config.ts +24 -0
  2. 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
+ });