add core component tests to web

This commit is contained in:
swithek 2026-08-21 23:06:51 +02:00
parent db769cf25d
commit 7a4e9bcd97
37 changed files with 7683 additions and 21 deletions

View File

@ -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 `<body>`.** 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

View File

@ -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("<AIChatSidebar>", { 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)
})
})

View File

@ -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<string, unknown> = {}) {
return mountUnderSidebarProvider(AppSidebar, {
props: {
allInitialSectionsLoaded: true,
notificationSidebarOpen: false,
...props,
},
})
}
function sidebar(wrapper: Awaited<ReturnType<typeof mountSidebar>>) {
return wrapper.findComponent(AppSidebar)
}
function itemNamed(
wrapper: Awaited<ReturnType<typeof mountSidebar>>,
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("<AppSidebar>", { 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]])
})
})
})

View File

@ -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 <body>, and the colour
// theme lives in an app-wide persistent state every mount in the file sees
describe("<AppSidebarHeader>", { 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()
}

View File

@ -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 <body>, 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 <body>, and the frozen
// clock the availability tests need is a global, so these cannot interleave
describe("<CalendarInput>", { 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<HTMLElement>(
"[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()
})
})

View File

@ -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("<DataSourceStatusIcon>", () => {
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")
})
})

View File

@ -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<void>; name: string } | null,
) {
return mountSuspended(DocumentDeletionModal, {
props: { modelValue: target },
})
}
// the dialog body is teleported into <body>, 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 <body> and the delete flow
// is driven by the global fake timers, so these tests cannot interleave
describe("<DocumentDeletionModal>", { 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")
})
})

View File

@ -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<string, unknown> = {}) {
return mountUnderSidebarProvider(DocumentHeader, {
props: {
allInitialSectionsLoaded: true,
breadcrumbs: BREADCRUMBS,
timestamps: null,
...props,
},
})
}
async function openOptionsMenu(
wrapper: Awaited<ReturnType<typeof mountHeader>>,
) {
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 <body>
describe("<DocumentHeader>", { 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")
})
})

View File

@ -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 <span>, the ancestors are links
function renderedNames(wrapper: VueWrapper) {
return wrapper.findAll("span.truncate").map((span) => span.text())
}
describe("<HeaderBreadcrumbs>", () => {
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"])
})
})

View File

@ -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<string, unknown> = {}) {
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<string, unknown> = {}) {
return mountSuspended(NotificationBox, { props: props })
}
function rows(wrapper: Awaited<ReturnType<typeof mountBox>>) {
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("<NotificationBox>", { 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
})
})
})

View File

@ -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<ReturnType<typeof mountSidebar>>) {
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("<NotificationSidebar>", { 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)
})
})

View File

@ -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<string, unknown> = {}) {
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<DOMWrapper<HTMLInputElement>, "exists">,
value: string,
) {
await input.setValue(value)
await nextTick()
}
describe("<NumberInput>", () => {
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<string, unknown>,
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)
})
})
})

View File

@ -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<HTMLInputElement>("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 <body>, the debounce runs
// on the global fake timers, and the document tree and organization live
// in the app-wide query cache
describe("<SearchModal>", { 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<HTMLElement>("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")
})
})

View File

@ -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<string, unknown>) {
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("<ShortcutTooltip>", { 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)
}
})
})

View File

@ -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<boolean>
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<boolean> }[] = []
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> = {}): 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<string, unknown> = {}) {
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 <body>, and the geometry
// mocks and the drag store behind them are module-level singletons every
// mount in the file shares
describe("<SidebarItem>", { 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()
}

View File

@ -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> = {}): 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<ReturnType<typeof mountGroup>>["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 <body>
describe("<SidebarNestedGroup>", { 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 }],
])
})
})

View File

@ -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("<ToastMessage>", () => {
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()
})
})

View File

@ -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<ReturnType<typeof mountAction>>,
) {
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("<AccountDeletionAction>", { 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)
})
})

View File

@ -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<string, unknown>) {
return mountSuspended(ActionModal, { props: props })
}
function dialogText() {
return (
document.body.querySelector("[data-slot='dialog-content']")?.textContent ??
""
)
}
// the dialog body is teleported into the shared <body> and the query cache
// is app-wide
describe("<ActionModal>", { 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)
})
})

View File

@ -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<ReturnType<typeof mountSection>>, 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("<AppsSection>", { 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)
})
})

View File

@ -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 <body> 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 <body> and the query cache
// is app-wide
describe("<BaseModal>", { 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<HTMLElement>("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)
})
})

View File

@ -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("<DataSourceRemovalAction>", { 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)
})
})

View File

@ -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> = {}): 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 <body>
describe("<DataSourceSection>", { 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")
})
})

View File

@ -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<string, unknown>) {
return mountWithFrozenClock(DataSourceUpsertAction, { props: props })
}
// name, url, username, password — in template order
function fields(wrapper: Awaited<ReturnType<typeof mountAction>>) {
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<ReturnType<typeof mountAction>>) {
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<typeof setResponseStatus>[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("<DataSourceUpsertAction>", { 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")
})
})
})

View File

@ -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<ReturnType<typeof mountAction>>,
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("<EmailChangeAction>", { 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)
})
})

View File

@ -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<ReturnType<typeof mountAction>>,
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("<PasswordChangeAction>", { 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()
})
})
})

View File

@ -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<ReturnType<typeof mountSection>>) {
return wrapper.get<HTMLInputElement>("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<ReturnType<typeof mountSection>>,
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("<ProfileSection>", { 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<ReturnType<typeof mountSection>>) {
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()
}

View File

@ -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<ReturnType<typeof mountAction>>,
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("<WorkspaceInvitationAction>", { 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)
})
})

View File

@ -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<OrganizationMember> = {}) {
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<ReturnType<typeof mountAction>>,
) {
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("<WorkspaceMemberRemovalAction>", { 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)
})
})

View File

@ -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<ReturnType<typeof mountSection>>["wrapper"],
) {
return wrapper.get<HTMLInputElement>("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 <body>
describe("<WorkspaceSection>", { 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<ReturnType<typeof mountSection>>["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()
}

View File

@ -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<ReturnType<typeof mountAction>>,
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("<WorkspaceURLChangeAction>", { 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)
})
})

View File

@ -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([])
})
})

View File

@ -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 <span class="iconify i-<name>">,
// 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<string, unknown> | null) {
seedQueryData(["auth", "session"], {
data: user ? { session: { id: "session-1" }, user: user } : null,
error: null,
})
}
export function seedAuthOrganization(
organization: Record<string, unknown> | 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<string, unknown>; slots?: Record<string, unknown> },
) {
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<string, unknown>
slots?: Record<string, unknown>
stubs?: Record<string, boolean>
} = {},
) {
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 <body> 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<T>(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 <body>; every
// actionable row in one carries a menuitem role
export function menuItem(text: string): HTMLElement {
const item = Array.from(
document.body.querySelectorAll<HTMLElement>("[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 <body> too, so their buttons
// cannot be reached through the wrapper
export function teleportedButton(text: string): HTMLButtonElement {
const button = Array.from(
document.body.querySelectorAll<HTMLButtonElement>("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 <body>
// 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<string, unknown> } = {},
) {
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<string, unknown>) {
seedAuthOrganization(organization)
return mockAuthEndpoint(
"organization/get-full-organization",
() => organization,
"GET",
)
}

View File

@ -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<string, unknown> & { 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")
})
})

View File

@ -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: {

View File

@ -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,
},
},
},

25
web/vitest.nuxt-setup.ts Normal file
View File

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