mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
- bot: the pricing section is "Included in $100+ Max plans" sincee229ebc4. - crew, together: once something is said the faces under the composer are out of the way (@hanzo/rooms b7fbadd); the header is where the room is read and where one leaves. - limits: the composer is Dev's since @hanzo/rooms 0f921c5, so at a laptop the fallback banner's message takes two lines beside its actions. - members: the reader's own row reads as his account, "(you)", with no menu (Members.tsx since5e216f28); the gate asserted the mark was absent. - leaderboard: /benchmarks also asks the research API for the coding board and each mcp.* tool (891bcc56); every address is answered from its snapshot, so the console holds no CORS refusal from the real network. - page-kit: an email body is mail, not a page (lib/publish MAIL,1b6bcb6a), and its hidden preheader is a 1px line on purpose. - weight: an import written inside a code sample (a template string, the server pages that highlight samples since11265a21) is text, not an import. - signin: a stranger on /chat is sent to this site's sign-up (e022a96b), so the note is read from the origin's sessionStorage across the hop.
283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
import { serveExport } from './export'
|
|
import { signIn } from '../session'
|
|
|
|
/**
|
|
* AN ORGANIZATION'S MEMBERS, AS ITS OWNER READS THEM.
|
|
*
|
|
* Josh lives in his personal organization and owns webby-ai, where a and z act
|
|
* from theirs. IAM answers the roster from `GET /v1/iam/memberships?org=`, whose
|
|
* rows carry the member's `<home>/<username>` and role and no name, and it lets
|
|
* Josh read no record in a and z's home organization. So a and z read as the
|
|
* username the row carries, Josh reads as his own account, and no user record
|
|
* outside webby-ai is asked for.
|
|
*
|
|
* Read at Settings → Members (/settings/organization/members), the one home of
|
|
* the roster and both ways in. Measured on the shipped bytes at a laptop and a
|
|
* phone; the `webkit` project runs this file in WebKit too.
|
|
*/
|
|
|
|
const ORG = 'webby-ai'
|
|
const JOSH = {
|
|
owner: 'joshuafl369',
|
|
name: 'joshuafl369',
|
|
displayName: 'Joshua Lynch',
|
|
email: '[email protected]',
|
|
}
|
|
|
|
/** IAM's rows for webby-ai: `{ status, data, data2 }`, one per member. */
|
|
const ROSTER = [
|
|
{ user: 'hanzo/a', role: 'admin' },
|
|
{ user: 'hanzo/z', role: 'admin' },
|
|
{ user: `${JOSH.owner}/${JOSH.name}`, role: 'owner' },
|
|
].map((m) => ({
|
|
owner: 'admin',
|
|
name: `${m.user}|${ORG}`,
|
|
createdTime: '2026-09-01T00:00:00Z',
|
|
user: m.user,
|
|
team: '',
|
|
org: ORG,
|
|
workspace: '',
|
|
project: '',
|
|
role: m.role,
|
|
}))
|
|
|
|
const INVITATION = {
|
|
owner: ORG,
|
|
name: 'inv-k7m2p9q4xw',
|
|
displayName: 'Invitation for [email protected]',
|
|
code: 'K7M2P9Q4XW',
|
|
quota: 1,
|
|
usedCount: 0,
|
|
email: '[email protected]',
|
|
state: 'Active',
|
|
}
|
|
|
|
const SIZES = [
|
|
{ width: 1440, height: 900 },
|
|
{ width: 390, height: 844 },
|
|
] as const
|
|
|
|
const json = (status: number, body: unknown) => ({
|
|
status,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
/** What the page asked IAM for, and how each invitations read was answered. */
|
|
interface Seen {
|
|
users: string[]
|
|
invitations: { url: string; status: number }[]
|
|
}
|
|
|
|
/** IAM as it answers Josh: his own record, webby-ai's roster and directory, and a 403 for any other org's user. */
|
|
async function iam(page: Page): Promise<Seen> {
|
|
const seen: Seen = { users: [], invitations: [] }
|
|
await page.route('**/v1/**', (route) => route.fulfill(json(200, {})))
|
|
await signIn(page, {
|
|
sub: '5f0c2d0e-8a51-4d3c-9d6b-0c7a1e2f3b4c',
|
|
name: JOSH.name,
|
|
email: JOSH.email,
|
|
orgs: [
|
|
{ org: JOSH.owner, role: 'admin' },
|
|
{ org: ORG, role: 'owner' },
|
|
],
|
|
})
|
|
await page.addInitScript((org) => localStorage.setItem('hanzo_iam_current_org', org), ORG)
|
|
await page.route(
|
|
(url) => url.pathname === '/v1/iam/account',
|
|
(route) =>
|
|
route.fulfill(
|
|
json(200, { status: 'ok', data: { ...JOSH, id: '5f0c2d0e-8a51-4d3c-9d6b-0c7a1e2f3b4c', type: 'normal-user' } }),
|
|
),
|
|
)
|
|
await page.route(
|
|
(url) => url.pathname === '/v1/iam/memberships',
|
|
(route) => {
|
|
const org = new URL(route.request().url()).searchParams.get('org')
|
|
return route.fulfill(
|
|
org === ORG ? json(200, { status: 'ok', msg: '', data: ROSTER, data2: ROSTER.length }) : json(400, { status: 'error', msg: 'auth:Unauthorized operation' }),
|
|
)
|
|
},
|
|
)
|
|
await page.route(
|
|
(url) => url.pathname === '/v1/iam/users' || url.pathname.startsWith('/v1/iam/users/'),
|
|
(route) => {
|
|
const url = new URL(route.request().url())
|
|
seen.users.push(url.pathname + url.search)
|
|
// webby-ai's own directory, which Josh may read as its owner: nobody lives there.
|
|
if (url.pathname === '/v1/iam/users' && url.searchParams.get('owner') === ORG) {
|
|
return route.fulfill(json(200, { users: [], total: 0 }))
|
|
}
|
|
if (url.pathname === `/v1/iam/users/${JOSH.owner}/${JOSH.name}`) {
|
|
return route.fulfill(json(200, { ...JOSH, type: 'normal-user' }))
|
|
}
|
|
return route.fulfill(json(403, { status: 403, error: 'forbidden' }))
|
|
},
|
|
)
|
|
await page.route(
|
|
(url) => url.pathname === '/v1/iam/invitations',
|
|
(route) => {
|
|
const url = new URL(route.request().url())
|
|
const status = url.searchParams.get('owner') === ORG ? 200 : 403
|
|
seen.invitations.push({ url: url.pathname + url.search, status })
|
|
return route.fulfill(
|
|
status === 200 ? json(200, { invitations: [INVITATION], total: 1 }) : json(403, { status: 403, error: 'forbidden' }),
|
|
)
|
|
},
|
|
)
|
|
return seen
|
|
}
|
|
|
|
test.describe('organization members', () => {
|
|
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 MEMBERS = '/settings/organization/members'
|
|
|
|
for (const size of SIZES) {
|
|
test(`at ${size.width}px the owner reads as owner, each member by what IAM names, and nothing runs off the side`, async ({ page }, info) => {
|
|
const faults: string[] = []
|
|
page.on('console', (m) => {
|
|
if (m.type() === 'error') faults.push(m.text())
|
|
})
|
|
page.on('pageerror', (e) => faults.push(String(e)))
|
|
|
|
await page.setViewportSize(size)
|
|
const seen = await iam(page)
|
|
await page.goto(`${base}${MEMBERS}`)
|
|
|
|
// THE ROLE AS STORED, and a menu on every row but the reader's own, which
|
|
// reads as his account and is marked his (Members.tsx, since 5e216f28).
|
|
await expect(page.getByRole('button', { name: 'Actions for a' })).toBeVisible({ timeout: 30_000 })
|
|
await expect(page.getByRole('button', { name: 'Actions for z' })).toBeVisible()
|
|
await expect(page.getByText(`${JOSH.displayName} (you)`)).toBeVisible()
|
|
await expect(page.getByRole('button', { name: `Actions for ${JOSH.displayName}` })).toHaveCount(0)
|
|
await expect(page.getByText('Owner', { exact: true }).first()).toBeVisible()
|
|
|
|
// NAMED FROM WHAT THE READER MAY READ, and no record in another org asked.
|
|
const foreign = seen.users.filter((u) => /^\/v1\/iam\/users\/[^/]+\/[^/]+/.test(u) && !u.includes(`/${JOSH.owner}/${JOSH.name}`))
|
|
expect(foreign, 'no single user record outside the reader is read').toEqual([])
|
|
|
|
// THE INVITATIONS NAME THEIR ORGANIZATION, and the answer is drawn.
|
|
await expect(page.getByText(INVITATION.email, { exact: true })).toBeVisible()
|
|
expect(seen.invitations.length, 'the invitations were read').toBeGreaterThan(0)
|
|
for (const read of seen.invitations) {
|
|
expect(read.url, 'the invitations read names webby-ai').toContain(`owner=${ORG}`)
|
|
expect(read.status, 'the invitations read is answered, not refused').not.toBe(403)
|
|
}
|
|
|
|
expect(
|
|
await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth),
|
|
'no sideways page scroll',
|
|
).toBe(false)
|
|
expect(faults.filter((f) => /#418|hydrat/i.test(f)), 'no hydration mismatch').toEqual([])
|
|
await page.screenshot({ path: info.outputPath(`members-${size.width}.png`), fullPage: true })
|
|
})
|
|
}
|
|
|
|
// TWO WAYS IN, AND THE PAGE SAYS WHICH HAPPENED. An email invitation is sent
|
|
// by IAM and says so only when IAM answers that it sent it; a send IAM refuses
|
|
// keeps the invitation and hands over its link. The shared link is its own
|
|
// control, made on request, open to LINK_SEATS people.
|
|
test('an invitation is emailed or shared by link, and the page says which happened', async ({ page }, info) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 })
|
|
await iam(page)
|
|
const created: Record<string, unknown>[] = []
|
|
let refuse = false
|
|
await page.addInitScript(() => {
|
|
const w = window as unknown as { copied: string[] }
|
|
w.copied = []
|
|
Object.defineProperty(navigator, 'clipboard', {
|
|
configurable: true,
|
|
value: { writeText: async (t: string) => void w.copied.push(t) },
|
|
})
|
|
})
|
|
await page.route(
|
|
(url) => url.pathname === '/v1/iam/invitations',
|
|
(route) => {
|
|
if (route.request().method() === 'POST') {
|
|
const row = { ...route.request().postDataJSON(), code: `MINTED${created.length}QXRT7VBNK2HZ8P`, generated: true, usedCount: 0 }
|
|
created.push(row)
|
|
return route.fulfill(json(200, row))
|
|
}
|
|
return route.fulfill(json(200, { invitations: [...created, INVITATION], total: created.length + 1 }))
|
|
},
|
|
)
|
|
const sends: unknown[] = []
|
|
await page.route(
|
|
(url) => /^\/v1\/iam\/invitations\/[^/]+\/[^/]+\/send$/.test(url.pathname),
|
|
(route) => {
|
|
sends.push(route.request().postDataJSON())
|
|
const name = new URL(route.request().url()).pathname.split('/')[5]
|
|
const inv = [...created, INVITATION].find((r) => r.name === name)
|
|
return route.fulfill(
|
|
refuse ? json(503, { status: 503, error: 'no email delivery is configured here' }) : json(200, { sent: true, to: inv?.email }),
|
|
)
|
|
},
|
|
)
|
|
const copied = () => page.evaluate(() => (window as unknown as { copied: string[] }).copied)
|
|
const link = (inv: Record<string, unknown>) => `https://hanzo.id/join?client_id=hanzo-app&invite=${inv.code}&org=${ORG}`
|
|
|
|
await page.goto(`${base}${MEMBERS}`)
|
|
const address = page.getByLabel('Email address to invite')
|
|
await expect(address).toBeVisible({ timeout: 30_000 })
|
|
|
|
// Sent: IAM answered, and only then does the page say so.
|
|
await address.fill('[email protected]')
|
|
await page.getByRole('button', { name: 'Send invite' }).click()
|
|
await expect(page.getByText('Invitation sent to [email protected]')).toBeVisible()
|
|
expect(created[0]).toMatchObject({ owner: ORG, email: '[email protected]', quota: 1, state: 'Active' })
|
|
expect(sends).toEqual([{}])
|
|
|
|
// Refused: the invitation stands, the reason is said, and its link is offered.
|
|
refuse = true
|
|
await address.fill('[email protected]')
|
|
await page.getByRole('button', { name: 'Send invite' }).click()
|
|
const alert = page.getByRole('alert').filter({ hasText: '[email protected]' })
|
|
await expect(alert).toContainText('The invitation for [email protected] was made, and the email was not sent: no email delivery is configured here')
|
|
await expect(page.getByText(/Invitation sent to bo@webby\.ai/)).toHaveCount(0)
|
|
await alert.getByRole('button', { name: 'Copy invitation link' }).click()
|
|
await expect.poll(copied).toContain(link(created[1]))
|
|
|
|
// The shared link: made on request, open to anyone, counted, and copied.
|
|
await page.getByRole('button', { name: 'Create invite link' }).click()
|
|
await expect(page.getByText('Anyone with this link can join · 0 of 25 used.')).toBeVisible()
|
|
expect(created[2]).toMatchObject({ owner: ORG, quota: 25, state: 'Active' })
|
|
expect(created[2].email ?? '', 'the link is pinned to nobody').toBe('')
|
|
const shared = page.getByRole('region', { name: 'Invite link' })
|
|
await expect(shared.getByText(link(created[2]))).toBeVisible()
|
|
await shared.getByRole('button', { name: 'Copy invite link' }).click()
|
|
await expect.poll(async () => (await copied()).at(-1)).toBe(link(created[2]))
|
|
|
|
// A pinned invitation can be sent again from the list.
|
|
refuse = false
|
|
await page.getByRole('button', { name: 'Resend' }).last().click()
|
|
await expect(page.getByText(`Invitation sent to ${INVITATION.email}`)).toBeVisible()
|
|
await page.screenshot({ path: info.outputPath('invite-1440.png'), fullPage: true })
|
|
})
|
|
|
|
// THE ACCOUNT HAS ONE HOME. Every /account address is its Settings page, so a
|
|
// bookmark lands where the thing now lives (components/account/Forward.tsx).
|
|
for (const [from, to] of [
|
|
['/account', '/settings'],
|
|
['/account/settings', '/settings'],
|
|
['/account/organization', '/settings/organization/members'],
|
|
['/account/billing', '/settings/organization/billing'],
|
|
['/account/invoices', '/settings/organization/billing'],
|
|
['/account/usage', '/settings/organization/usage'],
|
|
]) {
|
|
test(`${from} is ${to}`, async ({ page }) => {
|
|
await iam(page)
|
|
await page.goto(`${base}${from}`)
|
|
await page.waitForURL(`${base}${to}`, { timeout: 20_000 })
|
|
})
|
|
}
|
|
})
|