211 lines
10 KiB
TypeScript
211 lines
10 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The organization's theme from the platform, read on the server and written as the page's stylesheet
|
|
// (brand-kit BK12). The platform's answers are fakes here: the derivation is the kit's own, and every
|
|
// test looks at what the app would write, not at how the kit fetches it.
|
|
|
|
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
import { createRequire } from "node:module"
|
|
import path from "node:path"
|
|
import { deriveTheme, resolvedThemeStyles } from "@conusai-kit/brand"
|
|
import { createPlatformThemeCache } from "@conusai-kit/brand/platform-theme"
|
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
import {
|
|
cloudOrganizationLogo,
|
|
createPlatformThemeReader,
|
|
ORGANIZATION_LOGO_PATH,
|
|
organizationIdentity,
|
|
type PlatformThemeDeployment,
|
|
} from "../apps/web/src/server/platform-theme.js"
|
|
|
|
const root = path.resolve(import.meta.dirname, "..")
|
|
|
|
/** The platform's answer for the navy fixture: the kit's derivation as OKLCH role maps (BrandingResponse). */
|
|
function branding(etag: string) {
|
|
const styles = resolvedThemeStyles(deriveTheme({ primary: "#001f4d", accent: "#1f6feb" }))
|
|
const roles = (mode: "light" | "dark") =>
|
|
Object.fromEntries(Object.entries(styles[mode]).map(([name, value]) => [name.slice(2), value]))
|
|
return {
|
|
version: 1,
|
|
seed: { primary: "#001f4d", accent: "#1f6feb" },
|
|
light: roles("light"),
|
|
dark: roles("dark"),
|
|
logo_url: null,
|
|
etag,
|
|
}
|
|
}
|
|
|
|
type Reply = { status: number; body?: unknown; etag?: string }
|
|
|
|
/** A fetch that answers from a script and records every request. */
|
|
function platform(...replies: Reply[]) {
|
|
const calls: Array<{ url: string; headers: Record<string, string> }> = []
|
|
const fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
calls.push({ url: String(url), headers: { ...((init?.headers ?? {}) as Record<string, string>) } })
|
|
const reply = replies.shift()
|
|
if (!reply) throw new Error("no scripted reply left")
|
|
if (reply.status === 304) return new Response(null, { status: 304, headers: { etag: reply.etag ?? "" } })
|
|
return new Response(reply.body === undefined ? null : JSON.stringify(reply.body), {
|
|
status: reply.status,
|
|
headers: { "content-type": "application/json", ...(reply.etag ? { etag: reply.etag } : {}) },
|
|
})
|
|
}) as typeof globalThis.fetch
|
|
return { fetch, calls }
|
|
}
|
|
|
|
const deployment = (org: string | undefined): PlatformThemeDeployment => ({
|
|
CONUSAI_API_URL: "https://platform.example.test/api",
|
|
CONUSAI_API_TOKEN: "dt_test_fixture",
|
|
...(org === undefined ? {} : { CONUSAI_ORG_SLUG: org }),
|
|
})
|
|
|
|
describe("the organization's theme on the server", () => {
|
|
it("writes the organization's roles as the page's stylesheet, dark on the root and light under .light", async () => {
|
|
const { fetch, calls } = platform({ status: 200, body: branding('"etag-1"'), etag: '"etag-1"' })
|
|
const css = await createPlatformThemeReader({ fetch })(deployment("acme"))
|
|
expect(calls[0]?.url).toBe("https://platform.example.test/api/v1/orgs/acme/branding")
|
|
expect(calls[0]?.headers.authorization).toBe("Bearer dt_test_fixture")
|
|
expect(css.startsWith(":root{--background:oklch(")).toBe(true)
|
|
expect(css).toContain(".light{--background:oklch(")
|
|
expect(css).toContain("--primary:oklch(")
|
|
})
|
|
|
|
it("with no organization slug the app asks nothing and writes nothing: the kit defaults stand", async () => {
|
|
const { fetch, calls } = platform()
|
|
expect(await createPlatformThemeReader({ fetch })(deployment(undefined))).toBe("")
|
|
expect(await createPlatformThemeReader({ fetch })(deployment(" "))).toBe("")
|
|
expect(calls).toHaveLength(0)
|
|
})
|
|
|
|
it("a cleared cache reads the organization again, with no validator", async () => {
|
|
const first = platform({ status: 200, body: branding('"etag-1"'), etag: '"etag-1"' })
|
|
await createPlatformThemeReader({ fetch: first.fetch })(deployment("acme"))
|
|
const second = platform({ status: 200, body: branding('"etag-2"'), etag: '"etag-2"' })
|
|
await createPlatformThemeReader({ fetch: second.fetch })(deployment("acme"))
|
|
expect(second.calls[0]?.headers["if-none-match"]).toBeUndefined()
|
|
})
|
|
|
|
it("an unavailable platform (503) gives the kit defaults, an empty stylesheet", async () => {
|
|
const { fetch } = platform({ status: 503, body: { title: "Unavailable" } })
|
|
expect(await createPlatformThemeReader({ fetch })(deployment("acme"))).toBe("")
|
|
})
|
|
|
|
it("a 304 writes the cached theme again with the cached ETag as the validator", async () => {
|
|
const cache = createPlatformThemeCache()
|
|
const first = platform({ status: 200, body: branding('"etag-1"'), etag: '"etag-1"' })
|
|
const fresh = await createPlatformThemeReader({ fetch: first.fetch, cache })(deployment("acme"))
|
|
const again = platform({ status: 304, etag: '"etag-1"' })
|
|
const cached = await createPlatformThemeReader({ fetch: again.fetch, cache })(deployment("acme"))
|
|
expect(again.calls[0]?.headers["if-none-match"]).toBe('"etag-1"')
|
|
expect(cached).toBe(fresh)
|
|
})
|
|
|
|
it("a changed organization is never written with another organization's theme", async () => {
|
|
const cache = createPlatformThemeCache()
|
|
const acme = platform({ status: 200, body: branding('"etag-acme"'), etag: '"etag-acme"' })
|
|
const acmeCss = await createPlatformThemeReader({ fetch: acme.fetch, cache })(deployment("acme"))
|
|
const other = platform({ status: 404, body: { title: "Organization Not Found" } })
|
|
const otherCss = await createPlatformThemeReader({ fetch: other.fetch, cache })(deployment("other"))
|
|
expect(other.calls[0]?.url).toContain("/v1/orgs/other/branding")
|
|
expect(other.calls[0]?.headers["if-none-match"]).toBeUndefined()
|
|
expect(otherCss).toBe("")
|
|
expect(acmeCss).not.toBe("")
|
|
})
|
|
})
|
|
|
|
describe("the organization's name and logo on Cloud", () => {
|
|
const PNG = new Uint8Array(
|
|
readFileSync(createRequire(import.meta.url).resolve("@conusai-kit/brand/logo/favicon.png")),
|
|
)
|
|
const on = { CONUSAI_API_URL: "https://platform.example.test/api", CONUSAI_API_TOKEN: "dt", CONUSAI_ORG_SLUG: "logo" }
|
|
afterEach(() => vi.unstubAllGlobals())
|
|
|
|
it("names the app after the organization and serves its logo from the app, with the token only to the platform", async () => {
|
|
const seen: Array<{ url: string; authorization?: string }> = []
|
|
vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => {
|
|
const headers = (init?.headers ?? {}) as Record<string, string>
|
|
seen.push({ url: String(url), ...(headers.authorization ? { authorization: headers.authorization } : {}) })
|
|
if (String(url).endsWith("/branding")) {
|
|
return Response.json(
|
|
{ ...branding('"n"'), name: "Navy UAB", logo_url: "/blob/org/7/logo.png" },
|
|
{ headers: { etag: '"n"' } },
|
|
)
|
|
}
|
|
return new Response(PNG, { headers: { "content-type": "image/png" } })
|
|
})
|
|
expect(await organizationIdentity(on)).toEqual({ name: "Navy UAB", logo: ORGANIZATION_LOGO_PATH })
|
|
const logo = await cloudOrganizationLogo(on)
|
|
expect(logo?.type).toBe("image/png")
|
|
expect(seen.at(-1)).toEqual({
|
|
url: "https://platform.example.test/blob/org/7/logo.png",
|
|
authorization: "Bearer dt",
|
|
})
|
|
})
|
|
|
|
it("keeps the app's own when Cloud has none, and refuses what is not a logo", async () => {
|
|
vi.stubGlobal("fetch", async (url: string | URL) =>
|
|
String(url).endsWith("/branding")
|
|
? Response.json(
|
|
{ ...branding('"x"'), logo_url: "https://cdn.example.test/x.png" },
|
|
{ headers: { etag: '"x"' } },
|
|
)
|
|
: new Response("<html>", { headers: { "content-type": "image/png" } }),
|
|
)
|
|
const deployment = { ...on, CONUSAI_ORG_SLUG: "nologo" }
|
|
expect(await organizationIdentity(deployment)).toEqual({ logo: ORGANIZATION_LOGO_PATH })
|
|
expect(await cloudOrganizationLogo(deployment)).toBeNull()
|
|
expect(await organizationIdentity({ ...on, CONUSAI_ORG_SLUG: "" })).toEqual({})
|
|
})
|
|
})
|
|
|
|
describe("the app's code and its deployment contract", () => {
|
|
it("names no hex colour and no colour function in the app's pages, layout, stylesheet or server code", () => {
|
|
const files = listFiles(path.join(root, "apps/web/src"), /\.(?:tsx?|css)$/)
|
|
expect(files.some((file) => file.endsWith("src/routes/__root.tsx"))).toBe(true)
|
|
expect(files.some((file) => file.endsWith("src/ui/globals.css"))).toBe(true)
|
|
for (const file of files) {
|
|
const code = readFileSync(file, "utf8")
|
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
.replace(/^\s*\/\/.*$/gm, "")
|
|
.replace(/(?<=\s)\/\/ .*$/gm, "")
|
|
const relative = path.relative(root, file)
|
|
expect(code, relative).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
|
|
expect(code, relative).not.toMatch(/\b(?:rgba?|hsla?|hwb|lab|lch|oklch)\(/)
|
|
}
|
|
})
|
|
|
|
it("reads only the font, type scale and radius brand tokens in its stylesheet, never a colour token (BK3)", () => {
|
|
const css = readFileSync(path.join(root, "apps/web/src/ui/globals.css"), "utf8")
|
|
const reads = css.match(/var\(--brand-[a-z0-9-]+/g) ?? []
|
|
expect(reads.length).toBeGreaterThan(0)
|
|
for (const read of reads) expect(read, read).toMatch(/--brand-(?:font|radius|text|leading|app-font-size)-/)
|
|
})
|
|
|
|
it("draws the product UI at the app's 16px root and its one type scale, not the site's 18px root", () => {
|
|
const css = readFileSync(path.join(root, "apps/web/src/ui/globals.css"), "utf8")
|
|
expect(css).toMatch(/html:root \{\s*font-size: var\(--brand-app-font-size-root\);/)
|
|
expect(css).not.toContain("--brand-font-size-root")
|
|
expect(css).not.toContain("--brand-font-display")
|
|
})
|
|
|
|
it("asks the platform for branding:read, and only that new scope", () => {
|
|
const contract = JSON.parse(readFileSync(path.join(root, "conusai.json"), "utf8")) as {
|
|
token: { permissions: string[] }
|
|
}
|
|
expect(contract.token.permissions).toContain("branding:read")
|
|
expect(contract.token.permissions).toEqual(expect.arrayContaining(["blob:read", "blob:write"]))
|
|
})
|
|
|
|
it("lists the files it adds as the template's own", () => {
|
|
const list = readFileSync(path.join(root, "TEMPLATE-FILES"), "utf8").split("\n")
|
|
expect(list).toContain("apps/web/src/server/platform-theme.ts")
|
|
expect(list).toContain("test/platform-theme.test.ts")
|
|
})
|
|
})
|
|
|
|
function listFiles(directory: string, pattern: RegExp): string[] {
|
|
return readdirSync(directory).flatMap((name) => {
|
|
const full = path.join(directory, name)
|
|
return statSync(full).isDirectory() ? listFiles(full, pattern) : pattern.test(name) ? [full] : []
|
|
})
|
|
}
|