feat(llm): search web UI at /ui/search (refs #578)
A minimal, framework-free search page served by the llm service itself, in the same editorial style as the chat page, so the retrieval endpoints (#570) are usable from the dashboard and not only via curl. - src/llm/web/search.html: query box + collection/kind/docket/year filters (exactly the values the API validates), results with kind, linked label, date, docket/comment id, title and snippet, a "similar" toggle per result carrying a bib item_key (renders /similar/{key} in place), limit/offset pagination, and the search state mirrored into the query string so a search is shareable and survives a reload. Loading/error states surface the API's own 400/404 `detail`. All API data becomes text nodes — nothing is assigned to innerHTML — and the page carries no external script/style URL, so it renders behind oauth2-proxy with no outbound CDN access (fonts fall back to the local serif stack). An "llm:" tag filter is left as an HTML comment until the tagging chain (#575) makes chunks carry those tags. - api.py: `GET /ui/search` (HTMLResponse, like `/` serves chat.html), registered with and without the trailing slash so the headless probe's readiness check gets a 200 instead of a 307 redirect. - chat header → "Search", search header → "Chat". - tests/llm/test_api.py::TestSearchPage: the route serves the form and every allowed filter value, the trailing-slash form works, the results/similar/pagination/error code is present, the page has no external script/style URL and no innerHTML assignment, and both nav links resolve to routes that return 200. Headless probe, once the image is rolled (do not run before the roll — it probes the live service): python dev/scripts/nb_fe_smoke.py \ --url http://llm:8000/ui/search --network gateway nb_fe_smoke's --url mode probes `<base>/` and `<base>/?file=<notebook>` and fails on any console or page error; with the base above, both are the search page (the unused ?file= query is ignored by the route), which is why the route also answers the trailing-slash form.
This commit is contained in:
@@ -78,8 +78,8 @@ class ChatRequest(BaseModel):
|
||||
mode: str = "auto"
|
||||
|
||||
|
||||
def _page() -> str:
|
||||
return resources.files("llm").joinpath("web/chat.html").read_text()
|
||||
def _page(name: str = "chat.html") -> str:
|
||||
return resources.files("llm").joinpath(f"web/{name}").read_text()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -93,6 +93,20 @@ def index() -> str:
|
||||
return _page()
|
||||
|
||||
|
||||
@app.get("/ui/search", response_class=HTMLResponse)
|
||||
@app.get("/ui/search/", response_class=HTMLResponse)
|
||||
def search_page() -> str:
|
||||
"""The search UI (refs #578) — a framework-free page like the chat,
|
||||
calling ``/search`` and ``/similar/{key}`` from the browser.
|
||||
|
||||
Registered with and without the trailing slash so
|
||||
``dev/scripts/nb_fe_smoke.py --url http://llm:8000/ui/search`` gets a
|
||||
200 from its readiness probe (which appends ``/``) instead of the
|
||||
307 Starlette would otherwise redirect with.
|
||||
"""
|
||||
return _page("search.html")
|
||||
|
||||
|
||||
@app.get("/whoami")
|
||||
def whoami(x_auth_request_user: str = Header(default="")) -> dict:
|
||||
"""The Gitea username Traefik forwarded (empty if unset)."""
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 20px; margin: 0;
|
||||
}
|
||||
header .sub { font-size: 13px; opacity: .7; }
|
||||
header a.nav {
|
||||
font-family: var(--font-mono); font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: var(--primary-fg); text-decoration: underline dotted;
|
||||
}
|
||||
header .who {
|
||||
margin-left: auto; font-family: var(--font-mono); font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: .08em; opacity: .8;
|
||||
@@ -178,6 +182,7 @@
|
||||
<header>
|
||||
<h1>Library Chat</h1>
|
||||
<span class="sub">grounded in the indexed library — comments, FR rules, references</span>
|
||||
<a class="nav" href="/ui/search">Search</a>
|
||||
<span class="who" id="who"></span>
|
||||
</header>
|
||||
<div id="log">
|
||||
|
||||
379
src/llm/web/search.html
Normal file
379
src/llm/web/search.html
Normal file
@@ -0,0 +1,379 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Library Search</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="//dashboard.fhirworx.io/fav32.png">
|
||||
<style>
|
||||
/* HTI-5 editorial palette — same tokens as web/chat.html, so the two
|
||||
pages read as one product. No webfont <link> and no script src here
|
||||
on purpose: behind oauth2-proxy the page must render with no
|
||||
outbound CDN access, so the display/body/mono stacks fall back to
|
||||
the platform's local serifs (refs #578). */
|
||||
:root {
|
||||
--background: #F7F5F0; --foreground: #1A1A18; --card: #FAFAF7;
|
||||
--primary: #1C2B3A; --primary-fg: #F7F5F0; --secondary: #EDEBE6;
|
||||
--muted-fg: #6B6B68; --border: #D4D0C8; --accent: #2E3D8F;
|
||||
--destructive: #C0392B;
|
||||
--font-display: "Playfair Display", Georgia, serif;
|
||||
--font-body: "Source Serif 4", Georgia, serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: flex; flex-direction: column;
|
||||
background: var(--background); color: var(--foreground);
|
||||
font-family: var(--font-body); font-size: 16px; line-height: 1.55;
|
||||
}
|
||||
header {
|
||||
flex: 0 0 auto; background: var(--primary); color: var(--primary-fg);
|
||||
border-bottom: 3px solid var(--foreground);
|
||||
padding: 14px 22px; display: flex; align-items: baseline; gap: 14px;
|
||||
}
|
||||
header h1 {
|
||||
font-family: var(--font-display); font-weight: 700; font-size: 20px; margin: 0;
|
||||
}
|
||||
header .sub { font-size: 13px; opacity: .7; }
|
||||
header a.nav {
|
||||
font-family: var(--font-mono); font-size: 11px; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: var(--primary-fg); text-decoration: underline dotted;
|
||||
}
|
||||
header .who {
|
||||
margin-left: auto; font-family: var(--font-mono); font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: .08em; opacity: .8;
|
||||
}
|
||||
main { flex: 1 1 auto; width: 100%; max-width: 900px; margin: 0 auto; padding: 0 22px 40px; }
|
||||
form#search {
|
||||
border-bottom: 3px solid var(--foreground); background: var(--secondary);
|
||||
margin: 0 -22px 22px; padding: 16px 22px;
|
||||
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
|
||||
}
|
||||
form#search input[type=search], form#search input[type=text], form#search select {
|
||||
font-family: var(--font-body); font-size: 15px; padding: 8px 10px;
|
||||
background: var(--card); color: var(--foreground);
|
||||
border: 1px solid var(--border); border-radius: 3px;
|
||||
}
|
||||
form#search input#q { flex: 1 1 320px; font-size: 16px; }
|
||||
form#search input#docket, form#search input#year { width: 150px; }
|
||||
form#search label {
|
||||
display: flex; align-items: center; gap: 6px; font-size: 13px;
|
||||
color: var(--muted-fg); white-space: nowrap;
|
||||
}
|
||||
form#search input:focus, form#search select:focus {
|
||||
outline: none; border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 22%, transparent);
|
||||
}
|
||||
button {
|
||||
padding: 8px 20px; border: 1px solid var(--primary); border-radius: 3px;
|
||||
background: var(--primary); color: var(--primary-fg);
|
||||
font-family: var(--font-body); font-size: 15px; font-weight: 600; cursor: pointer;
|
||||
transition: background .15s ease;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #253748; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
button.ghost {
|
||||
background: transparent; color: var(--primary); border-color: var(--border);
|
||||
font-size: 12px; font-family: var(--font-mono); padding: 3px 9px;
|
||||
}
|
||||
button.ghost:hover:not(:disabled) { background: var(--secondary); }
|
||||
.status { color: var(--muted-fg); font-style: italic; margin: 14px 0; }
|
||||
.status.err { color: var(--destructive); font-style: normal; }
|
||||
.hint { max-width: 560px; color: var(--muted-fg); font-style: italic; margin: 20px 0; }
|
||||
.hit {
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--border);
|
||||
background: var(--card); border-radius: 3px;
|
||||
padding: 12px 14px; margin: 0 0 14px;
|
||||
}
|
||||
.hit .line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
|
||||
.hit .kind {
|
||||
font-family: var(--font-mono); font-size: 11px; padding: 1px 5px; border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
.hit .label, .hit a.label {
|
||||
font-family: var(--font-mono); font-size: .82em; color: var(--accent);
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
.hit .date, .hit .meta { color: var(--muted-fg); font-size: 12px; font-family: var(--font-mono); }
|
||||
.hit .title { margin-top: 3px; font-weight: 600; }
|
||||
.hit .snippet { margin-top: 6px; }
|
||||
.hit .score { margin-left: auto; color: var(--muted-fg); font-size: 11px; font-family: var(--font-mono); }
|
||||
.neighbours { margin: 10px 0 0 14px; border-left: 3px solid var(--border); padding-left: 12px; }
|
||||
.neighbours .caption {
|
||||
font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--muted-fg); margin-bottom: 6px;
|
||||
}
|
||||
.neighbours .caption.err { color: var(--destructive); }
|
||||
.neighbours .hit { background: var(--background); }
|
||||
.pager { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
|
||||
/* the class rule above outranks the UA's [hidden] { display: none } */
|
||||
.pager[hidden] { display: none; }
|
||||
.pager .range { color: var(--muted-fg); font-size: 12px; font-family: var(--font-mono); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Library Search</h1>
|
||||
<span class="sub">comments, FR rules and references — metadata-filtered similarity search</span>
|
||||
<a class="nav" href="/">Chat</a>
|
||||
<span class="who" id="who"></span>
|
||||
</header>
|
||||
<main>
|
||||
<form id="search">
|
||||
<input type="search" id="q" name="q" placeholder="e.g. telehealth originating site" autofocus>
|
||||
<label>collection
|
||||
<select id="collection">
|
||||
<option value="all" selected>all</option>
|
||||
<option value="comments">comments</option>
|
||||
<option value="rules">rules</option>
|
||||
<option value="corpus">corpus</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>kind
|
||||
<select id="kind">
|
||||
<option value="" selected>any</option>
|
||||
<option value="comment">comment</option>
|
||||
<option value="rule">rule</option>
|
||||
<option value="corpus">corpus</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>docket <input type="text" id="docket" placeholder="CMS-2023-0121"></label>
|
||||
<label>year <input type="text" id="year" placeholder="2024"></label>
|
||||
<!-- An "llm:" tag filter belongs here once the tagging chain (#575)
|
||||
lands and indexed chunks carry llm: tags; there is nothing to
|
||||
filter on yet, so no control is drawn. -->
|
||||
<button id="go" type="submit">Search</button>
|
||||
</form>
|
||||
<div id="status" class="status"></div>
|
||||
<div id="results"></div>
|
||||
<div id="pager" class="pager" hidden>
|
||||
<button id="prev" class="ghost" type="button">‹ prev</button>
|
||||
<span id="range" class="range"></span>
|
||||
<button id="next" class="ghost" type="button">next ›</button>
|
||||
</div>
|
||||
<div class="hint" id="hint">Search the indexed library. Results are ranked by
|
||||
embedding distance; filter by collection, chunk kind, docket or year, and
|
||||
follow “similar” to find neighbours of a cited item.</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var LIMIT = 10;
|
||||
var form = document.getElementById('search');
|
||||
var statusEl = document.getElementById('status');
|
||||
var resultsEl = document.getElementById('results');
|
||||
var pagerEl = document.getElementById('pager');
|
||||
var rangeEl = document.getElementById('range');
|
||||
var prevBtn = document.getElementById('prev');
|
||||
var nextBtn = document.getElementById('next');
|
||||
var goBtn = document.getElementById('go');
|
||||
var hintEl = document.getElementById('hint');
|
||||
var offset = 0;
|
||||
|
||||
fetch('/whoami').then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (d.user) document.getElementById('who').textContent = d.user;
|
||||
}).catch(function () {});
|
||||
|
||||
function val(id) { return document.getElementById(id).value.trim(); }
|
||||
|
||||
// The current form state as query params — also what lands in the
|
||||
// address bar, so a search is shareable and survives a reload.
|
||||
function params() {
|
||||
var p = new URLSearchParams();
|
||||
p.set('q', val('q'));
|
||||
var collection = val('collection');
|
||||
if (collection && collection !== 'all') p.set('collection', collection);
|
||||
if (val('kind')) p.set('kind', val('kind'));
|
||||
if (val('docket')) p.set('docket', val('docket'));
|
||||
if (val('year')) p.set('year', val('year'));
|
||||
if (offset) p.set('offset', String(offset));
|
||||
p.set('limit', String(LIMIT));
|
||||
return p;
|
||||
}
|
||||
|
||||
function fromUrl() {
|
||||
var p = new URLSearchParams(window.location.search);
|
||||
document.getElementById('q').value = p.get('q') || '';
|
||||
document.getElementById('collection').value = p.get('collection') || 'all';
|
||||
document.getElementById('kind').value = p.get('kind') || '';
|
||||
document.getElementById('docket').value = p.get('docket') || '';
|
||||
document.getElementById('year').value = p.get('year') || '';
|
||||
offset = Math.max(0, parseInt(p.get('offset') || '0', 10) || 0);
|
||||
return p.get('q') || '';
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
statusEl.textContent = text;
|
||||
statusEl.className = isError ? 'status err' : 'status';
|
||||
}
|
||||
|
||||
// Every value below becomes a text node (or an element's .textContent /
|
||||
// .href) — API data never reaches innerHTML.
|
||||
function hitEl(r) {
|
||||
var box = document.createElement('div');
|
||||
box.className = 'hit';
|
||||
var line = document.createElement('div');
|
||||
line.className = 'line';
|
||||
|
||||
var kind = document.createElement('span');
|
||||
kind.className = 'kind';
|
||||
kind.textContent = r.kind || 'chunk';
|
||||
line.appendChild(kind);
|
||||
|
||||
var label;
|
||||
if (r.url) {
|
||||
label = document.createElement('a');
|
||||
label.href = r.url; label.target = '_blank'; label.rel = 'noopener';
|
||||
} else {
|
||||
label = document.createElement('span'); // an empty href reloads the page
|
||||
}
|
||||
label.className = 'label';
|
||||
label.textContent = '[' + (r.label || r.id || '') + ']';
|
||||
line.appendChild(label);
|
||||
|
||||
if (r.date) {
|
||||
var date = document.createElement('span');
|
||||
date.className = 'date';
|
||||
date.textContent = r.date;
|
||||
line.appendChild(date);
|
||||
}
|
||||
var bits = [];
|
||||
if (r.docket) bits.push('docket ' + r.docket);
|
||||
if (r.comment_id) bits.push('comment ' + r.comment_id);
|
||||
if (bits.length) {
|
||||
var meta = document.createElement('span');
|
||||
meta.className = 'meta';
|
||||
meta.textContent = bits.join(' · ');
|
||||
line.appendChild(meta);
|
||||
}
|
||||
if (r.item_key) {
|
||||
var sim = document.createElement('button');
|
||||
sim.type = 'button';
|
||||
sim.className = 'ghost';
|
||||
sim.textContent = 'similar';
|
||||
sim.addEventListener('click', function () { showSimilar(box, sim, r.item_key); });
|
||||
line.appendChild(sim);
|
||||
}
|
||||
if (r.distance !== undefined && r.distance !== null) {
|
||||
var score = document.createElement('span');
|
||||
score.className = 'score';
|
||||
score.textContent = 'd=' + r.distance;
|
||||
line.appendChild(score);
|
||||
}
|
||||
box.appendChild(line);
|
||||
|
||||
if (r.title) {
|
||||
var title = document.createElement('div');
|
||||
title.className = 'title';
|
||||
title.textContent = r.title;
|
||||
box.appendChild(title);
|
||||
}
|
||||
if (r.snippet) {
|
||||
var snip = document.createElement('div');
|
||||
snip.className = 'snippet';
|
||||
snip.textContent = r.snippet;
|
||||
box.appendChild(snip);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
// A 400/404 carries FastAPI's {"detail": "…"} — show that, not just
|
||||
// the status code.
|
||||
async function detail(resp) {
|
||||
try {
|
||||
var body = await resp.json();
|
||||
if (body && body.detail) return String(body.detail);
|
||||
} catch (e) { /* not JSON */ }
|
||||
return 'request failed (' + resp.status + ')';
|
||||
}
|
||||
|
||||
// The bib item key a result carries is `item_key`; /similar is keyed
|
||||
// by it. Toggles the neighbours list open/closed in place.
|
||||
async function showSimilar(box, button, key) {
|
||||
var shown = box.querySelector('.neighbours');
|
||||
if (shown) { shown.remove(); button.textContent = 'similar'; return; }
|
||||
button.disabled = true;
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'neighbours';
|
||||
var cap = document.createElement('div');
|
||||
cap.className = 'caption';
|
||||
cap.textContent = 'similar to ' + key + '…';
|
||||
wrap.appendChild(cap);
|
||||
box.appendChild(wrap);
|
||||
try {
|
||||
var resp = await fetch('/similar/' + encodeURIComponent(key) +
|
||||
'?collection=' + encodeURIComponent(val('collection')) + '&limit=5');
|
||||
if (!resp.ok) throw new Error(await detail(resp));
|
||||
var body = await resp.json();
|
||||
cap.textContent = body.total + ' similar to ' + key;
|
||||
(body.results || []).forEach(function (r) { wrap.appendChild(hitEl(r)); });
|
||||
button.textContent = 'hide similar';
|
||||
} catch (e) {
|
||||
cap.className = 'caption err';
|
||||
cap.textContent = 'similar failed: ' + e.message;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(push) {
|
||||
var p = params();
|
||||
if (!p.get('q')) {
|
||||
setStatus('Enter a search term.', true);
|
||||
return;
|
||||
}
|
||||
if (push) {
|
||||
window.history.pushState({}, '', window.location.pathname + '?' + p.toString());
|
||||
}
|
||||
goBtn.disabled = true;
|
||||
setStatus('Searching…', false);
|
||||
resultsEl.textContent = '';
|
||||
pagerEl.hidden = true;
|
||||
if (hintEl) { hintEl.remove(); hintEl = null; }
|
||||
try {
|
||||
var resp = await fetch('/search?' + p.toString());
|
||||
if (!resp.ok) throw new Error(await detail(resp));
|
||||
var body = await resp.json();
|
||||
var results = body.results || [];
|
||||
results.forEach(function (r) { resultsEl.appendChild(hitEl(r)); });
|
||||
if (!results.length) {
|
||||
setStatus(offset ? 'No more results on this page.' : 'No results.', false);
|
||||
} else {
|
||||
setStatus(results.length + ' result' + (results.length > 1 ? 's' : '') +
|
||||
' for “' + body.query + '”', false);
|
||||
}
|
||||
prevBtn.disabled = offset === 0;
|
||||
// `total` is this page's size, so a full page means "there may be
|
||||
// more" and a short one means this is the last page.
|
||||
nextBtn.disabled = results.length < LIMIT;
|
||||
rangeEl.textContent = results.length
|
||||
? (offset + 1) + '–' + (offset + results.length)
|
||||
: 'none';
|
||||
pagerEl.hidden = offset === 0 && results.length < LIMIT;
|
||||
} catch (e) {
|
||||
setStatus('Error: ' + e.message, true);
|
||||
} finally {
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
offset = 0;
|
||||
run(true);
|
||||
});
|
||||
prevBtn.addEventListener('click', function () {
|
||||
offset = Math.max(0, offset - LIMIT);
|
||||
run(true);
|
||||
});
|
||||
nextBtn.addEventListener('click', function () {
|
||||
offset += LIMIT;
|
||||
run(true);
|
||||
});
|
||||
window.addEventListener('popstate', function () {
|
||||
if (fromUrl()) { run(false); } else { resultsEl.textContent = ''; setStatus('', false); }
|
||||
});
|
||||
|
||||
if (fromUrl()) run(false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +1,11 @@
|
||||
"""llm.api — FastAPI chat app."""
|
||||
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from llm.api import app
|
||||
from llm.api import _COLLECTION_CHOICES, _KIND_CHOICES, app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -74,6 +75,76 @@ class TestIndex:
|
||||
assert "innerHTML = ''" in html and "innerHTML = text" not in html
|
||||
|
||||
|
||||
class TestSearchPage:
|
||||
"""``GET /ui/search`` — the framework-free search UI (refs #578)."""
|
||||
|
||||
def test_serves_search_page(self):
|
||||
r = client.get("/ui/search")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers["content-type"]
|
||||
html = r.text
|
||||
assert "Library Search" in html
|
||||
# the form: query box + every filter the API accepts
|
||||
assert 'id="search"' in html
|
||||
assert 'id="q"' in html
|
||||
for field in ("collection", "kind", "docket", "year"):
|
||||
assert 'id="%s"' % field in html
|
||||
for value in sorted(_COLLECTION_CHOICES):
|
||||
assert 'value="%s"' % value in html
|
||||
for value in sorted(_KIND_CHOICES):
|
||||
assert 'value="%s"' % value in html
|
||||
# llm: tags aren't indexed yet — a placeholder, not a dead filter
|
||||
assert "#575" in html and 'id="tag"' not in html
|
||||
|
||||
def test_trailing_slash_also_serves_the_page(self):
|
||||
"""The headless probe's readiness check appends "/"; a 307 there
|
||||
would read as "service not responding"."""
|
||||
r = client.get("/ui/search/")
|
||||
assert r.status_code == 200
|
||||
assert "Library Search" in r.text
|
||||
|
||||
def test_renders_results_similar_and_pagination(self):
|
||||
html = client.get("/ui/search").text
|
||||
# results: label link, snippet, docket/comment id, similar
|
||||
assert "function hitEl(r)" in html
|
||||
assert "r.snippet" in html and "r.docket" in html and "r.comment_id" in html
|
||||
assert "if (r.url)" in html # no empty href for an unlinked label
|
||||
# /similar is keyed by the bib item key the result carries
|
||||
assert "function showSimilar" in html
|
||||
assert "r.item_key" in html
|
||||
assert "'/similar/' + encodeURIComponent(key)" in html
|
||||
# pagination + shareable URL
|
||||
assert 'id="prev"' in html and 'id="next"' in html
|
||||
assert "offset += LIMIT" in html
|
||||
assert "window.history.pushState" in html
|
||||
assert "new URLSearchParams(window.location.search)" in html
|
||||
# loading + error states, with the API's own 400 detail
|
||||
assert "'Searching…'" in html
|
||||
assert "body.detail" in html
|
||||
assert "setStatus('Error: ' + e.message, true)" in html
|
||||
|
||||
def test_no_external_script_or_style_urls(self):
|
||||
"""Framework-free and CDN-free: behind oauth2-proxy the page has
|
||||
no outbound access, so every byte of CSS/JS is inline."""
|
||||
html = client.get("/ui/search").text
|
||||
assert re.search(r"<script[^>]*\ssrc=", html) is None
|
||||
assert re.search(r"<link[^>]*stylesheet", html) is None
|
||||
assert "fonts.googleapis.com" not in html
|
||||
# no absolute off-site fetch at all (the favicon is the platform's
|
||||
# own, protocol-relative, host)
|
||||
assert "http://" not in html and "https://" not in html
|
||||
# API data never reaches innerHTML — nothing is ever assigned to it
|
||||
assert re.search(r"innerHTML\s*=", html) is None
|
||||
|
||||
def test_nav_links_between_the_two_pages_resolve(self):
|
||||
search_html = client.get("/ui/search").text
|
||||
chat_html = client.get("/").text
|
||||
assert '<a class="nav" href="/">Chat</a>' in search_html
|
||||
assert '<a class="nav" href="/ui/search">Search</a>' in chat_html
|
||||
assert client.get("/").status_code == 200
|
||||
assert client.get("/ui/search").status_code == 200
|
||||
|
||||
|
||||
class TestWhoami:
|
||||
def test_reads_forwarded_header(self):
|
||||
r = client.get("/whoami", headers={"X-Auth-Request-User": "kert"})
|
||||
|
||||
Reference in New Issue
Block a user