Files
hanzo.ai/e2e/gates/link.spec.ts
zeekay 0f5536b707 hanzo.ai reads in the reader's language, and Arabic right to left
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.
2026-10-22 19:54:31 -07:00

168 lines
7.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 { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { expect, test, type BrowserContext, type Page } from '@playwright/test'
import { ROOT, serveExport } from './export'
import { exchanged, issuer, jwt, type Seen } from './issuer'
import { AUP_VERSION, TERMS_VERSION } from '@hanzo/app/lib/terms'
/**
* THE SIGN-IN EMAIL'S LINK, OPENED.
*
* IAM's sign-in mail (hanzoai/iam `internal/otp/signin.go`) carries a Sign in
* button to this site's callback with the address and the code in the fragment,
* `/auth/callback#code=…&email=…`, and the same code printed under it
* (lib/auth/link, components/auth/opened):
*
* the browser that asked, its tab waiting that tab takes the code and signs
* in; the link's tab follows it
* the browser that asked, no tab waiting the link's tab signs in itself
* any other browser the code, to type where the sign-in
* began, and nothing spent
*
* The mail loads its mark from https://hanzo.ai/email/mark.png, so the export
* serves it there.
*/
const TOKEN = jwt({ sub: 'ada', name: 'ada', email: '[email protected]', orgs: [{ org: 'ada', role: 'owner' }] })
const CODE = '524019'
const LINK = `/auth/callback#code=${CODE}&email=ada%40example.com`
let base: string
let stop: () => Promise<void>
test.beforeAll(async () => {
const served = await serveExport()
base = served.url
stop = served.close
})
test.afterAll(async () => stop())
/** IAM answered for every page of one browser: an address with an account. */
async function world(context: BrowserContext): Promise<Seen> {
await context.route('**/*', (route) => {
const url = new URL(route.request().url())
return url.origin === base ? route.continue() : route.abort()
})
await context.route(`${base}/v1/billing/tier`, (route) =>
route.fulfill({ json: { user: 'ada', plan: 'dev', tier: { name: 'dev', displayName: 'Dev' }, balance: { currency: 'usd' } } }),
)
const seen = await issuer(context, {
site: base,
reply: { '/v1/iam/auth/identifier': { status: 'ok', data: { account: true, password: false } } },
token: TOKEN,
})
await context.route(
(u) => u.origin === base && ['/v1/iam/preferences', '/v1/iam/account'].includes(u.pathname),
(route) =>
route.fulfill({
json: new URL(route.request().url()).pathname === '/v1/iam/account'
? { status: 'ok', data: { name: 'ada', email: '[email protected]', createdTime: '2024-01-01T00:00:00Z' } }
: { status: 'ok', data: { terms: { terms: TERMS_VERSION, aup: AUP_VERSION } } },
}),
)
return seen
}
const card = (page: Page) => page.getByRole('region', { name: /^(Log in|Sign up)$/ })
/** /login, the address typed, and its link and code sent: the page that waits for them. */
async function asked(page: Page, seen: Seen): Promise<void> {
await page.addInitScript(() => {
if (!sessionStorage.getItem('gate.headed')) {
sessionStorage.setItem('gate.headed', '1')
sessionStorage.setItem('signin.destination', '/pricing')
}
})
await page.goto(`${base}/login`)
await page.locator('html[data-hydrated]').waitFor({ state: 'attached' })
await card(page).getByLabel('Email').fill('[email protected]')
await card(page).getByRole('button', { name: 'Continue with email' }).click()
await expect(card(page).getByText('To continue, click the link sent to')).toBeVisible()
expect(seen.posted.map((p) => p.path), 'the link and code, sent').toEqual(['/v1/iam/verification-codes'])
}
/**
* The one sign-in the code made: one send, then IAM's code login with that code.
* What the app asks once it opens (its organizations) is not the sign-in's.
*/
function spent(seen: Seen): void {
const signin = seen.posted.filter((p) => p.path === '/v1/iam/verification-codes' || p.path === '/v1/iam/login')
expect(signin.map((p) => p.path)).toEqual(['/v1/iam/verification-codes', '/v1/iam/login'])
expect(JSON.parse(signin[1].body)).toMatchObject({ username: '[email protected]', code: CODE })
exchanged(seen)
}
test('the link opened in the browser that asked hands its code to the waiting tab, which signs in; the link’s tab follows', async ({ context }) => {
const seen = await world(context)
const started = await context.newPage()
await asked(started, seen)
const opened = await context.newPage()
await opened.goto(`${base}${LINK}`)
await expect(opened.getByText('On the tab where you started.')).toBeVisible()
expect(new URL(opened.url()).hash, 'the code leaves the address bar').toBe('')
await started.waitForURL(`${base}/pricing`, { timeout: 20_000 })
expect(await started.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBe(TOKEN)
await opened.waitForURL(`${base}/`, { timeout: 20_000 })
spent(seen)
})
test('the link opened in the browser that asked, with no tab waiting, signs in where it opened', async ({ context }) => {
const seen = await world(context)
const started = await context.newPage()
await asked(started, seen)
await started.close()
// Signed in where it opened, and on to the host's landing (@hanzo/app
// `landing`): the callback and the first-run check are behind it.
const opened = await context.newPage()
await opened.goto(`${base}${LINK}`)
await opened.waitForURL((u) => u.pathname !== '/auth/callback' && u.pathname !== '/onboarding', { timeout: 20_000 })
expect(await opened.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBe(TOKEN)
spent(seen)
})
test('the link opened on another device shows its code and spends nothing; typed where the sign-in began, it signs in', async ({ browser, context }) => {
const seen = await world(context)
const started = await context.newPage()
await asked(started, seen)
const elsewhere = await browser.newContext()
try {
const other = await world(elsewhere)
const opened = await elsewhere.newPage()
await opened.goto(`${base}${LINK}`)
await expect(opened.getByRole('heading', { name: 'Your sign-in code' })).toBeVisible()
await expect(opened.getByText('Enter this code in the tab where you started signing in.')).toBeVisible()
await expect(opened.getByLabel('Verification code')).toHaveText(CODE)
expect(other.posted, 'a scanner or another device that opens the link signs nobody in').toEqual([])
expect(new URL(opened.url()).hash, 'the code leaves the address bar').toBe('')
} finally {
await elsewhere.close()
}
// Back where the sign-in began, the page has turned to the code on its own
// (five seconds with no sign-in in the tab): typed, in a field the browser fills.
const field = card(started).getByRole('textbox', { name: 'Verification code' })
await expect(field).toBeVisible({ timeout: 10_000 })
await expect(field).toHaveAttribute('autocomplete', 'one-time-code')
await expect(field).toHaveAttribute('inputmode', 'numeric')
await field.fill(CODE)
await card(started).getByRole('button', { name: 'Verify email address' }).click()
await started.waitForURL(`${base}/pricing`, { timeout: 20_000 })
expect(await started.evaluate(() => localStorage.getItem('hanzo_iam_access_token'))).toBe(TOKEN)
spent(seen)
})
test('the mark the sign-in mail loads is served at /email/mark.png, a 132px PNG', async ({ request }) => {
const res = await request.get(`${base}/email/mark.png`)
expect(res.status()).toBe(200)
expect(res.headers()['content-type']).toContain('image/png')
const png = await res.body()
expect(png.subarray(1, 4).toString(), 'a PNG').toBe('PNG')
// IHDR: width and height, big-endian, at bytes 16 and 20.
expect([png.readUInt32BE(16), png.readUInt32BE(20)], 'three times its 44px place').toEqual([132, 132])
expect(png.equals(readFileSync(join(ROOT, 'public', 'email', 'mark.png'))), 'the committed file, as shipped').toBe(true)
})