279 lines
13 KiB
Python
279 lines
13 KiB
Python
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import runpy
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
import docslib
|
|
from docslib import HeaderButton, __version__, build
|
|
from docslib.components import H1, Image, P
|
|
from docslib.hooks import Page
|
|
from docslib.server import create_app
|
|
|
|
|
|
def test_loader_renders_indexeddb_before_background_sync():
|
|
templates = Path(docslib.__file__).with_name("templates")
|
|
loader = (templates / "loader.js").read_text(encoding="utf-8")
|
|
client = (templates / "client.js").read_text(encoding="utf-8")
|
|
|
|
assert "const cached = await cacheGet();" in loader
|
|
assert "return cached;" in loader
|
|
assert "await cachePut(embedded);" in loader
|
|
assert "fetch(" not in loader
|
|
assert "renderAll();\n announceRendered();\n syncManifest();" in client
|
|
assert "manifest = remote" not in client
|
|
|
|
|
|
def test_all_endpoints_and_self_contained_build(tmp_path):
|
|
assets = tmp_path / "assets"
|
|
assets.mkdir()
|
|
image_bytes = b"not-a-real-png-but-good-enough-for-encoding"
|
|
(assets / "hero.png").write_bytes(image_bytes)
|
|
custom_css = tmp_path / "inject.css"
|
|
custom_css.write_text(".content { --custom-test: yes; }", encoding="utf-8")
|
|
custom_js = tmp_path / "inject.js"
|
|
custom_js.write_text('document.body.dataset.customTest = "yes";', encoding="utf-8")
|
|
|
|
@Page("1.1", "Child")
|
|
def child():
|
|
return P("Second page")
|
|
|
|
@Page("1.", "Home")
|
|
def home():
|
|
return [H1("Welcome"), Image("hero.png", "Hero")]
|
|
|
|
app = create_app(
|
|
title="Test docs",
|
|
assets_dir=assets,
|
|
custom_css=custom_css,
|
|
custom_js=custom_js,
|
|
manifest_path="http://127.0.0.1:9000/",
|
|
header_buttons=[
|
|
HeaderButton("Home", home),
|
|
HeaderButton.local("Child", "Child", section="1"),
|
|
HeaderButton.external("Source", "https://example.com"),
|
|
{"label": "Download", "href": "/index.zip"},
|
|
],
|
|
)
|
|
client = TestClient(app)
|
|
|
|
manifest_response = client.get("/manifest.json")
|
|
assert manifest_response.status_code == 200
|
|
manifest = manifest_response.json()
|
|
assert [page["number"] for page in manifest["pages"]] == ["1", "1.1"]
|
|
assert manifest["assets"]["hero.png"]["data"] == base64.b64encode(image_bytes).decode()
|
|
assert manifest["schema_version"] == 2
|
|
assert "html, body { width: 100%; height: 100dvh" in manifest["client"]["css"]["system"]
|
|
assert manifest["client"]["css"]["custom"] == ".content { --custom-test: yes; }"
|
|
assert "placeholderTick" in manifest["client"]["js"]["system"]
|
|
assert "COMPILED_FROM_VERSION" not in manifest["client"]["js"]["system"]
|
|
assert manifest["client"]["js"]["custom"] == 'document.body.dataset.customTest = "yes";'
|
|
assert len(manifest["revision"]) == 20
|
|
assert manifest["ui"]["header_buttons"] == [
|
|
{"label": "Home", "page": "1"},
|
|
{"label": "Child", "page": "1.1"},
|
|
{"label": "Source", "href": "https://example.com", "target": "_blank"},
|
|
{"label": "Download", "href": "/index.zip"},
|
|
]
|
|
assert "no-store" in manifest_response.headers["cache-control"]
|
|
assert "no-cache" in manifest_response.headers["cache-control"]
|
|
assert manifest_response.headers["pragma"] == "no-cache"
|
|
assert manifest_response.headers["expires"] == "0"
|
|
|
|
version_response = client.get("/version?no_cache=test")
|
|
assert version_response.status_code == 200
|
|
assert version_response.text == manifest["revision"]
|
|
assert "no-store" in version_response.headers["cache-control"]
|
|
assert "no-cache" in version_response.headers["cache-control"]
|
|
assert version_response.headers["pragma"] == "no-cache"
|
|
assert version_response.headers["expires"] == "0"
|
|
|
|
html_response = client.get("/index.html")
|
|
html = html_response.text
|
|
assert html_response.status_code == 200
|
|
assert "http://127.0.0.1:9000/manifest.json" in html
|
|
assert "http://127.0.0.1:9000/version" in html
|
|
assert 'globalThis.COMPILED_FROM_VERSION = "0.3.7";' in html
|
|
assert ".content { --custom-test: yes; }" in html
|
|
assert r'document.body.dataset.customTest = \"yes\";' in html
|
|
assert 'id="docslib-loading"' in html
|
|
assert "--docslib-loader-bg: #000000; --docslib-loader-text: #f4f4f4" in html
|
|
assert "--docslib-loader-bg: #ffffff; --docslib-loader-text: #111111" in html
|
|
assert "position: fixed; inset: 0; z-index: 2147483647; display: grid; width: 100vw; height: 100dvh" in html
|
|
assert "border: solid 4px currentColor; border-top: solid 4px transparent; border-radius: 9999px" in html
|
|
assert "animation: docslib-loader-spin .8s linear infinite" in html
|
|
assert "return cached" in html
|
|
assert 'id="docslib-loader-style"' in html
|
|
assert "docslib:rendered" in html
|
|
system_js = manifest["client"]["js"]["system"]
|
|
assert 'new CustomEvent("on_manifest_update"' in system_js
|
|
assert "currentManifest: manifest" in system_js
|
|
assert "previousRevision" in system_js
|
|
assert "cached," in system_js
|
|
assert "applied: false" in system_js
|
|
assert 'new CustomEvent("on_update_available"' in system_js
|
|
assert "currentRevision: lastSyncedRevision" in system_js
|
|
assert 'config.versionUrl || siblingResourceUrl(config.manifestUrl, "version")' in system_js
|
|
assert 'target.searchParams.set(' in system_js
|
|
assert '"no_cache"' in system_js
|
|
assert 'fetch(noCacheUrl(url), { cache: "no-store" })' in system_js
|
|
assert system_js.index("await fetchNoCache(versionUrl)") < system_js.index(
|
|
"await fetchNoCache(config.manifestUrl)"
|
|
)
|
|
assert system_js.index('new CustomEvent("on_update_available"') < system_js.index(
|
|
"await fetchNoCache(config.manifestUrl)"
|
|
)
|
|
assert "docslib-runtime-style" in html
|
|
assert "indexedDB.open" in html
|
|
assert "link.dataset.page = button.page" in html
|
|
assert 'class="brand-mark"' not in html
|
|
assert ">Knowledge base<" not in html
|
|
assert ">Search<" not in html
|
|
assert ">Ctrl K<" not in html
|
|
assert ">Up to date<" not in html
|
|
assert "manifestIdentity" in html
|
|
assert "100dvh" in html
|
|
assert "100vh" not in html
|
|
assert "html, body { width: 100%; height: 100dvh" in html
|
|
assert "min-height: calc(100dvh - 240px)" not in html
|
|
assert "overflow-y: auto" in html
|
|
assert "flex: 1 1 auto; flex-direction: column" in html
|
|
assert "padding: 0 18px; background: var(--shell)" in html
|
|
assert "--desktop-sidebar-width: clamp(270px, 25vw, 400px)" in html
|
|
assert "--selection-bg: #111111" in html
|
|
assert "--selection-bg: #f0f0f0" in html
|
|
assert ".mobile-menu-view .tree-link.active { color: var(--selection-text); background: var(--selection-bg); }" in html
|
|
assert "--elevated: #d4d4d4" in html
|
|
assert "--shadow: 0 24px 70px rgba(0, 0, 0, .4)" in html
|
|
assert "elements.pageSurface.scrollTo" in html
|
|
assert "font-family: system-ui; font-size: 16px" in html
|
|
assert ".glyph-button, .refresh-glyph { font-family: Arial" in html
|
|
assert ".nav-arrow { display: block; width: 10px; height: 10px; flex: 0 0 10px; border: 0 solid currentColor; transform: rotate(45deg); }" in html
|
|
assert ".nav-arrow-previous { border-bottom-width: 2px; border-left-width: 2px; }" in html
|
|
assert ".nav-arrow-next { border-top-width: 2px; border-right-width: 2px; }" in html
|
|
assert 'arrow.textContent = direction' not in html
|
|
assert "--shell: #000000" in html
|
|
assert 'id="desktop-search-input"' in html
|
|
assert '<header class="desktop-header">' in html
|
|
assert '<aside class="desktop-sidebar">' in html
|
|
assert 'class="sidebar-header-buttons"' in html
|
|
assert 'id="mobile-search-input"' in html
|
|
assert 'id="mobile-menu-view"' in html
|
|
assert 'id="mobile-search-view"' in html
|
|
assert "placeholderTick" in html
|
|
assert '".".repeat(dots)' in manifest["client"]["js"]["system"]
|
|
assert "setTimeout(placeholderTick, 5000)" in html
|
|
assert '.sidebar-header-buttons .header-link:not(.active):first-child' not in html
|
|
assert "border: 1px solid transparent" not in html
|
|
assert ".sidebar-header-buttons { display: flex; flex: 0 0 auto; flex-direction: column; gap: 4px; margin-bottom: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }" in html
|
|
assert ".desktop-sidebar > .page-tree { flex: 1; padding: 24px 2px 70px; mask-image: linear-gradient(transparent 0px, #000 24px, #000 calc(100% - 100px), transparent); scrollbar-width: none; }" in manifest["client"]["css"]["system"]
|
|
assert ".mobile-header-buttons { display: flex; flex-direction: column; gap: 6px; margin-bottom: 38px; border: 1px solid var(--border); border-radius: 24px; background: var(--surface); }" in html
|
|
assert ".sidebar-header-buttons .header-link { color: var(--text); }" in html
|
|
assert ".sidebar-header-buttons .header-link:hover { background: var(--control); }" in html
|
|
assert ".sidebar-header-buttons .header-link.active { color: #050505; background: #f0f0f0; }" not in html
|
|
assert "--tree-line: #2c2c2c" in html
|
|
assert "height: 44px" in html
|
|
assert ".refresh-button { display: none;" in html
|
|
assert "refresh-expanded" in html
|
|
assert "mobile-search-open" in html
|
|
assert "flex-direction: column-reverse" in html
|
|
assert ".mobile-search-control input { padding-left: 30px; font-size: 1.3rem; }" in html
|
|
assert "background: transparent; font-size: 2.3rem" in html
|
|
assert ".mobile-page-tree .tree-link { min-height: 46px; padding: 9px 14px; border-radius: 24px" in html
|
|
assert ".mobile-menu-button { width: 72px; height: 72px; flex: 0 0 72px; border-radius: 50%; color: var(--text); background: var(--control); font-size: 1.6rem; }" in html
|
|
assert ".nav-card { display: flex; min-height: 48px; align-items: center; gap: 8px; padding: 7px 20px; border: 0; border-radius: 30px" in html
|
|
assert "background: linear-gradient(0deg, var(--shell) 0%, transparent)" in html
|
|
assert "<script src=" not in html
|
|
assert "<link rel=" not in html
|
|
|
|
zip_response = client.get("/index.zip")
|
|
assert zip_response.status_code == 200
|
|
with zipfile.ZipFile(io.BytesIO(zip_response.content)) as archive:
|
|
assert archive.namelist() == ["index.html"]
|
|
assert archive.read("index.html") == html_response.content
|
|
|
|
assert client.get("/live").status_code == 404
|
|
|
|
|
|
def test_build_writes_static_bundle(tmp_path):
|
|
assert __version__ == "0.3.7"
|
|
|
|
@Page("1", "Static page")
|
|
def static_page():
|
|
return H1("Static documentation")
|
|
|
|
output = tmp_path / "nested" / "site"
|
|
result = build(
|
|
build_directory=output,
|
|
title="Static docs",
|
|
manifest_path="https://docs.example/manifest.json",
|
|
header_buttons=[HeaderButton("Home", static_page)],
|
|
)
|
|
|
|
assert sorted(path.name for path in output.iterdir()) == [
|
|
"index.html",
|
|
"index.zip",
|
|
"manifest.json",
|
|
"version",
|
|
]
|
|
assert output.joinpath("index.html").read_bytes() == result.html
|
|
assert json.loads(output.joinpath("manifest.json").read_text(encoding="utf-8")) == result.manifest
|
|
assert output.joinpath("version").read_text(encoding="utf-8") == result.manifest["revision"]
|
|
assert "https://docs.example/manifest.json" in result.html.decode("utf-8")
|
|
assert "https://docs.example/version" in result.html.decode("utf-8")
|
|
with zipfile.ZipFile(output / "index.zip") as archive:
|
|
assert archive.namelist() == ["index.html"]
|
|
assert archive.read("index.html") == result.html
|
|
|
|
|
|
def test_live_preview_reloads_a_changed_main_source(tmp_path):
|
|
source = tmp_path / "docs.py"
|
|
source.write_text(
|
|
'from docslib.hooks import Page\n@Page("1", "Live")\ndef page(): return "<p>one</p>"\n',
|
|
encoding="utf-8",
|
|
)
|
|
runpy.run_path(str(source), run_name="__main__")
|
|
client = TestClient(create_app(live_preview=True))
|
|
initial = client.get("/manifest.json").json()
|
|
assert "one" in initial["pages"][0]["content"]
|
|
|
|
source.write_text(
|
|
'from docslib.hooks import Page\n@Page("1", "Live")\ndef page(): return "<p>two</p>"\n',
|
|
encoding="utf-8",
|
|
)
|
|
stat = source.stat()
|
|
os.utime(source, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000))
|
|
|
|
live = client.get("/live").json()
|
|
assert live["changed"] is True
|
|
assert live["error"] is None
|
|
assert live["revision"] != initial["revision"]
|
|
assert client.get("/version").text == live["revision"]
|
|
assert "two" in client.get("/manifest.json").json()["pages"][0]["content"]
|
|
|
|
|
|
def test_live_preview_rebuilds_changed_custom_javascript(tmp_path):
|
|
custom_js = tmp_path / "inject.js"
|
|
custom_js.write_text("window.customVersion = 1;", encoding="utf-8")
|
|
|
|
@Page("1", "Runtime")
|
|
def runtime_page():
|
|
return "<p>runtime</p>"
|
|
|
|
client = TestClient(create_app(custom_js=custom_js, live_preview=True))
|
|
initial = client.get("/manifest.json").json()
|
|
assert initial["client"]["js"]["custom"] == "window.customVersion = 1;"
|
|
|
|
custom_js.write_text("window.customVersion = 2;", encoding="utf-8")
|
|
stat = custom_js.stat()
|
|
os.utime(custom_js, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000))
|
|
|
|
live = client.get("/live").json()
|
|
updated = client.get("/manifest.json").json()
|
|
assert live["changed"] is True
|
|
assert live["revision"] != initial["revision"]
|
|
assert updated["client"]["js"]["custom"] == "window.customVersion = 2;"
|