mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
168 lines
7.7 KiB
TypeScript
168 lines
7.7 KiB
TypeScript
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)
|
||
})
|