diff --git a/README.md b/README.md index 662d872..3b9eb81 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ $ mat README.md # render and open the browser $ mat # render this directory's standard document $ cat notes.md | mat - # read from stdin $ mat README.md -f # also render the local Markdown files it links to +$ mat README.md -w # keep re-rendering and reload the tab on every change $ mat README.md --output readme.html # write a self-contained file, open nothing $ mat README.md --output - # write the HTML to stdout $ mat README.md --theme dark # force a theme; default follows the OS @@ -67,6 +68,19 @@ recursively, and points those links at the rendered previews instead of the raw whose target does not exist or cannot be rendered keep pointing at the source and are reported on stderr. +`--watch` (short: `-w`) keeps `mat` running: every change to a rendered file re-renders it and +reloads the open tab. The reload travels over a WebSocket on `127.0.0.1` that lives and dies with +the process, on a random path; the page itself stays a plain `file://` document. Watched are +exactly the Markdown files that were rendered, so with `--follow-links` the set follows the links +as they appear and disappear. Images and other assets are not watched. + +A file that cannot be read or rendered is reported on stderr and leaves the last good preview in +place, so fixing it and saving again picks the session back up. `Ctrl+C` ends it with exit `0`. If +no browser can be started, the URL is printed and watching continues, because opening it by hand +is all that is missing. `--watch` cannot be combined with `--output` or with `-`, and on network +file systems changes may go unnoticed, because the `fs.watch` underneath does not reliably see +them. It can be made the [default](#configuration). + `--output` produces a file you can move or send: the diagram script and the fonts are embedded. Images are not — they stay absolute `file://` links to wherever they are on your disk. @@ -79,15 +93,20 @@ Images are not — they stay absolute `file://` links to wherever they are on yo ```json { "defaultDocuments": ["NOTES.md", "README.md"], - "followLinks": true + "followLinks": true, + "watch": true } ``` -Both keys are optional. `defaultDocuments` replaces the built-in list of documents `mat` tries -when called without a file, in the order given. `followLinks` makes `--follow-links` the default; -`--follow-links=false` turns it back off for one call. A flag on the command line always wins over -the file, and `--output` ignores a configured `followLinks`, because a single self-contained file -cannot hold the linked previews. +All three keys are optional. `defaultDocuments` replaces the built-in list of documents `mat` tries +when called without a file, in the order given. `followLinks` makes `--follow-links` the default, +and `watch` makes `--watch` the default, so plain `mat README.md` keeps running until `Ctrl+C` +instead of returning once the browser is open. `--follow-links=false` and `--watch=false` turn +either back off for one call, and a flag on the command line always wins over the file. + +Where a configured default cannot apply, it is dropped rather than turned into an error: `--output` +ignores both, because a single self-contained file cannot hold the linked previews and there is no +tab to reload, and reading from `-` ignores `watch`, because a pipe has no path to watch. A configuration file that is not valid JSON, or that contains unknown keys or wrong types, is an error: `mat` names the file and the problem, and exits `2`. Having no configuration file is the diff --git a/src/cli.ts b/src/cli.ts index e960625..aaf02bc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,8 +11,9 @@ import { DEFAULT_DOCUMENTS, findDefaultDocument } from './cli/default-document.t import { ConfigurationError, describeFileSystemError, RuntimeError } from './cli/errors.ts'; import { createLogger, type Logger } from './cli/logger.ts'; import { startUpdateCheck } from './cli/update-check.ts'; +import { runWatch } from './cli/watch.ts'; import { MAT_VERSION } from './generated/assets.ts'; -import { render } from './render/index.ts'; +import { type RenderOptions, render } from './render/index.ts'; import type { LinkAdmission } from './render/pipeline.ts'; export const EXIT_SUCCESS = 0; @@ -290,12 +291,27 @@ function writePreview(previewPath: string, html: string): void { } } -async function runRender( +export interface RenderedDocuments { + html: string; + /** Where the root document's preview belongs; a `--output` render has no use for it. */ + previewPath: string; + /** The root document's real path, or undefined when it was read from stdin. */ + realPath: string | undefined; + /** Every rendered document that has a path on disk: the root first, then the followed links. */ + renderedRealPaths: string[]; +} + +/** + * One render pass over the source and everything it links to, with the linked previews already + * written. Separate from `runRender` so that `--watch` can repeat exactly this step: every call + * builds a fresh follow queue, so a link added or removed since the last pass is picked up. + */ +async function renderDocuments( invocation: Invocation, configuration: Configuration, - out: OutputStreams, logger: Logger, -): Promise { + reload?: RenderOptions['reload'], +): Promise { const { bytes, label } = await readSource(invocation, configuration, logger); if (bytes.byteLength > WARN_BYTES) { @@ -304,8 +320,6 @@ async function runRender( const markdown = decodeMarkdown(bytes, label); const realPath = invocation.source === '-' ? undefined : label; - const toStdout = invocation.output === '-'; - const toFile = invocation.output !== undefined && !toStdout; // The configured default is suppressed by `--output` rather than rejected like the explicit // flag: a single self-contained file cannot hold the linked previews, and a configuration // meant as a default must not make `--output` unusable. @@ -324,9 +338,11 @@ async function runRender( // the shared cache. embedMode: invocation.output === undefined ? 'cache' : 'inline', followLinks: followQueue, + reload, }); const collectedMessages = [...messages]; + const renderedRealPaths = realPath === undefined ? [] : [realPath]; if (followQueue !== undefined) { for ( @@ -342,9 +358,11 @@ async function runRender( linkMode: 'absolute', embedMode: 'cache', followLinks: followQueue, + reload, }); writePreview(previewPathFor(queued.realPath), linked.html); + renderedRealPaths.push(queued.realPath); const documentLabel = relative(process.cwd(), queued.realPath); collectedMessages.push(...linked.messages.map((message) => `${documentLabel}: ${message}`)); @@ -353,13 +371,34 @@ async function runRender( reportMessages(collectedMessages, logger); - if (toStdout) { + return { + html, + // stdin has no path to key the preview on, so its content decides: two different documents + // piped in must not overwrite each other's tab. + previewPath: + realPath === undefined + ? join(previewDirectory(), `${createHash('sha256').update(markdown).digest('hex')}.html`) + : previewPathFor(realPath), + realPath, + renderedRealPaths, + }; +} + +async function runRender( + invocation: Invocation, + configuration: Configuration, + out: OutputStreams, + logger: Logger, +): Promise { + const { html, previewPath } = await renderDocuments(invocation, configuration, logger); + + if (invocation.output === '-') { out.stdout(html); return EXIT_SUCCESS; } - if (toFile && invocation.output !== undefined) { + if (invocation.output !== undefined) { const target = isAbsolute(invocation.output) ? invocation.output : resolve(invocation.output); try { @@ -373,11 +412,6 @@ async function runRender( return EXIT_SUCCESS; } - const previewPath = - realPath === undefined - ? join(previewDirectory(), `${createHash('sha256').update(markdown).digest('hex')}.html`) - : previewPathFor(realPath); - writePreview(previewPath, html); // Never string concatenation: it breaks on Windows paths, spaces and umlauts. @@ -394,6 +428,39 @@ async function runRender( return EXIT_SUCCESS; } +async function runWatchSession( + invocation: Invocation, + configuration: Configuration, + logger: Logger, + signal: AbortSignal, + onFirstRender: () => void, +): Promise { + let pinned = invocation; + + await runWatch({ + logger, + signal, + onFirstRender, + async render(reloadUrl) { + const rendered = await renderDocuments(pinned, configuration, logger, { url: reloadUrl }); + + writePreview(rendered.previewPath, rendered.html); + // Pinned to the path the first render resolved: called without a file, a session that + // started on `README.md` must not silently switch to an `index.md` created later. `--watch` + // is rejected for stdin, so there is always a path to pin. Retargeting the symlink a + // session was started through is knowingly not followed. + pinned = { ...pinned, source: rendered.realPath ?? pinned.source }; + + return { + previewUrl: pathToFileURL(rendered.previewPath).href, + renderedRealPaths: rendered.renderedRealPaths, + }; + }, + }); + + return EXIT_SUCCESS; +} + /** * `checkForUpdate` is injectable for the same reason `out` is: the real one talks to GitHub * and to the shared temp directory, neither of which a test may touch. @@ -436,10 +503,55 @@ export async function main( logger, }); + // A watch session reports right after its first render instead of at shutdown, where the hint + // would scroll past minutes later; the wrapper keeps the `finally` below from repeating it. + let reported = false; + const reportUpdateOnce = (): void => { + if (reported) { + return; + } + + reported = true; + updateCheck.report(); + }; + + // The configured default is suppressed by `--output` and by stdin rather than rejected like the + // explicit flag: neither can carry a session, and a default must not make them unusable. + const watch = + parsed.value.watch ?? + (configuration.watch === true && + parsed.value.output === undefined && + parsed.value.source !== '-'); + try { - return await runRender(parsed.value, configuration, out, logger); + if (!watch) { + return await runRender(parsed.value, configuration, out, logger); + } + + const controller = new AbortController(); + const abort = (): void => { + controller.abort(); + }; + + // `once` on both, so a second Ctrl+C during shutdown reaches the default handler and kills + // the process rather than being swallowed. + process.once('SIGINT', abort); + process.once('SIGTERM', abort); + + try { + return await runWatchSession( + parsed.value, + configuration, + logger, + controller.signal, + reportUpdateOnce, + ); + } finally { + process.off('SIGINT', abort); + process.off('SIGTERM', abort); + } } finally { - updateCheck.report(); + reportUpdateOnce(); } } catch (error) { const reason = error instanceof Error ? error.message : String(error); diff --git a/src/cli/commands/render.ts b/src/cli/commands/render.ts index fd64528..76ffefe 100644 --- a/src/cli/commands/render.ts +++ b/src/cli/commands/render.ts @@ -1,5 +1,6 @@ import { boolean, command, flag, optional, positional } from 'cmd-ts'; import type { ParseContext, ParsingResult } from 'cmd-ts/dist/cjs/argparser'; +import type { AstNode } from 'cmd-ts/dist/cjs/newparser/parser'; import { DEFAULT_FLAVOR_NAME } from '../../flavors/index.ts'; import { MAT_VERSION, type ThemeName } from '../../generated/assets.ts'; import { directoryType, flavorType, pathType, themeType } from '../argument-types.ts'; @@ -13,6 +14,7 @@ export interface Invocation { flavor: string; baseDir: string | undefined; followLinks: boolean | undefined; + watch: boolean | undefined; } const renderArguments = command({ @@ -27,6 +29,10 @@ const renderArguments = command({ description: 'Also render every local Markdown file the document links to', command: 'mat README.md -f', }, + { + description: 'Keep re-rendering and reload the browser on every change', + command: 'mat README.md -w', + }, { description: 'Write a self-contained file and open nothing', command: 'mat README.md --output readme.html', @@ -70,49 +76,69 @@ const renderArguments = command({ description: 'Also render linked local Markdown files and point their links at the previews. =false overrides the configuration file.', }), + watch: flag({ + long: 'watch', + short: 'w', + // `optional` for the same reason as `--follow-links` above. + type: optional(boolean), + description: + 'Re-render whenever a rendered file changes and reload the browser tab, until Ctrl+C. =false overrides the configuration file.', + }), }, handler: (invocation): Invocation => invocation, }); +function usageError(nodes: AstNode[], message: string): ParsingResult { + return { _tag: 'error', error: { errors: [{ nodes, message }] } }; +} + /** - * The command, plus the one rule that no single argument can check on its own. Reporting it from - * `parse` rather than from the handler puts it in the same error box as every other usage error. + * The command, plus the rules that no single argument can check on its own. Reporting them from + * `parse` rather than from the handler puts them in the same error box as every other usage error. */ export const renderCommand = { ...renderArguments, async parse(context: ParseContext): Promise> { const parsed = await renderArguments.parse(context); - if (parsed._tag === 'ok' && parsed.value.baseDir !== undefined && parsed.value.source !== '-') { - // Whenever a file is rendered — named or defaulted to — the base is that file's directory; + if (parsed._tag !== 'ok') { + return parsed; + } + + if (parsed.value.baseDir !== undefined && parsed.value.source !== '-') { + // Whenever a file is rendered, named or defaulted to, the base is that file's directory; // an override would silently resolve images against a directory it knows nothing about. - return { - _tag: 'error', - error: { - errors: [ - { - nodes: longOptionsNamed(context.nodes, 'base-dir'), - message: '--base-dir is only valid together with -', - }, - ], - }, - }; + return usageError( + longOptionsNamed(context.nodes, 'base-dir'), + '--base-dir is only valid together with -', + ); } - if (parsed._tag === 'ok' && parsed.value.followLinks && parsed.value.output !== undefined) { + if (parsed.value.followLinks && parsed.value.output !== undefined) { // `--output` produces a single self-contained file; following links needs one preview file // per document, which that contract cannot hold. - return { - _tag: 'error', - error: { - errors: [ - { - nodes: optionsNamed(context.nodes, 'follow-links', 'f'), - message: '--follow-links is only valid without --output', - }, - ], - }, - }; + return usageError( + optionsNamed(context.nodes, 'follow-links', 'f'), + '--follow-links is only valid without --output', + ); + } + + if (parsed.value.watch && parsed.value.output !== undefined) { + // `--output` writes once and opens nothing, so there is neither a tab to reload nor a reason + // to keep the process alive. + return usageError( + optionsNamed(context.nodes, 'watch', 'w'), + '--watch is only valid without --output', + ); + } + + if (parsed.value.watch && parsed.value.source === '-') { + // A pipe is read once and has no path to watch. This rules out `--base-dir` along with it, + // since that one only exists together with `-`. + return usageError( + optionsNamed(context.nodes, 'watch', 'w'), + '--watch is only valid without -', + ); } return parsed; diff --git a/src/cli/config.ts b/src/cli/config.ts index d03eb07..f58c2f5 100644 --- a/src/cli/config.ts +++ b/src/cli/config.ts @@ -6,6 +6,7 @@ import { ConfigurationError, describeFileSystemError } from './errors.ts'; export interface Configuration { readonly defaultDocuments?: readonly string[]; readonly followLinks?: boolean; + readonly watch?: boolean; } const MAX_CONFIGURATION_BYTES = 1024 * 1024; @@ -58,6 +59,18 @@ function validatedDocuments( }); } +function validatedBoolean( + key: string, + value: unknown, + problem: (message: string) => ConfigurationError, +): boolean { + if (typeof value !== 'boolean') { + throw problem(`${key}: expected true or false`); + } + + return value; +} + /** * Strict on purpose: an unknown key is far more likely a typo than an intention, and silently * ignoring it would leave the user believing a setting is active when it is not. @@ -70,19 +83,17 @@ function validated( throw problem('not a JSON object'); } - const configuration: { defaultDocuments?: string[]; followLinks?: boolean } = {}; + const configuration: { defaultDocuments?: string[]; followLinks?: boolean; watch?: boolean } = {}; for (const [key, value] of Object.entries(parsed)) { if (key === 'defaultDocuments') { configuration.defaultDocuments = validatedDocuments(value, problem); } else if (key === 'followLinks') { - if (typeof value !== 'boolean') { - throw problem('followLinks: expected true or false'); - } - - configuration.followLinks = value; + configuration.followLinks = validatedBoolean(key, value, problem); + } else if (key === 'watch') { + configuration.watch = validatedBoolean(key, value, problem); } else { - throw problem(`unknown key "${key}" (known: defaultDocuments, followLinks)`); + throw problem(`unknown key "${key}" (known: defaultDocuments, followLinks, watch)`); } } diff --git a/src/cli/file-watcher.ts b/src/cli/file-watcher.ts new file mode 100644 index 0000000..d47cb10 --- /dev/null +++ b/src/cli/file-watcher.ts @@ -0,0 +1,181 @@ +import { type FSWatcher, statSync, watch } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; + +export interface FileWatcher { + /** Replaces the watched set; a path that falls out of it stops being reported. */ + update(paths: readonly string[]): void; + close(): void; +} + +interface WatchedDirectory { + watcher: FSWatcher; + identities: Map; +} + +/** Inode included: an atomic save leaves size and timestamp free to repeat, but not the inode. */ +function identityOf(path: string): string { + try { + const stats = statSync(path); + + return `${stats.ino}:${stats.size}:${stats.mtimeMs}`; + } catch { + return 'gone'; + } +} + +function refreshIdentities(directory: string, identities: Map): boolean { + let replaced = false; + + for (const [fileName, previous] of identities) { + const identity = identityOf(join(directory, fileName)); + + if (identity !== previous) { + identities.set(fileName, identity); + replaced = true; + } + } + + return replaced; +} + +/** + * Watches the parent directories and filters the events by file name, rather than watching the + * files themselves: an editor saving atomically writes a sibling file and renames it over the + * target, which replaces the inode. A watch on the file keeps pointing at the inode that was + * replaced and stays silent from then on, while the directory sees the rename. + */ +export function createFileWatcher(onChange: () => void, debounceMilliseconds = 200): FileWatcher { + const watched = new Map(); + + let pendingChange: ReturnType | undefined; + let closed = false; + + function scheduleChange(): void { + if (closed) { + return; + } + + if (pendingChange !== undefined) { + clearTimeout(pendingChange); + } + + pendingChange = setTimeout(() => { + pendingChange = undefined; + onChange(); + }, debounceMilliseconds); + } + + function isInteresting(directory: string, fileName: string | null | undefined): boolean { + const entry = watched.get(directory); + + if (entry === undefined) { + return false; + } + + // Bun's inotify watcher reports a rename inside the directory under the name that vanished: an + // atomic save arrives as `index.md.tmp`, never as `index.md`. An unwatched name alone is + // therefore no reason to drop the event, so the watched files decide whether one was replaced. + // Every event refreshes the baselines, those matched by name included, or an edit reported + // under its own name would leave them stale and the next unrelated event would fire on them. + const replaced = refreshIdentities(directory, entry.identities); + + // Some platforms report an event without naming the file. Rendering once too often is cheaper + // than missing an edit, so an unnamed event counts as one of ours. + if (fileName === undefined || fileName === null || entry.identities.has(fileName)) { + return true; + } + + return replaced; + } + + function stopWatching(directory: string): void { + const entry = watched.get(directory); + + if (entry === undefined) { + return; + } + + watched.delete(directory); + entry.watcher.close(); + } + + function startWatching(directory: string, identities: Map): void { + let watcher: FSWatcher; + + try { + watcher = watch(directory, (_eventType, fileName: string | null | undefined) => { + if (isInteresting(directory, fileName)) { + scheduleChange(); + } + }); + } catch { + // Deliberately without scheduling a change: the pass that reads these paths reports the + // directory as a read error itself, and a render from here would spin while it stays gone. + return; + } + + // A watcher that errors out is dropped rather than kept as a handle that reports nothing ever + // again; the scheduled pass lets whatever went wrong surface as a real read error. + watcher.on('error', () => { + stopWatching(directory); + scheduleChange(); + }); + + watched.set(directory, { watcher, identities }); + } + + return { + update(paths) { + if (closed) { + return; + } + + const wanted = new Map>(); + + for (const path of paths) { + const directory = dirname(path); + const fileName = basename(path); + const identities = wanted.get(directory) ?? new Map(); + + // A name that stays watched keeps its baseline: re-reading it here would swallow a save + // that landed while the pass was rendering, because its event is still on its way and + // would then find the file exactly as this line recorded it. + identities.set( + fileName, + watched.get(directory)?.identities.get(fileName) ?? identityOf(path), + ); + wanted.set(directory, identities); + } + + for (const directory of watched.keys()) { + if (!wanted.has(directory)) { + stopWatching(directory); + } + } + + for (const [directory, identities] of wanted) { + const entry = watched.get(directory); + + if (entry === undefined) { + startWatching(directory, identities); + } else { + entry.identities = identities; + } + } + }, + close() { + closed = true; + + if (pendingChange !== undefined) { + clearTimeout(pendingChange); + pendingChange = undefined; + } + + for (const { watcher } of watched.values()) { + watcher.close(); + } + + watched.clear(); + }, + }; +} diff --git a/src/cli/reload-server.ts b/src/cli/reload-server.ts new file mode 100644 index 0000000..bf3a711 --- /dev/null +++ b/src/cli/reload-server.ts @@ -0,0 +1,57 @@ +import { randomBytes } from 'node:crypto'; + +const RELOAD_TOPIC = 'reload'; + +export interface ReloadServer { + url: string; + broadcast(): void; + stop(): void; +} + +/** + * A page cannot be stopped from opening a WebSocket to a loopback port, because same-origin rules + * do not apply to it, so the random path is what keeps a foreign tab from attaching to this + * session; binding to 127.0.0.1 keeps the rest of the network out. + */ +export function startReloadServer(): ReloadServer { + const path = `/${randomBytes(16).toString('hex')}`; + + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch(request, server) { + // `new URL` would throw on a request whose Host header cannot form one, and a throwing fetch + // handler takes the whole watch session down; `URL.parse` returns null instead. + if (URL.parse(request.url)?.pathname !== path) { + return new Response('not found', { status: 404 }); + } + + if (server.upgrade(request)) { + return undefined; + } + + return new Response('expected a websocket upgrade', { status: 426 }); + }, + websocket: { + // Nothing crosses this socket between renders, and Bun drops a connection idle for 120 + // seconds; what holds a tab nobody is typing in is `sendPings`, which is on by default. + open(socket) { + socket.subscribe(RELOAD_TOPIC); + }, + message() {}, + }, + }); + + return { + // `server.port` is typed as optional because a unix socket has none; the bound url always + // carries the port that was actually assigned. + url: `ws://127.0.0.1:${server.url.port}${path}`, + broadcast() { + server.publish(RELOAD_TOPIC, 'reload'); + }, + stop() { + // Force-closing is what lets the process exit at once: an open socket keeps it alive. + server.stop(true); + }, + }; +} diff --git a/src/cli/watch.ts b/src/cli/watch.ts new file mode 100644 index 0000000..3a2b2a7 --- /dev/null +++ b/src/cli/watch.ts @@ -0,0 +1,131 @@ +import { openInBrowser } from '../browser.ts'; +import { createFileWatcher, type FileWatcher } from './file-watcher.ts'; +import type { Logger } from './logger.ts'; +import { type ReloadServer, startReloadServer } from './reload-server.ts'; + +export interface WatchRenderResult { + previewUrl: string; + /** Every file whose change has to trigger the next render. */ + renderedRealPaths: readonly string[]; +} + +export interface WatchOptions { + /** Renders the document and everything it links to, with the reload client pointed at the url. */ + render(reloadUrl: string): Promise; + logger: Logger; + /** Ends the session; the caller wires it to SIGINT and SIGTERM. */ + signal: AbortSignal; + /** Runs once the first preview is on screen, for anything that would clutter the startup. */ + onFirstRender(): void; + openBrowser?: (url: string) => Promise; + startServer?: () => ReloadServer; + debounceMilliseconds?: number; +} + +function untilAborted(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); +} + +/** + * Renders once, opens the browser, and then keeps re-rendering until the signal fires, telling the + * open tab to reload after every pass. + * + * A failing first render throws, because a watch session with no preview to show has nothing to + * offer. Every later failure only prints: the last good preview stays on screen and the watch set + * stays armed, so saving the file again recovers the session. + */ +export async function runWatch({ + render, + logger, + signal, + onFirstRender, + openBrowser = openInBrowser, + startServer = startReloadServer, + debounceMilliseconds, +}: WatchOptions): Promise { + const server = startServer(); + let watcher: FileWatcher | undefined; + let rendering = false; + let pending = false; + + const renderAndBroadcast = async (): Promise => { + try { + const rendered = await render(server.url); + + // The session can end while a render runs: by then the server is stopped and the watcher + // closed, so there is nobody left to tell and nothing left to arm. + if (signal.aborted) { + return; + } + + watcher?.update(rendered.renderedRealPaths); + server.broadcast(); + logger.success('re-rendered'); + } catch (error) { + if (!signal.aborted) { + logger.error(error instanceof Error ? error.message : String(error)); + } + } + }; + + const rerender = async (): Promise => { + if (rendering) { + // Exactly one follow-up, however many changes land while a render runs: they will all be in + // the files by the time the next pass reads them. + pending = true; + + return; + } + + rendering = true; + + try { + do { + pending = false; + await renderAndBroadcast(); + } while (pending && !signal.aborted); + } finally { + rendering = false; + } + }; + + try { + const first = await render(server.url); + + // The first render of a large document takes seconds; an abort landing in that window must not + // still open a tab for a session that is already over. + if (signal.aborted) { + return; + } + + if (await openBrowser(first.previewUrl)) { + logger.success(first.previewUrl); + } else { + // Not the exit 3 of a one-shot render: opening the url by hand is all it takes, and from + // then on the reload channel works exactly as it would have. + logger.error(`could not open a browser; the preview is at ${first.previewUrl}`); + } + + onFirstRender(); + + watcher = createFileWatcher(() => { + void rerender(); + }, debounceMilliseconds); + watcher.update(first.renderedRealPaths); + + logger.info('watching for changes, press Ctrl+C to stop'); + + await untilAborted(signal); + } finally { + // A render still in flight is left alone: every file it writes is written atomically, so the + // worst it can leave behind is a preview one save out of date. + watcher?.close(); + server.stop(); + } +} diff --git a/src/html/reload-client.ts b/src/html/reload-client.ts new file mode 100644 index 0000000..f7aa430 --- /dev/null +++ b/src/html/reload-client.ts @@ -0,0 +1,52 @@ +const RECONNECT_DELAY_MILLISECONDS = 1000; +// Capped so a tab left open goes quiet roughly 25 seconds after mat exits, instead of reconnecting +// for as long as it stays open. +const MAX_RECONNECT_ATTEMPTS = 25; + +/** + * The url lands in a JavaScript string literal inside a classic script, so it is escaped for both + * contexts: JSON for the literal, and a unicode escape for every `<`, because a literal ` +(() => { + const url = ${escapeUrlLiteral(url)}; + let attempts = 0; + + const connect = () => { + try { + const socket = new WebSocket(url); + + socket.addEventListener('open', () => { + attempts = 0; + }); + + socket.addEventListener('message', (event) => { + if (event.data === 'reload') { + location.reload(); + } + }); + + socket.addEventListener('close', () => { + if (attempts < ${MAX_RECONNECT_ATTEMPTS}) { + attempts += 1; + setTimeout(connect, ${RECONNECT_DELAY_MILLISECONDS}); + } + }); + } catch {} + }; + + connect(); +})(); +`; +} diff --git a/src/render/index.ts b/src/render/index.ts index 53564bb..cb40380 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -2,6 +2,7 @@ import { VFile } from 'vfile'; import { DEFAULT_FLAVOR_NAME, type Flavor, flavorNames, getFlavor } from '../flavors/index.ts'; import type { ThemeName } from '../generated/assets.ts'; import { collectPageAssets } from '../html/page-assets.ts'; +import { reloadClientTag } from '../html/reload-client.ts'; import { buildDocument } from '../html/template.ts'; import { getProcessor, type RenderContext } from './pipeline.ts'; @@ -9,6 +10,7 @@ export interface RenderOptions extends RenderContext { title: string; theme: ThemeName; flavor?: string; + reload?: { url: string }; } export interface RenderResult { @@ -53,6 +55,11 @@ export async function render(markdown: string, options: RenderOptions): Promise< usesMath: body.includes('class="katex'), }); + // Kept out of `matContext`: the pipeline has no business knowing about the reload channel. + if (options.reload !== undefined) { + assets.scripts.push(reloadClientTag(options.reload.url)); + } + return { html: buildDocument({ title: options.title, diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 48e7e4f..8694b86 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -149,6 +149,7 @@ describe('arguments', () => { baseDir: undefined, // Not false: only "not given" lets a configuration file supply the default. followLinks: undefined, + watch: undefined, }); }); @@ -161,6 +162,20 @@ describe('arguments', () => { } }); + test('accepts both spellings of watch', async () => { + for (const args of [ + ['note.md', '--watch'], + ['note.md', '-w'], + ]) { + expect(await parse(args)).toMatchObject({ watch: true }); + } + }); + + test('accepts an explicit watch value', async () => { + expect(await parse(['note.md', '--watch=false'])).toMatchObject({ watch: false }); + expect(await parse(['note.md', '--watch=true'])).toMatchObject({ watch: true }); + }); + test('accepts an explicit follow-links value', async () => { expect(await parse(['note.md', '--follow-links=false'])).toMatchObject({ followLinks: false }); expect(await parse(['note.md', '--follow-links=true'])).toMatchObject({ followLinks: true }); @@ -213,6 +228,25 @@ describe('arguments', () => { ['a.md', '-f', '--output', 'a.html'], '--follow-links is only valid', ], + // `--output` writes once and opens nothing, so there is no tab to reload. + [ + 'watch with an output file', + ['a.md', '--watch', '--output', 'a.html'], + '--watch is only valid without --output', + ], + [ + 'the short watch flag with an output file', + ['a.md', '-w', '--output', '-'], + '--watch is only valid without --output', + ], + // A pipe is read once and has no path to watch. + ['watch with stdin', ['-', '--watch'], '--watch is only valid without -'], + ['a malformed watch value', ['a.md', '--watch=maybe'], 'expected value'], + [ + 'watch with stdin and a base directory', + ['-', '-w', '--base-dir=/tmp'], + '--watch is only valid without -', + ], ]; for (const [name, args, expected] of rejected) { diff --git a/tests/config.test.ts b/tests/config.test.ts index a8982ae..82345f7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -78,18 +78,22 @@ describe('loadConfiguration', () => { expect(loadConfiguration(configFile('{}'))).toEqual({}); }); - test('reads both keys', () => { - const path = configFile('{"defaultDocuments": ["NOTES.md", "docs/x.md"], "followLinks": true}'); + test('reads every key', () => { + const path = configFile( + '{"defaultDocuments": ["NOTES.md", "docs/x.md"], "followLinks": true, "watch": true}', + ); expect(loadConfiguration(path)).toEqual({ defaultDocuments: ['NOTES.md', 'docs/x.md'], followLinks: true, + watch: true, }); }); - test('reads followLinks set to false', () => { - expect(loadConfiguration(configFile('{"followLinks": false}'))).toEqual({ + test('reads the flags set to false', () => { + expect(loadConfiguration(configFile('{"followLinks": false, "watch": false}'))).toEqual({ followLinks: false, + watch: false, }); }); @@ -101,6 +105,7 @@ describe('loadConfiguration', () => { ['a string root', '"README.md"', 'not a JSON object'], ['an unknown key', '{"followLink": true}', 'unknown key "followLink"'], ['a non-boolean followLinks', '{"followLinks": "yes"}', 'followLinks: expected true or false'], + ['a non-boolean watch', '{"watch": "yes"}', 'watch: expected true or false'], [ 'a non-array defaultDocuments', '{"defaultDocuments": "README.md"}', diff --git a/tests/file-watcher.test.ts b/tests/file-watcher.test.ts new file mode 100644 index 0000000..8cac0d7 --- /dev/null +++ b/tests/file-watcher.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createFileWatcher, type FileWatcher } from '../src/cli/file-watcher.ts'; + +const DEBOUNCE_MILLISECONDS = 20; +const SLOW_DEBOUNCE_MILLISECONDS = 500; +const QUIET_MILLISECONDS = 250; +const DEADLINE_MILLISECONDS = 5000; +const POLL_INTERVAL_MILLISECONDS = 5; + +let scratch: string; +const openWatchers: FileWatcher[] = []; + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), 'mat-file-watcher-')); +}); + +afterEach(() => { + for (const watcher of openWatchers) { + watcher.close(); + } + + openWatchers.length = 0; + rmSync(scratch, { recursive: true, force: true }); +}); + +async function waitUntil( + condition: () => boolean, + timeoutMilliseconds = DEADLINE_MILLISECONDS, +): Promise { + const deadline = Date.now() + timeoutMilliseconds; + + while (!condition() && Date.now() < deadline) { + await Bun.sleep(POLL_INTERVAL_MILLISECONDS); + } +} + +function startWatching(paths: readonly string[], debounceMilliseconds = DEBOUNCE_MILLISECONDS) { + let changes = 0; + + const watcher = createFileWatcher(() => { + changes += 1; + }, debounceMilliseconds); + + openWatchers.push(watcher); + watcher.update(paths); + + return { + watcher, + changeCount: () => changes, + /** + * Waits for the expected calls and then for a quiet window in which a straggler would still + * arrive. How many raw events one write produces differs between macOS and Linux, so only a + * count taken after the debounce has settled says anything. + */ + async settledChangeCount( + expected: number, + quietMilliseconds = QUIET_MILLISECONDS, + ): Promise { + await waitUntil(() => changes >= expected); + await waitUntil(() => changes > expected, quietMilliseconds); + + return changes; + }, + }; +} + +describe('file watcher', () => { + test('reports a single change when a watched file is rewritten', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const session = startWatching([file]); + + writeFileSync(file, '# second'); + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('coalesces a burst of writes into a single change', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const session = startWatching([file]); + + for (let revision = 0; revision < 5; revision += 1) { + writeFileSync(file, `# revision ${revision}`); + } + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('reports a change when an atomic save renames a sibling over the file', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const session = startWatching([file]); + const staging = join(scratch, 'index.md.tmp'); + + writeFileSync(staging, '# second'); + renameSync(staging, file); + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('reports an atomic save that lands before update refreshes the watched set', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const session = startWatching([file]); + const staging = join(scratch, 'index.md.tmp'); + + writeFileSync(staging, '# second'); + renameSync(staging, file); + // The re-arm a finished render performs. It runs before the event for the save is delivered, + // and the save is only visible as a replaced inode, so a baseline taken here would hide it. + session.watcher.update([file]); + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('reports a change when a watched file is deleted and recreated', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const session = startWatching([file]); + + rmSync(file); + writeFileSync(file, '# second'); + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('reports nothing when an unrelated file in the same directory is written', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const session = startWatching([file]); + + writeFileSync(join(scratch, 'notes.md'), '# unrelated'); + + expect(await session.settledChangeCount(0)).toBe(0); + }); + + test('reports nothing for an unrelated sibling written after the watched file changed', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const session = startWatching([file]); + + writeFileSync(file, '# second'); + + expect(await session.settledChangeCount(1)).toBe(1); + + // The re-arm a finished render performs. It keeps the baselines, so the event for the edit + // above has to have refreshed them itself, or the sibling below inherits its replacement. + session.watcher.update([file]); + + writeFileSync(join(scratch, 'notes.md'), '# unrelated'); + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('coalesces changes to files in two directories into a single change', async () => { + const first = join(scratch, 'first'); + const second = join(scratch, 'second'); + mkdirSync(first); + mkdirSync(second); + + const readme = join(first, 'readme.md'); + const guide = join(second, 'guide.md'); + writeFileSync(readme, '# first'); + writeFileSync(guide, '# first'); + + const session = startWatching([readme, guide]); + + writeFileSync(readme, '# second'); + writeFileSync(guide, '# second'); + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('reports the added file and no longer the dropped one after update', async () => { + const first = join(scratch, 'first'); + const second = join(scratch, 'second'); + mkdirSync(first); + mkdirSync(second); + + const dropped = join(first, 'dropped.md'); + const added = join(second, 'added.md'); + writeFileSync(dropped, '# first'); + writeFileSync(added, '# first'); + + const session = startWatching([dropped]); + session.watcher.update([added]); + + writeFileSync(dropped, '# second'); + + expect(await session.settledChangeCount(0)).toBe(0); + + writeFileSync(added, '# second'); + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('reports a change to a file that update added to an already watched directory', async () => { + const readme = join(scratch, 'readme.md'); + const guide = join(scratch, 'guide.md'); + writeFileSync(readme, '# first'); + writeFileSync(guide, '# first'); + + const session = startWatching([readme]); + session.watcher.update([readme, guide]); + + writeFileSync(guide, '# second'); + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('reports nothing for a file that update dropped from a directory it keeps', async () => { + const readme = join(scratch, 'readme.md'); + const guide = join(scratch, 'guide.md'); + writeFileSync(readme, '# first'); + writeFileSync(guide, '# first'); + + const session = startWatching([readme, guide]); + session.watcher.update([readme]); + + writeFileSync(guide, '# second'); + + expect(await session.settledChangeCount(0)).toBe(0); + }); + + test('reports nothing when a watched path sits in a directory that cannot be watched', async () => { + const file = join(scratch, 'missing', 'index.md'); + + const session = startWatching([file]); + + mkdirSync(dirname(file)); + writeFileSync(file, '# first'); + + // A change reported from a directory that could not be watched would spin the render loop: + // every pass would fail on the same unreadable path and immediately ask for the next one. + expect(await session.settledChangeCount(0)).toBe(0); + }); + + test('keeps reporting changes when update repeats the same set', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const session = startWatching([file]); + session.watcher.update([file]); + + writeFileSync(file, '# second'); + + expect(await session.settledChangeCount(1)).toBe(1); + }); + + test('reports nothing when a watched file is written after close', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const session = startWatching([file]); + session.watcher.close(); + + writeFileSync(file, '# second'); + + expect(await session.settledChangeCount(0)).toBe(0); + }); + + test('reports nothing when close arrives while a change is still debounced', async () => { + const file = join(scratch, 'index.md'); + writeFileSync(file, '# first'); + + const slow = startWatching([file], SLOW_DEBOUNCE_MILLISECONDS); + // A second watcher on the same file with the short debounce: once it has reported, the event + // has been delivered, so the slow one is sitting on a timer that close must clear. + const probe = startWatching([file]); + + writeFileSync(file, '# second'); + await waitUntil(() => probe.changeCount() > 0); + + slow.watcher.close(); + + expect(await slow.settledChangeCount(0, SLOW_DEBOUNCE_MILLISECONDS * 2)).toBe(0); + }); +}); diff --git a/tests/fixtures/help.txt b/tests/fixtures/help.txt index dacdd8a..3bd960f 100644 --- a/tests/fixtures/help.txt +++ b/tests/fixtures/help.txt @@ -12,6 +12,7 @@ OPTIONS: FLAGS: --follow-links, -f - Also render linked local Markdown files and point their links at the previews. =false overrides the configuration file. [optional] + --watch, -w - Re-render whenever a rendered file changes and reload the browser tab, until Ctrl+C. =false overrides the configuration file. [optional] --help, -h - show help [optional] --version, -v - print the version [optional] @@ -29,5 +30,8 @@ EXAMPLES: Also render every local Markdown file the document links to $ mat README.md -f + Keep re-rendering and reload the browser on every change + $ mat README.md -w + Write a self-contained file and open nothing $ mat README.md --output readme.html diff --git a/tests/reload-server.test.ts b/tests/reload-server.test.ts new file mode 100644 index 0000000..bbb6f3d --- /dev/null +++ b/tests/reload-server.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { type ReloadServer, startReloadServer } from '../src/cli/reload-server.ts'; + +const POLL_INTERVAL_MILLISECONDS = 10; +const DEADLINE_MILLISECONDS = 5000; + +interface Client { + socket: WebSocket; + messages: string[]; + opened: boolean; +} + +const servers: ReloadServer[] = []; +const clients: Client[] = []; + +afterEach(() => { + for (const client of clients) { + client.socket.close(); + } + + for (const server of servers) { + server.stop(); + } + + clients.length = 0; + servers.length = 0; +}); + +function startServer(): ReloadServer { + const server = startReloadServer(); + servers.push(server); + + return server; +} + +async function waitUntil(condition: () => boolean, expectation: string): Promise { + const deadline = Date.now() + DEADLINE_MILLISECONDS; + + while (!condition()) { + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for ${expectation}`); + } + + await Bun.sleep(POLL_INTERVAL_MILLISECONDS); + } +} + +function connect(url: string): Client { + const client: Client = { socket: new WebSocket(url), messages: [], opened: false }; + + client.socket.addEventListener('open', () => { + client.opened = true; + }); + client.socket.addEventListener('message', (event) => { + client.messages.push(String(event.data)); + }); + + clients.push(client); + + return client; +} + +async function connectAndWait(url: string): Promise { + const client = connect(url); + await waitUntil(() => client.opened, `the client to connect to ${url}`); + + return client; +} + +async function expectRefused(client: Client): Promise { + await waitUntil( + () => client.socket.readyState === WebSocket.CLOSED, + 'the connection to be refused', + ); + + expect(client.opened).toBe(false); +} + +describe('reload server', () => { + test('exposes a loopback url with a bound port and a hex token as its path', () => { + const match = /^ws:\/\/127\.0\.0\.1:(\d+)\/[0-9a-f]{32}$/.exec(startServer().url); + + expect(match).not.toBeNull(); + expect(Number(match?.[1])).toBeGreaterThan(0); + }); + + test('delivers a broadcast to a connected client', async () => { + const server = startServer(); + const client = await connectAndWait(server.url); + + server.broadcast(); + + await waitUntil(() => client.messages.includes('reload'), 'the client to be reloaded'); + }); + + test('delivers a broadcast to every connected client, not just the first', async () => { + const server = startServer(); + const first = await connectAndWait(server.url); + const second = await connectAndWait(server.url); + + server.broadcast(); + + await waitUntil( + () => first.messages.includes('reload') && second.messages.includes('reload'), + 'both clients to be reloaded', + ); + }); + + test('refuses a connection on a path other than the token and answers it with 404', async () => { + const { host } = new URL(startServer().url); + // Shaped like a token, so the refusal proves the token itself is compared. + const wrongPath = '/0123456789abcdef0123456789abcdef'; + + await expectRefused(connect(`ws://${host}${wrongPath}`)); + + const response = await fetch(`http://${host}${wrongPath}`); + + expect(response.status).toBe(404); + }); + + test('answers a plain http request on the token path with 426, not an upgrade', async () => { + const { host, pathname } = new URL(startServer().url); + + const response = await fetch(`http://${host}${pathname}`); + + expect(response.status).toBe(426); + }); + + test('answers a request whose host is not a valid url with 404 instead of dying', async () => { + // A fetch handler that throws takes the whole watch session with it. The malformed request has + // to be written by hand, because `fetch` refuses to send one. + const { hostname, port } = new URL(startServer().url); + let reportAnswer: ((text: string) => void) | undefined; + const answer = new Promise((resolve) => { + reportAnswer = resolve; + }); + + const socket = await Bun.connect({ + hostname, + port: Number(port), + socket: { + open(connected) { + connected.write('GET / HTTP/1.1\r\nHost: a b\r\n\r\n'); + }, + data(_connected, chunk) { + reportAnswer?.(new TextDecoder().decode(chunk)); + }, + }, + }); + + try { + expect(await answer).toContain('404'); + } finally { + socket.end(); + } + }); + + test('uses a token of its own for every server', () => { + expect(new URL(startServer().url).pathname).not.toBe(new URL(startServer().url).pathname); + }); + + test('drops attached clients on stop and refuses new ones afterwards', async () => { + const server = startServer(); + const attached = await connectAndWait(server.url); + + server.stop(); + + await waitUntil( + () => attached.socket.readyState === WebSocket.CLOSED, + 'the attached client to be dropped', + ); + await expectRefused(connect(server.url)); + }); + + test('leaves nothing running after a stop, so the process exits on its own', async () => { + // Only a separate process can show this: a timer or a socket that outlives `stop` is + // invisible from inside, but would keep `mat --watch` from ever returning from Ctrl+C. + const moduleUrl = new URL('../src/cli/reload-server.ts', import.meta.url).href; + const child = Bun.spawn( + [ + process.execPath, + '-e', + `const { startReloadServer } = await import(${JSON.stringify(moduleUrl)}); + const server = startReloadServer(); + const socket = new WebSocket(server.url); + await new Promise((resolve) => socket.addEventListener('open', resolve)); + server.stop();`, + ], + { stdout: 'ignore', stderr: 'inherit' }, + ); + + try { + await waitUntil(() => child.exitCode !== null, 'the process to exit by itself'); + } finally { + child.kill(); + } + + expect(child.exitCode).toBe(0); + }); +}); diff --git a/tests/render.test.ts b/tests/render.test.ts index 5bf4bd3..4f764cf 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -86,4 +86,197 @@ describe('render', () => { test('is deterministic', async () => { expect(await renderHtml('# Same')).toBe(await renderHtml('# Same')); }); + + describe('reload client', () => { + const reloadUrl = 'ws://127.0.0.1:4711/reload'; + + function scriptTagCount(html: string): number { + return html.match(/` }, + }); + + expect(scriptTagCount(watched)).toBe(scriptTagCount(plain) + 1); + expect(watched).not.toContain('type="module"'); + expect(watched).not.toContain('`; + const client = runClientFrom(await renderHtml('# Hello', { reload: { url: hostileUrl } })); + + expect(client.currentUrl()).toBe(hostileUrl); + }); + + test('leaves the page without any socket code when reload is not set', async () => { + expect(await renderHtml('# Hello')).not.toContain('WebSocket'); + }); + + test('reloads on the reload message and ignores every other message', async () => { + const client = runClientFrom(await renderHtml('# Hello', { reload: { url: reloadUrl } })); + + expect(client.currentUrl()).toBe(reloadUrl); + + client.receive('ping'); + + expect(client.reloadCount()).toBe(0); + + client.receive('reload'); + + expect(client.reloadCount()).toBe(1); + }); + + test('waits a second before reconnecting', async () => { + const client = runClientFrom(await renderHtml('# Hello', { reload: { url: reloadUrl } })); + + // Reconnecting without a pause would spin a closed tab against a dead port as fast as the + // browser allows. + expect(client.closeAndReconnect()).toBe(1000); + }); + + test('stops reconnecting after twenty-five closes without a connection in between', async () => { + const client = runClientFrom(await renderHtml('# Hello', { reload: { url: reloadUrl } })); + + for (let attempt = 0; attempt < 25; attempt += 1) { + expect(client.closeAndReconnect()).toBeDefined(); + } + + expect(client.closeAndReconnect()).toBeUndefined(); + expect(client.socketCount()).toBe(26); + }); + + test('reconnects past the cap once a connection has opened', async () => { + const client = runClientFrom(await renderHtml('# Hello', { reload: { url: reloadUrl } })); + + for (let attempt = 0; attempt < 25; attempt += 1) { + expect(client.closeAndReconnect()).toBeDefined(); + } + + client.open(); + + expect(client.closeAndReconnect()).toBeDefined(); + }); + + test('reloads on a reload message from the socket a reconnect opened', async () => { + const client = runClientFrom(await renderHtml('# Hello', { reload: { url: reloadUrl } })); + + expect(client.closeAndReconnect()).toBeDefined(); + + client.receive('reload'); + + expect(client.reloadCount()).toBe(1); + }); + }); }); diff --git a/tests/watch-cli.test.ts b/tests/watch-cli.test.ts new file mode 100644 index 0000000..42aa5a3 --- /dev/null +++ b/tests/watch-cli.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CLI = join(import.meta.dir, '..', 'src', 'cli.ts'); + +// Same reasoning as in cli.test.ts: every case starts a fresh Bun that transpiles mat's whole +// module graph, and on a cold cache that has been measured past a minute. +const SPAWN_TIMEOUT = 180_000; +const DEADLINE_MILLISECONDS = 60_000; +const POLL_INTERVAL_MILLISECONDS = 25; +// The watcher's 200 ms debounce plus room for a render: a shorter window would let every negative +// case pass by simply looking too early. +const QUIET_MILLISECONDS = 3000; + +interface WatchProcess { + stderr(): string; + /** Resolves with the exit code, for the cases where mat is expected to end on its own. */ + exited: Promise; + /** Sends the signal and resolves with the exit code mat chose for itself. */ + stop(signal: NodeJS.Signals): Promise; + kill(): Promise; +} + +let scratch: string; +let watchCounter = 0; +const running: WatchProcess[] = []; + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), 'mat-watch-test-')); +}); + +afterEach(async () => { + // A case that failed mid-way leaves its session running, and a `mat --watch` never ends on its + // own; without this the test process would hang on the surviving children. + for (const watch of running) { + await watch.kill(); + } + + running.length = 0; + rmSync(scratch, { recursive: true, force: true }); +}); + +/** + * Deliberately does not await `child.exited`: a watch session only ends when it is told to, so the + * caller drives it from the outside and reads stderr as it is written. + * + * The empty `PATH` leaves the child with no browser launcher, which is also the test of that + * decision: watching has to continue after a failed launch, and the printed url is what the rest + * of every case works with. + */ +function spawnWatch(args: readonly string[], cwd: string): WatchProcess { + const emptyPath = join(scratch, 'no-launchers'); + mkdirSync(emptyPath, { recursive: true }); + + const id = watchCounter++; + const stdoutPath = join(scratch, `stdout-${id}`); + const stderrPath = join(scratch, `stderr-${id}`); + + const child = Bun.spawn([process.execPath, 'run', CLI, ...args], { + env: { + ...process.env, + TMPDIR: scratch, + PATH: emptyPath, + XDG_CONFIG_HOME: join(scratch, 'config-home'), + }, + cwd, + stdin: 'ignore', + stdout: Bun.file(stdoutPath), + stderr: Bun.file(stderrPath), + }); + + const watch: WatchProcess = { + stderr() { + try { + return readFileSync(stderrPath, 'utf8'); + } catch { + return ''; + } + }, + exited: child.exited, + stop(signal) { + child.kill(signal); + + return child.exited; + }, + async kill() { + child.kill('SIGKILL'); + await child.exited; + }, + }; + + running.push(watch); + + return watch; +} + +function workspace(name: string, files: Record): string { + const directory = join(scratch, name); + mkdirSync(directory, { recursive: true }); + + for (const [fileName, contents] of Object.entries(files)) { + writeFileSync(join(directory, fileName), contents); + } + + return directory; +} + +async function waitFor(condition: () => boolean, expectation: string): Promise { + const deadline = Date.now() + DEADLINE_MILLISECONDS; + + while (!condition()) { + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for ${expectation}`); + } + + await Bun.sleep(POLL_INTERVAL_MILLISECONDS); + } +} + +function renderCount(watch: WatchProcess): number { + return watch.stderr().split('re-rendered').length - 1; +} + +/** Resolves once the watch set is armed, which mat logs only after the first preview is written. */ +function waitUntilArmed(watch: WatchProcess): Promise { + return waitFor(() => watch.stderr().includes('watching for changes'), 'the watcher to be armed'); +} + +function waitForRenders(watch: WatchProcess, count: number): Promise { + return waitFor(() => renderCount(watch) >= count, `${count} re-renders`); +} + +function previewOf(watch: WatchProcess): string { + const url = /file:\/\/\S+/.exec(watch.stderr())?.[0]; + + if (url === undefined) { + throw new Error('no preview url on stderr'); + } + + return fileURLToPath(url); +} + +describe('watch session', () => { + test( + 're-renders a changed file into the same preview and exits 0 on SIGINT', + async () => { + const directory = workspace('rerender', { 'note.md': '# First' }); + const watch = spawnWatch(['note.md', '--watch'], directory); + + await waitUntilArmed(watch); + + const preview = previewOf(watch); + + expect(readFileSync(preview, 'utf8')).toContain('

'); + + writeFileSync(join(directory, 'note.md'), '# Second'); + await waitForRenders(watch, 1); + + expect(readFileSync(preview, 'utf8')).toContain('

'); + expect(await watch.stop('SIGINT')).toBe(0); + }, + SPAWN_TIMEOUT, + ); + + test( + 'exits 0 on SIGTERM as well', + async () => { + const directory = workspace('sigterm', { 'note.md': '# First' }); + const watch = spawnWatch(['note.md', '--watch'], directory); + + await waitUntilArmed(watch); + + expect(await watch.stop('SIGTERM')).toBe(0); + }, + SPAWN_TIMEOUT, + ); + + test( + 'keeps the last good preview when a render fails and recovers on the next save', + async () => { + const directory = workspace('recovery', { 'note.md': '# First' }); + const watch = spawnWatch(['note.md', '--watch'], directory); + + await waitUntilArmed(watch); + + const preview = previewOf(watch); + + rmSync(join(directory, 'note.md')); + await waitFor(() => watch.stderr().includes('no such file'), 'the read error'); + + expect(readFileSync(preview, 'utf8')).toContain('

'); + expect(renderCount(watch)).toBe(0); + + writeFileSync(join(directory, 'note.md'), '# Second'); + await waitForRenders(watch, 1); + + expect(readFileSync(preview, 'utf8')).toContain('

'); + expect(await watch.stop('SIGINT')).toBe(0); + }, + SPAWN_TIMEOUT, + ); + + test( + 'gives a linked preview the same reload channel and drops it once the link is gone', + async () => { + const directory = workspace('follow', { + 'a.md': '# A\n\n[b](b.md)', + 'b.md': '# B first', + }); + const watch = spawnWatch(['a.md', '-f', '--watch'], directory); + + await waitUntilArmed(watch); + + const root = readFileSync(previewOf(watch), 'utf8'); + const reloadUrl = /ws:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{32}/.exec(root)?.[0]; + const linkedHref = /href="(file:\/\/[^"]+)"/.exec(root)?.[1]; + + if (reloadUrl === undefined || linkedHref === undefined) { + throw new Error('the root preview carries no reload url, or no link to a preview'); + } + + // Reached through the href the reader would click, so this is the page they end up on. + expect(readFileSync(fileURLToPath(linkedHref), 'utf8')).toContain(reloadUrl); + + writeFileSync(join(directory, 'b.md'), '# B second'); + await waitForRenders(watch, 1); + + writeFileSync(join(directory, 'a.md'), '# A alone'); + await waitForRenders(watch, 2); + + writeFileSync(join(directory, 'b.md'), '# B third'); + // Nothing to wait for, so the assertion needs a window long enough that a render would have + // been logged by now. + await Bun.sleep(QUIET_MILLISECONDS); + + expect(renderCount(watch)).toBe(2); + expect(await watch.stop('SIGINT')).toBe(0); + }, + SPAWN_TIMEOUT, + ); + + test( + 'stays on the default document it started with when a higher-priority one appears', + async () => { + const directory = workspace('pinned', { 'README.md': '# From README' }); + const watch = spawnWatch(['--watch'], directory); + + await waitUntilArmed(watch); + + const preview = previewOf(watch); + + writeFileSync(join(directory, 'index.md'), '# From index'); + writeFileSync(join(directory, 'README.md'), '# From README again'); + await waitForRenders(watch, 1); + + expect(readFileSync(preview, 'utf8')).toContain('

'); + expect(await watch.stop('SIGINT')).toBe(0); + }, + SPAWN_TIMEOUT, + ); +}); + +describe('a configured watch', () => { + // The path `spawnWatch` hands every child as `XDG_CONFIG_HOME`. + function writeConfiguration(contents: string): void { + const directory = join(scratch, 'config-home', 'mat'); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, 'config.json'), contents); + } + + test( + 'starts a session without the flag', + async () => { + const directory = workspace('configured', { 'note.md': '# First' }); + writeConfiguration('{"watch": true}'); + + const watch = spawnWatch(['note.md'], directory); + + await waitUntilArmed(watch); + + writeFileSync(join(directory, 'note.md'), '# Second'); + await waitForRenders(watch, 1); + + expect(readFileSync(previewOf(watch), 'utf8')).toContain('

'); + expect(await watch.stop('SIGINT')).toBe(0); + }, + SPAWN_TIMEOUT, + ); + + // Exit 3 is the no-browser path of this suite, and reaching any exit at all is the point: a + // session would still be running instead. + const suppressed: ReadonlyArray<[string, readonly string[], number]> = [ + ['--watch=false turns it back off', ['note.md', '--watch=false'], 3], + ['--output suppresses it instead of being rejected', ['note.md', '--output', '-'], 0], + ['stdin suppresses it, having no path to watch', ['-'], 3], + ]; + + for (const [name, args, code] of suppressed) { + test( + name, + async () => { + const directory = workspace(`suppressed-${args.length}-${code}`, { 'note.md': '# First' }); + writeConfiguration('{"watch": true}'); + + const watch = spawnWatch(args, directory); + + expect(await watch.exited).toBe(code); + expect(watch.stderr()).not.toContain('watching for changes'); + }, + SPAWN_TIMEOUT, + ); + } +}); diff --git a/tests/watch-e2e.test.ts b/tests/watch-e2e.test.ts new file mode 100644 index 0000000..35f3843 --- /dev/null +++ b/tests/watch-e2e.test.ts @@ -0,0 +1,111 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { type Browser, chromium, type Page } from 'playwright'; + +const CLI = join(import.meta.dir, '..', 'src', 'cli.ts'); +const STARTUP_TIMEOUT_MILLISECONDS = 120_000; +const RELOAD_TIMEOUT_MILLISECONDS = 30_000; +const POLL_INTERVAL_MILLISECONDS = 25; + +let scratch: string; +let notePath: string; +let watch: Bun.Subprocess; +let browser: Browser; +let page: Page; +const socketUrls: string[] = []; +const consoleErrors: string[] = []; + +function readStderr(stderrPath: string): string { + try { + return readFileSync(stderrPath, 'utf8'); + } catch { + return ''; + } +} + +beforeAll(async () => { + scratch = mkdtempSync(join(tmpdir(), 'mat-watch-e2e-')); + + const directory = join(scratch, 'workspace'); + const emptyPath = join(scratch, 'no-launchers'); + mkdirSync(directory, { recursive: true }); + mkdirSync(emptyPath, { recursive: true }); + + notePath = join(directory, 'note.md'); + writeFileSync(notePath, '# First'); + + const stderrPath = join(scratch, 'stderr'); + + // The empty `PATH` keeps mat from opening a browser of its own; this test drives the one it + // controls, against the url mat prints instead. + watch = Bun.spawn([process.execPath, 'run', CLI, 'note.md', '--watch'], { + env: { + ...process.env, + TMPDIR: scratch, + PATH: emptyPath, + XDG_CONFIG_HOME: join(scratch, 'config-home'), + }, + cwd: directory, + stdin: 'ignore', + stdout: Bun.file(join(scratch, 'stdout')), + stderr: Bun.file(stderrPath), + }); + + const deadline = Date.now() + STARTUP_TIMEOUT_MILLISECONDS; + + while (!readStderr(stderrPath).includes('watching for changes')) { + if (Date.now() >= deadline) { + throw new Error(`mat never armed its watcher; stderr was:\n${readStderr(stderrPath)}`); + } + + await Bun.sleep(POLL_INTERVAL_MILLISECONDS); + } + + const previewUrl = /file:\/\/\S+/.exec(readStderr(stderrPath))?.[0]; + + if (previewUrl === undefined) { + throw new Error(`no preview url on stderr:\n${readStderr(stderrPath)}`); + } + + browser = await chromium.launch(); + page = await browser.newPage(); + + page.on('websocket', (socket) => socketUrls.push(socket.url())); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + page.on('pageerror', (error) => consoleErrors.push(error.message)); + + await page.goto(previewUrl); + // The client opens its socket while the document is parsed, so by `load` the object exists; the + // wait only guards against asserting before playwright has reported it. + await page.waitForEvent('websocket', { timeout: RELOAD_TIMEOUT_MILLISECONDS }); +}, STARTUP_TIMEOUT_MILLISECONDS); + +afterAll(async () => { + await browser?.close(); + watch?.kill('SIGINT'); + await watch?.exited; + rmSync(scratch, { recursive: true, force: true }); +}); + +describe('a watched preview in the browser', () => { + test('lets the injected client reach the session from a file:// page', () => { + // A `file://` document has the opaque origin `null`, which is exactly the case this proves is + // allowed to open a loopback WebSocket. + expect(socketUrls[0]).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/[0-9a-f]{32}$/); + expect(consoleErrors).toEqual([]); + }); + + test('reloads the tab when the document changes', async () => { + writeFileSync(notePath, '# Second'); + + await page.waitForFunction(() => document.querySelector('h1')?.id === 'second', undefined, { + timeout: RELOAD_TIMEOUT_MILLISECONDS, + }); + }); +}); diff --git a/tests/watch.test.ts b/tests/watch.test.ts new file mode 100644 index 0000000..d27007d --- /dev/null +++ b/tests/watch.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createLogger } from '../src/cli/logger.ts'; +import type { ReloadServer } from '../src/cli/reload-server.ts'; +import { runWatch, type WatchRenderResult } from '../src/cli/watch.ts'; + +const DEBOUNCE_MILLISECONDS = 20; +const DEADLINE_MILLISECONDS = 5000; +const POLL_INTERVAL_MILLISECONDS = 5; +const QUIET_MILLISECONDS = 250; + +let scratch: string; +const running: Array<() => Promise> = []; + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), 'mat-watch-')); +}); + +afterEach(async () => { + for (const stop of running) { + await stop(); + } + + running.length = 0; + rmSync(scratch, { recursive: true, force: true }); +}); + +async function waitFor(condition: () => boolean, expectation: string): Promise { + const deadline = Date.now() + DEADLINE_MILLISECONDS; + + while (!condition()) { + if (Date.now() >= deadline) { + throw new Error(`timed out waiting for ${expectation}`); + } + + await Bun.sleep(POLL_INTERVAL_MILLISECONDS); + } +} + +function stubServer(): ReloadServer { + return { url: 'ws://127.0.0.1:1/token', broadcast() {}, stop() {} }; +} + +interface Session { + renders(): number; + /** True once the watch set is armed, which is the only point a write can be seen from. */ + armed(): boolean; + stop(): Promise; +} + +/** + * Drives `runWatch` with a render step the test controls, which is what makes the window below + * reachable at all: it depends on changes landing while a render is still running. + */ +function startSession(file: string, onRender: (call: number) => Promise | void): Session { + const controller = new AbortController(); + let renders = 0; + let log = ''; + + const finished = runWatch({ + logger: createLogger((text) => { + log += text; + }, false), + signal: controller.signal, + onFirstRender() {}, + openBrowser: () => Promise.resolve(true), + startServer: stubServer, + debounceMilliseconds: DEBOUNCE_MILLISECONDS, + async render(): Promise { + renders += 1; + await onRender(renders); + + return { previewUrl: 'file:///preview.html', renderedRealPaths: [file] }; + }, + }); + + const stop = async (): Promise => { + controller.abort(); + await finished; + }; + + running.push(stop); + + return { + renders: () => renders, + armed: () => log.includes('watching for changes'), + stop, + }; +} + +describe('watch session', () => { + test('collapses every change that lands during a render into one follow-up', async () => { + const file = join(scratch, 'note.md'); + writeFileSync(file, '# first'); + + let release: (() => void) | undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + + const session = startSession(file, async (call) => { + if (call === 2) { + await held; + } + }); + + await waitFor(() => session.armed(), 'the watch set to be armed'); + + writeFileSync(file, '# second'); + await waitFor(() => session.renders() >= 2, 'the render that change triggered'); + + // Far enough apart to debounce separately, so two changes really do arrive while the render + // above is still held. + writeFileSync(file, '# third'); + await Bun.sleep(DEBOUNCE_MILLISECONDS * 4); + writeFileSync(file, '# fourth'); + await Bun.sleep(DEBOUNCE_MILLISECONDS * 4); + + expect(session.renders()).toBe(2); + + release?.(); + + await waitFor(() => session.renders() >= 3, 'the queued follow-up'); + await Bun.sleep(QUIET_MILLISECONDS); + + expect(session.renders()).toBe(3); + await session.stop(); + }); +});