US02-05: Build the Static Application Shell
Add the reusable browser shell primitives Phase B views build on: - store.js: observable store (get/set/subscribe) - events.js: job activity adapter — SSE preferred, polling fallback, sharing the event `seq` as cursor so a transport switch drops nothing - api.js: cancellable() (AbortController) + job endpoints; aborted requests reject with code "cancelled" Make the SSE stream generically consumable: emit default `message` events with the type in the JSON payload instead of `event: <type>`, so a browser EventSource receives the open-ended type set (state:*, claimed, …) via onmessage without enumerating it. The `event: done` sentinel and resumable `id:` cursors are unchanged. Tests: frontend/js/tests/ in-browser unit suite (api errors + cancellation, store transitions, routing, SSE→polling fallback) served over the app's static mount and driven by tests/e2e/test_frontend_shell.py, which also asserts asset loading, deep-link + reload restore, JSON-only /api/v1, and a clean console/network. Reuses the installed playwright — no JS toolchain added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,22 @@
|
|||||||
// Single API client: one place for fetch, JSON, and the error envelope.
|
// Single API client: one place for fetch, JSON, the error envelope, and request
|
||||||
const BASE = "/api/v1";
|
// cancellation. Every method accepts an optional { signal } from cancellable().
|
||||||
|
export const BASE = "/api/v1";
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, { signal, ...options } = {}) {
|
||||||
const response = await fetch(BASE + path, {
|
let response;
|
||||||
headers: { "Content-Type": "application/json" },
|
try {
|
||||||
...options,
|
response = await fetch(BASE + path, {
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
signal,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// A caller-cancelled fetch is not a failure; tag it so views can ignore it.
|
||||||
|
if (error.name === "AbortError") {
|
||||||
|
error.code = "cancelled";
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
let body = null;
|
let body = null;
|
||||||
try {
|
try {
|
||||||
body = await response.json();
|
body = await response.json();
|
||||||
@@ -22,17 +33,37 @@ async function request(path, options = {}) {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancellation: hand callers a signal to pass into any request and a cancel() to
|
||||||
|
// abort the in-flight fetch. Aborted requests reject with code "cancelled".
|
||||||
|
export function cancellable() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
return { signal: controller.signal, cancel: () => controller.abort() };
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
listAssets: (params = {}) =>
|
listAssets: (params = {}, opts = {}) =>
|
||||||
request("/inventory/assets?" + new URLSearchParams(params).toString()),
|
request("/inventory/assets?" + new URLSearchParams(params).toString(), opts),
|
||||||
listClusters: (params = {}) =>
|
listClusters: (params = {}, opts = {}) =>
|
||||||
request("/duplicates/clusters?" + new URLSearchParams(params).toString()),
|
request("/duplicates/clusters?" + new URLSearchParams(params).toString(), opts),
|
||||||
getCluster: (id) => request(`/duplicates/clusters/${encodeURIComponent(id)}`),
|
getCluster: (id, opts = {}) =>
|
||||||
decide: (id, payload) =>
|
request(`/duplicates/clusters/${encodeURIComponent(id)}`, opts),
|
||||||
|
decide: (id, payload, opts = {}) =>
|
||||||
request(`/duplicates/clusters/${encodeURIComponent(id)}/decision`, {
|
request(`/duplicates/clusters/${encodeURIComponent(id)}/decision`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
...opts,
|
||||||
}),
|
}),
|
||||||
thumbnailUrl: (assetId, size = 512) =>
|
thumbnailUrl: (assetId, size = 512) =>
|
||||||
`${BASE}/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`,
|
`${BASE}/assets/${encodeURIComponent(assetId)}/thumbnail?size=${size}`,
|
||||||
|
|
||||||
|
// ── Jobs ────────────────────────────────────────────────────────────────
|
||||||
|
startJob: (payload, opts = {}) =>
|
||||||
|
request("/jobs", { method: "POST", body: JSON.stringify(payload), ...opts }),
|
||||||
|
getJob: (id, opts = {}) => request(`/jobs/${encodeURIComponent(id)}`, opts),
|
||||||
|
cancelJob: (id, opts = {}) =>
|
||||||
|
request(`/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST", ...opts }),
|
||||||
|
jobEvents: (id, after = 0, opts = {}) =>
|
||||||
|
request(`/jobs/${encodeURIComponent(id)}/events?after=${after}`, opts),
|
||||||
|
jobEventsStreamUrl: (id, after = 0) =>
|
||||||
|
`${BASE}/jobs/${encodeURIComponent(id)}/events/stream?after=${after}`,
|
||||||
};
|
};
|
||||||
|
|||||||
62
frontend/js/events.js
Normal file
62
frontend/js/events.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Job activity adapter: prefer SSE (resumable, pushed), fall back to polling the
|
||||||
|
// same durable events when EventSource is missing or the stream errors. The cursor
|
||||||
|
// is the event `seq`, shared by both transports, so switching mid-stream never
|
||||||
|
// drops or repeats an event. subscribeJob returns a stop() handle.
|
||||||
|
import { api } from "./api.js";
|
||||||
|
|
||||||
|
// Mirrors ACTIVE_STATES in services/jobs.py; a job outside this set is terminal
|
||||||
|
// and the poller stops.
|
||||||
|
const ACTIVE = new Set(["queued", "running", "cancelling", "retry_queued"]);
|
||||||
|
|
||||||
|
export function subscribeJob(jobId, { onEvent, onDone, onError, after = 0, pollMs = 1000 } = {}) {
|
||||||
|
let cursor = after;
|
||||||
|
let stopped = false;
|
||||||
|
let source = null;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
if (source) source.close();
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
if (onDone) onDone(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (stopped) return;
|
||||||
|
try {
|
||||||
|
const data = await api.jobEvents(jobId, cursor);
|
||||||
|
for (const event of data.events) {
|
||||||
|
cursor = event.seq;
|
||||||
|
if (onEvent) onEvent(event);
|
||||||
|
}
|
||||||
|
if (!ACTIVE.has(data.state)) return stop();
|
||||||
|
} catch (error) {
|
||||||
|
if (!stopped && onError) onError(error);
|
||||||
|
}
|
||||||
|
if (!stopped) timer = setTimeout(poll, pollMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof EventSource === "function") {
|
||||||
|
source = new EventSource(api.jobEventsStreamUrl(jobId, cursor));
|
||||||
|
source.onmessage = (message) => {
|
||||||
|
const event = JSON.parse(message.data);
|
||||||
|
cursor = Number(message.lastEventId) || cursor;
|
||||||
|
if (onEvent) onEvent({ seq: cursor, ...event });
|
||||||
|
};
|
||||||
|
// Server sends `event: done` when the job reaches a terminal state.
|
||||||
|
source.addEventListener("done", stop);
|
||||||
|
// Network/stream error: drop SSE and continue from the same cursor via polling
|
||||||
|
// so no consumer notices the transport switch.
|
||||||
|
source.onerror = () => {
|
||||||
|
if (stopped || !source) return;
|
||||||
|
source.close();
|
||||||
|
source = null;
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stop };
|
||||||
|
}
|
||||||
23
frontend/js/store.js
Normal file
23
frontend/js/store.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Shared observable store: one state object, subscribe for changes, set() to
|
||||||
|
// patch. Views read state and re-render on notify. Nothing here touches the URL
|
||||||
|
// (the router owns navigable state) or the network (the api client owns that) —
|
||||||
|
// this is only the in-memory glue between them.
|
||||||
|
export function createStore(initial = {}) {
|
||||||
|
let state = { ...initial };
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
function set(patch) {
|
||||||
|
const next = typeof patch === "function" ? patch(state) : patch;
|
||||||
|
state = { ...state, ...next };
|
||||||
|
for (const listener of listeners) listener(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: () => state,
|
||||||
|
set,
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
10
frontend/js/tests/harness.html
Normal file
10
frontend/js/tests/harness.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Shell unit tests</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="./unit.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
150
frontend/js/tests/unit.js
Normal file
150
frontend/js/tests/unit.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// In-browser unit tests for the shell primitives. No server, no JS toolchain:
|
||||||
|
// globals (fetch, EventSource) are stubbed and the modules are exercised directly.
|
||||||
|
// Driven by tests/e2e/test_frontend_shell.py, which loads harness.html and reads
|
||||||
|
// window.__RESULTS__. Covers API errors + cancellation, store transitions,
|
||||||
|
// routing, and the SSE→polling event fallback (US02-05 acceptance).
|
||||||
|
import { createStore } from "../store.js";
|
||||||
|
import { parseHash, navigate } from "../router.js";
|
||||||
|
import { api, cancellable } from "../api.js";
|
||||||
|
import { subscribeJob } from "../events.js";
|
||||||
|
|
||||||
|
const cases = [];
|
||||||
|
function ok(name, cond) {
|
||||||
|
cases.push({ name, ok: !!cond });
|
||||||
|
}
|
||||||
|
async function throws(name, fn, predicate) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
cases.push({ name, ok: false });
|
||||||
|
} catch (error) {
|
||||||
|
cases.push({ name, ok: !!predicate(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const realFetch = window.fetch;
|
||||||
|
const realES = window.EventSource;
|
||||||
|
function jsonResponse(status, body) {
|
||||||
|
return { ok: status < 400, status, statusText: "", json: async () => body };
|
||||||
|
}
|
||||||
|
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
// ── store ────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
const store = createStore({ n: 0 });
|
||||||
|
const seen = [];
|
||||||
|
const off = store.subscribe((s) => seen.push(s.n));
|
||||||
|
store.set({ n: 1 });
|
||||||
|
store.set((s) => ({ n: s.n + 1 }));
|
||||||
|
ok("store applies object and updater patches", store.get().n === 2);
|
||||||
|
ok("store notifies each change", seen.length === 2 && seen[1] === 2);
|
||||||
|
off();
|
||||||
|
store.set({ n: 99 });
|
||||||
|
ok("store unsubscribe stops notifications", seen.length === 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── router ───────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
navigate("/inventory", { q: "beach", offset: 0, empty: "" });
|
||||||
|
const { path, params } = parseHash();
|
||||||
|
ok("router restores path", path === "/inventory");
|
||||||
|
ok("router restores non-empty filters", params.q === "beach" && params.offset === "0");
|
||||||
|
ok("router drops empty filters", !("empty" in params));
|
||||||
|
location.hash = "";
|
||||||
|
ok("router defaults to /inventory", parseHash().path === "/inventory");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── api errors + cancellation ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
window.fetch = async () =>
|
||||||
|
jsonResponse(404, { error: { code: "not_found", message: "unknown job x" } });
|
||||||
|
await throws(
|
||||||
|
"api surfaces the error envelope",
|
||||||
|
() => api.getJob("x"),
|
||||||
|
(e) => e.status === 404 && e.code === "not_found" && e.message === "unknown job x"
|
||||||
|
);
|
||||||
|
|
||||||
|
window.fetch = async () => {
|
||||||
|
const err = new Error("aborted");
|
||||||
|
err.name = "AbortError";
|
||||||
|
throw err;
|
||||||
|
};
|
||||||
|
const { signal, cancel } = cancellable();
|
||||||
|
cancel();
|
||||||
|
await throws(
|
||||||
|
"api tags aborted requests as cancelled",
|
||||||
|
() => api.getJob("x", { signal }),
|
||||||
|
(e) => e.code === "cancelled"
|
||||||
|
);
|
||||||
|
window.fetch = realFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── event fallback: polling when EventSource is unavailable ───────────────
|
||||||
|
{
|
||||||
|
window.EventSource = undefined;
|
||||||
|
const pages = [
|
||||||
|
jsonResponse(200, { state: "running", events: [{ seq: 1, type: "queued", message: null }] }),
|
||||||
|
jsonResponse(200, {
|
||||||
|
state: "succeeded",
|
||||||
|
events: [{ seq: 2, type: "state:succeeded", message: null }],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let call = 0;
|
||||||
|
window.fetch = async () => pages[Math.min(call++, pages.length - 1)];
|
||||||
|
const got = [];
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
subscribeJob("job1", {
|
||||||
|
pollMs: 1,
|
||||||
|
onEvent: (e) => got.push(e.seq),
|
||||||
|
onDone: resolve,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ok("polling delivers events in order", got.join(",") === "1,2");
|
||||||
|
ok("polling stops on terminal state", call >= 2);
|
||||||
|
window.fetch = realFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── event stream: SSE path, then done ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
class FakeES {
|
||||||
|
constructor() {
|
||||||
|
this.listeners = {};
|
||||||
|
setTimeout(() => {
|
||||||
|
this.onmessage?.({ lastEventId: "5", data: JSON.stringify({ type: "state:running", message: "go" }) });
|
||||||
|
this.listeners.done?.();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
addEventListener(type, fn) {
|
||||||
|
this.listeners[type] = fn;
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.EventSource = FakeES;
|
||||||
|
let polled = false;
|
||||||
|
window.fetch = async () => {
|
||||||
|
polled = true;
|
||||||
|
return jsonResponse(200, { state: "succeeded", events: [] });
|
||||||
|
};
|
||||||
|
const got = [];
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
subscribeJob("job2", { onEvent: (e) => got.push(e), onDone: resolve });
|
||||||
|
});
|
||||||
|
ok("SSE delivers event with seq from lastEventId", got.length === 1 && got[0].seq === 5);
|
||||||
|
ok("SSE carries type from payload", got[0].type === "state:running");
|
||||||
|
ok("SSE path does not poll", polled === false);
|
||||||
|
window.fetch = realFetch;
|
||||||
|
window.EventSource = realES;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
const failed = cases.filter((c) => !c.ok);
|
||||||
|
window.__RESULTS__ = { passed: cases.length - failed.length, failed: failed.length, cases };
|
||||||
|
const pre = document.createElement("pre");
|
||||||
|
pre.id = "results";
|
||||||
|
pre.textContent = cases.map((c) => `${c.ok ? "PASS" : "FAIL"} ${c.name}`).join("\n");
|
||||||
|
document.body.appendChild(pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -102,10 +102,13 @@ async def stream_events(job_id: str, request: Request, after: int = 0):
|
|||||||
for _ in range(600):
|
for _ in range(600):
|
||||||
for event in service.events_after(job_id, cursor):
|
for event in service.events_after(job_id, cursor):
|
||||||
cursor = event["seq"]
|
cursor = event["seq"]
|
||||||
|
# Default (unnamed) event so a browser EventSource receives every
|
||||||
|
# event via onmessage without enumerating the open-ended type set
|
||||||
|
# (state:*, claimed, …); the type travels in the JSON payload,
|
||||||
|
# matching the polling fallback's {seq, type, message} shape.
|
||||||
yield (
|
yield (
|
||||||
f"id: {event['seq']}\n"
|
f"id: {event['seq']}\n"
|
||||||
f"event: {event['type']}\n"
|
f"data: {json.dumps({'type': event['type'], 'message': event['message']})}\n\n"
|
||||||
f"data: {json.dumps({'message': event['message']})}\n\n"
|
|
||||||
)
|
)
|
||||||
snapshot = service.get(job_id)
|
snapshot = service.get(job_id)
|
||||||
if snapshot is None or snapshot["state"] not in ACTIVE_STATES:
|
if snapshot is None or snapshot["state"] not in ACTIVE_STATES:
|
||||||
|
|||||||
143
tests/e2e/test_frontend_shell.py
Normal file
143
tests/e2e/test_frontend_shell.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Static application shell: the JS unit suite plus browser journeys asserting
|
||||||
|
asset loading, deep links, reload-restores-filters, JSON-only APIs, and a clean
|
||||||
|
console/network (US02-05).
|
||||||
|
|
||||||
|
The unit suite (frontend/js/tests/) runs in the browser over the server's own
|
||||||
|
static mount — ES modules can't import over file://, so the running server serves
|
||||||
|
them; the tests themselves stub fetch/EventSource and never touch the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _image(path, seed):
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
Image.fromarray(rng.integers(0, 256, (96, 128, 3), dtype=np.uint8)).save(path, quality=90)
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port():
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return sock.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server(tmp_path):
|
||||||
|
data = tmp_path / "data"
|
||||||
|
data.mkdir()
|
||||||
|
lib = tmp_path / "lib"
|
||||||
|
lib.mkdir()
|
||||||
|
_image(lib / "beach.jpg", 1)
|
||||||
|
_image(lib / "city.jpg", 2)
|
||||||
|
|
||||||
|
from photo_pipeline.config import Config
|
||||||
|
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
||||||
|
from photo_pipeline.services.inventory import InventoryService
|
||||||
|
|
||||||
|
config = Config.from_env(
|
||||||
|
{"PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)}
|
||||||
|
)
|
||||||
|
run_migrations(config.database_url)
|
||||||
|
engine = create_db_engine(config.database_url)
|
||||||
|
InventoryService(create_session_factory(engine)).scan(lib)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
port = _free_port()
|
||||||
|
env = {
|
||||||
|
"PATH": __import__("os").environ.get("PATH", ""),
|
||||||
|
"PHOTO_PIPELINE_DATA_DIR": str(data),
|
||||||
|
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
|
||||||
|
"PHOTO_PIPELINE_HOST": "127.0.0.1",
|
||||||
|
"PHOTO_PIPELINE_PORT": str(port),
|
||||||
|
}
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "photo_pipeline", "serve"],
|
||||||
|
cwd=str(REPO),
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
base = f"http://127.0.0.1:{port}"
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
ready = False
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
out, err = proc.communicate()
|
||||||
|
pytest.fail(f"server exited: {err.decode(errors='replace')}")
|
||||||
|
try:
|
||||||
|
if httpx.get(f"{base}/api/v1/health/ready", timeout=1).status_code == 200:
|
||||||
|
ready = True
|
||||||
|
break
|
||||||
|
except httpx.HTTPError:
|
||||||
|
time.sleep(0.2)
|
||||||
|
if not ready:
|
||||||
|
proc.terminate()
|
||||||
|
pytest.fail("server never became ready")
|
||||||
|
try:
|
||||||
|
yield SimpleNamespace(base=base)
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def test_js_unit_suite_passes(page, server):
|
||||||
|
page.goto(f"{server.base}/app/js/tests/harness.html")
|
||||||
|
page.locator("#results").wait_for()
|
||||||
|
results = page.evaluate("window.__RESULTS__")
|
||||||
|
failing = [c["name"] for c in results["cases"] if not c["ok"]]
|
||||||
|
assert results["failed"] == 0, f"JS unit failures: {failing}"
|
||||||
|
assert results["passed"] >= 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_shell_loads_assets_without_console_or_network_errors(page, server):
|
||||||
|
errors, failed = [], []
|
||||||
|
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||||
|
page.on("requestfailed", lambda r: failed.append(r.url))
|
||||||
|
|
||||||
|
page.goto(f"{server.base}/app/#/inventory")
|
||||||
|
page.get_by_test_id("asset-row").first.wait_for()
|
||||||
|
assert page.locator("nav a[data-nav]").count() >= 2
|
||||||
|
assert errors == [], f"console errors: {errors}"
|
||||||
|
assert failed == [], f"failed requests: {failed}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_link_and_reload_restore_view_and_filters(page, server):
|
||||||
|
# Deep-link straight to a filtered inventory view, then reload: the hash router
|
||||||
|
# must restore both the view and the filter with no server-rendered state.
|
||||||
|
page.goto(f"{server.base}/app/#/inventory?q=beach")
|
||||||
|
search = page.get_by_label("Filter by path")
|
||||||
|
search.wait_for()
|
||||||
|
assert search.input_value() == "beach"
|
||||||
|
|
||||||
|
page.reload()
|
||||||
|
page.get_by_label("Filter by path").wait_for()
|
||||||
|
assert page.get_by_label("Filter by path").input_value() == "beach"
|
||||||
|
assert "q=beach" in page.evaluate("location.hash")
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_is_json_only_and_html_never_intercepts(server):
|
||||||
|
# /api/v1 always answers JSON — the static SPA mount must never shadow it.
|
||||||
|
ok = httpx.get(f"{server.base}/api/v1/inventory/assets", timeout=5)
|
||||||
|
assert ok.headers["content-type"].startswith("application/json")
|
||||||
|
|
||||||
|
missing = httpx.get(f"{server.base}/api/v1/does-not-exist", timeout=5)
|
||||||
|
assert missing.status_code == 404
|
||||||
|
assert missing.headers["content-type"].startswith("application/json")
|
||||||
|
|
||||||
|
shell = httpx.get(f"{server.base}/app/", timeout=5)
|
||||||
|
assert shell.headers["content-type"].startswith("text/html")
|
||||||
Reference in New Issue
Block a user