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