Files
kandev/apps/web/e2e/tests/plugins/bitbucket-plugin-contract.spec.ts
T
2026-08-16 21:13:41 +00:00

207 lines
9.5 KiB
TypeScript

import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { PrAssetCapture } from "../../helpers/pr-asset-capture";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
const PLUGIN_ID = "kandev-plugin-e2e";
const PACKAGE_PATH = path.resolve(
__dirname,
"../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz",
);
async function installFixture(page: Page): 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(PACKAGE_PATH);
await page.getByTestId("install-plugin-upload-submit").click();
await expect(page.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 15_000 });
}
function visibleEditor(scope: Locator | Page): Locator {
return scope.locator(".tiptap.ProseMirror:visible").first();
}
async function expectPersistedReference(
apiClient: ApiClient,
sessionId: string,
referenceID: string,
): Promise<void> {
await expect
.poll(
async () => {
const { messages } = await apiClient.listSessionMessages(sessionId);
return messages.some(
(message) =>
Array.isArray(message.metadata?.entity_references) &&
message.metadata.entity_references.some(
(reference) =>
typeof reference === "object" &&
reference !== null &&
(reference as Record<string, unknown>).id === referenceID,
),
);
},
{ timeout: 15_000, message: `wait for fixture reference ${referenceID}` },
)
.toBe(true);
}
test.describe("Bitbucket plugin contract", () => {
test.afterEach(async ({ apiClient }) => {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
});
test("runs the packaged second-provider contract through native host surfaces", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
test.setTimeout(120_000);
const capture = new PrAssetCapture(testPage, testInfo.file);
await installFixture(testPage);
// A declared browser action is authenticated by the host. The fixture
// exposes Bitbucket-shaped labels but receives only generic context.
await testPage.goto("/plugins/e2e-hello");
await testPage.reload();
await testPage.getByTestId("fixture-connection-status").click();
await expect(testPage.getByTestId("fixture-connection-result")).toHaveText("Connected");
// The manifest-declared repository provider participates in the existing
// native picker and contributes branches plus PR URL inspection.
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();
await testPage.getByTestId("remote-repo-option").filter({ hasText: "TEAM/fixture" }).click();
await expect(testPage.getByTestId("remote-repo-chip-trigger").first()).toContainText(
"TEAM/fixture",
);
await expect(testPage.getByTestId("remote-branch-chip-trigger")).toContainText("main");
await testPage.getByRole("button", { name: "Cancel" }).click();
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Bitbucket plugin contract task",
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("fixture contract task has no session");
// Native Link submenu action invokes the declared task-scoped action.
await kanban.goto();
await kanban.openTaskContextMenu(task.id);
const linkSubmenu = testPage.getByTestId("task-context-link");
await linkSubmenu.focus();
await testPage.keyboard.press("ArrowRight");
const bitbucketLink = testPage.getByTestId(
"task-context-link-plugin-kandev-plugin-e2e:link-bitbucket-pull-request",
);
await expect(bitbucketLink).toHaveText("Bitbucket Pull Request");
await expect(bitbucketLink.getByTestId("fixture-bitbucket-icon")).toBeVisible();
await bitbucketLink.click();
const linkDialog = testPage.getByRole("dialog", { name: "Link Bitbucket pull request" });
await expect(linkDialog).toContainText("Use a Bitbucket pull request URL for this task.");
await linkDialog.getByRole("button", { name: "Save" }).click();
await expect(linkDialog.getByTestId("fixture-link-pull-request-error")).toHaveText(
"Enter a Bitbucket pull request URL.",
);
await linkDialog
.getByLabel("Pull request")
.fill("https://bitbucket.example.test/projects/TEAM/repos/fixture/pull-requests/42");
await linkDialog.getByRole("button", { name: "Save" }).click();
await expect(linkDialog).toBeHidden();
await expect(
testPage.getByText("Bitbucket pull request linked", { exact: true }),
).toBeVisible();
// Desktop sessions expose provider reviews as Dockview panels. The mobile
// companion covers the shared multi-review selector.
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
// Desktop sessions expose provider reviews as Dockview tabs, rather than
// the center-layout's semantic Review tab used on smaller screens.
await testPage.getByText("Bitbucket Pull Request #42", { exact: true }).click();
await expect(testPage.getByTestId("fixture-review-panel-desktop")).toHaveAttribute(
"data-review-key",
"pull-request-42",
);
await capture.screenshot("desktop-native-review", {
caption: "Desktop native review panel supplied by a source-control plugin",
});
capture.flush();
// A candidate that is revoked after search must fail at send time and the
// error must not disclose the fixture's credential value. Exercise this
// first because entity-source menus dispose their selected source after a
// successful submission.
await session.clickSessionChatTab();
await session.waitForChatIdle({ timeout: 30_000 });
const editor = visibleEditor(session.activeChat());
await editor.fill("");
await editor.pressSequentially("#revoked");
await testPage.getByRole("option", { name: /Pull request #99/ }).click();
await expect(session.activeChat().getByTestId("entity-reference-chip")).toBeVisible();
await Promise.all([
expect(testPage.getByText("Message send status unknown", { exact: true })).toBeVisible(),
session.activeChat().getByTestId("submit-message-button").click(),
]);
await expect(testPage.locator("body")).not.toContainText("fixture-credential-secret");
// Search returns a manifest-owned source; a selected good reference is
// reauthorized by the live plugin during submission before metadata persists.
await editor.fill("");
await editor.pressSequentially("#Provider-neutral");
await testPage.getByRole("option", { name: /Pull request #42/ }).click();
await expect(session.activeChat().getByTestId("entity-reference-chip")).toBeVisible();
await session.activeChat().getByTestId("submit-message-button").click();
await expectPersistedReference(apiClient, task.session_id, "pull-request-42");
// A plugin-owned watch action creates a host task; the host stamps its
// provenance, while the fixture only requests a generic task write.
const watch = await apiClient.rawRequest(
"POST",
`/api/plugins/${PLUGIN_ID}/actions/watch-create-task`,
{ workspaceId: seedData.workspaceId },
);
expect(watch.ok).toBe(true);
const watchBody = (await watch.json()) as { task_id?: string; watch_created?: boolean };
expect(watchBody.watch_created).toBe(true);
expect(watchBody.task_id).toBeTruthy();
const watchedTask = await apiClient.rawRequest("GET", `/api/v1/tasks/${watchBody.task_id}`);
expect(watchedTask.ok).toBe(true);
expect((await watchedTask.json()) as { metadata?: { source?: string } }).toMatchObject({
metadata: { source: `plugin:${PLUGIN_ID}` },
});
// Disable/re-enable must revoke all native registrations then restore them
// from the package without leaving a stale provider or review panel.
await testPage.goto("/settings/plugins");
const row = testPage.getByTestId(`plugin-row-${PLUGIN_ID}`);
await row.getByRole("button", { name: "Disable" }).click();
await expect(row.getByText("Disabled", { exact: true })).toBeVisible();
await testPage.goto(`/t/${task.id}`);
await expect(testPage.getByTestId("fixture-review-panel-desktop")).toHaveCount(0);
await testPage.goto("/settings/plugins");
await row.getByRole("button", { name: "Enable" }).click();
await expect(row.getByText("Active", { exact: true })).toBeVisible();
await testPage.goto(`/t/${task.id}`);
// Re-enabling restores the provider registration, but must not override
// the native per-session rule that a previously offered review panel stays
// dismissed until the user opens it again.
await testPage.getByText("Bitbucket Pull Request #42", { exact: true }).click();
await expect(testPage.getByTestId("fixture-review-panel-desktop")).toBeVisible();
});
});