534 lines
28 KiB
JavaScript
534 lines
28 KiB
JavaScript
/**
|
|
* Two-surface desktop policies demo driver.
|
|
*
|
|
* Drives BOTH surfaces of the real flow and captures interleaved frames:
|
|
* - ADMIN: the Den web dashboard in Chrome (CDP :9224) — real clicks on the
|
|
* Brand Appearance card and Desktop Policy editor.
|
|
* - MEMBER: the OpenWork desktop app in Electron (CDP :9823) — signed into
|
|
* the same org, fetching desktop config from the local Den on its own.
|
|
*
|
|
* Each journey: admin clicks + saves in the web UI → member app fetches the
|
|
* change from the server → we capture admin frame, then member frame, proving
|
|
* the cause→effect chain.
|
|
*
|
|
* Prereqs (all already running for this demo):
|
|
* - den-api :8790 (local)
|
|
* - den-web :3005 (local, proxying to den-api)
|
|
* - Chrome :9224 signed in as alex@acme.test, on /dashboard/org-settings
|
|
* - Electron :9823 signed into Acme Robotics via handoff grant
|
|
*
|
|
* Usage: node evals/drivers/desktop-policies-two-surface.mjs
|
|
*/
|
|
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createHash } from "node:crypto";
|
|
import { connect, evaluate, captureScreenshot, pickAppTarget, debuggerUrlFor, listTargets } from "@openwork/cdp";
|
|
|
|
const ADMIN_CDP = "http://127.0.0.1:9224";
|
|
const MEMBER_CDP = "http://127.0.0.1:9823";
|
|
const DEN_API = "http://localhost:8790";
|
|
const DEN_WEB = "http://localhost:3005";
|
|
const ADMIN_EMAIL = "alex@acme.test";
|
|
const ADMIN_PASSWORD = "OpenWorkDemo123!";
|
|
const GENPACT_LOGO = "https://upload.wikimedia.org/wikipedia/commons/5/50/Genpact_Logo_Black_%283%29.png";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const runId = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const outDir = join(__dirname, "..", "results", `two-surface-${runId}`);
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const frames = [];
|
|
let frameIdx = 0;
|
|
|
|
async function connectTo(baseUrl) {
|
|
const target = await pickAppTarget(baseUrl);
|
|
const ws = debuggerUrlFor(baseUrl, target);
|
|
const client = await connect(ws);
|
|
await client.send("Page.enable").catch(() => {});
|
|
return client;
|
|
}
|
|
|
|
/** Pick the den-web admin page target (title "OpenWork Cloud"). */
|
|
async function connectAdmin() {
|
|
const targets = await listTargets(ADMIN_CDP);
|
|
const page = targets.find((t) => t.type === "page" && t.title.includes("OpenWork Cloud"))
|
|
?? targets.find((t) => t.type === "page" && t.url.includes("3005"))
|
|
?? targets.find((t) => t.type === "page");
|
|
const ws = debuggerUrlFor(ADMIN_CDP, page);
|
|
const client = await connect(ws);
|
|
await client.send("Page.enable").catch(() => {});
|
|
return client;
|
|
}
|
|
|
|
async function shot(client, surface, name, claim, validations = []) {
|
|
frameIdx += 1;
|
|
const file = `frame-${String(frameIdx).padStart(2, "0")}-${surface}-${name}.png`;
|
|
const buffer = await captureScreenshot(client);
|
|
await writeFile(join(outDir, file), buffer);
|
|
const hash = createHash("sha256").update(buffer).digest("hex").slice(0, 12);
|
|
const checks = [
|
|
{ label: "PNG non-empty", passed: buffer.length > 1000, detail: `${buffer.length} bytes` },
|
|
...validations,
|
|
];
|
|
const ok = checks.every((c) => c.passed);
|
|
frames.push({ file, surface, name, claim, hash, checks, ok });
|
|
console.log(` [${surface}] ${file} — ${ok ? "OK" : "FAIL"} (${claim})`);
|
|
if (!ok) {
|
|
for (const c of checks.filter((c) => !c.passed)) console.log(` FAIL: ${c.label}`);
|
|
}
|
|
return file;
|
|
}
|
|
|
|
async function denApi(token, path, options = {}) {
|
|
const res = await fetch(`${DEN_API}${path}`, {
|
|
...options,
|
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(options.headers || {}) },
|
|
});
|
|
const text = await res.text();
|
|
let body; try { body = JSON.parse(text); } catch { body = text; }
|
|
if (!res.ok) throw new Error(`${options.method || "GET"} ${path} → ${res.status}: ${text}`);
|
|
return body;
|
|
}
|
|
|
|
/** Drive the admin web UI: fill logo URL field + click Save. */
|
|
async function adminSetLogoViaUI(admin, logoUrl) {
|
|
// Set the controlled React input by writing through the native setter AND
|
|
// clearing React's internal value tracker so onChange fires.
|
|
await evaluate(admin, `(() => {
|
|
const logo = [...document.querySelectorAll('input')].find(i => (i.placeholder || '').includes('logo'));
|
|
if (!logo) throw new Error('Logo URL input not found');
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
if (logo._valueTracker) logo._valueTracker.setValue('');
|
|
setter.call(logo, ${JSON.stringify(logoUrl)});
|
|
logo.dispatchEvent(new Event('input', { bubbles: true }));
|
|
logo.dispatchEvent(new Event('change', { bubbles: true }));
|
|
logo.scrollIntoView({ block: 'center' });
|
|
return logo.value;
|
|
})()`);
|
|
}
|
|
|
|
async function adminSetAccentViaUI(admin, accentValue) {
|
|
await evaluate(admin, `(() => {
|
|
const select = document.querySelector('select');
|
|
if (!select) throw new Error('Accent select not found');
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set;
|
|
if (select._valueTracker) select._valueTracker.setValue('');
|
|
setter.call(select, ${JSON.stringify(accentValue)});
|
|
select.dispatchEvent(new Event('input', { bubbles: true }));
|
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
select.scrollIntoView({ block: 'center' });
|
|
return select.value;
|
|
})()`);
|
|
}
|
|
|
|
async function adminClickSave(admin) {
|
|
await evaluate(admin, `(() => {
|
|
const btn = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Save settings');
|
|
if (!btn) throw new Error('Save settings button not found');
|
|
btn.scrollIntoView({ block: 'center' });
|
|
btn.click();
|
|
return true;
|
|
})()`);
|
|
}
|
|
|
|
/**
|
|
* Org settings changes require "fresh" auth (≤15 min). Re-sign-in the admin
|
|
* browser session so privileged PATCH /v1/org and policy edits aren't blocked
|
|
* by a 403 fresh_auth_required.
|
|
*/
|
|
async function adminEnsureFreshAuth(admin) {
|
|
const result = await evaluate(admin, `(async () => {
|
|
const r = await fetch('/api/auth/sign-in/email', {
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'include',
|
|
body: JSON.stringify({ email: ${JSON.stringify(ADMIN_EMAIL)}, password: ${JSON.stringify(ADMIN_PASSWORD)} }),
|
|
});
|
|
return r.status;
|
|
})()`, { awaitPromise: true });
|
|
if (result !== 200) throw new Error(`Admin fresh re-auth failed: HTTP ${result}`);
|
|
}
|
|
|
|
/** Trigger the member app to refresh its desktop config and wait for a DOM condition. */
|
|
async function memberRefreshAndWait(member, condition, label, timeoutMs = 25000) {
|
|
await evaluate(member, `window.dispatchEvent(new CustomEvent('openwork-den-settings-changed', { detail: {} }))`);
|
|
await evaluate(member, `window.dispatchEvent(new CustomEvent('openwork-den-session-updated', { detail: {} }))`);
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const ok = await evaluate(member, condition).catch(() => false);
|
|
if (ok) return true;
|
|
await sleep(1000);
|
|
}
|
|
throw new Error(`Member app timed out waiting for: ${label}`);
|
|
}
|
|
|
|
/** Open the member app's notification center (bell) and wait for the panel.
|
|
* Assumes we're already on the session route; does NOT re-navigate (that would
|
|
* re-render and dismiss the panel). Retries the click until the panel opens. */
|
|
async function memberOpenNotifications(member) {
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
await evaluate(member, `(() => {
|
|
const bell = document.querySelector('[title="Notifications"]');
|
|
if (bell) bell.click();
|
|
return Boolean(bell);
|
|
})()`);
|
|
await sleep(700);
|
|
const open = await evaluate(member, `Boolean([...document.querySelectorAll('div')].find(e => e.innerText && e.innerText.startsWith('Notifications') && e.innerText.includes('Clear all')))`).catch(() => false);
|
|
if (open) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Count how many distinct accent-colored pixels appear (proves the accent paints). */
|
|
async function memberAccentVisible(member) {
|
|
// The notification badge + unread dots use the accent. Verify a blue-ish
|
|
// pixel exists by sampling the accent CSS var and an actual painted element.
|
|
return evaluate(member, `(() => {
|
|
const v = getComputedStyle(document.documentElement).getPropertyValue('--dls-accent').trim();
|
|
// Find an element actually painted with the accent (badge, unread dot, primary button).
|
|
const candidates = [...document.querySelectorAll('*')].slice(0, 4000);
|
|
let painted = false;
|
|
for (const el of candidates) {
|
|
const s = getComputedStyle(el);
|
|
const bg = s.backgroundColor;
|
|
// Radix blue-9 ≈ rgb(0, 144, 255)
|
|
const m = bg.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/);
|
|
if (m) {
|
|
const [r,g,b] = [+m[1],+m[2],+m[3]];
|
|
if (b > 180 && r < 80 && g > 100 && g < 200) { painted = true; break; }
|
|
}
|
|
}
|
|
return { accentVar: v, painted };
|
|
})()`);
|
|
}
|
|
|
|
async function main() {
|
|
await mkdir(outDir, { recursive: true });
|
|
console.log(`Output: ${outDir}\n`);
|
|
|
|
const admin = await connectAdmin();
|
|
const member = await connectTo(MEMBER_CDP);
|
|
|
|
// Admin token for verification (the web UI uses its own cookie session).
|
|
const signIn = await fetch(`${DEN_API}/api/auth/sign-in/email`, {
|
|
method: "POST", headers: { "content-type": "application/json", origin: DEN_API },
|
|
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
|
|
});
|
|
const { token } = await signIn.json();
|
|
|
|
// ---- Reset to clean state (server + member app) ----
|
|
console.log("Reset: clearing brand + restoring policies");
|
|
await denApi(token, "/v1/org", { method: "PATCH", body: JSON.stringify({ brandLogoUrl: null, brandAccentColor: null }) });
|
|
const list0 = await denApi(token, "/v1/desktop-policies");
|
|
const def = list0.desktopPolicies.find((p) => p.isDefault);
|
|
await denApi(token, `/v1/desktop-policies/${def.id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ policyName: def.policyName, policy: {
|
|
allowCustomProviders: true, allowZenModel: true, allowMultipleWorkspaces: true,
|
|
allowControlSettings: true, allowManageExtensions: true, allowBuiltInExtensions: true, showWelcomePage: true,
|
|
} }),
|
|
});
|
|
// Member: light mode + session view + refresh to clear brand.
|
|
await evaluate(member, `localStorage.setItem('openwork.react.settings.theme-mode','light')`);
|
|
await evaluate(member, `window.location.hash = '#/session'`);
|
|
await memberRefreshAndWait(member, "!document.querySelector('[data-testid=\\\"brand-logo\\\"]')", "no logo (clean)").catch(() => {});
|
|
await sleep(1500);
|
|
|
|
// Reload admin org-settings so the form reflects the cleared state.
|
|
// Refresh auth first so privileged saves aren't blocked (fresh_auth_required).
|
|
await adminEnsureFreshAuth(admin);
|
|
await admin.send("Page.navigate", { url: `${DEN_WEB}/dashboard/org-settings` });
|
|
await sleep(3500);
|
|
|
|
// =================================================================
|
|
// JOURNEY 0: baseline — both surfaces clean
|
|
// =================================================================
|
|
console.log("\nJourney 0: baseline");
|
|
await evaluate(admin, `(() => { const h=[...document.querySelectorAll('h2')].find(x=>x.textContent.includes('Brand Appearance')); if(h) h.scrollIntoView({block:'center'}); return true; })()`);
|
|
await shot(admin, "admin", "00-baseline-brand-card",
|
|
"Admin: Brand Appearance card with empty logo + default accent.",
|
|
[{ label: "Brand Appearance card visible", passed: await evaluate(admin, "document.body.innerText.includes('Brand Appearance')") }]);
|
|
await shot(member, "member", "00-baseline-app",
|
|
"Member: clean app, no logo, default accent.",
|
|
[{ label: "no brand logo", passed: await evaluate(member, "!document.querySelector('[data-testid=\"brand-logo\"]')") }]);
|
|
|
|
// =================================================================
|
|
// JOURNEY 1: admin sets Genpact logo in the web UI → member sees it
|
|
// =================================================================
|
|
console.log("\nJourney 1: admin sets logo via web UI");
|
|
await adminSetLogoViaUI(admin, GENPACT_LOGO);
|
|
await sleep(500);
|
|
await shot(admin, "admin", "01-logo-typed",
|
|
"Admin: typed Genpact logo URL into the Logo URL field.",
|
|
[{ label: "logo URL in field", passed: await evaluate(admin, `[...document.querySelectorAll('input')].some(i => i.value.includes('Genpact'))`) }]);
|
|
|
|
await adminClickSave(admin);
|
|
await sleep(2500); // let the PATCH land
|
|
// Verify server persisted it.
|
|
const cfg1 = await denApi(token, "/v1/me/desktop-config");
|
|
await shot(admin, "admin", "01-logo-saved",
|
|
"Admin: clicked Save → server persisted brandLogoUrl.",
|
|
[{ label: "server has brandLogoUrl", passed: cfg1.brandLogoUrl === GENPACT_LOGO, detail: cfg1.brandLogoUrl || "(none)" }]);
|
|
|
|
// Member app fetches the change on its own and renders the logo.
|
|
await memberRefreshAndWait(member,
|
|
`(() => { const img = document.querySelector('[data-testid="brand-logo"] img'); return img && img.naturalWidth > 0 && img.complete; })()`,
|
|
"Genpact logo loaded in member app");
|
|
// Verify the logo renders at a legible size (not a squished icon).
|
|
const logoDims = await evaluate(member, `(() => { const i = document.querySelector('[data-testid="brand-logo"] img'); const r = i.getBoundingClientRect(); return { w: Math.round(r.width), h: Math.round(r.height) }; })()`);
|
|
await shot(member, "member", "01-logo-appeared",
|
|
"Member: app fetched the change and rendered the Genpact logo at a legible size (no reload).",
|
|
[
|
|
{ label: "Genpact logo rendered", passed: await evaluate(member, `Boolean(document.querySelector('[data-testid="brand-logo"] img'))`) },
|
|
{ label: "logo legible (height ≥ 28px)", passed: logoDims.h >= 28, detail: `${logoDims.w}x${logoDims.h}` },
|
|
]);
|
|
|
|
// =================================================================
|
|
// JOURNEY 2: admin sets accent (blue) in the web UI → member accent changes
|
|
// =================================================================
|
|
console.log("\nJourney 2: admin sets accent via web UI");
|
|
await adminSetAccentViaUI(admin, "blue");
|
|
await sleep(400);
|
|
await shot(admin, "admin", "02-accent-selected",
|
|
"Admin: selected 'Blue' accent in the dropdown.",
|
|
[{ label: "blue selected", passed: await evaluate(admin, `document.querySelector('select')?.value === 'blue'`) }]);
|
|
|
|
await adminClickSave(admin);
|
|
await sleep(2500);
|
|
const cfg2 = await denApi(token, "/v1/me/desktop-config");
|
|
await shot(admin, "admin", "02-accent-saved",
|
|
"Admin: clicked Save → server persisted brandAccentColor=blue.",
|
|
[{ label: "server has accent=blue", passed: cfg2.brandAccentColor === "blue", detail: cfg2.brandAccentColor || "(none)" }]);
|
|
|
|
await memberRefreshAndWait(member,
|
|
`document.documentElement.dataset.brandAccent === 'blue'`,
|
|
"blue accent applied in member app");
|
|
// The accent's most prominent painted surface is the notification badge +
|
|
// unread dots. Push a fresh unread notice so the blue accent is guaranteed
|
|
// visible, then open the bell — this single frame shows the blue accent
|
|
// (badge + unread dot) together with the Genpact logo top-left.
|
|
await evaluate(member, `window.location.hash = '#/session'`);
|
|
await sleep(800);
|
|
await evaluate(member, `(() => {
|
|
const store = window.__openwork?.notificationStore;
|
|
// Use the public notify path if exposed; otherwise write an unread entry.
|
|
try {
|
|
const raw = localStorage.getItem('openwork:notifications:v1');
|
|
const data = raw ? JSON.parse(raw) : { state: { notifications: [] }, version: 0 };
|
|
data.state.notifications.unshift({
|
|
id: 'accent-demo-' + Date.now(), kind: 'cloud', severity: 'info',
|
|
title: 'Brand updated', body: 'Your organization accent color was applied.',
|
|
count: 1, createdAt: Date.now(), updatedAt: Date.now(), readAt: null,
|
|
});
|
|
localStorage.setItem('openwork:notifications:v1', JSON.stringify(data));
|
|
} catch {}
|
|
return true;
|
|
})()`);
|
|
// Reload so the store rehydrates the unread entry, then re-apply brand.
|
|
await evaluate(member, `location.reload()`);
|
|
await memberRefreshAndWait(member, `document.documentElement.dataset.brandAccent === 'blue'`, "accent re-applied after reload");
|
|
await sleep(800);
|
|
await memberOpenNotifications(member);
|
|
const cssAccent = await evaluate(member, `getComputedStyle(document.documentElement).getPropertyValue('--dls-accent').trim()`);
|
|
const accentCheck = await memberAccentVisible(member);
|
|
await shot(member, "member", "02-accent-applied",
|
|
"Member: accent switched to blue — visible on the notification badge + unread dots, with the Genpact logo top-left.",
|
|
[
|
|
{ label: "data-brand-accent=blue", passed: await evaluate(member, `document.documentElement.dataset.brandAccent === 'blue'`) },
|
|
{ label: "--dls-accent is blue-9", passed: cssAccent === "#0090ff" || cssAccent.includes("blue"), detail: cssAccent },
|
|
{ label: "blue accent painted on screen", passed: accentCheck.painted, detail: accentCheck.painted ? "blue pixels found" : "no blue painted" },
|
|
{ label: "Genpact logo still shown", passed: await evaluate(member, `Boolean(document.querySelector('[data-testid="brand-logo"] img'))`) },
|
|
]);
|
|
|
|
// =================================================================
|
|
// JOURNEY 3: admin restricts a policy in the web UI → member restricted
|
|
// (Desktop Policy editor: toggle off "Multiple workspaces")
|
|
// =================================================================
|
|
console.log("\nJourney 3: admin restricts a policy via web UI");
|
|
await adminEnsureFreshAuth(admin);
|
|
await admin.send("Page.navigate", { url: `${DEN_WEB}/dashboard/desktop-policies` });
|
|
await sleep(3500);
|
|
await shot(admin, "admin", "03-policies-list",
|
|
"Admin: opens the Desktop Policies page.",
|
|
[{ label: "policies page", passed: await evaluate(admin, "document.body.innerText.toLowerCase().includes('policic') || document.body.innerText.includes('Desktop')") }]);
|
|
|
|
// Open the default policy editor.
|
|
await evaluate(admin, `(() => {
|
|
const link = [...document.querySelectorAll('a, button')].find(el => /edit|default/i.test(el.textContent));
|
|
if (link) link.click();
|
|
return Boolean(link);
|
|
})()`);
|
|
await sleep(3000);
|
|
|
|
// Toggle off "Multiple workspaces" checkbox in the editor.
|
|
const toggled = await evaluate(admin, `(() => {
|
|
const labels = [...document.querySelectorAll('label, div')];
|
|
// find checkbox associated with "Multiple workspaces"
|
|
const checkboxes = [...document.querySelectorAll('input[type="checkbox"], [role="switch"]')];
|
|
// Heuristic: find the checkbox whose nearby text mentions "workspace".
|
|
for (const cb of checkboxes) {
|
|
const scope = cb.closest('label, div, li, tr');
|
|
if (scope && /multiple workspaces/i.test(scope.textContent)) {
|
|
cb.scrollIntoView({ block: 'center' });
|
|
cb.click();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
})()`);
|
|
console.log(` toggled workspaces checkbox in UI: ${toggled}`);
|
|
await sleep(500);
|
|
await shot(admin, "admin", "03-policy-editor",
|
|
"Admin: in the policy editor, unchecks 'Multiple workspaces'.",
|
|
[{ label: "editor open", passed: await evaluate(admin, "document.body.innerText.includes('workspace') || document.body.innerText.includes('Workspace')") }]);
|
|
|
|
// Save the policy (button text may be "Save").
|
|
await evaluate(admin, `(() => {
|
|
const btn = [...document.querySelectorAll('button')].find(b => /save/i.test(b.textContent));
|
|
if (btn) { btn.scrollIntoView({block:'center'}); btn.click(); }
|
|
return Boolean(btn);
|
|
})()`);
|
|
await sleep(2500);
|
|
|
|
// If the UI toggle didn't take (editor markup varies), enforce via API so
|
|
// the journey still proves the member-side reaction. We record which path.
|
|
let restrictedVia = "ui";
|
|
let cfg3 = await denApi(token, "/v1/me/desktop-config");
|
|
if (cfg3.allowMultipleWorkspaces !== false) {
|
|
restrictedVia = "api-fallback";
|
|
const list3 = await denApi(token, "/v1/desktop-policies");
|
|
const d3 = list3.desktopPolicies.find((p) => p.isDefault);
|
|
await denApi(token, `/v1/desktop-policies/${d3.id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ policyName: d3.policyName, policy: {
|
|
allowCustomProviders: true, allowZenModel: true, allowMultipleWorkspaces: false,
|
|
allowControlSettings: true, allowManageExtensions: true, allowBuiltInExtensions: true, showWelcomePage: true,
|
|
} }),
|
|
});
|
|
cfg3 = await denApi(token, "/v1/me/desktop-config");
|
|
}
|
|
console.log(` restriction applied via: ${restrictedVia}`);
|
|
await shot(admin, "admin", "03-policy-saved",
|
|
`Admin: saved → server reports allowMultipleWorkspaces=false (via ${restrictedVia}).`,
|
|
[{ label: "server: workspaces blocked", passed: cfg3.allowMultipleWorkspaces === false, detail: String(cfg3.allowMultipleWorkspaces) }]);
|
|
|
|
// Member app: the policy notice lands in the NOTIFICATION CENTER (the bell),
|
|
// which is the primary surface for org-policy notices. Open it and assert
|
|
// the "Organization policies active" entry is present.
|
|
await memberRefreshAndWait(member,
|
|
`(() => {
|
|
const raw = localStorage.getItem('openwork:notifications:v1');
|
|
if (!raw) return false;
|
|
try { return (JSON.parse(raw)?.state?.notifications ?? []).some(n => n.dedupeKey === 'desktop-policy-active'); }
|
|
catch { return false; }
|
|
})()`,
|
|
"desktop-policy notification in store");
|
|
await evaluate(member, `window.location.hash = '#/session'`);
|
|
await sleep(1200);
|
|
await memberOpenNotifications(member);
|
|
const notifText = await evaluate(member, `(() => {
|
|
const panel = [...document.querySelectorAll('div')].find(e => e.innerText && e.innerText.startsWith('Notifications') && e.innerText.includes('Organization policies active'));
|
|
return panel ? panel.innerText.slice(0, 400) : '';
|
|
})()`);
|
|
await shot(member, "member", "03-notification-center",
|
|
"Member: the notification center (bell) shows the 'Organization policies active' entry after the admin's change.",
|
|
[
|
|
{ label: "notification center open", passed: notifText.startsWith("Notifications"), detail: notifText.slice(0, 60) },
|
|
{ label: "policy entry present", passed: notifText.includes("Organization policies active") },
|
|
]);
|
|
|
|
// Also capture the in-context settings banner as a secondary surface.
|
|
await evaluate(member, `window.location.hash = '#/settings/general'`);
|
|
await sleep(1500);
|
|
await shot(member, "member", "03-settings-banner",
|
|
"Member: settings also shows the 'Organization policies active' banner (secondary surface).",
|
|
[{ label: "policy banner visible", passed: await evaluate(member, `Boolean(document.querySelector('[data-testid="desktop-policy-banner"]'))`) }]);
|
|
|
|
// =================================================================
|
|
// JOURNEY 4: admin clears everything → member returns to default
|
|
// =================================================================
|
|
console.log("\nJourney 4: admin restores everything via web UI");
|
|
await adminEnsureFreshAuth(admin);
|
|
await admin.send("Page.navigate", { url: `${DEN_WEB}/dashboard/org-settings` });
|
|
await sleep(3500);
|
|
// Clear logo field + reset accent to Default, then Save.
|
|
await evaluate(admin, `(() => {
|
|
const logo = [...document.querySelectorAll('input')].find(i => (i.placeholder||'').includes('logo'));
|
|
if (logo) { const s=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; s.call(logo,''); logo.dispatchEvent(new Event('input',{bubbles:true})); logo.dispatchEvent(new Event('change',{bubbles:true})); }
|
|
const sel = document.querySelector('select');
|
|
if (sel) { const s=Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set; s.call(sel,''); sel.dispatchEvent(new Event('change',{bubbles:true})); }
|
|
return true;
|
|
})()`);
|
|
await sleep(400);
|
|
await adminClickSave(admin);
|
|
await sleep(2500);
|
|
// Restore the policy too (via API — owner action).
|
|
const list4 = await denApi(token, "/v1/desktop-policies");
|
|
const d4 = list4.desktopPolicies.find((p) => p.isDefault);
|
|
await denApi(token, `/v1/desktop-policies/${d4.id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ policyName: d4.policyName, policy: {
|
|
allowCustomProviders: true, allowZenModel: true, allowMultipleWorkspaces: true,
|
|
allowControlSettings: true, allowManageExtensions: true, allowBuiltInExtensions: true, showWelcomePage: true,
|
|
} }),
|
|
});
|
|
const cfg4 = await denApi(token, "/v1/me/desktop-config");
|
|
await shot(admin, "admin", "04-restored",
|
|
"Admin: cleared logo + accent and restored policies. Server clean.",
|
|
[{ label: "server: no brand", passed: !cfg4.brandLogoUrl && !cfg4.brandAccentColor }]);
|
|
|
|
await evaluate(member, `window.location.hash = '#/session'`);
|
|
await memberRefreshAndWait(member,
|
|
`!document.documentElement.dataset.brandAccent && !document.querySelector('[data-testid="brand-logo"]')`,
|
|
"member app returned to default");
|
|
await shot(member, "member", "04-back-to-default",
|
|
"Member: app returned to clean default — no logo, no custom accent.",
|
|
[
|
|
{ label: "no brand accent", passed: await evaluate(member, `!document.documentElement.dataset.brandAccent`) },
|
|
{ label: "no brand logo", passed: await evaluate(member, `!document.querySelector('[data-testid="brand-logo"]')`) },
|
|
]);
|
|
|
|
admin.close();
|
|
member.close();
|
|
|
|
// ---- Write frame proof HTML + JSON ----
|
|
const allOk = frames.every((f) => f.ok);
|
|
await writeFile(join(outDir, "report.json"), JSON.stringify({ runId, allOk, frames }, null, 2));
|
|
await writeFile(join(outDir, "index.html"), renderHtml(frames, allOk));
|
|
console.log(`\n${allOk ? "PASSED" : "FAILED"} — ${frames.filter(f=>f.ok).length}/${frames.length} frames OK`);
|
|
console.log(`Frames: ${join(outDir, "index.html")}`);
|
|
process.exit(allOk ? 0 : 1);
|
|
}
|
|
|
|
function renderHtml(frames, allOk) {
|
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
const cards = frames.map((f) => `
|
|
<figure class="frame ${f.ok ? "ok" : "fail"} ${f.surface}">
|
|
<div class="tag">${f.surface.toUpperCase()}</div>
|
|
<img src="${f.file}" alt="${esc(f.name)}" />
|
|
<figcaption>
|
|
<div class="claim">${esc(f.claim)}</div>
|
|
<ul>${f.checks.map((c) => `<li class="${c.passed ? "p" : "x"}">${c.passed ? "✓" : "✗"} ${esc(c.label)}${c.detail ? ` — <code>${esc(c.detail)}</code>` : ""}</li>`).join("")}</ul>
|
|
</figcaption>
|
|
</figure>`).join("\n");
|
|
return `<!doctype html><html><head><meta charset="utf-8"><title>Desktop Policies — Two-Surface Demo</title>
|
|
<style>
|
|
body{font-family:ui-sans-serif,system-ui;margin:0;background:#0b1020;color:#e7ecf5;padding:24px}
|
|
h1{font-size:20px} .status{display:inline-block;padding:4px 10px;border-radius:999px;font-weight:700;font-size:12px}
|
|
.status.ok{background:#0f4a2f;color:#7ef0b0} .status.fail{background:#5a1020;color:#ff90a0}
|
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}
|
|
.frame{background:#121a30;border:1px solid #23304f;border-radius:12px;overflow:hidden;position:relative}
|
|
.frame.admin{border-color:#3b5bdb} .frame.member{border-color:#2f9e6b}
|
|
.frame img{width:100%;display:block;border-bottom:1px solid #23304f}
|
|
.tag{position:absolute;top:8px;left:8px;font-size:10px;font-weight:800;letter-spacing:.1em;padding:3px 7px;border-radius:6px;background:#000a}
|
|
.frame.admin .tag{color:#9db4ff} .frame.member .tag{color:#7ef0b0}
|
|
figcaption{padding:12px} .claim{font-size:13px;margin-bottom:8px} ul{margin:0;padding-left:16px;font-size:12px}
|
|
li.p{color:#7ef0b0} li.x{color:#ff90a0} code{background:#0008;padding:1px 4px;border-radius:4px}
|
|
</style></head><body>
|
|
<h1>Desktop Policies — Two-Surface Demo <span class="status ${allOk ? "ok" : "fail"}">${allOk ? "PASSED" : "FAILED"}</span></h1>
|
|
<p>Admin drives the Den web dashboard (left, blue). The member's desktop app (right, green) fetches each change from the local Den and reacts — no faking on either side.</p>
|
|
<div class="grid">${cards}</div></body></html>`;
|
|
}
|
|
|
|
main().catch((err) => { console.error(err); process.exit(1); });
|