mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Keep client plugin installations and local UI state alive through transient host disconnects. Reconcile changed bundles and removals on reconnect and refresh settings changed while offline. Connection switching still replaces the client and reinstalls plugins. Preserves the implementation from #6731, adds a failing-before/passing-after real browser regression, and corrects the English and Chinese lifecycle documentation. Verified with focused tests, browser reconnect journeys, Android debug offline interaction, root checks and green current-head CI. Co-authored-by: luke <luopeihuan@gmail.com> Co-authored-by: RoundRedDot <roundreddot@gmail.com>
249 lines
9.3 KiB
TypeScript
249 lines
9.3 KiB
TypeScript
import {
|
|
withRetainedPlugin,
|
|
editRetainedDraft,
|
|
expectRetainedDraft,
|
|
expectStoredPluginTitle,
|
|
hideRetainedPreferences,
|
|
showRetainedPreferences,
|
|
expectChangedPlugin,
|
|
expectRemovedPlugin,
|
|
} from "../support/helpers/plugin-reconnect";
|
|
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
|
|
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "../support/fixtures";
|
|
import { gotoAppShell } from "../support/helpers/app";
|
|
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
|
|
|
const PLUGIN_ID = "plugin-host-ui-e2e";
|
|
|
|
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
|
|
import { Icon, Modal, useToast, Markdown } from "@getpaseo/plugin/client/ui";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import React, { useState } from "react";
|
|
import { Pressable, Text, View } from "react-native";
|
|
|
|
function ModalBody({ onSaved }) {
|
|
usePaseo();
|
|
useQueryClient();
|
|
const toast = useToast();
|
|
|
|
function save() {
|
|
toast.show("Issue saved", { variant: "success" });
|
|
onSaved();
|
|
}
|
|
|
|
return <View>
|
|
<Text>Plugin modal contexts ready</Text>
|
|
<Markdown>{"## Plugin report"}</Markdown>
|
|
<Markdown>{"Checks **passed**. [Documentation](https://paseo.sh/docs/plugins)"}</Markdown>
|
|
<Markdown>{""}</Markdown>
|
|
<Pressable accessibilityRole="button" onPress={save}>
|
|
<Text>Save issue</Text>
|
|
</Pressable>
|
|
</View>;
|
|
}
|
|
|
|
function Surface({ playAudio }) {
|
|
const [audioStatus, setAudioStatus] = useState("Audio ready");
|
|
async function play(base64) {
|
|
setAudioStatus("Playing audio");
|
|
try { await playAudio({ base64, mimeType: "audio/wav" }); setAudioStatus("Audio finished"); }
|
|
catch { setAudioStatus("Audio rejected"); }
|
|
}
|
|
const [open, setOpen] = useState(false);
|
|
return <View>
|
|
<Text>{audioStatus}</Text>
|
|
<Pressable accessibilityRole="button" onPress={() => play(AUDIO_BASE64)}><Text>Play plugin audio</Text></Pressable>
|
|
<Pressable accessibilityRole="button" onPress={() => play("UklGRg==")}><Text>Play invalid audio</Text></Pressable>
|
|
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
|
|
<View style={{ flexDirection: "row" }}>
|
|
<Icon name="Pencil" size={18} />
|
|
<Text>Open plugin modal</Text>
|
|
</View>
|
|
</Pressable>
|
|
<Modal
|
|
title="Edit plugin issue"
|
|
icon={<Icon name="Pencil" size={18} />}
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
>
|
|
<Modal.Content>
|
|
<ModalBody onSaved={() => setOpen(false)} />
|
|
</Modal.Content>
|
|
</Modal>
|
|
</View>;
|
|
}
|
|
|
|
export default function contribute(plugin) {
|
|
plugin.addSurface("main", () => <Surface playAudio={plugin.playAudio} />);
|
|
plugin.addSidebarItem({
|
|
id: "main",
|
|
title: "Host UI",
|
|
icon: "PanelsTopLeft",
|
|
surface: "main",
|
|
});
|
|
return () => {};
|
|
}`;
|
|
|
|
async function openHostUiPlugin(page: Page): Promise<void> {
|
|
await gotoAppShell(page);
|
|
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
|
}
|
|
|
|
async function useNonCompactLayout(page: Page): Promise<void> {
|
|
await page.setViewportSize({ width: 1100, height: 800 });
|
|
}
|
|
|
|
async function useCompactLayout(page: Page): Promise<void> {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
}
|
|
|
|
async function openPluginModal(page: Page): Promise<void> {
|
|
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
|
|
}
|
|
|
|
async function expectPluginMarkdown(page: Page): Promise<void> {
|
|
await expect(page.getByRole("heading", { name: "Plugin report" })).toBeVisible();
|
|
await expect(page.getByText("Checks passed.", { exact: false })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: "Documentation", exact: true })).toHaveAttribute(
|
|
"href",
|
|
"https://paseo.sh/docs/plugins",
|
|
);
|
|
}
|
|
|
|
async function expectCenteredPluginDialog(page: Page): Promise<void> {
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
|
await expect(dialog.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
|
await expectPluginMarkdown(page);
|
|
}
|
|
|
|
async function closeCenteredPluginDialog(page: Page): Promise<void> {
|
|
const dialog = page.getByRole("dialog");
|
|
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
|
await expect(dialog).not.toBeVisible();
|
|
}
|
|
|
|
async function expectCompactPluginSheet(page: Page): Promise<void> {
|
|
await expect(page.getByText("Edit plugin issue", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).toBeVisible();
|
|
await expectPluginMarkdown(page);
|
|
}
|
|
|
|
async function savePluginIssue(page: Page): Promise<void> {
|
|
await page.getByRole("button", { name: "Save issue", exact: true }).click();
|
|
await expect(page.getByText("Issue saved", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("Plugin modal contexts ready", { exact: true })).not.toBeVisible();
|
|
}
|
|
|
|
test("plugin modal adapts its presentation and preserves host contexts", async ({ page }) => {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-host-ui-e2e-"));
|
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
|
const previousConfig = await client.getDaemonConfig();
|
|
await writeFile(
|
|
path.join(directory, "paseo-plugin.json"),
|
|
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
|
);
|
|
const audio = await readFile(path.resolve(__dirname, "../../assets/audio/thinking-tone.wav"));
|
|
await writeFile(
|
|
path.join(directory, "index.client.tsx"),
|
|
`const AUDIO_BASE64 = ${JSON.stringify(audio.toString("base64"))};\n${PLUGIN_SOURCE}`,
|
|
);
|
|
|
|
try {
|
|
await client.patchDaemonConfig({ pluginsEnabled: true });
|
|
await client.installDirectoryPlugin(directory);
|
|
await useNonCompactLayout(page);
|
|
await openHostUiPlugin(page);
|
|
|
|
await test.step("plugin audio finishes, rejects corrupt files, and recovers", async () => {
|
|
await playPluginAudio(page, "Play plugin audio", "Audio finished");
|
|
await playPluginAudio(page, "Play invalid audio", "Audio rejected");
|
|
await playPluginAudio(page, "Play plugin audio", "Audio finished");
|
|
});
|
|
|
|
await test.step("non-compact layouts use a centered dialog", async () => {
|
|
await openPluginModal(page);
|
|
await expectCenteredPluginDialog(page);
|
|
await closeCenteredPluginDialog(page);
|
|
});
|
|
|
|
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
|
|
await useCompactLayout(page);
|
|
await openPluginModal(page);
|
|
await expectCompactPluginSheet(page);
|
|
await savePluginIssue(page);
|
|
});
|
|
} finally {
|
|
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
|
await client
|
|
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
|
.catch(() => undefined);
|
|
await client.close().catch(() => undefined);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function playPluginAudio(page: Page, button: string, result: string) {
|
|
await page.getByRole("button", { name: button, exact: true }).click();
|
|
await expect(page.getByText(result, { exact: true })).toBeVisible();
|
|
}
|
|
|
|
test("plugin content and host labels follow app language without losing an open draft", async ({
|
|
page,
|
|
}, info) => {
|
|
test.setTimeout(180_000);
|
|
await verifyPluginTranslations(page, info);
|
|
});
|
|
|
|
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
await withRetainedPlugin(page, async (probe) => {
|
|
await probe.open();
|
|
await editRetainedDraft(page, "Connected draft", 0);
|
|
|
|
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
|
|
await probe.disconnect();
|
|
await expectRetainedDraft(page, "Connected draft", 1);
|
|
await editRetainedDraft(page, "Offline draft", 1);
|
|
await probe.changeStoredTitle("Saved by another client");
|
|
await expectStoredPluginTitle(page, "Agent monitor");
|
|
await probe.reconnect();
|
|
await expectStoredPluginTitle(page, "Saved by another client");
|
|
await expectRetainedDraft(page, "Offline draft", 2);
|
|
});
|
|
|
|
await test.step("inactive settings refresh without replacing the installation", async () => {
|
|
await hideRetainedPreferences(page);
|
|
await probe.disconnect();
|
|
await probe.changeStoredTitle("Saved while preferences hidden");
|
|
await probe.reconnect();
|
|
await showRetainedPreferences(page);
|
|
await expectStoredPluginTitle(page, "Saved while preferences hidden");
|
|
await expectRetainedDraft(page, "Offline draft", 2);
|
|
});
|
|
|
|
await test.step("changed code replaces the retained installation after reconnect", async () => {
|
|
await probe.disconnect();
|
|
await probe.changeCode();
|
|
await expectRetainedDraft(page, "Offline draft", 2);
|
|
await probe.reconnect();
|
|
await expectChangedPlugin(page);
|
|
await editRetainedDraft(page, "New code draft", 0);
|
|
});
|
|
|
|
await test.step("removal is reconciled after reconnect", async () => {
|
|
await probe.disconnect();
|
|
await probe.remove();
|
|
await expectRetainedDraft(page, "New code draft", 1);
|
|
await probe.reconnect();
|
|
await expectRemovedPlugin(page);
|
|
});
|
|
});
|
|
});
|