d94bdfa1bb
- Exported stopSharedSpinnerTicker() and wired it into InteractiveMode.stop(): a live block missed by per-component stopAnimation kept the shared 80ms interval alive as a lingering event-loop handle; the spinner suite uses it to observe a freshly armed ticker instead of one leaked by earlier files - Title-disposal tests now save/clear/restore PI_NO_TITLE (main() in ACP/RPC mode sets it process-wide), matching the prewarm and orphan-submit precedent
271 lines
8.3 KiB
TypeScript
271 lines
8.3 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import { stripVTControlCharacters } from "node:util";
|
|
import {
|
|
SPINNER_RENDER_INTERVAL_MS,
|
|
stopSharedSpinnerTicker,
|
|
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();
|
|
});
|
|
|
|
// Earlier test files may leak live blocks (components never stopAnimation'd),
|
|
// which keeps the shared ticker armed on a REAL interval and makes these
|
|
// fake-timer assertions observe a pre-existing timer instead of a fresh one.
|
|
beforeEach(() => {
|
|
stopSharedSpinnerTicker();
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
|
|
// Regression (issue #8731): concurrent live tool blocks — e.g. parallel task
|
|
// subagents — must share ONE spinner timer, not one per block, or active-work
|
|
// CPU scales with block count.
|
|
it("drives every concurrent live block from a single shared spinner timer", () => {
|
|
vi.useFakeTimers();
|
|
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
|
|
const renders = [vi.fn(), vi.fn(), vi.fn()];
|
|
const components = renders.map(
|
|
requestComponentRender =>
|
|
new ToolExecutionComponent(
|
|
"eval",
|
|
{ language: "py", code: "import time\ntime.sleep(10)" },
|
|
{},
|
|
undefined,
|
|
{ requestRender: vi.fn(), requestComponentRender } as unknown as TUI,
|
|
process.cwd(),
|
|
),
|
|
);
|
|
|
|
try {
|
|
const spinnerTimers = setIntervalSpy.mock.calls.filter(([, ms]) => ms === SPINNER_RENDER_INTERVAL_MS).length;
|
|
// One shared ticker for all three live blocks, not three.
|
|
expect(spinnerTimers).toBe(1);
|
|
|
|
// A single tick repaints every registered block in lockstep.
|
|
vi.advanceTimersByTime(SPINNER_RENDER_INTERVAL_MS);
|
|
for (const requestComponentRender of renders) {
|
|
expect(requestComponentRender).toHaveBeenCalledTimes(1);
|
|
}
|
|
} finally {
|
|
for (const component of components) component.stopAnimation();
|
|
}
|
|
});
|
|
});
|