Files
2026-11-14 09:56:45 +00:00

185 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.
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What every spec does: sign in through the dev adapter, call a procedure the way the page's own
// forms do (with the page's CSRF token), and make an organization to work in.
import { randomBytes } from "node:crypto"
import { type APIRequestContext, expect, type Page } from "@playwright/test"
import axe from "axe-core"
export const unique = (prefix: string): string => `${prefix}-${randomBytes(3).toString("hex")}`
/**
* Waits until the page is interactive: the shell sets `data-hydrated` on <html> once React has hydrated it
* (`apps/web/src/ui/hydrated.ts`). A click or a key before that is lost, so act after this, not in a retry loop.
*/
export async function ready(page: Page): Promise<void> {
await expect(page.locator("html")).toHaveAttribute("data-hydrated", "true")
}
/** Signs in as `email` (created on first use) and lands on the home page, ready to be used. */
export async function signIn(page: Page, email: string): Promise<void> {
await page.goto(`/auth/dev-login?email=${encodeURIComponent(email)}`)
await expect(page.locator('meta[name="csrf-token"]')).toHaveCount(1)
await ready(page)
}
/** The CSRF token of the page the browser is on: what the page's own forms send back. */
const csrfOf = (page: Page): Promise<string> =>
page
.locator('meta[name="csrf-token"]')
.getAttribute("content")
.then((value) => value ?? "")
/** Calls a procedure with the session of `page`. Throws on a refusal, with the problem's own words. */
export async function call<T = unknown>(page: Page, procedure: string, input: unknown): Promise<T> {
const response = await callRaw(page.request, await csrfOf(page), procedure, input)
const body = (await response.json().catch(() => null)) as T
if (!response.ok()) throw new Error(`${procedure} answered ${response.status()}: ${JSON.stringify(body)}`)
return body
}
export const callRaw = (request: APIRequestContext, csrf: string, procedure: string, input: unknown) =>
request.post(`/api/v1/${procedure}`, {
data: input,
headers: { "x-csrf-token": csrf, "content-type": "application/json", "sec-fetch-site": "same-origin" },
})
export interface Workspace {
/** The organization's id: the app tenant a business-service binding names. */
orgId: string
org: string
workspace: string
project: string
}
/** The signed-in user's own organization with one workspace and one project. */
export async function makeOrganization(page: Page, prefix = "acme"): Promise<Workspace> {
const org = unique(prefix)
const { orgId } = await call<{ orgId: string }>(page, "tenancy.org.create", { slug: org, name: `Org ${org}` })
const workspace = (
await call<{ slug: string }>(page, "tenancy.workspace.create", {
org,
slug: "main",
name: "Main",
visibility: "org",
})
).slug
const project = (
await call<{ slug: string }>(page, "tenancy.project.create", {
org,
workspace,
slug: "review",
name: "Review",
moduleKey: "documents",
})
).slug
return { orgId, org, workspace, project }
}
/**
* Binds an organization to the deployment on the fake platform, with exactly the service scopes the app's selected
* business modules declare (`declaredBusinessScopes`, as `pnpm dev` binds its demo organizations). In Cloud this is a
* project administrator's explicit act, and a lookup never creates a binding; a spec does it on purpose, after it has
* seen the refusal an unbound organization gets.
*/
export async function bindBusinessServices(
orgId: string,
grants: Readonly<Record<string, readonly string[]>>,
): Promise<void> {
const base = process.env.E2E_PLATFORM_URL
if (!base) throw new Error("e2e: E2E_PLATFORM_URL is set by the global setup")
const response = await fetch(`${base}/__e2e/app-bindings`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ app_tenant_id: orgId, grants }),
})
if (!response.ok)
throw new Error(`e2e: the fake platform refused the binding (${response.status}: ${await response.text()})`)
}
/** Puts axe into the page. */
async function injectAxe(page: Page): Promise<void> {
await page.addScriptTag({ content: axe.source })
}
/** WCAG 2.2 AA violations on the page the browser is on, as axe finds them. */
export async function violations(page: Page): Promise<string[]> {
await injectAxe(page)
// Finite entrance/hover transitions temporarily blend text and backgrounds.
// Audit their settled paint without disabling motion or waiting on continuous busy indicators.
await page.waitForFunction(
() =>
document
.getAnimations()
.every(
(animation) =>
!Number.isFinite(animation.effect?.getComputedTiming().endTime) ||
animation.playState === "finished" ||
animation.playState === "idle",
),
undefined,
{ timeout: 5000 },
)
const results = await page.evaluate(() =>
(
globalThis as unknown as {
axe: {
run(
context: unknown,
options: unknown,
): Promise<{ violations: Array<{ id: string; nodes: Array<{ target: unknown[] }> }> }>
}
}
).axe.run(document, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] } }),
)
return results.violations.map(
(violation) => `${violation.id}: ${violation.nodes.map((node) => node.target.join(" ")).join(", ")}`,
)
}
/** What the fake platform has recorded: the audit events and usage facts the worker sent, and the blobs it stored. */
export interface PlatformState {
audit: Array<{ action: string; resource_id: string | null }>
usage: Array<Record<string, unknown>>
blobs: Array<{ pathname: string; contentType: string; size: number }>
otel: { logs: Array<Record<string, unknown>>; traces: Array<Record<string, unknown>> }
}
export async function platformState(): Promise<PlatformState> {
const base = process.env.E2E_PLATFORM_URL
if (!base) throw new Error("e2e: E2E_PLATFORM_URL is set by the global setup")
const response = await fetch(`${base}/__e2e/state`)
if (!response.ok) throw new Error(`e2e: the fake platform answered ${response.status}`)
const state = (await response.json()) as PlatformState
// Audit and objects use the fake token’s tenant. Billing is scoped to the
// server-pinned deployment organization, as it is on Cloud.
const organization = process.env.E2E_USAGE_ORGANIZATION
if (!organization) throw new Error("e2e: the global setup pins E2E_USAGE_ORGANIZATION")
const billed = await fetch(`${base}/__e2e/state`, { headers: { "x-conusai-organization": organization } })
if (!billed.ok) throw new Error(`e2e: the fake billing state answered ${billed.status}`)
return { ...state, usage: ((await billed.json()) as PlatformState).usage }
}
/** Opens the person's menu at the foot of the frame's sidebar. */
export async function openPersonMenu(page: Page): Promise<void> {
await ready(page)
await page.locator('[data-guide="frame.person"]').click()
}
/**
* Chooses a language from the person's menu. The page changes language in place (the loaders run again, nothing
* reloads), so this waits for the document's `lang` to move off the one it had.
*/
export async function chooseLanguage(page: Page, language: string): Promise<void> {
const before = await page.locator("html").getAttribute("lang")
await openPersonMenu(page)
await page.getByRole("menuitemradio", { name: language }).click()
await expect(page.locator("html")).not.toHaveAttribute("lang", before ?? "")
}
/** Opens the scope switcher in the frame's sidebar. */
export async function openScope(page: Page): Promise<void> {
await ready(page)
await page.locator('[data-guide="frame.scope"]').click()
}