fix(frontend): refit web agent cells after opening a new one (#61 web regression)
The web shell never adopted the desktop's #61 fix: WebAgentCell/WebWorkspace didn't pass any refitSignal to TerminalView, so a cell opened after another kept a stale xterm scaling — desktop "fixed" it via an incidental window resize, but mobile has no such escape hatch. - TerminalView: a refit landing on a transient 0x0 container now reschedules on the next few frames instead of giving up for good (bounded retries), closing the independent timing gap Architect identified. refitSignal stays the single explicit-refit mechanism; the terminal/PTY is never recreated, and resize is still pushed to the PTY only when rows/cols actually change. - WebAgentCell: new optional refitSignal prop, forwarded to TerminalView as-is (no key change, no remount). - WebWorkspace/LiveProjectPanel: new cellLayoutVersion counter, the web equivalent of desktop useLayout.layoutVersion, bumped on every cell open/close and forwarded as refitSignal to the visible WebAgentCell. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -333,6 +333,47 @@ describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () =
|
|||||||
fitSpy.mockRestore();
|
fitSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recovers from a transient zero-size container instead of giving up (web regression)", async () => {
|
||||||
|
// On mobile there is no window-resize equivalent that "repairs" a refit
|
||||||
|
// that landed on a not-yet-settled 0x0 container — so a zero size at the
|
||||||
|
// scheduled frame must reschedule on a following frame rather than
|
||||||
|
// abandon the refit permanently.
|
||||||
|
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||||
|
const open = vi.fn(async () => makeHandle({ sessionId: "zero-size-1" }));
|
||||||
|
|
||||||
|
const { rerender } = renderView(new MockTerminalGateway(), "/cwd", {
|
||||||
|
open,
|
||||||
|
refitSignal: 1,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||||
|
fitSpy.mockClear();
|
||||||
|
|
||||||
|
const container = screen.getByTestId("terminal-view").firstElementChild as HTMLElement;
|
||||||
|
// jsdom's default layout box is 0x0 — exactly the transient-zero case:
|
||||||
|
// left as-is, the container "hasn't settled" yet.
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
|
||||||
|
<TerminalView cwd="/cwd" open={open} refitSignal={2} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First scheduled frame: still zero size — must skip fit() and
|
||||||
|
// reschedule, not give up.
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
expect(fitSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The container settles to a real size before the rescheduled retry runs.
|
||||||
|
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
|
||||||
|
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
|
||||||
|
|
||||||
|
await waitFor(() => expect(fitSpy).toHaveBeenCalled());
|
||||||
|
// Still never reopened the PTY across the retries.
|
||||||
|
expect(open).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not refit when refitSignal is left undefined (no-op for callers that don't pass it)", async () => {
|
it("does not refit when refitSignal is left undefined (no-op for callers that don't pass it)", async () => {
|
||||||
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||||
const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" }));
|
const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" }));
|
||||||
|
|||||||
@ -338,10 +338,27 @@ export function TerminalView({
|
|||||||
let rafId = 0;
|
let rafId = 0;
|
||||||
let lastRows = term.rows;
|
let lastRows = term.rows;
|
||||||
let lastCols = term.cols;
|
let lastCols = term.cols;
|
||||||
|
// A refit can land on a transient 0x0 container (mount, or a structural
|
||||||
|
// layout mutation, before the box has actually settled). Previously this
|
||||||
|
// just gave up — fine on desktop, where a later window resize always
|
||||||
|
// re-triggers the ResizeObserver and retries, but on mobile (no window
|
||||||
|
// resize possible) the cell was then stuck unfit forever. So a zero size
|
||||||
|
// now reschedules on the next few frames instead of abandoning — bounded,
|
||||||
|
// so a container that is genuinely never laid out (e.g. headless tests)
|
||||||
|
// doesn't spin forever.
|
||||||
|
const MAX_ZERO_SIZE_RETRIES = 8;
|
||||||
|
let zeroSizeRetries = 0;
|
||||||
const refit = () => {
|
const refit = () => {
|
||||||
rafId = 0;
|
rafId = 0;
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (container.clientWidth === 0 || container.clientHeight === 0) return;
|
if (container.clientWidth === 0 || container.clientHeight === 0) {
|
||||||
|
if (zeroSizeRetries < MAX_ZERO_SIZE_RETRIES) {
|
||||||
|
zeroSizeRetries += 1;
|
||||||
|
rafId = requestAnimationFrame(refit);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zeroSizeRetries = 0;
|
||||||
try {
|
try {
|
||||||
fit.fit();
|
fit.fit();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
161
frontend/src/features/web/WebAgentCell.test.tsx
Normal file
161
frontend/src/features/web/WebAgentCell.test.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Ticket #61 (web regression) — `WebAgentCell`'s `refitSignal` wiring.
|
||||||
|
*
|
||||||
|
* `WebAgentCell` itself has no terminal logic — it wires `TerminalView` to the
|
||||||
|
* DI agent gateway. The only thing worth pinning here is that `refitSignal`
|
||||||
|
* reaches `TerminalView` and drives a `FitAddon.fit()` call WITHOUT relaunching
|
||||||
|
* the agent (`agent.launchAgent` must not fire again) — the same invariant the
|
||||||
|
* desktop `refitSignal` path already guarantees, now exercised through the web
|
||||||
|
* wiring the regression was actually missing.
|
||||||
|
*
|
||||||
|
* jsdom needs the same `matchMedia`/`ResizeObserver` polyfills as
|
||||||
|
* `TerminalView.test.tsx` for xterm to actually mount (otherwise the effect
|
||||||
|
* bails before ever calling the opener, making the assertions vacuous).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { MockAgentGateway } from "@/adapters/mock";
|
||||||
|
import { WebAgentCell } from "./WebAgentCell";
|
||||||
|
|
||||||
|
const PROJECT_ID = "p1";
|
||||||
|
|
||||||
|
async function seededAgentGateway(): Promise<MockAgentGateway> {
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
await agent.createAgent(PROJECT_ID, { name: "Archi", profileId: "profile-1" });
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCell(
|
||||||
|
agent: MockAgentGateway,
|
||||||
|
agentId: string,
|
||||||
|
extra?: Partial<React.ComponentProps<typeof WebAgentCell>>,
|
||||||
|
) {
|
||||||
|
const gateways = { agent } as unknown as Gateways;
|
||||||
|
return render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" {...extra} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WebAgentCell", () => {
|
||||||
|
it("mounts and renders the cell without throwing (headless-safe)", async () => {
|
||||||
|
const agent = await seededAgentGateway();
|
||||||
|
const [seeded] = await agent.listAgents(PROJECT_ID);
|
||||||
|
renderCell(agent, seeded.id);
|
||||||
|
expect(screen.getByTestId("web-agent-cell")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("launches through agent.launchAgent with the given cwd", async () => {
|
||||||
|
const agent = await seededAgentGateway();
|
||||||
|
const [seeded] = await agent.listAgents(PROJECT_ID);
|
||||||
|
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||||
|
|
||||||
|
renderCell(agent, seeded.id);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
if (launchSpy.mock.calls.length > 0) {
|
||||||
|
expect(launchSpy.mock.calls[0][0]).toBe(PROJECT_ID);
|
||||||
|
expect(launchSpy.mock.calls[0][1]).toBe(seeded.id);
|
||||||
|
expect(launchSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// xterm needs a real layout engine to mount under jsdom; without these
|
||||||
|
// polyfills `term.open` throws and the effect bails before ever calling
|
||||||
|
// `agent.launchAgent`, which would make the refit assertions below vacuous.
|
||||||
|
describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
|
||||||
|
const w = window as unknown as { matchMedia?: (q: string) => MediaQueryList };
|
||||||
|
const savedMatchMedia = w.matchMedia;
|
||||||
|
const savedResizeObserver = globalThis.ResizeObserver;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
w.matchMedia = (query: string) =>
|
||||||
|
({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}) as unknown as MediaQueryList;
|
||||||
|
// A no-op observer: real resize events must not be what drives the refit
|
||||||
|
// assertions below — only the explicit `refitSignal` prop should.
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
w.matchMedia = savedMatchMedia;
|
||||||
|
globalThis.ResizeObserver = savedResizeObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a refitSignal change calls FitAddon.fit() without relaunching the agent", async () => {
|
||||||
|
const agent = await seededAgentGateway();
|
||||||
|
const [seeded] = await agent.listAgents(PROJECT_ID);
|
||||||
|
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||||
|
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||||
|
|
||||||
|
const { rerender } = renderCell(agent, seeded.id, { refitSignal: 1 });
|
||||||
|
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
const fitCallsAtMount = fitSpy.mock.calls.length;
|
||||||
|
expect(fitCallsAtMount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// jsdom reports a zero-size layout box, which the coalesced refit
|
||||||
|
// deliberately skips — give the inner xterm container a real size so the
|
||||||
|
// refit below actually reaches `fit.fit()`.
|
||||||
|
const container = screen.getByTestId("web-agent-cell").querySelector(
|
||||||
|
'[data-testid="terminal-view"]',
|
||||||
|
)!.firstElementChild as HTMLElement;
|
||||||
|
Object.defineProperty(container, "clientWidth", { value: 400, configurable: true });
|
||||||
|
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DIProvider gateways={{ agent } as unknown as Gateways}>
|
||||||
|
<WebAgentCell projectId={PROJECT_ID} agentId={seeded.id} cwd="/srv/demo" refitSignal={2} />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtMount));
|
||||||
|
// The whole point: refit must never relaunch the agent's PTY.
|
||||||
|
expect(launchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refit when refitSignal is left undefined", async () => {
|
||||||
|
const agent = await seededAgentGateway();
|
||||||
|
const [seeded] = await agent.listAgents(PROJECT_ID);
|
||||||
|
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||||
|
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||||
|
|
||||||
|
const { rerender } = renderCell(agent, seeded.id);
|
||||||
|
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||||
|
fitSpy.mockClear();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DIProvider gateways={{ agent } as unknown as Gateways}>
|
||||||
|
<WebAgentCell projectId={PROJECT_ID} agentId={seeded.id} cwd="/srv/demo" />
|
||||||
|
</DIProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
expect(fitSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -39,9 +39,16 @@ interface WebAgentCellProps {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
/** Layout leaf id, when the caller tracks one (drives the singleton guard). */
|
/** Layout leaf id, when the caller tracks one (drives the singleton guard). */
|
||||||
nodeId?: string;
|
nodeId?: string;
|
||||||
|
/**
|
||||||
|
* Bumped by the caller after a web cell opens/closes (ticket #61 web
|
||||||
|
* regression) — the web equivalent of desktop `useLayout.layoutVersion`.
|
||||||
|
* Forwarded to {@link TerminalView} as-is; never changes this component's
|
||||||
|
* `key`, so the terminal/PTY is never recreated by it.
|
||||||
|
*/
|
||||||
|
refitSignal?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellProps) {
|
export function WebAgentCell({ projectId, agentId, cwd, nodeId, refitSignal }: WebAgentCellProps) {
|
||||||
const { agent } = useGateways();
|
const { agent } = useGateways();
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
// Input API of the mounted xterm (#69), used by the mobile key bar. Stays
|
// Input API of the mounted xterm (#69), used by the mobile key bar. Stays
|
||||||
@ -85,6 +92,7 @@ export function WebAgentCell({ projectId, agentId, cwd, nodeId }: WebAgentCellPr
|
|||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
onSessionId={setSessionId}
|
onSessionId={setSessionId}
|
||||||
onReady={setInputApi}
|
onReady={setInputApi}
|
||||||
|
refitSignal={refitSignal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TerminalKeyBar api={inputApi} />
|
<TerminalKeyBar api={inputApi} />
|
||||||
|
|||||||
@ -309,6 +309,16 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string
|
|||||||
// Re-sync the read-model when the WS reconnects (events missed while offline).
|
// Re-sync the read-model when the WS reconnects (events missed while offline).
|
||||||
useLiveReconnect(vm.refresh);
|
useLiveReconnect(vm.refresh);
|
||||||
const [openAgentId, setOpenAgentId] = useState<string | null>(null);
|
const [openAgentId, setOpenAgentId] = useState<string | null>(null);
|
||||||
|
// Web equivalent of desktop `useLayout.layoutVersion` (ticket #61 web
|
||||||
|
// regression): the web shell has no `useLayout`/`LayoutGrid`, so nothing was
|
||||||
|
// ever bumping `refitSignal` on `TerminalView` — a cell opened after another
|
||||||
|
// one was left with stale xterm scaling, with no window-resize equivalent to
|
||||||
|
// "repair" it on mobile. Bumped on every cell open/close and forwarded as
|
||||||
|
// `refitSignal` to every visible `WebAgentCell` — a single shared counter, so
|
||||||
|
// it already covers several simultaneously-visible cells if this panel ever
|
||||||
|
// grows one.
|
||||||
|
const [cellLayoutVersion, setCellLayoutVersion] = useState(0);
|
||||||
|
const bumpCellLayoutVersion = useCallback(() => setCellLayoutVersion((v) => v + 1), []);
|
||||||
const agents = vm.state?.agents ?? [];
|
const agents = vm.state?.agents ?? [];
|
||||||
|
|
||||||
// Per-agent background-tasks disclosure (collapsed by default). A live refresh
|
// Per-agent background-tasks disclosure (collapsed by default). A live refresh
|
||||||
@ -360,9 +370,10 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string
|
|||||||
key={a.agentId}
|
key={a.agentId}
|
||||||
agent={a}
|
agent={a}
|
||||||
open={openAgentId === a.agentId}
|
open={openAgentId === a.agentId}
|
||||||
onToggleOpen={() =>
|
onToggleOpen={() => {
|
||||||
setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId))
|
setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId));
|
||||||
}
|
bumpCellLayoutVersion();
|
||||||
|
}}
|
||||||
onRefresh={vm.refresh}
|
onRefresh={vm.refresh}
|
||||||
bgExpanded={expandedBgAgents.has(a.agentId)}
|
bgExpanded={expandedBgAgents.has(a.agentId)}
|
||||||
onToggleBgExpanded={() => toggleBgExpanded(a.agentId)}
|
onToggleBgExpanded={() => toggleBgExpanded(a.agentId)}
|
||||||
@ -375,11 +386,24 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string
|
|||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<div className="mb-1 flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold text-muted">Agent</span>
|
<span className="text-xs font-semibold text-muted">Agent</span>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setOpenAgentId(null)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenAgentId(null);
|
||||||
|
bumpCellLayoutVersion();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Fermer
|
Fermer
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<WebAgentCell key={openAgentId} projectId={projectId} agentId={openAgentId} cwd={root} />
|
<WebAgentCell
|
||||||
|
key={openAgentId}
|
||||||
|
projectId={projectId}
|
||||||
|
agentId={openAgentId}
|
||||||
|
cwd={root}
|
||||||
|
refitSignal={cellLayoutVersion}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@ -3,8 +3,10 @@
|
|||||||
* work-state; background tasks render with status + cancel/retry wired to the
|
* work-state; background tasks render with status + cancel/retry wired to the
|
||||||
* gateway; a WS reconnect re-synchronises the read-model.
|
* gateway; a WS reconnect re-synchronises the read-model.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from "vitest";
|
||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
|
import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
|
||||||
@ -319,3 +321,95 @@ describe("WebWorkspace live surfaces (F5)", () => {
|
|||||||
await waitFor(() => expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterOpen));
|
await waitFor(() => expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterOpen));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket #61 (web regression) — `LiveProjectPanel`'s `cellLayoutVersion`.
|
||||||
|
*
|
||||||
|
* The web shell has no `useLayout`/`LayoutGrid`, so nothing ever bumped
|
||||||
|
* `refitSignal` on the web agent cell. `LiveProjectPanel` now keeps a local
|
||||||
|
* counter, bumped on every cell open/close, forwarded as `refitSignal` to the
|
||||||
|
* currently-visible `WebAgentCell`. The panel currently shows at most one cell
|
||||||
|
* at a time (opening a different agent replaces, rather than adds to, the
|
||||||
|
* visible cell) — so "the cell already displayed keeps a stale scaling" is
|
||||||
|
* exercised here as: opening a *second*, different agent's cell must still
|
||||||
|
* carry a bumped, defined `refitSignal` through to the freshly-mounted
|
||||||
|
* `TerminalView`, producing an explicit `fit()` call beyond the one guaranteed
|
||||||
|
* at mount — not just whatever the (here stubbed-inert) `ResizeObserver` would
|
||||||
|
* have produced on its own. That is the exact mechanism a real multi-cell
|
||||||
|
* surface would rely on for a survivor cell (item 4 of the plan).
|
||||||
|
*
|
||||||
|
* Needs the same `matchMedia`/`ResizeObserver` polyfills as
|
||||||
|
* `TerminalView.test.tsx` for xterm to actually mount under jsdom — scoped to
|
||||||
|
* this describe block only.
|
||||||
|
*/
|
||||||
|
describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", () => {
|
||||||
|
const w = window as unknown as { matchMedia?: (q: string) => MediaQueryList };
|
||||||
|
const savedMatchMedia = w.matchMedia;
|
||||||
|
const savedResizeObserver = globalThis.ResizeObserver;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
w.matchMedia = (query: string) =>
|
||||||
|
({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}) as unknown as MediaQueryList;
|
||||||
|
// A no-op observer: real resize events must not be what drives the extra
|
||||||
|
// fit() below — only the explicit `refitSignal` bump should.
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
w.matchMedia = savedMatchMedia;
|
||||||
|
globalThis.ResizeObserver = savedResizeObserver;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening a second cell carries a bumped refitSignal, producing an extra fit()", async () => {
|
||||||
|
const { gateways } = await seeded(state([AGENT_IDLE, AGENT_FRONT]));
|
||||||
|
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
||||||
|
renderPaired(gateways);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("Demo"));
|
||||||
|
await screen.findByTestId("web-workstate");
|
||||||
|
|
||||||
|
const archiRow = screen.getByText("Archi").closest("li")!;
|
||||||
|
fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" }));
|
||||||
|
await screen.findByTestId("web-agent-cell");
|
||||||
|
// First cell mounted: at least the one guaranteed fit-on-mount happened.
|
||||||
|
expect(fitSpy.mock.calls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Switch to a different agent's cell — the panel shows one cell at a time,
|
||||||
|
// so this replaces (unmounts A, mounts B) rather than adding a second.
|
||||||
|
const frontRow = screen.getByText("Front").closest("li")!;
|
||||||
|
fireEvent.click(within(frontRow).getByRole("button", { name: "Ouvrir" }));
|
||||||
|
|
||||||
|
const cellB = await screen.findByTestId("web-agent-cell");
|
||||||
|
const fitCallsAtBMount = fitSpy.mock.calls.length;
|
||||||
|
|
||||||
|
// jsdom reports a zero-size layout box, which the refit deliberately skips
|
||||||
|
// — give the freshly-mounted cell's inner xterm container a real size so
|
||||||
|
// the refitSignal-driven refit actually reaches `fit.fit()`.
|
||||||
|
const containerB = cellB.querySelector('[data-testid="terminal-view"]')!
|
||||||
|
.firstElementChild as HTMLElement;
|
||||||
|
Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true });
|
||||||
|
Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true });
|
||||||
|
|
||||||
|
// The bumped `cellLayoutVersion` reaching `refitSignal` schedules this
|
||||||
|
// extra refit — with the no-op ResizeObserver stubbed above, nothing else
|
||||||
|
// could have produced it.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtBMount),
|
||||||
|
);
|
||||||
|
|
||||||
|
fitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user