diff --git a/README.md b/README.md index 85da8ba..9d06d78 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ $html = $converter->convert('Hello *world*!'); - **Extensions**: Built-in extensions for external links, TOC, heading permalinks, @mentions, autolinks, default attributes - **Extensible**: Custom inline/block patterns, render events - **File support**: Parse and convert files directly +- **CLI tools**: `bin/djot` (one-shot convert) and `bin/djot-watch` (live-reload preview server) — see [CLI reference](https://php-collective.github.io/djot-php/reference/cli) ## Example diff --git a/bin/djot-watch b/bin/djot-watch new file mode 100755 index 0000000..91903c6 --- /dev/null +++ b/bin/djot-watch @@ -0,0 +1,25 @@ +#!/usr/bin/env php +run($argv)); diff --git a/composer.json b/composer.json index 90ee498..9f26c94 100644 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ } }, "bin": [ - "bin/djot" + "bin/djot", + "bin/djot-watch" ] } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 77d3c3e..b03361c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -121,3 +121,51 @@ php -S localhost:8000 -t public | 1 | General error | | 2 | File not found | | 3 | Invalid format | + +## Live Preview: `djot-watch` + +A companion long-running command that serves a live-reloading HTML preview of a `.djot` file in your browser. Useful while drafting in any editor (Vim, Helix, VS Code, Zed, Sublime — anything). + +### Usage + +```bash +./vendor/bin/djot-watch path/to/file.djot +``` + +This starts a local HTTP server on `http://127.0.0.1:8765/`, opens the URL in your default browser, and re-renders the file on every save. The browser tab refreshes automatically via Server-Sent Events. + +Press `Ctrl+C` to stop. + +### Flags + +| Flag | Description | +|------|-------------| +| `-p`, `--port PORT` | HTTP port (default `8765`; auto-bumps up to `+10` if taken). | +| `--host HOST` | Bind host (default `127.0.0.1`). | +| `--no-open` | Do not launch the browser on startup. | +| `--css FILE` | Path to a custom CSS file served at `/__assets/style.css`. | +| `-v`, `--version` | Print version. | +| `-h`, `--help` | Print help text. | + +### Custom Styling + +The watcher ships a minimal default stylesheet (system fonts, sensible spacing, dark-mode aware). Override with `--css`: + +```bash +./vendor/bin/djot-watch post.djot --css ./my-preview.css +``` + +### Editor Integration + +The watcher is editor-agnostic — it just watches the file you give it. Bind a key or task in your editor to run `./vendor/bin/djot-watch ${FILE}` so you can fire up the preview without leaving the editor. For Zed, see the [zed-djot extension README](https://github.com/php-collective/zed-djot) for a `tasks.json` snippet. + +### How It Works + +`djot-watch` boots a long-lived PHP process that: + +1. Renders your `.djot` file via the same `DjotConverter` used by `bin/djot`. +2. Spawns `php -S` on the chosen port with a small router script. +3. Polls the file for `(mtime, size)` changes every 250 ms; pushes a Server-Sent Events `reload` event when something changes. +4. Injects a tiny JS client into the served HTML that reloads on the SSE event. + +No daemon, no config file, no global state. Just the binary and your file. diff --git a/src/Watch/FileWatcher.php b/src/Watch/FileWatcher.php new file mode 100644 index 0000000..f0fc035 --- /dev/null +++ b/src/Watch/FileWatcher.php @@ -0,0 +1,65 @@ + + */ + private array $fingerprints = []; + + /** + * @param list $paths + */ + public function __construct(private readonly array $paths) + { + foreach ($this->paths as $path) { + $this->fingerprints[$path] = $this->fingerprint($path); + } + } + + public function poll(): bool + { + $changed = false; + foreach ($this->paths as $path) { + $current = $this->fingerprint($path); + if ($current !== $this->fingerprints[$path]) { + $this->fingerprints[$path] = $current; + $changed = true; + } + } + + return $changed; + } + + /** + * @return array{mtime: int, size: int, hash: string} + */ + private function fingerprint(string $path): array + { + clearstatcache(true, $path); + if (!is_file($path)) { + return ['mtime' => 0, 'size' => 0, 'hash' => '']; + } + $algo = in_array('xxh32', hash_algos(), true) ? 'xxh32' : 'md5'; + $hash = @hash_file($algo, $path); + + return [ + 'mtime' => (int)filemtime($path), + 'size' => (int)filesize($path), + 'hash' => is_string($hash) ? $hash : '', + ]; + } +} diff --git a/src/Watch/Renderer.php b/src/Watch/Renderer.php new file mode 100644 index 0000000..a6d84d6 --- /dev/null +++ b/src/Watch/Renderer.php @@ -0,0 +1,42 @@ +converter = $converter ?? new DjotConverter(); + } + + public function render(string $djot): string + { + return $this->converter->convert($djot); + } + + public function renderDocument(string $djot, ?string $cssPath): string + { + $body = $this->render($djot); + + return << + + + +Djot Preview + + + +{$body} + + + +HTML; + } +} diff --git a/src/Watch/Server.php b/src/Watch/Server.php new file mode 100644 index 0000000..84906c2 --- /dev/null +++ b/src/Watch/Server.php @@ -0,0 +1,88 @@ + $env + * + * @throws \RuntimeException + * + * @return int the port actually used + */ + public function start(string $host, int $port, string $routerPath, array $env): int + { + $actualPort = $this->pickPort($host, $port); + $docroot = dirname($routerPath); + $cmd = sprintf( + 'exec %s -S %s:%d -t %s %s', + escapeshellarg(PHP_BINARY), + escapeshellarg($host), + $actualPort, + escapeshellarg($docroot), + escapeshellarg($routerPath), + ); + + // Discard php -S stdio. The dev server's request log otherwise fills + // the pipe buffer (a few thousand requests) and the worker blocks on + // write, making the server appear hung. We don't surface those logs + // anywhere; if they're ever wanted, swap '/dev/null' for a log file. + $descriptors = [ + 0 => ['file', '/dev/null', 'r'], + 1 => ['file', '/dev/null', 'w'], + 2 => ['file', '/dev/null', 'w'], + ]; + + $envForProc = array_merge($_ENV, $env); + $proc = proc_open($cmd, $descriptors, $pipes, null, $envForProc); + if (!is_resource($proc)) { + throw new RuntimeException('Failed to start php -S'); + } + $this->process = $proc; + + return $actualPort; + } + + public function stop(): void + { + if (!is_resource($this->process)) { + return; + } + $status = proc_get_status($this->process); + if ($status['running']) { + proc_terminate($this->process, defined('SIGTERM') ? SIGTERM : 15); + } + proc_close($this->process); + $this->process = null; + } + + private function pickPort(string $host, int $port): int + { + for ($p = $port; $p < $port + 10; $p++) { + $sock = @stream_socket_server("tcp://{$host}:{$p}", $errno, $errstr); + if ($sock !== false) { + fclose($sock); + + return $p; + } + } + + throw new RuntimeException(sprintf('No free port found in range %d-%d', $port, $port + 9)); + } +} diff --git a/src/Watch/SseChannel.php b/src/Watch/SseChannel.php new file mode 100644 index 0000000..ab7d0a8 --- /dev/null +++ b/src/Watch/SseChannel.php @@ -0,0 +1,32 @@ +statePath)) { + file_put_contents($this->statePath, '0'); + } + } + + public function current(): int + { + clearstatcache(true, $this->statePath); + $raw = @file_get_contents($this->statePath); + if ($raw === false) { + return 0; + } + + return (int)trim($raw); + } + + public function bump(): void + { + $next = $this->current() + 1; + file_put_contents($this->statePath, (string)$next, LOCK_EX); + } +} diff --git a/src/Watch/Watcher.php b/src/Watch/Watcher.php new file mode 100644 index 0000000..a3c8dd6 --- /dev/null +++ b/src/Watch/Watcher.php @@ -0,0 +1,232 @@ + $argv + */ + public function run(array $argv): int + { + try { + $opts = $this->parseArgs($argv); + } catch (RuntimeException $e) { + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + + return 64; + } + + if ($opts['help']) { + $this->printHelp(); + + return 0; + } + if ($opts['version']) { + echo "djot-watch 0.1.0\n"; + + return 0; + } + if ($opts['target'] === null) { + fwrite(STDERR, "Error: no target file given. Try `djot-watch --help`.\n"); + + return 64; + } + if (!is_file($opts['target'])) { + fwrite(STDERR, "Error: target file does not exist: {$opts['target']}\n"); + + return 66; + } + + $statePath = (string)tempnam(sys_get_temp_dir(), 'djot_watch_'); + if ($statePath === '') { + fwrite(STDERR, "Error: could not create state file\n"); + + return 70; + } + + $channel = new SseChannel($statePath); + $fileWatcher = new FileWatcher([$opts['target']]); + $server = new Server(); + + $port = $server->start( + $opts['host'], + $opts['port'], + __DIR__ . '/router.php', + [ + 'DJOT_WATCH_TARGET' => $opts['target'], + 'DJOT_WATCH_STATE' => $statePath, + 'DJOT_WATCH_CSS' => $opts['css'] ?? '', + ], + ); + + $url = "http://{$opts['host']}:{$port}/"; + fwrite(STDOUT, "djot-watch serving {$opts['target']} at {$url}\n"); + fwrite(STDOUT, "Press Ctrl+C to stop.\n"); + + if (!$opts['no_open']) { + $this->openBrowser($url); + } + + $stopping = false; + if (function_exists('pcntl_async_signals')) { + pcntl_async_signals(true); + $handler = static function () use (&$stopping): void { + $stopping = true; + }; + pcntl_signal(SIGINT, $handler); + pcntl_signal(SIGTERM, $handler); + } + + try { + while (!$stopping) { + if ($fileWatcher->poll()) { + $channel->bump(); + fwrite(STDOUT, 'reload (' . $channel->current() . ")\n"); + } + usleep(self::POLL_INTERVAL_US); + } + } finally { + $server->stop(); + @unlink($statePath); + } + + return 0; + } + + /** + * @param list $argv + * + * @throws \RuntimeException + * + * @return array{ + * target: string|null, + * host: string, + * port: int, + * css: string|null, + * no_open: bool, + * help: bool, + * version: bool, + * } + */ + private function parseArgs(array $argv): array + { + $opts = [ + 'target' => null, + 'host' => self::DEFAULT_HOST, + 'port' => self::DEFAULT_PORT, + 'css' => null, + 'no_open' => false, + 'help' => false, + 'version' => false, + ]; + + $n = count($argv); + for ($i = 1; $i < $n; $i++) { + $arg = $argv[$i]; + switch ($arg) { + case '-h': + case '--help': + $opts['help'] = true; + + break; + case '-v': + case '--version': + $opts['version'] = true; + + break; + case '--no-open': + $opts['no_open'] = true; + + break; + case '-p': + case '--port': + if (!isset($argv[$i + 1])) { + throw new RuntimeException($arg . ' requires a value'); + } + $opts['port'] = (int)$argv[++$i]; + + break; + case '--host': + if (!isset($argv[$i + 1])) { + throw new RuntimeException($arg . ' requires a value'); + } + $opts['host'] = $argv[++$i]; + + break; + case '--css': + if (!isset($argv[$i + 1])) { + throw new RuntimeException($arg . ' requires a value'); + } + $opts['css'] = $argv[++$i]; + + break; + default: + if (str_starts_with($arg, '-')) { + throw new RuntimeException("Unknown option: {$arg}"); + } + if ($opts['target'] !== null) { + throw new RuntimeException("Only one target file supported (got '{$arg}' after '{$opts['target']}')"); + } + $opts['target'] = $arg; + } + } + + return $opts; + } + + private function openBrowser(string $url): void + { + $cmd = match (PHP_OS_FAMILY) { + 'Darwin' => 'open', + 'Windows' => 'start ""', + default => 'xdg-open', + }; + @exec(sprintf('%s %s >/dev/null 2>&1 &', $cmd, escapeshellarg($url))); + } + + private function printHelp(): void + { + echo << + +Options: + -p, --port PORT HTTP port (default 8765; auto-bumps up to +10) + --host HOST Bind host (default 127.0.0.1) + --no-open Do not open browser on startup + --css FILE Custom CSS file served at /__assets/style.css + -v, --version Print version + -h, --help Print this help + +HELP; + } +} diff --git a/src/Watch/assets/livereload.js b/src/Watch/assets/livereload.js new file mode 100644 index 0000000..c65bc78 --- /dev/null +++ b/src/Watch/assets/livereload.js @@ -0,0 +1,30 @@ +(function () { + var label = '[djot-watch]'; + function connect() { + console.log(label, 'opening EventSource /__sse'); + var es = new EventSource('/__sse'); + es.addEventListener('open', function () { + console.log(label, 'connected, readyState=' + es.readyState); + }); + // Named server event from router.php. + es.addEventListener('reload', function (ev) { + console.log(label, 'reload event received', ev.data); + window.location.reload(); + }); + // Fallback: also reload on any message event (defensive — if the + // server ever emits a bare `data:` line without `event: reload`, + // most browsers route it to 'message'). + es.addEventListener('message', function (ev) { + console.log(label, 'message event', ev.data); + if (ev.data && ev.data.indexOf('reload') !== -1) { + window.location.reload(); + } + }); + es.onerror = function (ev) { + console.log(label, 'error, readyState=' + es.readyState + ', reconnecting in 1s'); + es.close(); + setTimeout(connect, 1000); + }; + } + connect(); +})(); diff --git a/src/Watch/assets/style.css b/src/Watch/assets/style.css new file mode 100644 index 0000000..45070e5 --- /dev/null +++ b/src/Watch/assets/style.css @@ -0,0 +1,92 @@ +:root { + color-scheme: light dark; + --fg: #1a1a1a; + --bg: #fdfdfd; + --muted: #666; + --link: #0366d6; + --code-bg: #f6f8fa; + --border: #d0d7de; +} + +@media (prefers-color-scheme: dark) { + :root { + --fg: #e6e6e6; + --bg: #1a1a1a; + --muted: #aaa; + --link: #58a6ff; + --code-bg: #161b22; + --border: #30363d; + } +} + +html, +body { + background: var(--bg); + color: var(--fg); +} + +body { + font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + max-width: 760px; + margin: 2rem auto; + padding: 0 1rem; +} + +h1, h2, h3, h4, h5, h6 { + line-height: 1.25; + margin-top: 1.5em; +} + +h1 { + border-bottom: 1px solid var(--border); + padding-bottom: .3em; +} + +a { + color: var(--link); +} + +pre, +code { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} + +pre { + background: var(--code-bg); + padding: 1em; + border-radius: 6px; + overflow-x: auto; +} + +code { + background: var(--code-bg); + padding: .15em .35em; + border-radius: 3px; + font-size: 90%; +} + +pre code { + background: none; + padding: 0; +} + +blockquote { + border-left: 4px solid var(--border); + margin: 0; + padding: 0 1em; + color: var(--muted); +} + +table { + border-collapse: collapse; +} + +th, td { + border: 1px solid var(--border); + padding: .4em .8em; +} + +hr { + border: 0; + border-top: 1px solid var(--border); +} diff --git a/src/Watch/router.php b/src/Watch/router.php new file mode 100644 index 0000000..a38912c --- /dev/null +++ b/src/Watch/router.php @@ -0,0 +1,142 @@ + 0) { + @ob_end_clean(); + } + @ini_set('zlib.output_compression', '0'); + @ini_set('output_buffering', '0'); + @ini_set('implicit_flush', '1'); + ob_implicit_flush(true); + + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('X-Accel-Buffering: no'); + // Detect client disconnect so we can break out of the long-poll loop + // without blocking php -S (or downstream proc_close) for the full window. + ignore_user_abort(false); + + $channel = new SseChannel($statePath); + $last = $channel->current(); + + // 2 KiB of comment padding upfront. Some browsers (notably Chrome and + // its forks) defer EventSource parsing until they have ~1 KiB+ of + // body bytes; without this the first real event takes that long to + // surface, and a short-lived `event: reload` can be missed entirely. + echo ': ' . str_repeat('x', 2048) . "\n\n"; + echo ": connected\n\n"; + flush(); + if (connection_aborted()) { + return true; + } + + // Hold the connection for ~25s, then the browser reconnects automatically. + $deadline = time() + 25; + while (time() < $deadline) { + if (connection_aborted()) { + return true; + } + $now = $channel->current(); + if ($now !== $last) { + echo "event: reload\ndata: {$now}\n\n"; + flush(); + $last = $now; + } else { + // Periodic comment keeps the connection warm AND gives PHP a + // chance to notice client disconnects (connection_aborted() is + // only updated on write attempts). + echo ": ping\n\n"; + flush(); + } + usleep(100_000); + } + + return true; +} + +if ($path === '/' || $path === '/index.html') { + // The HTML is regenerated from the current djot every request; caching + // would defeat the live-preview loop. + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('Pragma: no-cache'); + header('Expires: 0'); + if (!is_file($target)) { + http_response_code(404); + echo "djot-watch: target file '{$target}' not found."; + + return true; + } + $djot = (string)file_get_contents($target); + $renderer = new Renderer(); + echo $renderer->renderDocument($djot, cssPath: $cssOverride !== '' ? $cssOverride : null); + + return true; +} + +http_response_code(404); +echo 'Not found'; + +return true; diff --git a/tests/TestCase/Extension/HeadingReferenceExtensionTest.php b/tests/TestCase/Extension/HeadingReferenceExtensionTest.php index ef5ec00..15cfcc6 100644 --- a/tests/TestCase/Extension/HeadingReferenceExtensionTest.php +++ b/tests/TestCase/Extension/HeadingReferenceExtensionTest.php @@ -251,7 +251,7 @@ public function testHeadingWithNoTextIsIgnored(): void public function testUserAuthoredLinkWithMatchingPlaceholderIsNotRewritten(): void { - $extension = new class('heading-ref') extends HeadingReferenceExtension { + $extension = new class ('heading-ref') extends HeadingReferenceExtension { protected function generatePlaceholderPrefix(): string { return 'collision-placeholder-'; diff --git a/tests/Watch/FileWatcherTest.php b/tests/Watch/FileWatcherTest.php new file mode 100644 index 0000000..41735e6 --- /dev/null +++ b/tests/Watch/FileWatcherTest.php @@ -0,0 +1,81 @@ +makeFile("initial\n"); + $watcher = new FileWatcher([$tmp]); + $this->assertFalse($watcher->poll()); + @unlink($tmp); + } + + public function testDetectsMtimeChange(): void + { + $tmp = $this->makeFile("initial\n"); + $watcher = new FileWatcher([$tmp]); + + // Force mtime forward past second-resolution boundary. + touch($tmp, time() + 2); + clearstatcache(true, $tmp); + + $this->assertTrue($watcher->poll(), 'change detected after touch'); + $this->assertFalse($watcher->poll(), 'no change on subsequent poll'); + + @unlink($tmp); + } + + public function testDetectsChangeOnAnyTrackedFile(): void + { + $a = $this->makeFile("A\n"); + $b = $this->makeFile("B\n"); + $watcher = new FileWatcher([$a, $b]); + + touch($b, time() + 2); + clearstatcache(true, $b); + $this->assertTrue($watcher->poll()); + + @unlink($a); + @unlink($b); + } + + public function testDetectsSameSecondReplaceViaSizeChange(): void + { + $tmp = $this->makeFile("first\n"); + $watcher = new FileWatcher([$tmp]); + // Write different-length content without advancing mtime past one-second resolution. + file_put_contents($tmp, "longer second content\n"); + clearstatcache(true, $tmp); + $this->assertTrue($watcher->poll()); + @unlink($tmp); + } + + public function testDetectsSameSecondSameSizeReplaceViaContentHash(): void + { + // A typo fix that preserves file length and lands in the same + // one-second filemtime() bucket would be invisible to a (mtime, size) + // fingerprint. The content-hash component catches it. + $tmp = $this->makeFile("teh quick brown\n"); + $watcher = new FileWatcher([$tmp]); + file_put_contents($tmp, "the quick brown\n"); + clearstatcache(true, $tmp); + $this->assertTrue($watcher->poll()); + @unlink($tmp); + } + + private function makeFile(string $content): string + { + $path = tempnam(sys_get_temp_dir(), 'fw_test_'); + self::assertNotFalse($path); + file_put_contents($path, $content); + + return $path; + } +} diff --git a/tests/Watch/IntegrationTest.php b/tests/Watch/IntegrationTest.php new file mode 100644 index 0000000..b14806c --- /dev/null +++ b/tests/Watch/IntegrationTest.php @@ -0,0 +1,137 @@ +markTestSkipped('pcntl extension not available'); + } + + $port = $this->findFreePort(); + $target = tempnam(sys_get_temp_dir(), 'djot_integ_') . '.djot'; + file_put_contents($target, "# Initial\n"); + + $bin = realpath(__DIR__ . '/../../bin/djot-watch'); + self::assertNotFalse($bin); + $cmd = sprintf( + '%s %s --port %d --no-open %s', + escapeshellarg(PHP_BINARY), + escapeshellarg($bin), + $port, + escapeshellarg($target), + ); + + // Redirect child stdio to /dev/null so its periodic stdout writes + // (e.g. "reload (N)") don't block on a full pipe buffer. + $descriptors = [ + 0 => ['file', '/dev/null', 'r'], + 1 => ['file', '/dev/null', 'w'], + 2 => ['file', '/dev/null', 'w'], + ]; + $proc = proc_open($cmd, $descriptors, $pipes); + self::assertIsResource($proc); + + try { + $url = "http://127.0.0.1:{$port}/"; + self::assertTrue($this->waitForServer($url), "Server did not respond at {$url}"); + + // Trigger a change: write different-length content and advance mtime. + touch($target, time() + 2); + file_put_contents($target, "# Updated content here\n"); + clearstatcache(true, $target); + + self::assertTrue( + $this->waitForReload("http://127.0.0.1:{$port}/__sse", 5), + 'No SSE reload event received within 5 seconds', + ); + } finally { + proc_terminate($proc, defined('SIGTERM') ? SIGTERM : 15); + // Give the watcher a moment to flush its finally block before reaping. + $deadline = microtime(true) + 2.0; + while (microtime(true) < $deadline) { + $status = proc_get_status($proc); + if (!$status['running']) { + break; + } + usleep(50_000); + } + proc_close($proc); + @unlink($target); + } + } + + private function findFreePort(): int + { + $sock = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + self::assertIsResource($sock); + $name = stream_socket_get_name($sock, false); + self::assertNotFalse($name); + fclose($sock); + $portStr = substr($name, (int)strrpos($name, ':') + 1); + + return (int)$portStr; + } + + private function waitForServer(string $url): bool + { + $ctx = stream_context_create(['http' => ['timeout' => 1, 'ignore_errors' => true]]); + for ($i = 0; $i < 50; $i++) { + usleep(100_000); + $body = @file_get_contents($url, false, $ctx); + if (is_string($body) && str_contains($body, ' ['timeout' => $timeoutSeconds]]); + $stream = @fopen($sseUrl, 'r', false, $ctx); + if (!is_resource($stream)) { + return false; + } + stream_set_timeout($stream, $timeoutSeconds); + + $deadline = microtime(true) + $timeoutSeconds; + $buffer = ''; + try { + while (microtime(true) < $deadline) { + $chunk = fread($stream, 256); + if ($chunk === false || $chunk === '') { + $info = stream_get_meta_data($stream); + if ($info['timed_out']) { + return false; + } + usleep(50_000); + + continue; + } + $buffer .= $chunk; + if (str_contains($buffer, 'event: reload')) { + return true; + } + } + + return false; + } finally { + fclose($stream); + } + } +} diff --git a/tests/Watch/RendererTest.php b/tests/Watch/RendererTest.php new file mode 100644 index 0000000..4cda1e3 --- /dev/null +++ b/tests/Watch/RendererTest.php @@ -0,0 +1,36 @@ +render('# Hello'); + $this->assertStringContainsString('assertStringContainsString('Hello', $html); + } + + public function testRenderDocumentWrapsFragmentWithLayout(): void + { + $renderer = new Renderer(); + $html = $renderer->renderDocument('# Hello', cssPath: null); + $this->assertStringContainsString('', strtolower($html)); + $this->assertStringContainsString('/__assets/livereload.js', $html); + $this->assertStringContainsString('/__assets/style.css', $html); + $this->assertStringContainsString('renderDocument('paragraph', cssPath: null); + $this->assertStringContainsString('Djot Preview', $html); + } +} diff --git a/tests/Watch/SseChannelTest.php b/tests/Watch/SseChannelTest.php new file mode 100644 index 0000000..9c5ca59 --- /dev/null +++ b/tests/Watch/SseChannelTest.php @@ -0,0 +1,50 @@ +path = $tmp; + } + + protected function tearDown(): void + { + @unlink($this->path); + } + + public function testInitialSequenceIsZero(): void + { + $channel = new SseChannel($this->path); + $this->assertSame(0, $channel->current()); + } + + public function testBumpIncrementsSequence(): void + { + $channel = new SseChannel($this->path); + $channel->bump(); + $this->assertSame(1, $channel->current()); + $channel->bump(); + $this->assertSame(2, $channel->current()); + } + + public function testCurrentIsReadableFromAnotherInstance(): void + { + $producer = new SseChannel($this->path); + $producer->bump(); + $producer->bump(); + + $consumer = new SseChannel($this->path); + $this->assertSame(2, $consumer->current()); + } +}