mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every import, the catalog pin and the docs name @hanzo/app and github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and components/rooms.tsx is components/app.tsx.
145 lines
7.8 KiB
TypeScript
145 lines
7.8 KiB
TypeScript
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') })
|
||
})
|