mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add utils tests in web
This commit is contained in:
parent
975c7187ff
commit
0a696b3063
4
.github/workflows/qa-web.yml
vendored
4
.github/workflows/qa-web.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
73
web/app/utils/api/comment.test.ts
Normal file
73
web/app/utils/api/comment.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
40
web/app/utils/api/data-source/generic-query.test.ts
Normal file
40
web/app/utils/api/data-source/generic-query.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
15
web/app/utils/api/data-source/index.test.ts
Normal file
15
web/app/utils/api/data-source/index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
132
web/app/utils/api/document.test.ts
Normal file
132
web/app/utils/api/document.test.ts
Normal 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: "",
|
||||
})
|
||||
})
|
||||
})
|
||||
53
web/app/utils/auth.test.ts
Normal file
53
web/app/utils/auth.test.ts
Normal 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
40
web/app/utils/html.browser.test.ts
Normal file
40
web/app/utils/html.browser.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
9
web/app/utils/html.test.ts
Normal file
9
web/app/utils/html.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
17
web/app/utils/icon.test.ts
Normal file
17
web/app/utils/icon.test.ts
Normal 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:/)
|
||||
}
|
||||
})
|
||||
})
|
||||
67
web/app/utils/json.test.ts
Normal file
67
web/app/utils/json.test.ts
Normal 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("")
|
||||
})
|
||||
})
|
||||
236
web/app/utils/object.test.ts
Normal file
236
web/app/utils/object.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
34
web/app/utils/promql.test.ts
Normal file
34
web/app/utils/promql.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
26
web/app/utils/sanitize.browser.test.ts
Normal file
26
web/app/utils/sanitize.browser.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
15
web/app/utils/sanitize.test.ts
Normal file
15
web/app/utils/sanitize.test.ts
Normal 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
91
web/app/utils/shortcuts.test.ts
Normal file
91
web/app/utils/shortcuts.test.ts
Normal 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
142
web/app/utils/time.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
257
web/app/utils/tiptap.browser.test.ts
Normal file
257
web/app/utils/tiptap.browser.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
221
web/app/utils/tiptap.test.ts
Normal file
221
web/app/utils/tiptap.test.ts
Normal 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
228
web/app/utils/url.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
55
web/app/utils/vue.nuxt.test.ts
Normal file
55
web/app/utils/vue.nuxt.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
301
web/app/utils/websocket.test.ts
Normal file
301
web/app/utils/websocket.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user