From f611c13d9855f514f3c3ebdf0b045ecc700606ba Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:05:44 +0200 Subject: [PATCH] add component tests for editor in web --- CLAUDE.md | 1 + server/core/CLAUDE.md | 4 +- web/CLAUDE.md | 70 +++ web/app/components/AppSidebar.nuxt.test.ts | 90 ++- .../components/AppSidebarHeader.nuxt.test.ts | 17 +- web/app/components/CalendarInput.nuxt.test.ts | 36 +- .../DocumentDeletionModal.nuxt.test.ts | 23 +- web/app/components/DocumentDeletionModal.vue | 1 + .../components/DocumentHeader.nuxt.test.ts | 57 +- web/app/components/DocumentHeader.vue | 7 +- .../components/NotificationBox.nuxt.test.ts | 53 +- web/app/components/SearchModal.nuxt.test.ts | 2 +- web/app/components/SearchModal.vue | 8 +- .../components/ShortcutTooltip.nuxt.test.ts | 5 +- web/app/components/SidebarItem.nuxt.test.ts | 18 +- .../editor/ContentEditor.nuxt.test.ts | 364 +++++++++++ .../DocumentContainer.client.nuxt.test.ts | 399 +++++++++++++ .../editor/DocumentContainer.client.vue | 3 +- .../components/editor/IconPicker.nuxt.test.ts | 95 +++ .../editor/IconPickerProvider.nuxt.test.ts | 218 +++++++ .../components/editor/IconStack.nuxt.test.ts | 149 +++++ web/app/components/editor/IconStack.vue | 1 + .../editor/MaintainerList.nuxt.test.ts | 178 ++++++ .../components/editor/NameEditor.nuxt.test.ts | 509 ++++++++++++++++ .../editor/NoDocumentIndicator.nuxt.test.ts | 79 +++ .../editor/ReviewerList.nuxt.test.ts | 426 +++++++++++++ .../editor/TextBubbleMenu.nuxt.test.ts | 262 ++++++++ .../components/editor/ai/ChatBox.nuxt.test.ts | 352 +++++++++++ .../blocks/callout/MainBlock.nuxt.test.ts | 147 +++++ .../blocks/code-block/Code.nuxt.test.ts | 234 ++++++++ .../code-block/CodeButtons.nuxt.test.ts | 287 +++++++++ .../blocks/code-block/CodeTitle.nuxt.test.ts | 128 ++++ .../blocks/figma/FigmaBlock.nuxt.test.ts | 449 ++++++++++++++ .../blocks/image/ImageBlock.nuxt.test.ts | 565 ++++++++++++++++++ .../editor/blocks/image/ImageBlock.vue | 2 +- .../blocks/mermaid/MainBlock.nuxt.test.ts | 347 +++++++++++ .../mermaid/MermaidPreview.nuxt.test.ts | 280 +++++++++ .../blocks/metrics/ConfigField.nuxt.test.ts | 37 ++ .../blocks/metrics/GridView.nuxt.test.ts | 33 + .../blocks/metrics/MainBlock.nuxt.test.ts | 403 +++++++++++++ .../metrics/PopoverConfigBox.nuxt.test.ts | 211 +++++++ .../VisualizationContainer.nuxt.test.ts | 357 +++++++++++ .../blocks/metrics/VisualizationContainer.vue | 1 + .../BaseModal.client.nuxt.test.ts | 269 +++++++++ .../config-modal/BottomConfig.nuxt.test.ts | 329 ++++++++++ .../config-modal/ColorSelect.nuxt.test.ts | 39 ++ .../config-modal/ConfigSidebar.nuxt.test.ts | 511 ++++++++++++++++ .../DataSourceSelect.nuxt.test.ts | 232 +++++++ .../metrics/config-modal/DataSourceSelect.vue | 1 + .../config-modal/ThresholdInput.nuxt.test.ts | 188 ++++++ .../config-modal/UnitSelect.nuxt.test.ts | 148 +++++ .../UnitSelectWithCustom.nuxt.test.ts | 169 ++++++ .../VisualizationTypeSelect.nuxt.test.ts | 132 ++++ .../HelpPopoverContent.nuxt.test.ts | 54 ++ .../query-help/MariaDBHelp.nuxt.test.ts | 61 ++ .../config-modal/query-help/MariaDBHelp.vue | 3 + .../query-help/MySQLHelp.nuxt.test.ts | 61 ++ .../config-modal/query-help/MySQLHelp.vue | 3 + .../query-help/PostgreSQLHelp.nuxt.test.ts | 61 ++ .../query-help/PostgreSQLHelp.vue | 3 + .../query-help/PrometheusHelp.nuxt.test.ts | 51 ++ .../query-help/PrometheusHelp.vue | 3 + .../query/LegendEditor.nuxt.test.ts | 156 +++++ .../query/QueryEditor.nuxt.test.ts | 151 +++++ .../editor/blocks/metrics/test-helpers.ts | 23 + .../visualizations/BarChart.nuxt.test.ts | 171 ++++++ .../visualizations/GaugeChart.nuxt.test.ts | 196 ++++++ .../visualizations/LineChart.nuxt.test.ts | 171 ++++++ .../metrics/visualizations/test-helpers.ts | 68 +++ .../BottomAction.nuxt.test.ts | 212 +++++++ .../LeftSideBlock.nuxt.test.ts | 130 ++++ .../MainBlock.nuxt.test.ts | 69 +++ .../ParameterListHeader.nuxt.test.ts | 117 ++++ .../ParameterListItemHeader.nuxt.test.ts | 43 ++ .../ParameterListItemHeaderTitle.nuxt.test.ts | 119 ++++ .../ParameterListItemHeaderType.nuxt.test.ts | 119 ++++ .../RightSideBlock.nuxt.test.ts | 175 ++++++ .../editor/comments/Comment.nuxt.test.ts | 90 +++ .../comments/CommentIndicator.nuxt.test.ts | 91 +++ .../CommentIndicatorContainer.nuxt.test.ts | 203 +++++++ .../comments/CommentRenderer.nuxt.test.ts | 443 ++++++++++++++ .../editor/comments/CommentRenderer.vue | 5 + .../editor/diff/DiffEditor.nuxt.test.ts | 209 +++++++ .../editor/diff/DiffTitle.nuxt.test.ts | 153 +++++ .../drag-handle/BlockHandle.nuxt.test.ts | 362 +++++++++++ .../menu-options/CoreMenu.nuxt.test.ts | 449 ++++++++++++++ .../menu-options/DefaultOptions.nuxt.test.ts | 185 ++++++ .../menu-options/MetricBlockMenu.nuxt.test.ts | 174 ++++++ .../menu-options/SplitDocMenu.nuxt.test.ts | 128 ++++ ...SplitDocParameterListItemMenu.nuxt.test.ts | 69 +++ .../SplitDocParameterListMenu.nuxt.test.ts | 70 +++ .../SplitDocRightSideMenu.nuxt.test.ts | 118 ++++ .../drag-handle/menu-options/test-helpers.ts | 80 +++ .../editor/hooks/HookMenuContent.nuxt.test.ts | 189 ++++++ .../ConfigMenu.nuxt.test.ts | 316 ++++++++++ .../container-image-watcher/ConfigMenu.vue | 4 + .../HookInputField.nuxt.test.ts | 83 +++ .../github-tracking/ConfigMenu.nuxt.test.ts | 435 ++++++++++++++ .../hooks/github-tracking/ConfigMenu.vue | 9 + .../FileSelectInput.nuxt.test.ts | 210 +++++++ .../FileSelectInputItem.nuxt.test.ts | 171 ++++++ .../ConfigMenu.nuxt.test.ts | 293 +++++++++ .../hooks/scheduled-reminder/ConfigMenu.vue | 3 + .../components/editor/hooks/test-helpers.ts | 88 +++ .../hooks/url-watcher/ConfigMenu.nuxt.test.ts | 310 ++++++++++ .../editor/hooks/url-watcher/ConfigMenu.vue | 4 + .../url-watcher/HookInputField.nuxt.test.ts | 79 +++ .../editor/link/LinkBubbleMenu.nuxt.test.ts | 345 +++++++++++ .../editor/link/LinkCreateMenu.nuxt.test.ts | 109 ++++ .../components/editor/link/test-helpers.ts | 43 ++ .../editor/slash/CommandButton.nuxt.test.ts | 81 +++ .../editor/slash/CommandList.nuxt.test.ts | 278 +++++++++ .../editor/test-helpers/node-view.ts | 166 +++++ .../components/editor/test-helpers/theme.ts | 21 + .../AccountDeletionAction.nuxt.test.ts | 28 +- .../settings/ActionModal.nuxt.test.ts | 68 ++- web/app/components/settings/ActionModal.vue | 45 +- .../settings/AppsSection.nuxt.test.ts | 64 +- .../settings/BaseModal.nuxt.test.ts | 59 +- .../DataSourceRemovalAction.nuxt.test.ts | 39 +- .../settings/DataSourceRemovalAction.vue | 1 + .../settings/DataSourceSection.nuxt.test.ts | 30 +- .../DataSourceUpsertAction.nuxt.test.ts | 42 +- .../settings/DataSourceUpsertAction.vue | 1 + .../settings/EmailChangeAction.nuxt.test.ts | 6 +- .../PasswordChangeAction.nuxt.test.ts | 42 +- .../settings/PasswordChangeAction.vue | 1 + .../settings/ProfileSection.nuxt.test.ts | 36 +- .../components/settings/ProfileSection.vue | 2 + .../WorkspaceInvitationAction.nuxt.test.ts | 13 +- .../WorkspaceMemberRemovalAction.nuxt.test.ts | 32 +- .../settings/WorkspaceMemberRemovalAction.vue | 1 + .../settings/WorkspaceSection.nuxt.test.ts | 23 +- .../components/settings/WorkspaceSection.vue | 1 + .../WorkspaceURLChangeAction.nuxt.test.ts | 6 +- web/app/components/test-helpers.ts | 17 + web/app/pages/accept-invite.vue | 2 + web/app/pages/login.vue | 3 + web/app/pages/signup.vue | 2 + web/app/pages/verify-email.vue | 1 + web/vitest.config.ts | 25 +- 141 files changed, 17542 insertions(+), 260 deletions(-) create mode 100644 web/app/components/editor/ContentEditor.nuxt.test.ts create mode 100644 web/app/components/editor/DocumentContainer.client.nuxt.test.ts create mode 100644 web/app/components/editor/IconPicker.nuxt.test.ts create mode 100644 web/app/components/editor/IconPickerProvider.nuxt.test.ts create mode 100644 web/app/components/editor/IconStack.nuxt.test.ts create mode 100644 web/app/components/editor/MaintainerList.nuxt.test.ts create mode 100644 web/app/components/editor/NameEditor.nuxt.test.ts create mode 100644 web/app/components/editor/NoDocumentIndicator.nuxt.test.ts create mode 100644 web/app/components/editor/ReviewerList.nuxt.test.ts create mode 100644 web/app/components/editor/TextBubbleMenu.nuxt.test.ts create mode 100644 web/app/components/editor/ai/ChatBox.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/callout/MainBlock.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/code-block/Code.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/code-block/CodeButtons.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/code-block/CodeTitle.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/figma/FigmaBlock.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/image/ImageBlock.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/mermaid/MainBlock.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/mermaid/MermaidPreview.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/ConfigField.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/GridView.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/MainBlock.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/PopoverConfigBox.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/VisualizationContainer.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/BaseModal.client.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/BottomConfig.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/ColorSelect.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/ConfigSidebar.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/DataSourceSelect.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/ThresholdInput.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/UnitSelect.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/UnitSelectWithCustom.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/VisualizationTypeSelect.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/query-help/HelpPopoverContent.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/query-help/MariaDBHelp.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/query-help/MySQLHelp.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/query-help/PostgreSQLHelp.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/query-help/PrometheusHelp.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/query/LegendEditor.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/config-modal/query/QueryEditor.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/test-helpers.ts create mode 100644 web/app/components/editor/blocks/metrics/visualizations/BarChart.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/visualizations/GaugeChart.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/visualizations/LineChart.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/metrics/visualizations/test-helpers.ts create mode 100644 web/app/components/editor/blocks/split-documentation/BottomAction.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/split-documentation/LeftSideBlock.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/split-documentation/MainBlock.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/split-documentation/ParameterListHeader.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/split-documentation/ParameterListItemHeader.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/split-documentation/ParameterListItemHeaderTitle.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/split-documentation/ParameterListItemHeaderType.nuxt.test.ts create mode 100644 web/app/components/editor/blocks/split-documentation/RightSideBlock.nuxt.test.ts create mode 100644 web/app/components/editor/comments/Comment.nuxt.test.ts create mode 100644 web/app/components/editor/comments/CommentIndicator.nuxt.test.ts create mode 100644 web/app/components/editor/comments/CommentIndicatorContainer.nuxt.test.ts create mode 100644 web/app/components/editor/comments/CommentRenderer.nuxt.test.ts create mode 100644 web/app/components/editor/diff/DiffEditor.nuxt.test.ts create mode 100644 web/app/components/editor/diff/DiffTitle.nuxt.test.ts create mode 100644 web/app/components/editor/drag-handle/BlockHandle.nuxt.test.ts create mode 100644 web/app/components/editor/drag-handle/menu-options/CoreMenu.nuxt.test.ts create mode 100644 web/app/components/editor/drag-handle/menu-options/DefaultOptions.nuxt.test.ts create mode 100644 web/app/components/editor/drag-handle/menu-options/MetricBlockMenu.nuxt.test.ts create mode 100644 web/app/components/editor/drag-handle/menu-options/SplitDocMenu.nuxt.test.ts create mode 100644 web/app/components/editor/drag-handle/menu-options/SplitDocParameterListItemMenu.nuxt.test.ts create mode 100644 web/app/components/editor/drag-handle/menu-options/SplitDocParameterListMenu.nuxt.test.ts create mode 100644 web/app/components/editor/drag-handle/menu-options/SplitDocRightSideMenu.nuxt.test.ts create mode 100644 web/app/components/editor/drag-handle/menu-options/test-helpers.ts create mode 100644 web/app/components/editor/hooks/HookMenuContent.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/container-image-watcher/ConfigMenu.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/container-image-watcher/HookInputField.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/github-tracking/ConfigMenu.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/github-tracking/FileSelectInput.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/github-tracking/FileSelectInputItem.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/scheduled-reminder/ConfigMenu.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/test-helpers.ts create mode 100644 web/app/components/editor/hooks/url-watcher/ConfigMenu.nuxt.test.ts create mode 100644 web/app/components/editor/hooks/url-watcher/HookInputField.nuxt.test.ts create mode 100644 web/app/components/editor/link/LinkBubbleMenu.nuxt.test.ts create mode 100644 web/app/components/editor/link/LinkCreateMenu.nuxt.test.ts create mode 100644 web/app/components/editor/link/test-helpers.ts create mode 100644 web/app/components/editor/slash/CommandButton.nuxt.test.ts create mode 100644 web/app/components/editor/slash/CommandList.nuxt.test.ts create mode 100644 web/app/components/editor/test-helpers/node-view.ts create mode 100644 web/app/components/editor/test-helpers/theme.ts diff --git a/CLAUDE.md b/CLAUDE.md index 01a8f3c5..b411b73f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/server/core/CLAUDE.md b/server/core/CLAUDE.md index a17066ad..332e5672 100644 --- a/server/core/CLAUDE.md +++ b/server/core/CLAUDE.md @@ -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 diff --git a/web/CLAUDE.md b/web/CLAUDE.md index cde19d7b..9c206c40 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -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`. +`` 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 `

`, 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 diff --git a/web/app/components/AppSidebar.nuxt.test.ts b/web/app/components/AppSidebar.nuxt.test.ts index 9bfea8d0..b30b8010 100644 --- a/web/app/components/AppSidebar.nuxt.test.ts +++ b/web/app/components/AppSidebar.nuxt.test.ts @@ -15,6 +15,7 @@ import { seedAuthOrganization, settleMutations, stubViewportMatches, + t, } from "./test-helpers" vi.mock("vue-sonner", () => ({ @@ -134,8 +135,8 @@ describe("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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() diff --git a/web/app/components/AppSidebarHeader.nuxt.test.ts b/web/app/components/AppSidebarHeader.nuxt.test.ts index e7402369..8313aa14 100644 --- a/web/app/components/AppSidebarHeader.nuxt.test.ts +++ b/web/app/components/AppSidebarHeader.nuxt.test.ts @@ -4,6 +4,7 @@ import { clearTeleportedOverlays, menuItem, mountUnderSidebarProvider, + t, } from "./test-helpers" const AVATAR = { src: "", alt: "Logo", initials: "AC" } @@ -33,7 +34,7 @@ describe("", { 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("", { 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("", { 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("", { 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("", { 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("", { concurrent: false }, () => { }) async function openAppearanceSubMenu() { - menuItem("Appearance").click() + menuItem(t("sidebar.header.appearance.title")).click() await nextTick() await nextTick() } diff --git a/web/app/components/CalendarInput.nuxt.test.ts b/web/app/components/CalendarInput.nuxt.test.ts index f1f03138..75fc1cc1 100644 --- a/web/app/components/CalendarInput.nuxt.test.ts +++ b/web/app/components/CalendarInput.nuxt.test.ts @@ -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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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, }) diff --git a/web/app/components/DocumentDeletionModal.nuxt.test.ts b/web/app/components/DocumentDeletionModal.nuxt.test.ts index 60275545..c70433a7 100644 --- a/web/app/components/DocumentDeletionModal.nuxt.test.ts +++ b/web/app/components/DocumentDeletionModal.nuxt.test.ts @@ -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; name: string } | null, @@ -19,6 +19,12 @@ function dialog() { // the dialog body is teleported into the shared 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("", { concurrent: false }, () => { beforeEach(clearTeleportedOverlays) @@ -41,7 +47,7 @@ describe("", { 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("", { 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("", { 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("", { 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) diff --git a/web/app/components/DocumentDeletionModal.vue b/web/app/components/DocumentDeletionModal.vue index 72513e25..c25d5481 100644 --- a/web/app/components/DocumentDeletionModal.vue +++ b/web/app/components/DocumentDeletionModal.vue @@ -58,6 +58,7 @@ async function deleteDocument() {

({ @@ -146,16 +147,19 @@ describe("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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 ({ diff --git a/web/app/components/DocumentHeader.vue b/web/app/components/DocumentHeader.vue index e1b88ce9..1037a792 100644 --- a/web/app/components/DocumentHeader.vue +++ b/web/app/components/DocumentHeader.vue @@ -170,7 +170,11 @@ function activateBranch(branch: "default" | "draft") {
- + diff --git a/web/app/components/NotificationBox.nuxt.test.ts b/web/app/components/NotificationBox.nuxt.test.ts index 32bed71c..953b7505 100644 --- a/web/app/components/NotificationBox.nuxt.test.ts +++ b/web/app/components/NotificationBox.nuxt.test.ts @@ -15,6 +15,7 @@ import { mockAuthOrganization, renderedIconNames, seedAuthOrganization, + t, } from "./test-helpers" vi.mock("vue-sonner", () => ({ @@ -100,7 +101,7 @@ describe("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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("", { 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) }) diff --git a/web/app/components/SearchModal.nuxt.test.ts b/web/app/components/SearchModal.nuxt.test.ts index 05afc54a..057a07a5 100644 --- a/web/app/components/SearchModal.nuxt.test.ts +++ b/web/app/components/SearchModal.nuxt.test.ts @@ -231,7 +231,7 @@ describe("", { concurrent: false }, () => { expect, }) => { mockEndpoint("GET", "/api/documents/search", () => { - throw new Error("boom") + throw createError({ statusCode: 500 }) }) await mountModal() diff --git a/web/app/components/SearchModal.vue b/web/app/components/SearchModal.vue index 698e5809..b358e27c 100644 --- a/web/app/components/SearchModal.vue +++ b/web/app/components/SearchModal.vue @@ -7,8 +7,13 @@ const open = defineModel({ 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) { > ", { 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 ({ diff --git a/web/app/components/SidebarItem.nuxt.test.ts b/web/app/components/SidebarItem.nuxt.test.ts index 9d44a152..2a7b6f0b 100644 --- a/web/app/components/SidebarItem.nuxt.test.ts +++ b/web/app/components/SidebarItem.nuxt.test.ts @@ -11,6 +11,7 @@ import { menuItem, mountUnderSidebarProvider, renderedIconNames, + t, } from "./test-helpers" interface CapturedDragOptions { @@ -242,16 +243,25 @@ describe("", { 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) diff --git a/web/app/components/editor/ContentEditor.nuxt.test.ts b/web/app/components/editor/ContentEditor.nuxt.test.ts new file mode 100644 index 00000000..5662777f --- /dev/null +++ b/web/app/components/editor/ContentEditor.nuxt.test.ts @@ -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 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 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 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("", { 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"]]) + }) +}) diff --git a/web/app/components/editor/DocumentContainer.client.nuxt.test.ts b/web/app/components/editor/DocumentContainer.client.nuxt.test.ts new file mode 100644 index 00000000..821591b6 --- /dev/null +++ b/web/app/components/editor/DocumentContainer.client.nuxt.test.ts @@ -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>()), + 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 = {}) { + 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("", { 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) + }) +}) diff --git a/web/app/components/editor/DocumentContainer.client.vue b/web/app/components/editor/DocumentContainer.client.vue index 28531784..be2a8d6c 100644 --- a/web/app/components/editor/DocumentContainer.client.vue +++ b/web/app/components/editor/DocumentContainer.client.vue @@ -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" diff --git a/web/app/components/editor/IconPicker.nuxt.test.ts b/web/app/components/editor/IconPicker.nuxt.test.ts new file mode 100644 index 00000000..94d04363 --- /dev/null +++ b/web/app/components/editor/IconPicker.nuxt.test.ts @@ -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 = {}) { + 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("", { 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() + }) +}) diff --git a/web/app/components/editor/IconPickerProvider.nuxt.test.ts b/web/app/components/editor/IconPickerProvider.nuxt.test.ts new file mode 100644 index 00000000..196e3df8 --- /dev/null +++ b/web/app/components/editor/IconPickerProvider.nuxt.test.ts @@ -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>) { + 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 and vue keeps +// patching it while the provider is mounted, so these tests cannot +// interleave and each provider is taken down properly +describe("", { 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("") + }) +}) diff --git a/web/app/components/editor/IconStack.nuxt.test.ts b/web/app/components/editor/IconStack.nuxt.test.ts new file mode 100644 index 00000000..b7257b86 --- /dev/null +++ b/web/app/components/editor/IconStack.nuxt.test.ts @@ -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 { + return { id: `id-${name}`, name: name, ...overrides } +} + +function mountStack(props: Record) { + 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 +describe("", { 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("") + }) +}) diff --git a/web/app/components/editor/IconStack.vue b/web/app/components/editor/IconStack.vue index 0220c7c2..dd27c798 100644 --- a/web/app/components/editor/IconStack.vue +++ b/web/app/components/editor/IconStack.vue @@ -116,6 +116,7 @@ const extra = computed(() => Math.max(0, props.icons.length - 3)) []) { + 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("", { 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) + }) +}) diff --git a/web/app/components/editor/NameEditor.nuxt.test.ts b/web/app/components/editor/NameEditor.nuxt.test.ts new file mode 100644 index 00000000..c0fa204d --- /dev/null +++ b/web/app/components/editor/NameEditor.nuxt.test.ts @@ -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("", { 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) + }) +}) diff --git a/web/app/components/editor/NoDocumentIndicator.nuxt.test.ts b/web/app/components/editor/NoDocumentIndicator.nuxt.test.ts new file mode 100644 index 00000000..ea02d1cb --- /dev/null +++ b/web/app/components/editor/NoDocumentIndicator.nuxt.test.ts @@ -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 = {}) { + 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("", () => { + 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() + }) +}) diff --git a/web/app/components/editor/ReviewerList.nuxt.test.ts b/web/app/components/editor/ReviewerList.nuxt.test.ts new file mode 100644 index 00000000..551bd0cc --- /dev/null +++ b/web/app/components/editor/ReviewerList.nuxt.test.ts @@ -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[]) { + 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( + "[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("", { 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) + }) +}) diff --git a/web/app/components/editor/TextBubbleMenu.nuxt.test.ts b/web/app/components/editor/TextBubbleMenu.nuxt.test.ts new file mode 100644 index 00000000..8eb54e76 --- /dev/null +++ b/web/app/components/editor/TextBubbleMenu.nuxt.test.ts @@ -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 = {}, +): Promise { + return mountSuspended(TextBubbleMenu, { + props: { editor: target, container: null, ...props }, + }) as Promise +} + +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("", { 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("

hello world

") + 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("

hello

") + 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("

hello

") + 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("

hello

") + 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("

hello world

") + 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("

hello

") + 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("

hello world

") + 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( + '

docs

', + ) + 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("

hello world

") + 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("

hello world

") + 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("

hello world

") + 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("

hello world

") + 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("

hello world

") + selectAll(target) + + const wrapper = await mountMenu(target) + + expect( + wrapper.get("[data-visible], .opacity-0").attributes("data-visible"), + ).toBeUndefined() + }) +}) diff --git a/web/app/components/editor/ai/ChatBox.nuxt.test.ts b/web/app/components/editor/ai/ChatBox.nuxt.test.ts new file mode 100644 index 00000000..b62f126b --- /dev/null +++ b/web/app/components/editor/ai/ChatBox.nuxt.test.ts @@ -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, + isStreaming: null as unknown as Ref, + toolStatus: null as unknown as Ref, + streamingText: null as unknown as Ref, + 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 = {}) { + 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("", { 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(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: "", + }, + ] + + const wrapper = await mountChat() + + expect(wrapper.find(".prose img").exists()).toBe(false) + expect(wrapper.get(".prose").text()).toContain(" { + 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) + }) +}) diff --git a/web/app/components/editor/blocks/callout/MainBlock.nuxt.test.ts b/web/app/components/editor/blocks/callout/MainBlock.nuxt.test.ts new file mode 100644 index 00000000..8e4cfcbf --- /dev/null +++ b/web/app/components/editor/blocks/callout/MainBlock.nuxt.test.ts @@ -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 = {}) { + 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("", { 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) + }) +}) diff --git a/web/app/components/editor/blocks/code-block/Code.nuxt.test.ts b/web/app/components/editor/blocks/code-block/Code.nuxt.test.ts new file mode 100644 index 00000000..f120e387 --- /dev/null +++ b/web/app/components/editor/blocks/code-block/Code.nuxt.test.ts @@ -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 + textContent?: string + type?: string + state?: Record + 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("", { 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) + }) +}) diff --git a/web/app/components/editor/blocks/code-block/CodeButtons.nuxt.test.ts b/web/app/components/editor/blocks/code-block/CodeButtons.nuxt.test.ts new file mode 100644 index 00000000..eb4a9acb --- /dev/null +++ b/web/app/components/editor/blocks/code-block/CodeButtons.nuxt.test.ts @@ -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 + textContent?: string + type?: string + editor?: EditorStub + updateAttributes?: (attrs: Record) => 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("", { 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", + ) + }) +}) diff --git a/web/app/components/editor/blocks/code-block/CodeTitle.nuxt.test.ts b/web/app/components/editor/blocks/code-block/CodeTitle.nuxt.test.ts new file mode 100644 index 00000000..8a0eb0fb --- /dev/null +++ b/web/app/components/editor/blocks/code-block/CodeTitle.nuxt.test.ts @@ -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 = {}, 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("", { 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", + ) + }, + ) +}) diff --git a/web/app/components/editor/blocks/figma/FigmaBlock.nuxt.test.ts b/web/app/components/editor/blocks/figma/FigmaBlock.nuxt.test.ts new file mode 100644 index 00000000..5119307b --- /dev/null +++ b/web/app/components/editor/blocks/figma/FigmaBlock.nuxt.test.ts @@ -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 = {}, + updateAttributes: (attrs: Record) => void = () => undefined, +) { + return mountNodeView(FigmaBlock, { + node: makeNode({ uid: "figma-1", ...attrs }), + updateAttributes: updateAttributes, + }) +} + +// the url popover is teleported into , 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("", { 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) + }) +}) diff --git a/web/app/components/editor/blocks/image/ImageBlock.nuxt.test.ts b/web/app/components/editor/blocks/image/ImageBlock.nuxt.test.ts new file mode 100644 index 00000000..357bb659 --- /dev/null +++ b/web/app/components/editor/blocks/image/ImageBlock.nuxt.test.ts @@ -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 = {}, + updateAttributes: (attrs: Record) => 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("", { 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) + }) +}) diff --git a/web/app/components/editor/blocks/image/ImageBlock.vue b/web/app/components/editor/blocks/image/ImageBlock.vue index e96abd36..e04ef08f 100644 --- a/web/app/components/editor/blocks/image/ImageBlock.vue +++ b/web/app/components/editor/blocks/image/ImageBlock.vue @@ -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(null) const fileInputRef = ref(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 }) diff --git a/web/app/components/editor/blocks/mermaid/MainBlock.nuxt.test.ts b/web/app/components/editor/blocks/mermaid/MainBlock.nuxt.test.ts new file mode 100644 index 00000000..34deff3d --- /dev/null +++ b/web/app/components/editor/blocks/mermaid/MainBlock.nuxt.test.ts @@ -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: "" }), + isLoading: ref(false), + loadError: ref(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>([[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 + 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("", { 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") + }) +}) diff --git a/web/app/components/editor/blocks/mermaid/MermaidPreview.nuxt.test.ts b/web/app/components/editor/blocks/mermaid/MermaidPreview.nuxt.test.ts new file mode 100644 index 00000000..d6ae79c8 --- /dev/null +++ b/web/app/components/editor/blocks/mermaid/MermaidPreview.nuxt.test.ts @@ -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>>(), + isLoading: null as unknown as Ref, + loadError: null as unknown as Ref, + } +}) + +vi.mock("./useMermaid", async () => { + const { ref } = await import("vue") + + mermaid.isLoading = ref(false) + mermaid.loadError = ref(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("", { 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: "" }) + 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("") + }, 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: "kept", + }) + + 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) => 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: "" }) + 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("") + }, 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) => 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: '' }) + await vi.advanceTimersByTimeAsync(0) + pending[0]?.({ 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) + }) +}) diff --git a/web/app/components/editor/blocks/metrics/ConfigField.nuxt.test.ts b/web/app/components/editor/blocks/metrics/ConfigField.nuxt.test.ts new file mode 100644 index 00000000..67d46532 --- /dev/null +++ b/web/app/components/editor/blocks/metrics/ConfigField.nuxt.test.ts @@ -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 unknown>) { + return mountSuspended(ConfigField, { slots: slots }) +} + +describe("", () => { + 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) + }) +}) diff --git a/web/app/components/editor/blocks/metrics/GridView.nuxt.test.ts b/web/app/components/editor/blocks/metrics/GridView.nuxt.test.ts new file mode 100644 index 00000000..775a4953 --- /dev/null +++ b/web/app/components/editor/blocks/metrics/GridView.nuxt.test.ts @@ -0,0 +1,33 @@ +import { describe, it } from "vitest" +import GridView from "./GridView.vue" +import { makeNode, mountNodeView } from "../../test-helpers/node-view" + +describe("", () => { + 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") + }) +}) diff --git a/web/app/components/editor/blocks/metrics/MainBlock.nuxt.test.ts b/web/app/components/editor/blocks/metrics/MainBlock.nuxt.test.ts new file mode 100644 index 00000000..76f59f05 --- /dev/null +++ b/web/app/components/editor/blocks/metrics/MainBlock.nuxt.test.ts @@ -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 } | null + editing?: { uid: string; name: string; color: string } | null + } = {}, +) { + const states = new Map>([[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 + updateAttributes?: (attrs: Record) => void + getPos?: () => number | undefined + nodeAtPos?: { attrs: Record } | 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("", { 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") + }) +}) diff --git a/web/app/components/editor/blocks/metrics/PopoverConfigBox.nuxt.test.ts b/web/app/components/editor/blocks/metrics/PopoverConfigBox.nuxt.test.ts new file mode 100644 index 00000000..371045f2 --- /dev/null +++ b/web/app/components/editor/blocks/metrics/PopoverConfigBox.nuxt.test.ts @@ -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("", { 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) + }) +}) diff --git a/web/app/components/editor/blocks/metrics/VisualizationContainer.nuxt.test.ts b/web/app/components/editor/blocks/metrics/VisualizationContainer.nuxt.test.ts new file mode 100644 index 00000000..2dd41393 --- /dev/null +++ b/web/app/components/editor/blocks/metrics/VisualizationContainer.nuxt.test.ts @@ -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 { + 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 = {}, +) { + 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("", { 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() + }) +}) diff --git a/web/app/components/editor/blocks/metrics/VisualizationContainer.vue b/web/app/components/editor/blocks/metrics/VisualizationContainer.vue index 12187083..c81a4758 100644 --- a/web/app/components/editor/blocks/metrics/VisualizationContainer.vue +++ b/web/app/components/editor/blocks/metrics/VisualizationContainer.vue @@ -415,6 +415,7 @@ function openModal() { diff --git a/web/app/components/editor/blocks/metrics/config-modal/BaseModal.client.nuxt.test.ts b/web/app/components/editor/blocks/metrics/config-modal/BaseModal.client.nuxt.test.ts new file mode 100644 index 00000000..e37db1bc --- /dev/null +++ b/web/app/components/editor/blocks/metrics/config-modal/BaseModal.client.nuxt.test.ts @@ -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("", { concurrent: false }, () => { + // the dialog body is teleported into 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) + }) +}) diff --git a/web/app/components/editor/blocks/metrics/config-modal/BottomConfig.nuxt.test.ts b/web/app/components/editor/blocks/metrics/config-modal/BottomConfig.nuxt.test.ts new file mode 100644 index 00000000..7cb7b457 --- /dev/null +++ b/web/app/components/editor/blocks/metrics/config-modal/BottomConfig.nuxt.test.ts @@ -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 { + return { + ...defaultMetricConfig(), + dataSourceId: DATA_SOURCE_ID, + ...overrides, + } +} + +function mountBottom(props: Record = {}) { + 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 , so these tests cannot interleave +describe("", { 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) + }) +}) diff --git a/web/app/components/editor/blocks/metrics/config-modal/ColorSelect.nuxt.test.ts b/web/app/components/editor/blocks/metrics/config-modal/ColorSelect.nuxt.test.ts new file mode 100644 index 00000000..d931f163 --- /dev/null +++ b/web/app/components/editor/blocks/metrics/config-modal/ColorSelect.nuxt.test.ts @@ -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("", { 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)]]) + }) +}) diff --git a/web/app/components/editor/blocks/metrics/config-modal/ConfigSidebar.nuxt.test.ts b/web/app/components/editor/blocks/metrics/config-modal/ConfigSidebar.nuxt.test.ts new file mode 100644 index 00000000..a7fed181 --- /dev/null +++ b/web/app/components/editor/blocks/metrics/config-modal/ConfigSidebar.nuxt.test.ts @@ -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 { + return { ...defaultMetricConfig(), ...overrides } +} + +function mountSidebar(props: Record = {}) { + 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[] { + 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): 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 +// , so these tests cannot interleave +describe("", { 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) + }) +}) diff --git a/web/app/components/editor/blocks/metrics/config-modal/DataSourceSelect.nuxt.test.ts b/web/app/components/editor/blocks/metrics/config-modal/DataSourceSelect.nuxt.test.ts new file mode 100644 index 00000000..15117475 --- /dev/null +++ b/web/app/components/editor/blocks/metrics/config-modal/DataSourceSelect.nuxt.test.ts @@ -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 = {}) { + 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 +// , so these tests cannot interleave +describe("", { 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("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() + }) +}) diff --git a/web/app/components/editor/blocks/metrics/config-modal/DataSourceSelect.vue b/web/app/components/editor/blocks/metrics/config-modal/DataSourceSelect.vue index 4e5444ad..7bb3b149 100644 --- a/web/app/components/editor/blocks/metrics/config-modal/DataSourceSelect.vue +++ b/web/app/components/editor/blocks/metrics/config-modal/DataSourceSelect.vue @@ -121,6 +121,7 @@ function handleDataSourceSelection(dataSourceId: string) { >