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.
209 lines
10 KiB
TypeScript
209 lines
10 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
||
import { serveExport } from './export'
|
||
import { signIn } from '../session'
|
||
|
||
/**
|
||
* ARABIC RUNS RIGHT TO LEFT, AND THE CHROME TURNS WITH IT.
|
||
*
|
||
* The language is @hanzo/i18n's (app/providers.tsx `Language`): the export is
|
||
* English, and a saved choice sets `<html lang dir>` once the page runs. Under
|
||
* `dir="rtl"` the bar, its menus, the phone sheet and the settings column are
|
||
* mirror images of the English ones, because their sides are logical
|
||
* properties, and an arrow that points along the line turns
|
||
* (`data-i18n-mirror`). This reads geometry off the rendered export, in both
|
||
* directions, at a laptop and a phone: a property left physical puts one
|
||
* control on the wrong side and nothing else fails.
|
||
*/
|
||
|
||
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: 1440, height: 900 }
|
||
const PHONE = { width: 390, height: 844 }
|
||
|
||
/** The key @hanzo/i18n keeps a reader's language under on this browser. */
|
||
const kept = (who = 'guest') => `hanzo.locale.v1:${who}`
|
||
|
||
/** 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()))
|
||
|
||
/** A page, in a language this browser chose before. */
|
||
async function read(page: Page, path: string, locale: 'en-US' | 'ar', who = 'guest') {
|
||
await page.addInitScript(([key, value]) => localStorage.setItem(key, value), [kept(who), locale] as const)
|
||
await page.goto(`${base}${path}`)
|
||
await page.locator('html[data-hydrated]').waitFor({ state: 'attached', timeout: 30_000 })
|
||
await expect(page.locator('html')).toHaveAttribute('dir', locale === 'ar' ? 'rtl' : 'ltr', { timeout: 20_000 })
|
||
await expect(page.locator('html')).toHaveAttribute('lang', locale)
|
||
}
|
||
|
||
/** Where a box stands: its two edges and its middle, in CSS px. */
|
||
type Box = { left: number; right: number; mid: number }
|
||
async function box(page: Page, selector: string): Promise<Box> {
|
||
const r = await page.locator(selector).first().evaluate((el) => {
|
||
const b = el.getBoundingClientRect()
|
||
return { left: b.left, right: b.right }
|
||
})
|
||
return { ...r, mid: (r.left + r.right) / 2 }
|
||
}
|
||
|
||
/** What in `root` reaches past the window's sides. */
|
||
const past = (page: Page, root: string) =>
|
||
page.evaluate((sel) => {
|
||
const out: string[] = []
|
||
const w = document.documentElement.clientWidth
|
||
for (const scope of Array.from(document.querySelectorAll(sel))) {
|
||
for (const el of [scope, ...Array.from(scope.querySelectorAll('*'))]) {
|
||
const b = el.getBoundingClientRect()
|
||
if (b.width > 0 && b.height > 0 && (b.left < -1 || b.right > w + 1)) out.push(`${el.tagName.toLowerCase()} ${Math.round(b.left)}..${Math.round(b.right)}`)
|
||
}
|
||
}
|
||
return out.slice(0, 5)
|
||
}, root)
|
||
|
||
const HEADER = 'header[data-slot="masthead"]'
|
||
const NAME = `${HEADER} a[data-slot="home"]`
|
||
|
||
for (const locale of ['en-US', 'ar'] as const) {
|
||
const rtl = locale === 'ar'
|
||
test(`${locale}: the bar, its menus and the footer at 1440`, async ({ page }, info) => {
|
||
await local(page)
|
||
await page.setViewportSize(LAPTOP)
|
||
await read(page, '/support', locale)
|
||
await page.screenshot({ path: info.outputPath(`chrome-${locale}-1440.png`) })
|
||
|
||
// The row, start to end: the name, the five menus, search, Log in, Try Hanzo.
|
||
const order = [
|
||
await box(page, NAME),
|
||
await box(page, `${HEADER} nav[data-slot="menus"]`),
|
||
await box(page, `${HEADER} button[data-slot="search"]`),
|
||
await box(page, `${HEADER} button[aria-controls="menu-login"]`),
|
||
await box(page, `${HEADER} a[href="/signup"]`),
|
||
].map((b) => b.mid)
|
||
const along = rtl ? [...order].reverse() : order
|
||
expect(along, `the bar reads from its ${rtl ? 'right' : 'left'}`).toEqual([...along].sort((a, b) => a - b))
|
||
const name = await box(page, NAME)
|
||
if (rtl) expect(name.right, 'the name stands at the right edge').toBeGreaterThan(LAPTOP.width - 120)
|
||
else expect(name.left, 'the name stands at the left edge').toBeLessThan(120)
|
||
expect(await past(page, HEADER), 'nothing in the bar reaches past the window').toEqual([])
|
||
|
||
// A menu's columns start where the line starts.
|
||
await page.locator(`${HEADER} button[aria-controls="menu-products"]`).click()
|
||
const plane = page.locator('#menu-products')
|
||
await expect(plane).toBeVisible()
|
||
const first = await plane.locator('> * > *').first().evaluate((el) => {
|
||
const b = el.getBoundingClientRect()
|
||
return { left: b.left, right: b.right }
|
||
})
|
||
if (rtl) expect(first.right, 'the first column stands at the right').toBeGreaterThan(LAPTOP.width - 140)
|
||
else expect(first.left, 'the first column stands at the left').toBeLessThan(140)
|
||
// A row that leaves the site says so with ↗, turned to face the line.
|
||
const turned = await plane.locator('svg[data-i18n-mirror]').first().evaluate((el) => getComputedStyle(el).transform)
|
||
expect(turned, '↗ faces the line').toBe(rtl ? 'matrix(-1, 0, 0, 1, 0, 0)' : 'none')
|
||
await page.screenshot({ path: info.outputPath(`menu-${locale}-1440.png`) })
|
||
await page.keyboard.press('Escape')
|
||
|
||
// Log in's card hangs from its trigger's end edge and stays in the window.
|
||
const login = page.locator(`${HEADER} button[aria-controls="menu-login"]`)
|
||
await login.click()
|
||
const card = await box(page, '#menu-login')
|
||
const trigger = await box(page, `${HEADER} button[aria-controls="menu-login"]`)
|
||
if (rtl) expect(Math.abs(card.left - trigger.left), 'the card hangs from the trigger’s left').toBeLessThan(2)
|
||
else expect(Math.abs(card.right - trigger.right), 'the card hangs from the trigger’s right').toBeLessThan(2)
|
||
expect(card.left).toBeGreaterThanOrEqual(0)
|
||
expect(card.right).toBeLessThanOrEqual(LAPTOP.width)
|
||
await page.keyboard.press('Escape')
|
||
|
||
await page.locator('footer[data-slot="footer"]').scrollIntoViewIfNeeded()
|
||
expect(await past(page, 'footer[data-slot="footer"]'), 'nothing in the footer reaches past the window').toEqual([])
|
||
await page.screenshot({ path: info.outputPath(`footer-${locale}-1440.png`) })
|
||
})
|
||
|
||
test(`${locale}: the bar and the sheet at 390`, async ({ page }, info) => {
|
||
await local(page)
|
||
await page.setViewportSize(PHONE)
|
||
await read(page, '/support', locale)
|
||
await page.screenshot({ path: info.outputPath(`chrome-${locale}-390.png`) })
|
||
|
||
const name = await box(page, NAME)
|
||
const menu = await box(page, `${HEADER} button[aria-controls="sheet"]`)
|
||
expect(rtl ? menu.mid < name.mid : menu.mid > name.mid, 'the menu button stands at the line’s end').toBe(true)
|
||
expect(await past(page, HEADER), 'nothing in the bar reaches past the window').toEqual([])
|
||
|
||
await page.locator(`${HEADER} button[aria-controls="sheet"]`).click()
|
||
const sheet = page.locator('#sheet')
|
||
await expect(sheet).toBeVisible()
|
||
const summary = sheet.locator('summary').first()
|
||
const [label, chevron] = await Promise.all([
|
||
summary.evaluate((el) => {
|
||
const b = el.getBoundingClientRect()
|
||
return { left: b.left, right: b.right }
|
||
}),
|
||
summary.locator('> span').last().evaluate((el) => {
|
||
const b = el.getBoundingClientRect()
|
||
return { left: b.left, right: b.right }
|
||
}),
|
||
])
|
||
if (rtl) expect(chevron.left - label.left, 'the chevron at the section’s left end').toBeLessThan(4)
|
||
else expect(label.right - chevron.right, 'the chevron at the section’s right end').toBeLessThan(4)
|
||
await summary.click()
|
||
expect(await past(page, '#sheet'), 'nothing in the sheet reaches past the window').toEqual([])
|
||
await page.screenshot({ path: info.outputPath(`sheet-${locale}-390.png`) })
|
||
})
|
||
}
|
||
|
||
test('Settings → General chooses the language, and the column turns to the right', async ({ page }, info) => {
|
||
await signIn(page)
|
||
const heard: unknown[] = []
|
||
let saved: string | undefined
|
||
await page.route(
|
||
(url) => url.pathname === '/v1/iam/preferences',
|
||
(route) => {
|
||
const body = JSON.parse(route.request().postData() || '{}') as { locale?: string }
|
||
heard.push(body)
|
||
if (body.locale) saved = body.locale
|
||
return route.fulfill({ json: { status: 'ok', data: saved ? { locale: saved } : {} } })
|
||
},
|
||
)
|
||
await page.setViewportSize(LAPTOP)
|
||
await page.goto(`${base}/settings`)
|
||
const picker = page.locator('select').filter({ has: page.locator('option[value="ar"]') })
|
||
await expect(picker).toBeVisible({ timeout: 30_000 })
|
||
await expect(page.locator('html')).toHaveAttribute('dir', 'ltr')
|
||
|
||
await picker.selectOption('ar')
|
||
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl')
|
||
await expect(page.locator('html')).toHaveAttribute('lang', 'ar')
|
||
await expect.poll(() => heard, 'the choice is kept with the account').toContainEqual({ locale: 'ar' })
|
||
expect(await page.evaluate((k) => localStorage.getItem(k), kept('gate')), 'and on this browser').toBe('ar')
|
||
|
||
const column = await box(page, 'nav[aria-label="Settings"]')
|
||
const pane = await box(page, 'main')
|
||
expect(column.mid, 'the column stands right of the page').toBeGreaterThan(pane.mid)
|
||
const field = page.locator('nav[aria-label="Settings"] input').first()
|
||
const mark = await page.locator('nav[aria-label="Settings"] svg.lucide-search').first().evaluate((el) => {
|
||
const b = el.getBoundingClientRect()
|
||
return (b.left + b.right) / 2
|
||
})
|
||
const width = await field.evaluate((el) => {
|
||
const b = el.getBoundingClientRect()
|
||
return { left: b.left, right: b.right }
|
||
})
|
||
expect(mark, 'search’s mark stands at the field’s start, its right').toBeGreaterThan((width.left + width.right) / 2)
|
||
// What is read in its own order stays in it: an address, and a key chord.
|
||
const direction = (sel: string) => page.locator(sel).first().evaluate((el) => getComputedStyle(el).direction)
|
||
expect(await direction('#settings-email'), 'the email address reads left to right').toBe('ltr')
|
||
expect(await direction('#settings-website'), 'the website reads left to right').toBe('ltr')
|
||
expect(await direction('nav[aria-label="Settings"] [data-i18n-ltr]'), '⌘ K reads left to right').toBe('ltr')
|
||
await page.screenshot({ path: info.outputPath('settings-ar-1440.png') })
|
||
|
||
await page.reload()
|
||
await expect(page.locator('html'), 'the choice holds across a load').toHaveAttribute('dir', 'rtl', { timeout: 30_000 })
|
||
})
|