@interop/byoe-react-template 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Login With Wallet: one button driving the library's CHAPI login flow, with
3
+ * a progress line per flow phase and an error alert. An authenticated visitor
4
+ * is bounced straight to the app.
5
+ */
6
+ import { Navigate } from 'react-router'
7
+ import {
8
+ Alert,
9
+ Box,
10
+ Button,
11
+ CircularProgress,
12
+ Paper,
13
+ Stack,
14
+ Typography
15
+ } from '@mui/material'
16
+ import WalletIcon from '@mui/icons-material/AccountBalanceWallet'
17
+ import { useLogin } from '@interop/was-react'
18
+
19
+ const PHASE_LABELS: Record<string, string> = {
20
+ probing: 'Contacting your wallet...',
21
+ 'storing-key': 'Storing your app key in the wallet...',
22
+ 'requesting-grants': 'Requesting storage access...',
23
+ verifying: 'Verifying the wallet response...'
24
+ }
25
+
26
+ export function LoginPage() {
27
+ const { login, status, phase, error } = useLogin()
28
+ const busy = status === 'authenticating'
29
+
30
+ if (status === 'authenticated') {
31
+ return <Navigate to="/" replace />
32
+ }
33
+
34
+ return (
35
+ <Box
36
+ sx={{
37
+ display: 'flex',
38
+ alignItems: 'center',
39
+ justifyContent: 'center',
40
+ minHeight: '100vh',
41
+ p: 2
42
+ }}
43
+ >
44
+ <Paper sx={{ p: 4, maxWidth: 420, width: '100%' }}>
45
+ <Stack spacing={3} sx={{ alignItems: 'center' }}>
46
+ <Typography variant="h5" component="h1">
47
+ BYOE Notes
48
+ </Typography>
49
+ <Typography color="text.secondary" align="center">
50
+ Your notes live in your own wallet-attached storage, encrypted so
51
+ only you can read them. Log in with your digital wallet to begin.
52
+ </Typography>
53
+ <Button
54
+ variant="contained"
55
+ size="large"
56
+ startIcon={busy ? <CircularProgress size={20} /> : <WalletIcon />}
57
+ onClick={() => void login()}
58
+ disabled={busy}
59
+ >
60
+ {busy ? 'Connecting your wallet...' : 'Login with wallet'}
61
+ </Button>
62
+ {busy && phase && (
63
+ <Typography color="text.secondary" data-testid="login-phase">
64
+ {PHASE_LABELS[phase] ?? phase}
65
+ </Typography>
66
+ )}
67
+ {error && (
68
+ <Alert severity="error" sx={{ width: '100%' }} role="alert">
69
+ {error}
70
+ </Alert>
71
+ )}
72
+ </Stack>
73
+ </Paper>
74
+ </Box>
75
+ )
76
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The example WAS-backed collection: list / add / edit / delete notes. All
3
+ * reads come from the in-memory entity store (hydrated from the encrypted
4
+ * local replica); the store's `insert` / `update` / `remove` verbs persist
5
+ * locally first and replicate to WAS in the background.
6
+ */
7
+ import { useState } from 'react'
8
+ import {
9
+ Box,
10
+ Button,
11
+ IconButton,
12
+ List,
13
+ ListItem,
14
+ ListItemText,
15
+ Stack,
16
+ TextField,
17
+ Typography
18
+ } from '@mui/material'
19
+ import DeleteIcon from '@mui/icons-material/Delete'
20
+ import EditIcon from '@mui/icons-material/Edit'
21
+ import { uuidv7 } from 'uuidv7'
22
+ import { getDeviceId } from '@interop/was-react'
23
+ import { useNotes, type Note } from '@/stores/notes'
24
+
25
+ function NoteRow({ note }: { note: Note }) {
26
+ const update = useNotes(state => state.update)
27
+ const remove = useNotes(state => state.remove)
28
+ const [editing, setEditing] = useState(false)
29
+ const [draft, setDraft] = useState(note.text)
30
+
31
+ async function save() {
32
+ const text = draft.trim()
33
+ if (text && text !== note.text) {
34
+ await update({
35
+ ...note,
36
+ text,
37
+ updatedAt: new Date().toISOString(),
38
+ deviceId: getDeviceId()
39
+ })
40
+ }
41
+ setEditing(false)
42
+ }
43
+
44
+ if (editing) {
45
+ return (
46
+ <ListItem>
47
+ <Stack direction="row" spacing={1} sx={{ width: '100%' }}>
48
+ <TextField
49
+ value={draft}
50
+ onChange={event => setDraft(event.target.value)}
51
+ onKeyDown={event => {
52
+ if (event.key === 'Enter') {
53
+ void save()
54
+ }
55
+ }}
56
+ size="small"
57
+ fullWidth
58
+ autoFocus
59
+ aria-label="edit note"
60
+ />
61
+ <Button onClick={() => void save()}>Save</Button>
62
+ <Button
63
+ color="inherit"
64
+ onClick={() => {
65
+ setDraft(note.text)
66
+ setEditing(false)
67
+ }}
68
+ >
69
+ Cancel
70
+ </Button>
71
+ </Stack>
72
+ </ListItem>
73
+ )
74
+ }
75
+
76
+ return (
77
+ <ListItem
78
+ secondaryAction={
79
+ <Stack direction="row" spacing={1}>
80
+ <IconButton
81
+ edge="end"
82
+ aria-label={`edit ${note.text}`}
83
+ onClick={() => setEditing(true)}
84
+ >
85
+ <EditIcon />
86
+ </IconButton>
87
+ <IconButton
88
+ edge="end"
89
+ aria-label={`delete ${note.text}`}
90
+ onClick={() => void remove(note.id)}
91
+ >
92
+ <DeleteIcon />
93
+ </IconButton>
94
+ </Stack>
95
+ }
96
+ >
97
+ <ListItemText
98
+ primary={note.text}
99
+ secondary={new Date(note.createdAt).toLocaleString()}
100
+ />
101
+ </ListItem>
102
+ )
103
+ }
104
+
105
+ export function NotesPage() {
106
+ const notes = useNotes(state => state.byId)
107
+ const insert = useNotes(state => state.insert)
108
+ const [text, setText] = useState('')
109
+
110
+ const sorted = [...notes.values()].sort((a, b) =>
111
+ b.createdAt.localeCompare(a.createdAt)
112
+ )
113
+
114
+ async function addNote() {
115
+ const trimmed = text.trim()
116
+ if (!trimmed) {
117
+ return
118
+ }
119
+ const now = new Date().toISOString()
120
+ await insert({
121
+ id: uuidv7(),
122
+ text: trimmed,
123
+ createdAt: now,
124
+ updatedAt: now,
125
+ deviceId: getDeviceId()
126
+ })
127
+ setText('')
128
+ }
129
+
130
+ return (
131
+ <Box>
132
+ <Typography variant="h5" component="h2" gutterBottom>
133
+ Notes
134
+ </Typography>
135
+ <Stack direction="row" spacing={1} sx={{ mb: 2 }}>
136
+ <TextField
137
+ value={text}
138
+ onChange={event => setText(event.target.value)}
139
+ onKeyDown={event => {
140
+ if (event.key === 'Enter') {
141
+ void addNote()
142
+ }
143
+ }}
144
+ placeholder="A new note..."
145
+ size="small"
146
+ fullWidth
147
+ aria-label="new note"
148
+ />
149
+ <Button variant="contained" onClick={() => void addNote()}>
150
+ Add
151
+ </Button>
152
+ </Stack>
153
+ {sorted.length === 0 ? (
154
+ <Typography color="text.secondary" data-testid="notes-empty">
155
+ No notes yet. Add one above.
156
+ </Typography>
157
+ ) : (
158
+ <List data-testid="notes-list">
159
+ {sorted.map(note => (
160
+ <NoteRow key={note.id} note={note} />
161
+ ))}
162
+ </List>
163
+ )}
164
+ </Box>
165
+ )
166
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The one example collection: a zustand entity store of notes, plus the
3
+ * {@link StoreRegistry} the library's rehydrate mechanism drives (hydrate on
4
+ * login, per-doc patch on remote sync, clear on logout).
5
+ *
6
+ * When adding a collection to an app built from this template: add a
7
+ * `{ key, id }` entry to `COLLECTIONS` in app.config.ts, create its entity
8
+ * store here, and give it a registry entry.
9
+ */
10
+ import { createEntityStore, type StoreRegistry } from '@interop/was-react'
11
+
12
+ /**
13
+ * `updatedAt` and `deviceId` are REQUIRED by the sync layer: remote conflicts
14
+ * are resolved last-writer-wins on the payload's own `(updatedAt, deviceId)`,
15
+ * so every insert/update must stamp both (see NotesPage). A payload without
16
+ * them loses every conflict to the server copy.
17
+ */
18
+ export interface Note {
19
+ id: string
20
+ text: string
21
+ createdAt: string
22
+ updatedAt: string
23
+ deviceId: string
24
+ }
25
+
26
+ /** Zustand hook holding the decrypted notes as a `Map<uuid, Note>`. */
27
+ export const useNotes = createEntityStore<Note>('notes')
28
+
29
+ /**
30
+ * Per-collection handlers for the rehydrate mechanism. `upsert` maps to the
31
+ * entity store's non-persisting `patch` (the sync stream already owns the
32
+ * persisted row) and `clear` to `replaceAll([])`.
33
+ */
34
+ export const registry: StoreRegistry = {
35
+ notes: {
36
+ hydrate: () => useNotes.getState().hydrate(),
37
+ upsert: doc => useNotes.getState().patch(doc as Note),
38
+ drop: uuid => useNotes.getState().drop(uuid),
39
+ clear: () => useNotes.getState().replaceAll([])
40
+ }
41
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Offline (mocked) tier: the app boots through the dev gate into the Notes page
3
+ * over the encrypted local replica -- no wallet, no WAS server. Adds / edits /
4
+ * deletes a note, proves persistence across a reload (IndexedDB), and checks the
5
+ * sync chip advertises local-only mode. Each test runs in an isolated browser
6
+ * context (fresh IndexedDB), so no cross-test cleanup.
7
+ */
8
+ import { test, expect, type Page } from '@playwright/test'
9
+
10
+ async function bootToNotes(page: Page) {
11
+ await page.goto('/')
12
+ await expect(page.getByRole('heading', { name: 'BYOE Notes' })).toBeVisible()
13
+ await expect(page.getByTestId('bootstrap-loading')).toHaveCount(0)
14
+ await expect(
15
+ page.getByRole('heading', { name: 'Notes', exact: true })
16
+ ).toBeVisible()
17
+ }
18
+
19
+ async function addNote(page: Page, text: string) {
20
+ await page.getByLabel('new note').locator('input').fill(text)
21
+ await page.getByRole('button', { name: 'Add' }).click()
22
+ await expect(page.getByTestId('notes-list').getByText(text)).toBeVisible()
23
+ }
24
+
25
+ test.beforeEach(async ({ page }) => {
26
+ await bootToNotes(page)
27
+ })
28
+
29
+ test('boots through the dev gate into an empty Notes page', async ({
30
+ page
31
+ }) => {
32
+ await expect(page.getByTestId('notes-empty')).toBeVisible()
33
+ await expect(page.getByTestId('notes-list')).toHaveCount(0)
34
+ })
35
+
36
+ test('adds a note', async ({ page }) => {
37
+ await addNote(page, 'Remember the milk')
38
+
39
+ await expect(page.getByTestId('notes-empty')).toHaveCount(0)
40
+ await expect(page.getByTestId('notes-list')).toBeVisible()
41
+ })
42
+
43
+ test('edits a note in place', async ({ page }) => {
44
+ await addNote(page, 'Draft title')
45
+
46
+ await page.getByRole('button', { name: 'edit Draft title' }).click()
47
+ const field = page.getByLabel('edit note').locator('input')
48
+ await field.fill('Final title')
49
+ await page.getByRole('button', { name: 'Save' }).click()
50
+
51
+ await expect(
52
+ page.getByTestId('notes-list').getByText('Final title')
53
+ ).toBeVisible()
54
+ await expect(page.getByText('Draft title')).toHaveCount(0)
55
+ })
56
+
57
+ test('deletes a note, returning to the empty state', async ({ page }) => {
58
+ await addNote(page, 'Delete me')
59
+
60
+ await page.getByRole('button', { name: 'delete Delete me' }).click()
61
+
62
+ await expect(page.getByText('Delete me')).toHaveCount(0)
63
+ await expect(page.getByTestId('notes-empty')).toBeVisible()
64
+ })
65
+
66
+ test('persists notes across a page reload (IndexedDB)', async ({ page }) => {
67
+ await addNote(page, 'Durable note')
68
+
69
+ await page.reload()
70
+ await expect(page.getByTestId('bootstrap-loading')).toHaveCount(0)
71
+
72
+ await expect(
73
+ page.getByTestId('notes-list').getByText('Durable note')
74
+ ).toBeVisible()
75
+ })
76
+
77
+ test('sync chip advertises local-only (offline) mode', async ({ page }) => {
78
+ const chip = page.getByTestId('sync-status-chip')
79
+ await expect(chip).toBeVisible()
80
+ await expect(chip).toHaveText(/Offline/)
81
+ await expect(chip).toHaveAttribute('data-sync-state', 'offline')
82
+ })
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Login-With-Wallet e2e against a real local freewallet + was-teaching-server.
3
+ *
4
+ * CHAPI has no mediator here; two injection seams stand in for it:
5
+ * - App side: `window.__WAS_REACT_E2E_CHAPI__` switches the library's
6
+ * `src/auth/chapi.ts` to a request queue (`__WAS_REACT_E2E_CHAPI_REQUESTS__` /
7
+ * `__WAS_REACT_E2E_CHAPI_RESPONSES__`) this spec services.
8
+ * - Wallet side: freewallet's own non-production `__E2E_CHAPI_GET_EVENT__` seam
9
+ * drives its /#/wallet/get popup with the app's captured VPR; the response VP
10
+ * is read back off `__E2E_CHAPI_RESPONSE__`.
11
+ * The CHAPI store() step has no wallet-side seam, so the spec lands the seed
12
+ * credential through the wallet's Add Credential paste flow -- the same
13
+ * wallet-vault write path -- and then acks the app's store request.
14
+ *
15
+ * One serialized test walks the life cycle on a single wallet account (signup
16
+ * is deliberately expensive PBKDF2): first login (store key + grants), a note
17
+ * write that replicates to WAS, then logout and a returning login that recovers
18
+ * the note from WAS.
19
+ */
20
+ import {
21
+ test,
22
+ expect,
23
+ type BrowserContext,
24
+ type Page,
25
+ type TestInfo
26
+ } from '@playwright/test'
27
+ import { APP_URL, WALLET_URL } from '../../playwright.wallet.config'
28
+
29
+ /* ----------------------------- wallet helpers ----------------------------- */
30
+
31
+ function testUser(testInfo: TestInfo) {
32
+ const token = `${Date.now()}-w${testInfo.workerIndex}`
33
+ return {
34
+ passphrase: `Str0ngpass-${token}-Aa1!`,
35
+ email: `e2e-${token}@example.com`
36
+ }
37
+ }
38
+
39
+ /** Creates a wallet account; leaves `page` logged in on the wallet dashboard. */
40
+ async function signupWallet(page: Page, testInfo: TestInfo) {
41
+ const { passphrase, email } = testUser(testInfo)
42
+ await page.goto(`${WALLET_URL}/#/signup`)
43
+ await page.locator('input[type="password"]').fill(passphrase)
44
+ await expect(page.getByRole('button', { name: 'Next' })).toBeEnabled({
45
+ timeout: 20_000
46
+ })
47
+ await page.getByRole('button', { name: 'Next' }).click()
48
+ await page.locator('input[type="email"]').fill(email)
49
+ await expect(page.getByRole('button', { name: 'Next' })).toBeEnabled()
50
+ await page.getByRole('button', { name: 'Next' }).click()
51
+ await expect(page).toHaveURL(/#\/signup\?.*step=storage/)
52
+ await page.getByRole('button', { name: 'Create Wallet' }).click()
53
+ // Signup binds the keyring (deliberately slow PBKDF2) + provisions storage.
54
+ await expect(page).toHaveURL(/#\/dashboard/, { timeout: 45_000 })
55
+ return { passphrase, email }
56
+ }
57
+
58
+ /**
59
+ * Services one app-captured `get()` VPR through the wallet's /#/wallet/get page
60
+ * (freewallet's injection seam), returning the CHAPI wire response.
61
+ */
62
+ async function driveWalletGet(
63
+ context: BrowserContext,
64
+ {
65
+ vpr,
66
+ passphrase,
67
+ selectCredential = false
68
+ }: { vpr: unknown; passphrase: string; selectCredential?: boolean }
69
+ ): Promise<unknown> {
70
+ const page = await context.newPage()
71
+ await page.addInitScript(
72
+ cfg => {
73
+ const win = window as unknown as {
74
+ __E2E_CHAPI_GET_EVENT__?: unknown
75
+ __E2E_CHAPI_RESPONSE__?: { value: unknown }
76
+ }
77
+ win.__E2E_CHAPI_RESPONSE__ = undefined
78
+ win.__E2E_CHAPI_GET_EVENT__ = {
79
+ credentialRequestOrigin: cfg.origin,
80
+ credentialRequestOptions: {
81
+ web: { VerifiablePresentation: cfg.vpr }
82
+ },
83
+ respondWith(promise: Promise<unknown>) {
84
+ void Promise.resolve(promise).then(value => {
85
+ win.__E2E_CHAPI_RESPONSE__ = { value: value ?? null }
86
+ })
87
+ }
88
+ }
89
+ },
90
+ { origin: APP_URL, vpr }
91
+ )
92
+ await page.goto(`${WALLET_URL}/#/wallet/get`)
93
+ // The popup always asks for the passphrase (it is designed for a cold
94
+ // cross-origin popup, not the dashboard session).
95
+ await page.locator('input[type="password"]').fill(passphrase)
96
+ await page.getByRole('button', { name: 'Continue' }).click()
97
+ await page
98
+ .locator('input[type="password"]')
99
+ .waitFor({ state: 'detached', timeout: 30_000 })
100
+ if (selectCredential) {
101
+ // The app key is not a wallet Login Credential, so it is not pre-selected
102
+ // on the share screen.
103
+ const checkbox = page.getByRole('checkbox').first()
104
+ await checkbox.waitFor({ timeout: 15_000 })
105
+ await checkbox.check()
106
+ }
107
+ await page.getByRole('button', { name: 'Continue' }).click()
108
+ await expect
109
+ .poll(
110
+ () =>
111
+ page.evaluate(
112
+ () =>
113
+ (
114
+ window as unknown as {
115
+ __E2E_CHAPI_RESPONSE__?: { value: unknown }
116
+ }
117
+ ).__E2E_CHAPI_RESPONSE__ !== undefined
118
+ ),
119
+ { timeout: 30_000, intervals: [500] }
120
+ )
121
+ .toBe(true)
122
+ const response = await page.evaluate(
123
+ () =>
124
+ (window as unknown as { __E2E_CHAPI_RESPONSE__?: { value: unknown } })
125
+ .__E2E_CHAPI_RESPONSE__
126
+ )
127
+ await page.close()
128
+ return (response as { value: unknown }).value
129
+ }
130
+
131
+ /**
132
+ * Lands a credential in the wallet vault via the dashboard Add Credential paste
133
+ * flow (`walletPage` must be logged in on the dashboard).
134
+ */
135
+ async function walletAddCredential(walletPage: Page, credentialJson: string) {
136
+ await walletPage.getByRole('link', { name: 'Add Credential' }).click()
137
+ await expect(walletPage).toHaveURL(/#\/add-credential/)
138
+ await walletPage
139
+ .getByRole('textbox', { name: /Paste a URL/ })
140
+ .fill(credentialJson)
141
+ await walletPage.getByRole('button', { name: 'Add' }).click()
142
+ await expect(walletPage).toHaveURL(/#\/accept-credentials/)
143
+ await walletPage.getByRole('button', { name: 'Accept all' }).click()
144
+ await expect(walletPage).toHaveURL(/#\/dashboard/)
145
+ }
146
+
147
+ /* ------------------------------ app helpers ------------------------------ */
148
+
149
+ interface ChapiBridgeRequest {
150
+ id: number
151
+ type: 'get' | 'store'
152
+ body: unknown
153
+ }
154
+
155
+ /** Arms the app-side CHAPI bridge (must run before the page loads). */
156
+ async function armChapiBridge(page: Page) {
157
+ await page.addInitScript(() => {
158
+ ;(
159
+ window as unknown as { __WAS_REACT_E2E_CHAPI__?: boolean }
160
+ ).__WAS_REACT_E2E_CHAPI__ = true
161
+ })
162
+ }
163
+
164
+ /** Pops the next queued CHAPI request from the app page. */
165
+ async function popChapiRequest(page: Page): Promise<ChapiBridgeRequest> {
166
+ let request: ChapiBridgeRequest | null = null
167
+ await expect
168
+ .poll(
169
+ async () => {
170
+ request = await page.evaluate(
171
+ () =>
172
+ (
173
+ window as unknown as {
174
+ __WAS_REACT_E2E_CHAPI_REQUESTS__?: ChapiBridgeRequest[]
175
+ }
176
+ ).__WAS_REACT_E2E_CHAPI_REQUESTS__?.shift() ?? null
177
+ )
178
+ return request
179
+ },
180
+ { timeout: 30_000, intervals: [300] }
181
+ )
182
+ .not.toBeNull()
183
+ return request as unknown as ChapiBridgeRequest
184
+ }
185
+
186
+ /** Posts a wire response for a bridged CHAPI request. */
187
+ async function respondChapi(page: Page, id: number, value: unknown) {
188
+ await page.evaluate(
189
+ ([responseId, responseValue]) => {
190
+ const win = window as unknown as {
191
+ __WAS_REACT_E2E_CHAPI_RESPONSES__?: Record<number, unknown>
192
+ }
193
+ win.__WAS_REACT_E2E_CHAPI_RESPONSES__ ??= {}
194
+ win.__WAS_REACT_E2E_CHAPI_RESPONSES__[responseId as number] =
195
+ responseValue
196
+ },
197
+ [id, value] as const
198
+ )
199
+ }
200
+
201
+ /** Runs the app side of one full wallet login from the login page. */
202
+ async function loginFromAppPage(
203
+ appPage: Page,
204
+ walletContext: BrowserContext,
205
+ {
206
+ passphrase,
207
+ walletPage,
208
+ expectFirstRun
209
+ }: { passphrase: string; walletPage: Page; expectFirstRun: boolean }
210
+ ) {
211
+ const loginButton = appPage.getByRole('button', { name: 'Login with wallet' })
212
+ await expect(loginButton).toBeEnabled({ timeout: 15_000 })
213
+ await loginButton.click()
214
+
215
+ // Popup #1: the seed probe.
216
+ const probe = await popChapiRequest(appPage)
217
+ expect(probe.type).toBe('get')
218
+ const probeResponse = await driveWalletGet(walletContext, {
219
+ vpr: probe.body,
220
+ passphrase,
221
+ selectCredential: !expectFirstRun
222
+ })
223
+ await respondChapi(appPage, probe.id, probeResponse)
224
+
225
+ if (expectFirstRun) {
226
+ // First run: the app stores its key credential in the wallet.
227
+ const store = await popChapiRequest(appPage)
228
+ expect(store.type).toBe('store')
229
+ const offered = store.body as {
230
+ verifiableCredential: Array<Record<string, unknown>>
231
+ }
232
+ const credential = offered.verifiableCredential[0]!
233
+ expect(credential.type).toContain('ByoeNotesAppKey')
234
+ await walletAddCredential(walletPage, JSON.stringify(credential))
235
+ await respondChapi(appPage, store.id, {
236
+ dataType: 'VerifiablePresentation',
237
+ data: store.body
238
+ })
239
+ }
240
+
241
+ // Popup #2: the storage grants.
242
+ const grants = await popChapiRequest(appPage)
243
+ expect(grants.type).toBe('get')
244
+ const grantsResponse = await driveWalletGet(walletContext, {
245
+ vpr: grants.body,
246
+ passphrase
247
+ })
248
+ await respondChapi(appPage, grants.id, grantsResponse)
249
+
250
+ // Login completes and the router lands on the notes shell.
251
+ await expect(appPage.getByTestId('sync-status-chip')).toBeVisible({
252
+ timeout: 60_000
253
+ })
254
+ await expect(appPage.getByRole('textbox', { name: 'new note' })).toBeVisible()
255
+ }
256
+
257
+ /** Adds a note on the app's notes page and waits for it to appear. */
258
+ async function addNote(appPage: Page, text: string) {
259
+ await appPage.getByRole('textbox', { name: 'new note' }).fill(text)
260
+ await appPage.getByRole('button', { name: 'Add' }).click()
261
+ await expect(appPage.getByTestId('notes-list').getByText(text)).toBeVisible()
262
+ }
263
+
264
+ function noteRow(appPage: Page, text: string) {
265
+ return appPage.getByTestId('notes-list').getByText(text)
266
+ }
267
+
268
+ /* --------------------------------- test ---------------------------------- */
269
+
270
+ test('login with wallet: first login, replication, logout/login recovery', async ({
271
+ context
272
+ }, testInfo) => {
273
+ /* Phase 0: create the wallet account (stays logged in on the dashboard). */
274
+ const walletPage = await context.newPage()
275
+ const { passphrase } = await signupWallet(walletPage, testInfo)
276
+
277
+ /* Phase 1: first login -- store the app key, approve grants, enter. */
278
+ const appPage = await context.newPage()
279
+ await armChapiBridge(appPage)
280
+ await appPage.goto(`${APP_URL}/#/login`)
281
+ await expect(
282
+ appPage.getByRole('button', { name: 'Login with wallet' })
283
+ ).toBeVisible()
284
+ await loginFromAppPage(appPage, context, {
285
+ passphrase,
286
+ walletPage,
287
+ expectFirstRun: true
288
+ })
289
+
290
+ /* Phase 2: write a note and wait for it to replicate to WAS. */
291
+ const noteText = `wallet-e2e-${Date.now()}`
292
+ await addNote(appPage, noteText)
293
+ await expect(appPage.getByTestId('sync-status-chip')).toHaveAttribute(
294
+ 'data-sync-state',
295
+ 'synced',
296
+ { timeout: 45_000 }
297
+ )
298
+
299
+ /* Phase 3: logout wipes the session; login again = returning path (the wallet
300
+ returns the stored app key; no store() this time), the note is recovered
301
+ from WAS. */
302
+ await appPage.getByRole('button', { name: 'log out' }).click()
303
+ await expect(
304
+ appPage.getByRole('button', { name: 'Login with wallet' })
305
+ ).toBeVisible({ timeout: 15_000 })
306
+ await loginFromAppPage(appPage, context, {
307
+ passphrase,
308
+ walletPage,
309
+ expectFirstRun: false
310
+ })
311
+ await expect(noteRow(appPage, noteText)).toBeVisible({ timeout: 30_000 })
312
+ })
@@ -0,0 +1,6 @@
1
+ import { startServerAndProvision } from './serverLifecycle.js'
2
+
3
+ /** Playwright globalSetup: boot the WAS server and provision dev grants. */
4
+ export default async function globalSetup(): Promise<void> {
5
+ await startServerAndProvision()
6
+ }
@@ -0,0 +1,6 @@
1
+ import { stopServer } from './serverLifecycle.js'
2
+
3
+ /** Playwright globalTeardown: stop the WAS server started in globalSetup. */
4
+ export default async function globalTeardown(): Promise<void> {
5
+ await stopServer()
6
+ }