Files

198 lines
9.6 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 { 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 })
})