diff --git a/.github/workflows/qa-web.yml b/.github/workflows/qa-web.yml
index e43acead..55b311e9 100644
--- a/.github/workflows/qa-web.yml
+++ b/.github/workflows/qa-web.yml
@@ -69,6 +69,10 @@ jobs:
run: pnpm install --frozen-lockfile
working-directory: web
+ - name: Install playwright browsers
+ run: pnpm exec playwright install --with-deps chromium
+ working-directory: web
+
- name: Run tests
run: pnpm run test
working-directory: web
diff --git a/web/CLAUDE.md b/web/CLAUDE.md
index 6bde53ce..05048bed 100644
--- a/web/CLAUDE.md
+++ b/web/CLAUDE.md
@@ -13,7 +13,9 @@ Package manager is **pnpm** (workspace; uses `nodeLinker: hoisted` so Electron F
```bash
pnpm install # installs deps; runs `prepare` (builds lezer-promql + nuxt prepare)
-pnpm setup # deps + explicit prepare (guarantees fresh lezer build + .nuxt types)
+pnpm setup # deps + explicit prepare (guarantees fresh lezer
+ # build + .nuxt types) + playwright chromium for
+ # browser-mode tests
pnpm start:dev:web # nuxt dev on :3000 (web build)
pnpm start:dev:desktop # concurrently runs nuxt dev + electron-forge start with DESKTOP_BUILD=hybrid
@@ -26,7 +28,11 @@ pnpm check-lint # check-types + check-eslint + check-fmt +
pnpm lint # fixing variant: check-types + knip --fix
# (removes dead exports/deps/files!) + eslint
# --fix + prettier --write
-pnpm qa # check-lint (will grow tests later); qa-fix = lint
+pnpm test # vitest run --coverage (node + browser + nuxt
+ # projects; browser needs the playwright chromium
+ # that `pnpm setup` installs)
+pnpm test-watch # vitest in watch mode
+pnpm qa # check-lint + test; qa-fix = lint + test
pnpm check-types # nuxt typecheck (regenerates .nuxt types, then
# vue-tsc -b --noEmit; plain vue-tsc --noEmit on
# the solution-style root tsconfig checks nothing)
@@ -39,7 +45,6 @@ pnpm check-knip # dead exports/files/dependencies ([knip.ts](knip.
pnpm build:lezer-promql # rebuild the forked PromQL grammar package
```
-There is no test runner configured in web/.
## Build modes (critical)
@@ -144,6 +149,9 @@ Note `noUncheckedIndexedAccess` makes every index access `T | undefined`, and Ty
- When a test exercises behaviour spanning two source files, it lives
with the file that initiates the behaviour (a round-trip test lands
beside the file that starts the round-trip).
+- **The test file mirrors the source file's order**: top-level
+ `describe` blocks appear in the same order as the functions they test
+ appear in the source, so the two files read side by side.
- **The suffix encodes the test environment** β never `.spec.ts`, never an
`@vitest-environment` pragma comment:
@@ -151,7 +159,7 @@ Note `noUncheckedIndexedAccess` makes every index access `T | undefined`, and Ty
| --- | --- | --- |
| `.test.ts` | node, no DOM | pure logic: utils, editor diff/blocks helpers, `electron/` (with `vi.mock("electron")`) |
| `.nuxt.test.ts` | nuxt runtime | composables/stores (auto-imports, `registerEndpoint`) and **all component tests** (`mountSuspended`, real children, mocked network) |
- | `.browser.test.ts` | vitest browser mode | DOM behavior happy-dom fakes (e.g. `getBoundingClientRect` geometry in drag-handle) |
+ | `.browser.test.ts` | vitest browser mode (headless chromium via playwright) | DOM behavior happy-dom fakes (e.g. real `getBoundingClientRect` geometry in tiptap overlays, DOMPurify) |
| `.test-d.ts` | typecheck only | compile-time contracts (`expectTypeOf`), e.g. `app/utils/api/` types |
| `.bench.ts` | `vitest bench` | perf on hot paths (diff/lcs) β add only when a regression bites |
diff --git a/web/app/utils/api/comment.test.ts b/web/app/utils/api/comment.test.ts
new file mode 100644
index 00000000..55b4033a
--- /dev/null
+++ b/web/app/utils/api/comment.test.ts
@@ -0,0 +1,73 @@
+import { describe, it } from "vitest"
+import {
+ makeWsDocumentCommentChangeTopic,
+ promoteFirstDocumentReplyToComment,
+ type DocumentComment,
+ type DocumentCommentReply,
+} from "./comment"
+
+function comment(replies?: DocumentCommentReply[]): DocumentComment {
+ return {
+ id: "c1",
+ organizationId: "o1",
+ documentId: "d1",
+ branchId: "b1",
+ anchorBlockId: "blk1",
+ userId: "author",
+ resolved: false,
+ content: { text: "original" },
+ createdAt: "2024-06-01T10:00:00Z",
+ replies,
+ }
+}
+
+function reply(
+ id: string,
+ userId: string,
+ createdAt: string,
+): DocumentCommentReply {
+ return {
+ id,
+ organizationId: "o1",
+ commentId: "c1",
+ userId,
+ content: { text: `reply-${id}` },
+ createdAt,
+ updatedAt: null,
+ }
+}
+
+describe("makeWsDocumentCommentChangeTopic", () => {
+ it("builds the comment change topic for the document", ({ expect }) => {
+ expect(makeWsDocumentCommentChangeTopic("d1")).toBe(
+ "change@documents.d1.comments",
+ )
+ })
+})
+
+describe("promoteFirstDocumentReplyToComment", () => {
+ it("promotes the earliest reply into the comment", ({ expect }) => {
+ const later = reply("r2", "u2", "2024-06-02T10:00:00Z")
+ const earliest = reply("r1", "u1", "2024-06-01T12:00:00Z")
+
+ const result = promoteFirstDocumentReplyToComment(
+ comment([later, earliest]),
+ )
+
+ expect(result).toMatchObject({
+ id: "c1",
+ userId: "u1",
+ content: { text: "reply-r1" },
+ createdAt: "2024-06-01T12:00:00Z",
+ replies: [later],
+ })
+ })
+
+ it("returns null when the comment has no replies", ({ expect }) => {
+ expect(promoteFirstDocumentReplyToComment(comment([]))).toBeNull()
+ })
+
+ it("returns null when the replies are missing entirely", ({ expect }) => {
+ expect(promoteFirstDocumentReplyToComment(comment(undefined))).toBeNull()
+ })
+})
diff --git a/web/app/utils/api/data-source/generic-query.test.ts b/web/app/utils/api/data-source/generic-query.test.ts
new file mode 100644
index 00000000..c4f26ac7
--- /dev/null
+++ b/web/app/utils/api/data-source/generic-query.test.ts
@@ -0,0 +1,40 @@
+import { describe, it } from "vitest"
+import { formatQueryTimeRange } from "./generic-query"
+
+describe("formatQueryTimeRange", () => {
+ it.for([
+ {
+ name: "returns no params without a time range",
+ timeRange: undefined,
+ expected: {},
+ },
+ {
+ name: "returns no params for an empty time range",
+ timeRange: {},
+ expected: {},
+ },
+ {
+ name: "converts Date bounds to ISO strings",
+ timeRange: {
+ from: new Date("2024-06-15T12:00:00Z"),
+ to: new Date("2024-06-15T13:00:00Z"),
+ },
+ expected: {
+ from: "2024-06-15T12:00:00.000Z",
+ to: "2024-06-15T13:00:00.000Z",
+ },
+ },
+ {
+ name: "passes string bounds through unchanged",
+ timeRange: { from: "now-1h", to: "now" },
+ expected: { from: "now-1h", to: "now" },
+ },
+ {
+ name: "includes only the bound that is set",
+ timeRange: { to: "now" },
+ expected: { to: "now" },
+ },
+ ])("$name", ({ timeRange, expected }, { expect }) => {
+ expect(formatQueryTimeRange(timeRange)).toEqual(expected)
+ })
+})
diff --git a/web/app/utils/api/data-source/index.test.ts b/web/app/utils/api/data-source/index.test.ts
new file mode 100644
index 00000000..edcd6e98
--- /dev/null
+++ b/web/app/utils/api/data-source/index.test.ts
@@ -0,0 +1,15 @@
+import { describe, it } from "vitest"
+import { DataSourceType, isDataSourceSQLBased } from "./index"
+
+describe("isDataSourceSQLBased", () => {
+ it.for([
+ { type: DataSourceType.PostgreSQL, expected: true },
+ { type: DataSourceType.MySQL, expected: true },
+ { type: DataSourceType.MariaDB, expected: true },
+ { type: DataSourceType.Prometheus, expected: false },
+ { type: null, expected: false },
+ { type: undefined, expected: false },
+ ])("returns $expected for $type", ({ type, expected }, { expect }) => {
+ expect(isDataSourceSQLBased(type)).toBe(expected)
+ })
+})
diff --git a/web/app/utils/api/document.test.ts b/web/app/utils/api/document.test.ts
new file mode 100644
index 00000000..01868e80
--- /dev/null
+++ b/web/app/utils/api/document.test.ts
@@ -0,0 +1,132 @@
+import { describe, it, vi } from "vitest"
+import {
+ DocumentHookType,
+ docNameByIdInDocumentTree,
+ defaultDocumentHookState,
+ isIdInDocumentTree,
+ makeWsDocumentMaintainersChangeTopic,
+ makeWsDocumentMetadataChangeTopic,
+ makeWsDocumentReviewersChangeTopic,
+ type DocumentTreeElement,
+} from "./document"
+
+function element(
+ id: string,
+ children?: DocumentTreeElement[] | null,
+): DocumentTreeElement {
+ return {
+ id,
+ documentName: `doc-${id}`,
+ icon: "icon",
+ protected: false,
+ children,
+ }
+}
+
+const tree = [element("a", [element("b", [element("c")]), element("d", null)])]
+
+describe("makeWsDocumentMetadataChangeTopic", () => {
+ it("builds the metadata change topic for the document", ({ expect }) => {
+ expect(makeWsDocumentMetadataChangeTopic("d1")).toBe(
+ "change@documents.d1.metadata",
+ )
+ })
+})
+
+describe("makeWsDocumentMaintainersChangeTopic", () => {
+ it("builds the maintainers change topic for the document", ({ expect }) => {
+ expect(makeWsDocumentMaintainersChangeTopic("d1")).toBe(
+ "change@documents.d1.maintainers",
+ )
+ })
+})
+
+describe("makeWsDocumentReviewersChangeTopic", () => {
+ it("builds the reviewers change topic for the document", ({ expect }) => {
+ expect(makeWsDocumentReviewersChangeTopic("d1")).toBe(
+ "change@documents.d1.reviewers",
+ )
+ })
+})
+
+describe("isIdInDocumentTree", () => {
+ it.for([
+ { name: "finds a top-level element", id: "a", expected: true },
+ { name: "finds a deeply nested element", id: "c", expected: true },
+ { name: "handles null children", id: "d", expected: true },
+ { name: "rejects an unknown id", id: "x", expected: false },
+ ])("$name", ({ id, expected }, { expect }) => {
+ expect(isIdInDocumentTree(tree, id)).toBe(expected)
+ })
+
+ it("returns false for an empty tree", ({ expect }) => {
+ expect(isIdInDocumentTree([], "a")).toBe(false)
+ })
+})
+
+describe("docNameByIdInDocumentTree", () => {
+ it.for([
+ {
+ name: "returns the name of a top-level element",
+ id: "a",
+ expected: "doc-a",
+ },
+ {
+ name: "returns the name of a nested element",
+ id: "c",
+ expected: "doc-c",
+ },
+ { name: "returns null for an unknown id", id: "x", expected: null },
+ ])("$name", ({ id, expected }, { expect }) => {
+ expect(docNameByIdInDocumentTree(tree, id)).toBe(expected)
+ })
+})
+
+describe("defaultDocumentHookState", () => {
+ it("stamps the scheduled reminder state with the current time", ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date("2024-06-15T12:00:00Z"))
+
+ try {
+ expect(
+ defaultDocumentHookState(DocumentHookType.ScheduledReminder),
+ ).toEqual({ lastActiveAt: new Date("2024-06-15T12:00:00Z") })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it("stamps the url watcher state with the current time", ({ expect }) => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date("2024-06-15T12:00:00Z"))
+
+ try {
+ expect(defaultDocumentHookState(DocumentHookType.URLWatcher)).toEqual({
+ lastCheckedAt: new Date("2024-06-15T12:00:00Z"),
+ status: "active",
+ })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it("returns an empty checksum state for github tracking", ({ expect }) => {
+ expect(defaultDocumentHookState(DocumentHookType.GitHubTracking)).toEqual({
+ pathsChecksums: {},
+ status: "active",
+ })
+ })
+
+ it("returns an empty digest state for the container image watcher", ({
+ expect,
+ }) => {
+ expect(
+ defaultDocumentHookState(DocumentHookType.ContainerImageWatcher),
+ ).toEqual({
+ status: "active",
+ digest: "",
+ })
+ })
+})
diff --git a/web/app/utils/auth.test.ts b/web/app/utils/auth.test.ts
new file mode 100644
index 00000000..6cb10e8f
--- /dev/null
+++ b/web/app/utils/auth.test.ts
@@ -0,0 +1,53 @@
+import { describe, it } from "vitest"
+import { postAuthDocumentUrl, postEmailVerificationUrl } from "./auth"
+
+describe("postAuthDocumentUrl", () => {
+ it.for([
+ {
+ name: "joins the base url with the decoded next path",
+ baseUrl: "https://app.test",
+ next: "%2Fdocs%2Fabc%3Fx%3D1",
+ expected: "https://app.test/docs/abc?x=1",
+ },
+ {
+ name: "falls back to the default path without a next path",
+ baseUrl: "https://app.test",
+ next: undefined,
+ expected: "https://app.test/",
+ },
+ {
+ name: "treats a missing base url as empty",
+ baseUrl: undefined,
+ next: "%2Fdocs",
+ expected: "/docs",
+ },
+ {
+ name: "returns the default path alone without base url and next",
+ baseUrl: undefined,
+ next: undefined,
+ expected: "/",
+ },
+ ])("$name", ({ baseUrl, next, expected }, { expect }) => {
+ expect(postAuthDocumentUrl(baseUrl, next)).toBe(expected)
+ })
+
+ it("uses a custom default path when given", ({ expect }) => {
+ expect(postAuthDocumentUrl("https://app.test", undefined, "/home")).toBe(
+ "https://app.test/home",
+ )
+ })
+})
+
+describe("postEmailVerificationUrl", () => {
+ it("builds the verification url with the email encoded", ({ expect }) => {
+ expect(postEmailVerificationUrl("https://app.test", "a+b@test.io")).toBe(
+ "https://app.test/verify-email?new=a%2Bb%40test.io",
+ )
+ })
+
+ it("treats a missing base url as empty", ({ expect }) => {
+ expect(postEmailVerificationUrl(undefined, "a@test.io")).toBe(
+ "/verify-email?new=a%40test.io",
+ )
+ })
+})
diff --git a/web/app/utils/html.browser.test.ts b/web/app/utils/html.browser.test.ts
new file mode 100644
index 00000000..392842dd
--- /dev/null
+++ b/web/app/utils/html.browser.test.ts
@@ -0,0 +1,40 @@
+import { afterEach, describe, it } from "vitest"
+import { waitForHtmlElementById } from "./html"
+
+// the page DOM is shared between tests, so they cannot interleave
+describe("waitForHtmlElementById", { concurrent: false }, () => {
+ afterEach(() => {
+ document.body.innerHTML = ""
+ })
+
+ it("resolves an element that is already present", async ({ expect }) => {
+ const el = document.createElement("div")
+ el.id = "present"
+ document.body.appendChild(el)
+
+ await expect(waitForHtmlElementById("present", 1000)).resolves.toBe(el)
+ })
+
+ it("resolves an element that appears before the timeout", async ({
+ expect,
+ }) => {
+ const pending = waitForHtmlElementById("late", 1000)
+
+ // let the first check miss, then attach the element for the next
+ // animation frame of the polling loop
+ await new Promise(requestAnimationFrame)
+ const el = document.createElement("div")
+ el.id = "late"
+ document.body.appendChild(el)
+
+ await expect(pending).resolves.toBe(el)
+ })
+
+ it("resolves null once the timeout elapses without the element", async ({
+ expect,
+ }) => {
+ // awaiting the unit's own promise is the concrete signal; the
+ // timeout budget is what bounds the polling loop
+ await expect(waitForHtmlElementById("never", 50)).resolves.toBeNull()
+ })
+})
diff --git a/web/app/utils/html.test.ts b/web/app/utils/html.test.ts
new file mode 100644
index 00000000..75ff4fdc
--- /dev/null
+++ b/web/app/utils/html.test.ts
@@ -0,0 +1,9 @@
+import { describe, it } from "vitest"
+import { waitForHtmlElementById } from "./html"
+
+describe("waitForHtmlElementById", () => {
+ // node has no document, so this exercises the SSR guard directly
+ it("resolves null when no document exists", async ({ expect }) => {
+ await expect(waitForHtmlElementById("any", 1000)).resolves.toBeNull()
+ })
+})
diff --git a/web/app/utils/icon.test.ts b/web/app/utils/icon.test.ts
new file mode 100644
index 00000000..77a81770
--- /dev/null
+++ b/web/app/utils/icon.test.ts
@@ -0,0 +1,17 @@
+import { describe, it } from "vitest"
+import { selectableIconList, selectableIcons } from "./icon"
+
+describe("selectableIconList", () => {
+ it("returns the id of every selectable icon in order", ({ expect }) => {
+ const list = selectableIconList()
+
+ expect(list).toEqual(selectableIcons.map((v) => v.id))
+ expect(list.length).toBeGreaterThan(0)
+ })
+
+ it("returns only prefixed icon ids", ({ expect }) => {
+ for (const id of selectableIconList()) {
+ expect(id).toMatch(/^mingcute:/)
+ }
+ })
+})
diff --git a/web/app/utils/json.test.ts b/web/app/utils/json.test.ts
new file mode 100644
index 00000000..7690d7a2
--- /dev/null
+++ b/web/app/utils/json.test.ts
@@ -0,0 +1,67 @@
+import { describe, it } from "vitest"
+import { jsonReviver, jsonStableStringify } from "./json"
+
+describe("jsonReviver", () => {
+ it.for([
+ {
+ name: "converts an RFC 3339 date string to a Date",
+ input: "2024-06-15T12:30:00Z",
+ expected: new Date("2024-06-15T12:30:00Z"),
+ },
+ {
+ name: "leaves a non-date string unchanged",
+ input: "hello",
+ expected: "hello",
+ },
+ {
+ name: "leaves a date-like string without time unchanged",
+ input: "2024-06-15",
+ expected: "2024-06-15",
+ },
+ {
+ name: "leaves numbers unchanged",
+ input: 42,
+ expected: 42,
+ },
+ {
+ name: "leaves booleans unchanged",
+ input: true,
+ expected: true,
+ },
+ {
+ name: "leaves null unchanged",
+ input: null,
+ expected: null,
+ },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(jsonReviver("key", input)).toEqual(expected)
+ })
+
+ it("revives date fields when used with JSON.parse", ({ expect }) => {
+ const parsed = JSON.parse(
+ '{"at":"2024-06-15T12:30:00Z","name":"doc"}',
+ jsonReviver,
+ ) as { at: Date; name: string }
+
+ expect(parsed.at).toEqual(new Date("2024-06-15T12:30:00Z"))
+ expect(parsed.name).toBe("doc")
+ })
+})
+
+describe("jsonStableStringify", () => {
+ it("serializes objects with sorted keys", ({ expect }) => {
+ expect(jsonStableStringify({ b: 2, a: 1 })).toBe('{"a":1,"b":2}')
+ })
+
+ it("produces identical output regardless of key insertion order", ({
+ expect,
+ }) => {
+ expect(jsonStableStringify({ b: 2, a: { d: 4, c: 3 } })).toBe(
+ jsonStableStringify({ a: { c: 3, d: 4 }, b: 2 }),
+ )
+ })
+
+ it("returns an empty string for undefined", ({ expect }) => {
+ expect(jsonStableStringify(undefined)).toBe("")
+ })
+})
diff --git a/web/app/utils/object.test.ts b/web/app/utils/object.test.ts
new file mode 100644
index 00000000..15b6efbd
--- /dev/null
+++ b/web/app/utils/object.test.ts
@@ -0,0 +1,236 @@
+import { describe, it, vi } from "vitest"
+import {
+ arraysEqual,
+ clone,
+ extractInitials,
+ extractNameFromEmail,
+ isValidDescendent,
+ lastFilePathElement,
+} from "./object"
+
+describe("clone", () => {
+ it("deep clones an object so mutations do not leak back", ({ expect }) => {
+ const original = { a: 1, nested: { b: [1, 2] } }
+
+ const copy = clone(original)
+ copy.nested.b.push(3)
+
+ expect(copy).not.toBe(original)
+ expect(original.nested.b).toEqual([1, 2])
+ expect(copy.nested.b).toEqual([1, 2, 3])
+ })
+
+ it.for([
+ { name: "returns null as-is", input: null },
+ { name: "returns undefined as-is", input: undefined },
+ { name: "returns numbers as-is", input: 42 },
+ { name: "returns strings as-is", input: "hello" },
+ ])("$name", ({ input }, { expect }) => {
+ expect(clone(input)).toBe(input)
+ })
+})
+
+describe("isValidDescendent", () => {
+ // the function only guards nulls and delegates to the DOM contains
+ // check, so a minimal stub is all the element needs to be
+ function element(contains: boolean) {
+ return { contains: vi.fn(() => contains) } as unknown as HTMLElement
+ }
+
+ it("returns true when the ancestor contains the descendent", ({ expect }) => {
+ const contains = vi.fn(() => true)
+ const ancestor = { contains } as unknown as HTMLElement
+ const descendent = element(false)
+
+ expect(isValidDescendent(ancestor, descendent)).toBe(true)
+ expect(contains).toHaveBeenCalledTimes(1)
+ expect(contains).toHaveBeenCalledWith(descendent)
+ })
+
+ it("returns false when the ancestor does not contain the descendent", ({
+ expect,
+ }) => {
+ expect(isValidDescendent(element(false), element(false))).toBe(false)
+ })
+
+ it.for([
+ { name: "returns false for a null ancestor", a: null, d: element(true) },
+ { name: "returns false for a null descendent", a: element(true), d: null },
+ {
+ name: "returns false for undefined arguments",
+ a: undefined,
+ d: undefined,
+ },
+ ])("$name", ({ a, d }, { expect }) => {
+ expect(isValidDescendent(a, d)).toBe(false)
+ })
+})
+
+describe("lastFilePathElement", () => {
+ it.for([
+ {
+ name: "returns the file name of a unix path",
+ input: "a/b/c.txt",
+ expected: "c.txt",
+ },
+ {
+ name: "returns the file name of a windows path",
+ input: "a\\b\\c.txt",
+ expected: "c.txt",
+ },
+ { name: "ignores a trailing slash", input: "a/b/", expected: "b" },
+ {
+ name: "returns a bare file name unchanged",
+ input: "c.txt",
+ expected: "c.txt",
+ },
+ {
+ name: "returns an empty string for an empty path",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "returns an empty string for a bare slash",
+ input: "/",
+ expected: "",
+ },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(lastFilePathElement(input)).toBe(expected)
+ })
+})
+
+describe("arraysEqual", () => {
+ it.for([
+ {
+ name: "returns true for the same reference",
+ a: [1],
+ b: null,
+ same: true,
+ expected: true,
+ },
+ {
+ name: "returns true when both are null",
+ a: null,
+ b: null,
+ same: false,
+ expected: true,
+ },
+ {
+ name: "returns false when only one is null",
+ a: null,
+ b: [1],
+ same: false,
+ expected: false,
+ },
+ {
+ name: "returns true for equal elements",
+ a: [1, 2],
+ b: [1, 2],
+ same: false,
+ expected: true,
+ },
+ {
+ name: "returns true for two empty arrays",
+ a: [],
+ b: [],
+ same: false,
+ expected: true,
+ },
+ {
+ name: "returns false for different lengths",
+ a: [1],
+ b: [1, 2],
+ same: false,
+ expected: false,
+ },
+ {
+ name: "returns false for different elements",
+ a: [1, 2],
+ b: [1, 3],
+ same: false,
+ expected: false,
+ },
+ ])("$name", ({ a, b, same, expected }, { expect }) => {
+ expect(arraysEqual(a, same ? a : b)).toBe(expected)
+ })
+
+ it("compares elements by reference, not structurally", ({ expect }) => {
+ expect(arraysEqual([{ x: 1 }], [{ x: 1 }])).toBe(false)
+ })
+})
+
+describe("extractInitials", () => {
+ it.for([
+ {
+ name: "takes the first letter of each word",
+ input: "John Doe",
+ expected: "JD",
+ },
+ {
+ name: "splits on hyphens too",
+ input: "mary-jane watson",
+ expected: "MJW",
+ },
+ {
+ name: "stops at the maximum when given",
+ input: "a b c d",
+ max: 2,
+ expected: "AB",
+ },
+ {
+ name: "skips non-letter characters to the first letter",
+ input: "πparty time",
+ expected: "PT",
+ },
+ {
+ name: "upper-cases unicode letters",
+ input: "Εukasz nowak",
+ expected: "ΕN",
+ },
+ { name: "ignores extra whitespace", input: " a b ", expected: "AB" },
+ {
+ name: "returns an empty string for empty input",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "returns an empty string without any letters",
+ input: "123 !!!",
+ expected: "",
+ },
+ ])("$name", ({ input, max, expected }, { expect }) => {
+ expect(extractInitials(input, max)).toBe(expected)
+ })
+})
+
+describe("extractNameFromEmail", () => {
+ it.for([
+ {
+ name: "returns the part before the at sign",
+ input: "john.doe@test.io",
+ expected: "john.doe",
+ },
+ {
+ name: "trims surrounding whitespace",
+ input: " a@test.io ",
+ expected: "a",
+ },
+ {
+ name: "returns an empty string without an at sign",
+ input: "john.doe",
+ expected: "",
+ },
+ {
+ name: "returns an empty string for a leading at sign",
+ input: "@test.io",
+ expected: "",
+ },
+ {
+ name: "returns an empty string for an empty input",
+ input: "",
+ expected: "",
+ },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(extractNameFromEmail(input)).toBe(expected)
+ })
+})
diff --git a/web/app/utils/promql.test.ts b/web/app/utils/promql.test.ts
new file mode 100644
index 00000000..993131c0
--- /dev/null
+++ b/web/app/utils/promql.test.ts
@@ -0,0 +1,34 @@
+import { describe, it } from "vitest"
+import { extractPromQLSelectors } from "./promql"
+
+describe("extractPromQLSelectors", () => {
+ it.for([
+ {
+ name: "extracts a bare metric name",
+ query: "up",
+ expected: ["up"],
+ },
+ {
+ name: "extracts a selector with label matchers from a function call",
+ query: 'rate(http_requests_total{job="api"}[5m])',
+ expected: ['http_requests_total{job="api"}'],
+ },
+ {
+ name: "extracts every selector of a binary expression",
+ query: 'a{env="prod"} + b',
+ expected: ['a{env="prod"}', "b"],
+ },
+ {
+ name: "extracts a selector with a dynamic duration placeholder",
+ query: "rate(http_requests_total[$__interval])",
+ expected: ["http_requests_total"],
+ },
+ {
+ name: "returns no selectors for an empty query",
+ query: "",
+ expected: [],
+ },
+ ])("$name", ({ query, expected }, { expect }) => {
+ expect(extractPromQLSelectors(query)).toEqual(expected)
+ })
+})
diff --git a/web/app/utils/sanitize.browser.test.ts b/web/app/utils/sanitize.browser.test.ts
new file mode 100644
index 00000000..bcc98634
--- /dev/null
+++ b/web/app/utils/sanitize.browser.test.ts
@@ -0,0 +1,26 @@
+import { describe, it } from "vitest"
+import { sanitizeSearchResult } from "./sanitize"
+
+describe("sanitizeSearchResult", () => {
+ it("keeps mark tags for highlighting", ({ expect }) => {
+ expect(sanitizeSearchResult("found a hit here")).toBe(
+ "found a hit here",
+ )
+ })
+
+ it("strips every other tag but keeps its text", ({ expect }) => {
+ expect(sanitizeSearchResult("bold and hit")).toBe(
+ "bold and hit",
+ )
+ })
+
+ it("strips attributes from mark tags", ({ expect }) => {
+ expect(sanitizeSearchResult('hit')).toBe(
+ "hit",
+ )
+ })
+
+ it("drops script content entirely", ({ expect }) => {
+ expect(sanitizeSearchResult("safe")).toBe("safe")
+ })
+})
diff --git a/web/app/utils/sanitize.test.ts b/web/app/utils/sanitize.test.ts
new file mode 100644
index 00000000..5ff745b0
--- /dev/null
+++ b/web/app/utils/sanitize.test.ts
@@ -0,0 +1,15 @@
+import { describe, it } from "vitest"
+import { sanitizeSearchResult } from "./sanitize"
+
+describe("sanitizeSearchResult", () => {
+ it("returns an empty string for empty input", ({ expect }) => {
+ expect(sanitizeSearchResult("")).toBe("")
+ })
+
+ // node has no window, so this exercises the server-side fallback
+ it("strips every tag on the server side", ({ expect }) => {
+ expect(sanitizeSearchResult("bold and hit")).toBe(
+ "bold and hit",
+ )
+ })
+})
diff --git a/web/app/utils/shortcuts.test.ts b/web/app/utils/shortcuts.test.ts
new file mode 100644
index 00000000..d1e2e545
--- /dev/null
+++ b/web/app/utils/shortcuts.test.ts
@@ -0,0 +1,91 @@
+import { describe, it } from "vitest"
+import { HostOsType } from "~/composables/useDetectHost"
+import {
+ extractShortcutKeys,
+ normalizeShortcut,
+ shortcutByOS,
+} from "./shortcuts"
+
+const shortcut = { macOS: "β+K", other: "Ctrl+K" }
+
+describe("shortcutByOS", () => {
+ it.for([
+ { os: HostOsType.MacOS, expected: "β+K" },
+ { os: HostOsType.Windows, expected: "Ctrl+K" },
+ { os: HostOsType.Linux, expected: "Ctrl+K" },
+ { os: HostOsType.Other, expected: "Ctrl+K" },
+ ])("returns $expected for the $os os", ({ os, expected }, { expect }) => {
+ expect(shortcutByOS(shortcut, os)).toBe(expected)
+ })
+})
+
+describe("normalizeShortcut", () => {
+ it.for([
+ {
+ name: "normalizes the command symbol for the default format",
+ shortcut: { macOS: "β+K", other: "Ctrl+K" },
+ os: HostOsType.MacOS,
+ format: undefined,
+ expected: "meta_k",
+ },
+ {
+ name: "normalizes control shortcuts for the default format",
+ shortcut: { macOS: "β+K", other: "Ctrl+K" },
+ os: HostOsType.Linux,
+ format: undefined,
+ expected: "control_k",
+ },
+ {
+ name: "normalizes multi-key shortcuts for the default format",
+ shortcut: { macOS: "β+β§+P", other: "Ctrl+Shift+P" },
+ os: HostOsType.MacOS,
+ format: undefined,
+ expected: "meta_shift_p",
+ },
+ {
+ name: "normalizes the command symbol for the codemirror format",
+ shortcut: { macOS: "β+K", other: "Ctrl+K" },
+ os: HostOsType.MacOS,
+ format: "codemirror" as const,
+ expected: "Cmd-k",
+ },
+ {
+ name: "normalizes control shortcuts for the codemirror format",
+ shortcut: { macOS: "Ctrl+Space", other: "Ctrl+Space" },
+ os: HostOsType.Other,
+ format: "codemirror" as const,
+ expected: "Ctrl-space",
+ },
+ {
+ name: "leaves a single plain key untouched",
+ shortcut: { macOS: "esc", other: "esc" },
+ os: HostOsType.MacOS,
+ format: undefined,
+ expected: "esc",
+ },
+ ])("$name", ({ shortcut, os, format, expected }, { expect }) => {
+ expect(normalizeShortcut(shortcut, os, format)).toBe(expected)
+ })
+})
+
+describe("extractShortcutKeys", () => {
+ it("splits a two-key shortcut with a connector between", ({ expect }) => {
+ expect(extractShortcutKeys("Ctrl+K")).toEqual([
+ { key: "Ctrl" },
+ { connector: true },
+ { key: "K" },
+ ])
+ })
+
+ it("returns a single key without connectors", ({ expect }) => {
+ expect(extractShortcutKeys("esc")).toEqual([{ key: "esc" }])
+ })
+
+ it("trims whitespace around keys", ({ expect }) => {
+ expect(extractShortcutKeys("Ctrl + K")).toEqual([
+ { key: "Ctrl" },
+ { connector: true },
+ { key: "K" },
+ ])
+ })
+})
diff --git a/web/app/utils/time.test.ts b/web/app/utils/time.test.ts
new file mode 100644
index 00000000..9442ca9b
--- /dev/null
+++ b/web/app/utils/time.test.ts
@@ -0,0 +1,142 @@
+import { enUS } from "date-fns/locale"
+import { describe, it, vi } from "vitest"
+import {
+ addDurationToDate,
+ convertDateFnsLocale,
+ dateToCalendarDate,
+ delay,
+ parseDurationString,
+ roundDateToNearest5Seconds,
+} from "./time"
+
+describe("convertDateFnsLocale", () => {
+ it.for([
+ { name: "resolves a known language code", input: "en", expected: enUS },
+ { name: "ignores the region part", input: "en-US", expected: enUS },
+ {
+ name: "falls back to english for unknown locales",
+ input: "fr",
+ expected: enUS,
+ },
+ {
+ name: "falls back to english for an empty locale",
+ input: "",
+ expected: enUS,
+ },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(convertDateFnsLocale(input)).toBe(expected)
+ })
+})
+
+describe("delay", () => {
+ it("resolves after the given number of milliseconds", async ({ expect }) => {
+ vi.useFakeTimers()
+
+ try {
+ let resolved = false
+ const pending = delay(1000).then(() => {
+ resolved = true
+ })
+
+ await vi.advanceTimersByTimeAsync(999)
+ expect(resolved).toBe(false)
+
+ await vi.advanceTimersByTimeAsync(1)
+ await pending
+ expect(resolved).toBe(true)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
+
+describe("parseDurationString", () => {
+ it.for([
+ { input: "1s", expected: 1000 },
+ { input: "500ms", expected: 500 },
+ { input: "2h", expected: 7200000 },
+ { input: "1m30s", expected: 90000 },
+ { input: "1h2m3s", expected: 3723000 },
+ { input: "100ns", expected: 0.0001 },
+ { input: "10Β΅s", expected: 0.01 },
+ { input: "10us", expected: 0.01 },
+ { input: "", expected: 0 },
+ { input: "xyz", expected: 0 },
+ ])("parses $input into $expected ms", ({ input, expected }, { expect }) => {
+ expect(parseDurationString(input)).toBeCloseTo(expected, 6)
+ })
+})
+
+describe("addDurationToDate", () => {
+ it("shifts the date by the parsed duration", ({ expect }) => {
+ const base = new Date("2024-06-15T12:00:00Z")
+
+ expect(addDurationToDate(base, "1m30s")).toEqual(
+ new Date("2024-06-15T12:01:30Z"),
+ )
+ })
+
+ it("returns an equal date for an empty duration", ({ expect }) => {
+ const base = new Date("2024-06-15T12:00:00Z")
+
+ expect(addDurationToDate(base, "")).toEqual(base)
+ })
+})
+
+describe("dateToCalendarDate", () => {
+ it("builds a calendar date from a Date's local components", ({ expect }) => {
+ const result = dateToCalendarDate(new Date(2024, 5, 15, 12))
+
+ expect(result.year).toBe(2024)
+ expect(result.month).toBe(6)
+ expect(result.day).toBe(15)
+ })
+
+ it("maps January to month 1", ({ expect }) => {
+ const result = dateToCalendarDate(new Date(2024, 0, 3, 12))
+
+ expect(result.year).toBe(2024)
+ expect(result.month).toBe(1)
+ expect(result.day).toBe(3)
+ })
+
+ it("parses a string input before converting", ({ expect }) => {
+ const fromString = dateToCalendarDate("2024-06-15T12:00:00")
+ const fromDate = dateToCalendarDate(new Date("2024-06-15T12:00:00"))
+
+ expect(fromString).toEqual(fromDate)
+ })
+})
+
+describe("roundDateToNearest5Seconds", () => {
+ it.for([
+ {
+ name: "keeps an exact boundary unchanged",
+ input: "2024-06-15T12:00:05.000Z",
+ expected: "2024-06-15T12:00:05Z",
+ },
+ {
+ name: "rounds a boundary with milliseconds up",
+ input: "2024-06-15T12:00:05.001Z",
+ expected: "2024-06-15T12:00:10Z",
+ },
+ {
+ name: "rounds mid-interval seconds up",
+ input: "2024-06-15T12:00:03.000Z",
+ expected: "2024-06-15T12:00:05Z",
+ },
+ {
+ name: "rolls over into the next minute",
+ input: "2024-06-15T12:00:58.500Z",
+ expected: "2024-06-15T12:01:00Z",
+ },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(roundDateToNearest5Seconds(input)).toBe(expected)
+ })
+
+ it("accepts a Date input", ({ expect }) => {
+ expect(
+ roundDateToNearest5Seconds(new Date("2024-06-15T12:00:03.000Z")),
+ ).toBe("2024-06-15T12:00:05Z")
+ })
+})
diff --git a/web/app/utils/time.ts b/web/app/utils/time.ts
index 1d5da9dd..853ee1af 100644
--- a/web/app/utils/time.ts
+++ b/web/app/utils/time.ts
@@ -52,7 +52,12 @@ export function dateToCalendarDate(date: Date | string): CalendarDate {
date = new Date(date)
}
- return new CalendarDate(date.getFullYear(), date.getMonth(), date.getDate())
+ // getMonth is 0-based while CalendarDate months are 1-based
+ return new CalendarDate(
+ date.getFullYear(),
+ date.getMonth() + 1,
+ date.getDate(),
+ )
}
/**
diff --git a/web/app/utils/tiptap.browser.test.ts b/web/app/utils/tiptap.browser.test.ts
new file mode 100644
index 00000000..991bfdc5
--- /dev/null
+++ b/web/app/utils/tiptap.browser.test.ts
@@ -0,0 +1,257 @@
+import { afterEach, beforeEach, describe, it, vi } from "vitest"
+import {
+ SCROLL_HIGHLIGHT_EDITOR_CLASS,
+ clearTiptapScrollElementHighlightOverlays,
+ highlightTiptapScrollElement,
+ nodeOverlayOffset,
+} from "./tiptap"
+
+const OVERLAY_SELECTOR = ".editor-scroll-highlight"
+
+// the page DOM and the stubbed globals (requestAnimationFrame,
+// ResizeObserver, timers) are shared between tests, so they cannot
+// interleave
+describe("nodeOverlayOffset", { concurrent: false }, () => {
+ afterEach(() => {
+ document.body.innerHTML = ""
+ })
+
+ function attach(el: T): T {
+ document.body.appendChild(el)
+ return el
+ }
+
+ it("returns zero offsets for a plain element", ({ expect }) => {
+ expect(nodeOverlayOffset(attach(document.createElement("div")))).toEqual({
+ extraTop: 0,
+ extraBottom: 0,
+ extraLeft: 0,
+ extraRight: 0,
+ })
+ })
+
+ it("pads list items on the left", ({ expect }) => {
+ const el = attach(document.createElement("div"))
+ el.setAttribute("data-type", "listItem")
+
+ expect(nodeOverlayOffset(el)).toMatchObject({ extraLeft: 25 })
+ })
+
+ it("extends image blocks by the real image overhang", ({ expect }) => {
+ const el = attach(document.createElement("div"))
+ el.setAttribute("data-type", "imageBlock")
+ el.style.width = "200px"
+ const img = document.createElement("img")
+ img.style.display = "block"
+ img.style.width = "320px"
+ el.appendChild(img)
+
+ expect(nodeOverlayOffset(el)).toMatchObject({ extraRight: 120 })
+ })
+
+ it("returns zero offsets for an image block without an image", ({
+ expect,
+ }) => {
+ const el = attach(document.createElement("div"))
+ el.setAttribute("data-type", "imageBlock")
+
+ expect(nodeOverlayOffset(el)).toMatchObject({ extraRight: 0 })
+ })
+
+ it("pads plain li elements on the left", ({ expect }) => {
+ expect(
+ nodeOverlayOffset(attach(document.createElement("li"))),
+ ).toMatchObject({ extraLeft: 25 })
+ })
+
+ it("leaves task list items unpadded", ({ expect }) => {
+ const el = attach(document.createElement("li"))
+ el.setAttribute("data-checked", "false")
+
+ expect(nodeOverlayOffset(el)).toMatchObject({ extraLeft: 0 })
+ })
+
+ it("leaves li elements containing a checkbox unpadded", ({ expect }) => {
+ const el = attach(document.createElement("li"))
+ const checkbox = document.createElement("input")
+ checkbox.type = "checkbox"
+ el.appendChild(checkbox)
+
+ expect(nodeOverlayOffset(el)).toMatchObject({ extraLeft: 0 })
+ })
+})
+
+describe("highlightTiptapScrollElement", { concurrent: false }, () => {
+ class ResizeObserverStub {
+ static instances: ResizeObserverStub[] = []
+
+ observe = vi.fn()
+ disconnect = vi.fn()
+
+ constructor() {
+ ResizeObserverStub.instances.push(this)
+ }
+ }
+
+ beforeEach(() => {
+ ResizeObserverStub.instances = []
+ vi.stubGlobal("ResizeObserver", ResizeObserverStub)
+
+ // run animation frames synchronously so positioning is immediate
+ // and deterministic
+ vi.stubGlobal(
+ "requestAnimationFrame",
+ (cb: FrameRequestCallback): number => {
+ cb(0)
+ return 0
+ },
+ )
+ })
+
+ afterEach(() => {
+ document.body.innerHTML = ""
+ })
+
+ // a fixed-position container at (10, 10) with an absolutely
+ // positioned target at (40, 20) inside it, so every rect the
+ // overlay math reads comes from real layout
+ function setupDom() {
+ const container = document.createElement("div")
+ container.className = SCROLL_HIGHLIGHT_EDITOR_CLASS
+ container.style.position = "fixed"
+ container.style.top = "10px"
+ container.style.left = "10px"
+ container.style.width = "400px"
+ container.style.height = "300px"
+
+ const target = document.createElement("div")
+ target.style.position = "absolute"
+ target.style.top = "20px"
+ target.style.left = "40px"
+ target.style.width = "100px"
+ target.style.height = "20px"
+
+ container.appendChild(target)
+ document.body.appendChild(container)
+
+ return { container, target }
+ }
+
+ function overlays() {
+ return document.querySelectorAll(OVERLAY_SELECTOR)
+ }
+
+ it("does nothing without a highlight container", ({ expect }) => {
+ const target = document.createElement("div")
+ document.body.appendChild(target)
+
+ highlightTiptapScrollElement("", target)
+
+ expect(overlays()).toHaveLength(0)
+ })
+
+ it("does nothing for a disconnected element without an id fallback", ({
+ expect,
+ }) => {
+ setupDom()
+
+ highlightTiptapScrollElement("", document.createElement("div"))
+
+ expect(overlays()).toHaveLength(0)
+ })
+
+ it("positions the overlay over the target's real layout", ({ expect }) => {
+ const { target } = setupDom()
+
+ highlightTiptapScrollElement("", target)
+
+ const overlay = overlays()[0] as HTMLElement
+ expect(overlay).toBeDefined()
+ expect(ResizeObserverStub.instances[0]?.observe).toHaveBeenCalledWith(
+ target,
+ )
+ // target rect (50, 30) relative to container rect (10, 10), minus
+ // the 5px highlight offsets; size 100x20 plus 5px on each side
+ expect(overlay.style.top).toBe("15px")
+ expect(overlay.style.left).toBe("35px")
+ expect(overlay.style.height).toBe("30px")
+ expect(overlay.style.width).toBe("110px")
+ })
+
+ it("falls back to looking the target up by id", ({ expect }) => {
+ const { target } = setupDom()
+ target.id = "block-1"
+
+ highlightTiptapScrollElement("block-1", document.createElement("div"))
+
+ expect(overlays()).toHaveLength(1)
+ })
+
+ it("replaces an existing overlay and disconnects its observer", ({
+ expect,
+ }) => {
+ const { target } = setupDom()
+
+ highlightTiptapScrollElement("", target)
+ highlightTiptapScrollElement("", target)
+
+ expect(overlays()).toHaveLength(1)
+ expect(ResizeObserverStub.instances[0]?.disconnect).toHaveBeenCalledTimes(1)
+ })
+
+ it("leaves the overlay unpositioned when the target has no size", ({
+ expect,
+ }) => {
+ const { target } = setupDom()
+ target.style.display = "none"
+
+ highlightTiptapScrollElement("", target)
+
+ const overlay = overlays()[0] as HTMLElement
+ expect(overlay).toBeDefined()
+ expect(overlay.style.top).toBe("")
+ })
+})
+
+describe(
+ "clearTiptapScrollElementHighlightOverlays",
+ { concurrent: false },
+ () => {
+ afterEach(() => {
+ document.body.innerHTML = ""
+ })
+
+ function appendOverlay() {
+ const overlay = document.createElement("div") as HTMLElement & {
+ _cleanup?: () => void
+ }
+ overlay.className = "editor-scroll-highlight"
+ overlay._cleanup = vi.fn()
+ document.body.appendChild(overlay)
+
+ return overlay
+ }
+
+ it("hides overlays immediately and removes them after the fade", ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+
+ try {
+ const overlay = appendOverlay()
+
+ clearTiptapScrollElementHighlightOverlays()
+
+ expect(overlay.hasAttribute("data-hidden")).toBe(true)
+ expect(overlay.isConnected).toBe(true)
+
+ vi.advanceTimersByTime(300)
+
+ expect(overlay.isConnected).toBe(false)
+ expect(overlay._cleanup).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+ },
+)
diff --git a/web/app/utils/tiptap.test.ts b/web/app/utils/tiptap.test.ts
new file mode 100644
index 00000000..506ec996
--- /dev/null
+++ b/web/app/utils/tiptap.test.ts
@@ -0,0 +1,221 @@
+import { InputRule } from "@tiptap/core"
+import { Schema } from "@tiptap/pm/model"
+import { EditorState, TextSelection } from "@tiptap/pm/state"
+import { describe, it, vi } from "vitest"
+import {
+ clearTiptapScrollElementHighlightOverlays,
+ highlightTiptapScrollElement,
+ isCursorInsideTiptapMark,
+ isCursorInsideTiptapNode,
+ isNodeInsideTiptapNode,
+ preventTiptapInputRuleInNode,
+ tiptapNodePosByCursor,
+} from "./tiptap"
+
+// minimal schema with a nestable container to exercise the ancestor
+// walks: doc > paragraph("plain bold") + callout > paragraph("inside")
+const schema = new Schema({
+ nodes: {
+ doc: { content: "block+" },
+ callout: { group: "block", content: "block+" },
+ paragraph: { group: "block", content: "inline*" },
+ text: { group: "inline" },
+ },
+ marks: {
+ bold: {},
+ },
+})
+
+// positions: paragraph opens at 0, "plain " spans [1, 7), "bold"
+// spans [7, 11); the callout opens at 12, its paragraph at 13, and
+// "inside" spans [14, 20)
+const pmDoc = schema.nodes.doc.create(null, [
+ schema.nodes.paragraph.create(null, [
+ schema.text("plain "),
+ schema.text("bold", [schema.marks.bold.create()]),
+ ]),
+ schema.nodes.callout.create(null, [
+ schema.nodes.paragraph.create(null, [schema.text("inside")]),
+ ]),
+])
+
+function stateWithSelection(from: number, to = from) {
+ return EditorState.create({
+ doc: pmDoc,
+ selection: TextSelection.create(pmDoc, from, to),
+ })
+}
+
+describe("highlightTiptapScrollElement", () => {
+ // node has no window, so this exercises the SSR guard directly
+ it("does nothing without a window", ({ expect }) => {
+ expect(() => {
+ highlightTiptapScrollElement("id", {} as HTMLElement)
+ }).not.toThrow()
+ })
+
+ it("does nothing without a document even when a window exists", ({
+ expect,
+ }) => {
+ vi.stubGlobal("window", {})
+
+ expect(() => {
+ highlightTiptapScrollElement("id", {} as HTMLElement)
+ }).not.toThrow()
+ })
+})
+
+describe("clearTiptapScrollElementHighlightOverlays", () => {
+ // node has no window, so this exercises the SSR guard directly
+ it("does nothing without a window", ({ expect }) => {
+ expect(() => {
+ clearTiptapScrollElementHighlightOverlays()
+ }).not.toThrow()
+ })
+
+ it("does nothing without a document even when a window exists", ({
+ expect,
+ }) => {
+ vi.stubGlobal("window", {})
+
+ expect(() => {
+ clearTiptapScrollElementHighlightOverlays()
+ }).not.toThrow()
+ })
+})
+
+describe("isCursorInsideTiptapNode", () => {
+ it("detects the cursor inside a matching container", ({ expect }) => {
+ expect(isCursorInsideTiptapNode(stateWithSelection(15), ["callout"])).toBe(
+ true,
+ )
+ })
+
+ it("rejects a cursor outside the container", ({ expect }) => {
+ expect(isCursorInsideTiptapNode(stateWithSelection(3), ["callout"])).toBe(
+ false,
+ )
+ })
+
+ it("rejects every cursor for an empty name list", ({ expect }) => {
+ expect(isCursorInsideTiptapNode(stateWithSelection(15), [])).toBe(false)
+ })
+})
+
+describe("isCursorInsideTiptapMark", () => {
+ it("detects a selection over marked text", ({ expect }) => {
+ expect(isCursorInsideTiptapMark(stateWithSelection(8, 10), "bold")).toBe(
+ true,
+ )
+ })
+
+ it("rejects a selection over plain text", ({ expect }) => {
+ expect(isCursorInsideTiptapMark(stateWithSelection(2, 4), "bold")).toBe(
+ false,
+ )
+ })
+
+ it("rejects marks unknown to the schema", ({ expect }) => {
+ expect(
+ isCursorInsideTiptapMark(stateWithSelection(8, 10), "underline"),
+ ).toBe(false)
+ })
+})
+
+describe("tiptapNodePosByCursor", () => {
+ it("returns the position and node of the closest matching ancestor", ({
+ expect,
+ }) => {
+ const result = tiptapNodePosByCursor(stateWithSelection(15), ["callout"])
+
+ expect(result?.pos).toBe(12)
+ expect(result?.node.type.name).toBe("callout")
+ })
+
+ it("prefers the deepest matching ancestor", ({ expect }) => {
+ const result = tiptapNodePosByCursor(stateWithSelection(15), [
+ "callout",
+ "paragraph",
+ ])
+
+ expect(result?.pos).toBe(13)
+ expect(result?.node.type.name).toBe("paragraph")
+ })
+
+ it("returns null without a matching ancestor", ({ expect }) => {
+ expect(tiptapNodePosByCursor(stateWithSelection(3), ["callout"])).toBeNull()
+ })
+})
+
+describe("isNodeInsideTiptapNode", () => {
+ it("detects a position nested inside the container", ({ expect }) => {
+ expect(isNodeInsideTiptapNode(stateWithSelection(0), 14, ["callout"])).toBe(
+ true,
+ )
+ })
+
+ it("rejects a position outside the container", ({ expect }) => {
+ expect(isNodeInsideTiptapNode(stateWithSelection(0), 1, ["callout"])).toBe(
+ false,
+ )
+ })
+})
+
+describe("preventTiptapInputRuleInNode", () => {
+ function makeRule() {
+ const handler = vi.fn()
+
+ return { rule: new InputRule({ find: /abc$/, handler }), handler }
+ }
+
+ function props(state: EditorState) {
+ return { state } as unknown as Parameters[0]
+ }
+
+ it("runs the rule when every ancestor is allowed", ({ expect }) => {
+ const { rule, handler } = makeRule()
+ const wrapped = preventTiptapInputRuleInNode(rule, ["callout", "paragraph"])
+ const ruleProps = props(stateWithSelection(15))
+
+ wrapped.handler(ruleProps)
+
+ expect(handler).toHaveBeenCalledTimes(1)
+ expect(handler).toHaveBeenCalledWith(ruleProps)
+ })
+
+ it("blocks the rule when an ancestor is not allowed", ({ expect }) => {
+ const { rule, handler } = makeRule()
+ const wrapped = preventTiptapInputRuleInNode(rule, ["callout"])
+
+ wrapped.handler(props(stateWithSelection(15)))
+
+ expect(handler).toHaveBeenCalledTimes(0)
+ })
+
+ it("runs the rule anywhere without container restrictions", ({ expect }) => {
+ const { rule, handler } = makeRule()
+ const wrapped = preventTiptapInputRuleInNode(rule, [])
+
+ wrapped.handler(props(stateWithSelection(15)))
+
+ expect(handler).toHaveBeenCalledTimes(1)
+ })
+
+ it("blocks nested cursors in root-only mode", ({ expect }) => {
+ const { rule, handler } = makeRule()
+ const wrapped = preventTiptapInputRuleInNode(rule, [], true)
+
+ wrapped.handler(props(stateWithSelection(15)))
+
+ expect(handler).toHaveBeenCalledTimes(0)
+ })
+
+ it("runs the rule for top-level cursors in root-only mode", ({ expect }) => {
+ const { rule, handler } = makeRule()
+ const wrapped = preventTiptapInputRuleInNode(rule, [], true)
+
+ wrapped.handler(props(stateWithSelection(3)))
+
+ expect(handler).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/utils/url.test.ts b/web/app/utils/url.test.ts
new file mode 100644
index 00000000..9fa7f555
--- /dev/null
+++ b/web/app/utils/url.test.ts
@@ -0,0 +1,228 @@
+import { describe, it } from "vitest"
+import {
+ addDeletionSuccessStatusToUrl,
+ createNameSlug,
+ createNameSlugWithId,
+ ensureHttps,
+ equalNameSlugs,
+ extractDocInfoFromSlug,
+ extractDomain,
+ isDeletionSuccessInUrl,
+ isOptimisticInsertId,
+ isXid,
+} from "./url"
+
+// default length of https://github.com/rs/xid
+const XID = "c9m4hqk2v70qadhkl33g"
+
+describe("isXid", () => {
+ it.for([
+ { name: "accepts a 20-character id", input: XID, expected: true },
+ { name: "rejects a shorter id", input: "abc", expected: false },
+ { name: "rejects an empty id", input: "", expected: false },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(isXid(input)).toBe(expected)
+ })
+})
+
+describe("isOptimisticInsertId", () => {
+ it.for([
+ { name: "accepts an optimistic id", input: "optimistic-1", expected: true },
+ { name: "rejects a regular id", input: XID, expected: false },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(isOptimisticInsertId(input)).toBe(expected)
+ })
+})
+
+describe("createNameSlug", () => {
+ it.for([
+ {
+ name: "replaces spaces with dashes",
+ input: "Hello World",
+ expected: "Hello-World",
+ },
+ {
+ name: "strips special characters",
+ input: "Hello, World!",
+ expected: "Hello-World",
+ },
+ {
+ name: "truncates to 20 characters without a trailing dash",
+ input: "a very long document name",
+ expected: "a-very-long-document",
+ },
+ {
+ name: "returns an empty string for an empty name",
+ input: "",
+ expected: "",
+ },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(createNameSlug(input)).toBe(expected)
+ })
+})
+
+describe("createNameSlugWithId", () => {
+ it("appends the id to the slugged name", ({ expect }) => {
+ expect(createNameSlugWithId("Hello World", XID)).toBe(`Hello-World-${XID}`)
+ })
+
+ it("returns the id alone for an empty name", ({ expect }) => {
+ expect(createNameSlugWithId("", XID)).toBe(XID)
+ })
+
+ it("returns the id alone when the name slugs to nothing", ({ expect }) => {
+ expect(createNameSlugWithId("!!!", XID)).toBe(XID)
+ })
+
+ it("throws for an id that is not 20 characters long", ({ expect }) => {
+ expect(() => createNameSlugWithId("Hello", "short-id")).toThrow(
+ "the ID must have exactly 20 characters to be included in a slug",
+ )
+ })
+})
+
+describe("equalNameSlugs", () => {
+ it.for([
+ {
+ name: "accepts identical strings",
+ a: "My Doc",
+ b: "My Doc",
+ expected: true,
+ },
+ {
+ name: "accepts a name and its slug",
+ a: "Hello World",
+ b: "Hello-World",
+ expected: true,
+ },
+ {
+ name: "accepts names that only differ beyond the slug cut-off",
+ a: "a very long document name",
+ b: "a very long document title",
+ expected: true,
+ },
+ {
+ name: "rejects different names",
+ a: "Hello",
+ b: "Goodbye",
+ expected: false,
+ },
+ ])("$name", ({ a, b, expected }, { expect }) => {
+ expect(equalNameSlugs(a, b)).toBe(expected)
+ })
+})
+
+describe("extractDocInfoFromSlug", () => {
+ it.for([
+ {
+ name: "extracts the id from a bare id slug",
+ input: XID,
+ expected: { id: XID },
+ },
+ {
+ name: "extracts the name and id from a combined slug",
+ input: `my-doc-${XID}`,
+ expected: { id: XID, name: "my-doc" },
+ },
+ {
+ name: "rejects a slug whose last part is not an id",
+ input: "my-doc-123",
+ expected: undefined,
+ },
+ {
+ name: "rejects a bare non-id slug",
+ input: "my",
+ expected: undefined,
+ },
+ { name: "rejects an empty slug", input: "", expected: undefined },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(extractDocInfoFromSlug(input)).toEqual(expected)
+ })
+})
+
+describe("ensureHttps", () => {
+ it.for([
+ {
+ name: "prefixes a bare domain",
+ input: "example.com",
+ expected: "https://example.com",
+ },
+ {
+ name: "keeps an existing https prefix",
+ input: "https://example.com",
+ expected: "https://example.com",
+ },
+ {
+ name: "keeps an existing http prefix",
+ input: "http://example.com",
+ expected: "http://example.com",
+ },
+ {
+ name: "matches the prefix case-insensitively",
+ input: "HTTPS://example.com",
+ expected: "HTTPS://example.com",
+ },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(ensureHttps(input)).toBe(expected)
+ })
+})
+
+describe("extractDomain", () => {
+ it.for([
+ {
+ name: "strips subdomains down to domain and tld",
+ input: "https://docs.sub.example.com/path?x=1",
+ expected: "example.com",
+ },
+ {
+ name: "handles a bare domain without protocol",
+ input: "example.com",
+ expected: "example.com",
+ },
+ {
+ name: "falls back to the hostname for localhost",
+ input: "http://localhost:8080",
+ expected: "localhost",
+ },
+ { name: "returns unparseable input unchanged", input: "", expected: "" },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(extractDomain(input)).toBe(expected)
+ })
+})
+
+describe("addDeletionSuccessStatusToUrl", () => {
+ it("appends the deletion status parameter", ({ expect }) => {
+ expect(addDeletionSuccessStatusToUrl("https://app.test/docs")).toBe(
+ "https://app.test/docs?deletion=success",
+ )
+ })
+
+ it("preserves existing query parameters", ({ expect }) => {
+ expect(addDeletionSuccessStatusToUrl("https://app.test/docs?x=1")).toBe(
+ "https://app.test/docs?x=1&deletion=success",
+ )
+ })
+})
+
+describe("isDeletionSuccessInUrl", () => {
+ it.for([
+ {
+ name: "detects the deletion success parameter",
+ input: "https://app.test/docs?deletion=success",
+ expected: true,
+ },
+ {
+ name: "rejects other deletion values",
+ input: "https://app.test/docs?deletion=failed",
+ expected: false,
+ },
+ {
+ name: "rejects urls without the parameter",
+ input: "https://app.test/docs",
+ expected: false,
+ },
+ { name: "rejects unparseable urls", input: "not a url", expected: false },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(isDeletionSuccessInUrl(input)).toBe(expected)
+ })
+})
diff --git a/web/app/utils/vue.nuxt.test.ts b/web/app/utils/vue.nuxt.test.ts
new file mode 100644
index 00000000..9bebdccb
--- /dev/null
+++ b/web/app/utils/vue.nuxt.test.ts
@@ -0,0 +1,55 @@
+import { describe, it } from "vitest"
+import { isReadonly, nextTick, ref } from "vue"
+import { useLastValidRef } from "./vue"
+
+describe("useLastValidRef", () => {
+ it("starts undefined while the source has no valid value", ({ expect }) => {
+ const last = useLastValidRef(ref(null))
+
+ expect(last.value).toBeUndefined()
+ })
+
+ it("picks up an initial valid value immediately", ({ expect }) => {
+ const last = useLastValidRef(ref("first"))
+
+ expect(last.value).toBe("first")
+ })
+
+ it("follows the source across valid updates", async ({ expect }) => {
+ const source = ref("first")
+ const last = useLastValidRef(source)
+
+ source.value = "second"
+ await nextTick()
+
+ expect(last.value).toBe("second")
+ })
+
+ it("keeps the last valid value when the source turns null", async ({
+ expect,
+ }) => {
+ const source = ref("first")
+ const last = useLastValidRef(source)
+
+ source.value = null
+ await nextTick()
+
+ expect(last.value).toBe("first")
+ })
+
+ it("keeps the last valid value when the source turns undefined", async ({
+ expect,
+ }) => {
+ const source = ref("first")
+ const last = useLastValidRef(source)
+
+ source.value = undefined
+ await nextTick()
+
+ expect(last.value).toBe("first")
+ })
+
+ it("returns a readonly ref", ({ expect }) => {
+ expect(isReadonly(useLastValidRef(ref("first")))).toBe(true)
+ })
+})
diff --git a/web/app/utils/websocket.test.ts b/web/app/utils/websocket.test.ts
new file mode 100644
index 00000000..f6c51f75
--- /dev/null
+++ b/web/app/utils/websocket.test.ts
@@ -0,0 +1,301 @@
+import { describe, it, vi, type Mock } from "vitest"
+import WsState, { extractFromTopic } from "./websocket"
+
+describe("extractFromTopic", () => {
+ it.for([
+ {
+ name: "splits a topic into descriptor, operation, args and clean topic",
+ input: "pub~change@documents.1",
+ expected: ["pub", "change", ["documents.1"], "change@documents.1"],
+ },
+ {
+ name: "returns empty parts without a descriptor separator",
+ input: "change@documents.1",
+ expected: ["", "", [], ""],
+ },
+ {
+ name: "returns empty parts for multiple descriptor separators",
+ input: "a~b~change@documents.1",
+ expected: ["", "", [], ""],
+ },
+ {
+ name: "returns empty parts without an operation separator",
+ input: "pub~documents.1",
+ expected: ["", "", [], ""],
+ },
+ {
+ name: "returns empty parts for multiple operation separators",
+ input: "pub~change@documents@extra",
+ expected: ["", "", [], ""],
+ },
+ {
+ name: "returns empty parts for an empty topic",
+ input: "",
+ expected: ["", "", [], ""],
+ },
+ ])("$name", ({ input, expected }, { expect }) => {
+ expect(extractFromTopic(input)).toEqual(expected)
+ })
+})
+
+const TOPIC = "change@documents.1"
+
+function fakeWs() {
+ return { send: vi.fn() } as unknown as WebSocket & { send: Mock }
+}
+
+// decodes every message sent over the fake socket
+function sentMessages(ws: { send: Mock }): { topic: string; id: number }[] {
+ return ws.send.mock.calls.map(
+ ([raw]) => JSON.parse(raw as string) as { topic: string; id: number },
+ )
+}
+
+function messageEvent(event: {
+ topic?: string
+ payload?: object
+ success?: boolean
+ id?: number
+}) {
+ return {
+ data: JSON.stringify({
+ topic: "",
+ payload: {},
+ success: false,
+ id: 0,
+ ...event,
+ }),
+ } as MessageEvent
+}
+
+// confirms the pending subscription of the given sent message id
+function confirm(state: WsState, id: number) {
+ state.processMessage(messageEvent({ success: true, id }))
+}
+
+// the constructor's auto-resubscribe interval keeps ticking on the
+// process-global clock, so these tests use a far-away interval and the
+// fake-timer test installs a global fake clock β both are shared state
+// that cannot interleave with other tests
+describe("WsState", { concurrent: false }, () => {
+ function makeState() {
+ const ws = fakeWs()
+ const state = new WsState(1_000_000)
+ state.ws = ws
+
+ return { ws, state }
+ }
+
+ describe("subscribe", () => {
+ it("sends a subscription message for a new topic", ({ expect }) => {
+ const { ws, state } = makeState()
+
+ state.subscribe(TOPIC, vi.fn())
+
+ expect(sentMessages(ws)).toEqual([{ topic: `sub~${TOPIC}`, id: 1 }])
+ })
+
+ it("does not send anything without a socket", ({ expect }) => {
+ const ws = fakeWs()
+ const state = new WsState(1_000_000)
+
+ state.subscribe(TOPIC, vi.fn())
+
+ expect(ws.send).toHaveBeenCalledTimes(0)
+ })
+
+ it("delivers publish payloads to every subscriber once confirmed", ({
+ expect,
+ }) => {
+ const { ws, state } = makeState()
+ const first = vi.fn()
+ const second = vi.fn()
+
+ state.subscribe(TOPIC, first)
+ state.subscribe(TOPIC, second)
+ confirm(state, 1)
+ state.processMessage(
+ messageEvent({ topic: `pub~${TOPIC}`, payload: { x: 1 } }),
+ )
+
+ expect(first).toHaveBeenCalledTimes(1)
+ expect(first).toHaveBeenCalledWith({ x: 1 })
+ expect(second).toHaveBeenCalledTimes(1)
+ expect(second).toHaveBeenCalledWith({ x: 1 })
+ // every subscribe retries all still-unconfirmed topics, so the
+ // second subscriber triggers another sub message
+ expect(sentMessages(ws)).toEqual([
+ { topic: `sub~${TOPIC}`, id: 1 },
+ { topic: `sub~${TOPIC}`, id: 2 },
+ ])
+ })
+
+ it("ignores publishes for unconfirmed topics", ({ expect }) => {
+ const { state } = makeState()
+ const fn = vi.fn()
+
+ state.subscribe(TOPIC, fn)
+ state.processMessage(
+ messageEvent({ topic: `pub~${TOPIC}`, payload: { x: 1 } }),
+ )
+
+ expect(fn).toHaveBeenCalledTimes(0)
+ })
+
+ it("ignores non-publish payloads", ({ expect }) => {
+ const { state } = makeState()
+ const fn = vi.fn()
+
+ state.subscribe(TOPIC, fn)
+ confirm(state, 1)
+ state.processMessage(
+ messageEvent({ topic: `sub~${TOPIC}`, payload: { x: 1 } }),
+ )
+
+ expect(fn).toHaveBeenCalledTimes(0)
+ })
+ })
+
+ describe("unsubscribe", () => {
+ it("sends an unsubscription message when the last subscriber leaves", ({
+ expect,
+ }) => {
+ const { ws, state } = makeState()
+ const fn = vi.fn()
+
+ const unsubscribe = state.subscribe(TOPIC, fn)
+ confirm(state, 1)
+ unsubscribe()
+ state.processMessage(
+ messageEvent({ topic: `pub~${TOPIC}`, payload: { x: 1 } }),
+ )
+
+ expect(sentMessages(ws)).toEqual([
+ { topic: `sub~${TOPIC}`, id: 1 },
+ { topic: `unsub~${TOPIC}`, id: 2 },
+ ])
+ expect(fn).toHaveBeenCalledTimes(0)
+ })
+
+ it("keeps the topic alive while other subscribers remain", ({ expect }) => {
+ const { ws, state } = makeState()
+ const leaving = vi.fn()
+ const staying = vi.fn()
+
+ const unsubscribe = state.subscribe(TOPIC, leaving)
+ state.subscribe(TOPIC, staying)
+ confirm(state, 1)
+ unsubscribe()
+ state.processMessage(
+ messageEvent({ topic: `pub~${TOPIC}`, payload: { x: 1 } }),
+ )
+
+ expect(staying).toHaveBeenCalledTimes(1)
+ expect(leaving).toHaveBeenCalledTimes(0)
+ // two sub messages (one per subscribe on the unconfirmed topic),
+ // but no unsub β the topic still has a subscriber
+ expect(sentMessages(ws)).toEqual([
+ { topic: `sub~${TOPIC}`, id: 1 },
+ { topic: `sub~${TOPIC}`, id: 2 },
+ ])
+ })
+
+ it("ignores repeated unsubscriptions", ({ expect }) => {
+ const { ws, state } = makeState()
+
+ const unsubscribe = state.subscribe(TOPIC, vi.fn())
+ unsubscribe()
+ unsubscribe()
+
+ expect(sentMessages(ws)).toEqual([
+ { topic: `sub~${TOPIC}`, id: 1 },
+ { topic: `unsub~${TOPIC}`, id: 2 },
+ ])
+ })
+ })
+
+ describe("processMessage", () => {
+ it("ignores confirmations for unknown message ids", ({ expect }) => {
+ const { state } = makeState()
+ const fn = vi.fn()
+
+ state.subscribe(TOPIC, fn)
+ confirm(state, 999)
+ state.processMessage(messageEvent({ topic: `pub~${TOPIC}`, payload: {} }))
+
+ expect(fn).toHaveBeenCalledTimes(0)
+ })
+
+ it("ignores confirmations for topics unsubscribed in the meantime", ({
+ expect,
+ }) => {
+ const { state } = makeState()
+ const fn = vi.fn()
+
+ const unsubscribe = state.subscribe(TOPIC, fn)
+ unsubscribe()
+ confirm(state, 1)
+ state.processMessage(messageEvent({ topic: `pub~${TOPIC}`, payload: {} }))
+
+ expect(fn).toHaveBeenCalledTimes(0)
+ })
+ })
+
+ describe("ws", () => {
+ it("marks all topics unconfirmed when the socket drops", ({ expect }) => {
+ const { state } = makeState()
+ const fn = vi.fn()
+
+ state.subscribe(TOPIC, fn)
+ confirm(state, 1)
+ state.ws = null
+ state.processMessage(messageEvent({ topic: `pub~${TOPIC}`, payload: {} }))
+
+ expect(fn).toHaveBeenCalledTimes(0)
+ })
+ })
+
+ describe("resubscribeAll", () => {
+ it("resends subscriptions for every topic", ({ expect }) => {
+ const { ws, state } = makeState()
+
+ state.subscribe(TOPIC, vi.fn())
+ confirm(state, 1)
+ state.resubscribeAll()
+
+ expect(sentMessages(ws)).toEqual([
+ { topic: `sub~${TOPIC}`, id: 1 },
+ { topic: `sub~${TOPIC}`, id: 2 },
+ ])
+ })
+ })
+
+ describe("auto-resubscription", () => {
+ it("retries unconfirmed subscriptions on the configured interval", ({
+ expect,
+ }) => {
+ vi.useFakeTimers()
+
+ try {
+ const ws = fakeWs()
+ const state = new WsState(5000)
+ state.ws = ws
+ state.subscribe(TOPIC, vi.fn())
+
+ vi.advanceTimersByTime(5000)
+
+ expect(sentMessages(ws)).toEqual([
+ { topic: `sub~${TOPIC}`, id: 1 },
+ { topic: `sub~${TOPIC}`, id: 2 },
+ ])
+
+ // confirmed topics are not retried on the next tick
+ confirm(state, 2)
+ vi.advanceTimersByTime(5000)
+ expect(ws.send).toHaveBeenCalledTimes(2)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+ })
+})
diff --git a/web/package.json b/web/package.json
index ce2f0182..9ce34a4e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,8 +12,9 @@
"main": ".vite/build/main.cjs",
"packageManager": "pnpm@11.1.3",
"scripts": {
- "setup": "pnpm run deps && pnpm run prepare",
+ "setup": "pnpm run deps && pnpm run prepare && pnpm run deps:browsers",
"deps": "pnpm install",
+ "deps:browsers": "playwright install chromium",
"build:web": "pnpm run build:lezer-promql && nuxt build",
"start:dev:web": "nuxt dev",
"start:dev:desktop": "DESKTOP_BUILD=hybrid concurrently -k -s first -n nuxt,electron -c blue,magenta \"nuxt dev --port 3000\" \"wait-on http://localhost:3000 && electron-forge start\"",
@@ -130,6 +131,8 @@
"@tailwindcss/typography": "^0.5.16",
"@types/markdown-it": "^14.1.2",
"@typescript-eslint/eslint-plugin": "^8.56.1",
+ "@vitest/browser": "^4.1.10",
+ "@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/eslint-plugin": "^1.6.27",
"@vue/test-utils": "^2.4.11",
@@ -139,6 +142,7 @@
"eslint-config-prettier": "^10.1.8",
"happy-dom": "^20.11.2",
"knip": "^6.32.2",
+ "playwright": "^1.62.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"typescript": "^5.9.2",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index bd39580b..16f1cb3c 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -269,7 +269,7 @@ importers:
version: 4.5.1(eslint@9.39.4(jiti@2.7.0))(jsonc-eslint-parser@2.4.2)(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.7.0)))(yaml-eslint-parser@1.3.2)
'@nuxt/test-utils':
specifier: ^4.1.0
- version: 4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15))
+ version: 4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(playwright-core@1.62.1)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15))
'@tailwindcss/typography':
specifier: ^0.5.16
version: 0.5.19(tailwindcss@4.3.0)
@@ -279,9 +279,15 @@ importers:
'@typescript-eslint/eslint-plugin':
specifier: ^8.56.1
version: 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@vitest/browser':
+ specifier: ^4.1.10
+ version: 4.1.10(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)
+ '@vitest/browser-playwright':
+ specifier: ^4.1.10
+ version: 4.1.10(playwright@1.62.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)
'@vitest/coverage-v8':
specifier: ^4.1.10
- version: 4.1.10(vitest@4.1.10)
+ version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)
'@vitest/eslint-plugin':
specifier: ^1.6.27
version: 1.6.27(@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)(vitest@4.1.10)
@@ -306,6 +312,9 @@ importers:
knip:
specifier: ^6.32.2
version: 6.32.2
+ playwright:
+ specifier: ^1.62.1
+ version: 1.62.1
prettier:
specifier: ^3.6.2
version: 3.8.3
@@ -317,7 +326,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.1.10
- version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
vue-tsc:
specifier: ^3.3.10
version: 3.3.10(typescript@5.9.3)
@@ -619,6 +628,9 @@ packages:
'@better-fetch/fetch@1.3.1':
resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==}
+ '@blazediff/core@1.9.1':
+ resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
+
'@bomb.sh/tab@0.0.15':
resolution: {integrity: sha512-Y90ub44TAvbdO9P8mcD/XPyQjFhiR5xmd4Fk7JErmWmEWEUimNnjWiBrVZ16Tj3GA1rLZ+uvCN2V/pzLawv31g==}
hasBin: true
@@ -4475,6 +4487,17 @@ packages:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.2.25
+ '@vitest/browser-playwright@4.1.10':
+ resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==}
+ peerDependencies:
+ playwright: '*'
+ vitest: 4.1.10
+
+ '@vitest/browser@4.1.10':
+ resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==}
+ peerDependencies:
+ vitest: 4.1.10
+
'@vitest/coverage-v8@4.1.10':
resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
peerDependencies:
@@ -6402,6 +6425,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -7878,6 +7906,16 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
+ playwright-core@1.62.1:
+ resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.62.1:
+ resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
plist@3.1.1:
resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==}
engines: {node: '>=10.4.0'}
@@ -7886,6 +7924,10 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
+ pngjs@7.0.0:
+ resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
+ engines: {node: '>=14.19.0'}
+
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -10074,6 +10116,8 @@ snapshots:
'@better-fetch/fetch@1.3.1': {}
+ '@blazediff/core@1.9.1': {}
+
'@bomb.sh/tab@0.0.15(cac@6.7.14)(citty@0.2.2)':
optionalDependencies:
cac: 6.7.14
@@ -10513,7 +10557,7 @@ snapshots:
make-fetch-happen: 10.2.1
nopt: 6.0.0
proc-log: 2.0.1
- semver: 7.8.1
+ semver: 7.8.5
tar: 6.2.1
which: 2.0.2
transitivePeerDependencies:
@@ -11471,7 +11515,7 @@ snapshots:
'@npmcli/fs@2.1.2':
dependencies:
'@gar/promisify': 1.1.3
- semver: 7.8.1
+ semver: 7.8.5
'@npmcli/move-file@2.0.1':
dependencies:
@@ -11885,7 +11929,7 @@ snapshots:
rc9: 3.0.1
std-env: 4.1.0
- '@nuxt/test-utils@4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15))':
+ '@nuxt/test-utils@4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(playwright-core@1.62.1)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15))':
dependencies:
'@clack/prompts': 1.7.0
'@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
@@ -11913,12 +11957,13 @@ snapshots:
tinyexec: 1.3.0
ufo: 1.6.4
unplugin: 3.3.0(esbuild@0.27.7)(rollup@4.60.4)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15))
- vitest-environment-nuxt: 2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15))
+ vitest-environment-nuxt: 2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(playwright-core@1.62.1)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15))
vue: 3.5.41(typescript@5.9.3)
optionalDependencies:
'@vue/test-utils': 2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3))
happy-dom: 20.11.2
- vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ playwright-core: 1.62.1
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
transitivePeerDependencies:
- '@farmfe/core'
- '@rspack/core'
@@ -14100,7 +14145,37 @@ snapshots:
vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)
vue: 3.5.34(typescript@5.9.3)
- '@vitest/coverage-v8@4.1.10(vitest@4.1.10)':
+ '@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)':
+ dependencies:
+ '@vitest/browser': 4.1.10(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)
+ '@vitest/mocker': 4.1.10(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ playwright: 1.62.1
+ tinyrainbow: 3.1.1
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+
+ '@vitest/browser@4.1.10(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)':
+ dependencies:
+ '@blazediff/core': 1.9.1
+ '@vitest/mocker': 4.1.10(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ '@vitest/utils': 4.1.10
+ magic-string: 0.30.21
+ pngjs: 7.0.0
+ sirv: 3.0.2
+ tinyrainbow: 3.1.1
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ ws: 8.21.0
+ transitivePeerDependencies:
+ - bufferutil
+ - msw
+ - utf-8-validate
+ - vite
+
+ '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.10
@@ -14112,7 +14187,9 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.1
- vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ optionalDependencies:
+ '@vitest/browser': 4.1.10(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)
'@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)(vitest@4.1.10)':
dependencies:
@@ -14122,7 +14199,7 @@ snapshots:
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
typescript: 5.9.3
- vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
transitivePeerDependencies:
- supports-color
@@ -14777,7 +14854,7 @@ snapshots:
nanostores: 1.3.0
zod: 4.4.3
optionalDependencies:
- vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
+ vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
vue: 3.5.41(typescript@5.9.3)
transitivePeerDependencies:
- '@cloudflare/workers-types'
@@ -16340,6 +16417,9 @@ snapshots:
fs.realpath@1.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -18170,6 +18250,14 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
+ playwright-core@1.62.1: {}
+
+ playwright@1.62.1:
+ dependencies:
+ playwright-core: 1.62.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
plist@3.1.1:
dependencies:
'@xmldom/xmldom': 0.9.10
@@ -18178,6 +18266,8 @@ snapshots:
pluralize@8.0.0: {}
+ pngjs@7.0.0: {}
+
points-on-curve@0.2.0: {}
points-on-path@0.2.1:
@@ -19583,9 +19673,9 @@ snapshots:
terser: 5.48.0
yaml: 2.9.0
- vitest-environment-nuxt@2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15)):
+ vitest-environment-nuxt@2.0.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(playwright-core@1.62.1)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15)):
dependencies:
- '@nuxt/test-utils': 4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15))
+ '@nuxt/test-utils': 4.1.0(@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.41)(vue@3.5.41(typescript@5.9.3)))(esbuild@0.27.7)(happy-dom@20.11.2)(magicast@0.5.3)(playwright-core@1.62.1)(rollup@4.60.4)(typescript@5.9.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15))
transitivePeerDependencies:
- '@cucumber/cucumber'
- '@farmfe/core'
@@ -19610,7 +19700,7 @@ snapshots:
- vitest
- webpack
- vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)):
+ vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))
@@ -19635,7 +19725,8 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 25.9.1
- '@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
+ '@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.10)
+ '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)
happy-dom: 20.11.2
transitivePeerDependencies:
- msw
diff --git a/web/vitest.config.ts b/web/vitest.config.ts
index b76fa781..8374213e 100644
--- a/web/vitest.config.ts
+++ b/web/vitest.config.ts
@@ -1,5 +1,6 @@
import { fileURLToPath } from "node:url"
import { defineVitestProject } from "@nuxt/test-utils/config"
+import { playwright } from "@vitest/browser-playwright"
import vue from "@vitejs/plugin-vue"
import Unimport from "unimport/unplugin"
import {
@@ -20,6 +21,44 @@ const sharedTestOptions = {
unstubEnvs: true,
} satisfies TestUserConfig
+// import graphs of app modules routinely pass through .vue files (e.g.
+// tiptap extensions registering node views), so the node and browser
+// projects must be able to compile them even though they never render
+// one. App code also references app/utils helpers and composable enums
+// (e.g. HostOsType) through nuxt auto-imports β unimport resolves the
+// same real modules here without booting a nuxt runtime. A factory
+// because vite plugin instances cannot be shared between projects.
+function appPlugins() {
+ return [
+ vue(),
+ Unimport.vite({
+ dirs: [
+ fileURLToPath(new URL("./app/utils", import.meta.url)),
+ fileURLToPath(new URL("./app/composables", import.meta.url)),
+ ],
+ dts: false,
+ }),
+ ]
+}
+
+// build-time constants injected by vite.electron.config.ts (electron)
+// and nuxt.config.ts (app); tests get inert stand-ins
+const appDefine = {
+ __API_BASE_URL__: JSON.stringify("http://test.local/core"),
+ __APP_BASE_URL__: JSON.stringify("http://test.local"),
+ __DESKTOP_BUILD__: "false",
+}
+
+const appAlias = {
+ "~": fileURLToPath(new URL("./app", import.meta.url)),
+ "@": fileURLToPath(new URL("./app", import.meta.url)),
+ // same fork alias as nuxt.config.ts β without it these projects
+ // would resolve the upstream package instead of the vendored grammar
+ "@prometheus-io/lezer-promql": fileURLToPath(
+ new URL("./packages/lezer-promql/dist/index.es.js", import.meta.url),
+ ),
+}
+
export default defineConfig({
test: {
...sharedTestOptions,
@@ -49,36 +88,14 @@ export default defineConfig({
},
// the suffix encodes the environment: .test.ts runs in plain node,
- // .nuxt.test.ts inside the nuxt runtime, and .browser.test.ts is
- // reserved for a future browser-mode project
+ // .nuxt.test.ts inside the nuxt runtime, and .browser.test.ts in a
+ // real headless chromium via playwright
projects: [
{
- // import graphs of app modules routinely pass through .vue
- // files (e.g. tiptap extensions registering node views), so
- // the node project must be able to compile them even though
- // it never renders one. App code also references app/utils
- // helpers through nuxt auto-imports β unimport resolves the
- // same real modules here without booting a nuxt runtime.
- plugins: [
- vue(),
- Unimport.vite({
- dirs: [fileURLToPath(new URL("./app/utils", import.meta.url))],
- dts: false,
- }),
- ],
- define: {
- // build-time constants injected by vite.electron.config.ts
- // (electron) and nuxt.config.ts (app); tests get inert
- // stand-ins
- __API_BASE_URL__: JSON.stringify("http://test.local/core"),
- __APP_BASE_URL__: JSON.stringify("http://test.local"),
- __DESKTOP_BUILD__: "false",
- },
+ plugins: appPlugins(),
+ define: appDefine,
resolve: {
- alias: {
- "~": fileURLToPath(new URL("./app", import.meta.url)),
- "@": fileURLToPath(new URL("./app", import.meta.url)),
- },
+ alias: appAlias,
},
test: {
...sharedTestOptions,
@@ -92,6 +109,41 @@ export default defineConfig({
],
},
},
+ {
+ plugins: appPlugins(),
+ define: appDefine,
+ resolve: {
+ alias: appAlias,
+ },
+ optimizeDeps: {
+ // pre-bundle everything the browser tests pull in, so vite
+ // never discovers a dependency mid-run and reloads the page
+ // (which vitest warns makes runs flaky)
+ include: [
+ "vue",
+ "slugify",
+ "dompurify",
+ "@tiptap/core",
+ "@tiptap/pm/model",
+ "@tiptap/pm/state",
+ "@tiptap/extension-list",
+ ],
+ },
+ test: {
+ ...sharedTestOptions,
+ name: "browser",
+ include: ["app/**/*.browser.test.ts"],
+ browser: {
+ enabled: true,
+ provider: playwright(),
+ headless: true,
+ // failure screenshots would pollute the source tree
+ // with __screenshots__ directories
+ screenshotFailures: false,
+ instances: [{ browser: "chromium" }],
+ },
+ },
+ },
await defineVitestProject({
test: {
...sharedTestOptions,
@@ -106,12 +158,17 @@ export default defineConfig({
environmentOptions: {
nuxt: {
overrides: {
- // deterministic base for anything url-building;
+ // deterministic bases for anything url-building;
// mocking useRuntimeConfig instead would starve
- // nuxt's own bootstrap, which consumes it too
+ // nuxt's own bootstrap, which consumes it too.
+ // The auth-realtime url must be absolute or the
+ // 02.auth plugin fails app init on every nuxt
+ // test file (better-auth rejects relative bases)
runtimeConfig: {
public: {
appBaseURL: "http://test.local",
+ authRealtimeAPIBaseHttpURL:
+ "http://test.local/auth-realtime",
},
},
},