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 +})