Merge OPR.0.4.0.38 founder forward-fix: terminal local-trust + opaque render + 120x40 geometry pin + test fixes

This commit is contained in:
mvschwarz committed 2026-06-21 19:44:01 -07:00
commit 212b4523f9
13 files changed
+276 -85

No files matched your search

+23 -1
View File
@@ -4,6 +4,9 @@ import { createDaemon } from "./startup.js";
import {
assertBindAuthInvariant,
detectTailscaleInterface,
isLoopbackBind,
isTailscaleBind,
resolveToIpOrNull,
} from "./middleware/auth-bearer-token.js";
/** OPR.0.3.4.9 — extracted for testability. Starts the periodic snapshot
@@ -23,6 +26,16 @@ export function startPeriodicSnapshotScheduler(deps: {
deps.psProjectionService.setPeriodicSnapshotState(true, intervalS);
}
async function isTrustedLocalOrTailnetBind(host: string): Promise<boolean> {
if (isLoopbackBind(host)) return true;
if (isTailscaleBind(host)) return true;
if (!/^[\d.]+$/.test(host) && !host.includes(":")) {
const resolvedIp = await resolveToIpOrNull(host);
if (resolvedIp) return isLoopbackBind(resolvedIp) || isTailscaleBind(resolvedIp);
}
return false;
}
export async function startServer(port?: number) {
const p = port ?? parseInt(readOpenRigEnv("OPENRIG_PORT", "RIGGED_PORT") ?? "7433", 10);
const dbPath = readOpenRigEnv("OPENRIG_DB", "RIGGED_DB") ?? "openrig.sqlite";
@@ -39,10 +52,15 @@ export async function startServer(port?: number) {
const bearerToken = process.env.OPENRIG_AUTH_BEARER_TOKEN ?? null;
let bindHosts: string[];
const terminalTokenEnv = process.env.OPENRIG_TERMINAL_BEARER_TOKEN?.trim();
let terminalBearerToken: string | null = terminalTokenEnv || null;
if (explicitHost) {
// Operator opt-in path — invariant enforces bearer requirement for
// genuinely public/LAN binds; loopback/tailscale binds short-circuit.
await assertBindAuthInvariant({ host: explicitHost, bearerToken });
if (!terminalBearerToken && !(await isTrustedLocalOrTailnetBind(explicitHost))) {
terminalBearerToken = bearerToken;
}
bindHosts = [explicitHost];
} else {
// Default path — loopback always, plus tailscale auto-add when active.
@@ -50,7 +68,11 @@ export async function startServer(port?: number) {
bindHosts = tailscaleIp ? ["127.0.0.1", tailscaleIp] : ["127.0.0.1"];
}
const { app, contextMonitor, deps, injectWebSocket } = await createDaemon({ dbPath, bearerToken });
const { app, contextMonitor, deps, injectWebSocket } = await createDaemon({
dbPath,
bearerToken,
terminalBearerToken,
});
// Multi-bind via N serve() instances sharing the same Hono app.
// Hono's serve() targets a single host:port; for multi-bind we spawn
+1 -1
View File
@@ -31,7 +31,7 @@ import { validateResumeToken } from "../domain/resume-token-validation.js";
function terminalAuthGuard(): MiddlewareHandler {
return async (c, next) => {
const token = c.get("terminalBearerToken" as never) as string | null;
const token = (c.get("terminalBearerToken" as never) as string | null | undefined) ?? null;
const mw = authBearerTokenMiddleware({ expectedToken: token });
return mw(c, next);
};
+11 -20
View File
@@ -168,6 +168,13 @@ interface DaemonOptions {
* comparison + 401 on missing/mismatch.
*/
bearerToken?: string | null;
/**
* Terminal bearer token for live-terminal routes. Null means the
* daemon bind posture is already trusted (loopback/tailnet), matching
* the Mission Control auth boundary. Non-null enforces bearer auth on
* terminal preview/transport/websocket routes.
*/
terminalBearerToken?: string | null;
}
interface DaemonResult {
@@ -1032,26 +1039,10 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
deps.missionControlBearerToken = opts?.bearerToken ?? null;
const terminalTokenEnv = process.env.OPENRIG_TERMINAL_BEARER_TOKEN?.trim();
if (terminalTokenEnv) {
deps.terminalBearerToken = terminalTokenEnv;
} else {
const { randomBytes } = await import("node:crypto");
const nodePath = await import("node:path");
const nodeFs = await import("node:fs");
const homeDir = process.env.OPENRIG_HOME ?? nodePath.default.join(process.env.HOME ?? "", ".openrig");
const tokenPath = nodePath.default.join(homeDir, "terminal-token");
try {
const existing = nodeFs.default.readFileSync(tokenPath, "utf-8").trim();
if (existing) { deps.terminalBearerToken = existing; }
} catch {}
if (!deps.terminalBearerToken) {
deps.terminalBearerToken = randomBytes(32).toString("hex");
try {
nodeFs.default.mkdirSync(homeDir, { recursive: true });
nodeFs.default.writeFileSync(tokenPath, deps.terminalBearerToken, { mode: 0o600 });
} catch {}
}
}
deps.terminalBearerToken =
opts && Object.prototype.hasOwnProperty.call(opts, "terminalBearerToken")
? opts.terminalBearerToken ?? null
: terminalTokenEnv || null;
// Notification dispatcher: chosen mechanism via env config.
// OPENRIG_NOTIFICATIONS_MECHANISM=ntfy|webhook|none (default none).
@@ -115,6 +115,28 @@ export function screenSnapshotEscape(
return `\x1b[2J${paintedRows}${cursor ? cursorPositionEscape(cursor.x, cursor.y) : "\x1b[H"}`;
}
/**
* Raw pipe output from a full-screen TUI is a repaint stream, not durable
* scrollback. Replaying cursor-addressed history into a fresh xterm before the
* current snapshot causes stale prompt/status rows to appear above or under the
* real screen. Preserve the AC-5 shared-history behavior for plain line output
* (and simple SGR color), but skip history that contains cursor movement,
* erase, alternate-screen, OSC, or carriage-return repaint semantics.
*/
export function isSafeHistoryReplay(data: string): boolean {
if (!data) return false;
if (/\r(?!\n)/.test(data)) return false;
const escapePattern = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[\(\)][ -~]|[@-Z\\-_])/g;
let match: RegExpExecArray | null;
while ((match = escapePattern.exec(data)) !== null) {
const sequence = match[0];
const final = sequence.at(-1);
if (final !== "m") return false;
}
return true;
}
/**
* One broker per live tmux session. Owns a single pipe-pane and fans its output
* out to every attached subscriber.
@@ -319,13 +341,14 @@ export class TerminalSessionBroker {
private async seed(sub: TerminalSubscriber): Promise<void> {
// AC-5: replay the broker-owned recent-output ring FIRST so the late
// subscriber's xterm builds the same scrollback the earlier subscribers
// have (raw bytes write into xterm's scrollback). The visible-screen seed
// then paints a cursor-correct current screen on top - screenSnapshotEscape
// opens with ESC[2J, which clears only the VISIBLE viewport, NOT the
// scrollback, so the recent scrolled-off output is preserved above. The
// first subscriber's ring is empty, so it gets only the visible seed.
// have for plain terminal streams. Cursor-addressed TUI repaint history is
// not scrollback; replaying it corrupts late subscribers, so those sessions
// seed from the current visible-screen snapshot only.
if (this.historyBytes > 0) {
try { sub.send(this.history.join("")); } catch { /* dead subscriber */ }
const history = this.history.join("");
if (isSafeHistoryReplay(history)) {
try { sub.send(history); } catch { /* dead subscriber */ }
}
}
// Best-effort: a failed capture (or an adapter without the seed methods)
// must never break the attach - the tail still streams live output.
+43 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const serveMock = vi.fn();
const createDaemonMock = vi.fn();
@@ -15,7 +15,23 @@ describe("daemon startServer", () => {
beforeEach(() => {
serveMock.mockReset();
createDaemonMock.mockReset();
createDaemonMock.mockResolvedValue({ app: { fetch: vi.fn() }, contextMonitor: { start: vi.fn(), stop: vi.fn() } });
createDaemonMock.mockResolvedValue({
app: { fetch: vi.fn() },
contextMonitor: { start: vi.fn(), stop: vi.fn() },
deps: {},
injectWebSocket: vi.fn(),
});
delete process.env.OPENRIG_HOST;
delete process.env.RIGGED_HOST;
delete process.env.OPENRIG_AUTH_BEARER_TOKEN;
delete process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
});
afterEach(() => {
delete process.env.OPENRIG_HOST;
delete process.env.RIGGED_HOST;
delete process.env.OPENRIG_AUTH_BEARER_TOKEN;
delete process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
});
it("binds the daemon to loopback", async () => {
@@ -28,4 +44,29 @@ describe("daemon startServer", () => {
expect.any(Function)
);
});
it("does not require a terminal bearer token for the default loopback bind", async () => {
const { startServer } = await import("../src/index.js");
await startServer(7441);
expect(createDaemonMock).toHaveBeenCalledWith(
expect.objectContaining({ terminalBearerToken: null }),
);
});
it("uses the daemon bearer token for terminal routes on explicit public binds", async () => {
process.env.OPENRIG_HOST = "0.0.0.0";
process.env.OPENRIG_AUTH_BEARER_TOKEN = "daemon-token";
const { startServer } = await import("../src/index.js");
await startServer(7441);
expect(createDaemonMock).toHaveBeenCalledWith(
expect.objectContaining({
bearerToken: "daemon-token",
terminalBearerToken: "daemon-token",
}),
);
});
});
+27
View File
@@ -65,6 +65,33 @@ describe("createDaemon startup composition", () => {
db.close();
});
it("defaults terminal auth to local-trusted mode without minting a token file", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-terminal-auth-"));
const priorHome = process.env.OPENRIG_HOME;
const priorToken = process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
process.env.OPENRIG_HOME = tmpDir;
delete process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
const cmuxFactory: CmuxTransportFactory = async () => {
throw Object.assign(new Error(""), { code: "ENOENT" });
};
const tmuxExec: ExecFn = async () => "";
try {
const { db, deps } = await createDaemon({ cmuxFactory, tmuxExec });
expect(deps.terminalBearerToken).toBeNull();
expect(fs.existsSync(path.join(tmpDir, "terminal-token"))).toBe(false);
db.close();
} finally {
if (priorHome === undefined) delete process.env.OPENRIG_HOME;
else process.env.OPENRIG_HOME = priorHome;
if (priorToken === undefined) delete process.env.OPENRIG_TERMINAL_BEARER_TOKEN;
else process.env.OPENRIG_TERMINAL_BEARER_TOKEN = priorToken;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("createDaemon app: GET /api/rigs/:rigId/sessions returns 200 (session routes mounted)", async () => {
const cmuxFactory: CmuxTransportFactory = async () => {
throw Object.assign(new Error(""), { code: "ENOENT" });
@@ -438,6 +438,31 @@ describe("TerminalSessionBroker - shared history ring (AC-5)", () => {
expect(startPipePane).toHaveBeenCalledOnce();
});
it("a LATE subscriber skips unsafe TUI repaint history and receives the current screen snapshot", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux({
capturePaneScreen: async () => "CURRENT SCREEN",
getPaneCursorPosition: async () => ({ x: 0, y: 0, width: 120, height: 40 }),
}), { pollMs: 10 }));
const a = makeSub();
await broker.attach(a);
fs.appendFileSync(
broker.pipeOutputPath!,
"\x1b[2J\x1b[12;1HSTALE TUI PROMPT\x1b[13;1Hoverpainted status",
);
await vi.waitFor(() => {
expect(a.received.join("")).toContain("STALE TUI PROMPT");
}, { timeout: 1000 });
const b = makeSub();
await broker.attach(b);
const bSeed = b.received.join("");
expect(bSeed).not.toContain("STALE TUI PROMPT");
expect(bSeed).not.toContain("overpainted status");
expect(bSeed).toContain("CURRENT SCREEN");
});
it("the history ring is bounded under sustained output", async () => {
const broker = track(new TerminalSessionBroker("dev@rig", makeTmux(), { pollMs: 5, maxHistoryBytes: 2048 }));
await broker.attach(makeSub());
@@ -1,5 +1,4 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { readTerminalBearerToken } from "../mission-control/missionControlAuth.js";
import "@xterm/xterm/css/xterm.css";
@@ -33,7 +32,9 @@ const ESCAPE_SEQ_MAP: Record<string, string> = {
"\x1b[2~": "IC",
};
const SMOKED_TERMINAL_BACKGROUND = "rgba(12,10,9,0.6)";
const LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09";
const LIVE_TERMINAL_COLS = 120;
const LIVE_TERMINAL_ROWS = 40;
type WsMessage = { type: "keys"; keys: string[] } | { type: "text"; text: string };
@@ -79,6 +80,41 @@ export function mapXtermInput(data: string): WsMessage[] {
return messages;
}
export function applyOpaqueTerminalBackground(container: HTMLElement): void {
const surfaces = [
container,
container.querySelector<HTMLElement>(".xterm"),
container.querySelector<HTMLElement>(".xterm-screen"),
container.querySelector<HTMLElement>(".xterm-viewport"),
container.querySelector<HTMLElement>(".xterm-rows"),
];
for (const surface of surfaces) {
if (surface) surface.style.backgroundColor = LIVE_TERMINAL_RENDER_BACKGROUND;
}
}
export function scrollTerminalViewportToPrompt(container: HTMLElement): void {
const scroll = () => {
const cursor = container.querySelector<HTMLElement>("textarea.xterm-helper-textarea");
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
if (!cursor) {
container.scrollTop = maxScrollTop;
return;
}
const parsedCursorTop = Number.parseFloat(cursor.style.top);
const cursorTop = Number.isFinite(parsedCursorTop) ? parsedCursorTop : cursor.offsetTop;
const lineHeight = cursor.offsetHeight || 14;
const cursorBottom = cursorTop + lineHeight;
const desiredScrollTop = cursorBottom - container.clientHeight + lineHeight * 3;
container.scrollTop = Math.min(maxScrollTop, Math.max(0, desiredScrollTop));
};
scroll();
window.requestAnimationFrame(scroll);
window.setTimeout(scroll, 50);
}
interface FocusedTerminalProps {
sessionName: string;
daemonBaseUrl?: string;
@@ -88,17 +124,25 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<unknown>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<unknown>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const generationRef = useRef(0);
const promptScrollUntilRef = useRef(0);
const [error, setError] = useState<string | null>(null);
const disposeTerminal = useCallback(() => {
const term = termRef.current as { dispose(): void } | null;
term?.dispose();
termRef.current = null;
fitAddonRef.current = null;
}, []);
const scrollLiveTerminalToPrompt = useCallback((term: { scrollToBottom(): void } | null) => {
if (term) {
term.scrollToBottom();
}
if (containerRef.current) {
scrollTerminalViewportToPrompt(containerRef.current);
}
}, []);
const connectForGeneration = useCallback((gen: number) => {
@@ -110,19 +154,21 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
ws.onopen = () => {
if (generationRef.current !== gen) { ws.close(); return; }
// OPR.0.4.0.38 FR-7: fit the CONTAINER for scroll/pan only; do NOT send a
// resize. The daemon broker owns fixed canonical geometry (120x40) shared
// across every viewer - a client-driven resize would shrink the shared
// pane for everyone else. The seed arrives from the broker on attach.
const fitAddon = fitAddonRef.current as { fit(): void } | null;
fitAddon?.fit();
// The daemon broker owns fixed canonical geometry (120x40). The client
// must keep the same grid and let the UI scroll/pan smaller surfaces.
promptScrollUntilRef.current = Date.now() + 2500;
const term = termRef.current as { scrollToBottom(): void } | null;
scrollLiveTerminalToPrompt(term);
};
ws.onmessage = (evt) => {
if (generationRef.current !== gen) return;
const term = termRef.current as { write(data: string): void } | null;
const term = termRef.current as { write(data: string): void; scrollToBottom(): void } | null;
if (typeof evt.data === "string" && term) {
term.write(evt.data);
if (Date.now() <= promptScrollUntilRef.current) {
scrollLiveTerminalToPrompt(term);
}
}
};
@@ -147,7 +193,7 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
wsRef.current = ws;
return ws;
}, [sessionName, daemonBaseUrl, disposeTerminal]);
}, [sessionName, daemonBaseUrl, disposeTerminal, scrollLiveTerminalToPrompt]);
useEffect(() => {
if (!containerRef.current) return;
@@ -155,7 +201,6 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
generationRef.current++;
const currentGen = generationRef.current;
let cleanedUp = false;
let resizeObs: ResizeObserver | undefined;
(async () => {
try {
@@ -163,32 +208,27 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
const term = new Terminal({
cursorBlink: true,
cols: LIVE_TERMINAL_COLS,
rows: LIVE_TERMINAL_ROWS,
fontSize: 12,
lineHeight: 1,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
// OPR.0.4.0.1 styling polish (FR-1): the live terminal CONTENT carries
// its OWN translucent smoked-black tint (stone-950 #0c0a09 at ~0.6 alpha)
// so it reads as a floating smoked-glass plate on EVERY surface -- incl.
// the truly-bare topology-tab + grid-popover surfaces that have no plate
// behind them -- not only over the popover/shell backdrop-blur. Foreground
// stays OPAQUE (#e0e0e0) so text is fully legible (AC-4 hard constraint);
// alpha is the starting point, tuned toward opaque by QA if a busy backdrop
// ever fights crispness. allowTransparency is required for a non-opaque bg.
theme: { background: SMOKED_TERMINAL_BACKGROUND, foreground: "#e0e0e0", cursor: "#e0e0e0" },
allowTransparency: true,
// xterm erase/redraw needs an opaque cell background. A translucent
// xterm render surface lets old TUI cells bleed through after clear
// screen / absolute cursor repaint, which corrupts Claude/Codex views.
theme: { background: LIVE_TERMINAL_RENDER_BACKGROUND, foreground: "#e0e0e0", cursor: "#e0e0e0" },
allowTransparency: false,
allowProposedApi: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(containerRef.current!);
// xterm's viewport keeps its own default black background outside the
// theme-painted row layer. Keep it aligned with the smoked live content
// so live terminals do not regress to an opaque black panel.
const viewport = containerRef.current!.querySelector<HTMLElement>(".xterm-viewport");
if (viewport) viewport.style.backgroundColor = SMOKED_TERMINAL_BACKGROUND;
fitAddon.fit();
// Some xterm DOM layers do not inherit the theme background. Pin every
// render layer opaque so clear/erase operations actually erase.
applyOpaqueTerminalBackground(containerRef.current!);
term.focus();
promptScrollUntilRef.current = Date.now() + 2500;
scrollTerminalViewportToPrompt(containerRef.current!);
termRef.current = term;
fitAddonRef.current = fitAddon;
term.onData((data: string) => {
const wsc = wsRef.current;
@@ -200,13 +240,10 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
});
// OPR.0.4.0.38 FR-7: no term.onResize -> ws resize relay. The pane
// geometry is fixed daemon-side; the client only fits its container
// viewport (scroll/pan) and never asks the pane to resize.
// geometry is fixed daemon-side; the client grid matches it exactly
// and never asks the pane to resize.
connectForGeneration(currentGen);
resizeObs = new ResizeObserver(() => { fitAddon.fit(); });
resizeObs.observe(containerRef.current!);
} catch (err) {
setError(err instanceof Error ? err.message : "Terminal initialization failed");
}
@@ -217,7 +254,6 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
mountedRef.current = false;
generationRef.current++;
if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; }
resizeObs?.disconnect();
const activeWs = wsRef.current;
if (activeWs) { activeWs.close(); wsRef.current = null; }
disposeTerminal();
@@ -243,7 +279,7 @@ export function FocusedTerminal({ sessionName, daemonBaseUrl }: FocusedTerminalP
key={`focused-terminal-live-${sessionName}`}
ref={containerRef}
data-testid={`focused-terminal-${sessionName}`}
className="h-full w-full min-h-[200px]"
className="h-full w-full min-h-[200px] overflow-auto bg-stone-950/60 backdrop-blur-sm"
/>
);
}
@@ -222,11 +222,10 @@ export function TerminalPreviewPopover({
data-terminal-preview-surface=""
data-reduced-motion={reducedMotion ? "true" : "false"}
className={cn(
// OPR.0.4.0.1 (FR-4 de-dup): the terminal CONTENT now carries its own
// smoked-glass tint (FocusedTerminal bg / the static smoked plate), so the
// popover drops its redundant opaque smoked background to stay
// transparent-glassy (not double-tinted toward opaque). The backdrop-blur
// stays for the floating-plate effect over the page behind it.
// OPR.0.4.0.1 (FR-4 de-dup): the terminal wrapper/static plate carry the
// smoked-glass surface, while the xterm renderer itself stays opaque for
// reliable erase/redraw. The popover drops its redundant bg so the wrapper
// remains the single terminal plate.
"nodrag nopan fixed z-[1000] max-h-[calc(100vh-1rem)] max-w-[calc(100vw-1rem)] overflow-hidden p-1.5 backdrop-blur-sm",
// OPR.0.4.0.1 (rev1-r2 fix): widen the shell to fit the live plate (880px +
// padding) when live so overflow-hidden no longer clips it; stay compact for
@@ -21,8 +21,12 @@ describe("FocusedTerminal lifecycle", () => {
// The broker owns fixed canonical geometry; the client must NOT send a
// resize (it would shrink the shared pane for every other viewer).
expect(src).not.toMatch(/type:\s*["']resize["']/);
// FitAddon still fits the CONTAINER (scroll/pan), just without a resize relay.
expect(src).toContain("fitAddon");
// OPR.0.4.0.38 forward-fix: the client now PINS to the broker canonical
// geometry (cols=120 rows=40) and scrolls/pans its container - FitAddon was
// removed entirely (no container-fit resize-fight), which is the stronger
// form of fixed-geometry mirroring.
expect(src).not.toContain("FitAddon");
expect(src).toContain("cols: LIVE_TERMINAL_COLS");
});
it("source guard: cleanup closes wsRef.current not local ws", async () => {
@@ -47,7 +51,10 @@ describe("FocusedTerminal lifecycle", () => {
expect(src).toContain("[disconnected - reconnecting...]");
expect(src).toContain("mountedRef.current");
expect(src).toMatch(/setTimeout\(\s*\(\)\s*=>\s*\{/);
expect(src).toContain("connect()");
// The reconnect schedules connectForGeneration(gen). (The prior
// toContain("connect()") was a false-positive match on resizeObs.disconnect()
// - now removed with FitAddon - so it is corrected to the real reconnect call.)
expect(src).toContain("connectForGeneration(gen)");
});
it("source guard: cleanup sets mountedRef false and clears reconnect timer", async () => {
@@ -40,6 +40,11 @@ vi.mock("@xterm/xterm", () => ({
write(data: string) { terminalWrites.push(data); }
onData(_cb: (data: string) => void) {}
onResize(_cb: (size: { cols: number; rows: number }) => void) {}
// The real xterm Terminal exposes focus() + scrollToBottom(); FocusedTerminal
// calls both (focus on open, scrollToBottom in the scroll-to-prompt path).
// Stub them so the component's setup does not throw in the mock.
focus() {}
scrollToBottom() {}
dispose() {
terminalDisposeCount++;
this.child?.remove();
@@ -211,9 +211,9 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(driverPopover.className).toContain("max-w-[calc(100vw-1rem)]");
expect(driverPopover.className).toContain("max-h-[calc(100vh-1rem)]");
expect(driverPopover.className).toContain("overflow-hidden");
// OPR.0.4.0.1 (FR-4): the popover dropped its redundant bg-stone-950/65 -- the
// terminal CONTENT now carries its own smoked-glass tint, so the popover stays
// transparent-glassy (backdrop-blur retained) instead of double-tinted opaque.
// OPR.0.4.0.1 (FR-4): the popover dropped its redundant bg-stone-950/65; the
// live terminal wrapper supplies the smoked plate while xterm's renderer stays
// opaque enough for reliable erase/redraw.
expect(driverPopover.className).not.toContain("bg-stone-950/65");
expect(driverPopover.className).toContain("font-mono");
expect(driverPopover.className).toContain("text-[8px]");
@@ -49,22 +49,37 @@ describe("OPR.0.4.0.1 terminal styling polish", () => {
expect(screen.getByTestId("preview-a@r").getAttribute("data-variant")).toBe("compact-terminal");
});
it("FR-1: the LIVE terminal CONTENT carries its own translucent smoked tint (not the plate-dependent rgba(0,0,0,0))", () => {
it("FR-1: the LIVE terminal keeps an opaque xterm render surface so erase/redraw is cursor-safe", () => {
const s = src("../src/components/terminal/FocusedTerminal.tsx");
expect(s).toContain('const SMOKED_TERMINAL_BACKGROUND = "rgba(12,10,9,0.6)"'); // stone-950 at ~0.6 alpha
expect(s).toContain("viewport.style.backgroundColor = SMOKED_TERMINAL_BACKGROUND");
expect(s).toContain('const LIVE_TERMINAL_RENDER_BACKGROUND = "#0c0a09"');
expect(s).toContain("const LIVE_TERMINAL_COLS = 120");
expect(s).toContain("const LIVE_TERMINAL_ROWS = 40");
expect(s).toContain("cols: LIVE_TERMINAL_COLS");
expect(s).toContain("rows: LIVE_TERMINAL_ROWS");
expect(s).toContain("lineHeight: 1");
expect(s).toContain("applyOpaqueTerminalBackground(containerRef.current!)");
expect(s).toContain("scrollTerminalViewportToPrompt(containerRef.current!)");
expect(s).toContain("term.scrollToBottom();");
expect(s).toContain('querySelector<HTMLElement>("textarea.xterm-helper-textarea")');
expect(s).toContain("const desiredScrollTop = cursorBottom - container.clientHeight + lineHeight * 3");
expect(s).toContain("container.scrollTop = Math.min(maxScrollTop, Math.max(0, desiredScrollTop))");
expect(s).toContain("term.focus();");
expect(s).toContain("allowTransparency: false");
expect(s).not.toContain("allowTransparency: true");
expect(s).not.toContain("FitAddon");
expect(s).not.toContain("fitAddon.fit");
expect(s).not.toContain('background: "rgba(0,0,0,0)"');
expect(s).toContain('foreground: "#e0e0e0"'); // text stays OPAQUE (AC-4 legibility)
});
it("FR-3/FR-4: the popover sets the measured LIVE width + drops its redundant opaque bg (content self-tints)", () => {
it("FR-3/FR-4: the popover sets the measured LIVE width + drops its redundant opaque bg", () => {
const s = src("../src/components/topology/TerminalPreviewPopover.tsx");
expect(s).toContain("w-[880px]"); // the LIVE inner sizer (optimal width)
// OPR.0.4.0.1 (rev1-r2 fix): the shell widens to fit the 880px live plate so
// overflow-hidden no longer clips it (the prior fixed compact shell did).
expect(s).toContain("w-[904px]");
expect(s).not.toContain("w-[820px]");
expect(s).not.toContain("bg-stone-950/65"); // dropped -> transparent-glassy
expect(s).not.toContain("bg-stone-950/65"); // dropped -> live wrapper supplies the plate
expect(s).toContain("backdrop-blur-sm");
});