Merge pull request 'test: unit tests for the 10 candidates (#139)' (#142) from test/unit-tests-139 into develop
Reviewed-on: #142
This commit was merged in pull request #142.
This commit is contained in:
87
apps/client/src/features/dictation/utils/encode-wav.test.ts
Normal file
87
apps/client/src/features/dictation/utils/encode-wav.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { encodeWavPcm16 } from "./encode-wav";
|
||||
|
||||
// Contract tests for `encodeWavPcm16` (encode-wav.ts). The dictation feature
|
||||
// streams microphone audio as mono 16-bit PCM WAV to the STT endpoint, which
|
||||
// whitelists audio/wav. A regression in the WAV header or PCM16 clamping would
|
||||
// produce audio the server cannot decode (silence / garbled transcripts), so we
|
||||
// assert the canonical 44-byte header layout and the sample quantisation rails.
|
||||
|
||||
// Read a DataView back out of a Blob. jsdom's Blob does not implement
|
||||
// `.arrayBuffer()`, so go through FileReader.readAsArrayBuffer instead.
|
||||
function readView(blob: Blob): Promise<DataView> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(new DataView(reader.result as ArrayBuffer));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function readStr(view: DataView, offset: number, length: number): string {
|
||||
let s = "";
|
||||
for (let i = 0; i < length; i++) s += String.fromCharCode(view.getUint8(offset + i));
|
||||
return s;
|
||||
}
|
||||
|
||||
describe("encodeWavPcm16", () => {
|
||||
it("writes the canonical RIFF/WAVE/fmt /data tags", async () => {
|
||||
const view = await readView(encodeWavPcm16(new Float32Array(4)));
|
||||
expect(readStr(view, 0, 4)).toBe("RIFF");
|
||||
expect(readStr(view, 8, 4)).toBe("WAVE");
|
||||
expect(readStr(view, 12, 4)).toBe("fmt ");
|
||||
expect(readStr(view, 36, 4)).toBe("data");
|
||||
});
|
||||
|
||||
it("writes a PCM fmt chunk (size=16, format=1, mono, 16-bit)", async () => {
|
||||
const samples = new Float32Array(10);
|
||||
const view = await readView(encodeWavPcm16(samples));
|
||||
expect(view.getUint32(16, true)).toBe(16); // fmt chunk size
|
||||
expect(view.getUint16(20, true)).toBe(1); // audioFormat = PCM
|
||||
expect(view.getUint16(22, true)).toBe(1); // channels = mono
|
||||
expect(view.getUint16(34, true)).toBe(16); // bits per sample
|
||||
});
|
||||
|
||||
it("derives byteRate, blockAlign and dataSize from the sample rate and length", async () => {
|
||||
const sampleRate = 16000;
|
||||
const samples = new Float32Array(10);
|
||||
const view = await readView(encodeWavPcm16(samples, sampleRate));
|
||||
expect(view.getUint32(28, true)).toBe(sampleRate * 2); // byteRate = sampleRate * 2
|
||||
expect(view.getUint16(32, true)).toBe(2); // blockAlign = 2 (mono * 16-bit)
|
||||
expect(view.getUint32(40, true)).toBe(samples.length * 2); // dataSize
|
||||
expect(view.getUint32(4, true)).toBe(36 + samples.length * 2); // RIFF chunk size
|
||||
});
|
||||
|
||||
it("defaults the sample rate to 16000 at offset 24", async () => {
|
||||
const view = await readView(encodeWavPcm16(new Float32Array(2)));
|
||||
expect(view.getUint32(24, true)).toBe(16000);
|
||||
});
|
||||
|
||||
it("writes the overridden sample rate at offset 24 (8000 / 48000)", async () => {
|
||||
const view8 = await readView(encodeWavPcm16(new Float32Array(2), 8000));
|
||||
expect(view8.getUint32(24, true)).toBe(8000);
|
||||
expect(view8.getUint32(28, true)).toBe(8000 * 2); // byteRate follows the override
|
||||
|
||||
const view48 = await readView(encodeWavPcm16(new Float32Array(2), 48000));
|
||||
expect(view48.getUint32(24, true)).toBe(48000);
|
||||
expect(view48.getUint32(28, true)).toBe(48000 * 2);
|
||||
});
|
||||
|
||||
it("clamps and quantises PCM16 samples to the asymmetric rails", async () => {
|
||||
// +1.0 -> 32767 (clamped>=0 uses *0x7fff), -1.0 -> -32768 (clamped<0 uses *0x8000),
|
||||
// 0 -> 0, and out-of-range values are clamped to the rails first.
|
||||
const samples = new Float32Array([1.0, -1.0, 0, 1.5, -1.5]);
|
||||
const view = await readView(encodeWavPcm16(samples));
|
||||
expect(view.getInt16(44 + 0 * 2, true)).toBe(32767); // +1.0
|
||||
expect(view.getInt16(44 + 1 * 2, true)).toBe(-32768); // -1.0
|
||||
expect(view.getInt16(44 + 2 * 2, true)).toBe(0); // 0
|
||||
expect(view.getInt16(44 + 3 * 2, true)).toBe(32767); // +1.5 -> clamped to +1.0
|
||||
expect(view.getInt16(44 + 4 * 2, true)).toBe(-32768); // -1.5 -> clamped to -1.0
|
||||
});
|
||||
|
||||
it("produces a mono blob of length 44 + samples.length * 2", () => {
|
||||
expect(encodeWavPcm16(new Float32Array(0)).size).toBe(44);
|
||||
expect(encodeWavPcm16(new Float32Array(100)).size).toBe(44 + 100 * 2);
|
||||
expect(encodeWavPcm16(new Float32Array(100)).type).toBe("audio/wav");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { canCreatePage } from "./can-create-page.ts";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { SpaceRole } from "@/lib/types.ts";
|
||||
|
||||
// Unit tests for `canCreatePage` (new-note-button.tsx). The home screen has no
|
||||
// active space, so the "New note" button resolves its target from the user's
|
||||
// writable spaces. This predicate mirrors the server space-ability mapping
|
||||
// (ADMIN/WRITER can manage pages, READER is read-only). The /spaces list endpoint
|
||||
// only returns membership.role (not CASL permissions), so a regression here would
|
||||
// either hide the button for legitimate writers or offer it to read-only members.
|
||||
|
||||
function spaceWithRole(role?: SpaceRole): ISpace {
|
||||
// Only `membership.role` is consulted by the predicate; the rest is filler.
|
||||
return {
|
||||
membership: role ? ({ role } as any) : undefined,
|
||||
} as ISpace;
|
||||
}
|
||||
|
||||
describe("canCreatePage", () => {
|
||||
it("is true for ADMIN and WRITER roles", () => {
|
||||
expect(canCreatePage(spaceWithRole(SpaceRole.ADMIN))).toBe(true);
|
||||
expect(canCreatePage(spaceWithRole(SpaceRole.WRITER))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for the READER role", () => {
|
||||
expect(canCreatePage(spaceWithRole(SpaceRole.READER))).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when membership / role is missing", () => {
|
||||
expect(canCreatePage(spaceWithRole(undefined))).toBe(false);
|
||||
expect(canCreatePage({} as ISpace)).toBe(false);
|
||||
});
|
||||
|
||||
it("filters an empty space list down to nothing writable", () => {
|
||||
const spaces: ISpace[] = [
|
||||
spaceWithRole(SpaceRole.READER),
|
||||
spaceWithRole(undefined),
|
||||
];
|
||||
expect(spaces.filter(canCreatePage)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
15
apps/client/src/features/home/components/can-create-page.ts
Normal file
15
apps/client/src/features/home/components/can-create-page.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { SpaceRole } from "@/lib/types.ts";
|
||||
|
||||
// The /spaces list endpoint returns membership.role but NOT membership.permissions
|
||||
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
|
||||
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
|
||||
// for the current user when their role is ADMIN or WRITER.
|
||||
//
|
||||
// Extracted from new-note-button.tsx into this pure sibling module so it can be
|
||||
// unit-tested without importing the component (whose dependency chain pulls in
|
||||
// main.tsx and renders the whole app at import time).
|
||||
export function canCreatePage(space: ISpace): boolean {
|
||||
const role = space.membership?.role;
|
||||
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
|
||||
}
|
||||
@@ -6,18 +6,9 @@ import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { SpaceRole } from "@/lib/types.ts";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
|
||||
// The /spaces list endpoint returns membership.role but NOT membership.permissions
|
||||
// (only /spaces/info includes CASL rules). Mirror the server space-ability mapping:
|
||||
// ADMIN and WRITER can manage pages, READER is read-only. So a space is writable
|
||||
// for the current user when their role is ADMIN or WRITER.
|
||||
function canCreatePage(space: ISpace): boolean {
|
||||
const role = space.membership?.role;
|
||||
return role === SpaceRole.ADMIN || role === SpaceRole.WRITER;
|
||||
}
|
||||
import { canCreatePage } from "./can-create-page.ts";
|
||||
|
||||
// Prominent home-screen action to create a new note (page). Because the home
|
||||
// screen has no active space, the target space is resolved from the user's
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Schema } from "@tiptap/pm/model";
|
||||
import { computeHistoryDiff } from "./history-diff.ts";
|
||||
|
||||
// Unit tests for `computeHistoryDiff` (history-diff.ts) — the pure core extracted
|
||||
// from history-editor.tsx. Given the editor schema plus old/new ProseMirror
|
||||
// document JSON it produces {decorationSet, added, deleted, total}: inline
|
||||
// decorations for text edits, whole-node decorations for added block nodes
|
||||
// (image/table), widget "ghosts" for deleted block nodes (callout), and an empty
|
||||
// diff for the first version or malformed JSON.
|
||||
//
|
||||
// We drive it with a hand-built ProseMirror schema rather than the real
|
||||
// `mainExtensions` because importing the editor extensions pulls in the whole app
|
||||
// (main.tsx) at module load. The schema below mirrors the relevant shape: a doc of
|
||||
// block content, an `image` block atom and a `table` block treated as whole-node
|
||||
// diffs, and a `callout` block treated as a deletable whole node.
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: "block+" },
|
||||
paragraph: {
|
||||
group: "block",
|
||||
content: "inline*",
|
||||
toDOM: () => ["p", 0],
|
||||
},
|
||||
callout: {
|
||||
group: "block",
|
||||
content: "inline*",
|
||||
toDOM: () => ["div", { class: "callout" }, 0],
|
||||
},
|
||||
image: {
|
||||
group: "block",
|
||||
atom: true,
|
||||
attrs: { src: { default: "" } },
|
||||
toDOM: (node) => ["img", { src: node.attrs.src }],
|
||||
},
|
||||
table: {
|
||||
group: "block",
|
||||
content: "paragraph+",
|
||||
toDOM: () => ["table", ["tbody", 0]],
|
||||
},
|
||||
text: { group: "inline" },
|
||||
},
|
||||
});
|
||||
|
||||
const para = (text: string) => ({
|
||||
type: "paragraph",
|
||||
content: text ? [{ type: "text", text }] : [],
|
||||
});
|
||||
const docOf = (...blocks: any[]) => ({ type: "doc", content: blocks });
|
||||
|
||||
describe("computeHistoryDiff", () => {
|
||||
it("returns an empty diff (counts 0) when there is no previous version", () => {
|
||||
const diff = computeHistoryDiff(schema, docOf(para("hello")), undefined);
|
||||
expect(diff.added).toBe(0);
|
||||
expect(diff.deleted).toBe(0);
|
||||
expect(diff.total).toBe(0);
|
||||
expect(diff.decorationSet.find()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns an empty diff when content is missing", () => {
|
||||
const diff = computeHistoryDiff(schema, undefined, docOf(para("x")));
|
||||
expect(diff.total).toBe(0);
|
||||
});
|
||||
|
||||
it("emits inline decorations and counts for a text edit", () => {
|
||||
const prev = docOf(para("hello world"));
|
||||
const next = docOf(para("hello brave world"));
|
||||
const diff = computeHistoryDiff(schema, next, prev);
|
||||
|
||||
expect(diff.added).toBeGreaterThan(0);
|
||||
const decos = diff.decorationSet.find();
|
||||
expect(decos.length).toBeGreaterThan(0);
|
||||
// An inline text addition is rendered with the inline-added class.
|
||||
const classes = decos.map((d) => (d.spec as any)?.class ?? (d as any).type?.attrs?.class);
|
||||
const hasInline = JSON.stringify(decos).includes("history-diff-added") ||
|
||||
classes.some((c) => c === "history-diff-added");
|
||||
expect(hasInline).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an added image as a whole-node addition", () => {
|
||||
const prev = docOf(para("text"));
|
||||
const next = docOf(para("text"), { type: "image", attrs: { src: "a.png" } });
|
||||
const diff = computeHistoryDiff(schema, next, prev);
|
||||
expect(diff.added).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(diff.decorationSet.find())).toContain(
|
||||
"history-diff-node-added",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an added table as a whole-node addition", () => {
|
||||
const prev = docOf(para("text"));
|
||||
const next = docOf(para("text"), {
|
||||
type: "table",
|
||||
content: [para("cell")],
|
||||
});
|
||||
const diff = computeHistoryDiff(schema, next, prev);
|
||||
expect(diff.added).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(diff.decorationSet.find())).toContain(
|
||||
"history-diff-node-added",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a widget ghost for a deleted callout", () => {
|
||||
const prev = docOf(para("text"), {
|
||||
type: "callout",
|
||||
content: [{ type: "text", text: "warning" }],
|
||||
});
|
||||
const next = docOf(para("text"));
|
||||
const diff = computeHistoryDiff(schema, next, prev);
|
||||
expect(diff.deleted).toBeGreaterThan(0);
|
||||
// The deleted whole node produces a widget decoration (toDOM callback).
|
||||
const decos = diff.decorationSet.find();
|
||||
expect(decos.some((d) => (d as any).type?.toDOM || (d as any).type?.widget)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to an empty diff (no throw) on malformed version JSON", () => {
|
||||
const malformed = { type: "doc", content: [{ type: "nonexistent-node" }] };
|
||||
expect(() =>
|
||||
computeHistoryDiff(schema, malformed, docOf(para("x"))),
|
||||
).not.toThrow();
|
||||
const diff = computeHistoryDiff(schema, malformed, docOf(para("x")));
|
||||
expect(diff.total).toBe(0);
|
||||
expect(diff.decorationSet.find()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
168
apps/client/src/features/page-history/components/history-diff.ts
Normal file
168
apps/client/src/features/page-history/components/history-diff.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { DOMSerializer, Node, Schema } from "@tiptap/pm/model";
|
||||
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
||||
import { recreateTransform } from "@docmost/editor-ext";
|
||||
|
||||
export interface HistoryDiff {
|
||||
decorationSet: DecorationSet;
|
||||
added: number;
|
||||
deleted: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Block-level nodes that are diffed as a whole ("this image/table/callout was
|
||||
// added/removed") instead of by inline character ranges.
|
||||
const SPECIAL_NODE_TYPES = new Set([
|
||||
"image",
|
||||
"attachment",
|
||||
"video",
|
||||
"excalidraw",
|
||||
"drawio",
|
||||
"mermaid",
|
||||
"mathBlock",
|
||||
"mathInline",
|
||||
"table",
|
||||
"details",
|
||||
"callout",
|
||||
]);
|
||||
|
||||
// Pure core of the history diff (extracted from history-editor.tsx, behaviour
|
||||
// preserving): given the editor schema and two ProseMirror document JSONs, return
|
||||
// the decoration set plus added/deleted/total counts. The widget decorations carry
|
||||
// lazy DOM-building callbacks (only run by ProseMirror at render time), so this
|
||||
// function itself does no DOM work and needs no live editor instance.
|
||||
//
|
||||
// `previousContent` undefined -> first version, so there is nothing to diff
|
||||
// (empty decorations, all counts 0). Malformed JSON that throws while building
|
||||
// nodes falls back to the same empty diff so the caller can still render plain
|
||||
// content without crashing.
|
||||
export function computeHistoryDiff(
|
||||
schema: Schema,
|
||||
content: any,
|
||||
previousContent?: any,
|
||||
): HistoryDiff {
|
||||
const empty: HistoryDiff = {
|
||||
decorationSet: DecorationSet.empty,
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
if (!content || !previousContent) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
try {
|
||||
const oldContent = Node.fromJSON(schema, previousContent);
|
||||
const newContent = Node.fromJSON(schema, content);
|
||||
|
||||
const tr = recreateTransform(oldContent, newContent, {
|
||||
complexSteps: false,
|
||||
wordDiffs: true,
|
||||
simplifyDiff: true,
|
||||
});
|
||||
|
||||
const changeSet = ChangeSet.create(oldContent).addSteps(
|
||||
tr.doc,
|
||||
tr.mapping.maps,
|
||||
[],
|
||||
);
|
||||
const changes = simplifyChanges(changeSet.changes, newContent);
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
let addedCount = 0;
|
||||
let deletedCount = 0;
|
||||
let changeIndex = 0;
|
||||
|
||||
for (const change of changes) {
|
||||
if (change.toB > change.fromB) {
|
||||
changeIndex++;
|
||||
const currentIndex = changeIndex;
|
||||
let foundSpecialNode: { node: Node; pos: number } | null = null;
|
||||
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
|
||||
if (SPECIAL_NODE_TYPES.has(node.type.name)) {
|
||||
const nodeEnd = pos + node.nodeSize;
|
||||
if (change.fromB <= pos && change.toB >= nodeEnd) {
|
||||
foundSpecialNode = { node, pos };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundSpecialNode) {
|
||||
const special = foundSpecialNode as { node: Node; pos: number };
|
||||
const nodeEnd = special.pos + special.node.nodeSize;
|
||||
decorations.push(
|
||||
Decoration.node(special.pos, nodeEnd, {
|
||||
class: "history-diff-node-added",
|
||||
"data-diff-index": String(currentIndex),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
decorations.push(
|
||||
Decoration.inline(change.fromB, change.toB, {
|
||||
class: "history-diff-added",
|
||||
"data-diff-index": String(currentIndex),
|
||||
}),
|
||||
);
|
||||
}
|
||||
addedCount += 1;
|
||||
}
|
||||
if (change.toA > change.fromA) {
|
||||
changeIndex++;
|
||||
const currentIndex = changeIndex;
|
||||
let foundDeletedNode: { node: Node; pos: number } | null = null;
|
||||
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
|
||||
if (SPECIAL_NODE_TYPES.has(node.type.name)) {
|
||||
const nodeEnd = pos + node.nodeSize;
|
||||
if (change.fromA <= pos && change.toA >= nodeEnd) {
|
||||
foundDeletedNode = { node, pos };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundDeletedNode) {
|
||||
const deletedNode = foundDeletedNode as { node: Node; pos: number };
|
||||
decorations.push(
|
||||
Decoration.widget(change.fromB, () => {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "history-diff-node-deleted";
|
||||
wrapper.setAttribute("data-diff-index", String(currentIndex));
|
||||
const serializer = DOMSerializer.fromSchema(schema);
|
||||
const dom = serializer.serializeNode(deletedNode.node);
|
||||
wrapper.appendChild(dom);
|
||||
return wrapper;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const deletedText = oldContent.textBetween(
|
||||
change.fromA,
|
||||
change.toA,
|
||||
"",
|
||||
);
|
||||
if (deletedText) {
|
||||
decorations.push(
|
||||
Decoration.widget(change.fromB, () => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "history-diff-deleted";
|
||||
span.setAttribute("data-diff-index", String(currentIndex));
|
||||
span.textContent = deletedText;
|
||||
return span;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
deletedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const decorationSet = DecorationSet.create(newContent, decorations);
|
||||
const total = addedCount + deletedCount;
|
||||
return { decorationSet, added: addedCount, deleted: deletedCount, total };
|
||||
} catch (e) {
|
||||
// Malformed version JSON: fall back to a plain (no-diff) render.
|
||||
console.error("History diff failed:", e);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,9 @@ import { useEffect } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
||||
import { Title } from "@mantine/core";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { DecorationSet } from "@tiptap/pm/view";
|
||||
import historyClasses from "./css/history.module.css";
|
||||
import { recreateTransform } from "@docmost/editor-ext";
|
||||
import { DOMSerializer, Node } from "@tiptap/pm/model";
|
||||
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
||||
import { computeHistoryDiff } from "./history-diff.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
diffCountsAtom,
|
||||
@@ -36,142 +34,18 @@ export function HistoryEditor({
|
||||
useEffect(() => {
|
||||
if (!editor || !content) return;
|
||||
|
||||
let decorationSet = DecorationSet.empty;
|
||||
let addedCount = 0;
|
||||
let deletedCount = 0;
|
||||
|
||||
if (previousContent) {
|
||||
try {
|
||||
const schema = editor.schema;
|
||||
const oldContent = Node.fromJSON(schema, previousContent);
|
||||
const newContent = Node.fromJSON(schema, content);
|
||||
|
||||
const tr = recreateTransform(oldContent, newContent, {
|
||||
complexSteps: false,
|
||||
wordDiffs: true,
|
||||
simplifyDiff: true,
|
||||
});
|
||||
|
||||
const changeSet = ChangeSet.create(oldContent).addSteps(
|
||||
tr.doc,
|
||||
tr.mapping.maps,
|
||||
[],
|
||||
// Pure diff computation lives in history-diff.ts; the component keeps the
|
||||
// editor side-effects (rendering the new content + wiring decorations).
|
||||
const { decorationSet, added, deleted, total } = computeHistoryDiff(
|
||||
editor.schema,
|
||||
content,
|
||||
previousContent,
|
||||
);
|
||||
const changes = simplifyChanges(changeSet.changes, newContent);
|
||||
|
||||
editor.commands.setContent(content);
|
||||
|
||||
const specialNodeTypes = new Set([
|
||||
"image",
|
||||
"attachment",
|
||||
"video",
|
||||
"excalidraw",
|
||||
"drawio",
|
||||
"mermaid",
|
||||
"mathBlock",
|
||||
"mathInline",
|
||||
"table",
|
||||
"details",
|
||||
"callout",
|
||||
]);
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
let changeIndex = 0;
|
||||
|
||||
for (const change of changes) {
|
||||
if (change.toB > change.fromB) {
|
||||
changeIndex++;
|
||||
const currentIndex = changeIndex;
|
||||
let foundSpecialNode: { node: Node; pos: number } | null = null;
|
||||
newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
|
||||
if (specialNodeTypes.has(node.type.name)) {
|
||||
const nodeEnd = pos + node.nodeSize;
|
||||
if (change.fromB <= pos && change.toB >= nodeEnd) {
|
||||
foundSpecialNode = { node, pos };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundSpecialNode) {
|
||||
const nodeEnd =
|
||||
foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
|
||||
decorations.push(
|
||||
Decoration.node(foundSpecialNode.pos, nodeEnd, {
|
||||
class: "history-diff-node-added",
|
||||
"data-diff-index": String(currentIndex),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
decorations.push(
|
||||
Decoration.inline(change.fromB, change.toB, {
|
||||
class: "history-diff-added",
|
||||
"data-diff-index": String(currentIndex),
|
||||
}),
|
||||
);
|
||||
}
|
||||
addedCount += 1;
|
||||
}
|
||||
if (change.toA > change.fromA) {
|
||||
changeIndex++;
|
||||
const currentIndex = changeIndex;
|
||||
let foundDeletedNode: { node: Node; pos: number } | null = null;
|
||||
oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
|
||||
if (specialNodeTypes.has(node.type.name)) {
|
||||
const nodeEnd = pos + node.nodeSize;
|
||||
if (change.fromA <= pos && change.toA >= nodeEnd) {
|
||||
foundDeletedNode = { node, pos };
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundDeletedNode) {
|
||||
decorations.push(
|
||||
Decoration.widget(change.fromB, () => {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "history-diff-node-deleted";
|
||||
wrapper.setAttribute("data-diff-index", String(currentIndex));
|
||||
const serializer = DOMSerializer.fromSchema(schema);
|
||||
const dom = serializer.serializeNode(foundDeletedNode!.node);
|
||||
wrapper.appendChild(dom);
|
||||
return wrapper;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const deletedText = oldContent.textBetween(
|
||||
change.fromA,
|
||||
change.toA,
|
||||
"",
|
||||
);
|
||||
if (deletedText) {
|
||||
decorations.push(
|
||||
Decoration.widget(change.fromB, () => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "history-diff-deleted";
|
||||
span.setAttribute("data-diff-index", String(currentIndex));
|
||||
span.textContent = deletedText;
|
||||
return span;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
deletedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
decorationSet = DecorationSet.create(newContent, decorations);
|
||||
} catch (e) {
|
||||
console.error("History diff failed:", e);
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
} else {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
|
||||
const total = addedCount + deletedCount;
|
||||
// @ts-ignore
|
||||
setDiffCounts({ added: addedCount, deleted: deletedCount, total });
|
||||
setDiffCounts({ added, deleted, total });
|
||||
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
|
||||
78
apps/server/src/core/label/dto/label.dto.spec.ts
Normal file
78
apps/server/src/core/label/dto/label.dto.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate, Matches } from 'class-validator';
|
||||
import { AddLabelsDto } from './label.dto';
|
||||
|
||||
// API-boundary validation for label names. `AddLabelsDto.names` applies the
|
||||
// matcher /^[a-z0-9_-][a-z0-9_~-]*$/ to every element (each: true): a name must
|
||||
// start with a lowercase letter, digit, hyphen or underscore (NOT a tilde) and
|
||||
// then contain only those plus tildes. This guards the label storage key against
|
||||
// uppercase, whitespace, accents and tilde-leading names.
|
||||
//
|
||||
// NOTE: the production DTO also runs `@Transform(normalizeLabelName)` BEFORE the
|
||||
// matcher (trim + collapse whitespace to '-' + lowercase). `normalizeLabelName`
|
||||
// itself is already covered (utils.spec.ts), so we deliberately do two things:
|
||||
// 1) lock the raw @Matches regex in isolation (a mirror DTO with ONLY the same
|
||||
// matcher) for the exact accept/reject set the regex must enforce; and
|
||||
// 2) sanity-check the real AddLabelsDto end-to-end for inputs whose normalized
|
||||
// form still exercises the matcher.
|
||||
|
||||
// Mirrors ONLY the production matcher so we test the regex, not the transform.
|
||||
class NameMatchProbe {
|
||||
@Matches(/^[a-z0-9_-][a-z0-9_~-]*$/)
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function matcherErrors(name: string) {
|
||||
const dto = plainToInstance(NameMatchProbe, { name });
|
||||
return validate(dto as object);
|
||||
}
|
||||
|
||||
function hasError(errors: any[], property: string, constraint?: string) {
|
||||
const err = errors.find((e) => e.property === property);
|
||||
if (!err) return false;
|
||||
if (!constraint) return true;
|
||||
return Object.keys(err.constraints ?? {}).includes(constraint);
|
||||
}
|
||||
|
||||
describe('label name @Matches regex', () => {
|
||||
it('accepts valid names', async () => {
|
||||
for (const name of ['foo', 'a~b', '1-2_3', '-lead']) {
|
||||
expect(hasError(await matcherErrors(name), 'name', 'matches')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a tilde-leading name', async () => {
|
||||
expect(hasError(await matcherErrors('~lead'), 'name', 'matches')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects whitespace, accents and empty', async () => {
|
||||
expect(hasError(await matcherErrors('a b'), 'name', 'matches')).toBe(true);
|
||||
expect(hasError(await matcherErrors('héllo'), 'name', 'matches')).toBe(true);
|
||||
expect(hasError(await matcherErrors(''), 'name', 'matches')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddLabelsDto.names (matcher applied per element)', () => {
|
||||
async function validateNames(names: unknown) {
|
||||
const dto = plainToInstance(AddLabelsDto, { pageId: 'p1', names });
|
||||
return validate(dto as object);
|
||||
}
|
||||
|
||||
it('accepts a list of valid names', async () => {
|
||||
const errors = await validateNames(['foo', 'a~b', '1-2_3']);
|
||||
expect(hasError(errors, 'names', 'matches')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a tilde-leading name even after normalization', async () => {
|
||||
// normalizeLabelName lowercases/collapses whitespace but does not strip a
|
||||
// leading tilde, so the matcher still fails.
|
||||
const errors = await validateNames(['~lead']);
|
||||
expect(hasError(errors, 'names', 'matches')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an accented name even after normalization', async () => {
|
||||
const errors = await validateNames(['héllo']);
|
||||
expect(hasError(errors, 'names', 'matches')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
NotificationType,
|
||||
DIRECT_NOTIFICATION_TYPES,
|
||||
UPDATES_NOTIFICATION_TYPES,
|
||||
getTypesForTab,
|
||||
} from './notification.constants';
|
||||
|
||||
// Contract tests for `getTypesForTab` (notification.constants.ts), which maps a
|
||||
// notification tab to the set of notification types it should contain.
|
||||
// - 'direct' -> a 5-type whitelist (mentions / comments / permission grants)
|
||||
// - 'updates' -> exactly [PAGE_UPDATED]
|
||||
// - 'all' -> undefined (no type filter)
|
||||
|
||||
describe('getTypesForTab', () => {
|
||||
it("returns exactly the 5 whitelisted types for 'direct'", () => {
|
||||
expect(getTypesForTab('direct')).toEqual([
|
||||
NotificationType.COMMENT_USER_MENTION,
|
||||
NotificationType.COMMENT_CREATED,
|
||||
NotificationType.COMMENT_RESOLVED,
|
||||
NotificationType.PAGE_USER_MENTION,
|
||||
NotificationType.PAGE_PERMISSION_GRANTED,
|
||||
]);
|
||||
expect(getTypesForTab('direct')).toHaveLength(5);
|
||||
expect(getTypesForTab('direct')).toBe(DIRECT_NOTIFICATION_TYPES);
|
||||
});
|
||||
|
||||
it("returns [PAGE_UPDATED] for 'updates'", () => {
|
||||
expect(getTypesForTab('updates')).toEqual([NotificationType.PAGE_UPDATED]);
|
||||
expect(getTypesForTab('updates')).toBe(UPDATES_NOTIFICATION_TYPES);
|
||||
});
|
||||
|
||||
it("returns undefined (no filter) for 'all'", () => {
|
||||
expect(getTypesForTab('all')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// CONTRACT vs the repository query (notification.repo.ts ~line 57):
|
||||
// direct -> WHERE type != PAGE_UPDATED
|
||||
// updates -> WHERE type = PAGE_UPDATED
|
||||
//
|
||||
// For 'updates' the whitelist and the SQL agree exactly. For 'direct' they
|
||||
// DIVERGE: the whitelist is a positive 5-type allow-list, but `type != PAGE_UPDATED`
|
||||
// returns EVERY non-PAGE_UPDATED type — including verification/approval types that
|
||||
// are NOT in the whitelist. So the repo would surface notifications the 'direct'
|
||||
// tab is not supposed to contain. We model the repo predicate and assert it should
|
||||
// match the whitelist; the 'direct' case genuinely fails today, so it is locked with
|
||||
// `test.failing` (suite stays green, flips red once repo + whitelist are reconciled).
|
||||
|
||||
// What the repo's WHERE clause would actually return, given all known types.
|
||||
const ALL_TYPES = Object.values(NotificationType);
|
||||
function repoTypesForTab(tab: 'direct' | 'updates'): string[] {
|
||||
if (tab === 'direct') {
|
||||
return ALL_TYPES.filter((t) => t !== NotificationType.PAGE_UPDATED);
|
||||
}
|
||||
return ALL_TYPES.filter((t) => t === NotificationType.PAGE_UPDATED);
|
||||
}
|
||||
|
||||
describe('getTypesForTab vs notification.repo query', () => {
|
||||
it("'updates' whitelist matches the repo's `type = PAGE_UPDATED` filter", () => {
|
||||
expect(new Set(repoTypesForTab('updates'))).toEqual(
|
||||
new Set(getTypesForTab('updates')),
|
||||
);
|
||||
});
|
||||
|
||||
// BUG LOCK: the 'direct' whitelist (5 types) does not match what the repo's
|
||||
// `type != PAGE_UPDATED` filter returns (all non-PAGE_UPDATED types). This SHOULD
|
||||
// match; it currently does not. Flips green once the repo filters by the whitelist
|
||||
// (e.g. `type IN (DIRECT_NOTIFICATION_TYPES)`).
|
||||
test.failing(
|
||||
"'direct' whitelist matches the repo's `type != PAGE_UPDATED` filter",
|
||||
() => {
|
||||
expect(new Set(repoTypesForTab('direct'))).toEqual(
|
||||
new Set(getTypesForTab('direct')),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
70
apps/server/src/core/page/dto/move-page.dto.spec.ts
Normal file
70
apps/server/src/core/page/dto/move-page.dto.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
// Imported exactly as page.service.ts does, so we test the real key generator
|
||||
// that feeds `position` at the API boundary.
|
||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||
import { MovePageDto } from './move-page.dto';
|
||||
|
||||
// PARITY BUG (Gitea #139, item 6): MovePageDto.position is bounded with
|
||||
// @MinLength(5) @MaxLength(12), but the actual positions are fractional-indexing
|
||||
// keys produced by `generateJitteredKeyBetween` (the same generator page.service
|
||||
// uses). Those bounds do NOT match the generator's real output range:
|
||||
// - a freshly generated key (null,null) is short (~5 chars) and currently
|
||||
// squeaks past MinLength(5);
|
||||
// - but DENSE between-inserts (repeatedly inserting between two adjacent keys)
|
||||
// grow the key well past 12 chars, which MaxLength(12) would WRONGLY reject —
|
||||
// a valid ordering key the server itself generated would be refused on move.
|
||||
//
|
||||
// The tests below assert the CORRECT contract: any key the generator can produce
|
||||
// must satisfy the DTO. The genuinely-failing case is marked `test.failing` so the
|
||||
// suite stays green while locking the bug; it flips red (alerting us) once the DTO
|
||||
// bounds are widened to cover the generator's real range.
|
||||
|
||||
function constraintErrors(position: unknown) {
|
||||
const dto = plainToInstance(MovePageDto, {
|
||||
pageId: 'page-1',
|
||||
position,
|
||||
});
|
||||
return validate(dto as object);
|
||||
}
|
||||
|
||||
function hasError(errors: any[], property: string) {
|
||||
return errors.some((e) => e.property === property);
|
||||
}
|
||||
|
||||
describe('MovePageDto.position vs generateJitteredKeyBetween parity', () => {
|
||||
it('accepts a freshly generated first key', async () => {
|
||||
const key = generateJitteredKeyBetween(null, null);
|
||||
const errors = await constraintErrors(key);
|
||||
expect(hasError(errors, 'position')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a key appended after an existing key', async () => {
|
||||
const first = generateJitteredKeyBetween(null, null);
|
||||
const next = generateJitteredKeyBetween(first, null);
|
||||
const errors = await constraintErrors(next);
|
||||
expect(hasError(errors, 'position')).toBe(false);
|
||||
});
|
||||
|
||||
// BUG LOCK: dense between-inserts produce keys longer than 12 chars, which
|
||||
// MaxLength(12) rejects even though they are valid ordering keys. This SHOULD
|
||||
// pass; it currently fails. Flips green when the DTO bound is fixed.
|
||||
test.failing(
|
||||
'accepts dense between-inserted keys (currently rejected by MaxLength(12))',
|
||||
async () => {
|
||||
let lo = generateJitteredKeyBetween(null, null);
|
||||
let hi = generateJitteredKeyBetween(lo, null);
|
||||
// Repeatedly insert just above `lo`, shrinking the gap so the key grows.
|
||||
let longest = lo;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const mid = generateJitteredKeyBetween(lo, hi);
|
||||
if (mid.length > longest.length) longest = mid;
|
||||
hi = mid;
|
||||
}
|
||||
expect(longest.length).toBeGreaterThan(12); // sanity: we produced a long key
|
||||
const errors = await constraintErrors(longest);
|
||||
expect(hasError(errors, 'position')).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SearchService } from './search.service';
|
||||
import { SearchService, buildTsQuery } from './search.service';
|
||||
|
||||
describe('SearchService', () => {
|
||||
it('should be defined', () => {
|
||||
@@ -99,3 +99,59 @@ describe('SearchService.searchSuggestions — onlyTemplates filter', () => {
|
||||
expect(isTemplateWhereCall(pageBuilder)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Unit tests for `buildTsQuery` (extracted from search.service.ts). It turns a raw
|
||||
// user query into a prefix tsquery string fed to `to_tsquery('english', ...)`.
|
||||
//
|
||||
// REAL BUG (Gitea #139, item 10): the previous inline `tsquery(query.trim() + '*')`
|
||||
// let to_tsquery operator characters through, so adversarial inputs could produce a
|
||||
// fragment that to_tsquery rejects -> 500. The extraction sanitizes the input
|
||||
// (strip everything but letters/numbers/whitespace) so these inputs degrade to a
|
||||
// safe, neutral query with NO throw, while normal queries keep working.
|
||||
describe('buildTsQuery', () => {
|
||||
it('builds a prefix query for a normal single word', () => {
|
||||
expect(buildTsQuery('hello')).toBe('hello:*');
|
||||
});
|
||||
|
||||
it('joins multiple words with AND and a trailing prefix match', () => {
|
||||
expect(buildTsQuery('foo bar')).toBe('foo&bar:*');
|
||||
});
|
||||
|
||||
it('preserves accented and non-Latin words', () => {
|
||||
expect(buildTsQuery('héllo café')).toBe('héllo&café:*');
|
||||
expect(buildTsQuery('日本語')).toBe('日本語:*');
|
||||
});
|
||||
|
||||
it('neutralizes to_tsquery operator inputs without throwing', () => {
|
||||
// Each of these previously risked an invalid to_tsquery -> 500. They must now
|
||||
// produce a safe (here empty) query and never throw.
|
||||
for (const input of ['&', '!', '*', '<->', '\\']) {
|
||||
expect(() => buildTsQuery(input)).not.toThrow();
|
||||
expect(buildTsQuery(input)).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('handles stopword-only input safely', () => {
|
||||
// pg-tsquery still tokenizes stopwords; to_tsquery reduces them to nothing.
|
||||
// The important contract is: no throw, and a deterministic string.
|
||||
expect(() => buildTsQuery('the a of')).not.toThrow();
|
||||
expect(buildTsQuery('the a of')).toBe('the&a&of:*');
|
||||
});
|
||||
|
||||
it('returns empty string for empty / whitespace-only / null-ish input', () => {
|
||||
expect(buildTsQuery('')).toBe('');
|
||||
expect(buildTsQuery(' ')).toBe('');
|
||||
expect(buildTsQuery(undefined as unknown as string)).toBe('');
|
||||
});
|
||||
|
||||
it('handles a very long input without throwing', () => {
|
||||
const long = 'a'.repeat(10000);
|
||||
expect(() => buildTsQuery(long)).not.toThrow();
|
||||
expect(buildTsQuery(long)).toBe(`${long}:*`);
|
||||
});
|
||||
|
||||
it('strips punctuation embedded in otherwise valid words', () => {
|
||||
expect(buildTsQuery('c++ code')).toBe('c&code:*');
|
||||
expect(buildTsQuery('a-b-c')).toBe('a&b&c:*');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,28 @@ import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const tsquery = require('pg-tsquery')();
|
||||
|
||||
// Build a safe prefix tsquery string from a raw user query.
|
||||
//
|
||||
// The previous inline form `tsquery(query.trim() + '*')` passed user input
|
||||
// (including to_tsquery operators like `&`, `|`, `!`, `<->`, `*`, backslashes)
|
||||
// straight through. pg-tsquery would then emit operator fragments that
|
||||
// `to_tsquery('english', ...)` can reject as a syntax error, turning a search
|
||||
// into a 500. We strip everything that is not a letter, number or whitespace
|
||||
// BEFORE handing the text to pg-tsquery, so adversarial input degrades to a
|
||||
// neutral (possibly empty) query instead of throwing, while normal word queries
|
||||
// (incl. accented / non-Latin words) are unaffected.
|
||||
export function buildTsQuery(raw: string): string {
|
||||
const cleaned = (raw ?? '')
|
||||
.normalize('NFC')
|
||||
// Keep Unicode letters/numbers and whitespace; drop everything else.
|
||||
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
if (!cleaned) return '';
|
||||
return tsquery(cleaned + '*');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
@@ -34,7 +56,7 @@ export class SearchService {
|
||||
if (query.length < 1) {
|
||||
return { items: [] };
|
||||
}
|
||||
const searchQuery = tsquery(query.trim() + '*');
|
||||
const searchQuery = buildTsQuery(query);
|
||||
|
||||
let queryResults = this.db
|
||||
.selectFrom('pages')
|
||||
|
||||
184
packages/editor-ext/src/lib/embed-provider.spec.ts
Normal file
184
packages/editor-ext/src/lib/embed-provider.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getEmbedUrlAndProvider,
|
||||
getEmbedProviderById,
|
||||
embedProviders,
|
||||
} from "./embed-provider";
|
||||
|
||||
// Contract tests for the embed providers (embed-provider.ts). `getEmbedUrlAndProvider`
|
||||
// matches a pasted URL against an ordered list of provider regexes and rewrites it
|
||||
// to the provider's canonical embeddable URL; if nothing matches it falls back to a
|
||||
// raw iframe. Each provider has a share-URL -> embed-URL contract plus passthrough
|
||||
// for already-embedded URLs. A regression here means an embed silently renders the
|
||||
// wrong thing or an unsupported provider, so we pin all 11 providers.
|
||||
|
||||
describe("getEmbedProviderById", () => {
|
||||
it("looks providers up case-insensitively by id", () => {
|
||||
expect(getEmbedProviderById("youtube")?.name).toBe("YouTube");
|
||||
expect(getEmbedProviderById("YOUTUBE")?.name).toBe("YouTube");
|
||||
expect(getEmbedProviderById("gdrive")?.name).toBe("Google Drive");
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown id", () => {
|
||||
expect(getEmbedProviderById("notaprovider")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("registers exactly 11 providers", () => {
|
||||
expect(embedProviders).toHaveLength(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmbedUrlAndProvider", () => {
|
||||
describe("YouTube", () => {
|
||||
it("rewrites watch?v / youtu.be / m. / music. to youtube-nocookie embeds", () => {
|
||||
const expected = "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ";
|
||||
for (const url of [
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://music.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
]) {
|
||||
expect(getEmbedUrlAndProvider(url)).toEqual({
|
||||
provider: "youtube",
|
||||
embedUrl: expected,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("passes an already-/embed/ URL through unchanged", () => {
|
||||
const url = "https://www.youtube.com/embed/dQw4w9WgXcQ";
|
||||
expect(getEmbedUrlAndProvider(url)).toEqual({
|
||||
provider: "youtube",
|
||||
embedUrl: url,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vimeo", () => {
|
||||
it("extracts the numeric video id from channel/group/album/plain URLs", () => {
|
||||
expect(getEmbedUrlAndProvider("https://vimeo.com/123456789").embedUrl).toBe(
|
||||
"https://player.vimeo.com/video/123456789",
|
||||
);
|
||||
expect(
|
||||
getEmbedUrlAndProvider(
|
||||
"https://vimeo.com/channels/staffpicks/123456789",
|
||||
).embedUrl,
|
||||
).toBe("https://player.vimeo.com/video/123456789");
|
||||
expect(
|
||||
getEmbedUrlAndProvider("https://vimeo.com/groups/name/videos/123456789")
|
||||
.embedUrl,
|
||||
).toBe("https://player.vimeo.com/video/123456789");
|
||||
expect(
|
||||
getEmbedUrlAndProvider("https://vimeo.com/album/123/video/456789")
|
||||
.embedUrl,
|
||||
).toBe("https://player.vimeo.com/video/456789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loom", () => {
|
||||
it("rewrites /share/ to /embed/", () => {
|
||||
expect(getEmbedUrlAndProvider("https://loom.com/share/abc123")).toEqual({
|
||||
provider: "loom",
|
||||
embedUrl: "https://loom.com/embed/abc123",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes an already-/embed/ URL through", () => {
|
||||
const url = "https://loom.com/embed/abc123";
|
||||
expect(getEmbedUrlAndProvider(url).embedUrl).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Airtable", () => {
|
||||
it("rewrites a share URL to an /embed/ URL", () => {
|
||||
expect(
|
||||
getEmbedUrlAndProvider("https://airtable.com/shrABC123/tblXYZ").embedUrl,
|
||||
).toBe("https://airtable.com/embed/shrABC123/tblXYZ");
|
||||
});
|
||||
|
||||
it("passes an already-/embed/ URL through", () => {
|
||||
const url = "https://airtable.com/embed/shrABC123";
|
||||
expect(getEmbedUrlAndProvider(url).embedUrl).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Miro", () => {
|
||||
it("rewrites /app/board/ to a /app/live-embed/ URL", () => {
|
||||
const res = getEmbedUrlAndProvider("https://miro.com/app/board/uXjVABC=");
|
||||
expect(res.provider).toBe("miro");
|
||||
expect(res.embedUrl).toContain("https://miro.com/app/live-embed/uXjVABC=");
|
||||
});
|
||||
|
||||
it("passes an already-/live-embed/ URL through", () => {
|
||||
const url = "https://miro.com/app/live-embed/uXjVABC=?embedMode=view_only";
|
||||
expect(getEmbedUrlAndProvider(url).embedUrl).toBe(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Figma", () => {
|
||||
it("wraps the file URL in the figma embed host (id length 22..128)", () => {
|
||||
const id22 = "a".repeat(22);
|
||||
const id128 = "b".repeat(128);
|
||||
const url22 = `https://www.figma.com/file/${id22}/Design`;
|
||||
const url128 = `https://www.figma.com/design/${id128}/Design`;
|
||||
expect(getEmbedUrlAndProvider(url22).embedUrl).toBe(
|
||||
`https://www.figma.com/embed?url=${url22}&embed_host=docmost`,
|
||||
);
|
||||
expect(getEmbedUrlAndProvider(url128).provider).toBe("figma");
|
||||
});
|
||||
|
||||
it("does NOT match a too-short id (< 22 chars) -> iframe fallback", () => {
|
||||
const url = `https://www.figma.com/file/${"a".repeat(10)}/Design`;
|
||||
expect(getEmbedUrlAndProvider(url).provider).toBe("iframe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Drive / Sheets", () => {
|
||||
it("rewrites a gdrive file URL to /preview using the id (match[4])", () => {
|
||||
expect(
|
||||
getEmbedUrlAndProvider("https://drive.google.com/file/d/1AbC_dEf-Gh/view")
|
||||
.embedUrl,
|
||||
).toBe("https://drive.google.com/file/d/1AbC_dEf-Gh/preview");
|
||||
});
|
||||
|
||||
it("passes a gsheets URL through unchanged", () => {
|
||||
const url = "https://docs.google.com/spreadsheets/d/1AbC_dEf-Gh/edit";
|
||||
expect(getEmbedUrlAndProvider(url)).toEqual({
|
||||
provider: "google sheets",
|
||||
embedUrl: url,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Typeform / Framer (passthrough providers)", () => {
|
||||
it("passes typeform and framer URLs through unchanged", () => {
|
||||
const tf = "https://my.typeform.com/to/abc123";
|
||||
expect(getEmbedUrlAndProvider(tf)).toEqual({
|
||||
provider: "typeform",
|
||||
embedUrl: tf,
|
||||
});
|
||||
const framer = "https://www.framer.com/embed/foo-bar";
|
||||
expect(getEmbedUrlAndProvider(framer)).toEqual({
|
||||
provider: "framer",
|
||||
embedUrl: framer,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fallback", () => {
|
||||
it("returns the raw iframe provider for an unknown URL", () => {
|
||||
const url = "https://example.com/some/random/page";
|
||||
expect(getEmbedUrlAndProvider(url)).toEqual({
|
||||
provider: "iframe",
|
||||
embedUrl: url,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns iframe for junk / non-URL input", () => {
|
||||
expect(getEmbedUrlAndProvider("not a url at all")).toEqual({
|
||||
provider: "iframe",
|
||||
embedUrl: "not a url at all",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
56
packages/editor-ext/src/lib/indent.spec.ts
Normal file
56
packages/editor-ext/src/lib/indent.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { clampIndent } from "./indent";
|
||||
|
||||
// Unit tests for `clampIndent` (indent.ts) — the pure core of the indent
|
||||
// extension. The extension stores an integer `indent` level on paragraphs and
|
||||
// headings (default range [0, 8]); `clampIndent` keeps every code path
|
||||
// (increment via Tab, outdent via Shift-Tab, and parsing junk `data-indent`
|
||||
// attributes from pasted HTML) inside the configured bounds. A regression would
|
||||
// let an out-of-range / NaN level reach renderHTML and produce broken padding.
|
||||
//
|
||||
// NOTE: the "excluded containers stay flat" behaviour (paragraphs inside list
|
||||
// items / table cells / code blocks) lives in `updateIndent` /
|
||||
// `appendTransaction`, which require a real ProseMirror EditorState and document
|
||||
// resolution — it cannot be isolated into a pure function, so it is intentionally
|
||||
// out of scope here and is exercised at the extension/editor level.
|
||||
|
||||
const MIN = 0;
|
||||
const MAX = 8;
|
||||
|
||||
describe("clampIndent", () => {
|
||||
it("leaves in-range values untouched", () => {
|
||||
expect(clampIndent(0, MIN, MAX)).toBe(0);
|
||||
expect(clampIndent(4, MIN, MAX)).toBe(4);
|
||||
expect(clampIndent(8, MIN, MAX)).toBe(8);
|
||||
});
|
||||
|
||||
it("clamps increments at the max (8)", () => {
|
||||
// Tab at level 8 would compute 9 -> stays at 8.
|
||||
expect(clampIndent(8 + 1, MIN, MAX)).toBe(8);
|
||||
expect(clampIndent(100, MIN, MAX)).toBe(8);
|
||||
});
|
||||
|
||||
it("clamps outdents at the min (0)", () => {
|
||||
// Shift-Tab at level 0 would compute -1 -> stays at 0.
|
||||
expect(clampIndent(0 - 1, MIN, MAX)).toBe(0);
|
||||
expect(clampIndent(-100, MIN, MAX)).toBe(0);
|
||||
});
|
||||
|
||||
it("treats non-finite junk (NaN / Infinity) as the min", () => {
|
||||
// parseInt('abc', 10) === NaN, which must not propagate to the attribute.
|
||||
expect(clampIndent(NaN, MIN, MAX)).toBe(MIN);
|
||||
expect(clampIndent(Infinity, MIN, MAX)).toBe(MIN);
|
||||
expect(clampIndent(-Infinity, MIN, MAX)).toBe(MIN);
|
||||
});
|
||||
|
||||
it("truncates fractional values toward zero before clamping", () => {
|
||||
expect(clampIndent(3.9, MIN, MAX)).toBe(3);
|
||||
expect(clampIndent(-0.5, MIN, MAX)).toBe(MIN);
|
||||
});
|
||||
|
||||
it("clamps junk data-indent values (negative / > max) to the rails", () => {
|
||||
// Mirrors parseHTML(parseInt(data-indent, 10)) for adversarial pasted HTML.
|
||||
expect(clampIndent(-3, MIN, MAX)).toBe(MIN);
|
||||
expect(clampIndent(42, MIN, MAX)).toBe(MAX);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,9 @@ const NON_INDENTABLE_ANCESTORS = new Set([
|
||||
'codeBlock',
|
||||
]);
|
||||
|
||||
const clampIndent = (value: number, min: number, max: number): number => {
|
||||
// Exported for unit testing: clamps a (possibly junk) indent level into the
|
||||
// [min, max] range, treating any non-finite value as `min`.
|
||||
export const clampIndent = (value: number, min: number, max: number): number => {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.max(min, Math.min(max, Math.trunc(value)));
|
||||
};
|
||||
|
||||
54
packages/editor-ext/src/lib/utils.spec.ts
Normal file
54
packages/editor-ext/src/lib/utils.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeUrl, isInternalFileUrl } from "./utils";
|
||||
|
||||
// Security contract tests for the editor URL helpers (utils.ts).
|
||||
// `sanitizeUrl` wraps @braintree/sanitize-url and maps its "about:blank" XSS
|
||||
// sentinel to "" so callers can treat empty as "blocked". `isInternalFileUrl`
|
||||
// decides whether a URL points at our own file-serving routes (used to skip
|
||||
// external-link affordances). A regression here is a stored-XSS or SSRF vector.
|
||||
|
||||
describe("sanitizeUrl", () => {
|
||||
it("blocks dangerous schemes (returns empty string)", () => {
|
||||
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
|
||||
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
|
||||
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
|
||||
// case-insensitive + leading whitespace must not bypass the filter
|
||||
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty / undefined input", () => {
|
||||
expect(sanitizeUrl(undefined)).toBe("");
|
||||
expect(sanitizeUrl("")).toBe("");
|
||||
});
|
||||
|
||||
it("allows safe https, relative file and mailto URLs", () => {
|
||||
// braintree normalises https URLs (may add a trailing slash); just assert
|
||||
// the scheme survives and it is not blanked out.
|
||||
expect(sanitizeUrl("https://example.com/page")).toMatch(/^https:\/\/example\.com\/page/);
|
||||
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
|
||||
expect(sanitizeUrl("mailto:user@example.com")).toBe("mailto:user@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInternalFileUrl", () => {
|
||||
it("is true only for /api/files/ and /files/ prefixes", () => {
|
||||
expect(isInternalFileUrl("/api/files/abc")).toBe(true);
|
||||
expect(isInternalFileUrl("/files/abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("trims whitespace before matching the prefix", () => {
|
||||
expect(isInternalFileUrl(" /api/files/abc")).toBe(true);
|
||||
expect(isInternalFileUrl("\t/files/abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for external URLs and other paths", () => {
|
||||
expect(isInternalFileUrl("https://example.com/api/files/abc")).toBe(false);
|
||||
expect(isInternalFileUrl("/other/files/abc")).toBe(false);
|
||||
expect(isInternalFileUrl("/apifiles/abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for empty / undefined input", () => {
|
||||
expect(isInternalFileUrl(undefined)).toBe(false);
|
||||
expect(isInternalFileUrl("")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user