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

145 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test, type Page } from '@playwright/test'
import { serveExport, read } from './export'
import { signIn } from '../session'
import { sideways } from './wide'
/**
* /dev, on the bytes that ship.
*
* `/dev` answers three ways at one address (app/dev/_door.tsx). A stranger at
* bare `/dev` reads Dev's page, which the export holds for crawlers, and its
* Open Dev signs in and comes back; a stranger anywhere else under `/dev`, or at
* `/dev` with a query from before Dev had paths, is sent to sign in to that
* place without the page ever painting; a member is drawn the builder at every
* one of them. app.spec.ts holds the builder itself — its places, its paths
* through reload and Back, an old query moved once — and login.spec.ts the
* round trip through the sign-in.
*/
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
const RELEASE = 'https://api.github.com/repos/hanzoai/dev/releases/latest'
/** GitHub's API answers any origin. */
const CORS = { 'access-control-allow-origin': '*' }
/** The platform, answered: no runs, no projects, `{}` for any other read. */
async function platform(page: Page) {
await page.route(
// The plan is the session fixture's (../session): a reader who pays, as the
// builder stands behind the organization gate (app/_web.tsx).
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/') && u.pathname !== '/v1/billing/tier' && u.pathname !== '/v1/billing/balance',
(route) => {
const url = new URL(route.request().url())
if (url.pathname.endsWith('/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' })
if (url.pathname === '/v1/agent/sessions') return route.fulfill({ json: { sessions: [] } })
if (url.pathname === '/v1/git/repos') return route.fulfill({ json: { data: [] } })
if (url.pathname === '/v1/projects') return route.fulfill({ json: [] })
return route.fulfill({ json: {} })
},
)
}
/** Waits for gui to mount the page, which is when its effects (the release read) have run. */
const hydrated = (page: Page) =>
page.waitForFunction(() => !document.documentElement.className.includes('t_unmounted'), null, { timeout: 60_000 })
/** Writes down, at the first parse of any page under /dev, whether its landing was on screen. */
async function watchLanding(page: Page) {
await page.addInitScript(() => {
document.addEventListener('DOMContentLoaded', () => {
if (!location.pathname.startsWith('/dev')) return
const landing = document.querySelector('[data-landing]')
sessionStorage.setItem('gate.landing', landing ? getComputedStyle(landing).display : 'absent')
})
})
}
test('the export holds Dev’s page at /dev: its copy, its own canonical, offered to crawlers', () => {
const html = read('dev.html')
expect(html).toContain('<title>Hanzo Dev — the coding agent')
expect(html).toContain('<link rel="canonical" href="https://hanzo.ai/dev"/>')
expect(html).not.toContain('noindex')
expect(html, 'the hero’s sentence is in the bytes').toMatch(/Describe the change\.(<br\/>|\s)*Dev ships it\./)
expect(html, 'the builder is not').not.toMatch(/<[a-z]+ [^>]*data-slot="app-rail"/)
expect(read('sitemap.xml'), '/dev is offered').toContain('<loc>https://hanzo.ai/dev</loc>')
})
for (const width of [1440, 390]) {
test(`signed out, /dev is Dev’s page at ${width}px, and Open Dev signs in to come back`, async ({ page }, info) => {
await page.route(RELEASE, (route) =>
route.fulfill({ headers: CORS, json: { tag_name: 'v9.8.7', html_url: 'https://github.com/hanzoai/dev/releases/tag/v9.8.7' } }),
)
await page.setViewportSize({ width, height: 900 })
await page.goto(`${base}/dev`)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(/Describe the change\.\s*Dev ships it\./, { timeout: 20_000 })
await expect(page.locator('header[data-slot="masthead"]')).toBeVisible()
await expect(page.locator('[data-slot="app-rail"]')).toHaveCount(0)
expect(await page.evaluate(() => document.documentElement.hasAttribute('data-app'))).toBe(false)
// The release is GitHub's answer, read once the page has hydrated.
await hydrated(page)
await expect(page.getByRole('link', { name: 'Latest release v9.8.7 →' })).toHaveAttribute('href', 'https://github.com/hanzoai/dev/releases/tag/v9.8.7', {
timeout: 30_000,
})
expect(await sideways(page), 'nothing wider than the window').toBeLessThanOrEqual(0)
await page.evaluate(() => window.scrollTo(0, 0))
await page.screenshot({ path: info.outputPath(`dev-${width}.png`) })
await page.screenshot({ path: info.outputPath(`dev-${width}-full.png`), fullPage: true })
const open = page.getByRole('link', { name: /^Open Dev/ }).first()
await expect(open).toHaveAttribute('href', '/login?next=%2Fdev')
await open.click()
await page.waitForURL((u) => u.pathname === '/login', { timeout: 20_000 })
expect(new URL(page.url()).searchParams.get('next')).toBe('/dev')
})
}
test('signed out, /dev says nothing about a release GitHub does not answer for', async ({ page }) => {
await page.route(RELEASE, (route) => route.fulfill({ status: 403, headers: CORS, json: { message: 'rate limited' } }))
const refused = page.waitForResponse(RELEASE, { timeout: 60_000 })
await page.goto(`${base}/dev`)
await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 20_000 })
await refused
await page.waitForTimeout(1000)
await expect(page.getByText(/Latest release/)).toHaveCount(0)
})
for (const place of ['/dev/hanzo/circle', '/dev/sess_0123456789abcdef0123456789abcdef', '/dev/-/settings/usage', '/dev?project=my-site', '/dev?at=-/customize']) {
test(`signed out, ${place} is a place in the builder: sign in to it, and the page never paints`, async ({ page }) => {
await watchLanding(page)
await page.goto(`${base}${place}`)
// Decided once the page has hydrated and IAM has answered, which a loaded runner takes its time over.
await page.waitForURL((u) => u.pathname === '/login' || u.pathname === '/signup', { timeout: 60_000 })
expect(new URL(page.url()).searchParams.get('next'), 'the sign-in comes back to the place').toBe(place)
expect(await page.evaluate(() => sessionStorage.getItem('signin.destination'))).toBe(place)
expect(await page.evaluate(() => sessionStorage.getItem('gate.landing')), 'the landing was off the page from its first parse').toBe('none')
})
}
test('signed in, /dev and a path under it are the builder, and Dev’s page is not drawn', async ({ page }, info) => {
await signIn(page)
await platform(page)
await watchLanding(page)
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${base}/dev`)
await expect(page.getByText('Build what’s next.')).toBeVisible({ timeout: 20_000 })
await expect(page.locator('[data-slot="app-rail"]')).toHaveCount(1)
await expect(page.locator('header[data-slot="masthead"]')).toBeHidden()
await expect(page.getByText('Describe the change.')).toBeHidden()
expect(await page.evaluate(() => sessionStorage.getItem('gate.landing')), 'a live session takes the page off before it paints').toBe('none')
await page.screenshot({ path: info.outputPath('dev-1440-signed-in.png') })
// The builder's grammar from before, moved once (app/dev/_door.tsx `moved`): a Settings section is the
// site's page at /settings, never a place under /dev (@hanzo/app lib/host `settings`).
await page.goto(`${base}/dev/-/settings/usage`)
await expect(page.getByText("What this organization's plan includes")).toBeVisible({ timeout: 20_000 })
expect(new URL(page.url()).pathname).toBe('/settings/organization/usage')
await page.screenshot({ path: info.outputPath('dev-1440-signed-in-settings.png') })
})