feat(folio-web): web sample app + CDP spec tests (#36)

* fix(runner): allow nil connection for web platform

* fix(testrun): skip SDK handshake for web platform

* feat(folio-web): add React/Vite web sample app

* feat(folio-web): add sanderling spec

* fix(chrome): use InsertText for multi-char text input

* feat(hierarchy): add Screen field populated from sanderling-screen attr

* fix(chrome): auto-detect viewport from CSS vars, fix InputText accumulation, expose route as screen

* fix(runner): fall back to hierarchy root screen when snapshot screen is empty

* fix(folio-web): broaden loggedIn extractor to all authenticated pages
This commit is contained in:
pj authored and GitHub committed 2026-04-22 23:59:30 +07:00
1 parent eed99e58aa
commit af7b7e27b0
31 files changed
+2970 -74

No files matched your search

@@ -0,0 +1,91 @@
import { useState, type FormEvent } from 'react'
import { createAccount } from '../db'
import { back, navigate } from '../router'
import { BackButton, Header, Screen } from '../components/Screen'
export function AddAccountPage(props: { onCreated: () => void }) {
const [name, setName] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
const trimmed = name.trim()
if (!trimmed) {
setErr('Account name is required')
return
}
if (trimmed.length > 40) {
setErr('Name is too long (max 40 characters)')
return
}
setBusy(true)
try {
await createAccount(trimmed)
props.onCreated()
navigate('/', { replace: true })
} catch (e) {
setErr(e instanceof Error ? e.message : 'Could not create account')
} finally {
setBusy(false)
}
}
return (
<Screen
header={
<Header
title="New account"
left={<BackButton onClick={() => back('/')} />}
/>
}
footer={
<button
id="add-account-submit"
className="btn primary"
type="submit"
form="add-account-form"
disabled={busy || name.trim().length === 0}
>
{busy ? 'Creating…' : 'Create account'}
</button>
}
>
<form
id="add-account-form"
onSubmit={onSubmit}
className="list"
style={{ gap: 14 }}
noValidate
>
<div className="field">
<label className="field-label" htmlFor="account-name">
Account name
</label>
<input
id="account-name"
className={'input' + (err ? ' invalid' : '')}
type="text"
autoFocus
autoComplete="off"
placeholder="e.g. Checking"
value={name}
maxLength={40}
onChange={(e) => {
setName(e.target.value)
if (err) setErr(null)
}}
/>
</div>
<div id="add-account-error" className="error" role="alert">
{err ?? ''}
</div>
<div className="muted" style={{ fontSize: 12 }}>
Use a short, recognizable name. You can create as many accounts as
you need.
</div>
</form>
</Screen>
)
}
@@ -0,0 +1,194 @@
import { useEffect, useState, type FormEvent } from 'react'
import {
createTransaction,
getAccount,
type Account,
type TxnType,
} from '../db'
import { parseCents } from '../format'
import { back, navigate } from '../router'
import { BackButton, Header, Screen } from '../components/Screen'
export function AddTransactionPage(props: {
accountId: string
onCreated: () => void
}) {
const [account, setAccount] = useState<Account | null | undefined>(undefined)
const [type, setType] = useState<TxnType>('credit')
const [amount, setAmount] = useState('')
const [note, setNote] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let alive = true
getAccount(props.accountId).then((a) => {
if (alive) setAccount(a ?? null)
})
return () => {
alive = false
}
}, [props.accountId])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
const cents = parseCents(amount)
if (cents === null) {
setErr('Enter a valid amount (e.g. 12.34)')
return
}
if (cents <= 0) {
setErr('Amount must be greater than zero')
return
}
setBusy(true)
try {
await createTransaction({
accountId: props.accountId,
type,
amount: cents,
note,
})
props.onCreated()
back(`/accounts/${props.accountId}`)
} catch (e) {
setErr(e instanceof Error ? e.message : 'Could not save transaction')
} finally {
setBusy(false)
}
}
if (account === null) {
return (
<Screen
header={
<Header
title="Add transaction"
left={<BackButton onClick={() => back('/')} />}
/>
}
>
<div className="empty">
<div className="empty-title">Account not found</div>
<button
className="btn"
type="button"
style={{ marginTop: 12, width: 'auto', padding: '10px 16px' }}
onClick={() => navigate('/', { replace: true })}
>
Back to accounts
</button>
</div>
</Screen>
)
}
const submitDisabled = busy || amount.trim() === ''
return (
<Screen
header={
<Header
title="Add transaction"
subtitle={account?.name}
left={
<BackButton
onClick={() => back(`/accounts/${props.accountId}`)}
/>
}
/>
}
footer={
<button
id="txn-submit"
className="btn primary"
type="submit"
form="add-txn-form"
disabled={submitDisabled}
>
{busy ? 'Saving…' : type === 'credit' ? 'Add credit' : 'Add debit'}
</button>
}
>
<form
id="add-txn-form"
onSubmit={onSubmit}
className="list"
style={{ gap: 16 }}
data-account-id={props.accountId}
data-type={type}
noValidate
>
<div
className="segmented"
role="tablist"
aria-label="Transaction type"
>
<button
id="txn-credit"
type="button"
role="tab"
className="credit"
aria-pressed={type === 'credit'}
onClick={() => setType('credit')}
>
Credit
</button>
<button
id="txn-debit"
type="button"
role="tab"
className="debit"
aria-pressed={type === 'debit'}
onClick={() => setType('debit')}
>
Debit
</button>
</div>
<div className="field">
<label className="field-label" htmlFor="txn-amount">
Amount
</label>
<input
id="txn-amount"
className={'input amount-input' + (err ? ' invalid' : '')}
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="0.00"
value={amount}
onChange={(e) => {
const v = e.target.value
if (v === '' || /^\d*(\.\d{0,2})?$/.test(v)) {
setAmount(v)
if (err) setErr(null)
}
}}
autoFocus
/>
</div>
<div className="field">
<label className="field-label" htmlFor="txn-note">
Note (optional)
</label>
<input
id="txn-note"
className="input"
type="text"
placeholder="What's this for?"
value={note}
maxLength={80}
onChange={(e) => setNote(e.target.value)}
/>
</div>
<div id="txn-error" className="error" role="alert">
{err ?? ''}
</div>
</form>
</Screen>
)
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useMemo, useState } from 'react'
import {
listAccounts,
listAllTransactions,
type Account,
type Transaction,
} from '../db'
import { formatCents, initialsOf, signedAmount } from '../format'
import { navigate } from '../router'
import { Header, Screen } from '../components/Screen'
export function HomePage(props: {
user: string
onLogout: () => void
reloadKey: number
}) {
const [accounts, setAccounts] = useState<Account[] | null>(null)
const [txns, setTxns] = useState<Transaction[]>([])
useEffect(() => {
let alive = true
Promise.all([listAccounts(), listAllTransactions()]).then(([a, t]) => {
if (!alive) return
setAccounts(a)
setTxns(t)
})
return () => {
alive = false
}
}, [props.reloadKey])
const balanceByAccount = useMemo(() => {
const m = new Map<string, number>()
for (const t of txns) {
m.set(t.accountId, (m.get(t.accountId) ?? 0) + signedAmount(t))
}
return m
}, [txns])
const totalBalance = useMemo(() => {
let sum = 0
for (const v of balanceByAccount.values()) sum += v
return sum
}, [balanceByAccount])
return (
<Screen
header={
<Header
title="Accounts"
subtitle={props.user}
right={
<button
id="logout"
type="button"
className="icon-btn"
onClick={props.onLogout}
aria-label="Sign out"
title="Sign out"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
}
/>
}
footer={
<>
<div
className="row"
style={{ justifyContent: 'space-between', alignItems: 'flex-end' }}
>
<div
id="total-balance"
className="balance-display"
data-value={totalBalance}
title={String(totalBalance)}
>
<span className="balance-label">Total balance</span>
<span
className={
'balance-value' +
(totalBalance > 0
? ' credit'
: totalBalance < 0
? ' debit'
: '')
}
>
{formatCents(totalBalance)}
</span>
</div>
<span
id="account-count"
className="faint"
style={{ fontSize: 12 }}
data-value={accounts?.length ?? 0}
>
{accounts?.length ?? 0} account
{(accounts?.length ?? 0) === 1 ? '' : 's'}
</span>
</div>
<button
id="add-account"
className="btn primary"
type="button"
onClick={() => navigate('/accounts/new')}
>
+ Add account
</button>
</>
}
>
{accounts === null ? (
<div className="empty">
<div className="empty-sub">Loading…</div>
</div>
) : accounts.length === 0 ? (
<div className="empty">
<div className="empty-icon" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="6" width="18" height="13" rx="2" />
<path d="M3 10h18" />
</svg>
</div>
<div className="empty-title">No accounts yet</div>
<div className="empty-sub">
Create your first account to start tracking transactions.
</div>
</div>
) : (
<div className="list" data-testid="account-list">
{accounts.map((a) => {
const bal = balanceByAccount.get(a.id) ?? 0
return (
<button
key={a.id}
type="button"
className="account-card"
data-testid="account-card"
data-account-id={a.id}
data-name={a.name}
data-balance={bal}
data-txn-count={countTxns(txns, a.id)}
aria-label={'account:' + a.id + ':' + bal}
onClick={() => navigate(`/accounts/${a.id}`)}
>
<span className="account-avatar" aria-hidden="true">
{initialsOf(a.name)}
</span>
<span className="account-meta">
<span className="account-name">{a.name}</span>
<span className="account-sub">
{countTxns(txns, a.id)} transactions
</span>
</span>
<span className="account-amount">{formatCents(bal)}</span>
</button>
)
})}
</div>
)}
</Screen>
)
}
function countTxns(all: Transaction[], accountId: string): number {
let n = 0
for (const t of all) if (t.accountId === accountId) n++
return n
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react'
import {
getAccount,
listTransactions,
type Account,
type Transaction,
} from '../db'
import {
balanceOf,
formatCents,
formatDate,
signedAmount,
} from '../format'
import { back, navigate } from '../router'
import { BackButton, Header, Screen } from '../components/Screen'
export function LedgerPage(props: {
accountId: string
reloadKey: number
}) {
const [account, setAccount] = useState<Account | null | undefined>(undefined)
const [txns, setTxns] = useState<Transaction[] | null>(null)
useEffect(() => {
let alive = true
Promise.all([
getAccount(props.accountId),
listTransactions(props.accountId),
]).then(([a, t]) => {
if (!alive) return
setAccount(a ?? null)
setTxns(t)
})
return () => {
alive = false
}
}, [props.accountId, props.reloadKey])
const balance = useMemo(() => (txns ? balanceOf(txns) : 0), [txns])
if (account === null) {
return (
<Screen
header={
<Header
title="Account"
left={<BackButton onClick={() => back('/')} />}
/>
}
>
<div className="empty">
<div className="empty-title">Account not found</div>
<div className="empty-sub">It may have been deleted.</div>
<button
className="btn"
type="button"
style={{ marginTop: 12, width: 'auto', padding: '10px 16px' }}
onClick={() => navigate('/', { replace: true })}
>
Back to accounts
</button>
</div>
</Screen>
)
}
return (
<Screen
header={
<Header
title={account?.name ?? 'Loading…'}
subtitle="Ledger"
left={<BackButton onClick={() => back('/')} />}
/>
}
footer={
<button
id="add-txn"
className="btn primary"
type="button"
disabled={!account}
onClick={() =>
navigate(`/accounts/${props.accountId}/transactions/new`)
}
>
+ Add transaction
</button>
}
>
<div
id="ledger"
className="card"
data-account-id={props.accountId}
data-account-name={account?.name ?? ''}
data-txn-count={txns?.length ?? 0}
title={String(txns?.length ?? 0)}
>
<div
id="ledger-balance"
className="balance-display"
data-value={balance}
title={String(balance)}
>
<span className="balance-label">Balance</span>
<span
className={
'balance-value' +
(balance > 0 ? ' credit' : balance < 0 ? ' debit' : '')
}
>
{formatCents(balance)}
</span>
</div>
</div>
<div className="section-label">Activity</div>
{txns === null ? (
<div className="empty">
<div className="empty-sub">Loading…</div>
</div>
) : txns.length === 0 ? (
<div className="empty">
<div className="empty-icon" aria-hidden="true">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18M3 12h18M3 18h12" />
</svg>
</div>
<div className="empty-title">No transactions yet</div>
<div className="empty-sub">
Add your first credit or debit to see it here.
</div>
</div>
) : (
<div className="card" style={{ padding: '0 16px' }}>
{txns.map((t) => {
const signed = signedAmount(t)
return (
<div
key={t.id}
className="txn-row"
data-testid="txn-row"
data-txn-id={t.id}
data-account-id={t.accountId}
data-type={t.type}
data-amount={t.amount}
data-signed={signed}
>
<div className={'txn-icon ' + t.type} aria-hidden="true">
{t.type === 'credit' ? '+' : '−'}
</div>
<div className="txn-meta">
<div className="txn-note">
{t.note || (t.type === 'credit' ? 'Credit' : 'Debit')}
</div>
<div className="txn-date">{formatDate(t.createdAt)}</div>
</div>
<div className={'txn-amount ' + t.type}>
{formatCents(signed, { signed: true })}
</div>
</div>
)
})}
</div>
)}
</Screen>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useState, type FormEvent } from 'react'
import { checkCredentials, DEMO_EMAIL, DEMO_PASSWORD } from '../auth'
import { setSession } from '../db'
import { navigate } from '../router'
import { Screen } from '../components/Screen'
export function LoginPage(props: { onLoggedIn: (user: string) => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
if (!email.trim() || !password) {
setErr('Enter email and password')
return
}
if (!checkCredentials(email, password)) {
setErr('Invalid email or password')
return
}
setBusy(true)
try {
await setSession(email.trim().toLowerCase())
props.onLoggedIn(email.trim().toLowerCase())
navigate('/', { replace: true })
} catch {
setErr('Could not sign in. Try again.')
} finally {
setBusy(false)
}
}
return (
<Screen>
<form
onSubmit={onSubmit}
className="list"
style={{ gap: 14, marginTop: 24 }}
noValidate
>
<div className="field">
<label className="field-label" htmlFor="email">
Email
</label>
<input
id="email"
className={'input' + (err ? ' invalid' : '')}
type="email"
autoComplete="email"
inputMode="email"
autoCapitalize="none"
placeholder={DEMO_EMAIL}
value={email}
onChange={(e) => {
setEmail(e.target.value)
if (err) setErr(null)
}}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="password">
Password
</label>
<input
id="password"
className={'input' + (err ? ' invalid' : '')}
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value)
if (err) setErr(null)
}}
/>
</div>
<div id="login-error" className="error" role="alert">
{err ?? ''}
</div>
<button
id="login-submit"
className="btn primary"
type="submit"
disabled={busy}
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<div
className="card"
style={{
marginTop: 8,
background: 'var(--surface-2)',
borderStyle: 'dashed',
}}
>
<div className="muted" style={{ fontSize: 12 }}>
Demo credentials
</div>
<div style={{ fontSize: 13 }}>
<div>
email: <span style={{ fontWeight: 600 }}>{DEMO_EMAIL}</span>
</div>
<div>
password:{' '}
<span style={{ fontWeight: 600 }}>{DEMO_PASSWORD}</span>
</div>
</div>
</div>
</form>
</Screen>
)
}