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:
@@ -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;
|
||||
})();
|
||||
Vendored
+45
@@ -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 {{{
|
||||
```
|
||||
|
||||

|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user