Files
tel/e2e/tel.spec.ts
2026-09-23 01:07:46 -07:00

550 lines
25 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
import { KEY } from '@hanzo/appearance/state'
/* 375 is the narrowest phone still in wide use, and it is the width the hero
breaks at first: every rung above it had room to hide a layout that only just
fits. */
const WIDTHS = [375, 390, 768, 1280, 1920]
const gotoClean = async (page: Page) => {
const errors: string[] = []
page.on('console', (m) => m.type() === 'error' && errors.push(m.text()))
page.on('pageerror', (e) => errors.push(e.message))
await page.goto('/')
return errors
}
test('renders the page', async ({ page }) => {
const errors = await gotoClean(page)
await expect(page).toHaveTitle(/Lux Tel/)
await expect(page.getByRole('heading', { level: 1 })).toContainText('needs to connect')
// The catalog reaches the page. Not a copy string — the pillars are data, and
// this is what fails if the projection breaks rather than the wording changes.
for (const pillar of ['Connectivity', 'Cellular', 'Messaging', 'Agentic AI']) {
await expect(page.getByRole('heading', { name: pillar, exact: true })).toBeVisible()
}
await expect(page.getByText('Lux Industries Inc.')).toBeVisible()
expect(errors).toEqual([])
})
test('contact path is a real mailto, and there is no form', async ({ page }) => {
await page.goto('/')
expect(await page.locator('form').count()).toBe(0)
const mailtos = page.locator('a[href^="mailto:"]')
expect(await mailtos.count()).toBeGreaterThan(0)
for (const href of await mailtos.evaluateAll((els) => els.map((e) => e.getAttribute('href')!))) {
expect(href).toMatch(/^mailto:[^@\s]+@lux\.tel/)
}
})
for (const width of WIDTHS) {
test(`no horizontal overflow at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
await page.goto('/')
const { scrollWidth, clientWidth } = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))
expect(scrollWidth).toBeLessThanOrEqual(clientWidth)
})
test(`tap targets are at least 44px at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
await page.goto('/')
const small = await page.locator('a:visible, button:visible').evaluateAll((els) =>
els
.map((e) => ({ text: (e.textContent ?? '').trim().slice(0, 40), h: e.getBoundingClientRect().height }))
.filter((r) => r.h < 44),
)
expect(small).toEqual([])
})
}
/*
The hero reads globe, then headline — and it is the ORDER that is asserted,
because every other property of this section survived the bug.
The globe was absolute at every width: full-bleed wallpaper behind the copy,
dimmed to 90%, with the scrim that makes text legible over it suppressed under
lg. It rendered, it propagated, it never overflowed, and every check in this
file passed while a phone showed body copy laid over a field of moving dots.
Nothing here can see "unreadable", so what is pinned instead is the geometry
that made it so: on a phone the canvas ENDS above where the headline STARTS.
Wide, the same one canvas goes back beside the copy — asserted from the same
numbers, so a change that fixes one width by breaking the other fails here.
*/
test('narrow, the globe sits above the headline; wide, beside it', async ({ page }) => {
const geometry = async () =>
page.evaluate(() => {
const r = (s: string) => {
const el = document.querySelector(s)
if (!el) return null
const b = el.getBoundingClientRect()
return { top: b.top + scrollY, bottom: b.bottom + scrollY, left: b.left, right: b.right }
}
return { globe: r('canvas'), headline: r('h1') }
})
for (const width of [375, 390, 768]) {
await page.setViewportSize({ width, height: 844 })
await page.goto('/')
const { globe, headline } = await geometry()
expect(globe, `no globe at ${width}`).not.toBeNull()
expect(globe!.bottom, `the globe is not clear of the headline at ${width}`).toBeLessThanOrEqual(headline!.top)
}
for (const width of [1280, 1920]) {
await page.setViewportSize({ width, height: 900 })
await page.goto('/')
const { globe, headline } = await geometry()
// BESIDE, which is a vertical fact: the two share a band rather than
// following one another. They also overlap horizontally, by design and at
// full bleed — the scrim exists precisely because they do — so the side is
// read from the canvas's midpoint, not from its near edge.
expect(globe!.bottom, `the globe stacked above the headline at ${width}`).toBeGreaterThan(headline!.top)
expect((globe!.left + globe!.right) / 2, `the globe left the right half at ${width}`).toBeGreaterThan(width / 2)
}
})
/*
The scale is @hanzo/appearance's to move, and this proves the page answers it.
The knobs are two custom properties on <html>; every rung in tailwind.config.ts
is a calc() against them, so type and spacing travel together. The failure this
guards is silent and total: point one utility at a literal and the site still
renders perfectly at the default, having quietly stopped listening. Written
against the package's own storage key rather than a copy of it, and driven the
way a person would — store a preference, load the page — so it also fails if
the site stops applying what it finds.
*/
test('a stored preference retunes type and spacing together', async ({ page }) => {
const measure = () =>
page.evaluate(() => {
const cs = (s: string, p: string) => parseFloat(getComputedStyle(document.querySelector(s)!).getPropertyValue(p))
return { headline: cs('h1', 'font-size'), lede: cs('.lede', 'font-size'), band: cs('.band', 'padding-top') }
})
await page.goto('/')
const published = await measure()
await page.addInitScript(
([k, v]) => localStorage.setItem(k, v),
[KEY, JSON.stringify({ type: 1.3, density: 'comfortable' })] as const,
)
await page.goto('/')
// WAITED FOR, not snapshotted. The preference lands on mount rather than in an
// inline head script (see src/appearance.tsx for why), so measuring as soon as
// `goto` resolves races hydration — and losing that race reports "the page
// ignores the knob", which is the failure this test exists to find. Waiting for
// the property asserts nothing on its own; the sizes below are still the claim.
await page.waitForFunction(() => document.documentElement.style.getPropertyValue('--type-scale') !== '')
const larger = await measure()
expect(larger.headline, 'type did not follow --type-scale').toBeCloseTo(published.headline * 1.3, 0)
expect(larger.lede, 'the ramp moved by rung rather than as a whole').toBeCloseTo(published.lede * 1.3, 0)
expect(larger.band, 'spacing did not follow --density').toBeCloseTo(published.band * 1.15, 0)
})
/*
Nothing on this site names anyone but Lux. Carriers, satellite operators,
spectrum partners and the platform behind the wire are implementation; the
product is Lux Industries. The rule covers copy, meta tags, alt text and markup,
so it is checked against the rendered DOM of every page rather than the source of
one — an alt attribute is exactly where a name survives a copy edit.
*/
test('nobody but Lux is named anywhere', async ({ page }) => {
const forbidden = ['telnyx', 'starlink', 'spacex', 'mansat', 'hanzo']
for (const path of ['/', '/network', '/products', '/solutions', '/pricing', '/company', '/legal', '/console']) {
await page.goto(path)
const html = (await page.content()).toLowerCase()
for (const name of forbidden) {
expect(html, `${name} appears on ${path}`).not.toContain(name)
}
}
})
/*
Sign-in is Lux ID and only Lux ID: the console offers one control, and it must
not have grown a password field.
*/
test('the console signs in through lux.id and holds no credential', async ({ page }) => {
await page.goto('/console')
await expect(page.getByRole('button', { name: /Sign in with Lux ID/i })).toBeVisible()
expect(await page.locator('input[type=password]').count()).toBe(0)
})
/*
WE DO NOT CLAIM TO OWN INFRASTRUCTURE. Lux delivers voice, messaging, numbering,
wireless and satellite as one service; it does not own the fibre, the
interconnect, the spectrum or the constellation, and a site that says otherwise
is making a claim a customer can check and we cannot support.
This is a gate rather than a note because the claims got written twice — the
first draft was built on "we own the layers most providers rent", and it reads
so naturally that it survives a careful edit. Checked against the rendered DOM,
which is where a claim that slipped back into a heading would live.
*/
test('the site claims no ownership of infrastructure', async ({ page }) => {
const claims = [
/\bwe own\b/i,
/\bowns? the (fibre|fiber|network|backbone|interconnect|spectrum|stack|telephony)/i,
/\bour own (fibre|fiber|backbone|interconnect|network|constellation|carrier)/i,
/owned end to end/i,
// The PARTICIPLE, which is how four of these survived the patterns above:
// "the physical layer, owned", "an owned terrestrial backbone", "its own
// backbone", "our own carrier network". Same claim, no "we", no match.
/\bowned\b/i,
/\bits own (fibre|fiber|backbone|interconnect|network|spectrum)/i,
// And the claim's mirror image: saying competitors RENT what we do not is
// the same assertion, made about somebody else.
/\b(providers|carriers|competitors) rent\b/i,
/\bthe interconnect is ours\b/i,
/\bwe operate our own\b/i,
]
for (const path of ['/', '/network', '/products', '/solutions', '/pricing', '/company']) {
await page.goto(path)
const text = (await page.locator('body').innerText()).replace(/\s+/g, ' ')
for (const claim of claims) {
expect(text, `${claim} appears on ${path}`).not.toMatch(claim)
}
}
})
/*
Endpoints are documentation, not product copy. They were printed on every
primitive's page and again in the console, so one rename meant editing a
marketing site — and a reader comparing two spellings of the same route has
found a bug rather than an API.
*/
test('no endpoint is printed as product copy', async ({ page }) => {
for (const path of ['/products/voice', '/products/numbers', '/products/orbital-broadband', '/console']) {
await page.goto(path)
const text = await page.locator('body').innerText()
expect(text, `an endpoint is printed on ${path}`).not.toMatch(/(GET|POST|PUT|DELETE)\s+\/v1\//)
}
})
/* Both ways it fails are silent: a canvas that threw is blank, and a fixed phase
is indistinguishable from a propagated one in any single frame. */
test('the globe draws the Earth and keeps propagating', async ({ page }) => {
await page.goto('/')
const canvas = page.locator('canvas').first()
await expect(canvas).toBeVisible()
const sample = () =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return { lit: 0, hash: 0 }
const d = ctx.getImageData(0, 0, el.width, el.height).data
let lit = 0
let hash = 0
// Every 41st pixel — coprime with the width, so not one column.
for (let i = 0; i < d.length; i += 4 * 41) {
if (d[i + 3] > 24) {
lit++
hash = (hash * 31 + i * d[i + 3]) % 2147483647
}
}
return { lit, hash }
})
// Blank samples 0; the globe ~330. The bar sits between, not at either.
const first = await sample()
expect(first.lit, 'the globe rendered nothing').toBeGreaterThan(100)
// One second is ~6km of orbital travel; every endpoint moves.
await page.waitForTimeout(1000)
const second = await sample()
expect(second.hash, 'the globe is a still image').not.toBe(first.hash)
})
/* Measures HEIGHT, not existence: the panel rendered fine inside a 44px box when
the header's backdrop-blur made it the containing block. */
test('the phone menu covers the viewport and reaches every section', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
await page.getByRole('button', { name: 'Open menu' }).click()
const panel = page.locator('div.fixed.inset-0').first()
await expect(panel).toBeVisible()
const { panelH, viewportH } = await panel.evaluate((el) => ({
panelH: el.getBoundingClientRect().height,
viewportH: window.innerHeight,
}))
expect(panelH, 'the menu panel is not full height').toBeGreaterThanOrEqual(viewportH - 1)
// Every top-level section is reachable, which is the whole point of having it.
for (const label of ['Products', 'Solutions', 'Network', 'Company']) {
await expect(panel.getByRole('link', { name: label, exact: true })).toBeVisible()
}
// The page behind must not scroll under it.
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe('hidden')
await page.getByRole('button', { name: 'Close menu' }).click()
await expect(panel).toHaveCount(0)
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe('hidden')
})
/*
The header menus, measured rather than looked at. Every one of these failures
renders — the menu opens, every link works, and it is only WRONG, which is why
it shipped: a panel 333px short of the container reads as a design, and a head
34px below its neighbours reads as a head.
The rule the numbers encode: every panel is placed against the container's edges,
and groups are grid columns, so heads share a top by construction rather than by
offset.
*/
for (const width of [1280, 1920]) {
test(`the header menus span the viewport at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
await page.goto('/')
for (const label of ['Products', 'Solutions', 'Company']) {
const trigger = page.getByRole('button', { name: label, exact: false }).first()
await trigger.hover()
// The panel is PORTALLED to the body, so it is not under `header` any more.
const panel = page.locator('body > div.fixed.inset-x-0').first()
await expect(panel).toBeVisible()
const m = await panel.evaluate((el) => {
const nav = document.querySelector('header nav')!
const cs = getComputedStyle(nav)
const n = nav.getBoundingClientRect()
const p = el.getBoundingClientRect()
// CONTENT box, not the border box: both the nav and the panel's inner
// container carry the same responsive padding, so comparing border boxes
// compares two paddings rather than two alignments.
const innerEl = el.firstElementChild as HTMLElement
const innerCs = getComputedStyle(innerEl)
const innerRect = innerEl.getBoundingClientRect()
const inner = {
left: innerRect.left + parseFloat(innerCs.paddingLeft),
right: innerRect.right - parseFloat(innerCs.paddingRight),
}
return {
panel: { left: p.left, right: p.right, top: p.top },
inner,
header: document.querySelector('header')!.getBoundingClientRect().bottom,
content: { left: n.left + parseFloat(cs.paddingLeft), right: n.right - parseFloat(cs.paddingRight) },
heads: [...el.querySelectorAll('.eyebrow')].map((h) => h.getBoundingClientRect().top),
opaque: getComputedStyle(el).backgroundColor,
viewport: window.innerWidth,
}
})
// EDGE TO EDGE. The panel escapes the container; only its content is held to
// the grid, so the links still land under the wordmark.
expect(m.panel.left, `${label} left edge`).toBeCloseTo(0, 1)
expect(m.panel.right, `${label} right edge`).toBeCloseTo(m.viewport, 1)
expect(m.inner.left, `${label} content left`).toBeCloseTo(m.content.left, 1)
expect(m.inner.right, `${label} content right`).toBeCloseTo(m.content.right, 1)
// OPAQUE. Glass put the moving globe under text somebody is reading.
expect(m.opaque, `${label} is not opaque`).toBe('rgb(0, 0, 0)')
// One row, so every group head shares a top. Balanced columns broke this
// silently: the break lands on the text metrics, so a long group pushed the
// head under it out of line with the rest of its row.
if (m.heads.length > 2) {
expect(new Set(m.heads).size, `${label} heads are not on one line`).toBe(1)
}
// Contiguous with the header. A gap is a strip belonging to neither, and
// crossing it closes the menu on the way to the thing you are reaching for.
expect(m.panel.top, `${label} floats off the header`).toBeCloseTo(m.header, 1)
// A panel widened past the container is the classic way to buy sideways scroll.
const { scrollWidth, clientWidth } = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))
expect(scrollWidth, `${label} open scrolls sideways`).toBeLessThanOrEqual(clientWidth)
await page.mouse.move(width / 2, 850)
await expect(panel).toHaveCount(0)
}
})
}
/* Drag turns the globe, and must not drag-select the hero heading behind it. */
test('the globe can be turned by hand', async ({ page }) => {
await page.goto('/')
const canvas = page.locator('canvas').first()
const box = (await canvas.boundingBox())!
const face = () =>
canvas.evaluate((el: HTMLCanvasElement) => {
const d = el.getContext('2d')!.getImageData(0, 0, el.width, el.height).data
let hash = 0
for (let i = 0; i < d.length; i += 4 * 97) hash = (hash * 31 + d[i + 3]) % 2147483647
return hash
})
const before = await face()
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 + 240, box.y + box.height / 2, { steps: 10 })
await page.mouse.up()
expect(await face(), 'dragging did not turn the globe').not.toBe(before)
expect(await page.evaluate(() => String(window.getSelection())), 'the drag selected page text').toBe('')
})
/*
EVERY internal link resolves.
The console's sidebar linked to /console/assistant, /console/reporting and
/console/debugging for as long as it existed. All three 404'd. Nothing caught it
because every other test visits pages it already knows about, and a nav item is
exactly the thing nobody re-checks after writing it.
This crawls what the site actually links to rather than a list kept beside it, so
a link added tomorrow is covered without anyone remembering to add it here.
*/
test('every internal link resolves', async ({ page, request }) => {
const seen = new Set<string>()
const bad: string[] = []
for (const path of ['/', '/products', '/solutions', '/network', '/company', '/pricing', '/legal', '/console', '/start']) {
await page.goto(path)
const hrefs = await page.locator('a[href]').evaluateAll((els) =>
els.map((e) => e.getAttribute('href') ?? '').filter((h) => h.startsWith('/')),
)
for (const h of hrefs) {
const url = h.split('#')[0]
if (!url || seen.has(url)) continue
seen.add(url)
// ONCE more before believing it. Run straight after a cache purge, the edge
// is refetching every path from origin and a cold miss can time out — which
// is not a dead link, and a suite that fails after every deploy stops being
// read. A link that is genuinely gone fails both times.
let res = await request.get(url)
if (!res.ok()) res = await request.get(url)
if (!res.ok()) bad.push(`${url} -> ${res.status()} (linked from ${path})`)
}
}
expect(seen.size, 'crawled nothing — the selector is wrong').toBeGreaterThan(30)
expect(bad, `dead internal links:\n${bad.join('\n')}`).toEqual([])
})
/*
The links that leave the site, limited to hosts WE run.
A link to status.lux.tel sat in the console shell beside a green dot that said
everything was fine. The host had never existed — no A record, no CNAME — so
every console page shipped a confident lie, and the internal crawl above could
not see it because the href left the site. Third-party links are somebody
else's uptime and are deliberately not asserted here; ours are ours, and a
host we name is a host we are claiming to run.
*/
test('every link to a host we run resolves', async ({ page, request }) => {
const ours = /(^|\.)(lux|hanzo|zoo)\.(tel|network|cloud|id|ai|ngo)$/
const seen = new Set<string>()
const bad: string[] = []
for (const path of ['/', '/products', '/solutions', '/network', '/company', '/pricing', '/legal', '/console']) {
await page.goto(path)
const hrefs = await page.locator('a[href]').evaluateAll((els) =>
els.map((e) => e.getAttribute('href') ?? '').filter((h) => h.startsWith('http')),
)
for (const h of hrefs) {
let host = ''
try {
host = new URL(h).host
} catch {
continue
}
if (!ours.test(host) || seen.has(host)) continue
seen.add(host)
let res = await request.get(`https://${host}`, { failOnStatusCode: false }).catch(() => null)
if (!res) res = await request.get(`https://${host}`, { failOnStatusCode: false }).catch(() => null)
if (!res) bad.push(`${host} does not resolve (linked from ${path})`)
else if (res.status() >= 500) bad.push(`${host} -> ${res.status()} (linked from ${path})`)
}
}
expect(seen.size, 'found no first-party outbound links — the filter is wrong').toBeGreaterThan(0)
expect(bad, `links to hosts we run that do not answer:\n${bad.join('\n')}`).toEqual([])
})
/*
The console's record views, in every state a real account passes through.
A signed-out visitor is the only state a browser reaches without a Lux ID, so the
rest are driven by seeding the token the console reads and INTERCEPTING the API —
which is the honest way to test a client: it asserts what this code does with an
answer, and never pretends to have logged anybody in.
What the interception is held to is the real contract: the paths, the `{data:[]}`
envelope and the field names come from /v1/tel, and `the console asks the tel API
the way it answers` below pins the request itself so a route rename fails here
rather than in production.
*/
const VIEWS = [
{ path: '/console/numbers', title: 'Numbers', api: '**/v1/tel/numbers' },
{ path: '/console/calls', title: 'Calls', api: '**/v1/tel/calls' },
{ path: '/console/messages', title: 'Messages', api: '**/v1/tel/messages' },
]
for (const v of VIEWS) {
test(`${v.title}: a signed-out visitor is asked to sign in, not shown an error`, async ({ page }) => {
await page.goto(v.path)
await expect(page.getByRole('heading', { name: v.title })).toBeVisible()
await expect(page.getByRole('button', { name: /Sign in with Lux ID/i })).toBeVisible()
// No table, and no half-rendered shell claiming the account is empty.
expect(await page.locator('table').count()).toBe(0)
})
test(`${v.title}: an empty account says what to do, not "no data"`, async ({ page }) => {
await page.route(v.api, (r) => r.fulfill({ json: { data: [] } }))
await page.addInitScript(() => localStorage.setItem('lux_tel_token', 'test'))
await page.goto(v.path)
// An empty state that names the next action, which is the only kind anyone acts on.
//
// WAITED FOR, not snapshotted. The request this mocks resolves after load, so
// reading innerText once races the render — and under three views in parallel
// whichever one lost the race failed, a different one each run. A retrying
// assertion asks the same question without depending on who got there first.
await expect(page.locator('main')).toContainText(/yet\./)
expect(await page.locator('main').innerText()).not.toMatch(/no data|nothing here/i)
})
test(`${v.title}: an expired session offers a way back in`, async ({ page }) => {
await page.route(v.api, (r) => r.fulfill({ status: 401, json: {} }))
await page.addInitScript(() => localStorage.setItem('lux_tel_token', 'stale'))
await page.goto(v.path)
// Not "401", and not a spinner that never resolves.
await expect(page.getByText(/session ended/i)).toBeVisible()
await expect(page.getByRole('button', { name: /Sign in again/i })).toBeVisible()
})
}
/*
The REQUEST, pinned. The token is the whole request: /v1/tel takes the org from
the validated token, so the console sends no X-Org-Id. A hardcoded org asks for
one the user may not belong to, and is invisible from the rendered page, which
is why it is asserted on the wire.
*/
test('the console asks the tel API the way it answers', async ({ page }) => {
let seen: { url: string; auth: string | undefined; org: string | undefined } | null = null
await page.route('**/v1/tel/numbers', (r) => {
const h = r.request().headers()
seen = { url: r.request().url(), auth: h['authorization'], org: h['x-org-id'] }
return r.fulfill({ json: { data: [{ id: 'n1', e164: '+441632960000', country: 'GB', type: 'local', capable: ['voice', 'sms'], monthly: 250, currency: 'GBP' }] } })
})
await page.addInitScript(() => localStorage.setItem('lux_tel_token', 'test-token'))
await page.goto('/console/numbers')
await expect(page.getByText('+441632960000')).toBeVisible()
expect(seen, 'the page never called the API').not.toBeNull()
expect(seen!.url).toContain('api.hanzo.ai/v1/tel/numbers')
expect(seen!.auth, 'the token was not sent').toBe('Bearer test-token')
expect(seen!.org, 'the org comes from the token; the console must not send X-Org-Id').toBeUndefined()
// Minor units are money, not a count: 250 GBP-minor is £2.50 and never 250.
await expect(page.getByText('£2.50')).toBeVisible()
})