mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
test(plugins): verify repeated timeline IDs in a real browser
Exercise the installed plugin through the real daemon and chat viewport. Read the oldest virtualized replacement before and after reload, using actual browser row heights instead of extending the legacy JSDOM harness.
This commit is contained in:
@@ -9,6 +9,9 @@ import {
|
||||
expectBothConsecutiveTools,
|
||||
trackPluginChatRecovery,
|
||||
reconnectPluginChat,
|
||||
withRepeatedOutputTimeline,
|
||||
readOldestPluginReplacement,
|
||||
expectOldestPluginReplacementReadable,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
for (const width of [1100, 390]) {
|
||||
@@ -54,3 +57,15 @@ test("inline-thinking example renders a streamed thought through host UI", async
|
||||
}, info) => {
|
||||
await viewInlineThinkingExample(page, info);
|
||||
});
|
||||
|
||||
test("keeps older plugin replacements readable when source messages reuse local output IDs", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await withRepeatedOutputTimeline(page, info, async () => {
|
||||
await readOldestPluginReplacement(page);
|
||||
await expectOldestPluginReplacementReadable(page);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await readOldestPluginReplacement(page);
|
||||
await expectOldestPluginReplacementReadable(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { expectReconnectingToastVisible } from "./workspace-ui";
|
||||
import { userScrollsTimelineToHistoryStart } from "./timeline-pagination";
|
||||
|
||||
const PLUGIN_ID = "timeline-transform-e2e";
|
||||
|
||||
@@ -367,3 +368,81 @@ export async function viewInlineThinkingExample(page: Page, info: TestInfo): Pro
|
||||
await example.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reuse plugin-local IDs across distinct source messages in a real virtualized chat. */
|
||||
export async function withRepeatedOutputTimeline(
|
||||
page: Page,
|
||||
info: TestInfo,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
info.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-local-ids-",
|
||||
title: "Repeated plugin output IDs",
|
||||
});
|
||||
try {
|
||||
for (const label of ["First", "Second", "Third"]) {
|
||||
await agent.client.sendAgentMessage(
|
||||
agent.agentId,
|
||||
`${label}: emit 1 coalesced agent stream updates`,
|
||||
);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await withDirectoryPlugin({
|
||||
pluginId: "timeline-local-ids",
|
||||
source: `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { z } from "zod";
|
||||
function Row({ item }) {
|
||||
return <View style={{ height: item.data.tall ? 120 : 24 }}>
|
||||
<Text>{item.data.label}</Text>
|
||||
</View>;
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addTimelineTransformer({
|
||||
id: "local-ids", query: { itemType: "user_message" },
|
||||
transform({ item }) {
|
||||
const label = item.text.split(":")[0];
|
||||
return { items: Array.from({ length: 60 }, (_, index) => ({
|
||||
type: "plugin", id: "part-" + index, kind: "reply", version: 1,
|
||||
data: { label: label + " replacement " + index, tall: label === "Second" },
|
||||
})) };
|
||||
},
|
||||
});
|
||||
client.addTimelineRenderer({
|
||||
kind: "reply", version: 1,
|
||||
schema: z.object({ label: z.string(), tall: z.boolean() }), Component: Row,
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
run: async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
|
||||
await run();
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readOldestPluginReplacement(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Third replacement 59", { exact: true }).first()).toBeAttached();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
return page.getByText("First replacement 0", { exact: true }).isVisible();
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function expectOldestPluginReplacementReadable(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("agent-chat-scroll")
|
||||
.locator("[data-index]")
|
||||
.getByText("First replacement 0", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
|
||||
await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
|
||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
@@ -56,11 +55,10 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
|
||||
};
|
||||
}
|
||||
|
||||
function MeasuredTestRow({ item, measuredHeight }: { item: StreamItem; measuredHeight?: number }) {
|
||||
function MeasuredTestRow({ item }: { item: StreamItem }) {
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const load = React.useCallback(() => setLoaded(true), []);
|
||||
const height =
|
||||
measuredHeight ?? (item.kind === "user_message" && item.text === "grown" ? 120 : 24);
|
||||
const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24;
|
||||
const measure = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!node?.parentElement) return;
|
||||
@@ -254,75 +252,6 @@ describe("createWebStreamStrategy", () => {
|
||||
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
|
||||
});
|
||||
|
||||
it("settles row measurement when a plugin reuses output IDs across source messages", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
const renderRow = (rowItem: StreamItem) => (
|
||||
<MeasuredTestRow
|
||||
item={rowItem}
|
||||
measuredHeight={rowItem.kind === "plugin" && rowItem.sourceId === "message-2" ? 120 : 24}
|
||||
/>
|
||||
);
|
||||
const historyVirtualized = projectPluginTimelineItems(
|
||||
[userMessage(0), userMessage(1), userMessage(2), userMessage(3), userMessage(4)],
|
||||
({ sourceId }) =>
|
||||
sourceId === "message-2" || sourceId === "message-3"
|
||||
? [
|
||||
{
|
||||
type: "plugin",
|
||||
id: "part-0",
|
||||
pluginId: "split",
|
||||
kind: "part",
|
||||
version: 1,
|
||||
data: null,
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
);
|
||||
const input: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: { historyVirtualized, historyMounted: [], liveHead: [] },
|
||||
boundary: { hasVirtualizedHistory: true, hasMountedHistory: false, hasLiveHead: false },
|
||||
renderers: {
|
||||
renderHistoryVirtualizedRow: renderRow,
|
||||
renderHistoryMountedRow: renderRow,
|
||||
renderLiveHeadRow: renderRow,
|
||||
renderLiveAuxiliary: () => null,
|
||||
},
|
||||
listEmptyComponent: null,
|
||||
viewportRef: React.createRef<StreamViewportHandle>(),
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: () => {},
|
||||
onNearHistoryStart: () => true,
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
};
|
||||
|
||||
// Before the fix, this render aborted with "Maximum update depth exceeded".
|
||||
act(() => root?.render(strategy.render(input)));
|
||||
|
||||
const rows = [...container.querySelectorAll<HTMLElement>("[data-history-row-id]")];
|
||||
expect(rows.map((row) => row.dataset.historyRowId)).toEqual(
|
||||
historyVirtualized.map((row) => row.id),
|
||||
);
|
||||
expect(rows.map((row) => row.style.transform)).toEqual([
|
||||
"translateY(0px)",
|
||||
"translateY(24px)",
|
||||
"translateY(48px)",
|
||||
"translateY(168px)",
|
||||
"translateY(192px)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains a row's DOM and local state when recent history becomes virtualized", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
container = document.createElement("div");
|
||||
|
||||
Reference in New Issue
Block a user