mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
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.
116 lines
5.1 KiB
TypeScript
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) }),
|
|
)
|
|
}
|