571 lines
25 KiB
TypeScript
571 lines
25 KiB
TypeScript
import { Page, APIRequestContext, expect } from '@playwright/test'
|
|
import {
|
|
env, apiBase, CHAIN_IDS, CREDS, LIVENESS, NETWORKS, orgRpc, PASSWORD_HINT,
|
|
shotPath, viewportName,
|
|
} from './env'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Environment & URL resolution — DERIVED, never redeclared.
|
|
// ---------------------------------------------------------------------------
|
|
// e2e/env.ts is the one definition (it reads ../NETWORKS.yaml at runtime). This
|
|
// file used to keep its OWN copies of the api-domain, explorer-domain and
|
|
// chainId tables, and they had already drifted from playwright.config.ts's
|
|
// copies. Deleting the duplicates is what fixed the testnet explorer host for
|
|
// every spec at once.
|
|
//
|
|
// The split: env.ts holds the FACTS (urls, chain IDs, viewports, credentials),
|
|
// this file holds the ACTIONS (rpc calls, http probes, screenshots). Specs take
|
|
// facts from './env' and actions from './helpers'. The accessors below exist
|
|
// because the 20 specs already call them — they are derivations, not copies.
|
|
export { orgRpc, CHAIN_IDS }
|
|
|
|
export type LuxUrls = typeof env.urls
|
|
|
|
// Lux's API host per network, computed from the one derivation in env.ts, for
|
|
// the specs that deliberately reach across networks at once (14-network-
|
|
// independence). Other orgs need no table of their own: every org is a sovereign
|
|
// L1 serving its own C-Chain from api.<org>.network on the flat /v1/chain/C/rpc
|
|
// path, which is exactly what orgRpc(org, network) returns. The four hand-kept
|
|
// per-org domain tables this file used to carry are gone.
|
|
export const API_DOMAINS: Record<string, string> =
|
|
Object.fromEntries(NETWORKS.map((n) => [n, apiBase('lux', n)]))
|
|
|
|
export function getUrls(): LuxUrls {
|
|
return env.urls
|
|
}
|
|
|
|
export function getEnv(): string {
|
|
return env.name
|
|
}
|
|
|
|
export function getNetwork(): string {
|
|
return env.network
|
|
}
|
|
|
|
export function isLocal(): boolean {
|
|
return env.isLocal
|
|
}
|
|
|
|
export function getChainIds() {
|
|
return env.chainIds
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JSON-RPC helpers
|
|
// ---------------------------------------------------------------------------
|
|
export async function rpcCall(
|
|
request: APIRequestContext,
|
|
url: string,
|
|
method: string,
|
|
params: unknown = [],
|
|
timeout = 15_000,
|
|
retries = 3,
|
|
): Promise<unknown> {
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
try {
|
|
const res = await request.post(url, {
|
|
data: { jsonrpc: '2.0', method, params, id: 1 },
|
|
headers: { 'Content-Type': 'application/json' },
|
|
ignoreHTTPSErrors: true,
|
|
timeout,
|
|
})
|
|
if (res.status() === 429 || res.status() >= 500) {
|
|
if (attempt < retries) { await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); continue }
|
|
}
|
|
// Say what the gateway actually did. A non-JSON body here used to surface
|
|
// as `Unexpected token '4'` from the JSON parser, which reads like a
|
|
// parser bug rather than what it is: the public gateway serving "404 page
|
|
// not found" for a chain that has fallen out of it. Measured on
|
|
// api.lux-dev.network mid-run, 5 probes out of 5.
|
|
const body = await res.text()
|
|
let json: { error?: unknown; result?: unknown }
|
|
try {
|
|
json = JSON.parse(body)
|
|
} catch {
|
|
throw new Error(`RPC ${method} on ${url}: HTTP ${res.status()} and the body is not JSON — `
|
|
+ `"${body.slice(0, 120).replace(/\s+/g, ' ')}"`)
|
|
}
|
|
if (json.error) throw new Error(`RPC ${method}: ${JSON.stringify(json.error)}`)
|
|
return json.result
|
|
} catch (e) {
|
|
if (attempt < retries) { await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); continue }
|
|
throw e
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function ethBlockNumber(request: APIRequestContext, rpcUrl: string): Promise<number> {
|
|
const result = await rpcCall(request, rpcUrl, 'eth_blockNumber') as string
|
|
return parseInt(result, 16)
|
|
}
|
|
|
|
export async function ethChainId(request: APIRequestContext, rpcUrl: string): Promise<number> {
|
|
const result = await rpcCall(request, rpcUrl, 'eth_chainId') as string
|
|
return parseInt(result, 16)
|
|
}
|
|
|
|
export async function ethGasPrice(request: APIRequestContext, rpcUrl: string): Promise<bigint> {
|
|
const result = await rpcCall(request, rpcUrl, 'eth_gasPrice') as string
|
|
return BigInt(result)
|
|
}
|
|
|
|
// eth_call / eth_getCode — the one way for read-only contract inspection over any
|
|
// C-Chain RPC. `data` is the 4-byte selector + ABI-encoded args (hex, 0x-prefixed).
|
|
export async function ethCall(
|
|
request: APIRequestContext,
|
|
rpcUrl: string,
|
|
to: string,
|
|
data: string,
|
|
block: string = 'latest',
|
|
): Promise<string> {
|
|
return await rpcCall(request, rpcUrl, 'eth_call', [{ to, data }, block]) as string
|
|
}
|
|
|
|
export async function ethGetCode(
|
|
request: APIRequestContext,
|
|
rpcUrl: string,
|
|
address: string,
|
|
block: string = 'latest',
|
|
): Promise<string> {
|
|
return await rpcCall(request, rpcUrl, 'eth_getCode', [address, block]) as string
|
|
}
|
|
|
|
// A contract is "deployed" iff eth_getCode returns more than the empty "0x".
|
|
export function hasCode(code: string | null | undefined): boolean {
|
|
return !!code && code !== '0x' && code.length > 2
|
|
}
|
|
|
|
// Decode the trailing 20 bytes of a 32-byte ABI word into a lowercased address.
|
|
// Right-aligned per the ABI (an `address` return occupies the low 20 bytes).
|
|
export function decodeAddress(word: string): string {
|
|
return ('0x' + word.slice(-40)).toLowerCase()
|
|
}
|
|
|
|
// ABI-encode a single uint256 argument to a 32-byte hex word (no 0x prefix),
|
|
// for appending to a 4-byte selector when building `data`.
|
|
export function encodeUint(value: number | bigint): string {
|
|
return BigInt(value).toString(16).padStart(64, '0')
|
|
}
|
|
|
|
export async function getNetworkId(request: APIRequestContext, infoUrl: string): Promise<string> {
|
|
const result = await rpcCall(request, infoUrl, 'info.getNetworkID', {}) as { networkID: string }
|
|
return result.networkID
|
|
}
|
|
|
|
export async function isBootstrapped(request: APIRequestContext, infoUrl: string, chain: string): Promise<boolean> {
|
|
const result = await rpcCall(request, infoUrl, 'info.isBootstrapped', { chain }) as { isBootstrapped: boolean }
|
|
return result.isBootstrapped
|
|
}
|
|
|
|
export async function getNodeVersion(request: APIRequestContext, infoUrl: string): Promise<string> {
|
|
const result = await rpcCall(request, infoUrl, 'info.getNodeVersion', {}) as { version: string }
|
|
return result.version
|
|
}
|
|
|
|
export async function getPeers(request: APIRequestContext, infoUrl: string): Promise<unknown[]> {
|
|
const result = await rpcCall(request, infoUrl, 'info.peers', { nodeIDs: [] }) as { peers: unknown[] }
|
|
return result.peers || []
|
|
}
|
|
|
|
export async function getCurrentValidators(request: APIRequestContext, platformUrl: string): Promise<unknown[]> {
|
|
const result = await rpcCall(request, platformUrl, 'platform.getCurrentValidators', { subnetID: '' }) as { validators: unknown[] }
|
|
return result.validators || []
|
|
}
|
|
|
|
export async function getBlockchains(request: APIRequestContext, platformUrl: string): Promise<unknown[]> {
|
|
const result = await rpcCall(request, platformUrl, 'platform.getBlockchains', {}) as { blockchains: unknown[] }
|
|
return result.blockchains || []
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Chain LIVENESS — the tip, not the readiness probe
|
|
// ---------------------------------------------------------------------------
|
|
// Kubernetes said 5/5 Ready on fifteen pods and every public API said 200 while
|
|
// mainnet had not produced a block in 58.9 hours. Readiness is a claim the node
|
|
// makes about itself; the tip is a fact you can check. Everything here reads
|
|
// the tip.
|
|
|
|
export interface Tip {
|
|
number: number
|
|
hash: string
|
|
/** Block timestamp, unix seconds — what the CHAIN says the time was. */
|
|
timestamp: number
|
|
/** Wall-clock seconds since that timestamp, at the moment of the call. */
|
|
ageSec: number
|
|
}
|
|
|
|
export async function getBlock(
|
|
request: APIRequestContext,
|
|
rpcUrl: string,
|
|
tag: string | number = 'latest',
|
|
): Promise<{ number: string; hash: string; timestamp: string } | null> {
|
|
const param = typeof tag === 'number' ? '0x' + tag.toString(16) : tag
|
|
return await rpcCall(request, rpcUrl, 'eth_getBlockByNumber', [param, false]) as
|
|
{ number: string; hash: string; timestamp: string } | null
|
|
}
|
|
|
|
export async function getTip(request: APIRequestContext, rpcUrl: string): Promise<Tip> {
|
|
const b = await getBlock(request, rpcUrl, 'latest')
|
|
if (!b) throw new Error(`eth_getBlockByNumber(latest) returned null on ${rpcUrl}`)
|
|
const timestamp = parseInt(b.timestamp, 16)
|
|
return {
|
|
number: parseInt(b.number, 16),
|
|
hash: b.hash,
|
|
timestamp,
|
|
ageSec: Math.round(Date.now() / 1000) - timestamp,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The chain's OWN recent cadence, in seconds — median gap over the last
|
|
* LIVENESS.gapSamples blocks. Median, not mean: one 27,375-second gap (which is
|
|
* exactly what the mainnet freeze looks like from inside the block list) drags
|
|
* a mean past any threshold and would let the stall define its own budget.
|
|
*/
|
|
export async function medianBlockGap(
|
|
request: APIRequestContext,
|
|
rpcUrl: string,
|
|
samples = LIVENESS.gapSamples,
|
|
): Promise<{ median: number; gaps: number[] }> {
|
|
const tip = await getTip(request, rpcUrl)
|
|
const times: number[] = [tip.timestamp]
|
|
for (let i = 1; i <= samples && tip.number - i >= 0; i++) {
|
|
const b = await getBlock(request, rpcUrl, tip.number - i)
|
|
if (!b) break
|
|
times.push(parseInt(b.timestamp, 16))
|
|
}
|
|
const gaps: number[] = []
|
|
for (let i = 0; i < times.length - 1; i++) gaps.push(times[i] - times[i + 1])
|
|
if (gaps.length === 0) return { median: 0, gaps }
|
|
const sorted = [...gaps].sort((a, b) => a - b)
|
|
const mid = Math.floor(sorted.length / 2)
|
|
const median = sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2)
|
|
return { median, gaps }
|
|
}
|
|
|
|
/** How old the tip is allowed to be, derived from the chain's own cadence. */
|
|
export function staleBudgetSec(medianGap: number): number {
|
|
return Math.max(medianGap * LIVENESS.staleGapMultiple, LIVENESS.staleFloorSec)
|
|
}
|
|
|
|
export function formatAge(sec: number): string {
|
|
if (sec < 90) return `${sec}s`
|
|
if (sec < 5400) return `${(sec / 60).toFixed(1)}min`
|
|
return `${(sec / 3600).toFixed(1)}h`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP helpers
|
|
// ---------------------------------------------------------------------------
|
|
export async function httpStatus(
|
|
request: APIRequestContext,
|
|
url: string,
|
|
timeout = 15_000
|
|
): Promise<number> {
|
|
try {
|
|
const res = await request.get(url, { ignoreHTTPSErrors: true, timeout })
|
|
return res.status()
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// Reads JSON, and when there is no JSON to read, SAYS SO. This used to be a
|
|
// bare `res.json()`, which handed the body of any error page straight to
|
|
// JSON.parse. A plain-text `404 page not found` then failed as `Unexpected
|
|
// non-whitespace character after JSON at position 4` — a complaint about the
|
|
// fifth byte of the phrase, naming neither the URL nor the status. Every
|
|
// caller wants the same two facts on failure: which endpoint, and what it
|
|
// actually served.
|
|
export async function httpGetJson(
|
|
request: APIRequestContext,
|
|
url: string,
|
|
timeout = 15_000
|
|
): Promise<unknown> {
|
|
const res = await request.get(url, {
|
|
ignoreHTTPSErrors: true,
|
|
timeout,
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
const body = await res.text()
|
|
const how = `GET ${url} → ${res.status()} ${res.headers()['content-type'] || 'no content-type'}`
|
|
if (!res.ok()) throw new Error(`${how}: ${body.slice(0, 200)}`)
|
|
try {
|
|
return JSON.parse(body)
|
|
} catch {
|
|
throw new Error(`${how}, body is not JSON: ${body.slice(0, 200)}`)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// "The explorer is showing its blocks" — asserted ONE way.
|
|
// ---------------------------------------------------------------------------
|
|
// Two specs used to assert this as `locator('text=/block/i').first()`, which
|
|
// resolves to a Radix menu TRIGGER whose label contains "block": hidden at
|
|
// 390/834, visible at 1440. Both tests therefore tracked the nav's collapse
|
|
// rather than the blocks table, and both went red the moment the suite gained
|
|
// mobile and tablet frames — while the explorer was in fact rendering
|
|
// "Total blocks 1,098,192" and four block links at all three (measured
|
|
// 2026-07-26). This asserts the section AND a link to a real block, which the
|
|
// old selector could never do: the heading "Total blocks 1,098,192" stays
|
|
// visible even when the widget below it says "No latest blocks found".
|
|
//
|
|
// ⚠ MEASURED DEFECT (explore.lux.network, 2026-07-26) — the Latest-blocks widget
|
|
// renders "No latest blocks found" under CONCURRENT cold loads, while the same
|
|
// page still shows the right total and the indexer endpoint behind it is fine
|
|
// (`/v1/indexer/cchain/blocks?limit=3` → 200 with 3 blocks on 12/12 polls;
|
|
// 10/10 serial page loads render 4 block links; 1 failure per 126 cases at 4
|
|
// workers, 0 at 1 worker). The widget has no retry and falls to its empty state
|
|
// on a dropped fetch. Navigation + one reload therefore live HERE, so the retry
|
|
// guards exactly what is asserted — the previous loop re-checked the *heading*,
|
|
// which is visible in the broken state, so it always broke out of the loop and
|
|
// then failed the link assertion. A genuinely empty explorer still goes RED:
|
|
// both attempts have to render blocks.
|
|
export async function expectBlocksSection(page: Page, url: string, timeout = 15_000) {
|
|
const heading = page.getByText(/latest blocks|total blocks/i).first()
|
|
const blockLink = page.locator('a[href*="/block/"], a[href*="/blocks/"]').first()
|
|
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
|
const shown = await heading.isVisible({ timeout }).catch(() => false)
|
|
&& await blockLink.isVisible({ timeout: 5_000 }).catch(() => false)
|
|
if (shown) break
|
|
if (attempt === 0) console.log(' explorer served no latest blocks — reloading once')
|
|
}
|
|
await expect(heading, 'no visible blocks section on the explorer').toBeVisible({ timeout })
|
|
await expect(blockLink,
|
|
'explorer renders no link to a block ("No latest blocks found") after a reload').toBeVisible({ timeout })
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// "The page actually rendered" — the ONE assertion for a route sweep
|
|
// ---------------------------------------------------------------------------
|
|
// Three of the surfaces in the registry answer 200 for a path that does not
|
|
// exist (lux.cloud, exchange, bridge — measured with a /zzz-bogus control), so
|
|
// a status-code sweep across them would report full coverage of nothing. This
|
|
// is the replacement: it looks at what the browser PAINTED.
|
|
//
|
|
// Everything asserted here is a POSITIVE signal — text was rendered, a real
|
|
// landmark element exists. Nothing asserts the absence of an error string;
|
|
// "no 'error' in the body" is how two runs were declared green today while the
|
|
// screen was blank. hanzo.vote is the shape this catches: HTTP 200, and
|
|
// `#root` with zero children on 6 of 6 loads.
|
|
|
|
export interface Render {
|
|
url: string
|
|
status: number | null
|
|
textLen: number
|
|
rootKids: number
|
|
heading: string
|
|
landmark: boolean
|
|
/** The framework's own error boundary is on screen (see CRASH_SCREENS). */
|
|
crashed: string | null
|
|
}
|
|
|
|
// The error boundaries these apps actually render, measured verbatim. Matching
|
|
// them is a POSITIVE detection — "this page is showing its crash screen" — not
|
|
// the absent-error-string check that produced two false passes today. A crashed
|
|
// page paints plenty of text and would sail through a length assertion:
|
|
// exchange /pool serves HTTP 200 with 553 characters, all of which say
|
|
// "Something went wrong. Sorry, an error occured while processing your request".
|
|
export const CRASH_SCREENS = [
|
|
'something went wrong',
|
|
'an error occured while processing your request',
|
|
'an error occurred while processing your request',
|
|
'application error: a client-side exception',
|
|
'unhandled runtime error',
|
|
'this page could not be found',
|
|
]
|
|
|
|
/** Load a route and report what it painted. Never throws on a bad page. */
|
|
export async function renderRoute(page: Page, url: string, settleMs = 2500): Promise<Render> {
|
|
let status: number | null = null
|
|
try {
|
|
const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 })
|
|
status = res?.status() ?? null
|
|
} catch {
|
|
status = null
|
|
}
|
|
await page.waitForTimeout(settleMs)
|
|
const raw = await page.evaluate((crashes: string[]) => {
|
|
const root = document.querySelector('#root, #__next, main, body')
|
|
const h = document.querySelector('h1, h2, [role="heading"]')
|
|
const text = (document.body?.innerText ?? '').trim()
|
|
const lower = text.toLowerCase()
|
|
return {
|
|
url: location.href,
|
|
textLen: text.length,
|
|
rootKids: root ? root.children.length : 0,
|
|
heading: (h?.textContent ?? '').trim().slice(0, 80),
|
|
landmark: !!document.querySelector('header, nav, main, [role="main"], [role="navigation"]'),
|
|
crashed: crashes.find((c) => lower.includes(c)) ?? null,
|
|
}
|
|
}, [...CRASH_SCREENS])
|
|
return { ...raw, status }
|
|
}
|
|
|
|
/**
|
|
* A page counts as painted when it rendered real text into a non-empty root.
|
|
*
|
|
* NOT a semantic-landmark check. A first attempt required a header/nav/main and
|
|
* failed Safe (all 31 routes), MPC and the admin console — all three lay out in
|
|
* plain divs and all three render perfectly. That assertion tracked markup
|
|
* fashion, not health. `landmark` is still recorded and logged; it is just not
|
|
* a gate.
|
|
*
|
|
* MEASURED thresholds: the thinnest genuinely-working page in the registry is
|
|
* superadmin at 62 characters ("Lux Admin SuperAdmin Sign in with Lux IAM to
|
|
* continue. Sign in"); a white screen is exactly 0 characters and 0 children
|
|
* (exchange /vote, reproduced on every load). 40 sits between them with room on
|
|
* both sides.
|
|
*/
|
|
export const MIN_RENDERED_TEXT = 40
|
|
|
|
export async function expectRendered(page: Page, url: string, what: string): Promise<Render> {
|
|
let r = await renderRoute(page, url)
|
|
|
|
// ONE reload if the first paint failed, and only then. Measured: the
|
|
// explorer's /stats and /advanced-filter serve their error boundary under
|
|
// CONCURRENT cold load and render perfectly on a solo load (3/3), the same
|
|
// no-retry-on-a-dropped-fetch weakness already documented in
|
|
// expectBlocksSection. The exchange's /pool, /positions, /bridge, /teleport
|
|
// and /vote fail on a SOLO load too — so one reload is exactly the line
|
|
// between "the page has no retry" and "the page is broken", and the broken
|
|
// ones stay red. This is not a way to get green: both attempts have to fail
|
|
// for a route to pass, and nothing is retried that painted correctly.
|
|
if (r.crashed || r.textLen < MIN_RENDERED_TEXT) {
|
|
console.log(` ${url} → ${r.crashed ?? 'white screen'} on first paint, reloading once`)
|
|
r = await renderRoute(page, url, 5_000)
|
|
}
|
|
|
|
// 1. It painted something.
|
|
expect(r.textLen,
|
|
`${what} is a WHITE SCREEN — ${url} → HTTP ${r.status}, body text ${r.textLen} chars, `
|
|
+ `root children ${r.rootKids}`).toBeGreaterThanOrEqual(MIN_RENDERED_TEXT)
|
|
expect(r.rootKids,
|
|
`${what} rendered text but its root has no children — ${url}`).toBeGreaterThan(0)
|
|
|
|
// 2. What it painted is the page, not the app's error boundary. Separate
|
|
// assertion because a crash screen passes (1) with hundreds of characters.
|
|
expect(r.crashed,
|
|
`${what} is serving its CRASH SCREEN at HTTP ${r.status} — ${url} rendered `
|
|
+ `"${r.crashed}". The route resolves and the app throws.`).toBe(null)
|
|
|
|
return r
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Which door the exchange answers on
|
|
// ---------------------------------------------------------------------------
|
|
// One product, two hostnames. `lux.exchange` is the vanity domain and the one
|
|
// an investor types; `exchange.lux.network` is the same deployment addressed by
|
|
// its cluster name. MEASURED 2026-07-26 from this network: the vanity host
|
|
// fails the TLS handshake in curl, python and chromium alike
|
|
// (net::ERR_SSL_PROTOCOL_ERROR), the cluster host serves 200 on all 22 routes.
|
|
//
|
|
// The suite must not go red for a TLS blocklist between this laptop and a CDN,
|
|
// and it must not silently skip 36 tests either — which is what it did. So:
|
|
// probe the vanity host once per worker, use it if it answers, otherwise fall
|
|
// through to the origin and SAY SO in the log. Whether lux.exchange is
|
|
// reachable is asserted separately and on purpose, in 02-exchange.
|
|
let exchangeDoor: string | null = null
|
|
|
|
export async function exchangeBase(request: APIRequestContext): Promise<string> {
|
|
if (exchangeDoor) return exchangeDoor
|
|
const vanity = env.urls.exchange
|
|
const status = await httpStatus(request, vanity, 12_000)
|
|
exchangeDoor = status > 0 ? vanity : env.urls.exchangeOrigin
|
|
if (status === 0) {
|
|
console.log(` ${new URL(vanity).host} unreachable from here — using `
|
|
+ `${new URL(env.urls.exchangeOrigin).host} (same deployment)`)
|
|
}
|
|
return exchangeDoor
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Screenshot helper — the ONE deliberate screenshot path.
|
|
// ---------------------------------------------------------------------------
|
|
// ~/Desktop/lux-e2e/<env>/<surface>-<viewport>.png. The viewport comes from the
|
|
// page itself, so the same call in the same spec lands in a distinct file per
|
|
// Playwright project — one frame per viewport, comparable across runs and envs.
|
|
// Failure evidence is the orthogonal concern and belongs to shot-reporter.ts.
|
|
export async function snap(page: Page, surface: string) {
|
|
const path = shotPath(surface, viewportName(page.viewportSize()))
|
|
try {
|
|
await page.screenshot({ path, fullPage: false, timeout: 30_000 })
|
|
} catch {
|
|
console.log(`WARN: screenshot ${surface} timed out, skipping`)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Latency benchmark
|
|
// ---------------------------------------------------------------------------
|
|
export async function benchmarkRpc(
|
|
request: APIRequestContext,
|
|
url: string,
|
|
method: string,
|
|
params: unknown = [],
|
|
iterations = 10
|
|
): Promise<{ avg: number; p50: number; p99: number; errors: number }> {
|
|
const times: number[] = []
|
|
let errors = 0
|
|
|
|
for (let i = 0; i < iterations; i++) {
|
|
const t0 = Date.now()
|
|
try {
|
|
await rpcCall(request, url, method, params, 10_000)
|
|
times.push(Date.now() - t0)
|
|
} catch {
|
|
errors++
|
|
}
|
|
}
|
|
|
|
if (times.length === 0) return { avg: 0, p50: 0, p99: 0, errors }
|
|
times.sort((a, b) => a - b)
|
|
const avg = times.reduce((a, b) => a + b, 0) / times.length
|
|
const p50 = times[Math.floor(times.length / 2)]
|
|
const p99 = times[times.length - 1]
|
|
return { avg: Math.round(avg), p50: Math.round(p50), p99: Math.round(p99), errors }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sign-in — the one flow, so an authenticated spec never re-derives it
|
|
// ---------------------------------------------------------------------------
|
|
// 50-login.spec.ts is the PROOF that this flow is real (token claims, userinfo
|
|
// 200, sign-out present). Every other authenticated spec just needs to *be*
|
|
// signed in, and must not carry a second copy of the click path — the second
|
|
// copy is what silently sends a run to github.com/login when the accessible
|
|
// name of a button changes.
|
|
//
|
|
// Returns the access token the sign-in produced, so a caller can assert on its
|
|
// claims. NEVER log the return value.
|
|
export const IAM_TOKEN_KEY = 'hanzo_iam_access_token'
|
|
|
|
export async function signIn(page: Page): Promise<string> {
|
|
expect(CREDS.password, PASSWORD_HINT).not.toBe('')
|
|
const cloudHost = new URL(env.urls.cloud).host
|
|
const idpHost = new URL(env.urls.id).host
|
|
const esc = (h: string) => h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
|
|
await page.goto(`${env.urls.cloud}/login`, { waitUntil: 'domcontentloaded' })
|
|
await page.waitForTimeout(2500)
|
|
await page.getByRole('button', { name: /continue with/i }).first().click()
|
|
await page.waitForURL(new RegExp(esc(idpHost)), { timeout: 45_000 })
|
|
|
|
await page.locator('input[type=password]').waitFor({ state: 'visible', timeout: 30_000 })
|
|
await page.locator('input[type=email], input[type=text]').first().fill(CREDS.user)
|
|
await page.locator('input[type=password]').first().fill(CREDS.password)
|
|
// EXACT accessible name — `button[type=submit]` matches "Continue with GitHub".
|
|
await page.getByRole('button', { name: /^(sign in|log in)$/i }).first().click()
|
|
|
|
await page.waitForURL(new RegExp(esc(cloudHost) + '/dashboard'), { timeout: 60_000 })
|
|
.catch(() => {})
|
|
await page.waitForTimeout(5000)
|
|
|
|
const jwt = await page.evaluate((k) => sessionStorage.getItem(k), IAM_TOKEN_KEY)
|
|
expect(jwt, `no ${IAM_TOKEN_KEY} in sessionStorage — the code→token exchange failed `
|
|
+ `(landed on ${page.url()})`).toBeTruthy()
|
|
return jwt!
|
|
}
|