mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
275 lines
13 KiB
TypeScript
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()
|
|
})
|