Files
mats 922562e1c2 editor: experiments QA panel (P0 of experiments plan)
New Experiments section in the rail: lists tracking experiment rules
(status, traffic, arms, targeting), shows this session's assignment via
evaluate-experiments, per-arm analytics with winner/significance, a QA
arm override (sets the storefront variant cookie), and a two-step
pause/resume kill-switch. Admin reads degrade when the tracking gate
refuses; eligibility always works. Mock tracking endpoints + harness
tests.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-11-07 12:00:37 +01:00

196 lines
11 KiB
TypeScript

// Framework-neutral request handler for the mock CMS, used by the Vite plugin
// (dev:mock) and directly by tests (as an injectable `fetch`).
import { Graph, HttpError } from './graph'
import { blueprints, render, renderPage, schemas, seedGraph, types } from './fixtures'
export interface MockOptions {
/** Require this bearer token (to test the 401 path). Default: open. */
token?: string
/** Require sign-in through the mock device flow (GET/POST /api/auth/device*). Default: open. */
auth?: boolean
/** Auto-approve a device grant this long after it starts (dev:mock). Tests call approve(). */
approveAfterMs?: number
}
export function createMockCms(opts: MockOptions = {}) {
let graph = seedGraph()
const published: string[] = []
// --- mock slask-tracking experiments (P0 QA panel) ---
const expRules: { experiment_id: string; targeting?: { field: string; op: string; value?: string }[]; traffic_fraction?: number; variant_weights?: Record<string, number>; disabled?: boolean }[] = [
{ experiment_id: 'exp_hero', targeting: [{ field: 'page_path', op: 'prefix', value: '/' }], traffic_fraction: 1, variant_weights: { a: 1, b: 1 } },
{ experiment_id: 'exp_checkout', targeting: [{ field: 'country', op: 'eq', value: 'SE' }], traffic_fraction: 0.5, variant_weights: { control: 9, onepage: 1 }, disabled: true },
]
const requests: string[] = []
let previewApi = true
// --- mock go-auth (RFC 8628 device grant) ---
const access = new Set<string>()
let refreshTok: string | null = null
let approved = false
let n = 0
const issue = () => {
const a = `mock-access-${++n}`
access.add(a)
refreshTok = `mock-refresh-${n}`
return { access_token: a, token_type: 'Bearer', expires_in: 900, refresh_token: refreshTok }
}
const needsAuth = () => !!(opts.token || opts.auth)
const authorised = (req: Request) => {
const h = req.headers.get('authorization')
return !!h && (h === `Bearer ${opts.token}` || access.has(h.replace(/^Bearer /, '')))
}
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'x-cms-mock': '1' } })
async function handle(req: Request): Promise<Response> {
const url = new URL(req.url, 'http://mock')
const path = url.pathname
const m = req.method
requests.push(`${m} ${path}${url.search}`)
try {
// The mock is its own issuer (same origin), so the browser can complete the flow with no network.
if (path === '/device') {
return new Response(
`<!doctype html><meta charset="utf-8"><title>Mock approval</title><body style="font:16px system-ui;max-width:420px;margin:15vh auto;text-align:center"><h1>Approve sign-in?</h1><p>Code <b>MOCK-1234</b></p><button style="font:inherit;padding:10px 24px" onclick="fetch('/__mock/approve',{method:'POST'}).then(()=>{document.body.innerHTML='<h1>Approved</h1><p>You can close this tab.</p>';setTimeout(close,800)})">Approve</button>`,
{ headers: { 'content-type': 'text/html; charset=utf-8' } },
)
}
if (path === '/__mock/approve' && m === 'POST') {
approved = true
return new Response(null, { status: 204 })
}
if (path === '/api/auth/config') return json(200, needsAuth() ? { mode: 'oidc', issuer: url.origin, clientId: 'mock' } : { mode: 'none' })
if (path === '/api/auth/device' && m === 'POST') {
approved = false
if (opts.approveAfterMs !== undefined) setTimeout(() => (approved = true), opts.approveAfterMs)
return json(200, { device_code: 'mock-device', user_code: 'MOCK-1234', verification_uri: `${url.origin}/device`, verification_uri_complete: `${url.origin}/device?user_code=MOCK-1234`, expires_in: 600, interval: 1 })
}
if (path === '/api/auth/device/token' && m === 'POST') return approved ? json(200, issue()) : json(400, { error: 'authorization_pending' })
if (path === '/api/auth/device/refresh' && m === 'POST') {
const b = (await req.json()) as { refresh_token?: string }
return b.refresh_token && b.refresh_token === refreshTok ? json(200, issue()) : json(400, { error: 'invalid_grant' })
}
if (needsAuth() && path.startsWith('/api/') && !authorised(req)) return json(401, { error: 'unauthorized: auth: no credentials' })
if (path === '/api/auth/me') return json(200, { sub: 'mock-user', scopes: ['admin'] })
if (path === '/api/types') return json(200, { types, locales: [] })
if (path === '/api/blueprints') return json(200, blueprints)
let r: RegExpExecArray | null
if ((r = /^\/api\/schemas\/([^/]+)$/.exec(path))) {
const s = schemas[decodeURIComponent(r[1])]
return s ? json(200, s) : json(404, { error: 'unknown type' })
}
if (path === '/api/nodes' && m === 'GET') {
const type = url.searchParams.get('type')
const q = (url.searchParams.get('q') ?? '').toLowerCase()
const limit = Math.max(0, Number(url.searchParams.get('limit') ?? 50))
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0))
let items = [...graph.nodes.values()]
if (type) items = items.filter((n) => n.dataType === type)
if (q) items = items.filter((n) => n.id.toLowerCase().includes(q) || JSON.stringify(n.data).toLowerCase().includes(q))
const total = items.length
items = limit === 0 ? items.slice(offset) : items.slice(offset, offset + limit)
return json(200, { items: items.map((n) => ({ id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version })), total, offset, limit })
}
if ((r = /^\/api\/nodes\/([^/]+)\/edges$/.exec(path))) {
graph.node(r[1])
return json(200, graph.edgesOf(r[1]))
}
if ((r = /^\/api\/nodes\/([^/]+)$/.exec(path)) && m === 'GET') {
const n = graph.node(r[1])
return json(200, { id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version })
}
if ((r = /^\/api\/tree\/([^/]+)$/.exec(path)) && m === 'GET') {
// TreeNode { node, edge, children[] } - one request for the whole page's data
const walk = (id: string, edge?: unknown): unknown => {
const n = graph.node(id)
return { node: { id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version }, edge, children: graph.children(id).map((e) => walk(e.to, e)) }
}
return json(200, walk(r[1]))
}
if (path === '/api/tracking/config' && m === 'GET') return json(200, { experiment_rules: expRules })
if (path === '/api/tracking/evaluate-experiments' && m === 'POST') {
const body = (await req.json()) as { experiments?: { id: string; cookieName?: string }[] }
const cookies = Object.fromEntries((req.headers.get('cookie') ?? '').split(';').map((p) => p.trim().split('=').map((s) => s.trim())).filter((kv) => kv.length === 2)) as Record<string, string>
const assignments = (body.experiments ?? []).map((e) => {
const rule = expRules.find((r) => r.experiment_id === e.id)
const cookieName = e.cookieName || `exp_${e.id}`
if (!rule || rule.disabled) return { experimentId: e.id, cookieName, variant: '', eligible: false }
const arms = Object.keys(rule.variant_weights ?? { a: 1, b: 1 })
const forced = cookies[cookieName]
return { experimentId: e.id, cookieName, variant: forced && arms.includes(forced) ? forced : arms[0], eligible: true }
})
return json(200, { assignments })
}
if (path === '/api/tracking/experiments/analytics' && m === 'GET')
return json(200, [
{
experiment_id: 'exp_hero',
variants: [
{ variant: 'a', sessions: 120, conversions: 12, conversion_rate: 0.1 },
{ variant: 'b', sessions: 130, conversions: 22, conversion_rate: 0.169 },
],
winner: 'b',
winner_probability: 0.87,
significant: true,
conclusion: 'b beats a',
total_sessions: 250,
},
])
const expToggle = /^\/api\/tracking\/config\/experiments\/([^/]+)\/disabled$/.exec(path)
if (expToggle && m === 'PATCH') {
const rule = expRules.find((x) => x.experiment_id === decodeURIComponent(expToggle[1]))
if (!rule) return json(404, { error: 'experiment rule not found' })
const body = (await req.json()) as { disabled?: boolean }
if (typeof body.disabled !== 'boolean') return json(422, { error: 'missing disabled' })
rule.disabled = body.disabled
return json(200, { disabled: rule.disabled })
}
if (path === '/api/edit' && m === 'POST') {
const body = (await req.json()) as { ops: any[] }
const res = graph.edit(body.ops)
const patches = res.touched.map((id) => {
if (graph.parents(id).length === 0) return { id, error: 'root cannot render standalone' } // like a real page node
return { id, html: render(graph, id) }
})
return json(200, { ...res, patches })
}
if (path === '/api/publish-roots' && m === 'POST') {
const body = (await req.json()) as { ids: string[] }
published.push(...body.ids)
return json(200, { publishedAt: new Date().toISOString(), results: body.ids.map((id) => ({ id })) })
}
if (path === '/api/preview' && !previewApi) return json(404, { error: 'no such route' })
if (path === '/api/preview' || (m === 'GET' && req.headers.get('accept')?.includes('text/html'))) {
return new Response(renderPage(graph), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-cms-page': '1' } })
}
return json(404, { error: `mock: no route for ${m} ${path}` })
} catch (e) {
if (e instanceof HttpError) return json(e.status, { error: e.message })
return json(500, { error: (e as Error).message })
}
}
return {
handle,
/** Back to the seed data (also exposed at POST /__mock/reset in dev). */
reset: () => void (graph = seedGraph()),
/** Approve the pending device grant (what the user does on the issuer's /device page). */
approve: () => void (approved = true),
/** Simulate access-token expiry: every issued access token starts returning 401. */
expireAccess: () => access.clear(),
/** Simulate revocation: the refresh token stops working too. */
revoke: () => {
access.clear()
refreshTok = null
},
get graph() {
return graph
},
published,
requests,
/** Simulate a CMS without GET /api/preview (the editor must fall back to the page URL). */
disablePreviewApi: () => void (previewApi = false),
fetch: ((input: RequestInfo | URL, init?: RequestInit) => handle(new Request(new URL(String(input), 'http://mock'), { ...init, signal: undefined }))) as typeof fetch,
}
}