From 0a696b30632ff57b3545dc4ecf9c96cfde41c4fd Mon Sep 17 00:00:00 2001 From: swithek <52840391+swithek@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:03:25 +0200 Subject: [PATCH] add utils tests in web --- .github/workflows/qa-web.yml | 4 + web/CLAUDE.md | 16 +- web/app/utils/api/comment.test.ts | 73 +++++ .../api/data-source/generic-query.test.ts | 40 +++ web/app/utils/api/data-source/index.test.ts | 15 + web/app/utils/api/document.test.ts | 132 ++++++++ web/app/utils/auth.test.ts | 53 +++ web/app/utils/html.browser.test.ts | 40 +++ web/app/utils/html.test.ts | 9 + web/app/utils/icon.test.ts | 17 + web/app/utils/json.test.ts | 67 ++++ web/app/utils/object.test.ts | 236 ++++++++++++++ web/app/utils/promql.test.ts | 34 ++ web/app/utils/sanitize.browser.test.ts | 26 ++ web/app/utils/sanitize.test.ts | 15 + web/app/utils/shortcuts.test.ts | 91 ++++++ web/app/utils/time.test.ts | 142 +++++++++ web/app/utils/time.ts | 7 +- web/app/utils/tiptap.browser.test.ts | 257 +++++++++++++++ web/app/utils/tiptap.test.ts | 221 +++++++++++++ web/app/utils/url.test.ts | 228 +++++++++++++ web/app/utils/vue.nuxt.test.ts | 55 ++++ web/app/utils/websocket.test.ts | 301 ++++++++++++++++++ web/package.json | 6 +- web/pnpm-lock.yaml | 123 ++++++- web/vitest.config.ts | 115 +++++-- 26 files changed, 2272 insertions(+), 51 deletions(-) create mode 100644 web/app/utils/api/comment.test.ts create mode 100644 web/app/utils/api/data-source/generic-query.test.ts create mode 100644 web/app/utils/api/data-source/index.test.ts create mode 100644 web/app/utils/api/document.test.ts create mode 100644 web/app/utils/auth.test.ts create mode 100644 web/app/utils/html.browser.test.ts create mode 100644 web/app/utils/html.test.ts create mode 100644 web/app/utils/icon.test.ts create mode 100644 web/app/utils/json.test.ts create mode 100644 web/app/utils/object.test.ts create mode 100644 web/app/utils/promql.test.ts create mode 100644 web/app/utils/sanitize.browser.test.ts create mode 100644 web/app/utils/sanitize.test.ts create mode 100644 web/app/utils/shortcuts.test.ts create mode 100644 web/app/utils/time.test.ts create mode 100644 web/app/utils/tiptap.browser.test.ts create mode 100644 web/app/utils/tiptap.test.ts create mode 100644 web/app/utils/url.test.ts create mode 100644 web/app/utils/vue.nuxt.test.ts create mode 100644 web/app/utils/websocket.test.ts 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", }, }, },