ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The account page in a browser (joint contract §12): a person exports their data -- every installed
// module's section in one file -- then deletes the account with the keyboard, and is signed out on
// every device; the browser modules' stored state is gone, and the same address signs up again as
// a new person with nothing. axe on each state the page shows.
import { readFileSync } from "node:fs"
import { expect, test } from "@playwright/test"
import { callRaw, makeOrganization, openPersonMenu, signIn, unique, violations } from "./support/app"
test("a person exports their data, then deletes the account and is signed out everywhere", async ({
page,
browser,
}) => {
const email = `${unique("leaver")}@example.test`
await signIn(page, email)
const { org } = await makeOrganization(page, "leaver")
// A second device of the same person.
const elsewhere = await browser.newContext()
const other = await elsewhere.newPage()
await signIn(other, email)
// What the browser modules keep for a signed-in person, as their providers store it.
await page.evaluate((slug) => {
sessionStorage.setItem(`conusai.guides.${slug}.offline`, "{}") // kit:guides
sessionStorage.setItem("conusai.support.session.v1:spk_e2e", "{}") // kit:support
sessionStorage.setItem(`unrelated.${slug}`, "kept")
}, org)
// The shell links to the page from every page.
await page.goto("/")
await openPersonMenu(page)
await page.getByRole("menuitem", { name: "Account", exact: true }).click()
await expect(page).toHaveURL(/\/settings\/account$/)
const account = page.getByRole("region", { name: "Your account", exact: true })
await expect(account.getByRole("heading", { level: 2, name: "Your account", exact: true })).toBeVisible()
await expect(account.getByText(email, { exact: true })).toBeVisible()
await expect(page.getByText(/You are the only member of Org leaver-/)).toBeVisible()
expect(await violations(page)).toEqual([])
// Export: one JSON file, every section named.
const download = page.waitForEvent("download")
await page.getByRole("button", { name: "Export my data" }).click()
const file = await download
expect(file.suggestedFilename()).toMatch(/^account-export-\d{4}-\d{2}-\d{2}\.json$/)
const exported = JSON.parse(readFileSync(await file.path(), "utf8")) as {
format: string
complete: boolean
sections: Record<string, { status: string; data: unknown }>
}
expect(exported.format).toBe("conusai.account-export.v1")
expect(exported.complete).toBe(true)
expect(exported.sections.account).toMatchObject({
status: "ok",
data: { profile: { email }, organizations: [{ slug: org, role: "owner" }] },
})
// Offline (the fake platform), the services hold nothing of the person, and say so.
expect(exported.sections.guides).toEqual({ status: "ok", data: null }) // kit:guides
expect(exported.sections.support).toEqual({ status: "ok", data: null }) // kit:support
await expect(page.getByRole("status").filter({ hasText: "Your export is downloaded." })).toBeVisible()
// The analytics erasure asks first, starts on Cancel, and gives focus back when it is cancelled.
const erase = page.getByRole("button", { name: "Erase my analytics data" })
await erase.click()
const erasure = page.getByRole("alertdialog", { name: "Erase my analytics data" })
await expect(erasure.getByText("cannot be undone")).toBeVisible()
await expect(erasure.getByRole("button", { name: "Cancel" })).toBeFocused()
expect(await violations(page)).toEqual([])
await page.keyboard.press("Escape")
await expect(erasure).toBeHidden()
await expect(erase).toBeFocused()
// A refusal stays in the dialog, which stays open; confirming again goes through.
await page.route("**/account.analytics.request", (route) =>
route.fulfill({
status: 503,
contentType: "application/problem+json",
body: JSON.stringify({ title: "Unavailable", status: 503, code: "unavailable", detail: "no answer" }),
}),
)
await erase.click()
await erasure.getByRole("button", { name: "Yes, erase it" }).click()
await expect(erasure.getByRole("alert")).toHaveText("That did not work: no answer")
await page.unroute("**/account.analytics.request")
await erasure.getByRole("button", { name: "Yes, erase it" }).click()
await expect(erasure).toBeHidden()
await expect(page.getByRole("status").filter({ hasText: "Your request is done." })).toBeVisible()
// A wrong address is refused, and nothing happens.
const confirm = page.getByLabel("Type your e-mail address to confirm")
await confirm.fill("somebody@example.test")
await page.getByRole("button", { name: "Delete my account" }).click()
await expect(
page.getByRole("alert").filter({ hasText: "type the account's e-mail address to confirm" }),
).toBeVisible()
expect(await violations(page)).toEqual([])
// The address, and Enter: the keyboard alone deletes the account.
await confirm.fill(email)
await confirm.press("Enter")
await expect(
page.getByRole("status").filter({ hasText: "Your account is deleted, and you are signed out." }),
).toBeVisible()
expect(await violations(page)).toEqual([])
// The browser modules' lifecycle handlers ran: their keys are gone, nobody else's.
const left = await page.evaluate(() => Object.keys(sessionStorage).sort())
expect(left).toEqual([`unrelated.${org}`])
// Signed out here, and on the other device.
await page.goto("/settings/account")
await expect(page).toHaveURL(/\/auth\/sign-in/)
await other.goto("/")
await expect(other.getByRole("heading", { level: 1, name: "Sign in", exact: true })).toBeVisible()
// The other device's cookie, sent to the API: its session is revoked, so it is nobody.
expect((await callRaw(other.request, "", "account.deletion.status", undefined)).status()).toBe(401)
await elsewhere.close()
// The same address is a new person now, with no organization.
await signIn(page, email)
await page.goto("/")
await expect(page.getByRole("heading", { level: 1, name: "Create your organization" })).toBeVisible()
await expect(page.getByRole("link", { name: `Org ${org}` })).toHaveCount(0)
})
for (const theme of ["light", "dark"] as const) {
test(`WCAG audits wait for finite dialog motion and still refuse bad contrast (${theme})`, async ({ page }, info) => {
await signIn(page, `${unique("motion-audit")}@example.test`)
await makeOrganization(page, "motion-audit")
await page.goto("/")
await openPersonMenu(page)
await page.getByRole("menuitemradio", { name: theme === "light" ? "Light" : "Dark", exact: true }).click()
await openPersonMenu(page)
await page.getByRole("menuitem", { name: "Account", exact: true }).click()
await page.emulateMedia({ reducedMotion: "no-preference" })
// Extend the real entrance to expose sampling during motion deterministically.
// Keep unrelated continuous motion running: an audit must not wait forever or disable it.
await page.evaluate(() => {
document.documentElement.style.setProperty("--kit-motion-standard", "2000ms")
const continuous = document.body.animate([{ opacity: 1 }, { opacity: 1 }], {
duration: 1000,
iterations: Infinity,
})
continuous.id = "audit-continuous"
})
const erase = page.getByRole("button", { name: "Erase my analytics data", exact: true })
await erase.click()
const dialog = page.getByRole("alertdialog", { name: "Erase my analytics data", exact: true })
expect(
await dialog.evaluate((el) => el.getAnimations().some((animation) => animation.playState === "running")),
).toBe(true)
expect(await violations(page)).toEqual([])
expect(
await page.evaluate(() =>
document
.getAnimations()
.some((animation) => animation.id === "audit-continuous" && animation.playState === "running"),
),
).toBe(true)
const button = dialog.getByRole("button", { name: "Yes, erase it", exact: true })
const settled = await button.evaluate((el) => {
const style = getComputedStyle(el)
const dialog = el.closest('[role="alertdialog"]')
if (!dialog) throw new Error("The confirmation dialog is missing")
const surface = getComputedStyle(dialog)
return {
color: style.color,
background: style.backgroundColor,
opacity: surface.opacity,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
}
})
await info.attach("settled-dialog-style", {
body: Buffer.from(JSON.stringify({ theme, ...settled }, null, 2)),
contentType: "application/json",
})
await page.screenshot({ path: info.outputPath(`motion-audit-${theme}.png`) })
// A genuine steady-state violation must still be reported after synchronization.
await page.evaluate(() => {
for (const animation of document.getAnimations()) if (animation.id === "audit-continuous") animation.cancel()
})
// Keep the known low-opacity rendering static: settling motion must not hide a real violation.
await dialog.evaluate((el) => {
el.style.opacity = "0.4"
})
const refused = await violations(page)
await info.attach("deliberate-contrast-refusal", {
body: Buffer.from(JSON.stringify(refused)),
contentType: "application/json",
})
expect(refused.some((issue) => issue.startsWith("color-contrast:"))).toBe(true)
await dialog.evaluate((el) => {
el.style.removeProperty("opacity")
})
expect(await violations(page)).toEqual([])
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(erase).toBeFocused()
})
}
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The default Agent and Help integrations over real Postgres and the simulated Cloud protocol.
import { expect, type Page, test } from "@playwright/test"
import { moduleSelected } from "../modules/inclusion"
import { call, makeOrganization, signIn, unique, violations } from "./support/app"
test.skip(
!["agent", "guides", "support"].every((id) => moduleSelected(id as "agent" | "guides" | "support")),
"Assistance was explicitly excluded",
)
async function prepare(page: Page) {
await signIn(page, `${unique("owner")}@example.test`)
const scope = await makeOrganization(page)
await page.goto(`/${scope.org}/${scope.workspace}/modules/notes`)
// Functional panel checks start after the person resolves privacy choices.
// The first-visit consent flow has its own keyboard and accessibility suite.
await page.getByRole("button", { name: "Reject analytics and replay", exact: true }).click()
await expect(page.getByRole("region", { name: "Privacy choices", exact: true })).toHaveCount(0)
return scope
}
test("Agent streams and persists a marked answer, and keeps a draft when switching panels", async ({ page }, info) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
const scope = await prepare(page)
await expect(page.locator("[data-conusai-guide-root]")).toHaveCount(0)
await page.getByRole("button", { name: "Agent", exact: true }).click()
await expect(page.getByText("Ask the Agent", { exact: true })).toBeVisible()
await expect(page.getByText("The Agent is AI and can make mistakes. Check anything important.")).toBeVisible()
const input = page.getByRole("textbox", { name: "Message the Agent" })
await input.fill("Explain this workspace")
await input.press("Shift+Enter")
await input.press("x")
await expect(input).toHaveValue("Explain this workspace\nx")
await input.press("Enter")
const answer = page.getByRole("log").getByText("This is the fake platform: no model is behind it.", { exact: true })
await expect(answer).toBeVisible()
await expect
.poll(async () => {
const list = await call<{ conversations: { conversationId: string }[] }>(page, "assistant.conversation.list", {
org: scope.org,
workspace: scope.workspace,
})
const id = list.conversations[0]?.conversationId
if (!id) return null
const conversation = await call<{ turns: { status: string; answerProvenance: unknown }[] }>(
page,
"assistant.conversation.get",
{ org: scope.org, workspace: scope.workspace, conversationId: id },
)
return conversation.turns.at(-1)
})
.toMatchObject({ status: "complete", answerProvenance: { ai_generated: true } })
await expect(input).toBeFocused()
await expect(page.locator(".kit-side-panel-head")).toBeInViewport({ ratio: 1 })
expect(await page.evaluate(() => window.scrollY)).toBe(0)
await page.screenshot({ path: info.outputPath("agent-desktop.png") })
expect(await violations(page)).toEqual([])
await page.reload()
await page.getByRole("button", { name: "Agent", exact: true }).click()
await expect(answer).toBeVisible()
await input.fill("Keep this draft")
await page.getByRole("button", { name: "Full screen", exact: true }).click()
const full = page.getByRole("dialog", { name: "Agent", exact: true })
await expect(full).toBeVisible()
await expect(full.getByRole("textbox", { name: "Message the Agent" })).toHaveValue("Keep this draft")
await expect(answer).toBeVisible()
expect(await violations(page)).toEqual([])
await page.screenshot({ path: info.outputPath("agent-fullscreen.png") })
await full.getByRole("button", { name: "Restore panel", exact: true }).click()
await expect(full).toBeHidden()
await expect(input).toHaveValue("Keep this draft")
await page.getByRole("button", { name: "Help", exact: true }).click()
await page.getByRole("tab", { name: "Agent", exact: true }).click()
await expect(input).toHaveValue("Keep this draft")
await input.focus()
await page.keyboard.press("Escape")
const guard = page.getByRole("alertdialog")
await expect(guard).toBeVisible()
await guard.getByRole("button", { name: /Keep/ }).click()
await expect(input).toHaveValue("Keep this draft")
await input.fill("")
await page.getByRole("button", { name: "Help", exact: true }).click()
await page.getByRole("tab", { name: "Agent", exact: true }).click()
await expect(answer).toBeVisible()
expect(errors).toEqual([])
})
test("Help runs a requested Notes tour and explains local Support on a phone", async ({ page }, info) => {
const scope = await prepare(page)
await page.setViewportSize({ width: 375, height: 812 })
await page.goto(`/${scope.org}/${scope.workspace}/${scope.project}/notes`)
await expect(page.locator("[data-conusai-guide-root]")).toHaveCount(0)
await page.getByRole("button", { name: "Help", exact: true }).click()
const panel = page.getByRole("dialog", { name: "Help", exact: true })
await expect(panel.getByText("Get started with notes", { exact: true })).toBeVisible()
await panel.getByRole("checkbox", { name: "Tell your team where the notes are" }).check()
await expect(panel.getByRole("checkbox", { name: "Tell your team where the notes are" })).toBeChecked()
await panel.getByRole("button", { name: "Start guide: Take the notes tour", exact: true }).click()
const tour = page.locator("[data-conusai-guide-root]")
await expect(tour.getByRole("heading", { name: "Write a note", exact: true })).toBeVisible()
await expect(panel).toBeHidden()
await page.screenshot({ path: info.outputPath("guides-mobile-tour.png") })
await tour.getByRole("button", { name: "Next", exact: true }).click()
await tour.getByRole("button", { name: "Next", exact: true }).click()
await tour.getByRole("button", { name: "Done", exact: true }).click()
await page.getByRole("button", { name: "Help", exact: true }).click()
await panel.getByRole("tab", { name: "Support", exact: true }).click()
await expect(panel.getByText("Support is not connected in this local demo", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Try again" })).toHaveCount(0)
await expect(page.locator(".support-launcher")).toHaveCount(0)
expect(await violations(page)).toEqual([])
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.screenshot({ path: info.outputPath("help-mobile-support.png") })
})
test("Support distinguishes loading, a retryable failure and a permission refusal", async ({ page }, info) => {
await prepare(page)
let release: (() => void) | undefined
const wait = new Promise<void>((resolve) => {
release = resolve
})
await page.route("**/api/v1/support.widget.config", async (route) => {
await wait
await route.fulfill({
status: 503,
contentType: "application/problem+json",
body: JSON.stringify({ status: 503, title: "Unavailable" }),
})
})
await page.getByRole("button", { name: "Help", exact: true }).click()
const panel = page.locator("[data-slot=side-panel]")
await panel.getByRole("tab", { name: "Support", exact: true }).click()
await expect(panel.locator("[data-slot=support-loading]")).toBeVisible()
await page.screenshot({ path: info.outputPath("support-loading.png") })
release?.()
await expect(panel.locator("[data-slot=support-unavailable]")).toBeVisible()
await page.unroute("**/api/v1/support.widget.config")
await panel.getByRole("button", { name: "Try again", exact: true }).focus()
await page.keyboard.press("Enter")
await expect(panel.locator("[data-slot=support-offline]")).toBeVisible()
await page.route("**/api/v1/support.widget.config", (route) =>
route.fulfill({
status: 403,
contentType: "application/problem+json",
body: JSON.stringify({ status: 403, title: "Forbidden" }),
}),
)
await page.reload()
await page.getByRole("button", { name: "Help", exact: true }).click()
await panel.getByRole("tab", { name: "Support", exact: true }).click()
await expect(panel.locator("[data-slot=support-denied]")).toBeVisible()
await expect(panel.getByRole("button", { name: "Try again" })).toHaveCount(0)
})
test("navigation keeps the Agent draft and respects reduced motion", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
const scope = await prepare(page)
await page.emulateMedia({ reducedMotion: "no-preference" })
await page.evaluate(() => {
const native = document.startViewTransition.bind(document)
const proof = window as unknown as { transitions: number; sameDocument: string }
proof.transitions = 0
proof.sameDocument = "preserved"
document.startViewTransition = ((...args: Parameters<typeof native>) => {
proof.transitions += 1
return native(...args)
}) as typeof document.startViewTransition
})
await page.getByRole("button", { name: "Agent", exact: true }).click()
const input = page.getByRole("textbox", { name: "Message the Agent" })
await input.fill("Keep this while navigating")
await page.getByRole("link", { name: "Workspace overview", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${scope.org}/${scope.workspace}$`))
await expect(input).toHaveValue("Keep this while navigating")
await expect
.poll(() => page.evaluate(() => (window as unknown as { transitions: number }).transitions))
.toBeGreaterThan(0)
await page.waitForFunction(() => !document.documentElement.matches(":active-view-transition"))
const before = await page.evaluate(() => (window as unknown as { transitions: number }).transitions)
await page.emulateMedia({ reducedMotion: "reduce" })
await page.getByRole("link", { name: "Notes in this workspace", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${scope.org}/${scope.workspace}/modules/notes$`))
await expect(input).toHaveValue("Keep this while navigating")
expect(await page.evaluate(() => (window as unknown as { transitions: number }).transitions)).toBe(before)
expect(await page.evaluate(() => (window as unknown as { sameDocument: string }).sameDocument)).toBe("preserved")
expect(errors).toEqual([])
})
+10
View File
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Starts the J-auth stack once for the run and removes it after (containers, volumes, processes).
import { startJauthStack } from "./stack"
export default async function globalSetup(): Promise<() => Promise<void>> {
const { stack, stop } = await startJauthStack()
process.env.JAUTH_STACK = JSON.stringify(stack)
return stop
}
+518
View File
@@ -0,0 +1,518 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Gate J-auth (joint contract §14) in a browser: real Better Auth sessions on the platform's pinned
// self-hosted Convex backend, a production build of this app (`node server.mjs`), headless Chromium.
//
// - sign-up and sign-in through the app's own pages; reload; a second device without a second login
// - server rendering of a signed-in page, with and without JavaScript, and client hydration
// - Support's widget-session mint and Guides' bootstrap resolving the one stable app user
// - sign-out of one device ending only its derived sessions; a revocation made at Better Auth
// itself reaching them through the relay; an administrator's disablement (admin plugin) refusing
// the user; Better Auth's account deletion and the app's own both erasing through the account flow
// - a Better Auth outage as a typed unavailable answer, never a signed-out or another person
// - network and CSP inspection: one origin, no policy violation, no server secret and no bearer
// in anything the browser received
//
// The fake platform answers the platform API (Support, Guides); everything about the sign-in is real.
import { execFileSync } from "node:child_process"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join } from "node:path"
import { type Browser, type BrowserContext, expect, type Page, test } from "@playwright/test"
import { CONSENT_COOKIE, recordConsentChoice } from "../../apps/web/src/observe/consent"
import { call, callRaw, makeOrganization, unique, violations } from "../support/app"
import { APP_ROOT, type JauthStack } from "./stack"
const stack = (): JauthStack => JSON.parse(process.env.JAUTH_STACK ?? "{}") as JauthStack
const PASSWORD = "correct horse battery staple"
/** Everything the browsers of this run received, for the inspection at the end. */
const seen = {
requests: [] as string[],
bodies: [] as Array<{ url: string; text: string }>,
headers: [] as Array<{ url: string; headers: Record<string, string> }>,
setCookies: [] as string[],
pageErrors: [] as string[],
}
/** This person has answered the privacy choices, so the banner is not over the forms this suite submits. */
const answerPrivacyChoices = (context: BrowserContext): Promise<void> =>
context.addCookies([
{
name: CONSENT_COOKIE,
value: encodeURIComponent(JSON.stringify(recordConsentChoice({ analytics: false, replay: false }))),
url: stack().baseUrl,
},
])
async function device(browser: Browser, index: number): Promise<{ context: BrowserContext; page: Page }> {
// A distinct client address per device, as the platform's ingress would send it (Better Auth keys
// its rate limits by it).
const context = await browser.newContext({ extraHTTPHeaders: { "x-forwarded-for": `10.20.0.${index}` } })
await answerPrivacyChoices(context)
await context.addInitScript(() => {
const target = globalThis as unknown as { __csp?: string[] }
target.__csp = []
document.addEventListener("securitypolicyviolation", (event) =>
target.__csp?.push(`${event.violatedDirective} ${event.blockedURI}`),
)
})
context.on("request", (request) => seen.requests.push(request.url()))
context.on("response", async (response) => {
const headers = await response.allHeaders().catch(() => ({}) as Record<string, string>)
seen.headers.push({ url: response.url(), headers })
for (const header of await response.headersArray().catch(() => [])) {
if (header.name.toLowerCase() === "set-cookie") seen.setCookies.push(header.value)
}
const type = headers["content-type"] ?? ""
if (/json|html|javascript|text|event-stream/.test(type) && !type.includes("event-stream")) {
const text = await response.text().catch(() => "")
seen.bodies.push({ url: response.url(), text })
}
})
const page = await context.newPage()
page.on("pageerror", (error) => seen.pageErrors.push(error.message))
return { context, page }
}
async function cspViolations(page: Page): Promise<string[]> {
return page.evaluate(() => (globalThis as unknown as { __csp?: string[] }).__csp ?? [])
}
/** The fake platform's record of the Support and Guides calls the app made. */
async function platform(): Promise<{
support: Array<Record<string, unknown>>
guides: Array<Record<string, unknown>>
}> {
const response = await fetch(`${stack().platformUrl}/__e2e/state`)
return (await response.json()) as { support: Array<Record<string, unknown>>; guides: Array<Record<string, unknown>> }
}
async function eventually<T>(
label: string,
probe: () => Promise<T | undefined>,
ms = 15_000,
): Promise<{ value: T; after: number }> {
const started = Date.now()
for (;;) {
const value = await probe()
if (value !== undefined) return { value, after: Date.now() - started }
if (Date.now() - started > ms) throw new Error(`j-auth: ${label} did not happen within ${ms} ms`)
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
const signedIn = (page: Page) => expect(page.locator('meta[name="csrf-token"]')).toHaveCount(1)
/** Signs up through the app's sign-up page, and lands signed in. */
async function signUp(page: Page, name: string, email: string): Promise<void> {
await page.goto("/auth/sign-up")
await page.getByLabel("Name").fill(name)
await page.getByLabel("E-mail address").fill(email)
await page.getByLabel("Password").fill(PASSWORD)
const answer = page.waitForResponse((response) => response.url().endsWith("/api/auth/sign-up/email"))
await page.getByRole("button", { name: "Create the account" }).click()
expect((await answer).status()).toBe(200)
await page.waitForURL((url) => url.pathname === "/" || url.pathname === "/onboarding")
await signedIn(page)
}
/**
* A sign-in made the way Better Auth's own client makes it, from inside the page: answers the Better
* Auth user the answer names, and proves the answer carried no bearer.
*/
async function betterAuthUserOf(page: Page, email: string): Promise<string> {
const answer = await authPost(page, "/sign-in/email", { email, password: PASSWORD })
expect(answer.status).toBe(200)
const body = JSON.parse(answer.body) as { user: { id: string }; token?: unknown }
// Better Auth's session token is in the HTTP-only cookie only: the proxy removed it from the body.
expect(body.token).toBeUndefined()
expect(answer.body).not.toMatch(/"token"\s*:/)
// That sign-in replaced the page's session: the page's CSRF token is the new session's after a reload.
await page.reload()
await signedIn(page)
return body.user.id
}
async function signInPage(page: Page, email: string, password = PASSWORD): Promise<number> {
await page.goto("/auth/sign-in")
await page.getByLabel("E-mail address").fill(email)
await page.getByLabel("Password").fill(password)
const answer = page.waitForResponse((response) => response.url().endsWith("/api/auth/sign-in/email"))
await page.locator("form").getByRole("button", { name: "Sign in", exact: true }).click()
return (await answer).status()
}
/** A procedure call as the page's own forms make it; answers the status, for a refusal. */
async function statusOf(page: Page, procedure: string, input: unknown): Promise<number> {
const csrf =
(await page
.locator('meta[name="csrf-token"]')
.getAttribute("content")
.catch(() => null)) ?? ""
return (await callRaw(page.request, csrf, procedure, input)).status()
}
/** A POST to the app's /api/auth from inside the page, as Better Auth's own client would make it. */
const authPost = (page: Page, path: string, body: unknown = {}) =>
page.evaluate(
async ({ path, body }) => {
const response = await fetch(`/api/auth${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
})
return { status: response.status, body: await response.text() }
},
{ path, body },
)
const sessionKey = (mint: Record<string, unknown>) => String((mint.body as Record<string, unknown>).identity_session)
const subjectOf = (mint: Record<string, unknown>) => String((mint.body as Record<string, unknown>).subject)
const mints = async () => (await platform()).support.filter((entry) => entry.route === "mint")
// The Support and Guides rows below use these; an app pruned of both modules keeps the helpers unused.
void [platform, eventually, sessionKey, subjectOf, mints]
test.describe
.serial("J-auth: Better Auth on the self-hosted Convex runtime, in a browser", () => {
const ada = { email: `${unique("ada")}@example.test`, name: "Ada Lovelace" }
let a: { context: BrowserContext; page: Page }
let b: { context: BrowserContext; page: Page }
let org = ""
let adaSubject = ""
test("sign-up, server rendering, hydration and reload", async ({ browser }) => {
a = await device(browser, 1)
const { page, context } = a
// Signed out: a protected page sends the visitor to the sign-in page, which offers Better Auth's form.
await page.goto("/settings/account")
await expect(page).toHaveURL(/\/auth\/sign-in/)
await expect(page.getByLabel("Password")).toBeVisible()
await signUp(page, ada.name, ada.email)
// A sign-in answer names the user and carries no bearer (Better Auth's own body has its token).
await betterAuthUserOf(page, ada.email)
// The one cookie Better Auth leaves in the browser: its HTTP-only session token.
const cookies = await context.cookies()
const betterAuth = cookies.filter((cookie) => cookie.name.includes("better-auth"))
expect(betterAuth.map((cookie) => cookie.name)).toEqual(["better-auth.session_token"])
expect(betterAuth[0]?.httpOnly).toBe(true)
// Server rendering: the signed-in page's HTML names the person before any script runs...
const html = await (await page.request.get("/settings/account")).text()
expect(html).toContain(ada.email)
// ...and with JavaScript off entirely, the same session renders the same person.
const noScript = await browser.newContext({
javaScriptEnabled: false,
storageState: await context.storageState(),
})
const plain = await noScript.newPage()
await plain.goto("/settings/account")
await expect(plain.getByText(ada.email, { exact: true })).toBeVisible()
await noScript.close()
// Hydration: the client controls work (a control that is server HTML only would not toggle).
await page.goto("/settings/account")
const root = page.locator("html")
await page.getByRole("button", { name: "Dark Theme", exact: true }).click()
await page.getByRole("option", { name: "Light", exact: true }).click()
await expect(root).toHaveClass("light")
await page.reload()
await expect(root).toHaveClass("light")
await expect(page.getByText(ada.email, { exact: true })).toBeVisible()
await signedIn(page)
expect(await violations(page)).toEqual([])
expect(await cspViolations(page)).toEqual([])
})
test("Support and Guides resolve one stable app user, on a second device without a second login", async ({
browser,
}) => {
const workspace = await makeOrganization(a.page, "jauth")
org = workspace.org
expect(org).toBeTruthy()
adaSubject = (await call<{ userId: string }>(a.page, "tenancy.me.get", undefined)).userId
expect(adaSubject).toBeTruthy()
// Guides load with the project; Support loads when the person opens its Help tab.
const config = a.page.waitForResponse((response) => response.url().endsWith("/api/v1/support.widget.config")) // kit:support
// kit:guides:begin
const bootstrap = a.page.waitForResponse((response) =>
response.url().endsWith("/api/v1/guides.runtime.bootstrap"),
)
// kit:guides:end
await a.page.goto(`/${workspace.org}/${workspace.workspace}/${workspace.project}`)
// kit:support:begin
await a.page.getByRole("button", { name: "Help", exact: true }).click()
await a.page.getByRole("tab", { name: "Support", exact: true }).click()
// kit:support:end
expect(await (await config).json()).toEqual({ mode: "platform", channel: "spk_jauth" }) // kit:support
expect((await bootstrap).status()).toBe(200) // kit:guides
// kit:support:begin
const first = (await call<{ token: string }>(a.page, "support.widgetSession.mint", { org })).token
expect(first).toMatch(/^sws_/)
// kit:support:end
b = await device(browser, 2)
expect(await signInPage(b.page, ada.email)).toBe(200)
await b.page.waitForURL((url) => !url.pathname.startsWith("/auth/"))
await signedIn(b.page)
// kit:support:begin
await call(b.page, "support.widgetSession.mint", { org })
const minted = await mints()
const mintA = minted[0]
const mintB = minted.find((entry) => mintA && sessionKey(entry) !== sessionKey(mintA))
expect(mintA && mintB).toBeTruthy()
expect(subjectOf(mintA as Record<string, unknown>)).toBe(adaSubject)
// The same app user on both devices, each with its own identity session (one device, one sign-in).
expect(subjectOf(mintB as Record<string, unknown>)).toBe(adaSubject)
expect(sessionKey(mintA as Record<string, unknown>)).toMatch(/^18:convex-better-auth:/)
expect(sessionKey(mintB as Record<string, unknown>)).not.toBe(sessionKey(mintA as Record<string, unknown>))
expect(((mintA as Record<string, unknown>).body as Record<string, unknown>).app_tenant).toBeTruthy()
// kit:support:end
// kit:guides:begin
await call(b.page, "guides.runtime.bootstrap", { org, path: "/" })
const boots = (await platform()).guides.filter((entry) => entry.route === "bootstrap")
expect(boots.length).toBeGreaterThanOrEqual(2)
for (const boot of boots) expect(JSON.stringify(boot)).toContain(adaSubject)
// kit:guides:end
})
test("signing out one device ends that device's derived sessions only", async ({ browser }) => {
const before = await a.context.storageState()
// kit:support:begin
const beforeMints = await mints()
const mintA = beforeMints[0] as Record<string, unknown>
const deviceSessionCount = beforeMints.filter((entry) => sessionKey(entry) === sessionKey(mintA)).length
expect(deviceSessionCount).toBeGreaterThan(0)
// kit:support:end
await a.page.goto("/")
await a.page.getByRole("button", { name: new RegExp(ada.email.replaceAll(".", "\\.")) }).click()
await a.page.getByRole("menuitem", { name: "Sign out", exact: true }).click()
await expect(a.page).toHaveURL(/\/auth\/signed-out$/)
expect((await a.context.cookies()).filter((cookie) => cookie.name.includes("better-auth"))).toEqual([])
// kit:support:begin
const revoked = await eventually("the sign-out's revocation", async () =>
(await platform()).support.find(
(entry) =>
entry.route === "revoke" &&
(entry.body as Record<string, unknown>).identity_session === sessionKey(mintA as Record<string, unknown>),
),
)
expect(revoked.value.revoked).toBe(deviceSessionCount)
// kit:support:end
// The cookie the device held is dead at Better Auth, not just deleted from the browser.
const replay = await browser.newContext({ storageState: before })
const response = await replay.request.post(`/api/v1/tenancy.me.get`, {
headers: { "sec-fetch-site": "same-origin" },
})
expect(response.status()).toBe(401)
await replay.close()
// The other device is untouched.
await b.page.reload()
await signedIn(b.page)
expect(await statusOf(b.page, "tenancy.me.get", undefined)).toBe(200)
})
test("a revocation made at Better Auth reaches the derived sessions through the relay", async ({ browser }) => {
const c = await device(browser, 3)
expect(await signInPage(c.page, ada.email)).toBe(200)
await signedIn(c.page)
// kit:support:begin
await call(c.page, "support.widgetSession.mint", { org })
const mintC = (await mints()).at(-1) as Record<string, unknown>
// kit:support:end
// Device B signs every other device out, at Better Auth (its own endpoint, through the proxy).
const revokedAt = Date.now() // kit:support
const revokeOthers = await authPost(b.page, "/revoke-other-sessions")
expect(revokeOthers.status).toBe(200)
expect(await statusOf(c.page, "tenancy.me.get", undefined)).toBe(401)
// kit:support:begin
await eventually("the relay's revocation of device C", async () =>
(await platform()).support.find(
(entry) =>
entry.route === "revoke" && (entry.body as Record<string, unknown>).identity_session === sessionKey(mintC),
),
)
const propagation = Date.now() - revokedAt
console.info(
`j-auth: a revocation at Better Auth reached Support within ${propagation} ms (relay interval 250 ms)`,
)
expect(propagation).toBeLessThan(10_000)
// kit:support:end
expect(await statusOf(b.page, "tenancy.me.get", undefined)).toBe(200)
await c.context.close()
})
test("an administrator's disablement (admin plugin) refuses the user and ends their sessions", async ({
browser,
}) => {
const grace = { email: `${unique("grace")}@example.test`, name: "Grace Hopper" }
const g = await device(browser, 4)
await signUp(g.page, grace.name, grace.email)
const graceId = await betterAuthUserOf(g.page, grace.email)
const graceOrg = (await makeOrganization(g.page, "grace")).org // kit:support
const graceSubject = (await call<{ userId: string }>(g.page, "tenancy.me.get", undefined)).userId
expect(graceSubject).not.toBe(adaSubject)
await call(g.page, "support.widgetSession.mint", { org: graceOrg }) // kit:support
// The proxy never hands a browser the admin plugin's routes.
expect((await authPost(g.page, "/admin/ban-user", { userId: graceId })).status).toBe(404)
// An administrator (BETTER_AUTH_ADMIN_USER_IDS of the Better Auth deployment) bans her at Better Auth.
const operator = await fetch(`${stack().baseUrl}/api/auth/sign-up/email`, {
method: "POST",
headers: { "content-type": "application/json", origin: stack().baseUrl, "x-forwarded-for": "10.20.0.99" },
body: JSON.stringify({ name: "Operator", email: `${unique("operator")}@example.test`, password: PASSWORD }),
})
expect(operator.status).toBe(200)
const operatorId = ((await operator.json()) as { user: { id: string } }).user.id
const operatorCookie = operator.headers
.getSetCookie()
.map((cookie) => cookie.split(";")[0])
.find((cookie) => cookie?.startsWith("better-auth.session_token="))
execFileSync(
process.execPath,
[join(APP_ROOT, "node_modules/convex/bin/main.js"), "env", "set", "BETTER_AUTH_ADMIN_USER_IDS", operatorId],
{
cwd: join(APP_ROOT, "better-auth"),
env: {
...process.env,
CONVEX_SELF_HOSTED_URL: stack().convexUrl,
CONVEX_SELF_HOSTED_ADMIN_KEY: stack().adminKey,
},
stdio: "ignore",
},
)
const ban = await fetch(`${stack().convexSiteUrl}/api/auth/admin/ban-user`, {
method: "POST",
headers: { "content-type": "application/json", origin: stack().baseUrl, cookie: operatorCookie ?? "" },
body: JSON.stringify({ userId: graceId, banReason: "j-auth" }),
})
expect(ban.status, await ban.text()).toBe(200)
// Her session is gone at once: the app's next request is signed out.
expect(await statusOf(g.page, "tenancy.me.get", undefined)).toBe(401)
// kit:support:begin
// The relay hands the disablement to the account flow, which revokes every derived session of hers.
await eventually("the disablement's revocation", async () =>
(await platform()).support.find(
(entry) => entry.route === "revoke" && (entry.body as Record<string, unknown>).subject === graceSubject,
),
)
// kit:support:end
// And she cannot sign in again: Better Auth refuses, and the page says so in her language.
expect(await signInPage(g.page, grace.email)).toBe(403)
await expect(g.page.locator("form").getByRole("alert")).toHaveText(
"This account is disabled. Ask the people who run this app.",
)
await g.context.close()
})
test("Better Auth's own account deletion erases the person through the account flow", async ({ browser }) => {
const linus = { email: `${unique("linus")}@example.test`, name: "Linus" }
const l = await device(browser, 5)
await signUp(l.page, linus.name, linus.email)
const linusOrg = (await makeOrganization(l.page, "linus")).org
const linusSubject = (await call<{ userId: string }>(l.page, "tenancy.me.get", undefined)).userId
expect(linusSubject).not.toBe(adaSubject)
await call(l.page, "support.widgetSession.mint", { org: linusOrg }) // kit:support
const deleted = await authPost(l.page, "/delete-user")
expect(deleted.status, deleted.body).toBe(200)
expect(await statusOf(l.page, "tenancy.me.get", undefined)).toBe(401)
// kit:support:begin
await eventually("the deletion's Support erasure", async () =>
(await platform()).support.find((entry) => entry.route === "erase" && entry.subject === linusSubject),
)
// kit:support:end
// kit:guides:begin
await eventually("the deletion's Guides erasure", async () =>
(await platform()).guides.find((entry) => entry.route === "erase" && entry.subject === linusSubject),
)
// kit:guides:end
// No resurrection: the same address signs up again as a new person with nothing.
await l.context.clearCookies()
await answerPrivacyChoices(l.context)
await signUp(l.page, linus.name, linus.email)
await l.page.goto("/")
await expect(l.page.getByText(`Org ${linusOrg}`)).toHaveCount(0)
await l.context.close()
})
test("the app's own account deletion ends the Better Auth account too", async ({ browser }) => {
const mary = { email: `${unique("mary")}@example.test`, name: "Mary" }
const m = await device(browser, 6)
await signUp(m.page, mary.name, mary.email)
await m.page.goto("/settings/account")
const view = await call<{ state: string }>(m.page, "account.deletion.request", { confirm: mary.email })
expect(view.state).toBe("completed")
expect(await statusOf(m.page, "tenancy.me.get", undefined)).toBe(401)
// The Better Auth account is gone with it: the password signs nobody in.
expect(await signInPage(m.page, mary.email)).toBe(401)
await m.context.close()
})
test("a Better Auth outage is a typed unavailable answer, never signed out or somebody else", async () => {
execFileSync("docker", ["pause", stack().backend])
try {
const response = await b.page.request.post("/api/v1/tenancy.me.get", {
headers: { "sec-fetch-site": "same-origin" },
})
expect(response.status()).toBe(503)
expect(((await response.json()) as { code?: string }).code).toBe("auth_provider_unavailable")
} finally {
execFileSync("docker", ["unpause", stack().backend])
}
expect(await statusOf(b.page, "tenancy.me.get", undefined)).toBe(200)
})
test("network and CSP: one origin, no violation, no server secret or bearer in the browser", async () => {
const origin = new URL(stack().baseUrl).origin
const foreign = seen.requests.filter(
(url) => !url.startsWith(origin) && !url.startsWith("data:") && !url.startsWith("blob:"),
)
expect(foreign).toEqual([])
expect(await cspViolations(b.page)).toEqual([])
expect(seen.pageErrors).toEqual([])
const documents = seen.headers.filter((entry) => (entry.headers["content-type"] ?? "").includes("text/html"))
expect(documents.length).toBeGreaterThan(5)
for (const entry of documents) {
const policy = entry.headers["content-security-policy"] ?? ""
expect(policy, entry.url).toContain("connect-src 'self'")
expect(policy, entry.url).toMatch(/script-src 'self' 'nonce-[^']+' 'strict-dynamic'/)
expect(policy, entry.url).not.toContain("unsafe-eval")
expect(policy, entry.url).not.toMatch(/\*/)
}
for (const secret of stack().secrets) {
expect(seen.bodies.filter((body) => body.text.includes(secret)).map((body) => body.url)).toEqual([])
expect(
seen.headers.filter((entry) => JSON.stringify(entry.headers).includes(secret)).map((entry) => entry.url),
).toEqual([])
}
// No bearer of Better Auth's: no token field from /api/auth, no cached Convex JWT cookie, no set-auth-token.
for (const body of seen.bodies.filter((entry) => entry.url.includes("/api/auth/"))) {
expect(body.text, body.url).not.toMatch(/"token"\s*:/)
}
expect(seen.setCookies.filter((cookie) => cookie.includes("convex_jwt") && !/Max-Age=0/i.test(cookie))).toEqual(
[],
)
expect(seen.headers.filter((entry) => "set-auth-token" in entry.headers)).toEqual([])
// The production build the browser loaded carries no server variable of Better Auth's.
const statics = join(APP_ROOT, "apps", "web", "dist", "client")
const files = (function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name)
return statSync(path).isDirectory() ? walk(path) : [path]
})
})(statics)
expect(files.filter((file) => file.endsWith(".js")).length).toBeGreaterThan(0)
const leaking = files.filter((file) => {
const text = readFileSync(file, "utf8")
return (
/BETTER_AUTH_CONVEX_ADMIN_KEY|BETTER_AUTH_SECRET|CONUSAI_API_TOKEN/.test(text) ||
stack().secrets.some((secret) => text.includes(secret))
)
})
expect(leaking).toEqual([])
})
})
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Gate J-auth in a browser (joint contract §14): headless Chromium against a production build of
// this app signing in with Better Auth on a disposable self-hosted Convex backend (stack.ts).
// pnpm run build && pnpm exec playwright test -c e2e/better-auth/playwright.config.ts
import { defineConfig } from "@playwright/test"
const port = Number(process.env.JAUTH_WEB_PORT ?? 3150)
export default defineConfig({
testDir: ".",
testMatch: "*.e2e.ts",
globalSetup: "./global-setup.ts",
workers: 1,
fullyParallel: false,
timeout: 120_000,
expect: { timeout: 20_000 },
reporter: [["list"]],
use: {
baseURL: `http://localhost:${port}`,
browserName: "chromium",
headless: true,
trace: "retain-on-failure",
},
})
+240
View File
@@ -0,0 +1,240 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The app as a production build with AUTH_PROVIDER=convex-better-auth, for gate J-auth (joint
// contract §14): a disposable Better Auth deployment on the platform's pinned self-hosted Convex
// image, a Postgres with the three roles, the fake platform of @conusai-kit/testing answering the
// real platform API (the app runs in platform mode, as in production: Support mints widget
// sessions and Guides bootstraps through it), the migrate job (which deploys the Better Auth
// functions), the worker and `node server.mjs`. Containers publish on 127.0.0.1 only (rootless Docker),
// and every container and volume is removed when the run ends.
//
// Run it with `pnpm run build` first, then
// pnpm exec playwright test -c e2e/better-auth/playwright.config.ts
import { type ChildProcess, execFileSync, spawn } from "node:child_process"
import { randomBytes } from "node:crypto"
import { mkdirSync, openSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { fakeNativeServices, startFakePlatform } from "@conusai-kit/testing/fake-platform"
export const APP_ROOT = new URL("../..", import.meta.url).pathname
const LOGS = join(APP_ROOT, "e2e", ".logs")
/** The platform's pinned self-hosted Convex backend (images/third-party.toml `convex-backend`). */
export const CONVEX_BACKEND_IMAGE =
process.env.CONVEX_BACKEND_IMAGE ??
"build.conusai.com/conusai/convex-backend@sha256:1738f1673f8d63161043a7859710d2301b1e9d6271e06afbb7af31594ea3a58f"
const WEB_PORT = Number(process.env.JAUTH_WEB_PORT ?? 3150)
/** What the specs read: the app, the fake platform and the Better Auth deployment's server side. */
export interface JauthStack {
readonly baseUrl: string
readonly platformUrl: string
readonly convexUrl: string
readonly convexSiteUrl: string
readonly adminKey: string
readonly backend: string
/** Server-side values a browser must never see: the network inspection looks for each. */
readonly secrets: readonly string[]
}
const sh = (command: string, args: string[], env?: NodeJS.ProcessEnv): string =>
execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}) }).trim()
async function until(label: string, check: () => Promise<boolean> | boolean, seconds = 120): Promise<void> {
const deadline = Date.now() + seconds * 1000
for (;;) {
try {
if (await check()) return
} catch {
// not yet
}
if (Date.now() > deadline) throw new Error(`j-auth: ${label} did not come up in ${seconds}s (logs in e2e/.logs)`)
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
function spawnLogged(name: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): ChildProcess {
mkdirSync(LOGS, { recursive: true })
const out = openSync(join(LOGS, `jauth-${name}.log`), "w")
return spawn(command, args, { cwd, env, stdio: ["ignore", out, out], detached: true })
}
function stopGroup(child: ChildProcess): void {
if (child.pid === undefined) return
try {
process.kill(-child.pid, "SIGTERM")
} catch {
// already gone
}
}
/** The host port Docker published for a container port, on 127.0.0.1. */
const portOf = (container: string, port: number): number =>
Number(
sh("docker", ["port", container, `${port}/tcp`])
.split("\n")[0]
?.split(":")
.at(-1),
)
const removeContainer = (name: string): void => {
try {
// -v: the anonymous data volume goes with the container.
sh("docker", ["rm", "-f", "-v", name])
} catch {
// already gone
}
}
export async function startJauthStack(): Promise<{ stack: JauthStack; stop(): Promise<void> }> {
const run = randomBytes(4).toString("hex")
const backend = `jauth-better-auth-${run}`
const postgres = `jauth-postgres-${run}`
const processes: ChildProcess[] = []
// Auth and persistence are real; Cloud service admission is an explicitly pinned simulation.
// Its identity comes from this server fixture, never from a readiness response.
const platformToken = `jauth-platform-token-${randomBytes(8).toString("hex")}`
const simulated = fakeNativeServices({ token: platformToken, projectId: 42, supportChannel: "spk_jauth" })
const platform = await startFakePlatform(0, { nativeServices: simulated.nativeServices })
const stop = async (): Promise<void> => {
for (const child of processes) stopGroup(child)
await platform.close()
removeContainer(backend)
removeContainer(postgres)
}
try {
sh("docker", [
"run",
"-d",
"--name",
backend,
"-l",
"conusai.test=j-auth",
"-e",
`INSTANCE_NAME=jauth_${run}`,
"-e",
`INSTANCE_SECRET=${randomBytes(32).toString("hex")}`,
// The backend reaches its own site URL (the issuer of Better Auth's Convex tokens) inside the container.
"-e",
"CONVEX_CLOUD_ORIGIN=http://127.0.0.1:3210",
"-e",
"CONVEX_SITE_ORIGIN=http://127.0.0.1:3211",
"-p",
"127.0.0.1::3210",
"-p",
"127.0.0.1::3211",
CONVEX_BACKEND_IMAGE,
])
sh("docker", [
"run",
"-d",
"--name",
postgres,
"-l",
"conusai.test=j-auth",
"-p",
"127.0.0.1::5432",
"-e",
"POSTGRES_PASSWORD=postgres",
"-e",
"POSTGRES_DB=appdev",
"-v",
`${join(APP_ROOT, "dev")}:/conusai-dev:ro`,
"--entrypoint",
"sh",
"postgres:18",
"/conusai-dev/postgres-entrypoint.sh",
"postgres",
])
const convexUrl = `http://127.0.0.1:${portOf(backend, 3210)}`
const convexSiteUrl = `http://127.0.0.1:${portOf(backend, 3211)}`
const pgPort = portOf(postgres, 5432)
const pg = (role: string) => `postgresql://appdev_${role}:appdev_${role}@127.0.0.1:${pgPort}/appdev`
await until("the Better Auth backend", async () => (await fetch(`${convexUrl}/version`)).ok)
const adminKey = sh("docker", ["exec", backend, "./generate_admin_key.sh"]).split("\n").at(-1) ?? ""
if (!adminKey) throw new Error("j-auth: the backend generated no admin key")
await until("postgres", () => {
sh("docker", ["exec", postgres, "psql", "-U", "appdev_app", "-h", "127.0.0.1", "-d", "appdev", "-c", "select 1"])
return true
})
const baseUrl = `http://localhost:${WEB_PORT}`
const betterAuthSecret = randomBytes(32).toString("base64url")
const sessionSecret = randomBytes(32).toString("base64url")
// A production run against the platform API (the fake platform answers it): no fake-platform
// switch, no dev sign-in, no insecure cookies, no OIDC variable -- what a deployment has.
const common: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
HOME: process.env.HOME,
TMPDIR: process.env.TMPDIR,
NODE_ENV: "production",
CONUSAI_API_URL: `${platform.url}/api`,
CONUSAI_API_TOKEN: platformToken,
...simulated.deployment,
CONUSAI_PUBLIC_URL: baseUrl,
CONUSAI_RELEASE: "j-auth",
CONUSAI_SUPPORT_CHANNEL: "spk_jauth", // kit:support
POSTGRES_URL: pg("app"),
POSTGRES_READER_URL: pg("reader"),
AUTH_SESSION_SECRET: sessionSecret,
AUTH_PROVIDER: "convex-better-auth",
AUTH_IDENTITY_NAMESPACE: "better-auth:jauth",
BETTER_AUTH_CONVEX_URL: convexUrl,
BETTER_AUTH_CONVEX_SITE_URL: convexSiteUrl,
BETTER_AUTH_CONVEX_ADMIN_KEY: adminKey,
BETTER_AUTH_RELAY_INTERVAL_MS: "250",
}
// The migrate job: the schema, then the Better Auth functions (it alone holds BETTER_AUTH_SECRET).
execFileSync(process.execPath, ["apps/worker/dist/migrate.js"], {
cwd: APP_ROOT,
env: { ...common, POSTGRES_MIGRATE_URL: pg("owner"), BETTER_AUTH_SECRET: betterAuthSecret },
stdio: "inherit",
})
// The deployed functions typecheck against the contexts Convex generated for them (deploy.mjs
// pushes without a typecheck: the runtime image carries no TypeScript).
execFileSync(
process.execPath,
[join(APP_ROOT, "node_modules/typescript/bin/tsc"), "-p", "better-auth/functions/tsconfig.json", "--noEmit"],
{ cwd: APP_ROOT, stdio: "inherit" },
)
processes.push(
spawnLogged("worker", process.execPath, ["apps/worker/dist/main.js"], APP_ROOT, {
...common,
WORKER_HEARTBEAT_FILE: join(LOGS, "jauth-worker-heartbeat"),
}),
)
processes.push(
spawnLogged("web", process.execPath, ["server.mjs", "--port", String(WEB_PORT)], join(APP_ROOT, "apps", "web"), {
...common,
HOSTNAME: "localhost",
}),
)
await until("the web shell", async () => (await fetch(`${baseUrl}/api/ready`)).ok, 120)
await until(
"the worker",
() => readFileSync(join(LOGS, "jauth-worker.log"), "utf8").includes("worker: serving"),
60,
)
return {
stack: {
baseUrl,
platformUrl: platform.url,
convexUrl,
convexSiteUrl,
adminKey,
backend,
secrets: [adminKey, betterAuthSecret, platformToken, sessionSecret],
},
stop,
}
} catch (error) {
await stop()
throw error
}
}
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The tenant logos in a browser (plan item AT2.6, spec §10.1, §10.3): one uploaded logo stands in for the
// other theme, an image larger than a stored logo may be is resized and not refused, the organization's and
// the app's share images are drawn, and a damaged PNG is refused with nothing stored.
import { expect, test } from "@playwright/test"
import { dimensions, markPng, noisePng } from "../test/support/images"
import { makeOrganization, signIn, unique } from "./support/app"
test("one uploaded logo is used on both themes, a larger image is resized, and the share images are drawn", async ({
page,
browser,
}) => {
await signIn(page, `${unique("admin")}@example.test`)
const { org } = await makeOrganization(page, "logos")
// only the dark theme's logo, and larger than a stored logo may be: it is resized, not refused
await page.goto(`/${org}/branding`)
const upload = noisePng(700, 300)
expect(upload.length).toBeGreaterThan(256 * 1024)
await page
.getByLabel("Logo for the dark theme")
.setInputFiles({ name: "logo.png", mimeType: "image/png", buffer: upload })
await page.getByRole("button", { name: "Save the brand" }).click()
await expect(page.getByAltText("Current logo")).toHaveCount(1)
await expect(page.getByLabel("Logo for the dark theme")).toHaveValue("")
// the page says what each theme shows
await expect(page.getByText("Light theme: the dark logo, because this theme has none of its own")).toBeVisible()
await expect(page.getByText("Dark theme: its own logo")).toBeVisible()
// the organization layout draws that logo for both themes, at 1x and 2x
const html = await (await page.request.get(`/${org}`)).text()
const source = `src="/${org}/brand/logo/dark\\?v=[0-9a-f]{16}&amp;s=1x"`
expect(html).toMatch(new RegExp(`class="app-logo app-logo-dark"[^>]*${source}`))
expect(html).toMatch(new RegExp(`class="app-logo app-logo-light"[^>]*${source}`))
// The page advertises the app's public share image: the organization's own (`/{org}/brand/og`)
// answers a member only, so a crawler could never fetch it, and an organization stays
// indistinguishable from a missing one to anyone outside it.
expect(html).toMatch(/<meta property="og:image" content="http:\/\/127.0.0.1:\d+\/api\/brand\/og"/)
expect(html).not.toContain(`/${org}/brand/og"`)
// the stored sizes are the standard ones, and nothing but a PNG is served
const one = await page.request.get(`/${org}/brand/logo/dark?s=1x`)
expect(one.headers()["content-type"]).toBe("image/png")
expect(dimensions(await one.body()).width).toBeLessThanOrEqual(420)
const two = await page.request.get(`/${org}/brand/logo/dark?s=2x`)
expect(dimensions(await two.body()).width).toBeLessThanOrEqual(840)
expect((await page.request.get(`/${org}/brand/logo/light`)).status()).toBe(404)
// the organization's share image: a member gets it, a stranger gets what a missing organization gets
const card = await page.request.get(`/${org}/brand/og`)
expect(card.status()).toBe(200)
expect(card.headers()["content-type"]).toBe("image/png")
expect(dimensions(await card.body())).toEqual({ width: 1200, height: 630 })
const stranger = await browser.newPage()
await signIn(stranger, `${unique("stranger")}@example.test`)
expect((await stranger.request.get(`/${org}/brand/og`)).status()).toBe(404)
await stranger.close()
// the app's own share image is public: a link preview is fetched without a session
const anonymous = await browser.newContext()
const appCard = await anonymous.request.get(`${new URL(page.url()).origin}/api/brand/og`)
expect(appCard.status()).toBe(200)
expect(dimensions(await appCard.body())).toEqual({ width: 1200, height: 630 })
await anonymous.close()
// removing the logo removes it from the page and from the store
await page.goto(`/${org}/branding`)
await page.getByRole("button", { name: "Remove the dark logo" }).click()
await expect(page.getByText("Dark theme: the app's logo, because no logo is set")).toBeVisible()
await page.getByRole("button", { name: "Save the brand" }).click()
await expect(page.getByText("The brand was saved.", { exact: true })).toBeVisible()
await expect(page.getByAltText("Current logo")).toHaveCount(0)
expect((await page.request.get(`/${org}/brand/logo/dark`)).status()).toBe(404)
})
test("a PNG that is damaged is refused and nothing is stored", async ({ page }) => {
await signIn(page, `${unique("admin")}@example.test`)
const { org } = await makeOrganization(page, "damaged")
const png = Buffer.from(markPng(300, 80))
png[30] = (png[30] ?? 0) ^ 0xff
await page.goto(`/${org}/branding`)
await page
.getByLabel("Logo for the light theme")
.setInputFiles({ name: "logo.png", mimeType: "image/png", buffer: png })
await page.getByRole("button", { name: "Save the brand" }).click()
await expect(page.getByRole("alert").filter({ hasText: "That did not work" })).toBeVisible()
await page.reload()
await expect(page.getByAltText("Current logo")).toHaveCount(0)
})
+153
View File
@@ -0,0 +1,153 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The tenant brand in a browser (plan item AT2.6): an administrator chooses colours and a logo and a
// member sees them, legibly in both themes, from the first byte; a member who is not an administrator
// cannot change them; and a logo that could run script is refused.
import { expect, type Locator, test } from "@playwright/test"
import { call, callRaw, makeOrganization, signIn, unique, violations } from "./support/app"
const channels = (color: string): [number, number, number] => {
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)/.exec(color)
if (!match) throw new Error(`not an rgb colour: ${color}`)
return [Number(match[1]), Number(match[2]), Number(match[3])]
}
const luminance = ([r, g, b]: [number, number, number]): number => {
const lin = (v: number) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4)
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
}
const contrast = (a: string, b: string): number => {
const [hi, lo] = [luminance(channels(a)), luminance(channels(b))].sort((x, y) => y - x) as [number, number]
return (hi + 0.05) / (lo + 0.05)
}
type ColourProperty = "backgroundColor" | "color" | "textDecorationColor" | "outlineColor"
/** A computed colour as sRGB, whatever space the stylesheet wrote it in (the theme roles are OKLCH). */
const rgbOf = (locator: Locator, property: ColourProperty): Promise<string> =>
locator.evaluate((el, name) => {
const canvas = document.createElement("canvas")
canvas.width = 1
canvas.height = 1
const context = canvas.getContext("2d") as CanvasRenderingContext2D
context.fillStyle = getComputedStyle(el)[name]
context.fillRect(0, 0, 1, 1)
const [r, g, b] = context.getImageData(0, 0, 1, 1).data
return `rgb(${r}, ${g}, ${b})`
}, property)
test("a tenant brand applies in both themes, from the first paint", async ({ page }) => {
await signIn(page, `${unique("admin")}@example.test`)
const { org } = await makeOrganization(page, "brand")
await page.goto(`/${org}/branding`)
await expect(page.getByRole("heading", { level: 2, name: "Brand" })).toBeVisible()
await page.getByLabel("Display name").fill("Acme Review")
await page.getByLabel("Primary colour").fill("#1a1a8c")
await page.getByLabel("Accent colour").fill("#f4c430")
// the preview answers before anything is saved, and says what the contrast is
await expect(page.getByText(/Text on the colour: \d+\.\d to 1/).first()).toBeVisible()
expect(await violations(page)).toEqual([])
// Confirm the write and loader refresh without losing the document or its open panels.
const documentStarted = await page.evaluate(() => performance.timeOrigin)
await page.getByRole("button", { name: "Save the brand" }).click()
await expect(page.getByText("The brand was saved.", { exact: true })).toBeVisible()
expect(await page.evaluate(() => performance.timeOrigin)).toBe(documentStarted)
await expect(page.getByLabel("Primary colour")).toHaveValue("#1a1a8c")
// server-rendered: the raw HTML of the organization page already carries the brand layer
const html = await (await page.request.get(`/${org}`)).text()
// The brand layer is the runtime theme roles (brand-kit BK6): the primary action, the focus ring and
// the sidebar's primary, never the kit's internal --brand-* tier.
expect(html).toMatch(/data-tenant-brand="#1a1a8c"[^>]*style="[^"]*--primary:light-dark\(#1a1a8c, #[0-9a-f]{6}\)/)
expect(html).toMatch(
/data-tenant-brand="#1a1a8c"[^>]*style="[^"]*--sidebar-primary:light-dark\(#1a1a8c, #[0-9a-f]{6}\)/,
)
expect(html).toMatch(/data-tenant-brand="#1a1a8c"[^>]*style="[^"]*--ring:light-dark\(/)
expect(html).not.toMatch(/data-tenant-brand="#1a1a8c"[^>]*style="[^"]*--brand-accent/)
expect(html).toContain("Acme Review")
// Agent/Help deliberately ship their localized attribution label in loader
// data. It must not brand the app's own screen, metadata or asset URLs.
const appHtml = html.replaceAll('poweredBy:"Powered by ConusAI"', "").replaceAll(/\/@fs\/[^"]+/g, "")
expect(appHtml.toLowerCase()).not.toContain("conusai")
expect((await page.locator("body").innerText()).toLowerCase()).not.toContain("conusai")
// the organization's colour moves the sidebar and the focus ring of the shell, not only the preview
await page.goto(`/${org}`)
const sidebar = await rgbOf(page.locator('[data-sidebar="sidebar"]').first(), "backgroundColor")
const navLink = page.getByRole("navigation", { name: "Main navigation" }).getByRole("link").first()
await page.keyboard.press("Tab")
await navLink.focus()
expect(await navLink.evaluate((el) => el.matches(":focus-visible"))).toBe(true)
const ring = await rgbOf(navLink, "outlineColor")
// #1a1a8c is 1.9:1 on the dark page, so the ring is the brand colour moved towards white: the dark
// half of the light-dark() pair the organization layout wrote, not the ConusAI teal.
const declared = await navLink.evaluate((el) => getComputedStyle(el).getPropertyValue("--sidebar-ring").trim())
const darkRing = /^light-dark\(#[0-9a-f]{6}, ?#([0-9a-f]{6})\)$/.exec(declared)?.[1]
expect(darkRing, `--sidebar-ring is ${declared}`).toBeDefined()
expect(channels(ring), "the focus ring follows the brand").toEqual(
[0, 2, 4].map((at) => Number.parseInt((darkRing as string).slice(at, at + 2), 16)),
)
expect(channels(ring)).not.toEqual([0x80, 0xcd, 0xc6])
expect(contrast(ring, sidebar), "the focus ring on the sidebar").toBeGreaterThanOrEqual(3)
expect(await violations(page)).toEqual([])
// the primary action's fill is the brand's colour on both themes, and what is written as text on it reads
await page.goto(`/${org}/branding`)
const panels = page.locator(".kit-brand-preview-panel")
await expect(panels).toHaveCount(2)
for (const [index, theme] of ["dark", "light"].entries()) {
const panel = panels.nth(index)
const action = panel.locator('[data-slot="brand-preview-action"]')
const fill = await rgbOf(action, "backgroundColor")
const onFill = await rgbOf(action, "color")
expect(channels(fill), theme).toEqual(theme === "dark" ? [0x5f, 0x5f, 0xaf] : [0x1a, 0x1a, 0x8c])
expect(contrast(fill, onFill), `${theme} text on the fill`).toBeGreaterThanOrEqual(4.5)
const link = panel.locator(".kit-brand-preview-link")
const ink = await rgbOf(link, "color")
const page_ = await rgbOf(panel, "backgroundColor")
expect(contrast(ink, page_), `${theme} link on the page`).toBeGreaterThanOrEqual(4.5)
}
// one element carries the theme class: the light preview, and nothing else on the page
expect(await page.evaluate(() => document.querySelectorAll(".light").length)).toBeLessThanOrEqual(1)
})
test("a member who is not an administrator reads the brand and cannot change it", async ({ page, browser }) => {
const adminEmail = `${unique("admin")}@example.test`
await signIn(page, adminEmail)
const { org } = await makeOrganization(page, "perm")
const memberEmail = `${unique("member")}@example.test`
const invite = await call<{ token: string }>(page, "tenancy.member.invite", {
org,
email: memberEmail,
role: "member",
})
const member = await browser.newPage()
await signIn(member, memberEmail)
await call(member, "tenancy.invitation.accept", { org, token: invite.token })
await member.goto(`/${org}/branding`)
await expect(member.getByText("Only administrators can change the brand.")).toBeVisible()
await expect(member.getByRole("button", { name: "Save the brand" })).toHaveCount(0)
// and the procedure answers them as it answers a stranger
const csrf = (await member.locator('meta[name="csrf-token"]').getAttribute("content")) ?? ""
const refused = await callRaw(member.request, csrf, "branding.brand.set", { org, primary: "#000000" })
expect(refused.status()).toBe(404)
await member.close()
})
test("a logo that carries a script is refused, and nothing is stored", async ({ page }) => {
await signIn(page, `${unique("admin")}@example.test`)
const { org } = await makeOrganization(page, "logo")
await page.goto(`/${org}/branding`)
await page.getByLabel("Logo for the light theme").setInputFiles({
name: "logo.svg",
mimeType: "image/svg+xml",
buffer: Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'),
})
await page.getByRole("button", { name: "Save the brand" }).click()
await expect(page.getByRole("alert").filter({ hasText: "That did not work" })).toBeVisible()
await page.reload()
await expect(page.getByAltText("Current logo")).toHaveCount(0)
})
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// One business procedure end to end: the CRM module's page over the platform's CRM service, through the
// business gateway's binding and admission, on the fake platform of @conusai-kit/testing (no live service).
// An organization nobody has bound sees the typed refusal in its own words; once an administrator binds it with
// the scopes the app declares, a member adds a party through the page's form and finds it in the list.
import { buildRegistry } from "@conusai-kit/core"
import { expect, test } from "@playwright/test"
import { declaredBusinessScopes } from "../apps/worker/src/fake-business-bindings"
import { appModules } from "../modules/index"
import { bindBusinessServices, makeOrganization, ready, signIn, unique, violations } from "./support/app"
test("a party is added through the CRM page once the organization is bound, and refused in words before", async ({
page,
}) => {
// The e2e app selects CRM (scripts/vendor/test-template.sh); its declared scopes are what the binding grants.
const grants = declaredBusinessScopes(buildRegistry([...appModules]))
expect(Object.keys(grants)).toContain("crm")
await signIn(page, `${unique("buyer")}@example.test`)
const { orgId, org, workspace, project } = await makeOrganization(page, "buyer")
const crm = `/${org}/${workspace}/${project}/crm`
// Not bound: the page says so in place of the list, as a refusal, never a crash.
await page.goto(crm)
await ready(page)
await expect(page.getByText("This app tenant and environment have no verified service binding.")).toBeVisible()
expect(await violations(page)).toEqual([])
// The administrator's act, then the member's: a party added through the form appears in the list.
await bindBusinessServices(orgId, grants)
await page.goto(crm)
await ready(page)
await expect(page.getByRole("heading", { level: 1, name: "Customers" })).toBeVisible()
const name = unique("Supplier")
await page.getByLabel("Name", { exact: true }).fill(name)
await page.getByLabel(/^Country/).fill("LT")
await page.getByRole("button", { name: "Add party" }).click()
// The service minted the party: its own page opens, and the list has it.
await expect(page.getByRole("heading", { level: 1, name })).toBeVisible()
expect(await violations(page)).toEqual([])
await page.getByRole("link", { name: "All parties" }).click()
await expect(page.getByRole("row").filter({ hasText: name })).toBeVisible()
expect(await violations(page)).toEqual([])
})
+165
View File
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The document-review flow in a browser (plan item AT2.3): upload a PDF, the worker reads it through the
// fake platform's OCR job API, the four labelled lines of the fixture become proposals (its drawing page
// is reported as not read), a person approves, edits and rejects, and the Excel export holds the approved
// lines and only those.
import { createHash, randomBytes } from "node:crypto"
import { readFileSync } from "node:fs"
import { expect, test } from "@playwright/test"
import { sheetRows } from "../modules/documents/__tests__/support/unzip"
import { moduleSelected } from "../modules/inclusion"
import { call, makeOrganization, signIn, unique, violations } from "./support/app"
test.skip(!moduleSelected("documents"), "Documents must be included in this isolated test configuration")
const PDF = Buffer.from("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n")
test("a fixture PDF becomes lines a person reviews, and only approved lines are exported", async ({ page }) => {
await signIn(page, `${unique("reviewer")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const base = `/${org}/${workspace}/${project}/documents`
await page.goto(base)
await expect(page.getByRole("heading", { level: 1, name: "Documents" })).toBeVisible()
expect(await violations(page)).toEqual([])
await page.getByRole("button", { name: "Add a document", exact: true }).click()
// upload through the file input of the form: the request answers before any OCR has run
await page
.getByLabel("Upload a document")
.setInputFiles({ name: "report.pdf", mimeType: "application/pdf", buffer: PDF })
await page.getByRole("button", { name: "Upload", exact: true }).click()
// an upload is three requests (begin, the piece, complete); once the last has answered the live list
// reads again, with the file in it: wait for that, or the navigation below would cut the upload off
await expect(page.getByRole("row", { name: /report\.pdf/ })).toBeVisible({ timeout: 30_000 })
// the worker picks the file up, submits it to the OCR job API and proposes the lines; the list follows
// through the live stream, with no reload and no navigation (a reload would lose this marker)
await page.evaluate(() => {
;(window as unknown as { conusaiSamePage?: boolean }).conusaiSamePage = true
})
await expect(page.getByRole("row").filter({ hasText: /report\.pdf.*Ready for review/ })).toBeVisible({
timeout: 60_000,
})
expect(await page.evaluate(() => (window as unknown as { conusaiSamePage?: boolean }).conusaiSamePage)).toBe(true)
await page.getByRole("link", { name: "Review report.pdf" }).click()
await expect(page.getByRole("heading", { level: 1 })).toContainText("report.pdf")
// what was proposed by a machine says so, in text
await expect(page.getByText("AI-generated content")).toBeVisible()
await expect(page.getByRole("row")).toHaveCount(5) // header and four lines
// the fixture's third page is a drawing: the extraction left it out, and the page says so
await expect(page.getByRole("heading", { name: "Pages not read" })).toBeVisible()
await expect(page.getByText("Page 3: drawing. It was left out of the extraction.")).toBeVisible()
await expect(page.getByRole("button", { name: "Export approved lines (Excel)" })).toBeDisabled()
expect(await violations(page)).toEqual([])
// each line shows where it was read
await expect(page.getByRole("complementary", { name: "Source, page 2" }).first()).toContainText(
"Total amount: 1 250,00 EUR",
)
// approve one, correct another, reject a third
await page.getByRole("button", { name: "Approve Reference" }).click()
await expect(page.getByRole("row", { name: /Reference.*Approved/ })).toBeVisible()
await page.getByRole("button", { name: "Edit Issued on" }).click()
await page.getByLabel("Label").fill("Issue date")
await page.getByLabel("Value").fill("2026-04-02")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByRole("row", { name: /Issue date.*Edited/ })).toBeVisible()
await page.getByRole("button", { name: "Approve Issue date" }).click()
await expect(page.getByRole("row", { name: /Issue date.*Approved/ })).toBeVisible()
await page.getByRole("button", { name: "Reject Signed by" }).click()
await expect(page.getByRole("row", { name: /Signed by.*Rejected/ })).toBeVisible()
// the header's counts follow the decisions on the same page
await expect(page.getByText("1 line to review. 2 lines approved.")).toBeVisible()
// the line nobody decided is still waiting, and is not exported
await expect(page.getByRole("row", { name: /Total amount.*To review/ })).toBeVisible()
const download = page.waitForEvent("download")
await page.getByRole("button", { name: "Export approved lines (Excel)" }).click()
const file = await download
expect(file.suggestedFilename()).toBe("report-approved.xlsx")
const path = await file.path()
const rows = sheetRows(new Uint8Array(readFileSync(path)))
expect(rows).toEqual([
["Label", "Value", "Page", "Source quote", "Edited by a reviewer"],
["Reference", "QR-2026-04", 1, "Reference: QR-2026-04", "no"],
["Issue date", "2026-04-02", 1, "Issued on: 2026-04-01", "yes"],
])
expect(await violations(page)).toEqual([])
})
test("a 30 MB PDF is uploaded in the browser with a progress bar, stored whole, and digested by the platform", async ({
page,
}, testInfo) => {
await signIn(page, `${unique("uploader")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const base = `/${org}/${workspace}/${project}/documents`
await page.goto(base)
await page.getByRole("button", { name: "Add a document", exact: true }).click()
// 30 MB that start like a PDF and are not all one byte, so a piece that arrives short or twice
// cannot hash to the same digest. The server entry passes such a body through unread (src/server.ts), so a
// step that buffered or cut it would show here.
const size = 30 * 1024 * 1024
const buffer = Buffer.concat([Buffer.from("%PDF-1.4\n"), randomBytes(size - 9)])
expect(buffer.length).toBe(size)
// Slow the upload to 8 MB/s, so that the bar is on screen for seconds and not for a blink: what
// the person sees is the requirement, and a loopback upload would be over before a poll looked.
const cdp = await page.context().newCDPSession(page)
await cdp.send("Network.enable")
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: 8 * 1024 * 1024,
})
await page.getByLabel("Upload a document").setInputFiles({ name: "tender.pdf", mimeType: "application/pdf", buffer })
await page.getByRole("button", { name: "Upload", exact: true }).click()
// the bar names the file and how far it has got, and the live list shows the file when the upload is done
await expect(page.getByRole("progressbar", { name: /Uploading tender\.pdf: \d+%/ })).toBeVisible()
await page.locator('[data-slot="side-panel"]').getByRole("button", { name: /Close/ }).click()
await expect(page.getByRole("progressbar", { name: /Uploading tender\.pdf: \d+%/ })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("document-upload-pending.png") })
await expect(page.getByRole("row", { name: /tender\.pdf/ })).toBeVisible({ timeout: 60_000 })
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: 0,
downloadThroughput: -1,
uploadThroughput: -1,
})
const listed = await call<{ files: Array<{ filename: string; size: number; sha256: string }> }>(
page,
"files.file.list",
{
org,
workspace,
project,
},
)
const stored = listed.files.find((file) => file.filename === "tender.pdf")
expect(stored?.size).toBe(size)
expect(stored?.sha256).toBe(createHash("sha256").update(buffer).digest("hex"))
})
test("another organization's member does not see the document", async ({ page, browser }) => {
await signIn(page, `${unique("owner")}@example.test`)
const mine = await makeOrganization(page)
const stranger = await browser.newPage()
await signIn(stranger, `${unique("stranger")}@example.test`)
const response = await stranger.goto(`/${mine.org}/${mine.workspace}/${mine.project}/documents`)
// the organization layout answers a non-member as it answers a stranger: the not-found page, as a
// 404, with no document list and no names
expect(response?.status()).toBe(404)
await expect(stranger.getByRole("heading", { level: 1, name: "Page not found" })).toBeVisible()
expect(await stranger.content()).not.toContain(`${mine.org} Review`)
await stranger.close()
})
+576
View File
@@ -0,0 +1,576 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The app frame in a browser: it stays mounted while a person moves, so an open right panel stays open; Settings
// is one page of sections by permission, and it renames the organization and sets its language; the theme can
// follow the device; the layout comes back from its cookies; and a phone gets the sidebar in a sheet.
import { expect, type Page, test } from "@playwright/test"
import { moduleSelected } from "../modules/inclusion"
import { call, makeOrganization, openPersonMenu, openScope, signIn, unique, violations } from "./support/app"
/** Marks the frame's element: a remount, or a full load, loses the mark. */
const markFrame = (page: Page): Promise<void> =>
page.locator('[data-slot="app-frame"]').evaluate((frame) => {
;(frame as HTMLElement & { kept?: boolean }).kept = true
})
const frameKept = (page: Page): Promise<boolean> =>
page.locator('[data-slot="app-frame"]').evaluate((frame) => (frame as HTMLElement & { kept?: boolean }).kept === true)
test("the frame stays mounted across pages, and the Agent stays open with what the person was writing", async ({
page,
}) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
await page.goto(`/${org}/${workspace}/${project}/knowledge`)
await markFrame(page)
await page.getByRole("button", { name: "Agent" }).click()
const panel = page.locator('[data-slot="side-panel"]')
await expect(panel.getByRole("heading").first()).toBeVisible()
const composer = panel.getByRole("textbox")
const workingIn = panel.locator(".kit-assistant-context")
await expect(workingIn).toHaveText("Working in Review")
await composer.fill("What is left to do?")
expect(await violations(page)).toEqual([])
// the page fills the centre beside the panel
const centre = page.getByRole("main")
expect((await centre.boundingBox())?.width).toBeGreaterThan(400)
// A project section is a link in the sidebar.
await page.getByRole("navigation", { name: "Main navigation" }).getByRole("link", { name: "Documents" }).click()
await expect(page).toHaveURL(new RegExp(`/${project}/documents$`))
await expect(composer).toHaveValue("What is left to do?")
// the sidebar
await page
.getByRole("navigation", { name: "Main navigation" })
.getByRole("link", { name: "Workspace overview" })
.click()
await expect(page).toHaveURL(new RegExp(`/${org}/${workspace}$`))
await expect(composer).toHaveValue("What is left to do?")
// the open panel follows to the workspace's own conversation, with no project open
await expect(workingIn).toHaveText("Working in Main")
// a plain link of a page
await page.getByRole("link", { name: "Review" }).first().click()
await expect(page).toHaveURL(new RegExp(`/${workspace}/${project}$`))
await expect(composer).toHaveValue("What is left to do?")
await expect(workingIn).toHaveText("Working in Review")
expect(await frameKept(page)).toBe(true)
})
test("the Agent is on the projects list, working in the workspace", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
await page.goto(`/${org}/${workspace}`)
await page.getByRole("button", { name: "Agent" }).click()
const panel = page.locator('[data-slot="side-panel"]')
await expect(panel.locator(".kit-assistant-context")).toHaveText("Working in Main")
await expect(panel.getByRole("textbox")).toBeEditable()
expect(await violations(page)).toEqual([])
})
test("organization settings, the workspace and a project remain connected by navigation", async ({
page,
}, testInfo) => {
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text())
})
page.on("response", (response) => {
if (response.status() >= 400) errors.push(`${response.status()} ${new URL(response.url()).pathname}`)
})
await signIn(page, "ada@example.test")
await page.goto("/acme-review/general")
await page.getByRole("link", { name: "Organization settings", exact: true }).click()
const sections = page.getByRole("navigation", { name: "Settings sections" })
await expect(sections.getByRole("link").first()).toBeVisible()
const destinations = await sections
.getByRole("link")
.evaluateAll((links) =>
links.map((link) => ({ href: link.getAttribute("href") ?? "", label: link.textContent?.trim() ?? "" })),
)
expect(destinations.length).toBeGreaterThan(1)
for (const { href, label } of destinations) {
await sections.getByRole("link", { name: label, exact: true }).click()
await expect(page).toHaveURL(new RegExp(`${href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible()
await expect(sections.getByRole("link", { name: label, exact: true })).toHaveAttribute("aria-current", "page")
}
await page
.getByRole("navigation", { name: "Main navigation" })
.getByRole("link", { name: "Workspace overview" })
.click()
await expect(page).toHaveURL(/\/acme-review\/general$/)
await page.getByRole("link", { name: "Team notes", exact: true }).click()
await expect(page).toHaveURL(/\/acme-review\/general\/notes$/)
await expect(page.getByRole("main")).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("project-navigation.png") })
expect(errors).toEqual([])
})
test("breadcrumbs distinguish repeated slugs at different scope levels", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const slug = unique("scope")
await call(page, "tenancy.org.create", { slug, name: "Organization" })
await call(page, "tenancy.workspace.create", { org: slug, slug, name: "Workspace", visibility: "org" })
await call(page, "tenancy.project.create", {
org: slug,
workspace: slug,
slug,
name: "Project",
moduleKey: "notes",
})
await page.goto(`/${slug}/${slug}/${slug}/notes`)
const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" })
await expect(breadcrumb.getByRole("link", { name: "Workspace" })).toHaveAttribute("href", `/${slug}/${slug}`)
await expect(breadcrumb.getByRole("link", { name: "Project", exact: true })).toHaveAttribute(
"href",
`/${slug}/${slug}/${slug}`,
)
await breadcrumb.getByRole("link", { name: "Workspace" }).click()
await expect(page).toHaveURL(new RegExp(`/${slug}/${slug}$`))
await openScope(page)
await page.getByRole("menuitemradio", { name: "General", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${slug}/general$`))
await openScope(page)
await page.getByRole("menuitemradio", { name: "Workspace", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${slug}/${slug}$`))
})
test("a newly created workspace can be selected and switched back", async ({ page }, testInfo) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
await page.goto(`/${org}/${workspace}`)
await page.goto(`/${org}/workspaces`)
await page.getByRole("button", { name: "Create a workspace", exact: true }).click()
await page.screenshot({ path: testInfo.outputPath("workspace-create-desktop.png") })
await page.setViewportSize({ width: 375, height: 812 })
await expect(page.getByRole("dialog")).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("workspace-create-mobile.png") })
await page.setViewportSize({ width: 1280, height: 800 })
await page.getByLabel("Name", { exact: true }).fill("Research")
await page.getByLabel("Short name").fill("research")
await page.getByRole("button", { name: "Create", exact: true }).click()
await expect(page.getByRole("link", { name: "Research", exact: true })).toBeVisible()
await openScope(page)
await page.getByRole("menuitemradio", { name: "Research", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${org}/research$`))
await expect(page.locator('[data-guide="frame.scope"]')).toContainText("Research")
await openScope(page)
await page.getByRole("menuitemradio", { name: "Main", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${org}/${workspace}$`))
await expect(page.locator('[data-guide="frame.scope"]')).toContainText("Main")
})
test("organization, workspace and settings breadcrumbs follow the route hierarchy", async ({ page }) => {
await signIn(page, "ada@example.test")
await page.goto("/acme-review")
await expect(page.getByRole("heading", { name: "Workspaces", level: 1 })).toBeVisible()
const list = page.getByRole("grid", { name: "Workspaces" })
await expect(list.getByRole("link", { name: "General", exact: true })).toBeVisible()
await list.getByRole("link", { name: "General", exact: true }).click()
await expect(page).toHaveURL(/\/acme-review\/general$/)
const breadcrumb = page.getByRole("navigation", { name: "Breadcrumb" })
await expect(breadcrumb.getByRole("link", { name: "Acme Review", exact: true })).toHaveCount(0)
await expect(breadcrumb.locator('[aria-current="page"]')).toHaveText("General")
await page.getByRole("link", { name: "Organization settings", exact: true }).click()
await expect(breadcrumb.getByRole("link", { name: "Acme Review", exact: true })).toBeVisible()
await expect(breadcrumb.getByRole("link", { name: "General", exact: true })).toHaveCount(0)
})
test("account settings follow the last accessible workspace", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
await page.goto(`/${org}/${workspace}`)
await expect
.poll(async () => (await page.context().cookies()).find((cookie) => cookie.name === "app_workspace")?.value)
.toBe(encodeURIComponent(`${org}/${workspace}`))
await page.goto("/settings/account")
await expect(page).toHaveURL(new RegExp(`/${org}/settings/account$`))
await expect(page.getByRole("heading", { name: "Your account", exact: true })).toBeVisible()
await expect(page.locator('[data-slot="app-frame"]')).toBeVisible()
})
test("member roles use the same select control as invitations", async ({ browser, page }, testInfo) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org } = await makeOrganization(page)
const email = `${unique("member")}@example.test`
const invite = await call<{ token: string }>(page, "tenancy.member.invite", { org, email, role: "viewer" })
const context = await browser.newContext({ baseURL: new URL(page.url()).origin })
const member = await context.newPage()
await signIn(member, email)
await call(member, "tenancy.invitation.accept", { org, token: invite.token })
await context.close()
await page.goto(`/${org}/members`)
const row = page.getByRole("row").filter({ hasText: email })
const role = row.getByRole("button", { name: /New role for/ })
const inviteRole = page.getByRole("button", { name: /Role$/ }).last()
await expect(role).toBeVisible()
const form = page.locator(".kit-tenancy-invite form")
const emailField = form.getByLabel("E-mail address")
const formRole = form.getByRole("button", { name: /Role/ })
const emailBox = await emailField.boundingBox()
const formRoleBox = await formRole.boundingBox()
expect(Math.abs((emailBox?.y ?? 0) - (formRoleBox?.y ?? 0))).toBeLessThan(2)
const save = row.getByRole("button", { name: /Save/ })
expect((await role.boundingBox())?.height).toBe((await save.boundingBox())?.height)
expect((await role.boundingBox())?.height).toBeGreaterThanOrEqual(24)
const nameCell = await row.getByRole("rowheader").boundingBox()
const roleControl = await role.boundingBox()
expect(
Math.abs((nameCell?.y ?? 0) + (nameCell?.height ?? 0) / 2 - (roleControl?.y ?? 0) - (roleControl?.height ?? 0) / 2),
).toBeLessThan(2)
expect((await role.boundingBox())?.height).toBeLessThanOrEqual((await inviteRole.boundingBox())?.height ?? 0)
await page.screenshot({ path: testInfo.outputPath("members-selects.png"), fullPage: true })
await role.focus()
await page.keyboard.press("Enter")
await page.getByRole("option", { name: "Editor", exact: true }).click()
await row.getByRole("button", { name: /Save/ }).click()
await expect(row.getByRole("cell", { name: "Editor", exact: true })).toBeVisible()
await page.setViewportSize({ width: 375, height: 812 })
await page.reload()
const mobileForm = page.locator(".kit-tenancy-invite form")
const mobilePositions = await mobileForm.evaluate((form) => {
const email = form.querySelector<HTMLInputElement>('input[name="email"]')?.getBoundingClientRect()
const role = form.querySelector<HTMLButtonElement>('[data-slot="select-trigger"]')?.getBoundingClientRect()
return { emailBottom: email?.bottom ?? 0, roleTop: role?.top ?? 0 }
})
expect(mobilePositions.roleTop).toBeGreaterThan(mobilePositions.emailBottom)
const mobileRole = page
.getByRole("row")
.filter({ hasText: email })
.getByRole("button", { name: /New role for/ })
await mobileRole.scrollIntoViewIfNeeded()
const membersRegion = page.getByRole("region", { name: "Members of the organization" })
expect(await membersRegion.evaluate((region) => region.scrollWidth <= region.clientWidth)).toBe(true)
await mobileRole.click()
await expect(page.getByRole("option", { name: "Viewer", exact: true })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("members-selects-mobile.png"), fullPage: true })
await page.keyboard.press("Escape")
await expect(mobileRole).toBeFocused()
expect(await violations(page)).toEqual([])
})
test("a refused owner role change stays clear and contained in its table row", async ({ page }, testInfo) => {
const email = `${unique("owner")}@example.test`
await signIn(page, email)
const { org } = await makeOrganization(page)
await page.goto(`/${org}/members`)
const row = page.getByRole("row").filter({ hasText: email })
const role = row.getByRole("button", { name: /New role for/ })
await role.click()
await page.getByRole("option", { name: "Editor", exact: true }).click()
await row.getByRole("button", { name: /Save/ }).click()
const alert = row.getByRole("alert")
await expect(alert).toContainText("last owner")
await expect(role).toHaveAttribute("aria-describedby", (await alert.getAttribute("id")) ?? "")
expect(await violations(page)).toEqual([])
await page.screenshot({ path: testInfo.outputPath("members-role-error.png"), fullPage: true })
})
test("invitation actions remain aligned in a table cell", async ({ page }, testInfo) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org } = await makeOrganization(page)
const email = `${unique("invite")}@example.test`
await call(page, "tenancy.member.invite", { org, email, role: "viewer" })
await page.goto(`/${org}/invitations`)
const row = page.getByRole("row").filter({ hasText: email })
const resend = row.getByRole("button", { name: /New link/ })
const revoke = row.getByRole("button", { name: /Revoke/ })
await expect(resend).toBeVisible()
await expect(revoke).toBeVisible()
expect(await resend.locator("xpath=ancestor::td").evaluate((cell) => getComputedStyle(cell).display)).toBe(
"table-cell",
)
expect((await resend.boundingBox())?.height).toBe((await revoke.boundingBox())?.height)
expect(await violations(page)).toEqual([])
await page.screenshot({ path: testInfo.outputPath("invitation-actions.png"), fullPage: true })
})
test("custom role forms use kit selects for creation and reassignment", async ({ page }, testInfo) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org } = await makeOrganization(page)
await page.goto(`/${org}/roles`)
const errors: string[] = []
page.on("pageerror", (error) => errors.push(error.message))
await expect(page.getByRole("textbox", { name: "Name", exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Create a role", exact: true }).click()
const name = page.getByRole("textbox", { name: "Name", exact: true })
const create = page.getByRole("button", { name: "Create the role" })
await create.click()
await expect(name).toBeFocused()
await expect(name).toHaveAttribute("aria-invalid", "true")
await name.fill("Review readers")
await page.getByRole("button", { name: /Copy the permissions of/ }).click()
await page.getByRole("option", { name: "Administrator", exact: true }).click()
await create.click()
const dialog = page.getByRole("alertdialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused()
expect(await violations(page)).toEqual([])
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(create).toBeFocused()
await page.keyboard.press("Escape")
await expect(page.getByRole("alertdialog")).toBeVisible()
await page.getByRole("alertdialog").getByRole("button", { name: /Keep/ }).click()
await expect(name).toHaveValue("Review readers")
await page.setViewportSize({ width: 375, height: 812 })
await name.scrollIntoViewIfNeeded()
await page.screenshot({ path: testInfo.outputPath("role-form-mobile.png"), fullPage: true })
expect(await name.evaluate((field) => field.getBoundingClientRect().right <= innerWidth)).toBe(true)
await page.setViewportSize({ width: 1280, height: 800 })
await page.screenshot({ path: testInfo.outputPath("role-form-desktop.png"), fullPage: true })
expect(errors).toEqual([])
await page.getByRole("button", { name: /Where it can be assigned/ }).click()
await page.getByRole("option", { name: "Workspace", exact: true }).click()
await page.getByRole("button", { name: /Copy the permissions of/ }).click()
await page.getByRole("option", { name: "Viewer", exact: true }).click()
await page.getByRole("button", { name: "Create the role" }).click()
await expect(page.getByRole("heading", { name: "Review readers" })).toBeVisible()
const boxes = page.locator(".kit-tenancy-matrix input[type=checkbox]")
const dimensions = await boxes.evaluateAll((controls) =>
controls.map((control) => {
const box = control.getBoundingClientRect()
return { width: box.width, height: box.height, padding: getComputedStyle(control).padding }
}),
)
expect(dimensions.length).toBeGreaterThan(0)
for (const box of dimensions) {
expect(box.width).toBe(box.height)
expect(box.padding).toBe("0px")
}
await expect(page.locator(".kit-tenancy-matrix input[type=checkbox]:disabled").first()).toBeDisabled()
const permission = page.locator('.kit-tenancy-matrix li[data-permission="notes.note.read"] input')
await permission.focus()
await expect(permission).toBeFocused()
const checked = await permission.isChecked()
await page.keyboard.press("Space")
await expect(permission).toBeChecked({ checked: !checked })
await page.keyboard.press("Space")
await expect(permission).toBeChecked({ checked })
await page.screenshot({ path: testInfo.outputPath("role-permission-checkboxes.png"), fullPage: true })
await page.getByRole("button", { name: /Move its members to/ }).click()
await page.getByRole("option", { name: "Editor", exact: true }).click()
await page.getByRole("button", { name: "Delete the role" }).click()
await expect(page.getByRole("alertdialog")).toContainText("Editor")
await page.getByRole("button", { name: "Delete it" }).click()
await expect(page).toHaveURL(new RegExp(`/${org}/roles$`))
})
test("a confirmed sensitive role opens its full-page editor", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org } = await makeOrganization(page)
await page.goto(`/${org}/roles`)
await page.getByRole("button", { name: "Create a role", exact: true }).click()
await page.getByRole("textbox", { name: "Name", exact: true }).fill("Operations")
await page.getByRole("button", { name: /Copy the permissions of/ }).click()
await page.getByRole("option", { name: "Administrator", exact: true }).click()
await page.getByRole("button", { name: "Create the role", exact: true }).click()
await page.getByRole("alertdialog").getByRole("button", { name: "Create it", exact: true }).click()
await expect(page.getByRole("heading", { name: "Operations", exact: true })).toBeVisible()
await expect(page.locator('[data-slot="form-panel"]')).toHaveCount(0)
})
test("an excluded Documents route shows the kit not-found state", async ({ page }) => {
test.skip(moduleSelected("documents"), "This checks the default-off configuration")
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const response = await page.goto(`/${org}/${workspace}/${project}/documents`)
expect(response?.status()).toBe(404)
await expect(page.getByRole("heading", { name: "Page not found", exact: true })).toBeVisible()
await expect(page.locator(".app-shell-header")).toBeHidden()
})
test("a record in the panel of the real frame: its address, the Help button, and Escape back to the row", async ({
page,
}) => {
await signIn(page, `${unique("owner")}@example.test`)
const scope = await makeOrganization(page)
const { noteId } = await call<{ noteId: string }>(page, "notes.note.create", { ...scope, title: "Plan", body: "b" })
const list = `/${scope.org}/${scope.workspace}/${scope.project}/notes`
await page.goto(list)
await markFrame(page)
await page.getByRole("rowheader", { name: "Plan" }).click()
await expect(page).toHaveURL(new RegExp(`\\?record=${noteId}$`))
const panel = page.locator('[data-slot="side-panel"]')
await expect(panel.getByLabel("Title")).toHaveValue("Plan")
// Help is a second occupant, and the record keeps its place
await page.getByRole("button", { name: "Help" }).click()
await expect(panel.getByRole("tab", { name: "Support" })).toBeVisible()
await expect(panel.getByRole("button", { name: /launch|chat/i })).toHaveCount(0)
await panel.getByRole("tab", { name: "Plan" }).click()
await expect(panel.getByLabel("Title")).toHaveValue("Plan")
// no launcher floats over the page
await expect(page.locator(".support-launcher, [data-support-launcher]")).toHaveCount(0)
expect(await frameKept(page)).toBe(true)
})
test("Settings has one row of sections, filtered by permission", async ({ browser, page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org } = await makeOrganization(page, "settings")
await page.goto(`/${org}`)
await page.getByRole("link", { name: "Organization settings", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${org}/settings$`))
const sections = page.getByRole("navigation", { name: "Settings sections" })
await expect(sections.getByRole("link", { name: "General", exact: true })).toBeVisible()
await expect(sections.getByRole("link", { name: "Workspaces", exact: true })).toBeVisible()
await expect(sections.getByRole("link", { name: "Account", exact: true })).toBeVisible()
await expect(sections.getByRole("list")).toHaveCount(0)
expect(await violations(page)).toEqual([])
// the organization's name and language, saved through tenancy.org.update, and the frame follows
await sections.getByRole("link", { name: "General" }).click()
await expect(page.getByLabel("Name", { exact: true })).toBeVisible()
await page.getByLabel("Name", { exact: true }).fill("Renamed")
await page.getByRole("button", { name: /Default language/ }).click()
await page.getByRole("option", { name: "Lietuvių" }).click()
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Saved", { exact: true })).toBeVisible()
await expect(page.locator('[data-guide="frame.scope"]')).toContainText("Renamed")
await page.reload()
await expect(page.getByLabel("Name", { exact: true })).toHaveValue("Renamed")
expect(await violations(page)).toEqual([])
// a new member starts in the organization's language
const invited = `${unique("invited")}@example.test`
const { token } = await call<{ token: string }>(page, "tenancy.member.invite", {
org,
email: invited,
role: "member",
})
const member = await (await browser.newContext({ baseURL: new URL(page.url()).origin })).newPage()
await signIn(member, invited)
await member.goto(`/invitations/${org}/${token}`)
await member
.getByRole("button", { name: "Priimti kvietimą" })
.or(member.getByRole("button", { name: "Accept the invitation" }))
.click()
await member.waitForURL(new RegExp(`/${org}/[a-z0-9-]+$`))
await member.goto(`/${org}`)
await expect(member.locator("html")).toHaveAttribute("lang", "lt")
// a member sees what they may: the personal section, and no General
await member.goto(`/${org}/settings/account`)
const theirs = member.getByRole("navigation", { name: "Nustatymų skyriai" })
await expect(theirs.getByRole("link", { name: "Bendrieji" })).toHaveCount(0)
await member.context().close()
})
test("the theme can follow the device, from the first paint", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org } = await makeOrganization(page)
await page.emulateMedia({ colorScheme: "light" })
await page.goto(`/${org}`)
const root = page.locator("html")
await expect(root).not.toHaveClass(/light/)
await openPersonMenu(page)
await page.getByRole("menuitemradio", { name: "System" }).click()
await expect(root).toHaveClass(/light/)
await page.reload()
await expect(root).toHaveClass(/light/)
await page.emulateMedia({ colorScheme: "dark" })
await expect(root).not.toHaveClass(/light/)
await page.reload()
await expect(root).not.toHaveClass(/light/)
expect(await violations(page)).toEqual([])
})
test("the person's menu and the page's foot hold what the header held", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org } = await makeOrganization(page)
await page.goto(`/${org}`)
await expect(page.locator(".app-shell-header")).toBeHidden()
// The privacy choices are two steps away, in the person's menu, and not a button at the foot of every page.
await expect(page.getByRole("main").getByRole("button", { name: "Privacy choices" })).toHaveCount(0)
await openPersonMenu(page)
await page.getByRole("menuitem", { name: "Privacy choices" }).click()
await expect(page.getByRole("region", { name: "Privacy choices" })).toBeVisible()
await openPersonMenu(page)
await expect(page.getByRole("menuitem", { name: "Sign out" })).toBeVisible()
await page.getByRole("menuitem", { name: "Sign out" }).click()
await expect(page).toHaveURL(/\/auth\/signed-out/)
})
test("the sidebar comes back as it was left, and a phone gets it in a sheet", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
await page.goto(`/${org}/${workspace}`)
const toggle = page.locator('[data-guide="frame.sidebar"]')
await expect
.poll(async () => (await page.context().cookies()).find((c) => c.name === "app_workspace")?.value)
.toBe(encodeURIComponent(`${org}/${workspace}`))
await page.getByRole("link", { name: "Organization settings", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${org}/settings(?:/account)?$`))
await page
.getByRole("navigation", { name: "Main navigation" })
.getByRole("link", { name: "Workspace overview" })
.click()
await expect(page).toHaveURL(new RegExp(`/${org}/${workspace}$`))
await toggle.click()
await expect
.poll(async () => (await page.context().cookies()).find((c) => c.name === "app_sidebar")?.value)
.toBe("closed")
await page.reload()
await expect(page.locator('[data-slot="sidebar"][data-state="collapsed"]').first()).toBeVisible()
await page.setViewportSize({ width: 375, height: 812 })
await page.reload()
await page.waitForLoadState("networkidle")
await toggle.click()
const sheet = page.getByRole("dialog")
await expect(sheet.getByRole("link", { name: "Workspace overview" })).toBeVisible()
expect(await violations(page)).toEqual([])
// Leaving Settings must not redirect back while its layout is still mounted.
await sheet.getByRole("link", { name: "Organization settings", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${org}/settings(?:/account)?$`))
await toggle.click()
await page.getByRole("dialog").getByRole("link", { name: "Workspace overview" }).click()
await expect(page).toHaveURL(new RegExp(`/${org}/${workspace}$`))
await expect(page.getByRole("heading", { name: "Projects" })).toBeVisible()
await expect(page.getByRole("dialog")).toHaveCount(0)
await toggle.click()
await page.getByRole("dialog").getByRole("link", { name: "Organization settings", exact: true }).press("Enter")
await expect(page).toHaveURL(new RegExp(`/${org}/settings(?:/account)?$`))
await toggle.click()
await page.getByRole("dialog").getByRole("link", { name: "Workspace overview" }).press("Enter")
await expect(page).toHaveURL(new RegExp(`/${org}/${workspace}$`))
})
test("the workspace picker can create and switch projects from the keyboard", async ({ page }) => {
await signIn(page, "ada@example.test")
const workspace = "/acme-review/general"
await page.goto(workspace)
// Its name holds what it shows (WCAG 2.5.3): the words with no project chosen, the project's name once one is.
const picker = page.getByRole("button", { name: /^(Choose a project$|Switch project: )/ })
await expect(page.getByRole("button", { name: "Choose a project", exact: true })).toBeEnabled()
await expect(picker).toBeEnabled()
await picker.press("Enter")
await page.getByRole("menuitem", { name: "Create a project" }).press("Enter")
await expect(page).toHaveURL(`${workspace}/modules?create=true`)
await page.reload()
const slug = unique("navigation")
await page.getByRole("textbox", { name: "Name", exact: true }).fill("Navigation test")
await page.getByRole("textbox", { name: "Short name", exact: true }).fill(slug)
await page.getByRole("button", { name: "Create", exact: true }).click()
await expect(page).toHaveURL(`${workspace}/${slug}`)
await expect(page.getByRole("link", { name: "Project overview" })).toHaveAttribute("aria-current", "page")
await expect(page.getByRole("button", { name: "Switch project: Navigation test", exact: true })).toBeEnabled()
await picker.press("Enter")
await page.getByRole("menuitemradio", { name: "Team notes" }).press("Enter")
await expect(page).toHaveURL(`${workspace}/notes`)
await page.goBack()
await expect(page).toHaveURL(`${workspace}/${slug}`)
await page.goForward()
await expect(page).toHaveURL(`${workspace}/notes`)
})
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// kit:guides -- the guides module's live suite; `kit template:prune --without guides` removes this folder.
//
// The code survey end to end against a running platform (survey.e2e.ts): this app deployed on ConusAI
// Cloud with the guides module, and the console. Not part of `pnpm e2e` (the fake platform has no
// Guides service); it skips, saying what is missing, when the platform's variables are not set.
// pnpm exec playwright test -c e2e/guides/playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: ".",
testMatch: "*.e2e.ts",
workers: 1,
fullyParallel: false,
timeout: 180_000,
expect: { timeout: 30_000 },
reporter: [["list"]],
use: {
...(process.env.E2E_GUIDES_APP_URL ? { baseURL: process.env.E2E_GUIDES_APP_URL } : {}),
browserName: "chromium",
headless: true,
trace: "retain-on-failure",
},
})
+217
View File
@@ -0,0 +1,217 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// kit:guides -- the guides module's live suite; `kit template:prune --without guides` removes this folder.
//
// The notes module's code survey (`notes.feedback`, ADR-046) end to end on a running platform:
//
// 1. the code-guides bundle the image ships (`conusai.guides.code.v1`, ADR-045) carries the survey;
// with the deploy job's publish credential it is published here as the deploy step does,
// otherwise the deployment's own bundle publication is relied on;
// 2. a signed-in member answers it from the notes page ("Give feedback"), with the keyboard where
// it matters, attaches a small PNG to the file question (through the app's own server), and
// sees the thank-you;
// 3. the console's results page counts the response and lists the file by its name, and its CSV
// export holds the answers;
// 4. the member deletes their account -- the guides module's account-deletion hook calls
// `eraseEndUser` -- and the answers and the file are gone from the results and the export.
//
// What it needs (it skips, naming what is missing, without them):
// E2E_GUIDES_APP_URL the deployed app, with the dev sign-in adapter (`/auth/dev-login`) of a
// stage environment, so every run signs in a new person
// E2E_GUIDES_CONSOLE_URL the console origin
// E2E_GUIDES_CONSOLE_STATE a Playwright storage-state file of a console member holding guides:read
// on the app's project
// E2E_GUIDES_PROJECT the project's slug in the console
// and, to publish the bundle from here: CONUSAI_API_URL, CONUSAI_GUIDES_PUBLISH_TOKEN and
// CONUSAI_GUIDES_DEPLOYMENT, as the platform hands them to a deploy job. The project's guide settings
// must allow the `survey` kind.
import { readFileSync } from "node:fs"
import { appModules } from "@app/modules"
import { buildRegistry } from "@conusai-kit/core"
import { guidesBundleOf, publishGuidesAtDeploy } from "@conusai-kit/guides/deploy"
import { type Browser, expect, type Page, test } from "@playwright/test"
import { call, signIn, unique } from "../support/app"
const KEY = "notes.feedback"
const TITLE = "How are notes working for you?"
const env = {
app: process.env.E2E_GUIDES_APP_URL,
console: process.env.E2E_GUIDES_CONSOLE_URL,
consoleState: process.env.E2E_GUIDES_CONSOLE_STATE,
project: process.env.E2E_GUIDES_PROJECT,
}
const missing = Object.entries({
E2E_GUIDES_APP_URL: env.app,
E2E_GUIDES_CONSOLE_URL: env.console,
E2E_GUIDES_CONSOLE_STATE: env.consoleState,
E2E_GUIDES_PROJECT: env.project,
})
.filter(([, value]) => !value)
.map(([name]) => name)
/** A 1 x 1 PNG: the smallest image the platform's content check accepts as one. */
const PNG = Buffer.from(
"89504e470d0a1a0a0000000d4948445200000001000000010806000000" +
"1f15c4890000000d49444154789c6360000002000154a24f5d0000000049454e44ae426082",
"hex",
)
/** Letters only, so the platform's masking of free text leaves the marker as it is. */
const marker = (): string =>
Array.from({ length: 10 }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("")
test("the image's code-guides bundle carries the notes survey in the unchanged format", () => {
const bundle = guidesBundleOf(buildRegistry([...appModules]))
expect(bundle.format).toBe("conusai.guides.code.v1")
const survey = bundle.guides.find((guide) => guide.key === KEY)
expect(survey?.content).toMatchObject({ kind: "survey", trigger: { manual: true }, frequency: "once" })
expect(Object.keys(survey ?? {})).toEqual(["key", "content"])
})
test.describe("the notes survey on a running platform", () => {
test.skip(missing.length > 0, `needs a running platform: set ${missing.join(", ")}`)
/** The console page of the survey's results, with its guide id from the guides list. */
async function openResults(browser: Browser): Promise<{ page: Page; url: string }> {
const context = await browser.newContext({ storageState: env.consoleState ?? "", acceptDownloads: true })
const page = await context.newPage()
await page.goto(`${env.console}/projects/${env.project}/guides`)
await page
.getByRole("link", { name: new RegExp(`${KEY.replace(".", "\\.")}|${TITLE.replace("?", "\\?")}`) })
.first()
.click()
await expect(page).toHaveURL(/\/guides\/[^/]+$/)
const url = `${page.url().replace(/\/$/, "")}/results`
await page.goto(url)
await expect(page.getByRole("heading", { name: "Results" })).toBeVisible()
return { page, url }
}
async function respondents(page: Page, url: string): Promise<number> {
await page.goto(url)
const figure = page.getByRole("figure", { name: "Respondents" })
await expect(figure).toBeVisible()
const value = /(\d[\d,.\s]*)/.exec((await figure.innerText()).replace("Respondents", ""))?.[1] ?? "NaN"
return Number(value.replace(/[,.\s]/g, ""))
}
async function exportCsv(page: Page, url: string): Promise<string> {
await page.goto(url)
const download = page.waitForEvent("download")
await page.getByRole("link", { name: "Export CSV" }).click()
return readFileSync((await (await download).path()) ?? "", "utf8")
}
test("a member answers it, the console counts and exports the response, and erasure removes it", async ({
page,
browser,
}) => {
// 1. Publication: the deploy step, when this run holds the deploy job's credential.
if (process.env.CONUSAI_GUIDES_PUBLISH_TOKEN && process.env.CONUSAI_GUIDES_DEPLOYMENT) {
const outcome = await publishGuidesAtDeploy({ registry: buildRegistry([...appModules]), env: process.env })
expect(outcome.status, JSON.stringify(outcome)).toBe("published")
if (outcome.status === "published") {
expect(outcome.guides.find((guide) => guide.key === KEY)?.status).toBe("published")
}
} else {
test.info().annotations.push({
type: "publication",
description: "no publish credential: relying on the deployment's guides.bundle publication (ADR-045)",
})
}
const results = await openResults(browser)
const before = await respondents(results.page, results.url)
// 2. A new member answers the survey from the notes page.
const email = `${unique("surveyed")}@example.test`
await signIn(page, email)
const org = unique("survey")
await call(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: "notes",
name: "Notes",
moduleKey: "notes",
})
).slug
await page.goto(`/${org}/${workspace}/${project}/notes`)
await page.getByRole("button", { name: "Give feedback" }).click()
const dialog = page.getByRole("dialog", { name: TITLE })
await expect(dialog).toBeVisible()
await expect(dialog.getByText("Question 1 of 4")).toBeVisible()
await dialog.getByRole("radio", { name: "4" }).check()
await page.keyboard.press("Enter")
await expect(dialog.getByText("Question 2 of 4")).toBeVisible()
await dialog.getByRole("radio", { name: "Meeting notes" }).check()
await dialog.getByRole("button", { name: "Next" }).click()
const text = `Faster search ${marker()}`
const field = dialog.getByRole("textbox", { name: "What would make notes better for you?" })
await expect(field).toHaveAccessibleDescription(/Do not enter personal data/)
await field.fill(text)
// The answer is never kept in the browser's storage.
expect(await page.evaluate(() => JSON.stringify({ ...sessionStorage, ...localStorage }))).not.toContain(text)
await dialog.getByRole("button", { name: "Next" }).click()
// The file question: a small PNG, uploaded through the app's own server as it is chosen.
await expect(dialog.getByText("Question 4 of 4")).toBeVisible()
const fileName = `screenshot-${marker()}.png`
const input = dialog.getByLabel("Choose files")
await expect(input).toHaveAccessibleDescription(/Files must not contain personal data such as identity documents\./)
await input.setInputFiles({ name: fileName, mimeType: "image/png", buffer: PNG })
await expect(dialog.getByRole("status")).toHaveText(`${fileName} attached.`)
await expect(dialog.getByRole("list", { name: "Attached files" })).toContainText(fileName)
expect(await page.evaluate(() => JSON.stringify({ ...sessionStorage, ...localStorage }))).not.toContain(fileName)
await dialog.getByRole("button", { name: "Submit" }).click()
await expect(dialog.getByRole("status")).toHaveText("Thank you. We read every answer.")
await dialog.getByRole("button", { name: "Close" }).click()
await expect(page.getByRole("dialog", { name: TITLE })).toHaveCount(0)
// 3. The console counts the response, lists the file by its name, and exports the answers.
await expect.poll(() => respondents(results.page, results.url), { timeout: 60_000 }).toBe(before + 1)
await results.page.goto(results.url)
await expect(results.page.getByRole("link", { name: fileName })).toBeVisible()
const csv = await exportCsv(results.page, results.url)
const header = csv.split(/\r?\n/)[0]
expect(header).toBe("environment,version,respondent,question_id,question,answer_ids,answer_labels,text,answered_at")
const mine = csv.split(/\r?\n/).find((line) => line.includes(text))
expect(mine, "the free-text answer is in the export").toBeTruthy()
const respondent = mine?.split(",")[2] ?? ""
const rows = csv.split(/\r?\n/).filter((line) => line.split(",")[2] === respondent)
// The file answer's row, if the export has one, is the console's to word; the others are pinned.
expect(
rows
.map((line) => line.split(",")[3])
.filter((id) => id !== "screenshot")
.sort(),
).toEqual(["ease", "improve", "use"])
expect(rows.find((line) => line.split(",")[3] === "ease")).toContain(",4,")
expect(rows.find((line) => line.split(",")[3] === "use")).toContain("meetings")
// 4. The member deletes the account: the guides module erases the end user on the platform.
await page.goto("/settings/account")
await page.getByLabel("Type your e-mail address to confirm").fill(email)
await page.getByRole("button", { name: "Delete my account" }).click()
await expect(
page.getByRole("status").filter({ hasText: "Your account is deleted, and you are signed out." }),
).toBeVisible()
await expect.poll(() => respondents(results.page, results.url), { timeout: 60_000 }).toBe(before)
await results.page.goto(results.url)
await expect(results.page.getByRole("link", { name: fileName })).toHaveCount(0)
const after = await exportCsv(results.page, results.url)
expect(after).not.toContain(text)
expect(after).not.toContain(fileName)
expect(after.split(/\r?\n/).some((line) => line.split(",")[2] === respondent)).toBe(false)
await results.page.context().close()
})
})
+181
View File
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// A slow current module must not replace the server-rendered link during its
// first gesture. Real app/Postgres; fake managed platform, not Cloud acceptance.
import { writeFile } from "node:fs/promises"
import { expect, test } from "@playwright/test"
import { call, makeOrganization, ready, signIn, unique } from "./support/app"
for (const scope of ["workspace", "workspace-row", "project"] as const) {
test(`${scope} server link survives hydration while its view code is delayed`, async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const slugs = await makeOrganization(page)
const notes = `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes`
const workspace = `/${slugs.org}/${slugs.workspace}/modules/notes`
let path = `${workspace}?q=no+matching+project`
let name = "Clear filter"
if (scope === "workspace-row") {
path = workspace
name = "Review"
}
if (scope === "project") {
const { noteId } = await call<{ noteId: string }>(page, "notes.note.create", {
...slugs,
title: "Hydration note",
body: "A server-rendered detail.",
})
path = `${notes}/${noteId}`
name = "All notes"
}
let release = (): void => {}
const code = new Promise<void>((resolve) => {
release = resolve
})
let requested = false
const chunk = scope === "project" ? "**/assets/pages-*.js" : "**/assets/workspace-page-*.js"
await page.route(chunk, async (route) => {
requested = true
await code
await route.continue()
})
try {
await page.goto(path, { waitUntil: "commit" })
const link = page.getByRole("link", { name, exact: true })
await expect(link).toBeVisible()
await expect.poll(() => requested).toBe(true)
const original = await page.evaluateHandle(
(label) => Array.from(document.querySelectorAll("a[href]")).find((node) => node.textContent === label) ?? null,
name,
)
expect(await original.evaluate((node) => Boolean(node?.isConnected && node.getClientRects().length > 0))).toBe(
true,
)
const element = original.asElement()
if (!element) throw new Error("the server did not retain its link")
const box = await element.boundingBox()
if (!box) throw new Error("the server link has no hit target")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
// Drive a real browser paint while the module response remains blocked.
// The current server node must remain a usable hit target, not fallback.
await page.evaluate(
() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))),
)
expect(await original.evaluate((node) => Boolean(node?.isConnected && node.getClientRects().length > 0))).toBe(
true,
)
// Nothing has hydrated while the page's code is held, so the document does not claim it is interactive.
expect(await page.locator("html").getAttribute("data-hydrated")).toBeNull()
release()
// A live page keeps its stream open, so the network never goes idle: wait for hydration itself.
await ready(page)
expect(await original.evaluate((node) => Boolean(node?.isConnected && node.getClientRects().length > 0))).toBe(
true,
)
// Hydration and late provider styles must preserve the original hit target.
// Do not move the pointer again: that would hide the lost-click defect.
expect(await element.boundingBox()).toEqual(box)
expect(
await original.evaluate((node, point) => node?.contains(document.elementFromPoint(point.x, point.y)) ?? false, {
x: box.x + box.width / 2,
y: box.y + box.height / 2,
}),
).toBe(true)
await page.mouse.up()
await expect(page).toHaveURL(scope === "workspace" ? workspace : notes)
await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
} finally {
release()
}
})
}
test("JS-only selection stays disabled until the matched workspace view is hydrated", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
let release = (): void => {}
const code = new Promise<void>((resolve) => {
release = resolve
})
let requested = false
await page.route("**/assets/workspace-page-*.js", async (route) => {
requested = true
await code
await route.continue()
})
try {
await page.goto(`/${org}/${workspace}/modules/notes`, { waitUntil: "commit" })
await expect.poll(() => requested).toBe(true)
const status = page.locator("[data-slot=resource-count]")
await expect(status).toHaveText("0 projects selected")
const checkbox = page.getByRole("checkbox", { name: "Review", exact: true })
await expect(checkbox).toBeDisabled()
const select = page.locator("label").filter({ has: checkbox }).click()
release()
await select
await expect(status).toHaveText("1 project selected")
} finally {
release()
}
})
test("a failed initial module has a recovery page and can reload after the network recovers", async ({
page,
}, info) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
const errors: string[] = []
const consoleMessages: string[] = []
const responses: { path: string; status: number }[] = []
let offline = true
page.on("pageerror", (error) => errors.push(error.message))
page.on("console", (message) => {
if (message.type() === "error" || message.type() === "warning") consoleMessages.push(message.text())
})
page.on("response", (response) => {
responses.push({ path: new URL(response.url()).pathname, status: response.status() })
})
await page.route("**/assets/workspace-page-*.js", (route) =>
offline
? route.fulfill({ status: 503, contentType: "text/javascript", body: "// module temporarily unavailable" })
: route.continue(),
)
await page.goto(`/${org}/${workspace}/modules/notes`)
await expect(page.getByRole("heading", { level: 1, name: "Something went wrong" })).toBeVisible()
await expect(page.getByRole("alert")).toContainText("Try again")
await expect(page.getByRole("link", { name: "Back to the start" })).toHaveAttribute("href", "/")
await page.screenshot({ path: info.outputPath("module-download-recovery.png") })
expect(responses.some((response) => response.path.includes("workspace-page-") && response.status === 503)).toBe(true)
// React reports its fallback from concurrent to synchronous rendering when
// this deliberately broken import reaches the boundary. Keep that diagnostic
// in the evidence, while rejecting unrelated errors and any after recovery.
expect(
errors.filter(
(message) =>
message !==
"There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root." &&
!message.startsWith("Minified React error #520;"),
),
).toEqual([])
const recoveryReports = errors.length
offline = false
const retry = page.getByRole("button", { name: "Try again", exact: true })
await retry.focus()
await retry.press("Enter")
await expect(page.getByRole("heading", { level: 1, name: "Notes in this workspace" })).toBeVisible()
await expect(page.getByRole("checkbox", { name: "Review", exact: true })).toBeEnabled()
await page.waitForLoadState("networkidle")
expect(errors).toHaveLength(recoveryReports)
await page.screenshot({ path: info.outputPath("module-download-recovered.png") })
const networkPath = info.outputPath("module-download-network.json")
const consolePath = info.outputPath("module-download-console.json")
await writeFile(networkPath, JSON.stringify(responses, null, 2))
await writeFile(consolePath, JSON.stringify({ recoverableErrors: errors, messages: consoleMessages }, null, 2))
await info.attach("module-download-network.json", {
path: networkPath,
contentType: "application/json",
})
await info.attach("module-download-console.json", {
path: consolePath,
contentType: "application/json",
})
})
+23
View File
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Shared selection styles reach the first paint: the table's geometry and count do not wait for
// hydration. The Notes workspace page needs no module-specific stylesheet.
import { expect, test } from "@playwright/test"
import { makeOrganization, signIn, unique } from "./support/app"
test("shared selection styles apply with the browser's scripts turned off", async ({ browser, page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
const state = await page.context().storageState()
const context = await browser.newContext({
baseURL: `http://127.0.0.1:${process.env.E2E_WEB_PORT ?? 3100}`,
javaScriptEnabled: false,
storageState: state,
})
const plain = await context.newPage()
await plain.goto(`/${org}/${workspace}/modules/notes`)
await expect(plain.locator("[data-slot=resource-count]")).toHaveCSS("font-variant-numeric", "tabular-nums")
const box = await plain.locator(".kit-data-table-box").first().boundingBox()
expect(box?.width).toBe(box?.height)
await context.close()
})
+66
View File
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The live note list (umbrella TS6.2 to TS6.6) in a real browser against the whole stack: a note
// added, renamed and deleted in one tab appears, changes and goes in another without a reload; the
// write answers ConusAI-Txid; another organization's member sees none of it and cannot open the
// first organization's stream. Every screen passes axe.
import { expect, test } from "@playwright/test"
import { call, callRaw, makeOrganization, signIn, unique, violations } from "./support/app"
test("a note written in one tab appears, changes and goes in another without a reload", async ({ browser }) => {
const context = await browser.newContext()
const watcher = await context.newPage()
await signIn(watcher, `${unique("live")}@example.test`)
const scope = await makeOrganization(watcher, "live")
const list = `/${scope.org}/${scope.workspace}/${scope.project}/notes`
await watcher.goto(list)
await expect(watcher.getByText("No notes yet.")).toBeVisible()
expect(await violations(watcher)).toEqual([])
// Mark the document: a reload would lose the mark, so finding it later proves there was none.
await watcher.evaluate(() => {
;(window as unknown as { liveMark: string }).liveMark = "kept"
})
const writer = await context.newPage()
await writer.goto(list)
const csrf = (await writer.locator('meta[name="csrf-token"]').getAttribute("content")) ?? ""
const created = await callRaw(writer.request, csrf, "notes.note.create", { ...scope, title: "Live one", body: "b" })
expect(created.ok()).toBe(true)
expect(created.headers()["conusai-txid"]).toMatch(/^\d+$/)
const note = (await created.json()) as { noteId: string; version: number }
await expect(watcher.getByRole("row", { name: /Live one/ })).toBeVisible()
await call(writer, "notes.note.update", {
...scope,
noteId: note.noteId,
title: "Live renamed",
body: "b",
expectedVersion: note.version,
})
await expect(watcher.getByRole("row", { name: /Live renamed/ })).toBeVisible()
await call(writer, "notes.note.delete", { ...scope, noteId: note.noteId })
await expect(watcher.getByRole("row", { name: /Live renamed/ })).toHaveCount(0)
expect(await watcher.evaluate(() => (window as unknown as { liveMark?: string }).liveMark)).toBe("kept")
expect(await violations(watcher)).toEqual([])
// A read answers no transaction id.
const read = await callRaw(writer.request, csrf, "notes.note.list", scope)
expect(read.headers()["conusai-txid"]).toBeUndefined()
// Another organization's member: refused the first organization's stream, and blind to its writes.
const other = await (await browser.newContext()).newPage()
await signIn(other, `${unique("outsider")}@example.test`)
const theirs = await makeOrganization(other, "other")
const otherCsrf = (await other.locator('meta[name="csrf-token"]').getAttribute("content")) ?? ""
const foreign = await other.request.post(`/api/v1/orgs/${scope.org}/live`, {
data: { protocol: "clp/1", after: null, entities: ["notes.note"], topics: [] },
headers: { "x-csrf-token": otherCsrf, "content-type": "application/json", "sec-fetch-site": "same-origin" },
})
expect(foreign.status()).toBe(404)
await other.goto(`/${theirs.org}/${theirs.workspace}/${theirs.project}/notes`)
await expect(other.getByText("No notes yet.")).toBeVisible()
await call(writer, "notes.note.create", { ...scope, title: "Not for others", body: "b" })
await expect(watcher.getByRole("row", { name: /Not for others/ })).toBeVisible()
await expect(other.getByText("No notes yet.")).toBeVisible()
await expect(other.getByRole("row", { name: /Not for others/ })).toHaveCount(0)
})
+237
View File
@@ -0,0 +1,237 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The example module in a browser (plan item AT2.2): add a note, attach a file through the files
// module's Blob upload, change and remove things, and delete the note -- with axe on every screen it
// shows. Another organization's member sees none of it.
import { readFileSync } from "node:fs"
import { expect, type Page, test } from "@playwright/test"
import { call, callRaw, makeOrganization, platformState, signIn, unique, violations } from "./support/app"
const MINUTES = Buffer.from("Agreed on Friday: ship the pilot.\n")
async function uploadText(page: Page, scope: Awaited<ReturnType<typeof makeOrganization>>, name: string) {
const begun = await call<{ fileId: string; parts: Array<{ number: number }> }>(page, "files.file.beginUpload", {
...scope,
filename: name,
contentType: "text/plain",
size: 1,
})
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
const part = begun.parts[0]
if (!part) throw new Error("the one-byte upload did not return a part")
const sent = await page.request.put(
`/api/v1/files.file.putPart?${new URLSearchParams({ ...scope, fileId: begun.fileId, part: String(part.number) })}`,
{
data: Buffer.from("x"),
headers: {
"content-type": "application/octet-stream",
"x-csrf-token": csrf ?? "",
"sec-fetch-site": "same-origin",
},
},
)
expect(sent.ok()).toBe(true)
const receipt = (await sent.json()) as { etag: string }
await call(page, "files.file.completeUpload", {
...scope,
fileId: begun.fileId,
parts: [{ number: part.number, etag: receipt.etag }],
})
return begun.fileId
}
test("a note takes attachments, is edited and removed, and every screen passes axe", async ({ page }) => {
await signIn(page, `${unique("author")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const base = `/${org}/${workspace}/${project}/notes`
// the project links to the module from the manifest's navigation entry: a project section is a link in the sidebar
await page.goto(`/${org}/${workspace}/${project}`)
const notesLink = page
.getByRole("navigation", { name: "Main navigation" })
.getByRole("link", { name: "Notes", exact: true })
await expect(notesLink).toBeVisible()
await expect(notesLink).toHaveAttribute("href", base)
await page.goto(base)
await expect(page.getByRole("heading", { level: 1, name: "Notes" })).toBeVisible()
await expect(page.getByText("No notes yet.")).toBeVisible()
expect(await violations(page)).toEqual([])
// a new note opens beside the list, with an address, and the list stays usable
await page.locator('[data-guide="notes.new"]').click()
await expect(page).toHaveURL(/\?new=1$/)
const panel = page.locator('[data-slot="side-panel"]')
await expect(panel.getByRole("heading", { name: "New note" })).toBeVisible()
await expect(page.getByText("No notes yet.")).toBeVisible()
expect(await violations(page)).toEqual([])
// what the schema refuses is said beside the field, and the person is put there
await panel.getByLabel("Text", { exact: true }).fill("Agenda and owners")
await panel.getByRole("button", { name: "Save" }).click()
await expect(panel.getByLabel("Title")).toBeFocused()
await panel.getByLabel("Title").fill("Kick-off")
await panel.getByRole("button", { name: "Save" }).click()
// the record takes its address, the row is the current one, and it survives a reload
await expect(page).toHaveURL(/\?record=[0-9a-f-]+$/)
await expect(page.getByRole("row", { name: /Kick-off/ })).toBeVisible()
await page.reload()
await expect(panel.getByLabel("Title")).toHaveValue("Kick-off")
expect(await violations(page)).toEqual([])
// the note's own page, for what needs a page
await panel.getByRole("link", { name: "Open in full" }).click()
await expect(page.getByRole("heading", { level: 1, name: "Kick-off" })).toBeVisible()
await expect(page.getByText("Nothing is attached.")).toBeVisible()
expect(await violations(page)).toEqual([])
// an upload through the files module (the platform's Blob store), then the link
await page.getByLabel("Attach a file").setInputFiles({ name: "minutes.txt", mimeType: "text/plain", buffer: MINUTES })
await page.getByRole("button", { name: "Attach", exact: true }).click()
await expect(page.getByRole("row").filter({ hasText: /minutes\.txt.*text\/plain.*1 KB/ })).toBeVisible()
await expect(page.getByText("1 attachment", { exact: true })).toBeVisible()
expect(await violations(page)).toEqual([])
const download = page.waitForEvent("download")
await page.getByRole("link", { name: "Download minutes.txt" }).click()
const downloaded = await download
expect(downloaded.suggestedFilename()).toBe("minutes.txt")
const downloadedPath = await downloaded.path()
expect(readFileSync(downloadedPath)).toEqual(MINUTES)
// what the worker sent the platform: ONE audit event and ONE usage fact for the upload, one audit event
// and one usage fact for the note, one audit event for the link
const noteId = page.url().split("/").pop() ?? ""
const { files } = await call<{ files: Array<{ fileId: string }> }>(page, "files.file.list", {
org,
workspace,
project,
})
expect(files).toHaveLength(1)
const fileId = files[0]?.fileId ?? ""
await expect(async () => {
const state = await platformState()
// The target carries the organization as `<org id>/<resource id>` (`auditTarget` of @conusai-kit/host/http), so a
// resource matches by its trailing segment.
const audited = (id: string) =>
state.audit.filter((event) => event.resource_id?.endsWith(`/${id}`)).map((event) => event.action)
expect(audited(fileId)).toEqual(["files.file_uploaded"])
expect(audited(noteId).sort()).toEqual(["notes.attachment_added", "notes.note_created"])
const metered = (key: string) => state.usage.filter((fact) => fact.idempotency_key === key)
expect(metered(`files_bytes_stored.${fileId}`)).toEqual([
expect.objectContaining({ meter: "files_bytes_stored", quantity: String(MINUTES.length) }),
])
expect(metered(`notes_created.${noteId}`)).toEqual([
expect.objectContaining({ meter: "notes_created", quantity: "1" }),
])
// and the bytes are in the platform's Blob store, once, under the organization's own prefix
expect(
state.blobs.filter((blob) => blob.size === MINUTES.length && blob.pathname.includes(`/files/`)),
).toHaveLength(1)
}).toPass({ timeout: 30_000 })
// edit, in the panel: the form carries the version it was read at
await page.getByRole("link", { name: "Edit note" }).click()
await panel.getByLabel("Title").fill("Kick-off call")
await panel.getByRole("button", { name: "Save" }).click()
// the list shows the note with its attachment count
await expect(page.getByRole("row").filter({ hasText: /Kick-off call.*1/ })).toBeVisible()
expect(await violations(page)).toEqual([])
// remove the attachment, then delete the note after being asked
await panel.getByRole("link", { name: "Open in full" }).click()
await expect(page.getByRole("heading", { level: 1, name: "Kick-off call" })).toBeVisible()
await page.getByRole("button", { name: "Remove minutes.txt" }).click()
await page.getByRole("alertdialog").getByRole("button", { name: "Remove", exact: true }).click()
await expect(page.getByText("Nothing is attached.")).toBeVisible()
await page.getByRole("button", { name: "Delete note" }).click()
// Audit the dialog's settled colors after its fade-in, not a half-transparent frame.
await expect(page.getByRole("alertdialog")).toHaveCSS("opacity", "1")
expect(await violations(page)).toEqual([])
await page.getByRole("button", { name: "Yes, delete the note" }).click()
await expect(page.getByText("No notes yet.")).toBeVisible()
})
test("a second editor's stale save is refused and says so", async ({ page }) => {
await signIn(page, `${unique("author")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const base = `/${org}/${workspace}/${project}/notes`
const { noteId } = await call<{ noteId: string }>(page, "notes.note.create", {
org,
workspace,
project,
title: "Plan",
body: "",
})
await page.goto(`${base}?record=${noteId}`)
await expect(page.getByLabel("Title")).toHaveValue("Plan")
// the same note opened in a second tab, saved there first
const second = await page.context().newPage()
await second.goto(page.url())
await expect(second.getByLabel("Title")).toHaveValue("Plan")
await second.getByLabel("Title").fill("Plan v2")
await second.getByRole("button", { name: "Save" }).click()
await expect(second.getByRole("row", { name: /Plan v2/ })).toBeVisible()
// the first tab's panel still holds version 1, and its draft is kept
await page.getByLabel("Title").fill("Plan v3")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByRole("alert")).toBeVisible()
await expect(page.getByLabel("Title")).toHaveValue("Plan v3")
expect(await violations(page)).toEqual([])
await second.close()
})
test("concurrent attachments cannot exceed the per-note cap", async ({ page }) => {
await signIn(page, `${unique("quota-owner")}@example.test`)
const scope = await makeOrganization(page)
const note = await call<{ noteId: string }>(page, "notes.note.create", {
...scope,
title: "Attachment quota",
body: "",
})
const existingFiles: string[] = []
for (let index = 0; index < 19; index += 1) {
const fileId = await uploadText(page, scope, `quota-${index}.txt`)
existingFiles.push(fileId)
await call(page, "notes.attachment.add", { ...scope, noteId: note.noteId, fileId })
}
const contenders = await Promise.all([
uploadText(page, scope, "quota-race-a.txt"),
uploadText(page, scope, "quota-race-b.txt"),
])
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
const results = await Promise.all(
contenders.map((fileId) =>
callRaw(page.request, csrf ?? "", "notes.attachment.add", { ...scope, noteId: note.noteId, fileId }),
),
)
expect(results.map((result) => result.status()).sort()).toEqual([200, 409])
const detail = await call<{ attachments: unknown[] }>(page, "notes.note.get", { ...scope, noteId: note.noteId })
expect(detail.attachments).toHaveLength(20)
expect(existingFiles).toHaveLength(19)
})
test("another organization's member does not see the notes", async ({ page, browser }) => {
await signIn(page, `${unique("owner")}@example.test`)
const mine = await makeOrganization(page)
const stranger = await browser.newPage()
await signIn(stranger, `${unique("stranger")}@example.test`)
const response = await stranger.goto(`/${mine.org}/${mine.workspace}/${mine.project}/notes`)
expect(response?.status()).toBe(404)
await expect(stranger.getByRole("heading", { level: 1, name: "Page not found" })).toBeVisible()
await stranger.close()
})
test("a path the module does not know is the not-found page", async ({ page }) => {
await signIn(page, `${unique("author")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const unknownModule = await page.goto(`/${org}/${workspace}/${project}/nothing-here`)
expect(unknownModule?.status()).toBe(404)
const unknownNote = await page.goto(`/${org}/${workspace}/${project}/notes/00000000-0000-0000-0000-000000000000`)
expect(unknownNote?.status()).toBe(404)
})
+79
View File
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Real Postgres and the app worker's invitation relay; provider services remain simulated.
import { expect, test } from "@playwright/test"
import { call, callRaw, makeOrganization, signIn, unique, violations } from "./support/app"
type Inbox = { items: Array<{ id: string; code: string; readAt: string | null }>; unreadCount: number }
test("invitations reach only their recipients and the shared inbox marks them read", async ({
page,
browser,
}, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 })
await signIn(page, `${unique("inbox-owner")}@example.test`)
const { org } = await makeOrganization(page)
const email = `${unique("inbox-viewer")}@example.test`
const invitation = await call<{ token: string }>(page, "tenancy.member.invite", { org, email, role: "viewer" })
const context = await browser.newContext({ baseURL: new URL(page.url()).origin })
try {
const member = await context.newPage()
await signIn(member, email)
await call(member, "tenancy.invitation.accept", { org, token: invitation.token })
await expect
.poll(async () => (await call<Inbox>(page, "notifications.inbox.list", { org })).items.map((item) => item.code))
.toContain("invitation_accepted")
await expect
.poll(async () => (await call<Inbox>(member, "notifications.inbox.list", { org })).items.map((item) => item.code))
.toContain("member_joined")
const ownerInbox = await call<Inbox>(page, "notifications.inbox.list", { org })
const accepted = ownerInbox.items.find((item) => item.code === "invitation_accepted")
if (!accepted) throw new Error("the invitation relay did not deliver its notification")
const csrf = await member.locator('meta[name="csrf-token"]').getAttribute("content")
const denial = await callRaw(member.request, csrf ?? "", "notifications.inbox.markRead", { org, id: accepted.id })
expect(denial.status()).toBe(404)
expect(
(await call<Inbox>(page, "notifications.inbox.list", { org })).items.find((item) => item.id === accepted.id)
?.readAt,
).toBeNull()
await page.goto(`/${org}`)
await page.locator('[data-guide="frame.notifications"]').click()
const panel = page.locator('[data-slot="side-panel"]')
await expect(panel.getByText("An invitation was accepted", { exact: true })).toBeVisible()
await panel.getByRole("button", { name: "Mark as read: An invitation was accepted", exact: true }).click()
await expect
.poll(
async () =>
(await call<Inbox>(page, "notifications.inbox.list", { org })).items.find((item) => item.id === accepted.id)
?.readAt,
)
.not.toBeNull()
const bar = await page.locator('[data-slot="frame-bar"]').boundingBox()
const head = await panel.locator(".kit-side-panel-head").boundingBox()
expect(head?.y ?? -1).toBeGreaterThanOrEqual((bar?.y ?? 0) + (bar?.height ?? 0))
await page.getByRole("button", { name: "Reject analytics and replay", exact: true }).click({ trial: true })
expect(await violations(page)).toEqual([])
await page.screenshot({ path: testInfo.outputPath("notification-invitation-desktop.png") })
await panel.getByRole("button", { name: "Close", exact: true }).click()
await expect(page.locator('[data-guide="frame.notifications"]')).toBeFocused()
await page.setViewportSize({ width: 768, height: 900 })
await page.locator('[data-guide="frame.notifications"]').click()
const overlay = page.locator('[data-slot="side-panel"]')
// The entrance animation must not make the document wider at the overlay breakpoint.
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
const overlayHead = await overlay.locator(".kit-side-panel-head").boundingBox()
const overlayBar = await page.locator('[data-slot="frame-bar"]').boundingBox()
expect(overlayHead?.y ?? -1).toBeGreaterThanOrEqual((overlayBar?.y ?? 0) + (overlayBar?.height ?? 0))
await page.getByRole("button", { name: "Reject analytics and replay", exact: true }).click({ trial: true })
await page.screenshot({ path: testInfo.outputPath("notification-invitation-tablet.png") })
await overlay.getByRole("button", { name: "Close", exact: true }).click()
await page.setViewportSize({ width: 360, height: 800 })
await page.locator('[data-guide="frame.notifications"]').click()
const sheet = page.getByRole("dialog")
await expect(sheet.getByRole("button", { name: "Close", exact: true })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.screenshot({ path: testInfo.outputPath("notification-invitation-mobile.png") })
await sheet.getByRole("button", { name: "Close", exact: true }).click()
} finally {
await context.close()
}
})
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The e2e stack's fake platform serves the deployment's own services, as `pnpm dev` does (scripts/dev.mjs): the
// business gateway a project administrator binds an organization to, and the e-mail the platform captures on a
// test install. Without the deployment, every business procedure in a browser journey was refused and no captured
// e-mail could be read (template lesson proc-5). Fake platform, not Cloud.
import { randomUUID } from "node:crypto"
import { expect, test } from "@playwright/test"
test("the e2e stack serves the deployment's business bindings and captured e-mail", async () => {
const base = process.env.E2E_PLATFORM_URL
const organization = process.env.E2E_USAGE_ORGANIZATION
if (!base || !organization) throw new Error("e2e: the global setup sets E2E_PLATFORM_URL and E2E_USAGE_ORGANIZATION")
// A binding for an app tenant, as a project administrator provisions it.
const bound = await fetch(`${base}/__e2e/app-bindings`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ app_tenant_id: randomUUID(), grants: { crm: ["crm.parties:read"] } }),
})
expect(bound.status).toBe(200)
// The deployment's captured e-mail, read with the deployment's own token.
const emails = await fetch(`${base}/api/v1/orgs/${organization}/emails`, {
headers: { authorization: "Bearer e2e-token" },
})
expect(emails.status).toBe(200)
})
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The end-to-end suite (`pnpm e2e`): Chromium against the whole app on this machine -- web, worker and
// migrate job over a real Postgres with the three roles, and the fake platform for Blob and OCR. One
// worker, in order: the specs share the stack, and each makes its own organization.
import { defineConfig } from "@playwright/test"
const port = Number(process.env.E2E_WEB_PORT ?? 3100)
export default defineConfig({
testDir: ".",
testMatch: "*.spec.ts",
testIgnore: ["better-auth/**"], // kit:convex-better-auth -- gate J-auth has its own run (better-auth/playwright.config.ts)
globalSetup: "./support/global-setup.ts",
workers: 1,
fullyParallel: false,
timeout: 90_000,
expect: { timeout: 20_000 },
reporter: [["list"]],
use: {
baseURL: `http://127.0.0.1:${port}`,
browserName: "chromium",
trace: "retain-on-failure",
},
})
+628
View File
@@ -0,0 +1,628 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The shell in a browser (plan item AT4.4): sign-in and its error states, the invitation link in every
// state it can be in, the organization switcher, the workspace and project pages, the 403 and 404
// pages (and that a stranger cannot tell a missing organization from one they are not in), the
// language and theme controls, and the demo data `pnpm dev` writes. Each screen passes axe, in
// English, and the ones that carry words of their own also in Lithuanian.
import { type Browser, type BrowserContext, expect, type Page, test } from "@playwright/test"
import { moduleSelected } from "../modules/inclusion"
import { call, makeOrganization, openScope, platformState, signIn, unique, violations } from "./support/app"
const base = `http://127.0.0.1:${process.env.E2E_WEB_PORT ?? 3100}`
async function chooseShellOption(page: Page, label: string, option: string): Promise<void> {
await page.getByRole("button", { name: new RegExp(`${label}$`) }).click()
await page.getByRole("option", { name: option, exact: true }).click()
}
/** A browser of its own, so two people can be signed in at once. */
async function person(browser: Browser, email: string): Promise<{ page: Page; context: BrowserContext }> {
const context = await browser.newContext({ baseURL: base })
const page = await context.newPage()
await signIn(page, email)
return { page, context }
}
/** The alerts of the page's own content, where the shell and the sign-in page put them. */
const alerts = (page: Page) => page.getByRole("alert")
const mainText = (page: Page): Promise<string> => page.locator("main").innerText()
test("the sign-in page offers the way in, words each failure in the person's language, and passes axe", async ({
page,
}) => {
await page.goto("/auth/sign-in")
await expect(page.getByRole("heading", { level: 1, name: "Sign in" })).toBeVisible()
await expect(alerts(page)).toHaveCount(0)
expect(await violations(page)).toEqual([])
for (const [code, words] of [
["denied", /The identity provider did not let you in/],
["noTransaction", /was not started in this browser/],
["sessionExpired", /Your session has ended/],
["provider", /could not complete the sign-in/],
["failed", /Signing in did not work/],
] as const) {
await page.goto(`/auth/sign-in?error=${code}`)
await expect(alerts(page)).toContainText(words)
}
expect(await violations(page)).toEqual([])
// Only the five words are ever shown: a code from a URL is not text to print.
await page.goto("/auth/sign-in?error=%3Cscript%3Ealert(1)%3C/script%3E")
await expect(alerts(page)).toHaveCount(0)
// In Lithuanian, with the same structure.
await page.goto("/auth/sign-in?error=denied")
await page.getByRole("group", { name: "Language" }).getByRole("button", { name: "Lietuvių" }).click()
await expect(page.getByRole("heading", { level: 1, name: "Prisijungimas" })).toBeVisible()
await expect(alerts(page)).toContainText("Tapatybės teikėjas jūsų neįleido")
expect(await violations(page)).toEqual([])
})
test("the privacy choices leave a first visit's sign-in form reachable, by pointer and by keyboard", async ({
browser,
}) => {
for (const viewport of [
{ width: 1280, height: 720 },
{ width: 390, height: 844 },
]) {
const context = await browser.newContext({ baseURL: base, viewport })
const page = await context.newPage()
await page.goto("/auth/sign-in")
await expect(page.getByRole("region", { name: "Privacy choices" })).toBeVisible()
// Tabbing to the field and to the button brings each out from under the choices (WCAG 2.2, 2.4.11).
for (const control of [
page.getByLabel("E-mail address"),
page.getByRole("main").getByRole("button", { name: "Sign in" }),
]) {
await control.focus()
const hit = await control.evaluate((element) => {
const box = element.getBoundingClientRect()
return document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2) === element
})
expect(hit, `${viewport.width}x${viewport.height}`).toBe(true)
}
// And nobody has to answer before they sign in.
await page.getByLabel("E-mail address").fill("ada@example.test")
await page.getByRole("main").getByRole("button", { name: "Sign in" }).click()
await expect(page).not.toHaveURL(/\/auth\/sign-in/)
await context.close()
}
})
test("a privacy choice applies in place; only a new yes to session replay takes a new document", async ({
browser,
}) => {
const context = await browser.newContext({ baseURL: base })
const page = await context.newPage()
await page.goto("/auth/sign-in")
const choices = page.getByRole("region", { name: "Privacy choices" })
const mark = () =>
page.evaluate(() => {
;(window as { sameDocument?: boolean }).sameDocument = true
})
const sameDocument = () => page.evaluate(() => (window as { sameDocument?: boolean }).sameDocument === true)
// No: the observer is told at once, the banner closes, and the document stays.
await mark()
await choices.getByRole("button", { name: "Reject analytics and replay" }).click()
await expect(choices).toHaveCount(0)
expect(await sameDocument()).toBe(true)
// Yes to analytics alone: the same.
await page.getByRole("button", { name: "Privacy choices" }).click()
await choices.getByRole("button", { name: "Accept analytics" }).click()
await expect(choices).toHaveCount(0)
expect(await sameDocument()).toBe(true)
// Yes to replay, which loads only with the observer at a document's start.
await page.getByRole("button", { name: "Privacy choices" }).click()
await choices.getByRole("checkbox", { name: /Also allow session replay/ }).check()
await Promise.all([page.waitForEvent("load"), choices.getByRole("button", { name: "Accept analytics" }).click()])
expect(await sameDocument()).toBe(false)
await context.close()
})
test("the document says when it is interactive: the server never does, the browser does once hydrated", async ({
page,
}) => {
// A press before React hydrates is lost (a tab stays unselected), so a test waits for this (`ready` of support/app).
const served = await (await page.request.get("/auth/sign-in")).text()
expect(served).not.toContain("data-hydrated")
await page.goto("/auth/sign-in")
await expect(page.locator("html")).toHaveAttribute("data-hydrated", "true")
})
test("a failed sign-in ends at the sign-in page with a reason, never at a 500", async ({ page }) => {
// The provider says no.
const denied = await page.request.get("/auth/callback?error=access_denied&error_description=secret+words", {
maxRedirects: 0,
})
expect(denied.status()).toBe(302)
expect(denied.headers().location).toBe("/auth/sign-in?error=denied")
// A callback no sign-in in this browser started.
const orphan = await page.request.get("/auth/callback?code=abc&state=xyz", { maxRedirects: 0 })
expect(orphan.status()).toBe(302)
// (this stack has no identity provider, so there is nothing a callback could complete)
expect(orphan.headers().location).toBe("/auth/sign-in?error=failed")
// An address the dev sign-in will not take.
const bad = await page.request.get("/auth/dev-login?email=nobody", { maxRedirects: 0 })
expect(bad.status()).toBe(302)
expect(bad.headers().location).toBe("/auth/sign-in?error=failed")
})
test("an address that needs a person is sent to sign in and brought back, whoever it names", async ({ page }) => {
await page.goto("/acme-review/general")
await expect(page).toHaveURL(/\/auth\/sign-in\?return_to=%2Facme-review%2Fgeneral$/)
await page.getByLabel("E-mail address").fill("ada@example.test")
await page.getByRole("button", { name: "Sign in" }).click()
await expect(page).toHaveURL(/\/acme-review\/general$/)
await expect(page.getByRole("heading", { level: 1, name: "Workspace General" })).toBeVisible()
// A return address outside this app is not followed.
const away = await page.request.get("/auth/dev-login?email=ada@example.test&return_to=https://evil.example/", {
maxRedirects: 0,
})
expect(away.headers().location).toBe("/")
})
test("the demo data: two organizations, one with a tenant brand, and the switcher between them", async ({
page,
}, testInfo) => {
await signIn(page, "ada@example.test")
await page.goto("/")
await expect(page.getByRole("link", { name: "Acme Review" })).toBeVisible()
await expect(page.getByRole("link", { name: "Northwind Traders" })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("organization-chooser.png") })
await page.getByRole("link", { name: "Acme Review", exact: true }).press("Enter")
await expect(page).toHaveURL(/\/acme-review$/)
await page.goto("/acme-review")
await expect(page.locator("[data-tenant-brand]")).toHaveCount(0)
expect(await violations(page)).toEqual([])
await openScope(page)
await page.getByRole("menuitemradio", { name: "Northwind Traders" }).click()
await expect(page).toHaveURL(/\/northwind$/)
await expect(page.locator('[data-tenant-brand="#1a1a8c"]')).toHaveCount(1)
await expect(page.locator('[data-guide="frame.scope"]')).toContainText("Northwind")
expect(await violations(page)).toEqual([])
// A menu is a portal, and it keeps the organization's colours.
await openScope(page)
const menu = page.getByRole("menu")
await expect(menu).toBeVisible()
const primary = await menu.evaluate((element) => getComputedStyle(element).getPropertyValue("--primary"))
const root = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--primary"))
expect(primary.trim()).not.toBe(root.trim())
await page.keyboard.press("Escape")
// Linus is in one organization only: nothing to switch between.
const linus = await person(page.context().browser() as Browser, "linus@example.test")
await linus.page.goto("/acme-review")
await expect(linus.page.getByLabel("Switch organization")).toHaveCount(0)
await linus.context.close()
})
test("the browser can initialize MCP, list exactly the opted-in tools, and call the scoped note-list tool", async ({
page,
}) => {
await signIn(page, "ada@example.test")
const scope = await makeOrganization(page, "mcp")
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
const headers = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
"x-csrf-token": csrf ?? "",
"sec-fetch-site": "same-origin",
"mcp-protocol-version": "2025-11-25",
}
const rpcBody = async (response: Awaited<ReturnType<typeof page.request.post>>) => {
const text = await response.text()
if (response.headers()["content-type"]?.includes("text/event-stream")) {
const data = text.split("\n").find((line) => line.startsWith("data: "))
if (!data) throw new Error("MCP response had no event data")
return JSON.parse(data.slice("data: ".length))
}
return JSON.parse(text)
}
const initialize = await page.request.post("/api/mcp", {
headers,
data: {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "app-template-browser-e2e", version: "1.0.0" },
},
},
})
expect(initialize.status()).toBe(200)
expect((await rpcBody(initialize)).result.protocolVersion).toBe("2025-11-25")
const listed = await page.request.post("/api/mcp", { headers, data: { jsonrpc: "2.0", id: 2, method: "tools/list" } })
const tools = (await rpcBody(listed)).result.tools as Array<{ name: string }>
expect(listed.status()).toBe(200)
// Exactly the procedures whose contract opts in with `.tool()` (docs/reference/app-kit.md), in registry
// order: the notes list, Knowledge retrieval, Agent run status and its workspace reads, and Support's
// project processing status (the S2.6 reference tool, selected in the e2e app), which opt in on purpose;
// no other read or write of any module (test/mcp.test.ts asserts the list with only Agent and Knowledge selected).
expect(tools.map((tool) => tool.name)).toEqual([
"agent.run.status",
"assistant.workspace.openProject",
"assistant.workspace.projects",
"knowledge.search",
"notes.note.list",
"support.projectProcessingStatus.get", // kit:support
])
const called = await page.request.post("/api/mcp", {
headers,
data: {
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: { name: "notes.note.list", arguments: scope },
},
})
expect(called.status()).toBe(200)
expect((await rpcBody(called)).result).toMatchObject({
structuredContent: { notes: [], hasMore: false },
})
const refused = await page.request.post("/api/mcp", {
headers,
data: { jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "notes.note.create", arguments: {} } },
})
expect((await rpcBody(refused)).error.code).toBe(-32602)
})
test("MCP enforces the real session, CSRF token, and tenant scope at its HTTP boundary", async ({ browser }) => {
const owner = await person(browser, `${unique("mcp-owner")}@example.test`)
const foreign = await makeOrganization(owner.page, "mcp-private")
const secret = unique("tenant-private-note")
await call(owner.page, "notes.note.create", { ...foreign, title: "Private note", body: secret })
const noSession = await browser.newContext({ baseURL: base })
const anonymous = await noSession.newPage()
const requestBody = (id: number, method: string, params?: unknown) => ({
jsonrpc: "2.0",
id,
method,
...(params === undefined ? {} : { params }),
})
const noAuth = await anonymous.request.post("/api/mcp", {
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
data: requestBody(1, "initialize"),
})
expect(noAuth.status()).toBe(401)
await noSession.close()
const csrf = await owner.page.locator('meta[name="csrf-token"]').getAttribute("content")
const initialized = await owner.page.request.post("/api/mcp", {
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
"sec-fetch-site": "same-origin",
"x-csrf-token": csrf ?? "",
"mcp-protocol-version": "2025-11-25",
},
data: requestBody(2, "initialize", {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "app-template-security-e2e", version: "1.0.0" },
}),
})
expect(initialized.status()).toBe(200)
const withoutCsrf = await owner.page.request.post("/api/mcp", {
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
"sec-fetch-site": "same-origin",
"mcp-protocol-version": "2025-11-25",
},
data: requestBody(3, "tools/list"),
})
expect(withoutCsrf.status()).toBe(403)
const stranger = await person(browser, `${unique("mcp-stranger")}@example.test`)
const strangerCsrf = await stranger.page.locator('meta[name="csrf-token"]').getAttribute("content")
const crossTenant = await stranger.page.request.post("/api/mcp", {
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
"sec-fetch-site": "same-origin",
"x-csrf-token": strangerCsrf ?? "",
"mcp-protocol-version": "2025-11-25",
},
data: requestBody(4, "tools/call", {
name: "notes.note.list",
arguments: foreign,
}),
})
expect(crossTenant.status()).toBe(200)
const responseText = await crossTenant.text()
const event = responseText.split("\n").find((line) => line.startsWith("data: "))
const rpcResponse = crossTenant.headers()["content-type"]?.includes("text/event-stream")
? event?.slice("data: ".length)
: responseText
expect(rpcResponse).toBeDefined()
const result = JSON.parse(rpcResponse ?? "null") as {
result?: { isError?: boolean; content?: Array<{ text?: string }>; structuredContent?: unknown }
}
expect(result.result?.isError).toBe(true)
expect(JSON.stringify(result)).not.toContain(secret)
expect(JSON.stringify(result.result?.structuredContent ?? null)).not.toContain(secret)
expect(result.result?.content?.[0]?.text).toBe("The tool request was refused.")
await owner.context.close()
await stranger.context.close()
})
test("a stranger cannot tell an organization that is not theirs from one that does not exist", async ({ browser }) => {
const { page, context } = await person(browser, `${unique("stranger")}@example.test`)
const seen: Array<{ status: number; text: string }> = []
for (const path of ["/acme-review", "/no-such-organization", "/acme-review/general/demo", "/northwind/general"]) {
const response = await page.goto(path)
seen.push({ status: response?.status() ?? 0, text: await mainText(page) })
}
expect(seen.map((entry) => entry.status)).toEqual([404, 404, 404, 404])
expect(new Set(seen.map((entry) => entry.text)).size).toBe(1)
expect(seen[0]?.text).toContain("Page not found")
await expect(page.locator('[data-slot="app-state"] [data-slot="empty"]')).toBeVisible()
await expect(page.locator(".app-shell-header")).toBeHidden()
await expect(page.getByRole("link", { name: "Back to the start" })).toBeVisible()
expect(await violations(page)).toEqual([])
await context.close()
})
test("a member whose role does not allow a page gets a 403 and no link to it", async ({ browser }) => {
const { page, context } = await person(browser, "linus@example.test")
for (const path of ["/acme-review/settings", "/acme-review/audit"]) {
const response = await page.goto(path)
expect(response?.status(), path).toBe(403)
await expect(page.getByRole("heading", { level: 1, name: "You do not have access" })).toBeVisible()
}
expect(await violations(page)).toEqual([])
await page.goto("/acme-review")
await expect(page.getByRole("link", { name: "Audit" })).toHaveCount(0)
// The members page shows a viewer the list and none of the controls.
await page.goto("/acme-review/members")
await expect(page.getByRole("heading", { level: 2, name: "Members" })).toBeVisible()
await expect(page.getByRole("heading", { name: "Invite" })).toHaveCount(0)
await context.close()
})
test("an organization, a workspace and a project are made from the pages, and each page passes axe", async ({
browser,
}) => {
const { page, context } = await person(browser, `${unique("founder")}@example.test`)
const slug = unique("studio")
await page.goto("/onboarding")
await page.getByLabel("Organization name", { exact: true }).fill("Studio")
await page.getByLabel("Address", { exact: true }).fill(slug)
await page.getByRole("button", { name: "Continue", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`/${slug}/general$`))
expect(await violations(page)).toEqual([])
await page.goto(`/${slug}/workspaces`)
await expect(page.getByRole("heading", { level: 2, name: "Workspaces" })).toBeVisible()
await page.locator('[data-slot="app-frame"]').evaluate((frame) => {
;(frame as HTMLElement & { keptDuringCreate?: boolean }).keptDuringCreate = true
})
await page.getByRole("button", { name: "Create a workspace", exact: true }).click()
await page.getByLabel("Name", { exact: true }).fill("Research")
await page.getByLabel("Short name").fill("research")
await page.getByRole("button", { name: "Create", exact: true }).click()
await expect(page.getByRole("link", { name: "Research" })).toBeVisible()
expect(
await page
.locator('[data-slot="app-frame"]')
.evaluate((frame) => (frame as HTMLElement & { keptDuringCreate?: boolean }).keptDuringCreate),
).toBe(true)
expect(await violations(page)).toEqual([])
// A workspace cannot take the name of a page of the shell.
await page.getByRole("button", { name: "Create a workspace", exact: true }).click()
await page.getByLabel("Name", { exact: true }).fill("Members")
await page.getByLabel("Short name").fill("members")
await page.getByRole("button", { name: "Create", exact: true }).click()
await expect(alerts(page)).toContainText("reserved")
await page.goto(`/${slug}/research`)
await expect(page.getByRole("heading", { level: 1, name: "Workspace Research" })).toBeVisible()
await expect(page.getByText("There are no projects in this workspace yet.")).toBeVisible()
await page.locator('[data-slot="app-frame"]').evaluate((frame) => {
;(frame as HTMLElement & { keptDuringCreate?: boolean }).keptDuringCreate = true
})
await page.getByRole("button", { name: "Create a project", exact: true }).click()
await page.getByLabel("Name", { exact: true }).fill("Field notes")
await page.getByLabel("Short name").fill("field-notes")
await page.getByRole("button", { name: /Module/ }).click()
await page.getByRole("option", { name: "notes" }).click()
await page.getByRole("button", { name: "Create", exact: true }).click()
await expect(page.locator('[data-slot="form-panel"]')).toHaveCount(0)
expect(
await page
.locator('[data-slot="app-frame"]')
.evaluate((frame) => (frame as HTMLElement & { keptDuringCreate?: boolean }).keptDuringCreate),
).toBe(true)
await page.getByRole("link", { name: "Field notes" }).click()
await expect(page).toHaveURL(new RegExp(`/${slug}/research/field-notes$`))
await expect(page.getByRole("heading", { level: 1, name: "Project Field notes" })).toBeVisible()
// The project's sections are links in the sidebar, after the project picker.
const sidebar = page.getByRole("navigation", { name: "Main navigation" })
await expect(sidebar.getByRole("link", { name: "Notes", exact: true })).toBeVisible()
await expect(sidebar.getByRole("link", { name: "Notes", exact: true })).toHaveAttribute(
"href",
new RegExp(`/${slug}/research/field-notes/notes$`),
)
await expect(sidebar.getByRole("link", { name: "Documents" })).toHaveCount(moduleSelected("documents") ? 1 : 0)
await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toBeVisible()
expect(await violations(page)).toEqual([])
await context.close()
})
test("an invitation: made from Members with a copyable link, opened signed out, joined into the workspace, and every other state worded", async ({
browser,
}) => {
const owner = await person(browser, `${unique("owner")}@example.test`)
// An address of this run's own, so the case passes on a database an earlier run used.
const invited = `${unique("invited")}@example.test`
const slug = unique("guild")
await call(owner.page, "tenancy.org.create", { slug, name: "Guild" })
// Settings > Members: the address and the role, and the link at once, with Copy and when it stops working.
await owner.page.context().grantPermissions(["clipboard-read", "clipboard-write"])
await owner.page.goto(`/${slug}/members`)
await owner.page.getByLabel("E-mail address").fill(invited)
await owner.page.getByRole("button", { name: "Invite", exact: true }).click()
await expect(owner.page.getByText(`${invited} is invited. Send them this link:`)).toBeVisible()
await expect(owner.page.getByText(`It works once, for ${invited} only, until`, { exact: false })).toBeVisible()
const link = await owner.page.getByLabel("Invitation link").inputValue()
expect(link).toMatch(new RegExp(`/invitations/${slug}/[A-Za-z0-9_-]{20,}$`))
await owner.page.getByRole("button", { name: "Copy link" }).click()
await expect(owner.page.getByText("The link is copied.")).toBeVisible()
expect(await owner.page.evaluate(() => navigator.clipboard.readText())).toBe(link)
expect(await violations(owner.page)).toEqual([])
await expect(owner.page.getByRole("rowheader", { name: invited })).toBeVisible()
const path = new URL(link).pathname
// Signed out: the first page's layout says who it is for, as what, and until when; opening it consumed nothing.
const guestContext = await browser.newContext({ baseURL: base })
const guest = await guestContext.newPage()
await guest.goto(path)
await expect(guest.getByRole("heading", { level: 1, name: "You are invited" })).toBeVisible()
await expect(guest.getByText(`${invited} is invited to join Guild as Editor.`)).toBeVisible()
expect(await violations(guest)).toEqual([])
// Signed in as somebody else: told who the link is for, and no accept button.
const other = await person(browser, `${unique("other")}@example.test`)
await other.page.goto(path)
await expect(other.page.getByText(`This invitation is for ${invited}.`)).toBeVisible()
await expect(other.page.getByRole("button", { name: "Accept the invitation" })).toHaveCount(0)
await other.context.close()
// "Create your account": the sign-in starts with the invited address and lands in the workspace, as a member.
await guest.getByRole("button", { name: "Create your account" }).click()
await expect(guest.getByLabel("E-mail address")).toHaveValue(invited)
await guest.getByRole("button", { name: "Sign in" }).click()
await expect(guest).toHaveURL(new RegExp(`/${slug}/general$`))
const me = await call<{ organizations: Array<{ slug: string; role: string }> }>(guest, "tenancy.me.get", undefined)
expect(me.organizations).toEqual([{ slug, name: "Guild", role: "member" }])
// The same link again: used. In Lithuanian, too.
await guest.goto(path)
await expect(alerts(guest)).toContainText("This invitation has already been used.")
await guest.getByRole("group", { name: "Language" }).getByRole("button", { name: "Lietuvių" }).click()
await expect(alerts(guest)).toContainText("Šis kvietimas jau panaudotas.")
expect(await violations(guest)).toEqual([])
await guestContext.close()
// A new link replaces the old one, which then says it was revoked; a revoked one says so too.
await owner.page.getByLabel("E-mail address").fill("later@example.test")
await owner.page.getByRole("button", { name: "Invite", exact: true }).click()
await expect(owner.page.getByText("later@example.test is invited. Send them this link:")).toBeVisible()
const first = await owner.page.getByLabel("Invitation link").inputValue()
await owner.page.getByRole("button", { name: "New link for later@example.test" }).click()
const second = await owner.page.getByLabel("Invitation link").inputValue()
expect(second).not.toBe(first)
const stranger = await (await browser.newContext({ baseURL: base })).newPage()
await stranger.goto(new URL(first).pathname)
await expect(alerts(stranger)).toContainText("This invitation was revoked.")
await owner.page.getByRole("button", { name: "Revoke the invitation to later@example.test" }).click()
await expect(
owner.page.getByRole("status").filter({ hasText: "The invitation to later@example.test is revoked." }),
).toBeVisible()
await stranger.goto(new URL(second).pathname)
await expect(alerts(stranger)).toContainText("This invitation was revoked.")
await stranger.context().close()
await owner.context.close()
})
test("the language is the person's own and follows them; the theme is this screen's", async ({ browser }) => {
const email = "ada@example.test"
const first = await person(browser, email)
await first.page.goto("/")
await expect(first.page.getByRole("heading", { level: 1, name: "Organizations" })).toBeVisible()
// The language changes in the same document: the loaders run again under the new cookie, nothing reloads.
await first.page.evaluate(() => {
;(window as { sameDocument?: boolean }).sameDocument = true
})
await chooseShellOption(first.page, "Language", "Lietuvių")
await expect(first.page.getByRole("heading", { level: 1, name: "Organizacijos" })).toBeVisible()
await expect(first.page.locator("html")).toHaveAttribute("lang", "lt")
expect(await first.page.evaluate(() => (window as { sameDocument?: boolean }).sameDocument)).toBe(true)
expect(await violations(first.page)).toEqual([])
await first.context.close()
// Another browser, no cookie: the choice was saved on the user.
const second = await person(browser, email)
await second.page.goto("/")
await expect(second.page.getByRole("heading", { level: 1, name: "Organizacijos" })).toBeVisible()
// The theme: dark first, the root carries the class, and the server renders it back.
const root = second.page.locator("html")
await expect(root).not.toHaveClass(/light/)
await chooseShellOption(second.page, "Tema", "Šviesi")
await expect(root).toHaveClass(/light/)
await expect(second.page.locator(".light")).toHaveCount(1)
await second.page.reload()
await expect(root).toHaveClass(/light/)
expect(await violations(second.page)).toEqual([])
const html = await (await second.page.request.get("/")).text()
expect(html).toMatch(/<html[^>]*class="light"/)
await second.context.close()
})
test("the health and readiness endpoints answer booleans and nothing that names a reason", async ({ request }) => {
const health = await request.get("/api/health")
expect(health.status()).toBe(200)
expect(await health.json()).toMatchObject({ status: "ok" })
const ready = await request.get("/api/ready")
expect(ready.status()).toBe(200)
expect(await ready.json()).toEqual({
ready: true,
checks: { store: true, reader: true, migrations: true, platform: true },
})
})
test("a request carries its trace id into the web shell's logs and spans, and no secret leaves", async ({ page }) => {
await signIn(page, "ada@example.test")
const trace = "4bf92f3577b34da6a3ce929d0e0e4736"
const csrf = (await page.locator('meta[name="csrf-token"]').getAttribute("content")) ?? ""
const response = await page.request.post("/api/v1/tenancy.org.get", {
headers: {
"x-csrf-token": csrf,
"content-type": "application/json",
"sec-fetch-site": "same-origin",
traceparent: `00-${trace}-00f067aa0ba902b7-01`,
"x-request-id": "e2e-request-1",
},
data: { org: "acme-review" },
})
expect(response.status()).toBe(200)
expect(response.headers()["x-request-id"]).toBe("e2e-request-1")
await expect(async () => {
const { otel } = await platformState()
// The bodies of what the platform received: the request's own headers (the token it authenticates with) are not what is under test.
const logs = JSON.stringify(otel.logs.map((record) => record.body))
expect(logs).toContain(trace)
expect(logs).toContain("tenancy.org.get")
const traces = JSON.stringify(otel.traces.map((record) => record.body))
expect(traces).toContain("procedure tenancy.org.get")
// The deployment token, the session secret and the person's address never reach the platform's log.
for (const secret of ["e2e-token", "ada@example.test"]) expect(logs + traces).not.toContain(secret)
}).toPass({ timeout: 30_000 })
})
+184
View File
@@ -0,0 +1,184 @@
// 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()
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Starts the stack once for the whole run and stops it after (Playwright's global setup returns the
// teardown). The OCR the fake platform answers with is the documents module's own fixture: a three-page
// document with four labelled lines and a drawing page the extraction leaves out, which is what the specs review.
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { APP_ROOT, startStack } from "./stack"
export default async function globalSetup(): Promise<() => Promise<void>> {
const document = JSON.parse(
readFileSync(join(APP_ROOT, "modules", "documents", "__tests__", "fixtures", "ocr-document.json"), "utf8"),
) as Record<string, unknown>
const stack = await startStack(document)
process.env.E2E_BASE_URL = stack.baseUrl
process.env.E2E_PLATFORM_URL = stack.platformUrl
process.env.E2E_USAGE_ORGANIZATION = stack.usageOrganization
return async () => {
await stack.stop()
}
}
+231
View File
@@ -0,0 +1,231 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The whole app, running, for the end-to-end suite: a Postgres with the three roles a linked managed
// database exposes, the fake platform of @conusai-kit/testing (Blob, OCR jobs, audit, usage), the
// migrate job, the worker and the web shell -- the same four things compose.yaml runs, as processes
// on this machine so the suite needs no image build.
//
// Postgres is the one thing that needs Docker. The web shell is a development-mode build on `server.mjs`,
// because @conusai-kit/auth refuses the password-free dev sign-in the suite uses in a production start
// (AUTH_DEV_LOGIN with NODE_ENV=production fails at boot, spec §7), which is the right refusal and
// not one to work around. It is a build and not `vite dev`: that server hands the browser its modules one by
// one after the `load` event, so a spec that fills a form right after `page.goto` would beat hydration
// (and the form would submit natively, as a reload).
import { type ChildProcess, execFileSync, spawn, spawnSync } from "node:child_process"
import { randomBytes } from "node:crypto"
import { mkdirSync, openSync, readFileSync } from "node:fs"
import { join } from "node:path"
import {
fakeNativeServices,
startFakePlatform,
validReply, // kit:documents
} from "@conusai-kit/testing/fake-platform"
import { deterministicModel } from "../../test/support/fake-model" // kit:documents
export const APP_ROOT = new URL("../..", import.meta.url).pathname
const LOGS = join(APP_ROOT, "e2e", ".logs")
export const PORTS = {
web: Number(process.env.E2E_WEB_PORT ?? 3100),
postgres: Number(process.env.E2E_POSTGRES_PORT ?? 55432),
}
export interface Stack {
readonly baseUrl: string
readonly platformUrl: string
readonly usageOrganization: string
/** The fake platform, so a spec can change what its OCR reads. */
readonly platform: Awaited<ReturnType<typeof startFakePlatform>>
stop(): Promise<void>
}
const url = (role: string): string => `postgresql://appdev_${role}:appdev_${role}@127.0.0.1:${PORTS.postgres}/appdev`
function sh(command: string, args: string[]): string {
return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
}
async function until(label: string, check: () => Promise<boolean> | boolean, seconds = 120): Promise<void> {
const deadline = Date.now() + seconds * 1000
for (;;) {
try {
if (await check()) return
} catch {
// not yet
}
if (Date.now() > deadline) throw new Error(`e2e: ${label} did not come up in ${seconds}s (logs in e2e/.logs)`)
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
/**
* Runs a node script of the app to its end without blocking this process. The fake platform is served from this
* process, and a script that calls it (the seed binds the demo organizations to the business services) would wait
* forever on a synchronous child: this process could not answer until the child had exited.
*/
function runToEnd(label: string, args: string[], env: NodeJS.ProcessEnv): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn("node", args, { cwd: APP_ROOT, env, stdio: "inherit" })
child.on("error", reject)
child.on("exit", (code, signal) =>
code === 0 ? resolve() : reject(new Error(`e2e: ${label} exited with ${code ?? signal}`)),
)
})
}
function spawnLogged(name: string, command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): ChildProcess {
mkdirSync(LOGS, { recursive: true })
const out = openSync(join(LOGS, `${name}.log`), "w")
// A process group of its own, so stopping it stops what it started.
return spawn(command, args, { cwd, env, stdio: ["ignore", out, out], detached: true })
}
function stopGroup(child: ChildProcess): void {
if (child.pid === undefined) return
try {
process.kill(-child.pid, "SIGTERM")
} catch {
// already gone
}
}
export async function startStack(document: Record<string, unknown>): Promise<Stack> {
const processes: ChildProcess[] = []
const name = `e2e-postgres-${randomBytes(4).toString("hex")}`
const simulated = fakeNativeServices({ token: "e2e-token", projectId: 42 })
// The deployment's own business gateway, CRM and the rest, as `pnpm dev` starts them (scripts/dev.mjs): a business
// procedure answers only for an organization an administrator bound (`bindBusinessServices` of support/app.ts).
const platform = await startFakePlatform(0, {
nativeServices: simulated.nativeServices,
// The captured-mail journey deliberately models an approved reconciliation grant.
deployment: { ...simulated.authority, permissions: ["emails:send", "emails:read"] },
})
platform.ocr.setDocument(document)
// The model is the deterministic reader of the unit tests: the suite proves the app around a model, not a model.
platform.ai.respond((request) => (request.response_format ? deterministicModel(request) : validReply(request))) // kit:documents
const container = sh("docker", [
"run",
"-d",
// No --rm: a server that exits keeps its log for the error below; stopPostgres removes the container.
"--name",
name,
"--label",
"conusai.test=app-e2e",
"-p",
`127.0.0.1:${PORTS.postgres}:5432`,
"-e",
"POSTGRES_PASSWORD=postgres",
"-e",
"POSTGRES_DB=appdev",
// The roles script goes in through dev/postgres-entrypoint.sh, whatever mode the checkout gave it.
"-v",
`${join(APP_ROOT, "dev")}:/conusai-dev:ro`,
"--entrypoint",
"sh",
"postgres:18",
"/conusai-dev/postgres-entrypoint.sh",
"postgres",
])
const stopPostgres = (): void => {
try {
sh("docker", ["rm", "-f", "--volumes", container])
} catch {
// already gone
}
}
try {
// The init script creates the roles after the server first starts, then it restarts: "ready"
// is the app's own login working, not pg_isready.
await until("postgres", () => {
sh("docker", ["exec", container, "psql", "-U", "appdev_app", "-h", "127.0.0.1", "-d", "appdev", "-c", "select 1"])
return true
}).catch((error: Error) => {
// The server's own words, not only that it did not come up.
// `docker logs` replays the server's stderr on its own stderr.
const logs = spawnSync("docker", ["logs", "--tail", "20", name], { encoding: "utf8" })
const log = `${logs.stdout ?? ""}${logs.stderr ?? ""}`
throw new Error(`${error.message}; the container's log ends:\n${log}`)
})
const common: NodeJS.ProcessEnv = {
...process.env,
// The fake platform is a development and test switch the app accepts in these modes alone (config/src/env.ts).
NODE_ENV: "development",
CONUSAI_FAKE_PLATFORM: "1",
// What the platform injects when the environment has OTLP: the web shell sends its logs and spans to
// the fake platform, where a spec reads them back with the request's trace id.
OTEL_EXPORTER_OTLP_ENDPOINT: `${platform.url}/otel`,
CONUSAI_API_URL: `${platform.url}/api`,
CONUSAI_API_TOKEN: "e2e-token",
// What the platform injects for usage metering (AT4.1): the fake platform keeps the usage of
// this organization beside the audit events, which is where a spec reads both.
...simulated.deployment,
CONUSAI_PUBLIC_URL: `http://127.0.0.1:${PORTS.web}`,
CONUSAI_RELEASE: "e2e",
POSTGRES_URL: url("app"),
POSTGRES_READER_URL: url("reader"),
AUTH_SESSION_SECRET: randomBytes(32).toString("base64url"),
}
delete common.POSTGRES_MIGRATE_URL
// The migrate job holds the owner's URL and nothing else does.
await runToEnd("the migrate job", ["apps/worker/dist/migrate.js"], {
...common,
POSTGRES_MIGRATE_URL: url("owner"),
})
// The demo data, written with the kit by the same script `pnpm dev` runs: the specs that look at it
// are proof it works, and the others each make their own organization beside it.
await runToEnd("the demo data", ["apps/worker/dist/seed-main.js"], common)
// The build reads NODE_ENV from `common` and writes it in, so the shell's security headers are the
// development ones this run's server expects. It replaces whatever is in `dist`, from the source as it is.
// `E2E_BUILD_MODE=production` builds for production instead, for a spec that asks for the bundle that
// ships: the server still runs as above, since the dev sign-in refuses a production start.
const web = join(APP_ROOT, "apps", "web")
const mode = process.env.E2E_BUILD_MODE === "production" ? "production" : "development"
execFileSync(process.execPath, ["node_modules/vite/bin/vite.js", "build", "--mode", mode], {
cwd: web,
env: { ...common, NODE_ENV: mode },
stdio: "inherit",
})
processes.push(
spawnLogged("worker", "node", ["apps/worker/dist/main.js"], APP_ROOT, {
...common,
WORKER_HEARTBEAT_FILE: join(LOGS, "worker-heartbeat"),
}),
)
processes.push(
spawnLogged("web", process.execPath, ["server.mjs", "--port", String(PORTS.web)], web, {
...common,
HOSTNAME: "127.0.0.1",
AUTH_DEV_LOGIN: "1",
AUTH_INSECURE_COOKIES: "1",
}),
)
const baseUrl = `http://127.0.0.1:${PORTS.web}`
await until("the web shell", async () => (await fetch(`${baseUrl}/api/ready`)).ok, 180)
await until("the worker", () => readFileSync(join(LOGS, "worker.log"), "utf8").includes("worker: serving"), 60)
return {
baseUrl,
platformUrl: platform.url,
usageOrganization: simulated.deployment.CONUSAI_ORG_SLUG,
platform,
async stop() {
for (const child of processes) stopGroup(child)
await platform.close()
stopPostgres()
},
}
} catch (error) {
for (const child of processes) stopGroup(child)
await platform.close()
stopPostgres()
throw error
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "../tsconfig.paths.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"types": ["node"],
"lib": ["ES2023", "dom"],
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"**/*.ts",
"../modules/documents/__tests__/support/unzip.ts",
"../test/support/fake-model.ts",
"../test/support/images.ts"
]
}
+135
View File
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Route reachability and assistive navigation on the actual app, with simulated provider services.
import { expect, test } from "@playwright/test"
import { moduleSelected, OPTIONAL_MODULE_IDS } from "../modules/inclusion"
import { modulePages } from "../modules/pages"
import { makeOrganization, signIn, unique, violations } from "./support/app"
test("selected module routes, settings links and unknown pages have deliberate accessible outcomes", async ({
page,
}, info) => {
test.setTimeout(120_000)
const errors: string[] = []
let expectedRefusal = false
await signIn(page, `${unique("route-review")}@example.test`)
const scope = await makeOrganization(page)
const base = `/${scope.org}/${scope.workspace}/${scope.project}`
await page.goto(`/${scope.org}/settings`)
await page.getByRole("button", { name: "Reject analytics and replay", exact: true }).click()
// The next hard navigation must not abort the previous document's dynamic bootstrap.
await expect(page.locator('[data-guide="frame.person"]')).toBeEnabled()
page.on("pageerror", (error) => errors.push(error.message))
page.on("console", (message) => {
if (!expectedRefusal && ["warning", "error"].includes(message.type())) errors.push(message.text())
})
const settings = await page
.locator('[data-slot="settings-layout"] a[href]')
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("href") ?? ""))
const routes = [
...new Set([
`/${scope.org}`,
`/${scope.org}/${scope.workspace}`,
base,
`/${scope.org}/${scope.workspace}/modules/notes`,
...settings,
...Object.keys(modulePages).map((id) => `${base}/${id}`),
]),
]
const outcomes: Array<{ path: string; status: number }> = []
for (const path of routes) {
const response = await page.goto(path)
outcomes.push({ path: path.replaceAll(scope.org, "<org>"), status: response?.status() ?? 0 })
expect(response?.status(), path).toBe(200)
await expect(page.locator("main h1").first()).toBeVisible()
await expect(page.locator('[data-guide="frame.person"]')).toBeEnabled()
expect(await violations(page), path).toEqual([])
}
expectedRefusal = true
for (const id of [
"missing-module",
...OPTIONAL_MODULE_IDS.filter((id) => !moduleSelected(id) && !["guides", "support"].includes(id)),
]) {
const response = await page.goto(`${base}/${id}`)
expect(response?.status(), id).toBe(404)
await expect(page.getByRole("heading", { name: "Page not found", exact: true })).toBeVisible()
expect(await violations(page), `refused ${id}`).toEqual([])
}
await info.attach("route-outcomes", {
body: Buffer.from(JSON.stringify(outcomes, null, 2)),
contentType: "application/json",
})
expect(errors).toEqual([])
})
test("keyboard skips the frame and client navigation focuses the new page in both themes", async ({
page,
browserName,
}, info) => {
await signIn(page, `${unique("keyboard-review")}@example.test`)
const scope = await makeOrganization(page)
await page.goto(`/${scope.org}/${scope.workspace}`)
await page.getByRole("button", { name: "Reject analytics and replay", exact: true }).click()
for (const colorScheme of ["light", "dark"] as const) {
await page.locator('[data-guide="frame.person"]').click()
await page.getByRole("menuitemradio", { name: "System", exact: true }).click()
await page.reload()
await page.locator('[data-guide="frame.person"]').click()
await page.getByRole("menuitemradio", { name: colorScheme === "light" ? "Light" : "Dark", exact: true }).click()
// Explicit preference wins even when a hidden document control was initially following the device.
await page.emulateMedia({ colorScheme: colorScheme === "light" ? "dark" : "light", reducedMotion: "reduce" })
if (colorScheme === "light") await expect(page.locator("html")).toHaveClass(/light/)
else await expect(page.locator("html")).not.toHaveClass(/light/)
await page.reload()
await page.locator("body").press(browserName === "webkit" ? "Alt+Tab" : "Tab")
await expect(page.getByRole("link", { name: "Skip to content", exact: true })).toBeFocused()
await page.keyboard.press("Enter")
await expect(page.locator("main")).toBeFocused()
const settings = page.getByRole("link", { name: "Organization settings", exact: true })
if (colorScheme === "light") {
let release = () => {}
const paused = new Promise<void>((resolve) => {
release = resolve
})
await page.route("**/*", async (route) => {
if (route.request().resourceType() === "fetch" && route.request().method() === "GET") await paused
await route.continue()
})
try {
await settings.press("Enter")
const skeleton = page.locator('[data-slot="page-skeleton"]')
await expect(skeleton).toBeVisible()
await expect(skeleton.getByRole("status")).toBeVisible()
const bar = skeleton.locator('[data-slot="skeleton"]').first()
await expect(bar).toBeVisible()
const painted = await bar.evaluate((el) => {
const style = getComputedStyle(el)
return { background: style.backgroundColor, animation: style.animationName }
})
expect(painted.background).not.toBe("rgba(0, 0, 0, 0)")
expect(painted.animation).toBe("none")
await page.screenshot({ path: info.outputPath("settings-loading-reduced-motion.png") })
} finally {
release()
await page.unrouteAll({ behavior: "wait" })
}
} else {
await settings.press("Enter")
}
await expect(page.locator("main h1").first()).toBeFocused()
for (const width of [360, 768, 1280]) {
await page.setViewportSize({ width, height: 900 })
const geometry = await page.evaluate(() => ({
width: innerWidth,
scroll: document.documentElement.scrollWidth,
overflow: Array.from(document.querySelectorAll("*"))
.map((el) => ({ tag: el.tagName, cls: String(el.className), right: el.getBoundingClientRect().right }))
.filter((el) => el.right > innerWidth + 1)
.slice(0, 12),
}))
expect(geometry.scroll, JSON.stringify(geometry)).toBeLessThanOrEqual(geometry.width)
expect(await violations(page)).toEqual([])
await page.screenshot({ path: info.outputPath(`settings-${colorScheme}-${width}.png`) })
}
await page.goto(`/${scope.org}/${scope.workspace}`)
}
})
+230
View File
@@ -0,0 +1,230 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// A module's pages above a project on a real Postgres (F10): the four answers of
// `/<org>/<workspace>/modules/<module>`, the query string reaching the server, the navigation at that scope,
// and a project that cannot take the reserved segment.
//
// `E2E_BUILD_MODE=production pnpm exec playwright test -c e2e/playwright.config.ts workspace-pages` runs this
// file on a production `vite build` (e2e/support/stack.ts).
import { type Browser, expect, type Page, test } from "@playwright/test"
import { call, callRaw, chooseLanguage, makeOrganization, signIn, unique, violations } from "./support/app"
const base = `http://127.0.0.1:${process.env.E2E_WEB_PORT ?? 3100}`
async function person(browser: Browser, email: string): Promise<Page> {
const context = await browser.newContext({ baseURL: base })
const page = await context.newPage()
await signIn(page, email)
return page
}
const pathOf = (org: string, workspace: string, query = ""): string => `/${org}/${workspace}/modules/notes${query}`
test("signed out is a 302 to sign in, and the path comes back", async ({ browser, page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
const context = await browser.newContext({ baseURL: base })
const response = await context.request.get(pathOf(org, workspace), { maxRedirects: 0 })
expect(response.status()).toBe(302)
expect(response.headers().location).toContain("/auth/sign-in")
expect(decodeURIComponent(response.headers().location ?? "")).toContain(pathOf(org, workspace))
await context.close()
})
test("signed in is a 200 with the page, the navigation and the query string on the server", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
await call(page, "tenancy.project.create", { org, workspace, slug: "other", name: "Other", moduleKey: "notes" })
const response = await page.goto(pathOf(org, workspace))
expect(response?.status()).toBe(200)
await expect(page.getByRole("heading", { level: 1, name: "Notes in this workspace" })).toBeVisible()
await expect(page.locator("main").getByRole("link", { name: "Review" })).toHaveAttribute(
"href",
`/${org}/${workspace}/${project}/notes`,
)
expect(await violations(page)).toEqual([])
// The first byte already holds the filtered list: `load` read `?q=`, the browser did not.
const filtered = await page.request.get(pathOf(org, workspace, "?q=oth"))
expect(filtered.status()).toBe(200)
const html = await filtered.text()
expect(html).toContain(">Other<")
expect(html).not.toContain(">Review<")
// A search can be submitted with the keyboard and recovered without editing the URL.
await page.getByRole("searchbox", { name: "Project name" }).fill("no matching project")
await page.getByRole("searchbox", { name: "Project name" }).press("Enter")
await expect(page.getByText("No project matches.")).toBeVisible()
await page.getByRole("link", { name: "Clear filter" }).click()
await expect(page.getByRole("link", { name: "Review" })).toBeVisible()
// `ui.nav` with `scope: "workspace"`, on the workspace's own page and on a project's.
await page.goto(`/${org}/${workspace}`)
const nav = page.getByRole("navigation", { name: "Main navigation" })
await expect(nav.getByRole("link", { name: "Notes in this workspace" })).toHaveAttribute(
"href",
pathOf(org, workspace),
)
await page.goto(`/${org}/${workspace}/${project}`)
await expect(nav.getByRole("link", { name: "Notes in this workspace" })).toBeVisible()
})
test("a count only the browser knows reads right in English and in Lithuanian", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
await call(page, "tenancy.project.create", { org, workspace, slug: "other", name: "Other", moduleKey: "notes" })
await page.goto(pathOf(org, workspace))
const status = page.locator("[data-slot=resource-count]")
await expect(status).toHaveText("0 projects selected")
await page
.locator("label")
.filter({ has: page.getByRole("checkbox", { name: "Review", exact: true }) })
.click()
await expect(status).toHaveText("1 project selected")
await page
.locator("label")
.filter({ has: page.getByRole("checkbox", { name: "Other", exact: true }) })
.click()
await expect(status).toHaveText("2 projects selected")
// The language changes in place: the selection stays, and the count reads in Lithuanian, in each plural form.
await chooseLanguage(page, "Lietuvių")
await expect(page.getByRole("heading", { level: 1, name: "Užrašai šioje darbo srityje" })).toBeVisible()
await expect(status).toHaveText("Pasirinkti 2 projektai")
await page
.locator("label")
.filter({ has: page.getByRole("checkbox", { name: "Other", exact: true }) })
.click()
await expect(status).toHaveText("Pasirinktas 1 projektas")
await page
.locator("label")
.filter({ has: page.getByRole("checkbox", { name: "Review", exact: true }) })
.click()
await expect(status).toHaveText("Pasirinkta 0 projektų")
})
test("selection controls stay square and keyboard accessible in both themes", async ({ page }, testInfo) => {
const errors: string[] = []
const responses: { path: string; status: number }[] = []
page.on("pageerror", (error) => errors.push(error.message))
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text())
})
page.on("requestfailed", (request) =>
errors.push(`${new URL(request.url()).pathname}: ${request.failure()?.errorText}`),
)
page.on("response", (response) =>
responses.push({ path: new URL(response.url()).pathname, status: response.status() }),
)
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
await call(page, "tenancy.project.create", { org, workspace, slug: "other", name: "Other", moduleKey: "notes" })
await page.goto(pathOf(org, workspace))
await page.waitForLoadState("networkidle")
const privacy = page.getByRole("checkbox", { name: /Also allow session replay/ })
if (await privacy.isVisible()) {
const size = await privacy.boundingBox()
expect(size?.width).toBe(size?.height)
const reject = page.getByRole("button", { name: "Reject analytics and replay" })
await reject.click()
// the choice applies in place: the banner closes and the document stays
await expect(page.getByRole("region", { name: "Privacy choices" })).toHaveCount(0)
}
for (const theme of ["light", "dark"]) {
await page.context().addCookies([{ name: "app_theme", value: theme, url: base }])
for (const width of [375, 778, 1280]) {
await page.setViewportSize({ width, height: 853 })
await page.reload()
await page.waitForLoadState("networkidle")
const boxes = await page.locator(".kit-data-table-box").evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect()
return { width: rect.width, height: rect.height }
}),
)
expect(boxes.length).toBe(3)
for (const box of boxes) expect(box.width).toBe(box.height)
const first = page.locator("tbody").getByRole("checkbox").first()
// A React Aria grid has one Tab stop; arrows reach its cells and controls.
await page.getByRole("button", { name: "Filter", exact: true }).focus()
await page.keyboard.press("Tab")
await page.keyboard.press("ArrowRight")
await expect(first).toBeFocused()
await page.keyboard.press("Space")
await expect(first).toBeChecked()
const all = page.getByRole("checkbox", { name: "Select all", exact: true })
expect(await all.evaluate((control) => (control as HTMLInputElement).indeterminate)).toBe(true)
await expect(page.locator("[data-slot=resource-count]")).toHaveText("1 project selected")
await page.screenshot({ path: testInfo.outputPath(`selection-${theme}-${width}.png`) })
await page.keyboard.press("Space")
await expect(first).not.toBeChecked()
}
expect(await violations(page)).toEqual([])
}
expect(errors).toEqual([])
expect(responses.filter((response) => response.status >= 400)).toEqual([])
await testInfo.attach("selection-network", {
body: JSON.stringify(responses, null, 2),
contentType: "application/json",
})
})
test("another organization is the 404 of a workspace that does not exist", async ({ browser, page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
const stranger = await person(browser, `${unique("stranger")}@example.test`)
const theirs = await makeOrganization(stranger, "other")
const refused = await stranger.request.get(pathOf(org, workspace), { maxRedirects: 0 })
const missing = await stranger.request.get(pathOf(theirs.org, "nowhere"), { maxRedirects: 0 })
expect(refused.status()).toBe(404)
expect(missing.status()).toBe(404)
expect((await stranger.request.get(pathOf(theirs.org, theirs.workspace))).status()).toBe(200)
await stranger.context().close()
})
test("a member without the permission gets a 403 and no link to it", async ({ browser, page }) => {
const owner = `${unique("owner")}@example.test`
const member = `${unique("member")}@example.test`
await signIn(page, owner)
const { org, workspace } = await makeOrganization(page)
// A custom role that reaches the organization and nothing of notes: a member who may not read them.
const role = await call<{ key: string }>(page, "tenancy.role.create", {
org,
name: "Guest",
level: "org",
permissions: ["tenancy.org.read", "tenancy.workspace.read"],
})
const invited = await call<{ token: string }>(page, "tenancy.member.invite", {
org,
email: member,
role: "viewer",
roleKey: role.key,
})
const memberPage = await person(browser, member)
await call(memberPage, "tenancy.invitation.accept", { org, token: invited.token })
const response = await memberPage.goto(pathOf(org, workspace))
expect(response?.status()).toBe(403)
await expect(memberPage.getByRole("heading", { level: 1, name: "You do not have access" })).toBeVisible()
await memberPage.goto(`/${org}/${workspace}`)
await expect(memberPage.getByRole("navigation", { name: "This workspace" })).toHaveCount(0)
await memberPage.context().close()
})
test("no project can take the route's segment", async ({ page }) => {
await signIn(page, `${unique("owner")}@example.test`)
const { org, workspace } = await makeOrganization(page)
await page.goto(`/${org}/${workspace}`)
const csrf = (await page.locator('meta[name="csrf-token"]').getAttribute("content")) ?? ""
const refused = await callRaw(page.request, csrf, "tenancy.project.create", {
org,
workspace,
slug: "modules",
name: "Modules",
moduleKey: "notes",
})
expect(refused.status()).toBe(409)
})