mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
Every word the landing, the header menus, the phone sheet, the footer,
the help corner and the way in (sign-in, sign-up, the reset, the
invitation, the callback, onboarding, the 404) write goes through
@hanzo/i18n's t(): one sentence is one message, its figures, names and
links arguments; copy written as data is phrase('…') where written and
t(label) where drawn. Plans, the chart, the API states and the lines
lib/kai and lib/enso build for the landing do the same.
Right to left: <html lang dir> is set before the first paint (the
package's bootScript first in the head); a figure, clock, range or
price outside a sentence is isolated; sides are logical gui props, the
layout's [data-join] corners logical, a bar grows from the start;
arrows along the line mirror (Onward for "words ->" links). The repo
dates read in the reader's language.
Gates select by slot, never by an English label (rtl, help). New gate
translated.spec.ts: in Arabic the landing at 1440 and 390, every menu
open, the phone sheet, sign-in, sign-up and the app signed in hold no
authored English, nothing scrolls sideways, every range is isolated;
French the same for English's commonest words. What another package
draws with no words prop is listed by name (@hanzo/ui's Footer label,
@hanzo/build's composer row and suggestions).
@hanzo/i18n 0.5.3 (the catalogs: 1,443 keys, every offered locale
complete), @hanzo/app 0.2.1 (the app's words). The link gate imports
the terms versions from @hanzo/app, the one SDK main now resolves.
187 lines
8.6 KiB
TypeScript
187 lines
8.6 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
||
import { serveExport } from './export'
|
||
import { signIn } from '../session'
|
||
import { DOCS } from '../../components/home/nav-data'
|
||
|
||
/**
|
||
* HELP AND PREFERENCES ARE IN THE CORNER OF EVERY SITE PAGE.
|
||
*
|
||
* @hanzo/ui's `Corner`, mounted once (app/providers.tsx, components/shell/
|
||
* help.tsx): a `?` at the window's bottom start corner opens one card, Help
|
||
* first (Get help, Docs, Contact us), then Theme and Language. No Currency:
|
||
* hanzo.ai bills in US dollars only. A theme chosen there is the page's and
|
||
* outlives a reload; a language chosen there sets `<html lang dir>`, and under
|
||
* Arabic the corner is bottom-right. The app keeps its account at that corner,
|
||
* so the app draws none; nor does a page whose cookie banner still asks.
|
||
* Measured on the shipped bytes, nothing off this machine answering.
|
||
*/
|
||
|
||
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 LAPTOP = { width: 1280, height: 800 }
|
||
const PHONE = { width: 390, height: 844 }
|
||
|
||
/** Nothing off this machine answers: the catalogs are the export's own chunks. */
|
||
const local = (page: Page) =>
|
||
page.route('**/*', (route) => (new URL(route.request().url()).origin === base ? route.continue() : route.abort()))
|
||
|
||
/** The `?` and the card it opens, found by the corner's own slots: their names are the reader's language. */
|
||
const trigger = (page: Page) => page.locator('[data-slot="corner-trigger"]')
|
||
const card = (page: Page) => page.locator('[data-slot="corner-card"]')
|
||
|
||
/** A page, hydrated, its corner drawn. */
|
||
async function read(page: Page, path: string) {
|
||
await page.goto(`${base}${path}`)
|
||
await page.locator('html[data-hydrated]').waitFor({ state: 'attached', timeout: 30_000 })
|
||
await expect(trigger(page)).toBeVisible({ timeout: 20_000 })
|
||
}
|
||
|
||
/** Where the trigger stands, against the window. */
|
||
async function where(page: Page) {
|
||
const t = (await trigger(page).boundingBox())!
|
||
const { width, height } = page.viewportSize()!
|
||
return { start: t.x, end: width - (t.x + t.width), foot: height - (t.y + t.height) }
|
||
}
|
||
|
||
/** How far the page scrolls sideways. */
|
||
const wide = (page: Page) => page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
|
||
|
||
test('the corner opens Help: Get help, Docs and Contact us, the docs in a new tab, and no Currency', async ({ page }, info) => {
|
||
await local(page)
|
||
await page.setViewportSize(LAPTOP)
|
||
await read(page, '/pricing')
|
||
const at = await where(page)
|
||
expect(at.start, 'at the start edge').toBeLessThanOrEqual(16)
|
||
expect(at.foot, 'at the foot').toBeLessThanOrEqual(16)
|
||
|
||
await expect(trigger(page)).toHaveAccessibleName('Help and preferences')
|
||
await trigger(page).click()
|
||
await expect(card(page)).toBeVisible()
|
||
await expect(card(page)).toHaveAccessibleName('Help and preferences')
|
||
const help = card(page).getByRole('list', { name: 'Help' })
|
||
await expect(help.getByRole('link')).toHaveCount(3)
|
||
await expect(help.getByRole('link', { name: 'Get help' })).toHaveAttribute('href', '/support')
|
||
const docs = help.getByRole('link', { name: 'Docs (opens in a new tab)' })
|
||
await expect(docs).toHaveAttribute('href', DOCS)
|
||
await expect(docs).toHaveAttribute('target', '_blank')
|
||
await expect(docs).toHaveAttribute('rel', /noopener/)
|
||
await expect(help.getByRole('link', { name: 'Contact us' })).toHaveAttribute('href', '/contact')
|
||
|
||
await expect(card(page).getByRole('radiogroup', { name: 'Theme' })).toBeVisible()
|
||
await expect(card(page).locator('[data-corner-row="language"]')).toContainText('English')
|
||
await expect(card(page).locator('[data-corner-row="currency"]')).toHaveCount(0)
|
||
await expect(card(page).getByText('Currency')).toHaveCount(0)
|
||
await page.screenshot({ path: info.outputPath('help-1280.png') })
|
||
|
||
await page.keyboard.press('Escape')
|
||
await expect(card(page)).toHaveCount(0)
|
||
await expect(trigger(page)).toBeFocused()
|
||
})
|
||
|
||
test('a theme chosen in the corner is the page’s, and stays after a reload', async ({ page }) => {
|
||
await local(page)
|
||
await page.emulateMedia({ colorScheme: 'dark' })
|
||
await page.setViewportSize(LAPTOP)
|
||
await read(page, '/')
|
||
await expect(page.locator('html')).not.toHaveClass(/(^|\s)light(\s|$)/)
|
||
|
||
await trigger(page).click()
|
||
await card(page).getByRole('radio', { name: 'Light' }).click()
|
||
await expect(page.locator('html')).toHaveClass(/(^|\s)light(\s|$)/)
|
||
await expect(card(page).getByRole('radio', { name: 'Light' })).toHaveAttribute('aria-checked', 'true')
|
||
|
||
await page.reload()
|
||
await page.locator('html[data-hydrated]').waitFor({ state: 'attached', timeout: 30_000 })
|
||
await expect(page.locator('html'), 'the choice is painted again').toHaveClass(/(^|\s)light(\s|$)/)
|
||
await expect(trigger(page)).toBeVisible({ timeout: 20_000 })
|
||
await trigger(page).click()
|
||
await expect(card(page).getByRole('radio', { name: 'Light' })).toHaveAttribute('aria-checked', 'true')
|
||
await card(page).getByRole('radio', { name: 'Dark' }).click()
|
||
await expect(page.locator('html')).not.toHaveClass(/(^|\s)light(\s|$)/)
|
||
})
|
||
|
||
for (const size of [LAPTOP, PHONE]) {
|
||
test(`choosing Arabic sets lang and dir, and the corner stands bottom-right, at ${size.width}`, async ({ page }, info) => {
|
||
await local(page)
|
||
await page.setViewportSize(size)
|
||
await read(page, '/pricing')
|
||
await trigger(page).click()
|
||
await card(page).locator('[data-corner-row="language"]').click()
|
||
// A long list is searched, and its English names are searched too.
|
||
await card(page).getByRole('combobox').fill('Arabic')
|
||
await card(page).getByRole('option', { name: /^العربية\s*(Arabic)?$/ }).click()
|
||
await expect(page.locator('html')).toHaveAttribute('lang', 'ar', { timeout: 20_000 })
|
||
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl')
|
||
await page.keyboard.press('Escape')
|
||
await expect(card(page)).toHaveCount(0)
|
||
const at = await where(page)
|
||
expect(at.end, 'at the end edge, the start of a right-to-left line').toBeLessThanOrEqual(16)
|
||
expect(at.foot).toBeLessThanOrEqual(16)
|
||
expect(await wide(page)).toBeLessThanOrEqual(0)
|
||
await page.screenshot({ path: info.outputPath(`help-ar-${size.width}.png`) })
|
||
|
||
// Kept: the next page opens in it.
|
||
await read(page, '/')
|
||
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl', { timeout: 20_000 })
|
||
expect((await where(page)).end).toBeLessThanOrEqual(16)
|
||
})
|
||
}
|
||
|
||
for (const path of ['/', '/pricing']) {
|
||
test(`at 390 the corner adds no sideways scroll on ${path}, shut or open`, async ({ page }) => {
|
||
await local(page)
|
||
await page.setViewportSize(PHONE)
|
||
await read(page, path)
|
||
expect(await wide(page), 'shut').toBeLessThanOrEqual(0)
|
||
await trigger(page).click()
|
||
const c = (await card(page).boundingBox())!
|
||
expect(c.x, 'the card inside the window').toBeGreaterThanOrEqual(0)
|
||
expect(c.x + c.width).toBeLessThanOrEqual(PHONE.width)
|
||
expect(c.y).toBeGreaterThanOrEqual(0)
|
||
expect(await wide(page), 'open').toBeLessThanOrEqual(0)
|
||
})
|
||
}
|
||
|
||
test('the app draws no corner: its rail keeps the account there', async ({ page }) => {
|
||
await local(page)
|
||
await signIn(page)
|
||
await page.route(
|
||
(u) => u.pathname.startsWith('/v1/') && !u.pathname.startsWith('/v1/iam/oauth/') && !u.pathname.startsWith('/v1/billing/'),
|
||
(route) => (new URL(route.request().url()).pathname.endsWith('/stream') ? route.fulfill({ status: 200, contentType: 'text/event-stream', body: '' }) : route.fulfill({ json: {} })),
|
||
)
|
||
await page.setViewportSize(LAPTOP)
|
||
for (const path of ['/chat', '/']) {
|
||
await page.goto(`${base}${path}`)
|
||
await expect(page.locator('[data-slot="frame"]')).toBeVisible({ timeout: 30_000 })
|
||
await expect(page.locator('[data-slot="me"]').first()).toBeVisible()
|
||
await expect(page.locator('[data-slot="corner"]'), `no corner at ${path}`).toHaveCount(0)
|
||
}
|
||
// A site page is still the site's, signed in or not.
|
||
await read(page, '/pricing')
|
||
})
|
||
|
||
test.describe('the cookie question', () => {
|
||
// Unanswered.
|
||
test.use({ storageState: { cookies: [], origins: [] } })
|
||
|
||
test('comes first: no corner while the banner asks, and the corner once it is answered', async ({ page }) => {
|
||
await local(page)
|
||
await page.setViewportSize(PHONE)
|
||
await page.goto(`${base}/pricing`)
|
||
await page.locator('html[data-hydrated]').waitFor({ state: 'attached', timeout: 30_000 })
|
||
const banner = page.getByRole('dialog', { name: 'Cookie preferences' })
|
||
await expect(banner).toBeVisible({ timeout: 20_000 })
|
||
await expect(page.locator('[data-slot="corner"]')).toHaveCount(0)
|
||
await banner.getByRole('button', { name: 'Accept all' }).click()
|
||
await expect(banner).toHaveCount(0)
|
||
await expect(trigger(page)).toBeVisible()
|
||
})
|
||
})
|