mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add component tests for editor in web
This commit is contained in:
parent
7a4e9bcd97
commit
f611c13d98
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ describe("<SearchModal>", { concurrent: false }, () => {
|
|||
expect,
|
||||
}) => {
|
||||
mockEndpoint("GET", "/api/documents/search", () => {
|
||||
throw new Error("boom")
|
||||
throw createError({ statusCode: 500 })
|
||||
})
|
||||
await mountModal()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
364
web/app/components/editor/ContentEditor.nuxt.test.ts
Normal file
364
web/app/components/editor/ContentEditor.nuxt.test.ts
Normal 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"]])
|
||||
})
|
||||
})
|
||||
399
web/app/components/editor/DocumentContainer.client.nuxt.test.ts
Normal file
399
web/app/components/editor/DocumentContainer.client.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
95
web/app/components/editor/IconPicker.nuxt.test.ts
Normal file
95
web/app/components/editor/IconPicker.nuxt.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
218
web/app/components/editor/IconPickerProvider.nuxt.test.ts
Normal file
218
web/app/components/editor/IconPickerProvider.nuxt.test.ts
Normal 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("")
|
||||
})
|
||||
})
|
||||
149
web/app/components/editor/IconStack.nuxt.test.ts
Normal file
149
web/app/components/editor/IconStack.nuxt.test.ts
Normal 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("")
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
178
web/app/components/editor/MaintainerList.nuxt.test.ts
Normal file
178
web/app/components/editor/MaintainerList.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
509
web/app/components/editor/NameEditor.nuxt.test.ts
Normal file
509
web/app/components/editor/NameEditor.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
79
web/app/components/editor/NoDocumentIndicator.nuxt.test.ts
Normal file
79
web/app/components/editor/NoDocumentIndicator.nuxt.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
426
web/app/components/editor/ReviewerList.nuxt.test.ts
Normal file
426
web/app/components/editor/ReviewerList.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
262
web/app/components/editor/TextBubbleMenu.nuxt.test.ts
Normal file
262
web/app/components/editor/TextBubbleMenu.nuxt.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
352
web/app/components/editor/ai/ChatBox.nuxt.test.ts
Normal file
352
web/app/components/editor/ai/ChatBox.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
147
web/app/components/editor/blocks/callout/MainBlock.nuxt.test.ts
Normal file
147
web/app/components/editor/blocks/callout/MainBlock.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
234
web/app/components/editor/blocks/code-block/Code.nuxt.test.ts
Normal file
234
web/app/components/editor/blocks/code-block/Code.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
449
web/app/components/editor/blocks/figma/FigmaBlock.nuxt.test.ts
Normal file
449
web/app/components/editor/blocks/figma/FigmaBlock.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
565
web/app/components/editor/blocks/image/ImageBlock.nuxt.test.ts
Normal file
565
web/app/components/editor/blocks/image/ImageBlock.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
347
web/app/components/editor/blocks/mermaid/MainBlock.nuxt.test.ts
Normal file
347
web/app/components/editor/blocks/mermaid/MainBlock.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
403
web/app/components/editor/blocks/metrics/MainBlock.nuxt.test.ts
Normal file
403
web/app/components/editor/blocks/metrics/MainBlock.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)]])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
23
web/app/components/editor/blocks/metrics/test-helpers.ts
Normal file
23
web/app/components/editor/blocks/metrics/test-helpers.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -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()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
90
web/app/components/editor/comments/Comment.nuxt.test.ts
Normal file
90
web/app/components/editor/comments/Comment.nuxt.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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]",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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],
|
||||
])
|
||||
})
|
||||
})
|
||||
443
web/app/components/editor/comments/CommentRenderer.nuxt.test.ts
Normal file
443
web/app/components/editor/comments/CommentRenderer.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
209
web/app/components/editor/diff/DiffEditor.nuxt.test.ts
Normal file
209
web/app/components/editor/diff/DiffEditor.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
153
web/app/components/editor/diff/DiffTitle.nuxt.test.ts
Normal file
153
web/app/components/editor/diff/DiffTitle.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
362
web/app/components/editor/drag-handle/BlockHandle.nuxt.test.ts
Normal file
362
web/app/components/editor/drag-handle/BlockHandle.nuxt.test.ts
Normal 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",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)])
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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),
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
189
web/app/components/editor/hooks/HookMenuContent.nuxt.test.ts
Normal file
189
web/app/components/editor/hooks/HookMenuContent.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user