import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import { act, type ReactNode } from "react";
import { createRoot as createReactRoot } from "react-dom/client";
import { AppContext, type AppContextValue } from "../../app-context";
import type { Session } from "../../types";
import { SessionHeader } from "./session-header";
// The header only renders inside the session shell, which always has the app
// context; its chat actions (copy link, pin) run through it. None of these
// tests run one, so any read of the context fails loudly.
const appContext = new Proxy({} as AppContextValue, {
get(_target, key) {
throw new Error(`The SessionHeader test's app context has no ${String(key)}`);
},
});
function createRoot(container: Element) {
const root = createReactRoot(container);
return {
render: (node: ReactNode) =>
root.render({node}),
unmount: () => root.unmount(),
};
}
beforeAll(() => {
GlobalRegistrator.register();
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterAll(() => {
GlobalRegistrator.unregister();
});
const session = {
id: "session-1",
workspaceId: "workspace-1",
initialMessage: "Run this exact bash script once in the workspace root.",
title: null,
titleSource: null,
parentSessionId: null,
model: "codex/gpt-5.6-sol",
reasoningEffort: "high",
latencyMode: "standard",
metadata: {},
status: "idle",
pinned: false,
effectiveControl: {
state: "active",
directState: "active",
primaryBlocker: null,
additionalBlockerCount: 0,
},
} as Session;
describe("SessionHeader mobile touch targets", () => {
test("keeps the editable session title at least 44px high on coarse pointers", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => {
root.render(
undefined}
inspectorOpen={false}
onToggleInspector={() => undefined}
onRename={async () => null}
onPin={async () => null}
/>,
);
});
const title = container.querySelector(
'button[title$="· click to rename"]',
);
expect(title).not.toBeNull();
expect(title!.className).toContain("pointer-coarse:min-h-11");
} finally {
await act(async () => root.unmount());
container.remove();
}
});
test("renders access beside the title actions and names workspace open/hide state", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const render = (inspectorOpen: boolean) => (
undefined}
inspectorOpen={inspectorOpen}
onToggleInspector={() => undefined}
onRename={async () => null}
onPin={async () => null}
accessSlot={Private}
/>
);
try {
await act(async () => root.render(render(false)));
const access = container.querySelector('[data-testid="session-access-slot"]');
const title = container.querySelector('button[title$="· click to rename"]');
expect(access).not.toBeNull();
expect(title).not.toBeNull();
expect(access?.parentElement?.contains(title ?? null)).toBe(true);
expect(container.querySelector('[aria-label="Open workspace"]')).not.toBeNull();
await act(async () => root.render(render(true)));
expect(container.querySelector('[aria-label="Hide workspace"]')).not.toBeNull();
} finally {
await act(async () => root.unmount());
container.remove();
}
});
});
test("reuses the compact Schedule button without requiring creation metadata", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
let opened = 0;
const render = (linked: boolean) => (
undefined}
inspectorOpen={false}
onToggleInspector={() => undefined}
onRename={async () => null}
onPin={async () => null}
onOpenSchedule={
linked
? () => {
opened++;
}
: null
}
/>
);
try {
await act(async () => root.render(render(true)));
const button = container.querySelector(
'button[title="Open schedules for this session"]',
);
expect(button?.textContent?.trim()).toBe("Schedule");
await act(async () => button?.click());
expect(opened).toBe(1);
await act(async () => root.render(render(false)));
expect(container.querySelector('button[title="Open schedules for this session"]')).toBeNull();
// A paused or finished schedule still links to its settings, marked inactive.
await act(async () =>
root.render(
undefined}
inspectorOpen={false}
onToggleInspector={() => undefined}
onRename={async () => null}
onPin={async () => null}
onOpenSchedule={() => {
opened++;
}}
/>,
),
);
const inactive = container.querySelector(
'button[title="No upcoming runs. Open schedules for this session"]',
);
expect(inactive?.textContent?.trim()).toBe("Schedule");
expect(inactive?.querySelector("svg")?.getAttribute("class")).toContain("lucide-calendar-off");
await act(async () => inactive?.click());
expect(opened).toBe(2);
} finally {
await act(async () => root.unmount());
container.remove();
}
});
test("narrow headers keep a compact lifecycle dot instead of hiding status", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const render = (value: Session, status: Session["status"]) => (
undefined}
inspectorOpen={false}
onToggleInspector={() => undefined}
onRename={async () => null}
onPin={async () => null}
/>
);
const compact = () => container.querySelector("[data-compact-session-status]");
try {
for (const [status, label] of [
["running", "Running"],
["failed", "Failed"],
["requires_action", "Waiting on you"],
["waiting_capacity", "Limit reached"],
] as const) {
await act(async () => root.render(render(session, status)));
expect(compact()?.dataset.compactSessionStatus).toBe(status);
expect(compact()?.textContent).toBe(label);
expect(compact()?.className).toContain("lg:hidden");
}
// A session the runtime could not start is stuck, not waiting on you.
await act(async () =>
root.render(
render(
{
...session,
admissionBlock: {
reason: "database_claim_rejected",
sqlState: "42501",
retryPolicy: "explicit_recheck",
blockedAt: "2026-09-25T12:00:00.000Z",
},
} as Session,
"requires_action",
),
),
);
expect(compact()?.dataset.compactSessionStatus).toBe("blocked");
expect(compact()?.textContent).toBe("Stuck");
expect(container.textContent).not.toContain("Waiting on you");
await act(async () =>
root.render(
render(
{
...session,
inputWait: { deadlineAt: "2026-09-25T12:00:00.000Z", reason: "Waiting for CI" },
} as Session,
"idle",
),
),
);
expect(compact()?.textContent).toBe("Waiting");
// Only the desktop badge carries the wait marker, so selectors stay unique.
expect(container.querySelectorAll("[data-session-wait-badge]")).toHaveLength(1);
await act(async () =>
root.render(
render(
{
...session,
effectiveControl: {
...session.effectiveControl,
state: "paused",
directState: "paused",
},
} as Session,
"idle",
),
),
);
expect(compact()?.textContent).toBe("Paused");
expect(container.querySelector(".sr-only.lg\\:hidden")?.textContent).toBe("Connection live.");
} finally {
await act(async () => root.unmount());
container.remove();
}
});
test("names the model cleanly for org- and workspace-connected copies alike", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const render = (model: string) => (
undefined}
inspectorOpen={false}
onToggleInspector={() => undefined}
onRename={async () => null}
onPin={async () => null}
billingClass="claude_subscription"
/>
);
try {
const seen: string[] = [];
for (const model of [
"organization-claude-subscription/claude-opus-5-5",
"workspace-claude-subscription/claude-opus-5-5",
]) {
await act(async () => root.render(render(model)));
const text = container.querySelector("header")?.textContent ?? "";
expect(text).toContain("Opus 5.5");
expect(text).not.toContain("claude-subscription");
expect(container.querySelector('[data-model-vendor="anthropic"]')).not.toBeNull();
seen.push(container.querySelector("[data-model-vendor]")?.parentElement?.textContent ?? "");
}
expect(seen[0]).toBe(seen[1]);
} finally {
await act(async () => root.unmount());
container.remove();
}
});