learning_ai_notes/web/e2e/release-flows.spec.ts

291 lines
11 KiB
TypeScript

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: "<p>Related body</p>",
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: "<p>Initial release body</p>",
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: "<p>Read-only public body</p>",
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<string, boolean> }).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<string, boolean> }).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<string, boolean> }).releaseCalls.editedNote).toBe(true);
await page.getByRole("button", { name: "Archive" }).click();
await expect.poll(() => (page as Page & { releaseCalls: Record<string, boolean> }).releaseCalls.archived).toBe(true);
await page.getByRole("button", { name: "Restore" }).click();
await expect.poll(() => (page as Page & { releaseCalls: Record<string, boolean> }).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<string, boolean> }).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<string, boolean> }).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<string, boolean> }).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<string, boolean> }).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),
});
}