Compare commits
2 commits
e3def2125a
...
6afc48778e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6afc48778e | ||
|
|
b36009bfc0 |
8 changed files with 186 additions and 32 deletions
12
README.md
12
README.md
|
|
@ -349,7 +349,17 @@ PrintFullDocumentation();
|
||||||
|
|
||||||
## Live-preview
|
## Live-preview
|
||||||
|
|
||||||
В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами, в которых объявлены страницы, а также за системными и пользовательскими CSS/JS. При изменении он пересобирает manifest; клиент сохраняет его и перезагружается с новым runtime. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.
|
В live-режиме браузер опрашивает `/live`. Сервер следит за файлами страниц, импортированными модулями с функциями `@Reactive`, а также за системными и пользовательскими CSS/JS. После успешной пересборки браузер загружает новый HTML и восстанавливает позицию прокрутки открытой страницы в пикселях без анимации. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.
|
||||||
|
|
||||||
|
Используйте `@Reactive` для обычных функций уровня модуля, например для локализации или общих значений. Декоратор сохраняет аргументы и возвращаемое значение функции. При редактировании её файла live preview заново выполняет рендер всех страниц.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from docslib import Reactive
|
||||||
|
|
||||||
|
@Reactive
|
||||||
|
def product_name() -> str:
|
||||||
|
return "Мой продукт"
|
||||||
|
```
|
||||||
|
|
||||||
HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта.
|
HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
from ._version import __version__
|
from ._version import __version__
|
||||||
from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator
|
from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator
|
||||||
from .hooks import Component, Page
|
from .hooks import Component, Page, Reactive
|
||||||
from .server import build, create_app, run
|
from .server import build, create_app, run
|
||||||
from .ui import HeaderButton
|
from .ui import HeaderButton
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Component", "DefaultGenerator", "FilesBuildResult", "FilesGenerator",
|
"Component", "DefaultGenerator", "FilesBuildResult", "FilesGenerator",
|
||||||
"HeaderButton", "Page", "build", "create_app", "run",
|
"HeaderButton", "Page", "Reactive", "build", "create_app", "run",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ from __future__ import annotations
|
||||||
import inspect
|
import inspect
|
||||||
from functools import update_wrapper
|
from functools import update_wrapper
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generic, ParamSpec
|
from typing import Callable, Generic, ParamSpec, TypeVar, cast
|
||||||
|
|
||||||
from .registry import PageDefinition, registry
|
from .registry import PageDefinition, registry
|
||||||
|
|
||||||
P = ParamSpec("P")
|
P = ParamSpec("P")
|
||||||
|
R = TypeVar("R")
|
||||||
VariantSelection = int | list[int] | tuple[int, ...]
|
VariantSelection = int | list[int] | tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,6 +54,30 @@ def _normalise_page_number(number: str | int | float) -> str:
|
||||||
return ".".join(parts)
|
return ".".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
class Reactive(Generic[P, R]):
|
||||||
|
"""Watch a helper's source file in live preview and preserve its return value."""
|
||||||
|
|
||||||
|
def __init__(self, function: Callable[P, R]) -> None:
|
||||||
|
if not callable(function):
|
||||||
|
raise TypeError("@Reactive must decorate a callable")
|
||||||
|
update_wrapper(self, function)
|
||||||
|
self.function = function
|
||||||
|
self._source_key: tuple[str, str] | None = None
|
||||||
|
if inspect.isfunction(function) and "<locals>" not in function.__qualname__:
|
||||||
|
source = inspect.getsourcefile(function)
|
||||||
|
if source:
|
||||||
|
self._source_key = str(Path(source).resolve()), function.__qualname__
|
||||||
|
registry.register_reactive(self._source_key, function)
|
||||||
|
|
||||||
|
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
|
function = self.function
|
||||||
|
if self._source_key is not None:
|
||||||
|
latest = registry.reactive_function(self._source_key)
|
||||||
|
if latest is not None:
|
||||||
|
function = cast(Callable[P, R], latest)
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Component(Generic[P]):
|
class Component(Generic[P]):
|
||||||
"""Normalise a component's string or list/tuple of strings into variants."""
|
"""Normalise a component's string or list/tuple of strings into variants."""
|
||||||
|
|
||||||
|
|
@ -102,5 +127,5 @@ class Page:
|
||||||
return function
|
return function
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Component", "Page"]
|
__all__ = ["Component", "Page", "Reactive"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from pathlib import Path
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
ReactiveFunctions = dict[tuple[str, str], Callable[..., object]]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PageDefinition:
|
class PageDefinition:
|
||||||
|
|
@ -18,6 +20,7 @@ class PageDefinition:
|
||||||
class Registry:
|
class Registry:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._pages: dict[str, PageDefinition] = {}
|
self._pages: dict[str, PageDefinition] = {}
|
||||||
|
self._reactive: ReactiveFunctions = {}
|
||||||
self._lock = RLock()
|
self._lock = RLock()
|
||||||
|
|
||||||
def register(self, page: PageDefinition) -> None:
|
def register(self, page: PageDefinition) -> None:
|
||||||
|
|
@ -31,6 +34,22 @@ class Registry:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return list(self._pages.values())
|
return list(self._pages.values())
|
||||||
|
|
||||||
|
def register_reactive(self, key: tuple[str, str], function: Callable[..., object]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._reactive[key] = function
|
||||||
|
|
||||||
|
def reactive_function(self, key: tuple[str, str]) -> Callable[..., object] | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._reactive.get(key)
|
||||||
|
|
||||||
|
def sources(self) -> set[tuple[str, str]]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
(page.source_file, page.module) for page in self._pages.values() if page.source_file
|
||||||
|
} | {
|
||||||
|
(path, function.__module__) for (path, _), function in self._reactive.items()
|
||||||
|
}
|
||||||
|
|
||||||
def resolve_page(
|
def resolve_page(
|
||||||
self,
|
self,
|
||||||
target: str | int | float | Callable[..., object],
|
target: str | int | float | Callable[..., object],
|
||||||
|
|
@ -79,13 +98,13 @@ class Registry:
|
||||||
)
|
)
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, PageDefinition]:
|
def snapshot(self) -> tuple[dict[str, PageDefinition], ReactiveFunctions]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return dict(self._pages)
|
return self._pages.copy(), self._reactive.copy()
|
||||||
|
|
||||||
def restore(self, snapshot: dict[str, PageDefinition]) -> None:
|
def restore(self, snapshot: tuple[dict[str, PageDefinition], ReactiveFunctions]) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._pages = dict(snapshot)
|
self._pages, self._reactive = snapshot
|
||||||
|
|
||||||
def remove_source(self, source_file: str | Path) -> None:
|
def remove_source(self, source_file: str | Path) -> None:
|
||||||
target = str(Path(source_file).resolve())
|
target = str(Path(source_file).resolve())
|
||||||
|
|
@ -95,11 +114,15 @@ class Registry:
|
||||||
for key, page in self._pages.items()
|
for key, page in self._pages.items()
|
||||||
if not page.source_file or str(Path(page.source_file).resolve()) != target
|
if not page.source_file or str(Path(page.source_file).resolve()) != target
|
||||||
}
|
}
|
||||||
|
self._reactive = {
|
||||||
|
key: function for key, function in self._reactive.items() if key[0] != target
|
||||||
|
}
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Clear all registered pages. Primarily useful in test suites."""
|
"""Clear all registered pages. Primarily useful in test suites."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._pages.clear()
|
self._pages.clear()
|
||||||
|
self._reactive.clear()
|
||||||
|
|
||||||
|
|
||||||
registry = Registry()
|
registry = Registry()
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
|
||||||
import runpy
|
import runpy
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
|
from types import ModuleType
|
||||||
from typing import Mapping, Sequence
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
@ -57,20 +57,18 @@ class DocumentationSite:
|
||||||
return self._last_error
|
return self._last_error
|
||||||
|
|
||||||
def _discover_sources(self) -> None:
|
def _discover_sources(self) -> None:
|
||||||
for page in registry.pages():
|
self._source_modules.clear()
|
||||||
if not page.source_file:
|
for source_file, module in registry.sources():
|
||||||
continue
|
path = Path(source_file)
|
||||||
path = Path(page.source_file).resolve()
|
self._source_modules.setdefault(path, set()).add(module)
|
||||||
self._source_modules.setdefault(path, set()).add(page.module)
|
watched = self._source_modules.keys() | set(self.compiler.watched_files())
|
||||||
|
for path in self._source_mtimes.keys() - watched:
|
||||||
|
del self._source_mtimes[path]
|
||||||
|
for path in watched - self._source_mtimes.keys():
|
||||||
try:
|
try:
|
||||||
self._source_mtimes[path] = path.stat().st_mtime_ns
|
self._source_mtimes[path] = path.stat().st_mtime_ns
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
for path in self.compiler.watched_files():
|
|
||||||
try:
|
|
||||||
self._source_mtimes.setdefault(path, path.stat().st_mtime_ns)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
def refresh_changed_sources(self) -> bool:
|
def refresh_changed_sources(self) -> bool:
|
||||||
if not self.live_preview:
|
if not self.live_preview:
|
||||||
|
|
@ -88,11 +86,15 @@ class DocumentationSite:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
snapshot = registry.snapshot()
|
||||||
|
module_snapshots: dict[ModuleType, dict[str, object]] = {}
|
||||||
try:
|
try:
|
||||||
for path, _ in changed:
|
for path, _ in changed:
|
||||||
self._reload_source(path)
|
self._reload_source(path, module_snapshots)
|
||||||
new_result = self.compiler.build()
|
new_result = self.compiler.build()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
for module, namespace in module_snapshots.items():
|
||||||
|
module.__dict__.clear()
|
||||||
|
module.__dict__.update(namespace)
|
||||||
registry.restore(snapshot)
|
registry.restore(snapshot)
|
||||||
self._last_error = f"{type(error).__name__}: {error}"
|
self._last_error = f"{type(error).__name__}: {error}"
|
||||||
for path, mtime in changed:
|
for path, mtime in changed:
|
||||||
|
|
@ -103,19 +105,22 @@ class DocumentationSite:
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
for path, mtime in changed:
|
for path, mtime in changed:
|
||||||
self._source_mtimes[path] = mtime
|
self._source_mtimes[path] = mtime
|
||||||
self._source_modules.clear()
|
|
||||||
self._discover_sources()
|
self._discover_sources()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _reload_source(self, path: Path) -> None:
|
def _reload_source(self, path: Path, snapshots: dict[ModuleType, dict[str, object]]) -> None:
|
||||||
modules = self._source_modules.get(path, set())
|
modules = self._source_modules.get(path, set())
|
||||||
if not modules:
|
if not modules:
|
||||||
return
|
return
|
||||||
registry.remove_source(path)
|
registry.remove_source(path)
|
||||||
reloadable = [name for name in modules if name not in {"__main__", "<run_path>"} and name in sys.modules]
|
reloadable = [name for name in modules if name not in {"__main__", "<run_path>"} and name in sys.modules]
|
||||||
if reloadable:
|
if reloadable:
|
||||||
|
# Read the source directly so a same-size edit within one second bypasses stale bytecode.
|
||||||
|
code = compile(path.read_bytes(), str(path), "exec", dont_inherit=True)
|
||||||
for name in reloadable:
|
for name in reloadable:
|
||||||
importlib.reload(sys.modules[name])
|
module = sys.modules[name]
|
||||||
|
snapshots.setdefault(module, module.__dict__.copy())
|
||||||
|
exec(code, module.__dict__)
|
||||||
else:
|
else:
|
||||||
# run_name is deliberately not __main__, so a guarded run() call is not executed again.
|
# run_name is deliberately not __main__, so a guarded run() call is not executed again.
|
||||||
runpy.run_path(str(path), run_name=f"_docslib_live_{abs(hash(path))}")
|
runpy.run_path(str(path), run_name=f"_docslib_live_{abs(hash(path))}")
|
||||||
|
|
@ -162,7 +167,7 @@ def create_app(
|
||||||
@app.get("/", include_in_schema=False)
|
@app.get("/", include_in_schema=False)
|
||||||
@app.get("/index.html", response_class=HTMLResponse)
|
@app.get("/index.html", response_class=HTMLResponse)
|
||||||
def index() -> HTMLResponse:
|
def index() -> HTMLResponse:
|
||||||
return HTMLResponse(site.result.html)
|
return HTMLResponse(site.result.html, headers=NO_CACHE_HEADERS if live_preview else None)
|
||||||
|
|
||||||
@app.get("/manifest.json", response_class=JSONResponse)
|
@app.get("/manifest.json", response_class=JSONResponse)
|
||||||
def manifest() -> JSONResponse:
|
def manifest() -> JSONResponse:
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@
|
||||||
let lastPlaceholderPage = -1;
|
let lastPlaceholderPage = -1;
|
||||||
let customScriptLoaded = false;
|
let customScriptLoaded = false;
|
||||||
let lastSyncedRevision = String(manifest.revision || "");
|
let lastSyncedRevision = String(manifest.revision || "");
|
||||||
|
let liveReloading = false;
|
||||||
let pageTransitionId = 0;
|
let pageTransitionId = 0;
|
||||||
let pageAnimation = null;
|
let pageAnimation = null;
|
||||||
let skipNextPageAnimation = false;
|
let skipNextPageAnimation = false;
|
||||||
|
|
@ -838,21 +839,54 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollLive() {
|
async function pollLive() {
|
||||||
|
if (liveReloading) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(config.liveUrl, { cache: "no-store" });
|
const response = await fetch(config.liveUrl, { cache: "no-store" });
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const live = await response.json();
|
const live = await response.json();
|
||||||
if (live.revision && live.revision !== lastSyncedRevision) {
|
if (liveReloading || !live.revision) return;
|
||||||
const changed = await syncManifest();
|
const revisionChanged = live.revision !== manifest.revision;
|
||||||
if (changed) location.reload();
|
if (!revisionChanged && live.updated_at === manifest.updated_at) return;
|
||||||
|
liveReloading = true;
|
||||||
|
if (revisionChanged) await syncManifest();
|
||||||
|
const docslibLiveScroll = { page: currentNumber, top: elements.pageSurface.scrollTop };
|
||||||
|
try {
|
||||||
|
history.replaceState({ ...history.state, docslibLiveScroll }, "");
|
||||||
|
} catch (_) { /* reload even when history state is unavailable */ }
|
||||||
|
location.reload();
|
||||||
|
} catch (_) {
|
||||||
|
liveReloading = false;
|
||||||
}
|
}
|
||||||
} catch (_) { /* local preview may stop */ }
|
}
|
||||||
|
|
||||||
|
async function restoreLiveScroll() {
|
||||||
|
history.scrollRestoration = "manual";
|
||||||
|
if (!history.state?.docslibLiveScroll) return;
|
||||||
|
const { docslibLiveScroll, ...state } = history.state;
|
||||||
|
history.replaceState(state, "");
|
||||||
|
const { page, top } = docslibLiveScroll;
|
||||||
|
if (page !== currentNumber || !Number.isFinite(top) || top <= 0) return;
|
||||||
|
|
||||||
|
const surface = elements.pageSurface;
|
||||||
|
surface.scrollTo({ top, behavior: "instant" });
|
||||||
|
if (surface.scrollTop >= top - 1) return;
|
||||||
|
|
||||||
|
// Images can contribute to the scroll height only after their dimensions are known.
|
||||||
|
const pendingImages = [...elements.content.querySelectorAll("img")].filter((image) => !image.complete);
|
||||||
|
if (!pendingImages.length) return;
|
||||||
|
pendingImages.forEach((image) => { image.loading = "eager"; });
|
||||||
|
await Promise.race([
|
||||||
|
waitForPrintAssets(elements.content),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 300)),
|
||||||
|
]);
|
||||||
|
if (page === currentNumber) surface.scrollTo({ top, behavior: "instant" });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
applyTheme(localStorage.getItem("docslib-theme") || "light");
|
applyTheme(localStorage.getItem("docslib-theme") || "light");
|
||||||
loadAccessibilityPreferences();
|
loadAccessibilityPreferences();
|
||||||
renderAll();
|
renderAll();
|
||||||
|
if (config.livePreview) await restoreLiveScroll();
|
||||||
announceRendered();
|
announceRendered();
|
||||||
syncManifest();
|
syncManifest();
|
||||||
if (config.livePreview) setInterval(pollLive, 1200);
|
if (config.livePreview) setInterval(pollLive, 1200);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPromise = new Promise((resolve, reject) => {
|
const dbPromise = config.livePreview ? null : new Promise((resolve, reject) => {
|
||||||
if (!window.indexedDB) return reject(new Error("IndexedDB"));
|
if (!window.indexedDB) return reject(new Error("IndexedDB"));
|
||||||
const request = indexedDB.open("docslib-cache", 1);
|
const request = indexedDB.open("docslib-cache", 1);
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectManifest() {
|
async function selectManifest() {
|
||||||
|
if (config.livePreview) return embedded;
|
||||||
try {
|
try {
|
||||||
const cached = await cacheGet();
|
const cached = await cacheGet();
|
||||||
if (isManifest(cached) && cached.schema_version === embedded.schema_version) {
|
if (isManifest(cached) && cached.schema_version === embedded.schema_version) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import base64
|
import base64
|
||||||
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import runpy
|
import runpy
|
||||||
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -24,7 +26,9 @@ def test_loader_renders_indexeddb_before_background_sync():
|
||||||
assert "return cached;" in loader
|
assert "return cached;" in loader
|
||||||
assert "await cachePut(embedded);" in loader
|
assert "await cachePut(embedded);" in loader
|
||||||
assert "fetch(" not in loader
|
assert "fetch(" not in loader
|
||||||
assert "renderAll();\n announceRendered();\n syncManifest();" in client
|
assert 'if (config.livePreview) return embedded;' in loader
|
||||||
|
assert client.index(" renderAll();") < client.index(" announceRendered();")
|
||||||
|
assert " announceRendered();\n syncManifest();" in client
|
||||||
assert "manifest = remote" not in client
|
assert "manifest = remote" not in client
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -343,6 +347,58 @@ def test_live_preview_reloads_a_changed_main_source(tmp_path):
|
||||||
assert "two" in client.get("/manifest.json").json()["pages"][0]["content"]
|
assert "two" in client.get("/manifest.json").json()["pages"][0]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_preview_reloads_imported_reactive_helpers(tmp_path, monkeypatch):
|
||||||
|
source = tmp_path / "reactive_preview_helper.py"
|
||||||
|
initial_source = (
|
||||||
|
"from docslib import Reactive\n"
|
||||||
|
"TITLE = 'one'\n"
|
||||||
|
"@Reactive\n"
|
||||||
|
"def product_name(prefix=''): return prefix + TITLE\n"
|
||||||
|
)
|
||||||
|
source.write_text(initial_source, encoding="utf-8")
|
||||||
|
os.utime(source, ns=(1_800_000_000_000_000_000,) * 2)
|
||||||
|
spec = importlib.util.spec_from_file_location(source.stem, source)
|
||||||
|
helper = importlib.util.module_from_spec(spec)
|
||||||
|
monkeypatch.setitem(sys.modules, source.stem, helper)
|
||||||
|
spec.loader.exec_module(helper)
|
||||||
|
product_name = helper.product_name
|
||||||
|
|
||||||
|
@Page("1", "First")
|
||||||
|
def first():
|
||||||
|
return product_name("First ")
|
||||||
|
|
||||||
|
@Page("2", "Second")
|
||||||
|
def second():
|
||||||
|
return product_name(prefix="Second ")
|
||||||
|
|
||||||
|
client = TestClient(create_app(live_preview=True))
|
||||||
|
initial = client.get("/manifest.json").json()
|
||||||
|
assert [page["content"] for page in initial["pages"]] == ["First one", "Second one"]
|
||||||
|
|
||||||
|
# Keep both file size and whole-second mtime to exercise rapid saves.
|
||||||
|
updated_source = initial_source.replace("'one'", "'two'")
|
||||||
|
source.write_text(updated_source, encoding="utf-8")
|
||||||
|
os.utime(source, ns=(1_800_000_000_100_000_000,) * 2)
|
||||||
|
live = client.get("/live").json()
|
||||||
|
assert live["changed"] is True
|
||||||
|
assert live["revision"] != initial["revision"]
|
||||||
|
updated = client.get("/manifest.json").json()
|
||||||
|
assert [page["content"] for page in updated["pages"]] == ["First two", "Second two"]
|
||||||
|
|
||||||
|
source.write_text(updated_source.replace("'two'", "None"), encoding="utf-8")
|
||||||
|
live = client.get("/live").json()
|
||||||
|
assert live["changed"] is False
|
||||||
|
assert live["error"].startswith("RuntimeError:")
|
||||||
|
assert client.get("/manifest.json").json() == updated
|
||||||
|
assert product_name() == "two"
|
||||||
|
|
||||||
|
source.write_text(updated_source.replace("'two'", "'three'"), encoding="utf-8")
|
||||||
|
live = client.get("/live").json()
|
||||||
|
assert live["changed"] is True
|
||||||
|
assert live["error"] is None
|
||||||
|
assert product_name() == "three"
|
||||||
|
|
||||||
|
|
||||||
def test_live_preview_rebuilds_changed_custom_javascript(tmp_path):
|
def test_live_preview_rebuilds_changed_custom_javascript(tmp_path):
|
||||||
custom_js = tmp_path / "inject.js"
|
custom_js = tmp_path / "inject.js"
|
||||||
custom_js.write_text("window.customVersion = 1;", encoding="utf-8")
|
custom_js.write_text("window.customVersion = 1;", encoding="utf-8")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue