mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add core component tests to web
This commit is contained in:
parent
db769cf25d
commit
7a4e9bcd97
|
|
@ -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
|
||||
|
|
|
|||
100
web/app/components/AIChatSidebar.nuxt.test.ts
Normal file
100
web/app/components/AIChatSidebar.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
518
web/app/components/AppSidebar.nuxt.test.ts
Normal file
518
web/app/components/AppSidebar.nuxt.test.ts
Normal 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]])
|
||||
})
|
||||
})
|
||||
})
|
||||
139
web/app/components/AppSidebarHeader.nuxt.test.ts
Normal file
139
web/app/components/AppSidebarHeader.nuxt.test.ts
Normal 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()
|
||||
}
|
||||
150
web/app/components/CalendarInput.nuxt.test.ts
Normal file
150
web/app/components/CalendarInput.nuxt.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
108
web/app/components/DataSourceStatusIcon.nuxt.test.ts
Normal file
108
web/app/components/DataSourceStatusIcon.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
106
web/app/components/DocumentDeletionModal.nuxt.test.ts
Normal file
106
web/app/components/DocumentDeletionModal.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
373
web/app/components/DocumentHeader.nuxt.test.ts
Normal file
373
web/app/components/DocumentHeader.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
113
web/app/components/HeaderBreadcrumbs.nuxt.test.ts
Normal file
113
web/app/components/HeaderBreadcrumbs.nuxt.test.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
641
web/app/components/NotificationBox.nuxt.test.ts
Normal file
641
web/app/components/NotificationBox.nuxt.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
175
web/app/components/NotificationSidebar.nuxt.test.ts
Normal file
175
web/app/components/NotificationSidebar.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
217
web/app/components/NumberInput.nuxt.test.ts
Normal file
217
web/app/components/NumberInput.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
268
web/app/components/SearchModal.nuxt.test.ts
Normal file
268
web/app/components/SearchModal.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
135
web/app/components/ShortcutTooltip.nuxt.test.ts
Normal file
135
web/app/components/ShortcutTooltip.nuxt.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
420
web/app/components/SidebarItem.nuxt.test.ts
Normal file
420
web/app/components/SidebarItem.nuxt.test.ts
Normal 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()
|
||||
}
|
||||
218
web/app/components/SidebarNestedGroup.nuxt.test.ts
Normal file
218
web/app/components/SidebarNestedGroup.nuxt.test.ts
Normal 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 }],
|
||||
])
|
||||
})
|
||||
})
|
||||
86
web/app/components/ToastMessage.nuxt.test.ts
Normal file
86
web/app/components/ToastMessage.nuxt.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
148
web/app/components/settings/AccountDeletionAction.nuxt.test.ts
Normal file
148
web/app/components/settings/AccountDeletionAction.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
225
web/app/components/settings/ActionModal.nuxt.test.ts
Normal file
225
web/app/components/settings/ActionModal.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
236
web/app/components/settings/AppsSection.nuxt.test.ts
Normal file
236
web/app/components/settings/AppsSection.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
262
web/app/components/settings/BaseModal.nuxt.test.ts
Normal file
262
web/app/components/settings/BaseModal.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
125
web/app/components/settings/DataSourceRemovalAction.nuxt.test.ts
Normal file
125
web/app/components/settings/DataSourceRemovalAction.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
182
web/app/components/settings/DataSourceSection.nuxt.test.ts
Normal file
182
web/app/components/settings/DataSourceSection.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
393
web/app/components/settings/DataSourceUpsertAction.nuxt.test.ts
Normal file
393
web/app/components/settings/DataSourceUpsertAction.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
124
web/app/components/settings/EmailChangeAction.nuxt.test.ts
Normal file
124
web/app/components/settings/EmailChangeAction.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
282
web/app/components/settings/PasswordChangeAction.nuxt.test.ts
Normal file
282
web/app/components/settings/PasswordChangeAction.nuxt.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
326
web/app/components/settings/ProfileSection.nuxt.test.ts
Normal file
326
web/app/components/settings/ProfileSection.nuxt.test.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
333
web/app/components/settings/WorkspaceSection.nuxt.test.ts
Normal file
333
web/app/components/settings/WorkspaceSection.nuxt.test.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
216
web/app/components/sidebar.test.ts
Normal file
216
web/app/components/sidebar.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
287
web/app/components/test-helpers.ts
Normal file
287
web/app/components/test-helpers.ts
Normal 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",
|
||||
)
|
||||
}
|
||||
75
web/app/components/toast.nuxt.test.ts
Normal file
75
web/app/components/toast.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
25
web/vitest.nuxt-setup.ts
Normal 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
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user