407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
import { describe, expect, test, vi } from "vitest";
|
|
import { EditorState, type TransactionSpec } from "@codemirror/state";
|
|
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
|
|
import type { Client } from "./client.ts";
|
|
|
|
// content_manager.ts imports codemirror/editor_state.ts for createEditorState
|
|
// and externalUpdate. That module (transitively, via lua_widget.ts ->
|
|
// widget_sandbox_iframe.ts) calls document.createElement at module scope, so
|
|
// it can't load under this project's Node-environment vitest config (no
|
|
// jsdom/happy-dom, and adding one is out of scope). Replace it with a
|
|
// minimal stand-in exposing the same two names -- content_manager.ts itself,
|
|
// the actual subject of these tests, is imported for real, unmocked.
|
|
vi.mock("./codemirror/editor_state.ts", async () => {
|
|
const { Annotation, EditorState: RealEditorState } = await import(
|
|
"@codemirror/state"
|
|
);
|
|
return {
|
|
createEditorState: (
|
|
_client: unknown,
|
|
_pageName: string,
|
|
text: string,
|
|
_readOnly: boolean,
|
|
) => RealEditorState.create({ doc: text }),
|
|
externalUpdate: Annotation.define<boolean>(),
|
|
};
|
|
});
|
|
|
|
const { ContentManager } = await import("./content_manager.ts");
|
|
|
|
// content_manager.ts's enriched-meta refresh (loadPage and, after this
|
|
// change, reloadPageContent) reads/writes document.body directly for
|
|
// frontmatter-derived page-decoration classes. No jsdom in this Node vitest
|
|
// config (see the module mock above), so provide the minimal shape used.
|
|
(globalThis as unknown as { document: { body: unknown } }).document = {
|
|
body: {
|
|
className: "",
|
|
removeAttribute(this: { className: string }, name: string) {
|
|
if (name === "class") this.className = "";
|
|
},
|
|
},
|
|
};
|
|
|
|
// Minimal stand-in for EditorView: real EditorState + real transaction
|
|
// application (state.update), so ChangeSet/annotation semantics are
|
|
// genuine -- just no DOM rendering.
|
|
function makeEditorViewStub(initialDoc: string) {
|
|
let state = EditorState.create({ doc: initialDoc });
|
|
const dispatched: TransactionSpec[] = [];
|
|
return {
|
|
get state() {
|
|
return state;
|
|
},
|
|
setState(newState: EditorState) {
|
|
state = newState;
|
|
},
|
|
dispatch(spec: TransactionSpec) {
|
|
dispatched.push(spec);
|
|
state = state.update(spec).state;
|
|
},
|
|
dispatched,
|
|
};
|
|
}
|
|
|
|
type ReadPageResult = { text: string; meta: PageMeta };
|
|
|
|
function makeClientStub(opts: {
|
|
initialDoc: string;
|
|
readPage: () => Promise<ReadPageResult>;
|
|
hasFullIndexCompleted?: () => Promise<boolean>;
|
|
getObjectByRef?: () => Promise<PageMeta | undefined>;
|
|
}) {
|
|
const editorView = makeEditorViewStub(opts.initialDoc);
|
|
const viewState: {
|
|
current?: { path: string; meta?: PageMeta };
|
|
unsavedChanges: boolean;
|
|
} = {
|
|
current: undefined,
|
|
unsavedChanges: false,
|
|
};
|
|
let currentPathValue = "";
|
|
const dispatchedEvents: { name: string; args: unknown[] }[] = [];
|
|
const viewDispatched: { type: string; [key: string]: unknown }[] = [];
|
|
|
|
const client = {
|
|
editorView,
|
|
viewState,
|
|
set currentPathValue(v: string) {
|
|
currentPathValue = v;
|
|
},
|
|
ui: {
|
|
viewState,
|
|
viewDispatch: (action: {
|
|
type: string;
|
|
path?: string;
|
|
meta?: PageMeta;
|
|
}) => {
|
|
viewDispatched.push(action);
|
|
if (action.type === "page-loaded" && action.path) {
|
|
viewState.current = { path: action.path, meta: action.meta };
|
|
}
|
|
if (action.type === "update-current-page-meta" && viewState.current) {
|
|
viewState.current = { ...viewState.current, meta: action.meta };
|
|
}
|
|
},
|
|
},
|
|
space: {
|
|
readPage: opts.readPage,
|
|
unwatchFile: () => {},
|
|
watchFile: () => {},
|
|
},
|
|
objectIndex: {
|
|
hasFullIndexCompleted: opts.hasFullIndexCompleted ?? (async () => false),
|
|
getObjectByRef: opts.getObjectByRef ?? (async () => undefined),
|
|
},
|
|
widgetCache: { clearPrewarm: () => {} },
|
|
pageMetaAugmenter: { setAugmentation: async () => {} },
|
|
eventHook: {
|
|
dispatchEvent: async (name: string, ...args: unknown[]) => {
|
|
dispatchedEvents.push({ name, args });
|
|
return [];
|
|
},
|
|
},
|
|
isReadOnlyMode: () => false,
|
|
dispatchAppEvent: async () => [],
|
|
currentPath: () => currentPathValue,
|
|
currentName: () => currentPathValue.replace(/\.md$/, ""),
|
|
dispatchedEvents,
|
|
viewDispatched,
|
|
};
|
|
return client;
|
|
}
|
|
|
|
function pageMeta(lastModified: string): PageMeta {
|
|
return {
|
|
name: "index",
|
|
tags: ["page"],
|
|
created: "",
|
|
lastModified,
|
|
perm: "rw",
|
|
} as PageMeta;
|
|
}
|
|
|
|
describe("ContentManager.loadPage base tracking (regression)", () => {
|
|
test("same-page reload merges against the previous disk text, not the newly-fetched one", async () => {
|
|
let diskText = "hello world\n";
|
|
let diskModified = "2026-01-01T00:00:00.000";
|
|
const client = makeClientStub({
|
|
initialDoc: "",
|
|
readPage: async () => ({ text: diskText, meta: pageMeta(diskModified) }),
|
|
});
|
|
client.currentPathValue = "index.md";
|
|
const cm = new ContentManager(client as unknown as Client);
|
|
|
|
// Fresh load establishes the base.
|
|
await cm.loadPage({ path: "index.md" }, false);
|
|
expect(client.editorView.state.sliceDoc()).toBe("hello world\n");
|
|
|
|
// An unsaved local edit sits in the editor -- stale relative to the disk
|
|
// change below, exactly the situation "Editor: Reload" or the
|
|
// first-sync-complete auto-reload can hit.
|
|
client.editorView.dispatch({
|
|
changes: { from: client.editorView.state.doc.length, insert: "LOCAL" },
|
|
});
|
|
expect(client.editorView.state.sliceDoc()).toBe("hello world\nLOCAL");
|
|
|
|
// Disk changes externally while that edit is still unsaved.
|
|
diskText = "hello world\nExternal line\n";
|
|
diskModified = "2026-01-01T00:00:05.000";
|
|
|
|
// Same-page reload (loadingDifferentPath stays false: previousPath ===
|
|
// newPath, both "index.md").
|
|
await cm.loadPage({ path: "index.md" }, false);
|
|
|
|
// Must not no-op: if lastKnownDiskText was clobbered to the new disk
|
|
// text before this merge runs, base === disk and the diff -- and thus
|
|
// this assertion -- would be empty regardless of what changed on disk.
|
|
expect(client.editorView.state.sliceDoc()).toContain("External line");
|
|
});
|
|
});
|
|
|
|
describe("ContentManager.reloadPageContent stale-navigation guard (regression)", () => {
|
|
test("bails out if the current page changed while the read was in flight", async () => {
|
|
let resolveReadPage!: (doc: ReadPageResult) => void;
|
|
const pending = new Promise<ReadPageResult>((resolve) => {
|
|
resolveReadPage = resolve;
|
|
});
|
|
const client = makeClientStub({
|
|
initialDoc: "page A content\n",
|
|
readPage: () => pending,
|
|
});
|
|
client.currentPathValue = "pageA.md";
|
|
const cm = new ContentManager(client as unknown as Client);
|
|
|
|
const reloadPromise = cm.reloadPageContent();
|
|
|
|
// User navigates to a different page while the fetch above is still in
|
|
// flight.
|
|
client.currentPathValue = "pageB.md";
|
|
client.editorView.setState(EditorState.create({ doc: "page B content\n" }));
|
|
|
|
// The stale fetch for page A finally resolves, with page-A-derived
|
|
// external content.
|
|
resolveReadPage({
|
|
text: "page A content\nEXTERNAL EDIT\n",
|
|
meta: pageMeta("2026-01-01T00:00:00.000"),
|
|
});
|
|
await reloadPromise;
|
|
|
|
// Page B's content must be untouched by page A's stale patch.
|
|
expect(client.editorView.state.sliceDoc()).toBe("page B content\n");
|
|
});
|
|
|
|
test("applies normally when the page hasn't changed during the fetch", async () => {
|
|
// initialDoc "" matches ContentManager's default lastKnownDiskText, so
|
|
// the merge is a clean append -- this test only guards against the
|
|
// stale-navigation check above being overzealous, not the merge itself
|
|
// (covered by external_merge.test.ts and the loadPage test above).
|
|
const client = makeClientStub({
|
|
initialDoc: "",
|
|
readPage: async () => ({
|
|
text: "hello\nexternal\n",
|
|
meta: pageMeta("2026-01-01T00:00:00.000"),
|
|
}),
|
|
});
|
|
client.currentPathValue = "index.md";
|
|
const cm = new ContentManager(client as unknown as Client);
|
|
|
|
await cm.reloadPageContent();
|
|
|
|
expect(client.editorView.state.sliceDoc()).toBe("hello\nexternal\n");
|
|
});
|
|
});
|
|
|
|
describe("ContentManager.applyExternalPatches monotonicity guard (regression)", () => {
|
|
test("drops a stale apply when an older in-flight read resolves after a newer one", async () => {
|
|
let resolveOlder!: (r: ReadPageResult) => void;
|
|
let resolveNewer!: (r: ReadPageResult) => void;
|
|
const olderPromise = new Promise<ReadPageResult>((resolve) => {
|
|
resolveOlder = resolve;
|
|
});
|
|
const newerPromise = new Promise<ReadPageResult>((resolve) => {
|
|
resolveNewer = resolve;
|
|
});
|
|
let call = 0;
|
|
const client = makeClientStub({
|
|
initialDoc: "",
|
|
readPage: () => {
|
|
call++;
|
|
if (call === 1) {
|
|
return Promise.resolve({
|
|
text: "base\n",
|
|
meta: pageMeta("2026-01-01T00:00:00.000"),
|
|
});
|
|
}
|
|
return call === 2 ? olderPromise : newerPromise;
|
|
},
|
|
});
|
|
client.currentPathValue = "index.md";
|
|
const cm = new ContentManager(client as unknown as Client);
|
|
|
|
// Seed the base (lastKnownDiskText/lastKnownDiskModified) via a fresh load.
|
|
await cm.loadPage({ path: "index.md" }, false);
|
|
expect(client.editorView.state.sliceDoc()).toBe("base\n");
|
|
|
|
// Two reloads for the same page, in flight concurrently (e.g. a direct
|
|
// reloadEditor racing an SSE-triggered reloadPageContent).
|
|
const olderReload = cm.reloadPageContent();
|
|
const newerReload = cm.reloadPageContent();
|
|
|
|
// The one reading the newer disk state resolves first.
|
|
resolveNewer({
|
|
text: "base\nAGENT V2\n",
|
|
meta: pageMeta("2026-01-01T00:00:10.000"),
|
|
});
|
|
await newerReload;
|
|
expect(client.editorView.state.sliceDoc()).toBe("base\nAGENT V2\n");
|
|
|
|
// The stale (older) read resolves after -- must not revert the editor.
|
|
resolveOlder({
|
|
text: "base\nAGENT V1\n",
|
|
meta: pageMeta("2026-01-01T00:00:05.000"),
|
|
});
|
|
await olderReload;
|
|
|
|
expect(client.editorView.state.sliceDoc()).toBe("base\nAGENT V2\n");
|
|
});
|
|
});
|
|
|
|
describe("ContentManager.reloadPageContent editor:pageReloaded notification", () => {
|
|
test("dispatches editor:pageReloaded after applying a real external patch", async () => {
|
|
let diskText = "hello world\n";
|
|
const client = makeClientStub({
|
|
initialDoc: "",
|
|
readPage: async () => ({
|
|
text: diskText,
|
|
meta: pageMeta("2026-01-01T00:00:00.000"),
|
|
}),
|
|
});
|
|
client.currentPathValue = "index.md";
|
|
const cm = new ContentManager(client as unknown as Client);
|
|
|
|
await cm.loadPage({ path: "index.md" }, false);
|
|
client.dispatchedEvents.length = 0;
|
|
|
|
diskText = "hello world\nExternal line\n";
|
|
await cm.reloadPageContent();
|
|
|
|
const reloaded = client.dispatchedEvents.filter(
|
|
(e) => e.name === "editor:pageReloaded",
|
|
);
|
|
expect(reloaded).toEqual([
|
|
{ name: "editor:pageReloaded", args: ["index", "index"] },
|
|
]);
|
|
});
|
|
|
|
test("does not dispatch editor:pageReloaded for a stale patch dropped by the monotonicity guard", async () => {
|
|
let resolveOlder!: (r: ReadPageResult) => void;
|
|
let resolveNewer!: (r: ReadPageResult) => void;
|
|
const olderPromise = new Promise<ReadPageResult>((resolve) => {
|
|
resolveOlder = resolve;
|
|
});
|
|
const newerPromise = new Promise<ReadPageResult>((resolve) => {
|
|
resolveNewer = resolve;
|
|
});
|
|
let call = 0;
|
|
const client = makeClientStub({
|
|
initialDoc: "",
|
|
readPage: () => {
|
|
call++;
|
|
if (call === 1) {
|
|
return Promise.resolve({
|
|
text: "base\n",
|
|
meta: pageMeta("2026-01-01T00:00:00.000"),
|
|
});
|
|
}
|
|
return call === 2 ? olderPromise : newerPromise;
|
|
},
|
|
});
|
|
client.currentPathValue = "index.md";
|
|
const cm = new ContentManager(client as unknown as Client);
|
|
await cm.loadPage({ path: "index.md" }, false);
|
|
|
|
const olderReload = cm.reloadPageContent();
|
|
const newerReload = cm.reloadPageContent();
|
|
|
|
resolveNewer({
|
|
text: "base\nV2\n",
|
|
meta: pageMeta("2026-01-01T00:00:10.000"),
|
|
});
|
|
await newerReload;
|
|
client.dispatchedEvents.length = 0;
|
|
|
|
// Resolves after the newer one, with older content -- dropped by the
|
|
// monotonicity guard in applyExternalPatches. Nothing changed in the
|
|
// editor, so no notification should fire either.
|
|
resolveOlder({
|
|
text: "base\nV1\n",
|
|
meta: pageMeta("2026-01-01T00:00:05.000"),
|
|
});
|
|
await olderReload;
|
|
|
|
expect(
|
|
client.dispatchedEvents.some((e) => e.name === "editor:pageReloaded"),
|
|
).toBe(false);
|
|
});
|
|
|
|
test("refreshes viewState.current.meta and body page-decoration classes from the object index", async () => {
|
|
// Frontmatter (and thus the indexed page decoration) only appears after
|
|
// the external edit below -- this is what the initial loadPage sees vs.
|
|
// what the live path must pick up without requiring re-navigation.
|
|
let diskText = "hello world\n";
|
|
const metaBeforeEdit = pageMeta("2026-01-01T00:00:00.000");
|
|
const metaAfterEdit = {
|
|
...pageMeta("2026-01-01T00:00:05.000"),
|
|
pageDecoration: { cssClasses: ["journal-page"] },
|
|
} as PageMeta;
|
|
let enrichedMeta = metaBeforeEdit;
|
|
const client = makeClientStub({
|
|
initialDoc: "",
|
|
readPage: async () => ({
|
|
text: diskText,
|
|
meta: pageMeta("2026-01-01T00:00:00.000"),
|
|
}),
|
|
hasFullIndexCompleted: async () => true,
|
|
getObjectByRef: async () => enrichedMeta,
|
|
});
|
|
client.currentPathValue = "index.md";
|
|
const cm = new ContentManager(client as unknown as Client);
|
|
|
|
await cm.loadPage({ path: "index.md" }, false);
|
|
expect(client.viewState.current?.meta).toEqual(metaBeforeEdit);
|
|
|
|
diskText = "hello world\nExternal line\n";
|
|
enrichedMeta = metaAfterEdit;
|
|
await cm.reloadPageContent();
|
|
|
|
// Without navigating away and back, viewState.current.meta must already
|
|
// reflect the post-edit frontmatter, and the body decoration classes
|
|
// derived from it.
|
|
expect(client.viewState.current?.meta).toEqual(metaAfterEdit);
|
|
expect(
|
|
(globalThis as unknown as { document: { body: { className: string } } })
|
|
.document.body.className,
|
|
).toBe("journal-page");
|
|
});
|
|
});
|