import assert from "node:assert/strict"; import { after, afterEach, before, mock, test } from "node:test"; import { JSDOM } from "jsdom"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { LifecycleActivity } from "./LifecycleActivity.tsx"; import { buildTranscript } from "../agentSessionTranscript.ts"; // --------------------------------------------------------------------------- // Shared fixtures // --------------------------------------------------------------------------- const BASE_PROPS = { agentAvatarUrl: null, agentName: "Test Agent", agentPubkey: "pubkey123", }; const BASE_IDENTITY = { turnId: "turn-1", sessionId: "session-1", channelId: "channel-1", }; /** * Build a pending permission lifecycle item with the given options array. * The card is actionable (awaiting a user decision) and has a request nonce. */ function pendingPermissionItem(options) { return { id: "perm-1", type: "lifecycle", renderClass: "permission", title: "Tool requires approval", text: "Run shell command", timestamp: "2026-08-10T00:00:00.000Z", requestNonce: "nonce-abc", actionable: true, options, ...BASE_IDENTITY, }; } // --------------------------------------------------------------------------- // jsdom + fake-timer setup (required for the interactive delivery-seam tests) // The static renderToStaticMarkup tests do not use document/window but the // setup is harmless for them: it only assigns globals they never read. // --------------------------------------------------------------------------- const dom = new JSDOM("", { url: "http://localhost", }); // Deterministic wall-clock epoch — far from real time to avoid expiry surprises. // expiresAt is set to FAKE_NOW_SECS + 9_999_999 in the interactive tests so // the card never expires during the test. const FAKE_NOW_MS = 1_000_000_000_000; before(() => { mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS }); Object.assign(globalThis, { document: dom.window.document, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true, window: dom.window, MutationObserver: class { observe() {} disconnect() {} takeRecords() { return []; } }, ResizeObserver: class { observe() {} unobserve() {} disconnect() {} }, }); dom.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {}, }); dom.window.MutationObserver = globalThis.MutationObserver; dom.window.ResizeObserver = globalThis.ResizeObserver; }); afterEach(async () => { const { cleanup } = await import("@testing-library/react"); cleanup(); mock.timers.reset(); mock.timers.enable({ apis: ["setInterval", "Date"], now: FAKE_NOW_MS }); }); after(() => { mock.timers.reset(); dom.window.close(); }); test("test_allow_once_renders_actionable_allow_button", () => { const html = renderToStaticMarkup( React.createElement(LifecycleActivity, { ...BASE_PROPS, item: pendingPermissionItem([ { optionId: "opt-allow", kind: "allow_once", label: "Allow once" }, ]), }), ); // The button must be present and labelled correctly. assert.ok( html.includes("permission-decision-opt-allow"), "allow_once option should render a button with its optionId testid", ); assert.ok( html.includes("Allow once"), "allow_once option should show its label", ); // The persistent-grant badge must NOT appear for a pure allow_once card. assert.ok( !html.includes("permission-decision-persistent-grant"), "allow_once card should not render the persistent-grant badge", ); }); // --------------------------------------------------------------------------- // reject_once — renders a red actionable Deny button // --------------------------------------------------------------------------- test("test_reject_once_renders_actionable_deny_button", () => { const html = renderToStaticMarkup( React.createElement(LifecycleActivity, { ...BASE_PROPS, item: pendingPermissionItem([ { optionId: "opt-deny", kind: "reject_once" }, ]), }), ); assert.ok( html.includes("permission-decision-opt-deny"), "reject_once option should render a button with its optionId testid", ); // Deny button uses destructive styling; verify at least the testid is there. assert.ok( !html.includes("permission-decision-persistent-grant"), "reject_once card should not render the persistent-grant badge", ); }); // --------------------------------------------------------------------------- // allow_always — not actionable, no badge (F3: persistent-grant badge removed) // --------------------------------------------------------------------------- test("test_allow_always_renders_no_button_and_no_badge", () => { // After F3: allow_always is NOT in ACTIONABLE_KINDS and the persistent-grant // badge has been removed. A card with only allow_always renders nothing // actionable — no button and no badge — because the two-option contract // (allow_once / reject_once only) is enforced at both the Rust sentinel and // the observer surface. const html = renderToStaticMarkup( React.createElement(LifecycleActivity, { ...BASE_PROPS, item: pendingPermissionItem([ { optionId: "opt-always", kind: "allow_always", label: "Always allow" }, ]), }), ); // No button for allow_always. assert.ok( !html.includes("permission-decision-opt-always"), "allow_always option must not render an actionable button", ); // No