@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user