Files
hanzo.ai/e2e/directory.spec.ts
zeekay 6c65e3e941 @hanzo/app 0.2.0: the app SDK, renamed from @hanzo/rooms
Every import, the catalog pin and the docs name @hanzo/app and
github.com/hanzoai/app. lib/rooms.ts is lib/app.ts and
components/rooms.tsx is components/app.tsx.
2026-10-22 19:07:20 -07:00

182 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The customize directory: Apps · Channels · Plugins · Skills.
//
// The taxonomy is the assertion. Those four words in that order are the ruled
// vocabulary for what a reader can add to this surface, and a fifth tab or a
// renamed one is a product decision that must not arrive by accident — which is
// exactly how "Connectors" survived in the source for as long as it did (the
// empty-catalog string still says it).
//
// The second claim is narrower and more valuable: NOTHING HERE INVENTS A LIST.
// Apps renders the integrations this site actually publishes, and Channels,
// Plugins and Skills say they have not asked rather than reporting zero. An
// absent list rendered as an empty one is the defect this file is pointed at.
//
// BASE_URL=http://localhost:3000 pnpm exec playwright test --project=chromium e2e/directory.spec.ts
import { test, expect } from '@playwright/test'
import { BASE, open, directory, settings, shot } from './app'
test.describe.configure({ timeout: 240_000 })
/** The ruled taxonomy, in source order. */
const TABS = ['Apps', 'Channels', 'Plugins', 'Skills'] as const
/** `Directory.tsx`, verbatim. The curly apostrophe and em dash are the file's. */
const NOT_ASKED = {
Channels:
'Channels are where a conversation reaches you. Nothing on this surface is bound to that list yet, ' +
'so rather than tell you there are none, it tells you it has not asked.',
Plugins:
'They live in your organisation’s registry, which this surface cannot read — so rather than tell you ' +
'the list is empty, it tells you it has not asked.',
} as const
test.describe('the customize directory', () => {
test('the taxonomy is the ruled four, in order and unbroken', async ({ page }) => {
await open(page, '/chat')
const panel = await directory(page)
await shot(page, 'directory-apps')
// Scoped to the panel on purpose: the shell repeats some of these words
// elsewhere, and an unscoped lookup matches twice.
for (const label of TABS) {
await expect(
panel.getByRole('button', { name: label, exact: true }),
`the taxonomy must offer "${label}"`,
).toBeVisible()
}
// ORDER, and nothing wedged between them. Asserting four separate
// presences cannot catch an insertion, and the four are no longer a strip
// of their own — they are a contiguous group inside a longer nav — so what
// is held is that they appear in this sequence with nothing in between.
const all = (
await panel.getByRole('button').evaluateAll((els) =>
els.map((e) => (e.getAttribute('aria-label') || e.textContent || '').trim()),
)
).filter(Boolean)
const first = all.indexOf(TABS[0])
expect(first, 'the taxonomy must appear in the panel at all').toBeGreaterThanOrEqual(0)
expect(
all.slice(first, first + TABS.length),
'the taxonomy is these four words in this order, with nothing between them',
).toEqual([...TABS])
// Apps is where the directory opens, and it says so about itself.
await expect(panel.getByRole('button', { name: 'Apps', exact: true })).toHaveAttribute('aria-current', 'true')
})
test('Apps rows are real, counted honestly, and each one opens a page', async ({ page }) => {
await open(page, '/chat')
const panel = await directory(page)
// The rows are read off the surface rather than compared to an imported
// fixture: `@hanzo/app/lib/integrations` reaches `lib/data/pricing.json` through
// the model-count module, and a JSON import with no type attribute is a
// hard error under Playwright's loader. Reading the rendered surface is
// also the stronger claim — a fixture proves the array, not the page.
const rows = panel.getByRole('link', { name: /^Open / })
// POLL, don't read once. The Directory compiles on a cold dev server the
// first time it is opened, so the rows can lag the panel by seconds; the
// list is static (`@hanzo/app/lib/integrations`), so once it renders it is complete.
// Reading count once caught the panel mid-compile and reported an empty
// catalog on a surface that has one.
await expect
.poll(() => rows.count(), { timeout: 30_000, message: 'the Apps tab must render the catalog, not an empty grid' })
.toBeGreaterThan(0)
const count = await rows.count()
// The tally must be the rows it drew. A count typed beside a list is how
// "600+ integrations" came to sit above nineteen of them.
await expect(
panel.getByText(`${count} ${count === 1 ? 'result' : 'results'}`),
`the tally must equal the ${count} rows drawn`,
).toBeVisible()
// Every row must POINT somewhere, in the one shape this site publishes.
// Whether the page answers is the next test's job — a cold dev server
// compiles each of these on first request, and one slow compile inside this
// test starved every assertion after it.
const names: string[] = []
for (let i = 0; i < count; i++) {
const row = rows.nth(i)
const href = await row.getAttribute('href')
expect(href, 'every row must link somewhere').toMatch(/^\/integrations\/[a-z0-9-]+$/)
names.push(((await row.getAttribute('aria-label')) ?? '').replace(/^Open /, ''))
}
// The search narrows to real rows rather than filtering a fixture.
await panel.getByRole('textbox', { name: 'Search apps' }).fill(names[0])
await expect(panel.getByRole('link', { name: `Open ${names[0]}` })).toBeVisible()
await expect(panel.getByText(/^\d+ results?$/)).toBeVisible()
// A query that matches nothing must say so in its own words, not render
// an empty grid under a tally of zero.
await panel.getByRole('textbox', { name: 'Search apps' }).fill('zzzznothingmatchesthis')
await expect(panel.getByText(/^Nothing matches/)).toBeVisible()
await shot(page, 'directory-apps-search')
})
test('an Apps row opens a page this site really serves', async ({ page }) => {
// Its own budget: against a dev server this is a COLD PAGE COMPILE, which
// has been measured at over ninety seconds, and the compile is the whole
// cost. Against the export it is a file read.
test.setTimeout(360_000)
// A directory of names that 404 is a catalog of dead ends, and this repo
// has shipped that twice already (118 dead docs links, 43 dead GitHub
// links). Sampled rather than exhaustive: against a dev server each row is
// a cold page compile, and one is enough to catch a broken convention.
await open(page, '/chat')
const panel = await directory(page)
const href = await panel.getByRole('link', { name: /^Open / }).first().getAttribute('href')
expect(href, 'the Apps tab must offer at least one row').toBeTruthy()
const res = await fetch(`${BASE}${href}`)
expect(res.status, `${href} is offered by the directory and must answer`).toBe(200)
const html = await res.text()
expect(html.length, `${href} answered 200 with an empty body`).toBeGreaterThan(500)
})
test('Channels, Plugins and Skills say they have not asked, never that there are none', async ({ page }) => {
await open(page, '/chat')
const panel = await directory(page)
for (const tab of ['Channels', 'Plugins', 'Skills'] as const) {
await panel.getByRole('button', { name: tab, exact: true }).click()
// The heading names the tab and states the condition — not connected,
// which is a different sentence from "you have none".
await expect(panel.getByText(`${tab} are not connected here yet`), `${tab} must state its condition`).toBeVisible()
// The body is the whole point: unknown must not read as zero.
const body = tab === 'Channels' ? NOT_ASKED.Channels : NOT_ASKED.Plugins
await expect(panel.getByText(body), `${tab} must say it has not asked`).toBeVisible()
// And it offers the place that DOES know.
await expect(panel.getByRole('link', { name: 'Open in console' })).toHaveAttribute(
'href',
`https://platform.hanzo.ai/${tab.toLowerCase()}`,
)
// Nothing may claim a count it did not read.
await expect(panel.getByText(/^\d+ results?$/), `${tab} must not report a tally`).toHaveCount(0)
await shot(page, `directory-${tab.toLowerCase()}`)
}
})
test('Settings offers the personal agent under Preferences', async ({ page }) => {
await open(page, '/chat')
const panel = await settings(page)
await expect(panel.getByText('Preferences', { exact: true }), 'the group must be named').toBeVisible()
const row = panel.getByRole('button', { name: 'Personal agent', exact: true })
await expect(row, 'the personal agent is where a reader is told it will live').toBeVisible()
await row.click()
// Selecting it must say the true thing — that nothing is bound yet — rather
// than presenting controls that change nothing.
await expect(panel.getByText(/Nothing is bound to this surface yet/)).toBeVisible()
await shot(page, 'settings-personal-agent')
})
})