259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, unknown> = {
|
|
"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);
|
|
});
|
|
});
|