Merge branch 'feat/bundle-routes' into int/wave4
This commit is contained in:
@@ -77,7 +77,7 @@ jobs:
|
||||
import os, re, sys
|
||||
sys.path.insert(0, os.getcwd())
|
||||
import html_generator
|
||||
page = html_generator.get_html_template("ci", {"items": []}, "1")
|
||||
page = html_generator.get_html_template("ci", {"items": []}, "1", inline_assets=True)
|
||||
bodies = re.findall(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>", page, re.S)
|
||||
assert bodies, "no inline <script> found in the generated page"
|
||||
out = os.environ.get("RUNNER_TEMP", ".")
|
||||
@@ -85,6 +85,12 @@ jobs:
|
||||
with open(os.path.join(out, f"bundle_{n}.js"), "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
print(f"wrote {len(bodies)} script bodies to {out}")
|
||||
# globals script + bundle as ONE script: catches a resource redeclaring
|
||||
# TARGET_NODE_ID / USCG_STANDALONE / fullManifest
|
||||
with open(os.path.join(out, "bundle_all.js"), "w", encoding="utf-8") as fh:
|
||||
fh.write("\n;\n".join(bodies))
|
||||
with open(os.path.join(out, "bundle_route.js"), "w", encoding="utf-8") as fh:
|
||||
fh.write(html_generator.get_dashboard_bundle("js").text)
|
||||
PY
|
||||
for f in "${RUNNER_TEMP:-.}"/bundle_*.js; do
|
||||
node --check "$f" || { echo "FAIL $f"; exit 1; }
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Read `ProjectStructure.md` in this directory for the full reference (file struct
|
||||
|
||||
1. **Python Backend** (runs inside ComfyUI server) — nodes, orchestration, generation, API endpoints
|
||||
2. **JS Frontend Bridge** (`web/`) — registers nodes in ComfyUI browser tab, served at `/extensions/`
|
||||
3. **Dashboard SPA** (`resources/`) — standalone app inlined into iframe by `html_generator.py`, zero external requests
|
||||
3. **Dashboard SPA** (`resources/`) — standalone app; `html_generator.py` builds a small shell (iframe `srcdoc` / `/config_tester/dashboard`) that loads two cached bundle routes (`/config_tester/dashboard_bundle_js` / `_css`, `?v=<hash>`); `inline_assets=True` / `USCG_INLINE_DASHBOARD_ASSETS=1` inlines them again
|
||||
|
||||
## Critical Files & Their Roles
|
||||
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ for f in resources/*.js; do
|
||||
done
|
||||
```
|
||||
|
||||
The Dashboard files in `resources/` are inlined into ONE script (see `js_files` in `html_generator.py`), so a duplicate top-level name only shows up in the assembled page. The `js-syntax` job therefore also builds the page with `html_generator.get_html_template()` and syntax-checks every inline script body (the snippet is in `.github/workflows/tests.yml`).
|
||||
The Dashboard files in `resources/` share ONE script scope (see `JS_FILES` in `html_generator.py`; the page loads them as one bundle after a small globals script), so a duplicate top-level name only shows up in the assembled page. The `js-syntax` job therefore also builds the page with `html_generator.get_html_template(..., inline_assets=True)` and syntax-checks every inline script body, plus the globals script + bundle joined as one script (the snippet is in `.github/workflows/tests.yml`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-5
@@ -244,6 +244,8 @@ All routes registered via `PromptServer.instance.routes`. One row per route this
|
||||
| POST | `/config_tester/save_manifest` | `__init__.py` | `{session_name, manifest}` | Full merge save (keeps server-side `meta` and `comparisons`) |
|
||||
| POST | `/config_tester/get_session_html` | `__init__.py` | `{session_name, node_id}` | Generate the dashboard HTML on demand (empty manifest for an unknown session, not a 404) |
|
||||
| GET | `/config_tester/dashboard` | `__init__.py` | `?session=` (optional) | Dashboard SPA as a top-level page (standalone mode, `USCG_STANDALONE`, own websocket via `resources/logic_standalone.js`). A lazy shell (no manifest inlined); gzip + content ETag (304 on `If-None-Match`) |
|
||||
| GET | `/config_tester/dashboard_bundle_js` | `__init__.py` | `?v=<hash>` (compared with the bundle hash, never used as a path) | The dashboard's concatenated `resources/logic_*.js` (`html_generator.get_dashboard_bundle('js')`). Fixed path, no path parameters. `text/javascript`, weak ETag (304 on `If-None-Match`), precomputed gzip, `Cache-Control: public, max-age=31536000, immutable` when `v` matches the current hash (`no-cache` otherwise; a stale `v` still gets the current bundle) |
|
||||
| GET | `/config_tester/dashboard_bundle_css` | `__init__.py` | `?v=<hash>` | Same for `resources/report.css` (`text/css`) |
|
||||
| GET | `/config_tester/manifest` | `__init__.py` | `?session=` | A session's manifest as compact JSON for the SPA (lazy loading). Takes a session NAME, never a path. Weak `ETag` (mtime+size) + `If-None-Match` -> 304, gzip when accepted, `Cache-Control: no-cache`; 400 empty name, 403 outside benchmarks, 404 unknown, 503 + `Retry-After` for a torn read (`save_manifest` is not atomic; 3 retries first). Logic in `run_api.load_manifest_for_fetch` |
|
||||
| POST | `/config_tester/run` | `__init__.py` | `{configs_json or config_name, session_name?, overwrite_existing?, force? (default true), front?, client_id?}` | Headless run: builds the Generator+Dashboard prompt and enqueues it in-process like `/prompt`. Helpers in `run_api.py` |
|
||||
| GET | `/config_tester/run_status` | `__init__.py` | `?prompt_id=` | `pending`, `running`, `success` or `error` + session / dashboard_url / item_count |
|
||||
@@ -331,10 +333,11 @@ All routes registered via `PromptServer.instance.routes`. One row per route this
|
||||
|
||||
|
||||
* **`html_generator.py`** (57 lines)
|
||||
* `get_html_template(title, manifest_data, node_id, standalone=False, inline_manifest=False)` — reads all `/resources/` files, inlines them into `template.html`. By default the page is a **lazy shell**: `fullManifest` is empty and flagged `__uscg_lazy__`, `manifest_data` is ignored, and the SPA fetches `GET /config_tester/manifest?session=<title>` itself (`inline_manifest=True` restores the old inlined data; used by tests)
|
||||
* **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ratings.js` → `logic_wfparams.js` → `logic_dragdrop.js` → `logic_ui.js` → `logic_upscale_modal.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_compare.js` → `logic_events.js` → `logic_init.js` → `logic_standalone.js` (all files share ONE inlined script: top-level names must be unique across them)
|
||||
* Replaces placeholders: `__CSS_CONTENT__`, `__JS_CONTENT__`, `__TITLE__`, `__NODE_ID__`
|
||||
* Zero external HTTP requests for code (JS/CSS inlined); the manifest comes from `/config_tester/manifest`
|
||||
* `get_html_template(title, manifest_data, node_id, standalone=False, inline_manifest=False, inline_assets=None)` — builds the dashboard page from `template.html`. By default the page is a **lazy shell**: `fullManifest` is empty and flagged `__uscg_lazy__`, `manifest_data` is ignored, and the SPA fetches `GET /config_tester/manifest?session=<title>` itself (`inline_manifest=True` restores the old inlined data; used by tests). The JS/CSS are NOT inlined by default: the shell carries a small per-page globals script (`TARGET_NODE_ID`, `USCG_STANDALONE`, `fullManifest`) followed by `<script src="/config_tester/dashboard_bundle_js?v=<hash>">` and a `<link>` to `dashboard_bundle_css` (~70 KB shell instead of ~527 KB). `inline_assets=True` (implied by `inline_manifest=True`, or env `USCG_INLINE_DASHBOARD_ASSETS=1`) inlines the same bytes instead (self-contained page for tests / offline export)
|
||||
* `get_dashboard_bundle(kind)` (`'js'`/`'css'`) returns a cached `Bundle` (text, body, precomputed gzip, content hash), rebuilt under a lock when a resource file's `(mtime_ns, size)` changes, so editing `resources/` still hot-reloads; `bundle_url(kind)`; `JS_FILES`/`CSS_FILES`/`BUNDLE_ROUTES`/`BUNDLE_CONTENT_TYPES`. `html_generator.py` must keep NO relative imports (the e2e conftest loads it by path). HTTP shaping lives in `run_api.bundle_response`
|
||||
* **JS Load Order:** `logic_state.js` → `logic_utils.js` → `logic_ratings.js` → `logic_wfparams.js` → `logic_dragdrop.js` → `logic_ui.js` → `logic_upscale_modal.js` → `logic_virtual.js` → `logic_pipeline.js` → `logic_compare.js` → `logic_events.js` → `logic_init.js` → `logic_standalone.js` (all files share ONE script scope, together with the shell's globals script: top-level names must be unique across them)
|
||||
* Replaces placeholders: `__CSS_TAG__`, `__JS_TAG__` (substituted LAST, so resource text is never placeholder-substituted), `__TITLE__`, `__NODE_ID__`, `__STANDALONE_FLAG__`
|
||||
* Two cached same-origin requests for code (the bundle routes; immutable after the first load, so a repeat load costs only the shell's 304); the manifest comes from `/config_tester/manifest`. The `/*__JSON_START__*/ ... /*__JSON_END__*/` markers in the globals script are regex-parsed by `web/dashboard.js` and `dashboard_node.py`: keep them exactly
|
||||
|
||||
---
|
||||
|
||||
@@ -834,7 +837,7 @@ __init__.py
|
||||
|
||||
3. **onNodeCreated Must Be Synchronous** — In `conf-builder-main.js` line 51, `onNodeCreated` MUST remain synchronous. Async initialization runs inside a fire-and-forget `(async () => { ... })()` IIFE. Making `onNodeCreated` itself async breaks widget registration.
|
||||
|
||||
4. **Resources Are Inlined, Data Is Not** — `html_generator.py` reads all `/resources/` files and bakes them into one HTML string (~500 KB; the same string rides ComfyUI's `executed` websocket message and history, so it must not grow with the session). The manifest is NOT in it: the page is a lazy shell and `loadSession()` fetches `/config_tester/manifest` (compact, gzip, ETag). Changes to dashboard JS/CSS: F5 on `/config_tester/dashboard` or the node's RELOAD button rebuild the page from `resources/` on every request; a node that is already showing a page keeps the old JS until RELOAD. The Generator's `executed` message carries a shell, so `web/dashboard.js` turns it into a `reload_manifest` postMessage (conditional re-fetch) instead of parsing data out of the HTML.
|
||||
4. **Resources Are Bundled, Data Is Not** — `html_generator.py` concatenates the `/resources/` JS and CSS into two cached bundles served by `/config_tester/dashboard_bundle_js` / `_css` (content-hash `?v=`, immutable caching); the page is a ~70 KB shell that references them (the shell rides ComfyUI's `executed` websocket message and history, so it must not grow with the session; `inline_assets=True` / `USCG_INLINE_DASHBOARD_ASSETS=1` inlines everything again). The manifest is NOT in it: the page is a lazy shell and `loadSession()` fetches `/config_tester/manifest` (compact, gzip, ETag). Changes to dashboard JS/CSS: F5 on `/config_tester/dashboard` or the node's RELOAD button rebuild the shell on every request and the bundle when a resource file changed (new `?v=` hash, so the browser fetches it once); a node that is already showing a page keeps the old JS until RELOAD. The Generator's `executed` message carries a shell, so `web/dashboard.js` turns it into a `reload_manifest` postMessage (conditional re-fetch) instead of parsing data out of the HTML.
|
||||
|
||||
5. **Manifest Merge on Save** — `manifest_utils.py` `save_manifest()` calls `merge_manifest_user_changes()` which reloads from disk before saving. This preserves user favorites/rejected/notes that changed concurrently while generation was running. Also, `flush_batch_with_vae()` (image_generation.py line 657) does its own disk sync.
|
||||
|
||||
|
||||
@@ -1025,7 +1025,7 @@ http://<comfyui-host>:8188/config_tester/dashboard?session=<session_name>
|
||||
- It opens its own ComfyUI websocket, so new images and the ETA bar update live while a run is going. After your phone sleeps, it reloads the session when you come back, without duplicating cards. Reverse proxies that do not upgrade websockets (some Tailscale Serve / nginx setups) lose live updates; the **R** key and the resume reload still work.
|
||||
- Touch screens get 44px buttons, and the lightbox gets a bottom bar (Prev / Favorite / Reject / Revise / Next). The in-node view is unchanged apart from the extra header button.
|
||||
- **GENERATE NEW** in the lightbox queues through the run API below, into the session you are viewing. "Pack workflow" in export needs the ComfyUI tab and is disabled here.
|
||||
- **Lazy loading:** the page itself is only the dashboard code (~500 KB, ~115 KB gzipped); the image data comes from `GET /config_tester/manifest?session=<name>` (compact JSON, gzip, `ETag`). A 1000-image session is about 160 KB over the wire instead of 3.7 MB of page plus 3.5 MB of `manifest.json`, and a phone that wakes up and finds nothing new pays one 304. Both the page and the manifest answer `If-None-Match`, so a reload with nothing changed is nearly free. `--enable-compress-response-body` is not needed.
|
||||
- **Lazy loading:** the page itself is a small shell (~70 KB, ~12 KB gzipped); the dashboard code is two cached files, `/config_tester/dashboard_bundle_js` (~409 KB, ~99 KB gzipped) and `_css` (~49 KB, ~9 KB gzipped), referenced by content hash with `Cache-Control: immutable`, so the browser downloads them once and every later load (and every node iframe) pays only the shell's 304. Editing a file in `resources/` changes the hash, so a reload picks it up. The image data comes from `GET /config_tester/manifest?session=<name>` (compact JSON, gzip, `ETag`). A 1000-image session is about 160 KB over the wire instead of 3.7 MB of page plus 3.5 MB of `manifest.json`, and a phone that wakes up and finds nothing new pays one 304. The page, the bundles and the manifest all answer `If-None-Match`. `--enable-compress-response-body` is not needed. Set `USCG_INLINE_DASHBOARD_ASSETS=1` to inline everything into the page again (one self-contained document).
|
||||
- Like ComfyUI itself, the page has no authentication. Only expose it on a network you trust.
|
||||
|
||||
---
|
||||
@@ -1563,6 +1563,8 @@ Bot pattern: `POST /run`, post `dashboard_url` to the channel, poll `run_status`
|
||||
|---|---|---|
|
||||
| POST | `/configbuilder/preview` | Body `{state}` (the Builder state); returns `{configs_json}` from the same function the node uses (what the JSON Preview panel shows) |
|
||||
| GET | `/config_tester/dashboard` | The Dashboard as a standalone page, optional `?session=` |
|
||||
| GET | `/config_tester/dashboard_bundle_js` | The dashboard's JavaScript bundle (`?v=<hash>`, cached immutable, gzip) |
|
||||
| GET | `/config_tester/dashboard_bundle_css` | The dashboard's stylesheet bundle (`?v=<hash>`, cached immutable, gzip) |
|
||||
| GET | `/config_tester/manifest` | A session's manifest as compact JSON `?session=<name>` (gzip, ETag / 304); what the Dashboard fetches |
|
||||
| POST | `/config_tester/save_comparisons` | Append / remove Pairwise Review votes `{session_name, append, remove}` |
|
||||
| POST | `/config_tester/clear_comparisons` | Delete every vote of a session `{session_name}` |
|
||||
@@ -1751,7 +1753,7 @@ Tests live in `tests/` and must not contain network or process-spawning code (no
|
||||
|
||||
2. **One config-to-JSON implementation**: `UltimateConfigBuilder.state_to_configs_json` in `config_builder_node.py` is the single source of truth. The Builder preview POSTs the state to `/configbuilder/preview`, which runs the same function; there is no JS converter any more.
|
||||
|
||||
3. **Dashboard runs in an iframe** — The dashboard HTML is set via `iframe.srcdoc`. All JS/CSS is inlined (no external code requests); the manifest is fetched from `/config_tester/manifest` (the page is a lazy shell with no item data). Communication between the parent ComfyUI page and the iframe uses `postMessage()`. The `dashboard.js` file bridges server events to iframe messages.
|
||||
3. **Dashboard runs in an iframe** — The dashboard HTML is set via `iframe.srcdoc`. The JS/CSS come from the two cached bundle routes (same origin, absolute `/config_tester/dashboard_bundle_*` paths); the manifest is fetched from `/config_tester/manifest` (the page is a lazy shell with no item data). Communication between the parent ComfyUI page and the iframe uses `postMessage()`. The `dashboard.js` file bridges server events to iframe messages.
|
||||
|
||||
4. **Dashboard auto-loads on node creation** — `web/dashboard.js` calls `forceLoadSession()` with a 500ms delay when a dashboard node is created. The `get_session_html` API endpoint returns a full HTML template with empty manifest for non-existent sessions (NOT a 404).
|
||||
|
||||
@@ -1832,6 +1834,7 @@ After modifying backend Python files, restart ComfyUI. After modifying `web/` JS
|
||||
* 📐 **Latent input size:** a connected latent's image size was always cells x 8; it is now cells x the loaded model's latent ratio (16 for Flux.2/MOSS-Image/Krea 2).
|
||||
|
||||
### Update 11/4/26: Standalone dashboard, pairwise review, image metrics, preview pass, workflow sweeps
|
||||
* ⚡ **Dashboard loads from cached bundles:** the dashboard page shrank from ~527 KB to ~70 KB. Its JavaScript and CSS are served once as content-hashed, immutable files (`/config_tester/dashboard_bundle_js` and `_css`), so a repeat load or a second dashboard costs only a small 304, and each run's result message (which carries the page) is ~450 KB lighter. Edit-and-reload of `resources/` still works. `USCG_INLINE_DASHBOARD_ASSETS=1` restores the single-file page.
|
||||
* 🧩 **Regional prompts (`COUPLE`):** `base COUPLE left | right` (or `COUPLE(rows)`, 2-4 regions, per-region `:w` weight) gives each part of the image its own prompt using native masked conditioning. Triggers, model prefix/suffix and HiRes "Append to end" text go into the base; works with HiRes, tiled HiRes and Florence2 crops. The conditioning cache on disk now keeps every entry (AND prompts included), so older cache files are re-encoded once. See [Prompt Combinators & Regional Prompts (COUPLE)](#prompt-combinators--regional-prompts-couple).
|
||||
* 📱 **Standalone Dashboard + Run API:** open the dashboard as a normal web page at `/config_tester/dashboard?session=<name>` (phone, second monitor) with live updates over its own websocket. `POST /config_tester/run` and `GET /config_tester/run_status` start and track headless runs (Discord bot, curl) without any workflow JSON.
|
||||
* ⚖️ **Pairwise Review:** blind A/B compare mode (`C` key) with matched, same-settings, one-axis and random pairing, Bradley-Terry ratings (Sort by Rating) and a per-setting scoreboard with 95% intervals. Votes are stored in the session manifest under `comparisons`.
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ Biggest architectural leverage: eliminates the #1 developer friction point.
|
||||
|
||||
STARTED (11/4/26): `/config_tester/dashboard` serves the SPA as a standalone page with its own websocket.
|
||||
DONE (11/4/26) Milestone 1: the page is a lazy shell (`html_generator`, `__uscg_lazy__`); the SPA fetches `GET /config_tester/manifest?session=` (compact JSON, gzip, ETag/304; the route is named `manifest`, not `get_manifest`). The node's `executed` message no longer carries the manifest (`web/dashboard.js` sends `reload_manifest`). Milestone 3 comes free for F5 / RELOAD (the page is rebuilt from `resources/` on every request); a node already showing a page keeps its old JS until RELOAD.
|
||||
Milestone 2 is blocked as written: ComfyUI's frontend imports every `web/**/*.js` as an extension module, so the `logic_*.js` files in `WEB_DIRECTORY` would run inside the ComfyUI page. Sketch of the cheap alternative: fixed path-less routes `GET /config_tester/dashboard_bundle_js?v=<hash>` / `..._css?v=<hash>` serving the concatenated files with ETag, `Cache-Control: public, max-age=31536000, immutable` and gzip, referenced from the shell with `<script src>` / `<link>` (the shell drops to ~70 KB and repeat loads to ~0 KB). Needs: rework `test_concatenated_script_parses_as_one_script`, the e2e fake origin serving the bundle, a script-order check.
|
||||
DONE (11/4/26) Milestone 2 via the cheap alternative (static `web/` serving is blocked: ComfyUI's frontend imports every `web/**/*.js` as an extension module): fixed path-less routes `GET /config_tester/dashboard_bundle_js?v=<hash>` / `..._css?v=<hash>` serve the concatenated `resources/` files (`html_generator.get_dashboard_bundle`, rebuilt on `(mtime, size)` change so Milestone 3 hot reload still works) with a weak ETag, `Cache-Control: public, max-age=31536000, immutable`, precomputed gzip; the shell references them with `<script src>` / `<link>` and shrank from ~527 KB to ~70 KB, repeat loads cost only the shell's 304. The node iframe uses them too (every run's `executed` message and history entry is ~450 KB lighter). `inline_assets=True` / env `USCG_INLINE_DASHBOARD_ASSETS=1` keeps a self-contained page (`inline_manifest=True` implies it). Tests: `tests/test_dashboard_bundle.py`, `tests/e2e/test_dashboard_bundle.py`.
|
||||
|
||||
#### **28. Onboarding Wizard & Tutorial**
|
||||
* **Milestone 1:** First-run wizard in Builder UI — walks through: create session → pick model → add LoRA → run → view dashboard (5 interactive steps)
|
||||
|
||||
+29
-2
@@ -13,14 +13,14 @@ import time
|
||||
import inspect
|
||||
from .sampler_node import SamplerGridTester
|
||||
from .dashboard_node import SamplerConfigDashboardViewer
|
||||
from .html_generator import get_html_template
|
||||
from .html_generator import get_html_template, get_dashboard_bundle, BUNDLE_CONTENT_TYPES
|
||||
from .config_builder_node import UltimateConfigBuilder
|
||||
from .json_text_node import SmartJSONTextNode
|
||||
from .metadata_packer import pack_metadata_into_image
|
||||
from .directory_scanner import scan_directory_for_images
|
||||
from .run_api import (
|
||||
RunTracker, sanitize_session_name_strict, load_manifest_for_fetch, normalize_configs_payload, apply_run_overrides,
|
||||
build_run_prompt, resolve_saved_config, saved_config_to_configs_obj, summarize_history, find_in_queue,
|
||||
build_run_prompt, resolve_saved_config, saved_config_to_configs_obj, summarize_history, find_in_queue, bundle_response,
|
||||
)
|
||||
from .workflow_sweep_collect import USCGSweepCollect, USCGSweepCollectVideo
|
||||
import sys
|
||||
@@ -649,6 +649,33 @@ def _dashboard_response(request, html):
|
||||
return resp
|
||||
|
||||
|
||||
def _bundle_http(request, kind):
|
||||
"""Serve the cached dashboard JS/CSS bundle. `kind` is a literal per route; the request only
|
||||
contributes ?v= (compared, never looked up) and the caching/encoding headers."""
|
||||
try:
|
||||
bundle = get_dashboard_bundle(kind)
|
||||
status, body, headers = bundle_response(
|
||||
bundle, request.query.get("v", ""), request.headers.get("If-None-Match"),
|
||||
request.headers.get("Accept-Encoding", ""))
|
||||
if body is None:
|
||||
return web.Response(status=status, headers=headers)
|
||||
return web.Response(status=status, body=body, content_type=BUNDLE_CONTENT_TYPES[kind],
|
||||
charset="utf-8", headers=headers)
|
||||
except Exception as e:
|
||||
print(f"[GridTester] /dashboard_bundle_{kind} failed: {e}")
|
||||
return web.Response(status=500, text="bundle unavailable")
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/config_tester/dashboard_bundle_js")
|
||||
async def dashboard_bundle_js(request):
|
||||
return _bundle_http(request, "js")
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/config_tester/dashboard_bundle_css")
|
||||
async def dashboard_bundle_css(request):
|
||||
return _bundle_http(request, "css")
|
||||
|
||||
|
||||
@server.PromptServer.instance.routes.get("/config_tester/dashboard")
|
||||
async def standalone_dashboard(request):
|
||||
"""
|
||||
|
||||
+3
-1
@@ -86,7 +86,9 @@ def _install_stubs():
|
||||
_subs = {
|
||||
"sampler_node": {"SamplerGridTester": type("SamplerGridTester", (), {})},
|
||||
"dashboard_node": {"SamplerConfigDashboardViewer": type("SamplerConfigDashboardViewer", (), {})},
|
||||
"html_generator": {"get_html_template": lambda *a, **kw: ""},
|
||||
"html_generator": {"get_html_template": lambda *a, **kw: "",
|
||||
"get_dashboard_bundle": lambda *a, **kw: None,
|
||||
"BUNDLE_CONTENT_TYPES": {}},
|
||||
"json_text_node": {"SmartJSONTextNode": type("SmartJSONTextNode", (), {})},
|
||||
"metadata_packer": {"pack_metadata_into_image": lambda *a, **kw: None},
|
||||
"directory_scanner": {
|
||||
|
||||
+120
-46
@@ -1,7 +1,103 @@
|
||||
import os
|
||||
import json
|
||||
import gzip
|
||||
import hashlib
|
||||
import threading
|
||||
from typing import NamedTuple
|
||||
|
||||
def get_html_template(title, manifest_data, node_id, standalone=False, inline_manifest=False):
|
||||
RESOURCE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources")
|
||||
|
||||
# JS load order (dependencies first!). logic_standalone.js must stay LAST.
|
||||
JS_FILES = (
|
||||
"logic_state.js", # Global variables
|
||||
"logic_utils.js", # API & Helper functions
|
||||
"logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard)
|
||||
"logic_wfparams.js", # Workflow Sweep: wf_params helpers (pure; must load before logic_ui/logic_pipeline)
|
||||
"logic_dragdrop.js", # Revise-modal image drag/send helpers (issue #22; pure, node-testable)
|
||||
"logic_ui.js", # DOM Creation, Modals, JSON Bars
|
||||
"logic_upscale_modal.js", # Upscale Modal (preset management, async upscale)
|
||||
"logic_virtual.js", # The Virtual Scroll Engine & Viewport
|
||||
"logic_pipeline.js", # Sorting & Filtering Logic
|
||||
"logic_compare.js", # Pairwise review: compare overlay, votes, scoreboard modal
|
||||
"logic_events.js", # Event Listeners (Message, Keyboard)
|
||||
"logic_init.js", # Initialization
|
||||
"logic_standalone.js" # Standalone-page mode (own websocket, run API); must stay LAST
|
||||
)
|
||||
CSS_FILES = ("report.css",)
|
||||
|
||||
# Fixed bundle routes (no path parameters; the ?v= query is only compared, never used for lookup).
|
||||
BUNDLE_ROUTES = {"js": "/config_tester/dashboard_bundle_js", "css": "/config_tester/dashboard_bundle_css"}
|
||||
BUNDLE_CONTENT_TYPES = {"js": "text/javascript", "css": "text/css"}
|
||||
|
||||
|
||||
class Bundle(NamedTuple):
|
||||
kind: str
|
||||
sig: tuple
|
||||
text: str
|
||||
body: bytes
|
||||
gzip: bytes
|
||||
hash: str
|
||||
|
||||
|
||||
_bundle_cache = {}
|
||||
_bundle_lock = threading.Lock()
|
||||
|
||||
|
||||
def _files_for(kind):
|
||||
if kind == "js":
|
||||
return JS_FILES
|
||||
if kind == "css":
|
||||
return CSS_FILES
|
||||
raise KeyError(kind)
|
||||
|
||||
|
||||
def _signature(names):
|
||||
"""(name, mtime_ns, size) per resource file: cheap change detection for hot reload."""
|
||||
sig = [("dir", RESOURCE_DIR, 0)]
|
||||
for n in names:
|
||||
st = os.stat(os.path.join(RESOURCE_DIR, n))
|
||||
sig.append((n, st.st_mtime_ns, st.st_size))
|
||||
return tuple(sig)
|
||||
|
||||
|
||||
def _build_text(kind):
|
||||
names = _files_for(kind)
|
||||
if kind == "css":
|
||||
with open(os.path.join(RESOURCE_DIR, names[0]), "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
text = ""
|
||||
for n in names:
|
||||
with open(os.path.join(RESOURCE_DIR, n), "r", encoding="utf-8") as f:
|
||||
text += f"\n/* --- {n} --- */\n"
|
||||
text += f.read() + "\n"
|
||||
return text
|
||||
|
||||
|
||||
def get_dashboard_bundle(kind):
|
||||
"""The page-independent JS or CSS bundle, rebuilt only when a resource file changed.
|
||||
|
||||
Thread-safe (called from the aiohttp loop and from the execution thread). Raises KeyError for
|
||||
an unknown kind and OSError when a resource file is missing.
|
||||
"""
|
||||
names = _files_for(kind)
|
||||
with _bundle_lock:
|
||||
sig = _signature(names)
|
||||
cached = _bundle_cache.get(kind)
|
||||
if cached is not None and cached.sig == sig:
|
||||
return cached
|
||||
text = _build_text(kind)
|
||||
body = text.encode("utf-8")
|
||||
bundle = Bundle(kind, sig, text, body, gzip.compress(body, 9, mtime=0),
|
||||
hashlib.sha1(body).hexdigest()[:20])
|
||||
_bundle_cache[kind] = bundle
|
||||
return bundle
|
||||
|
||||
|
||||
def bundle_url(kind):
|
||||
return f"{BUNDLE_ROUTES[kind]}?v={get_dashboard_bundle(kind).hash}"
|
||||
|
||||
|
||||
def get_html_template(title, manifest_data, node_id, standalone=False, inline_manifest=False, inline_assets=None):
|
||||
"""Build the dashboard page.
|
||||
|
||||
By default the page is a SHELL: no item data is inlined (an empty `fullManifest` flagged
|
||||
@@ -9,6 +105,10 @@ def get_html_template(title, manifest_data, node_id, standalone=False, inline_ma
|
||||
keeps the HTML (and ComfyUI's `executed` websocket message / history entry, which carries the
|
||||
whole string) ~500 KB instead of growing with the session. `manifest_data` is only used when
|
||||
`inline_manifest=True` (old behaviour; offline export / tests).
|
||||
|
||||
The JS/CSS are NOT inlined by default: the page references two cached bundle routes
|
||||
(`bundle_url`), which cuts the shell to ~70 KB. `inline_assets=True` (implied by
|
||||
`inline_manifest=True`, or env USCG_INLINE_DASHBOARD_ASSETS=1) inlines the same bytes instead.
|
||||
"""
|
||||
# 1. Normalize Data
|
||||
if not inline_manifest:
|
||||
@@ -24,57 +124,31 @@ def get_html_template(title, manifest_data, node_id, standalone=False, inline_ma
|
||||
# JSON.parse (web/dashboard.js's incremental refresh) still reads the original text.
|
||||
json_str = json_str.replace("</", "<\\/")
|
||||
|
||||
# 2. Resolve File Paths
|
||||
current_dir = os.path.dirname(__file__)
|
||||
resource_dir = os.path.join(current_dir, "resources")
|
||||
|
||||
# Define JS Load Order (Dependencies first!)
|
||||
js_files = [
|
||||
"logic_state.js", # Global variables
|
||||
"logic_utils.js", # API & Helper functions
|
||||
"logic_ratings.js", # Pairwise review: pure logic (normalisers, Bradley-Terry, pairing, scoreboard)
|
||||
"logic_wfparams.js", # Workflow Sweep: wf_params helpers (pure; must load before logic_ui/logic_pipeline)
|
||||
"logic_dragdrop.js", # Revise-modal image drag/send helpers (issue #22; pure, node-testable)
|
||||
"logic_ui.js", # DOM Creation, Modals, JSON Bars
|
||||
"logic_upscale_modal.js", # Upscale Modal (preset management, async upscale)
|
||||
"logic_virtual.js", # The Virtual Scroll Engine & Viewport
|
||||
"logic_pipeline.js", # Sorting & Filtering Logic
|
||||
"logic_compare.js", # Pairwise review: compare overlay, votes, scoreboard modal
|
||||
"logic_events.js", # Event Listeners (Message, Keyboard)
|
||||
"logic_init.js", # Initialization
|
||||
"logic_standalone.js" # Standalone-page mode (own websocket, run API); must stay LAST
|
||||
]
|
||||
# 2. Load template + assets
|
||||
if inline_assets is None:
|
||||
inline_assets = bool(inline_manifest)
|
||||
if os.environ.get("USCG_INLINE_DASHBOARD_ASSETS") == "1":
|
||||
inline_assets = True
|
||||
|
||||
try:
|
||||
# Load HTML
|
||||
with open(os.path.join(resource_dir, "template.html"), "r", encoding="utf-8") as f:
|
||||
with open(os.path.join(RESOURCE_DIR, "template.html"), "r", encoding="utf-8") as f:
|
||||
html_template = f.read()
|
||||
|
||||
# Load CSS
|
||||
with open(os.path.join(resource_dir, "report.css"), "r", encoding="utf-8") as f:
|
||||
css_content = f.read()
|
||||
|
||||
# Load and Concatenate JS
|
||||
js_content = ""
|
||||
for js_file in js_files:
|
||||
file_path = os.path.join(resource_dir, js_file)
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
js_content += f"\n/* --- {js_file} --- */\n"
|
||||
js_content += f.read() + "\n"
|
||||
|
||||
except FileNotFoundError as e:
|
||||
js_bundle = get_dashboard_bundle("js")
|
||||
css_bundle = get_dashboard_bundle("css")
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
return f"<h1>Error: Resource files not found.</h1><p>{e}</p>"
|
||||
|
||||
if inline_assets:
|
||||
css_tag = f"<style>\n/* INJECTED CSS */\n{css_bundle.text}\n</style>"
|
||||
js_tag = f"<script>\n/* INJECTED LOGIC */\n{js_bundle.text}\n</script>"
|
||||
else:
|
||||
css_tag = f'<link rel="stylesheet" href="{bundle_url("css")}">'
|
||||
js_tag = f'<script src="{bundle_url("js")}"></script>'
|
||||
|
||||
# 3. Inject Data
|
||||
wrapped_json = f"/*__JSON_START__*/\nlet fullManifest = {json_str};\n/*__JSON_END__*/"
|
||||
|
||||
# 4. Final Replacements
|
||||
final_html = html_template \
|
||||
.replace("__TITLE__", str(title)) \
|
||||
.replace("__NODE_ID__", str(node_id)) \
|
||||
.replace("__STANDALONE_FLAG__", "true" if standalone else "false") \
|
||||
.replace("let fullManifest = __JSON_DATA__;", wrapped_json) \
|
||||
.replace("__CSS_CONTENT__", css_content) \
|
||||
.replace("__JS_CONTENT__", js_content)
|
||||
# 4. Final Replacements (asset tags LAST so resource text is never placeholder-substituted)
|
||||
final_html = html_template .replace("__TITLE__", str(title)) .replace("__NODE_ID__", str(node_id)) .replace("__STANDALONE_FLAG__", "true" if standalone else "false") .replace("let fullManifest = __JSON_DATA__;", wrapped_json) .replace("__CSS_TAG__", css_tag) .replace("__JS_TAG__", js_tag)
|
||||
|
||||
return final_html
|
||||
return final_html
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
<head>
|
||||
<title>USCG - __TITLE__</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
/* INJECTED CSS */
|
||||
__CSS_CONTENT__
|
||||
</style>
|
||||
__CSS_TAG__
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -930,16 +927,13 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// INJECTED DATA
|
||||
// INJECTED DATA (per page)
|
||||
const TARGET_NODE_ID = "__NODE_ID__";
|
||||
// true when served by /config_tester/dashboard, or whenever this page is not inside a frame
|
||||
const USCG_STANDALONE = __STANDALONE_FLAG__ || (window.parent === window);
|
||||
let fullManifest = __JSON_DATA__;
|
||||
|
||||
// INJECTED LOGIC
|
||||
__JS_CONTENT__
|
||||
|
||||
</script>
|
||||
__JS_TAG__
|
||||
<style>
|
||||
/* Default behavior: The button is visible */
|
||||
.gh-hide-on-mobile {
|
||||
|
||||
+21
@@ -33,6 +33,27 @@ def _etag_matches(if_none_match, etag):
|
||||
return False
|
||||
|
||||
|
||||
def bundle_response(bundle, v, if_none_match, accept_encoding):
|
||||
"""HTTP shaping for the dashboard JS/CSS bundle routes: (status, body_or_None, headers).
|
||||
|
||||
`v` (the ?v= query) is only COMPARED with the bundle hash, never used to look anything up: a
|
||||
stale or bogus value still gets the current bundle, just with a no-cache policy.
|
||||
"""
|
||||
etag = 'W/"%s"' % bundle.hash
|
||||
headers = {
|
||||
"ETag": etag,
|
||||
"Vary": "Accept-Encoding",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "public, max-age=31536000, immutable" if v == bundle.hash else "no-cache",
|
||||
}
|
||||
if _etag_matches(if_none_match, etag):
|
||||
return 304, None, headers
|
||||
if "gzip" in str(accept_encoding or "").lower():
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
return 200, bundle.gzip, headers
|
||||
return 200, bundle.body, headers
|
||||
|
||||
|
||||
def load_manifest_for_fetch(benchmarks_base, raw_session, if_none_match=None):
|
||||
"""Backend of GET /config_tester/manifest?session=<name> (the dashboard SPA's data fetch).
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ _GEN_ORCH_DEPS = {
|
||||
},
|
||||
"html_generator": {
|
||||
"get_html_template": lambda *a, **kw: "",
|
||||
"get_dashboard_bundle": lambda *a, **kw: None,
|
||||
"BUNDLE_CONTENT_TYPES": {},
|
||||
},
|
||||
"conditioning_cache": {
|
||||
"ConditioningCache": type("ConditioningCache", (), {}),
|
||||
|
||||
+44
-7
@@ -17,17 +17,38 @@ SESSION = "smoke"
|
||||
N_ITEMS = 24
|
||||
|
||||
|
||||
def load_real_html_generator():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"uscg_real_html_generator", os.path.join(_NODE_ROOT, "html_generator.py"))
|
||||
_REAL_HG = None
|
||||
_REAL_RUN_API = None
|
||||
|
||||
|
||||
def _load_by_path(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(_NODE_ROOT, filename))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def build_dashboard_page(manifest, node_id="7", title=SESSION):
|
||||
"""The exact HTML string the Dashboard node shows in its iframe srcdoc (a lazy shell: no items inlined)."""
|
||||
return load_real_html_generator().get_html_template(title, manifest, node_id)
|
||||
def load_real_html_generator():
|
||||
"""Cached: the module keeps the bundle cache, so every page and every bundle response share it."""
|
||||
global _REAL_HG
|
||||
if _REAL_HG is None:
|
||||
_REAL_HG = _load_by_path("uscg_real_html_generator", "html_generator.py")
|
||||
return _REAL_HG
|
||||
|
||||
|
||||
def load_real_run_api():
|
||||
global _REAL_RUN_API
|
||||
if _REAL_RUN_API is None:
|
||||
_REAL_RUN_API = _load_by_path("uscg_real_run_api", "run_api.py")
|
||||
return _REAL_RUN_API
|
||||
|
||||
|
||||
def build_dashboard_page(manifest, node_id="7", title=SESSION, **kw):
|
||||
"""The exact HTML string the Dashboard node shows in its iframe srcdoc (a lazy shell: no items inlined).
|
||||
|
||||
Bundled by default (the JS/CSS come from the fake origin's bundle routes); pass inline_assets=True
|
||||
for the self-contained page."""
|
||||
return load_real_html_generator().get_html_template(title, manifest, node_id, **kw)
|
||||
|
||||
|
||||
def make_item(i, model, sampler, scheduler, cfg, steps, lora):
|
||||
@@ -110,6 +131,8 @@ class Dashboard:
|
||||
self.known_sessions = None # None: every session name exists (like the old /view); else a set
|
||||
self.hold_manifest = False # True: park manifest requests until release_manifest()
|
||||
self.held = []
|
||||
self.bundle_requests = [] # (kind, v) per GET /config_tester/dashboard_bundle_{js,css}
|
||||
self.bundle_status = None # e.g. 500: make the bundle routes fail
|
||||
|
||||
def release_manifest(self):
|
||||
self.hold_manifest = False
|
||||
@@ -155,6 +178,18 @@ class Dashboard:
|
||||
if path == "/":
|
||||
return route.fulfill(status=200, content_type="text/html; charset=utf-8",
|
||||
body=build_dashboard_page(self.manifest, title=self.title))
|
||||
if path in ("/config_tester/dashboard_bundle_js", "/config_tester/dashboard_bundle_css"):
|
||||
kind = "js" if path.endswith("_js") else "css"
|
||||
from urllib.parse import parse_qs
|
||||
v = (parse_qs(query).get("v") or [""])[0]
|
||||
self.bundle_requests.append((kind, v))
|
||||
if self.bundle_status:
|
||||
return route.fulfill(status=self.bundle_status, content_type="text/plain", body="bundle unavailable")
|
||||
hg = load_real_html_generator()
|
||||
status, body, headers = load_real_run_api().bundle_response(
|
||||
hg.get_dashboard_bundle(kind), v, None, "")
|
||||
return route.fulfill(status=status, content_type=hg.BUNDLE_CONTENT_TYPES[kind] + "; charset=utf-8",
|
||||
body=body, headers=headers)
|
||||
if path == "/config_tester/manifest":
|
||||
if self.hold_manifest:
|
||||
self.held.append(route)
|
||||
@@ -209,7 +244,9 @@ def dashboard(page, manifest_meta):
|
||||
@pytest.fixture
|
||||
def dashboard_kit():
|
||||
"""Raw pieces for tests that need a custom page around the dashboard (e.g. an iframe wrapper)."""
|
||||
return {"ORIGIN": ORIGIN, "Dashboard": Dashboard, "make_manifest": make_manifest}
|
||||
return {"ORIGIN": ORIGIN, "Dashboard": Dashboard, "make_manifest": make_manifest,
|
||||
"build_dashboard_page": build_dashboard_page, "load_real_html_generator": load_real_html_generator,
|
||||
"N_ITEMS": N_ITEMS}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Dashboard JS/CSS served as bundle routes (Roadmap 27, milestone 2).
|
||||
|
||||
The fake origin (conftest.py `Dashboard.handle`) serves the REAL bundles through `run_api.bundle_response`.
|
||||
Repeat-load caching is not testable here (page.route disables the HTTP cache): the live recipe covers it.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
if os.environ.get("USCG_REQUIRE_E2E") == "1":
|
||||
import playwright.sync_api # noqa: F401
|
||||
else:
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
|
||||
from playwright.sync_api import expect # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
ORIGIN = "http://uscg.test"
|
||||
N_ITEMS = 24
|
||||
|
||||
|
||||
def _setup(page, d):
|
||||
page.on("pageerror", lambda e: d.pageerrors.append(str(e)))
|
||||
page.route("**/*", d.handle)
|
||||
page.set_viewport_size({"width": 1600, "height": 1200})
|
||||
|
||||
|
||||
def test_page_loads_one_js_and_one_css_bundle(page, dashboard_kit):
|
||||
Dashboard, make_manifest = dashboard_kit["Dashboard"], dashboard_kit["make_manifest"]
|
||||
d = Dashboard(page, make_manifest())
|
||||
_setup(page, d)
|
||||
page.goto(ORIGIN + "/")
|
||||
page.wait_for_function(f"typeof processedData!=='undefined' && processedData.length==={N_ITEMS}")
|
||||
expect(page.locator("#grid .card:visible")).to_have_count(N_ITEMS)
|
||||
hg = dashboard_kit["load_real_html_generator"]()
|
||||
assert sorted(d.bundle_requests) == sorted([("js", hg.get_dashboard_bundle("js").hash),
|
||||
("css", hg.get_dashboard_bundle("css").hash)])
|
||||
assert page.evaluate("typeof uscgBootLoad") == "function"
|
||||
# a value that only report.css defines (:root --accent) is applied
|
||||
assert page.evaluate("getComputedStyle(document.documentElement).getPropertyValue('--accent').trim()") == "#00d1b2"
|
||||
assert page.evaluate("getComputedStyle(document.body).overflow") == "hidden"
|
||||
assert d.pageerrors == []
|
||||
|
||||
|
||||
def test_srcdoc_iframe_loads_the_bundle_from_the_parent_origin(page, dashboard_kit):
|
||||
Dashboard, make_manifest = dashboard_kit["Dashboard"], dashboard_kit["make_manifest"]
|
||||
d = Dashboard(page, make_manifest())
|
||||
_setup(page, d)
|
||||
shell = dashboard_kit["build_dashboard_page"](d.manifest)
|
||||
wrap = '<!doctype html><body><iframe id="f" style="width:1400px;height:900px"></iframe></body>'
|
||||
page.route(ORIGIN + "/wrap", lambda route: route.fulfill(status=200, content_type="text/html", body=wrap))
|
||||
page.goto(ORIGIN + "/wrap")
|
||||
page.evaluate("(h) => { document.getElementById('f').srcdoc = h; }", shell)
|
||||
page.locator("#f").wait_for()
|
||||
page.wait_for_function("document.getElementById('f').contentWindow.processedData && "
|
||||
f"document.getElementById('f').contentWindow.processedData.length==={N_ITEMS}")
|
||||
inner = page.frames[1]
|
||||
assert inner.evaluate("USCG_STANDALONE") is False
|
||||
assert inner.evaluate("processedData.length") == N_ITEMS
|
||||
assert {k for k, _ in d.bundle_requests} == {"js", "css"}
|
||||
assert d.pageerrors == []
|
||||
|
||||
|
||||
def test_inline_assets_page_makes_no_bundle_requests(page, dashboard_kit):
|
||||
Dashboard, make_manifest = dashboard_kit["Dashboard"], dashboard_kit["make_manifest"]
|
||||
d = Dashboard(page, make_manifest())
|
||||
_setup(page, d)
|
||||
page.route(ORIGIN + "/inline", lambda route: route.fulfill(
|
||||
status=200, content_type="text/html; charset=utf-8",
|
||||
body=dashboard_kit["build_dashboard_page"](d.manifest, inline_assets=True)))
|
||||
page.goto(ORIGIN + "/inline")
|
||||
page.wait_for_function(f"typeof processedData!=='undefined' && processedData.length==={N_ITEMS}")
|
||||
assert d.bundle_requests == []
|
||||
assert d.pageerrors == []
|
||||
|
||||
|
||||
def test_failing_bundle_route_does_not_hang_the_page(page, dashboard_kit):
|
||||
Dashboard, make_manifest = dashboard_kit["Dashboard"], dashboard_kit["make_manifest"]
|
||||
d = Dashboard(page, make_manifest())
|
||||
d.bundle_status = 500
|
||||
_setup(page, d)
|
||||
page.goto(ORIGIN + "/")
|
||||
page.wait_for_timeout(500)
|
||||
assert page.evaluate("typeof uscgBootLoad") == "undefined"
|
||||
assert page.evaluate("document.readyState") in ("interactive", "complete")
|
||||
assert sorted(k for k, _ in d.bundle_requests) == ["css", "js"]
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Dashboard JS/CSS bundle routes (Roadmap 27, milestone 2): html_generator bundles + run_api.bundle_response."""
|
||||
import gzip
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_PKG = os.path.basename(_ROOT)
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _load(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(f"{_PKG}.{name}_bundle_real", os.path.join(_ROOT, filename))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
mod.__package__ = _PKG
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
hg = _load("html_generator", "html_generator.py")
|
||||
run_api = _load("run_api", "run_api.py")
|
||||
|
||||
EXPECTED_JS = (
|
||||
"logic_state.js", "logic_utils.js", "logic_ratings.js", "logic_wfparams.js", "logic_dragdrop.js",
|
||||
"logic_ui.js", "logic_upscale_modal.js", "logic_virtual.js", "logic_pipeline.js", "logic_compare.js",
|
||||
"logic_events.js", "logic_init.js", "logic_standalone.js",
|
||||
)
|
||||
TOKENS = ("__CSS_TAG__", "__JS_TAG__", "__CSS_CONTENT__", "__JS_CONTENT__", "__TITLE__", "__NODE_ID__",
|
||||
"__STANDALONE_FLAG__")
|
||||
|
||||
|
||||
def _shell(title="t", node_id="1", **kw):
|
||||
return hg.get_html_template(title, {"items": []}, node_id, **kw)
|
||||
|
||||
|
||||
def _read(name):
|
||||
with open(os.path.join(hg.RESOURCE_DIR, name), "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_env(monkeypatch):
|
||||
monkeypatch.delenv("USCG_INLINE_DASHBOARD_ASSETS", raising=False)
|
||||
|
||||
|
||||
def test_js_files_exact_order():
|
||||
assert hg.JS_FILES == EXPECTED_JS
|
||||
assert hg.JS_FILES[-1] == "logic_standalone.js"
|
||||
assert hg.CSS_FILES == ("report.css",)
|
||||
|
||||
|
||||
def test_markers_and_each_file_once_in_order():
|
||||
text = hg.get_dashboard_bundle("js").text
|
||||
last = -1
|
||||
for f in EXPECTED_JS:
|
||||
marker = f"/* --- {f} --- */"
|
||||
assert text.count(marker) == 1
|
||||
pos = text.index(marker)
|
||||
assert pos > last
|
||||
last = pos
|
||||
assert text.count(_read(f)) == 1
|
||||
assert hg.get_dashboard_bundle("css").text == _read("report.css")
|
||||
|
||||
|
||||
def test_bundle_is_page_independent():
|
||||
b1 = hg.get_dashboard_bundle("js")
|
||||
_shell("a", "9", standalone=True)
|
||||
_shell("b", "x", inline_manifest=True)
|
||||
assert hg.get_dashboard_bundle("js") is b1
|
||||
for kind in ("js", "css"):
|
||||
for tok in TOKENS:
|
||||
assert tok not in hg.get_dashboard_bundle(kind).text
|
||||
|
||||
|
||||
def test_inline_and_bundled_modes_carry_the_same_code():
|
||||
page = _shell(inline_assets=True)
|
||||
js = hg.get_dashboard_bundle("js").text
|
||||
css = hg.get_dashboard_bundle("css").text
|
||||
assert "<script>\n/* INJECTED LOGIC */\n" + js + "\n</script>" in page
|
||||
assert "<style>\n/* INJECTED CSS */\n" + css + "\n</style>" in page
|
||||
|
||||
|
||||
def test_shell_references_bundles_with_hash():
|
||||
page = _shell(standalone=True)
|
||||
jh = hg.get_dashboard_bundle("js").hash
|
||||
ch = hg.get_dashboard_bundle("css").hash
|
||||
tag = f'<script src="/config_tester/dashboard_bundle_js?v={jh}"></script>'
|
||||
assert page.count(tag) == 1
|
||||
assert f'<link rel="stylesheet" href="/config_tester/dashboard_bundle_css?v={ch}">' in page
|
||||
assert "/* --- logic_" not in page
|
||||
size = len(page.encode("utf-8"))
|
||||
print(f"measured shell size: {size} bytes")
|
||||
assert size < 100 * 1024
|
||||
assert page.index("const TARGET_NODE_ID") < page.index(tag) < page.index('id="upscale-modal-overlay"')
|
||||
for tok in TOKENS:
|
||||
assert tok not in page
|
||||
|
||||
|
||||
def test_inline_manifest_implies_inline_assets_and_overrides():
|
||||
m = {"items": []}
|
||||
implied = hg.get_html_template("t", m, "1", inline_manifest=True)
|
||||
assert "/* INJECTED LOGIC */" in implied and "dashboard_bundle_js" not in implied
|
||||
assert "/* INJECTED LOGIC */" in hg.get_html_template("t", m, "1", inline_assets=True)
|
||||
off = hg.get_html_template("t", m, "1", inline_manifest=True, inline_assets=False)
|
||||
assert "/* INJECTED LOGIC */" not in off and "dashboard_bundle_js" in off
|
||||
|
||||
|
||||
def test_env_forces_inline(monkeypatch):
|
||||
monkeypatch.setenv("USCG_INLINE_DASHBOARD_ASSETS", "1")
|
||||
page = _shell()
|
||||
assert "/* INJECTED LOGIC */" in page and "dashboard_bundle_js" not in page
|
||||
|
||||
|
||||
def test_hash_follows_resource_changes(tmp_path, monkeypatch):
|
||||
dst = tmp_path / "resources"
|
||||
shutil.copytree(hg.RESOURCE_DIR, dst)
|
||||
monkeypatch.setattr(hg, "RESOURCE_DIR", str(dst))
|
||||
hg._bundle_cache.clear()
|
||||
try:
|
||||
b1 = hg.get_dashboard_bundle("js")
|
||||
assert hg.get_dashboard_bundle("js") is b1 # unchanged signature: cached object
|
||||
url1 = hg.bundle_url("js")
|
||||
init = dst / "logic_init.js"
|
||||
st = init.stat()
|
||||
os.utime(init, ns=(st.st_atime_ns, st.st_mtime_ns + 5_000_000_000)) # mtime only
|
||||
b2 = hg.get_dashboard_bundle("js")
|
||||
assert b2 is not b1 and b2.hash == b1.hash
|
||||
with open(init, "ab") as f:
|
||||
f.write(b"\n// hot reload edit\n")
|
||||
b3 = hg.get_dashboard_bundle("js")
|
||||
assert b3.hash != b1.hash
|
||||
assert hg.bundle_url("js") != url1 and b3.hash in hg.bundle_url("js")
|
||||
finally:
|
||||
hg._bundle_cache.clear()
|
||||
|
||||
|
||||
def test_gzip_round_trip_and_deterministic():
|
||||
for kind in ("js", "css"):
|
||||
b = hg.get_dashboard_bundle(kind)
|
||||
assert gzip.decompress(b.gzip) == b.body
|
||||
assert b.gzip == gzip.compress(b.body, 9, mtime=0)
|
||||
assert b.body == b.text.encode("utf-8")
|
||||
assert len(b.gzip) < len(b.body)
|
||||
|
||||
|
||||
def test_unknown_kind_raises():
|
||||
with pytest.raises(KeyError):
|
||||
hg.get_dashboard_bundle("../x")
|
||||
|
||||
|
||||
def test_bundle_response_headers():
|
||||
b = hg.get_dashboard_bundle("js")
|
||||
st, body, h = run_api.bundle_response(b, b.hash, None, "gzip, br")
|
||||
assert st == 200 and body == b.gzip
|
||||
assert h["Content-Encoding"] == "gzip"
|
||||
assert h["Cache-Control"] == "public, max-age=31536000, immutable"
|
||||
assert h["ETag"] == f'W/"{b.hash}"'
|
||||
assert h["Vary"] == "Accept-Encoding" and h["X-Content-Type-Options"] == "nosniff"
|
||||
st, body, h = run_api.bundle_response(b, b.hash, None, "")
|
||||
assert st == 200 and body == b.body and "Content-Encoding" not in h
|
||||
assert h["Vary"] == "Accept-Encoding" and h["X-Content-Type-Options"] == "nosniff"
|
||||
for v in ("bogus", "", None):
|
||||
_, _, h = run_api.bundle_response(b, v, None, "gzip")
|
||||
assert h["Cache-Control"] == "no-cache"
|
||||
for inm in (f'W/"{b.hash}"', f'"{b.hash}"', f'"zzz", W/"{b.hash}"'):
|
||||
st, body, h = run_api.bundle_response(b, b.hash, inm, "gzip")
|
||||
assert st == 304 and body is None and h["ETag"] == f'W/"{b.hash}"'
|
||||
st, _, _ = run_api.bundle_response(b, b.hash, 'W/"other"', "gzip")
|
||||
assert st == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("v", ["../../etc/passwd", "logic_ui.js", "..\\..\\windows\\win.ini"])
|
||||
def test_v_is_never_a_path(v):
|
||||
b = hg.get_dashboard_bundle("js")
|
||||
st, body, h = run_api.bundle_response(b, v, None, "")
|
||||
assert st == 200 and body == b.body and h["Cache-Control"] == "no-cache"
|
||||
|
||||
|
||||
def test_routes_are_fixed_paths():
|
||||
with open(os.path.join(_ROOT, "__init__.py"), encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
assert '"/config_tester/dashboard_bundle_js"' in src
|
||||
assert '"/config_tester/dashboard_bundle_css"' in src
|
||||
for path in re.findall(r'routes\.(?:get|post|put|delete)\(\s*"([^"]+)"', src):
|
||||
assert "{" not in path, path
|
||||
m = re.search(r"def _bundle_http\(.*?(?=\n@server)", src, re.S)
|
||||
assert m
|
||||
handlers = [m.group(0)]
|
||||
for fn in ("dashboard_bundle_js", "dashboard_bundle_css"):
|
||||
handlers.append(re.search(r"async def %s\(.*?(?=\n\n\n|\Z)" % fn, src, re.S).group(0))
|
||||
for body in handlers:
|
||||
for banned in ("match_info", "open(", "FileResponse", "enable_compression"):
|
||||
assert banned not in body, banned
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not installed")
|
||||
def test_bundle_parses_and_composes_with_globals():
|
||||
page = _shell(standalone=True)
|
||||
globals_js = re.search(r"<script>\s*// INJECTED DATA \(per page\)([\s\S]*?)</script>", page).group(1)
|
||||
js = hg.get_dashboard_bundle("js").text
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
for name, code in (("bundle.js", js), ("all.js", globals_js + "\n" + js)):
|
||||
path = os.path.join(d, name)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(code)
|
||||
out = subprocess.run([NODE, "--check", path], capture_output=True, timeout=60)
|
||||
assert out.returncode == 0, out.stderr.decode()
|
||||
@@ -165,7 +165,7 @@ def test_events_documented():
|
||||
|
||||
def test_js_files_order():
|
||||
src = _read("html_generator.py")
|
||||
m = re.search(r"js_files\s*=\s*\[(.*?)\n\s*\]", src, re.S)
|
||||
m = re.search(r"(?:JS_FILES|js_files)\s*=\s*[\[(](.*?)\n\s*[\])]", src, re.S)
|
||||
assert m
|
||||
files = re.findall(r'^\s*"([\w\-]+\.js)"', m.group(1), re.M)
|
||||
assert files and files[-1] == "logic_standalone.js"
|
||||
|
||||
@@ -75,9 +75,24 @@ def test_escaped_manifest_still_parses_with_dashboard_js_extraction():
|
||||
@pytest.mark.parametrize("standalone", [True, False])
|
||||
def test_concatenated_script_parses_as_one_script(standalone):
|
||||
page = _page(standalone=standalone)
|
||||
scripts = re.findall(r"<script>([\s\S]*?)</script>", page)
|
||||
scripts = re.findall(r"<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)</script>", page)
|
||||
assert scripts, "no inline script found"
|
||||
js = max(scripts, key=len)
|
||||
js = "\n;\n".join(scripts)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "page.js")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(js)
|
||||
out = subprocess.run([NODE, "--check", path], capture_output=True, timeout=60)
|
||||
assert out.returncode == 0, out.stderr.decode()
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not installed")
|
||||
def test_shell_script_parses_as_one_script():
|
||||
"""The shell's globals script + the bundle route's JS share one global lexical scope."""
|
||||
page = _shell(standalone=True)
|
||||
scripts = re.findall(r"<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)</script>", page)
|
||||
assert scripts
|
||||
js = "\n;\n".join(scripts) + "\n;\n" + html_generator.get_dashboard_bundle("js").text
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "page.js")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
@@ -106,7 +121,7 @@ def test_shell_inlines_no_item_data():
|
||||
|
||||
def test_shell_size_does_not_grow_with_the_session():
|
||||
big = [{"id": i, "note": "x" * 2500, "positive": "p" * 300} for i in range(1000)]
|
||||
assert len(_shell(big).encode("utf-8")) < 560 * 1024
|
||||
assert len(_shell(big).encode("utf-8")) < 100 * 1024
|
||||
assert len(_shell(big)) == len(_shell([]))
|
||||
|
||||
|
||||
@@ -115,7 +130,8 @@ def test_shell_placeholders_all_replaced():
|
||||
assert 'const TARGET_NODE_ID = "42";' in page
|
||||
assert "const USCG_STANDALONE = true" in page
|
||||
assert 'id="session-input" class="session-name-input" type="text" value="my-sess"' in page
|
||||
for token in ("__TITLE__", "__NODE_ID__", "__STANDALONE_FLAG__", "__CSS_CONTENT__", "__JS_CONTENT__"):
|
||||
for token in ("__TITLE__", "__NODE_ID__", "__STANDALONE_FLAG__", "__CSS_CONTENT__", "__JS_CONTENT__",
|
||||
"__CSS_TAG__", "__JS_TAG__"):
|
||||
assert token not in page
|
||||
|
||||
|
||||
@@ -133,18 +149,6 @@ def test_shell_parses_with_dashboard_js_extraction_as_lazy():
|
||||
assert out.stdout.decode() == '[true,0,"abc"]'
|
||||
|
||||
|
||||
@pytest.mark.skipif(NODE is None, reason="node not installed")
|
||||
def test_shell_script_parses_as_one_script():
|
||||
scripts = re.findall(r"<script>([\s\S]*?)</script>", _shell(standalone=True))
|
||||
js = max(scripts, key=len)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "page.js")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(js)
|
||||
out = subprocess.run([NODE, "--check", path], capture_output=True, timeout=60)
|
||||
assert out.returncode == 0, out.stderr.decode()
|
||||
|
||||
|
||||
def test_inline_manifest_flag_keeps_the_old_behaviour():
|
||||
page = _page()
|
||||
assert "__uscg_lazy__" not in page.split("/*__JSON_START__*/")[1].split("/*__JSON_END__*/")[0]
|
||||
|
||||
Reference in New Issue
Block a user