/** * Focused plugin-authoring docs captures. This generator is skipped in CI. * It installs the packaged Bitbucket plugin into an isolated E2E backend so * every image exercises the public plugin contract and host-owned UI. * * From apps/web, set CAPTURE_DOCS_MEDIA=1, DOCS_CODE_HOST_PLUGIN_PACKAGE to * the packaged plugin, and DOCS_SCREENSHOTS_DIR to a staging directory; then * run this spec through `pnpm e2e:run --host --project chromium -- ...`. */ import fs from "node:fs"; import path from "node:path"; import type { Locator, Page } from "@playwright/test"; import { expect, test } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; import { CODE_HOST_PULL_REQUEST as PULL_REQUEST, CODE_HOST_REVIEW_KEY as REVIEW_KEY, } from "./plugin-code-host-docs-fixture"; const CAPTURE = process.env.CAPTURE_DOCS_MEDIA === "1"; const PLUGIN_ID = "kandev-plugin-bitbucket"; const PACKAGE_PATH = process.env.DOCS_CODE_HOST_PLUGIN_PACKAGE?.trim(); const FIXTURE_ID = "kandev-plugin-e2e"; const FIXTURE_PACKAGE = path.resolve( __dirname, "../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz", ); const SCREENSHOTS_DIR = process.env.DOCS_SCREENSHOTS_DIR ? path.resolve(process.env.DOCS_SCREENSHOTS_DIR) : path.resolve(__dirname, "../../../../../docs/screenshots"); async function installPlugin(page: Page, id: string, packagePath: string): Promise { await page.goto("/settings/plugins"); await page.getByTestId("install-plugin-trigger").click(); await page.getByTestId("install-plugin-tab-upload").click(); await page.getByTestId("install-plugin-file-input").setInputFiles(packagePath); await page.getByTestId("install-plugin-upload-submit").click(); const row = page.getByTestId(`plugin-row-${id}`); await expect(row).toBeVisible({ timeout: 15_000 }); await expect(row.getByText("Active", { exact: true })).toBeVisible(); } async function settle(page: Page): Promise { await page.evaluate(async () => { await document.fonts.ready; const finite = document.getAnimations().filter((animation) => { const iterations = animation.effect?.getComputedTiming().iterations; return typeof iterations === "number" && Number.isFinite(iterations); }); await Promise.all(finite.map((animation) => animation.finished.catch(() => undefined))); }); } async function focusedShot( page: Page, name: string, locators: Locator[], padding = 16, bottomPadding = padding, ): Promise { await settle(page); fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); const boxes = []; for (const locator of locators) { for (let index = 0; index < (await locator.count()); index += 1) { const item = locator.nth(index); if (await item.isVisible()) { const box = await item.boundingBox(); if (box) boxes.push(box); } } } if (boxes.length === 0) throw new Error(`No visible capture target for ${name}`); const viewport = page.viewportSize(); if (!viewport) throw new Error("Viewport unavailable"); const left = Math.max(0, Math.min(...boxes.map((box) => box.x)) - padding); const top = Math.max(0, Math.min(...boxes.map((box) => box.y)) - padding); const right = Math.min( viewport.width, Math.max(...boxes.map((box) => box.x + box.width)) + padding, ); const bottom = Math.min( viewport.height, Math.max(...boxes.map((box) => box.y + box.height)) + bottomPadding, ); await page.screenshot({ path: path.join(SCREENSHOTS_DIR, `plugin-code-host-${name}.png`), clip: { x: left, y: top, width: right - left, height: bottom - top }, }); } async function mockBitbucket(page: Page, taskId: string): Promise { const repositories = { repositories: [ { provider_id: "bitbucket", provider_host: "bitbucket.org", owner_or_project: "northstar-labs", provider_repository_id: "northstar-labs/relay", name: "relay", clone_url: "https://bitbucket.org/northstar-labs/relay.git", default_branch: "main", }, ], }; const queue = { pull_requests: [PULL_REQUEST] }; await page.route(`**/api/plugins/${PLUGIN_ID}/actions/**`, async (route) => { const action = new URL(route.request().url()).pathname.split("/").at(-1) ?? ""; const request = route.request().postDataJSON() as { body?: { view?: string } } | null; const responses: Record = { "connection.get": { state: "connected", healthy: true, product: "cloud" }, "repositories.list": repositories, "pullrequests.queue": queue, "pullrequests.search": queue, "pullrequests.associations": { associations: [ { review_key: REVIEW_KEY, task_id: taskId, task_title: "Audit export retention controls", }, ], }, "watches.get": { watches: [] }, }; let response = responses[action]; if (action === "pullrequests.get") { response = request?.body?.view === "task" ? queue : PULL_REQUEST; } if (response === undefined) return route.continue(); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(response), }); }); } test.describe("Plugin code-host docs screenshots", () => { test.skip(!CAPTURE || !PACKAGE_PATH, "set CAPTURE_DOCS_MEDIA and DOCS_CODE_HOST_PLUGIN_PACKAGE"); test.afterEach(async ({ apiClient }) => { await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); await apiClient.rawRequest("DELETE", `/api/plugins/${FIXTURE_ID}`).catch(() => undefined); }); test("captures host-owned code-host surfaces", async ({ testPage, apiClient, seedData }) => { test.setTimeout(150_000); await testPage.setViewportSize({ width: 1440, height: 900 }); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Audit export retention controls", seedData.agentProfileId, { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }, ); if (!task.session_id || !PACKAGE_PATH) throw new Error("Capture task or package unavailable"); await mockBitbucket(testPage, task.id); await installPlugin(testPage, PLUGIN_ID, PACKAGE_PATH); await testPage.goto("/bitbucket"); const workbench = testPage.getByTestId("bitbucket-workbench"); await expect(workbench.getByText(PULL_REQUEST.title)).toBeVisible(); await focusedShot(testPage, "dashboard", [ workbench.getByTestId("bitbucket-scope-bar"), workbench.getByTestId("bitbucket-list-toolbar"), workbench.getByTestId("bitbucket-pr-queue"), ]); const kanban = new KanbanPage(testPage); await kanban.goto(); await kanban.createTaskButton.first().click(); await testPage.getByTestId("source-mode-remote").click(); await testPage.getByTestId("remote-repo-chip-trigger").first().click(); const repository = testPage .getByTestId("remote-repo-option") .filter({ hasText: "northstar-labs/relay" }); await expect(repository).toBeVisible(); await focusedShot(testPage, "repository-picker", [testPage.getByRole("dialog"), repository]); await testPage.getByRole("button", { name: "Cancel" }).click(); await kanban.openTaskContextMenu(task.id); const link = testPage.getByTestId("task-context-link"); await link.focus(); await testPage.keyboard.press("ArrowRight"); const bitbucketLink = testPage.getByTestId( "task-context-link-plugin-kandev-plugin-bitbucket:link-pull-request", ); await expect(bitbucketLink).toBeVisible(); await focusedShot(testPage, "link-menu", [testPage.locator('[role="menu"]:visible')]); await bitbucketLink.click(); const linkDialog = testPage.getByRole("dialog", { name: "Link Bitbucket pull request" }); await expect(linkDialog).toBeVisible(); await focusedShot(testPage, "link-dialog", [linkDialog]); await testPage.keyboard.press("Escape"); await testPage.goto("/"); const taskRow = testPage .getByTestId("sidebar-task-item") .filter({ hasText: "Audit export retention controls" }); const icon = taskRow.getByTestId(`registered-change-request-task-icon-${task.id}`); await icon.hover(); const summary = testPage.locator('[data-testid="pr-task-status-summary"]:visible').first(); await expect(summary).toBeVisible(); await expect(icon).toHaveClass(/text-sky-400/); await focusedShot(testPage, "sidebar-status", [taskRow, summary]); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); const chip = testPage.getByTestId("integration-change-request-status-chip").first(); await expect(chip).toBeVisible(); await chip.hover(); const statusPopover = testPage.getByTestId("integration-change-request-status-popover"); await expect(statusPopover).toBeVisible(); await focusedShot(testPage, "ci-popover", [chip, statusPopover]); await testPage.keyboard.press("Escape"); const topbar = testPage.getByTestId("integration-change-request-status-trigger").first(); await topbar.click(); const review = testPage.getByTestId("change-request-detail"); await expect(review).toBeVisible(); await focusedShot(testPage, "review-panel", [review]); }); test("captures dynamic composer reference search", async ({ testPage, apiClient, seedData }) => { test.setTimeout(120_000); await testPage.setViewportSize({ width: 1100, height: 760 }); await installPlugin(testPage, FIXTURE_ID, FIXTURE_PACKAGE); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Provider-neutral reference search", seedData.agentProfileId, { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }, ); if (!task.session_id) throw new Error("Reference capture task has no session"); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); const editor = session.activeChat().locator(".tiptap.ProseMirror:visible").first(); await editor.fill(""); await editor.pressSequentially("#Provider-neutral"); const option = testPage.getByRole("option", { name: /Pull request #42/ }); await expect(option).toBeVisible(); await focusedShot(testPage, "reference-search", [editor, option], 32, 4); }); });