Signed in only: a visitor is drawn nothing and goes to hanzo.id in this tab, and comes back to where they were

There is no popup. hanzo.id answers an authorize from a session it already
holds with a code and no screen, so a person signed in anywhere Hanzo signs
people in is signed in here by a bounce; anyone else signs in there and is
returned to the address they asked for (app/enter.ts). The page signs in
as hanzo-app, the Hanzo App's client, and stops publishing to hanzo.build,
which redirects to the same place on hanzo.ai/dev.

What New holds unsent waits in this tab across the trip, words carried as
?q= among it, and a draft sent while signed out is sent once its person is
in. The visitor's page (the product header over New, and the pitch) goes:
no host draws the builder for a visitor. The specs of what the builder
draws for nobody run under the mount harness, whose host now signs in.
This commit is contained in:
Zach Kelling
2026-09-28 11:32:43 -07:00
parent b07a1321f4
commit 296622e66e
31 changed files with 352 additions and 763 deletions
+15 -7
View File
@@ -3,17 +3,25 @@
Say what you want. Hanzo writes it, runs it, and puts it on a live URL — with Say what you want. Hanzo writes it, runs it, and puts it on a live URL — with
the database, the sign-in and the storage already there. the database, the sign-in and the storage already there.
The builder is one component, `<Builder host={…} />` from `@hanzo/build`, with The builder is one component, `<Builder host={…} />` from `@hanzo/build`. The
three hosts: this page (hanzo.build), and the Dev section of the Hanzo app at Hanzo app at hanzo.ai mounts it as its Dev mode, and that is where people use
hanzo.ai/dev and hanzo.app/dev. None of them forks it. hanzo.build is this it: hanzo.build and build.hanzo.ai redirect there, to the same place. This
frontend. A click stays on this origin — sign-in returns to `/auth/callback` repository's own page is the development host — the same component, signed in
here — and github.com is opened only for the repository grant. through hanzo.id — and a click stays on its origin, with github.com opened only
for the repository grant.
The page is signed-in only. A visitor is drawn nothing: the tab goes to
hanzo.id (no popup) and comes back to the address it asked for; hanzo.id
answers at once, with no screen, for a person already signed in there. Words
carried on arrival as `?q=` wait in New's composer, and a draft sent while
signed out is sent once its person is in (`landing.tsx` `hold`). It signs in as
`hanzo-app`, the Hanzo App's client.
``` ```
pnpm install pnpm install
pnpm dev # http://localhost:3200 pnpm dev # http://localhost:3200
pnpm test # the transport contract and the address grammar pnpm test # the transport contract and the address grammar
pnpm site # every screen of https://hanzo.build in a browser (SITE= for another origin) pnpm site # every browser spec against the dev server (SITE= for another origin)
pnpm e2e # one live agent turn, projected the way a run draws it pnpm e2e # one live agent turn, projected the way a run draws it
pnpm cover # unit tests and every browser spec against a dev server, one coverage report in coverage/ pnpm cover # unit tests and every browser spec against a dev server, one coverage report in coverage/
pnpm build:lib # lib/ — what npm ships pnpm build:lib # lib/ — what npm ships
@@ -100,7 +108,7 @@ collide.
## What is on the screen ## What is on the screen
**The rail** (hanzo.build). At the top, the mark. Signed in to an organization, **The rail** (this page, or `rail` left on). At the top, the mark. Signed in to an organization,
that spot is the organization switcher: the org, and the projects under it. that spot is the organization switcher: the org, and the projects under it.
New, Projects, Artifacts, Customize, Automations, then More (Codebase, Issues, New, Projects, Artifacts, Customize, Automations, then More (Codebase, Issues,
Templates, Machines, Docs), then the org's coding runs newest first with a live Templates, Machines, Docs), then the org's coding runs newest first with a live
+3 -11
View File
@@ -22,14 +22,6 @@ test:
test -s dist/index.html test -s dist/index.html
test -d dist/assets test -d dist/assets
# hanzo.build is bytes, not a process: the export is the whole product and its # Nothing is published: the builder is used at hanzo.ai (its Dev mode), and
# API is api.hanzo.ai on another origin. It publishes to the Sites plane, where # hanzo.build redirects there (hanzoai/universe static-sites build-to-hanzo-ai).
# the edge serves the prefix straight from S3 — no image, no pod, no replicas. # What ships is the package on npm (.github/workflows/publish.yml).
# dist/404.html, written by vite.config.ts, is what boots the router on an
# address that has no file of its own.
site:
slug: build
dir: dist
# The publish job is a fresh checkout: without a build line it has no dist.
build: corepack enable && pnpm install --frozen-lockfile && pnpm build
on: [main]
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.2.19", "version": "0.2.19",
"type": "module", "type": "module",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a page (hanzo.build) and as a component (hanzo.ai/dev).", "description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",
"packageManager": "[email protected]", "packageManager": "[email protected]",
"engines": { "engines": {
"node": ">=22.18" "node": ">=22.18"
+4 -4
View File
@@ -1,9 +1,9 @@
import { defineConfig } from '@playwright/test' import { defineConfig } from '@playwright/test'
/** /**
* The site, driven in a browser. `pnpm site` checks what hanzo.build serves; * The page, driven in a browser. `pnpm site` checks the dev server on :3200;
* `SITE=http://localhost:3200 pnpm site` checks the dev server before a push. * `SITE=<origin> pnpm site` checks another. Screenshots of every screen land in
* Screenshots of every screen land in test-results/. * test-results/.
*/ */
export default defineConfig({ export default defineConfig({
testDir: 'src/e2e', testDir: 'src/e2e',
@@ -11,7 +11,7 @@ export default defineConfig({
timeout: 60_000, timeout: 60_000,
reporter: 'list', reporter: 'list',
use: { use: {
baseURL: process.env.SITE || 'https://hanzo.build', baseURL: process.env.SITE || 'http://localhost:3200',
viewport: { width: 1440, height: 900 }, viewport: { width: 1440, height: 900 },
colorScheme: 'dark', colorScheme: 'dark',
}, },
+6 -9
View File
@@ -1,19 +1,17 @@
import { completePopupSignin, useIam } from '@hanzo/iam/react' import { useIam } from '@hanzo/iam/react'
import { Box, Text, YStack } from '@hanzo/ui' import { Box, Text, YStack } from '@hanzo/ui'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { back } from './enter.ts'
/** /**
* The return from Hanzo IAM. * The return from Hanzo IAM.
* *
* The SDK reads the authorization code and state from the URL and the PKCE * The SDK reads the authorization code and state from the URL and the PKCE
* verifier from storage, exchanges the code, and stores the tokens. Nothing * verifier from storage, exchanges the code, and stores the tokens. Nothing
* here touches a credential; this screen only decides where the reader lands, * here touches a credential; this screen only decides where the reader lands:
* and there is one place to land — this app answers one address. * the address they left from (enter.ts), or New.
*
* In the popup Sign in opens, this screen only hands the code back to the page
* that opened it and closes: that page holds the attempt and makes the exchange,
* so the person's work is still on it when the account arrives (enter.ts).
* *
* A failed exchange is reported rather than swallowed: it is the most expensive * A failed exchange is reported rather than swallowed: it is the most expensive
* refusal in the whole flow, and it is invisible from the outside. * refusal in the whole flow, and it is invisible from the outside.
@@ -24,11 +22,10 @@ export function Callback() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (completePopupSignin()) return
let cancelled = false let cancelled = false
handleCallback() handleCallback()
.then(() => { .then(() => {
if (!cancelled) go('/', { replace: true }) if (!cancelled) go(back(), { replace: true })
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
// The SDK hands every refusal over as an Error. // The SDK hands every refusal over as an Error.
+49 -15
View File
@@ -1,23 +1,57 @@
import { describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { enter } from './enter.ts' import { back, enter } from './enter.ts'
describe('signing in keeps the page when it can', () => { /** A tab at `href` with a sessionStorage of its own. */
it('completes in the popup and never redirects', async () => { function tab(href: string) {
const door = { login: vi.fn(), loginPopup: vi.fn(async () => undefined) } const u = new URL(href)
expect(await enter(door)).toBe(true) const store = new Map<string, string>()
expect(door.loginPopup).toHaveBeenCalledOnce() vi.stubGlobal('window', {
expect(door.login).not.toHaveBeenCalled() location: { pathname: u.pathname, search: u.search, hash: u.hash },
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
})
return store
}
afterEach(() => vi.unstubAllGlobals())
describe('signing in is one trip, in this tab', () => {
it('writes down where the tab is and leaves for hanzo.id', () => {
tab('http://localhost:3200/-/codebases?view=all#top')
const door = { login: vi.fn(async () => undefined) }
enter(door)
expect(door.login).toHaveBeenCalledOnce()
expect(back()).toBe('/-/codebases?view=all#top')
}) })
it('falls back to the redirect when the browser refuses the popup', async () => { it('reads the address once, and then lands on New', () => {
const door = { tab('http://localhost:3200/sess_0123456789abcdef0123456789abcdef')
login: vi.fn(), enter({ login: async () => undefined })
loginPopup: vi.fn(async () => { expect(back()).toBe('/sess_0123456789abcdef0123456789abcdef')
throw new Error('Failed to open login popup — blocked by browser?') expect(back()).toBe('/')
}), })
it('never returns off this page, or to the callback it is answering', () => {
for (const held of ['//evil.example/x', '/\\evil.example', 'https://evil.example/', '/auth/callback?code=c&state=s', '/auth/callback']) {
tab('http://localhost:3200/').set('signin.destination', held)
expect(back(), held).toBe('/')
} }
expect(await enter(door)).toBe(false) })
it('still leaves, and lands on New, in a browser that refuses storage', () => {
vi.stubGlobal('window', {
location: { pathname: '/-/sync', search: '', hash: '' },
get sessionStorage(): Storage {
throw new Error('SecurityError')
},
})
const door = { login: vi.fn(async () => undefined) }
enter(door)
expect(door.login).toHaveBeenCalledOnce() expect(door.login).toHaveBeenCalledOnce()
expect(back()).toBe('/')
}) })
}) })
+41 -14
View File
@@ -1,25 +1,52 @@
/** /**
* Signing in WITHOUT LOSING THE PAGE. * Signing in, one way: this tab goes to hanzo.id and comes back.
* *
* A full-page redirect unloads the document, and with it the sentence somebody * There is no popup. hanzo.id answers an authorize from a session it already
* typed and the project they had open. The popup keeps the document, so the * holds with a code and no screen (hanzoai/iam internal/oidc/prompt.go), so a
* work is still on screen when the account arrives. A browser that refuses the * person signed in anywhere Hanzo signs people in is signed in here by a bounce;
* popup falls back to the redirect, which is the only alternative there is. * anyone else signs in or up there and is returned. The address they were at
* is written down on the way out and read back by the callback (callback.tsx),
* and what they had typed on New waits in this tab (landing.tsx).
* *
* One function, so every Sign in on this surface behaves the same way. * sessionStorage, because the note belongs to this one trip and this one tab.
* Every access is guarded: a browser that refuses storage is still signed in,
* and lands on New.
*/ */
export interface Door { export interface Door {
login: () => unknown login: () => Promise<unknown>
loginPopup: () => Promise<unknown>
} }
/** True when the popup completed here; false when the browser was sent away. */ const KEY = 'signin.destination'
export async function enter(door: Door): Promise<boolean> {
/** Where a return with nowhere written down lands: New. */
const HOME = '/'
/** Write down where this tab is, and leave for hanzo.id. */
export function enter(door: Door): void {
try { try {
await door.loginPopup() const { pathname, search, hash } = window.location
return true window.sessionStorage.setItem(KEY, `${pathname}${search}${hash}`)
} catch { } catch {
void door.login() /* nowhere to write it; the return lands on New */
return false
} }
void door.login()
}
/**
* Read-once: where this sign-in started, when that is an address of this page
* and not the callback itself (whose code is spent by the time anyone reads
* this). `//host` and `/\host` begin with a slash and leave the origin, so a
* path is a slash not followed by either.
*/
export function back(): string {
let held: string | null = null
try {
held = window.sessionStorage.getItem(KEY)
window.sessionStorage.removeItem(KEY)
} catch {
return HOME
}
if (!held || !held.startsWith('/') || held.startsWith('//') || held.startsWith('/\\')) return HOME
if (/^\/auth\/callback\/?(?:[?#]|$)/.test(held)) return HOME
return held
} }
+34 -6
View File
@@ -1,9 +1,14 @@
// The builder's own page: Hanzo IAM for who is here, react-router for where. // The builder's own page: Hanzo IAM for who is here, react-router for where.
// //
// One of the builder's hosts; the Hanzo app's Dev section (hanzo.ai/dev, // The package's development host. The product is the Hanzo app at hanzo.ai,
// hanzo.app/dev) is the other, and draws the builder's runs in its own rail. // whose Dev mode mounts the same `<Builder>` and draws its runs in its own
// Everything the builder needs from a host is handed over as `Host` values here, // rail. Everything the builder needs from a host is handed over as `Host`
// so the builder imports neither IAM nor a router. // values here, so the builder imports neither IAM nor a router.
//
// SIGNED IN ONLY. A visitor is drawn nothing: this tab goes to hanzo.id and
// comes back to the address it asked for (enter.ts). A person hanzo.id already
// knows is answered at once, with no screen and nothing to press. Words carried
// on arrival as `?q=` wait in New's composer (landing.tsx `hold`).
import { IamProvider, useIam } from '@hanzo/iam/react' import { IamProvider, useIam } from '@hanzo/iam/react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
@@ -12,6 +17,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router'
import { Builder } from '../builder.tsx' import { Builder } from '../builder.tsx'
import type { Host, Person } from '../host.tsx' import type { Host, Person } from '../host.tsx'
import { hold } from '../landing.tsx'
import { enter } from './enter.ts' import { enter } from './enter.ts'
import { follow } from './stay.ts' import { follow } from './stay.ts'
import { administers, bearer, org, orgs, own, selectOrg, subject } from './token.ts' import { administers, bearer, org, orgs, own, selectOrg, subject } from './token.ts'
@@ -28,6 +34,26 @@ export function Mount() {
if (!isLoading) own(subject()) if (!isLoading) own(subject())
}, [isLoading, isAuthenticated]) }, [isLoading, isAuthenticated])
// Words carried on arrival are New's draft, and leave the address: a reload
// or a shared link must not carry them again.
useEffect(() => {
const q = new URLSearchParams(where.search)
const asked = q.get('q')?.trim()
if (!asked) return
hold({ draft: asked, files: [] })
q.delete('q')
const rest = q.toString()
navigate(`${where.pathname}${rest ? `?${rest}` : ''}${where.hash}`, { replace: true })
}, [where, navigate])
// Nobody is signed in: leave for hanzo.id, once IAM has said so.
const out = !isLoading && !isAuthenticated
useEffect(() => {
if (out) enter(door)
// `door` is a new object each render; the answer is what decides.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [out])
const scoped = org() const scoped = org()
const person: Person | null = useMemo(() => { const person: Person | null = useMemo(() => {
if (!isAuthenticated || !user) return null if (!isAuthenticated || !user) return null
@@ -64,11 +90,13 @@ export function Mount() {
home: window.location.origin, home: window.location.origin,
}, },
open: (href) => follow(href, window.location.href, { here: navigate, away: (to) => window.location.assign(to) }), open: (href) => follow(href, window.location.href, { here: navigate, away: (to) => window.location.assign(to) }),
signIn: () => void enter(door), signIn: () => enter(door),
signOut: () => void logout(), signOut: () => void logout(),
} }
return <Builder host={host} /> // A token still resolving is a person; with none, nothing is drawn while the tab leaves.
if (isAuthenticated || (isLoading && bearer())) return <Builder host={host} />
return null
} }
/** Identity above every screen, the callback included: it is the screen that completes it. */ /** Identity above every screen, the callback included: it is the screen that completes it. */
+3 -3
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { follow, step } from './stay.ts' import { follow, step } from './stay.ts'
const page = 'https://hanzo.build/-/codebases' const page = 'https://build.test/-/codebases'
describe('this page stays on its own frontend', () => { describe('this page stays on its own frontend', () => {
it('keeps a platform address on this page', () => { it('keeps a platform address on this page', () => {
@@ -13,8 +13,8 @@ describe('this page stays on its own frontend', () => {
it('routes an address on this origin inside the app', () => { it('routes an address on this origin inside the app', () => {
expect(step('/-/sync', page)).toEqual({ kind: 'here', path: '/-/sync' }) expect(step('/-/sync', page)).toEqual({ kind: 'here', path: '/-/sync' })
expect(step('https://hanzo.build/', page)).toEqual({ kind: 'here', path: '/' }) expect(step('https://build.test/', page)).toEqual({ kind: 'here', path: '/' })
expect(step('https://hanzo.build/platform/computers', page)).toEqual({ kind: 'here', path: '/' }) expect(step('https://build.test/platform/computers', page)).toEqual({ kind: 'here', path: '/' })
}) })
it('stays put for an address it cannot read', () => { it('stays put for an address it cannot read', () => {
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Where a click on this page may go. * Where a click on this page may go.
* *
* hanzo.build is this frontend. An address on this origin moves in the router. * This page is its own frontend. An address on this origin moves in the router.
* github.com is the grant. platform.hanzo.ai is another product, and a click * github.com is the grant. platform.hanzo.ai is another product, and a click
* that names it stays on this page. * that names it stays on this page.
*/ */
+9 -7
View File
@@ -11,12 +11,12 @@ const at = (href: string) => {
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals())
describe('where this page signs in', () => { describe('where this page signs in', () => {
it('is hanzo.id as the hanzo-build client, returning to this origin', () => { it('is hanzo.id as the Hanzo App\'s client, returning to this origin', () => {
at('https://hanzo.build/-/codebases') at('http://localhost:3200/-/codebases')
expect(origin({})).toEqual({ expect(origin({})).toEqual({
serverUrl: 'https://hanzo.id', serverUrl: 'https://hanzo.id',
clientId: 'hanzo-build', clientId: 'hanzo-app',
redirectUri: 'https://hanzo.build/auth/callback', redirectUri: 'http://localhost:3200/auth/callback',
organization: 'hanzo', organization: 'hanzo',
}) })
}) })
@@ -28,8 +28,8 @@ describe('where this page signs in', () => {
}) })
describe('where the platform is', () => { describe('where the platform is', () => {
it('is api.hanzo.ai from a hanzo.ai host or hanzo.build', () => { it('is api.hanzo.ai from a hanzo.ai host', () => {
for (const page of ['https://hanzo.ai/dev', 'https://app.hanzo.ai/dev', 'https://hanzo.build/']) { for (const page of ['https://hanzo.ai/dev', 'https://app.hanzo.ai/dev']) {
at(page) at(page)
expect(api({}), page).toBe('https://api.hanzo.ai') expect(api({}), page).toBe('https://api.hanzo.ai')
} }
@@ -40,10 +40,12 @@ describe('where the platform is', () => {
expect(api({})).toBe('http://localhost:3200') expect(api({})).toBe('http://localhost:3200')
at('https://nothanzo.ai/') at('https://nothanzo.ai/')
expect(api({})).toBe('https://nothanzo.ai') expect(api({})).toBe('https://nothanzo.ai')
at('https://hanzo.build/')
expect(api({})).toBe('https://hanzo.build')
}) })
it('is the platform a fork names', () => { it('is the platform a fork names', () => {
at('https://hanzo.build/') at('https://hanzo.ai/')
expect(api({ VITE_HANZO_API: 'https://api.lux.network/' })).toBe('https://api.lux.network') expect(api({ VITE_HANZO_API: 'https://api.lux.network/' })).toBe('https://api.lux.network')
}) })
}) })
+9 -8
View File
@@ -7,12 +7,13 @@
export type Env = { readonly [name: string]: string | undefined } export type Env = { readonly [name: string]: string | undefined }
/** /**
* The IAM client. It is `hanzo-build` under the estate's `<org>-<app>` scheme, * The IAM client: `hanzo-app`, the Hanzo App's, which hanzo.ai signs in through
* so the org is the name's first word, and the issuer returns a browser to * too — one product, one application. Under the estate's `<org>-<app>` scheme
* `/auth/callback` on this origin. * the org is the name's first word, and the issuer returns a browser to
* `/auth/callback` on this origin (the dev server's is registered on it).
*/ */
export function origin(env: Env) { export function origin(env: Env) {
const clientId = env.VITE_HANZO_CLIENT_ID || 'hanzo-build' const clientId = env.VITE_HANZO_CLIENT_ID || 'hanzo-app'
return { return {
serverUrl: (env.VITE_HANZO_IAM || 'https://hanzo.id').replace(/\/+$/, ''), serverUrl: (env.VITE_HANZO_IAM || 'https://hanzo.id').replace(/\/+$/, ''),
clientId, clientId,
@@ -22,13 +23,13 @@ export function origin(env: Env) {
} }
/** /**
* The platform. `api.hanzo.ai` on a hanzo.ai host or hanzo.build; this page's * The platform. `api.hanzo.ai` on a hanzo.ai host; this page's own origin
* own origin anywhere else, where the dev and preview servers proxy `/v1` — the * anywhere else, where the dev and preview servers proxy `/v1` — the gateway
* gateway admits an origin by allowlist and a localhost port is not on it. * admits an origin by allowlist and a localhost port is not on it.
*/ */
export function api(env: Env): string { export function api(env: Env): string {
if (env.VITE_HANZO_API) return env.VITE_HANZO_API.replace(/\/+$/, '') if (env.VITE_HANZO_API) return env.VITE_HANZO_API.replace(/\/+$/, '')
const host = window.location.hostname const host = window.location.hostname
if (host === 'hanzo.ai' || host.endsWith('.hanzo.ai') || host === 'hanzo.build') return 'https://api.hanzo.ai' if (host === 'hanzo.ai' || host.endsWith('.hanzo.ai')) return 'https://api.hanzo.ai'
return window.location.origin return window.location.origin
} }
-38
View File
@@ -49,8 +49,6 @@ import { Artifacts, Templates } from './shelf.tsx'
import { Find } from './find.tsx' import { Find } from './find.tsx'
import { DOCS, Slack, Who } from './foot.tsx' import { DOCS, Slack, Who } from './foot.tsx'
import { Settings } from './settings/index.tsx' import { Settings } from './settings/index.tsx'
import { Pitch } from './pitch.tsx'
import { Bar } from './visit.tsx'
type Order = 'newest' | 'running' type Order = 'newest' | 'running'
@@ -224,40 +222,6 @@ function Name({ onPress }: { onPress: () => void }) {
) )
} }
/**
* A visitor: no runs to list, so the product's header stands where the rail
* would. On New the composer fills the first screen and the pitch follows it.
*/
function Visit() {
const host = useHost()
const r = route(host.path)
// An address that names a part of the pitch (/#features) opens scrolled to it.
useEffect(() => {
const id = window.location.hash.slice(1)
if (id) document.getElementById(id)?.scrollIntoView()
}, [])
const start = () => {
const page = document.getElementById('visit')
page?.scrollTo({ top: 0, behavior: 'smooth' })
page?.querySelector<HTMLElement>('textarea, [contenteditable="true"], input[type="text"]')?.focus()
}
return (
<YStack flex={1} minH={0} minW={0} bg="$background">
<Bar name={<Name onPress={() => host.go('')} />} />
{r.kind === 'new' ? (
<YStack id="visit" flex={1} minH={0} overflow="scroll">
<YStack shrink={0} style={{ height: 'calc(100dvh - 60px)' }}>
<Pane />
</YStack>
<Pitch onStart={start} />
</YStack>
) : (
<Pane />
)}
</YStack>
)
}
/** Whichever pane the address names, beside a rail — the builder's or the host's. */ /** Whichever pane the address names, beside a rail — the builder's or the host's. */
function Pane({ onStarted }: { onStarted?: (id: string) => void }) { function Pane({ onStarted }: { onStarted?: (id: string) => void }) {
const host = useHost() const host = useHost()
@@ -299,8 +263,6 @@ function Screens({ rail }: { rail: boolean }) {
const r = route(host.path) const r = route(host.path)
if (r.kind === 'project') return <Project key={r.slug} slug={r.slug} /> if (r.kind === 'project') return <Project key={r.slug} slug={r.slug} />
if (!rail) return <Pane /> if (!rail) return <Pane />
// Signed out with no token to wait on: a token still resolving is a person, not a visitor.
if (!host.person && !host.token() && host.signIn) return <Visit />
return <Shell /> return <Shell />
} }
+59 -56
View File
@@ -1,7 +1,7 @@
/** /**
* The page around the builder, and the parts every screen shares: signing in * The page around the builder, and the parts every screen shares: signing in
* through hanzo.id (a popup that keeps the page, the redirect when the popup is * through hanzo.id (this tab goes and comes back, with nothing of the app drawn
* refused, the return and its refusals, signing out), who is signed in and which * to a visitor; the return and its refusals; signing out), who is signed in and which
* org they work in, a host that draws its own rail (`DevSection`), the reads and * org they work in, a host that draws its own rail (`DevSection`), the reads and
* the live feed every pane owes, the person's own settings, attaching files and * the live feed every pane owes, the person's own settings, attaching files and
* dictating, and the markdown an agent writes. * dictating, and the markdown an agent writes.
@@ -14,6 +14,7 @@ import type { BrowserContext, Page } from '@playwright/test'
import { ask, composer, status } from './composer.ts' import { ask, composer, status } from './composer.ts'
import { expect, test } from './fixture.ts' import { expect, test } from './fixture.ts'
import { mounted, went } from './mount.ts'
import { ORG, SESSION, serve, signIn, type Reply } from './signed.ts' import { ORG, SESSION, serve, signIn, type Reply } from './signed.ts'
import { org, PNG } from './stubs.ts' import { org, PNG } from './stubs.ts'
@@ -35,11 +36,16 @@ interface Asked {
*/ */
async function issuer( async function issuer(
ctx: BrowserContext, ctx: BrowserContext,
{ token = { status: 200, json: { access_token: TOKEN, refresh_token: 'r1', expires_in: 3600, token_type: 'Bearer' } as unknown }, user = { sub: `${ORG}/dave`, name: 'Dave', email: '[email protected]' } as Record<string, unknown> } = {}, {
token = { status: 200, json: { access_token: TOKEN, refresh_token: 'r1', expires_in: 3600, token_type: 'Bearer' } as unknown },
user = { sub: `${ORG}/dave`, name: 'Dave', email: '[email protected]' } as Record<string, unknown>,
held = Promise.resolve(),
}: { token?: unknown; user?: Record<string, unknown>; held?: Promise<void> } = {},
): Promise<Asked> { ): Promise<Asked> {
const asked: Asked = { authorize: [], token: [] } const asked: Asked = { authorize: [], token: [] }
await ctx.route(`${ISSUER}/.well-known/openid-configuration`, (r) => await ctx.route(`${ISSUER}/.well-known/openid-configuration`, async (r) => {
r.fulfill({ await held
await r.fulfill({
json: { json: {
issuer: ISSUER, issuer: ISSUER,
authorization_endpoint: `${ISSUER}/v1/iam/oauth/authorize`, authorization_endpoint: `${ISSUER}/v1/iam/oauth/authorize`,
@@ -48,8 +54,8 @@ async function issuer(
revocation_endpoint: `${ISSUER}/v1/iam/oauth/revoke`, revocation_endpoint: `${ISSUER}/v1/iam/oauth/revoke`,
end_session_endpoint: `${ISSUER}/v1/iam/oauth/logout`, end_session_endpoint: `${ISSUER}/v1/iam/oauth/logout`,
}, },
}), })
) })
await ctx.route(`${ISSUER}/v1/iam/oauth/authorize?**`, (r) => { await ctx.route(`${ISSUER}/v1/iam/oauth/authorize?**`, (r) => {
const url = new URL(r.request().url()) const url = new URL(r.request().url())
asked.authorize.push(url) asked.authorize.push(url)
@@ -73,52 +79,50 @@ const stored = (page: Page, key: string) => page.evaluate((k) => window.localSto
const arrived = (page: Page, who = 'Dave · acme') => expect(page.getByRole('button', { name: `Account: ${who}` })).toBeVisible({ timeout: 20_000 }) const arrived = (page: Page, who = 'Dave · acme') => expect(page.getByRole('button', { name: `Account: ${who}` })).toBeVisible({ timeout: 20_000 })
test.describe('signing in', () => { test.describe('signing in', () => {
test('a popup signs in and the page, with what was typed, stays', async ({ page, baseURL }) => { test('a visitor is drawn nothing: this tab goes to hanzo.id and comes back signed in, with the words it carried', async ({ page, baseURL }) => {
const asked = await issuer(page.context()) let answer = () => {}
const asked = await issuer(page.context(), { held: new Promise((done) => (answer = done)) })
await nothing(page) await nothing(page)
await page.goto('/') const popups: string[] = []
const ask = page.getByRole('textbox', { name: 'Describe a task or ask a question' }) page.on('popup', (p) => popups.push(p.url()))
await ask.fill('Add a cart to the shop') await page.goto('/?q=Add%20a%20cart%20to%20the%20shop')
const [popup] = await Promise.all([page.waitForEvent('popup'), page.getByRole('banner').getByRole('button', { name: 'Log in' }).click()]) // On its way to hanzo.id, held at discovery: the page draws nothing of the app.
await popup.waitForEvent('close') await page.waitForTimeout(500)
await expect(page.getByRole('textbox')).toHaveCount(0)
await expect(page.getByRole('navigation')).toHaveCount(0)
expect(asked.authorize).toEqual([])
answer()
await arrived(page) await arrived(page)
const ask = page.getByRole('textbox', { name: 'Describe a task or ask a question' })
await expect(ask).toHaveValue('Add a cart to the shop') await expect(ask).toHaveValue('Add a cart to the shop')
expect(page.url()).toBe(new URL('/', baseURL).href) expect(page.url()).toBe(new URL('/', baseURL).href)
expect(popups).toEqual([])
const at = asked.authorize[0]! const at = asked.authorize[0]!
expect(at.searchParams.get('client_id')).toBe('hanzo-build') expect(at.searchParams.get('client_id')).toBe('hanzo-app')
expect(at.searchParams.get('redirect_uri')).toBe(new URL('/auth/callback', baseURL).href) expect(at.searchParams.get('redirect_uri')).toBe(new URL('/auth/callback', baseURL).href)
expect(at.searchParams.get('code_challenge_method')).toBe('S256') expect(at.searchParams.get('code_challenge_method')).toBe('S256')
expect(asked.token).toHaveLength(1) expect(asked.token).toHaveLength(1)
expect(Object.fromEntries(asked.token[0]!)).toMatchObject({ grant_type: 'authorization_code', code: 'c0de', client_id: 'hanzo-build' }) expect(Object.fromEntries(asked.token[0]!)).toMatchObject({ grant_type: 'authorization_code', code: 'c0de', client_id: 'hanzo-app' })
expect(asked.token[0]!.get('code_verifier')).toBeTruthy() expect(asked.token[0]!.get('code_verifier')).toBeTruthy()
expect(await stored(page, 'hanzo_iam_access_token')).toBe(TOKEN) expect(await stored(page, 'hanzo_iam_access_token')).toBe(TOKEN)
expect(await stored(page, 'hanzo:who')).toBe(`${ORG}/dave`) expect(await stored(page, 'hanzo:who')).toBe(`${ORG}/dave`)
}) })
test('a browser that refuses the popup is sent to hanzo.id and brought back signed in', async ({ page, baseURL }) => { test('the return lands on the address the visitor asked for', async ({ page, baseURL }) => {
await page.addInitScript(() => {
window.open = () => null
})
const asked = await issuer(page.context()) const asked = await issuer(page.context())
await nothing(page) await nothing(page)
await page.goto('/-/codebases') await page.goto('/-/codebases')
await page.getByRole('banner').getByRole('button', { name: 'Log in' }).click()
await arrived(page) await arrived(page)
expect(page.url()).toBe(new URL('/', baseURL).href) await expect(page).toHaveURL(new URL('/-/codebases', baseURL).href)
expect(asked.authorize).toHaveLength(1) expect(asked.authorize).toHaveLength(1)
expect(asked.token[0]!.get('code')).toBe('c0de') expect(asked.token[0]!.get('code')).toBe('c0de')
expect(await stored(page, 'hanzo_iam_access_token')).toBe(TOKEN)
}) })
test('a refused exchange says why, and keeps nobody signed in', async ({ page }) => { test('a refused exchange says why, and keeps nobody signed in', async ({ page }) => {
await page.addInitScript(() => {
window.open = () => null
})
await issuer(page.context(), { token: { status: 400, json: { error: 'invalid_grant' } } }) await issuer(page.context(), { token: { status: 400, json: { error: 'invalid_grant' } } })
await nothing(page) await nothing(page)
await page.goto('/') await page.goto('/')
await page.getByRole('banner').getByRole('button', { name: 'Log in' }).click()
await expect(page.getByText('Token exchange failed (400): {"error":"invalid_grant"}')).toBeVisible({ timeout: 20_000 }) await expect(page.getByText('Token exchange failed (400): {"error":"invalid_grant"}')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('Try signing in again.')).toBeVisible() await expect(page.getByText('Try signing in again.')).toBeVisible()
expect(await stored(page, 'hanzo_iam_access_token')).toBeNull() expect(await stored(page, 'hanzo_iam_access_token')).toBeNull()
@@ -136,28 +140,6 @@ test.describe('signing in', () => {
await expect(page.getByText('OAuth error on a sign-in this browser did not start. Restart sign-in.')).toBeVisible() await expect(page.getByText('OAuth error on a sign-in this browser did not start. Restart sign-in.')).toBeVisible()
}) })
test('in the popup, the return hands the code to the page that opened it and closes, making no exchange', async ({ page, baseURL }) => {
const asked = await issuer(page.context())
// This page as Sign in's popup: named as the SDK names it, with a page that opened it.
await page.addInitScript(() => {
const w = window as unknown as { handed: unknown[]; shut: boolean }
w.handed = []
w.shut = false
window.name = 'hanzo_iam_login'
Object.defineProperty(window, 'opener', { value: { postMessage: (m: unknown, o: string) => w.handed.push({ m, o }) } })
window.close = () => {
w.shut = true
}
})
await nothing(page)
await page.goto('/auth/callback?code=c0de&state=s1')
await expect.poll(() => page.evaluate(() => (window as unknown as { shut: boolean }).shut)).toBe(true)
const handed = await page.evaluate(() => (window as unknown as { handed: unknown[] }).handed)
expect(handed[0]).toEqual({ m: { source: 'hanzo-iam', code: 'c0de', state: 's1' }, o: new URL(baseURL!).origin })
expect(asked.token).toEqual([])
expect(await stored(page, 'hanzo_iam_access_token')).toBeNull()
})
test('the return shows it is working until the account arrives', async ({ page }) => { test('the return shows it is working until the account arrives', async ({ page }) => {
let release = () => {} let release = () => {}
const gate = new Promise<void>((done) => (release = done)) const gate = new Promise<void>((done) => (release = done))
@@ -167,18 +149,33 @@ test.describe('signing in', () => {
await gate await gate
await r.fulfill({ json: { access_token: TOKEN, expires_in: 3600 } }) await r.fulfill({ json: { access_token: TOKEN, expires_in: 3600 } })
}) })
await page.addInitScript(() => {
window.open = () => null
})
await nothing(page) await nothing(page)
await page.goto('/') await page.goto('/')
await page.getByRole('banner').getByRole('button', { name: 'Log in' }).click()
// The trip to hanzo.id and back loads the page twice.
await expect(page.getByText('Completing sign-in…')).toBeVisible({ timeout: 20_000 }) await expect(page.getByText('Completing sign-in…')).toBeVisible({ timeout: 20_000 })
await expect(page.getByText('One moment.')).toBeVisible() await expect(page.getByText('One moment.')).toBeVisible()
release() release()
await arrived(page) await arrived(page)
}) })
test('words sent while signed out are sent once the person is in', async ({ page }) => {
const sent = await serve(page, ({ path, method }) => (path === '/v1/agent/coding' && method === 'POST' ? { status: 202, json: { sessionId: SESSION, repo: '', branch: '' } } : undefined))
await mounted(page, { path: '', org: ORG, admin: true, person: null, signIn: true })
const ask = page.getByRole('textbox', { name: 'Describe a task or ask a question' })
await ask.fill('Add a cart to the shop')
await ask.press('Enter')
await expect.poll(() => went(page)).toEqual(['sign in'])
expect(sent.filter((s) => s.path === '/v1/agent/coding')).toEqual([])
// Back from signing in: the same tab, now with a person.
await mounted(page, { path: '', org: ORG, admin: true, person: { name: 'Dave', email: '[email protected]', avatar: '' } })
await expect.poll(() => sent.filter((s) => s.method === 'POST' && s.path === '/v1/agent/coding').length).toBe(1)
expect((sent.find((s) => s.path === '/v1/agent/coding')!.body as { prompt: string }).prompt).toBe('Add a cart to the shop')
await expect.poll(() => went(page)).toEqual([SESSION])
// Once: a reload does not send it again.
await page.reload()
await page.waitForTimeout(500)
expect(sent.filter((s) => s.method === 'POST' && s.path === '/v1/agent/coding')).toHaveLength(1)
})
}) })
test.describe('who is signed in', () => { test.describe('who is signed in', () => {
@@ -213,9 +210,15 @@ test.describe('who is signed in', () => {
localStorage.setItem('hanzo.build.new.acme', '{"ask":"hers"}') localStorage.setItem('hanzo.build.new.acme', '{"ask":"hers"}')
localStorage.setItem('theme', 'dark') localStorage.setItem('theme', 'dark')
}) })
// hanzo.id is asked, and answers nothing, so this tab stays to be read.
const asked: URL[] = []
await page.context().route(`${ISSUER}/v1/iam/oauth/authorize?**`, (r) => {
asked.push(new URL(r.request().url()))
return r.fulfill({ status: 204 })
})
await nothing(page) await nothing(page)
await page.goto('/') await page.goto('/')
await expect(page.getByRole('banner').getByRole('button', { name: 'Log in' })).toBeVisible() await expect.poll(() => asked.length).toBe(1)
expect(await stored(page, 'hanzo:who')).toBeNull() expect(await stored(page, 'hanzo:who')).toBeNull()
expect(await stored(page, 'hanzo.build.slack')).toBeNull() expect(await stored(page, 'hanzo.build.slack')).toBeNull()
expect(await stored(page, 'hanzo.build.new.acme')).toBeNull() expect(await stored(page, 'hanzo.build.new.acme')).toBeNull()
@@ -763,7 +766,7 @@ test.describe('dictating without a microphone', () => {
test('a visitor’s choice of language asks them to sign in', async ({ page }) => { test('a visitor’s choice of language asks them to sign in', async ({ page }) => {
await serve(page, () => undefined) await serve(page, () => undefined)
await page.goto('/') await mounted(page, { path: '', org: null, admin: false, person: null, signIn: true })
await page.getByRole('button', { name: 'Dictation language:', exact: true }).click() await page.getByRole('button', { name: 'Dictation language:', exact: true }).click()
await page.getByRole('option', { name: 'Deutsch' }).click() await page.getByRole('option', { name: 'Deutsch' }).click()
await expect(status(page)).toHaveText('Sign in to save your settings.') await expect(status(page)).toHaveText('Sign in to save your settings.')
+5 -6
View File
@@ -11,6 +11,7 @@
import type { Page } from '@playwright/test' import type { Page } from '@playwright/test'
import { expect, test } from './fixture.ts' import { expect, test } from './fixture.ts'
import { mounted, went } from './mount.ts'
import type { Reply, Sent } from './signed.ts' import type { Reply, Sent } from './signed.ts'
import { catalogue, customize, LISTINGS, PNG, visitor } from './stubs.ts' import { catalogue, customize, LISTINGS, PNG, visitor } from './stubs.ts'
@@ -57,8 +58,7 @@ const sentTo = (sent: Sent[], method: string, path: string) => sent.filter((s) =
test('a visitor reads the catalogue, the fleet’s servers and the presets, and is asked to sign in for the rest', async ({ page }, info) => { test('a visitor reads the catalogue, the fleet’s servers and the presets, and is asked to sign in for the rest', async ({ page }, info) => {
await visitor(page) await visitor(page)
await page.route('https://hanzo.id/**', (r) => r.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Sign in</title>' })) await mounted(page, { path: '-/customize', org: null, admin: false, person: null, signIn: true })
await page.goto('/-/customize')
// Discover is what a visitor lands on; nothing there is theirs to add. // Discover is what a visitor lands on; nothing there is theirs to add.
await expect(page.getByRole('button', { name: 'Discover', pressed: true })).toBeVisible() await expect(page.getByRole('button', { name: 'Discover', pressed: true })).toBeVisible()
@@ -93,11 +93,10 @@ test('a visitor reads the catalogue, the fleet’s servers and the presets, and
await expect(page.getByText('Product & Fashion Create', { exact: true })).toBeVisible() await expect(page.getByText('Product & Fashion Create', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: /^Add / })).toHaveCount(0) await expect(page.getByRole('button', { name: /^Add / })).toHaveCount(0)
// Sign in opens IAM beside the page, which stays as it is. // Sign in is the host's to do.
await page.getByRole('button', { name: 'Yours', exact: true }).click() await page.getByRole('button', { name: 'Yours', exact: true }).click()
const [popup] = await Promise.all([page.waitForEvent('popup'), page.getByRole('button', { name: 'Sign in', exact: true }).click()]) await page.getByRole('button', { name: 'Sign in', exact: true }).click()
expect(popup.url()).toMatch(/^https:\/\/hanzo\.id\//) await expect.poll(() => went(page)).toContain('sign in')
await expect(page.getByText('Sign in to see your agents.')).toBeVisible()
}) })
// Skills. // Skills.
+8 -7
View File
@@ -8,6 +8,7 @@ import type { Page } from '@playwright/test'
import { DAVE, enter, held, MEMBER, serve, type Holds, type Reply, type Sent, type Who } from './cov-forge.ts' import { DAVE, enter, held, MEMBER, serve, type Holds, type Reply, type Sent, type Who } from './cov-forge.ts'
import { expect, test } from './fixture.ts' import { expect, test } from './fixture.ts'
import { mounted, went } from './mount.ts'
import { ORG, SESSION } from './signed.ts' import { ORG, SESSION } from './signed.ts'
const NEXT = `sess_${'c'.repeat(32)}` const NEXT = `sess_${'c'.repeat(32)}`
@@ -274,14 +275,14 @@ test.describe('New', () => {
expect(posted(sent, '/v1/audio/transcriptions')).toHaveLength(2) expect(posted(sent, '/v1/audio/transcriptions')).toHaveLength(2)
}) })
test('a visitor who sends is asked to sign in', async ({ page }) => { test('a visitor who sends is asked to sign in, and what they sent waits for them', async ({ page }) => {
await page.context().route('https://hanzo.id/**', (r) => r.fulfill({ contentType: 'text/html', body: '<title>Hanzo</title>Sign in' })) const sent = await serve(page, () => ({ status: 401, json: { status: 401, title: 'Unauthorized', detail: 'Sign in to use this.' } }))
await serve(page, () => ({ status: 401, json: { status: 401, title: 'Unauthorized', detail: 'Sign in to use this.' } })) await mounted(page, { path: '', org: null, admin: false, person: null, signIn: true })
await page.goto('/')
await ask(page).fill('Add a footer') await ask(page).fill('Add a footer')
const [popup] = await Promise.all([page.waitForEvent('popup'), ask(page).press('Enter')]) await ask(page).press('Enter')
expect(new URL(popup.url()).hostname).toBe('hanzo.id') await expect.poll(() => went(page)).toEqual(['sign in'])
await popup.close() expect(sent.filter((s) => s.path === '/v1/agent/coding')).toEqual([])
expect(JSON.parse((await page.evaluate(() => sessionStorage.getItem('hanzo.build.unsent'))) ?? '{}')).toEqual({ draft: 'Add a footer', files: [], sent: true })
}) })
}) })
+4 -5
View File
@@ -9,6 +9,7 @@ import type { Page } from '@playwright/test'
import { enter, held, via, type Holds, type Reply, type Sent, type Who } from './cov-forge.ts' import { enter, held, via, type Holds, type Reply, type Sent, type Who } from './cov-forge.ts'
import { expect, test } from './fixture.ts' import { expect, test } from './fixture.ts'
import { mounted, went } from './mount.ts'
import { ORG } from './signed.ts' import { ORG } from './signed.ts'
const id = (c: string) => `sess_${c.repeat(32)}` const id = (c: string) => `sess_${c.repeat(32)}`
@@ -454,12 +455,10 @@ test.describe('the workspace', () => {
}) })
test('a visitor is asked to sign in', async ({ page }) => { test('a visitor is asked to sign in', async ({ page }) => {
await page.context().route('https://hanzo.id/**', (r) => r.fulfill({ contentType: 'text/html', body: '<title>Hanzo</title>Sign in' })) await mounted(page, { path: 'shop', org: null, admin: false, person: null, signIn: true })
await page.goto('/shop')
await expect(page.getByText('Sign in to open this project.')).toBeVisible() await expect(page.getByText('Sign in to open this project.')).toBeVisible()
const [popup] = await Promise.all([page.waitForEvent('popup'), page.getByRole('button', { name: 'Sign in' }).click()]) await page.getByRole('button', { name: 'Sign in' }).click()
expect(new URL(popup.url()).hostname).toBe('hanzo.id') await expect.poll(() => went(page)).toContain('sign in')
await popup.close()
}) })
test('on a phone the chat and the work take turns', async ({ page }) => { test('on a phone the chat and the work take turns', async ({ page }) => {
+14 -6
View File
@@ -130,15 +130,23 @@ test.describe('the rail', () => {
await expect(rail(page).getByText('No runs yet.')).toBeVisible() await expect(rail(page).getByText('No runs yet.')).toBeVisible()
}) })
test('a person whose token IAM no longer honours signs in again from the rail', async ({ page }) => { test('a person whose token IAM no longer honours signs in again from the rail, in this tab', async ({ page, baseURL }) => {
await page.context().route('https://hanzo.id/**', (r) => r.fulfill({ contentType: 'text/html', body: '<title>Hanzo</title>Sign in' })) const asked: URL[] = []
await page.context().route('https://hanzo.id/**', (r) => {
const url = new URL(r.request().url())
if (url.pathname === '/v1/iam/oauth/authorize') asked.push(url)
return r.fulfill({ contentType: 'text/html', body: '<title>Hanzo</title>Sign in' })
})
const popups: string[] = []
page.on('popup', (p) => popups.push(p.url()))
await lapsed(page) await lapsed(page)
await serve(page, () => ({ status: 401, json: { status: 401, detail: 'Sign in to use this.' } })) await serve(page, () => ({ status: 401, json: { status: 401, detail: 'Sign in to use this.' } }))
await page.goto('/') await page.goto('/-/codebases')
await expect(rail(page).getByText('Sign in to see your runs.')).toBeVisible() await expect(rail(page).getByText('Sign in to see your runs.')).toBeVisible()
const [popup] = await Promise.all([page.waitForEvent('popup'), rail(page).getByRole('button', { name: 'Account: Sign in' }).click()]) await rail(page).getByRole('button', { name: 'Account: Sign in' }).click()
expect(new URL(popup.url()).hostname).toBe('hanzo.id') await page.waitForURL((u) => u.hostname === 'hanzo.id')
await popup.close() expect(asked[0]!.searchParams.get('redirect_uri')).toBe(new URL('/auth/callback', baseURL).href)
expect(popups).toEqual([])
}) })
}) })
+4 -5
View File
@@ -8,6 +8,7 @@ import type { Page } from '@playwright/test'
import { enter, held, serve, via, type Holds, type Reply, type Sent } from './cov-forge.ts' import { enter, held, serve, via, type Holds, type Reply, type Sent } from './cov-forge.ts'
import { expect, test } from './fixture.ts' import { expect, test } from './fixture.ts'
import { mounted, went } from './mount.ts'
import { ORG } from './signed.ts' import { ORG } from './signed.ts'
import { PNG } from './stubs.ts' import { PNG } from './stubs.ts'
@@ -197,13 +198,11 @@ test.describe('Artifacts', () => {
}) })
test('a visitor who takes a starter is asked to sign in', async ({ page }) => { test('a visitor who takes a starter is asked to sign in', async ({ page }) => {
await page.context().route('https://hanzo.id/**', (r) => r.fulfill({ contentType: 'text/html', body: '<title>Hanzo</title>Sign in' }))
await serve(page, ({ path }) => (path === '/v1/templates' ? { json: { data: STARTERS } } : { status: 401, json: { detail: 'Sign in' } })) await serve(page, ({ path }) => (path === '/v1/templates' ? { json: { data: STARTERS } } : { status: 401, json: { detail: 'Sign in' } }))
await page.goto('/-/artifacts') await mounted(page, { path: '-/artifacts', org: null, admin: false, person: null, signIn: true })
await expect(page.getByText('Sign in to see what your organization has built.')).toBeVisible() await expect(page.getByText('Sign in to see what your organization has built.')).toBeVisible()
const [popup] = await Promise.all([page.waitForEvent('popup'), page.getByRole('button', { name: 'Start from Circle' }).click()]) await page.getByRole('button', { name: 'Start from Circle' }).click()
expect(new URL(popup.url()).hostname).toBe('hanzo.id') await expect.poll(() => went(page)).toContain('sign in')
await popup.close()
}) })
}) })
+4 -5
View File
@@ -12,6 +12,7 @@ import type { Page } from '@playwright/test'
import { DAVE, enter, held, MEMBER, via, type Holds, type Reply, type Sent, type Who } from './cov-forge.ts' import { DAVE, enter, held, MEMBER, via, type Holds, type Reply, type Sent, type Who } from './cov-forge.ts'
import { expect, test } from './fixture.ts' import { expect, test } from './fixture.ts'
import { mounted, went } from './mount.ts'
import { ORG } from './signed.ts' import { ORG } from './signed.ts'
const MIN = 60_000 const MIN = 60_000
@@ -542,12 +543,10 @@ test.describe('Sync', () => {
} }
test('a visitor is asked to sign in', async ({ page }) => { test('a visitor is asked to sign in', async ({ page }) => {
await page.context().route('https://hanzo.id/**', (r) => r.fulfill({ contentType: 'text/html', body: '<title>Hanzo</title>Sign in' })) await mounted(page, { path: '-/sync', org: null, admin: false, person: null, signIn: true })
await page.goto('/-/sync')
await expect(page.getByText('Sign in to choose repositories the GitHub connection can read.')).toBeVisible() await expect(page.getByText('Sign in to choose repositories the GitHub connection can read.')).toBeVisible()
const [popup] = await Promise.all([page.waitForEvent('popup'), page.getByRole('button', { name: 'Sign in', exact: true }).last().click()]) await page.getByRole('button', { name: 'Sign in', exact: true }).last().click()
expect(new URL(popup.url()).hostname).toBe('hanzo.id') await expect.poll(() => went(page)).toContain('sign in')
await popup.close()
}) })
}) })
+5 -11
View File
@@ -9,6 +9,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from './fixture.ts' import { expect, test } from './fixture.ts'
import { browser, ev, finished, hold, NEXT, ORG, rig, SESSION, to } from './cov-run.ts' import { browser, ev, finished, hold, NEXT, ORG, rig, SESSION, to } from './cov-run.ts'
import { mounted, went } from './mount.ts'
import { visitor } from './stubs.ts' import { visitor } from './stubs.ts'
const desk = (p: Page) => p.getByRole('complementary', { name: 'Run details' }) const desk = (p: Page) => p.getByRole('complementary', { name: 'Run details' })
@@ -20,14 +21,8 @@ const refusal = (detail: string, code = 500) => ({ status: code, json: { detail
test('signed out, a run asks for a sign in wherever it would show something', async ({ page: p }, info) => { test('signed out, a run asks for a sign in wherever it would show something', async ({ page: p }, info) => {
await visitor(p) await visitor(p)
await p.route('**/.well-known/openid-configuration', (r) => r.fulfill({ status: 404 })) await mounted(p, { path: SESSION, org: null, admin: false, person: null, signIn: true })
// Each Sign in opens IAM's authorize page in a popup, which is left open: closing it sends the page there instead. const signs = () => went(p).then((w) => w.filter((x) => x === 'sign in'))
const signs: string[] = []
await p.context().route('https://hanzo.id/**', (r) => {
signs.push(r.request().url())
return r.fulfill({ body: 'signing in', contentType: 'text/html' })
})
await p.goto(`/${SESSION}`)
const t = p.getByLabel('Transcript') const t = p.getByLabel('Transcript')
await expect(t.getByText('Sign in to follow this run.')).toBeVisible() await expect(t.getByText('Sign in to follow this run.')).toBeVisible()
await expect(p.getByRole('button', { name: 'Manage this run' }).getByRole('heading', { name: 'Untitled run' })).toBeVisible() await expect(p.getByRole('button', { name: 'Manage this run' }).getByRole('heading', { name: 'Untitled run' })).toBeVisible()
@@ -40,12 +35,11 @@ test('signed out, a run asks for a sign in wherever it would show something', as
await expect(p.getByRole('menuitem', { name: /Copy run id/ })).toBeVisible() await expect(p.getByRole('menuitem', { name: /Copy run id/ })).toBeVisible()
await p.keyboard.press('Escape') await p.keyboard.press('Escape')
await t.getByRole('button', { name: 'Sign in' }).click() await t.getByRole('button', { name: 'Sign in' }).click()
await expect.poll(() => signs.length).toBe(1) await expect.poll(async () => (await signs()).length).toBe(1)
expect(signs[0]).toMatch(/client_id=hanzo-build/)
// The terminal says it too, and its one action signs in. // The terminal says it too, and its one action signs in.
await expect(desk(p).getByText('Sign in to follow this run.')).toBeVisible() await expect(desk(p).getByText('Sign in to follow this run.')).toBeVisible()
await desk(p).getByRole('button', { name: 'Sign in' }).click() await desk(p).getByRole('button', { name: 'Sign in' }).click()
await expect.poll(() => signs.length).toBe(2) await expect.poll(async () => (await signs()).length).toBe(2)
await tab(p, 'Git').click() await tab(p, 'Git').click()
await expect(desk(p).getByText('Sign in to see what this run pushed.')).toBeVisible() await expect(desk(p).getByText('Sign in to see what this run pushed.')).toBeVisible()
await tab(p, 'Files').click() await tab(p, 'Files').click()
+3 -9
View File
@@ -536,16 +536,10 @@ test('Account lists every organization, copies this one’s id, and switches to
await expect(page.getByRole('button', { name: 'Switch' })).toHaveCount(1) await expect(page.getByRole('button', { name: 'Switch' })).toHaveCount(1)
}) })
test('signed out, Account’s Sign in opens IAM’s sign-in', async ({ page }) => { test('signed out, Account’s Sign in asks the host to sign in', async ({ page }) => {
await page.route('**/.well-known/openid-configuration', (r) => r.fulfill({ status: 404 })) await mounted(page, { path: '-/settings/account', org: null, admin: false, person: null, signIn: true })
// IAM's pages are its own: the popup's request is kept here, and answered with nothing to draw.
await page.context().route('https://hanzo.id/**', (r) => r.fulfill({ status: 204 }))
await page.goto('/-/settings/account')
const asked = page.context().waitForEvent('request', (r) => new URL(r.url()).pathname === '/v1/iam/oauth/authorize')
await page.getByRole('button', { name: 'Sign in', exact: true }).click() await page.getByRole('button', { name: 'Sign in', exact: true }).click()
const url = new URL((await asked).url()) await expect.poll(() => went(page)).toContain('sign in')
expect(url.origin).toBe('https://hanzo.id')
expect(url.searchParams.get('client_id')).toBe('hanzo-build')
}) })
test('logging out from Account hands the session back to IAM and forgets it here', async ({ page }) => { test('logging out from Account hands the session back to IAM and forgets it here', async ({ page }) => {
+3 -1
View File
@@ -1,5 +1,5 @@
/** /**
* Opening the builder under a host other than hanzo.build's own (mount.tsx): * Opening the builder under a host other than this package's own (mount.tsx):
* the dev server's page, with its entry swapped for the mount, so the page * the dev server's page, with its entry swapped for the mount, so the page
* is served exactly as the app's is — the same stylesheets and module graph — * is served exactly as the app's is — the same stylesheets and module graph —
* and only the host differs. * and only the host differs.
@@ -18,6 +18,8 @@ export interface Mount {
token: string | null token: string | null
memberships?: string[] memberships?: string[]
theme?: Theme theme?: Theme
/** The host signs people in: its sign-in is written into `went` as 'sign in'. */
signIn?: boolean
} }
/** Opens `path` in a builder mounted by the host `m` describes. */ /** Opens `path` in a builder mounted by the host `m` describes. */
+5 -3
View File
@@ -1,8 +1,9 @@
/** /**
* The builder as another host mounts it — the Hanzo app's Dev section, or any * The builder as another host mounts it — the Hanzo app's Dev section, or any
* page that is not hanzo.build — for the specs that check what the builder does * page that is not this package's own — for the specs that check what the
* with a host that offers less: no theme to repaint, no sign-in of its own, no * builder does with a host that offers less, or with nobody signed in: no theme
* list of organizations or way to switch them. * to repaint, a sign-in only when the spec asks for one, no list of
* organizations or way to switch them.
* *
* `mounted()` (mount.ts) serves this in place of the app's own entry, and the * `mounted()` (mount.ts) serves this in place of the app's own entry, and the
* spec describes the host in `window.mount` before the page loads. Where the * spec describes the host in `window.mount` before the page loads. Where the
@@ -46,6 +47,7 @@ function Page() {
}, },
links: { github: '/-/sync', customize: '/-/customize', settings: '/-/settings', home: '/' }, links: { github: '/-/sync', customize: '/-/customize', settings: '/-/settings', home: '/' },
open: (href) => void window.went.push(href), open: (href) => void window.went.push(href),
signIn: m.signIn ? () => void window.went.push('sign in') : undefined,
} }
return ( return (
<div style={{ height: '100dvh', display: 'flex' }}> <div style={{ height: '100dvh', display: 'flex' }}>
+2 -1
View File
@@ -5,6 +5,7 @@
* stand-in served at its own address, so no card number is ever typed here. * stand-in served at its own address, so no card number is ever typed here.
*/ */
import { expect, test } from './fixture.ts' import { expect, test } from './fixture.ts'
import { mounted } from './mount.ts'
import { ORG, type Sent } from './signed.ts' import { ORG, type Sent } from './signed.ts'
import { org as platform, PLANS } from './stubs.ts' import { org as platform, PLANS } from './stubs.ts'
@@ -232,7 +233,7 @@ test('a member reads the limits, the roster and the connectors, and changes none
test('a visitor sees the plans and is asked to sign in to choose one', async ({ page }) => { test('a visitor sees the plans and is asked to sign in to choose one', async ({ page }) => {
await page.route((u) => u.pathname === '/v1/billing/plans', (r) => r.fulfill({ json: PLANS })) await page.route((u) => u.pathname === '/v1/billing/plans', (r) => r.fulfill({ json: PLANS }))
await page.goto('/-/plans') await mounted(page, { path: '-/plans', org: null, admin: false, person: null, signIn: true })
await expect(page.getByText('Sign in to see the plan you are on.')).toBeVisible() await expect(page.getByText('Sign in to see the plan you are on.')).toBeVisible()
await expect(page.getByLabel('Max plan', { exact: true }).getByText('Sign in to choose a plan.')).toBeVisible() await expect(page.getByLabel('Max plan', { exact: true }).getByText('Sign in to choose a plan.')).toBeVisible()
await expect(page.getByRole('button', { name: 'Current plan' })).toHaveCount(0) await expect(page.getByRole('button', { name: 'Current plan' })).toHaveCount(0)
+5 -14
View File
@@ -1,7 +1,8 @@
/** /**
* Every screen and state of the builder at four sizes — a phone, a tablet, a * Every screen and state of the builder at four sizes — a phone, a tablet, a
* laptop and a desktop — signed in as an org admin against the stubs the other * laptop and a desktop — signed in as an org admin against the stubs the other
* specs drive (stubs.ts) and signed out against what the platform tells anyone. * specs drive (stubs.ts). There is no signed-out page to draw: a visitor is
* sent to sign in (app/root.tsx).
* *
* Each one draws its heading, fits its window (fixture.ts `cramped`: nothing * Each one draws its heading, fits its window (fixture.ts `cramped`: nothing
* cut off or sideways, no control on another), throws nothing, and is saved at * cut off or sideways, no control on another), throws nothing, and is saved at
@@ -28,8 +29,8 @@ interface State {
/** The file name under screens/<size>/. */ /** The file name under screens/<size>/. */
name: string name: string
path: string path: string
/** The platform the page talks to; signed out when left out. */ /** The platform the page talks to, signed in. */
platform?: (page: Page) => Promise<unknown> platform: (page: Page) => Promise<unknown>
/** What the screen's heading says. */ /** What the screen's heading says. */
says: RegExp says: RegExp
/** What brings the screen to this state, on this size. */ /** What brings the screen to this state, on this size. */
@@ -102,15 +103,6 @@ const HEADINGS: Record<Section, [Platform, RegExp]> = {
const CUSTOMIZE = /What the agent brings to a run/ const CUSTOMIZE = /What the agent brings to a run/
const screens = Object.entries(SCREENS) as [Screen, [Platform, RegExp]][] const screens = Object.entries(SCREENS) as [Screen, [Platform, RegExp]][]
const signedOut: State[] = [
{ name: 'out-new', path: '/', says: /What.s up next\?/ },
...screens.map(([screen, [, says]]) => ({ name: `out-${screen}`, path: `/-/${screen}`, says })),
...SECTIONS.map((section) => ({ name: `out-settings-${section}`, path: `/-/settings/${section}`, says: /^Sign in to / })),
...TABS.map((tab) => ({ name: `out-customize-${tab}`, path: `/-/customize/${tab}`, says: CUSTOMIZE })),
{ name: 'out-run', path: RUN, says: /Sign in to follow this run/ },
{ name: 'out-project', path: '/shop', says: /Sign in to open this project/ },
]
const signedIn: State[] = [ const signedIn: State[] = [
{ name: 'new', path: '/', platform: stub.landing, says: /What.s up next\?/ }, { name: 'new', path: '/', platform: stub.landing, says: /What.s up next\?/ },
{ name: 'new-place', path: '/', platform: stub.landing, says: /What.s up next\?/, act: (p) => p.getByRole('button', { name: 'Where the run runs: Cloud' }).click(), over: true }, { name: 'new-place', path: '/', platform: stub.landing, says: /What.s up next\?/, act: (p) => p.getByRole('button', { name: 'Where the run runs: Cloud' }).click(), over: true },
@@ -182,7 +174,6 @@ const project: State[] = [
] ]
const STATES: State[] = [ const STATES: State[] = [
...signedOut,
...signedIn, ...signedIn,
...run('running'), ...run('running'),
...run('done'), ...run('done'),
@@ -219,7 +210,7 @@ for (const [size, box] of Object.entries(SIZES) as [Size, { width: number; heigh
test.fail(size === 'phone' && DRAWN_OVER.includes(s.name), 'the drawer’s close button is off the window (@hanzo/ui)') test.fail(size === 'phone' && DRAWN_OVER.includes(s.name), 'the drawer’s close button is off the window (@hanzo/ui)')
const errors: string[] = [] const errors: string[] = []
page.on('pageerror', (e) => errors.push(e.message)) page.on('pageerror', (e) => errors.push(e.message))
await (s.platform ?? stub.visitor)(page) await s.platform(page)
await page.goto(s.path) await page.goto(s.path)
await expect(page.getByText(s.says).filter({ visible: true }).first()).toBeVisible() await expect(page.getByText(s.says).filter({ visible: true }).first()).toBeVisible()
await s.act?.(page, size) await s.act?.(page, size)
-142
View File
@@ -1,142 +0,0 @@
/**
* The site in a browser: every screen draws, stays on this origin, and never
* asks platform.hanzo.ai for anything. Signed out, so it checks what a visitor
* sees — the product's header over each screen, not the rail; the native MCP
* servers and the skills catalogue are public and are read live.
*/
import type { Page } from '@playwright/test'
import { cramped, expect, test } from './fixture.ts'
const SCREENS: [string, string, RegExp][] = [
['new', '/', /What.s up next\?/],
['automations', '/-/automations', /New automation/],
['codebases', '/-/codebases', /Create and browse this organization.s repositories/],
['sync', '/-/sync', /Select repositories/],
['projects', '/-/projects', /Boards on the forge/],
['issues', '/-/issues', /Open work across every board/],
['artifacts', '/-/artifacts', /What this organization has built/],
['templates', '/-/templates', /Start from a working app/],
['mcp', '/-/mcp', /The fleet.s own servers/],
['customize', '/-/customize', /What the agent brings to a run/],
['run', '/sess_0992f90537264a6b154ebff799f38e1c', /Sign in to follow this run/],
]
const BAR = ['Download', 'Resources', 'Enterprise', 'Pricing', 'Log in', 'Sign up']
/** The IAM client this host signs in as: hanzo.app is the Hanzo App's. */
const client = (base: string) => (new URL(base).hostname === 'hanzo.app' ? 'hanzo-app' : 'hanzo-build')
/** Everything the page asked for and every error it threw, for the whole test. */
function watch(page: Page) {
const hosts = new Set<string>()
const errors: string[] = []
page.on('request', (r) => hosts.add(new URL(r.url()).hostname))
page.on('pageerror', (e) => errors.push(e.message))
return { hosts, errors }
}
for (const [name, path, says] of SCREENS) {
test(`${name} draws on this origin`, async ({ page, baseURL }, info) => {
const seen = watch(page)
await page.goto(path)
await expect(page.getByText(says).first()).toBeVisible()
const bar = page.getByRole('banner')
for (const label of BAR) await expect(bar.getByText(label, { exact: true })).toBeVisible()
await expect(page.getByRole('navigation', { name: 'Runs' })).toHaveCount(0)
await page.waitForLoadState('networkidle')
expect(new URL(page.url()).origin).toBe(new URL(baseURL!).origin)
expect([...seen.hosts]).not.toContain('platform.hanzo.ai')
expect(seen.errors).toEqual([])
await page.screenshot({ path: info.outputPath(`${name}.png`) })
})
}
test('the header leads to the product’s pages, and both doors open IAM beside the page', async ({ page, baseURL }) => {
const seen = watch(page)
await page.goto('/')
const bar = page.getByRole('banner')
const site = bar.getByRole('navigation', { name: 'Site' })
for (const [label, href] of [
['Download', '/#features'],
['Enterprise', 'https://hanzo.ai/enterprise'],
['Pricing', 'https://hanzo.ai/pricing'],
]) {
await expect(site.getByRole('link', { name: label })).toHaveAttribute('href', href)
}
await site.getByRole('button', { name: 'Resources' }).click()
const resources = page.getByRole('menu', { name: 'Resources' })
for (const label of ['Docs', 'Blog', 'Customers', 'Learn', 'Support']) await expect(resources.getByText(label, { exact: true })).toBeVisible()
await page.keyboard.press('Escape')
await expect(resources).toHaveCount(0)
for (const door of ['Log in', 'Sign up']) {
const [popup] = await Promise.all([page.waitForEvent('popup'), bar.getByRole('button', { name: door, exact: true }).click()])
expect(popup.url()).toMatch(/^https:\/\/hanzo\.id\//)
expect(popup.url()).toContain(`client_id=${client(baseURL!)}`)
await popup.close()
}
expect(new URL(page.url()).origin).toBe(new URL(baseURL!).origin)
expect([...seen.hosts]).not.toContain('platform.hanzo.ai')
})
test('the name at the top left when signed out', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('button', { name: 'Hanzo Build', exact: true })).toHaveCount(1)
await expect(page.getByRole('banner').getByText('Log in', { exact: true })).toBeVisible()
})
test('settings is a page of this site', async ({ page, baseURL }) => {
await page.goto('/-/codebases')
await page.getByText('Settings', { exact: true }).first().click()
await expect(page).toHaveURL(new URL('/-/settings/environments', baseURL).href)
await page.goto('/-/settings')
await expect(page.getByText('Sign in to see your settings.')).toBeVisible()
expect(new URL(page.url()).origin).toBe(new URL(baseURL!).origin)
})
test('Connectors lists the native servers to a visitor', async ({ page }, info) => {
await page.goto('/-/customize/connectors')
const count = page.getByText(/^\d+ servers · \d+ operations$/)
await expect(count).toBeVisible()
const servers = Number((await count.innerText()).split(' ')[0])
expect(servers).toBeGreaterThan(100)
await page.getByLabel('Search connectors').fill('git')
await expect(page.getByLabel('git', { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath('mcp-git.png') })
})
test('a run hides and shows its side pane', async ({ page }) => {
await page.goto('/sess_0992f90537264a6b154ebff799f38e1c')
await page.getByLabel('Hide the side pane').click()
await expect(page.getByText('Subscriptions', { exact: true })).toHaveCount(0)
await page.getByLabel('Show the side pane').click()
await expect(page.getByText('Subscriptions', { exact: true })).toBeVisible()
})
test('a run shows its codebase’s environment beside it', async ({ page }, info) => {
await page.goto('/sess_0992f90537264a6b154ebff799f38e1c')
await page.getByLabel('Environment', { exact: true }).click()
await expect(page.getByText('Sign in to see this codebase’s environment.')).toBeVisible()
await expect(page.getByText('This run', { exact: true })).toBeVisible()
await page.screenshot({ path: info.outputPath('environment.png') })
})
test('a phone gets every screen whole, with the header folded into one menu', async ({ page }, info) => {
await page.setViewportSize({ width: 390, height: 844 })
for (const [name, path, says] of SCREENS) {
await page.goto(path)
await expect(page.getByText(says).first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible()
expect(await cramped(page), name).toEqual([])
await page.screenshot({ path: info.outputPath(`phone-${name}.png`) })
}
await page.getByRole('button', { name: 'Menu' }).click()
const menu = page.getByRole('menu', { name: 'Menu' })
for (const label of ['Download', 'Enterprise', 'Pricing', 'Docs', 'Sign up']) await expect(menu.getByText(label, { exact: true })).toBeVisible()
})
test('a run asks a visitor to sign in, not for an API key', async ({ page }) => {
await page.goto('/sess_0992f90537264a6b154ebff799f38e1c')
await expect(page.getByText('Sign in to follow this run.').first()).toBeVisible()
await expect(page.getByText(/Authorization: Bearer/)).toHaveCount(0)
})
-108
View File
@@ -1,108 +0,0 @@
/**
* A visitor: the product's header over New, at each width, and every way out of
* it. hanzo.ai answers 204, so a click that leaves is caught as the navigation
* it asks for and the measured page stays; hanzo.id answers a bare page, so each
* door is the popup it opens.
*/
import type { Page } from '@playwright/test'
import { cramped, expect, test } from './fixture.ts'
import { serve } from './signed.ts'
async function visit(page: Page) {
await page.context().route('https://hanzo.id/**', (r) => r.fulfill({ contentType: 'text/html', body: '<title>Hanzo</title>Sign in' }))
await page.route(/^https:\/\/(hanzo\.ai|docs\.hanzo\.ai)\//, (r) => r.fulfill({ status: 204 }))
await serve(page, () => undefined)
await page.goto('/')
await expect(page.getByText('What’s up next?')).toBeVisible()
}
/** The address a click asks for, where the page is not let go. */
async function leaves(page: Page, click: () => Promise<void>) {
const [nav] = await Promise.all([page.waitForRequest((r) => r.isNavigationRequest() && r.url().startsWith('https://')), click()])
return nav.url()
}
async function door(page: Page, click: () => Promise<void>) {
const [popup] = await Promise.all([page.waitForEvent('popup'), click()])
const url = new URL(popup.url())
await popup.close()
return url
}
test('a visitor sees the header, not the rail, and each resource leaves for its page', async ({ page }, info) => {
await page.setViewportSize({ width: 1366, height: 768 })
await visit(page)
const bar = page.getByRole('banner')
await expect(page.getByRole('navigation', { name: 'Runs' })).toHaveCount(0)
await expect(bar.getByRole('button', { name: 'Menu' })).toBeHidden()
expect(await cramped(page)).toEqual([])
await page.screenshot({ path: info.outputPath('visit-laptop.png') })
for (const [label, href] of [
['Docs', 'https://docs.hanzo.ai/'],
['Blog', 'https://hanzo.ai/blog'],
['Customers', 'https://hanzo.ai/customers'],
['Learn', 'https://hanzo.ai/learn'],
['Support', 'https://hanzo.ai/support'],
]) {
await bar.getByRole('button', { name: 'Resources' }).click()
const menu = page.getByRole('menu', { name: 'Resources' })
expect(await leaves(page, () => menu.getByText(label, { exact: true }).click())).toBe(href)
await page.keyboard.press('Escape')
}
})
test('both doors in open IAM beside the page, and the draft stays', async ({ page }) => {
await visit(page)
await page.getByRole('textbox').first().fill('a landing page for a bakery')
const bar = page.getByRole('banner')
for (const name of ['Log in', 'Sign up']) {
const url = await door(page, () => bar.getByRole('button', { name, exact: true }).click())
expect(url.hostname).toBe('hanzo.id')
expect(url.searchParams.get('client_id')).toBe('hanzo-build')
}
await expect(page.getByRole('textbox').first()).toHaveValue('a landing page for a bakery')
})
test('the name leads back to New from anywhere a visitor opened', async ({ page }) => {
await visit(page)
await page.goto('/-/templates')
await expect(page.getByRole('banner')).toBeVisible()
await page.getByRole('button', { name: 'Hanzo Build', exact: true }).click()
await expect(page).toHaveURL(/\/$/)
await expect(page.getByText('What’s up next?')).toBeVisible()
})
test('on a phone the places fold into one menu, which leaves and signs in', async ({ page }, info) => {
await page.setViewportSize({ width: 390, height: 844 })
await visit(page)
const bar = page.getByRole('banner')
await expect(bar.getByRole('navigation', { name: 'Site' })).toBeHidden()
await expect(bar.getByRole('button', { name: 'Sign up', exact: true })).toBeHidden()
expect(await cramped(page)).toEqual([])
await bar.getByRole('button', { name: 'Menu' }).click()
const menu = page.getByRole('menu', { name: 'Menu' })
await page.screenshot({ path: info.outputPath('visit-phone-menu.png') })
await menu.getByText('Download', { exact: true }).click()
await expect(page.getByRole('heading', { name: 'Hanzo Dev' })).toBeVisible()
await page.keyboard.press('Escape')
await bar.getByRole('button', { name: 'Menu' }).click()
expect(await leaves(page, () => page.getByRole('menu', { name: 'Menu' }).getByText('Docs', { exact: true }).click())).toBe('https://docs.hanzo.ai/')
await page.keyboard.press('Escape')
await bar.getByRole('button', { name: 'Menu' }).click()
const url = await door(page, () => page.getByRole('menu', { name: 'Menu' }).getByText('Sign up', { exact: true }).click())
expect(url.hostname).toBe('hanzo.id')
await expect(page.getByRole('menu', { name: 'Menu' })).toHaveCount(0)
})
test('sending as a visitor asks them to sign in first', async ({ page }) => {
await visit(page)
const box = page.getByRole('textbox').first()
await box.fill('fix the flaky test')
const url = await door(page, () => box.press('Enter'))
expect(url.hostname).toBe('hanzo.id')
})
+56 -10
View File
@@ -61,12 +61,48 @@ const FIRST: Kept = { repo: null, branch: '', place: '', mode: 'build', model: E
const COLUMN = 768 const COLUMN = 768
/** /**
* What was typed here and not sent, for as long as this document lives. Signing * What was typed here and not sent, kept in this tab.
* in swaps a visitor's page for the rail, which draws New again in another *
* place; the popup keeps the document so the sentence can come along, and this * Signing in leaves the page for hanzo.id and comes back (app/enter.ts), and the
* is where it waits. * sentence is still in the composer after. A draft its person SENT while signed
* out is sent on the return: pressing Send was the ask, and the sign-in stood
* between it and the run. Only this page writes `sent`, so no link can start a
* run on somebody's behalf — an address carrying words only fills the composer.
*
* sessionStorage, because a draft belongs to the tab it was typed in and not to
* the next person at this browser; a browser that refuses storage keeps it for
* the life of the page, as before.
*/ */
let unsent: { draft: string; files: Attached[] } = { draft: '', files: [] } export interface Unsent {
draft: string
files: Attached[]
sent?: boolean
}
const UNSENT = 'hanzo.build.unsent'
let held: Unsent = { draft: '', files: [] }
function unsent(): Unsent {
try {
const v = JSON.parse(window.sessionStorage.getItem(UNSENT) ?? 'null') as Unsent | null
if (v && typeof v.draft === 'string' && Array.isArray(v.files)) return v
} catch {
/* a browser that refuses storage, or a value that is not ours */
}
return held
}
/** Hold what New has unsent, or forget it once there is nothing. */
export function hold(u: Unsent): void {
held = u
try {
if (!u.draft && !u.files.length) window.sessionStorage.removeItem(UNSENT)
else window.sessionStorage.setItem(UNSENT, JSON.stringify(u))
} catch {
/* kept for the life of the page */
}
}
export function Landing({ onStarted }: { onStarted: (session: string) => void }) { export function Landing({ onStarted }: { onStarted: (session: string) => void }) {
const host = useHost() const host = useHost()
@@ -78,11 +114,10 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
const kept: Kept = stored ?? { ...FIRST, ...prefs.code } const kept: Kept = stored ?? { ...FIRST, ...prefs.code }
const set = (patch: Partial<Kept>) => keep({ ...kept, ...patch }) const set = (patch: Partial<Kept>) => keep({ ...kept, ...patch })
const [draft, setDraft] = useState(kept.ask || unsent.draft) const [was] = useState(unsent)
const [files, setFiles] = useState<Attached[]>(unsent.files) const [draft, setDraft] = useState(kept.ask || was.draft)
useEffect(() => { const [files, setFiles] = useState<Attached[]>(was.files)
unsent = { draft, files } useEffect(() => hold({ draft, files }), [draft, files])
}, [draft, files])
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [note, setNote] = useState('') const [note, setNote] = useState('')
const [adding, setAdding] = useState<Source | null>(null) const [adding, setAdding] = useState<Source | null>(null)
@@ -133,6 +168,7 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
// The composer sends only a draft with words in it, and not while a send is out. // The composer sends only a draft with words in it, and not while a send is out.
const send = async () => { const send = async () => {
if (!signed) { if (!signed) {
hold({ draft, files, sent: true })
host.signIn?.() host.signIn?.()
return return
} }
@@ -166,6 +202,16 @@ export function Landing({ onStarted }: { onStarted: (session: string) => void })
} }
} }
// A draft sent while signed out goes once its person is in, and only once.
const owed = useRef(was.sent === true)
useEffect(() => {
if (!signed || !owed.current || !draft.trim()) return
owed.current = false
void send()
// Sent once, on the sign-in that it waited for.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [signed])
/** Start the agent that finds this codebase's environment, and open it. A refusal is the dialog's to say. */ /** Start the agent that finds this codebase's environment, and open it. A refusal is the dialog's to say. */
const setup = async () => { const setup = async () => {
const run = await start(t, { prompt: SETUP, repo: codebase, mode: 'setup' }) const run = await start(t, { prompt: SETUP, repo: codebase, mode: 'setup' })
-116
View File
@@ -1,116 +0,0 @@
/**
* What a visitor reads under New on hanzo.build: the same agent on their own
* computer, one line to install, and what it brings to a run. Each card names
* something this page or the CLI does today, and nothing it does not.
*/
import { SizableText, XStack, YStack } from '@hanzo/gui'
import { Check, Copy, GitPullRequest, LayoutTemplate, ListChecks, MonitorPlay, Plug, Server, Sparkles, Workflow } from '@hanzogui/lucide-icons-2'
import { Button } from '@hanzo/ui'
import { useState, type ReactNode } from 'react'
import { DOCS } from './foot.tsx'
export const INSTALL = 'curl -fsSL hanzo.sh | sh'
const FEATURES: { icon: ReactNode; title: string; body: string }[] = [
{ icon: <Sparkles size={18} />, title: 'Skills', body: 'Teach it a workflow once. A skill loads when a task matches, or when you call it by name.' },
{ icon: <ListChecks size={18} />, title: 'Plan first', body: 'Start a run in plan mode. Nothing is built until you approve the plan.' },
{ icon: <Plug size={18} />, title: 'Connectors and plugins', body: 'MCP servers, skills and agents behind one install. Your org’s credentials reach a run through a relay, never the sandbox.' },
{ icon: <Server size={18} />, title: 'Environments', body: 'Point it at a repository. An agent works out install and start, and every later run begins from that setup.' },
{ icon: <MonitorPlay size={18} />, title: 'Desktop and terminal', body: 'Watch each run’s browser and shell live, beside the conversation.' },
{ icon: <GitPullRequest size={18} />, title: 'Review', body: 'Every run ends as a diff on its own branch, with commits and a pull request to review.' },
{ icon: <LayoutTemplate size={18} />, title: 'Templates to live', body: 'Start from a working app and publish it to a live address.' },
{ icon: <Workflow size={18} />, title: 'Automations', body: 'Hand it repeating work, and it runs on your schedule.' },
]
/** One line to copy, with a button that copies it. */
function Line({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const copy = () => {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<XStack items="center" gap="$3" px="$4" height={52} rounded="$4" borderWidth={1} borderColor="$borderColor" bg="$panel" width="100%" maxW={560}>
<SizableText size="$3" color="$soft" fontFamily="$mono" aria-hidden>
$
</SizableText>
<SizableText flex={1} size="$3" color="$ink" fontFamily="$mono" numberOfLines={1}>
{text}
</SizableText>
<Button variant="ghost" size="icon-sm" aria-label={copied ? 'Copied' : 'Copy the install command'} onPress={copy}>
{copied ? <Check size={16} /> : <Copy size={16} />}
</Button>
</XStack>
)
}
function Card({ icon, title, body }: { icon: ReactNode; title: string; body: string }) {
return (
<YStack gap="$2" p="$5" rounded="$4" borderWidth={1} borderColor="$borderColor" bg="$panel" flex={1} minW={240}>
<XStack items="center" gap="$2.5">
{icon}
<SizableText size="$4" fontWeight="600" color="$ink">
{title}
</SizableText>
</XStack>
<SizableText size="$3" color="$soft">
{body}
</SizableText>
</YStack>
)
}
export function Pitch({ onStart }: { onStart: () => void }) {
return (
<YStack items="center" px="$4" pb="$12" gap="$12">
<YStack id="features" width="100%" maxW={1080} items="center" gap="$5" pt="$6">
<SizableText size="$2" color="$soft" fontFamily="$mono">
Powered by Zen 6 · routed by Enso
</SizableText>
<SizableText role="heading" aria-level={2} size="$9" color="$ink" style={{ textAlign: 'center' }}>
Hanzo Dev
</SizableText>
<SizableText size="$4" color="$soft" maxW={620} style={{ textAlign: 'center' }}>
The agent. It runs in your terminal, on any codebase, in any language. This page is that same agent in the browser.
</SizableText>
<Line text={INSTALL} />
<SizableText size="$2" color="$soft" fontFamily="$mono">
dev opens it. dev "fix the failing test" hands it a task.
</SizableText>
<a href={DOCS} style={{ color: 'inherit', textDecoration: 'none' }}>
<XStack items="center" height={34} px="$3" rounded="$3" borderWidth={1} borderColor="$borderColor" hoverStyle={{ bg: '$hover' }}>
<SizableText size="$3" color="$ink">
Read the docs
</SizableText>
</XStack>
</a>
</YStack>
<YStack width="100%" maxW={1080} gap="$5">
<SizableText role="heading" aria-level={2} size="$7" color="$ink">
Everything a run needs
</SizableText>
<XStack flexWrap="wrap" gap="$3">
{FEATURES.map((f) => (
<Card key={f.title} {...f} />
))}
</XStack>
</YStack>
<YStack width="100%" maxW={1080} items="center" gap="$4" py="$8" rounded="$6" borderWidth={1} borderColor="$borderColor" bg="$panel">
<SizableText role="heading" aria-level={2} size="$7" color="$ink" style={{ textAlign: 'center' }}>
Say what you want built.
</SizableText>
<SizableText size="$3" color="$soft" style={{ textAlign: 'center' }} px="$4">
In the browser here, or in your terminal.
</SizableText>
<Button variant="primary" size="sm" onPress={onStart}>
Start building
</Button>
</YStack>
</YStack>
)
}
-134
View File
@@ -1,134 +0,0 @@
/**
* The page to a visitor: the product's header over New.
*
* Signed out there are no runs to list, so the rail gives way to a header —
* Download (Hanzo Dev, under New, pitch.tsx), Resources, Enterprise, Pricing — and the two
* doors in, which are one door: IAM's page offers both. New stays the page, and
* sending from it asks the visitor to sign in with the sentence kept
* (landing.tsx). Below md the four places fold into one menu beside Log in.
*/
import { Popover, SizableText, XStack, YStack } from '@hanzo/gui'
import { ChevronDown, Menu } from '@hanzogui/lucide-icons-2'
import { Button } from '@hanzo/ui'
import { MenuLabel, MenuRow, MenuRule } from '@hanzo/ui/product'
import { useState, type ReactNode } from 'react'
import { useHost } from './host.tsx'
const SITE = 'https://hanzo.ai'
export const PLACES = [
{ label: 'Download', href: '/#features' },
{ label: 'Enterprise', href: `${SITE}/enterprise` },
{ label: 'Pricing', href: `${SITE}/pricing` },
] as const
export const RESOURCES = [
{ label: 'Docs', href: 'https://docs.hanzo.ai' },
{ label: 'Blog', href: `${SITE}/blog` },
{ label: 'Customers', href: `${SITE}/customers` },
{ label: 'Learn', href: `${SITE}/learn` },
{ label: 'Support', href: `${SITE}/support` },
] as const
const leave = (href: string) => window.location.assign(href)
/** One place in the header: an address, drawn as text. */
function Place({ href, children }: { href: string; children: ReactNode }) {
return (
<a href={href} style={{ color: 'inherit', textDecoration: 'none' }}>
<XStack items="center" height={34} px="$2.5" rounded="$3" hoverStyle={{ bg: '$hover' }}>
<SizableText size="$3" color="$ink">
{children}
</SizableText>
</XStack>
</a>
)
}
function Resources() {
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen} placement="bottom-start" offset={6}>
<Popover.Trigger asChild>
<XStack render="button" aria-label="Resources" aria-expanded={open} items="center" gap="$1" height={34} px="$2.5" rounded="$3" hoverStyle={{ bg: '$hover' }}>
<SizableText size="$3" color="$ink">
Resources
</SizableText>
<ChevronDown size={14} color="$soft" />
</XStack>
</Popover.Trigger>
<Popover.Content role="menu" aria-label="Resources" items="stretch" borderWidth={1} elevation="$2" p="$1" width={220} bg="$panel" borderColor="$borderColor">
<YStack gap="$1">
{RESOURCES.map((r) => (
<MenuRow key={r.label} label={r.label} onPress={() => leave(r.href)} />
))}
</YStack>
</Popover.Content>
</Popover>
)
}
/** Below md: every place, the resources and the door, in one menu. */
function Fold({ onEnter }: { onEnter: () => void }) {
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen} placement="bottom-end" offset={6} stayInFrame>
<Popover.Trigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="Menu">
<Menu size={18} />
</Button>
</Popover.Trigger>
<Popover.Content role="menu" aria-label="Menu" items="stretch" borderWidth={1} elevation="$2" p="$1" width={260} bg="$panel" borderColor="$borderColor">
<YStack gap="$1">
{PLACES.map((p) => (
<MenuRow key={p.label} label={p.label} onPress={() => leave(p.href)} />
))}
<MenuRule />
<MenuLabel>Resources</MenuLabel>
{RESOURCES.map((r) => (
<MenuRow key={r.label} label={r.label} onPress={() => leave(r.href)} />
))}
<MenuRule />
<MenuRow
label="Sign up"
onPress={() => {
setOpen(false)
onEnter()
}}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
/** The header a visitor sees. `name` is the wordmark the rail shows once signed in. */
export function Bar({ name }: { name: ReactNode }) {
const host = useHost()
const enter = () => host.signIn?.()
const [features, enterprise, pricing] = PLACES
return (
<XStack role="banner" height={60} px="$4" gap="$2" items="center" shrink={0} $md={{ px: '$6' }}>
{name}
<XStack role="navigation" aria-label="Site" items="center" gap="$1" ml="$4" $max-md={{ display: 'none' }}>
<Place href={features.href}>{features.label}</Place>
<Resources />
<Place href={enterprise.href}>{enterprise.label}</Place>
<Place href={pricing.href}>{pricing.label}</Place>
</XStack>
<XStack flex={1} />
<Button variant="ghost" size="sm" onPress={enter}>
Log in
</Button>
<XStack $max-md={{ display: 'none' }}>
<Button size="sm" variant="primary" onPress={enter}>
Sign up
</Button>
</XStack>
<XStack $md={{ display: 'none' }}>
<Fold onEnter={enter} />
</XStack>
</XStack>
)
}