Files
template-app/e2e/notes.spec.ts
T
2026-11-14 09:56:45 +00:00

238 lines
11 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The example module in a browser (plan item AT2.2): add a note, attach a file through the files
// module's Blob upload, change and remove things, and delete the note -- with axe on every screen it
// shows. Another organization's member sees none of it.
import { readFileSync } from "node:fs"
import { expect, type Page, test } from "@playwright/test"
import { call, callRaw, makeOrganization, platformState, signIn, unique, violations } from "./support/app"
const MINUTES = Buffer.from("Agreed on Friday: ship the pilot.\n")
async function uploadText(page: Page, scope: Awaited<ReturnType<typeof makeOrganization>>, name: string) {
const begun = await call<{ fileId: string; parts: Array<{ number: number }> }>(page, "files.file.beginUpload", {
...scope,
filename: name,
contentType: "text/plain",
size: 1,
})
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
const part = begun.parts[0]
if (!part) throw new Error("the one-byte upload did not return a part")
const sent = await page.request.put(
`/api/v1/files.file.putPart?${new URLSearchParams({ ...scope, fileId: begun.fileId, part: String(part.number) })}`,
{
data: Buffer.from("x"),
headers: {
"content-type": "application/octet-stream",
"x-csrf-token": csrf ?? "",
"sec-fetch-site": "same-origin",
},
},
)
expect(sent.ok()).toBe(true)
const receipt = (await sent.json()) as { etag: string }
await call(page, "files.file.completeUpload", {
...scope,
fileId: begun.fileId,
parts: [{ number: part.number, etag: receipt.etag }],
})
return begun.fileId
}
test("a note takes attachments, is edited and removed, and every screen passes axe", async ({ page }) => {
await signIn(page, `${unique("author")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const base = `/${org}/${workspace}/${project}/notes`
// the project links to the module from the manifest's navigation entry: a project section is a link in the sidebar
await page.goto(`/${org}/${workspace}/${project}`)
const notesLink = page
.getByRole("navigation", { name: "Main navigation" })
.getByRole("link", { name: "Notes", exact: true })
await expect(notesLink).toBeVisible()
await expect(notesLink).toHaveAttribute("href", base)
await page.goto(base)
await expect(page.getByRole("heading", { level: 1, name: "Notes" })).toBeVisible()
await expect(page.getByText("No notes yet.")).toBeVisible()
expect(await violations(page)).toEqual([])
// a new note opens beside the list, with an address, and the list stays usable
await page.locator('[data-guide="notes.new"]').click()
await expect(page).toHaveURL(/\?new=1$/)
const panel = page.locator('[data-slot="side-panel"]')
await expect(panel.getByRole("heading", { name: "New note" })).toBeVisible()
await expect(page.getByText("No notes yet.")).toBeVisible()
expect(await violations(page)).toEqual([])
// what the schema refuses is said beside the field, and the person is put there
await panel.getByLabel("Text", { exact: true }).fill("Agenda and owners")
await panel.getByRole("button", { name: "Save" }).click()
await expect(panel.getByLabel("Title")).toBeFocused()
await panel.getByLabel("Title").fill("Kick-off")
await panel.getByRole("button", { name: "Save" }).click()
// the record takes its address, the row is the current one, and it survives a reload
await expect(page).toHaveURL(/\?record=[0-9a-f-]+$/)
await expect(page.getByRole("row", { name: /Kick-off/ })).toBeVisible()
await page.reload()
await expect(panel.getByLabel("Title")).toHaveValue("Kick-off")
expect(await violations(page)).toEqual([])
// the note's own page, for what needs a page
await panel.getByRole("link", { name: "Open in full" }).click()
await expect(page.getByRole("heading", { level: 1, name: "Kick-off" })).toBeVisible()
await expect(page.getByText("Nothing is attached.")).toBeVisible()
expect(await violations(page)).toEqual([])
// an upload through the files module (the platform's Blob store), then the link
await page.getByLabel("Attach a file").setInputFiles({ name: "minutes.txt", mimeType: "text/plain", buffer: MINUTES })
await page.getByRole("button", { name: "Attach", exact: true }).click()
await expect(page.getByRole("row").filter({ hasText: /minutes\.txt.*text\/plain.*1 KB/ })).toBeVisible()
await expect(page.getByText("1 attachment", { exact: true })).toBeVisible()
expect(await violations(page)).toEqual([])
const download = page.waitForEvent("download")
await page.getByRole("link", { name: "Download minutes.txt" }).click()
const downloaded = await download
expect(downloaded.suggestedFilename()).toBe("minutes.txt")
const downloadedPath = await downloaded.path()
expect(readFileSync(downloadedPath)).toEqual(MINUTES)
// what the worker sent the platform: ONE audit event and ONE usage fact for the upload, one audit event
// and one usage fact for the note, one audit event for the link
const noteId = page.url().split("/").pop() ?? ""
const { files } = await call<{ files: Array<{ fileId: string }> }>(page, "files.file.list", {
org,
workspace,
project,
})
expect(files).toHaveLength(1)
const fileId = files[0]?.fileId ?? ""
await expect(async () => {
const state = await platformState()
// The target carries the organization as `<org id>/<resource id>` (`auditTarget` of @conusai-kit/host/http), so a
// resource matches by its trailing segment.
const audited = (id: string) =>
state.audit.filter((event) => event.resource_id?.endsWith(`/${id}`)).map((event) => event.action)
expect(audited(fileId)).toEqual(["files.file_uploaded"])
expect(audited(noteId).sort()).toEqual(["notes.attachment_added", "notes.note_created"])
const metered = (key: string) => state.usage.filter((fact) => fact.idempotency_key === key)
expect(metered(`files_bytes_stored.${fileId}`)).toEqual([
expect.objectContaining({ meter: "files_bytes_stored", quantity: String(MINUTES.length) }),
])
expect(metered(`notes_created.${noteId}`)).toEqual([
expect.objectContaining({ meter: "notes_created", quantity: "1" }),
])
// and the bytes are in the platform's Blob store, once, under the organization's own prefix
expect(
state.blobs.filter((blob) => blob.size === MINUTES.length && blob.pathname.includes(`/files/`)),
).toHaveLength(1)
}).toPass({ timeout: 30_000 })
// edit, in the panel: the form carries the version it was read at
await page.getByRole("link", { name: "Edit note" }).click()
await panel.getByLabel("Title").fill("Kick-off call")
await panel.getByRole("button", { name: "Save" }).click()
// the list shows the note with its attachment count
await expect(page.getByRole("row").filter({ hasText: /Kick-off call.*1/ })).toBeVisible()
expect(await violations(page)).toEqual([])
// remove the attachment, then delete the note after being asked
await panel.getByRole("link", { name: "Open in full" }).click()
await expect(page.getByRole("heading", { level: 1, name: "Kick-off call" })).toBeVisible()
await page.getByRole("button", { name: "Remove minutes.txt" }).click()
await page.getByRole("alertdialog").getByRole("button", { name: "Remove", exact: true }).click()
await expect(page.getByText("Nothing is attached.")).toBeVisible()
await page.getByRole("button", { name: "Delete note" }).click()
// Audit the dialog's settled colors after its fade-in, not a half-transparent frame.
await expect(page.getByRole("alertdialog")).toHaveCSS("opacity", "1")
expect(await violations(page)).toEqual([])
await page.getByRole("button", { name: "Yes, delete the note" }).click()
await expect(page.getByText("No notes yet.")).toBeVisible()
})
test("a second editor's stale save is refused and says so", async ({ page }) => {
await signIn(page, `${unique("author")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const base = `/${org}/${workspace}/${project}/notes`
const { noteId } = await call<{ noteId: string }>(page, "notes.note.create", {
org,
workspace,
project,
title: "Plan",
body: "",
})
await page.goto(`${base}?record=${noteId}`)
await expect(page.getByLabel("Title")).toHaveValue("Plan")
// the same note opened in a second tab, saved there first
const second = await page.context().newPage()
await second.goto(page.url())
await expect(second.getByLabel("Title")).toHaveValue("Plan")
await second.getByLabel("Title").fill("Plan v2")
await second.getByRole("button", { name: "Save" }).click()
await expect(second.getByRole("row", { name: /Plan v2/ })).toBeVisible()
// the first tab's panel still holds version 1, and its draft is kept
await page.getByLabel("Title").fill("Plan v3")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByRole("alert")).toBeVisible()
await expect(page.getByLabel("Title")).toHaveValue("Plan v3")
expect(await violations(page)).toEqual([])
await second.close()
})
test("concurrent attachments cannot exceed the per-note cap", async ({ page }) => {
await signIn(page, `${unique("quota-owner")}@example.test`)
const scope = await makeOrganization(page)
const note = await call<{ noteId: string }>(page, "notes.note.create", {
...scope,
title: "Attachment quota",
body: "",
})
const existingFiles: string[] = []
for (let index = 0; index < 19; index += 1) {
const fileId = await uploadText(page, scope, `quota-${index}.txt`)
existingFiles.push(fileId)
await call(page, "notes.attachment.add", { ...scope, noteId: note.noteId, fileId })
}
const contenders = await Promise.all([
uploadText(page, scope, "quota-race-a.txt"),
uploadText(page, scope, "quota-race-b.txt"),
])
const csrf = await page.locator('meta[name="csrf-token"]').getAttribute("content")
const results = await Promise.all(
contenders.map((fileId) =>
callRaw(page.request, csrf ?? "", "notes.attachment.add", { ...scope, noteId: note.noteId, fileId }),
),
)
expect(results.map((result) => result.status()).sort()).toEqual([200, 409])
const detail = await call<{ attachments: unknown[] }>(page, "notes.note.get", { ...scope, noteId: note.noteId })
expect(detail.attachments).toHaveLength(20)
expect(existingFiles).toHaveLength(19)
})
test("another organization's member does not see the notes", async ({ page, browser }) => {
await signIn(page, `${unique("owner")}@example.test`)
const mine = await makeOrganization(page)
const stranger = await browser.newPage()
await signIn(stranger, `${unique("stranger")}@example.test`)
const response = await stranger.goto(`/${mine.org}/${mine.workspace}/${mine.project}/notes`)
expect(response?.status()).toBe(404)
await expect(stranger.getByRole("heading", { level: 1, name: "Page not found" })).toBeVisible()
await stranger.close()
})
test("a path the module does not know is the not-found page", async ({ page }) => {
await signIn(page, `${unique("author")}@example.test`)
const { org, workspace, project } = await makeOrganization(page)
const unknownModule = await page.goto(`/${org}/${workspace}/${project}/nothing-here`)
expect(unknownModule?.status()).toBe(404)
const unknownNote = await page.goto(`/${org}/${workspace}/${project}/notes/00000000-0000-0000-0000-000000000000`)
expect(unknownNote?.status()).toBe(404)
})