import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
const PLUGIN_ID = "timeline-transform-e2e";
export const ASSISTANT_TEXT =
"Skill header\n\n# Instructions\n\nRead every paragraph and preserve the full message while this response is still streaming.";
const CLIENT_SOURCE = `import React, { useState } from "react";
import { Text, View, Pressable } from "react-native";
import { z } from "zod";
function Card({ item }) {
const [count, setCount] = useState(0);
return
{item.data.label + " " + item.data.phase}
{item.data.text}
setCount(count + 1)}>
{"Card clicks " + count}
;
}
export default function contribute(client) {
for (const itemType of ["assistant_message", "tool_call"]) {
client.addTimelineTransformer({
id: itemType.replaceAll("_", "-"),
query: { itemType },
transform({ item, phase }) {
return { items: [{ type: "plugin", kind: "card", version: 1, data: {
label: item.type === "tool_call" ? "Tool " + item.name : "Assistant",
text: item.type === "tool_call" ? item.callId : item.text,
phase,
} }] };
},
});
}
client.addTimelineRenderer({
kind: "card", version: 1,
schema: z.object({ label: z.string(), text: z.string(), phase: z.enum(["streaming", "complete"]) }),
Component: Card,
});
return () => {};
}`;
export async function withTimelinePlugin(
page: Page,
info: TestInfo,
mode: "assistant" | "tools",
run: (agent: MockAgentWorkspace) => Promise,
): Promise {
info.setTimeout(120_000);
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-plugin-",
title: "Timeline plugin regression",
model: "ten-second-stream",
featureValues:
mode === "assistant"
? {
mockStreamingAssistantResponse: ASSISTANT_TEXT,
mockStreamingAssistantIntervalMs: 300,
}
: {},
});
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
} finally {
await pluginClient.removePlugin(PLUGIN_ID);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await agent.cleanup();
await rm(directory, { recursive: true, force: true });
}
}
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise {
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
}
export async function interactWithStreamingCard(page: Page): Promise {
await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount(
1,
);
await page.getByRole("button", { name: "Card clicks 0", exact: true }).click();
await expect(page.getByText(/Skill header\s+# Instructions/)).toBeVisible();
await expect(page.getByRole("heading", { name: "Assistant streaming", exact: true })).toHaveCount(
1,
);
await expect(page.getByRole("button", { name: "Card clicks 1", exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toHaveCount(0);
}
export async function expectWholeCompletedCard(page: Page): Promise {
await expect(page.getByRole("heading", { name: "Assistant complete", exact: true })).toHaveCount(
1,
);
await expect(page.getByText(ASSISTANT_TEXT, { exact: true })).toBeVisible();
await expect(page.getByTestId("assistant-message")).toHaveCount(0);
}
export async function expectBothConsecutiveTools(page: Page): Promise {
// read and grep are consecutive source calls in the fixture. Overview used to
// send only the later call to the plugin, so the read card never existed.
const read = page.getByRole("heading", { name: "Tool read complete", exact: true }).first();
await read.scrollIntoViewIfNeeded();
await expect(read).toBeVisible();
const grep = page.getByRole("heading", { name: "Tool grep complete", exact: true }).first();
await grep.scrollIntoViewIfNeeded();
await expect(grep).toBeVisible();
}