add utils tests in web

This commit is contained in:
swithek 2026-08-17 23:03:25 +02:00
parent 975c7187ff
commit 0a696b3063
26 changed files with 2272 additions and 51 deletions

View File

@ -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

View File

@ -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 |

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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: "",
})
})
})

View File

@ -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",
)
})
})

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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:/)
}
})
})

View File

@ -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("")
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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 <mark>hit</mark> here")).toBe(
"found a <mark>hit</mark> here",
)
})
it("strips every other tag but keeps its text", ({ expect }) => {
expect(sanitizeSearchResult("<b>bold</b> and <mark>hit</mark>")).toBe(
"bold and <mark>hit</mark>",
)
})
it("strips attributes from mark tags", ({ expect }) => {
expect(sanitizeSearchResult('<mark class="x" onclick="y">hit</mark>')).toBe(
"<mark>hit</mark>",
)
})
it("drops script content entirely", ({ expect }) => {
expect(sanitizeSearchResult("<script>alert(1)</script>safe")).toBe("safe")
})
})

View File

@ -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("<b>bold</b> and <mark>hit</mark>")).toBe(
"bold and hit",
)
})
})

View File

@ -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" },
])
})
})

142
web/app/utils/time.test.ts Normal file
View File

@ -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")
})
})

View File

@ -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(),
)
}
/**

View File

@ -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<T extends HTMLElement>(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()
}
})
},
)

View File

@ -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<InputRule["handler"]>[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)
})
})

228
web/app/utils/url.test.ts Normal file
View File

@ -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)
})
})

View File

@ -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<string | null>(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<string | null>("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<string | null>("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<string | undefined>("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)
})
})

View File

@ -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<string>
}
// 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()
}
})
})
})

View File

@ -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",

View File

@ -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

View File

@ -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",
},
},
},