Files
oh-my-pi/packages/coding-agent/test/modes/components/tool-execution-spinner.test.ts
T
can1357 505af4188f fix(coding-agent/modes): pinned displaceable transcript snapshots to viewport
- Pinned displaceable snapshots like hub waiting polls and todo lists to the viewport during active execution.
- Prevented unpinned spinner ticks from repeatedly committing mutating rows to native scrollback and force-sealing blocks.
2026-07-24 01:25:29 +02:00

226 lines
6.7 KiB
TypeScript

import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { TUI } from "@oh-my-pi/pi-tui";
// Contract under test: live tool previews that render a pending/running status
// must keep the spinner glyph tied to the shared tool-frame ticker. This covers
// both the shared ToolExecutionComponent interval and renderer-local caches that
// would otherwise keep serving the first pending frame.
describe("ToolExecutionComponent live preview spinners", () => {
beforeAll(async () => {
await initTheme();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("animates the eval pending cell while the call is live", () => {
vi.useFakeTimers();
const requestRender = vi.fn();
const requestComponentRender = vi.fn();
const component = new ToolExecutionComponent(
"eval",
{ language: "py", code: "import time\ntime.sleep(10)" },
{},
undefined,
{ requestRender, requestComponentRender } as unknown as TUI,
process.cwd(),
);
try {
const firstFrame = stripVTControlCharacters(component.render(80).join("\n"));
vi.advanceTimersByTime(120);
const secondFrame = stripVTControlCharacters(component.render(80).join("\n"));
expect(requestComponentRender).toHaveBeenCalledWith(component);
expect(requestRender).not.toHaveBeenCalled();
expect(firstFrame).toContain("time.sleep(10)");
expect(secondFrame).toContain("time.sleep(10)");
expect(secondFrame).not.toBe(firstFrame);
} finally {
component.stopAnimation();
}
});
it("does not tick headerless bash pending previews", () => {
vi.useFakeTimers();
const requestRender = vi.fn();
const requestComponentRender = vi.fn();
const component = new ToolExecutionComponent(
"bash",
{ command: "sleep 600" },
{},
undefined,
{ requestRender, requestComponentRender } as unknown as TUI,
process.cwd(),
);
try {
requestRender.mockClear();
requestComponentRender.mockClear();
vi.advanceTimersByTime(500);
expect(requestRender).not.toHaveBeenCalled();
expect(requestComponentRender).not.toHaveBeenCalled();
} finally {
component.stopAnimation();
}
});
it("does not tick detached async bash result snapshots", () => {
vi.useFakeTimers();
const requestRender = vi.fn();
const requestComponentRender = vi.fn();
const component = new ToolExecutionComponent(
"bash",
{ command: "sleep 600", async: true },
{},
undefined,
{ requestRender, requestComponentRender } as unknown as TUI,
process.cwd(),
);
try {
component.updateResult(
{
content: [{ type: "text", text: "started background job" }],
details: {
command: "sleep 600",
async: { state: "running", jobId: "job-1", type: "bash" },
},
},
true,
);
requestRender.mockClear();
requestComponentRender.mockClear();
vi.advanceTimersByTime(500);
expect(requestRender).not.toHaveBeenCalled();
expect(requestComponentRender).not.toHaveBeenCalled();
} finally {
component.stopAnimation();
}
});
it("does not tick github pending previews whose Text is materialized per rebuild", () => {
vi.useFakeTimers();
const requestRender = vi.fn();
const requestComponentRender = vi.fn();
const component = new ToolExecutionComponent(
"github",
{ op: "run_watch", run: "12345" },
{},
undefined,
{ requestRender, requestComponentRender } as unknown as TUI,
process.cwd(),
);
try {
requestRender.mockClear();
requestComponentRender.mockClear();
vi.advanceTimersByTime(500);
expect(requestRender).not.toHaveBeenCalled();
expect(requestComponentRender).not.toHaveBeenCalled();
} finally {
component.stopAnimation();
}
});
it("does not tick custom tools whose pending label is a static tool-name Text", () => {
vi.useFakeTimers();
const requestRender = vi.fn();
const requestComponentRender = vi.fn();
// A renderResult-only custom tool renders the static tool-name label
// while pending, so the spinner interval must not start.
const tool = { name: "ext_tool", renderResult: () => undefined };
const component = new ToolExecutionComponent(
"ext_tool",
{ input: 1 },
{},
tool as never,
{ requestRender, requestComponentRender } as unknown as TUI,
process.cwd(),
);
try {
requestRender.mockClear();
requestComponentRender.mockClear();
vi.advanceTimersByTime(500);
expect(requestRender).not.toHaveBeenCalled();
expect(requestComponentRender).not.toHaveBeenCalled();
} finally {
component.stopAnimation();
}
});
it("pins the live vibe_wait wall and releases it after the final result", () => {
const component = new ToolExecutionComponent(
"vibe_wait",
{},
{},
undefined,
{ requestRender: vi.fn(), requestComponentRender: vi.fn() } as unknown as TUI,
process.cwd(),
);
const transcript = new TranscriptContainer();
transcript.addChild(component);
try {
transcript.render(80);
expect(transcript.isNativeScrollbackLiveRegionPinned()).toBe(true);
component.updateResult(
{
content: [{ type: "text", text: "No turns in flight to wait for." }],
details: { op: "wait", screens: [] },
},
false,
);
transcript.render(80);
expect(transcript.isNativeScrollbackLiveRegionPinned()).toBe(false);
} finally {
component.stopAnimation();
}
});
it("pins the displaceable hub waiting poll and releases it once jobs settle", () => {
const component = new ToolExecutionComponent(
"hub",
{ op: "wait" },
{},
undefined,
{ requestRender: vi.fn(), requestComponentRender: vi.fn() } as unknown as TUI,
process.cwd(),
);
const transcript = new TranscriptContainer();
transcript.addChild(component);
const runningJob = { id: "job_1", type: "task", status: "running", label: "Pr6450", durationMs: 12_600 };
try {
// Streaming waiting snapshot: every watched job still running.
component.updateResult(
{ content: [{ type: "text", text: "waiting" }], details: { jobs: [runningJob] } },
true,
);
transcript.render(80);
expect(transcript.isNativeScrollbackLiveRegionPinned()).toBe(true);
// Final snapshot with a settled job is a real result, not a poll frame.
component.updateResult(
{
content: [{ type: "text", text: "1 job settled" }],
details: { jobs: [{ ...runningJob, status: "completed" }] },
},
false,
);
transcript.render(80);
expect(transcript.isNativeScrollbackLiveRegionPinned()).toBe(false);
} finally {
component.stopAnimation();
}
});
});