Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

487 lines
14 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useComposerLayout } from "./useComposerLayout";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("useComposerLayout", () => {
it("keeps embedded drafts in normal flow without dock measurement styles", () => {
const { result } = renderHook(() =>
useComposerLayout(
createComposerLayoutInput({
dockComposerMetrics: {
attachmentHeight: 72,
inputHeight: 152,
inputMaxHeight: 182,
textHeight: 56
},
isDockLayout: false,
isHeroLayout: false
})
)
);
expect(result.current.composerStyle).toBeUndefined();
expect(result.current.promptInputAreaStyle).toBeUndefined();
expect(result.current.showProjectRow).toBe(false);
});
it("runs hero composer animations only while the AgentGUI window is active", () => {
const promptTips = [
{ id: "tip-1", label: "First", prompt: "Prompt one" },
{ id: "tip-2", label: "Second", prompt: "Prompt two" }
];
const { result, rerender } = renderHook(
({ isActive }) =>
useComposerLayout(
createComposerLayoutInput({
isActive,
isHeroLayout: true,
promptTips
})
),
{ initialProps: { isActive: false } }
);
expect(result.current.rotatingPromptTips).toEqual([promptTips[0]]);
expect(result.current.promptTipStyle).toBeUndefined();
expect(result.current.showEdgeGlow).toBe(false);
rerender({ isActive: true });
expect(result.current.rotatingPromptTips).toEqual([
...promptTips,
promptTips[0]
]);
expect(result.current.promptTipStyle).toMatchObject({
"--agent-gui-prompt-tip-count": 2,
"--agent-gui-prompt-tip-cycle-duration": "10400ms"
});
expect(result.current.showEdgeGlow).toBe(true);
});
it("does not probe a locked project while its session is still being created", () => {
const { result } = renderHook(() =>
useComposerLayout(
createComposerLayoutInput({
projectMissingProbeEnabled: false,
composerSettings: {
...createComposerLayoutInput({}).composerSettings,
projectLocked: true
},
selectedProjectPath: "/workspace/project"
})
)
);
expect(result.current.showProjectMissingProbe).toBe(false);
});
it("measures prompt-tip overflow only after ResizeObserver delivers layout", () => {
const resizeObservers: ResizeObserverMock[] = [];
class ResizeObserverMock implements ResizeObserver {
constructor(readonly callback: ResizeObserverCallback) {
resizeObservers.push(this);
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
const promptTip = document.createElement("span");
const promptTipParent = document.createElement("div");
promptTipParent.appendChild(promptTip);
const scrollWidth = vi
.spyOn(promptTip, "scrollWidth", "get")
.mockReturnValue(160);
const clientWidth = vi
.spyOn(promptTip, "clientWidth", "get")
.mockReturnValue(120);
const setIsPromptTipOverflowing = vi.fn();
renderHook(() =>
useComposerLayout(
createComposerLayoutInput({
isHeroLayout: true,
promptTips: [{ id: "tip-1", label: "Label", prompt: "Prompt" }],
promptTipRef: { current: promptTip },
setIsPromptTipOverflowing
})
)
);
expect(scrollWidth).not.toHaveBeenCalled();
expect(clientWidth).not.toHaveBeenCalled();
expect(resizeObservers).toHaveLength(1);
act(() => {
resizeObservers[0]?.callback([], resizeObservers[0]);
});
expect(scrollWidth).toHaveBeenCalledTimes(1);
expect(clientWidth).toHaveBeenCalledTimes(1);
expect(setIsPromptTipOverflowing).toHaveBeenLastCalledWith(true);
});
it("coalesces content invalidations and does not observe animated heights", () => {
const resizeObservers: ResizeObserverMock[] = [];
const animationFrames: FrameRequestCallback[] = [];
class ResizeObserverMock implements ResizeObserver {
readonly observed = new Set<Element>();
constructor(readonly callback: ResizeObserverCallback) {
resizeObservers.push(this);
}
observe(target: Element): void {
this.observed.add(target);
}
unobserve(target: Element): void {
this.observed.delete(target);
}
disconnect(): void {
this.observed.clear();
}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
animationFrames.push(callback);
return animationFrames.length;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
const inputShell = document.createElement("div");
const inputArea = document.createElement("div");
const editor = document.createElement("div");
editor.className = "agent-gui-node__composer-textarea";
editor.style.paddingTop = "12px";
const firstParagraph = document.createElement("p");
const lastParagraph = document.createElement("p");
editor.append(firstParagraph, lastParagraph);
inputArea.appendChild(editor);
inputShell.appendChild(inputArea);
let contentHeight = 24;
vi.spyOn(firstParagraph, "getBoundingClientRect").mockImplementation(() =>
createRect({ bottom: 24, top: 0 })
);
vi.spyOn(lastParagraph, "getBoundingClientRect").mockImplementation(() =>
createRect({ bottom: contentHeight, top: contentHeight - 24 })
);
const editorScrollHeight = vi.spyOn(editor, "scrollHeight", "get");
const setDockComposerMetrics = vi.fn();
const input = createComposerLayoutInput({
promptInputAreaRef: { current: inputArea },
setDockComposerMetrics
});
const rendered = renderHook(() => useComposerLayout(input));
expect(editorScrollHeight).not.toHaveBeenCalled();
expect(resizeObservers).toHaveLength(1);
expect(resizeObservers[0]?.observed).toEqual(new Set([inputShell]));
expect(animationFrames).toHaveLength(1);
act(() => {
animationFrames[0]?.(0);
});
expect(editorScrollHeight).not.toHaveBeenCalled();
expect(
applyLastMetricsStateUpdate(setDockComposerMetrics, {
attachmentHeight: 0,
inputHeight: 56,
inputMaxHeight: 110,
textHeight: 56
})
).toEqual({
attachmentHeight: 0,
inputHeight: 56,
inputMaxHeight: 110,
textHeight: 56
});
contentHeight = 72;
act(() => {
rendered.result.current.invalidateComposerMeasurement();
rendered.result.current.invalidateComposerMeasurement();
});
expect(animationFrames).toHaveLength(2);
act(() => {
animationFrames[1]?.(16);
});
expect(
applyLastMetricsStateUpdate(setDockComposerMetrics, {
attachmentHeight: 0,
inputHeight: 56,
inputMaxHeight: 110,
textHeight: 56
}).inputHeight
).toBe(98);
contentHeight = 24;
act(() => {
rendered.result.current.invalidateComposerMeasurement();
});
expect(animationFrames).toHaveLength(3);
act(() => {
animationFrames[2]?.(32);
});
expect(
applyLastMetricsStateUpdate(setDockComposerMetrics, {
attachmentHeight: 0,
inputHeight: 98,
inputMaxHeight: 110,
textHeight: 98
}).inputHeight
).toBe(56);
});
it("remeasures only when the stable host width changes", () => {
const resizeObservers: ResizeObserverMock[] = [];
const animationFrames: FrameRequestCallback[] = [];
class ResizeObserverMock implements ResizeObserver {
constructor(readonly callback: ResizeObserverCallback) {
resizeObservers.push(this);
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
animationFrames.push(callback);
return animationFrames.length;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
const inputShell = document.createElement("div");
const inputArea = document.createElement("div");
inputShell.appendChild(inputArea);
renderHook(() =>
useComposerLayout(
createComposerLayoutInput({
promptInputAreaRef: { current: inputArea }
})
)
);
act(() => {
animationFrames[0]?.(0);
});
act(() => {
resizeObservers[0]?.callback(
[createResizeObserverEntry(inputShell, 600)],
resizeObservers[0]
);
});
expect(animationFrames).toHaveLength(2);
act(() => {
animationFrames[1]?.(16);
});
act(() => {
resizeObservers[0]?.callback(
[createResizeObserverEntry(inputShell, 600)],
resizeObservers[0]
);
});
expect(animationFrames).toHaveLength(2);
act(() => {
resizeObservers[0]?.callback(
[createResizeObserverEntry(inputShell, 540)],
resizeObservers[0]
);
});
expect(animationFrames).toHaveLength(3);
});
it("measures mixed draft attachments as one dock grid row", () => {
const animationFrames: FrameRequestCallback[] = [];
const observed = new Set<Element>();
class ResizeObserverMock implements ResizeObserver {
constructor(readonly callback: ResizeObserverCallback) {}
observe(target: Element): void {
observed.add(target);
}
unobserve(target: Element): void {
observed.delete(target);
}
disconnect(): void {
observed.clear();
}
}
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
animationFrames.push(callback);
return animationFrames.length;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
const inputShell = document.createElement("div");
const inputArea = document.createElement("div");
const attachmentDrafts = document.createElement("div");
attachmentDrafts.dataset.testid = "agent-gui-composer-attachment-drafts";
const quoteDrafts = document.createElement("div");
quoteDrafts.dataset.testid = "agent-gui-composer-quote-drafts";
const imageDrafts = document.createElement("div");
imageDrafts.dataset.testid = "agent-gui-composer-image-drafts";
attachmentDrafts.append(quoteDrafts, imageDrafts);
vi.spyOn(attachmentDrafts, "scrollHeight", "get").mockReturnValue(84);
const editor = document.createElement("div");
editor.className = "agent-gui-node__composer-textarea";
const paragraph = document.createElement("p");
editor.appendChild(paragraph);
vi.spyOn(paragraph, "getBoundingClientRect").mockReturnValue(
createRect({ bottom: 24, top: 0 })
);
inputArea.append(attachmentDrafts, editor);
inputShell.appendChild(inputArea);
const setDockComposerMetrics = vi.fn();
renderHook(() =>
useComposerLayout(
createComposerLayoutInput({
draftQuoteCount: 1,
promptInputAreaRef: { current: inputArea },
setDockComposerMetrics
})
)
);
expect(inputArea.children).toHaveLength(2);
expect(observed).toEqual(new Set([inputShell, attachmentDrafts]));
act(() => animationFrames[0]?.(0));
expect(
applyLastMetricsStateUpdate(setDockComposerMetrics, {
attachmentHeight: 0,
inputHeight: 56,
inputMaxHeight: 110,
textHeight: 56
})
).toEqual({
attachmentHeight: 84,
inputHeight: 164,
inputMaxHeight: 218,
textHeight: 56
});
});
});
function applyLastMetricsStateUpdate(
setter: ReturnType<typeof vi.fn>,
value: ComposerLayoutInput["dockComposerMetrics"]
) {
const update = setter.mock.calls.at(-1)?.[0] as
| ComposerLayoutInput["dockComposerMetrics"]
| ((
current: ComposerLayoutInput["dockComposerMetrics"]
) => ComposerLayoutInput["dockComposerMetrics"])
| undefined;
if (typeof update === "function") {
return update(value);
}
return update ?? value;
}
function createRect({ bottom, top }: { bottom: number; top: number }): DOMRect {
return {
bottom,
height: bottom - top,
left: 0,
right: 100,
top,
width: 100,
x: 0,
y: top,
toJSON: () => ({})
};
}
function createResizeObserverEntry(
target: Element,
width: number
): ResizeObserverEntry {
return {
borderBoxSize: [],
contentBoxSize: [],
contentRect: {
...createRect({ bottom: 0, top: 0 }),
right: width,
width
},
devicePixelContentBoxSize: [],
target
};
}
type ComposerLayoutInput = Parameters<typeof useComposerLayout>[0];
function createComposerLayoutInput(
overrides: Partial<ComposerLayoutInput>
): ComposerLayoutInput {
const isHeroLayout = overrides.isHeroLayout ?? false;
return {
isActive: true,
isDockLayout: !isHeroLayout,
isHeroLayout,
inputDisabled: false,
projectMissingProbeEnabled: true,
showFileMentionPalette: false,
showFloatingCommandMenu: false,
promptTips: [],
promptTipsPrefix: "Tip: ",
composerSettings: {
sessionSettings: null,
draftSettings: {
model: null,
reasoningEffort: null,
speed: null,
planMode: false
},
supportsModel: false,
supportsReasoningEffort: false,
supportsSpeed: false,
supportsPlanMode: false,
isSettingsLoading: false,
modelUnavailable: false,
reasoningUnavailable: false,
speedUnavailable: false,
availableModels: [],
availableReasoningEfforts: [],
availableSpeeds: [],
projectLocked: false,
projectPathIsRemote: false
},
selectedProjectPath: "",
promptTipRef: { current: null },
promptInputAreaRef: { current: null },
setIsPromptTipOverflowing: vi.fn(),
dockComposerMetrics: {
attachmentHeight: 0,
inputHeight: 56,
inputMaxHeight: 110,
textHeight: 56
},
setDockComposerMetrics: vi.fn(),
draftImages: [],
draftLargeTexts: [],
...overrides
};
}