From 1d203d780e19e7724c0e80441eb4850f87f404be Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:50:24 +0000 Subject: [PATCH] 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. --- .../app/e2e/browser/plugin-timeline.spec.ts | 15 ++++ .../e2e/support/helpers/plugin-timeline.ts | 79 +++++++++++++++++++ .../src/agent-stream/strategy-web.test.tsx | 75 +----------------- 3 files changed, 96 insertions(+), 73 deletions(-) diff --git a/packages/app/e2e/browser/plugin-timeline.spec.ts b/packages/app/e2e/browser/plugin-timeline.spec.ts index d75653298e..5596685b38 100644 --- a/packages/app/e2e/browser/plugin-timeline.spec.ts +++ b/packages/app/e2e/browser/plugin-timeline.spec.ts @@ -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); + }); +}); diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts index 98ab7d303b..9dc23f46bd 100644 --- a/packages/app/e2e/support/helpers/plugin-timeline.ts +++ b/packages/app/e2e/support/helpers/plugin-timeline.ts @@ -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, +): Promise { + 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 + {item.data.label} + ; +} +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 { + 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 { + 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); +} diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 25e29a30c0..5ad0be03c2 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -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) => ( - - ); - 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(), - 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("[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");