291 lines
11 KiB
TypeScript
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),
|
|
});
|
|
}
|