/** * Pixel 5 coverage for `PluginComposerCapability` * (docs/specs/plugins/requirements/voice-extraction-host.md, "Mobile Design Contract"). * * Capability parity, not layout parity: the same action, the same insertion * and the same native submit have to work by touch, from the shipped mobile * composer toolbar and the shipped full-height dialogs. There is no separate * plugin drawer to open. * * Filename matches /mobile-.*\.spec\.ts/ so the `mobile-chrome` project picks * it up. */ import { expect, test } from "../../fixtures/test-base"; import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture"; import { SessionPage } from "../../pages/session-page"; import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; import type { ApiClient } from "../../helpers/api-client"; import type { Locator, Page } from "@playwright/test"; const DICTATED = "DICTATED"; async function uninstallViaApi(apiClient: ApiClient): Promise { await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); } function action(scope: Page | Locator): Locator { return scope.getByTestId("e2e-composer-action").first(); } /** * What the host owes a plugin action on a phone: a place in the shipped * composer toolbar whose hit box is fully on screen and not covered by a * native control. The button's own size is the plugin's business — the Voice * plugin, for instance, grows its control to 40px on a coarse pointer — and * this fixture keeps its buttons deliberately tiny so five of them fit where * a real plugin puts one. */ async function expectTouchReachable(page: Page, button: Locator): Promise { await expect(button).toBeVisible(); const box = await button.boundingBox(); expect(box, "the action must have a hit box").not.toBeNull(); expect(box!.width).toBeGreaterThan(0); expect(box!.height).toBeGreaterThan(0); const viewport = page.viewportSize(); expect(viewport).not.toBeNull(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width); // Nothing native may sit on top of it: a tap has to land on the action. const hit = await page.evaluate( ({ x, y }) => { const el = document.elementFromPoint(x, y); return el?.closest("[data-testid='e2e-composer-action']") !== null; }, { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }, ); expect(hit, "a native control is intercepting taps on the plugin action").toBe(true); } /** * Walks left from the end rather than using Control+Home, which is a no-op * under Android emulation and silently leaves the caret at the end. The * insertion then appends and the selection-honouring assertion passes for the * wrong reason. */ async function caretBackFromEnd(target: Locator, steps: number): Promise { await target.click(); for (let i = 0; i < steps; i++) await target.press("ArrowLeft"); } async function expectNoHorizontalOverflow(page: Page): Promise { expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe( await page.evaluate(() => document.documentElement.clientWidth), ); } test.describe("Mobile plugin composer actions", () => { test.describe.configure({ retries: 1, timeout: 120_000 }); test.afterEach(async ({ apiClient }) => { await uninstallViaApi(apiClient); }); test("task chat: the action is touch-reachable and native submit sends the dictated draft", async ({ testPage, apiClient, seedData, }) => { await installFixturePlugin(testPage); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Mobile composer capability", seedData.agentProfileId, { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }, ); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); const editor = await session.composerReady(); const composerAction = action(session.activeChat()); await expect(composerAction).toHaveAttribute("data-surface", "task-chat"); await expect(composerAction).toHaveAttribute("data-presentation", "mobile"); await expectTouchReachable(testPage, composerAction.getByTestId("e2e-composer-insert")); await editor.fill("head tail"); await caretBackFromEnd(editor, " tail".length); await composerAction.getByTestId("e2e-composer-insert").tap(); await expect(editor).toHaveText(`head ${DICTATED} tail`); await composerAction.getByTestId("e2e-composer-submit").tap(); await expect(composerAction).toHaveAttribute("data-status", "submitted"); await session.expectChatResponseVisible(`head ${DICTATED} tail`); // The composer toolbar still owns its own row: adding a plugin action // must not push the page sideways. await expectNoHorizontalOverflow(testPage); }); test("task creation: the action rides the prompt toolbar in the responsive dialog", async ({ testPage, }) => { await installFixturePlugin(testPage); // The shipped mobile entry point: the global rail is hidden on a phone, // so task creation starts from the board's floating action button. const kanban = new MobileKanbanPage(testPage); await kanban.goto(); await kanban.mobileFab.tap(); const dialog = testPage.getByTestId("create-task-dialog"); await expect(dialog).toBeVisible(); const description = dialog.getByTestId("task-description-input"); await expect(description).toBeVisible(); const composerAction = action(dialog); await expect(composerAction).toHaveAttribute("data-surface", "task-create"); await expect(composerAction).toHaveAttribute("data-presentation", "mobile"); await expectTouchReachable(testPage, composerAction.getByTestId("e2e-composer-insert")); await description.fill("head tail"); await caretBackFromEnd(description, " tail".length); await composerAction.getByTestId("e2e-composer-insert").tap(); await expect(description).toHaveValue(`head ${DICTATED} tail`); // The dialog still owns its own scrolling and the page does not pan. await expectNoHorizontalOverflow(testPage); }); test("new session: the action is reachable inside the full-height dialog and launches a session", async ({ testPage, apiClient, seedData, }) => { await installFixturePlugin(testPage); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Mobile new session composer", seedData.agentProfileId, { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }, ); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); const before = (await apiClient.listTaskSessions(task.id)).sessions.length; await session.openMobileNewSessionDialog(); const dialog = session.newSessionDialog(); await expect(dialog).toBeVisible(); const description = dialog.getByTestId("task-description-input"); await expect(description).toBeVisible(); const composerAction = action(dialog); await expect(composerAction).toHaveAttribute("data-surface", "new-session"); await expect(composerAction).toHaveAttribute("data-presentation", "mobile"); await expectTouchReachable(testPage, composerAction.getByTestId("e2e-composer-insert")); await description.fill("head tail"); await caretBackFromEnd(description, " tail".length); await composerAction.getByTestId("e2e-composer-insert").tap(); await expect(description).toHaveValue(`head ${DICTATED} tail`); await composerAction.getByTestId("e2e-composer-submit").tap(); await expect(composerAction).toHaveAttribute("data-status", "submitted"); await expect .poll(async () => (await apiClient.listTaskSessions(task.id)).sessions.length, { timeout: 60_000, }) .toBeGreaterThan(before); await expectNoHorizontalOverflow(testPage); }); });