mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
198 lines
9.6 KiB
TypeScript
198 lines
9.6 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 doors, 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} a[href="/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-platform"]`).click()
|
||
const plane = page.locator('#menu-platform')
|
||
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')
|
||
|
||
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 })
|
||
})
|