Skip to content

Commit 99fbea6

Browse files
committed
Add Playwright test suite (#243)
* Add Playwright for e2e testing * Add super basic test for the login to ensure auth guard and redirect are working * Add spec for add-phrase & bulk-add-phrases * Add stubs for specs for all mutations across the app * Add proper types to the db-helpers file * Add working cards spec to test card status dropdown and heart icon * Document the pattern to check results in both the DB and UI * Add requests spec * Always put toasts last so we can use them to await interactions * Write up some ideas for ideal testing framework * Add spec for decks * Add reviews.spec, and both-helpers to compare db to local * Make everything less flakey
1 parent 534632c commit 99fbea6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+3157
-79
lines changed

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# cp .env.example .env
22
VITE_SUPABASE_URL="http://localhost:54321"
33
VITE_SUPABASE_ANON_KEY=""
4+
# used for playwright e2e tests
5+
SUPABASE_SERVICE_ROLE_KEY=""
46

57
# Build/deploy details
68
# TAURI_SIGNING_PRIVATE_KEY=

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ tsconfig.tsbuildinfo
3030
.vercel
3131
.env*.local
3232
keystore.properties
33+
34+
# Playwright
35+
/playwright-report/
36+
/test-results/

CHANGELOG.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,41 @@ _November 2025_
55
Since April we have added direct messages, realtime connections for friend requests, alert badges
66
to help users discover features and important interactions.
77

8-
And we have opened up a new feature
9-
realm with the addition of "Phrase Requests", allowing you to describe a situation you're in and
10-
what you want to communicate, and ask native speakers to answer with a flash card that will prepare
11-
you for similar situations in the future. This "requests" concept provides a cross-language meta-
12-
layer, describing what linguists call "messages" (the actual _thing_ you want to convey), which
13-
mirrors the way people actually learn languages in the real world: by asking a friend or colleague
14-
who speaks the language already, "Hey, when I want to hail a cab, what do I say?"
8+
We have opened up a new feature realm with the addition of "Phrase Requests", allowing users to
9+
describe a situation and what they want to communicate, and ask native speakers to answer with a
10+
flash card that will prepare them for similar situations in the future. This "requests" concept
11+
provides a cross-language meta-layer, describing what linguists call "messages" (the actual _thing_
12+
you want to convey), which mirrors the way people actually learn languages in the real world: by
13+
asking a friend or colleague who speaks the language already, "Hey, when I want to hail a cab, what
14+
do I say?"
1515

1616
In the next phase, this "Requests" concept will become the basis for additional social features,
1717
and will allow us to identify _across all languages_ which types of cards will be most useful for
1818
learners of other languages, to help us make the most out of our crowd-sourcing model, so that work
1919
put into one language can be used to ease the path for others.
2020

2121
To make these more interactive and more social features perform better on device, and require less
22-
bandwidth and re-fetching from the server, this version also include a major upgrade to the way
22+
bandwidth and re-fetching from the server, this version include a major upgrade to the way
2323
data is fetched and stored on device, using Tanstack DB's live queries for all data in the app.
2424

25+
- Preloads almost the entire database in route loaders (we will replace this soon with more precise
26+
partial loaders, added in a recent version but not needed yet).
27+
- Local DB _Collections_ store normalised copies of data, backed by Zod schemas.
28+
- `useSomeData` style hooks are now based on `useLiveQuery` which allows us to join and filter data,
29+
and, importantly, to calculate aggregate data this way so we can replace postgres-view-based
30+
aggregation and have our aggregates automatically respond to any inserts into the local database.
31+
- Collections are initialised, populated, and invalidated/cleared differently to how the query
32+
cache worked, so we have to be more careful about race conditions on loading and logout.
33+
34+
To help us manage this new complexity, we have added Playwright for end-to-end testing, which pairs
35+
well with the Schema-backed runtime validation of our Collections to test all interactions between
36+
client and server.
37+
38+
- Use Playwright specs to orcestrate browser navigation through all main features of the app,
39+
particularly mutations.
40+
- Use `both-helpers.ts` to query the DB as service role and evaluate the local collection and
41+
return both, then compare them in the spec.
42+
2543
## 0.1 - The MEP
2644

2745
_April 2025_

e2e/example.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test('has title', async ({ page }) => {
4+
await page.goto('/')
5+
6+
// Expect a title "to contain" a substring.
7+
await expect(page).toHaveTitle(/Sunlo/)
8+
})

e2e/global-setup.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { execSync } from 'child_process'
2+
3+
async function globalSetup() {
4+
console.log('Global Setup: Resetting Supabase Database...')
5+
try {
6+
// Reset the database to a clean state with seeds
7+
execSync('pnpm supabase db reset', { stdio: 'inherit' })
8+
console.log('Global Setup: Database reset complete.')
9+
} catch (error) {
10+
console.error('Global Setup: Failed to reset database.', error)
11+
throw error
12+
}
13+
}
14+
15+
export default globalSetup

e2e/helpers/auth-helpers.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { expect, Page } from '@playwright/test'
2+
3+
// Test user credentials and IDs from seed data
4+
export const TEST_USER_UID = 'cf1f69ce-10fa-4059-8fd4-3c6dcef9ba18'
5+
export const TEST_USER_EMAIL = '[email protected]'
6+
export const FIRST_USER_UID = 'a2dfa256-ef7b-41b0-b05a-d97afab8dd21'
7+
export const FIRST_USER_EMAIL = '[email protected]'
8+
9+
/**
10+
* Log in with specific credentials
11+
*/
12+
export async function login(
13+
page: Page,
14+
email: string,
15+
password: string
16+
): Promise<void> {
17+
await page.goto('/login')
18+
await page.fill('input[name="email"]', email)
19+
await page.fill('input[name="password"]', password)
20+
await page.click('button[type="submit"]')
21+
await page.waitForURL(/\/learn/)
22+
await expect(
23+
page.getByText('Which deck are we studying today?')
24+
).toBeVisible()
25+
}
26+
27+
/**
28+
* Log in as the default test user ([email protected])
29+
*/
30+
export async function loginAsTestUser(page: Page): Promise<void> {
31+
await login(page, TEST_USER_EMAIL, 'password')
32+
}
33+
34+
export async function loginAsFirstUser(page: Page): Promise<void> {
35+
await login(page, '[email protected]', 'password')
36+
}
37+
38+
export async function loginAsSecondUser(page: Page): Promise<void> {
39+
await login(page, '[email protected]', 'password')
40+
}
41+
42+
export async function loginAsFriendUser(page: Page): Promise<void> {
43+
await login(page, '[email protected]', 'password')
44+
}
45+
46+
/**
47+
* Log out the current user
48+
*/
49+
export async function logout(page: Page): Promise<void> {
50+
// Navigate to profile and click logout
51+
await page.goto('/profile')
52+
await page.click('button:has-text("Log out")')
53+
await page.waitForURL(/\/login/)
54+
}

e2e/helpers/both-helpers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Page } from '@playwright/test'
2+
import { DailyReviewStateSchema } from '../../src/lib/schemas'
3+
import { getReviewSessionState } from './db-helpers'
4+
import { getReviewSessionLocal } from './collection-helpers'
5+
6+
/**
7+
* Get review session from both DB and local collection
8+
* Returns parsed, type-safe objects for easy comparison
9+
*/
10+
export async function getReviewSessionBoth(
11+
page: Page,
12+
uid: string,
13+
lang: string,
14+
daySession: string
15+
) {
16+
// Fetch from DB
17+
const { data: fromDB } = await getReviewSessionState(uid, lang)
18+
const parsedDB = fromDB ? DailyReviewStateSchema.parse(fromDB) : null
19+
20+
// Fetch from local collection
21+
const fromLocal = await getReviewSessionLocal(page, lang, daySession)
22+
23+
return { fromDB: parsedDB, fromLocal }
24+
}

e2e/helpers/collection-helpers.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Page } from '@playwright/test'
2+
import type { DailyReviewStateType } from '../../src/lib/schemas'
3+
4+
/**
5+
* Get review session from local collection
6+
*/
7+
export async function getReviewSessionLocal(
8+
page: Page,
9+
lang: string,
10+
daySession: string
11+
): Promise<DailyReviewStateType | null> {
12+
return await page.evaluate(
13+
({ lang, daySession }) => {
14+
// @ts-expect-error - accessing window global
15+
const reviewDaysCollection = window.__reviewDaysCollection
16+
if (!reviewDaysCollection) {
17+
throw new Error('reviewDaysCollection not attached to window')
18+
}
19+
20+
const key = `${daySession}--${lang}`
21+
return reviewDaysCollection.get(key) || null
22+
},
23+
{ lang, daySession }
24+
)
25+
}
26+
27+
/**
28+
* Clear review session from localStorage
29+
*/
30+
export async function clearReviewSessionFromLocal(
31+
page: Page,
32+
lang: string,
33+
daySession: string
34+
) {
35+
await page.evaluate(
36+
({ lang, daySession }) => {
37+
const key = `${daySession}--${lang}`
38+
localStorage.removeItem(key)
39+
},
40+
{ lang, daySession }
41+
)
42+
}

0 commit comments

Comments
 (0)