diff --git a/web/CLAUDE.md b/web/CLAUDE.md
index 6582dd70..cde19d7b 100644
--- a/web/CLAUDE.md
+++ b/web/CLAUDE.md
@@ -173,6 +173,14 @@ Note `noUncheckedIndexedAccess` makes every index access `T | undefined`, and Ty
`.nuxt.test.ts` suffix is the officially documented environment opt-in;
the vitest config still declares the env-by-suffix globs explicitly so
the mapping is configuration, not tool default.
+- **`app/components/shadcn/` is out of scope for the suite**: those
+ components are vendored and regenerated by the shadcn CLI, so they get
+ no tests of their own. Every vitest project excludes the directory from
+ test collection and it is excluded from coverage, so a test file placed
+ there would simply never run — do not write one. Rendering a shadcn
+ component as a real child of a component under test is expected and
+ fine; so is importing one as a test harness (see
+ `mountUnderSidebarProvider` below).
### Naming
@@ -355,6 +363,67 @@ it.for([
whole broken accounting at once instead of stopping at the first
failed count.
+### Component tests
+
+Component tests mount the real component inside the nuxt runtime with
+`mountSuspended` and drive it the way a user does. Shared helpers live in
+[app/components/test-helpers.ts](app/components/test-helpers.ts) — the
+things that come up in nearly every suite:
+
+- **Context providers.** `useSidebar`, `useSidebarWidth` and reka-ui's
+ tooltip context are installed once at page level in the app, so a
+ component that reads one cannot mount on its own. Mount it through
+ `mountUnderSidebarProvider` / `mountUnderTooltipProvider`.
+- **Overlays live in `
`.** Tooltip, dropdown, dialog and sheet
+ bodies are teleported out of the wrapper, so `wrapper.text()` never
+ sees them — reach them with `menuItem()`, `teleportedButton()` and
+ `openTooltipText()`. Unmounting does not always take a teleported node
+ with it (a pending presence transition outlives it) and reka-ui reuses
+ its generated ids across mounts, so suites that drive overlays call
+ `clearTeleportedOverlays()` from a `beforeEach`. Sheet-based suites
+ must **not**: ripping out a node vue still has mounted breaks its next
+ patch.
+- **No real IO, again.** The global route middleware asks better-auth for
+ the session on every navigation, which means every mount — so
+ [vitest.nuxt-setup.ts](vitest.nuxt-setup.ts) stubs a signed-out session
+ for the whole nuxt project (unstubbed it is a real request that costs
+ ~5s per mount). `@nuxt/icon` is pinned to `provider: "none"` in the
+ same config for the same reason. Suites that need a signed-in state
+ seed the cache with `seedAuthSession` / `seedAuthOrganization` /
+ `seedAuthAccounts`, or register endpoints.
+- **An endpoint is registered under the url the client actually asks
+ for**, which is not the same string everywhere: `$coreAPIClient` has an
+ empty base in tests, so it matches a bare path (`/api/documents/tree`),
+ while `$authRealtimeAPIClient` has an absolute one and matches the full
+ url. better-auth needs *both* spellings — the absolute one keeps the
+ request off the real network, the path one is what the test-time h3 app
+ matches a handler on — so it goes through `mockAuthEndpoint()`.
+- **Viewport-dependent layouts.** happy-dom has no `matchMedia`, so
+ `useMediaQuery` reports "not matching" for every query — pick a side
+ deliberately with `stubViewportMatches()`.
+- **Pointer and geometry behaviour** (the sidebar drag) is driven the way
+ [app/composables/useSidebarDraggable.nuxt.test.ts](app/composables/useSidebarDraggable.nuxt.test.ts)
+ does it: `mockNuxtImport` the vueuse primitives (`useDraggable`,
+ `useMouseInElement`, `useElementBounding`), then drive the captured
+ callbacks and refs. Everything between them and the component stays
+ real. Return **refs**, not plain objects — a plain `{ value }` leaves
+ every watcher and computed behind it inert, and the assertions then
+ pass or fail on flush timing rather than on behaviour.
+- **Settling.** A pinia-colada mutation that invalidates queries on
+ success resolves several macrotasks deep; use `settleMutations()`, or
+ `settleActionSubmit()` for the settings action modals (which also hold
+ their submit for `delay(300)`, so they mount through
+ `mountWithFrozenClock()`). better-auth backs off internally before
+ surfacing a failed request, and vee-validate renders messages on its
+ own scheduler — neither exposes a signal, so those assertions are the
+ documented `vi.waitFor` exception.
+- **Shared app state forces `{ concurrent: false }`.** Beyond `vi.mock`
+ singletons, the same applies to nuxt `useState`, pinia stores,
+ cookie-backed `usePersistentState`, `vi.stubGlobal`, fake timers and
+ the teleport target — all of them are shared by every mount in a file.
+ Mark **each** describe that touches them, nested ones included: the
+ option only covers the suite it is on.
+
### Snapshots
Snapshots are golden files, not lazy assertion dumps: use them only for
diff --git a/web/app/components/AIChatSidebar.nuxt.test.ts b/web/app/components/AIChatSidebar.nuxt.test.ts
new file mode 100644
index 00000000..bde532b1
--- /dev/null
+++ b/web/app/components/AIChatSidebar.nuxt.test.ts
@@ -0,0 +1,100 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { beforeEach, describe, it, vi } from "vitest"
+import AIChatSidebar from "./AIChatSidebar.vue"
+import { emitFrom } from "./test-helpers"
+
+// matchMedia is what useMediaQuery reads; happy-dom's own implementation
+// always reports "not matching", so the mobile layout needs a stub
+function stubViewport(matches: boolean) {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn((query: string) => ({
+ matches: matches,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ )
+}
+
+function mountSidebar() {
+ return mountSuspended(AIChatSidebar, {
+ // the chat box drags in the whole tiptap editor stack, which this
+ // component only positions
+ global: { stubs: { EditorAiChatBox: true } },
+ })
+}
+
+// the editor store is an app-wide pinia singleton and the viewport stub is
+// a global, both shared by every mount in the file
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ useEditorStore().aiAssistantOpen = false
+ })
+
+ it("collapses the panel to nothing while the assistant is closed", async ({
+ expect,
+ }) => {
+ stubViewport(false)
+
+ const wrapper = await mountSidebar()
+
+ expect(wrapper.get("div").attributes("style")).toContain("width: 0rem")
+ })
+
+ it("expands the panel when the assistant is open", async ({ expect }) => {
+ stubViewport(false)
+ useEditorStore().aiAssistantOpen = true
+
+ const wrapper = await mountSidebar()
+
+ expect(wrapper.get("div").attributes("style")).toContain("width: 30rem")
+ })
+
+ it("keeps the chat box mounted on wide viewports", async ({ expect }) => {
+ stubViewport(false)
+
+ const wrapper = await mountSidebar()
+
+ expect(wrapper.find("editor-ai-chat-box-stub").exists()).toBe(true)
+ })
+
+ it("leaves the sheet closed on narrow viewports while the assistant is closed", async ({
+ expect,
+ }) => {
+ stubViewport(true)
+
+ await mountSidebar()
+
+ expect(
+ document.body.querySelector("[data-slot='sheet-content']"),
+ ).toBeNull()
+ })
+
+ it("opens a sheet on narrow viewports when the assistant is open", async ({
+ expect,
+ }) => {
+ stubViewport(true)
+ useEditorStore().aiAssistantOpen = true
+
+ await mountSidebar()
+
+ expect(
+ document.body.querySelector("[data-slot='sheet-content']"),
+ ).not.toBeNull()
+ })
+
+ it("closes the assistant when the sheet is dismissed", async ({ expect }) => {
+ stubViewport(true)
+ const editorStore = useEditorStore()
+ editorStore.aiAssistantOpen = true
+ const wrapper = await mountSidebar()
+
+ emitFrom(wrapper, "Sheet", "update:open", false)
+ await nextTick()
+
+ expect(editorStore.aiAssistantOpen).toBe(false)
+ })
+})
diff --git a/web/app/components/AppSidebar.nuxt.test.ts b/web/app/components/AppSidebar.nuxt.test.ts
new file mode 100644
index 00000000..9bfea8d0
--- /dev/null
+++ b/web/app/components/AppSidebar.nuxt.test.ts
@@ -0,0 +1,518 @@
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+ seedQueryData,
+} from "~/composables/api/test-helpers"
+import { mockNuxtImport } from "@nuxt/test-utils/runtime"
+import AppSidebar from "./AppSidebar.vue"
+import {
+ emitFrom,
+ mockAuthEndpoint,
+ mountUnderSidebarProvider,
+ seedAuthOrganization,
+ settleMutations,
+ stubViewportMatches,
+} from "./test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+// signing out navigates, and the test app has no login page to land on
+const navigateToMock = vi.hoisted(() => vi.fn())
+mockNuxtImport("navigateTo", () => navigateToMock)
+
+const DOC_ID = "doc1".padEnd(20, "0")
+
+function treeElement() {
+ return {
+ id: DOC_ID,
+ documentName: "Runbook",
+ icon: "lucide:file",
+ protected: false,
+ children: null,
+ }
+}
+
+// AppSidebar refreshes the tree, the organization and the unread count on
+// mount, so every one of them needs an endpoint behind it
+function stubQueries(
+ options: {
+ tree?: unknown[]
+ unread?: number
+ gitHub?: { connected: boolean; configured: boolean }
+ slack?: { connected: boolean; configured: boolean }
+ } = {},
+) {
+ mockEndpoint("GET", "/api/documents/tree", () => options.tree ?? [])
+ mockEndpoint("GET", "/api/notifications/count", () => ({
+ count: options.unread ?? 0,
+ }))
+ seedQueryData(
+ ["github", "connected"],
+ options.gitHub ?? { connected: true, configured: true },
+ )
+ seedQueryData(
+ ["slack", "connected"],
+ options.slack ?? { connected: true, configured: true },
+ )
+}
+
+function mountSidebar(props: Record = {}) {
+ return mountUnderSidebarProvider(AppSidebar, {
+ props: {
+ allInitialSectionsLoaded: true,
+ notificationSidebarOpen: false,
+ ...props,
+ },
+ })
+}
+
+function sidebar(wrapper: Awaited>) {
+ return wrapper.findComponent(AppSidebar)
+}
+
+function itemNamed(
+ wrapper: Awaited>,
+ name: string,
+) {
+ const item = wrapper
+ .findAll("[data-slot='sidebar-menu-button']")
+ .find((el) => el.text().includes(name))
+ if (!item) {
+ throw new Error(`no sidebar item rendering "${name}"`)
+ }
+
+ return item
+}
+
+// the query cache, the editor store and the vue-sonner module mock are
+// app-wide singletons every mount in the file shares
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ navigateToMock.mockReset()
+ // the sidebar renders into a sheet on narrow viewports; the wide
+ // layout is the one these tests read
+ stubViewportMatches(false)
+ seedAuthOrganization({
+ id: "org-1",
+ name: "Acme Corp",
+ slug: "acme-corp",
+ logo: "",
+ members: [{ id: "m1", userId: "u1", user: { name: "Ada" } }],
+ invitations: [],
+ })
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("stays blank until the initial sections have loaded", async ({
+ expect,
+ }) => {
+ stubQueries()
+
+ const wrapper = await mountSidebar({ allInitialSectionsLoaded: false })
+
+ expect(sidebar(wrapper).text()).toBe("")
+ })
+
+ it("shows the workspace header", async ({ expect }) => {
+ stubQueries()
+
+ const wrapper = await mountSidebar()
+
+ expect(wrapper.text()).toContain("Acme Corp")
+ })
+
+ it("offers search and the inbox at the top", async ({ expect }) => {
+ stubQueries()
+
+ const wrapper = await mountSidebar()
+
+ expect(wrapper.text()).toContain("Search")
+ expect(wrapper.text()).toContain("Inbox")
+ })
+
+ it("shows the unread notification count on the inbox row", async ({
+ expect,
+ }) => {
+ stubQueries({ unread: 4 })
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(itemNamed(wrapper, "Inbox").text()).toContain("4")
+ })
+
+ it("marks the inbox row active while the notification sidebar is open", async ({
+ expect,
+ }) => {
+ stubQueries()
+
+ const wrapper = await mountSidebar({ notificationSidebarOpen: true })
+
+ expect(itemNamed(wrapper, "Inbox").attributes("data-active")).toBe("true")
+ })
+
+ it("lists the workspace pages under their own heading", async ({
+ expect,
+ }) => {
+ stubQueries({ tree: [treeElement()] })
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(wrapper.text()).toContain("Workspace")
+ expect(wrapper.text()).toContain("Runbook")
+ })
+
+ it("shows a placeholder row when the workspace has no pages", async ({
+ expect,
+ }) => {
+ stubQueries()
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(wrapper.text()).toContain("Add Page")
+ })
+
+ it("asks to open the search modal from the search row", async ({
+ expect,
+ }) => {
+ stubQueries()
+ const wrapper = await mountSidebar()
+
+ await itemNamed(wrapper, "Search").trigger("click")
+
+ expect(
+ document.body.querySelector("[data-slot='dialog-content']"),
+ ).not.toBeNull()
+ })
+
+ it("asks to toggle the notification sidebar from the inbox row", async ({
+ expect,
+ }) => {
+ stubQueries()
+ const wrapper = await mountSidebar()
+
+ await itemNamed(wrapper, "Inbox").trigger("click")
+
+ expect(sidebar(wrapper).emitted("toggle-notifications")).toHaveLength(1)
+ })
+
+ it("asks to create a page from the workspace heading action", async ({
+ expect,
+ }) => {
+ stubQueries()
+ const wrapper = await mountSidebar()
+
+ await wrapper.get("[data-sidebar='group-action']").trigger("click")
+
+ expect(sidebar(wrapper).emitted("create-document")).toEqual([[null]])
+ })
+
+ it("asks to create a page from the workspace header", async ({ expect }) => {
+ stubQueries()
+ const wrapper = await mountSidebar()
+
+ emitFrom(wrapper, "AppSidebarHeader", "create-new-item")
+ await nextTick()
+
+ expect(sidebar(wrapper).emitted("create-document")).toEqual([[null]])
+ })
+
+ it("asks to open the settings from the workspace header", async ({
+ expect,
+ }) => {
+ stubQueries()
+ const wrapper = await mountSidebar()
+
+ emitFrom(wrapper, "AppSidebarHeader", "open-settings")
+ await nextTick()
+
+ expect(sidebar(wrapper).emitted("open-settings")).toEqual([[null]])
+ })
+
+ it("sends the user to the login page after signing out", async ({
+ expect,
+ }) => {
+ stubQueries()
+ mockAuthEndpoint("sign-out", () => ({ success: true }))
+ const wrapper = await mountSidebar()
+
+ emitFrom(wrapper, "AppSidebarHeader", "log-out")
+ await settleMutations()
+
+ expect(navigateToMock).toHaveBeenCalledExactlyOnceWith({ name: "login" })
+ expect(toast.custom).toHaveBeenCalledTimes(0)
+ })
+
+ it("warns and stays put when signing out fails", async ({ expect }) => {
+ stubQueries()
+ mockAuthEndpoint("sign-out", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSidebar()
+
+ emitFrom(wrapper, "AppSidebarHeader", "log-out")
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(navigateToMock).toHaveBeenCalledTimes(0)
+ })
+
+ it("announces that every section has loaded", async ({ expect }) => {
+ stubQueries()
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(sidebar(wrapper).emitted("initial-load-complete")).toHaveLength(1)
+ })
+
+ describe("next steps", { concurrent: false }, () => {
+ it("invites the user to add team members while alone", async ({
+ expect,
+ }) => {
+ stubQueries()
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(wrapper.text()).toContain("Invite Team Members")
+ })
+
+ it("drops the invite row once the workspace has more members", async ({
+ expect,
+ }) => {
+ seedAuthOrganization({
+ id: "org-1",
+ name: "Acme Corp",
+ members: [
+ { id: "m1", userId: "u1", user: { name: "Ada" } },
+ { id: "m2", userId: "u2", user: { name: "Linus" } },
+ ],
+ invitations: [],
+ })
+ stubQueries()
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(wrapper.text()).not.toContain("Invite Team Members")
+ })
+
+ it("opens the member settings from the invite row", async ({ expect }) => {
+ stubQueries()
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ await itemNamed(wrapper, "Invite Team Members").trigger("click")
+
+ expect(sidebar(wrapper).emitted("open-settings")).toEqual([
+ ["org-members"],
+ ])
+ })
+
+ it("offers the github integration while it is unconnected", async ({
+ expect,
+ }) => {
+ stubQueries({ gitHub: { connected: false, configured: true } })
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(wrapper.text()).toContain("Activate GitHub Integration")
+ })
+
+ it("hides the github integration on a deployment without it", async ({
+ expect,
+ }) => {
+ stubQueries({ gitHub: { connected: false, configured: false } })
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(wrapper.text()).not.toContain("Activate GitHub Integration")
+ })
+
+ it("offers the slack integration while it is unconnected", async ({
+ expect,
+ }) => {
+ stubQueries({ slack: { connected: false, configured: true } })
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(wrapper.text()).toContain("Activate Slack Integration")
+ })
+
+ it("hides the slack integration on a deployment without it", async ({
+ expect,
+ }) => {
+ stubQueries({ slack: { connected: false, configured: false } })
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(wrapper.text()).not.toContain("Activate Slack Integration")
+ })
+
+ it("hides the whole section once there is nothing left to do", async ({
+ expect,
+ }) => {
+ seedAuthOrganization({
+ id: "org-1",
+ name: "Acme Corp",
+ members: [
+ { id: "m1", userId: "u1", user: { name: "Ada" } },
+ { id: "m2", userId: "u2", user: { name: "Linus" } },
+ ],
+ invitations: [],
+ })
+ stubQueries()
+
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ expect(wrapper.text()).not.toContain("Next Steps")
+ })
+
+ it("opens the github install page in the browser", async ({ expect }) => {
+ stubQueries({ gitHub: { connected: false, configured: true } })
+ mockEndpoint("GET", "/api/github/install", () => ({
+ url: "https://github.test/install",
+ }))
+ const open = vi.fn()
+ vi.stubGlobal("open", open)
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ await itemNamed(wrapper, "Activate GitHub Integration").trigger("click")
+ await settleMutations()
+
+ expect(open).toHaveBeenCalledExactlyOnceWith(
+ "https://github.test/install",
+ "_blank",
+ "noopener",
+ )
+ })
+
+ it("warns when the github install url cannot be fetched", async ({
+ expect,
+ }) => {
+ stubQueries({ gitHub: { connected: false, configured: true } })
+ mockEndpoint("GET", "/api/github/install", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ await itemNamed(wrapper, "Activate GitHub Integration").trigger("click")
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("opens the slack install page in the browser", async ({ expect }) => {
+ stubQueries({ slack: { connected: false, configured: true } })
+ mockEndpoint("GET", "/api/slack/install", () => ({
+ url: "https://slack.test/install",
+ }))
+ const open = vi.fn()
+ vi.stubGlobal("open", open)
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ await itemNamed(wrapper, "Activate Slack Integration").trigger("click")
+ await settleMutations()
+
+ expect(open).toHaveBeenCalledExactlyOnceWith(
+ "https://slack.test/install",
+ "_blank",
+ "noopener",
+ )
+ })
+
+ it("warns when the slack install url cannot be fetched", async ({
+ expect,
+ }) => {
+ stubQueries({ slack: { connected: false, configured: true } })
+ mockEndpoint("GET", "/api/slack/install", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ await itemNamed(wrapper, "Activate Slack Integration").trigger("click")
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe("page moves", { concurrent: false }, () => {
+ it("persists a move requested by the page tree", async ({ expect }) => {
+ stubQueries({ tree: [treeElement()] })
+ const calls = mockEndpoint("PUT", "/api/documents/tree", () => ({}))
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ emitFrom(wrapper, "SidebarNestedGroup", "update-location", {
+ id: DOC_ID,
+ parentId: null,
+ insertBeforeId: null,
+ })
+ await settleMutations()
+
+ expect(calls).toHaveLength(1)
+ expect(toast.custom).toHaveBeenCalledTimes(0)
+ })
+
+ it("warns when the move cannot be persisted", async ({ expect }) => {
+ stubQueries({ tree: [treeElement()] })
+ mockEndpoint("PUT", "/api/documents/tree", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ emitFrom(wrapper, "SidebarNestedGroup", "update-location", {
+ id: DOC_ID,
+ parentId: null,
+ insertBeforeId: null,
+ })
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("passes a deletion request on to its parent", async ({ expect }) => {
+ stubQueries()
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ emitFrom(wrapper, "SidebarNestedGroup", "delete", { id: DOC_ID })
+ await nextTick()
+
+ expect(sidebar(wrapper).emitted("delete-document")).toEqual([[DOC_ID]])
+ })
+
+ it("passes a duplication request on to its parent", async ({ expect }) => {
+ stubQueries()
+ const wrapper = await mountSidebar()
+ await settleMutations()
+
+ emitFrom(wrapper, "SidebarNestedGroup", "duplicate", { id: DOC_ID })
+ await nextTick()
+
+ expect(sidebar(wrapper).emitted("duplicate-document")).toEqual([[DOC_ID]])
+ })
+ })
+})
diff --git a/web/app/components/AppSidebarHeader.nuxt.test.ts b/web/app/components/AppSidebarHeader.nuxt.test.ts
new file mode 100644
index 00000000..e7402369
--- /dev/null
+++ b/web/app/components/AppSidebarHeader.nuxt.test.ts
@@ -0,0 +1,139 @@
+import { beforeEach, describe, it } from "vitest"
+import AppSidebarHeader from "./AppSidebarHeader.vue"
+import {
+ clearTeleportedOverlays,
+ menuItem,
+ mountUnderSidebarProvider,
+} from "./test-helpers"
+
+const AVATAR = { src: "", alt: "Logo", initials: "AC" }
+
+function mountHeader(workspaceName: string | null) {
+ return mountUnderSidebarProvider(AppSidebarHeader, {
+ props: { workspaceName: workspaceName, avatar: AVATAR },
+ })
+}
+
+// the dropdown body is teleported into the shared , and the colour
+// theme lives in an app-wide persistent state every mount in the file sees
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearTeleportedOverlays()
+ useAppearance().changeColorTheme("auto")
+ })
+
+ it("shows the workspace name", async ({ expect }) => {
+ const wrapper = await mountHeader("Acme Corp")
+
+ expect(wrapper.text()).toContain("Acme Corp")
+ })
+
+ it("falls back to a placeholder name when the workspace has none", async ({
+ expect,
+ }) => {
+ const wrapper = await mountHeader(null)
+
+ expect(wrapper.text()).toContain("Unknown")
+ })
+
+ it("shows the workspace initials while the logo has no source", async ({
+ expect,
+ }) => {
+ const wrapper = await mountHeader("Acme Corp")
+
+ expect(wrapper.text()).toContain("AC")
+ })
+
+ it("asks for a new document when the compose action is pressed", async ({
+ expect,
+ }) => {
+ const wrapper = await mountHeader("Acme Corp")
+ const header = wrapper.findComponent(AppSidebarHeader)
+
+ await header.get("[data-sidebar='menu-action']").trigger("click")
+
+ expect(header.emitted("create-new-item")).toHaveLength(1)
+ })
+
+ it("keeps the menu closed until the workspace button is pressed", async ({
+ expect,
+ }) => {
+ await mountHeader("Acme Corp")
+
+ expect(
+ document.body.querySelector("[data-slot='dropdown-menu-content']"),
+ ).toBeNull()
+ })
+
+ it("opens the workspace menu when the button is pressed", async ({
+ expect,
+ }) => {
+ const wrapper = await mountHeader("Acme Corp")
+
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ expect(
+ document.body.querySelector("[data-slot='dropdown-menu-content']"),
+ ).not.toBeNull()
+ })
+
+ it("asks to open settings from the menu", async ({ expect }) => {
+ const wrapper = await mountHeader("Acme Corp")
+ const header = wrapper.findComponent(AppSidebarHeader)
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem("Settings").click()
+ await nextTick()
+
+ expect(header.emitted("open-settings")).toHaveLength(1)
+ })
+
+ it("asks to log out from the menu", async ({ expect }) => {
+ const wrapper = await mountHeader("Acme Corp")
+ const header = wrapper.findComponent(AppSidebarHeader)
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem("Log Out").click()
+ await nextTick()
+
+ expect(header.emitted("log-out")).toHaveLength(1)
+ })
+
+ it.for([
+ { label: "Light Theme", expected: "light" },
+ { label: "Dark Theme", expected: "dark" },
+ ] as const)(
+ "switches the colour theme to $expected from the menu",
+ async ({ label, expected }, { expect }) => {
+ const wrapper = await mountHeader("Acme Corp")
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+ await openAppearanceSubMenu()
+
+ menuItem(label).click()
+ await nextTick()
+
+ expect(useAppearance().color.value.system).toBe(false)
+ expect(useAppearance().color.value.theme).toBe(expected)
+ },
+ )
+
+ it("switches the colour theme back to the system setting", async ({
+ expect,
+ }) => {
+ useAppearance().changeColorTheme("dark")
+ const wrapper = await mountHeader("Acme Corp")
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+ await openAppearanceSubMenu()
+
+ menuItem("System Theme").click()
+ await nextTick()
+
+ expect(useAppearance().color.value.system).toBe(true)
+ })
+})
+
+async function openAppearanceSubMenu() {
+ menuItem("Appearance").click()
+ await nextTick()
+ await nextTick()
+}
diff --git a/web/app/components/CalendarInput.nuxt.test.ts b/web/app/components/CalendarInput.nuxt.test.ts
new file mode 100644
index 00000000..f1f03138
--- /dev/null
+++ b/web/app/components/CalendarInput.nuxt.test.ts
@@ -0,0 +1,150 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { CalendarDate } from "@internationalized/date"
+import { beforeEach, describe, it, vi } from "vitest"
+import CalendarInput from "./CalendarInput.vue"
+import { clearTeleportedOverlays } from "./test-helpers"
+
+function mountInput(props: {
+ placeholder: string
+ modelValue?: CalendarDate
+ availableFromTomorrow?: boolean
+}) {
+ return mountSuspended(CalendarInput, { props: props })
+}
+
+// the popover body is teleported to , which every mount in the file
+// shares — reading it there is the only way to see the calendar
+function popoverText() {
+ return document.body.textContent
+}
+
+// the popover body is teleported into the shared , and the frozen
+// clock the availability tests need is a global, so these cannot interleave
+describe("", { concurrent: false }, () => {
+ beforeEach(clearTeleportedOverlays)
+
+ it("shows the placeholder while nothing is picked", async ({ expect }) => {
+ const wrapper = await mountInput({ placeholder: "Pick a date" })
+
+ expect(wrapper.text()).toContain("Pick a date")
+ })
+
+ it("shows the picked date instead of the placeholder", async ({ expect }) => {
+ const wrapper = await mountInput({
+ placeholder: "Pick a date",
+ modelValue: new CalendarDate(2026, 3, 14),
+ })
+
+ expect(wrapper.text()).toContain("March 14, 2026")
+ expect(wrapper.text()).not.toContain("Pick a date")
+ })
+
+ it("greys out the trigger while nothing is picked", async ({ expect }) => {
+ const wrapper = await mountInput({ placeholder: "Pick a date" })
+
+ expect(wrapper.get("button").classes()).toContain("text-muted-foreground")
+ })
+
+ it("keeps the trigger in the normal colour once a date is picked", async ({
+ expect,
+ }) => {
+ const wrapper = await mountInput({
+ placeholder: "Pick a date",
+ modelValue: new CalendarDate(2026, 3, 14),
+ })
+
+ expect(wrapper.get("button").classes()).not.toContain(
+ "text-muted-foreground",
+ )
+ })
+
+ it("keeps the calendar closed until the trigger is pressed", async ({
+ expect,
+ }) => {
+ await mountInput({ placeholder: "Pick a date" })
+
+ expect(popoverText()).not.toContain("March")
+ })
+
+ it("opens the calendar when the trigger is pressed", async ({ expect }) => {
+ const wrapper = await mountInput({
+ placeholder: "Pick a date",
+ modelValue: new CalendarDate(2026, 3, 14),
+ })
+
+ await wrapper.get("button").trigger("click")
+ await nextTick()
+
+ expect(wrapper.get("button").attributes("data-state")).toBe("open")
+ expect(document.body.querySelector("table")).not.toBeNull()
+ })
+
+ it("closes the calendar once a date is picked", async ({ expect }) => {
+ const wrapper = await mountInput({
+ placeholder: "Pick a date",
+ modelValue: new CalendarDate(2026, 3, 14),
+ })
+ await wrapper.get("button").trigger("click")
+ await nextTick()
+
+ await wrapper.setProps({ modelValue: new CalendarDate(2026, 3, 15) })
+ await nextTick()
+
+ expect(wrapper.get("button").attributes("data-state")).toBe("closed")
+ })
+
+ it("reports the day the user clicks in the calendar", async ({ expect }) => {
+ const wrapper = await mountInput({
+ placeholder: "Pick a date",
+ modelValue: new CalendarDate(2026, 3, 14),
+ })
+ await wrapper.get("button").trigger("click")
+ await nextTick()
+
+ const day = document.body.querySelector(
+ "[data-reka-calendar-cell-trigger][data-value='2026-03-20']",
+ )
+ day?.click()
+ await nextTick()
+
+ expect(String(wrapper.emitted("update:modelValue")?.at(-1)?.[0])).toBe(
+ "2026-03-20",
+ )
+ })
+
+ it("leaves every day selectable by default", async ({ expect }) => {
+ vi.setSystemTime(new Date("2026-03-14T12:00:00Z"))
+ const wrapper = await mountInput({
+ placeholder: "Pick a date",
+ modelValue: new CalendarDate(2026, 3, 14),
+ })
+
+ await wrapper.get("button").trigger("click")
+ await nextTick()
+
+ expect(
+ document.body.querySelector("[data-value='2026-03-14'][data-disabled]"),
+ ).toBeNull()
+ })
+
+ it("disables today and earlier when only tomorrow onwards is available", async ({
+ expect,
+ }) => {
+ vi.setSystemTime(new Date("2026-03-14T12:00:00Z"))
+ const wrapper = await mountInput({
+ placeholder: "Pick a date",
+ modelValue: new CalendarDate(2026, 3, 20),
+ availableFromTomorrow: true,
+ })
+
+ await wrapper.get("button").trigger("click")
+ await nextTick()
+
+ expect(
+ document.body.querySelector("[data-value='2026-03-14'][data-disabled]"),
+ ).not.toBeNull()
+ expect(
+ document.body.querySelector("[data-value='2026-03-15'][data-disabled]"),
+ ).toBeNull()
+ })
+})
diff --git a/web/app/components/DataSourceStatusIcon.nuxt.test.ts b/web/app/components/DataSourceStatusIcon.nuxt.test.ts
new file mode 100644
index 00000000..8afea28c
--- /dev/null
+++ b/web/app/components/DataSourceStatusIcon.nuxt.test.ts
@@ -0,0 +1,108 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { describe, it } from "vitest"
+import DataSourceStatusIcon from "./DataSourceStatusIcon.vue"
+import { renderedIconNames } from "./test-helpers"
+
+function makeDataSource(
+ type: DataSourceType,
+ status: DataSourceStatus = DataSourceStatus.Success,
+): DataSource {
+ return {
+ id: "ds-1",
+ name: "Metrics",
+ type: type,
+ url: "http://localhost:9090",
+ status: status,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: null,
+ }
+}
+
+describe("", () => {
+ it("renders a dashed placeholder with no icons when there is no data source", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSuspended(DataSourceStatusIcon, {
+ props: { dataSource: null },
+ })
+
+ expect(wrapper.classes()).toContain("border-dashed")
+ expect(renderedIconNames(wrapper)).toEqual([])
+ })
+
+ it.for([
+ { type: DataSourceType.Prometheus, expected: "devicon:prometheus" },
+ { type: DataSourceType.PostgreSQL, expected: "devicon:postgresql" },
+ { type: DataSourceType.MySQL, expected: "devicon:mysql" },
+ { type: DataSourceType.MariaDB, expected: "devicon:mariadb" },
+ ])("shows the $type logo", async ({ type, expected }, { expect }) => {
+ const wrapper = await mountSuspended(DataSourceStatusIcon, {
+ props: { dataSource: makeDataSource(type) },
+ })
+
+ expect(renderedIconNames(wrapper)[0]).toBe(expected)
+ })
+
+ it("falls back to the prometheus logo for a type this build does not know", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSuspended(DataSourceStatusIcon, {
+ props: {
+ dataSource: makeDataSource("clickhouse" as DataSourceType),
+ },
+ })
+
+ expect(renderedIconNames(wrapper)[0]).toBe("devicon:prometheus")
+ })
+
+ it("marks a reachable data source with a green check badge", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSuspended(DataSourceStatusIcon, {
+ props: { dataSource: makeDataSource(DataSourceType.Prometheus) },
+ })
+
+ expect(wrapper.classes()).toContain("border-status-success")
+ expect(wrapper.html()).toContain("bg-status-success")
+ expect(renderedIconNames(wrapper)).toContain("lucide:check")
+ })
+
+ it.for([
+ DataSourceStatus.Unauthorized,
+ DataSourceStatus.Unreachable,
+ DataSourceStatus.VersionNotSupported,
+ DataSourceStatus.NotReadOnly,
+ ])(
+ "marks a data source in status %s with a red cross badge",
+ async (status, { expect }) => {
+ const wrapper = await mountSuspended(DataSourceStatusIcon, {
+ props: {
+ dataSource: makeDataSource(DataSourceType.Prometheus, status),
+ },
+ })
+
+ expect(wrapper.classes()).toContain("border-status-error")
+ expect(wrapper.html()).toContain("bg-status-error")
+ expect(renderedIconNames(wrapper)).toContain("lucide:x")
+ },
+ )
+
+ it("defaults to the large size", async ({ expect }) => {
+ const wrapper = await mountSuspended(DataSourceStatusIcon, {
+ props: { dataSource: makeDataSource(DataSourceType.Prometheus) },
+ })
+
+ expect(wrapper.classes()).toContain("size-8.5")
+ })
+
+ it("renders at the small size when asked", async ({ expect }) => {
+ const wrapper = await mountSuspended(DataSourceStatusIcon, {
+ props: {
+ dataSource: makeDataSource(DataSourceType.Prometheus),
+ size: "6.5",
+ },
+ })
+
+ expect(wrapper.classes()).toContain("size-6.5")
+ })
+})
diff --git a/web/app/components/DocumentDeletionModal.nuxt.test.ts b/web/app/components/DocumentDeletionModal.nuxt.test.ts
new file mode 100644
index 00000000..60275545
--- /dev/null
+++ b/web/app/components/DocumentDeletionModal.nuxt.test.ts
@@ -0,0 +1,106 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { beforeEach, describe, it, vi } from "vitest"
+import DocumentDeletionModal from "./DocumentDeletionModal.vue"
+import { clearTeleportedOverlays, teleportedButton } from "./test-helpers"
+
+function mountModal(
+ target: { deleteDocument: () => Promise; name: string } | null,
+) {
+ return mountSuspended(DocumentDeletionModal, {
+ props: { modelValue: target },
+ })
+}
+
+// the dialog body is teleported into , so it is the only place the
+// modal's own markup can be read from
+function dialog() {
+ return document.body.querySelector("[data-slot='dialog-content']")
+}
+
+// the dialog body is teleported into the shared and the delete flow
+// is driven by the global fake timers, so these tests cannot interleave
+describe("", { concurrent: false }, () => {
+ beforeEach(clearTeleportedOverlays)
+
+ it("stays closed while there is no deletion target", async ({ expect }) => {
+ await mountModal(null)
+
+ expect(dialog()).toBeNull()
+ })
+
+ it("names the document it is about to delete", async ({ expect }) => {
+ await mountModal({ name: "Runbook", deleteDocument: vi.fn() })
+
+ expect(dialog()?.textContent).toContain("Runbook")
+ })
+
+ it("deletes the document when the confirm button is pressed", async ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+ const deleteDocument = vi.fn().mockResolvedValue(undefined)
+ const wrapper = await mountModal({ name: "Runbook", deleteDocument })
+
+ teleportedButton("Delete").click()
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(deleteDocument).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([null])
+ })
+
+ it("shows a spinner and disables both buttons while deleting", async ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+ const deleteDocument = vi.fn().mockResolvedValue(undefined)
+ await mountModal({ name: "Runbook", deleteDocument })
+
+ teleportedButton("Delete").click()
+ await nextTick()
+
+ expect(
+ dialog()?.querySelector(".i-svg-spinners\\:blocks-shuffle-3"),
+ ).not.toBeNull()
+ expect(teleportedButton("Delete").disabled).toBe(true)
+ expect(teleportedButton("Cancel").disabled).toBe(true)
+ })
+
+ it("closes without deleting when the cancel button is pressed", async ({
+ expect,
+ }) => {
+ const deleteDocument = vi.fn()
+ const wrapper = await mountModal({ name: "Runbook", deleteDocument })
+
+ teleportedButton("Cancel").click()
+ await nextTick()
+
+ expect(deleteDocument).toHaveBeenCalledTimes(0)
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([null])
+ })
+
+ it("closes without deleting when the close button is pressed", async ({
+ expect,
+ }) => {
+ const deleteDocument = vi.fn()
+ const wrapper = await mountModal({ name: "Runbook", deleteDocument })
+
+ teleportedButton("Close").click()
+ await nextTick()
+
+ expect(deleteDocument).toHaveBeenCalledTimes(0)
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([null])
+ })
+
+ it("keeps showing the document name while the dialog animates shut", async ({
+ expect,
+ }) => {
+ const wrapper = await mountModal({
+ name: "Runbook",
+ deleteDocument: vi.fn(),
+ })
+
+ await wrapper.setProps({ modelValue: null })
+
+ expect(document.body.textContent).toContain("Runbook")
+ })
+})
diff --git a/web/app/components/DocumentHeader.nuxt.test.ts b/web/app/components/DocumentHeader.nuxt.test.ts
new file mode 100644
index 00000000..81c06112
--- /dev/null
+++ b/web/app/components/DocumentHeader.nuxt.test.ts
@@ -0,0 +1,373 @@
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+ seedQueryData,
+} from "~/composables/api/test-helpers"
+import DocumentHeader from "./DocumentHeader.vue"
+import {
+ at,
+ findButtonByText,
+ menuItem,
+ mountUnderSidebarProvider,
+ seedAuthOrganization,
+ settleMutations,
+} from "./test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+const DOC_ID = "doc1".padEnd(20, "0")
+const MAIN_BRANCH = "main".padEnd(20, "0")
+const DRAFT_BRANCH = "draft".padEnd(20, "0")
+
+const BREADCRUMBS = [
+ { id: DOC_ID, name: "Runbook", href: "/Runbook", icon: "lucide:file" },
+]
+
+// the branch mutations invalidate the branch list and the document tree on
+// success; without endpoints behind them the refetch rejects and takes the
+// mutation down with it
+let branches: unknown[] = []
+
+function seedBranches(list: unknown[]) {
+ branches = list
+ seedQueryData(["documents", DOC_ID, "branches"], list)
+}
+
+function mainOnly() {
+ seedBranches([{ branchId: MAIN_BRANCH, branch: "main", default: true }])
+}
+
+function mainAndDraft() {
+ seedBranches([
+ { branchId: MAIN_BRANCH, branch: "main", default: true },
+ { branchId: DRAFT_BRANCH, branch: "draft", default: false },
+ ])
+}
+
+function mountHeader(props: Record = {}) {
+ return mountUnderSidebarProvider(DocumentHeader, {
+ props: {
+ allInitialSectionsLoaded: true,
+ breadcrumbs: BREADCRUMBS,
+ timestamps: null,
+ ...props,
+ },
+ })
+}
+
+async function openOptionsMenu(
+ wrapper: Awaited>,
+) {
+ const triggers = wrapper.findAll("[data-slot='dropdown-menu-trigger']")
+
+ await at(triggers, triggers.length - 1).trigger("click")
+}
+
+// the editor store, the query cache and the vue-sonner module mock are all
+// app-wide singletons every mount in the file shares, and the menus are
+// teleported into the shared
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ mockEndpoint("GET", `/api/documents/${DOC_ID}/branches`, () => branches)
+ mockEndpoint("GET", "/api/documents/tree", () => [])
+
+ const editorStore = useEditorStore()
+ editorStore.activeDocumentId = DOC_ID
+ editorStore.activeBranchId = MAIN_BRANCH
+ editorStore.mappedDefaultBranchId = MAIN_BRANCH
+ editorStore.aiAssistantOpen = false
+ useEditorMeta().setEditable(true)
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("stays blank until the initial sections have loaded", async ({
+ expect,
+ }) => {
+ mainOnly()
+
+ const wrapper = await mountHeader({ allInitialSectionsLoaded: false })
+
+ expect(wrapper.findComponent(DocumentHeader).text()).toBe("")
+ })
+
+ it("shows the document breadcrumbs", async ({ expect }) => {
+ mainOnly()
+
+ const wrapper = await mountHeader()
+
+ expect(wrapper.text()).toContain("Runbook")
+ })
+
+ it("omits the breadcrumbs while the document is still loading", async ({
+ expect,
+ }) => {
+ mainOnly()
+
+ const wrapper = await mountHeader({ breadcrumbs: null })
+
+ expect(wrapper.text()).not.toContain("Runbook")
+ })
+
+ it("shows when the document was last edited", async ({ expect }) => {
+ mainOnly()
+
+ const wrapper = await mountHeader({
+ timestamps: {
+ [MAIN_BRANCH]: {
+ updated: { at: "2026-03-14T12:00:00Z", user: { name: "Ada" } },
+ created: { at: "2026-03-01T12:00:00Z", user: { name: "Ada" } },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain("Edited Mar 14")
+ })
+
+ it("omits the timestamp when the branch has none", async ({ expect }) => {
+ mainOnly()
+
+ const wrapper = await mountHeader({ timestamps: {} })
+
+ expect(wrapper.text()).not.toContain("Edited")
+ })
+
+ it("offers a read/edit toggle while the document is not reviewable", async ({
+ expect,
+ }) => {
+ mainOnly()
+
+ const wrapper = await mountHeader()
+
+ expect(wrapper.text()).toContain("Toggle Edit Mode")
+ })
+
+ it("switches from edit mode to read mode", async ({ expect }) => {
+ mainOnly()
+ const wrapper = await mountHeader()
+
+ await findButtonByText(wrapper, "Toggle Edit Mode").trigger("click")
+
+ expect(wrapper.text()).toContain("Toggle Read Mode")
+ })
+
+ it("switches from read mode back to edit mode", async ({ expect }) => {
+ mainOnly()
+ useEditorMeta().setEditable(false)
+ const wrapper = await mountHeader()
+
+ await findButtonByText(wrapper, "Toggle Read Mode").trigger("click")
+
+ expect(wrapper.text()).toContain("Toggle Edit Mode")
+ })
+
+ it("offers a version picker once the document is reviewable", async ({
+ expect,
+ }) => {
+ mainAndDraft()
+
+ const wrapper = await mountHeader()
+
+ expect(wrapper.text()).toContain("Main Version")
+ })
+
+ it("names the draft version when the draft branch is active", async ({
+ expect,
+ }) => {
+ mainAndDraft()
+ useEditorStore().activeBranchId = DRAFT_BRANCH
+
+ const wrapper = await mountHeader()
+
+ expect(wrapper.text()).toContain("Draft Version")
+ })
+
+ it("falls back to the main version when the active branch is unknown", async ({
+ expect,
+ }) => {
+ mainAndDraft()
+ useEditorStore().activeBranchId = "gone".padEnd(20, "0")
+
+ const wrapper = await mountHeader()
+
+ expect(wrapper.text()).toContain("Main Version")
+ })
+
+ it("switches to the draft branch from the version picker", async ({
+ expect,
+ }) => {
+ mainAndDraft()
+ const editorStore = useEditorStore()
+ const wrapper = await mountHeader()
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem("Draft Version").click()
+ await nextTick()
+
+ expect(editorStore.activeBranchId).toBe(DRAFT_BRANCH)
+ expect(editorStore.targetBranchId).toBe(MAIN_BRANCH)
+ })
+
+ it("switches back to the main branch from the version picker", async ({
+ expect,
+ }) => {
+ mainAndDraft()
+ const editorStore = useEditorStore()
+ editorStore.activeBranchId = DRAFT_BRANCH
+ const wrapper = await mountHeader()
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem("Main Version").click()
+ await nextTick()
+
+ expect(editorStore.activeBranchId).toBe(MAIN_BRANCH)
+ expect(editorStore.targetBranchId).toBeNull()
+ })
+
+ it("asks to duplicate the document from the options menu", async ({
+ expect,
+ }) => {
+ mainOnly()
+ const wrapper = await mountHeader()
+ await openOptionsMenu(wrapper)
+
+ menuItem("Duplicate").click()
+ await nextTick()
+
+ expect(
+ wrapper.findComponent(DocumentHeader).emitted("duplicate-document"),
+ ).toHaveLength(1)
+ })
+
+ it("asks to delete the document from the options menu", async ({
+ expect,
+ }) => {
+ mainOnly()
+ const wrapper = await mountHeader()
+ await openOptionsMenu(wrapper)
+
+ menuItem("Delete").click()
+ await nextTick()
+
+ expect(
+ wrapper.findComponent(DocumentHeader).emitted("delete-document"),
+ ).toHaveLength(1)
+ })
+
+ it("opens the review workflow by branching off main and protecting it", async ({
+ expect,
+ }) => {
+ mainOnly()
+ const created = mockEndpoint(
+ "POST",
+ `/api/documents/${DOC_ID}/branches`,
+ () => ({ branchId: DRAFT_BRANCH }),
+ )
+ const updated = mockEndpoint(
+ "PUT",
+ `/api/documents/${DOC_ID}/branches/${MAIN_BRANCH}`,
+ () => ({}),
+ )
+ const wrapper = await mountHeader()
+ await openOptionsMenu(wrapper)
+
+ menuItem("Make Reviewable").click()
+ await settleMutations()
+
+ expect(created).toHaveLength(1)
+ expect(created[0]?.body).toEqual({
+ branch: "draft",
+ sourceBranchId: MAIN_BRANCH,
+ })
+ expect(updated[0]?.body).toEqual({ protected: true })
+ })
+
+ it("closes the review workflow by dropping every draft and unprotecting main", async ({
+ expect,
+ }) => {
+ mainAndDraft()
+ const deleted = mockEndpoint(
+ "DELETE",
+ `/api/documents/${DOC_ID}/branches/${DRAFT_BRANCH}`,
+ () => ({}),
+ )
+ const updated = mockEndpoint(
+ "PUT",
+ `/api/documents/${DOC_ID}/branches/${MAIN_BRANCH}`,
+ () => ({}),
+ )
+ const wrapper = await mountHeader()
+ await openOptionsMenu(wrapper)
+
+ menuItem("Make Non Reviewable").click()
+ await settleMutations()
+
+ expect(deleted).toHaveLength(1)
+ expect(updated[0]?.body).toEqual({ protected: false })
+ })
+
+ it("warns when the reviewability change fails", async ({ expect }) => {
+ mainOnly()
+ mockEndpoint("POST", `/api/documents/${DOC_ID}/branches`, () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountHeader()
+ await openOptionsMenu(wrapper)
+
+ menuItem("Make Reviewable").click()
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("leaves the branches alone when no document is active", async ({
+ expect,
+ }) => {
+ mainOnly()
+ const created = mockEndpoint(
+ "POST",
+ `/api/documents/${DOC_ID}/branches`,
+ () => ({ branchId: DRAFT_BRANCH }),
+ )
+ const wrapper = await mountHeader()
+ await openOptionsMenu(wrapper)
+ useEditorStore().activeDocumentId = null
+
+ menuItem("Make Reviewable").click()
+ await settleMutations()
+
+ expect(created).toHaveLength(0)
+ })
+
+ it("hides the assistant button while the feature is off", async ({
+ expect,
+ }) => {
+ mainOnly()
+ seedAuthOrganization({ id: "org-without-the-feature" })
+
+ const wrapper = await mountHeader()
+
+ expect(wrapper.text()).not.toContain("Rubber Duck")
+ })
+
+ it("publishes the header height so the page can offset content", async ({
+ expect,
+ }) => {
+ mainOnly()
+
+ await mountHeader()
+
+ expect(
+ document.documentElement.style.getPropertyValue(
+ "--document-header-height",
+ ),
+ ).toBe("0px")
+ })
+})
diff --git a/web/app/components/HeaderBreadcrumbs.nuxt.test.ts b/web/app/components/HeaderBreadcrumbs.nuxt.test.ts
new file mode 100644
index 00000000..3adfe06e
--- /dev/null
+++ b/web/app/components/HeaderBreadcrumbs.nuxt.test.ts
@@ -0,0 +1,113 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import type { VueWrapper } from "@vue/test-utils"
+import { describe, it } from "vitest"
+import HeaderBreadcrumbs from "./HeaderBreadcrumbs.vue"
+import type { DocumentBreadcrumb } from "./sidebar"
+
+function crumb(name: string): DocumentBreadcrumb {
+ return {
+ id: `id-${name}`,
+ name: name,
+ href: `/${name}`,
+ icon: `lucide:${name}`,
+ }
+}
+
+function crumbs(...names: string[]): DocumentBreadcrumb[] {
+ return names.map(crumb)
+}
+
+// the crumbs the component actually rendered, in document order. The
+// current page is a , the ancestors are links
+function renderedNames(wrapper: VueWrapper) {
+ return wrapper.findAll("span.truncate").map((span) => span.text())
+}
+
+describe("", () => {
+ it("renders nothing when there are no breadcrumbs", async ({ expect }) => {
+ const wrapper = await mountSuspended(HeaderBreadcrumbs, {
+ props: { breadcrumbs: [] },
+ })
+
+ expect(wrapper.find("nav").exists()).toBe(false)
+ })
+
+ it("shows only the current page when it is the whole path", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSuspended(HeaderBreadcrumbs, {
+ props: { breadcrumbs: crumbs("Runbook") },
+ })
+
+ expect(renderedNames(wrapper)).toEqual(["Runbook"])
+ expect(wrapper.findAll("a")).toHaveLength(0)
+ })
+
+ it("links the ancestor and shows the current page for a two-level path", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSuspended(HeaderBreadcrumbs, {
+ props: { breadcrumbs: crumbs("Root", "Leaf") },
+ })
+
+ expect(renderedNames(wrapper)).toEqual(["Root", "Leaf"])
+ expect(wrapper.findAll("a").map((a) => a.attributes("href"))).toEqual([
+ "/Root",
+ ])
+ })
+
+ it("shows all three crumbs of a three-level path without an ellipsis", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSuspended(HeaderBreadcrumbs, {
+ props: { breadcrumbs: crumbs("Root", "Middle", "Leaf") },
+ })
+
+ expect(renderedNames(wrapper)).toEqual(["Root", "Middle", "Leaf"])
+ expect(wrapper.find("[data-collapsed-exist]").exists()).toBe(false)
+ })
+
+ it("collapses the middle of a deeper path into an ellipsis", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSuspended(HeaderBreadcrumbs, {
+ props: { breadcrumbs: crumbs("Root", "A", "B", "Parent", "Leaf") },
+ })
+
+ expect(renderedNames(wrapper)).toEqual(["Root", "Parent", "Leaf"])
+ expect(wrapper.find("[data-collapsed-exist]").exists()).toBe(true)
+ })
+
+ it("links every ancestor it renders at its own href", async ({ expect }) => {
+ const wrapper = await mountSuspended(HeaderBreadcrumbs, {
+ props: { breadcrumbs: crumbs("Root", "A", "Parent", "Leaf") },
+ })
+
+ expect(wrapper.findAll("a").map((a) => a.attributes("href"))).toEqual([
+ "/Root",
+ "/Parent",
+ ])
+ })
+
+ it("shows each crumb's own icon", async ({ expect }) => {
+ const wrapper = await mountSuspended(HeaderBreadcrumbs, {
+ props: { breadcrumbs: crumbs("Root", "Leaf") },
+ })
+
+ expect(
+ wrapper
+ .findAll('[data-slot="breadcrumb-item"] .iconify')
+ .map((i) => i.classes().find((c) => c.startsWith("i-"))),
+ ).toEqual(["i-lucide:Root", "i-lucide:Leaf"])
+ })
+
+ it("re-renders when the breadcrumb path changes", async ({ expect }) => {
+ const wrapper = await mountSuspended(HeaderBreadcrumbs, {
+ props: { breadcrumbs: crumbs("Root", "Leaf") },
+ })
+
+ await wrapper.setProps({ breadcrumbs: crumbs("Other") })
+
+ expect(renderedNames(wrapper)).toEqual(["Other"])
+ })
+})
diff --git a/web/app/components/NotificationBox.nuxt.test.ts b/web/app/components/NotificationBox.nuxt.test.ts
new file mode 100644
index 00000000..32bed71c
--- /dev/null
+++ b/web/app/components/NotificationBox.nuxt.test.ts
@@ -0,0 +1,641 @@
+import { mockNuxtImport, mountSuspended } from "@nuxt/test-utils/runtime"
+import { flushPromises } from "@vue/test-utils"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+ seedQueryData,
+} from "~/composables/api/test-helpers"
+import NotificationBox from "./NotificationBox.vue"
+import {
+ at,
+ findButtonByText,
+ mockAuthOrganization,
+ renderedIconNames,
+ seedAuthOrganization,
+} from "./test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+// opening a notification navigates; the destination it picks is the whole
+// behaviour, and the test app has no page to land on
+const navigateToMock = vi.hoisted(() => vi.fn())
+mockNuxtImport("navigateTo", () => navigateToMock)
+
+const DOC_ID = "doc1".padEnd(20, "0")
+const USER_ID = "user".padEnd(20, "0")
+
+function makeNotification(overrides: Record = {}) {
+ return {
+ id: "notif-1",
+ userId: USER_ID,
+ organizationId: "org-1",
+ code: NotificationCode.DocumentReviewRequest,
+ metadata: { documentId: DOC_ID, branchId: "branch-1" },
+ read: false,
+ createdAt: "2026-03-14T11:00:00Z",
+ ...overrides,
+ }
+}
+
+function seedNotifications(notifications: unknown[]) {
+ seedQueryData(["notifications", "list", 100, 1], {
+ notifications: notifications,
+ pageCount: 1,
+ })
+ seedQueryData(["notifications", "count", false], { count: 0 })
+}
+
+function seedTree() {
+ seedQueryData(
+ ["documents", "tree"],
+ [
+ {
+ id: DOC_ID,
+ documentName: "Runbook",
+ icon: "lucide:file",
+ protected: false,
+ children: null,
+ },
+ ],
+ )
+}
+
+function mountBox(props: Record = {}) {
+ return mountSuspended(NotificationBox, { props: props })
+}
+
+function rows(wrapper: Awaited>) {
+ return wrapper.findAll("div.cursor-pointer")
+}
+
+// the query cache, the pinia websocket store and the vue-sonner module
+// mock are all app-wide singletons every mount in the file shares
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ navigateToMock.mockReset()
+ vi.setSystemTime(new Date("2026-03-14T12:00:00Z"))
+ // a successful mark-read invalidates both notification queries; without
+ // these the refetch rejects and drags the mutation down with it, so the
+ // failure toast lands in whichever test is running by then
+ mockEndpoint("GET", "/api/notifications", () => ({
+ notifications: [],
+ pageCount: 1,
+ }))
+ mockEndpoint("GET", "/api/notifications/count", () => ({ count: 0 }))
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("says the inbox is empty when there are no notifications", async ({
+ expect,
+ }) => {
+ seedNotifications([])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("All caught up")
+ })
+
+ it("lists one row per notification", async ({ expect }) => {
+ seedNotifications([makeNotification(), makeNotification({ id: "notif-2" })])
+
+ const wrapper = await mountBox()
+
+ expect(rows(wrapper)).toHaveLength(2)
+ })
+
+ it("names the document the notification is about", async ({ expect }) => {
+ seedTree()
+ seedNotifications([makeNotification()])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("Runbook")
+ })
+
+ it("falls back to a placeholder name for a deleted document", async ({
+ expect,
+ }) => {
+ seedNotifications([makeNotification()])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("Deleted Page")
+ })
+
+ it("labels a notification from the last minute as new", async ({
+ expect,
+ }) => {
+ seedNotifications([makeNotification({ createdAt: "2026-03-14T11:59:30Z" })])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("now")
+ })
+
+ it("shows how long ago an older notification arrived", async ({ expect }) => {
+ seedNotifications([makeNotification({ createdAt: "2026-03-14T11:00:00Z" })])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("1 hour ago")
+ })
+
+ it("dims a notification that has been read", async ({ expect }) => {
+ seedNotifications([makeNotification({ read: true })])
+
+ const wrapper = await mountBox()
+
+ expect(rows(wrapper)[0]?.classes()).toContain("opacity-60")
+ })
+
+ it("offers a mark-read button only on unread notifications", async ({
+ expect,
+ }) => {
+ seedNotifications([
+ makeNotification({ id: "notif-1", read: false }),
+ makeNotification({ id: "notif-2", read: true }),
+ ])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text().match(/Mark as read/g)).toHaveLength(1)
+ })
+
+ describe("descriptions", { concurrent: false }, () => {
+ it("describes a review request", async ({ expect }) => {
+ seedNotifications([makeNotification()])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("Review requested for the draft version")
+ })
+
+ it("names the hook that fired", async ({ expect }) => {
+ seedNotifications([
+ makeNotification({
+ code: NotificationCode.DocumentHookTrigerred,
+ metadata: {
+ documentId: DOC_ID,
+ branchId: "b",
+ blockId: null,
+ type: DocumentHookType.URLWatcher,
+ },
+ }),
+ ])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("hook was triggered")
+ })
+
+ it("names the commenter on a new comment", async ({ expect }) => {
+ seedAuthOrganization({
+ members: [{ userId: USER_ID, user: { name: "Ada" } }],
+ })
+ seedNotifications([
+ makeNotification({
+ code: NotificationCode.DocumentNewComment,
+ metadata: {
+ userId: USER_ID,
+ documentId: DOC_ID,
+ branchId: "b",
+ commentId: "c",
+ anchorBlockId: null,
+ },
+ }),
+ ])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("Ada has posted a new comment")
+ })
+
+ it("names the commenter on a reply", async ({ expect }) => {
+ seedAuthOrganization({
+ members: [{ userId: USER_ID, user: { name: "Ada" } }],
+ })
+ seedNotifications([
+ makeNotification({
+ code: NotificationCode.DocumentNewCommentReply,
+ metadata: {
+ userId: USER_ID,
+ documentId: DOC_ID,
+ branchId: "b",
+ commentId: "c",
+ commentReplyId: "r",
+ anchorBlockId: null,
+ },
+ }),
+ ])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("Ada has posted a reply to a comment")
+ })
+
+ it("calls an unknown commenter deleted", async ({ expect }) => {
+ seedAuthOrganization({ members: [] })
+ seedNotifications([
+ makeNotification({
+ code: NotificationCode.DocumentNewComment,
+ metadata: {
+ userId: USER_ID,
+ documentId: DOC_ID,
+ branchId: "b",
+ commentId: "c",
+ anchorBlockId: null,
+ },
+ }),
+ ])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("deleted has posted a new comment")
+ })
+
+ it("falls back to a generic description for an unknown code", async ({
+ expect,
+ }) => {
+ seedNotifications([makeNotification({ code: "notification.unknown" })])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).toContain("You have a new notification")
+ })
+ })
+
+ describe("icons", { concurrent: false }, () => {
+ it("marks a review request with a reviewer icon", async ({ expect }) => {
+ seedNotifications([makeNotification()])
+
+ const wrapper = await mountBox()
+
+ expect(renderedIconNames(wrapper)).toContain("lucide:file-user")
+ })
+
+ it.for([
+ { type: DocumentHookType.URLWatcher, expected: "mingcute:earth-2-line" },
+ {
+ type: DocumentHookType.GitHubTracking,
+ expected: "simple-icons:github",
+ },
+ { type: DocumentHookType.ScheduledReminder, expected: "lucide:timer" },
+ {
+ type: DocumentHookType.ContainerImageWatcher,
+ expected: "lucide:container",
+ },
+ ])(
+ "marks a $type hook trigger with its own icon",
+ async ({ type, expected }, { expect }) => {
+ seedNotifications([
+ makeNotification({
+ code: NotificationCode.DocumentHookTrigerred,
+ metadata: {
+ documentId: DOC_ID,
+ branchId: "b",
+ blockId: null,
+ type: type,
+ },
+ }),
+ ])
+
+ const wrapper = await mountBox()
+
+ expect(renderedIconNames(wrapper)).toContain(expected)
+ },
+ )
+
+ it("marks a hook type this build does not know with a warning icon", async ({
+ expect,
+ }) => {
+ seedNotifications([
+ makeNotification({
+ code: NotificationCode.DocumentHookTrigerred,
+ metadata: {
+ documentId: DOC_ID,
+ branchId: "b",
+ blockId: null,
+ type: "future-hook" as DocumentHookType,
+ },
+ }),
+ ])
+
+ const wrapper = await mountBox()
+
+ expect(renderedIconNames(wrapper)).toContain(
+ "lucide:file-exclamation-point",
+ )
+ })
+
+ it("marks a comment notification with a message icon", async ({
+ expect,
+ }) => {
+ seedNotifications([
+ makeNotification({
+ code: NotificationCode.DocumentNewComment,
+ metadata: {
+ userId: USER_ID,
+ documentId: DOC_ID,
+ branchId: "b",
+ commentId: "c",
+ anchorBlockId: null,
+ },
+ }),
+ ])
+
+ const wrapper = await mountBox()
+
+ expect(renderedIconNames(wrapper)).toContain("mingcute:message-4-fill")
+ })
+ })
+
+ describe("marking as read", { concurrent: false }, () => {
+ it("marks a single notification read", async ({ expect }) => {
+ const calls = mockEndpoint(
+ "PUT",
+ "/api/notifications/read-status",
+ () => ({}),
+ )
+ seedNotifications([makeNotification()])
+ const wrapper = await mountBox()
+
+ await findButtonByText(wrapper, "Mark as read").trigger("click")
+ await flushPromises()
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({ ids: ["notif-1"] })
+ })
+
+ it("warns when marking a single notification read fails", async ({
+ expect,
+ }) => {
+ mockEndpoint("PUT", "/api/notifications/read-status", () => {
+ throw new Error("boom")
+ })
+ seedNotifications([makeNotification()])
+ const wrapper = await mountBox()
+
+ await findButtonByText(wrapper, "Mark as read").trigger("click")
+ await flushPromises()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("marks every notification read with an empty id list", async ({
+ expect,
+ }) => {
+ const calls = mockEndpoint(
+ "PUT",
+ "/api/notifications/read-status",
+ () => ({}),
+ )
+ seedNotifications([makeNotification()])
+ const wrapper = await mountBox()
+
+ await findButtonByText(wrapper, "Read all").trigger("click")
+ await flushPromises()
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({ ids: [] })
+ })
+
+ it("does nothing when every notification is already read", async ({
+ expect,
+ }) => {
+ const calls = mockEndpoint(
+ "PUT",
+ "/api/notifications/read-status",
+ () => ({}),
+ )
+ seedNotifications([makeNotification({ read: true })])
+ const wrapper = await mountBox()
+
+ await findButtonByText(wrapper, "Read all").trigger("click")
+ await flushPromises()
+
+ expect(calls).toHaveLength(0)
+ })
+
+ it("warns when marking everything read fails", async ({ expect }) => {
+ mockEndpoint("PUT", "/api/notifications/read-status", () => {
+ throw new Error("boom")
+ })
+ seedNotifications([makeNotification()])
+ const wrapper = await mountBox()
+
+ await findButtonByText(wrapper, "Read all").trigger("click")
+ await flushPromises()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe("navigation", { concurrent: false }, () => {
+ it("announces the navigation when a notification is opened", async ({
+ expect,
+ }) => {
+ mockEndpoint("PUT", "/api/notifications/read-status", () => ({}))
+ seedAuthOrganization({ slug: "acme" })
+ seedTree()
+ seedNotifications([makeNotification()])
+ const wrapper = await mountBox()
+
+ await at(rows(wrapper), 0).trigger("click")
+ await flushPromises()
+
+ expect(wrapper.emitted("notification-navigation")).toHaveLength(1)
+ })
+
+ it.for([
+ {
+ name: "opens a review request at the document",
+ notification: {
+ code: NotificationCode.DocumentReviewRequest,
+ metadata: { documentId: DOC_ID, branchId: "b" },
+ },
+ expected: `/acme/Runbook-${DOC_ID}`,
+ },
+ {
+ name: "opens a hook trigger at the block that fired",
+ notification: {
+ code: NotificationCode.DocumentHookTrigerred,
+ metadata: {
+ documentId: DOC_ID,
+ branchId: "b",
+ blockId: "block-7",
+ type: DocumentHookType.URLWatcher,
+ },
+ },
+ expected: `/acme/Runbook-${DOC_ID}#block-7`,
+ },
+ {
+ name: "opens a document-wide hook trigger at the document",
+ notification: {
+ code: NotificationCode.DocumentHookTrigerred,
+ metadata: {
+ documentId: DOC_ID,
+ branchId: "b",
+ blockId: null,
+ type: DocumentHookType.URLWatcher,
+ },
+ },
+ expected: `/acme/Runbook-${DOC_ID}`,
+ },
+ {
+ name: "opens a new comment at the block it is anchored to",
+ notification: {
+ code: NotificationCode.DocumentNewComment,
+ metadata: {
+ userId: USER_ID,
+ documentId: DOC_ID,
+ branchId: "b",
+ commentId: "c",
+ anchorBlockId: "block-3",
+ },
+ },
+ expected: `/acme/Runbook-${DOC_ID}#block-3`,
+ },
+ {
+ name: "opens an unanchored comment at the document",
+ notification: {
+ code: NotificationCode.DocumentNewComment,
+ metadata: {
+ userId: USER_ID,
+ documentId: DOC_ID,
+ branchId: "b",
+ commentId: "c",
+ anchorBlockId: null,
+ },
+ },
+ expected: `/acme/Runbook-${DOC_ID}`,
+ },
+ {
+ name: "opens a comment reply at the block it is anchored to",
+ notification: {
+ code: NotificationCode.DocumentNewCommentReply,
+ metadata: {
+ userId: USER_ID,
+ documentId: DOC_ID,
+ branchId: "b",
+ commentId: "c",
+ commentReplyId: "r",
+ anchorBlockId: "block-4",
+ },
+ },
+ expected: `/acme/Runbook-${DOC_ID}#block-4`,
+ },
+ {
+ name: "opens an unanchored comment reply at the document",
+ notification: {
+ code: NotificationCode.DocumentNewCommentReply,
+ metadata: {
+ userId: USER_ID,
+ documentId: DOC_ID,
+ branchId: "b",
+ commentId: "c",
+ commentReplyId: "r",
+ anchorBlockId: null,
+ },
+ },
+ expected: `/acme/Runbook-${DOC_ID}`,
+ },
+ ])("$name", async ({ notification, expected }, { expect }) => {
+ mockEndpoint("PUT", "/api/notifications/read-status", () => ({}))
+ mockAuthOrganization({ id: "org-1", slug: "acme", members: [] })
+ seedTree()
+ seedNotifications([makeNotification(notification)])
+ const wrapper = await mountBox()
+
+ await at(rows(wrapper), 0).trigger("click")
+ await flushPromises()
+
+ expect(navigateToMock).toHaveBeenCalledExactlyOnceWith(expected)
+ })
+
+ it("goes nowhere for a notification code it does not know", async ({
+ expect,
+ }) => {
+ mockAuthOrganization({ id: "org-1", slug: "acme", members: [] })
+ seedTree()
+ seedNotifications([makeNotification({ code: "notification.unknown" })])
+ const wrapper = await mountBox()
+
+ await at(rows(wrapper), 0).trigger("click")
+ await flushPromises()
+
+ expect(navigateToMock).toHaveBeenCalledTimes(0)
+ })
+
+ it("stays put when the notification's document no longer exists", async ({
+ expect,
+ }) => {
+ seedAuthOrganization({ slug: "acme" })
+ seedNotifications([makeNotification()])
+ const wrapper = await mountBox()
+
+ await at(rows(wrapper), 0).trigger("click")
+ await flushPromises()
+
+ expect(wrapper.emitted("notification-navigation")).toBeUndefined()
+ })
+ })
+
+ describe("mobile", { concurrent: false }, () => {
+ it("hides the close button on wide viewports", async ({ expect }) => {
+ seedNotifications([])
+
+ const wrapper = await mountBox()
+
+ expect(wrapper.text()).not.toContain("Close notifications")
+ })
+
+ it("closes the box when its close button is pressed", async ({
+ expect,
+ }) => {
+ seedNotifications([])
+ const wrapper = await mountBox({ mobile: true })
+
+ await findButtonByText(wrapper, "Close notifications").trigger("click")
+
+ expect(wrapper.emitted("close-notification-box")).toHaveLength(1)
+ })
+ })
+
+ describe("websocket updates", { concurrent: false }, () => {
+ it("subscribes to notification creations while mounted", async ({
+ expect,
+ }) => {
+ const subscribe = vi.fn().mockReturnValue(vi.fn())
+ useWebSocketStateStore().state = { subscribe } as never
+ seedNotifications([])
+
+ await mountBox()
+
+ expect(subscribe).toHaveBeenCalledTimes(1)
+ expect(subscribe.mock.calls[0]?.[0]).toBe(WS_NOTIFICATION_CREATION_TOPIC)
+ useWebSocketStateStore().state = null
+ })
+
+ it("unsubscribes when it goes away", async ({ expect }) => {
+ const unsubscribe = vi.fn()
+ useWebSocketStateStore().state = {
+ subscribe: vi.fn().mockReturnValue(unsubscribe),
+ } as never
+ seedNotifications([])
+ const wrapper = await mountBox()
+
+ wrapper.unmount()
+
+ expect(unsubscribe).toHaveBeenCalledTimes(1)
+ useWebSocketStateStore().state = null
+ })
+ })
+})
diff --git a/web/app/components/NotificationSidebar.nuxt.test.ts b/web/app/components/NotificationSidebar.nuxt.test.ts
new file mode 100644
index 00000000..4c0d4b9e
--- /dev/null
+++ b/web/app/components/NotificationSidebar.nuxt.test.ts
@@ -0,0 +1,175 @@
+import { beforeEach, describe, it, vi } from "vitest"
+import NotificationSidebar from "./NotificationSidebar.vue"
+import { emitFrom, mountUnderSidebarProvider } from "./test-helpers"
+
+// matchMedia is what useMediaQuery reads; happy-dom's own implementation
+// always reports "not matching", so the mobile layout needs a stub
+function stubViewport(matches: boolean) {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn((query: string) => ({
+ matches: matches,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ )
+}
+
+function mountSidebar(open: boolean) {
+ return mountUnderSidebarProvider(NotificationSidebar, {
+ props: { modelValue: open },
+ // the notification box owns its own queries and websocket
+ // subscription; this component only positions it
+ stubs: { NotificationBox: true },
+ })
+}
+
+// the positioned panel is the box's own wrapper element
+function panel(wrapper: Awaited>) {
+ const element = wrapper.get("notification-box-stub").element.parentElement
+ if (!element) {
+ throw new Error("the notification box is not inside a panel")
+ }
+
+ return element
+}
+
+// the viewport stub is a global shared by every mount in the file
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ // the sidebar provider keeps its open state in this cookie, which
+ // every mount in the file shares
+ document.cookie = "sidebar-state=; max-age=0; path=/"
+ })
+
+ it("collapses the column while the inbox is closed", async ({ expect }) => {
+ stubViewport(false)
+
+ const wrapper = await mountSidebar(false)
+
+ expect(wrapper.html()).toContain("grid-cols-[0rem]")
+ })
+
+ it("opens the column when the inbox is open", async ({ expect }) => {
+ stubViewport(false)
+
+ const wrapper = await mountSidebar(true)
+
+ expect(wrapper.html()).toContain("grid-cols-[20rem]")
+ })
+
+ it("slides the panel out of view while the inbox is closed", async ({
+ expect,
+ }) => {
+ stubViewport(false)
+
+ const wrapper = await mountSidebar(false)
+
+ expect(panel(wrapper).className).toContain("-translate-x-full")
+ })
+
+ it("parks the panel against the left edge while the inbox is closed", async ({
+ expect,
+ }) => {
+ stubViewport(false)
+
+ const wrapper = await mountSidebar(false)
+
+ expect(panel(wrapper).getAttribute("style")).toContain("left: 0px")
+ })
+
+ it("offsets the open panel by the sidebar's width", async ({ expect }) => {
+ stubViewport(false)
+
+ const wrapper = await mountSidebar(true)
+
+ expect(panel(wrapper).getAttribute("style")).toContain("left: 224px")
+ })
+
+ it("keeps the inbox box mounted on wide viewports", async ({ expect }) => {
+ stubViewport(false)
+
+ const wrapper = await mountSidebar(false)
+
+ expect(wrapper.find("notification-box-stub").exists()).toBe(true)
+ })
+
+ it("leaves the sheet closed on narrow viewports while the inbox is closed", async ({
+ expect,
+ }) => {
+ stubViewport(true)
+
+ await mountSidebar(false)
+
+ expect(
+ document.body.querySelector("[data-slot='sheet-content']"),
+ ).toBeNull()
+ })
+
+ it("opens a sheet on narrow viewports when the inbox is open", async ({
+ expect,
+ }) => {
+ stubViewport(true)
+
+ await mountSidebar(true)
+
+ expect(
+ document.body.querySelector("[data-slot='sheet-content']"),
+ ).not.toBeNull()
+ })
+
+ it("keeps the panel against the left edge while the sidebar is collapsed", async ({
+ expect,
+ }) => {
+ stubViewport(false)
+ // the provider reads its open state from this cookie
+ document.cookie = "sidebar-state=false; path=/"
+
+ const wrapper = await mountSidebar(true)
+
+ expect(panel(wrapper).getAttribute("style")).toContain("left: 0px")
+ })
+
+ it("closes the inbox when the sheet is dismissed", async ({ expect }) => {
+ stubViewport(true)
+ const wrapper = await mountSidebar(true)
+
+ emitFrom(wrapper, "Sheet", "update:open", false)
+ await nextTick()
+
+ expect(
+ wrapper.findComponent(NotificationSidebar).emitted("update:modelValue"),
+ ).toEqual([[false]])
+ })
+
+ it("closes the inbox when the box asks to be closed", async ({ expect }) => {
+ stubViewport(true)
+ const wrapper = await mountSidebar(true)
+
+ emitFrom(wrapper, "NotificationBox", "close-notification-box")
+ await nextTick()
+
+ expect(
+ wrapper.findComponent(NotificationSidebar).emitted("update:modelValue"),
+ ).toEqual([[false]])
+ })
+
+ it("passes a navigation from the inbox box on to its parent", async ({
+ expect,
+ }) => {
+ stubViewport(false)
+ const wrapper = await mountSidebar(true)
+
+ emitFrom(wrapper, "NotificationBox", "notification-navigation")
+ await nextTick()
+
+ expect(
+ wrapper
+ .findComponent(NotificationSidebar)
+ .emitted("notification-navigation"),
+ ).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/NumberInput.nuxt.test.ts b/web/app/components/NumberInput.nuxt.test.ts
new file mode 100644
index 00000000..0c6a0830
--- /dev/null
+++ b/web/app/components/NumberInput.nuxt.test.ts
@@ -0,0 +1,217 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import type { DOMWrapper } from "@vue/test-utils"
+import { describe, it } from "vitest"
+import NumberInput from "./NumberInput.vue"
+
+async function mountInput(props: Record = {}) {
+ const wrapper = await mountSuspended(NumberInput, { props: props })
+
+ return {
+ wrapper,
+ input: wrapper.get("input"),
+ // the latest value the component pushed into its v-model, or
+ // undefined when it never pushed one
+ model: () => {
+ const updates = wrapper.emitted("update:modelValue")
+
+ return updates?.[updates.length - 1]?.[0]
+ },
+ }
+}
+
+// drives the input the way a user does: the DOM value changes, then the
+// component's input handler runs
+async function type(
+ input: Omit, "exists">,
+ value: string,
+) {
+ await input.setValue(value)
+ await nextTick()
+}
+
+describe("", () => {
+ it("shows the model value in the input", async ({ expect }) => {
+ const { input } = await mountInput({ modelValue: 42 })
+
+ expect(input.element.value).toBe("42")
+ })
+
+ it("shows an empty input when the model is undefined", async ({ expect }) => {
+ const { input } = await mountInput({ modelValue: undefined })
+
+ expect(input.element.value).toBe("")
+ })
+
+ it("follows the model when it changes from the outside", async ({
+ expect,
+ }) => {
+ const { wrapper, input } = await mountInput({ modelValue: 1 })
+
+ await wrapper.setProps({ modelValue: 7 })
+
+ expect(input.element.value).toBe("7")
+ })
+
+ it("reports the typed number through the model", async ({ expect }) => {
+ const { input, model } = await mountInput()
+
+ await type(input, "12")
+
+ expect(model()).toBe(12)
+ })
+
+ it("clears the model when the input is emptied", async ({ expect }) => {
+ const { input, model } = await mountInput({ modelValue: 5 })
+
+ await type(input, "")
+
+ expect(model()).toBeUndefined()
+ })
+
+ it("holds a half-typed decimal without reporting it", async ({ expect }) => {
+ const { input, model } = await mountInput({ decimal: true })
+
+ await type(input, "1.")
+
+ expect(input.element.value).toBe("1.")
+ expect(model()).toBeUndefined()
+ })
+
+ it("holds a half-typed negative without reporting it", async ({ expect }) => {
+ const { input, model } = await mountInput({ negative: true })
+
+ await type(input, "-")
+
+ expect(input.element.value).toBe("-")
+ expect(model()).toBeUndefined()
+ })
+
+ it("keeps trailing zeros after the decimal point while typing", async ({
+ expect,
+ }) => {
+ const { input, model } = await mountInput({ decimal: true })
+
+ await type(input, "2.50")
+
+ expect(input.element.value).toBe("2.50")
+ expect(model()).toBeUndefined()
+ })
+
+ it("raises a value below min up to min", async ({ expect }) => {
+ const { input, model } = await mountInput({ min: 10 })
+
+ await type(input, "3")
+
+ expect(model()).toBe(10)
+ expect(input.element.value).toBe("10")
+ })
+
+ it("lowers a value above max down to max", async ({ expect }) => {
+ const { input, model } = await mountInput({ max: 100 })
+
+ await type(input, "150")
+
+ expect(model()).toBe(100)
+ expect(input.element.value).toBe("100")
+ })
+
+ it("accepts any sign when no sign flag is set", async ({ expect }) => {
+ const { input, model } = await mountInput({ negative: false })
+
+ await type(input, "-5")
+
+ expect(model()).toBe(-5)
+ })
+
+ it("ignores a negative number when only positives are allowed", async ({
+ expect,
+ }) => {
+ const { input, model } = await mountInput({ positive: true })
+
+ await type(input, "-5")
+
+ expect(model()).toBeUndefined()
+ })
+
+ it("ignores zero when only positives are allowed", async ({ expect }) => {
+ const { input, model } = await mountInput({ positive: true })
+
+ await type(input, "0")
+
+ expect(model()).toBeUndefined()
+ })
+
+ it("accepts zero when zero is allowed", async ({ expect }) => {
+ const { input, model } = await mountInput({ positive: true, zero: true })
+
+ await type(input, "0")
+
+ expect(model()).toBe(0)
+ })
+
+ it("ignores a positive number when only negatives are allowed", async ({
+ expect,
+ }) => {
+ const { input, model } = await mountInput({ negative: true })
+
+ await type(input, "5")
+
+ expect(model()).toBeUndefined()
+ })
+
+ describe("keystroke filtering", () => {
+ // beforeinput is the only place the component can reject a keystroke, so
+ // the assertion is on whether the event was cancelled
+ async function press(
+ props: Record,
+ current: string,
+ data: string,
+ ) {
+ const wrapper = await mountSuspended(NumberInput, { props: props })
+ const input = wrapper.get("input")
+ input.element.value = current
+ input.element.setSelectionRange(current.length, current.length)
+
+ const event = new InputEvent("beforeinput", {
+ data: data,
+ cancelable: true,
+ bubbles: true,
+ })
+ input.element.dispatchEvent(event)
+
+ return event.defaultPrevented
+ }
+
+ it("accepts a digit", async ({ expect }) => {
+ expect(await press({}, "1", "2")).toBe(false)
+ })
+
+ it("rejects a letter", async ({ expect }) => {
+ expect(await press({}, "1", "a")).toBe(true)
+ })
+
+ it("rejects a decimal point when decimals are off", async ({ expect }) => {
+ expect(await press({}, "1", ".")).toBe(true)
+ })
+
+ it("accepts a decimal point when decimals are on", async ({ expect }) => {
+ expect(await press({ decimal: true }, "1", ".")).toBe(false)
+ })
+
+ it("rejects a minus sign when only positives are allowed", async ({
+ expect,
+ }) => {
+ expect(await press({ positive: true }, "", "-")).toBe(true)
+ })
+
+ it("accepts a minus sign when negatives are allowed", async ({
+ expect,
+ }) => {
+ expect(await press({ negative: true }, "", "-")).toBe(false)
+ })
+
+ it("accepts a keystroke that carries no data", async ({ expect }) => {
+ expect(await press({}, "1", "")).toBe(false)
+ })
+ })
+})
diff --git a/web/app/components/SearchModal.nuxt.test.ts b/web/app/components/SearchModal.nuxt.test.ts
new file mode 100644
index 00000000..05afc54a
--- /dev/null
+++ b/web/app/components/SearchModal.nuxt.test.ts
@@ -0,0 +1,268 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { flushPromises } from "@vue/test-utils"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+ seedQueryData,
+} from "~/composables/api/test-helpers"
+import SearchModal from "./SearchModal.vue"
+import { clearTeleportedOverlays, seedAuthOrganization } from "./test-helpers"
+
+const DEBOUNCE_MS = 300
+const DOC_ID = "doc1".padEnd(20, "0")
+
+function seedTree() {
+ seedQueryData(
+ ["documents", "tree"],
+ [
+ {
+ id: DOC_ID,
+ documentName: "Runbook",
+ icon: "lucide:file",
+ protected: false,
+ children: null,
+ },
+ ],
+ )
+}
+
+function mockSearch(results: unknown) {
+ return mockEndpoint("GET", "/api/documents/search", () => results)
+}
+
+function mountModal() {
+ return mountSuspended(SearchModal, { props: { modelValue: true } })
+}
+
+function dialogText() {
+ return (
+ document.body.querySelector("[data-slot='dialog-content']")?.textContent ??
+ ""
+ )
+}
+
+function searchInput() {
+ const input = document.body.querySelector("input")
+ if (!input) {
+ throw new Error("the search modal has no input")
+ }
+
+ return input
+}
+
+async function search(query: string) {
+ const input = searchInput()
+ input.value = query
+ input.dispatchEvent(new Event("input", { bubbles: true }))
+ await vi.advanceTimersByTimeAsync(DEBOUNCE_MS)
+ await flushPromises()
+ await nextTick()
+}
+
+// the dialog body is teleported into the shared , the debounce runs
+// on the global fake timers, and the document tree and organization live
+// in the app-wide query cache
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearTeleportedOverlays()
+ clearQueryCache()
+ vi.useFakeTimers()
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("stays closed while the model is false", async ({ expect }) => {
+ await mountSuspended(SearchModal, { props: { modelValue: false } })
+
+ expect(
+ document.body.querySelector("[data-slot='dialog-content']"),
+ ).toBeNull()
+ })
+
+ it("invites the user to start typing while the query is empty", async ({
+ expect,
+ }) => {
+ await mountModal()
+
+ expect(dialogText()).toContain("Start typing to search for pages")
+ })
+
+ it("sends the typed query to the search endpoint", async ({ expect }) => {
+ const calls = mockSearch([])
+ await mountModal()
+
+ await search("runbook")
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.query).toEqual({ q: "runbook" })
+ })
+
+ it("trims the query before searching", async ({ expect }) => {
+ const calls = mockSearch([])
+ await mountModal()
+
+ await search(" runbook ")
+
+ expect(calls[0]?.query).toEqual({ q: "runbook" })
+ })
+
+ it("does not search while the query is only whitespace", async ({
+ expect,
+ }) => {
+ const calls = mockSearch([])
+ await mountModal()
+
+ await search(" ")
+
+ expect(calls).toHaveLength(0)
+ })
+
+ it("reports that nothing matched the query", async ({ expect }) => {
+ mockSearch([])
+ await mountModal()
+
+ await search("nothing")
+
+ expect(dialogText()).toContain("No results found for")
+ expect(dialogText()).toContain("nothing")
+ })
+
+ it("lists the matching documents", async ({ expect }) => {
+ mockSearch([
+ { id: DOC_ID, documentId: DOC_ID, type: "document", text: "Runbook" },
+ ])
+ await mountModal()
+
+ await search("run")
+
+ expect(dialogText()).toContain("Runbook")
+ })
+
+ it("links a document hit at its slug under the organization", async ({
+ expect,
+ }) => {
+ seedAuthOrganization({ name: "Acme Corp", slug: "acme-corp" })
+ seedTree()
+ mockSearch([
+ { id: DOC_ID, documentId: DOC_ID, type: "document", text: "Runbook" },
+ ])
+ await mountModal()
+
+ await search("run")
+
+ expect(document.body.querySelector("a")?.getAttribute("href")).toBe(
+ `/Acme-Corp/Runbook-${DOC_ID}`,
+ )
+ })
+
+ it("links a hit inside a document at the matching block", async ({
+ expect,
+ }) => {
+ seedAuthOrganization({ name: "Acme Corp", slug: "acme-corp" })
+ seedTree()
+ mockSearch([
+ {
+ id: "block-7",
+ documentId: DOC_ID,
+ type: "heading",
+ text: "Rollback steps",
+ },
+ ])
+ await mountModal()
+
+ await search("roll")
+
+ expect(document.body.querySelector("a")?.getAttribute("href")).toBe(
+ `/Acme-Corp/Runbook-${DOC_ID}#block-7`,
+ )
+ })
+
+ it("falls back to the bare document id when the tree has no such document", async ({
+ expect,
+ }) => {
+ seedAuthOrganization({ name: "Acme Corp", slug: "acme-corp" })
+ mockSearch([
+ { id: DOC_ID, documentId: DOC_ID, type: "document", text: "Runbook" },
+ ])
+ await mountModal()
+
+ await search("run")
+
+ expect(document.body.querySelector("a")?.getAttribute("href")).toBe(
+ `/Acme-Corp/${DOC_ID}`,
+ )
+ })
+
+ it("drops the organization segment when there is no organization", async ({
+ expect,
+ }) => {
+ seedTree()
+ mockSearch([
+ { id: DOC_ID, documentId: DOC_ID, type: "document", text: "Runbook" },
+ ])
+ await mountModal()
+
+ await search("run")
+
+ expect(document.body.querySelector("a")?.getAttribute("href")).toBe(
+ `/Runbook-${DOC_ID}`,
+ )
+ })
+
+ it.for([
+ { type: "document", expected: "i-lucide:file-text" },
+ { type: "heading", expected: "i-lucide:heading" },
+ { type: "paragraph", expected: "i-lucide:text" },
+ ])(
+ "marks a $type hit with its own icon",
+ async ({ type, expected }, { expect }) => {
+ mockSearch([{ id: "x", documentId: DOC_ID, type: type, text: "Hit" }])
+ await mountModal()
+
+ await search("hit")
+
+ expect(document.body.querySelector("a")?.innerHTML).toContain(expected)
+ },
+ )
+
+ it("keeps the result list empty when the search fails", async ({
+ expect,
+ }) => {
+ mockEndpoint("GET", "/api/documents/search", () => {
+ throw new Error("boom")
+ })
+ await mountModal()
+
+ await search("run")
+
+ expect(dialogText()).toContain("No results found for")
+ })
+
+ it("closes when a result is followed", async ({ expect }) => {
+ seedTree()
+ mockSearch([
+ { id: DOC_ID, documentId: DOC_ID, type: "document", text: "Runbook" },
+ ])
+ const wrapper = await mountModal()
+ await search("run")
+
+ document.body.querySelector("a")?.click()
+ await nextTick()
+
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([false])
+ })
+
+ it("forgets the previous query when it is reopened", async ({ expect }) => {
+ mockSearch([])
+ const wrapper = await mountModal()
+ await search("nothing")
+
+ await wrapper.setProps({ modelValue: false })
+ await wrapper.setProps({ modelValue: true })
+ await nextTick()
+
+ expect(dialogText()).toContain("Start typing to search for pages")
+ })
+})
diff --git a/web/app/components/ShortcutTooltip.nuxt.test.ts b/web/app/components/ShortcutTooltip.nuxt.test.ts
new file mode 100644
index 00000000..149a0e1e
--- /dev/null
+++ b/web/app/components/ShortcutTooltip.nuxt.test.ts
@@ -0,0 +1,135 @@
+import { beforeEach, describe, it, vi } from "vitest"
+import ShortcutTooltip from "./ShortcutTooltip.vue"
+import {
+ clearTeleportedOverlays,
+ mountUnderTooltipProvider,
+ openTooltipText,
+} from "./test-helpers"
+
+const HOVER_DELAY_MS = 800
+
+const SHORTCUT = {
+ keyboardKey: { macOS: "⌘+K", other: "Ctrl+K" },
+ i18nKey: "shortcuts.keys.search-for-documents",
+}
+
+function mountTooltip(props: Record) {
+ return mountUnderTooltipProvider(ShortcutTooltip, {
+ props: props,
+ slots: { default: () => h("button", "Trigger") },
+ })
+}
+
+// the host os lives in a nuxt useState singleton shared by every mount in
+// the file, and the hover delay needs the global fake timers, so these
+// tests cannot interleave
+describe("", { concurrent: false }, () => {
+ beforeEach(clearTeleportedOverlays)
+
+ it("renders its slot untouched when no shortcut is given", async ({
+ expect,
+ }) => {
+ const wrapper = await mountTooltip({})
+
+ expect(wrapper.text()).toBe("Trigger")
+ })
+
+ it("renders its slot when a shortcut is given", async ({ expect }) => {
+ const wrapper = await mountTooltip({ shortcut: SHORTCUT })
+
+ expect(wrapper.text()).toContain("Trigger")
+ })
+
+ it("keeps the tooltip closed until the hover delay has passed", async ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+ const wrapper = await mountTooltip({ shortcut: SHORTCUT })
+
+ await wrapper.get("button").trigger("pointerenter")
+ await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS - 1)
+
+ expect(openTooltipText(wrapper)).not.toContain("Ctrl")
+ })
+
+ it("shows the shortcut once the hover delay has passed", async ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+ const wrapper = await mountTooltip({ shortcut: SHORTCUT })
+
+ await wrapper.get("button").trigger("pointerenter")
+ await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS)
+
+ expect(openTooltipText(wrapper)).toContain("Ctrl")
+ expect(openTooltipText(wrapper)).toContain("K")
+ })
+
+ it("labels the shortcut with its translated action name", async ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+ const wrapper = await mountTooltip({ shortcut: SHORTCUT })
+
+ await wrapper.get("button").trigger("pointerenter")
+ await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS)
+
+ expect(openTooltipText(wrapper)).toContain("Search for pages")
+ })
+
+ it("shows only the keys for a shortcut with no i18n key", async ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+ const wrapper = await mountTooltip({
+ shortcut: { keyboardKey: SHORTCUT.keyboardKey, i18nKey: null },
+ })
+
+ await wrapper.get("button").trigger("pointerenter")
+ await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS)
+
+ expect(openTooltipText(wrapper)).toBe("CtrlthenK")
+ })
+
+ it("cancels a pending open when the pointer leaves early", async ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+ const wrapper = await mountTooltip({ shortcut: SHORTCUT })
+ await wrapper.get("button").trigger("pointerenter")
+ await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS - 100)
+
+ await wrapper.get("button").trigger("pointerleave")
+ await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS)
+
+ expect(openTooltipText(wrapper)).not.toContain("Ctrl")
+ })
+
+ it("hides an open tooltip when the pointer leaves", async ({ expect }) => {
+ vi.useFakeTimers()
+ const wrapper = await mountTooltip({ shortcut: SHORTCUT })
+ await wrapper.get("button").trigger("pointerenter")
+ await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS)
+
+ await wrapper.get("button").trigger("pointerleave")
+ await nextTick()
+
+ expect(openTooltipText(wrapper)).not.toContain("Ctrl")
+ })
+
+ it("shows the macOS keys when the host is a mac", async ({ expect }) => {
+ vi.useFakeTimers()
+ useDetectHost().setOsType(HostOsType.MacOS)
+
+ try {
+ const wrapper = await mountTooltip({ shortcut: SHORTCUT })
+ await wrapper.get("button").trigger("pointerenter")
+ await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS)
+
+ expect(openTooltipText(wrapper)).toContain("⌘")
+ expect(openTooltipText(wrapper)).not.toContain("Ctrl")
+ } finally {
+ useDetectHost().setOsType(HostOsType.Other)
+ }
+ })
+})
diff --git a/web/app/components/SidebarItem.nuxt.test.ts b/web/app/components/SidebarItem.nuxt.test.ts
new file mode 100644
index 00000000..9d44a152
--- /dev/null
+++ b/web/app/components/SidebarItem.nuxt.test.ts
@@ -0,0 +1,420 @@
+import { mockNuxtImport } from "@nuxt/test-utils/runtime"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { defineComponent, h } from "vue"
+import SidebarItem from "./SidebarItem.vue"
+import {
+ SIDEBAR_ITEM_PLACEHOLDER_ID,
+ type SidebarItem as Item,
+} from "./sidebar"
+import {
+ at,
+ menuItem,
+ mountUnderSidebarProvider,
+ renderedIconNames,
+} from "./test-helpers"
+
+interface CapturedDragOptions {
+ disabled?: MaybeRefOrGetter
+ onStart?: () => void
+ onMove?: (pos: { x: number; y: number }) => void
+ onEnd?: () => void
+}
+
+// happy-dom has no layout, so the vueuse pointer/geometry primitives the
+// drag rides on are stubbed and driven directly — the same treatment
+// app/composables/useSidebarDraggable.nuxt.test.ts gives them one layer
+// down. Everything between them and the component stays real.
+const {
+ capturedDrags,
+ capturedMice,
+ useDraggableMock,
+ useMouseInElementMock,
+ useElementBoundingMock,
+} = vi.hoisted(() => {
+ const capturedDrags: CapturedDragOptions[] = []
+ const capturedMice: { isOutside: Ref }[] = []
+
+ return {
+ capturedDrags,
+ capturedMice,
+ useDraggableMock: vi.fn((_el: unknown, opts: CapturedDragOptions): any => {
+ capturedDrags.push(opts)
+
+ return { x: ref(0), y: ref(0) }
+ }),
+ useMouseInElementMock: vi.fn((): any => {
+ // real refs: the composable watches these, and a plain object
+ // would leave every watcher and computed behind them inert
+ const mouse = { isOutside: ref(true) }
+ capturedMice.push(mouse)
+
+ return mouse
+ }),
+ useElementBoundingMock: vi.fn((): any => ({
+ width: ref(0),
+ height: ref(0),
+ })),
+ }
+})
+
+mockNuxtImport("useDraggable", () => useDraggableMock)
+mockNuxtImport("useMouseInElement", () => useMouseInElementMock)
+mockNuxtImport("useElementBounding", () => useElementBoundingMock)
+
+function makeItem(overrides: Partial- = {}): Item {
+ return {
+ id: "doc-1",
+ name: "Runbook",
+ url: "/acme/runbook-doc-1",
+ partOfDocumentTree: true,
+ icon: "lucide:file",
+ active: false,
+ draggable: true,
+ children: [],
+ ...overrides,
+ }
+}
+
+async function mountItem(item: Item, props: Record = {}) {
+ const wrapper = await mountUnderSidebarProvider(SidebarItem, {
+ props: { parentId: null, item: item, wrapper: null, ...props },
+ })
+
+ return { wrapper, item: wrapper.findComponent(SidebarItem) }
+}
+
+// the dropdown body is teleported into the shared , and the geometry
+// mocks and the drag store behind them are module-level singletons every
+// mount in the file shares
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ // rows mounted by an earlier test can outlive it, and a pointer left
+ // over one of them keeps its watchers writing to the app-wide drag
+ // store — park every pointer before the next mount starts
+ capturedMice.forEach((mouse) => {
+ mouse.isOutside.value = true
+ })
+ capturedDrags.length = 0
+ capturedMice.length = 0
+ })
+
+ // a drag left in flight keeps its dragged row and drop target in the
+ // app-wide store, where the next test's rows would read it
+ afterEach(() => {
+ capturedDrags.forEach((drag) => drag.onEnd?.())
+ })
+
+ it("links a normal item at its url", async ({ expect }) => {
+ const { wrapper } = await mountItem(makeItem())
+
+ expect(wrapper.get("a").attributes("href")).toBe("/acme/runbook-doc-1")
+ expect(wrapper.text()).toContain("Runbook")
+ })
+
+ it("renders an item with no url as a plain clickable row", async ({
+ expect,
+ }) => {
+ const { wrapper } = await mountItem(makeItem({ url: null }))
+
+ expect(wrapper.find("a").exists()).toBe(false)
+ expect(wrapper.text()).toContain("Runbook")
+ })
+
+ it("runs an urlless item's own click handler", async ({ expect }) => {
+ const onClick = vi.fn()
+ const { wrapper } = await mountItem(makeItem({ url: null, onClick }))
+
+ await wrapper.get("[data-slot='sidebar-menu-button']").trigger("click")
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it("blanks the href of an optimistically inserted item", async ({
+ expect,
+ }) => {
+ const { wrapper } = await mountItem(
+ makeItem({ localOptimisticInsert: true }),
+ )
+
+ expect(wrapper.get("a").attributes("href")).toBeUndefined()
+ expect(wrapper.get("a").attributes("data-optimistic-insert")).toBe("")
+ })
+
+ it("shows the item's icon", async ({ expect }) => {
+ const { wrapper } = await mountItem(makeItem({ icon: "lucide:book" }))
+
+ expect(renderedIconNames(wrapper)).toContain("lucide:book")
+ })
+
+ it("shows the unread count when the item carries one", async ({ expect }) => {
+ const { wrapper } = await mountItem(makeItem({ url: null, count: 3 }))
+
+ expect(wrapper.get("[data-slot='sidebar-menu-button']").text()).toBe(
+ "Runbook3",
+ )
+ })
+
+ it("omits the count badge when the count is zero", async ({ expect }) => {
+ const { wrapper } = await mountItem(makeItem({ url: null, count: 0 }))
+
+ expect(wrapper.get("[data-slot='sidebar-menu-button']").text()).toBe(
+ "Runbook",
+ )
+ })
+
+ it("marks the active item", async ({ expect }) => {
+ const { wrapper } = await mountItem(makeItem(), { active: true })
+
+ expect(
+ wrapper
+ .get("[data-slot='sidebar-menu-button']")
+ .attributes("data-active"),
+ ).toBe("true")
+ })
+
+ it("asks to toggle the collapse when the chevron is pressed", async ({
+ expect,
+ }) => {
+ const { wrapper, item } = await mountItem(makeItem())
+
+ await at(wrapper.findAll("[data-sidebar='menu-action']"), 1).trigger(
+ "click",
+ )
+
+ expect(item.emitted("toggle-collapse")).toHaveLength(1)
+ })
+
+ it("turns the chevron once the item is open", async ({ expect }) => {
+ const { wrapper } = await mountItem(makeItem(), { open: true })
+
+ expect(
+ at(wrapper.findAll("[data-sidebar='menu-action']"), 1)
+ .get(".iconify")
+ .classes(),
+ ).toContain("rotate-90")
+ })
+
+ it("asks to create a sub page when the placeholder row is pressed", async ({
+ expect,
+ }) => {
+ const { wrapper, item } = await mountItem(
+ makeItem({ id: SIDEBAR_ITEM_PLACEHOLDER_ID, url: null, icon: null }),
+ )
+
+ await wrapper.get("[data-slot='sidebar-menu-button']").trigger("click")
+
+ expect(item.emitted("create")).toHaveLength(1)
+ })
+
+ it("styles the placeholder row as a placeholder", async ({ expect }) => {
+ const { wrapper } = await mountItem(
+ makeItem({ id: SIDEBAR_ITEM_PLACEHOLDER_ID, url: null, icon: null }),
+ )
+
+ expect(
+ wrapper
+ .get("[data-slot='sidebar-menu-button']")
+ .attributes("data-placeholder-variant"),
+ ).toBe("true")
+ expect(renderedIconNames(wrapper)).toContain("lucide:plus")
+ })
+
+ it("hides the options menu on the placeholder row", async ({ expect }) => {
+ const { wrapper } = await mountItem(
+ makeItem({ id: SIDEBAR_ITEM_PLACEHOLDER_ID, url: null, icon: null }),
+ )
+
+ expect(wrapper.find("[data-slot='dropdown-menu-trigger']").exists()).toBe(
+ false,
+ )
+ })
+
+ it("hides the options menu on items outside the document tree", async ({
+ expect,
+ }) => {
+ const { wrapper } = await mountItem(
+ makeItem({ partOfDocumentTree: false, url: null }),
+ )
+
+ expect(wrapper.find("[data-slot='dropdown-menu-trigger']").exists()).toBe(
+ false,
+ )
+ })
+
+ it.for([
+ { label: "Duplicate Page", event: "duplicate" },
+ { label: "Add Sub Page", event: "create" },
+ { label: "Delete Page", event: "delete" },
+ ] as const)(
+ "asks to $event the page from the options menu",
+ async ({ label, event }, { expect }) => {
+ const { wrapper, item } = await mountItem(makeItem())
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem(label).click()
+ await nextTick()
+
+ expect(item.emitted(event)).toHaveLength(1)
+ },
+ )
+
+ it("does not duplicate itself as a drag ghost while at rest", async ({
+ expect,
+ }) => {
+ const { wrapper } = await mountItem(makeItem())
+
+ expect(wrapper.findAllComponents(SidebarItem)).toHaveLength(1)
+ })
+
+ it.for([
+ { draggable: true, expected: false },
+ { draggable: false, expected: true },
+ ])(
+ "leaves dragging $expected for an item with draggable $draggable",
+ async ({ draggable, expected }, { expect }) => {
+ await mountItem(makeItem({ draggable: draggable }))
+
+ expect(toValue(at(capturedDrags, 0).disabled)).toBe(expected)
+ },
+ )
+
+ it("keeps its ghost hidden until the drag passes the threshold", async ({
+ expect,
+ }) => {
+ await mountDraggableItem()
+
+ nudge(0)
+ await nextTick()
+
+ expect(document.body.querySelector("[data-ghost]")).toBeNull()
+ endDrag()
+ })
+
+ it("shows a ghost of itself once the drag passes the threshold", async ({
+ expect,
+ }) => {
+ await mountDraggableItem()
+
+ await startDrag()
+ await nextTick()
+
+ expect(document.body.querySelector("[data-ghost]")).not.toBeNull()
+ endDrag()
+ })
+
+ it("drops its ghost again once the drag ends", async ({ expect }) => {
+ await mountDraggableItem()
+ await startDrag()
+ await nextTick()
+
+ endDrag()
+ await nextTick()
+
+ expect(document.body.querySelector("[data-ghost]")).toBeNull()
+ })
+
+ it("asks to be moved into the row it was dropped on", async ({ expect }) => {
+ const dragged = await mountPair()
+ await startDrag()
+
+ await hover(TARGET_ROW)
+ endDrag()
+
+ expect(dragged.emitted("update-location")).toEqual([
+ [{ parentId: "b", insertBeforeId: null }],
+ ])
+ })
+
+ it("asks to be moved above the row whose top edge it was dropped on", async ({
+ expect,
+ }) => {
+ const dragged = await mountPair()
+ await startDrag()
+
+ await hover(TARGET_ROW_TOP_EDGE)
+ endDrag()
+
+ expect(dragged.emitted("update-location")).toEqual([
+ [{ parentId: "root", insertBeforeId: "b" }],
+ ])
+ })
+
+ it("asks for nothing when the drag ends away from every row", async ({
+ expect,
+ }) => {
+ const dragged = await mountPair()
+ await startDrag()
+
+ endDrag()
+
+ expect(dragged.emitted("update-location")).toBeUndefined()
+ })
+})
+
+// the composable asks for the item's mouse first and its top edge second,
+// so a two-row mount fills the captured list in this order
+const DRAGGED_ROW = 0
+const TARGET_ROW = 2
+const TARGET_ROW_TOP_EDGE = 3
+
+function mountDraggableItem() {
+ // a real wrapper element: the drag store records it, and descendant
+ // checks against it decide which rows may be dropped on
+ return mountItem(makeItem({ id: "a" }), {
+ wrapper: document.createElement("div"),
+ })
+}
+
+// two rows under one provider — a drop target has to be a different row,
+// and rows coordinate through an app-wide store
+async function mountPair() {
+ // the props are built once: a fresh item object per render would give
+ // both rows a new identity on every store change and take the captured
+ // pointer stubs down with them
+ const rows = [
+ {
+ key: "a",
+ parentId: null,
+ item: makeItem({ id: "a" }),
+ wrapper: document.createElement("div"),
+ },
+ {
+ key: "b",
+ parentId: "root",
+ item: makeItem({ id: "b" }),
+ wrapper: document.createElement("div"),
+ },
+ ]
+ const Pair = defineComponent({
+ setup() {
+ return () => rows.map((row) => h(SidebarItem, row))
+ },
+ })
+ const wrapper = await mountUnderSidebarProvider(Pair, {})
+
+ return at(wrapper.findAllComponents(SidebarItem), DRAGGED_ROW)
+}
+
+function nudge(distance: number) {
+ const drag = at(capturedDrags, DRAGGED_ROW)
+ drag.onStart?.()
+ drag.onMove?.({ x: 0, y: 0 })
+ drag.onMove?.({ x: 0, y: distance })
+}
+
+// the composable only treats a pointer move as a drag past minDistance,
+// and the row it started from has to register as dragged before another
+// row can become a target
+async function startDrag() {
+ nudge(100)
+ await nextTick()
+}
+
+function endDrag() {
+ at(capturedDrags, DRAGGED_ROW).onEnd?.()
+}
+
+async function hover(mouseIndex: number) {
+ at(capturedMice, mouseIndex).isOutside.value = false
+ await nextTick()
+}
diff --git a/web/app/components/SidebarNestedGroup.nuxt.test.ts b/web/app/components/SidebarNestedGroup.nuxt.test.ts
new file mode 100644
index 00000000..b6f889a5
--- /dev/null
+++ b/web/app/components/SidebarNestedGroup.nuxt.test.ts
@@ -0,0 +1,218 @@
+import { beforeEach, describe, it } from "vitest"
+import SidebarItem from "./SidebarItem.vue"
+import SidebarNestedGroup from "./SidebarNestedGroup.vue"
+import {
+ SIDEBAR_ITEM_PLACEHOLDER_ID,
+ type SidebarItem as Item,
+} from "./sidebar"
+import {
+ emitFrom,
+ emitFromNth,
+ mountUnderSidebarProvider,
+} from "./test-helpers"
+
+function makeItem(id: string, overrides: Partial
- = {}): Item {
+ return {
+ id: id,
+ name: id,
+ url: `/acme/${id}`,
+ partOfDocumentTree: true,
+ icon: "lucide:file",
+ active: false,
+ draggable: true,
+ children: [],
+ ...overrides,
+ }
+}
+
+function placeholder(): Item {
+ return {
+ id: SIDEBAR_ITEM_PLACEHOLDER_ID,
+ name: "Add a page",
+ url: null,
+ partOfDocumentTree: true,
+ icon: null,
+ active: false,
+ draggable: false,
+ children: [],
+ }
+}
+
+async function mountGroup(items: Item[], itemId: string | null = null) {
+ const wrapper = await mountUnderSidebarProvider(SidebarNestedGroup, {
+ props: { modelValue: items, itemId: itemId },
+ })
+
+ return { wrapper, group: wrapper.findComponent(SidebarNestedGroup) }
+}
+
+function rowIds(wrapper: Awaited>["wrapper"]) {
+ return wrapper
+ .findAll("[data-item-id]")
+ .map((el) => el.attributes("data-item-id"))
+}
+
+// the collapse state is a cookie-backed singleton every mount in the file
+// shares, and the options menus are teleported into the shared
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ document.cookie = "sidebar-item-collapse=; max-age=0; path=/"
+ })
+
+ it("renders nothing for an empty group", async ({ expect }) => {
+ const { wrapper } = await mountGroup([])
+
+ expect(rowIds(wrapper)).toEqual([])
+ })
+
+ it("renders one row per item, in order", async ({ expect }) => {
+ const { wrapper } = await mountGroup([makeItem("a"), makeItem("b")])
+
+ expect(rowIds(wrapper)).toEqual(["a", "b"])
+ })
+
+ it("records how many children each row has", async ({ expect }) => {
+ const { wrapper } = await mountGroup([
+ makeItem("a", { children: [makeItem("a1")] }),
+ ])
+
+ expect(
+ wrapper.get("[data-item-id='a']").attributes("data-item-children"),
+ ).toBe("1")
+ })
+
+ it("renders a nested group for an item's children", async ({ expect }) => {
+ const { wrapper } = await mountGroup([
+ makeItem("a", { children: [makeItem("a1")] }),
+ ])
+
+ expect(rowIds(wrapper)).toEqual(["a", "a1"])
+ })
+
+ it("opens top-level items that have real children", async ({ expect }) => {
+ const { wrapper } = await mountGroup([
+ makeItem("a", { children: [makeItem("a1")] }),
+ ])
+
+ expect(wrapper.findComponent(SidebarItem).props("open")).toBe(true)
+ })
+
+ it("leaves a top-level item whose only child is the placeholder closed", async ({
+ expect,
+ }) => {
+ const { wrapper } = await mountGroup([
+ makeItem("a", { children: [placeholder()] }),
+ ])
+
+ expect(wrapper.findComponent(SidebarItem).props("open")).toBe(false)
+ })
+
+ it("leaves a childless top-level item closed", async ({ expect }) => {
+ const { wrapper } = await mountGroup([makeItem("a", { children: [] })])
+
+ expect(wrapper.findComponent(SidebarItem).props("open")).toBe(false)
+ })
+
+ it("leaves nested levels closed", async ({ expect }) => {
+ const { wrapper } = await mountGroup(
+ [makeItem("a", { children: [makeItem("a1")] })],
+ "parent",
+ )
+
+ expect(wrapper.findComponent(SidebarItem).props("open")).toBe(false)
+ })
+
+ it("opens a closed row when its collapse toggle fires", async ({
+ expect,
+ }) => {
+ const { wrapper } = await mountGroup([makeItem("a", { children: [] })])
+ emitFrom(wrapper, "SidebarItem", "toggle-collapse")
+ await nextTick()
+
+ expect(wrapper.findComponent(SidebarItem).props("open")).toBe(true)
+ })
+
+ it("closes an open row when its collapse toggle fires again", async ({
+ expect,
+ }) => {
+ const { wrapper } = await mountGroup([
+ makeItem("a", { children: [makeItem("a1")] }),
+ ])
+ emitFrom(wrapper, "SidebarItem", "toggle-collapse")
+ await nextTick()
+
+ expect(wrapper.findComponent(SidebarItem).props("open")).toBe(false)
+ })
+
+ it("reports a deletion with the row's own id", async ({ expect }) => {
+ const { wrapper, group } = await mountGroup([makeItem("a")])
+
+ emitFrom(wrapper, "SidebarItem", "delete")
+ await nextTick()
+
+ expect(group.emitted("delete")).toEqual([[{ id: "a" }]])
+ })
+
+ it("reports a duplication with the row's own id", async ({ expect }) => {
+ const { wrapper, group } = await mountGroup([makeItem("a")])
+
+ emitFrom(wrapper, "SidebarItem", "duplicate")
+ await nextTick()
+
+ expect(group.emitted("duplicate")).toEqual([[{ id: "a" }]])
+ })
+
+ it("makes a creation from a normal row a child of that row", async ({
+ expect,
+ }) => {
+ const { wrapper, group } = await mountGroup([makeItem("a")])
+
+ emitFrom(wrapper, "SidebarItem", "create")
+ await nextTick()
+
+ expect(group.emitted("create")).toEqual([[{ parentId: "a" }]])
+ })
+
+ it("makes a creation from a placeholder row a sibling in the same group", async ({
+ expect,
+ }) => {
+ const { wrapper, group } = await mountGroup([placeholder()], "parent")
+
+ emitFrom(wrapper, "SidebarItem", "create")
+ await nextTick()
+
+ expect(group.emitted("create")).toEqual([[{ parentId: "parent" }]])
+ })
+
+ it("reports a move with the row's id and the requested destination", async ({
+ expect,
+ }) => {
+ const { wrapper, group } = await mountGroup([makeItem("a")])
+
+ emitFrom(wrapper, "SidebarItem", "update-location", {
+ parentId: "b",
+ insertBeforeId: "c",
+ })
+ await nextTick()
+
+ expect(group.emitted("update-location")).toEqual([
+ [{ id: "a", parentId: "b", insertBeforeId: "c" }],
+ ])
+ })
+
+ it("passes a nested group's move straight through", async ({ expect }) => {
+ const { wrapper, group } = await mountGroup([
+ makeItem("a", { children: [makeItem("a1")] }),
+ ])
+ emitFromNth(wrapper, "SidebarNestedGroup", 1, "update-location", {
+ id: "a1",
+ parentId: null,
+ insertBeforeId: null,
+ })
+ await nextTick()
+
+ expect(group.emitted("update-location")).toEqual([
+ [{ id: "a1", parentId: null, insertBeforeId: null }],
+ ])
+ })
+})
diff --git a/web/app/components/ToastMessage.nuxt.test.ts b/web/app/components/ToastMessage.nuxt.test.ts
new file mode 100644
index 00000000..193c2871
--- /dev/null
+++ b/web/app/components/ToastMessage.nuxt.test.ts
@@ -0,0 +1,86 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { describe, it } from "vitest"
+import ToastMessage from "./ToastMessage.vue"
+import { renderedIconNames } from "./test-helpers"
+
+describe("", () => {
+ it("shows the title", async ({ expect }) => {
+ const wrapper = await mountSuspended(ToastMessage, {
+ props: { type: "success", title: "Workspace saved" },
+ })
+
+ expect(wrapper.text()).toContain("Workspace saved")
+ })
+
+ it("shows the description alongside the title", async ({ expect }) => {
+ const wrapper = await mountSuspended(ToastMessage, {
+ props: {
+ type: "success",
+ title: "Workspace saved",
+ description: "Everyone sees the new name now",
+ },
+ })
+
+ expect(wrapper.text()).toContain("Everyone sees the new name now")
+ })
+
+ it("renders only the title when no description is given", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSuspended(ToastMessage, {
+ props: { type: "success", title: "Workspace saved" },
+ })
+
+ expect(wrapper.findAll(".text-muted-foreground")).toHaveLength(0)
+ })
+
+ it.for([
+ { type: "success", expected: "mingcute:check-circle-fill" },
+ { type: "error", expected: "mingcute:close-circle-fill" },
+ { type: "info", expected: "mingcute:information-fill" },
+ { type: "warning", expected: "mingcute:warning-fill" },
+ ] as const)(
+ "marks a $type message with its own icon",
+ async ({ type, expected }, { expect }) => {
+ const wrapper = await mountSuspended(ToastMessage, {
+ props: { type: type, title: "Title" },
+ })
+
+ expect(renderedIconNames(wrapper)[0]).toBe(expected)
+ },
+ )
+
+ it.for([
+ { type: "success", expected: "text-status-success" },
+ { type: "error", expected: "text-status-error" },
+ { type: "info", expected: "text-status-info" },
+ { type: "warning", expected: "text-status-warning" },
+ ] as const)(
+ "colours a $type message with its own status colour",
+ async ({ type, expected }, { expect }) => {
+ const wrapper = await mountSuspended(ToastMessage, {
+ props: { type: type, title: "Title" },
+ })
+
+ expect(wrapper.get(".iconify").classes()).toContain(expected)
+ },
+ )
+
+ it("emits close when the close button is pressed", async ({ expect }) => {
+ const wrapper = await mountSuspended(ToastMessage, {
+ props: { type: "info", title: "Heads up" },
+ })
+
+ await wrapper.get("button").trigger("click")
+
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("does not emit close before the button is pressed", async ({ expect }) => {
+ const wrapper = await mountSuspended(ToastMessage, {
+ props: { type: "info", title: "Heads up" },
+ })
+
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+})
diff --git a/web/app/components/settings/AccountDeletionAction.nuxt.test.ts b/web/app/components/settings/AccountDeletionAction.nuxt.test.ts
new file mode 100644
index 00000000..250fd679
--- /dev/null
+++ b/web/app/components/settings/AccountDeletionAction.nuxt.test.ts
@@ -0,0 +1,148 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+} from "~/composables/api/test-helpers"
+import AccountDeletionAction from "./AccountDeletionAction.vue"
+import {
+ findButtonByText,
+ mockAuthEndpoint,
+ seedAuthOrganization,
+ settleMutations,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+function mountAction() {
+ return mountSuspended(AccountDeletionAction)
+}
+
+async function confirmDeletion(
+ wrapper: Awaited>,
+) {
+ await findButtonByText(wrapper, "Delete Account").trigger("click")
+ await vi.advanceTimersByTimeAsync(300)
+ await settleMutations()
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares, and the deletion is driven by the global
+// fake timers
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ vi.useFakeTimers()
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("explains what deleting the account does", async ({ expect }) => {
+ seedAuthOrganization({ members: [{ id: "m1" }, { id: "m2" }] })
+
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).toContain("This action is irreversible")
+ })
+
+ it("warns the last member that the workspace goes with them", async ({
+ expect,
+ }) => {
+ seedAuthOrganization({ members: [{ id: "m1" }] })
+
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).toContain("You are the last member")
+ })
+
+ it("leaves the workspace warning out for a shared workspace", async ({
+ expect,
+ }) => {
+ seedAuthOrganization({ members: [{ id: "m1" }, { id: "m2" }] })
+
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).not.toContain("You are the last member")
+ })
+
+ it("leaves the workspace warning out while the organization is unknown", async ({
+ expect,
+ }) => {
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).not.toContain("You are the last member")
+ })
+
+ it("asks the server to delete the account, pointing the callback at signup", async ({
+ expect,
+ }) => {
+ const calls = mockAuthEndpoint("delete-user", () => ({
+ success: true,
+ }))
+ const wrapper = await mountAction()
+
+ await confirmDeletion(wrapper)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({
+ callbackURL: "http://test.local/signup?deletion=success",
+ })
+ })
+
+ it("closes once the confirmation email is on its way", async ({ expect }) => {
+ mockAuthEndpoint("delete-user", () => ({ success: true }))
+ const wrapper = await mountAction()
+
+ await confirmDeletion(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("shows a spinner and disables both buttons while deleting", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("delete-user", () => ({ success: true }))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Delete Account").trigger("click")
+ await nextTick()
+
+ expect(
+ findButtonByText(wrapper, "Delete Account").attributes("disabled"),
+ ).toBeDefined()
+ expect(
+ findButtonByText(wrapper, "Cancel").attributes("disabled"),
+ ).toBeDefined()
+ })
+
+ it("warns and stays open when the deletion is refused", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("delete-user", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountAction()
+
+ await confirmDeletion(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("closes without deleting anything when cancelled", async ({ expect }) => {
+ const calls = mockAuthEndpoint("delete-user", () => ({
+ success: true,
+ }))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Cancel").trigger("click")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/settings/ActionModal.nuxt.test.ts b/web/app/components/settings/ActionModal.nuxt.test.ts
new file mode 100644
index 00000000..57107db7
--- /dev/null
+++ b/web/app/components/settings/ActionModal.nuxt.test.ts
@@ -0,0 +1,225 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+} from "~/composables/api/test-helpers"
+import ActionModal from "./ActionModal.vue"
+import WorkspaceURLChangeAction from "./WorkspaceURLChangeAction.vue"
+import type { OrganizationMember } from "./workspace"
+import {
+ clearTeleportedOverlays,
+ emitFrom,
+ mockAuthOrganization,
+ seedAuthAccounts,
+ seedAuthSession,
+ teleportedButton,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+const DATA_SOURCE: DataSource = {
+ id: "ds1".padEnd(20, "0"),
+ name: "Prod metrics",
+ type: DataSourceType.Prometheus,
+ url: "http://prometheus.test",
+ status: DataSourceStatus.Success,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: null,
+}
+
+const MEMBER: OrganizationMember = {
+ id: "member-1",
+ organizationId: "org-1",
+ userId: "u2",
+ role: "member",
+ user: { name: "Linus", email: "linus@oxynote.test" },
+}
+
+function mountModal(props: Record) {
+ return mountSuspended(ActionModal, { props: props })
+}
+
+function dialogText() {
+ return (
+ document.body.querySelector("[data-slot='dialog-content']")?.textContent ??
+ ""
+ )
+}
+
+// the dialog body is teleported into the shared and the query cache
+// is app-wide
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearTeleportedOverlays()
+ clearQueryCache()
+ seedAuthSession({ id: "u1", email: "ada@oxynote.test", name: "ada" })
+ seedAuthAccounts(["credential"])
+ mockAuthOrganization({
+ id: "org-1",
+ name: "Acme",
+ slug: "acme-corp",
+ members: [],
+ invitations: [],
+ })
+ mockEndpoint("GET", "/api/data-sources", () => [])
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("stays closed while no action is picked", async ({ expect }) => {
+ await mountModal({ modelValue: null })
+
+ expect(
+ document.body.querySelector("[data-slot='dialog-content']"),
+ ).toBeNull()
+ })
+
+ it.for([
+ { action: "email-change", expected: "Change Email" },
+ { action: "account-deletion", expected: "Delete Account" },
+ { action: "url-change", expected: "Change Workspace URL" },
+ { action: "workspace-invitation", expected: "Invite to Your Workspace" },
+ ])("titles the $action modal", async ({ action, expected }, { expect }) => {
+ await mountModal({ modelValue: action })
+
+ expect(dialogText()).toContain(expected)
+ })
+
+ it("titles the password modal as a change when a password is set", async ({
+ expect,
+ }) => {
+ await mountModal({ modelValue: "password-change" })
+
+ expect(dialogText()).toContain("Change Password")
+ })
+
+ it("titles the password modal as a first-time setup when none is set", async ({
+ expect,
+ }) => {
+ seedAuthAccounts(["github"])
+
+ await mountModal({ modelValue: "password-change" })
+
+ expect(dialogText()).toContain("Set Password")
+ })
+
+ it("titles the invitation modal differently once the workspace is full", async ({
+ expect,
+ }) => {
+ mockAuthOrganization({
+ id: "org-1",
+ members: Array.from({ length: 5 }, (_, i) => ({ id: `m${i}` })),
+ invitations: [],
+ })
+
+ await mountModal({ modelValue: "workspace-invitation" })
+
+ expect(dialogText()).toContain("Max Members Reached")
+ })
+
+ it("names the member in the removal modal", async ({ expect }) => {
+ await mountModal({ modelValue: "workspace-member-removal", opts: MEMBER })
+
+ expect(dialogText()).toContain("Remove from Your Workspace")
+ expect(dialogText()).toContain("Linus")
+ })
+
+ it("renders nothing for a member removal without a member", async ({
+ expect,
+ }) => {
+ await mountModal({ modelValue: "workspace-member-removal", opts: null })
+
+ expect(dialogText()).toBe("")
+ })
+
+ it("renders nothing for a member removal whose target is not a member", async ({
+ expect,
+ }) => {
+ await mountModal({
+ modelValue: "workspace-member-removal",
+ opts: DATA_SOURCE,
+ })
+
+ expect(dialogText()).toBe("")
+ })
+
+ it("titles the data source creation modal after its type", async ({
+ expect,
+ }) => {
+ await mountModal({
+ modelValue: "data-source-creation",
+ opts: DataSourceType.PostgreSQL,
+ })
+
+ expect(dialogText()).toContain("Connect PostgreSQL")
+ })
+
+ it("titles the data source update modal after the target's type", async ({
+ expect,
+ }) => {
+ await mountModal({ modelValue: "data-source-update", opts: DATA_SOURCE })
+
+ expect(dialogText()).toContain("Update Prometheus")
+ })
+
+ it("titles the data source removal modal after the target's type", async ({
+ expect,
+ }) => {
+ await mountModal({ modelValue: "data-source-removal", opts: DATA_SOURCE })
+
+ expect(dialogText()).toContain("Remove Prometheus Data Source")
+ expect(dialogText()).toContain("Prod metrics")
+ })
+
+ it.for(["data-source-creation", "data-source-update", "data-source-removal"])(
+ "renders nothing for %s without a target",
+ async (action, { expect }) => {
+ await mountModal({ modelValue: action, opts: null })
+
+ expect(dialogText()).toBe("")
+ },
+ )
+
+ it("renders nothing for an action it does not know", async ({ expect }) => {
+ await mountModal({ modelValue: "teleport-the-workspace" })
+
+ expect(dialogText()).toBe("")
+ })
+
+ it("clears the picked action when the close button is pressed", async ({
+ expect,
+ }) => {
+ const wrapper = await mountModal({ modelValue: "email-change" })
+
+ teleportedButton("Close").click()
+ await nextTick()
+
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([null])
+ })
+
+ it("clears the picked action when the child action closes", async ({
+ expect,
+ }) => {
+ const wrapper = await mountModal({ modelValue: "email-change" })
+
+ teleportedButton("Cancel").click()
+ await nextTick()
+
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([null])
+ })
+
+ it("passes a slug refresh from the child on to its parent", async ({
+ expect,
+ }) => {
+ const wrapper = await mountModal({ modelValue: "url-change" })
+
+ emitFrom(wrapper, WorkspaceURLChangeAction, "refresh-organization-slug")
+ await nextTick()
+
+ expect(wrapper.emitted("refresh-organization-slug")).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/settings/AppsSection.nuxt.test.ts b/web/app/components/settings/AppsSection.nuxt.test.ts
new file mode 100644
index 00000000..7ed9d1dd
--- /dev/null
+++ b/web/app/components/settings/AppsSection.nuxt.test.ts
@@ -0,0 +1,236 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+ seedQueryData,
+} from "~/composables/api/test-helpers"
+import AppsSection from "./AppsSection.vue"
+import { findButtonByText, settleMutations } from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+function seedApps(options: {
+ gitHub?: { connected: boolean; configured: boolean }
+ slack?: { connected: boolean; configured: boolean }
+}) {
+ seedQueryData(
+ ["github", "connected"],
+ options.gitHub ?? { connected: false, configured: true },
+ )
+ seedQueryData(
+ ["slack", "connected"],
+ options.slack ?? { connected: false, configured: true },
+ )
+}
+
+function mountSection() {
+ return mountSuspended(AppsSection)
+}
+
+// rows are told apart by the app they name; both offer the same actions
+function row(wrapper: Awaited>, app: string) {
+ const found = wrapper
+ .findAll("div.flex.w-full.items-center")
+ .find((el) => el.text().includes(app))
+ if (!found) {
+ throw new Error(`no app row for "${app}"`)
+ }
+
+ return found
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ mockEndpoint("GET", "/api/github", () => ({
+ connected: false,
+ configured: true,
+ }))
+ mockEndpoint("GET", "/api/slack", () => ({
+ connected: false,
+ configured: true,
+ }))
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("lists both integrations on a deployment that has them", async ({
+ expect,
+ }) => {
+ seedApps({})
+
+ const wrapper = await mountSection()
+
+ expect(wrapper.text()).toContain("GitHub")
+ expect(wrapper.text()).toContain("Slack")
+ })
+
+ it("hides github on a deployment without it", async ({ expect }) => {
+ seedApps({ gitHub: { connected: false, configured: false } })
+
+ const wrapper = await mountSection()
+
+ expect(wrapper.text()).not.toContain("GitHub")
+ })
+
+ it("hides slack on a deployment without it", async ({ expect }) => {
+ seedApps({ slack: { connected: false, configured: false } })
+
+ const wrapper = await mountSection()
+
+ expect(wrapper.text()).not.toContain("Slack")
+ })
+
+ it("offers to connect an unconnected integration", async ({ expect }) => {
+ seedApps({})
+
+ const wrapper = await mountSection()
+
+ expect(row(wrapper, "GitHub").text()).toContain("Connect")
+ })
+
+ it("reports a connected integration and offers to disconnect it", async ({
+ expect,
+ }) => {
+ seedApps({ gitHub: { connected: true, configured: true } })
+
+ const wrapper = await mountSection()
+
+ expect(row(wrapper, "GitHub").text()).toContain("Connected")
+ expect(row(wrapper, "GitHub").text()).toContain("Disconnect")
+ })
+
+ it("opens the github install page in the browser", async ({ expect }) => {
+ seedApps({})
+ mockEndpoint("GET", "/api/github/install", () => ({
+ url: "https://github.test/install",
+ }))
+ const open = vi.fn()
+ vi.stubGlobal("open", open)
+ const wrapper = await mountSection()
+
+ await row(wrapper, "GitHub").get("button").trigger("click")
+ await settleMutations()
+
+ expect(open).toHaveBeenCalledExactlyOnceWith(
+ "https://github.test/install",
+ "_blank",
+ "noopener",
+ )
+ })
+
+ it("warns when the github install url cannot be fetched", async ({
+ expect,
+ }) => {
+ seedApps({})
+ mockEndpoint("GET", "/api/github/install", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSection()
+
+ await row(wrapper, "GitHub").get("button").trigger("click")
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("opens the slack install page in the browser", async ({ expect }) => {
+ seedApps({})
+ mockEndpoint("GET", "/api/slack/install", () => ({
+ url: "https://slack.test/install",
+ }))
+ const open = vi.fn()
+ vi.stubGlobal("open", open)
+ const wrapper = await mountSection()
+
+ await row(wrapper, "Slack").get("button").trigger("click")
+ await settleMutations()
+
+ expect(open).toHaveBeenCalledExactlyOnceWith(
+ "https://slack.test/install",
+ "_blank",
+ "noopener",
+ )
+ })
+
+ it("warns when the slack install url cannot be fetched", async ({
+ expect,
+ }) => {
+ seedApps({})
+ mockEndpoint("GET", "/api/slack/install", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSection()
+
+ await row(wrapper, "Slack").get("button").trigger("click")
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("disconnects github", async ({ expect }) => {
+ seedApps({ gitHub: { connected: true, configured: true } })
+ const calls = mockEndpoint("DELETE", "/api/github", () => ({}))
+ const wrapper = await mountSection()
+
+ await findButtonByText(wrapper, "Disconnect").trigger("click")
+ await settleMutations()
+
+ expect(calls).toHaveLength(1)
+ expect(toast.custom).toHaveBeenCalledTimes(0)
+ })
+
+ it("warns when github cannot be disconnected", async ({ expect }) => {
+ seedApps({ gitHub: { connected: true, configured: true } })
+ mockEndpoint("DELETE", "/api/github", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSection()
+
+ await findButtonByText(wrapper, "Disconnect").trigger("click")
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("disconnects slack", async ({ expect }) => {
+ seedApps({ slack: { connected: true, configured: true } })
+ const calls = mockEndpoint("DELETE", "/api/slack", () => ({}))
+ const wrapper = await mountSection()
+
+ await row(wrapper, "Slack").get("button").trigger("click")
+ await settleMutations()
+
+ expect(calls).toHaveLength(1)
+ expect(toast.custom).toHaveBeenCalledTimes(0)
+ })
+
+ it("warns when slack cannot be disconnected", async ({ expect }) => {
+ seedApps({ slack: { connected: true, configured: true } })
+ mockEndpoint("DELETE", "/api/slack", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSection()
+
+ await row(wrapper, "Slack").get("button").trigger("click")
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("separates the two rows only when both are shown", async ({ expect }) => {
+ seedApps({ slack: { connected: false, configured: false } })
+
+ const wrapper = await mountSection()
+
+ expect(wrapper.find(".my-3\\.5").exists()).toBe(false)
+ })
+})
diff --git a/web/app/components/settings/BaseModal.nuxt.test.ts b/web/app/components/settings/BaseModal.nuxt.test.ts
new file mode 100644
index 00000000..048d5c81
--- /dev/null
+++ b/web/app/components/settings/BaseModal.nuxt.test.ts
@@ -0,0 +1,262 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+ seedQueryData,
+} from "~/composables/api/test-helpers"
+import BaseModal from "./BaseModal.vue"
+import DataSourceSection from "./DataSourceSection.vue"
+import ProfileSection from "./ProfileSection.vue"
+import WorkspaceSection from "./WorkspaceSection.vue"
+import {
+ clearTeleportedOverlays,
+ emitFrom,
+ mockAuthOrganization,
+ seedAuthAccounts,
+ seedAuthSession,
+ teleportedButton,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+const DATA_SOURCE: DataSource = {
+ id: "ds1".padEnd(20, "0"),
+ name: "Prod metrics",
+ type: DataSourceType.Prometheus,
+ url: "http://prometheus.test",
+ status: DataSourceStatus.Success,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: null,
+}
+
+type SettingsTarget = "org-members" | "github" | "metric-data-sources"
+
+function mountModal(open: boolean | SettingsTarget = true) {
+ return mountSuspended(BaseModal, { props: { modelValue: open } })
+}
+
+// the settings modal and the action modal it hosts are two separate
+// dialogs, each teleported into in whichever order they mounted
+function dialogText() {
+ return Array.from(
+ document.body.querySelectorAll("[data-slot='dialog-content']"),
+ )
+ .map((dialog) => dialog.textContent)
+ .join("")
+}
+
+// the dialog body is teleported into the shared and the query cache
+// is app-wide
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearTeleportedOverlays()
+ clearQueryCache()
+ seedAuthSession({ id: "u1", email: "ada@oxynote.test", name: "ada" })
+ seedAuthAccounts(["credential"])
+ mockAuthOrganization({
+ id: "org-1",
+ name: "Acme",
+ slug: "acme-corp",
+ logo: "",
+ members: [],
+ invitations: [],
+ })
+ seedQueryData(["github", "connected"], {
+ connected: false,
+ configured: true,
+ })
+ seedQueryData(["slack", "connected"], {
+ connected: false,
+ configured: true,
+ })
+ seedQueryData(["data-sources", "list"], [])
+ mockEndpoint("GET", "/api/github", () => ({
+ connected: false,
+ configured: true,
+ }))
+ mockEndpoint("GET", "/api/slack", () => ({
+ connected: false,
+ configured: true,
+ }))
+ mockEndpoint("GET", "/api/data-sources", () => [])
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("stays closed while the model is false", async ({ expect }) => {
+ await mountModal(false)
+
+ expect(
+ document.body.querySelector("[data-slot='dialog-content']"),
+ ).toBeNull()
+ })
+
+ it("lists every settings section in order", async ({ expect }) => {
+ await mountModal()
+
+ expect(
+ Array.from(document.body.querySelectorAll("h3")).map(
+ (h) => h.textContent,
+ ),
+ ).toEqual(["Profile", "Workspace", "Apps", "External Data Sources"])
+ })
+
+ it("gives the deep-linkable sections their anchors", async ({ expect }) => {
+ await mountModal()
+
+ expect(document.getElementById("settings-org")).not.toBeNull()
+ expect(document.getElementById("settings-apps")).not.toBeNull()
+ expect(document.getElementById("settings-data-sources")).not.toBeNull()
+ })
+
+ it.for([
+ { target: "org-members", anchor: "settings-org" },
+ { target: "github", anchor: "settings-apps" },
+ { target: "metric-data-sources", anchor: "settings-data-sources" },
+ ] as { target: SettingsTarget; anchor: string }[])(
+ "scrolls to $anchor when opened at $target",
+ async ({ target, anchor }, { expect }) => {
+ const scrolled: string[] = []
+ // happy-dom has no layout, so scrollIntoView is a no-op stub — the
+ // element it was called on is the observable part
+ vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation(
+ function (this: HTMLElement) {
+ scrolled.push(this.id)
+ },
+ )
+
+ // whenever() watches for a change, so the modal has to actually
+ // open on the target rather than start out on it
+ const wrapper = await mountModal(false)
+ await wrapper.setProps({ modelValue: target })
+ await nextTick()
+ await nextTick()
+
+ expect(scrolled).toContain(anchor)
+ },
+ )
+
+ it("scrolls nowhere when opened without a target", async ({ expect }) => {
+ const scrollIntoView = vi
+ .spyOn(HTMLElement.prototype, "scrollIntoView")
+ .mockImplementation(() => undefined)
+
+ const wrapper = await mountModal(false)
+ await wrapper.setProps({ modelValue: true })
+ await nextTick()
+ await nextTick()
+
+ expect(scrollIntoView).toHaveBeenCalledTimes(0)
+ })
+
+ it("closes when the close button is pressed", async ({ expect }) => {
+ const wrapper = await mountModal()
+
+ teleportedButton("Close").click()
+ await nextTick()
+
+ expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([false])
+ })
+
+ it.for([
+ { event: "email-change", expected: "Change Email" },
+ { event: "password-change", expected: "Change Password" },
+ { event: "account-deletion", expected: "Delete Account" },
+ ])(
+ "opens the $event action from the profile section",
+ async ({ event, expected }, { expect }) => {
+ const wrapper = await mountModal()
+
+ emitFrom(wrapper, ProfileSection, event)
+ await nextTick()
+
+ expect(dialogText()).toContain(expected)
+ },
+ )
+
+ it.for([
+ { event: "url-change", expected: "Change Workspace URL" },
+ { event: "invitation", expected: "Invite to Your Workspace" },
+ ])(
+ "opens the $event action from the workspace section",
+ async ({ event, expected }, { expect }) => {
+ const wrapper = await mountModal()
+
+ emitFrom(wrapper, WorkspaceSection, event)
+ await nextTick()
+
+ expect(dialogText()).toContain(expected)
+ },
+ )
+
+ it("opens the member removal action with the picked member", async ({
+ expect,
+ }) => {
+ const wrapper = await mountModal()
+
+ emitFrom(wrapper, WorkspaceSection, "member-removal", {
+ id: "member-1",
+ organizationId: "org-1",
+ userId: "u2",
+ role: "member",
+ user: { name: "Linus", email: "linus@oxynote.test" },
+ })
+ await nextTick()
+
+ expect(dialogText()).toContain("Remove from Your Workspace")
+ expect(dialogText()).toContain("Linus")
+ })
+
+ it("opens the data source creation action for the picked type", async ({
+ expect,
+ }) => {
+ const wrapper = await mountModal()
+
+ emitFrom(
+ wrapper,
+ DataSourceSection,
+ "data-source-creation",
+ DataSourceType.MySQL,
+ )
+ await nextTick()
+
+ expect(dialogText()).toContain("Connect MySQL")
+ })
+
+ it("opens the data source update action for the picked data source", async ({
+ expect,
+ }) => {
+ const wrapper = await mountModal()
+
+ emitFrom(wrapper, DataSourceSection, "data-source-update", DATA_SOURCE)
+ await nextTick()
+
+ expect(dialogText()).toContain("Update Prometheus")
+ })
+
+ it("opens the data source removal action for the picked data source", async ({
+ expect,
+ }) => {
+ const wrapper = await mountModal()
+
+ emitFrom(wrapper, DataSourceSection, "data-source-removal", DATA_SOURCE)
+ await nextTick()
+
+ expect(dialogText()).toContain("Remove Prometheus Data Source")
+ })
+
+ it("passes a slug refresh from an action on to its parent", async ({
+ expect,
+ }) => {
+ const wrapper = await mountModal()
+
+ emitFrom(wrapper, "SettingsActionModal", "refresh-organization-slug")
+ await nextTick()
+
+ expect(wrapper.emitted("refresh-organization-slug")).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/settings/DataSourceRemovalAction.nuxt.test.ts b/web/app/components/settings/DataSourceRemovalAction.nuxt.test.ts
new file mode 100644
index 00000000..6d8f2cfc
--- /dev/null
+++ b/web/app/components/settings/DataSourceRemovalAction.nuxt.test.ts
@@ -0,0 +1,125 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+} from "~/composables/api/test-helpers"
+import DataSourceRemovalAction from "./DataSourceRemovalAction.vue"
+import { findButtonByText, settleMutations } from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+const DATA_SOURCE: DataSource = {
+ id: "ds1".padEnd(20, "0"),
+ name: "Prod metrics",
+ type: DataSourceType.Prometheus,
+ url: "http://prometheus.test",
+ status: DataSourceStatus.Success,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: null,
+}
+
+function mountAction() {
+ return mountSuspended(DataSourceRemovalAction, {
+ props: { data: DATA_SOURCE },
+ })
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares, and the delete flow is driven by the
+// global fake timers
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ vi.useFakeTimers()
+ mockEndpoint("GET", "/api/data-sources", () => [])
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("names the data source it is about to remove", async ({ expect }) => {
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).toContain("Prod metrics")
+ })
+
+ it("removes the data source when the submit button is pressed", async ({
+ expect,
+ }) => {
+ const calls = mockEndpoint(
+ "DELETE",
+ `/api/data-sources/${DATA_SOURCE.id}`,
+ () => ({}),
+ )
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Remove Data Source").trigger("click")
+ await vi.advanceTimersByTimeAsync(300)
+ await settleMutations()
+
+ expect(calls).toHaveLength(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("confirms the removal", async ({ expect }) => {
+ mockEndpoint("DELETE", `/api/data-sources/${DATA_SOURCE.id}`, () => ({}))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Remove Data Source").trigger("click")
+ await vi.advanceTimersByTimeAsync(300)
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("shows a spinner and disables both buttons while removing", async ({
+ expect,
+ }) => {
+ mockEndpoint("DELETE", `/api/data-sources/${DATA_SOURCE.id}`, () => ({}))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Remove Data Source").trigger("click")
+ await nextTick()
+
+ expect(wrapper.find(".iconify").exists()).toBe(true)
+ expect(
+ findButtonByText(wrapper, "Remove Data Source").attributes("disabled"),
+ ).toBeDefined()
+ expect(
+ findButtonByText(wrapper, "Cancel").attributes("disabled"),
+ ).toBeDefined()
+ })
+
+ it("warns and stays open when the removal fails", async ({ expect }) => {
+ mockEndpoint("DELETE", `/api/data-sources/${DATA_SOURCE.id}`, () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Remove Data Source").trigger("click")
+ await vi.advanceTimersByTimeAsync(300)
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("closes without removing anything when cancelled", async ({ expect }) => {
+ const calls = mockEndpoint(
+ "DELETE",
+ `/api/data-sources/${DATA_SOURCE.id}`,
+ () => ({}),
+ )
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Cancel").trigger("click")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/settings/DataSourceSection.nuxt.test.ts b/web/app/components/settings/DataSourceSection.nuxt.test.ts
new file mode 100644
index 00000000..ac62e759
--- /dev/null
+++ b/web/app/components/settings/DataSourceSection.nuxt.test.ts
@@ -0,0 +1,182 @@
+import { afterEach, beforeEach, describe, it } from "vitest"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ seedQueryData,
+} from "~/composables/api/test-helpers"
+import DataSourceSection from "./DataSourceSection.vue"
+import { at, menuItem, mountUnderTooltipProvider } from "../test-helpers"
+
+function makeDataSource(overrides: Partial = {}): DataSource {
+ return {
+ id: "ds1".padEnd(20, "0"),
+ name: "Prod metrics",
+ type: DataSourceType.Prometheus,
+ url: "http://prometheus.test",
+ status: DataSourceStatus.Success,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: null,
+ ...overrides,
+ }
+}
+
+function seedDataSources(dataSources: DataSource[]) {
+ seedQueryData(["data-sources", "list"], dataSources)
+}
+
+async function mountSection() {
+ const wrapper = await mountUnderTooltipProvider(DataSourceSection, {})
+
+ return { wrapper, section: wrapper.findComponent(DataSourceSection) }
+}
+
+// the query cache is app-wide and the options menus are teleported into
+// the shared
+describe("", { concurrent: false }, () => {
+ beforeEach(clearQueryCache)
+
+ afterEach(disposeMockEndpoints)
+
+ it("offers all four data source types", async ({ expect }) => {
+ seedDataSources([])
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).toContain("Prometheus")
+ expect(wrapper.text()).toContain("PostgreSQL")
+ expect(wrapper.text()).toContain("MySQL")
+ expect(wrapper.text()).toContain("MariaDB")
+ })
+
+ it("describes a type that has nothing connected yet", async ({ expect }) => {
+ seedDataSources([])
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).toContain("Connect your Prometheus server")
+ })
+
+ it("lists the connected data sources of a type", async ({ expect }) => {
+ seedDataSources([makeDataSource()])
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).toContain("Prod metrics")
+ expect(wrapper.text()).toContain("http://prometheus.test")
+ })
+
+ it("keeps a data source out of another type's list", async ({ expect }) => {
+ seedDataSources([
+ makeDataSource({ name: "Analytics", type: DataSourceType.PostgreSQL }),
+ ])
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).toContain("Analytics")
+ expect(wrapper.text()).toContain("Connect your Prometheus server")
+ })
+
+ it("hides a data source that is still being inserted", async ({ expect }) => {
+ seedDataSources([
+ makeDataSource({
+ name: "Pending",
+ status: DataSourceStatus.LocalOptimisticInsert,
+ }),
+ ])
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).not.toContain("Pending")
+ })
+
+ it.for([
+ { index: 0, type: DataSourceType.Prometheus },
+ { index: 1, type: DataSourceType.PostgreSQL },
+ { index: 2, type: DataSourceType.MySQL },
+ { index: 3, type: DataSourceType.MariaDB },
+ ])(
+ "asks to connect a new $type data source",
+ async ({ index, type }, { expect }) => {
+ seedDataSources([])
+ const { wrapper, section } = await mountSection()
+
+ await at(wrapper.findAll("button"), index).trigger("click")
+
+ expect(section.emitted("data-source-creation")).toEqual([[type]])
+ },
+ )
+
+ it.for([
+ DataSourceType.PostgreSQL,
+ DataSourceType.MySQL,
+ DataSourceType.MariaDB,
+ ])(
+ "asks to update a %s data source picked from its options menu",
+ async (type, { expect }) => {
+ const dataSource = makeDataSource({ type: type })
+ seedDataSources([dataSource])
+ const { wrapper, section } = await mountSection()
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem("Update").click()
+ await nextTick()
+
+ expect(section.emitted("data-source-update")).toEqual([[dataSource]])
+ },
+ )
+
+ it.for([
+ DataSourceType.PostgreSQL,
+ DataSourceType.MySQL,
+ DataSourceType.MariaDB,
+ ])(
+ "asks to remove a %s data source picked from its options menu",
+ async (type, { expect }) => {
+ const dataSource = makeDataSource({ type: type })
+ seedDataSources([dataSource])
+ const { wrapper, section } = await mountSection()
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem("Remove").click()
+ await nextTick()
+
+ expect(section.emitted("data-source-removal")).toEqual([[dataSource]])
+ },
+ )
+
+ it("asks to update the data source picked from its options menu", async ({
+ expect,
+ }) => {
+ const dataSource = makeDataSource()
+ seedDataSources([dataSource])
+ const { wrapper, section } = await mountSection()
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem("Update").click()
+ await nextTick()
+
+ expect(section.emitted("data-source-update")).toEqual([[dataSource]])
+ })
+
+ it("asks to remove the data source picked from its options menu", async ({
+ expect,
+ }) => {
+ const dataSource = makeDataSource()
+ seedDataSources([dataSource])
+ const { wrapper, section } = await mountSection()
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem("Remove").click()
+ await nextTick()
+
+ expect(section.emitted("data-source-removal")).toEqual([[dataSource]])
+ })
+
+ it("shows when the data source was connected", async ({ expect }) => {
+ seedDataSources([makeDataSource()])
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).toContain("Jan 1, 2026")
+ })
+})
diff --git a/web/app/components/settings/DataSourceUpsertAction.nuxt.test.ts b/web/app/components/settings/DataSourceUpsertAction.nuxt.test.ts
new file mode 100644
index 00000000..bc24a748
--- /dev/null
+++ b/web/app/components/settings/DataSourceUpsertAction.nuxt.test.ts
@@ -0,0 +1,393 @@
+import { setResponseStatus } from "h3"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+} from "~/composables/api/test-helpers"
+import DataSourceUpsertAction from "./DataSourceUpsertAction.vue"
+import {
+ at,
+ findButtonByText,
+ mountWithFrozenClock,
+ settleActionSubmit,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+const EXISTING: DataSource = {
+ id: "ds1".padEnd(20, "0"),
+ name: "Prod metrics",
+ type: DataSourceType.Prometheus,
+ url: "http://prometheus.test",
+ status: DataSourceStatus.Success,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: null,
+}
+
+function mountAction(props: Record) {
+ return mountWithFrozenClock(DataSourceUpsertAction, { props: props })
+}
+
+// name, url, username, password — in template order
+function fields(wrapper: Awaited>) {
+ const inputs = wrapper.findAll("input")
+
+ return {
+ name: at(inputs, 0),
+ url: at(inputs, 1),
+ username: at(inputs, 2),
+ password: at(inputs, 3),
+ }
+}
+
+async function submit(wrapper: Awaited>) {
+ await wrapper.get("form").trigger("submit")
+ await settleActionSubmit()
+}
+
+// showToastMessage hands vue-sonner a render function, so the toast's own
+// props only exist once that is called
+function renderToastProps(render: unknown) {
+ if (typeof render !== "function") {
+ throw new Error("toast.custom was not called with a render function")
+ }
+
+ return (render as () => { props: { description?: string } })().props
+}
+
+function statusError(status: DataSourceStatus) {
+ return (_call: unknown, event: Parameters[0]) => {
+ setResponseStatus(event, 400)
+
+ return { code: `data_source.${status}` }
+ }
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares, and the submit flow is driven by the
+// global fake timers
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ mockEndpoint("GET", "/api/data-sources", () => [])
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ describe("connecting a new data source", { concurrent: false }, () => {
+ it("describes the data source being connected", async ({ expect }) => {
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+
+ expect(wrapper.text()).toContain("Connect a new Prometheus server")
+ })
+
+ it("starts with empty fields", async ({ expect }) => {
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+
+ expect(fields(wrapper).name.element.value).toBe("")
+ expect(fields(wrapper).url.element.value).toBe("")
+ })
+
+ it("sends the filled-in data source to the server", async ({ expect }) => {
+ const calls = mockEndpoint("POST", "/api/data-sources", () => ({
+ id: EXISTING.id,
+ }))
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+ await fields(wrapper).name.setValue("Prod metrics")
+ await fields(wrapper).url.setValue("http://prometheus.test")
+ await fields(wrapper).username.setValue("reader")
+ await fields(wrapper).password.setValue("secret")
+
+ await submit(wrapper)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({
+ type: DataSourceType.Prometheus,
+ name: "Prod metrics",
+ url: "http://prometheus.test",
+ credentials: { username: "reader", password: "secret" },
+ })
+ })
+
+ it("sends empty credentials when none were given", async ({ expect }) => {
+ const calls = mockEndpoint("POST", "/api/data-sources", () => ({
+ id: EXISTING.id,
+ }))
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+ await fields(wrapper).name.setValue("Prod metrics")
+ await fields(wrapper).url.setValue("http://prometheus.test")
+
+ await submit(wrapper)
+
+ expect(calls[0]?.body).toMatchObject({
+ credentials: { username: "", password: "" },
+ })
+ })
+
+ it("confirms and closes once the data source is connected", async ({
+ expect,
+ }) => {
+ mockEndpoint("POST", "/api/data-sources", () => ({ id: EXISTING.id }))
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+ await fields(wrapper).name.setValue("Prod metrics")
+ await fields(wrapper).url.setValue("http://prometheus.test")
+
+ await submit(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("rejects a url that is not a url", async ({ expect }) => {
+ const calls = mockEndpoint("POST", "/api/data-sources", () => ({
+ id: EXISTING.id,
+ }))
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+ await fields(wrapper).name.setValue("Prod metrics")
+ await fields(wrapper).url.setValue("not-a-url")
+
+ await submit(wrapper)
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("rejects an empty nickname", async ({ expect }) => {
+ const calls = mockEndpoint("POST", "/api/data-sources", () => ({
+ id: EXISTING.id,
+ }))
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+ await fields(wrapper).url.setValue("http://prometheus.test")
+
+ await submit(wrapper)
+
+ expect(calls).toHaveLength(0)
+ })
+
+ it.for([
+ {
+ status: DataSourceStatus.Unauthorized,
+ expected: "Authentication credentials are incorrect",
+ },
+ {
+ status: DataSourceStatus.Unreachable,
+ expected: "server is unreachable",
+ },
+ {
+ status: DataSourceStatus.VersionNotSupported,
+ expected: "is not supported",
+ },
+ {
+ status: DataSourceStatus.NotReadOnly,
+ expected: "only needs read permissions",
+ },
+ ])(
+ "explains a $status rejection from the server",
+ async ({ status, expected }, { expect }) => {
+ mockEndpoint("POST", "/api/data-sources", statusError(status))
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+ await fields(wrapper).name.setValue("Prod metrics")
+ await fields(wrapper).url.setValue("http://prometheus.test")
+
+ await submit(wrapper)
+
+ const rendered = vi
+ .mocked(toast.custom)
+ .mock.calls.map((call) => renderToastProps(call[0]))
+
+ expect(rendered[0]?.description).toContain(expected)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ },
+ )
+
+ it("falls back to a generic warning for an unrecognised failure", async ({
+ expect,
+ }) => {
+ mockEndpoint("POST", "/api/data-sources", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+ await fields(wrapper).name.setValue("Prod metrics")
+ await fields(wrapper).url.setValue("http://prometheus.test")
+
+ await submit(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("closes without connecting anything when cancelled", async ({
+ expect,
+ }) => {
+ const calls = mockEndpoint("POST", "/api/data-sources", () => ({
+ id: EXISTING.id,
+ }))
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+
+ await findButtonByText(wrapper, "Cancel").trigger("click")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+ })
+
+ describe("updating a data source", { concurrent: false }, () => {
+ it("describes the data source being updated", async ({ expect }) => {
+ const wrapper = await mountAction({ updateTarget: EXISTING })
+
+ expect(wrapper.text()).toContain("Update the settings of your Prometheus")
+ })
+
+ it("starts from the data source's current values", async ({ expect }) => {
+ const wrapper = await mountAction({ updateTarget: EXISTING })
+
+ expect(fields(wrapper).name.element.value).toBe("Prod metrics")
+ expect(fields(wrapper).url.element.value).toBe("http://prometheus.test")
+ })
+
+ it("hides the stored credentials behind a placeholder", async ({
+ expect,
+ }) => {
+ const wrapper = await mountAction({ updateTarget: EXISTING })
+
+ expect(fields(wrapper).username.attributes("placeholder")).toBe(
+ "[protected]",
+ )
+ })
+
+ it("sends only the fields that actually changed", async ({ expect }) => {
+ const calls = mockEndpoint(
+ "PUT",
+ `/api/data-sources/${EXISTING.id}`,
+ () => ({ id: EXISTING.id }),
+ )
+ const wrapper = await mountAction({ updateTarget: EXISTING })
+ await fields(wrapper).name.setValue("Staging metrics")
+
+ await submit(wrapper)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({ name: "Staging metrics" })
+ })
+
+ it("sends new credentials on their own", async ({ expect }) => {
+ const calls = mockEndpoint(
+ "PUT",
+ `/api/data-sources/${EXISTING.id}`,
+ () => ({ id: EXISTING.id }),
+ )
+ const wrapper = await mountAction({ updateTarget: EXISTING })
+ await fields(wrapper).password.setValue("new-secret")
+
+ await submit(wrapper)
+
+ expect(calls[0]?.body).toEqual({
+ credentials: { password: "new-secret" },
+ })
+ })
+
+ it("closes without a request when nothing changed", async ({ expect }) => {
+ const calls = mockEndpoint(
+ "PUT",
+ `/api/data-sources/${EXISTING.id}`,
+ () => ({ id: EXISTING.id }),
+ )
+ const wrapper = await mountAction({ updateTarget: EXISTING })
+
+ await submit(wrapper)
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("confirms and closes once the data source is updated", async ({
+ expect,
+ }) => {
+ mockEndpoint("PUT", `/api/data-sources/${EXISTING.id}`, () => ({
+ id: EXISTING.id,
+ }))
+ const wrapper = await mountAction({ updateTarget: EXISTING })
+ await fields(wrapper).name.setValue("Staging metrics")
+
+ await submit(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("explains a rejection from the server", async ({ expect }) => {
+ mockEndpoint(
+ "PUT",
+ `/api/data-sources/${EXISTING.id}`,
+ statusError(DataSourceStatus.Unreachable),
+ )
+ const wrapper = await mountAction({ updateTarget: EXISTING })
+ await fields(wrapper).name.setValue("Staging metrics")
+
+ await submit(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("falls back to a generic warning for an unrecognised failure", async ({
+ expect,
+ }) => {
+ mockEndpoint("PUT", `/api/data-sources/${EXISTING.id}`, () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountAction({ updateTarget: EXISTING })
+ await fields(wrapper).name.setValue("Staging metrics")
+
+ await submit(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+ })
+
+ describe("read-only warning", { concurrent: false }, () => {
+ it("leaves prometheus without a read-only warning", async ({ expect }) => {
+ const wrapper = await mountAction({
+ creationType: DataSourceType.Prometheus,
+ })
+
+ expect(wrapper.text()).not.toContain("read-only")
+ })
+
+ it.for([
+ DataSourceType.PostgreSQL,
+ DataSourceType.MySQL,
+ DataSourceType.MariaDB,
+ ])("warns that %s needs a read-only user", async (type, { expect }) => {
+ const wrapper = await mountAction({ creationType: type })
+
+ expect(wrapper.text()).toContain("read-only")
+ })
+ })
+})
diff --git a/web/app/components/settings/EmailChangeAction.nuxt.test.ts b/web/app/components/settings/EmailChangeAction.nuxt.test.ts
new file mode 100644
index 00000000..cbca13cd
--- /dev/null
+++ b/web/app/components/settings/EmailChangeAction.nuxt.test.ts
@@ -0,0 +1,124 @@
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { setResponseStatus } from "h3"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+} from "~/composables/api/test-helpers"
+import EmailChangeAction from "./EmailChangeAction.vue"
+import {
+ findButtonByText,
+ mockAuthEndpoint,
+ mountWithFrozenClock,
+ seedAuthSession,
+ settleActionSubmit,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+function mountAction() {
+ return mountWithFrozenClock(EmailChangeAction)
+}
+
+async function submitEmail(
+ wrapper: Awaited>,
+ email: string,
+) {
+ await wrapper.get("input").setValue(email)
+ await wrapper.get("form").trigger("submit")
+ await settleActionSubmit()
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares, and the submit flow is driven by the
+// global fake timers
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ seedAuthSession({ id: "u1", email: "ada@oxynote.test", name: "Ada" })
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("explains what changing the email does", async ({ expect }) => {
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).toContain("Changing your email address")
+ })
+
+ it("sends the new address to the server with a verification callback", async ({
+ expect,
+ }) => {
+ const calls = mockAuthEndpoint("change-email", () => ({ status: true }))
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "new@oxynote.test")
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toMatchObject({ newEmail: "new@oxynote.test" })
+ expect(
+ (calls[0]?.body as { callbackURL: string } | undefined)?.callbackURL,
+ ).toContain("new%40oxynote.test")
+ })
+
+ it("confirms and closes once the verification email is on its way", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("change-email", () => ({ status: true }))
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "new@oxynote.test")
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("closes without asking the server when the address is unchanged", async ({
+ expect,
+ }) => {
+ const calls = mockAuthEndpoint("change-email", () => ({ status: true }))
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "ada@oxynote.test")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("rejects an address that is not an email", async ({ expect }) => {
+ const calls = mockAuthEndpoint("change-email", () => ({ status: true }))
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "not-an-email")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("shows the server's rejection next to the field", async ({ expect }) => {
+ mockAuthEndpoint("change-email", (_call, event) => {
+ setResponseStatus(event, 400)
+
+ return { code: "EMAIL_TAKEN", message: "Email already taken" }
+ })
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "taken@oxynote.test")
+
+ expect(wrapper.text()).toContain("Email already taken")
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("closes without sending anything when cancelled", async ({ expect }) => {
+ const calls = mockAuthEndpoint("change-email", () => ({ status: true }))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Cancel").trigger("click")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/settings/PasswordChangeAction.nuxt.test.ts b/web/app/components/settings/PasswordChangeAction.nuxt.test.ts
new file mode 100644
index 00000000..b884ad32
--- /dev/null
+++ b/web/app/components/settings/PasswordChangeAction.nuxt.test.ts
@@ -0,0 +1,282 @@
+import { setResponseStatus } from "h3"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+} from "~/composables/api/test-helpers"
+import PasswordChangeAction from "./PasswordChangeAction.vue"
+import {
+ at,
+ findButtonByText,
+ mockAuthEndpoint,
+ mountWithFrozenClock,
+ seedAuthAccounts,
+ seedAuthSession,
+ settleActionSubmit,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+const VALID_PASSWORD = "correct-horse-1!"
+
+function mountAction() {
+ return mountWithFrozenClock(PasswordChangeAction)
+}
+
+async function submitPasswords(
+ wrapper: Awaited>,
+ values: { current: string; next: string; confirm?: string },
+) {
+ const inputs = wrapper.findAll("input")
+ await at(inputs, 0).setValue(values.current)
+ await at(inputs, 1).setValue(values.next)
+ await at(inputs, 2).setValue(values.confirm ?? values.next)
+ await wrapper.get("form").trigger("submit")
+ await settleActionSubmit()
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares, and the submit flow is driven by the
+// global fake timers
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ seedAuthSession({ id: "u1", email: "ada@oxynote.test", name: "Ada" })
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ describe("when the account has a password", { concurrent: false }, () => {
+ beforeEach(() => {
+ seedAuthAccounts(["credential"])
+ })
+
+ it("offers the change-password form", async ({ expect }) => {
+ const wrapper = await mountAction()
+
+ expect(wrapper.findAll("input")).toHaveLength(3)
+ expect(wrapper.text()).toContain("Choose a new password")
+ })
+
+ it("changes the password and revokes the other sessions", async ({
+ expect,
+ }) => {
+ const calls = mockAuthEndpoint("change-password", () => ({
+ token: "t",
+ }))
+ const wrapper = await mountAction()
+
+ await submitPasswords(wrapper, {
+ current: "old-password",
+ next: VALID_PASSWORD,
+ })
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({
+ currentPassword: "old-password",
+ newPassword: VALID_PASSWORD,
+ revokeOtherSessions: true,
+ })
+ })
+
+ it("confirms and closes once the password is changed", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("change-password", () => ({ token: "t" }))
+ const wrapper = await mountAction()
+
+ await submitPasswords(wrapper, {
+ current: "old-password",
+ next: VALID_PASSWORD,
+ })
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("rejects a new password that repeats no digit", async ({ expect }) => {
+ const calls = mockAuthEndpoint("change-password", () => ({ token: "t" }))
+ const wrapper = await mountAction()
+
+ await submitPasswords(wrapper, {
+ current: "old-password",
+ next: "correct-horse-!!",
+ })
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.text()).toContain("at least one number")
+ })
+
+ it("rejects a new password with no special symbol", async ({ expect }) => {
+ const calls = mockAuthEndpoint("change-password", () => ({ token: "t" }))
+ const wrapper = await mountAction()
+
+ await submitPasswords(wrapper, {
+ current: "old-password",
+ next: "correcthorse1234",
+ })
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.text()).toContain("at least one special symbol")
+ })
+
+ it("rejects a confirmation that does not match", async ({ expect }) => {
+ const calls = mockAuthEndpoint("change-password", () => ({ token: "t" }))
+ const wrapper = await mountAction()
+
+ await submitPasswords(wrapper, {
+ current: "old-password",
+ next: VALID_PASSWORD,
+ confirm: "something-else-2!",
+ })
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.text()).toContain("Passwords do not match")
+ })
+
+ it("rejects an empty current password", async ({ expect }) => {
+ const calls = mockAuthEndpoint("change-password", () => ({ token: "t" }))
+ const wrapper = await mountAction()
+
+ await submitPasswords(wrapper, { current: "", next: VALID_PASSWORD })
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.text()).toContain("enter your current password")
+ })
+
+ it("points at the current password field when the server rejects it", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("change-password", (_call, event) => {
+ setResponseStatus(event, 400)
+
+ return { code: "INVALID_PASSWORD", message: "nope" }
+ })
+ const wrapper = await mountAction()
+
+ await submitPasswords(wrapper, {
+ current: "wrong-password",
+ next: VALID_PASSWORD,
+ })
+
+ expect(wrapper.text()).toContain("Incorrect password")
+ expect(toast.custom).toHaveBeenCalledTimes(0)
+ })
+
+ it("warns when the change fails for any other reason", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("change-password", (_call, event) => {
+ setResponseStatus(event, 500)
+
+ return { code: "BOOM", message: "boom" }
+ })
+ const wrapper = await mountAction()
+
+ await submitPasswords(wrapper, {
+ current: "old-password",
+ next: VALID_PASSWORD,
+ })
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("closes without changing anything when cancelled", async ({
+ expect,
+ }) => {
+ const calls = mockAuthEndpoint("change-password", () => ({ token: "t" }))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Cancel").trigger("click")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+ })
+
+ describe("when the account has no password", { concurrent: false }, () => {
+ beforeEach(() => {
+ seedAuthAccounts(["github"])
+ })
+
+ it("offers to email a setup link instead of a form", async ({ expect }) => {
+ const wrapper = await mountAction()
+
+ expect(wrapper.findAll("input")).toHaveLength(0)
+ expect(wrapper.text()).toContain("doesn't have a password yet")
+ })
+
+ it("names the address the link goes to", async ({ expect }) => {
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).toContain("ada@oxynote.test")
+ })
+
+ it("asks the server for a password reset pointing at the reset page", async ({
+ expect,
+ }) => {
+ const calls = mockAuthEndpoint("request-password-reset", () => ({
+ status: true,
+ }))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Send Link").trigger("click")
+ await settleActionSubmit()
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({
+ email: "ada@oxynote.test",
+ redirectTo: "http://test.local/reset-password",
+ })
+ })
+
+ it("confirms and closes once the link is on its way", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("request-password-reset", () => ({ status: true }))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Send Link").trigger("click")
+ await settleActionSubmit()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("warns and stays open when the link cannot be sent", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("request-password-reset", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Send Link").trigger("click")
+ await settleActionSubmit()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("does nothing while the signed-in address is unknown", async ({
+ expect,
+ }) => {
+ seedAuthSession({ id: "u1", name: "Ada" })
+ const calls = mockAuthEndpoint("request-password-reset", () => ({
+ status: true,
+ }))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Send Link").trigger("click")
+ await settleActionSubmit()
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+ })
+})
diff --git a/web/app/components/settings/ProfileSection.nuxt.test.ts b/web/app/components/settings/ProfileSection.nuxt.test.ts
new file mode 100644
index 00000000..db7131e7
--- /dev/null
+++ b/web/app/components/settings/ProfileSection.nuxt.test.ts
@@ -0,0 +1,326 @@
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { setResponseHeader } from "h3"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+ seedQueryData,
+} from "~/composables/api/test-helpers"
+import ProfileSection from "./ProfileSection.vue"
+import {
+ findButtonByText,
+ mockAuthEndpoint,
+ mockAuthOrganization,
+ seedAuthAccounts,
+ seedAuthSession,
+ settleMutations,
+ WAIT_FOR_OPTIONS,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+function seedSlack(options: {
+ connected?: boolean
+ linkSettings?: { notifications: boolean } | null
+}) {
+ seedQueryData(["slack", "connected"], {
+ connected: options.connected ?? false,
+ configured: true,
+ })
+ seedQueryData(
+ ["slack", "user-link-settings"],
+ options.linkSettings === undefined ? null : options.linkSettings,
+ )
+}
+
+function mountSection() {
+ return mountSuspended(ProfileSection)
+}
+
+function usernameInput(wrapper: Awaited>) {
+ return wrapper.get("input[type='text']")
+}
+
+// the field saves on blur, and the chain behind it runs vee-validate's
+// async validation, the update request, and two refetches
+async function saveUsername(
+ wrapper: Awaited>,
+ value?: string,
+) {
+ if (value !== undefined) {
+ await usernameInput(wrapper).setValue(value)
+ }
+
+ await usernameInput(wrapper).trigger("blur")
+
+ for (let round = 0; round < 6; round++) {
+ await settleMutations()
+ }
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ seedAuthSession({
+ id: "u1",
+ email: "ada@oxynote.test",
+ name: "ada",
+ image: "https://cdn.test/ada.png",
+ })
+ seedAuthAccounts(["credential"])
+ mockAuthOrganization({ id: "org-1", members: [], invitations: [] })
+ seedSlack({})
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("shows the signed-in email address", async ({ expect }) => {
+ const wrapper = await mountSection()
+
+ expect(wrapper.text()).toContain("ada@oxynote.test")
+ })
+
+ it("starts the username field from the current name", async ({ expect }) => {
+ const wrapper = await mountSection()
+
+ expect(usernameInput(wrapper).element.value).toBe("ada")
+ })
+
+ it("asks to change the email when its pencil is pressed", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSection()
+
+ await findButtonByText(wrapper, "Change Email").trigger("click")
+
+ expect(wrapper.emitted("email-change")).toHaveLength(1)
+ })
+
+ it("asks to change the password when its button is pressed", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSection()
+
+ await findButtonByText(wrapper, "Change password").trigger("click")
+
+ expect(wrapper.emitted("password-change")).toHaveLength(1)
+ })
+
+ it("offers to set a password when the account has none", async ({
+ expect,
+ }) => {
+ seedAuthAccounts(["github"])
+
+ const wrapper = await mountSection()
+
+ expect(wrapper.text()).toContain("Set password")
+ expect(wrapper.text()).toContain("No password set")
+ })
+
+ it("asks to delete the account when its button is pressed", async ({
+ expect,
+ }) => {
+ const wrapper = await mountSection()
+
+ await findButtonByText(wrapper, "Delete Account").trigger("click")
+
+ expect(wrapper.emitted("account-deletion")).toHaveLength(1)
+ })
+
+ it("saves a changed username on blur", async ({ expect }) => {
+ const calls = mockAuthEndpoint("update-user", () => ({ status: true }))
+ const wrapper = await mountSection()
+
+ await saveUsername(wrapper, "ada-lovelace")
+
+ // the save chains vee-validate's validation, the update request and
+ // two refetches, none of which the component signals the end of
+ await vi.waitFor(() => {
+ expect(calls).toHaveLength(1)
+ }, WAIT_FOR_OPTIONS)
+ expect(calls[0]?.body).toEqual({ name: "ada-lovelace" })
+ })
+
+ it("confirms a saved username", async ({ expect }) => {
+ mockAuthEndpoint("update-user", () => ({ status: true }))
+ const wrapper = await mountSection()
+
+ await saveUsername(wrapper, "ada-lovelace")
+
+ await vi.waitFor(() => {
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ }, WAIT_FOR_OPTIONS)
+ })
+
+ it("saves nothing when the username is unchanged", async ({ expect }) => {
+ const calls = mockAuthEndpoint("update-user", () => ({ status: true }))
+ const wrapper = await mountSection()
+
+ await saveUsername(wrapper)
+
+ expect(calls).toHaveLength(0)
+ })
+
+ it("rejects a username with characters outside the allowed set", async ({
+ expect,
+ }) => {
+ const calls = mockAuthEndpoint("update-user", () => ({ status: true }))
+ const wrapper = await mountSection()
+
+ await saveUsername(wrapper, "ada lovelace!")
+
+ expect(calls).toHaveLength(0)
+ // vee-validate renders the message on its own scheduler, with no
+ // signal the component surfaces
+ await vi.waitFor(() => {
+ expect(wrapper.text()).toContain("Only letters, numbers, hyphens")
+ }, WAIT_FOR_OPTIONS)
+ })
+
+ it("warns when the username cannot be saved", async ({ expect }) => {
+ mockAuthEndpoint("update-user", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSection()
+
+ await saveUsername(wrapper, "ada-lovelace")
+
+ // better-auth backs off internally before surfacing a failed
+ // request, and exposes no signal for when it is done
+ await vi.waitFor(() => {
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ }, WAIT_FOR_OPTIONS)
+ })
+
+ it("uploads a picked avatar and saves the returned url", async ({
+ expect,
+ }) => {
+ mockEndpoint("PUT", "/api/users/image", (_call, event) => {
+ // the composable reads the stored url off the location header
+ setResponseHeader(event, "location", "https://cdn.test/new.png")
+
+ return null
+ })
+ const updates = mockAuthEndpoint("update-user", () => ({ status: true }))
+ const wrapper = await mountSection()
+
+ await pickAvatar(wrapper)
+
+ // the upload and the profile update are chained requests, each with
+ // its own internal scheduling
+ await vi.waitFor(() => {
+ expect(updates).toHaveLength(1)
+ }, WAIT_FOR_OPTIONS)
+ expect(updates[0]?.body).toEqual({ image: "https://cdn.test/new.png" })
+ // the confirmation lands after the refetches that follow the update;
+ // leaving it in flight would spill into the next test
+ await vi.waitFor(() => {
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ }, WAIT_FOR_OPTIONS)
+ })
+
+ it("warns when the avatar upload fails", async ({ expect }) => {
+ mockEndpoint("PUT", "/api/users/image", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSection()
+
+ await pickAvatar(wrapper)
+
+ await vi.waitFor(() => {
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ }, WAIT_FOR_OPTIONS)
+ })
+
+ it("does nothing when the file dialog is dismissed", async ({ expect }) => {
+ const calls = mockEndpoint("PUT", "/api/users/image", () => ({
+ url: "https://cdn.test/new.png",
+ }))
+ const wrapper = await mountSection()
+
+ await wrapper.get("input[type='file']").trigger("change")
+ await settleMutations()
+
+ expect(calls).toHaveLength(0)
+ })
+
+ describe("slack notifications", { concurrent: false }, () => {
+ it("stays hidden while slack is not connected", async ({ expect }) => {
+ seedSlack({ connected: false })
+
+ const wrapper = await mountSection()
+
+ expect(wrapper.text()).not.toContain("Slack Notifications")
+ })
+
+ it("appears once slack is connected", async ({ expect }) => {
+ seedSlack({ connected: true, linkSettings: { notifications: true } })
+
+ const wrapper = await mountSection()
+
+ expect(wrapper.text()).toContain("Slack Notifications")
+ })
+
+ it("explains how to link an unlinked slack account", async ({ expect }) => {
+ seedSlack({ connected: true, linkSettings: null })
+
+ const wrapper = await mountSection()
+
+ expect(wrapper.text()).toContain("link your Slack account")
+ expect(wrapper.get("button[role='switch']").attributes("disabled")).toBe(
+ "",
+ )
+ })
+
+ it("saves the new notification preference", async ({ expect }) => {
+ seedSlack({ connected: true, linkSettings: { notifications: false } })
+ const calls = mockEndpoint("PUT", "/api/slack/users/settings", () => ({
+ notifications: true,
+ }))
+ const wrapper = await mountSection()
+
+ await wrapper.get("button[role='switch']").trigger("click")
+ await settleMutations()
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({ notifications: true })
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ })
+
+ it("puts the switch back and warns when saving fails", async ({
+ expect,
+ }) => {
+ seedSlack({ connected: true, linkSettings: { notifications: false } })
+ mockEndpoint("PUT", "/api/slack/users/settings", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountSection()
+
+ await wrapper.get("button[role='switch']").trigger("click")
+ await settleMutations()
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(
+ wrapper.get("button[role='switch']").attributes("aria-checked"),
+ ).toBe("false")
+ })
+ })
+})
+
+// happy-dom does not let a test click through a real file dialog, so the
+// picked file is put on the input directly before its change handler runs
+async function pickAvatar(wrapper: Awaited>) {
+ const input = wrapper.get("input[type='file']")
+ const file = new File(["avatar"], "avatar.png", { type: "image/png" })
+ Object.defineProperty(input.element, "files", { value: [file] })
+
+ await input.trigger("change")
+ await settleMutations()
+}
diff --git a/web/app/components/settings/WorkspaceInvitationAction.nuxt.test.ts b/web/app/components/settings/WorkspaceInvitationAction.nuxt.test.ts
new file mode 100644
index 00000000..eb5a053a
--- /dev/null
+++ b/web/app/components/settings/WorkspaceInvitationAction.nuxt.test.ts
@@ -0,0 +1,196 @@
+import { setResponseStatus } from "h3"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+} from "~/composables/api/test-helpers"
+import WorkspaceInvitationAction from "./WorkspaceInvitationAction.vue"
+import {
+ findButtonByText,
+ mockAuthEndpoint,
+ mockAuthOrganization,
+ mountWithFrozenClock,
+ seedAuthSession,
+ settleActionSubmit,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+function member(id: string) {
+ return { id: id, userId: id, user: { name: id, email: `${id}@test` } }
+}
+
+function seedWorkspace(memberCount: number, pendingInvitations = 0) {
+ mockAuthOrganization({
+ id: "org-1",
+ name: "Acme Corp",
+ slug: "acme-corp",
+ members: Array.from({ length: memberCount }, (_, i) => member(`m${i}`)),
+ invitations: Array.from({ length: pendingInvitations }, (_, i) => ({
+ id: `inv${i}`,
+ status: "pending",
+ email: `inv${i}@test`,
+ role: "member",
+ organizationId: "org-1",
+ })),
+ })
+}
+
+function mountAction() {
+ return mountWithFrozenClock(WorkspaceInvitationAction)
+}
+
+async function submitEmail(
+ wrapper: Awaited>,
+ email: string,
+) {
+ await wrapper.get("input").setValue(email)
+ await wrapper.get("form").trigger("submit")
+ await settleActionSubmit()
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares, and the submit flow is driven by the
+// global fake timers
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ seedAuthSession({ id: "u1", email: "ada@oxynote.test", name: "Ada" })
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("invites the user to send an invitation", async ({ expect }) => {
+ seedWorkspace(1)
+
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).toContain("Send an invitation")
+ })
+
+ it("sends the invitation as an owner", async ({ expect }) => {
+ seedWorkspace(1)
+ const calls = mockAuthEndpoint("organization/invite-member", () => ({
+ id: "inv-1",
+ }))
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "new@oxynote.test")
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({
+ email: "new@oxynote.test",
+ role: "owner",
+ resend: true,
+ })
+ })
+
+ it("confirms and closes once the invitation is sent", async ({ expect }) => {
+ seedWorkspace(1)
+ mockAuthEndpoint("organization/invite-member", () => ({ id: "inv-1" }))
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "new@oxynote.test")
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("closes without inviting when the address is the user's own", async ({
+ expect,
+ }) => {
+ seedWorkspace(1)
+ const calls = mockAuthEndpoint("organization/invite-member", () => ({
+ id: "inv-1",
+ }))
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "ada@oxynote.test")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("rejects an address that is not an email", async ({ expect }) => {
+ seedWorkspace(1)
+ const calls = mockAuthEndpoint("organization/invite-member", () => ({
+ id: "inv-1",
+ }))
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "not-an-email")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("shows the server's rejection next to the field", async ({ expect }) => {
+ seedWorkspace(1)
+ mockAuthEndpoint("organization/invite-member", (_call, event) => {
+ setResponseStatus(event, 400)
+
+ return { code: "ALREADY_MEMBER", message: "Already a member" }
+ })
+ const wrapper = await mountAction()
+
+ await submitEmail(wrapper, "new@oxynote.test")
+
+ expect(wrapper.text()).toContain("Already a member")
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("counts pending invitations towards the member limit", async ({
+ expect,
+ }) => {
+ seedWorkspace(3, 2)
+
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).toContain("maximum number of members")
+ })
+
+ it("hides the email field once the workspace is full", async ({ expect }) => {
+ seedWorkspace(5)
+
+ const wrapper = await mountAction()
+
+ expect(wrapper.find("input").exists()).toBe(false)
+ expect(findButtonByText(wrapper, "Close").exists()).toBe(true)
+ })
+
+ it("keeps inviting available while there is room left", async ({
+ expect,
+ }) => {
+ seedWorkspace(4)
+
+ const wrapper = await mountAction()
+
+ expect(wrapper.find("input").exists()).toBe(true)
+ expect(wrapper.text()).not.toContain("maximum number of members")
+ })
+
+ it("keeps inviting available while the organization is unknown", async ({
+ expect,
+ }) => {
+ const wrapper = await mountAction()
+
+ expect(wrapper.find("input").exists()).toBe(true)
+ })
+
+ it("closes without inviting anyone when cancelled", async ({ expect }) => {
+ seedWorkspace(1)
+ const calls = mockAuthEndpoint("organization/invite-member", () => ({
+ id: "inv-1",
+ }))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Cancel").trigger("click")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/settings/WorkspaceMemberRemovalAction.nuxt.test.ts b/web/app/components/settings/WorkspaceMemberRemovalAction.nuxt.test.ts
new file mode 100644
index 00000000..058c06ee
--- /dev/null
+++ b/web/app/components/settings/WorkspaceMemberRemovalAction.nuxt.test.ts
@@ -0,0 +1,157 @@
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+} from "~/composables/api/test-helpers"
+import WorkspaceMemberRemovalAction from "./WorkspaceMemberRemovalAction.vue"
+import type { OrganizationMember } from "./workspace"
+import {
+ findButtonByText,
+ mockAuthEndpoint,
+ mountWithFrozenClock,
+ mockAuthOrganization,
+ settleActionSubmit,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+function makeMember(overrides: Partial = {}) {
+ return {
+ id: "member-1",
+ organizationId: "org-1",
+ userId: "user-1",
+ role: "member",
+ user: { name: "Ada", email: "ada@oxynote.test" },
+ ...overrides,
+ }
+}
+
+function mountAction(member: OrganizationMember) {
+ return mountWithFrozenClock(WorkspaceMemberRemovalAction, {
+ props: { member: member },
+ })
+}
+
+async function confirmRemoval(
+ wrapper: Awaited>,
+) {
+ await findButtonByText(wrapper, "Remove Member").trigger("click")
+ await settleActionSubmit()
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares, and the removal is driven by the global
+// fake timers
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ mockAuthOrganization({ id: "org-1", members: [], invitations: [] })
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("names the member it is about to remove", async ({ expect }) => {
+ const wrapper = await mountAction(makeMember())
+
+ expect(wrapper.text()).toContain("Ada")
+ })
+
+ it("removes a joined member", async ({ expect }) => {
+ const calls = mockAuthEndpoint("organization/remove-member", () => ({
+ member: { id: "member-1" },
+ }))
+ const wrapper = await mountAction(makeMember())
+
+ await confirmRemoval(wrapper)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({ memberIdOrEmail: "member-1" })
+ })
+
+ it("cancels a pending invitation instead of removing a member", async ({
+ expect,
+ }) => {
+ const calls = mockAuthEndpoint("organization/cancel-invitation", () => ({
+ id: "member-1",
+ }))
+ const wrapper = await mountAction(makeMember({ invitationPending: true }))
+
+ await confirmRemoval(wrapper)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.body).toEqual({ invitationId: "member-1" })
+ })
+
+ it("confirms and closes once the member is gone", async ({ expect }) => {
+ mockAuthEndpoint("organization/remove-member", () => ({
+ member: { id: "member-1" },
+ }))
+ const wrapper = await mountAction(makeMember())
+
+ await confirmRemoval(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("warns and closes when removing the member fails", async ({ expect }) => {
+ mockAuthEndpoint("organization/remove-member", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountAction(makeMember())
+
+ await confirmRemoval(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("warns and closes when cancelling the invitation fails", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("organization/cancel-invitation", () => {
+ throw new Error("boom")
+ })
+ const wrapper = await mountAction(makeMember({ invitationPending: true }))
+
+ await confirmRemoval(wrapper)
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("shows a spinner and disables both buttons while removing", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("organization/remove-member", () => ({
+ member: { id: "member-1" },
+ }))
+ const wrapper = await mountAction(makeMember())
+
+ await findButtonByText(wrapper, "Remove Member").trigger("click")
+ await nextTick()
+
+ expect(
+ findButtonByText(wrapper, "Remove Member").attributes("disabled"),
+ ).toBeDefined()
+ expect(
+ findButtonByText(wrapper, "Cancel").attributes("disabled"),
+ ).toBeDefined()
+ })
+
+ it("closes without removing anyone when cancelled", async ({ expect }) => {
+ const calls = mockAuthEndpoint("organization/remove-member", () => ({
+ member: { id: "member-1" },
+ }))
+ const wrapper = await mountAction(makeMember())
+
+ await findButtonByText(wrapper, "Cancel").trigger("click")
+
+ expect(calls).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/settings/WorkspaceSection.nuxt.test.ts b/web/app/components/settings/WorkspaceSection.nuxt.test.ts
new file mode 100644
index 00000000..3f2647f7
--- /dev/null
+++ b/web/app/components/settings/WorkspaceSection.nuxt.test.ts
@@ -0,0 +1,333 @@
+import { setResponseHeader } from "h3"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+ mockEndpoint,
+} from "~/composables/api/test-helpers"
+import WorkspaceSection from "./WorkspaceSection.vue"
+import {
+ findButtonByText,
+ menuItem,
+ mockAuthEndpoint,
+ mockAuthOrganization,
+ mountUnderTooltipProvider,
+ seedAuthSession,
+ settleMutations,
+ WAIT_FOR_OPTIONS,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+function member(id: string, name: string) {
+ return {
+ id: `member-${id}`,
+ organizationId: "org-1",
+ userId: id,
+ role: "member",
+ createdAt: "2026-01-01T00:00:00Z",
+ user: { name: name, email: `${name}@oxynote.test`, image: "" },
+ }
+}
+
+function seedWorkspace(
+ overrides: { members?: unknown[]; invitations?: unknown[] } = {},
+) {
+ mockAuthOrganization({
+ id: "org-1",
+ name: "Acme",
+ slug: "acme-corp",
+ logo: "",
+ members: overrides.members ?? [member("u1", "ada")],
+ invitations: overrides.invitations ?? [],
+ })
+}
+
+async function mountSection() {
+ const wrapper = await mountUnderTooltipProvider(WorkspaceSection, {})
+
+ return { wrapper, section: wrapper.findComponent(WorkspaceSection) }
+}
+
+function nameInput(
+ wrapper: Awaited>["wrapper"],
+) {
+ return wrapper.get("input[type='text']")
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares, and the member menus are teleported into
+// the shared
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ seedAuthSession({ id: "u1", email: "ada@oxynote.test", name: "ada" })
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("starts the name field from the workspace name", async ({ expect }) => {
+ seedWorkspace()
+
+ const { wrapper } = await mountSection()
+
+ expect(nameInput(wrapper).element.value).toBe("Acme")
+ })
+
+ it("shows the workspace url", async ({ expect }) => {
+ seedWorkspace()
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).toContain("oxynote.io/")
+ expect(wrapper.text()).toContain("acme-corp")
+ })
+
+ it("asks to change the url when its pencil is pressed", async ({
+ expect,
+ }) => {
+ seedWorkspace()
+ const { wrapper, section } = await mountSection()
+
+ await findButtonByText(wrapper, "Change Workspace URL").trigger("click")
+
+ expect(section.emitted("url-change")).toHaveLength(1)
+ })
+
+ it("asks to invite someone when the invite button is pressed", async ({
+ expect,
+ }) => {
+ seedWorkspace()
+ const { wrapper, section } = await mountSection()
+
+ await findButtonByText(wrapper, "Invite").trigger("click")
+
+ expect(section.emitted("invitation")).toHaveLength(1)
+ })
+
+ it("saves a changed workspace name on blur", async ({ expect }) => {
+ seedWorkspace()
+ const calls = mockAuthEndpoint("organization/update", () => ({
+ id: "org-1",
+ }))
+ const { wrapper } = await mountSection()
+
+ await nameInput(wrapper).setValue("Acme-EU")
+ await nameInput(wrapper).trigger("blur")
+
+ // the save chains vee-validate's validation, the update request and
+ // a refetch, none of which the component signals the end of
+ await vi.waitFor(() => {
+ expect(calls).toHaveLength(1)
+ }, WAIT_FOR_OPTIONS)
+ expect(calls[0]?.body).toEqual({
+ data: { name: "Acme-EU" },
+ organizationId: "org-1",
+ })
+ })
+
+ it("saves nothing when the name is unchanged", async ({ expect }) => {
+ seedWorkspace()
+ const calls = mockAuthEndpoint("organization/update", () => ({
+ id: "org-1",
+ }))
+ const { wrapper } = await mountSection()
+
+ await nameInput(wrapper).trigger("blur")
+ await settleMutations()
+
+ expect(calls).toHaveLength(0)
+ })
+
+ it("rejects a name with characters outside the allowed set", async ({
+ expect,
+ }) => {
+ seedWorkspace()
+ const calls = mockAuthEndpoint("organization/update", () => ({
+ id: "org-1",
+ }))
+ const { wrapper } = await mountSection()
+
+ await nameInput(wrapper).setValue("Acme Corp!")
+ await nameInput(wrapper).trigger("blur")
+
+ await vi.waitFor(() => {
+ expect(wrapper.text()).toContain("Only letters, numbers, hyphens")
+ }, WAIT_FOR_OPTIONS)
+ expect(calls).toHaveLength(0)
+ })
+
+ it("warns when the name cannot be saved", async ({ expect }) => {
+ seedWorkspace()
+ mockAuthEndpoint("organization/update", () => {
+ throw new Error("boom")
+ })
+ const { wrapper } = await mountSection()
+
+ await nameInput(wrapper).setValue("Acme-EU")
+ await nameInput(wrapper).trigger("blur")
+
+ await vi.waitFor(() => {
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ }, WAIT_FOR_OPTIONS)
+ })
+
+ it("uploads a picked logo and saves the returned url", async ({ expect }) => {
+ seedWorkspace()
+ mockEndpoint("PUT", "/api/organizations/logo", (_call, event) => {
+ // the composable reads the stored url off the location header
+ setResponseHeader(event, "location", "https://cdn.test/logo.png")
+
+ return null
+ })
+ const updates = mockAuthEndpoint("organization/update", () => ({
+ id: "org-1",
+ }))
+ const { wrapper } = await mountSection()
+
+ await pickLogo(wrapper)
+
+ await vi.waitFor(() => {
+ expect(updates).toHaveLength(1)
+ }, WAIT_FOR_OPTIONS)
+ expect(updates[0]?.body).toEqual({
+ data: { logo: "https://cdn.test/logo.png" },
+ organizationId: "org-1",
+ })
+ // the confirmation lands after the refetch that follows the update;
+ // leaving it in flight would spill into the next test
+ await vi.waitFor(() => {
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ }, WAIT_FOR_OPTIONS)
+ })
+
+ it("warns when the logo upload fails", async ({ expect }) => {
+ seedWorkspace()
+ mockEndpoint("PUT", "/api/organizations/logo", () => {
+ throw new Error("boom")
+ })
+ const { wrapper } = await mountSection()
+
+ await pickLogo(wrapper)
+
+ await vi.waitFor(() => {
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ }, WAIT_FOR_OPTIONS)
+ })
+
+ it("does nothing when the file dialog is dismissed", async ({ expect }) => {
+ seedWorkspace()
+ const calls = mockEndpoint("PUT", "/api/organizations/logo", () => null)
+ const { wrapper } = await mountSection()
+
+ await wrapper.get("input[type='file']").trigger("change")
+ await settleMutations()
+
+ expect(calls).toHaveLength(0)
+ })
+
+ describe("member list", { concurrent: false }, () => {
+ it("lists the joined members", async ({ expect }) => {
+ seedWorkspace({ members: [member("u1", "ada"), member("u2", "linus")] })
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).toContain("ada")
+ expect(wrapper.text()).toContain("linus@oxynote.test")
+ expect(wrapper.text()).toContain("Members (2/5)")
+ })
+
+ it("shows when a member joined", async ({ expect }) => {
+ seedWorkspace()
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).toContain("Joined")
+ expect(wrapper.text()).toContain("Jan 1, 2026")
+ })
+
+ it("lists a pending invitation as an invited member", async ({
+ expect,
+ }) => {
+ seedWorkspace({
+ invitations: [
+ {
+ id: "inv-1",
+ organizationId: "org-1",
+ status: "pending",
+ email: "grace@oxynote.test",
+ role: "member",
+ },
+ ],
+ })
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).toContain("grace@oxynote.test")
+ expect(wrapper.text()).toContain("Invited")
+ expect(wrapper.text()).toContain("Members (2/5)")
+ })
+
+ it("leaves an accepted invitation out of the list", async ({ expect }) => {
+ seedWorkspace({
+ invitations: [
+ {
+ id: "inv-1",
+ organizationId: "org-1",
+ status: "accepted",
+ email: "grace@oxynote.test",
+ role: "member",
+ },
+ ],
+ })
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.text()).not.toContain("grace@oxynote.test")
+ })
+
+ it("offers no options menu on the signed-in user's own row", async ({
+ expect,
+ }) => {
+ seedWorkspace()
+
+ const { wrapper } = await mountSection()
+
+ expect(wrapper.find("[data-slot='dropdown-menu-trigger']").exists()).toBe(
+ false,
+ )
+ })
+
+ it("asks to remove the member picked from their options menu", async ({
+ expect,
+ }) => {
+ seedWorkspace({ members: [member("u1", "ada"), member("u2", "linus")] })
+ const { wrapper, section } = await mountSection()
+ await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
+
+ menuItem("Remove").click()
+ await nextTick()
+
+ expect(section.emitted("member-removal")?.[0]?.[0]).toMatchObject({
+ id: "member-u2",
+ })
+ })
+ })
+})
+
+// happy-dom does not let a test click through a real file dialog, so the
+// picked file is put on the input directly before its change handler runs
+async function pickLogo(
+ wrapper: Awaited>["wrapper"],
+) {
+ const input = wrapper.get("input[type='file']")
+ const file = new File(["logo"], "logo.png", { type: "image/png" })
+ Object.defineProperty(input.element, "files", { value: [file] })
+
+ await input.trigger("change")
+ await settleMutations()
+}
diff --git a/web/app/components/settings/WorkspaceURLChangeAction.nuxt.test.ts b/web/app/components/settings/WorkspaceURLChangeAction.nuxt.test.ts
new file mode 100644
index 00000000..f6cd8f7c
--- /dev/null
+++ b/web/app/components/settings/WorkspaceURLChangeAction.nuxt.test.ts
@@ -0,0 +1,205 @@
+import { setResponseStatus } from "h3"
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import {
+ clearQueryCache,
+ disposeMockEndpoints,
+} from "~/composables/api/test-helpers"
+import WorkspaceURLChangeAction from "./WorkspaceURLChangeAction.vue"
+import {
+ findButtonByText,
+ mockAuthEndpoint,
+ mockAuthOrganization,
+ mountWithFrozenClock,
+ settleActionSubmit,
+} from "../test-helpers"
+
+vi.mock("vue-sonner", () => ({
+ toast: { custom: vi.fn(), dismiss: vi.fn() },
+}))
+
+function mountAction() {
+ return mountWithFrozenClock(WorkspaceURLChangeAction)
+}
+
+async function submitUrl(
+ wrapper: Awaited>,
+ url: string,
+) {
+ await wrapper.get("input").setValue(url)
+ await wrapper.get("form").trigger("submit")
+ await settleActionSubmit()
+}
+
+// the query cache and the vue-sonner module mock are app-wide singletons
+// every mount in the file shares, and the submit flow is driven by the
+// global fake timers
+describe("", { concurrent: false }, () => {
+ beforeEach(() => {
+ clearQueryCache()
+ vi.mocked(toast.custom).mockReset()
+ mockAuthOrganization({
+ id: "org-1",
+ name: "Acme Corp",
+ slug: "acme-corp",
+ members: [],
+ invitations: [],
+ })
+ })
+
+ afterEach(disposeMockEndpoints)
+
+ it("explains what changing the url does", async ({ expect }) => {
+ const wrapper = await mountAction()
+
+ expect(wrapper.text()).toContain("replace all your workspace URLs")
+ })
+
+ it("suggests the current slug as the placeholder", async ({ expect }) => {
+ const wrapper = await mountAction()
+
+ expect(wrapper.get("input").attributes("placeholder")).toBe("acme-corp")
+ })
+
+ it("checks the new slug before claiming it", async ({ expect }) => {
+ const checks = mockAuthEndpoint("organization/check-slug", () => ({
+ status: true,
+ }))
+ mockAuthEndpoint("organization/update", () => ({ id: "org-1" }))
+ const wrapper = await mountAction()
+
+ await submitUrl(wrapper, "acme-eu")
+
+ expect(checks).toHaveLength(1)
+ expect(checks[0]?.body).toEqual({ slug: "acme-eu" })
+ })
+
+ it("claims the new slug for the workspace", async ({ expect }) => {
+ mockAuthEndpoint("organization/check-slug", () => ({ status: true }))
+ const updates = mockAuthEndpoint("organization/update", () => ({
+ id: "org-1",
+ }))
+ const wrapper = await mountAction()
+
+ await submitUrl(wrapper, "acme-eu")
+
+ expect(updates).toHaveLength(1)
+ expect(updates[0]?.body).toEqual({
+ data: { slug: "acme-eu" },
+ organizationId: "org-1",
+ })
+ })
+
+ it("confirms the change and asks the page to refresh its slug", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("organization/check-slug", () => ({ status: true }))
+ mockAuthEndpoint("organization/update", () => ({ id: "org-1" }))
+ const wrapper = await mountAction()
+
+ await submitUrl(wrapper, "acme-eu")
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ expect(wrapper.emitted("refresh-organization-slug")).toHaveLength(1)
+ })
+
+ it("closes without asking the server when the url is unchanged", async ({
+ expect,
+ }) => {
+ const checks = mockAuthEndpoint("organization/check-slug", () => ({
+ status: true,
+ }))
+ const wrapper = await mountAction()
+
+ await submitUrl(wrapper, "acme-corp")
+
+ expect(checks).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+
+ it("rejects a url with characters outside the allowed set", async ({
+ expect,
+ }) => {
+ const checks = mockAuthEndpoint("organization/check-slug", () => ({
+ status: true,
+ }))
+ const wrapper = await mountAction()
+
+ await submitUrl(wrapper, "acme corp!")
+
+ expect(checks).toHaveLength(0)
+ expect(wrapper.text()).toContain("Only letters, numbers, hyphens")
+ })
+
+ it("rejects a url shorter than two characters", async ({ expect }) => {
+ const checks = mockAuthEndpoint("organization/check-slug", () => ({
+ status: true,
+ }))
+ const wrapper = await mountAction()
+
+ await submitUrl(wrapper, "a")
+
+ expect(checks).toHaveLength(0)
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("reports a slug that someone else already holds", async ({ expect }) => {
+ mockAuthEndpoint("organization/check-slug", (_call, event) => {
+ setResponseStatus(event, 400)
+
+ return { code: "SLUG_TAKEN", message: "taken" }
+ })
+ const wrapper = await mountAction()
+
+ await submitUrl(wrapper, "acme-eu")
+
+ expect(wrapper.text()).toContain("already taken")
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("reports a slug the server refuses for any other reason", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("organization/check-slug", (_call, event) => {
+ setResponseStatus(event, 500)
+
+ return { code: "BOOM", message: "boom" }
+ })
+ const wrapper = await mountAction()
+
+ await submitUrl(wrapper, "acme-eu")
+
+ expect(wrapper.text()).toContain("is invalid")
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("shows the server's rejection of the update next to the field", async ({
+ expect,
+ }) => {
+ mockAuthEndpoint("organization/check-slug", () => ({ status: true }))
+ mockAuthEndpoint("organization/update", (_call, event) => {
+ setResponseStatus(event, 403)
+
+ return { code: "FORBIDDEN", message: "Not allowed" }
+ })
+ const wrapper = await mountAction()
+
+ await submitUrl(wrapper, "acme-eu")
+
+ expect(wrapper.text()).toContain("Not allowed")
+ expect(wrapper.emitted("close")).toBeUndefined()
+ })
+
+ it("closes without changing anything when cancelled", async ({ expect }) => {
+ const checks = mockAuthEndpoint("organization/check-slug", () => ({
+ status: true,
+ }))
+ const wrapper = await mountAction()
+
+ await findButtonByText(wrapper, "Cancel").trigger("click")
+
+ expect(checks).toHaveLength(0)
+ expect(wrapper.emitted("close")).toHaveLength(1)
+ })
+})
diff --git a/web/app/components/sidebar.test.ts b/web/app/components/sidebar.test.ts
new file mode 100644
index 00000000..62e2b49a
--- /dev/null
+++ b/web/app/components/sidebar.test.ts
@@ -0,0 +1,216 @@
+import { describe, it } from "vitest"
+import {
+ SIDEBAR_ITEM_PLACEHOLDER_ID,
+ documentTreeBreadcrumbs,
+ extractDocumentTreeElement,
+ processDocumentTree,
+} from "./sidebar"
+
+// isXid() only checks for a 20-character length, and
+// createNameSlugWithId() rejects anything shorter
+const ID_A = "a".padEnd(20, "0")
+const ID_B = "b".padEnd(20, "0")
+const ID_C = "c".padEnd(20, "0")
+
+function treeElement(
+ id: string,
+ documentName: string,
+ children?: DocumentTreeElement[] | null,
+): DocumentTreeElement {
+ return {
+ id: id,
+ documentName: documentName,
+ icon: "lucide:file",
+ protected: false,
+ children: children,
+ }
+}
+
+describe("processDocumentTree", () => {
+ it("maps a flat tree onto sidebar items linking into the organization", ({
+ expect,
+ }) => {
+ const items = processDocumentTree(
+ [treeElement(ID_A, "Runbook", [])],
+ null,
+ "placeholder",
+ "Acme Corp",
+ )
+
+ expect(items).toEqual([
+ {
+ id: ID_A,
+ name: "Runbook",
+ icon: "lucide:file",
+ partOfDocumentTree: true,
+ active: false,
+ draggable: true,
+ url: `/Acme-Corp/Runbook-${ID_A}`,
+ prefetchUrlOnInteraction: true,
+ localOptimisticInsert: undefined,
+ children: [
+ expect.objectContaining({ id: SIDEBAR_ITEM_PLACEHOLDER_ID }),
+ ],
+ },
+ ])
+ })
+
+ it("marks the item whose id matches the active document", ({ expect }) => {
+ const items = processDocumentTree(
+ [treeElement(ID_A, "First", []), treeElement(ID_B, "Second", [])],
+ ID_B,
+ "placeholder",
+ "Acme",
+ )
+
+ expect(items.map((item) => item.active)).toEqual([false, true])
+ })
+
+ it("points an optimistically inserted item at '#' instead of its slug", ({
+ expect,
+ }) => {
+ const [item] = processDocumentTree(
+ [{ ...treeElement(ID_A, "Draft", []), localOptimisticInsert: true }],
+ null,
+ "placeholder",
+ "Acme",
+ )
+
+ expect(item?.url).toBe("#")
+ expect(item?.localOptimisticInsert).toBe(true)
+ })
+
+ it("recurses into children", ({ expect }) => {
+ const items = processDocumentTree(
+ [treeElement(ID_A, "Parent", [treeElement(ID_B, "Child", [])])],
+ null,
+ "placeholder",
+ "Acme",
+ )
+
+ expect(items[0]?.children).toEqual([
+ expect.objectContaining({ id: ID_B, name: "Child" }),
+ ])
+ })
+
+ it("gives an item with no children a single placeholder child", ({
+ expect,
+ }) => {
+ const items = processDocumentTree(
+ [treeElement(ID_A, "Leaf", null)],
+ null,
+ "Add a page",
+ "Acme",
+ )
+
+ expect(items[0]?.children).toEqual([
+ {
+ id: SIDEBAR_ITEM_PLACEHOLDER_ID,
+ name: "Add a page",
+ url: null,
+ partOfDocumentTree: true,
+ icon: null,
+ active: false,
+ draggable: false,
+ children: [],
+ },
+ ])
+ })
+
+ it("gives an item with an empty child list a placeholder child too", ({
+ expect,
+ }) => {
+ const items = processDocumentTree(
+ [treeElement(ID_A, "Leaf", [])],
+ null,
+ "Add a page",
+ "Acme",
+ )
+
+ expect(items[0]?.children).toEqual([
+ expect.objectContaining({ id: SIDEBAR_ITEM_PLACEHOLDER_ID }),
+ ])
+ })
+
+ it("returns a lone placeholder for an empty tree", ({ expect }) => {
+ const items = processDocumentTree([], null, "Add a page", "Acme")
+
+ expect(items).toEqual([
+ expect.objectContaining({
+ id: SIDEBAR_ITEM_PLACEHOLDER_ID,
+ name: "Add a page",
+ }),
+ ])
+ })
+})
+
+describe("extractDocumentTreeElement", () => {
+ it("returns the matching top-level element", ({ expect }) => {
+ const target = treeElement(ID_B, "Second", [])
+
+ expect(
+ extractDocumentTreeElement(
+ [treeElement(ID_A, "First", []), target],
+ ID_B,
+ ),
+ ).toBe(target)
+ })
+
+ it("finds an element nested several levels deep", ({ expect }) => {
+ const target = treeElement(ID_C, "Deep", [])
+ const tree = [
+ treeElement(ID_A, "Root", [treeElement(ID_B, "Middle", [target])]),
+ ]
+
+ expect(extractDocumentTreeElement(tree, ID_C)).toBe(target)
+ })
+
+ it("returns null when no element matches", ({ expect }) => {
+ expect(
+ extractDocumentTreeElement([treeElement(ID_A, "First", [])], ID_C),
+ ).toBeNull()
+ })
+
+ it("returns null for an empty tree", ({ expect }) => {
+ expect(extractDocumentTreeElement([], ID_A)).toBeNull()
+ })
+})
+
+describe("documentTreeBreadcrumbs", () => {
+ it("returns a single crumb when the target is at the root", ({ expect }) => {
+ expect(
+ documentTreeBreadcrumbs([treeElement(ID_A, "Runbook", [])], ID_A),
+ ).toEqual([
+ {
+ id: ID_A,
+ name: "Runbook",
+ icon: "lucide:file",
+ href: `/Runbook-${ID_A}`,
+ },
+ ])
+ })
+
+ it("returns the path from the root down to the target", ({ expect }) => {
+ const tree = [
+ treeElement(ID_A, "Root", [
+ treeElement(ID_B, "Middle", [treeElement(ID_C, "Leaf", [])]),
+ ]),
+ ]
+
+ expect(documentTreeBreadcrumbs(tree, ID_C).map((c) => c.name)).toEqual([
+ "Root",
+ "Middle",
+ "Leaf",
+ ])
+ })
+
+ it("returns an empty array when the target is absent", ({ expect }) => {
+ expect(
+ documentTreeBreadcrumbs([treeElement(ID_A, "Root", [])], ID_C),
+ ).toEqual([])
+ })
+
+ it("returns an empty array for an empty tree", ({ expect }) => {
+ expect(documentTreeBreadcrumbs([], ID_A)).toEqual([])
+ })
+})
diff --git a/web/app/components/test-helpers.ts b/web/app/components/test-helpers.ts
new file mode 100644
index 00000000..d4f8889d
--- /dev/null
+++ b/web/app/components/test-helpers.ts
@@ -0,0 +1,287 @@
+// shared helpers for the component test suites in this directory and its
+// subdirectories. Test-only: nothing here is imported by app code, and the
+// app/**/test-helpers.ts coverage exclude keeps it out of the denominator.
+import { mountSuspended } from "@nuxt/test-utils/runtime"
+import { flushPromises, type VueWrapper } from "@vue/test-utils"
+import { vi } from "vitest"
+import type { H3Event } from "h3"
+import {
+ mockEndpoint,
+ seedQueryData,
+ type RecordedCall,
+} from "~/composables/api/test-helpers"
+import { SidebarProvider } from "./shadcn/ui/sidebar"
+import { TooltipProvider } from "./shadcn/ui/tooltip"
+
+// eslint's ts program resolves .vue imports as error typed, so a component
+// handed to one of these helpers looks unsafe to it while vue-tsc types it
+// fine. Taking it as `any` keeps that false positive to the single disable
+// below instead of one at every call site.
+type TestComponent = any
+
+// @nuxt/icon's css mode renders every icon as ,
+// so the rendered icon set is the only observable trace of which icon a
+// component picked
+export function renderedIconNames(wrapper: VueWrapper): string[] {
+ return wrapper.findAll(".iconify").flatMap((icon) => {
+ const cls = icon.classes().find((c) => c.startsWith("i-"))
+
+ return cls ? [cls.slice("i-".length)] : []
+ })
+}
+
+// finds a button by the text it renders — including its sr-only label,
+// which is the only text most icon buttons have
+export function findButtonByText(wrapper: VueWrapper, text: string) {
+ const button = wrapper.findAll("button").find((b) => b.text().includes(text))
+ if (!button) {
+ throw new Error(`no button rendering "${text}"`)
+ }
+
+ return button
+}
+
+// useAuthSession reads the session, organization and account list through
+// pinia-colada cache entries. Seeding them before a mount is what puts a
+// component into a signed-in state without any request going out.
+export function seedAuthSession(user: Record | null) {
+ seedQueryData(["auth", "session"], {
+ data: user ? { session: { id: "session-1" }, user: user } : null,
+ error: null,
+ })
+}
+
+export function seedAuthOrganization(
+ organization: Record | null,
+) {
+ seedQueryData(["auth", "organization"], {
+ data: organization,
+ error: null,
+ })
+}
+
+export function seedAuthAccounts(providerIds: string[]) {
+ seedQueryData(["auth", "accounts"], {
+ data: providerIds.map((providerId) => ({ providerId: providerId })),
+ error: null,
+ })
+}
+
+// reka-ui tooltips inject a provider context that the app installs once,
+// at page level (app/pages/[[organizationSlug]]/[[documentSlug]].vue), so
+// a component rendering a tooltip needs one around it to mount at all
+export function mountUnderTooltipProvider(
+ component: TestComponent,
+ options: { props?: Record; slots?: Record },
+) {
+ return mountSuspended(TooltipProvider, {
+ slots: {
+ default: () => h(component, options.props, options.slots),
+ },
+ })
+}
+
+// useSidebar/useSidebarWidth inject a context the app installs once, at
+// page level — a component reading either needs a provider around it to
+// mount at all. SidebarProvider also installs the tooltip context.
+export function mountUnderSidebarProvider(
+ component: TestComponent,
+ options: {
+ props?: Record
+ slots?: Record
+ stubs?: Record
+ } = {},
+) {
+ return mountSuspended(SidebarProvider, {
+ slots: {
+ default: () => h(component, options.props, options.slots),
+ },
+ global: { stubs: options.stubs },
+ })
+}
+
+// unmounting a wrapper does not always take the overlay bodies reka-ui
+// teleported into with it — a pending presence transition outlives
+// it — and their generated ids repeat across mounts, so a stale node would
+// answer the next lookup. Suites driving overlays clear them per test.
+export function clearTeleportedOverlays() {
+ Array.from(document.body.children).forEach((child) => {
+ if (child.id !== "__nuxt" && child.id !== "teleports") {
+ child.remove()
+ }
+ })
+}
+
+// useMediaQuery reads matchMedia, which happy-dom does not implement — the
+// composable then reports "not matching" for every query, so any layout
+// that switches on viewport width needs this to pick a side deliberately
+export function stubViewportMatches(matches: boolean) {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn((query: string) => ({
+ matches: matches,
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ )
+}
+
+// noUncheckedIndexedAccess types every index access as possibly undefined
+// and the non-null assertion is banned, so an out-of-range index says so
+// instead of failing later on an unrelated line
+export function at(items: T[], index: number): T {
+ const item = items[index]
+ if (item === undefined) {
+ throw new Error(`no element at index ${index}`)
+ }
+
+ return item
+}
+
+// dropdown, context and sub menus are teleported into ; every
+// actionable row in one carries a menuitem role
+export function menuItem(text: string): HTMLElement {
+ const item = Array.from(
+ document.body.querySelectorAll("[role^='menuitem']"),
+ ).find((el) => el.textContent.includes(text))
+ if (!item) {
+ throw new Error(`no menu item rendering "${text}"`)
+ }
+
+ return item
+}
+
+// dialog and sheet bodies are teleported into too, so their buttons
+// cannot be reached through the wrapper
+export function teleportedButton(text: string): HTMLButtonElement {
+ const button = Array.from(
+ document.body.querySelectorAll("button"),
+ ).find((el) => el.textContent.includes(text))
+ if (!button) {
+ throw new Error(`no teleported button rendering "${text}"`)
+ }
+
+ return button
+}
+
+// reka-ui teleports an open tooltip body out of the wrapper into
+// and points the trigger's aria-describedby at it. Resolving through that
+// id keeps the assertion on this mount's tooltip rather than a leftover
+// node from an earlier one, and yields "" while the tooltip is closed.
+export function openTooltipText(wrapper: VueWrapper): string {
+ const id = wrapper
+ .get("[data-slot='tooltip-trigger']")
+ .attributes("aria-describedby")
+ if (!id) {
+ return ""
+ }
+
+ return document.getElementById(id)?.textContent ?? ""
+}
+
+// the settings action modals hold their submit for delay(300) so the
+// spinner is visible; driving that needs fake timers, but installing them
+// before the mount leaves the nuxt app's own async setup frozen — so they
+// go in once the component is up
+export async function mountWithFrozenClock(
+ component: TestComponent,
+ options: { props?: Record } = {},
+) {
+ const wrapper = await mountSuspended(component, { props: options.props })
+ vi.useFakeTimers()
+
+ return wrapper
+}
+
+// walks a submitted action all the way through: vee-validate's async
+// validation, the delay(300) spinner hold, the request, and the state
+// updates that follow — each needs its own turn of the frozen clock
+export async function settleActionSubmit() {
+ for (let round = 0; round < 2; round++) {
+ await settleMutations()
+ await vi.advanceTimersByTimeAsync(300)
+ }
+
+ await settleMutations()
+}
+
+// emits an event as if the named (or imported) child component had. eslint's
+// ts program cannot type a component found inside a wrapper, so every $emit
+// through one is reported unsafe; vue-tsc types them fine — funnelling them
+// through here keeps that to a single disable.
+export function emitFrom(
+ wrapper: VueWrapper,
+ target: TestComponent,
+ event: string,
+ ...args: unknown[]
+) {
+ emitFromNth(wrapper, target, 0, event, ...args)
+}
+
+// same, for the nth match — a component that renders itself recursively
+// has several instances of the same kind mounted at once
+export function emitFromNth(
+ wrapper: VueWrapper,
+ target: TestComponent,
+ index: number,
+ event: string,
+ ...args: unknown[]
+) {
+ const found = wrapper.findAllComponents(
+ typeof target === "string" ? { name: target } : target,
+ )[index]
+ if (!found) {
+ throw new Error(`no such component at index ${index}`)
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- eslint's ts program resolves .vue imports as error typed, vue-tsc accepts this
+ found.vm.$emit(event, ...args)
+}
+
+// vi.waitFor's one-second default is tight once the whole suite competes
+// for the same cores, and a timeout there reads as a real failure
+export const WAIT_FOR_OPTIONS = { timeout: 15_000 }
+
+// a pinia-colada mutation that invalidates queries on success only settles
+// after its own request, the invalidated queries' refetches, and the vue
+// updates in between — several macrotasks deep, so one flush is not enough
+export async function settleMutations() {
+ await flushPromises()
+ await flushPromises()
+ await flushPromises()
+}
+
+// a better-auth call needs both spellings of its url registered: the
+// absolute one is what keeps the request off the real network, and the
+// bare path is what the test-time h3 app then matches a handler on (the
+// origin is stripped on the way in). See vitest.nuxt-setup.ts for the
+// long version. The path entry is the one that actually answers, so its
+// recorded calls are what comes back.
+export function mockAuthEndpoint(
+ endpoint: string,
+ respond: (call: RecordedCall, event: H3Event) => unknown,
+ method: "GET" | "POST" = "POST",
+): RecordedCall[] {
+ mockEndpoint(
+ method,
+ `http://test.local/auth-realtime/api/auth/${endpoint}`,
+ respond,
+ )
+
+ return mockEndpoint(method, `/auth-realtime/api/auth/${endpoint}`, respond)
+}
+
+// components that refetch the organization after a change bypass the
+// seeded cache entry and go back to better-auth for it
+export function mockAuthOrganization(organization: Record) {
+ seedAuthOrganization(organization)
+
+ return mockAuthEndpoint(
+ "organization/get-full-organization",
+ () => organization,
+ "GET",
+ )
+}
diff --git a/web/app/components/toast.nuxt.test.ts b/web/app/components/toast.nuxt.test.ts
new file mode 100644
index 00000000..8c15fb19
--- /dev/null
+++ b/web/app/components/toast.nuxt.test.ts
@@ -0,0 +1,75 @@
+import { beforeEach, describe, it, vi } from "vitest"
+import { toast } from "vue-sonner"
+import ToastMessage from "./ToastMessage.vue"
+import { showToastMessage } from "./toast"
+
+vi.mock("vue-sonner", () => ({
+ toast: {
+ custom: vi.fn(),
+ dismiss: vi.fn(),
+ },
+}))
+
+// renders the toast body the way vue-sonner does: showToastMessage hands
+// it a render function, so the vnode only exists once that is called
+function renderToastBody() {
+ const render = vi.mocked(toast.custom).mock.calls[0]?.[0] as
+ | (() => {
+ type: unknown
+ props: Record & { onClose: () => void }
+ })
+ | undefined
+ if (typeof render !== "function") {
+ throw new Error("toast.custom was not called with a render function")
+ }
+
+ return render()
+}
+
+// vue-sonner is a module mock, so its call counts are shared by the whole
+// file and cannot be isolated across interleaving tests
+describe("showToastMessage", { concurrent: false }, () => {
+ beforeEach(() => {
+ vi.mocked(toast.custom).mockReset()
+ vi.mocked(toast.dismiss).mockReset()
+ })
+
+ it("pushes exactly one custom toast", ({ expect }) => {
+ showToastMessage("success", "Saved")
+
+ expect(toast.custom).toHaveBeenCalledTimes(1)
+ expect(toast.dismiss).toHaveBeenCalledTimes(0)
+ })
+
+ it("renders a ToastMessage carrying the type, title and description", ({
+ expect,
+ }) => {
+ showToastMessage("error", "Failed", "Try again later")
+
+ const body = renderToastBody()
+
+ expect(body.type).toBe(ToastMessage)
+ expect(body.props).toMatchObject({
+ type: "error",
+ title: "Failed",
+ description: "Try again later",
+ })
+ })
+
+ it("leaves the description undefined when none is given", ({ expect }) => {
+ showToastMessage("info", "Heads up")
+
+ expect(renderToastBody().props.description).toBeUndefined()
+ })
+
+ it("dismisses its own toast when the rendered message asks to close", ({
+ expect,
+ }) => {
+ vi.mocked(toast.custom).mockReturnValue("toast-1")
+ showToastMessage("warning", "Careful")
+
+ renderToastBody().props.onClose()
+
+ expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith("toast-1")
+ })
+})
diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs
index 02ce11d6..fcdf6933 100644
--- a/web/eslint.config.mjs
+++ b/web/eslint.config.mjs
@@ -96,6 +96,7 @@ export default withNuxt([
"knip.ts",
"vite.electron.config.ts",
"vitest.config.ts",
+ "vitest.nuxt-setup.ts",
"electron/preload.d.ts",
],
languageOptions: {
diff --git a/web/vitest.config.ts b/web/vitest.config.ts
index 96c6b373..2ffa2206 100644
--- a/web/vitest.config.ts
+++ b/web/vitest.config.ts
@@ -1,7 +1,9 @@
import { fileURLToPath } from "node:url"
+import type { ModuleOptions as NuxtIconOptions } from "@nuxt/icon"
import { defineVitestProject } from "@nuxt/test-utils/config"
import { playwright } from "@vitest/browser-playwright"
import vue from "@vitejs/plugin-vue"
+import type { NuxtConfig } from "nuxt/schema"
import Unimport from "unimport/unplugin"
import {
configDefaults,
@@ -49,6 +51,39 @@ function appPlugins() {
]
}
+// nuxt config the test app is booted with. A module's options only become
+// known keys on NuxtConfig through the declarations nuxt generates into
+// .nuxt, and only the nuxt.config tsconfig project loads those — this file
+// sits outside it, so @nuxt/icon's key is spelled out here.
+const nuxtOverrides: NuxtConfig & {
+ icon: { provider: NuxtIconOptions["provider"] }
+} = {
+ // @nuxt/icon otherwise resolves any icon missing from the client bundle
+ // over the public iconify api — real network io on every component mount
+ // that renders one. "none" stubs its fetch out. Setting it through
+ // appConfig instead does not work: the module overwrites that key with
+ // its own resolved options.
+ icon: {
+ provider: "none",
+ },
+ // deterministic bases for anything url-building; mocking
+ // useRuntimeConfig instead would starve nuxt's own bootstrap, which
+ // consumes it too. The auth-realtime url must be absolute or the
+ // 02.auth plugin fails app init on every nuxt test file (better-auth
+ // rejects relative bases)
+ runtimeConfig: {
+ public: {
+ appBaseURL: "http://test.local",
+ authRealtimeAPIBaseHttpURL: "http://test.local/auth-realtime",
+ },
+ },
+}
+
+// vendored shadcn-vue components are regenerated by the shadcn CLI, so
+// they are out of scope for this suite: no test file under here is
+// collected by any project, and nothing here counts towards coverage
+const shadcnExclude = "app/components/shadcn/**"
+
// build-time constants injected by vite.electron.config.ts (electron)
// and nuxt.config.ts (app); tests get inert stand-ins
const appDefine = {
@@ -79,8 +114,7 @@ export default defineConfig({
reporter: ["text"],
include: ["app/**/*.{ts,vue}", "electron/**/*.ts"],
exclude: [
- // vendored, regenerated by the shadcn CLI
- "app/components/shadcn/**",
+ shadcnExclude,
"**/*.d.ts",
// test-only helper modules colocated with the suites they
// serve — test code, not app code
@@ -92,10 +126,10 @@ export default defineConfig({
// untestable branches stay visible in the report, marked with
// NOCOV comments in place
thresholds: {
- statements: 56,
- branches: 41,
- functions: 41,
- lines: 56,
+ statements: 64,
+ branches: 49.6,
+ functions: 52.5,
+ lines: 65.4,
},
},
@@ -116,6 +150,7 @@ export default defineConfig({
include: ["app/**/*.test.ts", "electron/**/*.test.ts"],
exclude: [
...configDefaults.exclude,
+ shadcnExclude,
"**/*.nuxt.test.ts",
"**/*.browser.test.ts",
],
@@ -153,6 +188,7 @@ export default defineConfig({
...sharedTestOptions,
name: "browser",
include: ["app/**/*.browser.test.ts"],
+ exclude: [...configDefaults.exclude, shadcnExclude],
browser: {
enabled: true,
provider: playwright(),
@@ -174,24 +210,16 @@ export default defineConfig({
// cold CI runners that routinely exceeds the 10s default
// and fails the suite before any test runs
hookTimeout: 60_000,
+ // component tests drive real request chains through the
+ // test-time h3 app; under the full suite's parallelism the
+ // slowest of them outgrows the 5s default
+ testTimeout: 20_000,
include: ["app/**/*.nuxt.test.ts"],
+ exclude: [...configDefaults.exclude, shadcnExclude],
+ setupFiles: ["./vitest.nuxt-setup.ts"],
environmentOptions: {
nuxt: {
- overrides: {
- // deterministic bases for anything url-building;
- // mocking useRuntimeConfig instead would starve
- // nuxt's own bootstrap, which consumes it too.
- // The auth-realtime url must be absolute or the
- // 02.auth plugin fails app init on every nuxt
- // test file (better-auth rejects relative bases)
- runtimeConfig: {
- public: {
- appBaseURL: "http://test.local",
- authRealtimeAPIBaseHttpURL:
- "http://test.local/auth-realtime",
- },
- },
- },
+ overrides: nuxtOverrides,
},
},
},
diff --git a/web/vitest.nuxt-setup.ts b/web/vitest.nuxt-setup.ts
new file mode 100644
index 00000000..6642fbc1
--- /dev/null
+++ b/web/vitest.nuxt-setup.ts
@@ -0,0 +1,25 @@
+import { registerEndpoint } from "@nuxt/test-utils/runtime"
+
+// mounting anything inside the nuxt runtime boots a router, and the global
+// middleware in app/middleware/01.redirect.global.ts asks better-auth for
+// the current session before every navigation. Unstubbed that is a real
+// request to the (nonexistent) test host, costing ~5s per mount before it
+// gives up — so the nuxt project stubs a signed-out session for every
+// suite. Suites that need a session register the same url again: the
+// endpoint registry resolves the most recently registered handler first.
+//
+// It takes both spellings of the url, for two different reasons.
+// @nuxt/test-utils only diverts a request away from the real network when
+// the string the client asked for is in its registry, and better-auth asks
+// for the absolute url its client was built with — so the absolute entry is
+// what keeps the request off the network. The diverted request then reaches
+// the test-time h3 app with the origin stripped, so the handler that
+// actually answers it is the one filed under the bare path. With only the
+// absolute entry the call 404s; with only the path entry it goes to the
+// network and stalls.
+registerEndpoint("http://test.local/auth-realtime/api/auth/get-session", () => {
+ return null
+})
+registerEndpoint("/auth-realtime/api/auth/get-session", () => {
+ return null
+})