mirror of
https://github.com/hanzoai/build.git
synced 2026-10-23 02:18:08 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.')
|
||||||
|
|||||||
@@ -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,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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user