Files
hanzo.ai/e2e/gates/hosted.spec.ts
zeekay 35c6961ade apps: /apps is a root of the app, for the apps in beta that draw inside it
The edge answers every path under /apps with its one page (lib/edge.ts `APP`,
`ROOTS`, `DATA`), the page signs a stranger in to the address and draws the app
for a member, and hosted.spec.ts holds who sees what: a member holding beta.crm
alone has CRM and its live host in the pane, staff and a SuperAdmin have all
five, a reader the switch is off for has the request path, a stranger is sent to
sign in and back, a phone has the apps as rows.

Needs @hanzo/app >= 0.2.38 (lib/hosted).

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 03:29:13 -07:00

101 lines
5.4 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { serveExport } from './export'
import { answer, signIn, type Who } from '../session'
/**
* THE APPS THAT DRAW INSIDE THE APP, at /apps/<id> (@hanzo/app lib/hosted).
*
* CRM, Commerce, CMS, Analytics and Insights stay standalone apps on hosts of
* their own. The app gives each reader who `opens` one a mark on the rail and a
* pane that frames the app's live host (`?embed=1`), and everyone else the Betas
* list's request path. The export is served under hanzo.ai, as host.spec.ts does,
* because a page on a developer's own machine opens every app and a flag would
* measure nothing; nothing here reaches the apps' hosts, a frame's page included.
*/
const MAPPED = ['hanzo.ai']
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())
const at = (path: string) => `http://hanzo.ai:${port}${path}`
const ALL = ['crm', 'commerce', 'cms', 'analytics', 'insights']
/** The reader, the cloud holding exactly these switches for them, and nothing off this machine answering. */
async function reader(page: Page, who: Who, ...held: string[]) {
await page.route('**/*', (route) => (new URL(route.request().url()).port === port ? route.continue() : route.abort()))
await signIn(page, who)
await answer(page, '/v1/flag/platform', { flags: Object.fromEntries(held.map((id) => [`beta.${id}`, true])) })
await answer(page, '/v1/marketing/beta', { betas: [] })
await answer(page, '/v1/flag/optin', { on: false, blocked: false, public: [] })
}
const ada: Who = { name: 'ada', email: '[email protected]' }
test('a member with beta.crm alone has CRM on the rail and its live host in the pane, and nothing else', async ({ page }) => {
await reader(page, ada, 'crm')
await page.goto(at('/apps/crm'))
await expect(page.locator('[data-slot="rail-app-crm"]')).toBeVisible({ timeout: 30_000 })
for (const id of ALL.slice(1)) await expect(page.locator(`[data-slot="rail-app-${id}"]`)).toHaveCount(0)
await expect(page.locator('[data-slot="rail-app-crm"]')).toHaveAttribute('aria-current', 'page')
const frames = page.locator('iframe')
await expect(frames).toHaveCount(1)
await expect(frames.first()).toHaveAttribute('src', 'https://crm.hanzo.ai/?embed=1')
// No sidebar beside it, as Tabs and Betas.
await expect(page.getByText('New chat')).toHaveCount(0)
})
test('staff, for whom every beta opens, have all five on the rail, each going to its own address', async ({ page }) => {
await reader(page, { name: 'zach', email: '[email protected]', orgs: [{ org: 'admin', role: 'admin' }] }, ...ALL)
await page.goto(at('/apps/cms'))
for (const id of ALL) await expect(page.locator(`[data-slot="rail-app-${id}"]`)).toBeVisible({ timeout: 30_000 })
await expect(page.locator('iframe')).toHaveAttribute('src', 'https://cms.hanzo.ai/admin?embed=1')
await page.locator('[data-slot="rail-app-insights"]').click()
await expect(page).toHaveURL(at('/apps/insights'))
await expect(page.locator('iframe')).toHaveAttribute('src', 'https://insights.hanzo.ai/?embed=1')
})
test('a SuperAdmin has all five with no switch held', async ({ page }) => {
await reader(page, { sub: 'z', name: 'z', email: '[email protected]', org: 'admin' })
await page.goto(at('/apps/commerce'))
for (const id of ALL) await expect(page.locator(`[data-slot="rail-app-${id}"]`)).toBeVisible({ timeout: 30_000 })
await expect(page.locator('iframe')).toHaveAttribute('src', 'https://commerce-admin.hanzo.ai/admin/?embed=1')
})
test('a reader the switch is off for is shown the request path at the address, and no frame', async ({ page }) => {
await reader(page, ada)
await page.goto(at('/apps/crm'))
await expect(page.locator('[data-slot="hosted-request"]')).toBeVisible({ timeout: 30_000 })
await expect(page.locator('iframe')).toHaveCount(0)
await expect(page.locator('[data-slot="rail-app-crm"]')).toHaveCount(0)
await expect(page.locator('[data-slot="beta-request"]')).toBeVisible()
})
test('a stranger at an address under /apps is sent to sign in and back to it, and nothing is framed', async ({ page }) => {
await page.route('**/*', (route) => (new URL(route.request().url()).port === port ? route.continue() : route.abort()))
await page.goto(at('/apps/crm'))
await expect(page).toHaveURL(/\/(login|signup)\?next=%2Fapps%2Fcrm$/, { timeout: 30_000 })
await expect(page.locator('iframe')).toHaveCount(0)
})
test('on a phone the pane is full-bleed under the bar, and the menu lists the apps as rows', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await reader(page, ada, 'crm', 'analytics')
await page.goto(at('/apps/analytics'))
const frame = page.locator('iframe')
await expect(frame).toBeVisible({ timeout: 30_000 })
const box = (await frame.boundingBox())!
expect(box.width).toBeGreaterThanOrEqual(388)
expect(await page.evaluate(() => document.scrollingElement!.scrollWidth <= window.innerWidth)).toBe(true)
await page.getByRole('button', { name: 'Open sidebar' }).click()
await expect(page.locator('[data-slot="place-app-crm"]')).toBeVisible()
await expect(page.locator('[data-slot="place-app-analytics"]')).toHaveAttribute('aria-current', 'page')
await expect(page.locator('[data-slot="place-app-cms"]')).toHaveCount(0)
})