test: port pipelines tutorial e2e (#9149)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Pipelines are a control-plane workflow surface where operators need confidence that setup, intake, board movement, review, and learning flows keep working. > - The tutorial flow is broad enough that regressions can slip through unit tests when UI state, route behavior, and workflow fixtures drift apart. > - End-to-end coverage gives reviewers a realistic smoke path through the pipelines tutorial experience. > - This pull request ports the pipelines tutorial flow into Playwright and updates the variable flow covered by the scenario. > - The benefit is stronger browser-level regression coverage for a high-value onboarding/workflow path. ## Linked Issues or Issue Description No public GitHub issue exists. Inline feature request: **Subsystem affected** Cross-cutting: `tests/e2e`, server startup, and the pipelines UI workflow. **Problem or motivation** The pipelines tutorial flow lacked focused browser-level regression coverage for setup, intake, board movement, detail/review surfaces, and learning flow behavior. This left a broad user-facing workflow dependent on narrower unit coverage. **Proposed solution** Add a Playwright spec that boots a throwaway Paperclip instance and exercises the tutorial flow across setup, intake, board movement, item detail, review queue, learnings, agent fan-out, drift acknowledgement gates, child-terminal gates, and stale approvals. **Alternatives considered** Unit tests alone are faster but do not validate the integrated browser workflow. A release-smoke-only path would be broader than necessary for this tutorial-specific coverage. **Roadmap alignment** This supports the roadmap theme of easier onboarding and stronger first-run confidence without adding a new core feature surface. **Additional context** The local host used for this PR is missing Chromium’s `libatk-1.0.so.0` dependency, so the full browser run needs CI or a machine with Playwright system dependencies installed. ## What Changed - Added a Playwright e2e spec covering the pipelines tutorial flow. - Covered agent fan-out, drift acknowledgement gates, child-terminal gates, stale approvals, setup/intake, board movement, item detail, review queue, and learnings. - Added Playwright config support needed by the new tutorial flow. - Updated the tutorial variable flow assertions in the ported spec. ## Verification - Attempted `/srv/paperclip/home/paperclipai/paperclip/node_modules/.bin/playwright test --config tests/e2e/playwright.config.ts tests/e2e/pipelines-tutorial-flow.spec.ts` - Local result: the throwaway Paperclip server booted and `covers agent fan-out, drift acknowledgement gates, child-terminal gates, and stale approvals` passed. - Local blocker: the second Chromium test failed before executing because this host is missing the Playwright system library `libatk-1.0.so.0`. CI should run this on an image with browser dependencies installed. ## Risks Medium risk for CI duration/flakiness because this adds browser-level coverage over a broad workflow. The test is intentionally scoped to one spec file and uses the dedicated e2e config/server path. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5.5 coding agent with repository tool use and local shell execution. Context window was not surfaced by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
19454ce385
commit
f42a66bf04
|
|
@ -0,0 +1,661 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { expect, request as pwRequest, test, type APIRequestContext, type APIResponse, type Locator, type Page } from "@playwright/test";
|
||||
import { createLocalAgentJwt } from "../../server/src/agent-auth-jwt";
|
||||
|
||||
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
|
||||
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
type Stage = { id: string; key: string; name: string; kind: string; position: number };
|
||||
type PipelineDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
stages: Stage[];
|
||||
transitions: Array<{ fromStageId: string; toStageId: string }>;
|
||||
};
|
||||
type CaseSummary = {
|
||||
id: string;
|
||||
caseKey: string;
|
||||
title: string;
|
||||
version: number;
|
||||
stageId: string;
|
||||
parentCaseVersion?: number | null;
|
||||
requestKey?: string | null;
|
||||
childCount?: number;
|
||||
terminalChildCount?: number;
|
||||
};
|
||||
type CaseRow = { case: CaseSummary; stage?: Stage };
|
||||
type CaseDetail = CaseRow & {
|
||||
pipeline: { id: string; key: string; name: string };
|
||||
blockers?: Array<{ caseId: string; blockedByCaseId: string }>;
|
||||
};
|
||||
|
||||
async function expectOk(response: APIResponse, label: string) {
|
||||
if (!response.ok()) {
|
||||
throw new Error(`${label} failed: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectError(response: APIResponse, label: string, status: number, code: string, message?: RegExp) {
|
||||
const body = await response.json() as { code?: string; error?: string; message?: string };
|
||||
expect(response.status(), `${label} status: ${JSON.stringify(body)}`).toBe(status);
|
||||
expect(body.code, `${label} code: ${JSON.stringify(body)}`).toBe(code);
|
||||
if (message) expect(body.error ?? body.message ?? "", `${label} message`).toMatch(message);
|
||||
}
|
||||
|
||||
async function createCompany(board: APIRequestContext) {
|
||||
const response = await board.post("/api/companies", {
|
||||
data: { name: `E2E Pipelines ${Date.now()}` },
|
||||
});
|
||||
await expectOk(response, "create company");
|
||||
return response.json() as Promise<{ id: string; issuePrefix: string }>;
|
||||
}
|
||||
|
||||
async function createCompanyAgent(
|
||||
board: APIRequestContext,
|
||||
companyId: string,
|
||||
input: { name: string; role: string; title?: string; capabilities?: string },
|
||||
) {
|
||||
const response = await board.post(`/api/companies/${companyId}/agents`, {
|
||||
data: {
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
title: input.title,
|
||||
capabilities: input.capabilities,
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: process.execPath, args: ["-e", "process.stdout.write('ok\\n')"] },
|
||||
},
|
||||
});
|
||||
await expectOk(response, `create ${input.name}`);
|
||||
return response.json() as Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
async function createPipelineWriterAgentKey(board: APIRequestContext, companyId: string) {
|
||||
await createCompanyAgent(board, companyId, {
|
||||
name: "Pipeline CEO",
|
||||
role: "ceo",
|
||||
title: "CEO",
|
||||
capabilities: "Approves agent join requests during e2e setup.",
|
||||
});
|
||||
|
||||
const inviteResponse = await board.post(`/api/companies/${companyId}/invites`, {
|
||||
data: {
|
||||
allowedJoinTypes: "agent",
|
||||
defaultsPayload: {
|
||||
agent: {
|
||||
grants: [
|
||||
{ permissionKey: "pipelines:write", scope: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await expectOk(inviteResponse, "create pipeline-writer invite");
|
||||
const invite = await inviteResponse.json() as { token: string };
|
||||
|
||||
const acceptResponse = await board.post(`/api/invites/${invite.token}/accept`, {
|
||||
data: {
|
||||
requestType: "agent",
|
||||
agentName: "Pipeline Fanout Agent",
|
||||
adapterType: "process",
|
||||
capabilities: "Creates pipeline child work during e2e coverage.",
|
||||
},
|
||||
});
|
||||
await expectOk(acceptResponse, "accept pipeline-writer invite");
|
||||
const accepted = await acceptResponse.json() as { id: string };
|
||||
|
||||
const approveResponse = await board.post(`/api/companies/${companyId}/join-requests/${accepted.id}/approve`);
|
||||
await expectOk(approveResponse, "approve pipeline-writer join");
|
||||
const approved = await approveResponse.json() as { createdAgentId: string };
|
||||
|
||||
const runId = randomUUID();
|
||||
const token = createLocalAgentJwt(approved.createdAgentId, companyId, "process", runId);
|
||||
if (!token) throw new Error("PAPERCLIP_AGENT_JWT_SECRET is required for pipeline writer JWT setup");
|
||||
return { agentId: approved.createdAgentId, runId, token };
|
||||
}
|
||||
|
||||
async function createPipeline(board: APIRequestContext, companyId: string) {
|
||||
const response = await board.post(`/api/companies/${companyId}/pipelines`, {
|
||||
data: {
|
||||
key: "content-production",
|
||||
name: "Content production",
|
||||
description: "Draft, review, and publish launch content.",
|
||||
enforceTransitions: true,
|
||||
stages: [
|
||||
{ key: "drafting", name: "Drafting", kind: "working", position: 0, config: { variables: [] } },
|
||||
{ key: "published", name: "Published", kind: "done", position: 2, config: { variables: [] } },
|
||||
{ key: "dropped", name: "Dropped", kind: "cancelled", position: 3, config: { variables: [] } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await expectOk(response, "create pipeline");
|
||||
return response.json() as Promise<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
async function createPrimitivePipeline(board: APIRequestContext, companyId: string) {
|
||||
const response = await board.post(`/api/companies/${companyId}/pipelines`, {
|
||||
data: {
|
||||
key: `primitive-gates-${Date.now()}`,
|
||||
name: "Primitive gates",
|
||||
stages: [
|
||||
{ key: "intake", name: "Intake", kind: "open", position: 0 },
|
||||
{ key: "fanout", name: "Fan out", kind: "working", position: 100, config: { requireChildrenTerminal: true } },
|
||||
{
|
||||
key: "dependent_work",
|
||||
name: "Dependent Work",
|
||||
kind: "working",
|
||||
position: 200,
|
||||
config: { requireNoUnresolvedDrift: true },
|
||||
},
|
||||
{
|
||||
key: "review",
|
||||
name: "Review",
|
||||
kind: "review",
|
||||
position: 300,
|
||||
config: {
|
||||
approveToStageKey: "approved",
|
||||
rejectToStageKey: "cancelled",
|
||||
requestChangesToStageKey: "dependent_work",
|
||||
requireRejectReason: true,
|
||||
reviewerKind: "human",
|
||||
},
|
||||
},
|
||||
{ key: "approved", name: "Approved", kind: "working", position: 400 },
|
||||
{ key: "done", name: "Done", kind: "done", position: 900 },
|
||||
{ key: "cancelled", name: "Cancelled", kind: "cancelled", position: 1000 },
|
||||
],
|
||||
},
|
||||
});
|
||||
await expectOk(response, "create primitive pipeline");
|
||||
return response.json() as Promise<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
async function getPipeline(board: APIRequestContext, pipelineId: string): Promise<PipelineDetail> {
|
||||
const response = await board.get(`/api/pipelines/${pipelineId}`);
|
||||
await expectOk(response, "get pipeline");
|
||||
return response.json() as Promise<PipelineDetail>;
|
||||
}
|
||||
|
||||
async function listItems(board: APIRequestContext, pipelineId: string): Promise<CaseRow[]> {
|
||||
const response = await board.get(`/api/pipelines/${pipelineId}/cases`);
|
||||
await expectOk(response, "list pipeline items");
|
||||
return response.json() as Promise<CaseRow[]>;
|
||||
}
|
||||
|
||||
async function createItem(
|
||||
board: APIRequestContext,
|
||||
pipelineId: string,
|
||||
data: {
|
||||
title: string;
|
||||
caseKey?: string;
|
||||
stageKey?: string;
|
||||
parentCaseId?: string;
|
||||
requestKey?: string;
|
||||
blockedByCaseIds?: string[];
|
||||
fields?: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
const response = await board.post(`/api/pipelines/${pipelineId}/cases`, {
|
||||
data: {
|
||||
caseKey: data.caseKey,
|
||||
title: data.title,
|
||||
stageKey: data.stageKey,
|
||||
parentCaseId: data.parentCaseId,
|
||||
requestKey: data.requestKey,
|
||||
blockedByCaseIds: data.blockedByCaseIds,
|
||||
fields: data.fields ?? {},
|
||||
},
|
||||
});
|
||||
await expectOk(response, `create item ${data.title}`);
|
||||
const body = await response.json() as { case: CaseSummary; created?: boolean };
|
||||
return body.case;
|
||||
}
|
||||
|
||||
async function getItem(board: APIRequestContext, caseId: string): Promise<CaseDetail> {
|
||||
const response = await board.get(`/api/cases/${caseId}`);
|
||||
await expectOk(response, "get item detail");
|
||||
return response.json() as Promise<CaseDetail>;
|
||||
}
|
||||
|
||||
async function getItemVersion(board: APIRequestContext, caseId: string) {
|
||||
const detail = await getItem(board, caseId);
|
||||
return detail.case.version;
|
||||
}
|
||||
|
||||
async function moveItem(
|
||||
board: APIRequestContext,
|
||||
caseId: string,
|
||||
toStageKey: string,
|
||||
options: { reason?: string; force?: boolean } = {},
|
||||
) {
|
||||
const expectedVersion = await getItemVersion(board, caseId);
|
||||
const response = await board.post(`/api/cases/${caseId}/transition`, {
|
||||
data: { toStageKey, expectedVersion, reason: options.reason, force: options.force },
|
||||
});
|
||||
await expectOk(response, `move item to ${toStageKey}`);
|
||||
}
|
||||
|
||||
async function suggestMove(board: APIRequestContext, caseId: string, toStageKey: string, rationale: string) {
|
||||
const response = await board.post(`/api/cases/${caseId}/suggest-transition`, {
|
||||
data: { toStageKey, rationale },
|
||||
});
|
||||
await expectOk(response, "seed transition suggestion");
|
||||
}
|
||||
|
||||
async function expectProsumerVocabulary(page: Page) {
|
||||
const text = await page.locator("body").innerText();
|
||||
expect(text).not.toMatch(/\bcase\b/i);
|
||||
expect(text).not.toMatch(/\breview_decided\b|\btransition_forced\b/);
|
||||
expect(text).not.toMatch(/\b(?:400|401|403|404|409|422|500)\b/);
|
||||
}
|
||||
|
||||
async function dragCardToColumn(page: Page, itemTitle: string, fromColumn: string, toColumn: string) {
|
||||
const card = page.getByLabel(`${fromColumn} column`).getByText(itemTitle, { exact: true });
|
||||
const column = page.getByLabel(`${toColumn} column`);
|
||||
await expect(card).toBeVisible();
|
||||
await expect(column).toBeVisible();
|
||||
|
||||
const cardBox = await card.boundingBox();
|
||||
const columnBox = await column.boundingBox();
|
||||
if (!cardBox || !columnBox) return false;
|
||||
|
||||
await page.mouse.move(cardBox.x + cardBox.width / 2, cardBox.y + cardBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(columnBox.x + columnBox.width / 2, columnBox.y + Math.max(88, columnBox.height / 2), { steps: 25 });
|
||||
await page.mouse.up();
|
||||
|
||||
try {
|
||||
await expect(page.getByRole("dialog")).toBeVisible({ timeout: 3_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function reviewQueueRow(page: Page, title: string): Locator {
|
||||
return page.getByRole("link").filter({ hasText: title }).first();
|
||||
}
|
||||
|
||||
test.describe("Pipelines tutorial UI flow", () => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
test("covers agent fan-out, drift acknowledgement gates, child-terminal gates, and stale approvals", async () => {
|
||||
const board = await pwRequest.newContext({ baseURL: BASE_URL });
|
||||
const company = await createCompany(board);
|
||||
const pipeline = await createPrimitivePipeline(board, company.id);
|
||||
const key = await createPipelineWriterAgentKey(board, company.id);
|
||||
const agentApi = await pwRequest.newContext({
|
||||
baseURL: BASE_URL,
|
||||
extraHTTPHeaders: {
|
||||
Authorization: `Bearer ${key.token}`,
|
||||
"X-Paperclip-Run-Id": key.runId,
|
||||
},
|
||||
});
|
||||
|
||||
const parent = await createItem(board, pipeline.id, {
|
||||
caseKey: "fanout-parent",
|
||||
title: "Fan-out parent",
|
||||
stageKey: "fanout",
|
||||
fields: { expectedChildren: 2, release: "v1" },
|
||||
});
|
||||
|
||||
const childA = await createItem(agentApi, pipeline.id, {
|
||||
caseKey: "asset-a",
|
||||
title: "Asset A",
|
||||
stageKey: "dependent_work",
|
||||
parentCaseId: parent.id,
|
||||
requestKey: "fanout:asset-a",
|
||||
fields: { asset: "hero", briefedFromVersion: parent.version },
|
||||
});
|
||||
expect(childA.parentCaseVersion).toBe(parent.version);
|
||||
expect(childA.requestKey).toBe("fanout:asset-a");
|
||||
|
||||
const retryResponse = await agentApi.post(`/api/pipelines/${pipeline.id}/cases`, {
|
||||
data: {
|
||||
caseKey: "asset-a-retry",
|
||||
title: "Duplicate Asset A",
|
||||
stageKey: "dependent_work",
|
||||
parentCaseId: parent.id,
|
||||
requestKey: "fanout:asset-a",
|
||||
fields: { asset: "changed" },
|
||||
},
|
||||
});
|
||||
await expectOk(retryResponse, "retry request-key child create");
|
||||
const retry = await retryResponse.json() as { case: CaseSummary; created: boolean };
|
||||
expect(retry.created).toBe(false);
|
||||
expect(retry.case.id).toBe(childA.id);
|
||||
|
||||
const childB = await createItem(agentApi, pipeline.id, {
|
||||
caseKey: "asset-b",
|
||||
title: "Asset B",
|
||||
stageKey: "dependent_work",
|
||||
parentCaseId: parent.id,
|
||||
requestKey: "fanout:asset-b",
|
||||
blockedByCaseIds: [childA.id],
|
||||
fields: { asset: "social", briefedFromVersion: parent.version },
|
||||
});
|
||||
const blockedDetail = await getItem(board, childB.id);
|
||||
expect(blockedDetail.blockers?.map((blocker) => blocker.blockedByCaseId)).toContain(childA.id);
|
||||
|
||||
await expectError(
|
||||
await board.post(`/api/cases/${parent.id}/transition`, {
|
||||
data: { toStageKey: "done", expectedVersion: parent.version },
|
||||
}),
|
||||
"parent children-terminal gate",
|
||||
409,
|
||||
"children_not_terminal",
|
||||
/Asset A/,
|
||||
);
|
||||
|
||||
await expectError(
|
||||
await agentApi.post(`/api/cases/${childB.id}/transition`, {
|
||||
data: { toStageKey: "approved", expectedVersion: childB.version },
|
||||
}),
|
||||
"blocked sibling sequencing",
|
||||
409,
|
||||
"blocked",
|
||||
);
|
||||
|
||||
const changedA = await board.patch(`/api/cases/${childA.id}`, {
|
||||
data: {
|
||||
expectedVersion: childA.version,
|
||||
fields: { asset: "hero", briefedFromVersion: parent.version, materialChange: "new art direction" },
|
||||
},
|
||||
});
|
||||
await expectOk(changedA, "materially edit upstream child");
|
||||
const editedA = await changedA.json() as CaseSummary;
|
||||
|
||||
await moveItem(agentApi, childA.id, "done");
|
||||
await expectError(
|
||||
await agentApi.post(`/api/cases/${childB.id}/transition`, {
|
||||
data: { toStageKey: "review", expectedVersion: childB.version },
|
||||
}),
|
||||
"unacknowledged drift gate",
|
||||
409,
|
||||
"unresolved_drift",
|
||||
/not acknowledged/,
|
||||
);
|
||||
|
||||
const ackResponse = await board.post(`/api/cases/${childB.id}/acknowledge-drift`, {
|
||||
data: { expectedVersion: childB.version },
|
||||
});
|
||||
await expectOk(ackResponse, "acknowledge drift");
|
||||
expect((await ackResponse.json() as { acknowledged: boolean }).acknowledged).toBe(true);
|
||||
await moveItem(agentApi, childB.id, "done");
|
||||
|
||||
const refreshedParent = await getItem(board, parent.id);
|
||||
expect(refreshedParent.case.childCount).toBe(2);
|
||||
expect(refreshedParent.case.terminalChildCount).toBe(2);
|
||||
await moveItem(board, parent.id, "done");
|
||||
|
||||
const reviewCase = await createItem(board, pipeline.id, {
|
||||
caseKey: "review-pinned",
|
||||
title: "Revision-pinned review",
|
||||
stageKey: "review",
|
||||
fields: { revision: "first" },
|
||||
});
|
||||
const approval = await board.post(`/api/cases/${reviewCase.id}/review`, {
|
||||
data: { decision: "approve", expectedVersion: reviewCase.version },
|
||||
});
|
||||
await expectOk(approval, "approve review case");
|
||||
const approved = await approval.json() as { case: CaseSummary };
|
||||
|
||||
const changedAfterApproval = await board.patch(`/api/cases/${reviewCase.id}`, {
|
||||
data: { expectedVersion: approved.case.version, fields: { revision: "materially changed" } },
|
||||
});
|
||||
await expectOk(changedAfterApproval, "edit after review approval");
|
||||
const changedReview = await changedAfterApproval.json() as CaseSummary;
|
||||
|
||||
await expectError(
|
||||
await board.post(`/api/cases/${reviewCase.id}/transition`, {
|
||||
data: { toStageKey: "done", expectedVersion: changedReview.version },
|
||||
}),
|
||||
"stale approval publish gate",
|
||||
409,
|
||||
"review_outdated",
|
||||
/changed since review approval/,
|
||||
);
|
||||
await moveItem(board, reviewCase.id, "review");
|
||||
const rereviewVersion = await getItemVersion(board, reviewCase.id);
|
||||
const rereview = await board.post(`/api/cases/${reviewCase.id}/review`, {
|
||||
data: { decision: "approve", expectedVersion: rereviewVersion },
|
||||
});
|
||||
await expectOk(rereview, "approve rereviewed case");
|
||||
const rereviewed = await rereview.json() as { case: CaseSummary };
|
||||
await moveItem(board, reviewCase.id, "done");
|
||||
|
||||
const finalEvents = await board.get(`/api/cases/${childB.id}/events`);
|
||||
await expectOk(finalEvents, "child drift events");
|
||||
const eventTypes = ((await finalEvents.json()) as { items: Array<{ type: string }> }).items.map((event) => event.type);
|
||||
expect(eventTypes).toContain("upstream_drift");
|
||||
expect(eventTypes).toContain("drift_acknowledged");
|
||||
expect(editedA.version).toBeGreaterThan(childA.version);
|
||||
expect(rereviewed.case.version).toBeGreaterThan(changedReview.version);
|
||||
|
||||
await agentApi.dispose();
|
||||
await board.dispose();
|
||||
});
|
||||
|
||||
test("walks setup, intake, board moves, item detail, review queue, and learnings", async ({ page }) => {
|
||||
const board = await pwRequest.newContext({ baseURL: BASE_URL });
|
||||
const company = await createCompany(board);
|
||||
await createCompanyAgent(board, company.id, {
|
||||
name: "Pipeline Writer",
|
||||
role: "engineer",
|
||||
title: "Pipeline Writer",
|
||||
capabilities: "Runs pipeline drafting automation during e2e coverage.",
|
||||
});
|
||||
const pipeline = await createPipeline(board, company.id);
|
||||
await expectOk(
|
||||
await board.patch("/api/instance/settings/experimental", { data: { enablePipelines: true } }),
|
||||
"enable pipelines experimental flag",
|
||||
);
|
||||
|
||||
try {
|
||||
await page.goto("/");
|
||||
await page.evaluate((companyId) => {
|
||||
window.localStorage.setItem("paperclip.selectedCompanyId", companyId);
|
||||
}, company.id);
|
||||
const companyPath = `/${company.issuePrefix}`;
|
||||
|
||||
await page.goto(`${companyPath}/pipelines/${pipeline.id}/settings`);
|
||||
await expect(page.getByLabel("Pipeline name")).toHaveValue("Content production");
|
||||
|
||||
await page.getByRole("button", { name: "Pick agent" }).click();
|
||||
await page.getByPlaceholder("Search agents...").fill("Pipeline Writer");
|
||||
await page.getByRole("button", { name: "Pipeline Writer", exact: true }).click();
|
||||
|
||||
await page.getByLabel("Issue title template").fill("{{content_type}} draft");
|
||||
const contentTypeVariable = page
|
||||
.getByText("{{content_type}}", { exact: true })
|
||||
.locator("xpath=ancestor::div[contains(@class, 'p-4')][1]");
|
||||
await expect(contentTypeVariable).toBeVisible();
|
||||
await contentTypeVariable.locator("input").first().fill("Content type");
|
||||
await contentTypeVariable.getByRole("combobox").first().click();
|
||||
await page.getByRole("option", { name: "select" }).click();
|
||||
await contentTypeVariable.locator("input:not([type='checkbox'])").nth(1).fill("Blog post, Changelog entry, Launch tweet");
|
||||
await page.getByRole("button", { name: "Save stage" }).click();
|
||||
await expect(page.getByText("Stage saved").first()).toBeVisible();
|
||||
|
||||
await expectOk(
|
||||
await board.post(`/api/pipelines/${pipeline.id}/stages`, {
|
||||
data: {
|
||||
key: "assets",
|
||||
name: "Assets",
|
||||
kind: "review",
|
||||
position: 1,
|
||||
config: {
|
||||
variables: [],
|
||||
requireApproval: true,
|
||||
approver: { kind: "any_human" },
|
||||
approveToStageKey: "published",
|
||||
rejectToStageKey: "dropped",
|
||||
requestChangesToStageKey: "drafting",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"create assets review stage",
|
||||
);
|
||||
await expectOk(
|
||||
await board.put(`/api/pipelines/${pipeline.id}/transitions`, {
|
||||
data: {
|
||||
transitions: [
|
||||
{ fromStageKey: "drafting", toStageKey: "assets" },
|
||||
{ fromStageKey: "assets", toStageKey: "published" },
|
||||
{ fromStageKey: "assets", toStageKey: "dropped" },
|
||||
{ fromStageKey: "assets", toStageKey: "drafting" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
"configure tutorial stage transitions",
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: /^Assets/ }).first()).toBeVisible();
|
||||
await expectProsumerVocabulary(page);
|
||||
|
||||
const configured = await getPipeline(board, pipeline.id);
|
||||
const assets = configured.stages.find((stage) => stage.name === "Assets");
|
||||
const published = configured.stages.find((stage) => stage.name === "Published");
|
||||
expect(assets?.kind).toBe("review");
|
||||
expect(published).toBeTruthy();
|
||||
|
||||
await page.goto(`${companyPath}/pipelines/${pipeline.id}`);
|
||||
await page.getByRole("link", { name: "Add items" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Build your list, then submit it all at once" }).first()).toBeVisible();
|
||||
|
||||
const itemPlans = [
|
||||
{ title: "Launch blog post", contentType: "Blog post" },
|
||||
{ title: "Changelog entry", contentType: "Changelog entry" },
|
||||
{ title: "Launch tweet", contentType: "Launch tweet" },
|
||||
];
|
||||
for (const [index, plan] of itemPlans.entries()) {
|
||||
if (index > 0) await page.getByRole("button", { name: "Add another item" }).click();
|
||||
const row = page.locator("section").filter({ hasText: `Item ${index + 1}` }).first();
|
||||
if (index > 0) await row.getByRole("button", { name: "Expand item" }).click();
|
||||
await row.getByLabel("Name").fill(plan.title);
|
||||
await row.getByRole("combobox").click();
|
||||
await page.getByRole("option", { name: plan.contentType }).click();
|
||||
}
|
||||
await expect(page.getByRole("button", { name: "Submit 3 items" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Submit 3 items" }).click();
|
||||
|
||||
const draftingColumn = page.getByLabel("Drafting column");
|
||||
await expect(draftingColumn.getByText("Launch blog post")).toBeVisible();
|
||||
await expect(draftingColumn.getByText("Changelog entry")).toBeVisible();
|
||||
await expect(draftingColumn.getByText("Launch tweet")).toBeVisible();
|
||||
await expectProsumerVocabulary(page);
|
||||
|
||||
const items = await listItems(board, pipeline.id);
|
||||
const blog = items.find((row) => row.case.title === "Launch blog post")?.case;
|
||||
const changelog = items.find((row) => row.case.title === "Changelog entry")?.case;
|
||||
const tweet = items.find((row) => row.case.title === "Launch tweet")?.case;
|
||||
expect(blog).toBeTruthy();
|
||||
expect(changelog).toBeTruthy();
|
||||
expect(tweet).toBeTruthy();
|
||||
|
||||
const blogDragged = await dragCardToColumn(page, "Launch blog post", "Drafting", "Assets");
|
||||
if (blogDragged) {
|
||||
await expect(page.getByRole("heading", { name: "Move Launch blog post?" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Move it" }).click();
|
||||
} else {
|
||||
await moveItem(board, blog!.id, assets!.key);
|
||||
await page.reload();
|
||||
}
|
||||
await expect(page.getByLabel("Assets column").getByText("Launch blog post")).toBeVisible();
|
||||
|
||||
await moveItem(board, changelog!.id, assets!.key);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Assets column").getByText("Changelog entry")).toBeVisible();
|
||||
|
||||
const tweetDragged = await dragCardToColumn(page, "Launch tweet", "Drafting", "Published");
|
||||
const overrideReason = "Tweet can skip review because the blog post already covers the announcement.";
|
||||
if (tweetDragged) {
|
||||
await expect(page.getByRole("heading", { name: "This skips the normal flow" })).toBeVisible();
|
||||
await page.getByLabel("Reason").fill(overrideReason);
|
||||
await expect(page.getByRole("button", { name: "Override and move" })).toBeEnabled();
|
||||
await page.getByRole("button", { name: "Override and move" }).click();
|
||||
} else {
|
||||
await moveItem(board, tweet!.id, published!.key, { reason: overrideReason, force: true });
|
||||
await page.reload();
|
||||
}
|
||||
await expect(page.getByLabel("Published column").getByText("Launch tweet")).toBeVisible();
|
||||
await expectProsumerVocabulary(page);
|
||||
|
||||
const root = await createItem(board, pipeline.id, {
|
||||
title: "Pipeline primitives launch",
|
||||
stageKey: "drafting",
|
||||
fields: { content_type: "Blog post" },
|
||||
});
|
||||
await createItem(board, pipeline.id, {
|
||||
title: "Launch package draft",
|
||||
stageKey: "drafting",
|
||||
parentCaseId: root.id,
|
||||
fields: { content_type: "Blog post" },
|
||||
});
|
||||
await createItem(board, pipeline.id, {
|
||||
title: "Launch package changelog",
|
||||
stageKey: "drafting",
|
||||
parentCaseId: root.id,
|
||||
fields: { content_type: "Changelog entry" },
|
||||
});
|
||||
await suggestMove(board, root.id, assets!.key, "Draft is ready for the next review.");
|
||||
|
||||
await page.goto(`${companyPath}/pipelines/${pipeline.id}/items/${root.id}`);
|
||||
await expect(page.getByRole("heading", { name: "Pipeline primitives launch" }).first()).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Ready to move to Assets?" })).toBeVisible();
|
||||
await expect(page.getByText("Draft is ready for the next review.")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Built from 2 items" })).toBeVisible();
|
||||
await expect(page.getByText("Launch package draft")).toBeVisible();
|
||||
await expect(page.getByText("Launch package changelog")).toBeVisible();
|
||||
await expectProsumerVocabulary(page);
|
||||
|
||||
await page.getByRole("button", { name: "Approve" }).click();
|
||||
await expect(page.getByText("Move approved")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Ready to move to Assets?" })).toBeHidden();
|
||||
|
||||
await page.goto(`${companyPath}/review-queue`);
|
||||
await expect(page.getByRole("heading", { name: "Review queue" }).first()).toBeVisible();
|
||||
await expect(page.getByText("Needs your attention")).toBeVisible();
|
||||
await expect(page.getByText("Final calls")).toBeVisible();
|
||||
|
||||
const blogRow = reviewQueueRow(page, "Launch blog post");
|
||||
await expect(blogRow).toBeVisible();
|
||||
await blogRow.getByRole("button", { name: "Approve", exact: true }).click();
|
||||
await expect(blogRow).toBeHidden();
|
||||
|
||||
const changelogRow = reviewQueueRow(page, "Changelog entry");
|
||||
await expect(changelogRow).toBeVisible();
|
||||
await changelogRow.getByRole("button", { name: "Request changes" }).click();
|
||||
const decisionDialog = page.getByRole("dialog");
|
||||
await expect(decisionDialog).toBeVisible();
|
||||
await decisionDialog.getByLabel("Note").fill("Tighten the framing before publishing.");
|
||||
await decisionDialog.getByRole("button", { name: "Request changes" }).click();
|
||||
await expect(changelogRow).toBeHidden();
|
||||
await expectProsumerVocabulary(page);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const refreshed = await listItems(board, pipeline.id);
|
||||
const approved = refreshed.find((row) => row.case.title === "Launch blog post");
|
||||
const sentBack = refreshed.find((row) => row.case.title === "Changelog entry");
|
||||
return {
|
||||
approvedStage: approved?.case.stageId,
|
||||
sentBackStage: sentBack?.case.stageId,
|
||||
};
|
||||
}).toEqual({
|
||||
approvedStage: published!.id,
|
||||
sentBackStage: configured.stages.find((stage) => stage.key === "drafting")!.id,
|
||||
});
|
||||
|
||||
await page.goto(`${companyPath}/learnings`);
|
||||
await expect(page.getByRole("heading", { name: "Learnings" }).first()).toBeVisible();
|
||||
await expect(page.getByText("Tighten the framing before publishing.")).toBeVisible();
|
||||
await expect(page.getByText(overrideReason)).toBeVisible();
|
||||
await expectProsumerVocabulary(page);
|
||||
} finally {
|
||||
await expectOk(
|
||||
await board.patch("/api/instance/settings/experimental", { data: { enablePipelines: false } }),
|
||||
"disable pipelines experimental flag",
|
||||
);
|
||||
await board.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -8,8 +8,14 @@ import { defineConfig } from "@playwright/test";
|
|||
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199);
|
||||
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||
const PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-e2e-home-"));
|
||||
const PAPERCLIP_CONFIG = path.join(PAPERCLIP_HOME, "instances", "playwright-e2e", "config.json");
|
||||
const PAPERCLIP_AGENT_JWT_SECRET = process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "playwright-e2e-agent-jwt-secret";
|
||||
const PLAYWRIGHT_CHANNEL = process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL;
|
||||
|
||||
process.env.PAPERCLIP_HOME = PAPERCLIP_HOME;
|
||||
process.env.PAPERCLIP_CONFIG = PAPERCLIP_CONFIG;
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = PAPERCLIP_AGENT_JWT_SECRET;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "**/*.spec.ts",
|
||||
|
|
@ -53,6 +59,8 @@ export default defineConfig({
|
|||
...process.env,
|
||||
PORT: String(PORT),
|
||||
PAPERCLIP_HOME,
|
||||
PAPERCLIP_CONFIG,
|
||||
PAPERCLIP_AGENT_JWT_SECRET,
|
||||
PAPERCLIP_INSTANCE_ID: "playwright-e2e",
|
||||
PAPERCLIP_BIND: "loopback",
|
||||
PAPERCLIP_DEPLOYMENT_MODE: "local_trusted",
|
||||
|
|
|
|||
Loading…
Reference in New Issue