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