Files
hanzo.ai/e2e/gates/host.spec.ts

275 lines
13 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { serveExport } from './export'
import { PLANS } from './api'
import { bare } from './heard'
import { asked as askedOf, issuer } from './issuer'
import { rail } from './rail'
import { answer, signIn, type Who } from '../session'
/**
* THE APEX IS THE APP; HANZO.TEAM IS THE WORKSPACE.
*
* One export serves both, so the split is made in the browser off the hostname
* (`lib/host.ts`), and the hostname is the fixture. Chromium resolves hanzo.ai,
* www.hanzo.ai and hanzo.team to this machine, where the export is served, so
* each page is the shipped bytes under the name a reader types, on the port
* the server was given. Every request that is not for the export, or answered
* below, is aborted: nothing leaves this machine, and the move to hanzo.team is
* answered here by a stub.
*
* What is measured: the apex rail is Chat and Dev, with one Workspace row at the
* foot that opens hanzo.team signed in; a room
* asked for on the apex is REPLACED by the same address on hanzo.team; every
* other host keeps the whole rail; and Free opens the app for a personal
* organization on the apex while hanzo.team asks it for the Team plan.
*/
const MAPPED = ['hanzo.ai', 'www.hanzo.ai', 'hanzo.team']
test.use({
launchOptions: { args: [`--host-resolver-rules=${MAPPED.map((host) => `MAP ${host} 127.0.0.1`).join(', ')}`] },
})
let port: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
port = new URL(served.url).port
stop = served.close
})
test.afterAll(async () => stop())
/** The export under a host name. */
const at = (host: string, path: string) => `http://${host}:${port}${path}`
/** The addresses the workspace host was asked for, in order. */
async function hermetic(page: Page): Promise<string[]> {
// FIRST, so every narrower route registered after it wins.
await page.route('**/*', (route) =>
new URL(route.request().url()).port === port ? route.continue() : route.abort(),
)
const moved: string[] = []
await page.route('https://hanzo.team/**', (route) => {
moved.push(route.request().url())
return route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>Hanzo Team</title>' })
})
return moved
}
/** A signed-in reader and the reads the rail's list makes, answered. */
async function reader(page: Page, who?: Who): Promise<void> {
await signIn(page, who)
await answer(page, '/v1/billing/balance', { available: 0 })
// The plan authority, as the gates answer it: the rooms price the Team ask from it.
await answer(page, '/v1/billing/plans', PLANS)
await answer(page, '/v1/agents', { agents: [] })
await answer(page, '/v1/agents/chat/conversations', { conversations: [] })
await answer(page, '/v1/agents/sessions', { sessions: [] })
await answer(page, '/v1/projects', [])
await answer(page, '/v1/team/rooms', { rooms: [] })
await answer(page, '/v1/channels/inbox', { items: [] })
}
/** The rooms that are hanzo.team's, as the rail names them. */
const ROOMS = ['Agents', 'Bots', 'Meet', 'Calendar', 'Contacts', 'Inbox', 'Board', 'Drive']
/** A reader the cloud's flags put in both cohorts: every tier opens. */
async function flagged(page: Page): Promise<void> {
await reader(page)
await answer(page, '/v1/flags', { featureFlags: { 'tier.alpha': true, 'tier.beta': true } })
}
/** The rooms behind the alpha and beta tiers. */
const TIERED = ['Agents', 'Inbox', 'Board', 'Drive']
// Tiers are IAM's SuperAdmin answer (home org `admin`) or the cloud's flags. An
// organization's name is neither, and nor is an `admin` membership held from
// another org; none of these readers is flagged.
for (const [why, who] of [
['an ecosystem organization', { sub: 'gate', name: 'gate', email: '[email protected]', org: 'hanzo' }],
['an admin membership held from another org', { sub: 'bob', name: 'bob', email: '[email protected]', orgs: [{ org: 'acme', role: 'owner' }, { org: 'admin', role: 'member' }] }],
] as [string, Who][]) {
test(`on hanzo.team ${why} opens no tier`, async ({ page }) => {
await hermetic(page)
await reader(page, who)
// Two memberships ask for a pick first; the reader works in their home org.
if ((who.orgs?.length ?? 1) > 1) {
await page.addInitScript((home) => {
localStorage.setItem('hanzo:org:picked', '1')
localStorage.setItem('hanzo_iam_current_org', home)
}, who.orgs![0].org)
}
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(at('hanzo.team', '/home'))
await rail(page, '[data-slot="list"]')
const places = page.locator('[data-slot="places-list"]')
await expect(places.getByRole('button', { name: 'Chat', exact: true })).toBeVisible()
for (const name of TIERED) await expect(places.getByRole('button', { name, exact: true }), name).toHaveCount(0)
})
}
test('on hanzo.team a SuperAdmin opens every tier unflagged', async ({ page }) => {
await hermetic(page)
await reader(page, { sub: 'z', name: 'z', email: '[email protected]', org: 'admin' })
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(at('hanzo.team', '/home'))
await rail(page, '[data-slot="list"]')
const places = page.locator('[data-slot="places-list"]')
for (const name of TIERED) await expect(places.getByRole('button', { name, exact: true }), name).toBeVisible()
})
test('on the apex a signed-in /login lands in the app at /, in Chat, and /dev in Dev', async ({ page }) => {
const moved = await hermetic(page)
await reader(page)
await page.setViewportSize({ width: 1440, height: 900 })
// Nowhere to return to: a sign-in lands on the app, which is `/`.
await page.goto(at('hanzo.ai', '/login'))
await page.waitForURL((u) => u.hostname === 'hanzo.ai' && u.pathname === '/' && !u.search, { timeout: 20_000 })
await expect(page.locator('main textarea').first()).toBeVisible({ timeout: 20_000 })
// An old query is carried into Dev's own address, its path, in place.
await page.goto(at('hanzo.ai', '/dev?view=templates'))
await page.waitForURL((u) => u.pathname === '/dev/templates' && !u.search, { timeout: 20_000 })
expect(moved, 'the app is the apex, not hanzo.team').toEqual([])
})
test('a room asked for on the apex is replaced by the same address on hanzo.team', async ({ page }) => {
const moved = await hermetic(page)
await reader(page)
const before = await page.evaluate(() => history.length)
await page.goto(at('hanzo.ai', '/board'))
await page.waitForURL((u) => bare(u.href) === 'https://hanzo.team/board')
expect(moved.map(bare)).toEqual(['https://hanzo.team/board'])
// REPLACED, not pushed: one entry for the visit and none for the move, so
// Back does not return to a page that only leaves again.
expect(await page.evaluate(() => history.length)).toBe(before + 1)
})
test('a stranger is moved too, query and all, before any sign-in starts', async ({ page }) => {
const moved = await hermetic(page)
const seen = await issuer(page)
await page.goto(at('www.hanzo.ai', '/home?room=team%3Aacme%2Fr1'))
await page.waitForURL((u) => bare(u.href) === 'https://hanzo.team/?room=team%3Aacme%2Fr1')
expect(moved.map(bare)).toEqual(['https://hanzo.team/?room=team%3Aacme%2Fr1'])
expect(askedOf(seen), 'the room left before Orgs could send anyone to hanzo.id').toEqual([])
})
test('every other host keeps the whole rail and its rooms', async ({ page }) => {
await hermetic(page)
await flagged(page)
await page.setViewportSize({ width: 1440, height: 900 })
for (const host of ['127.0.0.1', 'hanzo.team']) {
await page.goto(at(host, '/home'))
await rail(page, '[data-slot="list"]')
const places = page.locator('[data-slot="places-list"]')
for (const name of ['Chat', 'Dev', ...ROOMS])
await expect(places.getByRole('button', { name, exact: true }), `${name} on ${host}`).toBeVisible()
await expect(places.getByRole('link', { name: 'Workspace', exact: true })).toHaveCount(0)
await expect(page.locator('[data-leave]'), `no Workspace row on ${host}`).toHaveCount(0)
await page.goto(at(host, '/board'))
await expect(page.locator('[data-slot="workspace"][data-hydrated="true"]')).toBeVisible({ timeout: 20_000 })
expect(new URL(page.url()).host, `${host} keeps its rooms`).toBe(`${host}:${port}`)
}
})
/**
* A person in one organization, on the tier billing names, of the kind IAM's
* row says. The tier alone decides.
*/
async function member(page: Page, org: string, tier: string, personal: boolean): Promise<void> {
await reader(page, { sub: org, name: org, email: `${org}@hanzo.ai`, org })
await answer(page, '/v1/billing/tier', {
user: org,
tier: { name: tier, displayName: tier, dailyCreditsCents: 0, maxAgents: 1, unlimitedAgents: false },
balance: { currency: 'usd', prepaidAvailable: 0, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 0 },
})
await answer(page, '/v1/iam/organizations', {
organizations: [{ owner: 'admin', name: org, displayName: org, isPersonal: personal }],
})
}
/**
* The room is open. It is mounted under the veil while `Orgs` reads, so
* being in the tree proves nothing: a press has to reach it. On the apex /, /chat
* and /dev are the app, open once its column takes a press.
*/
async function open(page: Page, app = false): Promise<void> {
if (app) await page.getByRole('navigation', { name: 'Hanzo' }).click({ trial: true, timeout: 20_000 })
else
await page
.locator('[data-slot="places-list"]')
.getByRole('button', { name: 'Chat', exact: true })
.click({ trial: true, timeout: 20_000 })
await expect(page.getByRole('region', { name: 'Subscribe' })).toHaveCount(0)
}
/** The subscribe ask for `org`, on the per-seat Team plan. */
async function asked(page: Page, org: string): Promise<void> {
const ask = page.getByRole('region', { name: 'Subscribe' })
await expect(ask).toBeVisible({ timeout: 20_000 })
await expect(ask.getByRole('heading', { level: 1 })).toHaveText(org)
const subscribe = ask.getByRole('link', { name: /^Subscribe — \$\d+\.\d\d \/ seat \/ month$/ })
await expect(subscribe).toBeVisible()
expect(new URL((await subscribe.getAttribute('href')) ?? '').searchParams.get('plan')).toBe('team')
await expect(page.locator('[data-slot="list"]')).toHaveCount(0)
}
test('Free opens the app for a personal organization on the apex, at /, /chat and /dev, and hanzo.team asks it for the Team plan', async ({ page }) => {
await hermetic(page)
await member(page, 'bob', 'free', true)
await page.setViewportSize({ width: 1440, height: 900 })
for (const path of ['/', '/chat', '/dev']) {
await page.goto(at('hanzo.ai', path))
await open(page, true)
}
await page.goto(at('hanzo.team', '/home'))
await asked(page, 'bob')
})
test('a team organization on Free opens the app on the apex, at /, /chat and /dev, and hanzo.team asks it for the Team plan', async ({ page }) => {
// No plan stands in front of Chat or Dev: the Team plan is the workspace's.
await hermetic(page)
await member(page, 'acme', 'free', false)
await page.setViewportSize({ width: 1440, height: 900 })
for (const path of ['/', '/chat', '/dev']) {
await page.goto(at('hanzo.ai', path))
await open(page, true)
}
await page.goto(at('hanzo.team', '/home'))
await asked(page, 'acme')
})
test('a team organization on any paid plan opens on the apex and on hanzo.team', async ({ page }) => {
// A team holding Max 20x, a personal rung: the tier is paid, and that is the
// whole question. The plan's slug is never read.
await hermetic(page)
await member(page, 'webby', 'max-20x', false)
await page.setViewportSize({ width: 1440, height: 900 })
for (const [host, path] of [['hanzo.ai', '/'], ['hanzo.ai', '/chat'], ['hanzo.ai', '/dev'], ['hanzo.team', '/home']] as const) {
await page.goto(at(host, path))
await open(page, host === 'hanzo.ai')
}
})
test('hanzo.team offers only a team organization', async ({ page }) => {
await hermetic(page)
await answer(page, '/v1/billing/plans', PLANS)
await signIn(page, { sub: 'carol', name: 'carol', email: '[email protected]', org: 'carol' })
// A token that names no organization yet.
await page.addInitScript(() => {
const part = (v: unknown) => btoa(JSON.stringify(v)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const exp = Math.floor(Date.now() / 1000) + 3600
const token = `${part({ alg: 'none', typ: 'JWT' })}.${part({ sub: 'carol', name: 'carol', email: '[email protected]', exp, orgs: [] })}.`
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_id_token', token)
})
await page.goto(at('hanzo.team', '/home'))
const ask = page.getByRole('region', { name: 'Organizations' })
await expect(ask).toBeVisible({ timeout: 20_000 })
await ask.getByRole('button', { name: 'New organization' }).click()
const dialog = page.getByRole('dialog', { name: 'Create an organization' })
await expect(dialog.getByRole('radio', { name: /^Organization/ })).toBeChecked()
await expect(dialog.getByRole('radio', { name: /^Personal/ })).toHaveCount(0)
await expect(dialog.getByRole('button', { name: /^Create and subscribe — \$\d+\.\d\d \/ seat \/ month$/ })).toBeVisible()
})