@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.
- package/.github/workflows/ci.yml +44 -0
- package/.github/workflows/publish.yml +38 -0
- package/CHANGELOG.md +20 -0
- package/CLAUDE.md +55 -0
- package/LICENSE.md +20 -0
- package/README.md +217 -0
- package/_spec/phase2-handoff.md +143 -0
- package/eslint.config.js +40 -0
- package/index.html +12 -0
- package/package.json +60 -0
- package/playwright.config.ts +38 -0
- package/playwright.wallet.config.ts +82 -0
- package/playwright.was.config.ts +56 -0
- package/pnpm-workspace.yaml +11 -0
- package/prettier.config.js +9 -0
- package/scripts/provision-dev-grants.ts +42 -0
- package/src/App.tsx +34 -0
- package/src/app.config.ts +73 -0
- package/src/components/AppShell.tsx +58 -0
- package/src/components/DevGate.tsx +51 -0
- package/src/dev/bootstrap.ts +54 -0
- package/src/dev/devSeed.ts +13 -0
- package/src/dev/devSync.ts +80 -0
- package/src/main.tsx +24 -0
- package/src/pages/LoginPage.tsx +76 -0
- package/src/pages/NotesPage.tsx +166 -0
- package/src/stores/notes.ts +41 -0
- package/src/vite-env.d.ts +1 -0
- package/test/browser/notes.spec.ts +82 -0
- package/test/browser-wallet/walletLogin.spec.ts +312 -0
- package/test/browser-was/globalSetup.ts +6 -0
- package/test/browser-was/globalTeardown.ts +6 -0
- package/test/browser-was/replication.spec.ts +154 -0
- package/test/browser-was/serverLifecycle.ts +92 -0
- package/test/node/notesStore.test.ts +149 -0
- package/tsconfig.dev.json +17 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +18 -0
|
@@ -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
|
+
})
|