Files
kandev/apps/web/e2e/tests/plugins/mobile-plugin-modal.spec.ts
T
2026-09-16 10:38:31 +01:00

114 lines
4.7 KiB
TypeScript

/**
* Mobile-Chrome geometry coverage for opaque content rendered by
* `PluginModalHost`. The fixture is installed through the real upload flow so
* the test exercises the packaged `host.openModal` contract on a phone.
*/
import { expect, test } from "../../fixtures/test-base";
import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture";
test.describe("Mobile plugin modal content", () => {
test.afterEach(async ({ apiClient }) => {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
});
test("keeps long plugin content scrollable and dismissible", async ({ testPage, prCapture }) => {
test.setTimeout(120_000);
await installFixturePlugin(testPage);
await testPage.goto("/");
await testPage.reload();
// The route registration and the global shortcut dispatcher are fed by
// separate React updates. Wait for the store's active-plugin snapshot so
// the dispatcher has the manifest keybinding before sending the shortcut.
await testPage.waitForFunction(
(pluginId) => {
const store = (
window as Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
plugins?: { loaded: boolean; items: Array<{ id: string; status: string }> };
};
};
}
).__KANDEV_E2E_STORE__;
const plugins = store?.getState().plugins;
return Boolean(
plugins?.loaded &&
plugins.items.some((plugin) => plugin.id === pluginId && plugin.status === "active"),
);
},
PLUGIN_ID,
{ timeout: 30_000 },
);
await testPage.goto("/plugins/e2e-hello");
await expect(testPage.locator("#hello-plugin-page")).toBeVisible({ timeout: 15_000 });
await testPage.keyboard.press("ControlOrMeta+Shift+J");
const dialog = testPage.getByRole("dialog", { name: "Demo Modal" });
const body = dialog.locator('[data-testid^="plugin-modal-body-"]');
const title = dialog.locator('[data-slot="dialog-title"]');
const close = dialog.locator('[data-slot="dialog-close"]');
const finalAction = dialog.getByTestId("hello-long-modal-final-action");
await expect(dialog).toBeVisible({ timeout: 15_000 });
await expect(body).toHaveCount(1);
await expect(finalAction).toBeVisible();
await dialog.evaluate(async (element) => {
const animations = element.getAnimations({ subtree: true }).filter((animation) => {
if (animation.playState !== "running") return false;
const iterations = animation.effect?.getComputedTiming().iterations;
return typeof iterations === "number" && Number.isFinite(iterations);
});
await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined)));
});
const metrics = await body.evaluate((element) => {
const node = element as HTMLElement;
return { clientHeight: node.clientHeight, scrollHeight: node.scrollHeight };
});
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
const viewport = await testPage.evaluate(() => ({ width: innerWidth, height: innerHeight }));
for (const [label, locator] of [
["dialog", dialog],
["title", title],
["close", close],
] as const) {
const box = await locator.boundingBox();
if (!box) throw new Error(`${label} has no layout box`);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
}
await body.evaluate((element) => {
const node = element as HTMLElement;
node.scrollTop = node.scrollHeight;
});
await expect
.poll(() => body.evaluate((element) => (element as HTMLElement).scrollTop))
.toBeGreaterThan(0);
await expect(finalAction).toBeInViewport();
const finalBox = await finalAction.boundingBox();
if (!finalBox) throw new Error("final plugin action has no layout box");
expect(finalBox.y).toBeGreaterThanOrEqual(0);
expect(finalBox.y + finalBox.height).toBeLessThanOrEqual(viewport.height);
await finalAction.tap();
await expect(finalAction).toHaveText("Plugin modal action complete");
if (prCapture.capturing) {
await prCapture.screenshot("long-plugin-modal", {
caption:
"Mobile plugin modal content stays inside one scroll body with a reachable final action.",
});
}
await close.tap();
await expect(dialog).not.toBeVisible();
const overflow = await testPage.evaluate(
() => document.documentElement.scrollWidth > innerWidth,
);
expect(overflow).toBe(false);
});
});