Files
zeekayandHanzo Dev 9bbdc64ee0 One /dev: the builder is platform.hanzo.ai/dev
This export carried a second copy of the builder at /dev — the composer,
a run's transcript and a project pane (app/(app)/dev and
components/workspace/{Build,Session,project}.tsx), plus DevStudio.tsx and
enter.ts, which nothing else used. They are deleted, and dev.hanzo.ai is no
longer published from this export.

Every link to the builder is DEV or dev(ref) from lib/host.ts. dev() carries
a run id or a project slug into the path only when it is exactly one, so no
caller can make it name another address. The shell's Dev tile, Projects and
Runs open it there; /code forwards there. The edge's permanent redirects for
hanzo.ai/dev, /code, dev.hanzo.ai and hanzo.app live in universe.

Gates and specs that drove the old /dev drive /chat where they tested the
shared shell, and are removed where the builder itself was the subject.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 15:53:59 -07:00

485 lines
22 KiB
TypeScript

// The app surface, shared. Everything the four live-surface specs need to
// address /chat, /dev and the rooms between them without guessing.
//
// These specs drive a RUNNING dev server, not the export, so they live in the
// `chromium` project and cannot gate a build — same rule the older specs follow.
//
// BASE_URL=http://localhost:3000 pnpm exec playwright test --project=chromium e2e/agentic-coding.spec.ts
//
// THREE THINGS ABOUT THIS SURFACE DEFEAT THE OBVIOUS SPEC, and every helper
// here exists because one of them was measured:
//
// 1. gui buttons are NOT disabled, they are `aria-disabled`. `Button` renders
// `<button role="button" aria-disabled="true">` with no `disabled`
// attribute, so Playwright's actionability check reports "element is not
// enabled" and click() burns its full timeout instead of failing fast.
// Read the attribute; never trust `isDisabled()` to mean what it says.
//
// 2. `fill()` does not reach the composer. gui's Textarea is controlled
// through `onChangeText`; a fill sets the DOM value and the React draft
// stays empty, so the send button never enables. Type the keys.
//
// 3. The room is INERT until the account state resolves. `Chat.tsx` computes
// `anonymous = hydrated && !isLoading && !isAuthenticated`, and until that
// settles the model slot reads "Select model" and the composer refuses
// input. Waiting for `load` is not waiting for the app.
import { expect, type Page, type Locator } from '@playwright/test'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
/**
* The app runs on a dev server, which is not where the marketing export is
* served. One variable still: set BASE_URL and everything follows it.
*/
export const BASE = process.env.BASE_URL ?? 'http://localhost:3000'
/** Where a reader looks after a failure. One directory, one file per step. */
export const SHOTS = process.env.E2E_SHOTS ?? '/home/z/Desktop/e2e-coverage'
export const RAIL = '[data-slot="sidebar"]'
export const BOARD = '[data-slot="agent-board"]'
export const RUN_ROW = '[data-slot="run-row"]'
/** The rail and the room together — what a surface spanning both is scoped to. */
export const SHELL = '[data-slot="workspace"]'
/**
* The composer is addressed BY ROLE, not by slot.
*
* It used to name itself `data-slot="composer"` with a `composer-send` button
* beside it, and both are gone: `/chat` became a channel surface whose field is
* a plain textarea placeholdered `Message # <channel>` and whose send is an
* icon button with an accessible name. A role and an accessible name are what
* the room owes a reader anyway, so they are the more durable address — this
* spec has now watched the slot vocabulary change under it three times.
*
* SCOPED TO THE ROOM, because "the first textbox on the page" stopped being the
* composer the day the sidebar grew a filter. That field is `Filter sidebar`,
* it sits outside `main`, and it comes FIRST — so the build surface asserted
* its placeholder and read `Filter`, and a starter typed its sentence into the
* sidebar. The room owns its composer; the frame around it owns other fields,
* and there will be more of them.
*/
export const field = (page: Page): Locator => page.locator('main').getByRole('textbox').first()
export const sendButton = (page: Page): Locator => page.getByRole('button', { name: 'Send', exact: true }).first()
/**
* Every room the app publishes — the set a rung is allowed to land in.
*
* This is a CEILING, not a checklist. HOW MANY of these the rail offers is a
* live product decision and must not be asserted: within one afternoon the
* sixth rung was renamed (`Code` → `Dev`) and then the rail was cut to two
* rooms outright ("the rail is Chat and Dev, the two the landing offers"). A
* spec that demanded six reported a broken rail twice, and both times the rail
* was fine and the specification had moved.
*
* The labels are not listed for the same reason. What a rung owes a reader is
* mechanical and does not move: press it, ARRIVE somewhere real, and be marked
* as where you are. That is what the spec holds.
*/
export const ROUTES = ['/chat', '/inbox', '/work', '/board', '/drive'] as const
/**
* Controls that mark a position — the CANDIDATES for "room rung".
*
* `aria-current` is the only structural signal on this rail: a control that
* marks a place carries `"true"` where you are and `"false"` elsewhere, and one
* that is not a place carries none at all.
*
* It returns candidates rather than rooms, and the difference is load-bearing:
* in-page tabs mark themselves exactly the same way. Measured on `/dev`, the
* four marked controls were `Chat`, `Dev`, `Build` and `CLI` — the first two
* navigate and the last two are tabs inside the room you are already in, and
* nothing in the markup separates them. Only pressing one does, which is why
* the caller classifies by where it lands.
*
* Two narrower anchors were tried and both broke within the hour: the rung
* column is NOT inside `[data-slot="sidebar"]` (a separate, unnamed column, so
* scoping to the rail returned three buttons out of eight), and anchoring on
* the `Customize` rung failed the moment that rung was deleted. A label is not
* a structure.
*/
export async function rungs(page: Page): Promise<string[]> {
await drawer(page)
const marked = page.locator('button[aria-current], [role="button"][aria-current]')
await expect(marked.first(), 'the rail must mark the position a reader is in').toBeAttached({ timeout: 30_000 })
// Take each marked control's SIBLING GROUP, not the marked controls
// themselves, because the two shells disagree about what to mark. One writes
// `aria-current` on every rung (`"true"` on one, `"false"` on the rest); the
// other writes it ONLY on the active rung and leaves the others bare. Reading
// the marked elements alone therefore returned all six rooms on one shell and
// exactly one — the room you were already standing in — on the other, which
// reported a rail that reaches nowhere.
//
// The group is a superset on both, and the caller narrows it the only way
// that is reliable anyway: by where pressing one lands.
return page.evaluate(() => {
const names: string[] = []
const seen = new Set<Element>()
for (const el of document.querySelectorAll('button[aria-current], [role="button"][aria-current]')) {
const parent = el.parentElement
if (!parent || seen.has(parent)) continue
seen.add(parent)
for (const child of parent.children) {
if (child.tagName !== 'BUTTON' && child.getAttribute('role') !== 'button') continue
const name = (child.getAttribute('aria-label') || child.textContent || '').trim()
if (name && !names.includes(name)) names.push(name)
}
}
return names
})
}
/**
* The gateway the app itself calls, read from the same variable the app reads.
* A spec that hard-codes a second copy of this is a spec that can agree with
* nothing.
*/
export function api(): string {
return env('NEXT_PUBLIC_HANZO_API_URL') ?? 'https://api.hanzo.ai'
}
/**
* The credential the signed-out client carries (`hooks/useAi.ts`): the org's
* IAM-minted publishable key. It is read from the environment at run time and
* never written down here — a spec is a published file.
*
* It is read from the SAME variable the app reads, so a spec cannot pass while
* the page is carrying something else.
*
* Absent is a legitimate state, not a failure: without it the gateway answers
* 403 and the board's honest empty state is the correct thing to assert.
*/
export function token(): string | undefined {
return env('NEXT_PUBLIC_PUBLISHABLE_KEY') || undefined
}
/** `.env.local` is what starts the dev server, so it is what the specs read. */
let cache: Record<string, string> | null = null
function env(key: string): string | undefined {
if (process.env[key]) return process.env[key]
if (!cache) {
cache = {}
for (const file of ['.env.local', '.env']) {
try {
for (const line of readFileSync(join(process.cwd(), file), 'utf8').split('\n')) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line)
if (m && cache[m[1]] === undefined) cache[m[1]] = m[2].trim().replace(/^["']|["']$/g, '')
}
} catch {
/* A missing env file is a state, not an error. */
}
}
}
return cache[key] || undefined
}
/**
* The room has finished arriving.
*
* The rail ships in the HTML, so its presence proves nothing about hydration,
* and NO NAMED CONTROL is a safe signal either — two were tried and the surface
* outlived both inside an afternoon. The model combobox is in the shipped HTML
* (during prerender `anonymous` is false, so `Chat.tsx` renders a
* `ModelSelector` over an empty catalog reading "Select model") and DISAPPEARS
* on hydration, so waiting for it waits for the state before the one you want.
* `Sign in` worked until a later shell stopped drawing it, and then every test
* in the suite reported an inert room.
*
* So the signal is the shape of arriving rather than anything drawn: hydrate,
* then stop changing.
*/
export async function settled(page: Page): Promise<void> {
// The rail is the usual first thing to arrive, but it is NOT required: it is
// one named element on a surface whose named elements keep moving, and
// demanding it reported three mobile tests broken on a shell that had simply
// renamed things. Quiescence below is the real gate.
await expect(page.locator(RAIL).first())
.toBeAttached({ timeout: 20_000 })
.catch(() => {})
// gui marks the tree unmounted until it hydrates. The marker is the
// package's and may go away, so its absence is not treated as failure — the
// quiescence check below is the real gate.
await page
.waitForFunction(() => !document.documentElement.className.includes('t_unmounted'), null, { timeout: 20_000 })
.catch(() => {})
// QUIESCENCE, not a named control. Every label-shaped settle signal tried
// here was outlived by the surface inside an afternoon: the model combobox
// (which ships in the HTML and disappears on hydration), then `Sign in`,
// which a later shell stopped drawing. What does not move is that the room
// stops changing once it has finished arriving, so that is what is waited
// for — two identical readings of the control count, a beat apart.
let last = -1
let same = 0
await expect
.poll(
async () => {
const now = await page.locator('button, [role="button"]').count()
same = now === last ? same + 1 : 0
last = now
return same >= 2 && now > 0
},
{ timeout: 40_000, intervals: [400], message: 'the room never stopped changing — it did not finish arriving' },
)
.toBe(true)
}
/**
* The stylesheet actually arrived.
*
* gui compiles every style prop to an atomic class in an external sheet. A
* document that loaded none answers every layout question with the base rule,
* so a geometry assertion made against it measures nothing and passes. Check
* the instrument before believing a number — the house rule from
* `e2e/gates/workspace.spec.ts`.
*/
export async function styled(page: Page): Promise<void> {
const display = await page.locator(RAIL).first().evaluate((el) => getComputedStyle(el).display)
expect(display, 'the rail must compute as flex, or no gui stylesheet arrived').toBe('flex')
}
/** True when a gui control will accept a click. See note 1 at the top. */
export async function ready(control: Locator): Promise<boolean> {
return (await control.getAttribute('aria-disabled')) !== 'true'
}
/**
* Type into a gui-controlled field, and confirm the draft actually landed.
*
* Two ways it does not. `fill()` never reaches the React state at all (note 2
* at the top). And a dev server that reloads mid-word remounts the field empty
* — measured at seven reloads in fifteen seconds with a second writer on the
* tree — which reads downstream as "the send button never enabled" and blames
* the composer for the server's behaviour. Typing again is what a reader would
* do and is the only thing that distinguishes the two.
*/
export async function type(field: Locator, text: string, tries = 3): Promise<void> {
for (let i = 0; i < tries; i++) {
await field.click({ timeout: 20_000 }).catch(() => {})
await field.press('ControlOrMeta+a').catch(() => {})
await field.pressSequentially(text, { delay: 8 }).catch(() => {})
if ((await field.inputValue().catch(() => '')) === text) return
}
throw new Error(`the field would not hold a draft after ${tries} attempts — is the server reloading?`)
}
/**
* Put a draft in the composer and wait until the surface agrees it is sendable.
*
* Typing is not enough on its own: the value can be in the DOM while the send
* button is still `aria-disabled`, because the room re-renders on its own
* schedule and a reload takes the draft with it. The pair — text in, control
* armed — is the only state a reader would call "ready to send", so it is the
* one worth waiting for.
*/
export async function compose(field: Locator, control: Locator, text: string, tries = 4): Promise<void> {
for (let i = 0; i < tries; i++) {
await type(field, text, 2).catch(() => {})
try {
await expect.poll(() => ready(control), { timeout: 12_000, intervals: [250] }).toBe(true)
return
} catch {
/* Lost the draft; a reader would type it again. */
}
}
throw new Error('the composer never armed its send control — text went in and the button stayed disabled')
}
/** One screenshot per meaningful step, named for the step. */
export async function shot(page: Page, name: string): Promise<void> {
await page.screenshot({ path: join(SHOTS, `${name}.png`), fullPage: false }).catch(() => {
/* A screenshot is evidence, never the assertion. */
})
}
/**
* Open a room and wait for it to be the app rather than its own HTML.
*
* `domcontentloaded` rather than `networkidle`: the room holds an open SSE
* stream when a run is live, and a spec that waits for the network to go quiet
* waits for something that is designed never to happen.
*/
export async function open(page: Page, route: string): Promise<void> {
await page.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded', timeout: 90_000 })
await quiet(page)
await settled(page)
}
/**
* Take the dev server's own overlay out of the page.
*
* `next dev` mounts a `<nextjs-portal>` in the BOTTOM-LEFT corner, which is
* where the icon rail's last rung now sits. Measured at 1440x950: the Settings
* rung's box is (8, 880, 64, 58) and `document.elementFromPoint` at its centre
* returns NEXTJS-PORTAL, so the press lands on the badge and the panel never
* opens — silently, with no console error, looking exactly like a broken
* handler. Customize, one rung higher, is unaffected, which is what makes the
* pair read as a product regression rather than an obstruction.
*
* The badge exists only in `next dev`; the export has no portal, so hiding it
* removes the dev server from the measurement rather than removing anything the
* product ships. It is INJECTED rather than deleted because Next re-mounts it.
*/
export async function quiet(page: Page): Promise<void> {
await page
.addStyleTag({ content: 'nextjs-portal, [data-nextjs-toast], #__next-build-watcher { display: none !important }' })
.catch(() => {
/* No overlay to hide is the normal case against an export. */
})
}
/**
* Click a rail room and arrive there — through a dev server that may reload
* underneath the click.
*
* `router.push` is a client transition, and a module invalidation lands as a
* full navigation back to the CURRENT route, which silently undoes it. Measured
* against a dev server with a second writer on the tree: nine reloads of /dev
* inside one 30s `waitForURL`, so the assertion timed out on an app that works.
*
* The reader's own behaviour is the fix — press it again — and polling the URL
* rather than awaiting one navigation is what survives the bounce.
*/
export async function go(page: Page, label: string, route: string, tries = 4): Promise<void> {
const arrived = () => new RegExp(`${route}(/)?$`).test(new URL(page.url()).pathname)
for (let i = 0; i < tries; i++) {
await page
.getByRole('button', { name: label, exact: true })
.first()
.click({ timeout: 20_000 })
.catch(() => {
/* A reload mid-click; the next turn of the loop re-reads the rail. */
})
try {
await expect.poll(arrived, { timeout: 20_000, intervals: [250] }).toBe(true)
await settled(page)
return
} catch {
if (i === tries - 1) throw new Error(`${label} never reached ${route} — at ${page.url()} after ${tries} presses`)
}
}
}
/**
* Make the rail reachable, whatever the viewport.
*
* Below `$sm` the rail is `display: none` until the drawer opens, so every rail
* control is present in the DOM and unclickable — which reads as "the button
* did nothing" rather than "you are on a phone". Opening it is a no-op on a
* laptop, where the toggle is not rendered at all.
*/
export async function drawer(page: Page): Promise<void> {
if (await page.locator(RAIL).first().isVisible().catch(() => false)) return
const toggle = page.getByRole('button', { name: 'Open sidebar' })
if (await toggle.count()) {
await toggle.first().click({ timeout: 15_000 }).catch(() => {})
await expect(page.locator(RAIL).first()).toBeVisible({ timeout: 15_000 }).catch(() => {})
}
}
/**
* Make the sidebar's contents reachable, whatever the viewport.
*
* The toggle carries ONE name and it states what pressing it will DO —
* `shown ? 'Close sidebar' : 'Open sidebar'` — so wherever the sidebar is
* already open there is no 'Open sidebar' to press. Seven specs pressed it
* unconditionally and each spent 30s waiting for a button whose ABSENCE was
* the state they wanted; at 1440 that is every run, so the sidebar was never
* the thing under test and the failure never named it.
*
* Press it only when it is there, which is the same shape `drawer()` above
* uses for the rail: a precondition asks for a STATE, not for a click.
*/
export async function sidebar(page: Page): Promise<void> {
const open = page.getByRole('button', { name: 'Open sidebar' })
if (await open.count()) await open.first().click({ timeout: 15_000 }).catch(() => {})
}
/**
* Bring a sidebar tool into the document before reaching for it.
*
* `More` is a COLLAPSED folder and it holds seven of them — Tabs, Connectors,
* Skills, Terminals, Usage, Docs and Settings — so none of those labels exists
* in the document until somebody opens it. Six specs pressed one straight
* after `drawer()` and reported "Settings never opened the Settings panel"
* about a Settings that was never on screen to press: the folder was the
* missing step, and the failure named the panel instead.
*
* A no-op when the label is already there, so it costs nothing to ask.
*/
export async function reveal(page: Page, label: string): Promise<void> {
const target = page.getByRole('button', { name: label, exact: true })
if (await target.count()) return
const more = page.getByRole('button', { name: 'More', exact: true })
if (!(await more.count())) return
await more.first().click({ timeout: 15_000 }).catch(() => {})
await target.first().waitFor({ state: 'attached', timeout: 15_000 }).catch(() => {})
}
/**
* Open Settings and hand back the shell it now spans.
*
* Settings is the sidebar's SECOND LEVEL: the column swaps its rooms for the
* settings rows and the chosen row's pane fills the room. So the two halves live
* in different subtrees and a caller that presses a row and then reads what the
* row opened has to be scoped above both — which is what `SHELL` is for.
*
* The press is retried like `go()`: a dev-server reload between the press and
* the assertion takes the list down with the page.
*/
export async function settings(page: Page, tries = 4): Promise<Locator> {
const shell = page.locator(SHELL).first()
// A settings row marks itself current, and only Settings draws one — so its
// presence is the arrival, and there is no address to wait for.
const row = page.locator('[aria-current="true"]').filter({ hasText: 'General' }).first()
for (let i = 0; i < tries; i++) {
await drawer(page)
await reveal(page, 'Settings')
await page
.getByRole('button', { name: 'Settings', exact: true })
.first()
.click({ timeout: 20_000 })
.catch(() => {})
try {
await expect(row).toBeVisible({ timeout: 25_000 })
// A phone puts the drawer away when a row is pressed, so the list a caller
// is about to read has to be brought back out.
await drawer(page)
return shell
} catch {
if (i === tries - 1) throw new Error('Settings never opened its list')
await settled(page).catch(() => {})
}
}
return shell
}
/**
* The customize directory — the surface carrying the Apps · Channels · Plugins ·
* Skills taxonomy, OPENED ON APPS.
*
* It is defined by what it CONTAINS, not by the control that opens it, and not
* by the row it happens to land on. It began behind a `Customize` rung; that
* rung was deleted and the same four rows moved inside Settings, where they are
* a group in the column under the heading `Customize` and the surface opens on
* `General` instead. A spec bound to the doorway reported the taxonomy missing
* when only the door had moved; one bound to the landing row reported the
* catalog empty while it sat one click away.
*/
export async function directory(page: Page): Promise<Locator> {
const shell = await settings(page)
const apps = shell.getByRole('button', { name: 'Apps', exact: true }).first()
if (!(await apps.count())) {
throw new Error('no surface here carries the Apps · Channels · Plugins · Skills taxonomy')
}
// Land on Apps rather than where Settings opens.
if ((await apps.getAttribute('aria-current')) !== 'true') {
await apps.click().catch(() => {})
await drawer(page)
}
return shell
}