Files
store/e2e/store.spec.ts
zeekayandHanzo Dev f7785887de The 8.x rewrite compiled; this is what it was actually rendering
@hanzo/gui accepts a prop it does not recognise, and lets one prop silently
undo another. There is no error and no type failure, so the green `next build`
the last commit reported proved only that the imports resolved. Driving the
built export in a browser and reading computed styles off it — the only
evidence that tells "styled" apart from "the prop went nowhere" — found six
defects, every one of them visible on the page.

The card body was collapsed to zero. `flex={1}` on `CardContent` is React
Native's spelling and compiles to `flex: 1 1 0px` — a ZERO basis. The card is a
column whose height comes from its own content, so a zero-basis body
contributed nothing: the card sized itself from header plus footer alone, the
body measured 0px, every description vanished, and the tag row and byline
painted outside the box and under the buttons. All 202 cards. `grow={1}` leaves
the basis at `auto`, so the body is as tall as its text and then takes the
slack the grid's stretch hands the card, which is what pins every footer to the
same line. `height="100%"` on the Card went with it: `.cards` is a grid and a
grid stretches its items to the row already.

Every panel heading on the app page was body text. `size="$6"` on `CardTitle`
is a VARIANT, not a style prop: it expands to the whole typographic row for
that step — size, leading, tracking AND weight — and the weight column of the
Hanzo ladder is 400 at every step, so it compiled to
`font-weight: var(--f-weight-6)` and replaced the 600 CardTitle declares for
itself. Measured 400. The weight is restated after it; `size` stays, because it
is the only prop that pairs 17px with its 24px leading.

Every button that navigates was underlined and announced as a button. `asChild`
renders the control as the anchor it wraps — right markup — and picks up the
user-agent underline, and gui's Button frame declares `role="button"` and
stamps it on whatever it renders, so five `<a href>` told assistive tech they
were buttons. The underline is refused once through `hanzo-button`, the class
@hanzo/ui publishes for hosts hooking the control from CSS. The role is stated
once in `LinkButton`, which is now the store's one way to say "a button that
navigates" and picks its anchor from the destination: a store path routes on
the client, `hanzo://` hands off in place, GitHub opens a tab.

The mark disappeared on a phone. As a plain flex child it took its share of the
squeeze: 48px on a desktop, 17px at 390, and 0 at 320.

Every filter read "All Types ( 202 )". Written as JSX children, the source's own
newlines around the parentheses fold into text. The label is one string now —
and the type row and the category row, which were the same twenty lines twice,
are one `Facet` reading a field.

Every icon is a broken image. The urls in the data are presigned R2 links
inherited from the upstream Shinkai store, signed 2025-11-05 with a 24-hour
expiry, so all 202 have been dead since the day after. The grid had no fallback
and rendered the browser's glyph with the alt text spilling out of the box; the
app page had one, but only for a MISSING url, never a failing one. Both are the
same fact, so `AppIcon` states it once. `Screenshots` applies the same rule to
the gallery, and takes the panel with it when nothing survives.

The e2e suite tested the Tailwind DOM that no longer exists — `[class*="grid"]`,
`[class*="rounded-xl"]`, a title of "Hanzo Store", a hard-coded `#7c3aed` — and
nine of its twenty assertions were `expect(count).toBeGreaterThanOrEqual(0)`,
which cannot fail. It is replaced by one spec that reads computed styles and
measured boxes, and asserts these six defects specifically. Each was verified
to FAIL when its defect is put back: body height 0 against `> 60`, mark width 0
against 48, an extra `underline`, an h2 weight that is not 600.

typescript stays on 5.9.3 for the reason the previous commit gives; `tsgo` was
a leftover of the removed native-preview and the script runs `tsc`.

Verified: `next build` compiles and exports 209 static pages; `tsc --noEmit`
clean; `playwright test --project=chromium` 21 passed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 17:55:29 -07:00

292 lines
11 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.
import { test, expect, type Page } from '@playwright/test'
/**
* What the store has to keep being true.
*
* These assert the things that ACTUALLY broke in the move to @hanzo/gui, because
* gui silently ignores a prop it does not recognise and silently lets one prop
* undo another: no error, no type failure, and the build stays green while the
* page renders wrong. So every check here reads a COMPUTED style or a measured
* box off the running page — the only evidence that tells "styled" apart from
* "the prop went nowhere".
*
* Nothing here asserts that a count is `>= 0`. A test that cannot fail is not a
* test, and the suite this replaced was mostly those.
*/
/** The data arrives over fetch, so every check waits for a card, not for load. */
async function store(page: Page) {
await page.goto('/')
await page.waitForSelector('[data-slot="card"]')
}
const box = (page: Page, selector: string) =>
page
.locator(selector)
.first()
.evaluate((el) => {
const r = el.getBoundingClientRect()
return { width: Math.round(r.width), height: Math.round(r.height) }
})
const columns = (page: Page, selector: string) =>
page
.locator(selector)
.evaluate((el) => getComputedStyle(el).gridTemplateColumns.split(' ').length)
test.describe('store', () => {
test('every card shows its description, tags and byline', async ({ page }) => {
await store(page)
// `flex={1}` on the card body compiled to `flex: 1 1 0px` and collapsed it to
// zero: the description vanished, and the tag row and byline painted OUTSIDE
// the box, over the buttons. The body has to be as tall as its own contents.
const body = await box(page, '[data-slot="card-content"]')
expect(body.height).toBeGreaterThan(60)
const description = page.locator('[data-slot="card-content"] > *').first()
await expect(description).toBeVisible()
expect((await description.textContent())?.trim().length).toBeGreaterThan(0)
})
test('a long description is held to three lines', async ({ page }) => {
await store(page)
const lines = await page.evaluate(() => {
const bodies = [...document.querySelectorAll('[data-slot="card-content"]')]
const longest = bodies
.map((el) => el.firstElementChild!)
.sort((a, b) => b.textContent!.length - a.textContent!.length)[0]
return (
longest.getBoundingClientRect().height / parseFloat(getComputedStyle(longest).lineHeight)
)
})
expect(Math.round(lines)).toBe(3)
})
test('every footer in a row sits on the same line', async ({ page }) => {
await store(page)
const offsets = await page.evaluate(() =>
[...document.querySelectorAll('[data-slot="card"]')].slice(0, 3).map((card) =>
Math.round(
card.querySelector('[data-slot="card-footer"]')!.getBoundingClientRect().top -
card.getBoundingClientRect().top,
),
),
)
expect(new Set(offsets).size).toBe(1)
})
test('a button that navigates is not underlined', async ({ page }) => {
await store(page)
// `asChild` renders the control as the anchor it wraps — correct markup, and
// the user-agent underline on every label.
const decorations = await page.evaluate(() => [
...new Set(
[...document.querySelectorAll('[data-slot="button"]')].map(
(el) => getComputedStyle(el).textDecorationLine,
),
),
])
expect(decorations).toEqual(['none'])
})
test('a filter reads as one phrase, and filters to the count it declares', async ({ page }) => {
await store(page)
// Written as JSX children the source's own newlines folded into the label and
// every button read "Agent ( 23 )".
const agent = page.getByRole('button', { name: /^Agent \(\d+\)$/ })
await expect(agent).toBeVisible()
const declared = Number((await agent.textContent())!.match(/\((\d+)\)/)![1])
await agent.click()
await expect(page.getByText(`${declared} apps found`)).toBeVisible()
expect(await page.locator('[data-slot="card"]').count()).toBe(declared)
})
test('search narrows the grid, and says so when nothing matches', async ({ page }) => {
await store(page)
const all = await page.locator('[data-slot="card"]').count()
const search = page.getByPlaceholder('Search apps…')
await search.fill('audio')
await expect(page.locator('[data-slot="card"]').first()).toBeVisible()
const narrowed = await page.locator('[data-slot="card"]').count()
expect(narrowed).toBeGreaterThan(0)
expect(narrowed).toBeLessThan(all)
await search.fill('nothing-matches-this-query')
await expect(page.getByText('No apps found matching your criteria')).toBeVisible()
})
test('artwork that does not load falls back to the initial', async ({ page }) => {
await store(page)
// Every icon in the data is a presigned url that expired in 2025; with no
// fallback the grid rendered 202 broken-image glyphs, alt text spilling out
// of the box.
await expect(page.locator('.app-icon').first()).toBeVisible()
expect(await box(page, '.app-icon')).toEqual({ width: 64, height: 64 })
})
test('the store hands off to the desktop app over one protocol', async ({ page }) => {
await store(page)
const install = page.getByRole('link', { name: /Install in Hanzo/ }).first()
expect(await install.getAttribute('href')).toMatch(/^hanzo:\/\/install\/[^?]+\?name=/)
})
test('the mark keeps its size on a phone', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 })
await store(page)
// As a plain flex child the mark took its share of the squeeze and rendered
// 0px wide at this width.
expect((await box(page, '.brand > :first-child')).width).toBe(48)
})
test('nothing scrolls the page sideways', async ({ page }) => {
for (const width of [1280, 768, 390, 320]) {
await page.setViewportSize({ width, height: 800 })
await store(page)
const [scrollWidth, clientWidth] = await page.evaluate(() => [
document.body.scrollWidth,
document.body.clientWidth,
])
expect(scrollWidth, `body overflows at ${width}px`).toBe(clientWidth)
}
})
test('the grid is one column on a phone and three on a desktop', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await store(page)
expect(await columns(page, '.cards')).toBe(1)
await page.setViewportSize({ width: 1280, height: 800 })
await store(page)
expect(await columns(page, '.cards')).toBe(3)
})
test('no Tailwind utility or Radix attribute reaches the page', async ({ page }) => {
await store(page)
const residue = await page.evaluate(() => {
const found = new Set<string>()
const utility =
/^(flex$|grid$|hidden$|block$|text-|bg-|border-|p-\d|px-|py-|m-\d|mx-|my-|gap-|w-\d|h-\d|min-|max-|rounded-|items-|justify-|space-|leading-|tracking-|shadow-|absolute$|relative$|sticky$|overflow-|z-\d)/
for (const el of document.querySelectorAll('*')) {
for (const name of el.classList) if (utility.test(name)) found.add(name)
for (const attr of el.attributes) if (attr.name.startsWith('data-radix')) found.add(attr.name)
}
return [...found]
})
expect(residue).toEqual([])
})
test('the wallet modal reads the store’s tokens rather than a palette of its own', async ({
page,
}) => {
await store(page)
const bound = await page.evaluate(() => {
const root = getComputedStyle(document.documentElement)
const rk = getComputedStyle(document.querySelector('[data-rk]')!)
return {
primary: root.getPropertyValue('--primary').trim(),
accent: rk.getPropertyValue('--rk-colors-accentColor').trim(),
border: root.getPropertyValue('--border').trim(),
modalBorder: rk.getPropertyValue('--rk-colors-modalBorder').trim(),
}
})
expect(bound.primary).not.toBe('')
expect(bound.accent).toBe(bound.primary)
expect(bound.modalBorder).toBe(bound.border)
})
})
test.describe('app page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/apps/audio-insight')
})
test('a panel heading is a heading, not body text', async ({ page }) => {
await page.waitForSelector('h2')
// `size="$6"` is a VARIANT: it expands to the whole typographic row for that
// step, and the weight column of the Hanzo ladder is 400 at every step, so it
// quietly replaced the 600 CardTitle declares for itself.
const weights = await page.evaluate(() =>
[...document.querySelectorAll('h2')].map((el) => getComputedStyle(el).fontWeight),
)
expect(weights.length).toBeGreaterThan(0)
expect([...new Set(weights)]).toEqual(['600'])
})
test('the title is a real h1 at display size', async ({ page }) => {
const h1 = page.locator('h1')
await expect(h1).toHaveText('Audio Insight')
expect(
await h1.evaluate((el) => {
const c = getComputedStyle(el)
return { size: c.fontSize, weight: c.fontWeight }
}),
).toEqual({ size: '32px', weight: '700' })
})
test('commands are typeset in mono', async ({ page }) => {
await page.waitForSelector('pre.code')
expect(
await page
.locator('pre.code')
.first()
.evaluate((el) => getComputedStyle(el).fontFamily),
).toContain('Geist Mono')
})
test('the record and the rail are two columns on a desktop, one on a phone', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 })
await page.waitForSelector('.detail')
expect(await columns(page, '.detail')).toBe(2)
await page.setViewportSize({ width: 390, height: 800 })
expect(await columns(page, '.detail')).toBe(1)
})
test('the install button carries the same deep link the grid does', async ({ page }) => {
const install = page.getByRole('link', { name: /Open in Hanzo Desktop/ })
expect(await install.getAttribute('href')).toBe(
'hanzo://install/audio-insight?name=Audio+Insight&type=Agent',
)
})
})
test.describe('documents', () => {
for (const [path, title] of [
['/guidelines', 'App Store Guidelines'],
['/terms', 'Terms of Service'],
['/privacy', 'Privacy Policy'],
]) {
test(`${path} is typeset prose wearing the store's chrome`, async ({ page }) => {
await page.goto(path)
await expect(page.locator('.doc h1')).toHaveText(title)
await expect(page.getByRole('link', { name: 'Back to Store' })).toBeVisible()
// The footer enumerates the site's routes in one place, so all three
// documents are reachable from all three.
await expect(page.locator('.site-footer a')).toHaveCount(3)
// Prose leading, not UI leading — the whole reason these are CSS and not
// component vocabulary.
const leading = await page.locator('.doc').evaluate((el) => {
const c = getComputedStyle(el)
return parseFloat(c.lineHeight) / parseFloat(c.fontSize)
})
expect(leading).toBeGreaterThan(1.5)
})
}
})