Files
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

116 lines
5.1 KiB
TypeScript

import type { Page } from '@playwright/test'
import { issuer } from './gates/issuer'
/**
* A SIGNED-IN ROOM, WITHOUT SIGNING IN.
*
* The workspace draws almost nothing for a stranger, so every spec about it
* needs a session — and gating them on a token this machine happens to hold
* meant they skipped wherever it had expired, which is most of the time and all
* of CI. Verification that only runs on one laptop is verification nobody has.
*
* Two things make a session here, and BOTH are needed: the token says a reader
* is signed in, and userinfo says who they are. Several rooms wait on the second
* before they read anything, so a token alone leaves the empty state on screen
* and a spec asserting against it proves nothing — measured on /dev, where the
* project row was pressed, the store took it, and the pane never moved.
*
* Signed by nothing and read by nobody: no server ever sees it. It exists to
* make the client ask, and what it asks for is answered by the spec.
*/
export interface Who {
sub?: string
name?: string
email?: string
org?: string
/** The token's whole `orgs` claim, home first; `org` as its owner when absent. */
orgs?: { org: string; role: string }[]
}
export async function signIn(page: Page, who: Who = {}): Promise<void> {
const sub = who.sub ?? 'gate'
const name = who.name ?? 'gate'
const email = who.email ?? '[email protected]'
const orgs = who.orgs ?? [{ org: who.org ?? 'hanzo', role: 'owner' }]
// The SDK settles the session off userinfo, which `./gates/issuer` answers for
// whoever the bearer names — so a gate never waits on IAM for a token IAM never
// minted, and the room is not veiled for the length of that wait.
await issuer(page)
// A SIGNED-IN SUBJECT HAS A PLAN, because the app requires one: every room
// stands behind the organization gate, and an unread tier is not a plan. Left
// unanswered, this read fails and the gate correctly shows the ask instead of
// the room — which is right in production and made thirty-four gates measure
// a subscribe screen. A fixture that signs in is a fixture that pays.
await page.route('**/v1/billing/tier', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
user: sub,
tier: { name: 'dev', displayName: 'Dev', dailyCreditsCents: 0, maxAgents: 8, unlimitedAgents: false },
balance: { currency: 'usd', prepaidAvailable: 0, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 0 },
}),
}),
)
// The plan is read with the ledger beside it (lib/hanzo/tier readTier), and a
// read that fails is no plan, so a fixture that pays answers the balance too.
await page.route(
(url) => url.pathname === '/v1/billing/balance',
(route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ balance: 0, holds: 0, available: 0 }) }),
)
await page.addInitScript(
({ s, n, e, o, c }) => {
const part = (v: unknown) =>
btoa(JSON.stringify(v)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const exp = Math.floor(Date.now() / 1000) + 3600
const token = `${part({ alg: 'none', typ: 'JWT' })}.${part({
sub: s,
name: n,
email: e,
exp,
orgs: o,
})}.`
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_id_token', token)
localStorage.setItem('hanzo_iam_expires_at', String(exp * 1000))
// The browser is this subject's already, so own() keeps the record below.
localStorage.setItem('hanzo:who', s)
// A SUBJECT IN SEVERAL ORGANIZATIONS HAS PICKED ONE, as a returning reader
// has: the app and every room stand behind @hanzo/app `Orgs`, which asks
// for a pick first. `org`, where named beside `orgs`, is the one in use.
if (o.length > 1) {
if (!localStorage.getItem('hanzo:org:picked')) localStorage.setItem('hanzo:org:picked', '1')
if (c && !localStorage.getItem('hanzo_iam_current_org')) localStorage.setItem('hanzo_iam_current_org', c)
}
},
{ s: sub, n: name, e: email, o: orgs, c: who.orgs && who.org ? who.org : null },
)
}
/**
* Answers one org-scoped read with a body of the spec's choosing.
*
* MATCHED ON PATHNAME, and that is the whole of it. Written as the glob
* `**${path}` this matched the full URL — so a reader that pages
* (`/v1/channels/inbox?limit=100`) was never answered at all, the real request
* went out, failed, and the room drew its empty state. The spec then asserted
* against a room it had not set up, which is the worst kind of green.
*
* Measured: the inbox mock took 0 requests as a glob and 2 as a predicate.
*
* The path is also the half that does not move between builds — see
* `e2e/gates/answer.spec.ts` for the other reason globs over a whole URL are
* the wrong tool here.
*/
export async function answer(page: Page, path: string, body: unknown): Promise<void> {
await page.route(
(url) => url.pathname === path,
(route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }),
)
}