add schema tests in server/auth-realtime

This commit is contained in:
swithek 2026-08-24 15:43:20 +02:00
parent 291bcd4ef6
commit da60012825
4 changed files with 962 additions and 15 deletions

View File

@ -202,10 +202,27 @@ stale.
[src/test-setup.ts](src/test-setup.ts) turns that warning into a failure so
it cannot be missed: vitest prints intercepted stderr only under the
verbose reporter, so in a piped or CI run the warning is invisible.
- `src/schema/` is out of scope for the suite: it is a declarative mirror of
web's tiptap extension list, and the behaviour that depends on it is
covered through the transformer in `ydocument` and `operations`. It is
excluded from coverage.
- `src/schema/` is the one place that departs from 1:1 pairing: the whole
directory is tested through [src/schema/index.test.ts](src/schema/index.test.ts)
rather than a test file per node. The files are one unit — a single
editor schema, re-exported through `index.ts` — and the tests that
matter run against the assembled whole: a node round-tripped through
the transformer proves it is registered, which no per-file test can.
The per-node HTML assertions are data tables in the same file for the
same reason.
What the suite pins there is the contract with the two services on
either side. **Round trips** (prosemirror JSON → Y.Doc → prosemirror
JSON) prove the schema knows each node, because a node it does not know
is silently dropped on the way through — including the non-string
attributes (`queries`, `thresholds`) that `cloneXmlElement` exists to
protect. **The uid list** on the `UniqueID` extension is checked to
contain every addressable block and to name only extensions that
exist, because `operations.ts` addresses blocks by uid and a block
missing from that list is one the assistant can never edit. **The
`data-type` strings** are pinned because this service never serializes
to HTML — those callbacks exist purely to stay identical to web's
definitions, so nothing else would notice them drifting apart.
### Naming

View File

@ -0,0 +1,932 @@
import { describe, it } from "vitest"
import {
CalloutBlock,
CodeBlock,
CodeBlockTitle,
getEditorExtensions,
ParameterList,
ParameterListHeader,
ParameterListItem,
ParameterListItemHeader,
ParameterListItemHeaderTitle,
ParameterListItemHeaderType,
SplitDocumentation,
SplitDocumentationLeftSide,
SplitDocumentationRightSide,
TitledCodeBlock,
AddedMark,
CommentMark,
DeletedMark,
} from "./index.js"
import { FigmaBlock } from "./figma.js"
import { ImageBlock } from "./image.js"
import { MermaidBlock } from "./mermaid.js"
import { MetricBlock, MetricGrid } from "./metric.js"
import { transformer } from "../ydocument.js"
interface PMNode {
type: string
attrs?: Record<string, unknown>
content?: PMNode[]
text?: string
marks?: { type: string; attrs?: Record<string, unknown> }[]
}
// the schema's real job is to survive the trip every document takes
// between the editor and core: prosemirror JSON into a Y.Doc and back
// out. A node the schema does not know is silently dropped on the way
// through, so a round trip is what proves it is registered.
function roundTrip(block: PMNode): PMNode {
const ydoc = transformer.toYdoc(
{ type: "doc", content: [block] },
"content",
)
const back = transformer.fromYdoc(ydoc, "content") as PMNode
const first = back.content?.[0]
if (!first) {
throw new Error("the round trip produced an empty document")
}
return first
}
function paragraph(uid: string, text: string): PMNode {
return {
type: "paragraph",
attrs: { uid },
content: [{ type: "text", text }],
}
}
function heading(uid: string, text: string): PMNode {
return {
type: "heading",
attrs: { uid, level: 2 },
content: [{ type: "text", text }],
}
}
function metricBlock(uid: string): PMNode {
return {
type: "metricBlock",
attrs: {
uid,
title: "Requests",
queries: [{ expr: "up", legend: "up" }],
thresholds: [{ value: 10, color: "red" }],
decimals: 2,
unitType: "short",
},
}
}
function titledCodeBlock(uid: string): PMNode {
return {
type: "titledCodeBlock",
attrs: { uid },
content: [
{
type: "codeBlockTitle",
attrs: { uid: `${uid}-title` },
content: [{ type: "text", text: "restart.sh" }],
},
{
type: "codeBlock",
attrs: { uid: `${uid}-code`, language: "bash" },
content: [
{
type: "text",
text: "systemctl restart",
},
],
},
],
}
}
describe("getEditorExtensions", () => {
it("gives every extension a unique name", ({ expect }) => {
const names = getEditorExtensions().map((e) => e.name)
expect(new Set(names).size).toBe(names.length)
})
// a document cannot be parsed at all without them, and they are the
// two the custom blocks never declare themselves
it("includes the document and text nodes every schema needs", ({
expect,
}) => {
const names = getEditorExtensions().map((e) => e.name)
expect(names).toContain("doc")
expect(names).toContain("text")
})
// operations.ts addresses every block by its uid, so a block missing
// from this list is one the assistant can never edit
it.for([
{ name: "callout", input: "calloutBlock" },
{ name: "metric", input: "metricBlock" },
{ name: "metric grid", input: "metricGrid" },
{ name: "image", input: "imageBlock" },
{ name: "figma", input: "figmaBlock" },
{ name: "mermaid", input: "mermaidBlock" },
{ name: "titled code", input: "titledCodeBlock" },
{ name: "split documentation", input: "splitDocumentation" },
{
name: "parameter list",
input: "splitDocumentationParameterList",
},
])("gives the $name block a uid", ({ input }, { expect }) => {
const uid = getEditorExtensions().find(
(e) => e.name === "uniqueID",
)
const types = (uid?.options as { types?: string[] } | undefined)
?.types
expect(types).toContain(input)
})
// a name here that no extension answers to would silently leave that
// block without uids
it("lists only registered extensions as uid carriers", ({ expect }) => {
const extensions = getEditorExtensions()
const names = new Set(extensions.map((e) => e.name))
const uid = extensions.find((e) => e.name === "uniqueID")
const types =
(uid?.options as { types?: string[] } | undefined)
?.types ?? []
expect(types.filter((t) => !names.has(t))).toEqual([])
})
})
describe("editor schema", () => {
describe("round trip", () => {
it.for([
{
name: "a paragraph",
input: paragraph("p1", "Restart it"),
},
{ name: "a heading", input: heading("h1", "Runbook") },
{
name: "an image",
input: {
type: "imageBlock",
attrs: {
uid: "i1",
src: "https://example/x.png",
alt: "x",
width: 400,
},
},
},
{
name: "a figma embed",
input: {
type: "figmaBlock",
attrs: {
uid: "f1",
src: "https://figma/x",
width: 800,
height: 600,
},
},
},
{
name: "a horizontal rule",
input: {
type: "horizontalRule",
attrs: { uid: "r1" },
},
},
])("keeps $name intact", ({ input }, { expect }) => {
expect(roundTrip(input)).toEqual(input)
})
// the reason cloneXmlElement exists: Y.XmlElement.clone() drops
// everything that is not a string, which would strip a metric
// block's queries on every fork and merge
it("keeps a metric block's array and number attributes", ({
expect,
}) => {
const result = roundTrip(metricBlock("m1"))
expect(result.attrs?.queries).toEqual([
{ expr: "up", legend: "up" },
])
expect(result.attrs?.thresholds).toEqual([
{ value: 10, color: "red" },
])
expect(result.attrs?.decimals).toBe(2)
})
it("keeps a mermaid block's source text", ({ expect }) => {
const result = roundTrip({
type: "mermaidBlock",
attrs: { uid: "d1" },
content: [
{
type: "text",
text: "graph TD;\n A-->B;",
},
],
})
expect(result.content?.[0]?.text).toBe(
"graph TD;\n A-->B;",
)
})
it("keeps a callout's icon and its nested paragraph", ({
expect,
}) => {
const result = roundTrip({
type: "calloutBlock",
attrs: { uid: "c1", icon: "lucide:siren" },
content: [
paragraph("c1-p", "Page the on-call"),
],
})
expect(result.attrs?.icon).toBe("lucide:siren")
expect(result.content?.[0]?.content?.[0]?.text).toBe(
"Page the on-call",
)
})
it("keeps the blocks inside a metric grid", ({ expect }) => {
const result = roundTrip({
type: "metricGrid",
attrs: { uid: "g1" },
content: [metricBlock("m1"), metricBlock("m2")],
})
expect(result.content).toHaveLength(2)
expect(result.content?.[1]?.attrs?.uid).toBe("m2")
})
it("keeps a titled code block's title and language", ({
expect,
}) => {
const result = roundTrip(titledCodeBlock("t1"))
expect(result.content?.[0]?.content?.[0]?.text).toBe(
"restart.sh",
)
expect(result.content?.[1]?.attrs?.language).toBe(
"bash",
)
})
// the widest node in the schema: a heading and prose on the
// left, code or metrics on the right
it("keeps both sides of a split documentation block", ({
expect,
}) => {
const result = roundTrip({
type: "splitDocumentation",
attrs: { uid: "s1" },
content: [
{
type: "splitDocumentationLeftSide",
attrs: { uid: "s1-l" },
content: [
heading(
"s1-h",
"Endpoint",
),
paragraph(
"s1-p",
"Returns a page",
),
],
},
{
type: "splitDocumentationRightSide",
attrs: { uid: "s1-r" },
content: [
titledCodeBlock("s1-c"),
],
},
],
})
expect(result.content?.[0]?.type).toBe(
"splitDocumentationLeftSide",
)
expect(result.content?.[1]?.type).toBe(
"splitDocumentationRightSide",
)
expect(result.content?.[0]?.content?.[0]?.type).toBe(
"heading",
)
})
it("keeps a parameter list's header and items", ({
expect,
}) => {
const result = roundTrip({
type: "splitDocumentationParameterList",
attrs: { uid: "pl1" },
content: [
{
type: "splitDocumentationParameterListHeader",
attrs: { uid: "pl1-h" },
content: [
{
type: "text",
text: "Parameters",
},
],
},
{
type: "splitDocumentationParameterListItem",
attrs: { uid: "pl1-i" },
content: [
{
type: "splitDocumentationParameterListItemHeader",
attrs: {
uid: "pl1-ih",
},
content: [
{
type: "splitDocumentationParameterListItemHeaderTitle",
attrs: {
uid: "pl1-t",
},
content: [
{
type: "text",
text: "limit",
},
],
},
{
type: "splitDocumentationParameterListItemHeaderType",
attrs: {
uid: "pl1-ty",
},
content: [
{
type: "text",
text: "number",
},
],
},
],
},
paragraph(
"pl1-d",
"How many to return",
),
],
},
],
})
expect(result.content?.[0]?.content?.[0]?.text).toBe(
"Parameters",
)
expect(
result.content?.[1]?.content?.[0]?.content?.[0]
?.content?.[0]?.text,
).toBe("limit")
})
})
describe("marks", () => {
// comment marks carry the id the comment sidebar resolves; a
// dropped attribute orphans the thread
it("keeps a comment mark's id", ({ expect }) => {
const result = roundTrip({
type: "paragraph",
attrs: { uid: "p1" },
content: [
{
type: "text",
text: "flagged",
marks: [
{
type: "comment",
attrs: {
commentId: "cmt-1",
},
},
],
},
],
})
expect(result.content?.[0]?.marks?.[0]?.type).toBe(
"comment",
)
expect(
result.content?.[0]?.marks?.[0]?.attrs
?.commentId,
).toBe("cmt-1")
})
// the diff view marks additions and deletions inline
it.for([
{ name: "added", input: "added" },
{ name: "deleted", input: "deleted" },
])("keeps a $name mark", ({ input }, { expect }) => {
const result = roundTrip({
type: "paragraph",
attrs: { uid: "p1" },
content: [
{
type: "text",
text: "changed",
marks: [{ type: input }],
},
],
})
expect(result.content?.[0]?.marks?.[0]?.type).toBe(
input,
)
})
it.for([
{ name: "bold", input: "bold" },
{ name: "italic", input: "italic" },
{ name: "strike", input: "strike" },
{ name: "underline", input: "underline" },
{ name: "code", input: "code" },
])("keeps a $name mark", ({ input }, { expect }) => {
const result = roundTrip({
type: "paragraph",
attrs: { uid: "p1" },
content: [
{
type: "text",
text: "styled",
marks: [{ type: input }],
},
],
})
expect(result.content?.[0]?.marks?.[0]?.type).toBe(
input,
)
})
it("keeps a link's href", ({ expect }) => {
const result = roundTrip({
type: "paragraph",
attrs: { uid: "p1" },
content: [
{
type: "text",
text: "docs",
marks: [
{
type: "link",
attrs: {
href: "https://example/docs",
},
},
],
},
],
})
expect(
result.content?.[0]?.marks?.[0]?.attrs?.href,
).toBe("https://example/docs")
})
})
})
// the HTML side of the schema. This service never serializes a document
// to HTML — the transformer works on prosemirror JSON — so these
// callbacks exist only to keep the definitions identical to web's. A
// data-type that drifts apart is a block web writes and cannot parse
// back, which is why the strings are pinned here rather than left to the
// two copies to agree by luck.
type Attrs = Record<string, unknown>
interface AttributeSpec {
default?: unknown
parseHTML?: (element: {
getAttribute(name: string): string | null
}) => unknown
renderHTML?: (attrs: Attrs) => Attrs
}
interface NodeSpec {
renderHTML?: (
this: { options: Attrs },
props: { HTMLAttributes: Attrs; node?: { attrs: Attrs } },
) => [string, Attrs, ...unknown[]]
parseHTML?: (this: { options: Attrs }) => { tag: string }[]
addAttributes?: (this: {
options: Attrs
}) => Record<string, AttributeSpec>
}
function specOf(node: { config: unknown }): NodeSpec {
return node.config as NodeSpec
}
function render(
node: { config: unknown },
attrs: Attrs = {},
options: Attrs = {},
): [string, Attrs, ...unknown[]] {
const rendered = specOf(node).renderHTML?.call(
{ options },
{ HTMLAttributes: {}, node: { attrs } },
)
if (!rendered) {
throw new Error("the node declares no renderHTML")
}
return rendered
}
function parseSelector(node: { config: unknown }): string | undefined {
return specOf(node).parseHTML?.call({ options: {} })[0]?.tag
}
function attributesOf(node: {
config: unknown
}): Record<string, AttributeSpec> {
return specOf(node).addAttributes?.call({ options: {} }) ?? {}
}
describe("schema html contract", () => {
it.for([
{
name: "callout",
input: CalloutBlock,
expected: "callout-block",
},
{
name: "metric grid",
input: MetricGrid,
expected: "metric-grid",
},
{
name: "metric",
input: MetricBlock,
expected: "metric-block",
},
{ name: "figma", input: FigmaBlock, expected: "figma-block" },
{
name: "mermaid",
input: MermaidBlock,
expected: "mermaid-block",
},
{ name: "code", input: CodeBlock, expected: "code-block" },
{
name: "code title",
input: CodeBlockTitle,
expected: "code-block-title",
},
{
name: "titled code",
input: TitledCodeBlock,
expected: "titled-code-block",
},
{
name: "split documentation",
input: SplitDocumentation,
expected: "split-documentation",
},
{
name: "split left side",
input: SplitDocumentationLeftSide,
expected: "split-documentation-left-side",
},
{
name: "split right side",
input: SplitDocumentationRightSide,
expected: "split-documentation-right-side",
},
{
name: "parameter list",
input: ParameterList,
expected: "split-documentation-parameter-list",
},
{
name: "parameter list header",
input: ParameterListHeader,
expected: "split-documentation-parameter-list-header",
},
{
name: "parameter list item",
input: ParameterListItem,
expected: "split-documentation-parameter-list-item",
},
{
name: "parameter list item header",
input: ParameterListItemHeader,
expected: "split-documentation-parameter-list-item-header",
},
{
name: "parameter list item title",
input: ParameterListItemHeaderTitle,
expected: "split-documentation-parameter-list-item-header-title",
},
{
name: "parameter list item type",
input: ParameterListItemHeaderType,
expected: "split-documentation-parameter-list-item-header-type",
},
])(
"tags the $name block as $expected and parses it back",
({ input, expected }, { expect }) => {
expect(render(input)[1]["data-type"]).toBe(expected)
expect(parseSelector(input)).toContain(expected)
},
)
it.for([
{ name: "image", input: ImageBlock, expected: "img" },
{ name: "mermaid", input: MermaidBlock, expected: "pre" },
{ name: "code", input: CodeBlock, expected: "pre" },
{ name: "callout", input: CalloutBlock, expected: "div" },
])(
"renders the $name block as a $expected element",
({ input, expected }, { expect }) => {
expect(render(input)[0]).toBe(expected)
},
)
describe("callout icon", () => {
it("reads the icon off data-icon", ({ expect }) => {
const icon = attributesOf(CalloutBlock).icon
expect(
icon?.parseHTML?.({
getAttribute: (name) =>
name === "data-icon"
? "lucide:zap"
: null,
}),
).toBe("lucide:zap")
})
it("writes the icon back to data-icon", ({ expect }) => {
const icon = attributesOf(CalloutBlock).icon
expect(
icon?.renderHTML?.({ icon: "lucide:siren" }),
).toEqual({
"data-icon": "lucide:siren",
})
})
it("defaults to the plain text icon", ({ expect }) => {
expect(attributesOf(CalloutBlock).icon?.default).toBe(
"lucide:text",
)
})
})
describe("figma attributes", () => {
// the width and height arrive as strings on the element and are
// stored as numbers, so a document round-trips as numbers
it.for([
{ name: "width", input: "width" },
{ name: "height", input: "height" },
])("parses $name into a number", ({ input }, { expect }) => {
const attr = attributesOf(FigmaBlock)[input]
expect(
attr?.parseHTML?.({
getAttribute: () => "800",
}),
).toBe(800)
})
it.for([
{ name: "src", input: "src" },
{ name: "width", input: "width" },
{ name: "height", input: "height" },
])(
"parses a missing $name as null",
({ input }, { expect }) => {
const attr = attributesOf(FigmaBlock)[input]
expect(
attr?.parseHTML?.({
getAttribute: () => null,
}),
).toBeNull()
},
)
it.for([
{ name: "src", input: "src", expected: "data-src" },
{
name: "width",
input: "width",
expected: "data-width",
},
{
name: "height",
input: "height",
expected: "data-height",
},
])(
"writes $name back to $expected",
({ input, expected }, { expect }) => {
const attr = attributesOf(FigmaBlock)[input]
expect(
attr?.renderHTML?.({ [input]: 800 }),
).toEqual({
[expected]: 800,
})
},
)
// an unset value writes no attribute at all rather than an
// empty one, which is what keeps the markup clean
it.for([
{ name: "src", input: "src" },
{ name: "width", input: "width" },
{ name: "height", input: "height" },
])(
"writes nothing when $name is unset",
({ input }, { expect }) => {
const attr = attributesOf(FigmaBlock)[input]
expect(attr?.renderHTML?.({})).toEqual({})
},
)
})
describe("marks", () => {
it.for([
{
name: "added",
input: AddedMark,
expected: "data-added",
},
{
name: "deleted",
input: DeletedMark,
expected: "data-deleted",
},
])(
"flags a $name range with $expected",
({ input, expected }, { expect }) => {
expect(render(input)[1][expected]).toBe("true")
expect(parseSelector(input)).toContain(expected)
},
)
it("writes a comment's id to data-comment-id", ({ expect }) => {
const attr = attributesOf(CommentMark).commentId
expect(
attr?.renderHTML?.({ commentId: "cmt-1" }),
).toEqual({
"data-comment-id": "cmt-1",
})
})
it("reads a comment's id off data-comment-id", ({ expect }) => {
const attr = attributesOf(CommentMark).commentId
expect(
attr?.parseHTML?.({
getAttribute: (name) =>
name === "data-comment-id"
? "cmt-1"
: null,
}),
).toBe("cmt-1")
})
})
describe("code block language", () => {
it("puts the language on the inner code element", ({
expect,
}) => {
const rendered = render(
CodeBlock,
{ language: "bash" },
{ languageClassPrefix: "language-" },
)
const code = rendered[2] as [
string,
Attrs,
...unknown[],
]
expect(code[1].class).toBe("language-bash")
})
it("leaves the class unset when no language is chosen", ({
expect,
}) => {
const rendered = render(
CodeBlock,
{},
{ languageClassPrefix: "language-" },
)
const code = rendered[2] as [
string,
Attrs,
...unknown[],
]
expect(code[1].class).toBeNull()
})
})
})
describe("split documentation orientation", () => {
it.for([
{ name: "set", input: "true", expected: true },
{ name: "absent", input: null, expected: false },
{ name: "some other value", input: "yes", expected: false },
])(
"reads an orientation that is $name as $expected",
({ input, expected }, { expect }) => {
const attr = attributesOf(SplitDocumentation).inversed
expect(
attr?.parseHTML?.({
getAttribute: () => input,
}),
).toBe(expected)
},
)
it("writes the orientation back only when it is inverted", ({
expect,
}) => {
const attr = attributesOf(SplitDocumentation).inversed
expect(attr?.renderHTML?.({ inversed: true })).toEqual({
"data-inversed": "true",
})
expect(attr?.renderHTML?.({ inversed: false })).toEqual({})
})
it("defaults to the uninverted orientation", ({ expect }) => {
expect(attributesOf(SplitDocumentation).inversed?.default).toBe(
false,
)
})
})
describe("comment mark rendering", () => {
it("renders a comment as a span carrying its attributes", ({
expect,
}) => {
const rendered = specOf(CommentMark).renderHTML?.call(
{ options: {} },
{ HTMLAttributes: { "data-comment-id": "cmt-1" } },
)
expect(rendered?.[0]).toBe("span")
expect(rendered?.[1]["data-comment-id"]).toBe("cmt-1")
})
})
describe("code block without configured options", () => {
// the prefix comes from the extension's options, and a caller that
// renders the node without them still has to produce a usable class
it("falls back to an unprefixed language class", ({ expect }) => {
const rendered = render(CodeBlock, { language: "bash" }, {})
const code = rendered[2] as [string, Attrs, ...unknown[]]
expect(code[1].class).toBe("bash")
})
})
describe("code block options", () => {
// the definition extends CodeBlockLowlight, so its own defaults are
// layered over whatever the parent extension supplied — and it has to
// hold up when there is no parent to layer over
it.for([
{ name: "a parent extension", input: true },
{ name: "no parent at all", input: false },
])("resolves its defaults with $name", ({ input }, { expect }) => {
const addOptions = (
CodeBlock.config as {
addOptions?: (this: {
parent?: () => Record<string, unknown>
}) => Record<string, unknown>
}
).addOptions
const options = addOptions?.call(
input
? { parent: () => ({ tabSize: 8 }) }
: { parent: undefined },
)
expect(options?.languageClassPrefix).toBe("language-")
expect(options?.tabSize).toBe(input ? 8 : 2)
})
})

View File

@ -115,8 +115,11 @@ export function getEditorExtensions(): Extensions {
UniqueID.configure({
types: contentExtensionsWithIDs.map((v) => v.name),
attributeName: "uid",
// annotated because tiptap declares generateID as
// returning any, which propagates to the arrow
// NOCOV: UniqueID calls this only while an editor is
// applying a transaction, which never happens in this
// service — it transforms documents, it does not edit
// them. Annotated because tiptap declares generateID as
// returning any, which propagates to the arrow.
generateID: (): string => nanoid(),
}),
]

View File

@ -36,11 +36,6 @@ export default defineConfig({
// loaded through node's --import before the app
// graph exists, so it can hold no imports of ours
"src/sentry.ts",
// the editor schema mirrors web's tiptap
// extension list; it is configuration, and the
// behaviour that depends on it is covered through
// the transformer in ydocument and operations
"src/schema/**",
"src/**/test-helpers.ts",
"src/test-setup.ts",
],
@ -49,10 +44,10 @@ export default defineConfig({
// genuinely untestable branches stay visible in the
// report, marked with NOCOV comments in place
thresholds: {
statements: 98.4,
branches: 96.3,
functions: 98.4,
lines: 98.4,
statements: 98.5,
branches: 96.6,
functions: 98.5,
lines: 98.5,
},
},
},