From a8c691924d3940ed04a632e2ae205c5ce414629e Mon Sep 17 00:00:00 2001 From: glass beadian Date: Tue, 13 Oct 2026 19:40:01 +0000 Subject: [PATCH] e2e: S9 governed boost scenario in the governance sim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S9: boost draft (providerMetadata.sourceRemoteRef) → immediate-rule approve → POST /publishing/drafts/:id/amplify { connectionId, kind: repost } → verify the source status reblogged on Mastodon. ONLY=S9 runs it standalone. Gap list updated: boost + Temporal-queue + status-read gaps closed (auto-publish remains a governance-model decision, not a missing route). --- scripts/e2e/brand-palette-check.mjs | 136 ++++++++++++++++++++++++++++ scripts/e2e/governance-sim.mjs | 47 +++++++++- 2 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 scripts/e2e/brand-palette-check.mjs diff --git a/scripts/e2e/brand-palette-check.mjs b/scripts/e2e/brand-palette-check.mjs new file mode 100644 index 0000000..f2aae5c --- /dev/null +++ b/scripts/e2e/brand-palette-check.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Brand palette guard: fails when any computed color on the embedded surfaces +// drifts into the blue family (OKLCH hue 200–300°, chroma > 0.006). +// +// node scripts/e2e/brand-palette-check.mjs +// +// Requires Playwright with Chromium (e.g. `pnpm dlx playwright install +// chromium`); PLAYWRIGHT_PATH can point at an existing node_modules. +// +// Surfaces checked (logged in via a fresh check account): +// /chat — the Element iframe (custom_themes must apply) +// /files/drive — the oCIS web iframe (the theme.json colorPalette must apply) +// +// Suggested wiring: run after every Element/oCIS image bump in CI. + +const PLAYWRIGHT_DIR = process.env.PLAYWRIGHT_PATH ?? ""; +let chromium; +try { + ({ chromium } = await import(`${PLAYWRIGHT_DIR}/playwright/index.mjs`)); +} catch { + ({ chromium } = await import("playwright")); +} + +const API = (process.env.API_URL ?? "https://api.occupymedia.cc").replace(/\/$/, ""); +const USER = `palette-check-${Date.now()}`; +const PASS = `PaletteCheck!${Math.floor(Math.random() * 1e6)}`; + +/** sRGB (0-255) → OKLCH {L, C, H}. */ +function srgbToOklch(r, g, b) { + const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + const R = lin(r), G = lin(g), B = lin(b); + const l = 0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B; + const m = 0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B; + const s = 0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B; + const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s); + const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; + const A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; + const Bc = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; + const C = Math.sqrt(A * A + Bc * Bc); + let H = (Math.atan2(Bc, A) * 180) / Math.PI; + if (H < 0) H += 360; + return { L, C, H }; +} + +function parseColor(value) { + const hex = value.match(/^#([0-9a-f]{6})$/i); + if (hex) { + const v = hex[1]; + return srgbToOklch(parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)); + } + const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + if (rgb) return srgbToOklch(Number(rgb[1]), Number(rgb[2]), Number(rgb[3])); + return null; +} + +/** Blue-family: OKLCH hue 200–300° with visible chroma. */ +function isBlue({ C, H }) { + return C > 0.006 && H >= 200 && H <= 300; +} + +async function collectFrameColors(frame) { + return frame.evaluate(() => { + const seen = new Map(); + for (const node of document.querySelectorAll("*")) { + const cs = getComputedStyle(node); + for (const prop of ["backgroundColor", "color", "borderTopColor", "borderBottomColor"]) { + const value = cs[prop]; + if (value && value !== "rgba(0, 0, 0, 0)" && value !== "transparent") { + seen.set(value, (seen.get(value) ?? 0) + 1); + } + } + } + return [...seen.entries()].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count); + }); +} + +const api = { + async call(method, path, body, token) { + const res = await fetch(`${API}${path}`, { + method, + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(body ? { "content-type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const data = await res.json().catch(() => ({})); + return { status: res.status, data }; + }, +}; + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); +const page = await ctx.newPage(); + +const reg = await api.call("POST", "/auth/register", { + username: USER, password: PASS, displayName: "Palette Guard", +}); +if (reg.status >= 300 && reg.status !== 409) throw new Error(`register failed: ${reg.status}`); +const token = reg.data.token ?? (await api.call("POST", "/auth/login", { username: USER, password: PASS })).data.token; + +const failures = []; + +async function checkSurface(name, url, waitMs) { + await page.goto(url); + await page.waitForTimeout(waitMs); + for (const frame of page.frames()) { + let colors; + try { + colors = await collectFrameColors(frame); + } catch { + continue; // detached or inaccessible frame + } + for (const { value, count } of colors) { + const oklch = parseColor(value); + if (oklch && isBlue(oklch)) { + failures.push({ surface: name, frame: frame.url().slice(0, 60), value, count, oklch }); + } + } + } + console.log(`checked ${name}`); +} + +await checkSurface("/chat", `${API.replace("/api", "https://app.occupymedia.cc")}/chat`.replace("https://app.occupymedia.cc", "https://app.occupymedia.cc"), 16000); +await checkSurface("/files/drive", "https://app.occupymedia.cc/files/drive", 15000); + +if (failures.length > 0) { + console.error("BLUE VIOLATIONS:"); + for (const f of failures) { + console.error(` ${f.surface} | ${f.frame} | ${f.value} ×${f.count} | OKLCH ${f.oklch.L.toFixed(3)} ${f.oklch.C.toFixed(3)} ${f.oklch.H.toFixed(1)}°`); + } + process.exit(1); +} +console.log("PASS — no blue-family colors on any embedded surface"); +await browser.close(); +process.exit(0); diff --git a/scripts/e2e/governance-sim.mjs b/scripts/e2e/governance-sim.mjs index 8095c1f..52879bb 100644 --- a/scripts/e2e/governance-sim.mjs +++ b/scripts/e2e/governance-sim.mjs @@ -403,6 +403,46 @@ async function runImmediate(sourceIdx) { } } +// S9 — the governed boost: approved draft + the amplify route = a real reblog. +async function runBoost(sourceIdx) { + section("S9 — governed boost: approved draft + amplify route executes a reblog", { + why: "POST /publishing/drafts/:draftId/amplify { connectionId, kind: 'repost' }: the governance gate is the draft state (approved only) and the target is providerMetadata.sourceRemoteRef recorded at creation — never a caller-supplied id.", + }); + const source = ctx.sources[sourceIdx % ctx.sources.length]; + const before = await masto(ctx.radar, `/api/v1/statuses/${source.id}`); + const reblogsBefore = before.reblogs_count ?? 0; + note(`source ${source.id} reblogs_before ${reblogsBefore}`); + + // The immediate rule (re-created; runImmediate's finally removes it) makes + // approve fire E1 directly, so the draft reaches 'approved' without a poll. + const rules = (await api("GET", `/chatgov/rules?accountId=${ctx.connectionId}`, { token: ctx.tokens.owner, as: "owner" })).data; + let rule = (Array.isArray(rules) ? rules : []).find((r) => r.scope === "account" && !r.supersededBy); + if (!rule) rule = (await api("POST", "/chatgov/rules", { token: ctx.tokens.owner, as: "owner", body: { scope: "account", accountId: ctx.connectionId, policy: { mode: "immediate" } } })).data; + ctx.ruleId = rule.id; + + const draft = await api("POST", "/publishing/drafts", { + token: ctx.tokens.owner, as: "owner", + body: { post: { text: `[e2e-gov-${RUN}-S9] Boost proposal for ${source.url} #${TAG}`, + mediaAssetIds: [], visibility: "provider_default", + providerMetadata: { connectionId: ctx.connectionId, sourceRemoteRef: source.id } } }, + }); + const draftId = draft.data.id; + note(`boost draft ${draftId}`); + + await api("POST", `/publishing/drafts/${draftId}/submit`, { token: ctx.tokens.owner, as: "owner" }); + const approve = await api("POST", `/publishing/drafts/${draftId}/approve`, { token: ctx.tokens.owner, as: "owner", ok: false }); + check("approve succeeds (immediate rule)", true, approve.status < 300, approve.data); + + const amp = await api("POST", `/publishing/drafts/${draftId}/amplify`, { + token: ctx.tokens.owner, as: "owner", body: { connectionId: ctx.connectionId, kind: "repost" }, + }); + check("amplify accepted (200)", 200, amp.status, amp.data); + + const after = await masto(ctx.radar, `/api/v1/statuses/${source.id}`); + check("source reblogged", true, after.reblogged === true || (after.reblogs_count ?? 0) > reblogsBefore, after); + note(`reblogs ${reblogsBefore} → ${after.reblogs_count ?? "?"}`); +} + function writeReport(fatal) { const lines = []; const scen = sections.filter((s) => /^S\d/.test(s.name)); @@ -439,10 +479,7 @@ function writeReport(fatal) { log(`report written to ${REPORT}`); } -gap("Governed Mastodon boost/reblog: drafts only dispatch via connector.publish (status, optional providerMetadata.inReplyToId). connector-mastodon has amplify(repost) but no API route reaches it for Mastodon (POST /archive/items/:id/amplify is Telegram-only). Scenarios therefore execute a reply to the source post."); -gap("No auto-dispatch on poll pass: E2 moves the draft to 'approved' only; execution needs a separate POST /publishing/drafts/:id/publish by a publication.publish holder."); -gap("LIVE DEPLOY: publish workflows are started on Temporal task queue 'tmbh-publishing' (current API default, TEMPORAL_TASK_QUEUE unset) but the deployed scheduler worker (image built 2026-10-10) polls 'superposter-publishing'. Workflows sit Running with an unclaimed first task and scheduled_publications stay 'pending' — approved actions never reach Mastodon. Fix: set TEMPORAL_TASK_QUEUE identically on api+scheduler, or redeploy the scheduler from current source."); -gap("No read route for scheduled_publications / publication receipts (remote status id); execution is verified on the Mastodon side via the radar token."); +gap("No auto-dispatch on poll pass: E2 moves the draft to 'approved' only; execution needs a separate POST /publishing/drafts/:id/publish (or /amplify) by a publication.schedule holder. Whether a passing poll should auto-execute is a governance-model decision (execute-on-vote vs editor-release), not a missing route."); deviations.push("Connection created through the real OAuth authorization-code flow (radar web sign-in + consent), not with {instanceUrl, access_token} — no such route exists."); deviations.push("Invited members join as 'Editor' (an existing role holding chatgov.vote per migration 024); u1–u3 are then PATCHed to 'Admin' via /team/members/:membershipId/role."); deviations.push("Polls are opened explicitly (POST /chatgov/polls with draftId + supplied rule, kind yes_no) because the workspace has no posting_rules; with no live rules draftPollRule() accepts the supplied rule."); @@ -465,6 +502,8 @@ try { } if (!only || only.has("S7")) try { await runImmediate(i++); } catch (e) { check("scenario completed without error", "ok", String(e.message), e.data); } + if (!only || only.has("S9")) try { await runBoost(i++); } + catch (e) { check("scenario completed without error", "ok", String(e.message), e.data); } } catch (e) { fatal = e.message; console.error(e);