Add CLAUDE.md and the test harness

CLAUDE.md records the Nova constraints this extension is built around —
established by reading Nova 14.1's bundle and probing its preview server,
and expensive to re-derive: no previewer extension point, generated pages
must live under the preview root, dot-directories are served, and no API
reaches into a preview WebView. It also flags the one unverified
assumption, that a tab showing Preview still appears in
nova.workspace.textEditors, with its symptom and fix.

Tests/ is the harness that built the extension, kept rather than left in a
scratchpad: a stubbed Nova runtime backed by the real filesystem, node
suites for generation and the four close paths, and a headless-browser
render test that skips itself without a browser. It caught the ./ doubling
in rebased links and checkbox state not surviving serialization.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TueXpMi7eAtgWPuMr4ii52
This commit is contained in:
2026-08-20 23:02:08 -03:00
co-authored by Claude Opus 5
parent 8a597e19a8
commit aebc72b85e
8 changed files with 544 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
const fs = require("fs");
const path = require("path");
const stub = require("./nova-stub.js");
const EXT = path.join(__dirname, "..");
const ROOT = path.join(__dirname, ".tmp", "close");
const OUT = path.join(ROOT, ".nova", "MermaidPreview");
fs.rmSync(ROOT, { recursive: true, force: true });
fs.mkdirSync(path.join(ROOT, "docs"), { recursive: true });
const MD = path.join(ROOT, "docs", "note.md");
fs.writeFileSync(MD, "# Note\n\n```mermaid\ngraph TD\nA-->B\n```\n");
const nova = stub.build({ root: ROOT, extensionPath: EXT });
global.nova = nova;
global.Disposable = stub.Disposable;
global.CompositeDisposable = stub.CompositeDisposable;
global.Range = stub.Range;
require(path.join(EXT, "Scripts", "main.js")).activate();
const results = [];
const check = (name, ok) => results.push([ok ? "PASS" : "FAIL", name]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const pages = () => (fs.existsSync(OUT) ? fs.readdirSync(OUT).filter((f) => f.endsWith(".html")) : []);
function openSource() {
const editor = new stub.FakeEditor(MD, fs.readFileSync(MD, "utf8"));
nova.workspace.textEditors.push(editor);
nova.workspace.activeTextEditor = editor;
return editor;
}
function pageEditor() {
return nova.workspace.textEditors.find((e) => e.document.path.endsWith(".html"));
}
(async () => {
// -- 1. closing the generated page removes it ------------------------
let source = openSource();
await nova.__invoke("mermaidpreview.preview");
check("preview opens a page", pages().length === 1);
const page = pageEditor();
nova.__close(page);
check("page survives the grace period", pages().length === 1);
await sleep(3000);
check("closing the page tab deletes it", pages().length === 0);
check("last page out removes the directory", !fs.existsSync(OUT));
// -- 2. a tab flipping to preview must not count as a close ----------
nova.workspace.textEditors = [];
source = openSource();
await nova.__invoke("mermaidpreview.preview");
const flipping = pageEditor();
flipping.fireDestroy(); // destroyed, but the tab is still open
await sleep(3000);
check("a rebuilt editor is not mistaken for a close", pages().length === 1);
// -- 3. closing the Markdown file cleans up too ----------------------
nova.__close(source);
check("closing the source deletes the page immediately", pages().length === 0);
check("directory removed with it", !fs.existsSync(OUT));
// -- 4. cleanup can be turned off ------------------------------------
nova.workspace.textEditors = [];
nova.config.set("mermaidpreview.cleanUpOnClose", false);
source = openSource();
await nova.__invoke("mermaidpreview.preview");
const kept = pageEditor();
nova.__close(kept);
nova.__close(source);
await sleep(3000);
check("nothing is deleted when the setting is off", pages().length === 1);
for (const [status, name] of results) console.log(`${status} ${name}`);
process.exitCode = results.some(([s]) => s === "FAIL") ? 1 : 0;
})();
+45
View File
@@ -0,0 +1,45 @@
---
title: Front matter should be hidden
---
# Sample Document
Intro paragraph with a [relative link](./other.md), `inline code`, **bold** and a
[TOC link](#a-second-heading).
- [ ] an unchecked task
- [x] a checked task
- a plain item
| Column | Another |
| --- | --- |
| one | two |
| three | four |
```js
const answer = 42;
```
```mermaid
graph TD
A[Start] --> B{Works?}
B -->|yes| C[Ship it]
B -->|no| D[Fix it]
D --> A
```
## A Second Heading
```mermaid
sequenceDiagram
Nova->>Extension: Preview Markdown
Extension->>Disk: write preview.html
Disk-->>Nova: file changed
Nova->>Preview: reload
```
```mermaid
this is not valid mermaid at all {{{
```
![an image](img/logo.png)
+61
View File
@@ -0,0 +1,61 @@
const fs = require("fs");
const path = require("path");
const stub = require("./nova-stub.js");
const EXT = path.join(__dirname, "..");
const ROOT = path.join(__dirname, ".tmp", "generate");
fs.rmSync(ROOT, { recursive: true, force: true });
fs.mkdirSync(path.join(ROOT, "docs"), { recursive: true });
fs.writeFileSync(path.join(ROOT, "docs", "one.md"), "# One\n\n```mermaid\ngraph TD\nA-->B\n```\n");
fs.writeFileSync(path.join(ROOT, "docs", "two.md"), "# Two\n");
const nova = stub.build({ root: ROOT, extensionPath: EXT });
global.nova = nova;
global.Disposable = stub.Disposable;
global.CompositeDisposable = stub.CompositeDisposable;
global.Range = stub.Range;
const preview = require(path.join(EXT, "Scripts", "preview.js"));
const OUT = path.join(ROOT, ".nova", "MermaidPreview");
const results = [];
function check(name, condition) {
results.push([condition ? "PASS" : "FAIL", name]);
}
// -- generate ------------------------------------------------------------
const one = { path: path.join(ROOT, "docs", "one.md"), mode: "markdown",
editor: new stub.FakeEditor(path.join(ROOT, "docs", "one.md"), fs.readFileSync(path.join(ROOT, "docs", "one.md"), "utf8")) };
const two = { path: path.join(ROOT, "docs", "two.md"), mode: "markdown",
editor: new stub.FakeEditor(path.join(ROOT, "docs", "two.md"), fs.readFileSync(path.join(ROOT, "docs", "two.md"), "utf8")) };
const pathOne = preview.generate(one);
check("generate writes a page", fs.existsSync(pathOne));
check("assets installed", fs.existsSync(path.join(OUT, "assets", "vendor", "mermaid.min.js")));
check("stylesheet installed", fs.existsSync(path.join(OUT, "assets", "preview.css")));
check("page references relative assets", fs.readFileSync(pathOne, "utf8").includes('src="assets/vendor/mermaid.min.js"'));
check("page embeds the markdown", fs.readFileSync(pathOne, "utf8").includes("graph TD"));
const mtime = fs.statSync(path.join(OUT, "assets", "preview.css")).mtimeMs;
const pathTwo = preview.generate(two);
check("second page has its own filename", pathOne !== pathTwo && fs.existsSync(pathTwo));
check("assets not reinstalled when current", fs.statSync(path.join(OUT, "assets", "preview.css")).mtimeMs === mtime);
// -- discard -------------------------------------------------------------
preview.discard(pathOne);
check("discard removes that page", !fs.existsSync(pathOne));
check("discard keeps the other page", fs.existsSync(pathTwo));
check("discard keeps assets while a page remains", fs.existsSync(path.join(OUT, "assets", "preview.css")));
preview.discard(pathTwo);
check("last discard removes the directory", !fs.existsSync(OUT));
check("workspace otherwise untouched", fs.existsSync(path.join(ROOT, "docs", "one.md")));
// -- discard is safe to repeat -------------------------------------------
let threw = false;
try { preview.discard(pathTwo); } catch (e) { threw = true; }
check("discarding twice does not throw", !threw);
for (const [status, name] of results) console.log(`${status} ${name}`);
process.exitCode = results.some(([s]) => s === "FAIL") ? 1 : 0;
+107
View File
@@ -0,0 +1,107 @@
// A minimal stand-in for Nova's extension runtime, backed by the real fs, so the
// extension's own Scripts/*.js can be exercised outside Nova.
const fs = require("fs");
const nodePath = require("path");
class Disposable {
constructor(fn) { this.fn = fn; this.disposed = false; }
dispose() { if (!this.disposed) { this.disposed = true; if (this.fn) this.fn(); } }
}
class CompositeDisposable {
constructor() { this.items = []; }
add(d) { this.items.push(d); }
dispose() { for (const d of this.items) d.dispose(); this.items = []; }
}
class Range {
constructor(start, end) { this.start = start; this.end = end; }
}
class FakeEditor {
constructor(path, text) {
this.document = {
path, uri: `file://${path}`, isUntitled: false, isRemote: false,
syntax: path.endsWith(".md") ? "markdown" : "html",
length: text.length,
getTextInRange: () => text,
};
this.handlers = { save: [], stop: [], destroy: [] };
}
onDidSave(cb) { this.handlers.save.push(cb); return new Disposable(); }
onDidStopChanging(cb) { this.handlers.stop.push(cb); return new Disposable(); }
onDidDestroy(cb) { this.handlers.destroy.push(cb); return new Disposable(); }
fireDestroy() { for (const cb of this.handlers.destroy.slice()) cb(this); }
}
function build({ root, extensionPath }) {
const config = new Map();
const commands = new Map();
const configApi = {
get: (k) => (config.has(k) ? config.get(k) : null),
set: (k, v) => config.set(k, v),
onDidChange: () => new Disposable(),
};
const nova = {
extension: { path: extensionPath },
config: configApi,
commands: {
register: (name, cb) => { commands.set(name, cb); return new Disposable(() => commands.delete(name)); },
},
path: {
join: (...a) => nodePath.join(...a),
dirname: (p) => nodePath.dirname(p),
basename: (p) => nodePath.basename(p),
extname: (p) => nodePath.extname(p),
isAbsolute: (p) => nodePath.isAbsolute(p),
relative: (a, b) => nodePath.relative(a, b),
splitext: (p) => { const e = nodePath.extname(p); return [p.slice(0, p.length - e.length), e]; },
},
fs: {
F_OK: fs.constants.F_OK,
access: (p) => fs.existsSync(p),
mkdir: (p) => fs.mkdirSync(p),
copy: (a, b) => fs.copyFileSync(a, b),
remove: (p) => fs.unlinkSync(p),
rmdir: (p) => fs.rmdirSync(p),
listdir: (p) => fs.readdirSync(p),
stat: (p) => { const st = fs.statSync(p); return { isDirectory: () => st.isDirectory() }; },
open: (p, mode) => {
if (mode === "w") {
const chunks = [];
return { write: (s) => chunks.push(s), close: () => fs.writeFileSync(p, chunks.join("")) };
}
const text = fs.readFileSync(p, "utf8");
return { read: () => text, close: () => {} };
},
},
workspace: {
path: root,
previewRootPath: root,
config: { get: () => null, set: () => {} },
textEditors: [],
activeTextEditor: null,
openFile: async (p) => {
let editor = nova.workspace.textEditors.find((e) => e.document.path === p);
if (!editor) {
editor = new FakeEditor(p, require('fs').readFileSync(p, 'utf8'));
nova.workspace.textEditors.push(editor);
}
return editor;
},
showInformativeMessage: (m) => messages.push(["info", m]),
showErrorMessage: (m) => messages.push(["error", m]),
showActionPanel: (m, o, cb) => cb(0),
},
};
const messages = [];
nova.__messages = messages;
nova.__invoke = (name, ...args) => commands.get(name)(...args);
nova.__close = (editor) => {
nova.workspace.textEditors = nova.workspace.textEditors.filter((e) => e !== editor);
editor.fireDestroy();
};
return nova;
}
module.exports = { build, FakeEditor, Disposable, CompositeDisposable, Range };
+96
View File
@@ -0,0 +1,96 @@
// Renders a fixture through the real render.js in a headless browser, because
// everything the page does — Markdown, Mermaid, the DOM fix-ups — only happens
// where there is a DOM. Skips itself when no browser can be found.
const fs = require("fs");
const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const EXT = path.join(__dirname, "..");
const { buildDocument } = require(path.join(EXT, "Scripts", "render.js"));
function findBrowser() {
if (process.env.MERMAID_PREVIEW_CHROME) return process.env.MERMAID_PREVIEW_CHROME;
const cache = path.join(os.homedir(), "Library", "Caches", "ms-playwright");
if (fs.existsSync(cache)) {
for (const entry of fs.readdirSync(cache)) {
const candidate = path.join(cache, entry, "chrome-headless-shell-mac-arm64", "chrome-headless-shell");
if (fs.existsSync(candidate)) return candidate;
}
}
const chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
return fs.existsSync(chrome) ? chrome : null;
}
const browser = findBrowser();
if (!browser) {
console.log("SKIP no headless browser found (set MERMAID_PREVIEW_CHROME to one)");
process.exit(0);
}
const work = path.join(__dirname, ".tmp", "render");
fs.rmSync(work, { recursive: true, force: true });
fs.mkdirSync(path.join(work, "out", "assets", "vendor"), { recursive: true });
fs.mkdirSync(path.join(work, "docs"), { recursive: true });
for (const name of ["preview.css", "preview.js"]) {
fs.copyFileSync(path.join(EXT, "Assets", name), path.join(work, "out", "assets", name));
}
for (const name of ["markdown-it.min.js", "mermaid.min.js"]) {
fs.copyFileSync(path.join(EXT, "Assets", "vendor", name), path.join(work, "out", "assets", "vendor", name));
}
const markdown = fs.readFileSync(path.join(__dirname, "fixtures", "sample.md"), "utf8");
fs.writeFileSync(path.join(work, "docs", "sample.md"), markdown);
const results = [];
const check = (name, ok) => results.push([ok ? "PASS" : "FAIL", name]);
function render(theme) {
const page = path.join(work, "out", `sample-${theme}.html`);
fs.writeFileSync(
page,
buildDocument({
markdown,
title: "sample.md",
sourcePath: path.join(work, "docs", "sample.md"),
sourceRelDir: "../docs/",
assetsRelDir: "assets",
mode: "markdown",
theme,
contentWidth: 860,
})
);
return execFileSync(
browser,
["--headless", "--disable-gpu", "--no-sandbox", "--virtual-time-budget=10000", "--dump-dom", `file://${page}`],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024 }
);
}
const dom = render("auto");
// Only the rendered <main>. The Markdown source is embedded further down the
// page as data, so a naive slice to end-of-document would find anything.
const body = dom.slice(dom.indexOf('id="content"'), dom.indexOf('id="preview-data"'));
check("both valid diagrams drawn", (dom.match(/<svg/g) || []).length === 2);
check("the broken diagram reports in place", dom.includes("mermaid-error-message") && dom.includes("is-error"));
check("front matter is not rendered", !body.includes("Front matter should be hidden"));
check("headings get ids", dom.includes('<h2 id="a-second-heading"'));
check("in-document anchors are left alone", dom.includes('href="#a-second-heading"'));
check("relative links are rebased once", dom.includes('href="../docs/other.md"') && !dom.includes("docs/./"));
check("relative images are rebased", dom.includes('src="../docs/img/logo.png"'));
check("task list becomes checkboxes", (dom.match(/<input type="checkbox"/g) || []).length === 2);
check("checked state survives", dom.includes('checked=""'));
check("wide tables scroll on their own", dom.includes('class="table-scroll"'));
check("content is revealed", !body.includes('aria-busy="true"'));
const dark = render("dark");
check("dark theme reaches the page", dark.includes('data-theme="dark"'));
check("dark theme still draws diagrams", (dark.match(/<svg/g) || []).length === 2);
for (const [status, name] of results) console.log(`${status} ${name}`);
process.exitCode = results.some(([s]) => s === "FAIL") ? 1 : 0;
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Runs every test. No dependencies beyond node; render.test.js additionally
# wants a headless browser and skips itself without one.
set -eu
cd "$(dirname "$0")"
echo "— syntax —"
for f in ../Scripts/*.js ../Assets/preview.js ./*.js; do node --check "$f"; done
python3 -c "import json; json.load(open('../extension.json'))"
echo "ok"
for test in generate close render; do
echo "$test"
node "$test.test.js"
done