add component tests for editor in web

This commit is contained in:
swithek 2026-08-22 13:05:44 +02:00
parent 7a4e9bcd97
commit f611c13d98
141 changed files with 17542 additions and 260 deletions

View File

@ -106,6 +106,7 @@ Component build/test/qa commands are listed in the nested CLAUDE.md files.
- Only comment **complex implementations, edge cases, and locations that would confuse a future reader**. Do not add comments that restate what the code does, and do not add comments just to acknowledge a prompt or show that you understood the task.
- Always use `//` single-line comments. Never use `/* */` — not even for multi-line blocks.
- **Never reference CLAUDE.md or any other agent-instruction file from a comment.** Comments stand alone for any reader, and CLAUDE.md is agent-facing documentation, not part of the codebase's own narrative. Where a comment would say "see CLAUDE.md", inline the actual rule or rationale instead. Links to real project docs (READMEs, upstream issues, specs) stay fine.
- Wrap comments at 80 characters. Continuation lines also start with `// `.
- Within a single comment, the **first sentence starts with a lowercase letter**; every following sentence starts with an uppercase letter. Sentences are still separated with proper punctuation. However, if the first word is the name of a class/function, use the casing of that class/function and don't override it.

View File

@ -95,7 +95,9 @@ make check-lint # golangci-lint run (verification only)
- Never write comments that narrate the next line or justify a diff; comments state
constraints and reasons the code cannot express.
- Comments stand alone: don't reference design docs by section number ("§5.4",
"M1") or assume the reader has another file open.
"M1"), never point at CLAUDE.md or any other agent-instruction file, and
don't assume the reader has another file open. Inline the actual rule or
reason instead.
### Globals

View File

@ -106,6 +106,8 @@ Pages with `definePageMeta({ skipAuth: true })` are reachable when signed-out (l
All user-facing text **must** live under [i18n/locales/en/](i18n/locales/en/) — never inline. The setup combines multiple JSON files into one big i18n object, so each file must have a **root namespace key** (e.g. `{ "sidebar": { ... } }`). To add a new namespace, also register the filename in `nuxt.config.ts` under `i18n.locales[0].files`.
`<i18n-t>` carries **`scope="global"`** on every use. Its default scope is `parent`, which looks for an ancestor that opened its own i18n scope; nothing in this app does, so without the prop vue-i18n warns once per render and falls back to the global scope anyway.
Form validation (vee-validate) uses its own internal messages — see [README.md](README.md).
## UI
@ -113,6 +115,7 @@ Form validation (vee-validate) uses its own internal messages — see [README.md
- **shadcn-vue** components live in [app/components/shadcn/ui/](app/components/shadcn/ui/) with prefix `ShadcnUi`. Configured in [components.json](components.json).
- **Tailwind v4** via `@tailwindcss/vite`, theme in [app/assets/css/main.css](app/assets/css/main.css).
- Icons via `@nuxt/icon` (CSS mode) — only icons listed by `selectableIconList()` in [app/utils/icon.ts](app/utils/icon.ts) are bundled client-side. Custom SVGs live in [app/assets/custom-icons/](app/assets/custom-icons/) and are reachable under the `custom-icons:` prefix.
- **Open a11y bug: dialogs have no `DialogDescription`.** Every modal renders its description as a plain `<p>`, so the `aria-describedby` reka-ui puts on `DialogContent` points at nothing — screen readers get no description, and reka-ui warns once per mount. **This is scheduled to be fixed, not accepted.** The warning is suppressed in [vitest.config.ts](vitest.config.ts)'s `onConsoleLog` purely to keep test output readable in the meantime; that branch comes out with the fix, and the warning is still visible in `nuxt dev`. Note that routing the text through `ShadcnUiDialogDescription` is *not* a drop-in fix: the wrapper runs its class through `cn()`, and default tailwind-merge reads the custom `text-2sm` as a colour utility and drops it in favour of the wrapper's `text-sm`. Closing this needs either reka-ui's `DialogDescription` imported directly (no `cn()`) or `extendTailwindMerge` teaching `cn()` the custom text scale.
## Formatting & TS
@ -370,6 +373,32 @@ Component tests mount the real component inside the nuxt runtime with
[app/components/test-helpers.ts](app/components/test-helpers.ts) — the
things that come up in nearly every suite:
- **Never hardcode a translation.** User-facing text is asserted through
`t()` from
[app/components/test-helpers.ts](app/components/test-helpers.ts), which
resolves the key against the same i18n instance the component renders
with — `expect(wrapper.text()).toContain(t("sidebar.sections.top.inbox"))`,
never `toContain("Inbox")`. A copied english string is a second,
unmaintained definition of the message: it keeps passing when the copy
changes and, in the other direction, a message edit silently breaks a
suite that has nothing to do with it. This covers every place the text
appears — assertions, the text a lookup helper searches for
(`findButtonByText(wrapper, t("..."))`), and props the test feeds in.
Rules that follow from it:
- **Interpolated messages take their values through `t()` too**, the
same ones the component passes:
`t("sidebar.search.no-results", { query: "run" })` — never a hand-spliced
`"No results found for " + query`, and never a bare prefix like
`toContain("No results found for")` that skips the interpolation
entirely.
- **Pick the key the component actually renders.** Many messages share
an english value (six different `cancel-button` keys all read
"Cancel"), so the wrong key still passes today and only diverges when
one of them is reworded. Follow the component to its key rather than
grepping the locale file for the text.
- **`t()` needs the nuxt app context**, so it only runs inside a test
body or a hook — never at module scope. Text shared across a suite
goes in a helper function, not a top-level `const`.
- **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
@ -398,6 +427,11 @@ things that come up in nearly every suite:
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()`.
- **A failing endpoint answers with an h3 error**, never a bare `throw
new Error(...)`. h3 treats anything that is not an `H3Error` as
unhandled and dumps it with its full stack to stderr — for an outcome
the test is asserting on. `throw createError({ statusCode: 500 })`
fails the request the same way and stays quiet.
- **Viewport-dependent layouts.** happy-dom has no `matchMedia`, so
`useMediaQuery` reports "not matching" for every query — pick a side
deliberately with `stubViewportMatches()`.
@ -423,6 +457,42 @@ things that come up in nearly every suite:
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.
- **A component that keeps watching after its test** — one holding a
colour-mode watcher, a branch sync, or a teleported overlay vue still
patches — is taken down with `enableAutoUnmount(afterEach)` rather than
left mounted. A leftover instance answers the next test too, and
`clearTeleportedOverlays()` cannot be used on it: ripping out a node
vue still has mounted breaks its next patch.
### Editor component tests
The editor suites reuse a few things the rest of the components do not:
- **Node views** ([app/components/editor/test-helpers/node-view.ts](app/components/editor/test-helpers/node-view.ts))
mount on their own through `mountNodeView()`, which supplies every
`nodeViewProps` entry plus the `onDragStart` / `decorationClasses`
injections tiptap's renderer would provide. `makeNode()` and
`makeEditor()` stand in for the node and the editor; the command chain
a node view builds is recorded, so a suite asserts *which* commands ran
and with what rather than reaching into prosemirror.
- **Theme colours** are resolved by painting CSS variables onto a canvas,
which happy-dom has no 2d context for — anything rendering a chart, a
metric block or a caret needs `stubThemeColorContext()` from
[app/components/editor/test-helpers/theme.ts](app/components/editor/test-helpers/theme.ts)
first.
- **Charts** are asserted through the option object handed to echarts
(`chartOption()`), with `vue-echarts` replaced by a stub — echarts
itself needs a canvas.
- **Virtualized lists** (`vue-virtual-scroller`) measure row heights,
which happy-dom reports as zero, so nothing is ever drawn: suites that
need the rows mock the module with a pass-through component.
- **`editor.commands` is rebuilt on every access**, so a spy on one of
them is never the object the component calls. Shadow the getter with a
recording proxy when a suite needs to see which commands ran.
- **A live editor** is built with the real extensions when the behaviour
under test reads the document (link marks, comment marks, the merged
diff). A collaboration-backed component also needs a provider
stand-in carrying `document`, `awareness` and `on`/`off`.
### Snapshots

View File

@ -15,6 +15,7 @@ import {
seedAuthOrganization,
settleMutations,
stubViewportMatches,
t,
} from "./test-helpers"
vi.mock("vue-sonner", () => ({
@ -134,8 +135,8 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
expect(wrapper.text()).toContain("Search")
expect(wrapper.text()).toContain("Inbox")
expect(wrapper.text()).toContain(t("sidebar.sections.top.search-button"))
expect(wrapper.text()).toContain(t("sidebar.sections.top.inbox"))
})
it("shows the unread notification count on the inbox row", async ({
@ -146,7 +147,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(itemNamed(wrapper, "Inbox").text()).toContain("4")
expect(
itemNamed(wrapper, t("sidebar.sections.top.inbox")).text(),
).toContain("4")
})
it("marks the inbox row active while the notification sidebar is open", async ({
@ -156,7 +159,11 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar({ notificationSidebarOpen: true })
expect(itemNamed(wrapper, "Inbox").attributes("data-active")).toBe("true")
expect(
itemNamed(wrapper, t("sidebar.sections.top.inbox")).attributes(
"data-active",
),
).toBe("true")
})
it("lists the workspace pages under their own heading", async ({
@ -167,7 +174,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(wrapper.text()).toContain("Workspace")
expect(wrapper.text()).toContain(
t("sidebar.sections.main-workspace.heading"),
)
expect(wrapper.text()).toContain("Runbook")
})
@ -179,7 +188,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(wrapper.text()).toContain("Add Page")
expect(wrapper.text()).toContain(
t("sidebar.sections.main-workspace.heading-action-title"),
)
})
it("asks to open the search modal from the search row", async ({
@ -188,7 +199,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
stubQueries()
const wrapper = await mountSidebar()
await itemNamed(wrapper, "Search").trigger("click")
await itemNamed(wrapper, t("sidebar.sections.top.search-button")).trigger(
"click",
)
expect(
document.body.querySelector("[data-slot='dialog-content']"),
@ -201,7 +214,7 @@ describe("<AppSidebar>", { concurrent: false }, () => {
stubQueries()
const wrapper = await mountSidebar()
await itemNamed(wrapper, "Inbox").trigger("click")
await itemNamed(wrapper, t("sidebar.sections.top.inbox")).trigger("click")
expect(sidebar(wrapper).emitted("toggle-notifications")).toHaveLength(1)
})
@ -256,7 +269,7 @@ describe("<AppSidebar>", { concurrent: false }, () => {
it("warns and stays put when signing out fails", async ({ expect }) => {
stubQueries()
mockAuthEndpoint("sign-out", () => {
throw new Error("boom")
throw createError({ statusCode: 500 })
})
const wrapper = await mountSidebar()
@ -285,7 +298,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(wrapper.text()).toContain("Invite Team Members")
expect(wrapper.text()).toContain(
t("sidebar.sections.next-steps.items.invite-team-members"),
)
})
it("drops the invite row once the workspace has more members", async ({
@ -305,7 +320,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(wrapper.text()).not.toContain("Invite Team Members")
expect(wrapper.text()).not.toContain(
t("sidebar.sections.next-steps.items.invite-team-members"),
)
})
it("opens the member settings from the invite row", async ({ expect }) => {
@ -313,7 +330,10 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
await itemNamed(wrapper, "Invite Team Members").trigger("click")
await itemNamed(
wrapper,
t("sidebar.sections.next-steps.items.invite-team-members"),
).trigger("click")
expect(sidebar(wrapper).emitted("open-settings")).toEqual([
["org-members"],
@ -328,7 +348,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(wrapper.text()).toContain("Activate GitHub Integration")
expect(wrapper.text()).toContain(
t("sidebar.sections.next-steps.items.connect-github"),
)
})
it("hides the github integration on a deployment without it", async ({
@ -339,7 +361,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(wrapper.text()).not.toContain("Activate GitHub Integration")
expect(wrapper.text()).not.toContain(
t("sidebar.sections.next-steps.items.connect-github"),
)
})
it("offers the slack integration while it is unconnected", async ({
@ -350,7 +374,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(wrapper.text()).toContain("Activate Slack Integration")
expect(wrapper.text()).toContain(
t("sidebar.sections.next-steps.items.connect-slack"),
)
})
it("hides the slack integration on a deployment without it", async ({
@ -361,7 +387,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(wrapper.text()).not.toContain("Activate Slack Integration")
expect(wrapper.text()).not.toContain(
t("sidebar.sections.next-steps.items.connect-slack"),
)
})
it("hides the whole section once there is nothing left to do", async ({
@ -381,7 +409,9 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
expect(wrapper.text()).not.toContain("Next Steps")
expect(wrapper.text()).not.toContain(
t("sidebar.sections.next-steps.heading"),
)
})
it("opens the github install page in the browser", async ({ expect }) => {
@ -394,7 +424,10 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
await itemNamed(wrapper, "Activate GitHub Integration").trigger("click")
await itemNamed(
wrapper,
t("sidebar.sections.next-steps.items.connect-github"),
).trigger("click")
await settleMutations()
expect(open).toHaveBeenCalledExactlyOnceWith(
@ -409,12 +442,15 @@ describe("<AppSidebar>", { concurrent: false }, () => {
}) => {
stubQueries({ gitHub: { connected: false, configured: true } })
mockEndpoint("GET", "/api/github/install", () => {
throw new Error("boom")
throw createError({ statusCode: 500 })
})
const wrapper = await mountSidebar()
await settleMutations()
await itemNamed(wrapper, "Activate GitHub Integration").trigger("click")
await itemNamed(
wrapper,
t("sidebar.sections.next-steps.items.connect-github"),
).trigger("click")
await settleMutations()
expect(toast.custom).toHaveBeenCalledTimes(1)
@ -430,7 +466,10 @@ describe("<AppSidebar>", { concurrent: false }, () => {
const wrapper = await mountSidebar()
await settleMutations()
await itemNamed(wrapper, "Activate Slack Integration").trigger("click")
await itemNamed(
wrapper,
t("sidebar.sections.next-steps.items.connect-slack"),
).trigger("click")
await settleMutations()
expect(open).toHaveBeenCalledExactlyOnceWith(
@ -445,12 +484,15 @@ describe("<AppSidebar>", { concurrent: false }, () => {
}) => {
stubQueries({ slack: { connected: false, configured: true } })
mockEndpoint("GET", "/api/slack/install", () => {
throw new Error("boom")
throw createError({ statusCode: 500 })
})
const wrapper = await mountSidebar()
await settleMutations()
await itemNamed(wrapper, "Activate Slack Integration").trigger("click")
await itemNamed(
wrapper,
t("sidebar.sections.next-steps.items.connect-slack"),
).trigger("click")
await settleMutations()
expect(toast.custom).toHaveBeenCalledTimes(1)
@ -478,7 +520,7 @@ describe("<AppSidebar>", { concurrent: false }, () => {
it("warns when the move cannot be persisted", async ({ expect }) => {
stubQueries({ tree: [treeElement()] })
mockEndpoint("PUT", "/api/documents/tree", () => {
throw new Error("boom")
throw createError({ statusCode: 500 })
})
const wrapper = await mountSidebar()
await settleMutations()

View File

@ -4,6 +4,7 @@ import {
clearTeleportedOverlays,
menuItem,
mountUnderSidebarProvider,
t,
} from "./test-helpers"
const AVATAR = { src: "", alt: "Logo", initials: "AC" }
@ -33,7 +34,7 @@ describe("<AppSidebarHeader>", { concurrent: false }, () => {
}) => {
const wrapper = await mountHeader(null)
expect(wrapper.text()).toContain("Unknown")
expect(wrapper.text()).toContain(t("sidebar.default-workspace-name"))
})
it("shows the workspace initials while the logo has no source", async ({
@ -82,7 +83,7 @@ describe("<AppSidebarHeader>", { concurrent: false }, () => {
const header = wrapper.findComponent(AppSidebarHeader)
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
menuItem("Settings").click()
menuItem(t("sidebar.header.settings")).click()
await nextTick()
expect(header.emitted("open-settings")).toHaveLength(1)
@ -93,15 +94,15 @@ describe("<AppSidebarHeader>", { concurrent: false }, () => {
const header = wrapper.findComponent(AppSidebarHeader)
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
menuItem("Log Out").click()
menuItem(t("sidebar.header.log-out")).click()
await nextTick()
expect(header.emitted("log-out")).toHaveLength(1)
})
it.for([
{ label: "Light Theme", expected: "light" },
{ label: "Dark Theme", expected: "dark" },
{ label: "sidebar.header.appearance.light", expected: "light" },
{ label: "sidebar.header.appearance.dark", expected: "dark" },
] as const)(
"switches the colour theme to $expected from the menu",
async ({ label, expected }, { expect }) => {
@ -109,7 +110,7 @@ describe("<AppSidebarHeader>", { concurrent: false }, () => {
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
await openAppearanceSubMenu()
menuItem(label).click()
menuItem(t(label)).click()
await nextTick()
expect(useAppearance().color.value.system).toBe(false)
@ -125,7 +126,7 @@ describe("<AppSidebarHeader>", { concurrent: false }, () => {
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
await openAppearanceSubMenu()
menuItem("System Theme").click()
menuItem(t("sidebar.header.appearance.auto")).click()
await nextTick()
expect(useAppearance().color.value.system).toBe(true)
@ -133,7 +134,7 @@ describe("<AppSidebarHeader>", { concurrent: false }, () => {
})
async function openAppearanceSubMenu() {
menuItem("Appearance").click()
menuItem(t("sidebar.header.appearance.title")).click()
await nextTick()
await nextTick()
}

View File

@ -2,7 +2,7 @@ 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"
import { clearTeleportedOverlays, t } from "./test-helpers"
function mountInput(props: {
placeholder: string
@ -24,23 +24,31 @@ describe("<CalendarInput>", { concurrent: false }, () => {
beforeEach(clearTeleportedOverlays)
it("shows the placeholder while nothing is picked", async ({ expect }) => {
const wrapper = await mountInput({ placeholder: "Pick a date" })
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
})
expect(wrapper.text()).toContain("Pick a date")
expect(wrapper.text()).toContain(
t("editor.hooks.time-expiration.calendar-placeholder"),
)
})
it("shows the picked date instead of the placeholder", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: "Pick a date",
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
expect(wrapper.text()).toContain("March 14, 2026")
expect(wrapper.text()).not.toContain("Pick a date")
expect(wrapper.text()).not.toContain(
t("editor.hooks.time-expiration.calendar-placeholder"),
)
})
it("greys out the trigger while nothing is picked", async ({ expect }) => {
const wrapper = await mountInput({ placeholder: "Pick a date" })
const wrapper = await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
})
expect(wrapper.get("button").classes()).toContain("text-muted-foreground")
})
@ -49,7 +57,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
expect,
}) => {
const wrapper = await mountInput({
placeholder: "Pick a date",
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
@ -61,14 +69,16 @@ describe("<CalendarInput>", { concurrent: false }, () => {
it("keeps the calendar closed until the trigger is pressed", async ({
expect,
}) => {
await mountInput({ placeholder: "Pick a date" })
await mountInput({
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
})
expect(popoverText()).not.toContain("March")
})
it("opens the calendar when the trigger is pressed", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: "Pick a date",
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
@ -81,7 +91,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
it("closes the calendar once a date is picked", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: "Pick a date",
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
await wrapper.get("button").trigger("click")
@ -95,7 +105,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
it("reports the day the user clicks in the calendar", async ({ expect }) => {
const wrapper = await mountInput({
placeholder: "Pick a date",
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
await wrapper.get("button").trigger("click")
@ -115,7 +125,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
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",
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 14),
})
@ -132,7 +142,7 @@ describe("<CalendarInput>", { concurrent: false }, () => {
}) => {
vi.setSystemTime(new Date("2026-03-14T12:00:00Z"))
const wrapper = await mountInput({
placeholder: "Pick a date",
placeholder: t("editor.hooks.time-expiration.calendar-placeholder"),
modelValue: new CalendarDate(2026, 3, 20),
availableFromTomorrow: true,
})

View File

@ -1,7 +1,7 @@
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"
import { clearTeleportedOverlays, t, teleportedButton } from "./test-helpers"
function mountModal(
target: { deleteDocument: () => Promise<void>; name: string } | null,
@ -19,6 +19,12 @@ function dialog() {
// 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
// t() reaches for the nuxt app context, so the label can only be
// resolved inside a test — not once at module scope
function confirmButton() {
return teleportedButton(t("editor.document-deletion-modal.confirm-button"))
}
describe("<DocumentDeletionModal>", { concurrent: false }, () => {
beforeEach(clearTeleportedOverlays)
@ -41,7 +47,7 @@ describe("<DocumentDeletionModal>", { concurrent: false }, () => {
const deleteDocument = vi.fn().mockResolvedValue(undefined)
const wrapper = await mountModal({ name: "Runbook", deleteDocument })
teleportedButton("Delete").click()
confirmButton().click()
await vi.advanceTimersByTimeAsync(300)
expect(deleteDocument).toHaveBeenCalledTimes(1)
@ -55,14 +61,17 @@ describe("<DocumentDeletionModal>", { concurrent: false }, () => {
const deleteDocument = vi.fn().mockResolvedValue(undefined)
await mountModal({ name: "Runbook", deleteDocument })
teleportedButton("Delete").click()
confirmButton().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)
expect(confirmButton().disabled).toBe(true)
expect(
teleportedButton(t("editor.document-deletion-modal.cancel-button"))
.disabled,
).toBe(true)
})
it("closes without deleting when the cancel button is pressed", async ({
@ -71,7 +80,7 @@ describe("<DocumentDeletionModal>", { concurrent: false }, () => {
const deleteDocument = vi.fn()
const wrapper = await mountModal({ name: "Runbook", deleteDocument })
teleportedButton("Cancel").click()
teleportedButton(t("editor.document-deletion-modal.cancel-button")).click()
await nextTick()
expect(deleteDocument).toHaveBeenCalledTimes(0)
@ -84,7 +93,7 @@ describe("<DocumentDeletionModal>", { concurrent: false }, () => {
const deleteDocument = vi.fn()
const wrapper = await mountModal({ name: "Runbook", deleteDocument })
teleportedButton("Close").click()
teleportedButton(t("general.modal-close-screen-reader-hint")).click()
await nextTick()
expect(deleteDocument).toHaveBeenCalledTimes(0)

View File

@ -58,6 +58,7 @@ async function deleteDocument() {
</ShadcnUiDialogHeader>
<div class="flex flex-col gap-2 self-stretch">
<i18n-t
scope="global"
keypath="editor.document-deletion-modal.description"
tag="p"
class="text-2sm text-muted-foreground"

View File

@ -14,6 +14,7 @@ import {
mountUnderSidebarProvider,
seedAuthOrganization,
settleMutations,
t,
} from "./test-helpers"
vi.mock("vue-sonner", () => ({
@ -146,16 +147,19 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
expect(wrapper.text()).toContain("Toggle Edit Mode")
expect(wrapper.text()).toContain(t("editor.navbar.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")
await findButtonByText(
wrapper,
t("editor.navbar.toggle-edit-mode"),
).trigger("click")
expect(wrapper.text()).toContain("Toggle Read Mode")
expect(wrapper.text()).toContain(t("editor.navbar.toggle-read-mode"))
})
it("switches from read mode back to edit mode", async ({ expect }) => {
@ -163,9 +167,12 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
useEditorMeta().setEditable(false)
const wrapper = await mountHeader()
await findButtonByText(wrapper, "Toggle Read Mode").trigger("click")
await findButtonByText(
wrapper,
t("editor.navbar.toggle-read-mode"),
).trigger("click")
expect(wrapper.text()).toContain("Toggle Edit Mode")
expect(wrapper.text()).toContain(t("editor.navbar.toggle-edit-mode"))
})
it("offers a version picker once the document is reviewable", async ({
@ -175,7 +182,9 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
expect(wrapper.text()).toContain("Main Version")
expect(wrapper.text()).toContain(
t("editor.navbar.document-modes.main.title"),
)
})
it("names the draft version when the draft branch is active", async ({
@ -186,7 +195,9 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
expect(wrapper.text()).toContain("Draft Version")
expect(wrapper.text()).toContain(
t("editor.navbar.document-modes.draft.title"),
)
})
it("falls back to the main version when the active branch is unknown", async ({
@ -197,7 +208,9 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
expect(wrapper.text()).toContain("Main Version")
expect(wrapper.text()).toContain(
t("editor.navbar.document-modes.main.title"),
)
})
it("switches to the draft branch from the version picker", async ({
@ -208,7 +221,7 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
menuItem("Draft Version").click()
menuItem(t("editor.navbar.document-modes.draft.title")).click()
await nextTick()
expect(editorStore.activeBranchId).toBe(DRAFT_BRANCH)
@ -224,7 +237,7 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
menuItem("Main Version").click()
menuItem(t("editor.navbar.document-modes.main.title")).click()
await nextTick()
expect(editorStore.activeBranchId).toBe(MAIN_BRANCH)
@ -238,7 +251,7 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
await openOptionsMenu(wrapper)
menuItem("Duplicate").click()
menuItem(t("editor.navbar.document-options.duplicate.title")).click()
await nextTick()
expect(
@ -253,7 +266,7 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
await openOptionsMenu(wrapper)
menuItem("Delete").click()
menuItem(t("editor.navbar.document-options.delete.title")).click()
await nextTick()
expect(
@ -278,7 +291,9 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
await openOptionsMenu(wrapper)
menuItem("Make Reviewable").click()
menuItem(
t("editor.navbar.document-options.review-workflow.enable-title"),
).click()
await settleMutations()
expect(created).toHaveLength(1)
@ -306,7 +321,9 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
await openOptionsMenu(wrapper)
menuItem("Make Non Reviewable").click()
menuItem(
t("editor.navbar.document-options.review-workflow.disable-title"),
).click()
await settleMutations()
expect(deleted).toHaveLength(1)
@ -316,12 +333,14 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
it("warns when the reviewability change fails", async ({ expect }) => {
mainOnly()
mockEndpoint("POST", `/api/documents/${DOC_ID}/branches`, () => {
throw new Error("boom")
throw createError({ statusCode: 500 })
})
const wrapper = await mountHeader()
await openOptionsMenu(wrapper)
menuItem("Make Reviewable").click()
menuItem(
t("editor.navbar.document-options.review-workflow.enable-title"),
).click()
await settleMutations()
expect(toast.custom).toHaveBeenCalledTimes(1)
@ -340,7 +359,9 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
await openOptionsMenu(wrapper)
useEditorStore().activeDocumentId = null
menuItem("Make Reviewable").click()
menuItem(
t("editor.navbar.document-options.review-workflow.enable-title"),
).click()
await settleMutations()
expect(created).toHaveLength(0)
@ -354,7 +375,7 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
const wrapper = await mountHeader()
expect(wrapper.text()).not.toContain("Rubber Duck")
expect(wrapper.text()).not.toContain(t("editor.ai-chat.title"))
})
it("publishes the header height so the page can offset content", async ({

View File

@ -170,7 +170,11 @@ function activateBranch(branch: "default" | "draft") {
</ShadcnUiTooltipTrigger>
<ShadcnUiTooltipContent side="bottom" align="end">
<div class="flex flex-col gap-1">
<i18n-t keypath="editor.navbar.timestamp.full-edited" tag="div">
<i18n-t
scope="global"
keypath="editor.navbar.timestamp.full-edited"
tag="div"
>
<template #user>
<span class="font-bold">
{{
@ -190,6 +194,7 @@ function activateBranch(branch: "default" | "draft") {
</template>
</i18n-t>
<i18n-t
scope="global"
keypath="editor.navbar.timestamp.full-created"
tag="div"
>

View File

@ -15,6 +15,7 @@ import {
mockAuthOrganization,
renderedIconNames,
seedAuthOrganization,
t,
} from "./test-helpers"
vi.mock("vue-sonner", () => ({
@ -100,7 +101,7 @@ describe("<NotificationBox>", { concurrent: false }, () => {
const wrapper = await mountBox()
expect(wrapper.text()).toContain("All caught up")
expect(wrapper.text()).toContain(t("notification.empty-title"))
})
it("lists one row per notification", async ({ expect }) => {
@ -127,7 +128,7 @@ describe("<NotificationBox>", { concurrent: false }, () => {
const wrapper = await mountBox()
expect(wrapper.text()).toContain("Deleted Page")
expect(wrapper.text()).toContain(t("notification.document-fallback"))
})
it("labels a notification from the last minute as new", async ({
@ -137,7 +138,7 @@ describe("<NotificationBox>", { concurrent: false }, () => {
const wrapper = await mountBox()
expect(wrapper.text()).toContain("now")
expect(wrapper.text()).toContain(t("notification.now-time-label"))
})
it("shows how long ago an older notification arrived", async ({ expect }) => {
@ -175,7 +176,9 @@ describe("<NotificationBox>", { concurrent: false }, () => {
const wrapper = await mountBox()
expect(wrapper.text()).toContain("Review requested for the draft version")
expect(wrapper.text()).toContain(
t("notification.messages.document-review-request-description"),
)
})
it("names the hook that fired", async ({ expect }) => {
@ -268,7 +271,9 @@ describe("<NotificationBox>", { concurrent: false }, () => {
const wrapper = await mountBox()
expect(wrapper.text()).toContain("You have a new notification")
expect(wrapper.text()).toContain(
t("notification.messages.default-description"),
)
})
})
@ -367,7 +372,10 @@ describe("<NotificationBox>", { concurrent: false }, () => {
seedNotifications([makeNotification()])
const wrapper = await mountBox()
await findButtonByText(wrapper, "Mark as read").trigger("click")
await findButtonByText(
wrapper,
t("notification.actions.mark-read"),
).trigger("click")
await flushPromises()
expect(calls).toHaveLength(1)
@ -378,12 +386,15 @@ describe("<NotificationBox>", { concurrent: false }, () => {
expect,
}) => {
mockEndpoint("PUT", "/api/notifications/read-status", () => {
throw new Error("boom")
throw createError({ statusCode: 500 })
})
seedNotifications([makeNotification()])
const wrapper = await mountBox()
await findButtonByText(wrapper, "Mark as read").trigger("click")
await findButtonByText(
wrapper,
t("notification.actions.mark-read"),
).trigger("click")
await flushPromises()
expect(toast.custom).toHaveBeenCalledTimes(1)
@ -400,7 +411,10 @@ describe("<NotificationBox>", { concurrent: false }, () => {
seedNotifications([makeNotification()])
const wrapper = await mountBox()
await findButtonByText(wrapper, "Read all").trigger("click")
await findButtonByText(
wrapper,
t("notification.read-all-button"),
).trigger("click")
await flushPromises()
expect(calls).toHaveLength(1)
@ -418,7 +432,10 @@ describe("<NotificationBox>", { concurrent: false }, () => {
seedNotifications([makeNotification({ read: true })])
const wrapper = await mountBox()
await findButtonByText(wrapper, "Read all").trigger("click")
await findButtonByText(
wrapper,
t("notification.read-all-button"),
).trigger("click")
await flushPromises()
expect(calls).toHaveLength(0)
@ -426,12 +443,15 @@ describe("<NotificationBox>", { concurrent: false }, () => {
it("warns when marking everything read fails", async ({ expect }) => {
mockEndpoint("PUT", "/api/notifications/read-status", () => {
throw new Error("boom")
throw createError({ statusCode: 500 })
})
seedNotifications([makeNotification()])
const wrapper = await mountBox()
await findButtonByText(wrapper, "Read all").trigger("click")
await findButtonByText(
wrapper,
t("notification.read-all-button"),
).trigger("click")
await flushPromises()
expect(toast.custom).toHaveBeenCalledTimes(1)
@ -594,7 +614,9 @@ describe("<NotificationBox>", { concurrent: false }, () => {
const wrapper = await mountBox()
expect(wrapper.text()).not.toContain("Close notifications")
expect(wrapper.text()).not.toContain(
t("notification.actions.close-notification-box"),
)
})
it("closes the box when its close button is pressed", async ({
@ -603,7 +625,10 @@ describe("<NotificationBox>", { concurrent: false }, () => {
seedNotifications([])
const wrapper = await mountBox({ mobile: true })
await findButtonByText(wrapper, "Close notifications").trigger("click")
await findButtonByText(
wrapper,
t("notification.actions.close-notification-box"),
).trigger("click")
expect(wrapper.emitted("close-notification-box")).toHaveLength(1)
})

View File

@ -231,7 +231,7 @@ describe("<SearchModal>", { concurrent: false }, () => {
expect,
}) => {
mockEndpoint("GET", "/api/documents/search", () => {
throw new Error("boom")
throw createError({ statusCode: 500 })
})
await mountModal()

View File

@ -7,8 +7,13 @@ const open = defineModel<boolean>({ required: true })
const { t } = useI18n({ useScope: "global" })
const { searchDocuments, fetchDocumentTree } = useDocumentAPI()
const { fetchOrganization } = useAuthSession()
const resetLinkHighlightClearState = inject<() => void>(
// only the document page provides this, so every other mount — the
// component tests included — has no ancestor to inject from. The call
// site already guards for that; the explicit default is what keeps vue
// from warning its way to the same undefined
const resetLinkHighlightClearState = inject<(() => void) | undefined>(
"resetLinkHighlightClearState",
undefined,
)
const searchQuery = ref("")
@ -153,6 +158,7 @@ function resultTypeText(type: string) {
>
<Icon name="lucide:search-x" class="size-8 opacity-50" />
<i18n-t
scope="global"
keypath="sidebar.search.no-results"
class="text-2base"
tag="span"

View File

@ -4,6 +4,7 @@ import {
clearTeleportedOverlays,
mountUnderTooltipProvider,
openTooltipText,
t,
} from "./test-helpers"
const HOVER_DELAY_MS = 800
@ -74,7 +75,9 @@ describe("<ShortcutTooltip>", { concurrent: false }, () => {
await wrapper.get("button").trigger("pointerenter")
await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS)
expect(openTooltipText(wrapper)).toContain("Search for pages")
expect(openTooltipText(wrapper)).toContain(
t("shortcuts.keys.search-for-documents"),
)
})
it("shows only the keys for a shortcut with no i18n key", async ({

View File

@ -11,6 +11,7 @@ import {
menuItem,
mountUnderSidebarProvider,
renderedIconNames,
t,
} from "./test-helpers"
interface CapturedDragOptions {
@ -242,16 +243,25 @@ describe("<SidebarItem>", { concurrent: false }, () => {
})
it.for([
{ label: "Duplicate Page", event: "duplicate" },
{ label: "Add Sub Page", event: "create" },
{ label: "Delete Page", event: "delete" },
{
label: "sidebar.item-dropdown-menu-buttons.duplicate-page",
event: "duplicate",
},
{
label: "sidebar.item-dropdown-menu-buttons.add-sub-page",
event: "create",
},
{
label: "sidebar.item-dropdown-menu-buttons.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()
menuItem(t(label)).click()
await nextTick()
expect(item.emitted(event)).toHaveLength(1)

View File

@ -0,0 +1,364 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { HocuspocusProvider } from "@hocuspocus/provider"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { Awareness } from "y-protocols/awareness"
import * as Y from "yjs"
import ContentEditor from "./ContentEditor.vue"
import BlockHandle from "./drag-handle/BlockHandle.vue"
import { stubThemeColorContext } from "./test-helpers/theme"
import { METRIC_BLOCK_NAME } from "./blocks/node-names"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
} from "~/composables/api/test-helpers"
import {
emitFrom,
stubViewportMatches,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
// the comment renderer drives its own tiptap instances and comment
// requests; the content editor's contract with it is which of its actions
// it forwards, which the stub keeps observable
const childStubs = {
CommentRenderer: true,
}
interface Branch {
ydoc: Y.Doc
awareness: Awareness
provider: HocuspocusProvider
sync: () => void
}
function makeBranch(): Branch {
const ydoc = new Y.Doc()
const awareness = new Awareness(ydoc)
const handlers: Record<string, (() => void)[]> = {}
return {
ydoc: ydoc,
awareness: awareness,
provider: {
document: ydoc,
awareness: awareness,
on: (event: string, handler: () => void) => {
handlers[event] ??= []
handlers[event].push(handler)
},
off: () => undefined,
} as unknown as HocuspocusProvider,
sync: () => {
handlers.synced?.forEach((handler) => {
handler()
})
},
}
}
function paragraph(text: string, uid: string) {
const elem = new Y.XmlElement("paragraph")
elem.setAttribute("uid", uid)
elem.insert(0, [new Y.XmlText(text)])
return elem
}
function mountEditor(
options: { branch?: Branch; documentHooks?: DocumentHook[] } = {},
) {
const branch = options.branch ?? makeBranch()
return mountSuspended(ContentEditor, {
props: {
activeBranchProvider: branch.provider,
activeBranchYdoc: branch.ydoc,
documentHooks: options.documentHooks ?? [],
nameEditor: null,
userCaretDetails: { name: "Me", color: "#ff0000" },
},
global: { stubs: childStubs },
})
}
function editorOf(wrapper: VueWrapper) {
const emitted = wrapper.emitted("editor-ready")
const editor = emitted?.[0]?.[0]
if (!editor) {
throw new Error("the content editor never reported itself as ready")
}
return editor as {
commands: {
setTextSelection: (pos: number) => boolean
} & Record<string, (...args: unknown[]) => boolean>
}
}
// tiptap rebuilds editor.commands on every access, so a spy on one of
// them would never be the object the component calls. Shadowing the
// getter with a recording proxy is what makes those calls observable.
function watchCommands(editor: {
commands: Record<string, (...args: unknown[]) => boolean>
}): string[] {
const called: string[] = []
const real = editor.commands
const proxy = new Proxy(real, {
get(target, name: string | symbol) {
return (...args: unknown[]) => {
called.push(String(name))
const command = target[String(name)]
return command ? command(...args) : false
}
},
})
Object.defineProperty(editor, "commands", {
get: () => proxy,
configurable: true,
})
return called
}
function editingNodeUid(branch: Branch): unknown {
return branch.awareness.getLocalState()?.editingNodeUid
}
// the editable flag is a shared cookie state, the editor store and the
// viewport stub are app-wide, so these tests cannot interleave
describe("<ContentEditor>", { concurrent: false }, () => {
enableAutoUnmount(afterEach)
beforeEach(() => {
stubThemeColorContext()
stubViewportMatches(true)
clearQueryCache()
useEditorMeta().setEditable(true)
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().activateMetricBlockConfig(null)
})
afterEach(disposeMockEndpoints)
it("hands its editor to the host once it is ready", async ({ expect }) => {
const wrapper = await mountEditor()
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("renders the document without spellchecking", async ({ expect }) => {
const wrapper = await mountEditor()
await vi.waitFor(() => {
expect(wrapper.find(".ProseMirror").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.get(".ProseMirror").attributes("spellcheck")).toBe("false")
})
it("shows the content the branch carries", async ({ expect }) => {
const branch = makeBranch()
branch.ydoc
.getXmlFragment("content")
.insert(0, [paragraph("hello world", "block-1")])
const wrapper = await mountEditor({ branch: branch })
await vi.waitFor(() => {
expect(wrapper.text()).toContain("hello world")
}, WAIT_FOR_OPTIONS)
})
it("stays editable while the branch is", async ({ expect }) => {
const wrapper = await mountEditor()
expect(wrapper.classes()).not.toContain("editor-not-editable")
})
it("marks itself read-only in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountEditor()
expect(wrapper.classes()).toContain("editor-not-editable")
})
it("marks itself read-only while a diff is shown", async ({ expect }) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountEditor()
expect(wrapper.classes()).toContain("editor-not-editable")
})
it("tells collaborators which block the reader is in", async ({ expect }) => {
const branch = makeBranch()
branch.ydoc
.getXmlFragment("content")
.insert(0, [paragraph("hello world", "block-1")])
const wrapper = await mountEditor({ branch: branch })
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
editorOf(wrapper).commands.setTextSelection(3)
await nextTick()
expect(editingNodeUid(branch)).toEqual({
uid: "block-1",
type: "paragraph",
})
})
it("tells collaborators nothing for a block without an id", async ({
expect,
}) => {
const branch = makeBranch()
const elem = new Y.XmlElement("paragraph")
elem.insert(0, [new Y.XmlText("hello")])
branch.ydoc.getXmlFragment("content").insert(0, [elem])
const wrapper = await mountEditor({ branch: branch })
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
editorOf(wrapper).commands.setTextSelection(2)
await nextTick()
expect(editingNodeUid(branch)).toBeNull()
})
it("tells collaborators which metric block is being configured", async ({
expect,
}) => {
const branch = makeBranch()
const wrapper = await mountEditor({ branch: branch })
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
useEditorStore().activateMetricBlockConfig("metric-1")
await nextTick()
expect(editingNodeUid(branch)).toEqual({
uid: "metric-1",
type: METRIC_BLOCK_NAME,
})
})
it("tells collaborators the metric block was closed again", async ({
expect,
}) => {
const branch = makeBranch()
const wrapper = await mountEditor({ branch: branch })
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
useEditorStore().activateMetricBlockConfig("metric-1")
await nextTick()
useEditorStore().activateMetricBlockConfig(null)
await nextTick()
expect(editingNodeUid(branch)).toBeNull()
})
it("tells collaborators nothing once it is gone", async ({ expect }) => {
const branch = makeBranch()
branch.ydoc
.getXmlFragment("content")
.insert(0, [paragraph("hello world", "block-1")])
const wrapper = await mountEditor({ branch: branch })
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
editorOf(wrapper).commands.setTextSelection(3)
wrapper.unmount()
expect(editingNodeUid(branch)).toBeNull()
})
it("refreshes the hook highlights when the hooks change", async ({
expect,
}) => {
const wrapper = await mountEditor()
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
const called = watchCommands(editorOf(wrapper))
await wrapper.setProps({
documentHooks: [
{
id: "hook-1",
blockId: "block-1",
score: "0",
} as unknown as DocumentHook,
],
})
expect(called).toEqual(["refreshHookDecorations"])
})
it("redraws the comment indicators when the diff is turned off", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountEditor()
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
const called = watchCommands(editorOf(wrapper))
useEditorStore().setReviewableDiffActive(false)
await nextTick()
await nextTick()
expect(called).toEqual([
"refreshNodeCommentOverlays",
"refreshTextCommentIndicators",
])
})
it("leaves the indicators alone when the diff is turned on", async ({
expect,
}) => {
const wrapper = await mountEditor()
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
const called = watchCommands(editorOf(wrapper))
useEditorStore().setReviewableDiffActive(true)
await nextTick()
await nextTick()
expect(called).toEqual([])
})
it("passes a settings request from the block handle on", async ({
expect,
}) => {
const wrapper = await mountEditor()
await vi.waitFor(() => {
expect(wrapper.emitted("editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
emitFrom(wrapper, BlockHandle, "open-settings", "github")
await nextTick()
expect(wrapper.emitted("open-settings")).toEqual([["github"]])
})
})

View File

@ -0,0 +1,399 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import DocumentContainer from "./DocumentContainer.client.vue"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
seedQueryData,
} from "~/composables/api/test-helpers"
import { at, emitFrom, seedAuthSession } from "~/components/test-helpers"
import { stubThemeColorContext } from "./test-helpers/theme"
// the real provider opens a websocket to the realtime service; the
// stand-in records what it was asked to sync and lets the suite fire the
// callbacks the container passes it
interface ProviderOptions {
name: string
onSynced: () => void
onConnect: () => void
onAuthenticationFailed: () => void
onStateless: (message: { payload: string }) => void
}
interface ProviderEntry {
name: string
options: ProviderOptions
destroy: () => void
destroyed: boolean
}
const providers = vi.hoisted(() => {
return {
created: [] as ProviderEntry[],
}
})
vi.mock("@hocuspocus/provider", () => {
function FakeProvider(options: ProviderOptions): ProviderEntry {
const entry: ProviderEntry = {
name: options.name,
options: options,
destroy: () => {
entry.destroyed = true
},
destroyed: false,
}
providers.created.push(entry)
return entry
}
return { HocuspocusProvider: FakeProvider }
})
const redirectToLogin = vi.hoisted(() => vi.fn())
vi.mock("~/plugins/03.api-fetch", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
redirectToLogin: redirectToLogin,
}))
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("brancha")
const TARGET_BRANCH_ID = makeXid("branchb")
// the three editors each drive a live tiptap instance of their own; the
// container's contract with them is which of them it shows and what it
// does with the editors they hand back
const editorStubs = {
NameEditor: true,
ContentEditor: true,
DiffEditor: true,
}
function seedBranches(...branches: { branchId: string; protected: boolean }[]) {
seedQueryData(["documents", DOCUMENT_ID, "branches"], branches)
}
function mountContainer(props: Record<string, unknown> = {}) {
return mountSuspended(DocumentContainer, {
props: { allInitialSectionsLoaded: true, timestamps: {}, ...props },
global: { stubs: editorStubs },
})
}
function providerFor(branchId: string) {
const entry = providers.created.find(
(candidate) => candidate.name === `${DOCUMENT_ID}-${branchId}`,
)
if (!entry) {
throw new Error(`no provider was created for branch ${branchId}`)
}
return entry
}
async function sync(branchId: string) {
providerFor(branchId).options.onSynced()
await nextTick()
}
function bodyShown(wrapper: VueWrapper): boolean {
return wrapper.findComponent({ name: "NameEditor" }).exists()
}
// the editor store, the auth and query caches and the recorded providers
// are all shared, so these tests cannot interleave
describe("<DocumentContainer>", { concurrent: false }, () => {
// a container keeps syncing its branches for as long as it is mounted,
// so a leftover one would answer for the next test too
enableAutoUnmount(afterEach)
beforeEach(() => {
stubThemeColorContext()
providers.created.splice(0)
redirectToLogin.mockReset()
clearQueryCache()
useEditorMeta().setEditable(true)
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().updateTargetBranchId(null)
useEditorStore().updatePreloadedBranchIds([])
useEditorStore().setReviewableDiffActive(false)
seedAuthSession({ id: makeXid("usme"), name: "Me" })
seedBranches({ branchId: BRANCH_ID, protected: false })
seedQueryData(["documents", "hooks", DOCUMENT_ID, BRANCH_ID], [])
})
afterEach(disposeMockEndpoints)
it("syncs the active branch under the document and branch id", async ({
expect,
}) => {
await mountContainer()
expect(providers.created.map((entry) => entry.name)).toEqual([
`${DOCUMENT_ID}-${BRANCH_ID}`,
])
})
it("syncs the preloaded branches too", async ({ expect }) => {
useEditorStore().updatePreloadedBranchIds([TARGET_BRANCH_ID])
await mountContainer()
expect(providers.created.map((entry) => entry.name)).toContain(
`${DOCUMENT_ID}-${TARGET_BRANCH_ID}`,
)
})
it("syncs the branch a diff compares against", async ({ expect }) => {
useEditorStore().updateTargetBranchId(TARGET_BRANCH_ID)
await mountContainer()
expect(providers.created.map((entry) => entry.name)).toContain(
`${DOCUMENT_ID}-${TARGET_BRANCH_ID}`,
)
})
it("syncs each branch only once", async ({ expect }) => {
useEditorStore().updatePreloadedBranchIds([BRANCH_ID])
useEditorStore().updateTargetBranchId(BRANCH_ID)
await mountContainer()
expect(providers.created).toHaveLength(1)
})
it("syncs nothing while no page is open", async ({ expect }) => {
useEditorStore().updateActiveDocumentId(null)
await mountContainer()
expect(providers.created).toHaveLength(0)
})
it("stays empty until the active branch has synced", async ({ expect }) => {
const wrapper = await mountContainer()
expect(bodyShown(wrapper)).toBe(false)
})
it("shows the editors once the active branch has synced", async ({
expect,
}) => {
const wrapper = await mountContainer()
await sync(BRANCH_ID)
expect(bodyShown(wrapper)).toBe(true)
expect(wrapper.findComponent({ name: "ContentEditor" }).exists()).toBe(true)
})
it("stays empty until the sidebar has finished loading", async ({
expect,
}) => {
const wrapper = await mountContainer({ allInitialSectionsLoaded: false })
await sync(BRANCH_ID)
expect(bodyShown(wrapper)).toBe(false)
})
it("reports the first sync as the initial load", async ({ expect }) => {
const wrapper = await mountContainer()
await sync(BRANCH_ID)
expect(wrapper.emitted("initial-load-complete")).toHaveLength(1)
})
it("treats a preloaded branch's sync as no initial load", async ({
expect,
}) => {
useEditorStore().updateTargetBranchId(TARGET_BRANCH_ID)
const wrapper = await mountContainer()
await sync(TARGET_BRANCH_ID)
expect(wrapper.emitted("initial-load-complete")).toBeUndefined()
})
it("sends the reader to the login page when the sync is rejected", async ({
expect,
}) => {
await mountContainer()
providerFor(BRANCH_ID).options.onAuthenticationFailed()
expect(redirectToLogin).toHaveBeenCalledTimes(1)
})
it("reports an error the realtime service sends", async ({ expect }) => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {
return undefined
})
await mountContainer()
providerFor(BRANCH_ID).options.onStateless({
payload: JSON.stringify({ type: "error" }),
})
expect(consoleError).toHaveBeenCalledTimes(1)
})
it("stays quiet for any other realtime message", async ({ expect }) => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {
return undefined
})
await mountContainer()
providerFor(BRANCH_ID).options.onStateless({
payload: JSON.stringify({ type: "info" }),
})
expect(consoleError).toHaveBeenCalledTimes(0)
})
it("keeps a protected branch read-only", async ({ expect }) => {
seedBranches({ branchId: BRANCH_ID, protected: true })
await mountContainer()
expect(useEditorMeta().isEditable.value).toBe(false)
})
it("lets an unprotected branch be edited", async ({ expect }) => {
await mountContainer()
expect(useEditorMeta().isEditable.value).toBe(true)
})
it("keeps an unknown branch read-only", async ({ expect }) => {
seedBranches({ branchId: TARGET_BRANCH_ID, protected: false })
await mountContainer()
expect(useEditorMeta().isEditable.value).toBe(false)
})
it("passes the content editor on once it is ready", async ({ expect }) => {
const wrapper = await mountContainer()
await sync(BRANCH_ID)
const editor = {
chain: () => ({ run: () => true }),
commands: { refreshGapDecorations: vi.fn() },
setEditable: vi.fn(),
}
emitFrom(wrapper, "ContentEditor", "editor-ready", editor)
await nextTick()
expect(wrapper.emitted("editor-ready")).toEqual([[editor]])
})
it("passes the name editor on once it is ready", async ({ expect }) => {
const wrapper = await mountContainer()
await sync(BRANCH_ID)
const editor = { setEditable: vi.fn() }
emitFrom(wrapper, "NameEditor", "editor-ready", editor)
await nextTick()
expect(wrapper.emitted("name-editor-ready")).toEqual([[editor]])
})
it("makes the editors read-only on a protected branch", async ({
expect,
}) => {
seedBranches({ branchId: BRANCH_ID, protected: true })
const wrapper = await mountContainer()
await sync(BRANCH_ID)
const editor = { setEditable: vi.fn() }
emitFrom(wrapper, "NameEditor", "editor-ready", editor)
await nextTick()
expect(editor.setEditable).toHaveBeenCalledWith(false)
})
it("hides the content editor and shows the diff while a diff is on", async ({
expect,
}) => {
useEditorStore().updateTargetBranchId(TARGET_BRANCH_ID)
const wrapper = await mountContainer()
await sync(BRANCH_ID)
// the diff renders against the content editor, so it only appears
// once ContentEditor has handed one over
emitFrom(wrapper, "ContentEditor", "editor-ready", {
chain: () => ({ run: () => true }),
commands: { refreshGapDecorations: vi.fn() },
setEditable: vi.fn(),
})
useEditorStore().setReviewableDiffActive(true)
await nextTick()
expect(wrapper.findComponent({ name: "DiffEditor" }).exists()).toBe(true)
})
it("shows no diff without a branch to compare against", async ({
expect,
}) => {
const wrapper = await mountContainer()
await sync(BRANCH_ID)
useEditorStore().setReviewableDiffActive(true)
await nextTick()
expect(wrapper.findComponent({ name: "DiffEditor" }).exists()).toBe(false)
})
it("shows no diff of a branch against itself", async ({ expect }) => {
useEditorStore().updateTargetBranchId(BRANCH_ID)
const wrapper = await mountContainer()
await sync(BRANCH_ID)
useEditorStore().setReviewableDiffActive(true)
await nextTick()
expect(wrapper.findComponent({ name: "DiffEditor" }).exists()).toBe(false)
})
it("passes a live icon change on", async ({ expect }) => {
const wrapper = await mountContainer()
await sync(BRANCH_ID)
emitFrom(wrapper, "NameEditor", "updated-live-icon", "mingcute:at-fill")
await nextTick()
expect(wrapper.emitted("updated-live-icon")).toEqual([["mingcute:at-fill"]])
})
it("passes a settings request on", async ({ expect }) => {
const wrapper = await mountContainer()
await sync(BRANCH_ID)
emitFrom(wrapper, "ContentEditor", "open-settings", "github")
await nextTick()
expect(wrapper.emitted("open-settings")).toEqual([["github"]])
})
it("closes every branch sync when it is taken down", async ({ expect }) => {
useEditorStore().updatePreloadedBranchIds([TARGET_BRANCH_ID])
const wrapper = await mountContainer()
wrapper.unmount()
expect(providers.created.map((entry) => entry.destroyed)).toEqual([
true,
true,
])
expect(at(providers.created, 0).destroyed).toBe(true)
})
})

View File

@ -296,7 +296,8 @@ function showSettings(target: "github") {
v-if="
editorStore.reviewableDiffActive &&
targetBranchProvider &&
activeBranchProvider !== targetBranchProvider
activeBranchProvider !== targetBranchProvider &&
contentEditor
"
:target-branch-ydoc="targetBranchProvider.ydoc"
:active-branch-ydoc="activeBranchProvider.ydoc"

View File

@ -0,0 +1,95 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { beforeEach, describe, it } from "vitest"
import IconPicker from "./IconPicker.vue"
import { renderedIconNames } from "~/components/test-helpers"
function mountPicker(props: Record<string, unknown> = {}) {
return mountSuspended(IconPicker, { props: { icon: null, ...props } })
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<IconPicker>", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("shows the icon it was given", async ({ expect }) => {
const wrapper = await mountPicker({ icon: "mingcute:tag-fill" })
expect(renderedIconNames(wrapper)).toEqual(["mingcute:tag-fill"])
})
it("shows nothing while there is no icon", async ({ expect }) => {
const wrapper = await mountPicker()
expect(renderedIconNames(wrapper)).toEqual([])
})
it("stays inert while there is no icon to change", async ({ expect }) => {
const wrapper = await mountPicker()
expect(wrapper.get("button").attributes("data-disabled")).toBe("")
})
it("can be pressed once it has an icon", async ({ expect }) => {
const wrapper = await mountPicker({ icon: "mingcute:tag-fill" })
expect(wrapper.get("button").attributes("data-disabled")).toBeUndefined()
})
it("stays inert in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountPicker({ icon: "mingcute:tag-fill" })
expect(wrapper.get("button").attributes("data-disabled")).toBe("")
})
it("stays inert while a reviewable diff is shown", async ({ expect }) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountPicker({ icon: "mingcute:tag-fill" })
expect(wrapper.get("button").attributes("data-disabled")).toBe("")
})
it("renders at full size by default", async ({ expect }) => {
const wrapper = await mountPicker({ icon: "mingcute:tag-fill" })
expect(wrapper.get("button").attributes("data-small")).toBe("false")
})
it("renders small when the host asks for it", async ({ expect }) => {
const wrapper = await mountPicker({
icon: "mingcute:tag-fill",
size: "icon-xsm",
})
expect(wrapper.get("button").attributes("data-small")).toBe("true")
})
it("highlights an icon a diff changed", async ({ expect }) => {
const wrapper = await mountPicker({
icon: "mingcute:tag-fill",
isModified: true,
})
expect(wrapper.get("button").classes()).toContain("bg-diff-added")
})
it("leaves an unchanged icon unhighlighted", async ({ expect }) => {
const wrapper = await mountPicker({ icon: "mingcute:tag-fill" })
expect(wrapper.get("button").classes()).not.toContain("bg-diff-added")
})
it("reports nothing on its own when pressed", async ({ expect }) => {
const wrapper = await mountPicker({ icon: "mingcute:tag-fill" })
await wrapper.get("button").trigger("click")
expect(wrapper.emitted("select")).toBeUndefined()
})
})

View File

@ -0,0 +1,218 @@
import { enableAutoUnmount, flushPromises } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import IconPickerProvider from "./IconPickerProvider.vue"
import IconPicker from "./IconPicker.vue"
import { mountUnderTooltipProvider, t } from "~/components/test-helpers"
// the real list is virtualized off the rendered row heights, which
// happy-dom reports as zero — nothing would ever be drawn. The stand-in
// renders every row the picker hands it.
vi.mock("vue-virtual-scroller", async () => {
const { defineComponent, h } = await import("vue")
return {
RecycleScroller: defineComponent({
name: "RecycleScroller",
props: { items: { type: Array, required: true } },
setup:
(props, { slots }) =>
() =>
h(
"div",
(props.items as { id: string }[]).map((item) =>
slots.default?.({ item }),
),
),
}),
}
})
// the provider installs the icon-picker context that its children reach
// through useIconPicker, so an IconPicker inside the slot is what opens
// it. Each icon in the list carries a tooltip, whose context the app
// installs once at page level.
function mountProvider() {
return mountUnderTooltipProvider(IconPickerProvider, {
slots: {
default: () => h(IconPicker, { icon: "mingcute:tag-fill" }),
},
})
}
// the picker positions itself with an async floating-ui call, so a test
// that leaves it half-open trips over the teardown
async function openPicker(wrapper: Awaited<ReturnType<typeof mountProvider>>) {
await wrapper.get("button").trigger("click")
await flushPromises()
}
function popover(): HTMLElement | null {
return document.body.querySelector(".z-dropdown")
}
function popoverOpen(): boolean {
return popover()?.classList.contains("opacity-100") ?? false
}
function filterInput(): HTMLInputElement | null {
return popover()?.querySelector("input") ?? null
}
async function typeFilter(value: string) {
const input = filterInput()
if (!input) {
throw new Error("the icon picker is not open")
}
input.value = value
input.dispatchEvent(new Event("input", { bubbles: true }))
await nextTick()
}
// the picker body is teleported into a shared <body> and vue keeps
// patching it while the provider is mounted, so these tests cannot
// interleave and each provider is taken down properly
describe("<IconPickerProvider>", { concurrent: false }, () => {
enableAutoUnmount(afterEach)
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("renders its children", async ({ expect }) => {
const wrapper = await mountProvider()
expect(wrapper.findComponent(IconPicker).exists()).toBe(true)
})
it("keeps the picker out of the way until it is asked for", async ({
expect,
}) => {
await mountProvider()
expect(popoverOpen()).toBe(false)
})
it("opens the picker for the child that asked", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
expect(popoverOpen()).toBe(true)
})
it("offers a filter over the icon list", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
expect(filterInput()?.placeholder).toBe(
t("editor.icon-select-menu.input-placeholder"),
)
})
it("says so when nothing matches the filter", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
await typeFilter("definitely-not-an-icon")
expect(popover()?.textContent).toContain(
t("editor.icon-select-menu.no-results"),
)
})
it("keeps only the icons matching the filter", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
const total = popover()?.querySelectorAll("button").length ?? 0
await typeFilter("tag")
const filtered = popover()?.querySelectorAll("button").length ?? 0
expect(filtered).toBeGreaterThan(0)
expect(filtered).toBeLessThan(total)
})
it("hands the chosen icon to the child that asked", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
await typeFilter("tag 2")
popover()?.querySelectorAll("button")[0]?.click()
await nextTick()
expect(wrapper.findComponent(IconPicker).emitted("select")).toEqual([
["mingcute:tag-2-fill"],
])
expect(popoverOpen()).toBe(false)
})
it("closes the picker on escape", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
await nextTick()
expect(popoverOpen()).toBe(false)
})
it("stays open on any other key", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
document.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }))
await nextTick()
expect(popoverOpen()).toBe(true)
})
it("closes the picker on a click outside it", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))
await nextTick()
expect(popoverOpen()).toBe(false)
})
it("stays open while clicking inside it", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
popover()?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))
await nextTick()
expect(popoverOpen()).toBe(true)
})
it("stays open while clicking the trigger that opened it", async ({
expect,
}) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
wrapper
.get("button")
.element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))
await nextTick()
expect(popoverOpen()).toBe(true)
})
it("clears the filter each time it opens", async ({ expect }) => {
const wrapper = await mountProvider()
await openPicker(wrapper)
await typeFilter("tag")
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
await nextTick()
await openPicker(wrapper)
expect(filterInput()?.value).toBe("")
})
})

View File

@ -0,0 +1,149 @@
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import IconStack, { type IconMetadata } from "./IconStack.vue"
import {
clearTeleportedOverlays,
mountUnderTooltipProvider,
renderedIconNames,
t,
} from "~/components/test-helpers"
function person(
name: string,
overrides: Partial<IconMetadata> = {},
): IconMetadata {
return { id: `id-${name}`, name: name, ...overrides }
}
function mountStack(props: Record<string, unknown>) {
return mountUnderTooltipProvider(IconStack, {
props: { title: t("editor.name-editor.maintainers"), ...props },
})
}
// each avatar renders either an image, an icon or the person's initials
function avatarLabels(wrapper: VueWrapper): string[] {
return wrapper.findAll("li").map((item) => item.text())
}
// the tooltip bodies are teleported into a shared <body>
describe("<IconStack>", { concurrent: false }, () => {
beforeEach(clearTeleportedOverlays)
it("names what the stack is showing", async ({ expect }) => {
const wrapper = await mountStack({ icons: [] })
expect(wrapper.get("span").text()).toBe(t("editor.name-editor.maintainers"))
})
it("shows a single placeholder while the stack is empty", async ({
expect,
}) => {
const wrapper = await mountStack({ icons: [] })
expect(wrapper.findAll("li")).toHaveLength(1)
expect(renderedIconNames(wrapper)).toEqual(["lucide:plus"])
})
it("shows the placeholder icon the host asked for", async ({ expect }) => {
const wrapper = await mountStack({ icons: [], default: "lucide:user" })
expect(renderedIconNames(wrapper)).toEqual(["lucide:user"])
})
it("falls back to a person's initials", async ({ expect }) => {
const wrapper = await mountStack({ icons: [person("Ada Lovelace")] })
expect(avatarLabels(wrapper)).toEqual(["AL"])
})
it("shows a person's picture when they have one", async ({ expect }) => {
const wrapper = await mountStack({
icons: [person("Ada", { url: "https://cdn.test/ada.png" })],
})
expect(wrapper.get("img").attributes("src")).toBe(
"https://cdn.test/ada.png",
)
})
it("shows an entry's icon when it has one", async ({ expect }) => {
const wrapper = await mountStack({
icons: [person("Reminder", { icon: "lucide:bell" })],
})
expect(renderedIconNames(wrapper)).toEqual(["lucide:bell"])
})
it("shows at most three entries", async ({ expect }) => {
const wrapper = await mountStack({
icons: [person("A"), person("B"), person("C"), person("D")],
})
expect(avatarLabels(wrapper).slice(0, 3)).toEqual(["A", "B", "C"])
})
it("counts the entries it could not fit", async ({ expect }) => {
const wrapper = await mountStack({
icons: [person("A"), person("B"), person("C"), person("D"), person("E")],
})
expect(wrapper.text()).toContain("+2")
})
it("counts nothing when everything fits", async ({ expect }) => {
const wrapper = await mountStack({ icons: [person("A"), person("B")] })
expect(wrapper.text()).not.toContain("+")
})
it("marks an approved entry", async ({ expect }) => {
const wrapper = await mountStack({
icons: [person("Ada", { approved: true })],
})
expect(renderedIconNames(wrapper)).toEqual(["lucide:check"])
})
it("puts stale hooks first", async ({ expect }) => {
const wrapper = await mountStack({
icons: [
person("Fresh", { icon: "lucide:bell" }),
person("Stale", { icon: "lucide:bell", staleHook: true }),
],
})
expect(avatarLabels(wrapper).at(0)).toBe("")
expect(wrapper.findAll("[data-hook-stale]")).toHaveLength(1)
})
it("orders hooks of equal freshness by when they last ran", async ({
expect,
}) => {
const wrapper = await mountStack({
icons: [
person("Newer", { hookUpdatedAt: new Date("2026-02-01T00:00:00Z") }),
person("Older", { hookUpdatedAt: new Date("2026-01-01T00:00:00Z") }),
],
})
expect(avatarLabels(wrapper)).toEqual(["O", "N"])
})
it("marks itself clickable when the host says so", async ({ expect }) => {
const wrapper = await mountStack({ icons: [], clickable: true })
expect(wrapper.get("div").classes()).toContain("cursor-pointer")
})
it("disables its avatars when the host says so", async ({ expect }) => {
const wrapper = await mountStack({
icons: [person("Ada")],
disabled: true,
})
expect(
wrapper.get("[data-slot='avatar']").attributes("data-disabled"),
).toBe("")
})
})

View File

@ -116,6 +116,7 @@ const extra = computed(() => Math.max(0, props.icons.length - 3))
<ShadcnUiAvatar class="size-6 border">
<ShadcnUiAvatarFallback>
<i18n-t
scope="global"
keypath="editor.icon-stack.more-indicator"
tag="div"
class="items-center justify-center text-xs font-normal"

View File

@ -0,0 +1,178 @@
import type { VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import MaintainerList from "./MaintainerList.vue"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
mountUnderTooltipProvider,
seedAuthOrganization,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
import type WsState from "~/utils/websocket"
const DOCUMENT_ID = makeXid("doc")
function member(id: string, name: string, image?: string) {
return { userId: id, user: { name: name, image: image } }
}
function seedMembers(...members: ReturnType<typeof member>[]) {
seedAuthOrganization({ members: members })
}
// the maintainer avatars each carry a tooltip, whose context the app
// installs once at page level
function mountList() {
return mountUnderTooltipProvider(MaintainerList, {})
}
// the trigger and the popover both list the maintainers; the trigger is
// the only part inside the wrapper
function stackNames(wrapper: VueWrapper): string[] {
return wrapper.findAll("li").map((item) => item.text())
}
// the editor store, the auth cache and the websocket store are shared
// app-wide, so these tests cannot interleave
describe("<MaintainerList>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
clearQueryCache()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useWebSocketStateStore().state = null
})
afterEach(disposeMockEndpoints)
it("shows nothing while the page has no maintainers", async ({ expect }) => {
mockEndpoint("GET", `/api/documents/${DOCUMENT_ID}/maintainers`, () => [])
seedMembers(member(makeXid("usr"), "Ada"))
const wrapper = await mountList()
expect(wrapper.text()).toBe("")
})
it("names the maintainers of the page", async ({ expect }) => {
const adaId = makeXid("usa")
mockEndpoint("GET", `/api/documents/${DOCUMENT_ID}/maintainers`, () => [
adaId,
])
seedMembers(member(adaId, "Ada"), member(makeXid("usb"), "Grace"))
const wrapper = await mountList()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(t("editor.name-editor.maintainers"))
}, WAIT_FOR_OPTIONS)
expect(stackNames(wrapper)).toEqual(["A"])
})
it("leaves out members who do not maintain the page", async ({ expect }) => {
const adaId = makeXid("usa")
mockEndpoint("GET", `/api/documents/${DOCUMENT_ID}/maintainers`, () => [
adaId,
])
seedMembers(member(adaId, "Ada"), member(makeXid("usb"), "Grace"))
const wrapper = await mountList()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(t("editor.name-editor.maintainers"))
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).not.toContain("G")
})
it("lists every maintainer in the popover", async ({ expect }) => {
const adaId = makeXid("usa")
const graceId = makeXid("usb")
mockEndpoint("GET", `/api/documents/${DOCUMENT_ID}/maintainers`, () => [
adaId,
graceId,
])
seedMembers(member(adaId, "Ada"), member(graceId, "Grace"))
const wrapper = await mountList()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(t("editor.name-editor.maintainers"))
}, WAIT_FOR_OPTIONS)
await wrapper.get("[data-slot='popover-trigger']").trigger("click")
await nextTick()
expect(document.body.textContent).toContain("Ada")
expect(document.body.textContent).toContain("Grace")
})
it("shows a maintainer's picture when they have one", async ({ expect }) => {
const adaId = makeXid("usa")
mockEndpoint("GET", `/api/documents/${DOCUMENT_ID}/maintainers`, () => [
adaId,
])
seedMembers(member(adaId, "Ada", "https://cdn.test/ada.png"))
const wrapper = await mountList()
await vi.waitFor(() => {
expect(wrapper.find("img").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.get("img").attributes("src")).toBe(
"https://cdn.test/ada.png",
)
})
it("refetches the maintainers when the server says they changed", async ({
expect,
}) => {
const adaId = makeXid("usa")
const calls = mockEndpoint(
"GET",
`/api/documents/${DOCUMENT_ID}/maintainers`,
() => [adaId],
)
seedMembers(member(adaId, "Ada"))
const handlers: (() => void)[] = []
const subscribe = vi.fn((_topic: string, handler: () => void) => {
handlers.push(handler)
return () => undefined
})
useWebSocketStateStore().state = {
subscribe: subscribe,
} as unknown as WsState
await mountList()
await vi.waitFor(() => {
expect(calls.length).toBeGreaterThan(0)
}, WAIT_FOR_OPTIONS)
const before = calls.length
handlers.forEach((handler) => {
handler()
})
await vi.waitFor(() => {
expect(calls.length).toBeGreaterThan(before)
}, WAIT_FOR_OPTIONS)
expect(subscribe).toHaveBeenCalledTimes(1)
expect(subscribe.mock.calls[0]?.[0]).toBe(
makeWsDocumentMaintainersChangeTopic(DOCUMENT_ID),
)
})
it("subscribes to nothing while no page is open", async ({ expect }) => {
useEditorStore().updateActiveDocumentId(null)
const subscribe = vi.fn()
useWebSocketStateStore().state = {
subscribe: subscribe,
} as unknown as WsState
await mountList()
expect(subscribe).toHaveBeenCalledTimes(0)
})
})

View File

@ -0,0 +1,509 @@
import { registerEndpoint } from "@nuxt/test-utils/runtime"
import type { HocuspocusProvider } from "@hocuspocus/provider"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import { Awareness } from "y-protocols/awareness"
import * as Y from "yjs"
import NameEditor from "./NameEditor.vue"
import IconPicker from "./IconPicker.vue"
import DiffTitle from "./diff/DiffTitle.vue"
import ReviewerList from "./ReviewerList.vue"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
seedQueryData,
trackEndpointDisposal,
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
emitFrom,
findButtonByText,
menuItem,
mountUnderTooltipProvider,
seedAuthOrganization,
seedAuthSession,
settleMutations,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
vi.mock("vue-sonner", () => ({
toast: { custom: vi.fn(), dismiss: vi.fn() },
}))
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("brancha")
const TARGET_BRANCH_ID = makeXid("branchb")
const ME = makeXid("usme")
const REVIEWABLE_ACTION_DELAY_MS = 300
interface Branch {
ydoc: Y.Doc
provider: HocuspocusProvider
}
function makeBranch(icon = "mingcute:tag-fill"): Branch {
const ydoc = new Y.Doc()
ydoc.getText("icon").insert(0, icon)
return {
ydoc: ydoc,
provider: {
document: ydoc,
awareness: new Awareness(ydoc),
} as unknown as HocuspocusProvider,
}
}
function titleOf(ydoc: Y.Doc, text: string) {
const paragraph = new Y.XmlElement("paragraph")
paragraph.insert(0, [new Y.XmlText(text)])
ydoc.getXmlFragment("name").insert(0, [paragraph])
}
// the merge request goes through the auth-realtime client, which asks for
// an absolute url; the path spelling is the one the test-time app answers
function mockMergeEndpoint(respond: () => unknown) {
const calls: unknown[] = []
const handler = () => {
calls.push(true)
return respond()
}
trackEndpointDisposal(
registerEndpoint(
`http://test.local/auth-realtime/api/documents/${DOCUMENT_ID}/merge`,
{ method: "PUT", handler: handler },
),
)
trackEndpointDisposal(
registerEndpoint(`/auth-realtime/api/documents/${DOCUMENT_ID}/merge`, {
method: "PUT",
handler: handler,
}),
)
return calls
}
function mountEditor(
options: {
active?: Branch
target?: Branch | null
documentHooks?: DocumentHook[]
contentEditor?: unknown
} = {},
) {
const active = options.active ?? makeBranch()
// the hook icon stack carries tooltips, whose context the app installs
// once at page level
return mountUnderTooltipProvider(NameEditor, {
props: {
documentHooks: options.documentHooks ?? [],
activeBranchYdoc: active.ydoc,
activeBranchProvider: active.provider,
targetBranchProvider: options.target?.provider ?? null,
contentEditor: options.contentEditor ?? null,
userCaretDetails: { name: "Me", color: "#ff0000" },
timestamps: {},
},
})
}
async function openActionMenu(wrapper: VueWrapper) {
const trigger = actionMenuTrigger(wrapper)
await trigger?.trigger("pointerdown", { button: 0 })
await trigger?.trigger("click")
await nextTick()
}
// the icon picker comes first, then the review action and the menu that
// switches between the review actions
function actionMenuTrigger(wrapper: VueWrapper) {
return wrapper.findAll("button")[2]
}
// the wrapper is the tooltip provider the editor is mounted inside, so
// the editor's own events are read off the component
function emittedFrom(wrapper: VueWrapper, event: string): unknown[][] {
return wrapper.findComponent(NameEditor).emitted(event) ?? []
}
// the editor store, the auth and query caches, the mocked toast module
// and the teleported overlays are all shared, so these tests cannot
// interleave
describe("<NameEditor>", { concurrent: false }, () => {
enableAutoUnmount(afterEach)
beforeEach(() => {
clearTeleportedOverlays()
clearQueryCache()
vi.mocked(toast.custom).mockReset()
useEditorMeta().setEditable(true)
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().updateTargetBranchId(TARGET_BRANCH_ID)
useEditorStore().setBranchReviewableActionsActive(false)
useEditorStore().setReviewableDiffActive(false)
seedAuthSession({ id: ME, name: "Me" })
seedAuthOrganization({ members: [] })
seedQueryData(["documents", DOCUMENT_ID, "branches"], [{ id: BRANCH_ID }])
seedQueryData(
["documents", DOCUMENT_ID, "branches", BRANCH_ID, "reviewers"],
[],
)
})
afterEach(() => {
vi.useRealTimers()
disposeMockEndpoints()
})
it("shows the icon the branch carries", async ({ expect }) => {
const wrapper = await mountEditor({
active: makeBranch("mingcute:at-fill"),
})
expect(wrapper.findComponent(IconPicker).props("icon")).toBe(
"mingcute:at-fill",
)
})
it("stores a newly picked icon on the branch", async ({ expect }) => {
const active = makeBranch()
const wrapper = await mountEditor({ active: active })
emitFrom(wrapper, IconPicker, "select", "mingcute:hashtag-fill")
await nextTick()
expect(active.ydoc.getText("icon").toJSON()).toBe("mingcute:hashtag-fill")
expect(emittedFrom(wrapper, "updated-live-icon").at(-1)).toEqual([
"mingcute:hashtag-fill",
])
})
it("follows an icon change made by a collaborator", async ({ expect }) => {
const active = makeBranch()
const wrapper = await mountEditor({ active: active })
active.ydoc.getText("icon").delete(0, active.ydoc.getText("icon").length)
active.ydoc.getText("icon").insert(0, "mingcute:at-fill")
await nextTick()
expect(wrapper.findComponent(IconPicker).props("icon")).toBe(
"mingcute:at-fill",
)
})
it("marks the icon as changed only while a diff is shown", async ({
expect,
}) => {
const wrapper = await mountEditor({
active: makeBranch("mingcute:at-fill"),
target: makeBranch("mingcute:tag-fill"),
})
expect(wrapper.findComponent(IconPicker).props("isModified")).toBe(false)
useEditorStore().setReviewableDiffActive(true)
await nextTick()
expect(wrapper.findComponent(IconPicker).props("isModified")).toBe(true)
})
it("hands the name editor to the host once it is ready", async ({
expect,
}) => {
const wrapper = await mountEditor()
await vi.waitFor(() => {
expect(emittedFrom(wrapper, "editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("shows the name the branch carries", async ({ expect }) => {
const active = makeBranch()
titleOf(active.ydoc, "Payments runbook")
const wrapper = await mountEditor({ active: active })
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Payments runbook")
}, WAIT_FOR_OPTIONS)
})
it("reports the name as it is edited", async ({ expect }) => {
const active = makeBranch()
const wrapper = await mountEditor({ active: active })
await vi.waitFor(() => {
expect(emittedFrom(wrapper, "editor-ready")).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
titleOf(active.ydoc, "Renamed")
await vi.waitFor(() => {
expect(emittedFrom(wrapper, "updated-live-name").at(-1)).toEqual([
"Renamed",
])
}, WAIT_FOR_OPTIONS)
})
it("shows the title diff instead of the editor while a diff is on", async ({
expect,
}) => {
const wrapper = await mountEditor({ target: makeBranch() })
expect(wrapper.findComponent(DiffTitle).exists()).toBe(false)
useEditorStore().setReviewableDiffActive(true)
await nextTick()
expect(wrapper.findComponent(DiffTitle).exists()).toBe(true)
})
it("keeps the plain title when there is nothing to diff against", async ({
expect,
}) => {
const wrapper = await mountEditor({ target: null })
useEditorStore().setReviewableDiffActive(true)
await nextTick()
expect(wrapper.findComponent(DiffTitle).exists()).toBe(false)
})
it("shows the page's own hooks", async ({ expect }) => {
const wrapper = await mountEditor({
documentHooks: [
{
id: "hook-1",
type: DocumentHookType.URLWatcher,
blockId: null,
score: "100",
settings: { url: "https://oxynote.test" },
state: { status: "active" },
} as unknown as DocumentHook,
{
id: "hook-2",
type: DocumentHookType.URLWatcher,
blockId: "block-1",
score: "100",
settings: { url: "https://other.test" },
state: { status: "active" },
} as unknown as DocumentHook,
],
})
expect(wrapper.text()).toContain(t("editor.name-editor.hooks"))
expect(wrapper.find(".bg-hook-decoration").attributes("style")).toContain(
"display: none",
)
})
it("marks the page when one of its hooks has fired", async ({ expect }) => {
const wrapper = await mountEditor({
documentHooks: [
{
id: "hook-1",
type: DocumentHookType.URLWatcher,
blockId: null,
score: "0",
settings: { url: "https://oxynote.test" },
state: { status: "active" },
} as unknown as DocumentHook,
],
})
expect(
wrapper.find(".bg-hook-decoration").attributes("style"),
).toBeUndefined()
})
it("offers no review actions while the branch has none", async ({
expect,
}) => {
const wrapper = await mountEditor()
expect(wrapper.text()).not.toContain(
t("editor.name-editor.review-workflow.approve.title"),
)
expect(wrapper.text()).not.toContain(
t("editor.name-editor.review-workflow.show-diff"),
)
})
it("offers to approve a reviewable branch", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
const wrapper = await mountEditor()
expect(wrapper.text()).toContain(
t("editor.name-editor.review-workflow.approve.title"),
)
expect(wrapper.text()).toContain(
t("editor.name-editor.review-workflow.show-diff"),
)
})
it("offers to revoke an approval the reader already gave", async ({
expect,
}) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedQueryData(
["documents", DOCUMENT_ID, "branches", BRANCH_ID, "reviewers"],
[{ userId: ME, currentlyApproved: true }],
)
const wrapper = await mountEditor()
expect(wrapper.text()).toContain(
t("editor.name-editor.review-workflow.unapprove.title"),
)
})
it("approves the branch", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/branches/${BRANCH_ID}/review-approve`,
() => null,
)
const wrapper = await mountEditor()
vi.useFakeTimers()
await findButtonByText(
wrapper,
t("editor.name-editor.review-workflow.approve.title"),
).trigger("click")
await vi.advanceTimersByTimeAsync(REVIEWABLE_ACTION_DELAY_MS)
await settleMutations()
expect(calls).toHaveLength(1)
expect(calls[0]?.body).toEqual({ approved: true })
expect(toast.custom).toHaveBeenCalledTimes(1)
})
it("warns when the approval fails", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/branches/${BRANCH_ID}/review-approve`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
const wrapper = await mountEditor()
vi.useFakeTimers()
await findButtonByText(
wrapper,
t("editor.name-editor.review-workflow.approve.title"),
).trigger("click")
await vi.advanceTimersByTimeAsync(REVIEWABLE_ACTION_DELAY_MS)
await settleMutations()
expect(toast.custom).toHaveBeenCalledTimes(1)
})
it("switches the action to merging the branch", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
const wrapper = await mountEditor()
await openActionMenu(wrapper)
menuItem(t("editor.name-editor.review-workflow.merge.title")).click()
await nextTick()
expect(wrapper.text()).toContain(
t("editor.name-editor.review-workflow.merge.title"),
)
})
it("merges the branch", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
const calls = mockMergeEndpoint(() => null)
// merging invalidates the branch and reviewer queries, which fetch
// again before the mutation settles
mockEndpoint("GET", `/api/documents/${DOCUMENT_ID}/branches`, () => [
{ id: BRANCH_ID },
])
mockEndpoint(
"GET",
`/api/documents/${DOCUMENT_ID}/branches/${BRANCH_ID}/reviewers`,
() => [],
)
mockEndpoint(
"GET",
`/api/documents/${DOCUMENT_ID}/branches/${TARGET_BRANCH_ID}/reviewers`,
() => [],
)
const wrapper = await mountEditor()
await openActionMenu(wrapper)
menuItem(t("editor.name-editor.review-workflow.merge.title")).click()
await nextTick()
// the merge invalidates queries that fetch again, so the run is
// left on the real clock rather than being stepped through
await findButtonByText(
wrapper,
t("editor.name-editor.review-workflow.merge.title"),
).trigger("click")
await vi.waitFor(() => {
expect(emittedFrom(wrapper, "branch-merged")).toEqual([[false]])
}, WAIT_FOR_OPTIONS)
expect(calls).toHaveLength(1)
})
it("turns the diff on and off", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
const wrapper = await mountEditor({ target: makeBranch() })
await wrapper.get("[role='switch']").trigger("click")
expect(useEditorStore().reviewableDiffActive).toBe(true)
expect(emittedFrom(wrapper, "diff-mode-changed")).toEqual([[true]])
})
it("turns the diff off when the branch stops being reviewable", async ({
expect,
}) => {
useEditorStore().setBranchReviewableActionsActive(true)
await mountEditor({ target: makeBranch() })
useEditorStore().setReviewableDiffActive(true)
useEditorStore().setBranchReviewableActionsActive(false)
await nextTick()
expect(useEditorStore().reviewableDiffActive).toBe(false)
})
it("shows the reviewers only when the page has more than one branch", async ({
expect,
}) => {
seedQueryData(
["documents", DOCUMENT_ID, "branches"],
[{ id: BRANCH_ID }, { id: TARGET_BRANCH_ID }],
)
const wrapper = await mountEditor()
expect(wrapper.findComponent(ReviewerList).exists()).toBe(true)
})
it("hides the reviewers for a page with a single branch", async ({
expect,
}) => {
const wrapper = await mountEditor()
expect(wrapper.findComponent(ReviewerList).exists()).toBe(false)
})
})

View File

@ -0,0 +1,79 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { describe, it } from "vitest"
import NoDocumentIndicator from "./NoDocumentIndicator.vue"
import {
findButtonByText,
renderedIconNames,
t,
} from "~/components/test-helpers"
const CREATE_BUTTON = "editor.no-document-indicator.create-document-button"
function mountIndicator(props: Record<string, unknown> = {}) {
return mountSuspended(NoDocumentIndicator, {
props: { allSectionsLoaded: true, createLoading: false, ...props },
})
}
// the body fades in with v-show, which leaves it in the dom with an
// inline display:none
function hidden(wrapper: VueWrapper): boolean {
return (wrapper.get("div").element as HTMLElement).style.display === "none"
}
describe("<NoDocumentIndicator>", () => {
it("invites the reader to create their first page", async ({ expect }) => {
const wrapper = await mountIndicator()
expect(wrapper.text()).toContain(t("editor.no-document-indicator.heading"))
expect(wrapper.text()).toContain(t(CREATE_BUTTON))
})
it("stays hidden until the sidebar has finished loading", async ({
expect,
}) => {
const wrapper = await mountIndicator({ allSectionsLoaded: false })
expect(hidden(wrapper)).toBe(true)
})
it("appears once the sidebar has finished loading", async ({ expect }) => {
const wrapper = await mountIndicator()
expect(hidden(wrapper)).toBe(false)
})
it("reports that it has finished mounting", async ({ expect }) => {
const wrapper = await mountIndicator()
expect(wrapper.emitted("load-complete")).toHaveLength(1)
})
it("asks for a new page when the button is pressed", async ({ expect }) => {
const wrapper = await mountIndicator()
await findButtonByText(wrapper, t(CREATE_BUTTON)).trigger("click")
expect(wrapper.emitted("create-document")).toHaveLength(1)
})
it("spins while the page is being created", async ({ expect }) => {
const wrapper = await mountIndicator({ createLoading: true })
expect(renderedIconNames(wrapper)).toEqual([
"svg-spinners:blocks-shuffle-3",
])
expect(
findButtonByText(wrapper, t(CREATE_BUTTON)).attributes("disabled"),
).toBe("")
})
it("takes no second request while one is in flight", async ({ expect }) => {
const wrapper = await mountIndicator({ createLoading: true })
await findButtonByText(wrapper, t(CREATE_BUTTON)).trigger("click")
expect(wrapper.emitted("create-document")).toBeUndefined()
})
})

View File

@ -0,0 +1,426 @@
import type { VueWrapper } from "@vue/test-utils"
import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ReviewerList from "./ReviewerList.vue"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
seedQueryData,
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
menuItem,
mountUnderTooltipProvider,
seedAuthOrganization,
seedAuthSession,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
import type WsState from "~/utils/websocket"
vi.mock("vue-sonner", () => ({
toast: { custom: vi.fn(), dismiss: vi.fn() },
}))
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("brancha")
const TARGET_BRANCH_ID = makeXid("branchb")
const ME = makeXid("usme")
const ADA = makeXid("usada")
const GRACE = makeXid("usgrc")
const ALAN = makeXid("usaln")
function member(id: string, name: string) {
return {
userId: id,
user: { name: name, email: `${name.toLowerCase()}@test`, image: undefined },
}
}
function seedMembers(...members: ReturnType<typeof member>[]) {
seedAuthOrganization({ members: members })
}
function seedReviewers(
branchId: string,
reviewers: { userId: string; currentlyApproved: boolean }[],
) {
seedQueryData(
["documents", DOCUMENT_ID, "branches", branchId, "reviewers"],
reviewers,
)
}
function mountList() {
return mountUnderTooltipProvider(ReviewerList, {})
}
async function openPopover(wrapper: VueWrapper) {
await wrapper.get("[data-slot='popover-trigger']").trigger("click")
await nextTick()
}
function popoverText(): string {
return (
document.body.querySelector("[data-slot='popover-content']")?.textContent ??
""
)
}
function popoverButton(hint: string): HTMLButtonElement {
const button = Array.from(
document.body.querySelectorAll<HTMLButtonElement>(
"[data-slot='popover-content'] button",
),
).find((candidate) => candidate.textContent.includes(hint))
if (!button) {
throw new Error(`no popover button rendering "${hint}"`)
}
return button
}
// the editor store, the auth cache, the query cache, the mocked toast
// module and the teleported overlays are all shared, so these tests
// cannot interleave
describe("<ReviewerList>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
clearQueryCache()
vi.mocked(toast.custom).mockReset()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().updateTargetBranchId(TARGET_BRANCH_ID)
useEditorStore().setBranchReviewableActionsActive(false)
useWebSocketStateStore().state = null
seedAuthSession({ id: ME, name: "Me" })
})
afterEach(disposeMockEndpoints)
it("shows nothing while the branch has no reviewers and no actions", async ({
expect,
}) => {
seedMembers(member(ADA, "Ada"))
seedReviewers(BRANCH_ID, [])
seedReviewers(TARGET_BRANCH_ID, [])
const wrapper = await mountList()
expect(wrapper.text()).toBe("")
})
it("appears once the branch can be reviewed", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedMembers(member(ADA, "Ada"))
seedReviewers(BRANCH_ID, [])
seedReviewers(TARGET_BRANCH_ID, [])
const wrapper = await mountList()
expect(wrapper.text()).toContain(t("editor.name-editor.reviewers"))
})
it("shows the reviewers taking part", async ({ expect }) => {
seedMembers(member(ADA, "Ada"), member(GRACE, "Grace"))
seedReviewers(BRANCH_ID, [
{ userId: ADA, currentlyApproved: true },
{ userId: GRACE, currentlyApproved: false },
])
seedReviewers(TARGET_BRANCH_ID, [])
const wrapper = await mountList()
expect(wrapper.findAll("li")).toHaveLength(2)
})
it("leaves out reviewers who are no longer members", async ({ expect }) => {
seedMembers(member(ADA, "Ada"))
seedReviewers(BRANCH_ID, [
{ userId: ADA, currentlyApproved: true },
{ userId: GRACE, currentlyApproved: false },
])
seedReviewers(TARGET_BRANCH_ID, [])
const wrapper = await mountList()
expect(wrapper.findAll("li")).toHaveLength(1)
})
it("separates the reviewers who approved from those still invited", async ({
expect,
}) => {
seedMembers(member(ADA, "Ada"), member(GRACE, "Grace"))
seedReviewers(BRANCH_ID, [
{ userId: ADA, currentlyApproved: true },
{ userId: GRACE, currentlyApproved: false },
])
seedReviewers(TARGET_BRANCH_ID, [])
const wrapper = await mountList()
await openPopover(wrapper)
expect(popoverText()).toContain(
t("editor.name-editor.reviewer-popover.approved-by-label"),
)
expect(popoverText()).toContain("Ada")
expect(popoverText()).toContain(
t("editor.name-editor.reviewer-popover.invited-label"),
)
expect(popoverText()).toContain("Grace")
})
it("offers no per-reviewer actions while the branch cannot be reviewed", async ({
expect,
}) => {
seedMembers(member(ADA, "Ada"))
seedReviewers(BRANCH_ID, [{ userId: ADA, currentlyApproved: true }])
seedReviewers(TARGET_BRANCH_ID, [])
const wrapper = await mountList()
await openPopover(wrapper)
expect(
document.body.querySelectorAll("[data-slot='popover-content'] button"),
).toHaveLength(0)
})
it("asks an approver to review again", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedMembers(member(ADA, "Ada"))
seedReviewers(BRANCH_ID, [{ userId: ADA, currentlyApproved: true }])
seedReviewers(TARGET_BRANCH_ID, [])
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/branches/${BRANCH_ID}/reviewers`,
() => null,
)
const wrapper = await mountList()
await openPopover(wrapper)
popoverButton(
t(
"editor.name-editor.reviewer-popover.request-review-screen-reader-hint",
),
).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({ userId: ADA })
expect(toast.custom).toHaveBeenCalledTimes(1)
})
it("warns when the review request fails", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedMembers(member(ADA, "Ada"))
seedReviewers(BRANCH_ID, [{ userId: ADA, currentlyApproved: true }])
seedReviewers(TARGET_BRANCH_ID, [])
mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/branches/${BRANCH_ID}/reviewers`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
const wrapper = await mountList()
await openPopover(wrapper)
popoverButton(
t(
"editor.name-editor.reviewer-popover.request-review-screen-reader-hint",
),
).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("withdraws an invitation", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedMembers(member(GRACE, "Grace"))
seedReviewers(BRANCH_ID, [{ userId: GRACE, currentlyApproved: false }])
seedReviewers(TARGET_BRANCH_ID, [])
const calls = mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/branches/${BRANCH_ID}/reviewers`,
() => null,
)
const wrapper = await mountList()
await openPopover(wrapper)
popoverButton(
t("editor.name-editor.reviewer-popover.invite-remove-screen-reader-hint"),
).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.query).toEqual({ userId: GRACE })
})
it("warns when the invitation cannot be withdrawn", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedMembers(member(GRACE, "Grace"))
seedReviewers(BRANCH_ID, [{ userId: GRACE, currentlyApproved: false }])
seedReviewers(TARGET_BRANCH_ID, [])
mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/branches/${BRANCH_ID}/reviewers`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
const wrapper = await mountList()
await openPopover(wrapper)
popoverButton(
t("editor.name-editor.reviewer-popover.invite-remove-screen-reader-hint"),
).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("suggests the reviewers of the branch being merged into", async ({
expect,
}) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedMembers(member(ADA, "Ada"), member(GRACE, "Grace"), member(ME, "Me"))
seedReviewers(BRANCH_ID, [])
seedReviewers(TARGET_BRANCH_ID, [
{ userId: ADA, currentlyApproved: true },
{ userId: ME, currentlyApproved: true },
])
const wrapper = await mountList()
await openPopover(wrapper)
popoverButton(
t("editor.name-editor.reviewer-popover.invite-button"),
).click()
await nextTick()
expect(menuItem("Ada")).toBeDefined()
expect(document.body.textContent).toContain(
t("editor.name-editor.reviewer-popover.suggestions-label"),
)
})
it("offers every other member as an invitee", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedMembers(member(ADA, "Ada"), member(ALAN, "Alan"), member(ME, "Me"))
seedReviewers(BRANCH_ID, [{ userId: ADA, currentlyApproved: false }])
seedReviewers(TARGET_BRANCH_ID, [])
const wrapper = await mountList()
await openPopover(wrapper)
popoverButton(
t("editor.name-editor.reviewer-popover.invite-button"),
).click()
await nextTick()
expect(menuItem("Alan")).toBeDefined()
expect(document.body.textContent).not.toContain(
t("editor.name-editor.reviewer-popover.suggestions-label"),
)
})
it("invites the member the reader picked", async ({ expect }) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedMembers(member(ALAN, "Alan"))
seedReviewers(BRANCH_ID, [])
seedReviewers(TARGET_BRANCH_ID, [])
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/branches/${BRANCH_ID}/reviewers`,
() => null,
)
const wrapper = await mountList()
await openPopover(wrapper)
popoverButton(
t("editor.name-editor.reviewer-popover.invite-button"),
).click()
await nextTick()
menuItem("Alan").click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({ userId: ALAN })
})
it("offers nobody to invite when everyone is already a reviewer", async ({
expect,
}) => {
useEditorStore().setBranchReviewableActionsActive(true)
seedMembers(member(ADA, "Ada"), member(ME, "Me"))
seedReviewers(BRANCH_ID, [{ userId: ADA, currentlyApproved: false }])
seedReviewers(TARGET_BRANCH_ID, [])
const wrapper = await mountList()
await openPopover(wrapper)
expect(
popoverButton(t("editor.name-editor.reviewer-popover.invite-button"))
.disabled,
).toBe(true)
})
it("refetches the reviewers when the server says they changed", async ({
expect,
}) => {
seedMembers(member(ADA, "Ada"))
seedReviewers(BRANCH_ID, [{ userId: ADA, currentlyApproved: true }])
seedReviewers(TARGET_BRANCH_ID, [])
const calls = mockEndpoint(
"GET",
`/api/documents/${DOCUMENT_ID}/branches/${BRANCH_ID}/reviewers`,
() => [{ userId: ADA, currentlyApproved: true }],
)
const handlers: (() => void)[] = []
const subscribe = vi.fn((_topic: string, handler: () => void) => {
handlers.push(handler)
return () => undefined
})
useWebSocketStateStore().state = {
subscribe: subscribe,
} as unknown as WsState
await mountList()
handlers.forEach((handler) => {
handler()
})
await vi.waitFor(() => {
expect(calls.length).toBeGreaterThan(0)
}, WAIT_FOR_OPTIONS)
expect(subscribe.mock.calls[0]?.[0]).toBe(
makeWsDocumentReviewersChangeTopic(DOCUMENT_ID),
)
})
it("subscribes to nothing while no page is open", async ({ expect }) => {
useEditorStore().updateActiveDocumentId(null)
seedMembers(member(ADA, "Ada"))
const subscribe = vi.fn()
useWebSocketStateStore().state = {
subscribe: subscribe,
} as unknown as WsState
await mountList()
expect(subscribe).toHaveBeenCalledTimes(0)
})
})

View File

@ -0,0 +1,262 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { Editor } from "@tiptap/vue-3"
import Blockquote from "@tiptap/extension-blockquote"
import Bold from "@tiptap/extension-bold"
import Code from "@tiptap/extension-code"
import Document from "@tiptap/extension-document"
import Heading from "@tiptap/extension-heading"
import Italic from "@tiptap/extension-italic"
import Link from "@tiptap/extension-link"
import Paragraph from "@tiptap/extension-paragraph"
import Strike from "@tiptap/extension-strike"
import Text from "@tiptap/extension-text"
import Underline from "@tiptap/extension-underline"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import { afterEach, describe, it } from "vitest"
import TextBubbleMenu from "./TextBubbleMenu.vue"
import { CommentMark } from "./comments/comment-mark"
import LinkCreateMenu from "./link/LinkCreateMenu.vue"
import { at, renderedIconNames, t } from "~/components/test-helpers"
let editor: Editor | null = null
function makeMenuEditor(content: string): Editor {
const element = document.createElement("div")
document.body.appendChild(element)
editor = new Editor({
element: element,
extensions: [
Document,
Paragraph,
Text,
Heading.configure({ levels: [1, 2, 3] }),
Bold,
Italic,
Underline,
Strike,
Code,
Blockquote,
Link.configure({ openOnClick: false }),
CommentMark,
],
content: content,
})
return editor
}
function mountMenu(
target: Editor,
props: Record<string, unknown> = {},
): Promise<VueWrapper> {
return mountSuspended(TextBubbleMenu, {
props: { editor: target, container: null, ...props },
}) as Promise<VueWrapper>
}
function selectAll(target: Editor) {
target.commands.setTextSelection({
from: 1,
to: target.state.doc.content.size - 1,
})
}
function buttonIcons(wrapper: VueWrapper): string[] {
return renderedIconNames(wrapper)
}
// the menu is rendered into the editor's own dom, so each test takes its
// menu and editor down again
describe("<TextBubbleMenu>", { concurrent: false }, () => {
enableAutoUnmount(afterEach)
afterEach(() => {
if (editor) {
const element = editor.view.dom.parentElement
editor.destroy()
element?.remove()
editor = null
}
})
it("offers the formatting actions for ordinary text", async ({ expect }) => {
const target = makeMenuEditor("<p>hello world</p>")
selectAll(target)
const wrapper = await mountMenu(target)
expect(buttonIcons(wrapper)).toEqual([
"lucide:bold",
"lucide:italic",
"lucide:underline",
"lucide:strikethrough",
"lucide:link",
"lucide:square-code",
"lucide:message-square-text",
])
})
it("offers only a comment inside a heading", async ({ expect }) => {
const target = makeMenuEditor("<h1>hello</h1>")
selectAll(target)
const wrapper = await mountMenu(target)
expect(buttonIcons(wrapper)).toEqual(["lucide:message-square-text"])
})
it("offers no second comment inside an existing one", async ({ expect }) => {
const target = makeMenuEditor("<p>hello</p>")
selectAll(target)
target.commands.setMark(CommentMark.name, { commentId: "c-1" })
target.commands.setTextSelection({ from: 2, to: 4 })
const wrapper = await mountMenu(target)
expect(buttonIcons(wrapper)).not.toContain("lucide:message-square-text")
expect(buttonIcons(wrapper)).toContain("lucide:bold")
})
it("offers nothing inside a commented heading", async ({ expect }) => {
const target = makeMenuEditor("<h1>hello</h1>")
selectAll(target)
target.commands.setMark(CommentMark.name, { commentId: "c-1" })
target.commands.setTextSelection({ from: 2, to: 4 })
const wrapper = await mountMenu(target)
expect(buttonIcons(wrapper)).toEqual([])
})
it.for([
{ icon: "lucide:bold", mark: "bold" },
{ icon: "lucide:italic", mark: "italic" },
{ icon: "lucide:underline", mark: "underline" },
{ icon: "lucide:strikethrough", mark: "strike" },
{ icon: "lucide:square-code", mark: "code" },
])("applies $mark to the selection", async ({ icon, mark }, { expect }) => {
const target = makeMenuEditor("<p>hello world</p>")
selectAll(target)
const wrapper = await mountMenu(target)
await at(
wrapper.findAll("button"),
buttonIcons(wrapper).indexOf(icon),
).trigger("click")
expect(target.isActive(mark)).toBe(true)
})
it("marks the actions already applied to the selection", async ({
expect,
}) => {
const target = makeMenuEditor("<p><strong>hello</strong></p>")
selectAll(target)
const wrapper = await mountMenu(target)
expect(at(wrapper.findAll("button"), 0).attributes("data-status")).toBe(
"active",
)
})
it("asks for an address when the selection has no link", async ({
expect,
}) => {
const target = makeMenuEditor("<p>hello world</p>")
selectAll(target)
const wrapper = await mountMenu(target)
await at(
wrapper.findAll("button"),
buttonIcons(wrapper).indexOf("lucide:link"),
).trigger("click")
expect(wrapper.findComponent(LinkCreateMenu).exists()).toBe(true)
})
it("hands an existing link to the host to edit", async ({ expect }) => {
const target = makeMenuEditor(
'<p><a href="https://oxynote.test">docs</a></p>',
)
selectAll(target)
const wrapper = await mountMenu(target)
await at(
wrapper.findAll("button"),
buttonIcons(wrapper).indexOf("lucide:link"),
).trigger("click")
expect(wrapper.emitted("edit-link")).toHaveLength(1)
expect(wrapper.findComponent(LinkCreateMenu).exists()).toBe(false)
})
it("puts the formatting actions back once a link is made", async ({
expect,
}) => {
const target = makeMenuEditor("<p>hello world</p>")
selectAll(target)
const wrapper = await mountMenu(target)
await at(
wrapper.findAll("button"),
buttonIcons(wrapper).indexOf("lucide:link"),
).trigger("click")
await wrapper.get("input").trigger("keydown", { key: "Escape" })
expect(wrapper.findComponent(LinkCreateMenu).exists()).toBe(false)
expect(buttonIcons(wrapper)).toContain("lucide:bold")
})
it("asks the host for a comment thread", async ({ expect }) => {
const target = makeMenuEditor("<p>hello world</p>")
selectAll(target)
const wrapper = await mountMenu(target)
await at(
wrapper.findAll("button"),
buttonIcons(wrapper).indexOf("lucide:message-square-text"),
).trigger("click")
expect(wrapper.emitted("add-thread")).toHaveLength(1)
})
it("offers only a comment while a diff is shown", async ({ expect }) => {
const target = makeMenuEditor("<p>hello world</p>")
selectAll(target)
const wrapper = await mountMenu(target, {
diffContext: { positionMap: [] },
})
expect(buttonIcons(wrapper)).toEqual(["lucide:message-square-text"])
expect(wrapper.text()).toContain(t("editor.bubble-menu.comment-label"))
})
it("asks the host for a comment thread while a diff is shown", async ({
expect,
}) => {
const target = makeMenuEditor("<p>hello world</p>")
selectAll(target)
const wrapper = await mountMenu(target, {
diffContext: { positionMap: [] },
})
await wrapper.get("button").trigger("click")
expect(wrapper.emitted("add-thread")).toHaveLength(1)
})
it("stays invisible until the menu is positioned", async ({ expect }) => {
const target = makeMenuEditor("<p>hello world</p>")
selectAll(target)
const wrapper = await mountMenu(target)
expect(
wrapper.get("[data-visible], .opacity-0").attributes("data-visible"),
).toBeUndefined()
})
})

View File

@ -0,0 +1,352 @@
import { mockNuxtImport, mountSuspended } from "@nuxt/test-utils/runtime"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import type { Ref } from "vue"
import ChatBox from "./ChatBox.vue"
import { makeXid } from "~/composables/api/test-helpers"
import { findButtonByText, t } from "~/components/test-helpers"
// the real composable talks to the server over a websocket; the suite
// drives the state it exposes instead
const chat = vi.hoisted(() => {
return {
messages: null as unknown as Ref<{ role: string; text: string }[]>,
isConnected: null as unknown as Ref<boolean>,
isStreaming: null as unknown as Ref<boolean>,
toolStatus: null as unknown as Ref<string | null>,
streamingText: null as unknown as Ref<string>,
pendingConfirm: null as unknown as Ref<{
actions: { summary: string; documentName?: string }[]
} | null>,
connect: vi.fn(),
disconnect: vi.fn(),
sendMessage: vi.fn(),
setActiveDocument: vi.fn(),
answerConfirm: vi.fn(),
resetChat: vi.fn(),
}
})
mockNuxtImport("useAIChat", () => {
return () => chat
})
function mountChat(props: Record<string, unknown> = {}) {
return mountSuspended(ChatBox, { props: props })
}
function messageTexts(wrapper: VueWrapper): string[] {
return wrapper.findAll(".prose").map((message) => message.text())
}
function sendButton(wrapper: VueWrapper) {
return findButtonByText(wrapper, t("editor.ai-chat.send"))
}
// the mocked chat state and the editor store are shared by the whole
// file, so these tests cannot interleave
describe("<ChatBox>", { concurrent: false }, () => {
// every mounted box watches the open page, so a leftover one would
// answer for the next test too
enableAutoUnmount(afterEach)
beforeEach(() => {
chat.messages = ref([])
chat.isConnected = ref(true)
chat.isStreaming = ref(false)
chat.toolStatus = ref<string | null>(null)
chat.streamingText = ref("")
chat.pendingConfirm = ref(null)
chat.connect.mockClear()
chat.disconnect.mockClear()
chat.sendMessage.mockClear()
chat.setActiveDocument.mockClear()
chat.answerConfirm.mockClear()
chat.resetChat.mockClear()
useEditorStore().updateActiveDocumentId(null)
})
it("connects when it opens", async ({ expect }) => {
await mountChat()
expect(chat.connect).toHaveBeenCalledTimes(1)
})
it("disconnects when it closes", async ({ expect }) => {
const wrapper = await mountChat()
wrapper.unmount()
expect(chat.disconnect).toHaveBeenCalledTimes(1)
})
it("tells the assistant which page is open", async ({ expect }) => {
const documentId = makeXid("doc")
useEditorStore().updateActiveDocumentId(documentId)
await mountChat()
expect(chat.setActiveDocument).toHaveBeenCalledWith(documentId)
})
it("tells the assistant when the reader navigates away", async ({
expect,
}) => {
await mountChat()
useEditorStore().updateActiveDocumentId(makeXid("other"))
await nextTick()
expect(chat.setActiveDocument).toHaveBeenCalledTimes(2)
})
it("invites the reader to start a conversation", async ({ expect }) => {
const wrapper = await mountChat()
expect(wrapper.text()).toContain(t("editor.ai-chat.title"))
expect(wrapper.text()).toContain("Tell me what's on your mind")
})
it("shows the conversation so far", async ({ expect }) => {
chat.messages.value = [
{ role: ChatMessageRole.User, text: "hello" },
{ role: ChatMessageRole.Assistant, text: "quack" },
]
const wrapper = await mountChat()
expect(messageTexts(wrapper)).toEqual(["hello", "quack"])
})
it("renders an answer's markdown", async ({ expect }) => {
chat.messages.value = [
{ role: ChatMessageRole.Assistant, text: "**bold** answer" },
]
const wrapper = await mountChat()
expect(wrapper.get(".prose strong").text()).toBe("bold")
})
// DOMPurify drops target from the sanitized markup, so the rel pair is
// the part of the link rule that reaches the page
it("marks links in an answer as safe to follow", async ({ expect }) => {
chat.messages.value = [
{
role: ChatMessageRole.Assistant,
text: "[docs](https://oxynote.test)",
},
]
const wrapper = await mountChat()
const link = wrapper.get(".prose a")
expect(link.attributes("href")).toBe("https://oxynote.test")
expect(link.attributes("rel")).toBe("noopener noreferrer")
expect(link.text()).toBe("docs")
})
it("strips scripting out of an answer", async ({ expect }) => {
chat.messages.value = [
{
role: ChatMessageRole.Assistant,
text: "<img src=x onerror=alert(1)>",
},
]
const wrapper = await mountChat()
expect(wrapper.find(".prose img").exists()).toBe(false)
expect(wrapper.get(".prose").text()).toContain("<img")
})
it("shows the answer as it streams in", async ({ expect }) => {
chat.streamingText.value = "thinking out loud"
const wrapper = await mountChat()
expect(wrapper.text()).toContain("thinking out loud")
})
it("says what the assistant is busy with", async ({ expect }) => {
chat.isStreaming.value = true
chat.toolStatus.value = t("editor.ai-chat.tool-status.read-document")
const wrapper = await mountChat()
expect(wrapper.text()).toContain(
t("editor.ai-chat.tool-status.read-document"),
)
})
it("shows a waiting indicator before the first token", async ({ expect }) => {
chat.isStreaming.value = true
const wrapper = await mountChat()
expect(wrapper.findAll(".animate-bounce")).toHaveLength(3)
})
it("reports the connection being up", async ({ expect }) => {
const wrapper = await mountChat()
expect(wrapper.find(".bg-green-500").exists()).toBe(true)
})
it("reports the connection being down", async ({ expect }) => {
chat.isConnected.value = false
const wrapper = await mountChat()
expect(wrapper.find(".bg-red-500").exists()).toBe(true)
})
it("sends what the reader typed", async ({ expect }) => {
const wrapper = await mountChat()
await wrapper.get("textarea").setValue(" how do i deploy? ")
await sendButton(wrapper).trigger("click")
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
expect(chat.sendMessage).toHaveBeenCalledWith("how do i deploy?")
expect((wrapper.get("textarea").element as HTMLTextAreaElement).value).toBe(
"",
)
})
it("sends the message on enter", async ({ expect }) => {
const wrapper = await mountChat()
await wrapper.get("textarea").setValue("hello")
await wrapper.get("textarea").trigger("keydown", { key: "Enter" })
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
})
it("starts a new line on shift-enter", async ({ expect }) => {
const wrapper = await mountChat()
await wrapper.get("textarea").setValue("hello")
await wrapper
.get("textarea")
.trigger("keydown", { key: "Enter", shiftKey: true })
expect(chat.sendMessage).toHaveBeenCalledTimes(0)
})
it("sends nothing while the box is empty", async ({ expect }) => {
const wrapper = await mountChat()
await wrapper.get("textarea").setValue(" ")
await wrapper.get("textarea").trigger("keydown", { key: "Enter" })
expect(chat.sendMessage).toHaveBeenCalledTimes(0)
expect(sendButton(wrapper).attributes("disabled")).toBe("")
})
it("stops the reader typing while the connection is down", async ({
expect,
}) => {
chat.isConnected.value = false
const wrapper = await mountChat()
expect(wrapper.get("textarea").attributes("disabled")).toBe("")
})
it("stops the reader typing while an answer is streaming", async ({
expect,
}) => {
chat.isStreaming.value = true
const wrapper = await mountChat()
expect(wrapper.get("textarea").attributes("disabled")).toBe("")
})
it("starts a new conversation on request", async ({ expect }) => {
const wrapper = await mountChat()
await findButtonByText(wrapper, t("editor.ai-chat.new-chat")).trigger(
"click",
)
expect(chat.resetChat).toHaveBeenCalledTimes(1)
})
it("asks the reader to confirm the changes the assistant wants", async ({
expect,
}) => {
chat.pendingConfirm.value = {
actions: [{ summary: "Rewrite the intro", documentName: "Runbook" }],
}
const wrapper = await mountChat()
expect(wrapper.text()).toContain(t("editor.ai-chat.confirm.title"))
expect(wrapper.text()).toContain("Rewrite the intro")
expect(wrapper.text()).toContain("Runbook")
})
it("approves the changes once", async ({ expect }) => {
chat.pendingConfirm.value = { actions: [{ summary: "Rewrite" }] }
const wrapper = await mountChat()
await findButtonByText(
wrapper,
t("editor.ai-chat.confirm.approve"),
).trigger("click")
expect(chat.answerConfirm).toHaveBeenCalledTimes(1)
expect(chat.answerConfirm).toHaveBeenCalledWith(true)
})
it("approves every change from now on", async ({ expect }) => {
chat.pendingConfirm.value = { actions: [{ summary: "Rewrite" }] }
const wrapper = await mountChat()
await findButtonByText(
wrapper,
t("editor.ai-chat.confirm.approve-all"),
).trigger("click")
expect(chat.answerConfirm).toHaveBeenCalledWith(true, true)
})
it("declines the changes", async ({ expect }) => {
chat.pendingConfirm.value = { actions: [{ summary: "Rewrite" }] }
const wrapper = await mountChat()
await findButtonByText(
wrapper,
t("editor.ai-chat.confirm.decline"),
).trigger("click")
expect(chat.answerConfirm).toHaveBeenCalledWith(false)
})
it("stops the reader typing while a confirmation is pending", async ({
expect,
}) => {
chat.pendingConfirm.value = { actions: [{ summary: "Rewrite" }] }
const wrapper = await mountChat()
expect(wrapper.get("textarea").attributes("disabled")).toBe("")
})
it("offers no close button on a wide screen", async ({ expect }) => {
const wrapper = await mountChat()
expect(wrapper.findAll("header button")).toHaveLength(0)
})
it("closes itself when asked to on a narrow screen", async ({ expect }) => {
const wrapper = await mountChat({ mobile: true })
await wrapper.get("header button").trigger("click")
expect(wrapper.emitted("close-chat-box")).toHaveLength(1)
})
})

View File

@ -0,0 +1,147 @@
import { beforeEach, describe, it, vi } from "vitest"
import MainBlock from "./MainBlock.vue"
import IconPicker from "../../IconPicker.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
import { emitFrom, renderedIconNames } from "~/components/test-helpers"
function mountCallout(overrides: Record<string, unknown> = {}) {
return mountNodeView(MainBlock, {
node: makeNode({ uid: "callout-1", icon: "lucide:info" }),
...overrides,
})
}
// the icon-picker button carries the diff highlight when the icon changed
function iconPickerHighlighted(wrapper: {
get: (selector: string) => { classes: () => string[] }
}): boolean {
return wrapper.get("button").classes().includes("bg-diff-added")
}
// the editor store is app-wide, so these tests cannot interleave
describe("<CalloutMainBlock>", { concurrent: false }, () => {
beforeEach(() => {
useEditorStore().setReviewableDiffActive(false)
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountCallout()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("callout-1")
expect(root.attributes("data-uid")).toBe("callout-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountCallout({
node: makeNode({
uid: "callout-1",
icon: "lucide:info",
nodeCommentId: "comment-1",
diffStatus: "added",
}),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("added")
})
it("shows the icon the node carries", async ({ expect }) => {
const wrapper = await mountCallout()
expect(renderedIconNames(wrapper)).toEqual(["lucide:info"])
})
it("stores a newly picked icon on the node", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountCallout({ updateAttributes: updateAttributes })
emitFrom(wrapper, IconPicker, "select", "lucide:triangle-alert")
expect(updateAttributes).toHaveBeenCalledTimes(1)
expect(updateAttributes).toHaveBeenCalledWith({
icon: "lucide:triangle-alert",
})
})
it("leaves the icon unhighlighted outside a reviewable diff", async ({
expect,
}) => {
const wrapper = await mountCallout({
node: makeNode({
uid: "callout-1",
icon: "lucide:info",
oldNode: { attrs: { icon: "lucide:star" } },
}),
})
expect(iconPickerHighlighted(wrapper)).toBe(false)
})
it("highlights the icon when the diff changed it", async ({ expect }) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountCallout({
node: makeNode({
uid: "callout-1",
icon: "lucide:info",
oldNode: { attrs: { icon: "lucide:star" } },
}),
})
expect(iconPickerHighlighted(wrapper)).toBe(true)
})
it("reads the previous icon out of a serialized old node", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountCallout({
node: makeNode({
uid: "callout-1",
icon: "lucide:info",
oldNode: JSON.stringify({ attrs: { icon: "lucide:star" } }),
}),
})
expect(iconPickerHighlighted(wrapper)).toBe(true)
})
it("leaves the icon unhighlighted when the diff kept it", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountCallout({
node: makeNode({
uid: "callout-1",
icon: "lucide:info",
oldNode: { attrs: { icon: "lucide:info" } },
}),
})
expect(iconPickerHighlighted(wrapper)).toBe(false)
})
it("leaves the icon unhighlighted when the node has no previous version", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountCallout({
node: makeNode({
uid: "callout-1",
icon: "lucide:info",
oldNode: null,
}),
})
expect(iconPickerHighlighted(wrapper)).toBe(false)
})
})

View File

@ -0,0 +1,234 @@
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import Code from "./Code.vue"
import {
makeEditor,
makeEditorState,
makeNode,
mountNodeView,
} from "../../test-helpers/node-view"
import { CODE_BLOCK_NAME, TITLED_CODE_BLOCK_NAME } from "../node-names"
import { DiffStatus } from "~/components/editor/diff/position-map"
import { t } from "~/components/test-helpers"
function mountCode(
options: {
attrs?: Record<string, unknown>
textContent?: string
type?: string
state?: Record<string, unknown>
getPos?: () => number | undefined
} = {},
) {
return mountNodeView(Code, {
node: makeNode(
{ uid: "code-1", ...options.attrs },
{
typeName: CODE_BLOCK_NAME,
textContent: options.textContent ?? "",
nodeSize: 10,
},
),
extension: { options: { type: options.type ?? "document" } },
editor: makeEditor({ state: options.state ?? makeEditorState() }).editor,
...(options.getPos ? { getPos: options.getPos } : {}),
})
}
// the placeholder is an overlay div, separate from the code content
function placeholderText(wrapper: VueWrapper): string {
const overlay = wrapper.find(".pointer-events-none")
return overlay.exists() ? overlay.text() : ""
}
// the button row fades in while the caret is inside the block
function buttonsVisible(wrapper: VueWrapper): boolean {
return wrapper
.get("[data-node-view-wrapper] > div:last-child")
.classes()
.includes("opacity-100")
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<Code>", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountCode()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("code-1")
expect(root.attributes("data-uid")).toBe("code-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountCode({
attrs: { nodeCommentId: "comment-1", diffStatus: DiffStatus.Modified },
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("modified")
})
it("renders the code inside a pre element", async ({ expect }) => {
const wrapper = await mountCode()
expect(wrapper.get("[data-node-view-wrapper]").element.tagName).toBe("PRE")
expect(wrapper.get("[data-node-view-content]").element.tagName).toBe("CODE")
})
it("prompts for code while the block is empty and editable", async ({
expect,
}) => {
const wrapper = await mountCode()
expect(placeholderText(wrapper)).toBe(
t("editor.placeholders.content.extended-code-block.content"),
)
})
it("marks an empty block as unfilled in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountCode()
expect(placeholderText(wrapper)).toBe(
t("editor.placeholders.content.extended-code-block.content-empty"),
)
})
it("marks an empty block as unfilled while a reviewable diff is shown", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountCode()
expect(placeholderText(wrapper)).toBe(
t("editor.placeholders.content.extended-code-block.content-empty"),
)
})
it("shows no placeholder once the block has code", async ({ expect }) => {
const wrapper = await mountCode({ textContent: "echo hi" })
expect(placeholderText(wrapper)).toBe("")
})
it("tints an unchanged block", async ({ expect }) => {
const wrapper = await mountCode({
attrs: { diffStatus: DiffStatus.Unchanged },
})
expect(wrapper.get("[data-node-view-wrapper]").classes()).toContain(
"bg-muted!",
)
})
it.for([DiffStatus.Added, DiffStatus.Removed])(
"leaves the diff colour of a %s block alone",
async (diffStatus, { expect }) => {
const wrapper = await mountCode({ attrs: { diffStatus: diffStatus } })
expect(wrapper.get("[data-node-view-wrapper]").classes()).not.toContain(
"bg-muted!",
)
},
)
it("sizes a document code block for body text", async ({ expect }) => {
const wrapper = await mountCode()
expect(wrapper.get("[data-node-view-wrapper]").classes()).toContain(
"text-sm",
)
expect(wrapper.get("[data-node-view-content]").classes()).toContain(
"min-h-11",
)
})
it("sizes a comment code block smaller", async ({ expect }) => {
const wrapper = await mountCode({ type: "comment" })
expect(wrapper.get("[data-node-view-wrapper]").classes()).toContain(
"text-2sm",
)
expect(wrapper.get("[data-node-view-content]").classes()).toContain(
"min-h-9",
)
})
it("joins its border with the title above it inside a titled block", async ({
expect,
}) => {
const wrapper = await mountCode({
state: makeEditorState({ parentTypeName: TITLED_CODE_BLOCK_NAME }),
})
expect(wrapper.get("[data-node-view-wrapper]").classes()).toContain(
"rounded-t-none",
)
})
it("renders borderless outside a titled block", async ({ expect }) => {
const wrapper = await mountCode()
expect(wrapper.get("[data-node-view-wrapper]").classes()).toContain(
"border-0",
)
})
it("renders borderless when the block has no resolvable position", async ({
expect,
}) => {
const wrapper = await mountCode({
state: makeEditorState({ parentTypeName: TITLED_CODE_BLOCK_NAME }),
getPos: () => undefined,
})
expect(wrapper.get("[data-node-view-wrapper]").classes()).toContain(
"border-0",
)
})
it("shows the block buttons while the caret is inside the block", async ({
expect,
}) => {
const wrapper = await mountCode({
state: makeEditorState({ from: 2, to: 4 }),
})
expect(buttonsVisible(wrapper)).toBe(true)
})
it("hides the block buttons while the caret is elsewhere", async ({
expect,
}) => {
const wrapper = await mountCode({
state: makeEditorState({ from: 40, to: 40 }),
})
expect(buttonsVisible(wrapper)).toBe(false)
})
it("hides the block buttons when the block has no resolvable position", async ({
expect,
}) => {
const wrapper = await mountCode({
state: makeEditorState({ from: 2, to: 4 }),
getPos: () => undefined,
})
expect(buttonsVisible(wrapper)).toBe(false)
})
})

View File

@ -0,0 +1,287 @@
import { flushPromises, type VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it, vi } from "vitest"
import CodeButtons from "./CodeButtons.vue"
import {
makeEditor,
makeNode,
mountNodeView,
type EditorStub,
} from "../../test-helpers/node-view"
import { emitFrom } from "~/components/test-helpers"
import { Select } from "~/components/shadcn/ui/select"
const GO_SNIPPET =
'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("hi")\n}\n'
const COPIED_RESET_MS = 700
function mountButtons(
options: {
attrs?: Record<string, unknown>
textContent?: string
type?: string
editor?: EditorStub
updateAttributes?: (attrs: Record<string, unknown>) => void
onError?: (error: unknown) => void
} = {},
) {
return mountNodeView(
CodeButtons,
{
node: makeNode(
{ uid: "code-1", language: null, ...options.attrs },
{ textContent: options.textContent ?? "" },
),
extension: { options: { type: options.type ?? "document" } },
editor: (options.editor ?? makeEditor()).editor,
...(options.updateAttributes
? { updateAttributes: options.updateAttributes }
: {}),
},
{ onError: options.onError },
)
}
// the only plain button in the row is the copy one; the other is the
// language select's trigger
function copyButton(wrapper: VueWrapper) {
return wrapper.get("[data-slot='button']")
}
function selectedLanguage(wrapper: VueWrapper): string {
return wrapper.get("[data-slot='select-value']").text()
}
// the copy icon turns green for a moment once the code is on the clipboard
function copyIconMarked(wrapper: VueWrapper): boolean {
return copyButton(wrapper).get(".iconify").classes().includes("text-chart-2")
}
// the editable flag is a shared cookie state, and the clipboard spy and
// fake timers are global, so these tests cannot interleave
describe("<CodeButtons>", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(true)
})
it("shows the language stored on the node", async ({ expect }) => {
const wrapper = await mountButtons({
attrs: { language: "python" },
textContent: GO_SNIPPET,
})
expect(selectedLanguage(wrapper)).toBe("Python")
})
it("detects the language of an unlabelled block from its code", async ({
expect,
}) => {
const wrapper = await mountButtons({ textContent: GO_SNIPPET })
expect(selectedLanguage(wrapper)).toBe("Go")
})
it("falls back to plaintext for an empty block", async ({ expect }) => {
const wrapper = await mountButtons({ textContent: " \n " })
expect(selectedLanguage(wrapper)).toBe("Plaintext")
})
it("falls back to plaintext when nothing recognises the code", async ({
expect,
}) => {
const wrapper = await mountButtons({ textContent: "hello" })
expect(selectedLanguage(wrapper)).toBe("Plaintext")
})
it("falls back to plaintext when the best guess is a weak one", async ({
expect,
}) => {
const wrapper = await mountButtons({ textContent: "x" })
expect(selectedLanguage(wrapper)).toBe("Plaintext")
})
it("stores a language the user picks", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountButtons({ updateAttributes: updateAttributes })
emitFrom(wrapper, Select, "update:modelValue", "rust")
expect(updateAttributes).toHaveBeenCalledTimes(1)
expect(updateAttributes).toHaveBeenCalledWith({ language: "rust" })
})
it("stores nothing when the picked language is already set", async ({
expect,
}) => {
const updateAttributes = vi.fn()
const wrapper = await mountButtons({
attrs: { language: "rust" },
updateAttributes: updateAttributes,
})
emitFrom(wrapper, Select, "update:modelValue", "rust")
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("re-detects the language when the editor reports an update", async ({
expect,
}) => {
const editor = makeEditor()
const wrapper = await mountButtons({
textContent: "hello",
editor: editor,
})
await wrapper.setProps({
node: makeNode(
{ uid: "code-1", language: null },
{ textContent: GO_SNIPPET },
),
})
editor.on.mock.calls.forEach(([, handler]) => {
;(handler as () => void)()
})
await nextTick()
expect(editor.on).toHaveBeenCalledTimes(1)
expect(selectedLanguage(wrapper)).toBe("Go")
})
it("keeps a stored language when the editor reports an update", async ({
expect,
}) => {
const editor = makeEditor()
const wrapper = await mountButtons({
attrs: { language: "python" },
textContent: GO_SNIPPET,
editor: editor,
})
editor.on.mock.calls.forEach(([, handler]) => {
;(handler as () => void)()
})
await nextTick()
expect(selectedLanguage(wrapper)).toBe("Python")
})
it("stops listening for editor updates once unmounted", async ({
expect,
}) => {
const editor = makeEditor()
const wrapper = await mountButtons({ editor: editor })
wrapper.unmount()
expect(editor.off).toHaveBeenCalledTimes(1)
expect(editor.off.mock.calls[0]?.[0]).toBe("update")
expect(editor.off.mock.calls[0]?.[1]).toBe(editor.on.mock.calls[0]?.[1])
})
it("copies the block's code to the clipboard", async ({ expect }) => {
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined)
const wrapper = await mountButtons({ textContent: GO_SNIPPET })
await copyButton(wrapper).trigger("click")
await flushPromises()
expect(writeText).toHaveBeenCalledTimes(1)
expect(writeText).toHaveBeenCalledWith(GO_SNIPPET)
expect(copyIconMarked(wrapper)).toBe(true)
})
it("copies an empty string for a block with no code", async ({ expect }) => {
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined)
const wrapper = await mountButtons()
await copyButton(wrapper).trigger("click")
await flushPromises()
expect(writeText).toHaveBeenCalledWith("")
})
it("drops the copied marker shortly after copying", async ({ expect }) => {
vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined)
const wrapper = await mountButtons({ textContent: GO_SNIPPET })
vi.useFakeTimers()
await copyButton(wrapper).trigger("click")
await flushPromises()
await vi.advanceTimersByTimeAsync(COPIED_RESET_MS)
expect(copyIconMarked(wrapper)).toBe(false)
})
it("marks nothing as copied when the clipboard rejects", async ({
expect,
}) => {
const failure = new Error("denied")
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockRejectedValue(failure)
const errors: unknown[] = []
const wrapper = await mountButtons({
textContent: GO_SNIPPET,
onError: (error) => errors.push(error),
})
await copyButton(wrapper).trigger("click")
await flushPromises()
expect(writeText).toHaveBeenCalledTimes(1)
expect(copyIconMarked(wrapper)).toBe(false)
expect(errors).toEqual([failure])
})
it("hides itself for a block carrying a diff status", async ({ expect }) => {
const wrapper = await mountButtons({ attrs: { diffStatus: "added" } })
expect((wrapper.element as HTMLElement).style.display).toBe("none")
})
it("lets the language be changed while editing", async ({ expect }) => {
const wrapper = await mountButtons()
expect(
wrapper.get("[data-slot='select-trigger']").attributes("disable"),
).toBe("false")
})
it("blocks the language select in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountButtons()
expect(
wrapper.get("[data-slot='select-trigger']").attributes("disable"),
).toBe("true")
})
it("blocks the language select while the editor is not editable", async ({
expect,
}) => {
const wrapper = await mountButtons({
editor: makeEditor({ isEditable: false }),
})
expect(
wrapper.get("[data-slot='select-trigger']").attributes("disable"),
).toBe("true")
})
it("sizes a comment block's controls smaller", async ({ expect }) => {
const wrapper = await mountButtons({ type: "comment" })
expect(wrapper.get("[data-slot='select-trigger']").classes()).toContain(
"text-2sm",
)
})
})

View File

@ -0,0 +1,128 @@
import { beforeEach, describe, it } from "vitest"
import CodeTitle from "./CodeTitle.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
import { CODE_BLOCK_TITLE_NAME } from "../node-names"
import { DiffStatus } from "~/components/editor/diff/position-map"
import { t } from "~/components/test-helpers"
function mountTitle(attrs: Record<string, unknown> = {}, textContent = "") {
return mountNodeView(CodeTitle, {
node: makeNode(
{ uid: "title-1", ...attrs },
{ typeName: CODE_BLOCK_TITLE_NAME, textContent: textContent },
),
})
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<CodeTitle>", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountTitle()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("title-1")
expect(root.attributes("data-uid")).toBe("title-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountTitle({
nodeCommentId: "comment-1",
diffStatus: DiffStatus.Modified,
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("modified")
})
it("prompts for a title while the node is empty and editable", async ({
expect,
}) => {
const wrapper = await mountTitle()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(t("editor.placeholders.content.extended-code-block.header-title"))
})
it("marks an empty title as unfilled in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountTitle()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t("editor.placeholders.content.extended-code-block.header-title-empty"),
)
})
it("marks an empty title as unfilled while a reviewable diff is shown", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountTitle()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t("editor.placeholders.content.extended-code-block.header-title-empty"),
)
})
it("shows no placeholder once the title has text", async ({ expect }) => {
const wrapper = await mountTitle({}, "setup.sh")
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBeUndefined()
})
it("shows a caret while editing", async ({ expect }) => {
const wrapper = await mountTitle()
expect(wrapper.get("[data-node-view-content]").classes()).toContain(
"caret-foreground",
)
})
it("hides the caret while a reviewable diff is shown", async ({ expect }) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountTitle()
expect(wrapper.get("[data-node-view-content]").classes()).toContain(
"caret-transparent",
)
})
it("tints an unchanged title", async ({ expect }) => {
const wrapper = await mountTitle({ diffStatus: DiffStatus.Unchanged })
expect(wrapper.get("[data-node-view-wrapper]").classes()).toContain(
"bg-muted-highlight",
)
})
it.for([DiffStatus.Added, DiffStatus.Removed])(
"leaves the diff colour of a %s title alone",
async (diffStatus, { expect }) => {
const wrapper = await mountTitle({ diffStatus: diffStatus })
expect(wrapper.get("[data-node-view-wrapper]").classes()).not.toContain(
"bg-muted-highlight",
)
},
)
})

View File

@ -0,0 +1,449 @@
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it, vi } from "vitest"
import FigmaBlock from "./FigmaBlock.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
import { clearTeleportedOverlays, t } from "~/components/test-helpers"
const FIGMA_URL = "https://www.figma.com/file/abc123/Design"
const OTHER_FIGMA_URL = "https://www.figma.com/file/xyz789/Other"
const MIN_SIZE = 128
const DEFAULT_HEIGHT = 450
function mountFigma(
attrs: Record<string, unknown> = {},
updateAttributes: (attrs: Record<string, unknown>) => void = () => undefined,
) {
return mountNodeView(FigmaBlock, {
node: makeNode({ uid: "figma-1", ...attrs }),
updateAttributes: updateAttributes,
})
}
// the url popover is teleported into <body>, so it is out of the
// wrapper's reach
function popoverInput(): HTMLInputElement | null {
return document.body.querySelector("input")
}
function typeUrl(value: string) {
const input = popoverInput()
if (!input) {
throw new Error("the url popover is not open")
}
input.value = value
input.dispatchEvent(new Event("input", { bubbles: true }))
}
function pressKey(key: string) {
const input = popoverInput()
if (!input) {
throw new Error("the url popover is not open")
}
input.dispatchEvent(new KeyboardEvent("keydown", { key: key, bubbles: true }))
}
function dragBy(dx: number, dy: number) {
window.dispatchEvent(
new MouseEvent("mousemove", { clientX: 100 + dx, clientY: 200 + dy }),
)
}
async function startDrag(wrapper: VueWrapper) {
await wrapper
.get("[aria-label='Resize']")
.trigger("mousedown", { clientX: 100, clientY: 200 })
}
function embedSrc(wrapper: VueWrapper): string | undefined {
return wrapper.get("iframe").attributes("src")
}
// the editable flag, the colour mode and the editor store are all shared
// app-wide, so these tests cannot interleave
describe("<FigmaBlock>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
useAppearance().changeColorTheme("light")
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountFigma()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("figma-1")
expect(root.attributes("data-uid")).toBe("figma-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountFigma({
nodeCommentId: "comment-1",
diffStatus: "added",
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("added")
})
it("invites the reader to embed a design while the block is empty", async ({
expect,
}) => {
const wrapper = await mountFigma()
expect(wrapper.text()).toBe(t("editor.figma.description"))
expect(wrapper.find("iframe").exists()).toBe(false)
})
it("reports an empty block as empty in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountFigma()
expect(wrapper.text()).toBe(t("editor.figma.empty"))
})
it("embeds the design the node points at", async ({ expect }) => {
const wrapper = await mountFigma({ src: FIGMA_URL })
expect(embedSrc(wrapper)).toBe(
"https://embed.figma.com/design/abc123?embed-host=oxynote&theme=light",
)
})
it("embeds the design in the reader's colour theme", async ({ expect }) => {
useAppearance().changeColorTheme("dark")
const wrapper = await mountFigma({ src: FIGMA_URL })
expect(embedSrc(wrapper)).toContain("theme=dark")
})
it("stays empty for a src that is not a figma url", async ({ expect }) => {
const wrapper = await mountFigma({ src: "https://example.com/thing" })
expect(wrapper.find("iframe").exists()).toBe(false)
expect(wrapper.text()).toBe(t("editor.figma.description"))
})
it.for([
{ status: "added", expected: "diff-added" },
{ status: "removed", expected: "diff-removed" },
{ status: "modified", expected: "diff-modified" },
])(
"marks a $status embed with its diff overlay",
async ({ status, expected }, { expect }) => {
const wrapper = await mountFigma({ src: FIGMA_URL, diffStatus: status })
expect(wrapper.get(".diff-overlay").classes()).toContain(expected)
},
)
it("shows no diff overlay on an unchanged embed", async ({ expect }) => {
const wrapper = await mountFigma({
src: FIGMA_URL,
diffStatus: "unchanged",
})
expect(wrapper.find(".diff-overlay").exists()).toBe(false)
})
it("gives the embed the height the node stores", async ({ expect }) => {
const wrapper = await mountFigma({ src: FIGMA_URL, height: 320 })
expect(wrapper.get("iframe").attributes("style")).toContain("height: 320px")
})
it("falls back to a default height when the node stores none", async ({
expect,
}) => {
const wrapper = await mountFigma({ src: FIGMA_URL })
expect(wrapper.get("iframe").attributes("style")).toContain(
`height: ${DEFAULT_HEIGHT}px`,
)
})
it("reads a stored size written as a string", async ({ expect }) => {
const wrapper = await mountFigma({
src: FIGMA_URL,
width: "300",
height: "200",
})
expect(wrapper.get("iframe").attributes("style")).toContain("height: 200px")
expect(wrapper.get(".group").attributes("style")).toContain("width: 300px")
})
it("ignores a stored size that is not a number", async ({ expect }) => {
const wrapper = await mountFigma({
src: FIGMA_URL,
width: "wide",
height: {},
})
expect(wrapper.get(".group").attributes("style")).toBeUndefined()
expect(wrapper.get("iframe").attributes("style")).toContain(
`height: ${DEFAULT_HEIGHT}px`,
)
})
it("offers the edit and resize controls while editing", async ({
expect,
}) => {
const wrapper = await mountFigma({ src: FIGMA_URL })
expect(wrapper.find("[aria-label='Edit URL']").exists()).toBe(true)
expect(wrapper.find("[aria-label='Resize']").exists()).toBe(true)
})
it("hides the edit and resize controls in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountFigma({ src: FIGMA_URL })
expect(wrapper.find("[aria-label='Edit URL']").exists()).toBe(false)
expect(wrapper.find("[aria-label='Resize']").exists()).toBe(false)
})
it("opens the url popover from the empty state", async ({ expect }) => {
const wrapper = await mountFigma()
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
expect(popoverInput()).not.toBeNull()
})
it("prefills the popover with the current url", async ({ expect }) => {
const wrapper = await mountFigma({ src: FIGMA_URL })
await wrapper.get("[aria-label='Edit URL']").trigger("click")
expect(popoverInput()?.value).toBe(FIGMA_URL)
})
it("keeps the popover closed in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountFigma()
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
expect(popoverInput()).toBeNull()
})
it("stores a figma url entered in the popover", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountFigma({ src: FIGMA_URL }, updateAttributes)
await wrapper.get("[aria-label='Edit URL']").trigger("click")
typeUrl(` ${OTHER_FIGMA_URL} `)
pressKey("Enter")
await nextTick()
expect(updateAttributes).toHaveBeenCalledTimes(1)
expect(updateAttributes).toHaveBeenCalledWith({ src: OTHER_FIGMA_URL })
expect(popoverInput()).toBeNull()
})
it("keeps the popover open for a url that is not a figma one", async ({
expect,
}) => {
const updateAttributes = vi.fn()
const wrapper = await mountFigma({ src: FIGMA_URL }, updateAttributes)
await wrapper.get("[aria-label='Edit URL']").trigger("click")
typeUrl("https://example.com/thing")
pressKey("Enter")
await nextTick()
expect(updateAttributes).toHaveBeenCalledTimes(0)
expect(popoverInput()).not.toBeNull()
})
it("keeps the popover open for an empty url", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountFigma({ src: FIGMA_URL }, updateAttributes)
await wrapper.get("[aria-label='Edit URL']").trigger("click")
typeUrl(" ")
pressKey("Enter")
await nextTick()
expect(updateAttributes).toHaveBeenCalledTimes(0)
expect(popoverInput()).not.toBeNull()
})
it("abandons the popover on escape", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountFigma({ src: FIGMA_URL }, updateAttributes)
await wrapper.get("[aria-label='Edit URL']").trigger("click")
typeUrl(OTHER_FIGMA_URL)
pressKey("Escape")
await nextTick()
expect(updateAttributes).toHaveBeenCalledTimes(0)
expect(popoverInput()).toBeNull()
})
it("keeps the popover open on any other key", async ({ expect }) => {
const wrapper = await mountFigma({ src: FIGMA_URL })
await wrapper.get("[aria-label='Edit URL']").trigger("click")
pressKey("a")
await nextTick()
expect(popoverInput()).not.toBeNull()
})
it("closes the popover from its close button", async ({ expect }) => {
const wrapper = await mountFigma({ src: FIGMA_URL })
await wrapper.get("[aria-label='Edit URL']").trigger("click")
document.body.querySelectorAll("button")[0]?.click()
await nextTick()
expect(popoverInput()).toBeNull()
})
it("closes the popover on a click outside it", async ({ expect }) => {
const wrapper = await mountFigma({ src: FIGMA_URL })
await wrapper.get("[aria-label='Edit URL']").trigger("click")
await nextTick()
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))
await nextTick()
expect(popoverInput()).toBeNull()
})
it("keeps the popover open while clicking inside it", async ({ expect }) => {
const wrapper = await mountFigma({ src: FIGMA_URL })
await wrapper.get("[aria-label='Edit URL']").trigger("click")
await nextTick()
popoverInput()?.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
)
await nextTick()
expect(popoverInput()).not.toBeNull()
})
it("resizes the embed while the handle is dragged", async ({ expect }) => {
const wrapper = await mountFigma({
src: FIGMA_URL,
width: 300,
height: 200,
})
await startDrag(wrapper)
dragBy(50, 30)
await nextTick()
expect(wrapper.get(".group").attributes("style")).toContain("width: 350px")
expect(wrapper.get("iframe").attributes("style")).toContain("height: 230px")
})
it("stores the new size once the drag ends", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountFigma(
{ src: FIGMA_URL, width: 300, height: 200 },
updateAttributes,
)
await startDrag(wrapper)
dragBy(50, 30)
window.dispatchEvent(new MouseEvent("mouseup"))
await nextTick()
expect(updateAttributes).toHaveBeenCalledTimes(1)
expect(updateAttributes).toHaveBeenCalledWith({ width: 350, height: 230 })
})
it("stores nothing when the handle is released without a drag", async ({
expect,
}) => {
const updateAttributes = vi.fn()
const wrapper = await mountFigma(
{ src: FIGMA_URL, width: 300, height: 200 },
updateAttributes,
)
await startDrag(wrapper)
window.dispatchEvent(new MouseEvent("mouseup"))
await nextTick()
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("keeps the embed at its minimum size when dragged past it", async ({
expect,
}) => {
const wrapper = await mountFigma({
src: FIGMA_URL,
width: 300,
height: 200,
})
await startDrag(wrapper)
dragBy(-500, -500)
await nextTick()
expect(wrapper.get(".group").attributes("style")).toContain(
`width: ${MIN_SIZE}px`,
)
expect(wrapper.get("iframe").attributes("style")).toContain(
`height: ${MIN_SIZE}px`,
)
})
it("keeps the embed no taller than it is wide", async ({ expect }) => {
const wrapper = await mountFigma({
src: FIGMA_URL,
width: 300,
height: 200,
})
await startDrag(wrapper)
dragBy(0, 400)
await nextTick()
expect(wrapper.get("iframe").attributes("style")).toContain("height: 300px")
})
it("starts a drag from the element's own width when the node stores none", async ({
expect,
}) => {
const wrapper = await mountFigma({ src: FIGMA_URL })
await startDrag(wrapper)
dragBy(200, 0)
await nextTick()
expect(wrapper.get(".group").attributes("style")).toContain("width: 200px")
})
it("stops resizing once the block is unmounted", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountFigma(
{ src: FIGMA_URL, width: 300, height: 200 },
updateAttributes,
)
await startDrag(wrapper)
wrapper.unmount()
dragBy(50, 30)
window.dispatchEvent(new MouseEvent("mouseup"))
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
})

View File

@ -0,0 +1,565 @@
import { mockNuxtImport } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { setResponseHeader, setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ImageBlock from "./ImageBlock.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
import {
disposeMockEndpoints,
makeXid,
matchingString,
mockEndpoint,
} from "~/composables/api/test-helpers"
import { settleMutations, t, WAIT_FOR_OPTIONS } from "~/components/test-helpers"
vi.mock("vue-sonner", () => ({
toast: {
custom: vi.fn(),
dismiss: vi.fn(),
},
}))
const DOCUMENT_ID = makeXid("doc")
const useRouteMock = vi.hoisted(() => vi.fn())
mockNuxtImport("useRoute", () => useRouteMock)
function onDocumentPage() {
useRouteMock.mockReturnValue({
params: { documentSlug: `page-${DOCUMENT_ID}` },
})
}
function mountImage(
attrs: Record<string, unknown> = {},
updateAttributes: (attrs: Record<string, unknown>) => void = () => undefined,
) {
return mountNodeView(ImageBlock, {
node: makeNode({ uid: "image-1", ...attrs }),
updateAttributes: updateAttributes,
})
}
function fileInput(wrapper: VueWrapper): HTMLInputElement {
return wrapper.get("input[type='file']").element as HTMLInputElement
}
async function pickFile(wrapper: VueWrapper, file?: File) {
const input = wrapper.get("input[type='file']")
Object.defineProperty(input.element, "files", {
value: file ? [file] : [],
configurable: true,
})
await input.trigger("change")
await settleMutations()
}
function pngFile() {
return new File(["binary"], "shot.png", { type: "image/png" })
}
// the resize maths starts from the rendered image box, which happy-dom
// reports as zero-sized — every resize test states the size it drags from
function sizeImage(wrapper: VueWrapper, width: number, height: number) {
vi.spyOn(wrapper.get("img").element, "getBoundingClientRect").mockReturnValue(
{ width: width, height: height } as DOMRect,
)
}
async function startDrag(wrapper: VueWrapper) {
await wrapper
.get("[aria-label='Resize image']")
.trigger("mousedown", { clientX: 100, clientY: 200 })
}
function dragBy(dx: number, dy: number) {
window.dispatchEvent(
new MouseEvent("mousemove", { clientX: 100 + dx, clientY: 200 + dy }),
)
}
// the route mock, the editable flag, the editor store and the mocked
// toast module are all shared by the whole file, so these tests cannot
// interleave
describe("<ImageBlock>", { concurrent: false }, () => {
beforeEach(() => {
vi.mocked(toast.custom).mockReset()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
onDocumentPage()
})
afterEach(disposeMockEndpoints)
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountImage()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("image-1")
expect(root.attributes("data-uid")).toBe("image-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountImage({
nodeCommentId: "comment-1",
diffStatus: "modified",
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("modified")
})
it("invites the reader to upload while the block is empty", async ({
expect,
}) => {
const wrapper = await mountImage()
expect(wrapper.text()).toBe(t("editor.image.description"))
expect(wrapper.find("img").exists()).toBe(false)
})
it("reports an empty block as empty in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountImage()
expect(wrapper.text()).toBe(t("editor.image.empty"))
})
it("reports progress while an upload is running", async ({ expect }) => {
const wrapper = await mountImage({ uploading: true })
expect(wrapper.text()).toBe(t("editor.image.uploading"))
})
it("reports upload as unavailable off a document page", async ({
expect,
}) => {
useRouteMock.mockReturnValue({ params: {} })
const wrapper = await mountImage()
expect(wrapper.text()).toBe(t("editor.image.upload-unavailable"))
})
it("reports upload as unavailable for an unrecognisable document slug", async ({
expect,
}) => {
useRouteMock.mockReturnValue({ params: { documentSlug: "not-a-doc" } })
const wrapper = await mountImage()
expect(wrapper.text()).toBe(t("editor.image.upload-unavailable"))
})
it("shows the uploaded image with its alt text and title", async ({
expect,
}) => {
const wrapper = await mountImage({
src: "https://cdn.test/a.png",
alt: "A diagram",
title: "Architecture",
})
const image = wrapper.get("img")
expect(image.attributes("src")).toBe("https://cdn.test/a.png")
expect(image.attributes("alt")).toBe("A diagram")
expect(image.attributes("title")).toBe("Architecture")
})
it("leaves the alt text and title empty when the node has none", async ({
expect,
}) => {
const wrapper = await mountImage({ src: "https://cdn.test/a.png" })
const image = wrapper.get("img")
expect(image.attributes("alt")).toBe("")
expect(image.attributes("title")).toBe("")
})
it("sizes the image to the width the node stores", async ({ expect }) => {
const wrapper = await mountImage({
src: "https://cdn.test/a.png",
width: 240,
})
expect(wrapper.get("img").attributes("style")).toContain("width: 240px")
})
it("reads a stored width written as a string", async ({ expect }) => {
const wrapper = await mountImage({
src: "https://cdn.test/a.png",
width: "240",
})
expect(wrapper.get("img").attributes("style")).toContain("width: 240px")
})
it("falls back to a thumbnail width when the node stores none", async ({
expect,
}) => {
const wrapper = await mountImage({ src: "https://cdn.test/a.png" })
expect(wrapper.get("img").attributes("style")).toBeUndefined()
expect(wrapper.get("img").classes()).toContain("w-32")
})
it("ignores a stored width that is not a number", async ({ expect }) => {
const wrapper = await mountImage({
src: "https://cdn.test/a.png",
width: {},
})
expect(wrapper.get("img").classes()).toContain("w-32")
})
it.for([
{ status: "added", expected: "diff-added" },
{ status: "removed", expected: "diff-removed" },
{ status: "modified", expected: "diff-modified" },
])(
"marks a $status image with its diff overlay",
async ({ status, expected }, { expect }) => {
const wrapper = await mountImage({
src: "https://cdn.test/a.png",
diffStatus: status,
})
expect(wrapper.get(".diff-overlay").classes()).toContain(expected)
},
)
it("shows no diff overlay on an unchanged image", async ({ expect }) => {
const wrapper = await mountImage({
src: "https://cdn.test/a.png",
diffStatus: "unchanged",
})
expect(wrapper.find(".diff-overlay").exists()).toBe(false)
})
it("offers the resize handle on an image while editing", async ({
expect,
}) => {
const wrapper = await mountImage({ src: "https://cdn.test/a.png" })
expect(wrapper.find("[aria-label='Resize image']").exists()).toBe(true)
})
it("hides the resize handle in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountImage({ src: "https://cdn.test/a.png" })
expect(wrapper.find("[aria-label='Resize image']").exists()).toBe(false)
})
it("scales the image while the handle is dragged", async ({ expect }) => {
const wrapper = await mountImage({ src: "https://cdn.test/a.png" })
sizeImage(wrapper, 200, 100)
await startDrag(wrapper)
dragBy(100, 0)
await nextTick()
expect(wrapper.get("img").attributes("style")).toContain("width: 300px")
})
it("scales by whichever axis the pointer moved further along", async ({
expect,
}) => {
const wrapper = await mountImage({ src: "https://cdn.test/a.png" })
sizeImage(wrapper, 200, 100)
await startDrag(wrapper)
dragBy(20, 100)
await nextTick()
expect(wrapper.get("img").attributes("style")).toContain("width: 400px")
})
it("keeps the image at its minimum width when dragged past it", async ({
expect,
}) => {
const wrapper = await mountImage({ src: "https://cdn.test/a.png" })
sizeImage(wrapper, 200, 100)
await startDrag(wrapper)
dragBy(-500, -500)
await nextTick()
expect(wrapper.get("img").attributes("style")).toContain("width: 128px")
})
it("stores the new width once the drag ends", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountImage(
{ src: "https://cdn.test/a.png" },
updateAttributes,
)
sizeImage(wrapper, 200, 100)
await startDrag(wrapper)
dragBy(100, 0)
window.dispatchEvent(new MouseEvent("mouseup"))
await nextTick()
expect(updateAttributes).toHaveBeenCalledTimes(1)
expect(updateAttributes).toHaveBeenCalledWith({ width: 300 })
})
it("stores nothing when the handle is released without a drag", async ({
expect,
}) => {
const updateAttributes = vi.fn()
const wrapper = await mountImage(
{ src: "https://cdn.test/a.png" },
updateAttributes,
)
sizeImage(wrapper, 200, 100)
await startDrag(wrapper)
window.dispatchEvent(new MouseEvent("mouseup"))
await nextTick()
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("does not resize an image it cannot measure", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountImage(
{ src: "https://cdn.test/a.png" },
updateAttributes,
)
await startDrag(wrapper)
dragBy(100, 0)
window.dispatchEvent(new MouseEvent("mouseup"))
await nextTick()
expect(wrapper.get("img").attributes("style")).toBeUndefined()
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("stops resizing once the block is unmounted", async ({ expect }) => {
const updateAttributes = vi.fn()
const wrapper = await mountImage(
{ src: "https://cdn.test/a.png" },
updateAttributes,
)
sizeImage(wrapper, 200, 100)
await startDrag(wrapper)
wrapper.unmount()
dragBy(100, 0)
window.dispatchEvent(new MouseEvent("mouseup"))
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("opens the file picker from the empty state", async ({ expect }) => {
const wrapper = await mountImage()
// a real click on the hidden input bubbles back to the container
// and would re-enter the handler, so the spy stands in for it
const click = vi
.spyOn(fileInput(wrapper), "click")
.mockImplementation(() => undefined)
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
expect(click).toHaveBeenCalledTimes(1)
})
it("keeps the file picker shut in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountImage()
const click = vi.spyOn(fileInput(wrapper), "click")
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
expect(click).toHaveBeenCalledTimes(0)
})
it("keeps the file picker shut while an upload is running", async ({
expect,
}) => {
const wrapper = await mountImage({ uploading: true })
const click = vi.spyOn(fileInput(wrapper), "click")
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
expect(click).toHaveBeenCalledTimes(0)
})
it("uploads a picked image and points the block at it", async ({
expect,
}) => {
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/files`,
(_call, event) => {
setResponseHeader(event, "location", "https://cdn.test/stored.png")
return null
},
)
const updateAttributes = vi.fn()
const wrapper = await mountImage({}, updateAttributes)
await pickFile(wrapper, pngFile())
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.query).toEqual({ id: "image-1", location: "document" })
expect(updateAttributes).toHaveBeenCalledTimes(2)
expect(updateAttributes).toHaveBeenNthCalledWith(1, { uploading: true })
expect(updateAttributes).toHaveBeenNthCalledWith(2, {
src: matchingString(
new RegExp(`/api/documents/${DOCUMENT_ID}/files/image-1$`),
),
uid: "image-1",
uploading: false,
})
expect(toast.custom).toHaveBeenCalledTimes(0)
})
it("uploads under a generated id when the node has none", async ({
expect,
}) => {
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/files`,
(_call, event) => {
setResponseHeader(event, "location", "https://cdn.test/stored.png")
return null
},
)
const updateAttributes = vi.fn()
const wrapper = await mountImage({ uid: null }, updateAttributes)
await pickFile(wrapper, pngFile())
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.query.id).toEqual(expect.any(String))
expect(calls[0]?.query.id).not.toBe("")
})
it("does nothing when the file dialog is dismissed", async ({ expect }) => {
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/files`,
() => null,
)
const updateAttributes = vi.fn()
const wrapper = await mountImage({}, updateAttributes)
await pickFile(wrapper)
expect(calls).toHaveLength(0)
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("warns that upload is unavailable off a document page", async ({
expect,
}) => {
useRouteMock.mockReturnValue({ params: {} })
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/files`,
() => null,
)
const updateAttributes = vi.fn()
const wrapper = await mountImage({}, updateAttributes)
await pickFile(wrapper, pngFile())
expect(calls).toHaveLength(0)
expect(updateAttributes).toHaveBeenCalledTimes(0)
expect(toast.custom).toHaveBeenCalledTimes(1)
})
it("warns and clears the upload flag when the upload fails", async ({
expect,
}) => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {
return undefined
})
mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/files`,
(_call, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
const updateAttributes = vi.fn()
const wrapper = await mountImage({}, updateAttributes)
await pickFile(wrapper, pngFile())
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
expect(updateAttributes).toHaveBeenNthCalledWith(2, { uploading: false })
expect(consoleError).toHaveBeenCalledTimes(1)
})
it("leaves a rejected file type to the storage error handler", async ({
expect,
}) => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {
return undefined
})
mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/files`,
(_call, event) => {
setResponseStatus(event, 400)
return { code: "storage.invalid_content_type" }
},
)
const updateAttributes = vi.fn()
const wrapper = await mountImage({}, updateAttributes)
await pickFile(wrapper, pngFile())
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
expect(updateAttributes).toHaveBeenNthCalledWith(2, { uploading: false })
expect(consoleError).toHaveBeenCalledTimes(0)
})
it("warns when the upload response carries no location", async ({
expect,
}) => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {
return undefined
})
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/files`, () => null)
const updateAttributes = vi.fn()
const wrapper = await mountImage({}, updateAttributes)
await pickFile(wrapper, pngFile())
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
expect(consoleError).toHaveBeenCalledTimes(1)
})
})

View File

@ -12,6 +12,7 @@ const props = defineProps(nodeViewProps)
const { t } = useI18n({ useScope: "global" })
const { isEditable } = useEditorMeta()
const editorStore = useEditorStore()
const { uploadDocumentFile } = useDocumentFileAPI()
const imgRef = ref<HTMLImageElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
@ -175,7 +176,6 @@ async function handleFileChange(event: Event) {
return
}
const { uploadDocumentFile } = useDocumentFileAPI()
const blockId = (props.node.attrs.uid as string) || nanoid()
props.updateAttributes({ uploading: true })

View File

@ -0,0 +1,347 @@
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it, vi } from "vitest"
import MainBlock from "./MainBlock.vue"
import MermaidPreview from "./MermaidPreview.vue"
import {
makeEditor,
makeNode,
mountNodeView,
} from "../../test-helpers/node-view"
import { MERMAID_BLOCK_NAME } from "../node-names"
import { DiffStatus } from "../../diff/position-map"
import { t } from "~/components/test-helpers"
vi.mock("./useMermaid", async () => {
const { ref } = await import("vue")
return {
useMermaid: () => ({
render: vi.fn().mockResolvedValue({ svg: "<svg></svg>" }),
isLoading: ref(false),
loadError: ref<string | null>(null),
}),
}
})
let uidCounter = 0
// every block keeps its open/closed state in the app-wide editor store,
// keyed by uid — a fresh uid per test keeps them from sharing it
function nextUid(): string {
uidCounter++
return `mermaid-${uidCounter}`
}
const refreshTextCommentIndicators = vi.fn()
// the awareness the collaboration composable reads: the composable takes
// anything that is not a real Editor instance as the provider itself, so
// the editor stand-in carries the awareness directly
function makeCollaborationEditor(
editing: { uid: string; name: string; color: string } | null,
) {
const states = new Map<number, Record<string, unknown>>([[1, {}]])
if (editing) {
states.set(2, {
editingNodeUid: { uid: editing.uid },
user: { name: editing.name, color: editing.color },
})
}
return makeEditor({
commands: { refreshTextCommentIndicators: refreshTextCommentIndicators },
awareness: {
clientID: 1,
getStates: () => states,
on: vi.fn(),
off: vi.fn(),
},
})
}
function mountMermaid(
options: {
uid?: string
attrs?: Record<string, unknown>
textContent?: string
editing?: { uid: string; name: string; color: string } | null
} = {},
) {
const uid = options.uid ?? nextUid()
return mountNodeView(MainBlock, {
node: makeNode(
{ uid: uid, ...options.attrs },
{
typeName: MERMAID_BLOCK_NAME,
textContent: options.textContent ?? "",
},
),
editor: makeCollaborationEditor(options.editing ?? null).editor,
})
}
function toggleButton(wrapper: VueWrapper) {
return wrapper.get("button")
}
// the code pane sits in the dom either way and is hidden with v-show
function codeVisible(wrapper: VueWrapper): boolean {
const pane = wrapper.get(".overflow-x-auto").element as HTMLElement
return pane.style.display !== "none"
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<MermaidMainBlock>", { concurrent: false }, () => {
beforeEach(() => {
refreshTextCommentIndicators.mockReset()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const uid = nextUid()
const wrapper = await mountMermaid({ uid: uid })
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe(uid)
expect(root.attributes("data-uid")).toBe(uid)
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountMermaid({
attrs: { nodeCommentId: "comment-1", diffStatus: DiffStatus.Added },
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("added")
})
it("opens the code pane for a block with no diagram yet", async ({
expect,
}) => {
const wrapper = await mountMermaid()
expect(codeVisible(wrapper)).toBe(true)
})
it("keeps the code pane closed for a block that has a diagram", async ({
expect,
}) => {
const wrapper = await mountMermaid({ textContent: "graph TD; A-->B;" })
expect(codeVisible(wrapper)).toBe(false)
})
it("leaves an already tracked block's code pane as the reader left it", async ({
expect,
}) => {
const uid = nextUid()
useEditorStore().setMermaidBlockShowCode(uid, false)
const wrapper = await mountMermaid({ uid: uid })
expect(codeVisible(wrapper)).toBe(false)
})
it("closes the code pane when the reader hides it", async ({ expect }) => {
const wrapper = await mountMermaid()
await toggleButton(wrapper).trigger("click")
expect(codeVisible(wrapper)).toBe(false)
expect(toggleButton(wrapper).attributes("aria-pressed")).toBe("false")
})
it("reopens the code pane when the reader shows it again", async ({
expect,
}) => {
const wrapper = await mountMermaid({ textContent: "graph TD; A-->B;" })
await toggleButton(wrapper).trigger("click")
expect(codeVisible(wrapper)).toBe(true)
expect(toggleButton(wrapper).attributes("aria-pressed")).toBe("true")
})
it("remembers the code pane state across blocks with the same uid", async ({
expect,
}) => {
const uid = nextUid()
const wrapper = await mountMermaid({ uid: uid })
await toggleButton(wrapper).trigger("click")
const other = await mountMermaid({ uid: uid })
expect(codeVisible(other)).toBe(false)
})
it("refreshes the comment indicators after the pane is toggled", async ({
expect,
}) => {
const wrapper = await mountMermaid()
await toggleButton(wrapper).trigger("click")
await nextTick()
expect(refreshTextCommentIndicators).toHaveBeenCalledTimes(1)
})
it("prompts for code while the block is empty and editable", async ({
expect,
}) => {
const wrapper = await mountMermaid()
expect(wrapper.text()).toContain(
t("editor.placeholders.content.mermaid.content"),
)
})
it("marks an empty block as unfilled in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountMermaid()
expect(wrapper.text()).toContain(
t("editor.placeholders.content.mermaid.content-empty"),
)
})
it("shows no placeholder once the block has code", async ({ expect }) => {
const wrapper = await mountMermaid({ textContent: "graph TD; A-->B;" })
expect(wrapper.text()).not.toContain(
t("editor.placeholders.content.mermaid.content"),
)
})
it("previews the code the block holds", async ({ expect }) => {
const wrapper = await mountMermaid({ textContent: "graph TD; A-->B;" })
expect(wrapper.findComponent(MermaidPreview).props()).toEqual(
expect.objectContaining({ source: "graph TD; A-->B;" }),
)
})
it("previews only the modified side of a diffed block", async ({
expect,
}) => {
const wrapper = await mountMermaid({
textContent: "graph TD; A-->B;graph TD; A-->C;",
attrs: { modifiedTextContent: "graph TD; A-->C;" },
})
expect(wrapper.findComponent(MermaidPreview).props()).toEqual(
expect.objectContaining({ source: "graph TD; A-->C;" }),
)
})
it.for([
{ status: DiffStatus.Added, expected: "diff-added" },
{ status: DiffStatus.Removed, expected: "diff-removed" },
])(
"marks a $status block with its diff colour whatever the pane shows",
async ({ status, expected }, { expect }) => {
const wrapper = await mountMermaid({ attrs: { diffStatus: status } })
expect(wrapper.get("[data-node-view-wrapper]").classes()).toContain(
expected,
)
},
)
it("marks a modified block only while its code is hidden", async ({
expect,
}) => {
const wrapper = await mountMermaid({
textContent: "graph TD; A-->B;",
attrs: { diffStatus: DiffStatus.Modified },
})
expect(wrapper.get("[data-node-view-wrapper]").classes()).toContain(
"diff-modified",
)
})
it("leaves a modified block's colour to the inline diff while its code shows", async ({
expect,
}) => {
const wrapper = await mountMermaid({
attrs: { diffStatus: DiffStatus.Modified },
})
expect(wrapper.get("[data-node-view-wrapper]").classes()).not.toContain(
"diff-modified",
)
})
it("shows no diff colour on an unchanged block", async ({ expect }) => {
const wrapper = await mountMermaid({
attrs: { diffStatus: DiffStatus.Unchanged },
})
expect(wrapper.get("[data-node-view-wrapper]").classes()).not.toContain(
"diff-modified",
)
})
it("names the collaborator editing the block", async ({ expect }) => {
const uid = nextUid()
const wrapper = await mountMermaid({
uid: uid,
textContent: "graph TD; A-->B;",
editing: { uid: uid, name: "Ada", color: "#ff0000" },
})
expect(wrapper.text()).toContain("Ada")
expect(
wrapper.get("[data-node-view-wrapper]").attributes("style"),
).toContain("border-color: #ff0000")
})
it("names nobody while the block's code pane is open", async ({ expect }) => {
const uid = nextUid()
const wrapper = await mountMermaid({
uid: uid,
editing: { uid: uid, name: "Ada", color: "#ff0000" },
})
expect(wrapper.text()).not.toContain("Ada")
})
it("names nobody in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const uid = nextUid()
const wrapper = await mountMermaid({
uid: uid,
textContent: "graph TD; A-->B;",
editing: { uid: uid, name: "Ada", color: "#ff0000" },
})
expect(wrapper.text()).not.toContain("Ada")
})
it("names nobody when the collaborator is editing another block", async ({
expect,
}) => {
const wrapper = await mountMermaid({
textContent: "graph TD; A-->B;",
editing: { uid: "elsewhere", name: "Ada", color: "#ff0000" },
})
expect(wrapper.text()).not.toContain("Ada")
})
})

View File

@ -0,0 +1,280 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import type { Ref } from "vue"
import MermaidPreview from "./MermaidPreview.vue"
import { t, WAIT_FOR_OPTIONS } from "~/components/test-helpers"
const DEBOUNCE_MS = 400
// the real composable dynamically imports mermaid and drives a browser
// renderer; the suite drives its three outputs directly instead
const mermaid = vi.hoisted(() => {
return {
render:
vi.fn<(id: string, source: string) => Promise<Record<string, string>>>(),
isLoading: null as unknown as Ref<boolean>,
loadError: null as unknown as Ref<string | null>,
}
})
vi.mock("./useMermaid", async () => {
const { ref } = await import("vue")
mermaid.isLoading = ref(false)
mermaid.loadError = ref<string | null>(null)
return {
useMermaid: () => ({
render: mermaid.render,
isLoading: mermaid.isLoading,
loadError: mermaid.loadError,
}),
}
})
function mountPreview(source: string) {
return mountSuspended(MermaidPreview, {
props: { source: source, uid: "mermaid-1" },
})
}
// puts the colour mode back on "follow the system" and hands back a
// switch that flips the system preference to dark, the way a reader
// changing their OS theme would
function followSystemTheme(): () => void {
const listeners: ((event: { matches: boolean }) => void)[] = []
const query = {
matches: false,
media: "(prefers-color-scheme: dark)",
onchange: null,
addEventListener: (
_event: string,
listener: (e: { matches: boolean }) => void,
) => {
listeners.push(listener)
},
removeEventListener: () => undefined,
dispatchEvent: () => true,
}
vi.stubGlobal(
"matchMedia",
vi.fn(() => query),
)
useAppearance().changeColorTheme("auto")
return () => {
query.matches = true
listeners.forEach((listener) => {
listener({ matches: true })
})
}
}
function previewHtml(wrapper: VueWrapper): string {
return wrapper.get(".mermaid-preview").html()
}
// the mocked composable's refs and the colour mode are shared by the
// whole file, so these tests cannot interleave
describe("<MermaidPreview>", { concurrent: false }, () => {
// each preview keeps watching the colour mode for as long as it is
// mounted, so a leftover one would redraw during the next test
enableAutoUnmount(afterEach)
beforeEach(() => {
// the debounce tests leave the clock frozen behind them
vi.useRealTimers()
mermaid.render.mockReset()
mermaid.render.mockResolvedValue({ svg: "<svg><g></g></svg>" })
mermaid.isLoading.value = false
mermaid.loadError.value = null
useAppearance().changeColorTheme("light")
})
it("reports that the diagram engine is still loading", async ({ expect }) => {
mermaid.isLoading.value = true
const wrapper = await mountPreview("")
expect(wrapper.text()).toContain(t("editor.mermaid.preview.loading"))
})
it("reports a diagram engine that failed to load", async ({ expect }) => {
mermaid.loadError.value = "network down"
const wrapper = await mountPreview("")
expect(wrapper.text()).toContain(t("editor.mermaid.preview.load-error"))
expect(wrapper.text()).toContain("Network down")
})
it("invites the reader to write code while the block is empty", async ({
expect,
}) => {
const wrapper = await mountPreview(" ")
expect(wrapper.text()).toContain(t("editor.mermaid.preview.empty"))
expect(mermaid.render).toHaveBeenCalledTimes(0)
})
it("renders the diagram the code describes", async ({ expect }) => {
const wrapper = await mountPreview("graph TD; A-->B;")
await vi.waitFor(() => {
expect(previewHtml(wrapper)).toContain("<svg>")
}, WAIT_FOR_OPTIONS)
expect(mermaid.render).toHaveBeenCalledTimes(1)
expect(mermaid.render.mock.calls[0]?.[1]).toBe("graph TD; A-->B;")
})
it("strips scripting out of the rendered diagram", async ({ expect }) => {
mermaid.render.mockResolvedValue({
svg: "<svg><text>kept</text><script>alert(1)</script></svg>",
})
const wrapper = await mountPreview("graph TD; A-->B;")
await vi.waitFor(() => {
expect(previewHtml(wrapper)).toContain("kept")
}, WAIT_FOR_OPTIONS)
expect(previewHtml(wrapper)).not.toContain("alert(1)")
})
it("expands tabs in the code before rendering it", async ({ expect }) => {
const wrapper = await mountPreview("graph TD;\n\tA-->B;")
await vi.waitFor(() => {
expect(mermaid.render).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
expect(mermaid.render.mock.calls[0]?.[1]).toBe("graph TD;\n A-->B;")
expect(wrapper.exists()).toBe(true)
})
it("reports a diagram the engine could not draw", async ({ expect }) => {
mermaid.render.mockResolvedValue({ error: "syntax error in graph" })
const wrapper = await mountPreview("graph TD; A-->")
await vi.waitFor(() => {
expect(wrapper.text()).toContain(t("editor.mermaid.preview.render-error"))
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).toContain("Syntax error in graph")
})
it("stays hidden until the first diagram is drawn", async ({ expect }) => {
let resolveRender: (value: Record<string, string>) => void = () => undefined
mermaid.render.mockReturnValue(
new Promise((resolve) => {
resolveRender = resolve
}),
)
const wrapper = await mountPreview("graph TD; A-->B;")
expect(wrapper.get(".mermaid-preview").classes()).toContain("opacity-0")
resolveRender({ svg: "<svg><g></g></svg>" })
await vi.waitFor(() => {
expect(wrapper.get(".mermaid-preview").classes()).toContain("opacity-100")
}, WAIT_FOR_OPTIONS)
})
it("shows an empty block right away", async ({ expect }) => {
const wrapper = await mountPreview("")
expect(wrapper.get(".mermaid-preview").classes()).toContain("opacity-100")
})
it("redraws the diagram once the code settles", async ({ expect }) => {
const wrapper = await mountPreview("graph TD; A-->B;")
await vi.waitFor(() => {
expect(mermaid.render).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
vi.useFakeTimers()
await wrapper.setProps({ source: "graph TD; A-->C;" })
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS)
expect(mermaid.render).toHaveBeenCalledTimes(2)
expect(mermaid.render.mock.calls[1]?.[1]).toBe("graph TD; A-->C;")
})
it("leaves the diagram alone while the code is still being typed", async ({
expect,
}) => {
const wrapper = await mountPreview("graph TD; A-->B;")
await vi.waitFor(() => {
expect(mermaid.render).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
vi.useFakeTimers()
await wrapper.setProps({ source: "graph TD; A-->C;" })
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS - 1)
expect(mermaid.render).toHaveBeenCalledTimes(1)
})
it("clears the diagram when the code is deleted", async ({ expect }) => {
const wrapper = await mountPreview("graph TD; A-->B;")
await vi.waitFor(() => {
expect(previewHtml(wrapper)).toContain("<svg>")
}, WAIT_FOR_OPTIONS)
vi.useFakeTimers()
await wrapper.setProps({ source: "" })
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS)
expect(wrapper.text()).toContain(t("editor.mermaid.preview.empty"))
expect(mermaid.render).toHaveBeenCalledTimes(1)
})
it("keeps only the newest diagram when two renders overlap", async ({
expect,
}) => {
const pending: ((value: Record<string, string>) => void)[] = []
mermaid.render.mockImplementation(
() =>
new Promise((resolve) => {
pending.push(resolve)
}),
)
const wrapper = await mountPreview("graph TD; A-->B;")
await vi.waitFor(() => {
expect(pending).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
vi.useFakeTimers()
await wrapper.setProps({ source: "graph TD; A-->C;" })
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS)
pending[1]?.({ svg: '<svg><g id="second"></g></svg>' })
await vi.advanceTimersByTimeAsync(0)
pending[0]?.({ svg: '<svg><g id="first"></g></svg>' })
await vi.advanceTimersByTimeAsync(0)
expect(previewHtml(wrapper)).toContain("second")
expect(previewHtml(wrapper)).not.toContain("first")
})
it("redraws the diagram in the reader's new colour theme", async ({
expect,
}) => {
// the colour mode is read out of a cookie the composable re-reads
// per instance, so a change made from the test would never reach a
// mounted component. Following the system theme instead gives a
// switch the mounted preview does see.
const switchToDark = followSystemTheme()
const wrapper = await mountPreview("graph TD; A-->B;")
await vi.waitFor(() => {
expect(mermaid.render).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
switchToDark()
await vi.waitFor(() => {
expect(mermaid.render).toHaveBeenCalledTimes(2)
}, WAIT_FOR_OPTIONS)
expect(wrapper.exists()).toBe(true)
})
})

View File

@ -0,0 +1,37 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import ConfigField from "./ConfigField.vue"
import { t } from "~/components/test-helpers"
function mountField(slots: Record<string, () => unknown>) {
return mountSuspended(ConfigField, { slots: slots })
}
describe("<ConfigField>", () => {
it("shows the label above the field", async ({ expect }) => {
const wrapper = await mountField({
label: () => t("editor.metrics.config.data-source-label"),
default: () => h("input"),
})
expect(wrapper.text()).toBe(t("editor.metrics.config.data-source-label"))
})
it("renders the field contents", async ({ expect }) => {
const wrapper = await mountField({
label: () => t("editor.metrics.config.data-source-label"),
default: () => h("input", { id: "field" }),
})
expect(wrapper.find("input#field").exists()).toBe(true)
})
it("renders nothing but the frame when no slots are filled", async ({
expect,
}) => {
const wrapper = await mountField({})
expect(wrapper.text()).toBe("")
expect(wrapper.findAll("div")).toHaveLength(3)
})
})

View File

@ -0,0 +1,33 @@
import { describe, it } from "vitest"
import GridView from "./GridView.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
describe("<MetricGridView>", () => {
it("marks itself as the metric grid", async ({ expect }) => {
const wrapper = await mountNodeView(GridView, { node: makeNode({}) })
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-type")).toBe("metric-grid")
expect(root.classes()).toContain("metric-grid")
})
it("exposes the node's diff status on the wrapper", async ({ expect }) => {
const wrapper = await mountNodeView(GridView, {
node: makeNode({ diffStatus: "added" }),
})
expect(
wrapper.get("[data-node-view-wrapper]").attributes("data-diff-status"),
).toBe("added")
})
it("lays the blocks out in a grid", async ({ expect }) => {
const wrapper = await mountNodeView(GridView, { node: makeNode({}) })
expect(wrapper.get("[data-node-view-content]").classes()).toContain(
"metric-grid-content",
)
expect(wrapper.get("[data-node-view-content]").classes()).toContain("grid")
})
})

View File

@ -0,0 +1,403 @@
import type { VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import MainBlock from "./MainBlock.vue"
import { stubChartColorContext } from "./test-helpers"
import { MetricBlockWidth, TimeRangePreset, type MetricConfig } from "./utils"
import {
makeEditor,
makeNode,
mountNodeView,
} from "../../test-helpers/node-view"
import { DiffStatus } from "~/components/editor/diff/position-map"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
} from "~/composables/api/test-helpers"
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
let uidCounter = 0
// the store keys every block's config by uid and keeps it for the whole
// document, so each test works on a uid of its own
function nextUid(): string {
uidCounter++
return `metric-block-${uidCounter}`
}
// the editor a metric block reads: the collaboration composable takes
// anything that is not a real Editor instance as the awareness provider,
// so the stand-in carries the awareness directly
function makeMetricEditor(
options: {
nodeAtPos?: { attrs: Record<string, unknown> } | null
editing?: { uid: string; name: string; color: string } | null
} = {},
) {
const states = new Map<number, Record<string, unknown>>([[1, {}]])
if (options.editing) {
states.set(2, {
editingNodeUid: { uid: options.editing.uid },
user: { name: options.editing.name, color: options.editing.color },
})
}
return makeEditor({
state: {
doc: {
nodeAt: () =>
options.nodeAtPos === undefined ? null : options.nodeAtPos,
},
},
awareness: {
clientID: 1,
getStates: () => states,
on: vi.fn(),
off: vi.fn(),
},
})
}
function mountBlock(
options: {
uid?: string
attrs?: Record<string, unknown>
updateAttributes?: (attrs: Record<string, unknown>) => void
getPos?: () => number | undefined
nodeAtPos?: { attrs: Record<string, unknown> } | null
editing?: { uid: string; name: string; color: string } | null
} = {},
) {
const uid = options.uid ?? nextUid()
return mountNodeView(MainBlock, {
node: makeNode({ uid: uid, ...options.attrs }),
editor: makeMetricEditor({
nodeAtPos:
"nodeAtPos" in options ? options.nodeAtPos : { attrs: { uid: uid } },
editing: options.editing,
}).editor,
getPos: options.getPos ?? (() => 5),
updateAttributes: options.updateAttributes ?? (() => undefined),
})
}
// the config object the block published into the store, which is what the
// config modal reads and writes through
function storedConfig(uid: string): MetricConfig | undefined {
return useEditorStore().metricBlockConfigs[DOCUMENT_ID]?.[BRANCH_ID]?.[uid]
}
function wrapperClasses(wrapper: VueWrapper): string[] {
return wrapper.get("[data-node-view-wrapper]").classes()
}
// the editor store and the editable flag are shared app-wide, so these
// tests cannot interleave
describe("<MetricMainBlock>", { concurrent: false }, () => {
beforeEach(() => {
stubChartColorContext()
clearQueryCache()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
})
afterEach(disposeMockEndpoints)
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const uid = nextUid()
const wrapper = await mountBlock({ uid: uid })
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe(uid)
expect(root.attributes("data-uid")).toBe(uid)
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountBlock({
attrs: { nodeCommentId: "comment-1", diffStatus: DiffStatus.Added },
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("added")
})
it.for([
MetricBlockWidth.Compact,
MetricBlockWidth.Standard,
MetricBlockWidth.Wide,
])("gives a %s block its own grid span", async (width, { expect }) => {
const wrapper = await mountBlock({ attrs: { width: width } })
expect(wrapperClasses(wrapper)).toContain(`metric-block-${width}`)
})
it("spans a block with no stored width like a standard one", async ({
expect,
}) => {
const wrapper = await mountBlock()
expect(wrapperClasses(wrapper)).toContain("metric-block-standard")
})
it("publishes the block's config for the config modal to read", async ({
expect,
}) => {
const uid = nextUid()
await mountBlock({
uid: uid,
attrs: { title: "Requests", timeRange: TimeRangePreset.Last1Hour },
})
expect(storedConfig(uid)).toEqual(
expect.objectContaining({
title: "Requests",
timeRange: TimeRangePreset.Last1Hour,
}),
)
})
it("publishes a legacy block's config over its schema defaults", async ({
expect,
}) => {
const uid = nextUid()
await mountBlock({
uid: uid,
attrs: {
title: "",
config: {
title: "Legacy",
type: GenericQueryChartType.Bar,
unit: { type: null, custom: "req/s" },
axisBounds: { min: 0, max: 10 },
},
},
})
expect(storedConfig(uid)).toEqual(
expect.objectContaining({
title: "Legacy",
visualizationType: GenericQueryChartType.Bar,
}),
)
expect(storedConfig(uid)?.unit).toEqual({ type: null, custom: "req/s" })
expect(storedConfig(uid)?.axisBounds).toEqual({ min: 0, max: 10 })
})
it("publishes nothing for a block shown inside a diff", async ({
expect,
}) => {
const uid = nextUid()
await mountBlock({ uid: uid, attrs: { diffStatus: DiffStatus.Added } })
expect(storedConfig(uid)).toBeUndefined()
expect(
useEditorStore().metricBlockDiffStatuses[DOCUMENT_ID]?.[BRANCH_ID]?.[uid],
).toBe(DiffStatus.Added)
})
it("publishes the previous config of a modified block", async ({
expect,
}) => {
const uid = nextUid()
await mountBlock({
uid: uid,
attrs: {
diffStatus: DiffStatus.Modified,
oldNode: { attrs: { title: "Before" } },
},
})
expect(
useEditorStore().metricBlockOldConfigs[DOCUMENT_ID]?.[BRANCH_ID]?.[uid],
).toEqual(expect.objectContaining({ title: "Before" }))
})
it("publishes no previous config for an added block", async ({ expect }) => {
const uid = nextUid()
await mountBlock({ uid: uid, attrs: { diffStatus: DiffStatus.Added } })
expect(
useEditorStore().metricBlockOldConfigs[DOCUMENT_ID]?.[BRANCH_ID]?.[uid],
).toBeNull()
})
it("publishes nothing while a reviewable diff is shown", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const uid = nextUid()
await mountBlock({ uid: uid })
expect(storedConfig(uid)).toBeUndefined()
})
it("stores an edited field on the node", async ({ expect }) => {
const updateAttributes = vi.fn()
const uid = nextUid()
await mountBlock({ uid: uid, updateAttributes: updateAttributes })
const config = storedConfig(uid)
if (config) {
config.title = "Renamed"
}
expect(updateAttributes).toHaveBeenCalledTimes(1)
expect(updateAttributes).toHaveBeenCalledWith({ title: "Renamed" })
})
it("flattens a legacy config on the first edit", async ({ expect }) => {
const updateAttributes = vi.fn()
const uid = nextUid()
await mountBlock({
uid: uid,
updateAttributes: updateAttributes,
attrs: {
config: {
title: "Legacy",
dataSourceId: "ds-1",
type: GenericQueryChartType.Gauge,
unit: { type: null, custom: "req/s" },
},
},
})
const config = storedConfig(uid)
if (config) {
config.title = "Renamed"
}
expect(updateAttributes).toHaveBeenCalledTimes(1)
expect(updateAttributes).toHaveBeenCalledWith(
expect.objectContaining({
title: "Renamed",
dataSourceId: "ds-1",
visualizationType: GenericQueryChartType.Gauge,
unitCustom: "req/s",
config: null,
}),
)
})
it("stores nothing while a reviewable diff is shown", async ({ expect }) => {
const updateAttributes = vi.fn()
const uid = nextUid()
await mountBlock({ uid: uid, updateAttributes: updateAttributes })
const config = storedConfig(uid)
useEditorStore().setReviewableDiffActive(true)
if (config) {
config.title = "Renamed"
}
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("stores nothing for a block with no resolvable position", async ({
expect,
}) => {
const updateAttributes = vi.fn()
const uid = nextUid()
await mountBlock({
uid: uid,
updateAttributes: updateAttributes,
getPos: () => undefined,
})
const config = storedConfig(uid)
if (config) {
config.title = "Renamed"
}
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("stores nothing for a block that has moved away", async ({ expect }) => {
const updateAttributes = vi.fn()
const uid = nextUid()
await mountBlock({
uid: uid,
updateAttributes: updateAttributes,
nodeAtPos: { attrs: { uid: "somebody-else" } },
})
const config = storedConfig(uid)
if (config) {
config.title = "Renamed"
}
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("stores nothing for a block that has been deleted", async ({ expect }) => {
const updateAttributes = vi.fn()
const uid = nextUid()
await mountBlock({
uid: uid,
updateAttributes: updateAttributes,
nodeAtPos: null,
})
const config = storedConfig(uid)
if (config) {
config.title = "Renamed"
}
expect(updateAttributes).toHaveBeenCalledTimes(0)
})
it("drops the old block's entries when its uid changes", async ({
expect,
}) => {
const uid = nextUid()
const nextUidValue = nextUid()
const wrapper = await mountBlock({ uid: uid })
await wrapper.setProps({ node: makeNode({ uid: nextUidValue }) })
expect(storedConfig(uid)).toBeUndefined()
expect(storedConfig(nextUidValue)).toBeDefined()
})
it("names the collaborator editing the block", async ({ expect }) => {
const uid = nextUid()
const wrapper = await mountBlock({
uid: uid,
editing: { uid: uid, name: "Ada", color: "#ff0000" },
})
expect(wrapper.text()).toContain("Ada")
expect(
wrapper.get("[data-node-view-wrapper]").attributes("style"),
).toContain("border-color: #ff0000")
})
it("names nobody when the collaborator is editing another block", async ({
expect,
}) => {
const wrapper = await mountBlock({
editing: { uid: "elsewhere", name: "Ada", color: "#ff0000" },
})
expect(wrapper.text()).not.toContain("Ada")
})
})

View File

@ -0,0 +1,211 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it, vi } from "vitest"
import PopoverConfigBox from "./PopoverConfigBox.vue"
import {
defaultMetricConfig,
RefreshInterval,
TimeRangePreset,
type MetricConfig,
} from "./utils"
import { makeEditor } from "../../test-helpers/node-view"
import { stubChartColorContext } from "./test-helpers"
import {
at,
emitFrom,
emitFromNth,
findButtonByText,
t,
} from "~/components/test-helpers"
import { Select } from "~/components/shadcn/ui/select"
const BLOCK_UID = "metric-1"
function mountBox(
options: {
config?: MetricConfig
getPos?: () => number | undefined
setNodeSelection?: (pos: number) => void
} = {},
) {
const editor = makeEditor({
commands: {
setNodeSelection: options.setNodeSelection ?? (() => undefined),
},
})
return mountSuspended(PopoverConfigBox, {
props: {
uid: BLOCK_UID,
editor: editor.editor,
getPos: options.getPos ?? (() => 3),
modelValue: options.config ?? defaultMetricConfig(),
},
})
}
function selectedValues(wrapper: VueWrapper): string[] {
return wrapper.findAll("[data-slot='select-value']").map((v) => v.text())
}
function triggerDisabled(
wrapper: VueWrapper,
index: number,
): string | undefined {
return at(wrapper.findAll("[data-slot='select-trigger']"), index).attributes(
"disable",
)
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<PopoverConfigBox>", { concurrent: false }, () => {
beforeEach(() => {
stubChartColorContext()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().activateMetricBlockConfig(null)
})
it("labels the two settings it offers", async ({ expect }) => {
const wrapper = await mountBox()
expect(wrapper.text()).toContain(
t("editor.metrics.config.time-range-label"),
)
expect(wrapper.text()).toContain(
t("editor.metrics.config.refresh-interval-label"),
)
})
it("shows the time range and refresh interval the config carries", async ({
expect,
}) => {
const wrapper = await mountBox({
config: {
...defaultMetricConfig(),
timeRange: TimeRangePreset.Last24Hours,
refreshInterval: RefreshInterval.S30,
},
})
expect(selectedValues(wrapper)).toEqual([
t("editor.metrics.config.time-range-options.last_24_hours"),
t("editor.metrics.config.refresh-interval-options.30s"),
])
})
it("prompts for a time range and interval when the config has none", async ({
expect,
}) => {
const wrapper = await mountBox({
config: {
...defaultMetricConfig(),
timeRange: null,
refreshInterval: null,
},
})
expect(selectedValues(wrapper)).toEqual([
t("editor.metrics.config.time-range-placeholder"),
t("editor.metrics.config.refresh-interval-placeholder"),
])
})
it("stores a time range the reader picks", async ({ expect }) => {
const config = defaultMetricConfig()
const wrapper = await mountBox({ config: config })
emitFrom(wrapper, Select, "update:modelValue", TimeRangePreset.Last7Days)
await nextTick()
expect(config.timeRange).toBe(TimeRangePreset.Last7Days)
})
it("stores a refresh interval the reader picks", async ({ expect }) => {
const config = defaultMetricConfig()
const wrapper = await mountBox({ config: config })
emitFromNth(wrapper, Select, 1, "update:modelValue", RefreshInterval.H1)
await nextTick()
expect(config.refreshInterval).toBe(RefreshInterval.H1)
})
it("lets both settings be changed while editing", async ({ expect }) => {
const wrapper = await mountBox()
expect(triggerDisabled(wrapper, 0)).toBe("false")
expect(triggerDisabled(wrapper, 1)).toBe("false")
})
it("blocks both settings in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountBox()
expect(triggerDisabled(wrapper, 0)).toBe("true")
expect(triggerDisabled(wrapper, 1)).toBe("true")
})
it("blocks both settings while a reviewable diff is shown", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountBox()
expect(triggerDisabled(wrapper, 0)).toBe("true")
expect(triggerDisabled(wrapper, 1)).toBe("true")
})
it("offers to edit the metrics while editing", async ({ expect }) => {
const wrapper = await mountBox()
expect(wrapper.text()).toContain(
t("editor.metrics.config.modal-trigger-button-normal"),
)
})
it("offers only to view the metrics in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountBox()
expect(wrapper.text()).toContain(
t("editor.metrics.config.modal-trigger-button-readonly"),
)
})
it("opens the config modal for this block", async ({ expect }) => {
const setNodeSelection = vi.fn()
const wrapper = await mountBox({ setNodeSelection: setNodeSelection })
await findButtonByText(
wrapper,
t("editor.metrics.config.modal-trigger-button-normal"),
).trigger("click")
expect(setNodeSelection).toHaveBeenCalledTimes(1)
expect(setNodeSelection).toHaveBeenCalledWith(3)
expect(useEditorStore().activeMetricBlockConfig).toBe(BLOCK_UID)
})
it("opens the config modal for a block with no resolvable position", async ({
expect,
}) => {
const setNodeSelection = vi.fn()
const wrapper = await mountBox({
getPos: () => undefined,
setNodeSelection: setNodeSelection,
})
await findButtonByText(
wrapper,
t("editor.metrics.config.modal-trigger-button-normal"),
).trigger("click")
expect(setNodeSelection).toHaveBeenCalledTimes(0)
expect(useEditorStore().activeMetricBlockConfig).toBe(BLOCK_UID)
})
})

View File

@ -0,0 +1,357 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import VisualizationContainer from "./VisualizationContainer.vue"
import { stubChartColorContext } from "./test-helpers"
import {
defaultMetricConfig,
TimeRangePreset,
type MetricConfig,
} from "./utils"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
} from "~/composables/api/test-helpers"
import {
findButtonByText,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
const DATA_SOURCE_ID = makeXid("ds")
const QUERY_URL = `/api/data-sources/${DATA_SOURCE_ID}/query`
// the chart components draw through echarts onto a canvas happy-dom does
// not implement; the container's contract with them is which one it picks
// and what it hands over
const chartStubs = {
LineChart: true,
BarChart: true,
GaugeChart: true,
}
function seriesResult(): unknown {
return {
status: GenericQueryResultStatus.Ok,
data: [
{
labels: { instance: "web-1" },
metrics: [
[1700000000, 1],
[1700000060, 2],
],
},
],
}
}
function metricConfig(overrides: Partial<MetricConfig> = {}): MetricConfig {
return {
...defaultMetricConfig(),
dataSourceId: DATA_SOURCE_ID,
visualizationType: GenericQueryChartType.Line,
timeRange: TimeRangePreset.Last5Minutes,
queries: [{ name: "Query 1", query: "up", legendFormat: "" }],
...overrides,
}
}
let uidCounter = 0
// the store tracks each block's next refresh by uid and keeps it for the
// whole document, so a shared uid would make later blocks look up to date
function nextUid(): string {
uidCounter++
return `metric-${uidCounter}`
}
function mountContainer(
config: MetricConfig,
props: Record<string, unknown> = {},
) {
return mountSuspended(VisualizationContainer, {
props: { config: config, uid: nextUid(), ...props },
global: { stubs: chartStubs },
})
}
function chartNames(wrapper: VueWrapper): string[] {
return ["LineChart", "BarChart", "GaugeChart"].filter((name) =>
wrapper.findComponent({ name: name }).exists(),
)
}
// the editor store, the query cache and the editable flag are shared
// app-wide, so these tests cannot interleave
describe("<VisualizationContainer>", { concurrent: false }, () => {
beforeEach(() => {
stubChartColorContext()
clearQueryCache()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().updateActiveDocumentId(makeXid("doc"))
useEditorStore().updateActiveBranchId(makeXid("branch"))
useEditorStore().activateMetricBlockConfig(null)
})
afterEach(disposeMockEndpoints)
it("asks for a visualization type before anything else", async ({
expect,
}) => {
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
)
expect(wrapper.text()).toContain(
t("editor.metrics.status.type-not-selected.title"),
)
expect(wrapper.text()).toContain(
t("editor.metrics.status.type-not-selected.normal-action-button"),
)
})
it("offers only to view the metrics in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
)
expect(wrapper.text()).toContain(
t("editor.metrics.status.type-not-selected.readonly-action-button"),
)
})
it("hides the action button when the host asks it to", async ({ expect }) => {
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
{ hideEmptyActionButton: true },
)
expect(wrapper.findAll("button")).toHaveLength(0)
})
it("shows a bare placeholder in a simplified host", async ({ expect }) => {
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
{ simplifiedEmpty: true },
)
expect(wrapper.text()).toBe(
t("editor.metrics.status.simplified-config-in-progress.title"),
)
})
it("asks for a data source once a type is chosen", async ({ expect }) => {
const wrapper = await mountContainer(
metricConfig({ dataSourceId: null, queries: [] }),
)
expect(wrapper.text()).toContain(
t("editor.metrics.status.data-source-not-selected.title"),
)
})
it("shows a bare placeholder for a missing data source in a simplified host", async ({
expect,
}) => {
const wrapper = await mountContainer(
metricConfig({ dataSourceId: null, queries: [] }),
{ simplifiedEmpty: true },
)
expect(wrapper.text()).toBe(
t("editor.metrics.status.simplified-config-in-progress.title"),
)
})
it("reports that the queries returned nothing", async ({ expect }) => {
mockEndpoint("GET", QUERY_URL, () => ({
status: GenericQueryResultStatus.NoData,
data: [],
}))
const wrapper = await mountContainer(metricConfig())
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
t("editor.metrics.status.no-data-loaded.title"),
)
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).toContain(
"The current configuration returned no data",
)
})
it("draws a line chart from the returned series", async ({ expect }) => {
mockEndpoint("GET", QUERY_URL, () => seriesResult())
const wrapper = await mountContainer(metricConfig({ title: "Requests" }))
await vi.waitFor(() => {
expect(chartNames(wrapper)).toEqual(["LineChart"])
}, WAIT_FOR_OPTIONS)
expect(wrapper.findComponent({ name: "LineChart" }).props("title")).toBe(
"Requests",
)
})
it("draws a bar chart when that is the chosen type", async ({ expect }) => {
mockEndpoint("GET", QUERY_URL, () => seriesResult())
const wrapper = await mountContainer(
metricConfig({ visualizationType: GenericQueryChartType.Bar }),
)
await vi.waitFor(() => {
expect(chartNames(wrapper)).toEqual(["BarChart"])
}, WAIT_FOR_OPTIONS)
})
it("draws a gauge when that is the chosen type", async ({ expect }) => {
mockEndpoint("GET", QUERY_URL, () => seriesResult())
const wrapper = await mountContainer(
metricConfig({ visualizationType: GenericQueryChartType.Gauge }),
)
await vi.waitFor(() => {
expect(chartNames(wrapper)).toEqual(["GaugeChart"])
}, WAIT_FOR_OPTIONS)
})
it("passes only the enabled thresholds to the chart", async ({ expect }) => {
mockEndpoint("GET", QUERY_URL, () => seriesResult())
const wrapper = await mountContainer(
metricConfig({
thresholds: [
{ value: 10, label: "warn", color: "#ff0000" },
{ label: "disabled", color: "#00ff00" },
],
}),
)
await vi.waitFor(() => {
expect(chartNames(wrapper)).toEqual(["LineChart"])
}, WAIT_FOR_OPTIONS)
expect(
wrapper.findComponent({ name: "LineChart" }).props("thresholds"),
).toEqual([{ value: 10, label: "warn", color: "#ff0000" }])
})
it("reports the error a single failing query returned", async ({
expect,
}) => {
mockEndpoint("GET", QUERY_URL, (_call, event) => {
setResponseStatus(event, 400)
return { code: "query.error", message: "bad selector" }
})
const wrapper = await mountContainer(metricConfig())
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
t("editor.metrics.status.query-error.title"),
)
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).not.toContain("Query 1:")
})
it("numbers the failing query when there are several", async ({ expect }) => {
mockEndpoint("GET", QUERY_URL, (call, event) => {
if (call.query.q === "down") {
setResponseStatus(event, 400)
return { code: "query.error", message: "bad selector" }
}
return seriesResult()
})
const wrapper = await mountContainer(
metricConfig({
queries: [
{ name: "Query 1", query: "up", legendFormat: "" },
{ name: "Query 2", query: "down", legendFormat: "" },
],
}),
)
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
t("editor.metrics.status.query-error.title"),
)
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).toContain("Query 2:")
})
it("reports data the chosen chart cannot show", async ({ expect }) => {
mockEndpoint("GET", QUERY_URL, () => ({
status: GenericQueryResultStatus.ChartAndDataMismatch,
data: [],
}))
const wrapper = await mountContainer(metricConfig())
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
t("editor.metrics.status.invalid-data.title"),
)
}, WAIT_FOR_OPTIONS)
})
it("opens the config modal from the action button", async ({ expect }) => {
const uid = nextUid()
const wrapper = await mountContainer(
metricConfig({ visualizationType: null }),
{ uid: uid },
)
await findButtonByText(
wrapper,
t("editor.metrics.status.type-not-selected.normal-action-button"),
).trigger("click")
expect(useEditorStore().activeMetricBlockConfig).toBe(uid)
})
it("reports its loading state around a refresh", async ({ expect }) => {
mockEndpoint("GET", QUERY_URL, () => seriesResult())
const wrapper = await mountContainer(metricConfig())
await vi.waitFor(() => {
expect(wrapper.emitted("loading")).toEqual([[true], [false]])
}, WAIT_FOR_OPTIONS)
})
it("skips the refresh while refreshing is disabled", async ({ expect }) => {
const calls = mockEndpoint("GET", QUERY_URL, () => seriesResult())
const wrapper = await mountContainer(metricConfig(), {
disableRefresh: true,
})
expect(wrapper.emitted("loading")).toBeUndefined()
expect(calls.length).toBeLessThanOrEqual(1)
})
it("skips the refresh for a block that is not due yet", async ({
expect,
}) => {
mockEndpoint("GET", QUERY_URL, () => seriesResult())
const uid = nextUid()
useEditorStore().setMetricBlockNextRefreshTimestamp(uid, 60_000)
const wrapper = await mountContainer(metricConfig(), { uid: uid })
expect(wrapper.emitted("loading")).toBeUndefined()
})
})

View File

@ -415,6 +415,7 @@ function openModal() {
<ShadcnUiEmptyDescription>
<i18n-t
v-if="data.multipleQueries"
scope="global"
keypath="editor.metrics.status.query-error.multi-query-description"
tag="div"
>

View File

@ -0,0 +1,269 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { enableAutoUnmount } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it } from "vitest"
import BaseModal from "./BaseModal.client.vue"
import ConfigSidebar from "./ConfigSidebar.vue"
import { stubChartColorContext } from "../test-helpers"
import {
defaultMetricConfig,
RefreshInterval,
TimeRangePreset,
type MetricConfig,
} from "../utils"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
} from "~/composables/api/test-helpers"
import {
emitFrom,
stubViewportMatches,
t,
teleportedButton,
} from "~/components/test-helpers"
import { DiffStatus } from "~/components/editor/diff/position-map"
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
let uidCounter = 0
function nextUid(): string {
uidCounter++
return `metric-modal-${uidCounter}`
}
// the modal reads everything it shows out of the editor store, which is
// where the metric block publishes its config
function activateBlock(
options: {
config?: MetricConfig
diffStatus?: DiffStatus
oldConfig?: MetricConfig | null
} = {},
): string {
const uid = nextUid()
const store = useEditorStore()
store.updateActiveDocumentId(DOCUMENT_ID)
store.updateActiveBranchId(BRANCH_ID)
store.setMetricBlockConfig(uid, options.config ?? defaultMetricConfig())
if (options.diffStatus) {
store.setMetricBlockDiffInfo(
uid,
options.diffStatus,
options.oldConfig ?? null,
)
}
store.activateMetricBlockConfig(uid)
return uid
}
function mountModal() {
return mountSuspended(BaseModal)
}
function dialogText(): string {
return document.body.querySelector("[role='dialog']")?.textContent ?? ""
}
function dialogOpen(): boolean {
return document.body.querySelector("[role='dialog']") !== null
}
// the editor store, the editable flag, the viewport stub and the
// teleported dialog body are all shared, so these tests cannot interleave
describe("<MetricConfigBaseModal>", { concurrent: false }, () => {
// the dialog body is teleported into <body> and vue keeps patching it
// for as long as the modal is mounted, so each test's modal is taken
// down properly rather than ripped out of the dom
enableAutoUnmount(afterEach)
beforeEach(() => {
clearQueryCache()
stubChartColorContext()
stubViewportMatches(true)
mockEndpoint("GET", "/api/data-sources", () => [])
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().activateMetricBlockConfig(null)
})
afterEach(disposeMockEndpoints)
it("stays shut while no block is being configured", async ({ expect }) => {
await mountModal()
expect(dialogOpen()).toBe(false)
})
it("opens for the block being configured", async ({ expect }) => {
activateBlock()
await mountModal()
expect(dialogOpen()).toBe(true)
expect(dialogText()).toContain(
t("editor.metrics.config.modal-title-normal"),
)
})
it("titles itself as read-only in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
activateBlock()
await mountModal()
expect(dialogText()).toContain(
t("editor.metrics.config.modal-title-readonly"),
)
})
it.for([
{
status: DiffStatus.Added,
expectedKey: "editor.metrics.config.modal-title-diff-added",
},
{
status: DiffStatus.Removed,
expectedKey: "editor.metrics.config.modal-title-diff-removed",
},
{
status: DiffStatus.Modified,
expectedKey: "editor.metrics.config.modal-title-diff-modified",
},
])(
"titles itself for a $status block in a diff",
async ({ status, expectedKey }, { expect }) => {
activateBlock({ diffStatus: status })
await mountModal()
expect(dialogText()).toContain(t(expectedKey))
},
)
it("shows the block's time range and refresh interval", async ({
expect,
}) => {
activateBlock({
config: {
...defaultMetricConfig(),
timeRange: TimeRangePreset.Last24Hours,
refreshInterval: RefreshInterval.S30,
},
})
await mountModal()
expect(dialogText()).toContain(
t("editor.metrics.config.time-range-options.last_24_hours"),
)
expect(dialogText()).toContain(
t("editor.metrics.config.refresh-interval-options-short.30s"),
)
})
it("shows the time range a diff replaced beside the new one", async ({
expect,
}) => {
activateBlock({
config: {
...defaultMetricConfig(),
timeRange: TimeRangePreset.Last24Hours,
},
diffStatus: DiffStatus.Modified,
oldConfig: {
...defaultMetricConfig(),
timeRange: TimeRangePreset.Last1Hour,
},
})
await mountModal()
expect(dialogText()).toContain(
t("editor.metrics.config.time-range-options.last_24_hours"),
)
expect(dialogText()).toContain(
t("editor.metrics.config.time-range-options.last_1_hour"),
)
})
it("shows the refresh interval a diff replaced beside the new one", async ({
expect,
}) => {
activateBlock({
config: {
...defaultMetricConfig(),
refreshInterval: RefreshInterval.S30,
},
diffStatus: DiffStatus.Modified,
oldConfig: {
...defaultMetricConfig(),
refreshInterval: RefreshInterval.H1,
},
})
await mountModal()
expect(dialogText()).toContain(
t("editor.metrics.config.refresh-interval-options-short.30s"),
)
expect(dialogText()).toContain(
t("editor.metrics.config.refresh-interval-options-short.1h"),
)
})
it("shows the settings only once on a wide screen", async ({ expect }) => {
activateBlock()
const wrapper = await mountModal()
expect(
document.body.querySelectorAll("[role='dialog'] .w-70"),
).toHaveLength(1)
expect(wrapper.findAllComponents(ConfigSidebar)).toHaveLength(1)
})
it("folds the settings into the body on a narrow screen", async ({
expect,
}) => {
stubViewportMatches(false)
activateBlock()
const wrapper = await mountModal()
expect(
document.body.querySelectorAll("[role='dialog'] .w-70"),
).toHaveLength(0)
expect(wrapper.findAllComponents(ConfigSidebar)).toHaveLength(1)
})
it("closes when the reader dismisses it", async ({ expect }) => {
activateBlock()
await mountModal()
teleportedButton(t("general.modal-close-screen-reader-hint")).click()
await nextTick()
expect(useEditorStore().activeMetricBlockConfig).toBeNull()
})
it("stands aside when the reader asks for the data source settings", async ({
expect,
}) => {
activateBlock()
const wrapper = await mountModal()
emitFrom(wrapper, ConfigSidebar, "open-settings")
await nextTick()
expect(useEditorStore().activeMetricBlockConfig).toBeNull()
expect(wrapper.emitted("open-settings")).toHaveLength(1)
})
})

View File

@ -0,0 +1,329 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it } from "vitest"
import BottomConfig from "./BottomConfig.vue"
import QueryEditor from "./query/QueryEditor.vue"
import LegendEditor from "./query/LegendEditor.vue"
import { stubChartColorContext } from "../test-helpers"
import { defaultMetricConfig, type MetricConfig } from "../utils"
import {
at,
clearTeleportedOverlays,
emitFrom,
emitFromNth,
findButtonByText,
t,
} from "~/components/test-helpers"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
seedQueryData,
} from "~/composables/api/test-helpers"
import { DiffStatus } from "~/components/editor/diff/position-map"
const DATA_SOURCE_ID = makeXid("ds")
const DATA_SOURCES = [
{
id: DATA_SOURCE_ID,
name: "Prod",
url: "https://db.test",
type: DataSourceType.PostgreSQL,
status: DataSourceStatus.Success,
},
]
function query(
name: string,
q: string,
legendFormat = "",
): {
name: string
query: string
legendFormat: string
} {
return { name: name, query: q, legendFormat: legendFormat }
}
function configWith(overrides: Partial<MetricConfig> = {}): MetricConfig {
return {
...defaultMetricConfig(),
dataSourceId: DATA_SOURCE_ID,
...overrides,
}
}
function mountBottom(props: Record<string, unknown> = {}) {
return mountSuspended(BottomConfig, {
props: { modelValue: configWith(), ...props },
})
}
function queryEditors(wrapper: VueWrapper) {
return wrapper.findAllComponents(QueryEditor)
}
function legendEditors(wrapper: VueWrapper) {
return wrapper.findAllComponents(LegendEditor)
}
// the editable flag is a shared cookie state, the editor store and the
// query cache are app-wide, and the help popover is teleported into a
// shared <body>, so these tests cannot interleave
describe("<BottomConfig>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
clearQueryCache()
stubChartColorContext()
mockEndpoint("GET", "/api/data-sources", () => DATA_SOURCES)
// seeding the cache too puts the data source's type in place before
// the first render, so the help popover knows which language to
// explain right away
seedQueryData(["data-sources", "list"], DATA_SOURCES)
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
afterEach(disposeMockEndpoints)
it("shows the single query without naming it", async ({ expect }) => {
const wrapper = await mountBottom({
modelValue: configWith({ queries: [query("Query 1", "up")] }),
})
expect(queryEditors(wrapper)).toHaveLength(1)
expect(legendEditors(wrapper)).toHaveLength(1)
expect(wrapper.text()).not.toContain("Query 1")
expect(wrapper.text()).toContain(t("editor.metrics.config.query-label"))
})
it("names each query once there are several", async ({ expect }) => {
const wrapper = await mountBottom({
modelValue: configWith({
queries: [query("Query 1", "up"), query("Query 2", "down")],
}),
})
expect(queryEditors(wrapper)).toHaveLength(2)
expect(wrapper.text()).toContain("Query 1")
expect(wrapper.text()).toContain("Query 2")
})
it("stores a query the reader typed", async ({ expect }) => {
const config = configWith({ queries: [query("Query 1", "up")] })
const wrapper = await mountBottom({ modelValue: config })
emitFrom(wrapper, QueryEditor, "update:modelValue", "rate(up[5m])")
await nextTick()
expect(config.queries).toEqual([query("Query 1", "rate(up[5m])")])
})
it("stores a legend format the reader typed", async ({ expect }) => {
const config = configWith({ queries: [query("Query 1", "up")] })
const wrapper = await mountBottom({ modelValue: config })
emitFrom(wrapper, LegendEditor, "update:modelValue", "{{instance}}")
await nextTick()
expect(config.queries).toEqual([query("Query 1", "up", "{{instance}}")])
})
it("stores the second query the reader typed", async ({ expect }) => {
const config = configWith({
queries: [query("Query 1", "up"), query("Query 2", "down")],
})
const wrapper = await mountBottom({ modelValue: config })
emitFromNth(wrapper, QueryEditor, 1, "update:modelValue", "sum(down)")
await nextTick()
expect(config.queries).toEqual([
query("Query 1", "up"),
query("Query 2", "sum(down)"),
])
})
it("adds a numbered query", async ({ expect }) => {
const config = configWith({ queries: [query("Query 1", "up")] })
const wrapper = await mountBottom({ modelValue: config })
await findButtonByText(
wrapper,
t("editor.metrics.config.add-query-button"),
).trigger("click")
expect(config.queries).toEqual([
query("Query 1", "up"),
query("Query 2", ""),
])
})
it("adds the first query to a config that has none", async ({ expect }) => {
const config = configWith({ queries: null })
const wrapper = await mountBottom({ modelValue: config })
await findButtonByText(
wrapper,
t("editor.metrics.config.add-query-button"),
).trigger("click")
expect(config.queries).toEqual([query("Query 1", "")])
})
it("renumbers the remaining queries after a deletion", async ({ expect }) => {
const config = configWith({
queries: [
query("Query 1", "a"),
query("Query 2", "b"),
query("Query 3", "c"),
],
})
const wrapper = await mountBottom({ modelValue: config })
await at(wrapper.findAll("button"), 0).trigger("click")
expect(config.queries).toEqual([
query("Query 1", "b"),
query("Query 2", "c"),
])
})
it("refuses to delete the last remaining query", async ({ expect }) => {
const config = configWith({
queries: [query("Query 1", "a"), query("Query 2", "b")],
})
const wrapper = await mountBottom({ modelValue: config })
await at(wrapper.findAll("button"), 0).trigger("click")
await at(wrapper.findAll("button"), 0).trigger("click")
expect(config.queries).toEqual([query("Query 1", "b")])
})
it("offers no editing controls in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountBottom({
modelValue: configWith({
queries: [query("Query 1", "a"), query("Query 2", "b")],
}),
})
expect(wrapper.text()).not.toContain(
t("editor.metrics.config.add-query-button"),
)
expect(
wrapper.findAll("button").filter((b) => b.text() === ""),
).toHaveLength(0)
})
it("explains the query language of the chosen data source", async ({
expect,
}) => {
const wrapper = await mountBottom({
modelValue: configWith({ queries: [query("Query 1", "up")] }),
})
const help = findButtonByText(
wrapper,
t("editor.metrics.config.query-explanation-button-label"),
)
await help.trigger("pointerdown", { button: 0 })
await help.trigger("click")
await nextTick()
expect(document.body.textContent).toContain(
t(
"editor.metrics.config.query-explanations.postgresql.main-placeholders.postgresql-queries",
),
)
})
it("shows the query a diff replaced beside the new one", async ({
expect,
}) => {
const wrapper = await mountBottom({
modelValue: configWith({ queries: [query("Query 1", "after")] }),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({ queries: [query("Query 1", "before")] }),
})
const editors = queryEditors(wrapper)
expect(editors).toHaveLength(2)
expect(at(editors, 0).props("diffStatus")).toBe(DiffStatus.Added)
expect(at(editors, 1).props("modelValue")).toBe("before")
expect(at(editors, 1).props("diffStatus")).toBe(DiffStatus.Removed)
})
it("shows the legend format a diff replaced beside the new one", async ({
expect,
}) => {
const wrapper = await mountBottom({
modelValue: configWith({ queries: [query("Query 1", "up", "after")] }),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({
queries: [query("Query 1", "up", "before")],
}),
})
const editors = legendEditors(wrapper)
expect(editors).toHaveLength(2)
expect(at(editors, 1).props("modelValue")).toBe("before")
expect(at(editors, 1).props("diffStatus")).toBe(DiffStatus.Removed)
})
it("marks a query the diff added", async ({ expect }) => {
const wrapper = await mountBottom({
modelValue: configWith({
queries: [query("Query 1", "a"), query("Query 2", "b")],
}),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({ queries: [query("Query 1", "a")] }),
})
const editors = queryEditors(wrapper)
expect(editors).toHaveLength(2)
expect(at(editors, 0).props("diffStatus")).toBeNull()
expect(at(editors, 1).props("diffStatus")).toBe(DiffStatus.Added)
})
it("marks a query the diff dropped", async ({ expect }) => {
const wrapper = await mountBottom({
modelValue: configWith({ queries: [query("Query 1", "a")] }),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({
queries: [query("Query 1", "a"), query("Query 2", "b")],
}),
})
const editors = queryEditors(wrapper)
expect(editors).toHaveLength(2)
expect(at(editors, 1).props("modelValue")).toBe("b")
expect(at(editors, 1).props("diffStatus")).toBe(DiffStatus.Removed)
})
it("leaves an unchanged query unmarked in a diff", async ({ expect }) => {
const wrapper = await mountBottom({
modelValue: configWith({
queries: [query("Query 1", "a"), query("Query 2", "b")],
}),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({
queries: [query("Query 1", "a"), query("Query 2", "changed")],
}),
})
const editors = queryEditors(wrapper)
expect(at(editors, 0).props("diffStatus")).toBeNull()
expect(at(editors, 1).props("diffStatus")).toBe(DiffStatus.Added)
expect(at(editors, 2).props("diffStatus")).toBe(DiffStatus.Removed)
})
})

View File

@ -0,0 +1,39 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { beforeEach, describe, it } from "vitest"
import ColorSelect from "./ColorSelect.vue"
import { at } from "~/components/test-helpers"
import { stubChartColorContext, stubThresholdPalette } from "../test-helpers"
let palette: string[] = []
function mountSelect(color?: string) {
return mountSuspended(ColorSelect, { props: { modelValue: color } })
}
// the palette lives on the document element, shared by every mount, so
// these tests cannot interleave
describe("<ColorSelect>", { concurrent: false }, () => {
beforeEach(() => {
stubChartColorContext()
palette = stubThresholdPalette()
})
it("offers every colour of the threshold palette", async ({ expect }) => {
const wrapper = await mountSelect()
const swatches = wrapper.findAll("button div")
expect(swatches).toHaveLength(palette.length)
expect(swatches.map((s) => s.attributes("style"))).toEqual(
palette.map((color) => `background-color: ${color};`),
)
})
it("picks the colour the reader clicked", async ({ expect }) => {
const wrapper = await mountSelect(at(palette, 0))
await at(wrapper.findAll("button"), 3).trigger("click")
expect(wrapper.emitted("update:modelValue")).toEqual([[at(palette, 3)]])
})
})

View File

@ -0,0 +1,511 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { DOMWrapper, VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it } from "vitest"
import ConfigSidebar from "./ConfigSidebar.vue"
import ConfigField from "../ConfigField.vue"
import ThresholdInput from "./ThresholdInput.vue"
import DataSourceSelect from "./DataSourceSelect.vue"
import { stubChartColorContext, stubThresholdPalette } from "../test-helpers"
import {
defaultMetricConfig,
VisualizationDataUnit,
type MetricConfig,
} from "../utils"
import {
at,
clearTeleportedOverlays,
emitFrom,
findButtonByText,
t,
} from "~/components/test-helpers"
import {
ANY_STRING,
clearQueryCache,
disposeMockEndpoints,
mockEndpoint,
} from "~/composables/api/test-helpers"
import { DiffStatus } from "~/components/editor/diff/position-map"
function configWith(overrides: Partial<MetricConfig> = {}): MetricConfig {
return { ...defaultMetricConfig(), ...overrides }
}
function mountSidebar(props: Record<string, unknown> = {}) {
return mountSuspended(ConfigSidebar, {
props: { modelValue: configWith(), ...props },
})
}
// the fields are labelled through ConfigField's label slot, which is the
// only stable way to tell one input from the next
function fieldInputs(
wrapper: VueWrapper,
label: string,
): DOMWrapper<Element>[] {
const field = wrapper
.findAllComponents(ConfigField)
.find((f) => f.text().startsWith(label))
if (!field) {
throw new Error(`no config field labelled "${label}"`)
}
return field.findAll("input")
}
function inputValue(input: DOMWrapper<Element>): string {
return (input.element as HTMLInputElement).value
}
// the editable flag is a shared cookie state, the editor store and the
// query cache are app-wide, and the popovers are teleported into a shared
// <body>, so these tests cannot interleave
describe("<ConfigSidebar>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
clearQueryCache()
stubChartColorContext()
stubThresholdPalette()
mockEndpoint("GET", "/api/data-sources", () => [])
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
afterEach(disposeMockEndpoints)
it("labels every setting it offers", async ({ expect }) => {
const wrapper = await mountSidebar()
expect(wrapper.text()).toContain(t("editor.metrics.config.title-label"))
expect(wrapper.text()).toContain(t("editor.metrics.config.unit-label"))
expect(wrapper.text()).toContain(
t("editor.metrics.config.thresholds-label"),
)
expect(wrapper.text()).toContain(t("editor.metrics.config.decimals-label"))
expect(wrapper.text()).toContain(
t("editor.metrics.config.bounds-min-label"),
)
expect(wrapper.text()).toContain(
t("editor.metrics.config.bounds-max-label"),
)
})
it("shows the title the config carries", async ({ expect }) => {
const wrapper = await mountSidebar({
modelValue: configWith({ title: "Requests" }),
})
expect(
inputValue(
at(fieldInputs(wrapper, t("editor.metrics.config.title-label")), 0),
),
).toBe("Requests")
})
it("stores a title the reader typed", async ({ expect }) => {
const config = configWith()
const wrapper = await mountSidebar({ modelValue: config })
await at(
fieldInputs(wrapper, t("editor.metrics.config.title-label")),
0,
).setValue("Requests")
expect(config.title).toBe("Requests")
})
it("prompts for a title while it is empty", async ({ expect }) => {
const wrapper = await mountSidebar()
expect(
at(
fieldInputs(wrapper, t("editor.metrics.config.title-label")),
0,
).attributes("placeholder"),
).toBe(t("editor.metrics.config.title-placeholder"))
})
it("reports an empty title as empty in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountSidebar()
expect(
at(
fieldInputs(wrapper, t("editor.metrics.config.title-label")),
0,
).attributes("placeholder"),
).toBe(t("editor.metrics.config.title-empty-value-placeholder"))
})
it("offers to add a first threshold", async ({ expect }) => {
const config = configWith({
visualizationType: GenericQueryChartType.Line,
})
const wrapper = await mountSidebar({ modelValue: config })
await findButtonByText(
wrapper,
t("editor.metrics.config.add-threshold-button"),
).trigger("click")
expect(config.thresholds).toEqual([
{ value: undefined, label: undefined, color: ANY_STRING },
])
})
it("says there are no thresholds in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountSidebar({
modelValue: configWith({
visualizationType: GenericQueryChartType.Line,
}),
})
expect(wrapper.text()).toContain(
t("editor.metrics.config.no-thresholds-button"),
)
})
it("lists the thresholds the config carries", async ({ expect }) => {
const wrapper = await mountSidebar({
modelValue: configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [
{ value: 10, label: "warn", color: "#ff0000" },
{ value: 20, label: "crit", color: "#00ff00" },
],
}),
})
expect(wrapper.findAllComponents(ThresholdInput)).toHaveLength(2)
})
it("stores a threshold value the reader typed", async ({ expect }) => {
const config = configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [{ value: 10, label: "warn", color: "#ff0000" }],
})
const wrapper = await mountSidebar({ modelValue: config })
await at(
fieldInputs(wrapper, t("editor.metrics.config.thresholds-label")),
0,
).setValue("42")
expect(config.thresholds).toEqual([
{ value: 42, label: "warn", color: "#ff0000" },
])
})
it("stores a threshold label the reader typed", async ({ expect }) => {
const config = configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [{ value: 10, label: "warn", color: "#ff0000" }],
})
const wrapper = await mountSidebar({ modelValue: config })
await at(
fieldInputs(wrapper, t("editor.metrics.config.thresholds-label")),
1,
).setValue("critical")
expect(config.thresholds).toEqual([
{ value: 10, label: "critical", color: "#ff0000" },
])
})
it("drops a threshold the reader deleted", async ({ expect }) => {
const config = configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [
{ value: 10, label: "warn", color: "#ff0000" },
{ value: 20, label: "crit", color: "#00ff00" },
],
})
const wrapper = await mountSidebar({ modelValue: config })
await at(
wrapper.findAllComponents(ThresholdInput)[0]?.findAll("button") ?? [],
1,
).trigger("click")
expect(config.thresholds).toEqual([
{ value: 20, label: "crit", color: "#00ff00" },
])
})
it("gives a gauge a base threshold row of its own", async ({ expect }) => {
const wrapper = await mountSidebar({
modelValue: configWith({
visualizationType: GenericQueryChartType.Gauge,
}),
})
const rows = wrapper.findAllComponents(ThresholdInput)
expect(rows).toHaveLength(1)
expect(at(rows, 0).props("baseThreshold")).toEqual({
label: t("editor.metrics.config.base-threshold-label"),
})
expect(wrapper.text()).toContain(
t("editor.metrics.config.add-threshold-button"),
)
})
it("stores the decimal places the reader typed", async ({ expect }) => {
const config = configWith()
const wrapper = await mountSidebar({ modelValue: config })
await at(
fieldInputs(wrapper, t("editor.metrics.config.decimals-label")),
0,
).setValue("3")
expect(config.decimals).toBe(3)
})
it("stores the axis bounds the reader typed", async ({ expect }) => {
const config = configWith()
const wrapper = await mountSidebar({ modelValue: config })
await at(
fieldInputs(wrapper, t("editor.metrics.config.bounds-min-label")),
0,
).setValue("-5")
await at(
fieldInputs(wrapper, t("editor.metrics.config.bounds-max-label")),
0,
).setValue("100")
expect(config.axisBounds).toEqual({ min: -5, max: 100 })
})
it("keeps the lower bound below the lowest threshold", async ({ expect }) => {
const config = configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [{ value: 10, label: "warn", color: "#ff0000" }],
})
const wrapper = await mountSidebar({ modelValue: config })
await at(
fieldInputs(wrapper, t("editor.metrics.config.bounds-min-label")),
0,
).setValue("50")
expect(config.axisBounds.min).toBe(10)
})
it("keeps the upper bound above the highest threshold", async ({
expect,
}) => {
const config = configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [{ value: 10, label: "warn", color: "#ff0000" }],
})
const wrapper = await mountSidebar({ modelValue: config })
await at(
fieldInputs(wrapper, t("editor.metrics.config.bounds-max-label")),
0,
).setValue("5")
expect(config.axisBounds.max).toBe(10)
})
it("clears a bound the reader emptied", async ({ expect }) => {
const config = configWith({ axisBounds: { min: 1, max: 2 } })
const wrapper = await mountSidebar({ modelValue: config })
await at(
fieldInputs(wrapper, t("editor.metrics.config.bounds-min-label")),
0,
).setValue("")
await at(
fieldInputs(wrapper, t("editor.metrics.config.bounds-max-label")),
0,
).setValue("")
expect(config.axisBounds).toEqual({ min: null, max: null })
})
it("shows the title a diff replaced beside the new one", async ({
expect,
}) => {
const wrapper = await mountSidebar({
modelValue: configWith({ title: "After" }),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({ title: "Before" }),
})
const inputs = fieldInputs(wrapper, t("editor.metrics.config.title-label"))
expect(inputs).toHaveLength(2)
expect(inputValue(at(inputs, 0))).toBe("After")
expect(at(inputs, 0).classes()).toContain("bg-diff-field-added")
expect(inputValue(at(inputs, 1))).toBe("Before")
expect(at(inputs, 1).classes()).toContain("bg-diff-field-removed")
})
it("shows the unit a diff replaced beside the new one", async ({
expect,
}) => {
const wrapper = await mountSidebar({
modelValue: configWith({
unit: { type: VisualizationDataUnit.Bytes, custom: null },
}),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({
unit: { type: VisualizationDataUnit.Bits, custom: null },
}),
})
expect(wrapper.text()).toContain(
t("editor.metrics.config.unit-options.data.options.bytes"),
)
expect(wrapper.text()).toContain(
t("editor.metrics.config.unit-options.data.options.bits"),
)
})
it("shows the decimal places a diff replaced beside the new one", async ({
expect,
}) => {
const wrapper = await mountSidebar({
modelValue: configWith({ decimals: 3 }),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({ decimals: 1 }),
})
const inputs = fieldInputs(
wrapper,
t("editor.metrics.config.decimals-label"),
)
expect(inputValue(at(inputs, 0))).toBe("3")
expect(inputValue(at(inputs, 1))).toBe("1")
})
it("shows the bounds a diff replaced beside the new ones", async ({
expect,
}) => {
const wrapper = await mountSidebar({
modelValue: configWith({ axisBounds: { min: 0, max: 10 } }),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({ axisBounds: { min: 1, max: 20 } }),
})
expect(
inputValue(
at(
fieldInputs(wrapper, t("editor.metrics.config.bounds-min-label")),
1,
),
),
).toBe("1")
expect(
inputValue(
at(
fieldInputs(wrapper, t("editor.metrics.config.bounds-max-label")),
1,
),
),
).toBe("20")
})
it("marks each threshold a diff added, removed or left alone", async ({
expect,
}) => {
const wrapper = await mountSidebar({
modelValue: configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [
{ value: 10, label: "warn", color: "#ff0000" },
{ value: 30, label: "crit", color: "#00ff00" },
],
}),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [{ value: 10, label: "warn", color: "#ff0000" }],
}),
})
const rows = wrapper.findAllComponents(ThresholdInput)
expect(rows).toHaveLength(2)
expect(at(rows, 0).props("diffStatus")).toBe(DiffStatus.Unchanged)
expect(at(rows, 1).props("diffStatus")).toBe(DiffStatus.Added)
})
it("marks a threshold the diff changed as both added and removed", async ({
expect,
}) => {
const wrapper = await mountSidebar({
modelValue: configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [{ value: 30, label: "warn", color: "#ff0000" }],
}),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [{ value: 10, label: "warn", color: "#ff0000" }],
}),
})
const rows = wrapper.findAllComponents(ThresholdInput)
expect(rows).toHaveLength(2)
expect(at(rows, 0).props("diffStatus")).toBe(DiffStatus.Added)
expect(at(rows, 1).props("diffStatus")).toBe(DiffStatus.Removed)
})
it("marks a threshold the diff dropped as removed", async ({ expect }) => {
const wrapper = await mountSidebar({
modelValue: configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [],
}),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({
visualizationType: GenericQueryChartType.Line,
thresholds: [{ value: 10, label: "warn", color: "#ff0000" }],
}),
})
const rows = wrapper.findAllComponents(ThresholdInput)
expect(rows).toHaveLength(1)
expect(at(rows, 0).props("diffStatus")).toBe(DiffStatus.Removed)
})
it("shows the base gauge colour a diff replaced beside the new one", async ({
expect,
}) => {
const wrapper = await mountSidebar({
modelValue: configWith({
visualizationType: GenericQueryChartType.Gauge,
baseThresholdColor: "#111111",
}),
diffStatus: DiffStatus.Modified,
oldConfig: configWith({
visualizationType: GenericQueryChartType.Gauge,
baseThresholdColor: "#222222",
}),
})
const rows = wrapper.findAllComponents(ThresholdInput)
expect(at(rows, 0).props("color")).toBe("#111111")
expect(at(rows, 1).props("color")).toBe("#222222")
})
it("asks the host to open the data source settings", async ({ expect }) => {
const wrapper = await mountSidebar()
emitFrom(wrapper, DataSourceSelect, "open-settings")
await nextTick()
expect(wrapper.emitted("open-settings")).toHaveLength(1)
})
})

View File

@ -0,0 +1,232 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import DataSourceSelect from "./DataSourceSelect.vue"
import { stubChartColorContext } from "../test-helpers"
import { defaultMetricConfig, type MetricConfig } from "../utils"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
menuItem,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
import { DiffStatus } from "~/components/editor/diff/position-map"
const PROMETHEUS_ID = makeXid("dsa")
const BROKEN_ID = makeXid("dsb")
function dataSources() {
return [
{
id: PROMETHEUS_ID,
name: "Prod Prometheus",
url: "https://prom.test",
type: DataSourceType.Prometheus,
status: DataSourceStatus.Success,
},
{
id: BROKEN_ID,
name: "Broken",
url: "https://broken.test",
type: DataSourceType.Prometheus,
status: DataSourceStatus.Unreachable,
},
]
}
function configWith(dataSourceId: string | null): MetricConfig {
return { ...defaultMetricConfig(), dataSourceId: dataSourceId }
}
function mountSelect(props: Record<string, unknown> = {}) {
return mountSuspended(DataSourceSelect, { props: props })
}
async function openMenu(wrapper: VueWrapper) {
await wrapper
.get("[data-slot='dropdown-menu-trigger']")
.trigger("pointerdown", {
button: 0,
})
await wrapper.get("[data-slot='dropdown-menu-trigger']").trigger("click")
await nextTick()
}
// the editable flag is a shared cookie state, the editor store and the
// query cache are app-wide, and the menu body is teleported into a shared
// <body>, so these tests cannot interleave
describe("<DataSourceSelect>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
clearQueryCache()
stubChartColorContext()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
afterEach(disposeMockEndpoints)
it("invites the reader to pick a data source when none is set", async ({
expect,
}) => {
mockEndpoint("GET", "/api/data-sources", () => dataSources())
const wrapper = await mountSelect({ modelValue: configWith(null) })
expect(wrapper.text()).toContain(
t("editor.metrics.config.data-source-label-missing"),
)
expect(wrapper.text()).toContain(
t("editor.metrics.config.data-source-description-missing"),
)
})
it("says only that none is set in read mode", async ({ expect }) => {
mockEndpoint("GET", "/api/data-sources", () => dataSources())
useEditorMeta().setEditable(false)
const wrapper = await mountSelect({ modelValue: configWith(null) })
expect(wrapper.text()).toContain(
t("editor.metrics.config.data-source-label-missing"),
)
expect(wrapper.text()).not.toContain(
t("editor.metrics.config.data-source-description-missing"),
)
})
it("names the chosen data source and its address", async ({ expect }) => {
mockEndpoint("GET", "/api/data-sources", () => dataSources())
const wrapper = await mountSelect({
modelValue: configWith(PROMETHEUS_ID),
})
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Prod Prometheus")
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).toContain("https://prom.test")
})
it("reports a data source that no longer exists", async ({ expect }) => {
mockEndpoint("GET", "/api/data-sources", () => [])
const wrapper = await mountSelect({
modelValue: configWith(makeXid("gone")),
})
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
t("editor.metrics.config.data-source-label-deleted"),
)
}, WAIT_FOR_OPTIONS)
})
it("names the data source of the version a diff replaced", async ({
expect,
}) => {
mockEndpoint("GET", "/api/data-sources", () => dataSources())
const wrapper = await mountSelect({
oldConfig: configWith(PROMETHEUS_ID),
})
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Prod Prometheus")
}, WAIT_FOR_OPTIONS)
})
it.for([
{ status: DiffStatus.Added, expected: "bg-diff-field-added" },
{ status: DiffStatus.Removed, expected: "bg-diff-field-removed" },
])(
"tints a $status data source field",
async ({ status, expected }, { expect }) => {
mockEndpoint("GET", "/api/data-sources", () => dataSources())
const wrapper = await mountSelect({
modelValue: configWith(null),
diffStatus: status,
})
expect(
wrapper.get("[data-slot='dropdown-menu-trigger']").classes(),
).toContain(expected)
},
)
it("offers every connected data source", async ({ expect }) => {
mockEndpoint("GET", "/api/data-sources", () => dataSources())
const wrapper = await mountSelect({ modelValue: configWith(null) })
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
t("editor.metrics.config.data-source-label-missing"),
)
}, WAIT_FOR_OPTIONS)
await openMenu(wrapper)
await vi.waitFor(() => {
expect(menuItem("Prod Prometheus")).toBeDefined()
}, WAIT_FOR_OPTIONS)
expect(menuItem("Broken").getAttribute("data-disabled")).not.toBeNull()
})
it("stores the data source the reader picked", async ({ expect }) => {
mockEndpoint("GET", "/api/data-sources", () => dataSources())
const config = configWith(null)
const wrapper = await mountSelect({ modelValue: config })
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
t("editor.metrics.config.data-source-label-missing"),
)
}, WAIT_FOR_OPTIONS)
await openMenu(wrapper)
await vi.waitFor(() => {
expect(menuItem("Prod Prometheus")).toBeDefined()
}, WAIT_FOR_OPTIONS)
menuItem("Prod Prometheus").click()
await nextTick()
expect(config.dataSourceId).toBe(PROMETHEUS_ID)
})
it("points at the settings when there is nothing to pick", async ({
expect,
}) => {
mockEndpoint("GET", "/api/data-sources", () => [])
const wrapper = await mountSelect({ modelValue: configWith(null) })
await openMenu(wrapper)
const link = Array.from(
document.body.querySelectorAll<HTMLButtonElement>("button"),
).find((b) =>
b.textContent.includes(
t("editor.metrics.config.data-source-no-options.placeholder"),
),
)
link?.click()
await nextTick()
expect(wrapper.emitted("open-settings")).toHaveLength(1)
})
it("offers no menu in read mode", async ({ expect }) => {
mockEndpoint("GET", "/api/data-sources", () => dataSources())
useEditorMeta().setEditable(false)
const wrapper = await mountSelect({ modelValue: configWith(null) })
expect(
wrapper.get("[data-slot='dropdown-menu-trigger']").attributes("disabled"),
).toBeDefined()
})
})

View File

@ -121,6 +121,7 @@ function handleDataSourceSelection(dataSourceId: string) {
>
<template v-if="!fetchDataSources.state.value.data?.length">
<i18n-t
scope="global"
keypath="editor.metrics.config.data-source-no-options.main"
class="px-1 pt-0.75 pb-1 text-2sm text-popover-foreground"
tag="div"

View File

@ -0,0 +1,188 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import ThresholdInput from "./ThresholdInput.vue"
import { stubChartColorContext, stubThresholdPalette } from "../test-helpers"
import { at, clearTeleportedOverlays, t } from "~/components/test-helpers"
import { DiffStatus } from "~/components/editor/diff/position-map"
function mountThreshold(props: Record<string, unknown> = {}) {
return mountSuspended(ThresholdInput, {
props: {
visualizationType: GenericQueryChartType.Line,
color: "#ff0000",
...props,
},
})
}
function inputs(wrapper: VueWrapper) {
return wrapper.findAll("input")
}
function valueOf(wrapper: VueWrapper, index: number): string {
return (at(inputs(wrapper), index).element as HTMLInputElement).value
}
// the editable flag is a shared cookie state, the editor store is
// app-wide and the popover body is teleported into a shared <body>, so
// these tests cannot interleave
describe("<ThresholdInput>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
stubChartColorContext()
stubThresholdPalette()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("asks for a value and a label", async ({ expect }) => {
const wrapper = await mountThreshold()
expect(inputs(wrapper).map((i) => i.attributes("placeholder"))).toEqual([
t("editor.metrics.config.threshold-value-placeholder"),
t("editor.metrics.config.threshold-label-placeholder"),
])
})
it("reports empty fields as empty in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountThreshold()
expect(inputs(wrapper).map((i) => i.attributes("placeholder"))).toEqual([
t("editor.metrics.config.threshold-value-empty-placeholder"),
t("editor.metrics.config.threshold-label-empty-placeholder"),
])
})
it("shows the value and label it was given", async ({ expect }) => {
const wrapper = await mountThreshold({ value: 42, label: "warn" })
expect(valueOf(wrapper, 0)).toBe("42")
expect(valueOf(wrapper, 1)).toBe("warn")
})
it("shows the threshold's colour on the swatch", async ({ expect }) => {
const wrapper = await mountThreshold({ color: "#00ff00" })
expect(wrapper.get("button div").attributes("style")).toBe(
"background-color: #00ff00;",
)
})
it("reports a value the reader typed", async ({ expect }) => {
const wrapper = await mountThreshold()
await at(inputs(wrapper), 0).setValue("13")
expect(wrapper.emitted("update:value")).toEqual([[13]])
})
it("reports a label the reader typed", async ({ expect }) => {
const wrapper = await mountThreshold()
await at(inputs(wrapper), 1).setValue("critical")
expect(wrapper.emitted("update:label")).toEqual([["critical"]])
})
it("reports when the value field is left", async ({ expect }) => {
const wrapper = await mountThreshold()
await at(inputs(wrapper), 0).trigger("blur")
expect(wrapper.emitted("input-blur")).toHaveLength(1)
})
it("asks to be deleted", async ({ expect }) => {
const wrapper = await mountThreshold()
await at(wrapper.findAll("button"), 1).trigger("click")
expect(wrapper.emitted("delete")).toHaveLength(1)
})
it("hides its delete button in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountThreshold()
expect(wrapper.findAll("button")).toHaveLength(1)
})
it("drops the label field for a gauge", async ({ expect }) => {
const wrapper = await mountThreshold({
visualizationType: GenericQueryChartType.Gauge,
})
expect(inputs(wrapper)).toHaveLength(1)
expect(at(inputs(wrapper), 0).attributes("placeholder")).toBe(
t("editor.metrics.config.threshold-value-placeholder"),
)
})
it("shows only a fixed label for the base threshold", async ({ expect }) => {
const wrapper = await mountThreshold({
visualizationType: GenericQueryChartType.Gauge,
baseThreshold: { label: t("editor.metrics.config.base-threshold-label") },
})
expect(inputs(wrapper)).toHaveLength(1)
expect(valueOf(wrapper, 0)).toBe(
t("editor.metrics.config.base-threshold-label"),
)
expect(wrapper.emitted("update:label")).toEqual([
[t("editor.metrics.config.base-threshold-label")],
])
})
it("cannot be deleted when it is the base threshold", async ({ expect }) => {
const wrapper = await mountThreshold({
baseThreshold: { label: t("editor.metrics.config.base-threshold-label") },
})
expect(wrapper.findAll("button")).toHaveLength(1)
})
it.for([
{ status: DiffStatus.Added, expected: "bg-diff-field-added" },
{ status: DiffStatus.Removed, expected: "bg-diff-field-removed" },
])(
"tints a $status threshold row",
async ({ status, expected }, { expect }) => {
const wrapper = await mountThreshold({ diffStatus: status })
expect(wrapper.classes()).toContain(expected)
},
)
it("picks a colour from the palette popover", async ({ expect }) => {
const palette = stubThresholdPalette()
const wrapper = await mountThreshold()
await at(wrapper.findAll("button"), 0).trigger("click")
await nextTick()
const swatches = Array.from(
document.body.querySelectorAll<HTMLButtonElement>(
"[data-slot='popover-content'] button",
),
)
swatches[2]?.click()
await nextTick()
expect(wrapper.emitted("update:color")).toEqual([[at(palette, 2)]])
})
it("offers no colour popover in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountThreshold()
await at(wrapper.findAll("button"), 0).trigger("click")
await nextTick()
expect(
document.body.querySelectorAll("[data-slot='popover-content']"),
).toHaveLength(0)
})
})

View File

@ -0,0 +1,148 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import UnitSelect from "./UnitSelect.vue"
import { VisualizationCoreUnit, VisualizationDataUnit } from "../utils"
import { clearTeleportedOverlays, menuItem, t } from "~/components/test-helpers"
import { DiffStatus } from "~/components/editor/diff/position-map"
function mountUnitSelect(props: Record<string, unknown> = {}) {
return mountSuspended(UnitSelect, { props: props })
}
function triggerLabel(wrapper: VueWrapper): string {
return wrapper.get("button").text()
}
async function openMenu(wrapper: VueWrapper) {
await wrapper.get("button").trigger("pointerdown", { button: 0 })
await wrapper.get("button").trigger("click")
await nextTick()
}
// the editable flag is a shared cookie state, the editor store is
// app-wide, and the menu bodies are teleported into a shared <body>, so
// these tests cannot interleave
describe("<UnitSelect>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("prompts for a unit while none is chosen", async ({ expect }) => {
const wrapper = await mountUnitSelect()
expect(triggerLabel(wrapper)).toBe(
t("editor.metrics.config.unit-placeholder"),
)
})
it("reports an unset unit as empty in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountUnitSelect()
expect(triggerLabel(wrapper)).toBe(
t("editor.metrics.config.unit-empty-value-placeholder"),
)
})
it("names the chosen unit through its category", async ({ expect }) => {
const wrapper = await mountUnitSelect({
modelValue: VisualizationDataUnit.Megabytes,
})
expect(triggerLabel(wrapper)).toBe(
t("editor.metrics.config.unit-options.data.options.megabytes"),
)
})
it("names a chosen core unit directly", async ({ expect }) => {
const wrapper = await mountUnitSelect({
modelValue: VisualizationCoreUnit.Custom,
})
expect(triggerLabel(wrapper)).toBe(
t("editor.metrics.config.unit-options.custom"),
)
})
it("falls back to the unit the diff replaced", async ({ expect }) => {
const wrapper = await mountUnitSelect({
modelValue: null,
oldUnit: VisualizationDataUnit.Bytes,
})
expect(triggerLabel(wrapper)).toBe(
t("editor.metrics.config.unit-options.data.options.bytes"),
)
})
it("prefers the current unit over the one the diff replaced", async ({
expect,
}) => {
const wrapper = await mountUnitSelect({
modelValue: VisualizationDataUnit.Megabytes,
oldUnit: VisualizationDataUnit.Bytes,
})
expect(triggerLabel(wrapper)).toBe(
t("editor.metrics.config.unit-options.data.options.megabytes"),
)
})
it.for([
{ status: DiffStatus.Added, expected: "bg-diff-field-added" },
{ status: DiffStatus.Removed, expected: "bg-diff-field-removed" },
])("tints a $status unit field", async ({ status, expected }, { expect }) => {
const wrapper = await mountUnitSelect({ diffStatus: status })
expect(wrapper.get("button").classes()).toContain(expected)
})
it("leaves an unchanged unit field untinted", async ({ expect }) => {
const wrapper = await mountUnitSelect({ diffStatus: DiffStatus.Unchanged })
expect(wrapper.get("button").classes()).not.toContain("bg-diff-field-added")
expect(wrapper.get("button").classes()).not.toContain(
"bg-diff-field-removed",
)
})
it("offers no menu in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountUnitSelect()
expect(wrapper.get("button").attributes("disabled")).toBe("")
})
it("groups the units by category", async ({ expect }) => {
const wrapper = await mountUnitSelect()
await openMenu(wrapper)
expect(
menuItem(t("editor.metrics.config.unit-options.time.title")),
).toBeDefined()
expect(
menuItem(t("editor.metrics.config.unit-options.data.title")),
).toBeDefined()
expect(
menuItem(t("editor.metrics.config.unit-options.misc.title")),
).toBeDefined()
})
it("picks a core unit straight off the menu", async ({ expect }) => {
const wrapper = await mountUnitSelect()
await openMenu(wrapper)
menuItem(t("editor.metrics.config.unit-options.custom")).click()
await nextTick()
expect(wrapper.emitted("update:modelValue")).toEqual([
[VisualizationCoreUnit.Custom],
])
})
})

View File

@ -0,0 +1,169 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import UnitSelectWithCustom from "./UnitSelectWithCustom.vue"
import UnitSelect from "./UnitSelect.vue"
import { stubChartColorContext } from "../test-helpers"
import {
defaultMetricConfig,
VISUALIZATION_MAX_CUSTOM_UNIT_LENGTH,
VisualizationCoreUnit,
VisualizationDataUnit,
type MetricConfig,
} from "../utils"
import { emitFrom, t } from "~/components/test-helpers"
import { DiffStatus } from "~/components/editor/diff/position-map"
function configWith(unit: MetricConfig["unit"]): MetricConfig {
return { ...defaultMetricConfig(), unit: unit }
}
function mountUnit(props: Record<string, unknown> = {}) {
return mountSuspended(UnitSelectWithCustom, { props: props })
}
function customInput(wrapper: VueWrapper) {
return wrapper.find("input")
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<UnitSelectWithCustom>", { concurrent: false }, () => {
beforeEach(() => {
stubChartColorContext()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("offers the unit list for the config it edits", async ({ expect }) => {
const wrapper = await mountUnit({
modelValue: configWith({ type: VisualizationDataUnit.Bytes }),
})
expect(wrapper.get("button").text()).toBe(
t("editor.metrics.config.unit-options.data.options.bytes"),
)
})
it("hides the custom field for a non-custom unit", async ({ expect }) => {
const wrapper = await mountUnit({
modelValue: configWith({ type: VisualizationDataUnit.Bytes }),
})
expect(customInput(wrapper).exists()).toBe(false)
})
it("asks for the custom unit once custom is chosen", async ({ expect }) => {
const wrapper = await mountUnit({
modelValue: configWith({ type: VisualizationCoreUnit.Custom }),
})
expect(customInput(wrapper).attributes("placeholder")).toBe(
t("editor.metrics.config.unit-custom-placeholder"),
)
})
it("reports an unset custom unit as empty in read mode", async ({
expect,
}) => {
useEditorMeta().setEditable(false)
const wrapper = await mountUnit({
modelValue: configWith({ type: VisualizationCoreUnit.Custom }),
})
expect(customInput(wrapper).attributes("placeholder")).toBe(
t("editor.metrics.config.unit-custom-empty-value-placeholder"),
)
})
it("shows the custom unit the config holds", async ({ expect }) => {
const wrapper = await mountUnit({
modelValue: configWith({
type: VisualizationCoreUnit.Custom,
custom: "req/s",
}),
})
expect((customInput(wrapper).element as HTMLInputElement).value).toBe(
"req/s",
)
})
it("stores a unit type the reader picks", async ({ expect }) => {
const config = configWith({ type: null })
const wrapper = await mountUnit({ modelValue: config })
emitFrom(
wrapper,
UnitSelect,
"update:modelValue",
VisualizationDataUnit.Bits,
)
await nextTick()
expect(config.unit.type).toBe(VisualizationDataUnit.Bits)
})
it("stores the custom unit the reader typed", async ({ expect }) => {
const config = configWith({ type: VisualizationCoreUnit.Custom })
const wrapper = await mountUnit({ modelValue: config })
await customInput(wrapper).setValue(" req/s ")
expect(config.unit.custom).toBe("req/s")
})
it("cuts an overlong custom unit down to size", async ({ expect }) => {
const config = configWith({ type: VisualizationCoreUnit.Custom })
const wrapper = await mountUnit({ modelValue: config })
await customInput(wrapper).setValue("abcdefghijkl")
expect(config.unit.custom).toHaveLength(
VISUALIZATION_MAX_CUSTOM_UNIT_LENGTH,
)
})
it("shows the unit of the version a diff replaced", async ({ expect }) => {
const wrapper = await mountUnit({
oldConfig: configWith({
type: VisualizationCoreUnit.Custom,
custom: "old/s",
}),
})
expect(wrapper.get("button").text()).toBe(
t("editor.metrics.config.unit-options.custom"),
)
expect((customInput(wrapper).element as HTMLInputElement).value).toBe(
"old/s",
)
})
it.for([
{ status: DiffStatus.Added, expected: "bg-diff-field-added" },
{ status: DiffStatus.Removed, expected: "bg-diff-field-removed" },
])(
"tints a $status custom unit field",
async ({ status, expected }, { expect }) => {
const wrapper = await mountUnit({
modelValue: configWith({ type: VisualizationCoreUnit.Custom }),
diffStatus: status,
})
expect(customInput(wrapper).classes()).toContain(expected)
},
)
it("shows nothing at all with neither a config nor an old one", async ({
expect,
}) => {
const wrapper = await mountUnit({})
expect(wrapper.get("button").text()).toBe(
t("editor.metrics.config.unit-placeholder"),
)
expect(customInput(wrapper).exists()).toBe(false)
})
})

View File

@ -0,0 +1,132 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import VisualizationTypeSelect from "./VisualizationTypeSelect.vue"
import { stubChartColorContext } from "../test-helpers"
import { defaultMetricConfig, type MetricConfig } from "../utils"
import { at, t } from "~/components/test-helpers"
const LINE = 0
const BAR = 1
const GAUGE = 2
function configWith(
visualizationType: MetricConfig["visualizationType"],
): MetricConfig {
return { ...defaultMetricConfig(), visualizationType: visualizationType }
}
function mountTypeSelect(props: Record<string, unknown> = {}) {
return mountSuspended(VisualizationTypeSelect, { props: props })
}
function tiles(wrapper: VueWrapper) {
return wrapper.findAll("div.flex-1")
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<VisualizationTypeSelect>", { concurrent: false }, () => {
beforeEach(() => {
stubChartColorContext()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("offers the three visualization types", async ({ expect }) => {
const wrapper = await mountTypeSelect({ modelValue: configWith(null) })
expect(tiles(wrapper).map((t) => t.text())).toEqual([
t("editor.metrics.config.type-options.line-chart.title"),
t("editor.metrics.config.type-options.bar-chart.title"),
t("editor.metrics.config.type-options.gauge-chart.title"),
])
})
it("marks the chosen type", async ({ expect }) => {
const wrapper = await mountTypeSelect({
modelValue: configWith(GenericQueryChartType.Bar),
})
expect(at(tiles(wrapper), BAR).classes()).toContain("border-primary")
expect(at(tiles(wrapper), LINE).classes()).not.toContain("border-primary")
})
it("marks nothing while no type is chosen", async ({ expect }) => {
const wrapper = await mountTypeSelect({ modelValue: configWith(null) })
expect(
tiles(wrapper).filter((t) => t.classes().includes("border-primary")),
).toHaveLength(0)
})
it.for([
{ index: LINE, expected: GenericQueryChartType.Line },
{ index: BAR, expected: GenericQueryChartType.Bar },
{ index: GAUGE, expected: GenericQueryChartType.Gauge },
])(
"stores the $expected type when picked",
async ({ index, expected }, { expect }) => {
const config = configWith(null)
const wrapper = await mountTypeSelect({ modelValue: config })
await at(tiles(wrapper), index).trigger("click")
expect(config.visualizationType).toBe(expected)
},
)
it("changes nothing in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const config = configWith(null)
const wrapper = await mountTypeSelect({ modelValue: config })
await at(tiles(wrapper), BAR).trigger("click")
expect(config.visualizationType).toBeNull()
})
it("changes nothing while a reviewable diff is shown", async ({ expect }) => {
useEditorStore().setReviewableDiffActive(true)
const config = configWith(null)
const wrapper = await mountTypeSelect({ modelValue: config })
await at(tiles(wrapper), BAR).trigger("click")
expect(config.visualizationType).toBeNull()
})
it("changes nothing when there is no config to edit", async ({ expect }) => {
const wrapper = await mountTypeSelect({})
await at(tiles(wrapper), BAR).trigger("click")
expect(wrapper.emitted("update:modelValue")).toBeUndefined()
})
it("marks the type a diff added and the one it replaced", async ({
expect,
}) => {
const wrapper = await mountTypeSelect({
modelValue: configWith(GenericQueryChartType.Gauge),
oldConfig: configWith(GenericQueryChartType.Line),
isModified: true,
})
expect(at(tiles(wrapper), GAUGE).classes()).toContain("bg-diff-field-added")
expect(at(tiles(wrapper), LINE).classes()).toContain(
"bg-diff-field-removed",
)
})
it("leaves an unmodified type unmarked", async ({ expect }) => {
const wrapper = await mountTypeSelect({
modelValue: configWith(GenericQueryChartType.Gauge),
oldConfig: configWith(GenericQueryChartType.Gauge),
})
expect(at(tiles(wrapper), GAUGE).classes()).not.toContain(
"bg-diff-field-added",
)
})
})

View File

@ -0,0 +1,54 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import HelpPopoverContent from "./HelpPopoverContent.vue"
import { t } from "~/components/test-helpers"
function mountHelp(dataSourceType: DataSourceType) {
return mountSuspended(HelpPopoverContent, {
props: { dataSourceType: dataSourceType },
})
}
describe("<HelpPopoverContent>", () => {
it.for([
{
type: DataSourceType.Prometheus,
expectedKey:
"editor.metrics.config.query-explanations.prometheus.main-placeholders.prometheus-queries",
},
{
type: DataSourceType.PostgreSQL,
expectedKey:
"editor.metrics.config.query-explanations.postgresql.main-placeholders.postgresql-queries",
},
{
type: DataSourceType.MySQL,
expectedKey:
"editor.metrics.config.query-explanations.mysql.main-placeholders.mysql-queries",
},
{
type: DataSourceType.MariaDB,
expectedKey:
"editor.metrics.config.query-explanations.mariadb.main-placeholders.mariadb-queries",
},
])("explains $type queries", async ({ type, expectedKey }, { expect }) => {
const wrapper = await mountHelp(type)
expect(wrapper.text()).toContain(t(expectedKey))
})
it("explains only the data source it was given", async ({ expect }) => {
const wrapper = await mountHelp(DataSourceType.MySQL)
expect(wrapper.text()).not.toContain(
t(
"editor.metrics.config.query-explanations.prometheus.main-placeholders.prometheus-queries",
),
)
expect(wrapper.text()).not.toContain(
t(
"editor.metrics.config.query-explanations.mariadb.main-placeholders.mariadb-queries",
),
)
})
})

View File

@ -0,0 +1,61 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import MariaDBHelp from "./MariaDBHelp.vue"
import { t } from "~/components/test-helpers"
describe("<MariaDBHelp>", () => {
it("explains what the query should return", async ({ expect }) => {
const wrapper = await mountSuspended(MariaDBHelp)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.mariadb.main-placeholders.mariadb-queries",
),
)
})
it("shows the expected column names as code", async ({ expect }) => {
const wrapper = await mountSuspended(MariaDBHelp)
expect(wrapper.findAll("code").map((c) => c.text())).toContain(
t(
"editor.metrics.config.query-explanations.mariadb.main-placeholders.time-column",
),
)
})
it("links to the official documentation", async ({ expect }) => {
const wrapper = await mountSuspended(MariaDBHelp)
const link = wrapper.get("a")
expect(link.text()).toBe(
t(
"editor.metrics.config.query-explanations.mariadb.main-placeholders.docs-link",
),
)
expect(link.attributes("href")).toBe(
useRuntimeConfig().public.mariadbQueryGuideURL,
)
expect(link.attributes("rel")).toBe("noopener noreferrer")
})
it("lists a single-series and a multi-series example", async ({ expect }) => {
const wrapper = await mountSuspended(MariaDBHelp)
expect(wrapper.text()).toContain(
t("editor.metrics.config.query-explanations.mariadb.examples-title"),
)
expect(wrapper.findAll("li")).toHaveLength(2)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.mariadb.examples.example-1.title",
),
)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.mariadb.examples.example-2.title",
),
)
})
})

View File

@ -3,6 +3,7 @@ const config = useRuntimeConfig()
</script>
<template>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.mariadb.main"
tag="div"
>
@ -123,6 +124,7 @@ const config = useRuntimeConfig()
}}
</code>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.mariadb.examples.example-1.description"
tag="div"
>
@ -155,6 +157,7 @@ const config = useRuntimeConfig()
}}
</code>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.mariadb.examples.example-2.description"
tag="div"
>

View File

@ -0,0 +1,61 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import MySQLHelp from "./MySQLHelp.vue"
import { t } from "~/components/test-helpers"
describe("<MySQLHelp>", () => {
it("explains what the query should return", async ({ expect }) => {
const wrapper = await mountSuspended(MySQLHelp)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.mysql.main-placeholders.mysql-queries",
),
)
})
it("shows the expected column names as code", async ({ expect }) => {
const wrapper = await mountSuspended(MySQLHelp)
expect(wrapper.findAll("code").map((c) => c.text())).toContain(
t(
"editor.metrics.config.query-explanations.mysql.main-placeholders.time-column",
),
)
})
it("links to the official documentation", async ({ expect }) => {
const wrapper = await mountSuspended(MySQLHelp)
const link = wrapper.get("a")
expect(link.text()).toBe(
t(
"editor.metrics.config.query-explanations.mysql.main-placeholders.docs-link",
),
)
expect(link.attributes("href")).toBe(
useRuntimeConfig().public.mysqlQueryGuideURL,
)
expect(link.attributes("rel")).toBe("noopener noreferrer")
})
it("lists a single-series and a multi-series example", async ({ expect }) => {
const wrapper = await mountSuspended(MySQLHelp)
expect(wrapper.text()).toContain(
t("editor.metrics.config.query-explanations.mysql.examples-title"),
)
expect(wrapper.findAll("li")).toHaveLength(2)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.mysql.examples.example-1.title",
),
)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.mysql.examples.example-2.title",
),
)
})
})

View File

@ -3,6 +3,7 @@ const config = useRuntimeConfig()
</script>
<template>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.mysql.main"
tag="div"
>
@ -123,6 +124,7 @@ const config = useRuntimeConfig()
}}
</code>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.mysql.examples.example-1.description"
tag="div"
>
@ -155,6 +157,7 @@ const config = useRuntimeConfig()
}}
</code>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.mysql.examples.example-2.description"
tag="div"
>

View File

@ -0,0 +1,61 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import PostgreSQLHelp from "./PostgreSQLHelp.vue"
import { t } from "~/components/test-helpers"
describe("<PostgreSQLHelp>", () => {
it("explains what the query should return", async ({ expect }) => {
const wrapper = await mountSuspended(PostgreSQLHelp)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.postgresql.main-placeholders.postgresql-queries",
),
)
})
it("shows the expected column names as code", async ({ expect }) => {
const wrapper = await mountSuspended(PostgreSQLHelp)
expect(wrapper.findAll("code").map((c) => c.text())).toContain(
t(
"editor.metrics.config.query-explanations.postgresql.main-placeholders.time-column",
),
)
})
it("links to the official documentation", async ({ expect }) => {
const wrapper = await mountSuspended(PostgreSQLHelp)
const link = wrapper.get("a")
expect(link.text()).toBe(
t(
"editor.metrics.config.query-explanations.postgresql.main-placeholders.docs-link",
),
)
expect(link.attributes("href")).toBe(
useRuntimeConfig().public.postgresqlQueryGuideURL,
)
expect(link.attributes("rel")).toBe("noopener noreferrer")
})
it("lists a single-series and a multi-series example", async ({ expect }) => {
const wrapper = await mountSuspended(PostgreSQLHelp)
expect(wrapper.text()).toContain(
t("editor.metrics.config.query-explanations.postgresql.examples-title"),
)
expect(wrapper.findAll("li")).toHaveLength(2)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.postgresql.examples.example-1.title",
),
)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.postgresql.examples.example-2.title",
),
)
})
})

View File

@ -3,6 +3,7 @@ const config = useRuntimeConfig()
</script>
<template>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.postgresql.main"
tag="div"
>
@ -134,6 +135,7 @@ const config = useRuntimeConfig()
}}
</code>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.postgresql.examples.example-1.description"
tag="div"
>
@ -166,6 +168,7 @@ const config = useRuntimeConfig()
}}
</code>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.postgresql.examples.example-2.description"
tag="div"
>

View File

@ -0,0 +1,51 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import PrometheusHelp from "./PrometheusHelp.vue"
import { t } from "~/components/test-helpers"
describe("<PrometheusHelp>", () => {
it("explains what a prometheus query is", async ({ expect }) => {
const wrapper = await mountSuspended(PrometheusHelp)
expect(wrapper.text()).toContain(
t(
"editor.metrics.config.query-explanations.prometheus.main-placeholders.prometheus-queries",
),
)
})
it("shows the query syntax pieces as code", async ({ expect }) => {
const wrapper = await mountSuspended(PrometheusHelp)
expect(wrapper.findAll("code").map((c) => c.text())).toContain(
t(
"editor.metrics.config.query-explanations.prometheus.main-placeholders.rate-function",
),
)
})
it("links to the official documentation", async ({ expect }) => {
const wrapper = await mountSuspended(PrometheusHelp)
const link = wrapper.get("a")
expect(link.text()).toBe(
t(
"editor.metrics.config.query-explanations.prometheus.main-placeholders.official-prometheus-docs",
),
)
expect(link.attributes("href")).toBe(
useRuntimeConfig().public.prometheusQueryGuideURL,
)
expect(link.attributes("rel")).toBe("noopener noreferrer")
})
it("lists both worked examples", async ({ expect }) => {
const wrapper = await mountSuspended(PrometheusHelp)
expect(wrapper.text()).toContain(
t("editor.metrics.config.query-explanations.prometheus.examples-title"),
)
expect(wrapper.findAll("li")).toHaveLength(2)
})
})

View File

@ -3,6 +3,7 @@ const config = useRuntimeConfig()
</script>
<template>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.prometheus.main"
tag="div"
>
@ -64,6 +65,7 @@ const config = useRuntimeConfig()
</div>
<ul class="mt-1 flex list-disc flex-col gap-1 pl-4">
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.prometheus.examples.example-1.description"
tag="li"
>
@ -96,6 +98,7 @@ const config = useRuntimeConfig()
</template>
</i18n-t>
<i18n-t
scope="global"
keypath="editor.metrics.config.query-explanations.prometheus.examples.example-2.description"
tag="li"
>

View File

@ -0,0 +1,156 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import CodeMirror from "vue-codemirror6"
import LegendEditor from "./LegendEditor.vue"
import { TimeRangePreset } from "../../utils"
import { makeXid } from "~/composables/api/test-helpers"
import { emitFrom, t } from "~/components/test-helpers"
import { DiffStatus } from "~/components/editor/diff/position-map"
const DATA_SOURCE_ID = makeXid("ds")
function mountEditor(props: Record<string, unknown> = {}) {
return mountSuspended(LegendEditor, {
props: {
dataSourceId: DATA_SOURCE_ID,
timeRange: TimeRangePreset.Last5Minutes,
query: "up",
modelValue: "",
...props,
},
})
}
function content(wrapper: VueWrapper) {
return wrapper.get(".cm-content")
}
// codemirror reports focus changes through the wrapper component's own
// focus event rather than a dom event on the editable area
async function focusEditor(wrapper: VueWrapper) {
emitFrom(wrapper, CodeMirror, "focus", true)
await nextTick()
}
async function blurEditor(wrapper: VueWrapper) {
emitFrom(wrapper, CodeMirror, "focus", false)
await nextTick()
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<LegendEditor>", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("shows the legend format it was given", async ({ expect }) => {
const wrapper = await mountEditor({ modelValue: "{{instance}} rps" })
expect(content(wrapper).text()).toBe("{{instance}} rps")
})
it("prompts for a legend format while the field is empty", async ({
expect,
}) => {
const wrapper = await mountEditor()
expect(content(wrapper).attributes("aria-placeholder")).toBe(
"{{some label}} {{another label}}",
)
})
it("reports an empty legend format as empty in read mode", async ({
expect,
}) => {
useEditorMeta().setEditable(false)
const wrapper = await mountEditor()
expect(content(wrapper).attributes("aria-placeholder")).toBe(
t("editor.metrics.config.legend-format-empty-value-placeholder"),
)
})
it("stays fully lit once a data source and time range are set", async ({
expect,
}) => {
const wrapper = await mountEditor()
expect(wrapper.classes()).not.toContain("opacity-50")
expect(content(wrapper).attributes("contenteditable")).toBe("true")
})
it("dims itself while no data source is set", async ({ expect }) => {
const wrapper = await mountEditor({ dataSourceId: null })
expect(wrapper.classes()).toContain("opacity-50")
expect(content(wrapper).attributes("contenteditable")).toBe("false")
})
it("dims itself while no time range is set", async ({ expect }) => {
const wrapper = await mountEditor({ timeRange: null })
expect(wrapper.classes()).toContain("opacity-50")
})
it("hides its caret in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountEditor()
expect(wrapper.classes()).toContain("legend-editor--readonly")
})
it("hides its caret while a reviewable diff is shown", async ({ expect }) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountEditor()
expect(wrapper.classes()).toContain("legend-editor--readonly")
})
it.for([
{ status: DiffStatus.Added, expected: "bg-diff-field-added" },
{ status: DiffStatus.Removed, expected: "bg-diff-field-removed" },
])(
"tints a $status legend field",
async ({ status, expected }, { expect }) => {
const wrapper = await mountEditor({ diffStatus: status })
expect(wrapper.classes()).toContain(expected)
},
)
it("highlights the label placeholders", async ({ expect }) => {
const wrapper = await mountEditor({ modelValue: "{{instance}}" })
expect(wrapper.findAll(".cm-line span").length).toBeGreaterThan(0)
})
it("trims the legend format once the reader leaves the field", async ({
expect,
}) => {
const wrapper = await mountEditor({ modelValue: " {{instance}} " })
await focusEditor(wrapper)
await blurEditor(wrapper)
expect(content(wrapper).text()).toBe("{{instance}}")
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([
"{{instance}}",
])
})
it("leaves the format alone on a blur before it was ever focused", async ({
expect,
}) => {
const wrapper = await mountEditor({ modelValue: " {{instance}} " })
await blurEditor(wrapper)
expect(wrapper.emitted("update:modelValue")).toBeUndefined()
})
})

View File

@ -0,0 +1,151 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import CodeMirror from "vue-codemirror6"
import QueryEditor from "./QueryEditor.vue"
import { TimeRangePreset } from "../../utils"
import { makeXid } from "~/composables/api/test-helpers"
import { emitFrom, t } from "~/components/test-helpers"
import { DiffStatus } from "~/components/editor/diff/position-map"
const DATA_SOURCE_ID = makeXid("ds")
function mountEditor(props: Record<string, unknown> = {}) {
return mountSuspended(QueryEditor, {
props: {
dataSourceId: DATA_SOURCE_ID,
timeRange: TimeRangePreset.Last5Minutes,
modelValue: "",
...props,
},
})
}
function content(wrapper: VueWrapper) {
return wrapper.get(".cm-content")
}
// codemirror reports focus changes through the wrapper component's own
// focus event rather than a dom event on the editable area
async function focusEditor(wrapper: VueWrapper) {
emitFrom(wrapper, CodeMirror, "focus", true)
await nextTick()
}
async function blurEditor(wrapper: VueWrapper) {
emitFrom(wrapper, CodeMirror, "focus", false)
await nextTick()
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<QueryEditor>", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("shows the query it was given", async ({ expect }) => {
const wrapper = await mountEditor({ modelValue: "up{job='api'}" })
expect(content(wrapper).text()).toBe("up{job='api'}")
})
it("prompts for a query while the field is empty", async ({ expect }) => {
const wrapper = await mountEditor()
expect(content(wrapper).attributes("aria-placeholder")).toBe(
t("editor.metrics.config.query-placeholder.default"),
)
})
it("stays fully lit once a data source and time range are set", async ({
expect,
}) => {
const wrapper = await mountEditor()
expect(wrapper.classes()).not.toContain("opacity-50")
expect(content(wrapper).attributes("contenteditable")).toBe("true")
})
it("dims itself while no data source is set", async ({ expect }) => {
const wrapper = await mountEditor({ dataSourceId: null })
expect(wrapper.classes()).toContain("opacity-50")
expect(content(wrapper).attributes("contenteditable")).toBe("false")
})
it("dims itself while no time range is set", async ({ expect }) => {
const wrapper = await mountEditor({ timeRange: null })
expect(wrapper.classes()).toContain("opacity-50")
})
it("hides its caret in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountEditor()
expect(wrapper.classes()).toContain("query-editor--readonly")
})
it("hides its caret while a reviewable diff is shown", async ({ expect }) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountEditor()
expect(wrapper.classes()).toContain("query-editor--readonly")
})
it.for([
{ status: DiffStatus.Added, expected: "bg-diff-field-added" },
{ status: DiffStatus.Removed, expected: "bg-diff-field-removed" },
])(
"tints a $status query field",
async ({ status, expected }, { expect }) => {
const wrapper = await mountEditor({ diffStatus: status })
expect(wrapper.classes()).toContain(expected)
},
)
it("reports that the reader started editing", async ({ expect }) => {
const wrapper = await mountEditor({ modelValue: "up" })
await focusEditor(wrapper)
expect(wrapper.emitted("focus-change")).toEqual([[true]])
})
it("reports that the reader finished editing", async ({ expect }) => {
const wrapper = await mountEditor({ modelValue: "up" })
await focusEditor(wrapper)
await blurEditor(wrapper)
expect(wrapper.emitted("focus-change")?.slice(0, 2)).toEqual([
[true],
[false],
])
})
it("reports nothing for a blur before the field was ever focused", async ({
expect,
}) => {
const wrapper = await mountEditor({ modelValue: "up" })
await blurEditor(wrapper)
expect(wrapper.emitted("focus-change")).toBeUndefined()
})
it("trims the query once the reader leaves the field", async ({ expect }) => {
const wrapper = await mountEditor({ modelValue: " up " })
await focusEditor(wrapper)
await blurEditor(wrapper)
expect(content(wrapper).text()).toBe("up")
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["up"])
})
})

View File

@ -0,0 +1,23 @@
// shared helpers for the metric block component suites. Test-only: the
// app/**/test-helpers.ts coverage exclude keeps this out of the
// denominator, and nothing here is imported by app code.
export { stubThemeColorContext as stubChartColorContext } from "../../test-helpers/theme"
// the threshold palette is read straight off the root element's CSS
// variables, which no stylesheet defines under happy-dom. Distinct values
// let a suite tell one swatch from another.
export function stubThresholdPalette(): string[] {
const colors = Array.from(
{ length: 16 },
(_value, index) => `#${(index + 1).toString(16).padStart(2, "0")}0000`,
)
colors.forEach((color, index) => {
document.documentElement.style.setProperty(
`--chart-threshold-${index + 1}`,
color,
)
})
return colors
}

View File

@ -0,0 +1,171 @@
import { beforeEach, describe, it, vi } from "vitest"
import BarChart from "./BarChart.vue"
import { chartOption, chartSeries, mountChart } from "./test-helpers"
import { stubChartColorContext } from "../test-helpers"
import { VisualizationDataUnit } from "../utils"
import type { MultipleBarChartData } from "."
// echarts draws onto a canvas happy-dom does not implement; the option
// the component builds is the whole contract under test
vi.mock("vue-echarts", async () => {
const { defineComponent, h } = await import("vue")
return {
default: defineComponent({
name: "VChart",
props: { option: { type: Object, required: true } },
setup: () => () => h("div"),
}),
}
})
const SERIES: MultipleBarChartData = [
{
name: "web-1",
data: [
[new Date("2026-01-01T00:00:00Z"), 1],
[new Date("2026-01-01T00:01:00Z"), 3],
],
},
{
name: "web-2",
data: [[new Date("2026-01-01T00:00:00Z"), 2]],
},
]
function mountBar(props: Record<string, unknown> = {}) {
return mountChart(BarChart, {
seriesData: SERIES,
unit: { type: null, custom: null },
...props,
})
}
// chartStyles paints colours onto a canvas, so every mount needs the
// stand-in context installed first
describe("<BarChart>", { concurrent: false }, () => {
beforeEach(stubChartColorContext)
it("draws one bar series per result", async ({ expect }) => {
const wrapper = await mountBar()
const option = chartOption(wrapper)
expect(option.series).toHaveLength(2)
expect(option.series?.map((series) => series.name)).toEqual([
"web-1",
"web-2",
])
expect(chartSeries(option).type).toBe("bar")
expect(chartSeries(option).data).toEqual(SERIES[0]?.data)
})
it("draws nothing for an empty result", async ({ expect }) => {
const wrapper = await mountBar({ seriesData: [] })
expect(chartOption(wrapper).series).toEqual([])
})
it("hides the title area when there is no title", async ({ expect }) => {
const wrapper = await mountBar()
expect(chartOption(wrapper).title.show).toBe(false)
})
it("shows the title it was given", async ({ expect }) => {
const wrapper = await mountBar({ title: "Requests" })
const option = chartOption(wrapper)
expect(option.title.show).toBe(true)
expect(option.title.text).toBe("Requests")
})
it("leaves room above the plot for a title", async ({ expect }) => {
const withTitle = await mountBar({ title: "Requests" })
const withoutTitle = await mountBar()
expect(chartOption(withTitle).grid.top).toBeGreaterThan(
chartOption(withoutTitle).grid.top,
)
})
it("keeps the legend hidden unless it is asked for", async ({ expect }) => {
const wrapper = await mountBar()
expect(chartOption(wrapper).legend.show).toBeFalsy()
})
it("lists every series in the legend", async ({ expect }) => {
const wrapper = await mountBar({ showLegend: true })
const option = chartOption(wrapper)
expect(option.legend.show).toBe(true)
expect(option.legend.data).toEqual(["web-1", "web-2"])
expect(option.grid.bottom).toBeGreaterThan(10)
})
it("animates by default", async ({ expect }) => {
const wrapper = await mountBar()
expect(chartSeries(chartOption(wrapper)).animation).toBe(true)
})
it("holds the animation still when asked to", async ({ expect }) => {
const wrapper = await mountBar({ disableAnimation: true })
expect(chartSeries(chartOption(wrapper)).animation).toBe(false)
})
it("marks each threshold on the first series only", async ({ expect }) => {
const wrapper = await mountBar({
thresholds: [{ value: 2, color: "#ff0000", label: "warn" }],
})
const option = chartOption(wrapper)
expect(chartSeries(option).markLine?.data).toEqual([
expect.objectContaining({ yAxis: 2 }),
])
expect(chartSeries(option).markLine?.data[0]?.label.show).toBe(true)
expect(chartSeries(option).markLine?.data[0]?.label.formatter).toBe("warn")
expect(chartSeries(option, 1).markLine).toBeUndefined()
})
it("leaves an unlabelled threshold line unlabelled", async ({ expect }) => {
const wrapper = await mountBar({
thresholds: [{ value: 2, color: "#ff0000" }],
})
expect(
chartSeries(chartOption(wrapper)).markLine?.data[0]?.label.show,
).toBe(false)
})
it("marks nothing when there are no thresholds", async ({ expect }) => {
const wrapper = await mountBar()
expect(chartSeries(chartOption(wrapper)).markLine?.data).toEqual([])
})
it("honours the axis bounds the config fixes", async ({ expect }) => {
const wrapper = await mountBar({ axisBounds: { min: -5, max: 50 } })
const option = chartOption(wrapper)
expect(option.yAxis.min).toBe(-5)
expect(option.yAxis.max).toBe(50)
})
it("labels the value axis in the configured unit", async ({ expect }) => {
const wrapper = await mountBar({
unit: { type: VisualizationDataUnit.Bytes, custom: null },
decimals: 0,
})
const label = chartOption(wrapper).yAxis.axisLabel.formatter
expect(label(1024)).toBe("1 KB")
})
})

View File

@ -0,0 +1,196 @@
import { beforeEach, describe, it, vi } from "vitest"
import GaugeChart from "./GaugeChart.vue"
import {
chartOption,
chartSeries,
mountChart,
type ChartOption,
type ChartSeries,
} from "./test-helpers"
import { stubChartColorContext } from "../test-helpers"
import { VisualizationDataUnit } from "../utils"
import type { MultipleGaugeChartData } from "."
// echarts draws onto a canvas happy-dom does not implement; the option
// the component builds is the whole contract under test
vi.mock("vue-echarts", async () => {
const { defineComponent, h } = await import("vue")
return {
default: defineComponent({
name: "VChart",
props: { option: { type: Object, required: true } },
setup: () => () => h("div"),
}),
}
})
const BASE_COLOR = "#8a3ffc"
const GAUGES: MultipleGaugeChartData = [{ name: "cpu", value: 40 }]
function mountGauge(props: Record<string, unknown> = {}) {
return mountChart(GaugeChart, {
gauges: GAUGES,
baseThresholdColor: BASE_COLOR,
unit: { type: null, custom: null },
...props,
})
}
// the ring that shows the value, which is the last series of each gauge
function valueRing(option: ChartOption, index = 0): ChartSeries {
return chartSeries(option, index)
}
// chartStyles paints colours onto a canvas, so every mount needs the
// stand-in context installed first
describe("<GaugeChart>", { concurrent: false }, () => {
beforeEach(stubChartColorContext)
it("draws the value and its name", async ({ expect }) => {
const wrapper = await mountGauge()
const ring = valueRing(chartOption(wrapper))
expect(ring.type).toBe("gauge")
expect(ring.data).toEqual([{ value: 40, name: "cpu" }])
})
it("draws one gauge per result", async ({ expect }) => {
const wrapper = await mountGauge({
gauges: [
{ name: "cpu", value: 40 },
{ name: "memory", value: 70 },
],
})
const option = chartOption(wrapper)
expect(option.series).toHaveLength(2)
expect(
option.series?.map(
(series) => (series.data as { name: string }[])[0]?.name,
),
).toEqual(["cpu", "memory"])
})
it("draws nothing for an empty result", async ({ expect }) => {
const wrapper = await mountGauge({ gauges: [] })
expect(chartOption(wrapper).series).toBeUndefined()
})
it("hides the title area when there is no title", async ({ expect }) => {
const wrapper = await mountGauge()
expect(chartOption(wrapper).title.show).toBe(false)
})
it("shows the title it was given", async ({ expect }) => {
const wrapper = await mountGauge({ title: "CPU" })
const option = chartOption(wrapper)
expect(option.title.show).toBe(true)
expect(option.title.text).toBe("CPU")
})
it("animates by default", async ({ expect }) => {
const wrapper = await mountGauge()
expect(valueRing(chartOption(wrapper)).animation).toBe(true)
})
it("holds the animation still when asked to", async ({ expect }) => {
const wrapper = await mountGauge({ disableAnimation: true })
expect(valueRing(chartOption(wrapper)).animation).toBe(false)
})
it("colours the value with the base colour when there are no thresholds", async ({
expect,
}) => {
const wrapper = await mountGauge()
expect(valueRing(chartOption(wrapper)).progress?.itemStyle.color).toBe(
BASE_COLOR,
)
})
it("adds a threshold ring in front of the value ring", async ({ expect }) => {
const wrapper = await mountGauge({
thresholds: [{ value: 50, color: "#ff0000" }],
})
const option = chartOption(wrapper)
expect(option.series).toHaveLength(2)
expect(chartSeries(option).axisLine?.lineStyle.color).toEqual([
[expect.any(Number), BASE_COLOR],
[1, "#ff0000"],
])
})
it("colours the value with the highest threshold it has passed", async ({
expect,
}) => {
const wrapper = await mountGauge({
gauges: [{ name: "cpu", value: 80 }],
thresholds: [
{ value: 50, color: "#ffaa00" },
{ value: 75, color: "#ff0000" },
],
})
expect(valueRing(chartOption(wrapper), 1).progress?.itemStyle.color).toBe(
"#ff0000",
)
})
it("colours a value below every threshold with the base colour", async ({
expect,
}) => {
const wrapper = await mountGauge({
gauges: [{ name: "cpu", value: 10 }],
thresholds: [{ value: 50, color: "#ff0000" }],
})
expect(valueRing(chartOption(wrapper), 1).progress?.itemStyle.color).toBe(
BASE_COLOR,
)
})
it("honours the axis bounds the config fixes", async ({ expect }) => {
const wrapper = await mountGauge({ axisBounds: { min: 0, max: 200 } })
const ring = valueRing(chartOption(wrapper))
expect(ring.min).toBe(0)
expect(ring.max).toBe(200)
})
it("shows the value in the configured unit", async ({ expect }) => {
const wrapper = await mountGauge({
gauges: [{ name: "cpu", value: 1024 }],
unit: { type: VisualizationDataUnit.Bytes, custom: null },
decimals: 0,
})
expect(valueRing(chartOption(wrapper)).detail?.formatter).toBe("1 KB")
})
it("shrinks the text of a long value so it still fits", async ({
expect,
}) => {
const short = await mountGauge({ gauges: [{ name: "cpu", value: 1 }] })
const long = await mountGauge({
gauges: [{ name: "cpu", value: 123456789.123456 }],
decimals: 6,
})
expect(valueRing(chartOption(long)).detail?.fontSize).toBeLessThan(
valueRing(chartOption(short)).detail?.fontSize ?? 0,
)
})
})

View File

@ -0,0 +1,171 @@
import { beforeEach, describe, it, vi } from "vitest"
import LineChart from "./LineChart.vue"
import { chartOption, chartSeries, mountChart } from "./test-helpers"
import { stubChartColorContext } from "../test-helpers"
import { VisualizationDataUnit } from "../utils"
import type { MultipleLineChartData } from "."
// echarts draws onto a canvas happy-dom does not implement; the option
// the component builds is the whole contract under test
vi.mock("vue-echarts", async () => {
const { defineComponent, h } = await import("vue")
return {
default: defineComponent({
name: "VChart",
props: { option: { type: Object, required: true } },
setup: () => () => h("div"),
}),
}
})
const SERIES: MultipleLineChartData = [
{
name: "web-1",
data: [
[new Date("2026-01-01T00:00:00Z"), 1],
[new Date("2026-01-01T00:01:00Z"), 3],
],
},
{
name: "web-2",
data: [[new Date("2026-01-01T00:00:00Z"), 2]],
},
]
function mountLine(props: Record<string, unknown> = {}) {
return mountChart(LineChart, {
seriesData: SERIES,
unit: { type: null, custom: null },
...props,
})
}
// chartStyles paints colours onto a canvas, so every mount needs the
// stand-in context installed first
describe("<LineChart>", { concurrent: false }, () => {
beforeEach(stubChartColorContext)
it("draws one line per series", async ({ expect }) => {
const wrapper = await mountLine()
const option = chartOption(wrapper)
expect(option.series).toHaveLength(2)
expect(option.series?.map((series) => series.name)).toEqual([
"web-1",
"web-2",
])
expect(chartSeries(option).type).toBe("line")
expect(chartSeries(option).data).toEqual(SERIES[0]?.data)
})
it("draws nothing for an empty result", async ({ expect }) => {
const wrapper = await mountLine({ seriesData: [] })
expect(chartOption(wrapper).series).toEqual([])
})
it("hides the title area when there is no title", async ({ expect }) => {
const wrapper = await mountLine()
expect(chartOption(wrapper).title.show).toBe(false)
})
it("shows the title it was given", async ({ expect }) => {
const wrapper = await mountLine({ title: "Requests" })
const option = chartOption(wrapper)
expect(option.title.show).toBe(true)
expect(option.title.text).toBe("Requests")
})
it("leaves room above the plot for a title", async ({ expect }) => {
const withTitle = await mountLine({ title: "Requests" })
const withoutTitle = await mountLine()
expect(chartOption(withTitle).grid.top).toBeGreaterThan(
chartOption(withoutTitle).grid.top,
)
})
it("keeps the legend hidden unless it is asked for", async ({ expect }) => {
const wrapper = await mountLine()
expect(chartOption(wrapper).legend.show).toBeFalsy()
})
it("lists every series in the legend", async ({ expect }) => {
const wrapper = await mountLine({ showLegend: true })
const option = chartOption(wrapper)
expect(option.legend.show).toBe(true)
expect(option.legend.data).toEqual(["web-1", "web-2"])
expect(option.grid.bottom).toBeGreaterThan(10)
})
it("animates by default", async ({ expect }) => {
const wrapper = await mountLine()
expect(chartSeries(chartOption(wrapper)).animation).toBe(true)
})
it("holds the animation still when asked to", async ({ expect }) => {
const wrapper = await mountLine({ disableAnimation: true })
expect(chartSeries(chartOption(wrapper)).animation).toBe(false)
})
it("marks each threshold on the first series only", async ({ expect }) => {
const wrapper = await mountLine({
thresholds: [{ value: 2, color: "#ff0000", label: "warn" }],
})
const option = chartOption(wrapper)
expect(chartSeries(option).markLine?.data).toEqual([
expect.objectContaining({ yAxis: 2 }),
])
expect(chartSeries(option).markLine?.data[0]?.label.show).toBe(true)
expect(chartSeries(option).markLine?.data[0]?.label.formatter).toBe("warn")
expect(chartSeries(option, 1).markLine).toBeUndefined()
})
it("leaves an unlabelled threshold line unlabelled", async ({ expect }) => {
const wrapper = await mountLine({
thresholds: [{ value: 2, color: "#ff0000" }],
})
expect(
chartSeries(chartOption(wrapper)).markLine?.data[0]?.label.show,
).toBe(false)
})
it("marks nothing when there are no thresholds", async ({ expect }) => {
const wrapper = await mountLine()
expect(chartSeries(chartOption(wrapper)).markLine?.data).toEqual([])
})
it("honours the axis bounds the config fixes", async ({ expect }) => {
const wrapper = await mountLine({ axisBounds: { min: -5, max: 50 } })
const option = chartOption(wrapper)
expect(option.yAxis.min).toBe(-5)
expect(option.yAxis.max).toBe(50)
})
it("labels the value axis in the configured unit", async ({ expect }) => {
const wrapper = await mountLine({
unit: { type: VisualizationDataUnit.Bytes, custom: null },
decimals: 0,
})
const label = chartOption(wrapper).yAxis.axisLabel.formatter
expect(label(1024)).toBe("1 KB")
})
})

View File

@ -0,0 +1,68 @@
// shared helpers for the chart component suites. Test-only: the
// app/**/test-helpers.ts coverage exclude keeps this out of the
// denominator, and nothing here is imported by app code.
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
// eslint's ts program resolves .vue imports as error typed, so a chart
// component handed to mountChart looks unsafe to it while vue-tsc types
// it fine
type TestComponent = any
export interface ChartSeries {
name?: string
type?: string
data?: unknown
animation?: boolean
markLine?: {
data: {
yAxis: number
label: { show: boolean; formatter?: string }
}[]
}
min?: number
max?: number
axisLine?: { lineStyle: { color: [number, string][] } }
progress?: { itemStyle: { color: string } }
detail?: { formatter: string; fontSize: number }
}
// the slice of the echarts option the suites assert on
export interface ChartOption {
title: { show: boolean; text?: string }
legend: { show?: boolean; data?: string[] }
grid: { top: number; bottom: number }
yAxis: {
min?: number
max?: number
axisLabel: { formatter: (value: number) => string }
}
series?: ChartSeries[]
}
export function mountChart(
component: TestComponent,
props: Record<string, unknown>,
) {
return mountSuspended(component, { props: props })
}
// the whole drawing contract of a chart component is the option object it
// hands echarts
export function chartOption(wrapper: VueWrapper): ChartOption {
return wrapper
.findComponent({ name: "VChart" })
.props("option") as ChartOption
}
// noUncheckedIndexedAccess types every index access as possibly
// undefined, and the non-null assertion is banned, so an out-of-range
// series says so instead of failing later on an unrelated line
export function chartSeries(option: ChartOption, index = 0): ChartSeries {
const series = option.series?.[index]
if (!series) {
throw new Error(`no chart series at index ${index}`)
}
return series
}

View File

@ -0,0 +1,212 @@
import type { VueWrapper } from "@vue/test-utils"
import { describe, it } from "vitest"
import BottomAction from "./BottomAction.vue"
import {
findButtonByText,
mountUnderTooltipProvider,
renderedIconNames,
t,
} from "~/components/test-helpers"
const SHORTCUT = {
keyboardKey: { macOS: "⌘+K", other: "Ctrl+K" },
i18nKey: null,
}
function mountAction(props: Record<string, unknown>) {
return mountUnderTooltipProvider(BottomAction, { props: props })
}
// the emitted button-click payloads, in the order they were emitted
function clicks(wrapper: VueWrapper): unknown[] {
return (
wrapper.findComponent(BottomAction).emitted("button-click") ?? []
).map((args) => args[0])
}
describe("<SplitDocumentationBottomAction>", () => {
it("labels the first button with the given text", async ({ expect }) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.left-side-bottom-action-button",
),
})
expect(wrapper.text()).toContain(
t("editor.split-documentation.left-side-bottom-action-button"),
)
})
it("shows a plus icon on the first button by default", async ({ expect }) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.left-side-bottom-action-button",
),
})
expect(renderedIconNames(wrapper)).toEqual(["lucide:circle-plus"])
})
it("shows the given icon on the first button", async ({ expect }) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.left-side-bottom-action-button",
),
buttonIcon: "lucide:list-plus",
})
expect(renderedIconNames(wrapper)).toEqual(["lucide:list-plus"])
})
it("reports a first-button click", async ({ expect }) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.left-side-bottom-action-button",
),
})
await findButtonByText(
wrapper,
t("editor.split-documentation.left-side-bottom-action-button"),
).trigger("click")
expect(clicks(wrapper)).toEqual(["first"])
})
it("renders only one button when no second text is given", async ({
expect,
}) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.right-side-bottom-action-buttons.add-code",
),
})
expect(wrapper.findAll("button")).toHaveLength(1)
})
it("renders the second button when its text is given", async ({ expect }) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.right-side-bottom-action-buttons.add-code",
),
secondButtonText: t(
"editor.split-documentation.right-side-bottom-action-buttons.add-metrics",
),
})
expect(wrapper.findAll("button")).toHaveLength(2)
expect(wrapper.text()).toContain(
t(
"editor.split-documentation.right-side-bottom-action-buttons.add-metrics",
),
)
})
it("shows a plus icon on the second button by default", async ({
expect,
}) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.right-side-bottom-action-buttons.add-code",
),
buttonIcon: "lucide:code",
secondButtonText: t(
"editor.split-documentation.right-side-bottom-action-buttons.add-metrics",
),
})
expect(renderedIconNames(wrapper)).toEqual([
"lucide:code",
"lucide:circle-plus",
])
})
it("shows the given icon on the second button", async ({ expect }) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.right-side-bottom-action-buttons.add-code",
),
buttonIcon: "lucide:code",
secondButtonText: t(
"editor.split-documentation.right-side-bottom-action-buttons.add-metrics",
),
secondButtonIcon: "lucide:chart-line",
})
expect(renderedIconNames(wrapper)).toEqual([
"lucide:code",
"lucide:chart-line",
])
})
it("reports a second-button click", async ({ expect }) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.right-side-bottom-action-buttons.add-code",
),
secondButtonText: t(
"editor.split-documentation.right-side-bottom-action-buttons.add-metrics",
),
})
await findButtonByText(
wrapper,
t(
"editor.split-documentation.right-side-bottom-action-buttons.add-metrics",
),
).trigger("click")
expect(clicks(wrapper)).toEqual(["second"])
})
it("wraps a button carrying a shortcut in a tooltip trigger", async ({
expect,
}) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.left-side-bottom-action-button",
),
buttonShortcut: SHORTCUT,
})
expect(wrapper.findAll("[data-slot='tooltip-trigger']")).toHaveLength(1)
})
it("swallows a mousedown on the overlay background itself", async ({
expect,
}) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.left-side-bottom-action-button",
),
})
const event = new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
})
wrapper.get("div").element.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
it("lets a mousedown on the button through", async ({ expect }) => {
const wrapper = await mountAction({
buttonText: t(
"editor.split-documentation.left-side-bottom-action-button",
),
})
const event = new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
})
findButtonByText(
wrapper,
t("editor.split-documentation.left-side-bottom-action-button"),
).element.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})

View File

@ -0,0 +1,130 @@
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import LeftSideBlock from "./LeftSideBlock.vue"
import {
commandArgs,
commandNames,
makeEditor,
makeNode,
mountNodeViewUnderTooltipProvider,
} from "../../test-helpers/node-view"
import { at, findButtonByText, t } from "~/components/test-helpers"
const ADD_BUTTON = "editor.split-documentation.left-side-bottom-action-button"
function mountLeftSide(overrides: Record<string, unknown> = {}) {
return mountNodeViewUnderTooltipProvider(LeftSideBlock, {
node: makeNode({ uid: "left-1" }),
...overrides,
})
}
// the bottom action row is the wrapper's second child, after the node
// content, and is hidden with v-show — which leaves it in the dom with
// an inline display:none
function actionsHidden(wrapper: VueWrapper): boolean {
const row = at(wrapper.findAll("[data-node-view-wrapper] > div"), 1)
return (row.element as HTMLElement).style.display === "none"
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<SplitDocumentationLeftSideBlock>", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorMeta().updateLock(false)
useEditorStore().setReviewableDiffActive(false)
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountLeftSide()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("left-1")
expect(root.attributes("data-uid")).toBe("left-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountLeftSide({
node: makeNode({
uid: "left-1",
nodeCommentId: "comment-1",
diffStatus: "modified",
}),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("modified")
})
it("offers the add-parameters action while editing is possible", async ({
expect,
}) => {
const wrapper = await mountLeftSide()
expect(wrapper.text()).toContain(t(ADD_BUTTON))
expect(actionsHidden(wrapper)).toBe(false)
})
it("hides the add-parameters action in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountLeftSide()
expect(actionsHidden(wrapper)).toBe(true)
})
it("hides the add-parameters action while the editor is locked", async ({
expect,
}) => {
useEditorMeta().updateLock(true)
const wrapper = await mountLeftSide()
expect(actionsHidden(wrapper)).toBe(true)
})
it("hides the add-parameters action while a reviewable diff is shown", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountLeftSide()
expect(actionsHidden(wrapper)).toBe(true)
})
it("appends a parameter list at the node's position", async ({ expect }) => {
const { editor, commands } = makeEditor()
const wrapper = await mountLeftSide({ editor: editor, getPos: () => 7 })
await findButtonByText(wrapper, t(ADD_BUTTON)).trigger("click")
expect(commandNames(commands)).toEqual([
"focus",
"appendParameterListOnLeftSide",
"run",
])
expect(commandArgs(commands, "appendParameterListOnLeftSide")).toEqual([7])
})
it("runs no command when the node has no resolvable position", async ({
expect,
}) => {
const { editor, commands } = makeEditor()
const wrapper = await mountLeftSide({
editor: editor,
getPos: () => undefined,
})
await findButtonByText(wrapper, t(ADD_BUTTON)).trigger("click")
expect(commands).toEqual([])
})
})

View File

@ -0,0 +1,69 @@
import { describe, it } from "vitest"
import MainBlock from "./MainBlock.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
function contentClasses(wrapper: {
find: (s: string) => { classes: () => string[] }
}) {
return wrapper.find("[data-node-view-content]").classes()
}
describe("<SplitDocumentationMainBlock>", () => {
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountNodeView(MainBlock, {
node: makeNode({ uid: "block-1" }),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("block-1")
expect(root.attributes("data-uid")).toBe("block-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountNodeView(MainBlock, {
node: makeNode({
uid: "block-1",
nodeCommentId: "comment-1",
diffStatus: "added",
}),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("added")
})
it("lays the two sides out in document order by default", async ({
expect,
}) => {
const wrapper = await mountNodeView(MainBlock, {
node: makeNode({ uid: "block-1" }),
})
expect(contentClasses(wrapper)).toContain("flex-col")
expect(contentClasses(wrapper)).not.toContain("flex-col-reverse")
})
it("reverses the two sides when the node is inversed", async ({ expect }) => {
const wrapper = await mountNodeView(MainBlock, {
node: makeNode({ uid: "block-1", inversed: true }),
})
expect(contentClasses(wrapper)).toContain("flex-col-reverse")
expect(contentClasses(wrapper)).not.toContain("flex-col")
})
it("treats a missing inversed attribute as not inversed", async ({
expect,
}) => {
const wrapper = await mountNodeView(MainBlock, {
node: makeNode({ uid: "block-1", inversed: null }),
})
expect(contentClasses(wrapper)).toContain("flex-col")
})
})

View File

@ -0,0 +1,117 @@
import { beforeEach, describe, it } from "vitest"
import ParameterListHeader from "./ParameterListHeader.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
import { SPLIT_DOCUMENTATION_PARAMETER_LIST_HEADER_NAME } from "../node-names"
import { t } from "~/components/test-helpers"
function mountHeader(overrides: Record<string, unknown> = {}) {
return mountNodeView(ParameterListHeader, {
node: makeNode(
{ uid: "header-1" },
{ typeName: SPLIT_DOCUMENTATION_PARAMETER_LIST_HEADER_NAME },
),
...overrides,
})
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe(
"<SplitDocumentationParameterListHeader>",
{ concurrent: false },
() => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountHeader()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("header-1")
expect(root.attributes("data-uid")).toBe("header-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountHeader({
node: makeNode(
{
uid: "header-1",
nodeCommentId: "comment-1",
diffStatus: "added",
},
{ typeName: SPLIT_DOCUMENTATION_PARAMETER_LIST_HEADER_NAME },
),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("added")
})
it("prompts for content while the node is empty and editable", async ({
expect,
}) => {
const wrapper = await mountHeader()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t(
"editor.placeholders.content.split-documentation.parameter-list.header-name",
),
)
})
it("marks an empty node as unfilled in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountHeader()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t(
"editor.placeholders.content.split-documentation.parameter-list.header-name-empty",
),
)
})
it("marks an empty node as unfilled while a reviewable diff is shown", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountHeader()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t(
"editor.placeholders.content.split-documentation.parameter-list.header-name-empty",
),
)
})
it("shows no placeholder once the node has text", async ({ expect }) => {
const wrapper = await mountHeader({
node: makeNode(
{ uid: "header-1" },
{
typeName: SPLIT_DOCUMENTATION_PARAMETER_LIST_HEADER_NAME,
textContent: "Timeout",
},
),
})
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBeUndefined()
})
},
)

View File

@ -0,0 +1,43 @@
import { describe, it } from "vitest"
import ParameterListItemHeader from "./ParameterListItemHeader.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
describe("<SplitDocumentationParameterListItemHeader>", () => {
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountNodeView(ParameterListItemHeader, {
node: makeNode({ uid: "item-header-1" }),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("item-header-1")
expect(root.attributes("data-uid")).toBe("item-header-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountNodeView(ParameterListItemHeader, {
node: makeNode({
uid: "item-header-1",
nodeCommentId: "comment-1",
diffStatus: "modified",
}),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("modified")
})
it("lays the title and type out side by side", async ({ expect }) => {
const wrapper = await mountNodeView(ParameterListItemHeader, {
node: makeNode({ uid: "item-header-1" }),
})
expect(wrapper.get("[data-node-view-content]").classes()).toContain(
"items-center",
)
})
})

View File

@ -0,0 +1,119 @@
import { beforeEach, describe, it } from "vitest"
import ParameterListItemHeaderTitle from "./ParameterListItemHeaderTitle.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
import { SPLIT_DOCUMENTATION_PARAMETER_LIST_ITEM_HEADER_TITLE_NAME } from "../node-names"
import { t } from "~/components/test-helpers"
function mountHeader(overrides: Record<string, unknown> = {}) {
return mountNodeView(ParameterListItemHeaderTitle, {
node: makeNode(
{ uid: "header-1" },
{ typeName: SPLIT_DOCUMENTATION_PARAMETER_LIST_ITEM_HEADER_TITLE_NAME },
),
...overrides,
})
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe(
"<SplitDocumentationParameterListItemHeaderTitle>",
{ concurrent: false },
() => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountHeader()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("header-1")
expect(root.attributes("data-uid")).toBe("header-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountHeader({
node: makeNode(
{
uid: "header-1",
nodeCommentId: "comment-1",
diffStatus: "added",
},
{
typeName: SPLIT_DOCUMENTATION_PARAMETER_LIST_ITEM_HEADER_TITLE_NAME,
},
),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("added")
})
it("prompts for content while the node is empty and editable", async ({
expect,
}) => {
const wrapper = await mountHeader()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t(
"editor.placeholders.content.split-documentation.parameter-list.item.header-name",
),
)
})
it("marks an empty node as unfilled in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountHeader()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t(
"editor.placeholders.content.split-documentation.parameter-list.item.header-name-empty",
),
)
})
it("marks an empty node as unfilled while a reviewable diff is shown", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountHeader()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t(
"editor.placeholders.content.split-documentation.parameter-list.item.header-name-empty",
),
)
})
it("shows no placeholder once the node has text", async ({ expect }) => {
const wrapper = await mountHeader({
node: makeNode(
{ uid: "header-1" },
{
typeName: SPLIT_DOCUMENTATION_PARAMETER_LIST_ITEM_HEADER_TITLE_NAME,
textContent: "Timeout",
},
),
})
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBeUndefined()
})
},
)

View File

@ -0,0 +1,119 @@
import { beforeEach, describe, it } from "vitest"
import ParameterListItemHeaderType from "./ParameterListItemHeaderType.vue"
import { makeNode, mountNodeView } from "../../test-helpers/node-view"
import { SPLIT_DOCUMENTATION_PARAMETER_LIST_ITEM_HEADER_TYPE_NAME } from "../node-names"
import { t } from "~/components/test-helpers"
function mountHeader(overrides: Record<string, unknown> = {}) {
return mountNodeView(ParameterListItemHeaderType, {
node: makeNode(
{ uid: "header-1" },
{ typeName: SPLIT_DOCUMENTATION_PARAMETER_LIST_ITEM_HEADER_TYPE_NAME },
),
...overrides,
})
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe(
"<SplitDocumentationParameterListItemHeaderType>",
{ concurrent: false },
() => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountHeader()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("header-1")
expect(root.attributes("data-uid")).toBe("header-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountHeader({
node: makeNode(
{
uid: "header-1",
nodeCommentId: "comment-1",
diffStatus: "added",
},
{
typeName: SPLIT_DOCUMENTATION_PARAMETER_LIST_ITEM_HEADER_TYPE_NAME,
},
),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-1")
expect(root.attributes("data-diff-status")).toBe("added")
})
it("prompts for content while the node is empty and editable", async ({
expect,
}) => {
const wrapper = await mountHeader()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t(
"editor.placeholders.content.split-documentation.parameter-list.item.header-type",
),
)
})
it("marks an empty node as unfilled in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountHeader()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t(
"editor.placeholders.content.split-documentation.parameter-list.item.header-type-empty",
),
)
})
it("marks an empty node as unfilled while a reviewable diff is shown", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountHeader()
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBe(
t(
"editor.placeholders.content.split-documentation.parameter-list.item.header-type-empty",
),
)
})
it("shows no placeholder once the node has text", async ({ expect }) => {
const wrapper = await mountHeader({
node: makeNode(
{ uid: "header-1" },
{
typeName: SPLIT_DOCUMENTATION_PARAMETER_LIST_ITEM_HEADER_TYPE_NAME,
textContent: "Timeout",
},
),
})
expect(
wrapper.get("[data-node-view-content]").attributes("data-placeholder"),
).toBeUndefined()
})
},
)

View File

@ -0,0 +1,175 @@
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import RightSideBlock from "./RightSideBlock.vue"
import {
commandArgs,
commandNames,
makeEditor,
makeNode,
mountNodeViewUnderTooltipProvider,
} from "../../test-helpers/node-view"
import { at, findButtonByText, t } from "~/components/test-helpers"
import { SUPPRESS_SCROLL_TO_SELECTION_META } from "../../scroll-control"
const ADD_CODE_BUTTON =
"editor.split-documentation.right-side-bottom-action-buttons.add-code"
const ADD_METRICS_BUTTON =
"editor.split-documentation.right-side-bottom-action-buttons.add-metrics"
function mountRightSide(overrides: Record<string, unknown> = {}) {
return mountNodeViewUnderTooltipProvider(RightSideBlock, {
node: makeNode({ uid: "right-1" }),
...overrides,
})
}
// the bottom action row is the wrapper's second child, after the node
// content, and is hidden with v-show — which leaves it in the dom with
// an inline display:none
function actionsHidden(wrapper: VueWrapper): boolean {
const row = at(wrapper.findAll("[data-node-view-wrapper] > div"), 1)
return (row.element as HTMLElement).style.display === "none"
}
// the editable flag is a shared cookie state and the editor store is
// app-wide, so these tests cannot interleave
describe("<SplitDocumentationRightSideBlock>", { concurrent: false }, () => {
beforeEach(() => {
useEditorMeta().setEditable(true)
useEditorMeta().updateLock(false)
useEditorStore().setReviewableDiffActive(false)
})
it("identifies the wrapper by the node's uid", async ({ expect }) => {
const wrapper = await mountRightSide()
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("id")).toBe("right-1")
expect(root.attributes("data-uid")).toBe("right-1")
})
it("exposes the node's comment id and diff status on the wrapper", async ({
expect,
}) => {
const wrapper = await mountRightSide({
node: makeNode({
uid: "right-1",
nodeCommentId: "comment-2",
diffStatus: "removed",
}),
})
const root = wrapper.get("[data-node-view-wrapper]")
expect(root.attributes("data-node-comment-id")).toBe("comment-2")
expect(root.attributes("data-diff-status")).toBe("removed")
})
it("offers both add actions while editing is possible", async ({
expect,
}) => {
const wrapper = await mountRightSide()
expect(wrapper.text()).toContain(t(ADD_CODE_BUTTON))
expect(wrapper.text()).toContain(t(ADD_METRICS_BUTTON))
expect(actionsHidden(wrapper)).toBe(false)
})
it("hides the add actions in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountRightSide()
expect(actionsHidden(wrapper)).toBe(true)
})
it("hides the add actions while the editor is locked", async ({ expect }) => {
useEditorMeta().updateLock(true)
const wrapper = await mountRightSide()
expect(actionsHidden(wrapper)).toBe(true)
})
it("hides the add actions while a reviewable diff is shown", async ({
expect,
}) => {
useEditorStore().setReviewableDiffActive(true)
const wrapper = await mountRightSide()
expect(actionsHidden(wrapper)).toBe(true)
})
it("appends a code block at the node's position", async ({ expect }) => {
const { editor, commands } = makeEditor()
const wrapper = await mountRightSide({ editor: editor, getPos: () => 4 })
await findButtonByText(wrapper, t(ADD_CODE_BUTTON)).trigger("click")
expect(commandNames(commands)).toEqual([
"focus",
"appendBlockOnRightSide",
"run",
])
expect(commandArgs(commands, "appendBlockOnRightSide")).toEqual([4, "code"])
})
it("appends a metrics block without scrolling the selection into view", async ({
expect,
}) => {
const { editor, commands } = makeEditor()
const wrapper = await mountRightSide({ editor: editor, getPos: () => 4 })
await findButtonByText(wrapper, t(ADD_METRICS_BUTTON)).trigger("click")
expect(commandNames(commands)).toEqual([
"setMeta",
"focus",
"appendBlockOnRightSide",
"run",
])
expect(commandArgs(commands, "setMeta")).toEqual([
SUPPRESS_SCROLL_TO_SELECTION_META,
true,
])
expect(commandArgs(commands, "appendBlockOnRightSide")).toEqual([
4,
"metrics",
])
})
it("appends at the very start of the document", async ({ expect }) => {
const { editor, commands } = makeEditor()
const wrapper = await mountRightSide({ editor: editor, getPos: () => 0 })
await findButtonByText(wrapper, t(ADD_CODE_BUTTON)).trigger("click")
expect(commandArgs(commands, "appendBlockOnRightSide")).toEqual([0, "code"])
})
it("runs no command when the node has no resolvable position", async ({
expect,
}) => {
const { editor, commands } = makeEditor()
const wrapper = await mountRightSide({
editor: editor,
getPos: () => undefined,
})
await findButtonByText(wrapper, t(ADD_CODE_BUTTON)).trigger("click")
expect(commands).toEqual([])
})
it("runs no command when the position is negative", async ({ expect }) => {
const { editor, commands } = makeEditor()
const wrapper = await mountRightSide({ editor: editor, getPos: () => -1 })
await findButtonByText(wrapper, t(ADD_CODE_BUTTON)).trigger("click")
expect(commands).toEqual([])
})
})

View File

@ -0,0 +1,90 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it, vi } from "vitest"
import Comment from "./Comment.vue"
import { WAIT_FOR_OPTIONS } from "~/components/test-helpers"
function paragraphDoc(text: string) {
return {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: text }] }],
}
}
function mountComment(content: Record<string, unknown>) {
return mountSuspended(Comment, { props: { content: content } })
}
// the editor is created in onMounted, so every assertion waits for the
// first render it produces
describe("<Comment>", () => {
it("renders the comment's text", async ({ expect }) => {
const wrapper = await mountComment(paragraphDoc("Looks good to me"))
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Looks good to me")
}, WAIT_FOR_OPTIONS)
})
it("renders the comment read-only", async ({ expect }) => {
const wrapper = await mountComment(paragraphDoc("Looks good to me"))
await vi.waitFor(() => {
expect(wrapper.find(".ProseMirror").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.get(".ProseMirror").attributes("contenteditable")).toBe(
"false",
)
})
it("turns spellchecking off", async ({ expect }) => {
const wrapper = await mountComment(paragraphDoc("Teh comment"))
await vi.waitFor(() => {
expect(wrapper.find(".ProseMirror").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.get(".ProseMirror").attributes("spellcheck")).toBe("false")
})
it("renders an empty comment as an empty paragraph", async ({ expect }) => {
const wrapper = await mountComment({
type: "doc",
content: [{ type: "paragraph" }],
})
await vi.waitFor(() => {
expect(wrapper.find(".ProseMirror").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).toBe("")
})
it("re-renders when it is handed another comment", async ({ expect }) => {
const wrapper = await mountComment(paragraphDoc("First"))
await vi.waitFor(() => {
expect(wrapper.text()).toContain("First")
}, WAIT_FOR_OPTIONS)
await wrapper.setProps({ content: paragraphDoc("Second") })
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Second")
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).not.toContain("First")
})
it("keeps the formatting a comment carries", async ({ expect }) => {
const wrapper = await mountComment({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "bold", marks: [{ type: "bold" }] }],
},
],
})
await vi.waitFor(() => {
expect(wrapper.find("strong").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.get("strong").text()).toBe("bold")
})
})

View File

@ -0,0 +1,91 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { describe, it } from "vitest"
import CommentIndicator from "./CommentIndicator.vue"
import { renderedIconNames } from "~/components/test-helpers"
function mountIndicator(props: Record<string, unknown> = {}) {
return mountSuspended(CommentIndicator, {
props: {
commentType: "text",
top: 100,
left: 50,
forcedHighlight: false,
hovered: false,
...props,
},
})
}
function position(wrapper: VueWrapper): string {
return wrapper.get("div").attributes("style") ?? ""
}
describe("<CommentIndicator>", () => {
it("shows a comment bubble", async ({ expect }) => {
const wrapper = await mountIndicator()
expect(renderedIconNames(wrapper)).toEqual(["mingcute:message-4-fill"])
})
it("sits just above and left of a text comment", async ({ expect }) => {
const wrapper = await mountIndicator()
expect(position(wrapper)).toContain("top: 88px")
expect(position(wrapper)).toContain("left: 44px")
})
it("sits closer to a node comment", async ({ expect }) => {
const wrapper = await mountIndicator({ commentType: "node" })
expect(position(wrapper)).toContain("top: 92px")
expect(position(wrapper)).toContain("left: 44px")
})
it("lifts a hovered text comment further up", async ({ expect }) => {
const wrapper = await mountIndicator({ hovered: true })
expect(position(wrapper)).toContain("top: 84px")
})
it("lifts a hovered node comment further up", async ({ expect }) => {
const wrapper = await mountIndicator({
commentType: "node",
hovered: true,
})
expect(position(wrapper)).toContain("top: 86px")
})
it("lifts a forcibly highlighted comment too", async ({ expect }) => {
const wrapper = await mountIndicator({ forcedHighlight: true })
expect(position(wrapper)).toContain("top: 84px")
})
it("stays faded while nothing points at it", async ({ expect }) => {
const wrapper = await mountIndicator()
expect(wrapper.get("div").attributes("data-hovered")).toBeUndefined()
})
it("becomes solid once the pointer is on it", async ({ expect }) => {
const wrapper = await mountIndicator({ hovered: true })
expect(wrapper.get("div").attributes("data-hovered")).toBe("")
})
it("becomes solid while it is forcibly highlighted", async ({ expect }) => {
const wrapper = await mountIndicator({ forcedHighlight: true })
expect(wrapper.get("div").attributes("data-hovered")).toBe("")
})
it("animates its moves while the page is still", async ({ expect }) => {
const wrapper = await mountIndicator()
expect(wrapper.get("div").classes()).toContain(
"transition-[top,left,opacity]",
)
})
})

View File

@ -0,0 +1,203 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it } from "vitest"
import CommentIndicatorContainer from "./CommentIndicatorContainer.vue"
import CommentIndicator from "./CommentIndicator.vue"
import { at } from "~/components/test-helpers"
let container: HTMLElement | null = null
function overlay(nodeCommentId: string, forcedHighlight = false) {
return {
nodeCommentId: nodeCommentId,
top: 10,
left: 20,
forcedHighlight: forcedHighlight,
}
}
function indicator(commentId: string, forcedHighlight = false) {
return {
commentId: commentId,
top: 30,
left: 40,
forcedHighlight: forcedHighlight,
}
}
function mountContainer(props: Record<string, unknown> = {}) {
return mountSuspended(CommentIndicatorContainer, {
props: { nodeCommentState: null, textCommentState: null, ...props },
})
}
function indicators(wrapper: VueWrapper) {
return wrapper.findAllComponents(CommentIndicator)
}
// the indicators are teleported into an element the editor owns, so each
// test needs one in the document to teleport into
describe("<CommentIndicatorContainer>", { concurrent: false }, () => {
beforeEach(() => {
container = document.createElement("div")
document.body.appendChild(container)
})
afterEach(() => {
container?.remove()
container = null
})
it("renders nothing while there are no comments", async ({ expect }) => {
const wrapper = await mountContainer()
expect(indicators(wrapper)).toHaveLength(0)
})
it("renders nothing while the editor has no overlay container", async ({
expect,
}) => {
const wrapper = await mountContainer({
nodeCommentState: {
container: null,
overlays: [overlay("node-1")],
hoveredNodeCommentId: null,
},
})
expect(indicators(wrapper)).toHaveLength(0)
})
it("places one indicator per commented node", async ({ expect }) => {
const wrapper = await mountContainer({
nodeCommentState: {
container: container,
overlays: [overlay("node-1"), overlay("node-2")],
hoveredNodeCommentId: null,
},
})
expect(indicators(wrapper)).toHaveLength(2)
expect(at(indicators(wrapper), 0).props("commentType")).toBe("node")
expect(container?.querySelectorAll(".absolute")).toHaveLength(2)
})
it("places one indicator per commented text range", async ({ expect }) => {
const wrapper = await mountContainer({
textCommentState: {
container: container,
indicators: [indicator("text-1")],
hoveredCommentId: null,
},
})
expect(indicators(wrapper)).toHaveLength(1)
expect(at(indicators(wrapper), 0).props("commentType")).toBe("text")
})
it("marks the node indicator the pointer is on", async ({ expect }) => {
const wrapper = await mountContainer({
nodeCommentState: {
container: container,
overlays: [overlay("node-1"), overlay("node-2")],
hoveredNodeCommentId: "node-2",
},
})
expect(at(indicators(wrapper), 0).props("hovered")).toBe(false)
expect(at(indicators(wrapper), 1).props("hovered")).toBe(true)
})
it("marks the text indicator the pointer is on", async ({ expect }) => {
const wrapper = await mountContainer({
textCommentState: {
container: container,
indicators: [indicator("text-1"), indicator("text-2")],
hoveredCommentId: "text-1",
},
})
expect(at(indicators(wrapper), 0).props("hovered")).toBe(true)
expect(at(indicators(wrapper), 1).props("hovered")).toBe(false)
})
it("passes a forced highlight through", async ({ expect }) => {
const wrapper = await mountContainer({
nodeCommentState: {
container: container,
overlays: [overlay("node-1", true)],
hoveredNodeCommentId: null,
},
})
expect(at(indicators(wrapper), 0).props("forcedHighlight")).toBe(true)
})
it("asks to open the node comment that was clicked", async ({ expect }) => {
const wrapper = await mountContainer({
nodeCommentState: {
container: container,
overlays: [overlay("node-1")],
hoveredNodeCommentId: null,
},
})
await at(indicators(wrapper), 0).trigger("click")
expect(wrapper.emitted("open-comment")).toEqual([["node", "node-1"]])
})
it("asks to open the text comment that was clicked", async ({ expect }) => {
const wrapper = await mountContainer({
textCommentState: {
container: container,
indicators: [indicator("text-1")],
hoveredCommentId: null,
},
})
await at(indicators(wrapper), 0).trigger("click")
expect(wrapper.emitted("open-comment")).toEqual([["text", "text-1"]])
})
it("reports the pointer entering and leaving a node indicator", async ({
expect,
}) => {
const wrapper = await mountContainer({
nodeCommentState: {
container: container,
overlays: [overlay("node-1")],
hoveredNodeCommentId: null,
},
})
await at(indicators(wrapper), 0).trigger("mouseenter")
await at(indicators(wrapper), 0).trigger("mouseleave")
expect(wrapper.emitted("comment-hover-change")).toEqual([
["node", "node-1", true],
["node", "node-1", false],
])
})
it("reports the pointer entering and leaving a text indicator", async ({
expect,
}) => {
const wrapper = await mountContainer({
textCommentState: {
container: container,
indicators: [indicator("text-1")],
hoveredCommentId: null,
},
})
await at(indicators(wrapper), 0).trigger("mouseenter")
await at(indicators(wrapper), 0).trigger("mouseleave")
expect(wrapper.emitted("comment-hover-change")).toEqual([
["text", "text-1", true],
["text", "text-1", false],
])
})
})

View File

@ -0,0 +1,443 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { Editor as TiptapEditor, type Editor } from "@tiptap/core"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import CommentRenderer from "./CommentRenderer.vue"
import { CommentMark } from "./comment-mark"
import { NodeComment } from "./node-comment-extension"
import { CommentExtensions } from "./utils"
import UniqueID from "../tiptap-utils/unique-id"
import { stubThemeColorContext } from "../test-helpers/theme"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
seedQueryData,
} from "~/composables/api/test-helpers"
import {
seedAuthOrganization,
seedAuthSession,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
import type WsState from "~/utils/websocket"
// the thread list is virtualized off the rendered row heights, which
// happy-dom reports as zero — no comment would ever be drawn. The
// stand-ins render every row the thread hands them.
vi.mock("vue-virtual-scroller", async () => {
const { defineComponent, h } = await import("vue")
return {
DynamicScroller: defineComponent({
name: "DynamicScroller",
props: { items: { type: Array, required: true } },
setup:
(props, { slots }) =>
() =>
h(
"div",
(props.items as Record<string, unknown>[]).map((item, index) =>
slots.default?.({ item: item, index: index, active: true }),
),
),
}),
DynamicScrollerItem: defineComponent({
name: "DynamicScrollerItem",
setup:
(_props, { slots }) =>
() =>
h("div", slots.default?.()),
}),
}
})
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const ME = makeXid("usme")
const COMMENT_ID = makeXid("cmt")
let contentEditor: Editor | null = null
// the renderer anchors comments in a live document, so the suite drives a
// real editor holding the same mark and node comment extensions the app
// gives its content editor
function textDoc(text: string): Editor {
const element = document.createElement("div")
document.body.appendChild(element)
contentEditor = new TiptapEditor({
element: element,
extensions: [
...CommentExtensions,
UniqueID.configure({ types: ["paragraph"], attributeName: "uid" }),
CommentMark,
NodeComment.configure({ types: ["paragraph"] }),
],
content: `<p>${text}</p>`,
})
return contentEditor
}
function commentBody(text: string) {
return {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: text }] }],
}
}
function seedComments(comments: Record<string, unknown>[]) {
seedQueryData(["documents", DOCUMENT_ID, "comments", BRANCH_ID], comments)
}
function serverComment(overrides: Record<string, unknown> = {}) {
return {
id: COMMENT_ID,
documentId: DOCUMENT_ID,
branchId: BRANCH_ID,
userId: ME,
content: commentBody("looks good"),
blockId: "block-1",
resolved: false,
replies: [],
createdAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
}
}
function mountRenderer(editor: Editor) {
return mountSuspended(CommentRenderer, {
props: { contentEditor: editor, container: null },
})
}
interface CommentRendererApi {
isCommentPopoverOpen: (targetId?: string) => boolean
selectComment: (target: {
textComment: boolean
id?: string
pos?: number
}) => Promise<void>
addNewComment: (pos: number | "text-selection") => Promise<void>
}
// eslint's ts program cannot type a component exposed through a wrapper,
// so the exposed surface is named here
function api(wrapper: VueWrapper): CommentRendererApi {
return wrapper.vm as unknown as CommentRendererApi
}
function popoverOpen(wrapper: VueWrapper): boolean {
return wrapper.find(".z-popover").exists()
}
function popoverButton(wrapper: VueWrapper, text: string) {
const button = wrapper
.findAll("button")
.find((candidate) => candidate.text() === text)
if (!button) {
throw new Error(`no popover button rendering "${text}"`)
}
return button
}
// the editor store, the auth and query caches and the websocket store are
// shared app-wide, so these tests cannot interleave
describe("<CommentRenderer>", { concurrent: false }, () => {
enableAutoUnmount(afterEach)
beforeEach(() => {
stubThemeColorContext()
clearQueryCache()
useEditorMeta().setEditable(true)
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(false)
useWebSocketStateStore().state = null
seedAuthSession({ id: ME, name: "Me" })
seedAuthOrganization({
members: [{ userId: ME, user: { name: "Me", image: undefined } }],
})
seedComments([])
})
afterEach(() => {
disposeMockEndpoints()
if (contentEditor) {
const element = contentEditor.view.dom.parentElement
contentEditor.destroy()
element?.remove()
contentEditor = null
}
})
it("stays out of the way until a comment is opened", async ({ expect }) => {
const editor = textDoc("hello world")
const wrapper = await mountRenderer(editor)
expect(popoverOpen(wrapper)).toBe(false)
})
it("opens an empty thread for the selected text", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.setTextSelection({ from: 1, to: 6 })
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment("text-selection")
await nextTick()
expect(popoverOpen(wrapper)).toBe(true)
expect(wrapper.text()).toContain(
t("editor.comment-thread.title-new-thread"),
)
})
it("marks the selected text as commented", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.setTextSelection({ from: 1, to: 6 })
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment("text-selection")
expect(editor.getHTML()).toContain("data-comment-id")
})
it("opens an empty thread for a whole block", async ({ expect }) => {
const editor = textDoc("hello world")
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment(0)
await nextTick()
expect(popoverOpen(wrapper)).toBe(true)
expect(wrapper.text()).toContain(
t("editor.comment-thread.title-new-thread"),
)
})
it("opens nothing while the reader is not signed in", async ({ expect }) => {
seedAuthSession(null)
const editor = textDoc("hello world")
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment(0)
await nextTick()
expect(popoverOpen(wrapper)).toBe(false)
})
it("reports which thread is open", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
expect(api(wrapper).isCommentPopoverOpen(COMMENT_ID)).toBe(false)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await nextTick()
expect(api(wrapper).isCommentPopoverOpen(COMMENT_ID)).toBe(true)
expect(api(wrapper).isCommentPopoverOpen("another-comment")).toBe(false)
})
it("shows the thread of an existing block comment", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
// the thread list resolves asynchronously and each comment body is
// a tiptap editor that renders once it is mounted
await vi.waitFor(() => {
expect(wrapper.text()).toContain("looks good")
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).toContain(
t("editor.comment-thread.title-reply-existing-thread"),
)
})
it("shows the thread of an existing text comment", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.setTextSelection({ from: 1, to: 6 })
editor.commands.addCommentMark({ commentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: true, id: COMMENT_ID })
await vi.waitFor(() => {
expect(wrapper.text()).toContain("looks good")
}, WAIT_FOR_OPTIONS)
})
it("opens nothing for a comment that is not in the document", async ({
expect,
}) => {
const editor = textDoc("hello world")
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: true, id: COMMENT_ID })
await nextTick()
expect(popoverOpen(wrapper)).toBe(false)
})
it("shows the replies a thread already has", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([
serverComment({
replies: [
{
id: makeXid("rpl"),
commentId: COMMENT_ID,
userId: ME,
content: commentBody("agreed"),
createdAt: new Date("2026-01-02T00:00:00Z"),
},
],
}),
])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await vi.waitFor(() => {
expect(wrapper.text()).toContain("agreed")
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).toContain("looks good")
})
it("closes the thread on request", async ({ expect }) => {
const editor = textDoc("hello world")
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment(0)
await nextTick()
await popoverButton(
wrapper,
t("editor.comment-thread.close-button"),
).trigger("click")
expect(popoverOpen(wrapper)).toBe(false)
})
it("keeps the comment button out of reach while the draft is empty", async ({
expect,
}) => {
const editor = textDoc("hello world")
const wrapper = await mountRenderer(editor)
await api(wrapper).addNewComment(0)
await nextTick()
expect(
popoverButton(
wrapper,
t("editor.comment-thread.comment-button"),
).attributes("disabled"),
).toBe("")
})
it("offers to resolve a thread that already exists", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await nextTick()
expect(wrapper.text()).toContain(t("editor.comment-thread.resolve-button"))
})
it("resolves the thread", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/comments/${COMMENT_ID}/resolve`,
() => null,
)
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await nextTick()
await popoverButton(
wrapper,
t("editor.comment-thread.resolve-button"),
).trigger("click")
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(popoverOpen(wrapper)).toBe(false)
})
it("warns when the thread cannot be resolved", async ({ expect }) => {
const editor = textDoc("hello world")
editor.commands.addNodeComment(0, { nodeCommentId: COMMENT_ID })
seedComments([serverComment()])
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/comments/${COMMENT_ID}/resolve`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
const wrapper = await mountRenderer(editor)
await api(wrapper).selectComment({ textComment: false, id: COMMENT_ID })
await nextTick()
await popoverButton(
wrapper,
t("editor.comment-thread.resolve-button"),
).trigger("click")
await vi.waitFor(() => {
expect(popoverOpen(wrapper)).toBe(false)
}, WAIT_FOR_OPTIONS)
})
it("refetches the comments when the server says they changed", async ({
expect,
}) => {
const editor = textDoc("hello world")
const calls = mockEndpoint(
"GET",
`/api/documents/${DOCUMENT_ID}/comments`,
() => [],
)
const handlers: (() => void)[] = []
const subscribe = vi.fn((_topic: string, handler: () => void) => {
handlers.push(handler)
return () => undefined
})
useWebSocketStateStore().state = {
subscribe: subscribe,
} as unknown as WsState
await mountRenderer(editor)
handlers.forEach((handler) => {
handler()
})
await vi.waitFor(() => {
expect(calls.length).toBeGreaterThan(0)
}, WAIT_FOR_OPTIONS)
})
})

View File

@ -1750,6 +1750,7 @@ function updateNodeCommentIdInEditor(
<div class="text-2sm opacity-60">
<i18n-t
v-if="!item.edited"
scope="global"
keypath="editor.comment-thread.comment-header"
tag="span"
>
@ -1772,6 +1773,7 @@ function updateNodeCommentIdInEditor(
</i18n-t>
<i18n-t
v-else
scope="global"
keypath="editor.comment-thread.edited-comment-header"
tag="span"
>
@ -1886,18 +1888,21 @@ function updateNodeCommentIdInEditor(
>
<i18n-t
v-if="editTarget"
scope="global"
keypath="editor.comment-thread.title-edit-existing-thread"
class="text-xs font-semibold"
tag="div"
/>
<i18n-t
v-else-if="loadedCommentThread.length"
scope="global"
keypath="editor.comment-thread.title-reply-existing-thread"
class="text-xs font-semibold"
tag="div"
/>
<i18n-t
v-else
scope="global"
keypath="editor.comment-thread.title-new-thread"
class="text-xs font-semibold"
tag="div"

View File

@ -0,0 +1,209 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { Editor } from "@tiptap/core"
import { enableAutoUnmount, type VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import * as Y from "yjs"
import DiffEditor from "./DiffEditor.vue"
import CommentIndicatorContainer from "../comments/CommentIndicatorContainer.vue"
import TextBubbleMenu from "../TextBubbleMenu.vue"
import BlockHandle from "../drag-handle/BlockHandle.vue"
import { stubThemeColorContext } from "../test-helpers/theme"
import { clearQueryCache, makeXid } from "~/composables/api/test-helpers"
import {
emitFrom,
stubViewportMatches,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
// the comment renderer drives its own tiptap instances and comment
// requests; the diff editor's contract with it is which of its actions
// it forwards
const childStubs = {
CommentRenderer: true,
}
function paragraph(text: string) {
const elem = new Y.XmlElement("paragraph")
elem.insert(0, [new Y.XmlText(text)])
return elem
}
function branchDoc(...texts: string[]): Y.Doc {
const ydoc = new Y.Doc()
ydoc.getXmlFragment("content").insert(0, texts.map(paragraph))
return ydoc
}
function mountDiff(target: Y.Doc, active: Y.Doc) {
return mountSuspended(DiffEditor, {
props: {
targetBranchYdoc: target,
activeBranchYdoc: active,
contentEditor: {} as unknown as Editor,
},
global: { stubs: childStubs },
})
}
function diffEditorOf(wrapper: VueWrapper) {
return wrapper.findComponent(TextBubbleMenu).props("editor") as {
commands: Record<string, (...args: unknown[]) => boolean>
}
}
// tiptap rebuilds editor.commands on every access, so a spy on one of
// them would never be the object the component calls
function watchCommands(editor: {
commands: Record<string, (...args: unknown[]) => boolean>
}): [string, unknown[]][] {
const called: [string, unknown[]][] = []
const real = editor.commands
const proxy = new Proxy(real, {
get(target, name: string | symbol) {
return (...args: unknown[]) => {
called.push([String(name), args])
const command = target[String(name)]
return command ? command(...args) : false
}
},
})
Object.defineProperty(editor, "commands", {
get: () => proxy,
configurable: true,
})
return called
}
// the editor store, the editable flag and the viewport stub are shared
// app-wide, so these tests cannot interleave
describe("<DiffEditor>", { concurrent: false }, () => {
enableAutoUnmount(afterEach)
beforeEach(() => {
stubThemeColorContext()
stubViewportMatches(true)
clearQueryCache()
useEditorMeta().setEditable(true)
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
useEditorStore().setReviewableDiffActive(true)
})
it("shows the content of both branches merged", async ({ expect }) => {
const wrapper = await mountDiff(
branchDoc("kept", "removed"),
branchDoc("kept", "added"),
)
await vi.waitFor(() => {
expect(wrapper.text()).toContain("kept")
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).toContain("removed")
expect(wrapper.text()).toContain("added")
})
it("shows nothing for two empty branches", async ({ expect }) => {
const wrapper = await mountDiff(new Y.Doc(), new Y.Doc())
await vi.waitFor(() => {
expect(wrapper.find(".ProseMirror").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.get(".ProseMirror").text()).toBe("")
})
it("renders the diff read-only", async ({ expect }) => {
const wrapper = await mountDiff(branchDoc("kept"), branchDoc("kept"))
await vi.waitFor(() => {
expect(wrapper.find(".ProseMirror").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.classes()).toContain("editor-not-editable")
})
it("offers a bubble menu limited to commenting", async ({ expect }) => {
const wrapper = await mountDiff(branchDoc("kept"), branchDoc("kept"))
await vi.waitFor(() => {
expect(wrapper.findComponent(TextBubbleMenu).exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(
wrapper.findComponent(TextBubbleMenu).props("diffContext"),
).toBeDefined()
})
it("offers the block handle over the merged content", async ({ expect }) => {
const wrapper = await mountDiff(branchDoc("kept"), branchDoc("kept"))
await vi.waitFor(() => {
expect(wrapper.findComponent(BlockHandle).exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
})
it("highlights the node a comment indicator points at", async ({
expect,
}) => {
const wrapper = await mountDiff(branchDoc("kept"), branchDoc("kept"))
await vi.waitFor(() => {
expect(wrapper.findComponent(TextBubbleMenu).exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
const called = watchCommands(diffEditorOf(wrapper))
emitFrom(
wrapper,
CommentIndicatorContainer,
"comment-hover-change",
"node",
"comment-1",
true,
)
await nextTick()
expect(called).toEqual([
["setNodeCommentForcedHighlight", ["comment-1", true]],
])
})
it("highlights the text a comment indicator points at", async ({
expect,
}) => {
const wrapper = await mountDiff(branchDoc("kept"), branchDoc("kept"))
await vi.waitFor(() => {
expect(wrapper.findComponent(TextBubbleMenu).exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
const called = watchCommands(diffEditorOf(wrapper))
emitFrom(
wrapper,
CommentIndicatorContainer,
"comment-hover-change",
"text",
"comment-1",
false,
)
await nextTick()
expect(called).toEqual([
["setCommentMarkForcedHighlight", ["comment-1", false]],
])
})
it("stops the diff once it is taken down", async ({ expect }) => {
const wrapper = await mountDiff(branchDoc("kept"), branchDoc("kept"))
await vi.waitFor(() => {
expect(wrapper.findComponent(TextBubbleMenu).exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
wrapper.unmount()
expect(wrapper.findComponent(TextBubbleMenu).exists()).toBe(false)
})
})

View File

@ -0,0 +1,153 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { describe, it, vi } from "vitest"
import * as Y from "yjs"
import DiffTitle from "./DiffTitle.vue"
import { WAIT_FOR_OPTIONS } from "~/components/test-helpers"
function titleDoc(text?: string): Y.Doc {
const ydoc = new Y.Doc()
if (text !== undefined) {
const paragraph = new Y.XmlElement("paragraph")
paragraph.insert(0, [new Y.XmlText(text)])
ydoc.getXmlFragment("name").insert(0, [paragraph])
}
return ydoc
}
function setTitle(ydoc: Y.Doc, text: string) {
const fragment = ydoc.getXmlFragment("name")
const paragraph = new Y.XmlElement("paragraph")
paragraph.insert(0, [new Y.XmlText(text)])
fragment.delete(0, fragment.length)
fragment.insert(0, [paragraph])
}
function mountTitle(target: Y.Doc, active: Y.Doc) {
return mountSuspended(DiffTitle, {
props: { targetBranchYdoc: target, activeBranchYdoc: active },
})
}
function added(wrapper: VueWrapper): string[] {
return wrapper.findAll(".diff-text-added").map((span) => span.text())
}
function removed(wrapper: VueWrapper): string[] {
return wrapper.findAll(".diff-text-removed").map((span) => span.text())
}
// the diff is debounced behind a shared timer, so these tests cannot
// interleave
describe("<DiffTitle>", { concurrent: false }, () => {
it("shows an unchanged title plainly", async ({ expect }) => {
const wrapper = await mountTitle(titleDoc("Runbook"), titleDoc("Runbook"))
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Runbook")
}, WAIT_FOR_OPTIONS)
expect(added(wrapper)).toEqual([])
expect(removed(wrapper)).toEqual([])
})
it("marks the word the draft added to the title", async ({ expect }) => {
const wrapper = await mountTitle(
titleDoc("Payments runbook"),
titleDoc("Payments runbook v2"),
)
await vi.waitFor(() => {
expect(added(wrapper)).toEqual(["v2"])
}, WAIT_FOR_OPTIONS)
expect(removed(wrapper)).toEqual([])
})
it("marks the word the draft removed from the title", async ({ expect }) => {
const wrapper = await mountTitle(
titleDoc("Payments runbook v2"),
titleDoc("Payments runbook"),
)
await vi.waitFor(() => {
expect(removed(wrapper)).toEqual(["v2"])
}, WAIT_FOR_OPTIONS)
expect(added(wrapper)).toEqual([])
})
it("marks a reworded title on both sides", async ({ expect }) => {
const wrapper = await mountTitle(titleDoc("Run"), titleDoc("Runbook"))
await vi.waitFor(() => {
expect(added(wrapper)).toEqual(["Runbook"])
}, WAIT_FOR_OPTIONS)
expect(removed(wrapper)).toEqual(["Run"])
})
it("shows nothing for two empty titles", async ({ expect }) => {
const wrapper = await mountTitle(titleDoc(), titleDoc())
await vi.waitFor(() => {
expect(wrapper.find(".ProseMirror").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.text()).toBe("")
})
it("renders the title read-only and without spellchecking", async ({
expect,
}) => {
const wrapper = await mountTitle(titleDoc("Runbook"), titleDoc("Runbook"))
await vi.waitFor(() => {
expect(wrapper.find(".ProseMirror").exists()).toBe(true)
}, WAIT_FOR_OPTIONS)
expect(wrapper.get(".ProseMirror").attributes("contenteditable")).toBe(
"false",
)
expect(wrapper.get(".ProseMirror").attributes("spellcheck")).toBe("false")
})
it("follows a change made to the draft title", async ({ expect }) => {
const active = titleDoc("Runbook")
const wrapper = await mountTitle(titleDoc("Runbook"), active)
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Runbook")
}, WAIT_FOR_OPTIONS)
setTitle(active, "Runbook v2")
await vi.waitFor(() => {
expect(added(wrapper)).toEqual(["v2"])
}, WAIT_FOR_OPTIONS)
})
it("follows a change made to the main title", async ({ expect }) => {
const target = titleDoc("Runbook")
const wrapper = await mountTitle(target, titleDoc("Runbook"))
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Runbook")
}, WAIT_FOR_OPTIONS)
setTitle(target, "Runbook v2")
await vi.waitFor(() => {
expect(removed(wrapper)).toEqual(["v2"])
}, WAIT_FOR_OPTIONS)
})
// a change made after the title is gone must not reach the torn-down
// editor; the document itself still takes it
it("stops following the documents once it is gone", async ({ expect }) => {
const active = titleDoc("Runbook")
const wrapper = await mountTitle(titleDoc("Runbook"), active)
await vi.waitFor(() => {
expect(wrapper.text()).toContain("Runbook")
}, WAIT_FOR_OPTIONS)
wrapper.unmount()
setTitle(active, "Runbook v2")
expect(active.getXmlFragment("name").length).toBe(1)
})
})

View File

@ -0,0 +1,362 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { Node as PMNode } from "@tiptap/pm/model"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it, vi } from "vitest"
import BlockHandle from "./BlockHandle.vue"
import { DragHandle } from "./MainElement"
import CoreMenu from "./menu-options/CoreMenu.vue"
import { makeEditor } from "../test-helpers/node-view"
import {
clearTeleportedOverlays,
menuItem,
renderedIconNames,
stubViewportMatches,
t,
} from "~/components/test-helpers"
const MENU_CLOSE_ANIMATION_MS = 150
function hook(blockId: string, score: number): DocumentHook {
return { blockId: blockId, score: score } as unknown as DocumentHook
}
// the editor the handle reads the hovered node through
function makeHandleEditor(
node: Record<string, unknown> | null = {
attrs: { uid: "block-1" },
type: { name: "paragraph" },
nodeSize: 4,
toJSON: () => ({ type: "paragraph", attrs: { uid: "block-1" } }),
},
) {
const nodeElement = document.createElement("div")
return makeEditor({
registerPlugin: vi.fn(),
unregisterPlugin: vi.fn(),
commands: {
setMeta: vi.fn(),
hasNodeComment: () => false,
focus: vi.fn(),
},
state: {
doc: {
nodeAt: () => node,
resolve: () => ({
depth: 0,
node: () => ({ type: { name: "doc" } }),
}),
},
tr: { delete: () => ({ doc: { forEach: () => undefined } }) },
schema: { nodes: {} },
},
view: { nodeDOM: () => nodeElement, dispatch: vi.fn() },
})
}
function mountHandle(props: Record<string, unknown> = {}) {
return mountSuspended(BlockHandle, {
props: { editor: makeHandleEditor().editor, ...props },
})
}
// the drag handle reports the block the pointer is over through the
// callback it was handed, not through a dom event
function hoverNode(
wrapper: VueWrapper,
pos: number,
node: unknown = { type: { name: "paragraph" } },
) {
const onNodeChange = wrapper
.findComponent(DragHandle)
.props("onNodeChange") as (data: {
pos: number
node: PMNode | null
}) => void
onNodeChange({ pos: pos, node: node as PMNode | null })
}
function handleRoot(wrapper: VueWrapper) {
return wrapper.get(".z-drag-handle")
}
function dragOverlays(): NodeListOf<Element> {
return document.body.querySelectorAll("[aria-hidden='true'].z-editor-overlay")
}
function grabbingCursor(): boolean {
return document.documentElement.classList.contains("cursor-grabbing!")
}
async function openBlockMenu(wrapper: VueWrapper) {
const triggers = wrapper.findAll("[data-slot='dropdown-menu-trigger']")
const trigger = triggers[triggers.length - 1]
await trigger?.trigger("pointerdown", { button: 0 })
await trigger?.trigger("click")
await nextTick()
}
// the editable flag is a shared cookie state, the editor store, the
// viewport stub, the document element's classes and the teleported menu
// bodies are all shared, so these tests cannot interleave
describe("<BlockHandle>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
stubViewportMatches(true)
useEditorMeta().setEditable(true)
useEditorMeta().updateLock(false)
useEditorStore().setReviewableDiffActive(false)
document.documentElement.className = ""
})
it("offers a hook handle and a block handle on a wide screen", async ({
expect,
}) => {
const wrapper = await mountHandle()
expect(wrapper.findAll("[data-slot='dropdown-menu-trigger']")).toHaveLength(
2,
)
expect(wrapper.text()).toContain(t("editor.hook-handle.screen-reader-hint"))
expect(wrapper.text()).toContain(t("editor.drag-handle.screen-reader-hint"))
})
it("folds the hook handle away on a narrow screen", async ({ expect }) => {
stubViewportMatches(false)
const wrapper = await mountHandle()
expect(wrapper.findAll("[data-slot='dropdown-menu-trigger']")).toHaveLength(
1,
)
})
it("offers no hook handle in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountHandle()
expect(wrapper.findAll("[data-slot='dropdown-menu-trigger']")).toHaveLength(
1,
)
})
it("leaves the hook icon unmarked for a block with no hooks", async ({
expect,
}) => {
const wrapper = await mountHandle()
hoverNode(wrapper, 10)
await nextTick()
expect(
wrapper.get(".i-mingcute\\:leaf-line").attributes("data-hook-status"),
).toBeUndefined()
})
it("marks the hook icon fresh for a block with healthy hooks", async ({
expect,
}) => {
const wrapper = await mountHandle({
documentHooks: [hook("block-1", 1)],
})
hoverNode(wrapper, 10)
await nextTick()
expect(
wrapper.get(".i-mingcute\\:leaf-line").attributes("data-hook-status"),
).toBe("fresh")
})
it("marks the hook icon stale when a hook has run out", async ({
expect,
}) => {
const wrapper = await mountHandle({
documentHooks: [hook("block-1", 1), hook("block-1", 0)],
})
hoverNode(wrapper, 10)
await nextTick()
expect(
wrapper.get(".i-mingcute\\:leaf-line").attributes("data-hook-status"),
).toBe("stale")
})
it("ignores hooks belonging to other blocks", async ({ expect }) => {
const wrapper = await mountHandle({
documentHooks: [hook("block-2", 0)],
})
hoverNode(wrapper, 10)
await nextTick()
expect(
wrapper.get(".i-mingcute\\:leaf-line").attributes("data-hook-status"),
).toBeUndefined()
})
it("hands the hovered block to the block menu", async ({ expect }) => {
const wrapper = await mountHandle()
hoverNode(wrapper, 10)
await nextTick()
await openBlockMenu(wrapper)
expect(wrapper.findComponent(CoreMenu).props("hovered")).toEqual(
expect.objectContaining({ nodePos: 10, nodeId: "block-1" }),
)
})
it("forgets the hovered block when the pointer leaves the document", async ({
expect,
}) => {
const wrapper = await mountHandle()
hoverNode(wrapper, 10)
await nextTick()
await openBlockMenu(wrapper)
hoverNode(wrapper, 10, null)
await nextTick()
expect(wrapper.findComponent(CoreMenu).props("hovered")).toBeNull()
})
it("forgets a block that is no longer in the document", async ({
expect,
}) => {
const wrapper = await mountHandle({
editor: makeHandleEditor(null).editor,
})
hoverNode(wrapper, 10)
await nextTick()
await openBlockMenu(wrapper)
expect(wrapper.findComponent(CoreMenu).props("hovered")).toBeNull()
})
it("highlights the block while the pointer is on the handle", async ({
expect,
}) => {
const wrapper = await mountHandle()
hoverNode(wrapper, 10)
await nextTick()
await handleRoot(wrapper).trigger("mouseenter")
expect(dragOverlays()).toHaveLength(1)
})
it("drops the highlight when the pointer leaves the handle", async ({
expect,
}) => {
const wrapper = await mountHandle()
hoverNode(wrapper, 10)
await nextTick()
await handleRoot(wrapper).trigger("mouseenter")
await handleRoot(wrapper).trigger("mouseleave")
expect(dragOverlays()).toHaveLength(0)
})
it("highlights nothing while no block is hovered", async ({ expect }) => {
const wrapper = await mountHandle()
await handleRoot(wrapper).trigger("mouseenter")
expect(dragOverlays()).toHaveLength(0)
})
it("locks the editor while the block menu is open", async ({ expect }) => {
const wrapper = await mountHandle()
await openBlockMenu(wrapper)
expect(useEditorMeta().isLocked.value).toBe(true)
})
it("unlocks the editor once the menu has finished closing", async ({
expect,
}) => {
const wrapper = await mountHandle()
await openBlockMenu(wrapper)
vi.useFakeTimers()
await openBlockMenu(wrapper)
await vi.advanceTimersByTimeAsync(MENU_CLOSE_ANIMATION_MS)
expect(useEditorMeta().isLocked.value).toBe(false)
})
it("keeps the block menu's own actions reachable", async ({ expect }) => {
const wrapper = await mountHandle()
hoverNode(wrapper, 10)
await nextTick()
await openBlockMenu(wrapper)
expect(
menuItem(t("editor.drag-handle.options.default.duplicate-block")),
).toBeDefined()
})
it("folds the hook options into the block menu on a narrow screen", async ({
expect,
}) => {
stubViewportMatches(false)
const wrapper = await mountHandle()
await openBlockMenu(wrapper)
expect(document.body.textContent).toContain(t("editor.hooks.add-new"))
})
it("shows a grabbing cursor while a block is dragged", async ({ expect }) => {
const wrapper = await mountHandle()
await handleRoot(wrapper).trigger("dragstart")
expect(grabbingCursor()).toBe(true)
})
it("puts the cursor back once the drag ends", async ({ expect }) => {
const wrapper = await mountHandle()
await handleRoot(wrapper).trigger("dragstart")
await handleRoot(wrapper).trigger("dragend")
expect(grabbingCursor()).toBe(false)
})
it("refuses to start a drag in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
const wrapper = await mountHandle()
const event = new Event("dragstart", { cancelable: true, bubbles: true })
handleRoot(wrapper).element.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
expect(grabbingCursor()).toBe(false)
})
it("passes a comment request from the block menu on", async ({ expect }) => {
const wrapper = await mountHandle()
hoverNode(wrapper, 10)
await nextTick()
await openBlockMenu(wrapper)
menuItem(t("editor.drag-handle.options.add-node-comment")).click()
await nextTick()
expect(wrapper.emitted("add-node-comment")).toEqual([[10]])
})
it("shows the icons for both handles", async ({ expect }) => {
const wrapper = await mountHandle()
expect(renderedIconNames(wrapper)).toEqual([
"mingcute:leaf-line",
"mingcute:dots-line",
])
})
})

View File

@ -0,0 +1,449 @@
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import CoreMenu from "./CoreMenu.vue"
import {
hoveredBlock,
mountMenuOptions,
type HoveredBlock,
} from "./test-helpers"
import {
commandArgs,
commandNames,
makeEditor,
} from "../../test-helpers/node-view"
import { METRIC_BLOCK_NAME } from "../../blocks/node-names"
import { SUPPRESS_SCROLL_TO_SELECTION_META } from "../../scroll-control"
import { clearTeleportedOverlays, menuItem, t } from "~/components/test-helpers"
vi.mock("vue-sonner", () => ({
toast: {
custom: vi.fn(),
dismiss: vi.fn(),
},
}))
const COPY_LINK = "editor.drag-handle.options.copy-link"
const DUPLICATE = "editor.drag-handle.options.default.duplicate-block"
const ADD_COMMENT = "editor.drag-handle.options.add-node-comment"
const OPEN_COMMENT = "editor.drag-handle.options.open-node-comment"
const DELETE = "editor.drag-handle.options.delete-block"
// the editor the menu reads the document through, plus the transaction
// and view calls a deletion goes through
function makeMenuEditor(
options: {
nodeAtPos?: {
typeName?: string
nodeSize?: number
attrs?: Record<string, unknown>
json?: Record<string, unknown>
} | null
ancestorTypeNames?: string[]
hasNodeComment?: boolean
} = {},
) {
const node =
options.nodeAtPos === null
? null
: {
type: { name: options.nodeAtPos?.typeName ?? "paragraph" },
nodeSize: options.nodeAtPos?.nodeSize ?? 6,
attrs: options.nodeAtPos?.attrs ?? { uid: "block-1" },
toJSON: () =>
options.nodeAtPos?.json ?? {
type: options.nodeAtPos?.typeName ?? "paragraph",
attrs: options.nodeAtPos?.attrs ?? { uid: "block-1" },
},
}
const ancestors = options.ancestorTypeNames ?? []
const deletedRanges: [number, number][] = []
const transaction = {
delete: (from: number, to: number) => {
deletedRanges.push([from, to])
return transaction
},
doc: { forEach: () => undefined },
}
const dispatch = vi.fn()
const focus = vi.fn()
const hasNodeComment = vi.fn(() => options.hasNodeComment ?? false)
const { editor, commands } = makeEditor({
state: {
doc: {
nodeAt: () => node,
resolve: () => ({
depth: ancestors.length,
node: (depth: number) => ({
type: { name: ancestors[depth - 1] ?? "doc" },
}),
}),
nodesBetween: () => undefined,
},
tr: transaction,
schema: { nodes: {} },
},
view: { dispatch: dispatch },
commands: { hasNodeComment: hasNodeComment, focus: focus },
})
return { editor, commands, dispatch, focus, deletedRanges, hasNodeComment }
}
function mountCoreMenu(
editor: ReturnType<typeof makeMenuEditor>["editor"],
hovered: HoveredBlock | null,
dataSyncProvider: unknown = null,
) {
return mountMenuOptions(CoreMenu, {
editor: editor,
hovered: hovered,
dataSyncProvider: dataSyncProvider,
})
}
function block(typeName: string, attrs: Record<string, unknown> = {}) {
return hoveredBlock(10, { typeName: typeName, attrs: attrs })
}
function menuLabels(): string[] {
return Array.from(
document.body.querySelectorAll<HTMLElement>("[role^='menuitem']"),
).map((item) => item.textContent.trim())
}
// the editable flag is a shared cookie state, the editor store and the
// mocked toast module are app-wide, and the menu body is teleported into
// a shared <body>, so these tests cannot interleave
describe("<CoreMenu>", { concurrent: false }, () => {
beforeEach(() => {
vi.mocked(toast.custom).mockReset()
useEditorMeta().setEditable(true)
useEditorStore().setReviewableDiffActive(false)
useEditorStore().setBranchReviewableActionsActive(false)
})
afterEach(clearTeleportedOverlays)
it("offers the block actions for an ordinary block", async ({ expect }) => {
const { editor } = makeMenuEditor()
await mountCoreMenu(editor, block("paragraph", { uid: "block-1" }))
expect(menuLabels()).toEqual([
t("editor.drag-handle.options.default.add-above-block"),
t("editor.drag-handle.options.default.add-below-block"),
t(COPY_LINK),
t(DUPLICATE),
t(ADD_COMMENT),
t(DELETE),
])
})
it("offers only the comment action in read mode", async ({ expect }) => {
useEditorMeta().setEditable(false)
useEditorStore().setBranchReviewableActionsActive(true)
const { editor } = makeMenuEditor()
await mountCoreMenu(editor, block("paragraph", { uid: "block-1" }))
expect(menuLabels()).toEqual([t(COPY_LINK), t(ADD_COMMENT)])
})
it("offers no comment action while comments are not allowed", async ({
expect,
}) => {
useEditorMeta().setEditable(false)
const { editor } = makeMenuEditor()
await mountCoreMenu(editor, block("paragraph", { uid: "block-1" }))
expect(menuLabels()).toEqual([t(COPY_LINK)])
})
it("offers the split documentation actions for one of those blocks", async ({
expect,
}) => {
const { editor } = makeMenuEditor()
await mountCoreMenu(editor, block("splitDocumentation"))
expect(menuLabels()).toContain(
t("editor.drag-handle.options.split-doc.add-parameter-list"),
)
expect(menuLabels()).toContain(
t("editor.drag-handle.options.split-doc.invert-sides"),
)
expect(menuLabels()).toContain(
t("editor.drag-handle.options.default.add-above-block"),
)
})
it("offers the right-side actions for a block inside one", async ({
expect,
}) => {
const { editor } = makeMenuEditor({
ancestorTypeNames: ["splitDocumentationRightSide"],
})
await mountCoreMenu(editor, block("codeBlock"))
expect(menuLabels()).toContain(
t(
"editor.drag-handle.options.split-doc-right-side.add-code-block-above-block",
),
)
expect(menuLabels()).not.toContain(
t("editor.drag-handle.options.default.add-above-block"),
)
})
it("offers the parameter list actions for a parameter list", async ({
expect,
}) => {
const { editor } = makeMenuEditor()
await mountCoreMenu(editor, block("splitDocumentationParameterList"))
expect(menuLabels()).toContain(
t("editor.drag-handle.options.split-doc-parameter-list.add-above-block"),
)
})
it("offers the parameter actions for a parameter", async ({ expect }) => {
const { editor } = makeMenuEditor()
await mountCoreMenu(editor, block("splitDocumentationParameterListItem"))
expect(menuLabels()).toContain(
t(
"editor.drag-handle.options.split-doc-parameter-list-item.add-above-block",
),
)
})
it("offers the resize action for a metric block", async ({ expect }) => {
const { editor } = makeMenuEditor()
await mountCoreMenu(editor, block(METRIC_BLOCK_NAME))
expect(menuLabels()).toContain(
t("editor.drag-handle.options.metric-block.width"),
)
expect(menuLabels()).toContain(
t("editor.drag-handle.options.default.add-above-block"),
)
})
it("offers no link for a block without an id", async ({ expect }) => {
const { editor } = makeMenuEditor({ nodeAtPos: { attrs: {} } })
await mountCoreMenu(editor, block("paragraph"))
expect(menuLabels()).not.toContain(t(COPY_LINK))
})
it("copies a link to the hovered block", async ({ expect }) => {
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined)
const { editor } = makeMenuEditor()
await mountCoreMenu(editor, block("paragraph", { uid: "block-1" }))
menuItem(t(COPY_LINK)).click()
expect(writeText).toHaveBeenCalledTimes(1)
expect(writeText.mock.calls[0]?.[0]).toContain("#block-1")
})
it("duplicates the hovered block right after it", async ({ expect }) => {
const { editor, commands } = makeMenuEditor()
await mountCoreMenu(editor, block("paragraph", { uid: "block-1" }))
menuItem(t(DUPLICATE)).click()
expect(commandNames(commands)).toEqual(["focus", "insertContentAt", "run"])
expect(commandArgs(commands, "insertContentAt")).toEqual([
16,
{ type: "paragraph", attrs: { uid: null } },
])
})
it("strips the comment marks off a duplicated block", async ({ expect }) => {
const { editor, commands } = makeMenuEditor({
nodeAtPos: {
json: {
type: "paragraph",
attrs: { uid: "block-1", nodeCommentId: "c-1" },
content: [
{
type: "text",
text: "hi",
marks: [{ type: "comment" }, { type: "bold" }],
},
],
},
},
})
await mountCoreMenu(editor, block("paragraph", { uid: "block-1" }))
menuItem(t(DUPLICATE)).click()
expect(commandArgs(commands, "insertContentAt")?.[1]).toEqual({
type: "paragraph",
attrs: { uid: null, nodeCommentId: null },
content: [{ type: "text", text: "hi", marks: [{ type: "bold" }] }],
})
})
it("duplicates a metric block without scrolling the selection", async ({
expect,
}) => {
const { editor, commands } = makeMenuEditor({
nodeAtPos: { typeName: METRIC_BLOCK_NAME, attrs: { uid: "block-1" } },
})
await mountCoreMenu(editor, block(METRIC_BLOCK_NAME, { uid: "block-1" }))
menuItem(t(DUPLICATE)).click()
expect(commandNames(commands)).toEqual([
"setMeta",
"focus",
"insertContentAt",
"run",
])
expect(commandArgs(commands, "setMeta")).toEqual([
SUPPRESS_SCROLL_TO_SELECTION_META,
true,
])
})
it("duplicates nothing when the block is no longer there", async ({
expect,
}) => {
const { editor, commands } = makeMenuEditor({ nodeAtPos: null })
await mountCoreMenu(editor, block("paragraph", { uid: "block-1" }))
menuItem(t(DUPLICATE)).click()
expect(commands).toEqual([])
})
it("asks to add a comment on the hovered block", async ({ expect }) => {
const { editor } = makeMenuEditor()
const wrapper = await mountCoreMenu(
editor,
block("paragraph", { uid: "block-1" }),
)
menuItem(t(ADD_COMMENT)).click()
await nextTick()
expect(wrapper.findComponent(CoreMenu).emitted("add-node-comment")).toEqual(
[[10]],
)
})
it("asks to open the comment a block already carries", async ({ expect }) => {
const { editor } = makeMenuEditor({ hasNodeComment: true })
const wrapper = await mountCoreMenu(
editor,
block("paragraph", { uid: "block-1" }),
)
expect(menuLabels()).toContain(t(OPEN_COMMENT))
expect(menuLabels()).not.toContain(t(ADD_COMMENT))
menuItem(t(OPEN_COMMENT)).click()
await nextTick()
expect(
wrapper.findComponent(CoreMenu).emitted("open-node-comment"),
).toEqual([[10]])
})
it("deletes the hovered block", async ({ expect }) => {
const { editor, dispatch, focus, deletedRanges } = makeMenuEditor()
await mountCoreMenu(editor, block("paragraph", { uid: "block-1" }))
menuItem(t(DELETE)).click()
await nextTick()
expect(deletedRanges).toEqual([[10, 16]])
expect(dispatch).toHaveBeenCalledTimes(1)
expect(focus).toHaveBeenCalledWith(undefined, { scrollIntoView: false })
expect(toast.custom).toHaveBeenCalledTimes(0)
})
it("deletes nothing when the block is no longer there", async ({
expect,
}) => {
const { editor, dispatch, deletedRanges } = makeMenuEditor({
nodeAtPos: null,
})
await mountCoreMenu(editor, block("paragraph", { uid: "block-1" }))
menuItem(t(DELETE)).click()
await nextTick()
expect(deletedRanges).toEqual([])
expect(dispatch).toHaveBeenCalledTimes(0)
})
it("refuses to delete a block someone else is editing", async ({
expect,
}) => {
const { editor, dispatch, deletedRanges } = makeMenuEditor()
const provider = {
awareness: {
clientID: 1,
getStates: () =>
new Map<number, Record<string, unknown>>([
[2, { editingNodeUid: { uid: "block-1" } }],
]),
},
}
await mountCoreMenu(
editor,
block("paragraph", { uid: "block-1" }),
provider,
)
menuItem(t(DELETE)).click()
await nextTick()
expect(deletedRanges).toEqual([])
expect(dispatch).toHaveBeenCalledTimes(0)
expect(toast.custom).toHaveBeenCalledTimes(1)
})
it("keeps the last hovered block's actions while the menu closes", async ({
expect,
}) => {
const { editor } = makeMenuEditor()
const hovered = shallowRef<HoveredBlock | null>(block("splitDocumentation"))
await mountMenuOptions(CoreMenu, () => ({
editor: editor,
hovered: hovered.value,
dataSyncProvider: null,
}))
hovered.value = null
await nextTick()
expect(menuLabels()).toContain(
t("editor.drag-handle.options.split-doc.invert-sides"),
)
})
it("shows only the block-agnostic actions before anything is hovered", async ({
expect,
}) => {
const { editor } = makeMenuEditor()
await mountCoreMenu(editor, null)
expect(menuLabels()).toEqual([t(DUPLICATE), t(ADD_COMMENT), t(DELETE)])
})
})

View File

@ -0,0 +1,185 @@
import { afterEach, describe, it } from "vitest"
import DefaultOptions from "./DefaultOptions.vue"
import { hoveredBlock, mountMenuOptions } from "./test-helpers"
import {
commandArgs,
commandNames,
makeEditor,
} from "../../test-helpers/node-view"
import { METRIC_BLOCK_NAME, METRIC_GRID_NAME } from "../../blocks/node-names"
import { clearTeleportedOverlays, menuItem, t } from "~/components/test-helpers"
const ADD_ABOVE = "editor.drag-handle.options.default.add-above-block"
const ADD_BELOW = "editor.drag-handle.options.default.add-below-block"
// the editor a neighbour insert reads the document through
function makeDocEditor(
node: { typeName: string; nodeSize: number } | null,
gridBounds: { before: number; after: number } | null = null,
) {
return makeEditor({
state: {
doc: {
nodeAt: () =>
node
? { type: { name: node.typeName }, nodeSize: node.nodeSize }
: null,
resolve: () => ({
depth: gridBounds ? 1 : 0,
node: () => ({ type: { name: METRIC_GRID_NAME } }),
before: () => gridBounds?.before ?? 0,
after: () => gridBounds?.after ?? 0,
}),
},
},
})
}
// the menu body is teleported into a shared <body>, so these tests
// cannot interleave
describe("<DefaultOptions>", { concurrent: false }, () => {
afterEach(clearTeleportedOverlays)
it("offers to add a block on either side", async ({ expect }) => {
const { editor } = makeDocEditor({ typeName: "paragraph", nodeSize: 6 })
await mountMenuOptions(DefaultOptions, {
editor: editor,
hovered: hoveredBlock(10),
})
expect(menuItem(t(ADD_ABOVE))).toBeDefined()
expect(menuItem(t(ADD_BELOW))).toBeDefined()
})
it("adds an empty slash paragraph above the hovered block", async ({
expect,
}) => {
const { editor, commands } = makeDocEditor({
typeName: "paragraph",
nodeSize: 6,
})
await mountMenuOptions(DefaultOptions, {
editor: editor,
hovered: hoveredBlock(10),
})
menuItem(t(ADD_ABOVE)).click()
expect(commandNames(commands)).toEqual([
"focus",
"insertContentAt",
"setTextSelection",
"run",
])
expect(commandArgs(commands, "insertContentAt")).toEqual([
10,
{ type: "paragraph", content: [{ type: "text", text: "/" }] },
])
expect(commandArgs(commands, "setTextSelection")).toEqual([12])
})
it("adds the new block after the hovered one when asked", async ({
expect,
}) => {
const { editor, commands } = makeDocEditor({
typeName: "paragraph",
nodeSize: 6,
})
await mountMenuOptions(DefaultOptions, {
editor: editor,
hovered: hoveredBlock(10),
})
menuItem(t(ADD_BELOW)).click()
expect(commandArgs(commands, "insertContentAt")?.[0]).toBe(16)
})
it("adds a sibling list item next to a list item", async ({ expect }) => {
const { editor, commands } = makeDocEditor({
typeName: "listItem",
nodeSize: 4,
})
await mountMenuOptions(DefaultOptions, {
editor: editor,
hovered: hoveredBlock(10),
})
menuItem(t(ADD_ABOVE)).click()
expect(commandArgs(commands, "insertContentAt")).toEqual([
10,
{ type: "listItem", content: [{ type: "paragraph", content: [] }] },
])
expect(commandArgs(commands, "setTextSelection")).toEqual([11])
})
it("adds an unchecked sibling next to a task item", async ({ expect }) => {
const { editor, commands } = makeDocEditor({
typeName: "taskItem",
nodeSize: 4,
})
await mountMenuOptions(DefaultOptions, {
editor: editor,
hovered: hoveredBlock(10),
})
menuItem(t(ADD_ABOVE)).click()
expect(commandArgs(commands, "insertContentAt")).toEqual([
10,
{
type: "taskItem",
attrs: { checked: false },
content: [{ type: "paragraph", content: [] }],
},
])
})
it("adds the block outside the grid a metric block sits in", async ({
expect,
}) => {
const { editor, commands } = makeDocEditor(
{ typeName: METRIC_BLOCK_NAME, nodeSize: 2 },
{ before: 4, after: 20 },
)
await mountMenuOptions(DefaultOptions, {
editor: editor,
hovered: hoveredBlock(10),
})
menuItem(t(ADD_BELOW)).click()
expect(commandArgs(commands, "insertContentAt")?.[0]).toBe(20)
})
it("adds nothing when the hovered block is no longer there", async ({
expect,
}) => {
const { editor, commands } = makeDocEditor(null)
await mountMenuOptions(DefaultOptions, {
editor: editor,
hovered: hoveredBlock(10),
})
menuItem(t(ADD_ABOVE)).click()
expect(commands).toEqual([])
})
it("does nothing while no block is hovered", async ({ expect }) => {
const { editor, commands } = makeDocEditor({
typeName: "paragraph",
nodeSize: 6,
})
await mountMenuOptions(DefaultOptions, {
editor: editor,
hovered: null,
})
menuItem(t(ADD_ABOVE)).click()
expect(commands).toEqual([])
})
})

View File

@ -0,0 +1,174 @@
import { afterEach, describe, it, vi } from "vitest"
import MetricBlockMenu from "./MetricBlockMenu.vue"
import { hoveredBlock, mountMenuOptions } from "./test-helpers"
import { makeEditor } from "../../test-helpers/node-view"
import { MetricBlockWidth } from "../../blocks/metrics/utils"
import { clearTeleportedOverlays, menuItem, t } from "~/components/test-helpers"
// the editor a resize goes through: the transaction it builds records
// which node it re-marked and with what
function makeResizeEditor(
nodeAtPos: { attrs: Record<string, unknown> } | null,
) {
const setNodeMarkup = vi.fn(
(pos: number, _type: unknown, attrs: Record<string, unknown>) => ({
pos: pos,
attrs: attrs,
}),
)
const dispatch = vi.fn()
const { editor } = makeEditor({
state: {
doc: { nodeAt: () => nodeAtPos },
tr: { setNodeMarkup: setNodeMarkup },
},
view: { dispatch: dispatch },
})
return { editor, setNodeMarkup, dispatch }
}
async function openSizeMenu() {
const trigger = menuItem(t("editor.drag-handle.options.metric-block.width"))
trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))
trigger.click()
await nextTick()
await nextTick()
}
// the menu bodies are teleported into a shared <body>, so these tests
// cannot interleave
describe("<MetricBlockMenu>", { concurrent: false }, () => {
afterEach(clearTeleportedOverlays)
it("offers to resize the block", async ({ expect }) => {
const { editor } = makeResizeEditor({ attrs: {} })
await mountMenuOptions(MetricBlockMenu, {
editor: editor,
hovered: hoveredBlock(5),
})
expect(
menuItem(t("editor.drag-handle.options.metric-block.width")),
).toBeDefined()
})
it("offers the three block widths", async ({ expect }) => {
const { editor } = makeResizeEditor({ attrs: {} })
await mountMenuOptions(MetricBlockMenu, {
editor: editor,
hovered: hoveredBlock(5),
})
await openSizeMenu()
expect(
menuItem(
t("editor.drag-handle.options.metric-block.width-options.compact"),
),
).toBeDefined()
expect(
menuItem(
t("editor.drag-handle.options.metric-block.width-options.standard"),
),
).toBeDefined()
expect(
menuItem(t("editor.drag-handle.options.metric-block.width-options.wide")),
).toBeDefined()
})
it("marks the width the block already has", async ({ expect }) => {
const { editor } = makeResizeEditor({ attrs: {} })
await mountMenuOptions(MetricBlockMenu, {
editor: editor,
hovered: hoveredBlock(5, { attrs: { width: MetricBlockWidth.Wide } }),
})
await openSizeMenu()
expect(
menuItem(
t("editor.drag-handle.options.metric-block.width-options.wide"),
).querySelector(".i-lucide\\:check"),
).not.toBeNull()
expect(
menuItem(
t("editor.drag-handle.options.metric-block.width-options.compact"),
).querySelector(".i-lucide\\:check"),
).toBeNull()
})
it("treats a block with no stored width as a standard one", async ({
expect,
}) => {
const { editor } = makeResizeEditor({ attrs: {} })
await mountMenuOptions(MetricBlockMenu, {
editor: editor,
hovered: hoveredBlock(5),
})
await openSizeMenu()
expect(
menuItem(
t("editor.drag-handle.options.metric-block.width-options.standard"),
).querySelector(".i-lucide\\:check"),
).not.toBeNull()
})
it("resizes the block to the width that was picked", async ({ expect }) => {
const { editor, setNodeMarkup, dispatch } = makeResizeEditor({
attrs: { uid: "metric-1", width: MetricBlockWidth.Standard },
})
await mountMenuOptions(MetricBlockMenu, {
editor: editor,
hovered: hoveredBlock(5),
})
await openSizeMenu()
menuItem(
t("editor.drag-handle.options.metric-block.width-options.compact"),
).click()
expect(setNodeMarkup).toHaveBeenCalledTimes(1)
expect(setNodeMarkup).toHaveBeenCalledWith(5, null, {
uid: "metric-1",
width: MetricBlockWidth.Compact,
})
expect(dispatch).toHaveBeenCalledTimes(1)
})
it("resizes nothing when the block is no longer there", async ({
expect,
}) => {
const { editor, setNodeMarkup, dispatch } = makeResizeEditor(null)
await mountMenuOptions(MetricBlockMenu, {
editor: editor,
hovered: hoveredBlock(5),
})
await openSizeMenu()
menuItem(
t("editor.drag-handle.options.metric-block.width-options.compact"),
).click()
expect(setNodeMarkup).toHaveBeenCalledTimes(0)
expect(dispatch).toHaveBeenCalledTimes(0)
})
it("resizes nothing while no block is hovered", async ({ expect }) => {
const { editor, dispatch } = makeResizeEditor({ attrs: {} })
await mountMenuOptions(MetricBlockMenu, {
editor: editor,
hovered: null,
})
await openSizeMenu()
menuItem(
t("editor.drag-handle.options.metric-block.width-options.compact"),
).click()
expect(dispatch).toHaveBeenCalledTimes(0)
})
})

View File

@ -0,0 +1,128 @@
import { afterEach, describe, it } from "vitest"
import SplitDocMenu from "./SplitDocMenu.vue"
import { hoveredBlock, mountMenuOptions } from "./test-helpers"
import {
commandArgs,
commandNames,
makeEditor,
} from "../../test-helpers/node-view"
import { SUPPRESS_SCROLL_TO_SELECTION_META } from "../../scroll-control"
import { SPLIT_DOCUMENTATION_LEFT_SIDE_NAME } from "../../blocks/node-names"
import { clearTeleportedOverlays, menuItem, t } from "~/components/test-helpers"
const ADD_PARAMETERS = "editor.drag-handle.options.split-doc.add-parameter-list"
const INVERT = "editor.drag-handle.options.split-doc.invert-sides"
// a split documentation block holds a left and a right side; the left one
// is where a parameter list goes
function splitDocBlock(nodePos: number, leftSideFirst = true) {
return hoveredBlock(nodePos, {
children: leftSideFirst
? [
{ typeName: SPLIT_DOCUMENTATION_LEFT_SIDE_NAME, nodeSize: 4 },
{ typeName: "splitDocumentationRightSide", nodeSize: 6 },
]
: [
{ typeName: "splitDocumentationRightSide", nodeSize: 6 },
{ typeName: SPLIT_DOCUMENTATION_LEFT_SIDE_NAME, nodeSize: 4 },
],
})
}
// the menu body is teleported into a shared <body>, so these tests
// cannot interleave
describe("<SplitDocMenu>", { concurrent: false }, () => {
afterEach(clearTeleportedOverlays)
it("offers to add parameters and to swap the sides", async ({ expect }) => {
const { editor } = makeEditor()
await mountMenuOptions(SplitDocMenu, {
editor: editor,
hovered: splitDocBlock(2),
})
expect(menuItem(t(ADD_PARAMETERS))).toBeDefined()
expect(menuItem(t(INVERT))).toBeDefined()
})
it("appends a parameter list to the left side", async ({ expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocMenu, {
editor: editor,
hovered: splitDocBlock(2),
})
menuItem(t(ADD_PARAMETERS)).click()
expect(commandNames(commands)).toEqual([
"focus",
"appendParameterListOnLeftSide",
"run",
])
expect(commandArgs(commands, "appendParameterListOnLeftSide")).toEqual([3])
})
it("finds the left side wherever it sits in the block", async ({
expect,
}) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocMenu, {
editor: editor,
hovered: splitDocBlock(2, false),
})
menuItem(t(ADD_PARAMETERS)).click()
expect(commandArgs(commands, "appendParameterListOnLeftSide")).toEqual([9])
})
it("adds nothing to a block with no left side", async ({ expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocMenu, {
editor: editor,
hovered: hoveredBlock(2),
})
menuItem(t(ADD_PARAMETERS)).click()
expect(commands).toEqual([])
})
it("swaps the sides without scrolling the selection into view", async ({
expect,
}) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocMenu, {
editor: editor,
hovered: splitDocBlock(2),
})
menuItem(t(INVERT)).click()
expect(commandNames(commands)).toEqual([
"setMeta",
"focus",
"invertSplitDocumentation",
"run",
])
expect(commandArgs(commands, "setMeta")).toEqual([
SUPPRESS_SCROLL_TO_SELECTION_META,
true,
])
expect(commandArgs(commands, "invertSplitDocumentation")).toEqual([2])
})
it("does nothing while no block is hovered", async ({ expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocMenu, {
editor: editor,
hovered: null,
})
menuItem(t(ADD_PARAMETERS)).click()
menuItem(t(INVERT)).click()
expect(commands).toEqual([])
})
})

View File

@ -0,0 +1,69 @@
import { afterEach, describe, it } from "vitest"
import SplitDocParameterListItemMenu from "./SplitDocParameterListItemMenu.vue"
import { hoveredBlock, mountMenuOptions } from "./test-helpers"
import {
commandArgs,
commandNames,
makeEditor,
} from "../../test-helpers/node-view"
import { clearTeleportedOverlays, menuItem, t } from "~/components/test-helpers"
const ADD_ABOVE =
"editor.drag-handle.options.split-doc-parameter-list-item.add-above-block"
const ADD_BELOW =
"editor.drag-handle.options.split-doc-parameter-list-item.add-below-block"
// the menu body is teleported into a shared <body>, so these tests
// cannot interleave
describe("<SplitDocParameterListItemMenu>", { concurrent: false }, () => {
afterEach(clearTeleportedOverlays)
it("offers to add a parameter on either side", async ({ expect }) => {
const { editor } = makeEditor()
await mountMenuOptions(SplitDocParameterListItemMenu, {
editor: editor,
hovered: hoveredBlock(9),
})
expect(menuItem(t(ADD_ABOVE))).toBeDefined()
expect(menuItem(t(ADD_BELOW))).toBeDefined()
})
it.for([
{ label: ADD_ABOVE, side: "above" },
{ label: ADD_BELOW, side: "below" },
])(
"inserts a parameter $side the hovered one",
async ({ label, side }, { expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocParameterListItemMenu, {
editor: editor,
hovered: hoveredBlock(9),
})
menuItem(t(label)).click()
expect(commandNames(commands)).toEqual([
"focus",
"insertParameterListItemOnLeftSide",
"run",
])
expect(
commandArgs(commands, "insertParameterListItemOnLeftSide"),
).toEqual([9, side])
},
)
it("does nothing while no block is hovered", async ({ expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocParameterListItemMenu, {
editor: editor,
hovered: null,
})
menuItem(t(ADD_ABOVE)).click()
expect(commands).toEqual([])
})
})

View File

@ -0,0 +1,70 @@
import { afterEach, describe, it } from "vitest"
import SplitDocParameterListMenu from "./SplitDocParameterListMenu.vue"
import { hoveredBlock, mountMenuOptions } from "./test-helpers"
import {
commandArgs,
commandNames,
makeEditor,
} from "../../test-helpers/node-view"
import { clearTeleportedOverlays, menuItem, t } from "~/components/test-helpers"
const ADD_ABOVE =
"editor.drag-handle.options.split-doc-parameter-list.add-above-block"
const ADD_BELOW =
"editor.drag-handle.options.split-doc-parameter-list.add-below-block"
// the menu body is teleported into a shared <body>, so these tests
// cannot interleave
describe("<SplitDocParameterListMenu>", { concurrent: false }, () => {
afterEach(clearTeleportedOverlays)
it("offers to add a parameter list on either side", async ({ expect }) => {
const { editor } = makeEditor()
await mountMenuOptions(SplitDocParameterListMenu, {
editor: editor,
hovered: hoveredBlock(7),
})
expect(menuItem(t(ADD_ABOVE))).toBeDefined()
expect(menuItem(t(ADD_BELOW))).toBeDefined()
})
it.for([
{ label: ADD_ABOVE, side: "above" },
{ label: ADD_BELOW, side: "below" },
])(
"inserts a parameter list $side the hovered one",
async ({ label, side }, { expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocParameterListMenu, {
editor: editor,
hovered: hoveredBlock(7),
})
menuItem(t(label)).click()
expect(commandNames(commands)).toEqual([
"focus",
"insertParameterListOnLeftSide",
"run",
])
expect(commandArgs(commands, "insertParameterListOnLeftSide")).toEqual([
7,
side,
])
},
)
it("does nothing while no block is hovered", async ({ expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocParameterListMenu, {
editor: editor,
hovered: null,
})
menuItem(t(ADD_ABOVE)).click()
expect(commands).toEqual([])
})
})

View File

@ -0,0 +1,118 @@
import { afterEach, describe, it } from "vitest"
import SplitDocRightSideMenu from "./SplitDocRightSideMenu.vue"
import { hoveredBlock, mountMenuOptions } from "./test-helpers"
import {
commandArgs,
commandNames,
makeEditor,
} from "../../test-helpers/node-view"
import { SUPPRESS_SCROLL_TO_SELECTION_META } from "../../scroll-control"
import { clearTeleportedOverlays, menuItem, t } from "~/components/test-helpers"
// the menu body is teleported into a shared <body>, so these tests
// cannot interleave
describe("<SplitDocRightSideMenu>", { concurrent: false }, () => {
afterEach(clearTeleportedOverlays)
it("offers all four insertion points", async ({ expect }) => {
const { editor } = makeEditor()
await mountMenuOptions(SplitDocRightSideMenu, {
editor: editor,
hovered: hoveredBlock(4),
})
expect(document.body.querySelectorAll("[role^='menuitem']")).toHaveLength(4)
})
it.for([
{
label:
"editor.drag-handle.options.split-doc-right-side.add-code-block-above-block",
side: "above",
},
{
label:
"editor.drag-handle.options.split-doc-right-side.add-code-block-below-block",
side: "below",
},
])(
"inserts a code block $side the hovered one",
async ({ label, side }, { expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocRightSideMenu, {
editor: editor,
hovered: hoveredBlock(4),
})
menuItem(t(label)).click()
expect(commandNames(commands)).toEqual([
"focus",
"insertBlockOnRightSide",
"run",
])
expect(commandArgs(commands, "insertBlockOnRightSide")).toEqual([
4,
side,
"code",
])
},
)
it.for([
{
label:
"editor.drag-handle.options.split-doc-right-side.add-metrics-above-block",
side: "above",
},
{
label:
"editor.drag-handle.options.split-doc-right-side.add-metrics-below-block",
side: "below",
},
])(
"inserts a metrics block $side the hovered one without scrolling",
async ({ label, side }, { expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocRightSideMenu, {
editor: editor,
hovered: hoveredBlock(4),
})
menuItem(t(label)).click()
expect(commandNames(commands)).toEqual([
"setMeta",
"focus",
"insertBlockOnRightSide",
"run",
])
expect(commandArgs(commands, "setMeta")).toEqual([
SUPPRESS_SCROLL_TO_SELECTION_META,
true,
])
expect(commandArgs(commands, "insertBlockOnRightSide")).toEqual([
4,
side,
"metrics",
])
},
)
it("does nothing while no block is hovered", async ({ expect }) => {
const { editor, commands } = makeEditor()
await mountMenuOptions(SplitDocRightSideMenu, {
editor: editor,
hovered: null,
})
menuItem(
t(
"editor.drag-handle.options.split-doc-right-side.add-code-block-above-block",
),
).click()
expect(commands).toEqual([])
})
})

View File

@ -0,0 +1,80 @@
// shared helpers for the drag-handle menu option suites. Test-only: the
// app/**/test-helpers.ts coverage exclude keeps this out of the
// denominator, and nothing here is imported by app code.
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { Node as PMNode } from "@tiptap/pm/model"
import {
DropdownMenu,
DropdownMenuContent,
} from "~/components/shadcn/ui/dropdown-menu"
// eslint's ts program resolves .vue imports as error typed, so a
// component handed to mountMenuOptions looks unsafe to it while vue-tsc
// types it fine
type TestComponent = any
export interface HoveredBlock {
node: PMNode
nodePos: number
nodeId: string
nodeHooks: DocumentHook[] | null
nodeHookStatus: "stale" | "fresh" | null
}
// the block the drag handle is pointing at, reduced to what the option
// components read off it
export function hoveredBlock(
nodePos: number,
options: {
attrs?: Record<string, unknown>
typeName?: string
children?: { typeName: string; nodeSize: number }[]
} = {},
): HoveredBlock {
const children = options.children ?? []
return {
node: {
attrs: options.attrs ?? {},
type: { name: options.typeName ?? "testNode" },
forEach: (
callback: (
child: { type: { name: string } },
offset: number,
index: number,
) => void,
) => {
let offset = 0
children.forEach((child, index) => {
callback({ type: { name: child.typeName } }, offset, index)
offset += child.nodeSize
})
},
} as unknown as PMNode,
nodePos: nodePos,
nodeId: `block-${nodePos}`,
nodeHooks: null,
nodeHookStatus: null,
}
}
// the option components render bare menu items, which reka-ui only lets
// mount inside an open menu — and teleports into <body> from there. A
// getter keeps the props reactive, so a suite can change what the handle
// is pointing at mid-test.
export function mountMenuOptions(
component: TestComponent,
props: Record<string, unknown> | (() => Record<string, unknown>),
) {
return mountSuspended(DropdownMenu, {
props: { open: true },
slots: {
default: () =>
h(DropdownMenuContent, null, {
default: () =>
h(component, typeof props === "function" ? props() : props),
}),
},
})
}

View File

@ -0,0 +1,189 @@
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import HookMenuContent from "./HookMenuContent.vue"
import {
makeHook,
menuText,
mountHookMenu,
openHookSubMenu,
} from "./test-helpers"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const ADD_NEW = "editor.hooks.add-new"
function reminder(overrides: Partial<DocumentHook> = {}) {
return makeHook({
id: `reminder-${overrides.score ?? "100"}`,
type: DocumentHookType.ScheduledReminder,
settings: {
scale: "linear",
duration: "24h",
schedule: new Date("2026-09-01T10:00:00Z"),
},
state: { status: "active" },
...overrides,
})
}
function urlWatcher(overrides: Partial<DocumentHook> = {}) {
return makeHook({
id: "url-1",
type: DocumentHookType.URLWatcher,
settings: { url: "https://oxynote.test" },
state: { status: "active" },
...overrides,
})
}
function mockGitHub(configured: boolean) {
mockEndpoint("GET", "/api/github", () => ({
connected: false,
configured: configured,
}))
mockEndpoint("GET", "/api/github/repositories", () => [])
}
function mountContent(props: Record<string, unknown> = {}) {
return mountHookMenu(HookMenuContent, {
documentHooks: [],
nodeId: "block-1",
...props,
})
}
// the editor store, the query cache and the teleported menu bodies are
// all shared, so these tests cannot interleave
describe("<HookMenuContent>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
clearQueryCache()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
})
afterEach(disposeMockEndpoints)
it("offers to add a hook", async ({ expect }) => {
mockGitHub(true)
await mountContent()
expect(menuText()).toContain(t(ADD_NEW))
})
it("offers every hook type once github is available", async ({ expect }) => {
mockGitHub(true)
await mountContent()
await openHookSubMenu(t(ADD_NEW))
await vi.waitFor(() => {
expect(menuText()).toContain(t("editor.hooks.github-tracking.title"))
}, WAIT_FOR_OPTIONS)
expect(menuText()).toContain(t("editor.hooks.time-expiration.title"))
expect(menuText()).toContain(t("editor.hooks.url-watcher.title"))
expect(menuText()).toContain(
t("editor.hooks.container-image-watcher.title"),
)
})
it("leaves github out when the server has no integration", async ({
expect,
}) => {
mockGitHub(false)
await mountContent()
await openHookSubMenu(t(ADD_NEW))
await vi.waitFor(() => {
expect(menuText()).not.toContain(t("editor.hooks.github-tracking.title"))
}, WAIT_FOR_OPTIONS)
expect(menuText()).toContain(t("editor.hooks.time-expiration.title"))
})
it("lists the hooks already on the block", async ({ expect }) => {
mockGitHub(true)
await mountContent({ documentHooks: [reminder(), urlWatcher()] })
expect(menuText()).toContain("Remind on")
expect(menuText()).toContain("Watching oxynote.test")
})
it("leaves out hooks belonging to another block", async ({ expect }) => {
mockGitHub(true)
await mountContent({
documentHooks: [urlWatcher({ blockId: "block-2" })],
})
expect(menuText()).not.toContain("Watching oxynote.test")
})
it("puts the hooks that have fired above the active ones", async ({
expect,
}) => {
mockGitHub(true)
await mountContent({
documentHooks: [reminder(), urlWatcher({ score: "0" })],
})
const labels = Array.from(
document.body.querySelectorAll<HTMLElement>("[role^='menuitem']"),
).map((item) => item.textContent)
expect(labels[0]).toContain("Changes in oxynote.test")
expect(labels[1]).toContain("Remind on")
})
it("orders hooks of the same kind by when they last ran", async ({
expect,
}) => {
mockGitHub(true)
await mountContent({
documentHooks: [
urlWatcher({
id: "url-newer",
settings: { url: "https://newer.test" },
updatedAt: new Date("2026-02-01T00:00:00Z"),
}),
urlWatcher({
id: "url-older",
settings: { url: "https://older.test" },
updatedAt: new Date("2026-01-01T00:00:00Z"),
}),
],
})
const labels = Array.from(
document.body.querySelectorAll<HTMLElement>("[role^='menuitem']"),
).map((item) => item.textContent)
expect(labels[0]).toContain("older.test")
expect(labels[1]).toContain("newer.test")
})
it("shows only the add action for a block with no hooks", async ({
expect,
}) => {
mockGitHub(true)
await mountContent()
expect(document.body.querySelectorAll("[role^='menuitem']")).toHaveLength(1)
})
})

View File

@ -0,0 +1,316 @@
import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ConfigMenu from "./ConfigMenu.vue"
import {
makeHook,
menuButton,
menuText,
mountHookMenu,
openHookSubMenu,
typeInMenu,
} from "../test-helpers"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
settleMutations,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
vi.mock("vue-sonner", () => ({
toast: { custom: vi.fn(), dismiss: vi.fn() },
}))
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const HOOK_ID = makeXid("hook")
const NEW_HOOK_LABEL = "editor.hooks.container-image-watcher.title"
function imageHook(overrides: Partial<DocumentHook> = {}) {
return makeHook({
id: HOOK_ID,
type: DocumentHookType.ContainerImageWatcher,
documentId: DOCUMENT_ID,
branchId: BRANCH_ID,
settings: {
image: t("editor.hooks.container-image-watcher.image-input-placeholder"),
},
...overrides,
})
}
function mountMenu(props: Record<string, unknown> = {}) {
return mountHookMenu(ConfigMenu, { nodeId: "block-1", ...props })
}
// the editor store, the query cache, the mocked toast module and the
// teleported menu bodies are all shared, so these tests cannot interleave
describe("<ContainerImageWatcherConfigMenu>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
clearQueryCache()
vi.mocked(toast.custom).mockReset()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
})
afterEach(disposeMockEndpoints)
it("offers to start watching a website", async ({ expect }) => {
await mountMenu()
expect(menuText()).toContain(t(NEW_HOOK_LABEL))
})
it("names the image an active hook watches", async ({ expect }) => {
await mountMenu({ hook: imageHook() })
expect(menuText()).toContain("Watching postgres:16.4-alpine")
})
it("reports a hook that has already fired", async ({ expect }) => {
await mountMenu({ hook: imageHook({ score: "0" }) })
expect(menuText()).toContain("Updates in postgres:16.4-alpine")
})
it("prefills the image reference of the hook it edits", async ({
expect,
}) => {
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
expect(document.body.querySelector<HTMLInputElement>("input")?.value).toBe(
t("editor.hooks.container-image-watcher.image-input-placeholder"),
)
})
it("explains what an active block hook will do", async ({ expect }) => {
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
expect(menuText()).toContain("the block will be highlighted")
})
it("explains what an active document-wide hook will do", async ({
expect,
}) => {
await mountMenu({ hook: imageHook(), nodeId: null })
await openHookSubMenu("Watching postgres:16.4-alpine")
expect(menuText()).toContain("the relevant sections will be highlighted")
})
it("warns about an image it cannot reach", async ({ expect }) => {
await mountMenu({
hook: imageHook({
state: { digest: "sha256:old", status: "unauthorized" },
}),
})
await openHookSubMenu("Watching postgres:16.4-alpine")
expect(menuText()).toContain("cannot be reached")
})
it("keeps the create button out of reach until an image is typed", async ({
expect,
}) => {
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
expect(menuButton(t("editor.hooks.create")).disabled).toBe(true)
})
it("creates a hook for the image the reader typed", async ({ expect }) => {
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await typeInMenu("redis:7")
menuButton(t("editor.hooks.create")).click()
await settleMutations()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({
type: DocumentHookType.ContainerImageWatcher,
branchId: BRANCH_ID,
blockId: "block-1",
settings: { image: "redis:7" },
})
expect(
wrapper.findComponent(ConfigMenu).emitted("force-close"),
).toHaveLength(1)
})
it("warns when the hook cannot be created", async ({ expect }) => {
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, (_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
})
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await typeInMenu("redis:7")
menuButton(t("editor.hooks.create")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("updates the image an existing hook watches", async ({ expect }) => {
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => ({ id: HOOK_ID }),
)
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
await typeInMenu("redis:8")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({
settings: { image: "redis:8" },
})
})
it("warns when the hook cannot be updated", async ({ expect }) => {
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
await typeInMenu("redis:8")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("deletes the hook", async ({ expect }) => {
const calls = mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => null,
)
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
menuButton(t("editor.hooks.delete")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be deleted", async ({ expect }) => {
mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
menuButton(t("editor.hooks.delete")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("offers to approve a hook that has fired", async ({ expect }) => {
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
() => ({ id: HOOK_ID }),
)
await mountMenu({ hook: imageHook({ score: "0" }) })
await openHookSubMenu("Updates in postgres:16.4-alpine")
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be approved", async ({ expect }) => {
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: imageHook({ score: "0" }) })
await openHookSubMenu("Updates in postgres:16.4-alpine")
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("offers no approval for a hook that has not fired", async ({ expect }) => {
await mountMenu({ hook: imageHook() })
await openHookSubMenu("Watching postgres:16.4-alpine")
expect(menuText()).not.toContain(t("editor.hooks.reset"))
})
it("creates nothing while no page is open", async ({ expect }) => {
useEditorStore().updateActiveDocumentId(null)
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await typeInMenu("redis:7")
menuButton(t("editor.hooks.create")).click()
await settleMutations()
expect(calls).toHaveLength(0)
})
})

View File

@ -155,6 +155,7 @@ async function resetHook() {
</span>
<i18n-t
v-else-if="Number(hookData.score) !== 0"
scope="global"
keypath="editor.hooks.container-image-watcher.existing-item"
tag="span"
class="truncate"
@ -165,6 +166,7 @@ async function resetHook() {
</i18n-t>
<i18n-t
v-else
scope="global"
keypath="editor.hooks.container-image-watcher.triggered-item"
tag="span"
class="truncate"
@ -185,6 +187,7 @@ async function resetHook() {
<div class="flex w-[14rem] flex-col">
<template v-if="hookData?.state.status === 'unauthorized'">
<i18n-t
scope="global"
keypath="editor.hooks.container-image-watcher.unreachable-image"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
@ -207,6 +210,7 @@ async function resetHook() {
>
<div class="flex flex-col gap-1 px-0.75 pb-0.75 text-2sm">
<i18n-t
scope="global"
:keypath="
props.nodeId
? 'editor.hooks.container-image-watcher.existing-item-block-explanation'

View File

@ -0,0 +1,83 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { describe, it } from "vitest"
import HookInputField from "./HookInputField.vue"
import { t } from "~/components/test-helpers"
function mountField(props: Record<string, unknown> = {}) {
return mountSuspended(HookInputField, {
props: {
placeholder: t(
"editor.hooks.container-image-watcher.image-input-placeholder",
),
...props,
},
slots: {
label: () => t("editor.hooks.container-image-watcher.image-input-label"),
},
})
}
describe("<ContainerImageWatcherHookInputField>", () => {
it("labels the field", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.text()).toBe(
t("editor.hooks.container-image-watcher.image-input-label"),
)
})
it("prompts with the placeholder it was given", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.get("input").attributes("placeholder")).toBe(
t("editor.hooks.container-image-watcher.image-input-placeholder"),
)
})
it("shows the value it was given", async ({ expect }) => {
const wrapper = await mountField({ modelValue: "redis:7" })
expect((wrapper.get("input").element as HTMLInputElement).value).toBe(
"redis:7",
)
})
it("reports what the reader typed", async ({ expect }) => {
const wrapper = await mountField()
await wrapper.get("input").setValue("redis:7")
expect(wrapper.emitted("update:modelValue")).toEqual([["redis:7"]])
})
it("stays editable by default", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.get("input").attributes("disabled")).toBeUndefined()
})
it("locks the field when the host asks it to", async ({ expect }) => {
const wrapper = await mountField({ disabled: true })
expect(wrapper.get("input").attributes("disabled")).toBeDefined()
})
it("sizes itself for a menu by default", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.get("input").classes()).toContain("text-2sm")
})
it("takes the sizing the host asks for", async ({ expect }) => {
const wrapper = await mountField({ inputClass: "h-10 text-base" })
expect(wrapper.get("input").classes()).toContain("h-10")
expect(wrapper.get("input").classes()).not.toContain("text-2sm")
})
it("takes any text as an image reference", async ({ expect }) => {
const wrapper = await mountField()
expect(wrapper.get("input").attributes("type")).toBeUndefined()
})
})

View File

@ -0,0 +1,435 @@
import { setResponseStatus } from "h3"
import type { VueWrapper } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import ConfigMenu from "./ConfigMenu.vue"
import FileSelectInput from "./FileSelectInput.vue"
import {
makeHook,
menuButton,
menuText,
mountHookMenu,
openHookSubMenu,
} from "../test-helpers"
import {
clearQueryCache,
disposeMockEndpoints,
makeXid,
mockEndpoint,
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
emitFrom,
emitFromNth,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
import { Select } from "~/components/shadcn/ui/select"
vi.mock("vue-sonner", () => ({
toast: { custom: vi.fn(), dismiss: vi.fn() },
}))
const DOCUMENT_ID = makeXid("doc")
const BRANCH_ID = makeXid("branch")
const HOOK_ID = makeXid("hook")
const NEW_HOOK_LABEL = "editor.hooks.github-tracking.title"
const REPOSITORY = "runbooks"
function githubHook(overrides: Partial<DocumentHook> = {}) {
return makeHook({
id: HOOK_ID,
type: DocumentHookType.GitHubTracking,
documentId: DOCUMENT_ID,
branchId: BRANCH_ID,
settings: {
repository: REPOSITORY,
branch: "main",
paths: ["docs/readme.md"],
},
state: { pathsChecksums: {}, status: "active" },
...overrides,
})
}
function mockGitHub(
options: { connected?: boolean; repositories?: string[] } = {},
) {
mockEndpoint("GET", "/api/github", () => ({
connected: options.connected ?? true,
}))
mockEndpoint("GET", "/api/github/repositories", () =>
(options.repositories ?? [REPOSITORY]).map((name) => ({ name: name })),
)
mockEndpoint("GET", `/api/github/repositories/${REPOSITORY}/branches`, () => [
"main",
"next",
])
mockEndpoint("GET", `/api/github/repositories/${REPOSITORY}/tree`, () => [
{
type: "file",
name: "docs/readme.md",
items: null,
checksum: "sum-1",
},
])
}
function mountMenu(props: Record<string, unknown> = {}) {
return mountHookMenu(ConfigMenu, { nodeId: "block-1", ...props })
}
async function pickRepository(wrapper: VueWrapper, name: string) {
emitFrom(wrapper, Select, "update:modelValue", name)
await nextTick()
}
async function pickBranch(wrapper: VueWrapper, name: string) {
emitFromNth(wrapper, Select, 1, "update:modelValue", name)
await nextTick()
}
async function pickPaths(wrapper: VueWrapper, paths: string[]) {
emitFrom(wrapper, FileSelectInput, "update:modelValue", paths)
await nextTick()
}
// the editor store, the query cache, the mocked toast module and the
// teleported menu bodies are all shared, so these tests cannot interleave
describe("<GitHubTrackingConfigMenu>", { concurrent: false }, () => {
beforeEach(() => {
clearTeleportedOverlays()
clearQueryCache()
vi.mocked(toast.custom).mockReset()
useEditorStore().updateActiveDocumentId(DOCUMENT_ID)
useEditorStore().updateActiveBranchId(BRANCH_ID)
})
afterEach(disposeMockEndpoints)
it("offers to start watching a repository", async ({ expect }) => {
mockGitHub()
await mountMenu()
expect(menuText()).toContain(t(NEW_HOOK_LABEL))
})
it("names the repository an active hook watches", async ({ expect }) => {
mockGitHub()
await mountMenu({ hook: githubHook() })
expect(menuText()).toContain(`Watching ${REPOSITORY}`)
})
it("reports a hook that has already fired", async ({ expect }) => {
mockGitHub()
await mountMenu({ hook: githubHook({ score: "0" }) })
expect(menuText()).toContain(`Changes in ${REPOSITORY}`)
})
it("asks the reader to connect github first", async ({ expect }) => {
mockGitHub({ connected: false })
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await vi.waitFor(() => {
expect(menuText()).toContain("connect your GitHub organization")
}, WAIT_FOR_OPTIONS)
menuButton(
t("editor.hooks.github-tracking.not-connected.placeholder"),
).click()
await nextTick()
expect(wrapper.findComponent(ConfigMenu).emitted("open-settings")).toEqual([
["github"],
])
})
it("says so when the connected account has no repositories", async ({
expect,
}) => {
mockGitHub({ repositories: [] })
await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await vi.waitFor(() => {
expect(menuText()).toContain("no accessible repositories")
}, WAIT_FOR_OPTIONS)
})
it("warns about a repository it can no longer reach", async ({ expect }) => {
mockGitHub()
await mountMenu({
hook: githubHook({
state: { pathsChecksums: {}, status: "missing_repository" },
}),
})
await openHookSubMenu(`Watching ${REPOSITORY}`)
await vi.waitFor(() => {
expect(menuText()).toContain(
"The selected GitHub repository cannot be accessed",
)
}, WAIT_FOR_OPTIONS)
})
it("warns about a branch it can no longer reach", async ({ expect }) => {
mockGitHub()
await mountMenu({
hook: githubHook({
state: { pathsChecksums: {}, status: "missing_branch" },
}),
})
await openHookSubMenu(`Watching ${REPOSITORY}`)
await vi.waitFor(() => {
expect(menuText()).toContain(
"The selected GitHub branch cannot be accessed",
)
}, WAIT_FOR_OPTIONS)
})
it("explains what an active block hook will do", async ({ expect }) => {
mockGitHub()
await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await vi.waitFor(() => {
expect(menuText()).toContain("the block will be highlighted")
}, WAIT_FOR_OPTIONS)
})
it("explains what an active document-wide hook will do", async ({
expect,
}) => {
mockGitHub()
await mountMenu({ hook: githubHook(), nodeId: null })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await vi.waitFor(() => {
expect(menuText()).toContain("the relevant sections will be highlighted")
}, WAIT_FOR_OPTIONS)
})
it("keeps the create button out of reach until everything is picked", async ({
expect,
}) => {
mockGitHub()
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
expect(menuButton(t("editor.hooks.create")).disabled).toBe(true)
})
it("creates a hook for the repository, branch and files picked", async ({
expect,
}) => {
mockGitHub()
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
await pickPaths(wrapper, ["docs/readme.md"])
menuButton(t("editor.hooks.create")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({
type: DocumentHookType.GitHubTracking,
branchId: BRANCH_ID,
blockId: "block-1",
settings: {
repository: REPOSITORY,
branch: "main",
paths: ["docs/readme.md"],
},
})
expect(
wrapper.findComponent(ConfigMenu).emitted("force-close"),
).toHaveLength(1)
})
it("warns when the hook cannot be created", async ({ expect }) => {
mockGitHub()
mockEndpoint("POST", `/api/documents/${DOCUMENT_ID}/hooks`, (_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
})
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
await pickPaths(wrapper, ["docs/readme.md"])
menuButton(t("editor.hooks.create")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("updates what an existing hook watches", async ({ expect }) => {
mockGitHub()
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await pickBranch(wrapper, "next")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
expect(calls[0]?.body).toEqual({
settings: {
repository: REPOSITORY,
branch: "next",
paths: ["docs/readme.md"],
},
})
})
it("warns when the hook cannot be updated", async ({ expect }) => {
mockGitHub()
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
const wrapper = await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
await pickBranch(wrapper, "next")
menuButton(t("editor.hooks.update")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("deletes the hook", async ({ expect }) => {
mockGitHub()
const calls = mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
() => null,
)
await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
menuButton(t("editor.hooks.delete")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be deleted", async ({ expect }) => {
mockGitHub()
mockEndpoint(
"DELETE",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: githubHook() })
await openHookSubMenu(`Watching ${REPOSITORY}`)
menuButton(t("editor.hooks.delete")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("offers to approve a hook that has fired", async ({ expect }) => {
mockGitHub()
const calls = mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
() => ({ id: HOOK_ID }),
)
await mountMenu({ hook: githubHook({ score: "0" }) })
await openHookSubMenu(`Changes in ${REPOSITORY}`)
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(calls).toHaveLength(1)
}, WAIT_FOR_OPTIONS)
})
it("warns when the hook cannot be approved", async ({ expect }) => {
mockGitHub()
mockEndpoint(
"PUT",
`/api/documents/${DOCUMENT_ID}/hooks/${HOOK_ID}/reset`,
(_c, event) => {
setResponseStatus(event, 500)
return { message: "boom" }
},
)
await mountMenu({ hook: githubHook({ score: "0" }) })
await openHookSubMenu(`Changes in ${REPOSITORY}`)
menuButton(t("editor.hooks.reset")).click()
await vi.waitFor(() => {
expect(toast.custom).toHaveBeenCalledTimes(1)
}, WAIT_FOR_OPTIONS)
})
it("creates nothing while no page is open", async ({ expect }) => {
mockGitHub()
useEditorStore().updateActiveDocumentId(null)
const calls = mockEndpoint(
"POST",
`/api/documents/${DOCUMENT_ID}/hooks`,
() => ({ id: HOOK_ID }),
)
const wrapper = await mountMenu()
await openHookSubMenu(t(NEW_HOOK_LABEL))
await pickRepository(wrapper, REPOSITORY)
await pickBranch(wrapper, "main")
await pickPaths(wrapper, ["docs/readme.md"])
menuButton(t("editor.hooks.create")).click()
await nextTick()
expect(calls).toHaveLength(0)
})
})

View File

@ -206,6 +206,7 @@ async function resetHook() {
</span>
<i18n-t
v-else-if="Number(hookData.score) !== 0"
scope="global"
keypath="editor.hooks.github-tracking.existing-item"
tag="span"
class="truncate"
@ -216,6 +217,7 @@ async function resetHook() {
</i18n-t>
<i18n-t
v-else
scope="global"
keypath="editor.hooks.github-tracking.triggered-item"
tag="span"
class="truncate"
@ -241,6 +243,7 @@ async function resetHook() {
"
>
<i18n-t
scope="global"
keypath="editor.hooks.github-tracking.not-connected.main"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
@ -269,6 +272,7 @@ async function resetHook() {
</template>
<template v-else-if="!fetchGitHubRepositories.state.value.data?.length">
<i18n-t
scope="global"
keypath="editor.hooks.github-tracking.no-repositories"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
@ -284,6 +288,7 @@ async function resetHook() {
</template>
<template v-else-if="hookData?.state.status === 'missing_repository'">
<i18n-t
scope="global"
keypath="editor.hooks.github-tracking.missing-target-repository"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
@ -299,6 +304,7 @@ async function resetHook() {
</template>
<template v-else-if="hookData?.state.status === 'missing_branch'">
<i18n-t
scope="global"
keypath="editor.hooks.github-tracking.missing-target-branch"
tag="div"
class="px-0.75 pb-0.75 text-center text-2sm"
@ -331,6 +337,7 @@ async function resetHook() {
>
<div class="flex flex-col gap-1 px-0.75 pb-0.75 text-2sm">
<i18n-t
scope="global"
:keypath="
props.nodeId
? 'editor.hooks.github-tracking.existing-item-block-explanation'
@ -483,6 +490,7 @@ async function resetHook() {
<template #selection-text="{ count }">
<i18n-t
v-if="count > 1"
scope="global"
keypath="editor.hooks.github-tracking.selection-text.other"
tag="span"
>
@ -490,6 +498,7 @@ async function resetHook() {
</i18n-t>
<i18n-t
v-if="count === 1"
scope="global"
keypath="editor.hooks.github-tracking.selection-text.one"
tag="span"
/>

View File

@ -0,0 +1,210 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import type { VueWrapper } from "@vue/test-utils"
import { beforeEach, describe, it } from "vitest"
import FileSelectInput from "./FileSelectInput.vue"
import { clearTeleportedOverlays, t } from "~/components/test-helpers"
import type { GitHubFileTreeItem } from "~/utils/api/github"
function file(name: string): GitHubFileTreeItem {
return { type: "file", name: name, items: null, checksum: `sum-${name}` }
}
function folder(
name: string,
items: GitHubFileTreeItem[] = [],
): GitHubFileTreeItem {
return { type: "folder", name: name, items: items, checksum: `sum-${name}` }
}
function mountInput(props: Record<string, unknown> = {}) {
return mountSuspended(FileSelectInput, {
props: {
placeholder: t("editor.hooks.github-tracking.path-select-placeholder"),
emptyFolderPlaceholder: t(
"editor.hooks.github-tracking.empty-folder-placeholder",
),
options: [file("readme.md")],
...props,
},
slots: {
label: () => t("editor.hooks.github-tracking.path-select-label"),
"selection-text": (slotProps: { count: number }) =>
`${slotProps.count} files selected`,
},
})
}
// the file tree is teleported into <body> with the popover
async function openTree(wrapper: VueWrapper) {
await wrapper.get("button").trigger("click")
await nextTick()
}
function treeRow(text: string): HTMLElement {
const row = Array.from(
document.body.querySelectorAll<HTMLElement>(
"[data-slot='popover-content'] [class*='cursor-pointer']",
),
).find((candidate) => candidate.textContent.trim() === text)
if (!row) {
throw new Error(`no tree row rendering "${text}"`)
}
return row
}
function treeRows(): string[] {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
"[data-slot='popover-content'] [class*='cursor-pointer']",
),
).map((row) => row.textContent.trim())
}
// the popover body is teleported into a shared <body>, so these tests
// cannot interleave
describe("<FileSelectInput>", { concurrent: false }, () => {
beforeEach(clearTeleportedOverlays)
it("labels the field", async ({ expect }) => {
const wrapper = await mountInput()
expect(wrapper.text()).toContain(
t("editor.hooks.github-tracking.path-select-label"),
)
})
it("prompts for a selection while nothing is picked", async ({ expect }) => {
const wrapper = await mountInput()
expect(wrapper.get("button").text()).toContain(
t("editor.hooks.github-tracking.path-select-placeholder"),
)
})
it("counts what the reader has picked", async ({ expect }) => {
const wrapper = await mountInput({ modelValue: ["a.md", "b.md"] })
expect(wrapper.get("button").text()).toContain("2 files selected")
})
it("stays editable by default", async ({ expect }) => {
const wrapper = await mountInput()
expect(wrapper.get("button").attributes("disabled")).toBeUndefined()
})
it("locks the field when the host asks it to", async ({ expect }) => {
const wrapper = await mountInput({ disabled: true })
expect(wrapper.get("button").attributes("disabled")).toBeDefined()
})
it("puts folders before files in the tree", async ({ expect }) => {
const wrapper = await mountInput({
options: [file("z.md"), folder("docs"), file("a.md")],
})
await openTree(wrapper)
expect(treeRows()).toEqual(["docs", "a.md", "z.md"])
})
it("picks the file the reader clicked", async ({ expect }) => {
const wrapper = await mountInput({ options: [file("readme.md")] })
await openTree(wrapper)
treeRow("readme.md").click()
await nextTick()
expect(wrapper.emitted("update:modelValue")).toEqual([[["readme.md"]]])
})
it("adds to what was already picked", async ({ expect }) => {
const wrapper = await mountInput({
options: [file("a.md"), file("b.md")],
modelValue: ["a.md"],
})
await openTree(wrapper)
treeRow("b.md").click()
await nextTick()
expect(wrapper.emitted("update:modelValue")).toEqual([[["a.md", "b.md"]]])
})
it("drops a file the reader unpicked", async ({ expect }) => {
const wrapper = await mountInput({
options: [file("a.md"), file("b.md")],
modelValue: ["a.md", "b.md"],
})
await openTree(wrapper)
treeRow("a.md").click()
await nextTick()
expect(wrapper.emitted("update:modelValue")).toEqual([[["b.md"]]])
})
it("picks a whole folder along with everything inside it", async ({
expect,
}) => {
const wrapper = await mountInput({
options: [folder("docs", [file("docs/a.md"), file("docs/b.md")])],
})
await openTree(wrapper)
treeRow("docs").click()
await nextTick()
expect(wrapper.emitted("update:modelValue")).toEqual([
[["docs", "docs/a.md", "docs/b.md"]],
])
})
it("drops a whole folder along with everything inside it", async ({
expect,
}) => {
const wrapper = await mountInput({
options: [folder("docs", [file("docs/a.md")])],
modelValue: ["docs", "docs/a.md", "other.md"],
})
await openTree(wrapper)
treeRow("docs").click()
await nextTick()
expect(wrapper.emitted("update:modelValue")).toEqual([[["other.md"]]])
})
it("picks a folder's contents recursively", async ({ expect }) => {
const wrapper = await mountInput({
options: [
folder("docs", [folder("docs/api", [file("docs/api/index.md")])]),
],
})
await openTree(wrapper)
treeRow("docs").click()
await nextTick()
expect(wrapper.emitted("update:modelValue")).toEqual([
[["docs", "docs/api", "docs/api/index.md"]],
])
})
it("picks a file only once", async ({ expect }) => {
const wrapper = await mountInput({
options: [folder("docs", [file("docs/a.md")])],
modelValue: ["docs/a.md"],
})
await openTree(wrapper)
treeRow("docs").click()
await nextTick()
expect(wrapper.emitted("update:modelValue")).toEqual([
[["docs/a.md", "docs"]],
])
})
})

Some files were not shown because too many files have changed in this diff Show More