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:
paseo-bot[bot]
2026-11-01 22:50:24 +00:00
parent b6511ea5f9
commit 1d203d780e
3 changed files with 96 additions and 73 deletions
@@ -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");