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

352 lines
20 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { serveExport } from './export'
import { PLANS } from './api'
import { config, person } from './heard'
import { issuer, jwt } from './issuer'
/**
* THE ORGANIZATION COMES FIRST.
*
* A signed-in reader with no organization is shown one thing — make one — and
* none of the workspace. One with several picks the one they are working in
* before any room is drawn. Past both the room opens, whatever the
* organization's plan, and every request under it names the organization: off
* hanzo.team no plan stands in front of anything, the shared sign-up
* organization's members and a team on Free included. Measured on the shipped
* bytes with billing answered from here; hanzo.team's half, where the Team plan
* is asked for, is in `host.spec.ts`.
*/
const tier = (name: string) => ({
user: 'bob',
tier: { name, displayName: name, dailyCreditsCents: 0, maxAgents: 1, unlimitedAgents: false },
balance: { currency: 'usd', prepaidAvailable: 0, creditsRemaining: 0, dailyRemaining: 0, effectiveAvailable: 0 },
})
/** An unsigned stand-in for bob's access token, naming `orgs`. */
const bob = (orgs: string[]) => jwt({ sub: 'bob', name: 'bob', email: '[email protected]', owner: 'hanzo', orgs: orgs.map((org) => ({ org, role: 'admin' })) })
/** `personal` names the organizations IAM's rows mark as their owner's own. */
/** `at` is the address arrived at: a room of the workspace unless it says otherwise. */
type Arrival = { orgs: string[]; plan: string; personal?: string[]; at?: string }
async function arrive(page: Page, base: string, { orgs, plan, personal = [], at = '/home' }: Arrival) {
await page.route('**/v1/**', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
await person(page)
await config(page)
await page.route('https://www.googletagmanager.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
)
await page.route('https://connect.facebook.net/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
)
await page.route('**/v1/billing/tier', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(tier(plan)) }),
)
// The plan authority, as the gates answer it: the rooms price the Team ask from it.
await page.route('**/v1/billing/plans', (route) => route.fulfill({ json: PLANS }))
await page.route(
(url) => url.pathname === '/v1/iam/organizations',
(route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
organizations: orgs.map((name) => ({ owner: 'admin', name, displayName: name, isPersonal: personal.includes(name) })),
}),
}),
)
await page.route('**/v1/account/orgs', (route) =>
route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ org: 'acme', displayName: 'Acme' }) }),
)
// IAM. The token it mints is the renewed one, minted on the refresh token once
// an organization is made (session.ts `renew`): it names the organization the
// account now belongs to.
await issuer(page, { token: bob(['acme']) })
// Checkout is hanzo.ai/pay, this site's /billing page, or billing.hanzo.ai
await page.route(/(https:\/\/hanzo\.ai\/pay(\/|\?|#|$)|https:\/\/(pay|billing)\.hanzo\.ai\/|:\d+\/billing(\/|\?|#|$))/, (route) =>
route.fulfill({ status: 200, contentType: 'text/html', body: `<title>checkout</title><p id="at">${route.request().url()}</p>` }),
)
await page.addInitScript(
({ orgs }) => {
if (sessionStorage.getItem('gate.seeded')) return
sessionStorage.setItem('gate.seeded', '1')
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: 'bob',
name: 'bob',
email: '[email protected]',
owner: 'hanzo',
orgs: orgs.map((org) => ({ org, role: 'admin' })),
exp,
})}.`
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_id_token', token)
localStorage.setItem('hanzo_iam_refresh_token', 'r1')
localStorage.setItem('hanzo_iam_expires_at', String(exp * 1000))
},
{ orgs },
)
await page.goto(`${base}${at}`, { waitUntil: 'domcontentloaded' })
}
const rail = (page: Page) => page.locator('[data-slot="list"]')
test.describe('the organization comes first', () => {
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
test('no organization: make one, and go straight to checkout for it', async ({ page }) => {
await arrive(page, base, { orgs: [], plan: 'free' })
const ask = page.getByRole('region', { name: 'Organizations' })
await expect(ask).toBeVisible()
await expect(ask.getByText('You do not belong to one yet.')).toBeVisible()
await expect(rail(page), 'none of the workspace').toHaveCount(0)
await ask.getByRole('button', { name: /^\+ Details/ }).click()
await expect(ask.getByText('An organization owns every agent, credit and member.')).toBeVisible()
await ask.getByRole('button', { name: 'New organization' }).click()
const dialog = page.getByRole('dialog', { name: 'Create an organization' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('radio', { name: /^Organization/ })).toBeChecked()
await dialog.getByPlaceholder('Acme').fill('Acme')
// A team organization is sold the per-seat Team plan. A personal one starts
// on Free, which creates it and nothing else, and a paid rung is an upgrade
// that goes to checkout.
await dialog.getByRole('radio', { name: /^Personal/ }).check()
await expect(dialog.getByRole('radio', { name: /^Free/ })).toBeChecked()
await expect(dialog.getByRole('button', { name: 'Create', exact: true })).toBeVisible()
await dialog.getByRole('radio', { name: /^Pro\b/ }).check()
await expect(dialog.getByRole('button', { name: /^Create and subscribe — \$\d+\.\d\d \/ month$/ })).toBeVisible()
await dialog.getByRole('radio', { name: /^Organization/ }).check()
const go = dialog.getByRole('button', { name: /^Create and subscribe — \$\d+\.\d\d \/ seat \/ month$/ })
await expect(go).toBeVisible()
// GA4 counts the new account as a sign-up. The call is written down where it
// outlives the trip to checkout.
await page.route('https://www.googletagmanager.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
)
await page.evaluate(() => {
const w = window as unknown as { gtag?: (...a: unknown[]) => void; google_tag_manager?: object }
const real = w.gtag
w.google_tag_manager = { 'G-RBVVVLFZDR': {} } // gtag running, so GA4 is the browser's to send
w.gtag = (...a: unknown[]) => {
if (a[0] === 'event') sessionStorage.setItem('gate.ga', String(a[1]))
real?.(...a)
}
})
await go.click()
await page.waitForURL(/(hanzo\.ai\/pay|pay\.hanzo\.ai|billing\.hanzo\.ai|\/billing)/)
const at = await page.locator('#at').textContent()
expect(at, 'checkout sells the team organization the Team plan').toMatch(/plan=team\b/)
expect(at).toMatch(/returnUrl=/)
// The token that predates the organization is expired, so the next load
// refreshes into one that carries it — and the browser stays the person's,
// with the organization they just made selected.
await page.goto(`${base}/home`, { waitUntil: 'domcontentloaded' })
expect(await page.evaluate(() => sessionStorage.getItem('gate.ga')), 'GA4 heard the sign-up').toBe('sign_up')
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_expires_at'))).toBe('0')
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBeTruthy()
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_current_org'))).toBe('acme')
})
for (const at of ['/home', '/chat']) {
test(`a team organization on Free opens ${at}: no plan stands in front of it`, async ({ page }) => {
await arrive(page, base, { orgs: ['acme'], plan: 'free', at })
// /chat is the app, whose window is the frame; /home a room with its rail.
await expect(at === '/chat' ? page.locator('[data-slot="frame"]') : rail(page)).toHaveCount(1, { timeout: 20_000 })
await expect(page.getByRole('region', { name: 'Subscribe' })).toHaveCount(0)
await expect(page.getByText('nothing in it works')).toHaveCount(0)
})
}
test('a personal organization on Free opens the app', async ({ page }) => {
await arrive(page, base, { orgs: ['bob'], plan: 'free', personal: ['bob'] })
// The room is mounted under the veil while `Orgs` reads, so being in the
// tree proves nothing. A press has to reach it: that is the room, open.
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)
})
test('a team organization on any paid tier opens the app, whatever plan it holds', async ({ page }) => {
// Max 20x is a personal rung held by a team: the tier is paid, and `Orgs`
// reads the tier, never the plan's slug.
await arrive(page, base, { orgs: ['webby'], plan: 'max-20x' })
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)
})
test('a personal organization made on Free goes straight in, and is counted as a sign-up', async ({ page }) => {
await arrive(page, base, { orgs: [], plan: 'free' })
const checkout: string[] = []
page.on('request', (r) => {
if (/(pay|billing)\.hanzo\.ai/.test(new URL(r.url()).hostname)) checkout.push(r.url())
})
await page.route('https://www.googletagmanager.com/**', (route) =>
route.fulfill({ status: 200, contentType: 'text/javascript', body: '' }),
)
await page.getByRole('region', { name: 'Organizations' }).getByRole('button', { name: 'New organization' }).click()
const dialog = page.getByRole('dialog', { name: 'Create an organization' })
await dialog.getByRole('radio', { name: /^Personal/ }).check()
await page.evaluate(() => {
const w = window as unknown as { gtag?: (...a: unknown[]) => void; google_tag_manager?: object }
const real = w.gtag
w.google_tag_manager = { 'G-RBVVVLFZDR': {} } // gtag running, so GA4 is the browser's to send
// The way this tab signed in, as the sign-in panel writes it down.
sessionStorage.setItem('hz_signin_method', 'github')
w.gtag = (...a: unknown[]) => {
if (a[0] === 'event') sessionStorage.setItem('ga.event', `${String(a[1])} ${String((a[2] as { method?: string })?.method)}`)
real?.(...a)
}
})
const back = page.waitForEvent('load')
await dialog.getByRole('button', { name: 'Create', exact: true }).click()
await back
expect(new URL(page.url()).pathname, 'the room it was made from, reloaded').toBe('/home')
expect(checkout, 'Free sells nothing').toEqual([])
expect(await page.evaluate(() => sessionStorage.getItem('ga.event')), 'GA4 heard the sign-up, and how').toBe('sign_up github')
// The token was renewed on the way back in, and the one it minted names acme.
await expect.poll(() => page.evaluate(() => localStorage.getItem('hanzo_iam_refresh_token'))).toBe('r2')
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_current_org'))).toBe('acme')
expect(new URL(page.url()).pathname, 'still in, not sent to sign in').toBe('/home')
})
test('a paid organization opens the room, and every call names it', async ({ page }) => {
const named = page.waitForRequest((r) => r.url().includes('/v1/agents/chat/conversations'))
await arrive(page, base, { orgs: ['acme'], plan: 'pro' })
await expect(rail(page)).toHaveCount(1)
await expect(page.getByRole('region', { name: 'Organizations' })).toHaveCount(0)
expect((await named).headers()['x-org-id'], 'the thread list is the organization\'s').toBe('acme')
})
test('several organizations: pick one on arrival, and the room is that one\'s', async ({ page }) => {
await arrive(page, base, { orgs: ['acme', 'beta'], plan: 'pro' })
const pick = page.getByRole('region', { name: 'Choose an organization' })
await expect(pick).toBeVisible()
await expect(rail(page)).toHaveCount(0)
const named = page.waitForRequest((r) => r.url().includes('/v1/agents/chat/conversations') && r.headers()['x-org-id'] === 'beta')
await pick.getByRole('button', { name: 'beta' }).click()
await expect(rail(page)).toHaveCount(1)
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_current_org'))).toBe('beta')
await named
})
test('the chooser: each organization with its slug and role, filtered as typed, and only the reader\'s own', async ({ page }) => {
await arrive(page, base, { orgs: ['acme', 'beta'], plan: 'pro' })
const pick = page.getByRole('region', { name: 'Choose an organization' })
const mine = pick.getByRole('list', { name: 'Your organizations' }).getByRole('button')
await expect(mine).toHaveCount(2)
await expect(mine.first()).toContainText('acme')
await expect(mine.first()).toContainText('Admin')
await pick.getByPlaceholder('Find an organization').fill('BET')
await expect(mine).toHaveCount(1)
await expect(mine.first()).toContainText('beta')
await expect(pick.getByRole('button', { name: 'New organization' })).toBeVisible()
await expect(page.getByRole('region', { name: 'All organizations' }), 'a person is never shown every organization').toHaveCount(0)
})
// THE FOCUSED PAGES' ONE BAR (@hanzo/app `Chrome`): the brand at the start,
// who is signed in at the end, above the fold and with nothing to scroll
// sideways. At a laptop's width the account and its two ways out are on the
// bar; at a phone's they fold into the face, a menu of the same two.
for (const [width, wide] of [[1280, true], [390, false]] as const) {
test(`the chooser stands under the focused pages' bar at ${width}px: the brand at the start, the account at the end`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 })
await arrive(page, base, { orgs: ['acme', 'beta'], plan: 'pro' })
const bar = page.getByRole('region', { name: 'Choose an organization' }).locator('header[data-slot="masthead"]')
const home = bar.getByRole('link', { name: 'Hanzo AI home' })
await expect(home).toContainText('Hanzo AI')
const account = wide ? bar.getByRole('region', { name: 'Account' }) : bar.getByRole('button', { name: 'Account: bob' })
await expect(account).toBeVisible()
const start = (await home.boundingBox())!
const end = (await account.boundingBox())!
// Each on the page's gutter, the same distance from its own edge.
expect(start.x, 'the brand at the start').toBeLessThan(width / 4)
expect(end.x, 'the account at the end').toBeGreaterThan(width / 2)
expect(Math.abs(start.x - (width - end.x - end.width)), 'one gutter both sides').toBeLessThan(4)
expect(end.y + end.height, 'above the fold').toBeLessThan(100)
expect(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1)).toBe(false)
await account.click()
const menu = page.getByRole('menu', { name: 'Account' })
await expect(menu).toContainText('[email protected]')
await expect(menu.getByRole('menuitem', { name: 'Use another account' })).toBeVisible()
await expect(menu.getByRole('menuitem', { name: 'Log out' })).toBeVisible()
})
}
test('Use another account signs out, and the sign-in comes back to the chooser', async ({ page }) => {
await arrive(page, base, { orgs: ['acme', 'beta'], plan: 'pro' })
const account = page.getByRole('region', { name: 'Choose an organization' }).getByRole('region', { name: 'Account' })
const revoked = page.waitForRequest((r) => r.url().endsWith('/v1/iam/oauth/revoke'))
await account.click()
await page.getByRole('menu', { name: 'Account' }).getByRole('menuitem', { name: 'Use another account' }).click()
await revoked
await page.waitForURL(/\/login\?from=logout/)
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token')), 'signed out').toBeNull()
expect(await page.evaluate(() => sessionStorage.getItem('signin.destination')), 'the sign-in comes back here').toBe('/home')
})
test('at 390px Log out, from the folded account, signs out', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await arrive(page, base, { orgs: ['acme', 'beta'], plan: 'pro' })
await page.getByRole('region', { name: 'Choose an organization' }).getByRole('button', { name: 'Account: bob' }).click()
const revoked = page.waitForRequest((r) => r.url().endsWith('/v1/iam/oauth/revoke'))
await page.getByRole('menu', { name: 'Account' }).getByRole('menuitem', { name: 'Log out' }).click()
await revoked
await page.waitForURL(/\/login\?from=logout/)
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_access_token')), 'signed out').toBeNull()
})
test('a SuperAdmin searches every organization on IAM and opens one through IAM\'s assume', async ({ page }) => {
await arrive(page, base, { orgs: ['admin', 'acme'], plan: 'pro' })
const pick = page.getByRole('region', { name: 'Choose an organization' })
await expect(pick).toBeVisible()
await expect(pick.getByRole('button', { name: 'New organization' }), 'IAM lets no SuperAdmin own a customer org').toHaveCount(0)
// The search is IAM's, on the server: what it answers for `zoo` is the list.
await page.route(
(url) => url.pathname === '/v1/iam/organizations' && url.searchParams.get('q') === 'zoo',
(route) => route.fulfill({ json: { organizations: [{ owner: 'admin', name: 'zoo', displayName: 'Zoo Labs' }] } }),
)
const assumed = jwt({ sub: 'bob', name: 'bob', owner: 'admin', orgs: [{ org: 'admin', role: 'admin' }, { org: 'zoo', role: 'admin' }], assumed: 'zoo' })
await page.route('**/v1/iam/assume', (route) => route.fulfill({ json: { status: 'ok', data: { accessToken: assumed } } }))
await pick.getByPlaceholder('Search every organization').fill('zoo')
const every = page.getByRole('region', { name: 'All organizations' }).getByRole('button', { name: /Zoo Labs/ })
await expect(every).toBeVisible()
const stepped = page.waitForRequest((r) => r.url().endsWith('/v1/iam/assume'))
await every.click()
expect((await stepped).postDataJSON(), 'the step in is IAM\'s, never a membership').toEqual({ org: 'zoo' })
await expect(page.getByRole('status', { name: 'Support mode' })).toContainText('zoo')
expect(await page.evaluate(() => localStorage.getItem('hanzo_iam_current_org'))).toBe('zoo')
})
for (const ecoOrg of ['hanzo', 'admin']) {
test(`${ecoOrg} on Free opens: the shared sign-up organization is never asked for a plan`, async ({ page }) => {
await arrive(page, base, { orgs: [ecoOrg], plan: 'free' })
await expect(rail(page)).toHaveCount(1, { timeout: 20_000 })
await expect(page.getByRole('region', { name: 'Subscribe' })).toHaveCount(0)
})
}
test('at 390px the ask fits the phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await arrive(page, base, { orgs: [], plan: 'free' })
await expect(page.getByRole('region', { name: 'Organizations' })).toBeVisible()
await page.getByRole('button', { name: 'New organization' }).click()
await expect(page.getByRole('dialog', { name: 'Create an organization' })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1)).toBe(false)
})
})