diff --git a/web/e2e/release-flows.spec.ts b/web/e2e/release-flows.spec.ts new file mode 100644 index 0000000..1809e5e --- /dev/null +++ b/web/e2e/release-flows.spec.ts @@ -0,0 +1,290 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +const workspace = { + id: "ws-1", + name: "Launch Workspace", + description: "Release notes", + members: [{ userId: "user-1", role: "owner" }], + updatedAt: "2026-05-05T00:00:00.000Z", + updatedBy: "user-1", + noteCount: 2, +}; + +const relatedNote = { + id: "note-2", + workspaceId: "ws-1", + title: "Related rollout note", + body: "

Related body

", + status: "active", + tags: ["release"], + links: [], + updatedAt: "2026-05-04T00:00:00.000Z", + updatedBy: "user-1", + createdBy: "user-1", + createdAt: "2026-05-04T00:00:00.000Z", + sourceType: "manual", +}; + +function accessToken() { + const payload = Buffer.from(JSON.stringify({ exp: 4102444800 })).toString("base64url"); + return `test.${payload}.token`; +} + +async function seedAuth(page: Page) { + await page.addInitScript((token) => { + localStorage.setItem("notelett_auth_user", JSON.stringify({ id: "user-1", email: "user@example.com", name: "Release Tester" })); + localStorage.setItem("notelett_access_token", token); + localStorage.setItem("notelett_refresh_token", "refresh-token"); + }, accessToken()); +} + +async function mockReleaseApis(page: Page) { + let note = { + id: "note-1", + workspaceId: "ws-1", + title: "Launch readiness note", + body: "

Initial release body

", + status: "active", + tags: ["launch"], + links: [], + updatedAt: "2026-05-05T00:00:00.000Z", + updatedBy: "user-1", + createdBy: "user-1", + createdAt: "2026-05-05T00:00:00.000Z", + sourceType: "manual", + }; + + let reviewItems = [ + actionDoc("act-1", "Approve release summary", "proposed"), + actionDoc("act-2", "Reject stale task", "proposed"), + ]; + + const calls = { + createdNote: false, + editedNote: false, + archived: false, + restored: false, + linked: false, + promptRun: false, + intake: false, + approved: false, + rejected: false, + }; + (page as Page & { releaseCalls?: typeof calls }).releaseCalls = calls; + + await page.route("**/api/auth/refresh", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ accessToken: accessToken(), refreshToken: "refresh-token" }), + }), + ); + await page.route("**/api/kill-switch**", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ disabled: false, message: null }) }), + ); + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname.replace(/^\/api/, ""); + const method = request.method(); + + if (path === "/workspaces/summaries") { + return json(route, { items: [workspace], total: 1 }); + } + if (path === "/workspaces") { + return json(route, { items: [workspace], total: 1 }); + } + if (path === "/notes" && method === "POST") { + calls.createdNote = true; + const input = request.postDataJSON() as { id: string; title: string; body: string; workspaceId: string; tags?: string[] }; + note = { ...note, ...input, status: "draft", updatedAt: "2026-05-05T01:00:00.000Z" }; + return json(route, note, 201); + } + if (path === "/notes" && method === "GET") { + const search = url.searchParams.get("search"); + if (search === "note-1") return json(route, { items: [note], total: 1 }); + if (search === "Related") return json(route, { items: [relatedNote], total: 1 }); + return json(route, { items: [note, relatedNote], total: 2 }); + } + if (path === "/notes/search" && method === "POST") { + return json(route, { items: [], total: 0, mode: "lexical" }); + } + if (path === "/notes/note-1" && method === "GET") { + return json(route, note); + } + if (path === "/notes/note-1" && method === "PATCH") { + calls.editedNote = true; + const input = request.postDataJSON() as { title?: string; body?: string }; + note = { ...note, ...input, updatedAt: "2026-05-05T02:00:00.000Z" }; + return json(route, note); + } + if (path === "/notes/note-1/archive") { + calls.archived = true; + note = { ...note, status: "archived" }; + return json(route, note); + } + if (path === "/notes/note-1/restore") { + calls.restored = true; + note = { ...note, status: "active" }; + return json(route, note); + } + if (path === "/notes/note-1/versions") { + return json(route, { items: [], total: 0 }); + } + if (path === "/note-tasks") return json(route, { items: [], total: 0 }); + if (path === "/note-artifacts") return json(route, { items: [], total: 0 }); + if (path === "/note-relationships" && method === "POST") { + calls.linked = true; + return json(route, { id: "rel-1" }, 201); + } + if (path === "/note-relationships") return json(route, { items: [], total: 0 }); + if (path === "/note-agent-actions/pending" && method === "GET") { + return json(route, { items: reviewItems, total: reviewItems.length }); + } + if (path === "/note-agent-actions" && method === "GET") { + return json(route, { items: [], total: 0 }); + } + if (path.startsWith("/note-agent-actions/") && method === "PATCH") { + const id = path.split("/").pop(); + const body = request.postDataJSON() as { state: "approved" | "rejected" }; + calls.approved ||= body.state === "approved"; + calls.rejected ||= body.state === "rejected"; + reviewItems = reviewItems.filter((item) => item.id !== id); + return json(route, actionDoc(id ?? "act", body.state === "approved" ? "Approved" : "Rejected", body.state)); + } + if (path === "/note-prompts") { + return json(route, { + items: [{ + id: "tmpl-1", + slug: "summarize", + name: "Summarize", + description: "Summarize the current note", + category: "transform", + inputType: "text", + outputType: "text", + isBuiltin: true, + }], + total: 1, + }); + } + if (path === "/notes/note-1/reading-time") return json(route, { wordCount: 120, readingTimeMinutes: 1 }); + if (path === "/note-prompts/run") { + calls.promptRun = true; + return json(route, { content: "Prompt summary result", model: "mock", usage: { totalTokens: 12 } }); + } + if (path === "/intake") { + calls.intake = true; + return json(route, { jobId: "job-1", noteId: "note-1", contentType: "article", ruleMatched: null, templateSlug: "summarize", status: "queued" }, 201); + } + if (path === "/public/note-shares/public-token") { + return route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "Cache-Control": "no-store", "X-Robots-Tag": "noindex, nofollow" }, + body: JSON.stringify({ + product: "NoteLett", + noteId: "note-public", + workspaceId: "ws-1", + title: "Public release note", + body: "

Read-only public body

", + updatedAt: "2026-05-05T00:00:00.000Z", + expiresAt: "2026-06-05T00:00:00.000Z", + }), + }); + } + if (path === "/broadcasts/active") return json(route, { items: [], total: 0 }); + if (path === "/surveys/active") return json(route, { items: [], total: 0 }); + if (path === "/saved-views") return json(route, { items: [], total: 0 }); + + return json(route, {}); + }); +} + +test.beforeEach(async ({ page }) => { + await seedAuth(page); + await mockReleaseApis(page); +}); + +test("create note and intake URL from dashboard", async ({ page }) => { + await page.goto("/dashboard"); + await page.getByLabel("URL to process").fill("https://example.com/release-plan"); + await page.getByRole("button", { name: "Process URL" }).click(); + await expect.poll(() => (page as Page & { releaseCalls: Record }).releaseCalls.intake).toBe(true); + + await page.getByRole("button", { name: "New Note" }).click({ force: true }); + await page.getByLabel("Note title").fill("Release E2E note"); + await page.getByLabel("Note body").fill("Created by Playwright"); + await page.getByRole("button", { name: "Create" }).click(); + await expect.poll(() => (page as Page & { releaseCalls: Record }).releaseCalls.createdNote).toBe(true); +}); + +test("edit, archive, restore, link, and run a prompt from note detail", async ({ page }) => { + await page.goto("/notes/note-1"); + await expect(page.locator("#note-title")).toHaveValue("Launch readiness note"); + await page.getByRole("textbox", { name: "Title", exact: true }).fill("Edited release note"); + await page.getByRole("button", { name: "Save now" }).click(); + await expect.poll(() => (page as Page & { releaseCalls: Record }).releaseCalls.editedNote).toBe(true); + + await page.getByRole("button", { name: "Archive" }).click(); + await expect.poll(() => (page as Page & { releaseCalls: Record }).releaseCalls.archived).toBe(true); + await page.getByRole("button", { name: "Restore" }).click(); + await expect.poll(() => (page as Page & { releaseCalls: Record }).releaseCalls.restored).toBe(true); + + await page.getByRole("button", { name: "Link Note" }).click(); + await page.getByLabel("Search notes to link").fill("Related"); + await page.getByRole("button", { name: "Search" }).click(); + await page.getByRole("button", { name: "Select note: Related rollout note" }).click(); + await page.getByRole("button", { name: "Link", exact: true }).click(); + await expect.poll(() => (page as Page & { releaseCalls: Record }).releaseCalls.linked).toBe(true); + + await page.getByRole("button", { name: "Run: Summarize" }).click(); + await expect(page.getByText("Prompt summary result")).toBeVisible(); + await expect.poll(() => (page as Page & { releaseCalls: Record }).releaseCalls.promptRun).toBe(true); +}); + +test("approve and reject review proposals", async ({ page }) => { + await page.goto("/reviews"); + await expect(page.getByRole("button", { name: /Approve release summary/ })).toBeVisible(); + await page.getByRole("button", { name: "Approve", exact: true }).click(); + await expect.poll(() => (page as Page & { releaseCalls: Record }).releaseCalls.approved).toBe(true); + + await expect(page.getByRole("button", { name: /Reject stale task/ })).toBeVisible(); + await page.getByRole("button", { name: "Reject", exact: true }).click(); + await expect.poll(() => (page as Page & { releaseCalls: Record }).releaseCalls.rejected).toBe(true); +}); + +test("settings smoke and public share page", async ({ page }) => { + await page.goto("/settings"); + await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); + await expect(page.getByText("Connect your agent (MCP)")).toBeVisible(); + + await page.goto("/share/public-token"); + await expect(page.getByText("Read-only public share")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Public release note" })).toBeVisible(); + await expect(page.getByText("Read-only public body")).toBeVisible(); +}); + +function actionDoc(id: string, afterSummary: string, state: string) { + return { + id, + productId: "notelett", + workspaceId: "ws-1", + noteId: "note-1", + actorId: "agent-1", + actorType: "agent", + actionType: "update", + state, + afterSummary, + beforeSummary: "Before text", + updatedAt: "2026-05-05T00:00:00.000Z", + }; +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +}