Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
584 lines
21 KiB
TypeScript
584 lines
21 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { JSDOM } from "jsdom";
|
|
|
|
import { runWithAct } from "../test-support/renderReactDom.tsx";
|
|
|
|
/**
|
|
* Regression tests for the notes rich editor: markdown that MDX cannot parse
|
|
* (e.g. unbalanced angle tags such as `<host>` in prose) used to render as an
|
|
* empty editor, so an AI-written or imported note looked like it had lost its
|
|
* content. The editor must fall back to the raw markdown source view instead.
|
|
*/
|
|
|
|
const NOTE_MARKDOWN_MDX_CANNOT_PARSE = [
|
|
"# SlurmDB Agent Skill",
|
|
"",
|
|
"## Overview",
|
|
"",
|
|
"Run the exporter with mysql -h <host> -u <user> -P 3306.",
|
|
"",
|
|
"The content after the angle tags must stay visible.",
|
|
].join("\n");
|
|
|
|
const PLAIN_NOTE_MARKDOWN = ["# Steps", "", "Promote the replica, then restart the agent."].join("\n");
|
|
|
|
// Public author-supplied note from issue #3205. Commands are inert test content.
|
|
const ISSUE_3205_MARKDOWN = readFileSync(new URL("./test-fixtures/issue-3205.md", import.meta.url), "utf8");
|
|
|
|
const setupDom = () => {
|
|
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
|
pretendToBeVisual: true,
|
|
url: "http://localhost",
|
|
});
|
|
const window = dom.window;
|
|
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
|
const installGlobal = (key: string, value: unknown) => {
|
|
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
|
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
|
};
|
|
|
|
class ResizeObserverStub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
|
|
function LoadedImageStub() {
|
|
const image = window.document.createElement("img");
|
|
Object.defineProperty(image, "src", {
|
|
get: () => image.getAttribute("src") ?? "",
|
|
set: (src: string) => {
|
|
image.setAttribute("src", src);
|
|
queueMicrotask(() => image.dispatchEvent(new window.Event("load")));
|
|
},
|
|
});
|
|
return image;
|
|
}
|
|
|
|
Object.assign(window.Range.prototype, {
|
|
getClientRects: () => [],
|
|
getBoundingClientRect: () => new window.DOMRect(),
|
|
});
|
|
|
|
// CodeMirror needs these browser APIs when the full note contains code blocks.
|
|
window.matchMedia = (query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener() {},
|
|
removeListener() {},
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
dispatchEvent: () => false,
|
|
});
|
|
|
|
for (const [key, value] of Object.entries({
|
|
window,
|
|
Window: window.Window,
|
|
Image: LoadedImageStub,
|
|
document: window.document,
|
|
navigator: window.navigator,
|
|
HTMLElement: window.HTMLElement,
|
|
HTMLImageElement: window.HTMLImageElement,
|
|
HTMLInputElement: window.HTMLInputElement,
|
|
HTMLTextAreaElement: window.HTMLTextAreaElement,
|
|
HTMLSelectElement: window.HTMLSelectElement,
|
|
Element: window.Element,
|
|
SVGElement: window.SVGElement,
|
|
Node: window.Node,
|
|
DocumentFragment: window.DocumentFragment,
|
|
Range: window.Range,
|
|
NodeFilter: window.NodeFilter,
|
|
MutationObserver: window.MutationObserver,
|
|
CustomEvent: window.CustomEvent,
|
|
DOMRect: window.DOMRect,
|
|
Event: window.Event,
|
|
KeyboardEvent: window.KeyboardEvent,
|
|
MouseEvent: window.MouseEvent,
|
|
getComputedStyle: window.getComputedStyle.bind(window),
|
|
requestAnimationFrame: window.requestAnimationFrame.bind(window),
|
|
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
|
|
ResizeObserver: ResizeObserverStub,
|
|
IS_REACT_ACT_ENVIRONMENT: true,
|
|
})) {
|
|
installGlobal(key, value);
|
|
}
|
|
|
|
return {
|
|
window,
|
|
cleanup() {
|
|
for (const [key, descriptor] of previousGlobals) {
|
|
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
else delete (globalThis as Record<string, unknown>)[key];
|
|
}
|
|
dom.window.close();
|
|
},
|
|
};
|
|
};
|
|
|
|
type DomHarness = ReturnType<typeof setupDom>;
|
|
|
|
type ActiveFormats = {
|
|
bold: boolean;
|
|
italic: boolean;
|
|
underline: boolean;
|
|
strikethrough: boolean;
|
|
code: boolean;
|
|
};
|
|
|
|
type RenderEditorProps = {
|
|
value: string;
|
|
noteId?: string;
|
|
editorMode?: "edit" | "preview" | "source";
|
|
onActiveFormatsChange?: (formats: ActiveFormats) => void;
|
|
};
|
|
|
|
const renderEditor = async (
|
|
window: DomHarness["window"],
|
|
props: RenderEditorProps,
|
|
) => {
|
|
const { act } = await import("react");
|
|
const { createRoot } = await import("react-dom/client");
|
|
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
|
|
const { InlineMarkdownEditor } = await import("./InlineMarkdownEditor.tsx");
|
|
|
|
const rootNode = window.document.getElementById("root");
|
|
assert.ok(rootNode);
|
|
const root = createRoot(rootNode);
|
|
const changes: string[] = [];
|
|
const render = async (nextProps: RenderEditorProps) => act(async () => {
|
|
root.render(
|
|
<I18nProvider locale="en">
|
|
<InlineMarkdownEditor
|
|
noteId={nextProps.noteId ?? "note-1"}
|
|
value={nextProps.value}
|
|
placeholder="Write Markdown notes here..."
|
|
editorMode={nextProps.editorMode ?? "edit"}
|
|
onChange={(next) => changes.push(next)}
|
|
onActiveFormatsChange={nextProps.onActiveFormatsChange}
|
|
hosts={[]}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
});
|
|
await render(props);
|
|
// Let the deferred MDX import (and its parse-error reporting) settle.
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
});
|
|
return {
|
|
rootNode,
|
|
changes,
|
|
rerender: render,
|
|
async unmount() {
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
},
|
|
};
|
|
};
|
|
|
|
const querySourceFallback = (rootNode: HTMLElement) =>
|
|
rootNode.querySelector<HTMLTextAreaElement>("[data-note-markdown-source-fallback] textarea");
|
|
|
|
const setTextareaValue = (window: DomHarness["window"], textarea: HTMLTextAreaElement, nextValue: string) => {
|
|
const setValue = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
|
assert.ok(setValue);
|
|
setValue.call(textarea, nextValue);
|
|
textarea.dispatchEvent(new window.Event("input", { bubbles: true }));
|
|
};
|
|
|
|
test("literal Markdown comparisons render without changing the note source", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const value = "Memory<1GB";
|
|
const { rootNode, changes, unmount } = await renderEditor(window, {
|
|
value,
|
|
editorMode: "preview",
|
|
});
|
|
try {
|
|
assert.ok(!querySourceFallback(rootNode), "a comparison must render as ordinary text");
|
|
assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, value);
|
|
assert.deepEqual(changes, [], "reading the note must preserve its original source");
|
|
} finally {
|
|
await unmount();
|
|
}
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("comparison variants render in paragraphs, tables and alongside HTML", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const value = [
|
|
"<1GB",
|
|
"",
|
|
"Memory<1GB, x<=2, x<-1, x<+2, x<.5, x < 3, x\\<4, x<5",
|
|
"",
|
|
"| Limit |",
|
|
"| --- |",
|
|
"| **Memory<1GB** |",
|
|
"",
|
|
'<a href="https://example.com/">limit<1</a><2',
|
|
"",
|
|
'<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="limit" width="80" height="40" /><3',
|
|
"",
|
|
"`literal<1 and <host>`",
|
|
"",
|
|
"```sh",
|
|
"cat <<'EOF'",
|
|
"<host> and x<1",
|
|
"EOF",
|
|
"```",
|
|
].join("\n");
|
|
const { rootNode, changes, unmount } = await renderEditor(window, { value });
|
|
try {
|
|
assert.ok(!querySourceFallback(rootNode));
|
|
const editable = rootNode.querySelector("[contenteditable]");
|
|
assert.ok(editable);
|
|
assert.match(editable.textContent ?? "", /Memory<1GB, x<=2, x<-1, x<\+2, x<\.5, x < 3, x<4, x<5/);
|
|
assert.equal(editable.querySelector("td strong")?.textContent, "Memory<1GB");
|
|
assert.equal(editable.querySelector("a")?.getAttribute("href"), "https://example.com/");
|
|
assert.equal(editable.querySelector("a")?.textContent, "limit<1");
|
|
const image = editable.querySelector("img");
|
|
assert.equal(image?.getAttribute("alt"), "limit");
|
|
assert.equal(image?.getAttribute("width"), "80");
|
|
assert.equal(image?.getAttribute("height"), "40");
|
|
assert.equal(editable.querySelector("code")?.textContent, "literal<1 and <host>");
|
|
const { getCodeMirrorBlockText } = await import("./InlineMarkdownEditor.tsx");
|
|
const codeBlock = editable.querySelector(".cm-editor");
|
|
assert.ok(codeBlock);
|
|
assert.equal(getCodeMirrorBlockText(codeBlock), "cat <<'EOF'\n<host> and x<1\nEOF");
|
|
assert.deepEqual(changes, []);
|
|
} finally {
|
|
await unmount();
|
|
}
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("issue 3205 author note survives source, preview and edit without rewriting", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const value = ISSUE_3205_MARKDOWN;
|
|
const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value, editorMode: "source" });
|
|
try {
|
|
assert.equal(rootNode.querySelector("textarea")?.value, value);
|
|
for (const editorMode of ["preview", "edit", "preview"] as const) {
|
|
await rerender({ value, editorMode });
|
|
await runWithAct(async () => { await new Promise((resolve) => setTimeout(resolve, 30)); });
|
|
assert.ok(!querySourceFallback(rootNode), `${editorMode} must render the author's note`);
|
|
const editable = rootNode.querySelector("[contenteditable]");
|
|
assert.ok(editable);
|
|
assert.equal(editable.getAttribute("contenteditable"), editorMode === "edit" ? "true" : "false");
|
|
assert.equal(editable.querySelector("h1")?.textContent, "设备TCP栈调优操作记录");
|
|
assert.match(editable.querySelector("table")?.textContent ?? "", /内存<1GB设备/);
|
|
assert.match(editable.textContent ?? "", /配置回滚/);
|
|
assert.equal(editable.querySelectorAll(".cm-editor").length, 10);
|
|
}
|
|
await rerender({ value, editorMode: "source" });
|
|
assert.equal(rootNode.querySelector("textarea")?.value, value);
|
|
assert.deepEqual(changes, [], "view changes must not rewrite the author's source");
|
|
} finally {
|
|
await unmount();
|
|
}
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("editing a comparison emits Markdown that can be reopened", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const value = "Memory<1GB";
|
|
const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value });
|
|
try {
|
|
assert.deepEqual(changes, []);
|
|
const editable = rootNode.querySelector("[contenteditable]");
|
|
assert.ok(editable);
|
|
const { getNearestEditorFromDOMNode, $getRoot, $isTextNode } = await import("lexical");
|
|
const editor = getNearestEditorFromDOMNode(editable);
|
|
assert.ok(editor);
|
|
await runWithAct(async () => {
|
|
editor.update(() => {
|
|
const text = $getRoot().getFirstDescendant();
|
|
assert.ok($isTextNode(text));
|
|
text.setTextContent("Memory<2GB");
|
|
}, { discrete: true });
|
|
});
|
|
assert.ok(changes.length > 0, "real edits must still be saved");
|
|
const edited = changes.at(-1)!;
|
|
await rerender({ value: edited, editorMode: "preview" });
|
|
assert.ok(!querySourceFallback(rootNode));
|
|
assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, "Memory<2GB");
|
|
await rerender({ value: edited, editorMode: "source" });
|
|
assert.equal(rootNode.querySelector("textarea")?.value, edited);
|
|
} finally {
|
|
await unmount();
|
|
}
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("preview task checkboxes still save explicit changes without rewriting comparisons", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const value = "- [ ] Memory<1GB\n- [x] Ready";
|
|
const { rootNode, changes, unmount } = await renderEditor(window, { value, editorMode: "preview" });
|
|
try {
|
|
assert.deepEqual(changes, []);
|
|
const task = rootNode.querySelector<HTMLElement>('li[role="checkbox"]');
|
|
assert.ok(task);
|
|
await runWithAct(async () => {
|
|
task.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 0 }));
|
|
});
|
|
assert.deepEqual(changes, ["- [x] Memory<1GB\n- [x] Ready"]);
|
|
assert.equal(rootNode.querySelector('li[role="checkbox"]')?.getAttribute("aria-checked"), "true");
|
|
} finally {
|
|
await unmount();
|
|
}
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("switching between a comparison note and an unsupported note keeps the fallback scoped", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const value = "Memory<1GB";
|
|
const { rootNode, changes, rerender, unmount } = await renderEditor(window, { value });
|
|
try {
|
|
for (const props of [
|
|
{ noteId: "unsupported", value: NOTE_MARKDOWN_MDX_CANNOT_PARSE },
|
|
{ noteId: "comparison", value },
|
|
]) {
|
|
await rerender(props);
|
|
await runWithAct(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); });
|
|
if (props.noteId === "unsupported") {
|
|
assert.equal(querySourceFallback(rootNode)?.value, props.value);
|
|
} else {
|
|
assert.ok(!querySourceFallback(rootNode));
|
|
assert.equal(rootNode.querySelector("[contenteditable]")?.textContent, value);
|
|
}
|
|
}
|
|
assert.deepEqual(changes, []);
|
|
} finally {
|
|
await unmount();
|
|
}
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("unrenderable markdown stays visible via the raw source fallback", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const { rootNode, unmount } = await renderEditor(window, { value: NOTE_MARKDOWN_MDX_CANNOT_PARSE });
|
|
|
|
const fallback = querySourceFallback(rootNode);
|
|
assert.ok(fallback, "expected the raw markdown fallback to be rendered");
|
|
assert.equal(fallback.value, NOTE_MARKDOWN_MDX_CANNOT_PARSE);
|
|
|
|
const notice = rootNode.querySelector("[data-note-markdown-source-notice]");
|
|
assert.ok(notice, "expected an explanatory notice next to the fallback");
|
|
|
|
// The rich editor must not sit next to the fallback showing a blank page.
|
|
assert.equal(rootNode.querySelectorAll("[contenteditable]").length, 0);
|
|
await unmount();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("plain markdown still renders in the rich editor", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const { rootNode, unmount } = await renderEditor(window, { value: PLAIN_NOTE_MARKDOWN });
|
|
|
|
assert.equal(querySourceFallback(rootNode), null);
|
|
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
|
|
assert.ok(editable, "expected the rich editor to stay mounted");
|
|
assert.match(editable.textContent || "", /Promote the replica/);
|
|
await unmount();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("unrenderable markdown in preview mode falls back to a read-only source view", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const { rootNode, changes, unmount } = await renderEditor(window, {
|
|
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
|
editorMode: "preview",
|
|
});
|
|
|
|
const fallback = querySourceFallback(rootNode);
|
|
assert.ok(fallback, "expected the raw markdown fallback in preview mode");
|
|
assert.equal(fallback.value, NOTE_MARKDOWN_MDX_CANNOT_PARSE);
|
|
assert.equal(fallback.readOnly, true);
|
|
|
|
// Read-only must also block the custom Tab insertion and history handling.
|
|
await runWithAct(async () => {
|
|
fallback.dispatchEvent(new window.KeyboardEvent("keydown", {
|
|
key: "Tab",
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
});
|
|
assert.deepEqual(changes, [], "read-only fallback must not mutate the note");
|
|
await unmount();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("retry imports the latest fallback draft, not the stale prop value", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
// The host keeps the original (invalid) `value` prop, like the 300 ms
|
|
// draft debounce in NotesManager does while the user edits the fallback.
|
|
const { rootNode, changes, unmount } = await renderEditor(window, { value: NOTE_MARKDOWN_MDX_CANNOT_PARSE });
|
|
|
|
const fallback = querySourceFallback(rootNode);
|
|
assert.ok(fallback);
|
|
|
|
const fixedMarkdown = NOTE_MARKDOWN_MDX_CANNOT_PARSE.replace("mysql -h <host> -u <user>", "mysql with host and user");
|
|
await runWithAct(async () => {
|
|
setTextareaValue(window, fallback, fixedMarkdown);
|
|
});
|
|
assert.deepEqual(changes, [fixedMarkdown]);
|
|
|
|
const retry = rootNode.querySelector<HTMLButtonElement>("[data-note-markdown-source-retry]");
|
|
assert.ok(retry, "expected a retry action on the fallback notice");
|
|
await runWithAct(async () => {
|
|
retry.click();
|
|
});
|
|
await runWithAct(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
});
|
|
|
|
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
|
|
assert.ok(editable, "expected the rich editor back after the retry");
|
|
assert.match(editable.textContent || "", /The content after the angle tags/);
|
|
assert.equal(querySourceFallback(rootNode), null, "the fallback must be gone after a successful retry");
|
|
await unmount();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("retry rebinds active-format listeners after the rich editor remounts", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const formatChanges: ActiveFormats[] = [];
|
|
const onActiveFormatsChange = (formats: ActiveFormats) => {
|
|
formatChanges.push(formats);
|
|
};
|
|
const { rootNode, changes, unmount } = await renderEditor(window, {
|
|
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
|
onActiveFormatsChange,
|
|
});
|
|
|
|
assert.ok(querySourceFallback(rootNode), "expected the raw markdown fallback");
|
|
assert.equal(rootNode.querySelectorAll("[contenteditable]").length, 0);
|
|
const callsWhileFallback = formatChanges.length;
|
|
|
|
const fallback = querySourceFallback(rootNode);
|
|
assert.ok(fallback);
|
|
const fixedMarkdown = NOTE_MARKDOWN_MDX_CANNOT_PARSE.replace(
|
|
"mysql -h <host> -u <user>",
|
|
"mysql with host and user",
|
|
);
|
|
await runWithAct(async () => {
|
|
setTextareaValue(window, fallback, fixedMarkdown);
|
|
});
|
|
assert.deepEqual(changes, [fixedMarkdown]);
|
|
|
|
const retry = rootNode.querySelector<HTMLButtonElement>("[data-note-markdown-source-retry]");
|
|
assert.ok(retry, "expected a retry action on the fallback notice");
|
|
await runWithAct(async () => {
|
|
retry.click();
|
|
});
|
|
await runWithAct(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
});
|
|
|
|
const editable = rootNode.querySelector<HTMLElement>("[contenteditable]");
|
|
assert.ok(editable, "expected the rich editor back after the retry");
|
|
assert.equal(querySourceFallback(rootNode), null, "the fallback must be gone after a successful retry");
|
|
assert.ok(
|
|
formatChanges.length > callsWhileFallback,
|
|
"expected onActiveFormatsChange after the rich editor remounted",
|
|
);
|
|
await unmount();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("same-note external markdown clears an active fallback", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const { rootNode, rerender, unmount } = await renderEditor(window, {
|
|
value: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
|
});
|
|
assert.ok(querySourceFallback(rootNode));
|
|
|
|
await rerender({ value: PLAIN_NOTE_MARKDOWN });
|
|
await runWithAct(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
});
|
|
|
|
assert.equal(querySourceFallback(rootNode), null);
|
|
assert.match(rootNode.querySelector<HTMLElement>("[contenteditable]")?.textContent || "", /Promote the replica/);
|
|
await unmount();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("leading and trailing whitespace does not hide an unrenderable note", async () => {
|
|
const { window, cleanup } = setupDom();
|
|
try {
|
|
const { rootNode, unmount } = await renderEditor(window, {
|
|
value: `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`,
|
|
});
|
|
|
|
const fallback = querySourceFallback(rootNode);
|
|
assert.ok(fallback, "expected padded unrenderable markdown to use the source fallback");
|
|
assert.equal(fallback.value, `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`);
|
|
await unmount();
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("a stale parse error is ignored when the same note has newer markdown", async () => {
|
|
const { shouldApplyMdxParseFailure } = await import("./InlineMarkdownEditor.tsx");
|
|
|
|
assert.equal(shouldApplyMdxParseFailure({
|
|
currentNoteId: "note-1",
|
|
failedNoteId: "note-1",
|
|
currentMarkdown: PLAIN_NOTE_MARKDOWN,
|
|
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
|
}), false);
|
|
assert.equal(shouldApplyMdxParseFailure({
|
|
currentNoteId: "note-1",
|
|
failedNoteId: "note-1",
|
|
currentMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
|
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
|
}), true);
|
|
assert.equal(shouldApplyMdxParseFailure({
|
|
currentNoteId: "note-1",
|
|
failedNoteId: "note-1",
|
|
currentMarkdown: `\n${NOTE_MARKDOWN_MDX_CANNOT_PARSE}\n`,
|
|
failedMarkdown: NOTE_MARKDOWN_MDX_CANNOT_PARSE,
|
|
}), true);
|
|
});
|