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>
196 lines
11 KiB
TypeScript
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,
|
|
}
|
|
}
|