Files

206 lines
9.7 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'
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('the signed-in app hides the floating privacy choices control', async ({ page }) => {
await local(page)
await signIn(page)
await page.addInitScript(() => sessionStorage.setItem('hz_consent_policy', JSON.stringify({ mode: 'us_privacy', region: 'US-CA', version: 2, refused: false })))
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()
const floating = page.locator('[data-slot="consent-choices"]')
await expect(floating).toHaveCount(1)
await expect(floating, `the privacy control cannot cover the app at ${path}`).toBeHidden()
}
})
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()
})
})