Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0427b3079e | ||
|
|
f4e2969fec | ||
|
|
7d5d1181c0 | ||
|
|
b41d7ad03a | ||
|
|
b2de3b7eeb | ||
|
|
1b43116fe6 | ||
|
|
8f3bbde733 | ||
|
|
6e24d7db17 | ||
|
|
43a2804562 | ||
|
|
42b659e807 | ||
|
|
240d962ec3 | ||
|
|
2aba489036 | ||
|
|
10181aa019 | ||
|
|
2d492d9d2e | ||
|
|
1fe16c1342 | ||
|
|
68c2048724 | ||
|
|
fcdca7a37c | ||
|
|
77404cfd03 | ||
|
|
49ce2d9945 | ||
|
|
5f86b58b88 | ||
|
|
888c03e4c1 | ||
|
|
bce46843b3 | ||
|
|
89b14872e6 | ||
|
|
090a059c17 | ||
|
|
b2f8eaf89a | ||
|
|
8001933692 | ||
|
|
d9ad68b72b | ||
|
|
902db45741 | ||
|
|
af7f2ff92a | ||
|
|
dea3024f83 | ||
|
|
e46113a51a | ||
|
|
d8f2248600 | ||
|
|
d5ee4dcb29 | ||
|
|
19da7f47df | ||
|
|
069d8979fb | ||
|
|
13fcd42f13 | ||
|
|
5ca87c1b7c | ||
|
|
4bbe02b3f6 | ||
|
|
fb200ceb93 | ||
|
|
2fdb9fb897 | ||
|
|
b119dcf47d | ||
|
|
3e1ce3e72a | ||
|
|
36f500d58b | ||
|
|
a173182158 | ||
|
|
609df470d6 | ||
|
|
a92109b65f | ||
|
|
6678358e4c | ||
|
|
6a22bc212d | ||
|
|
60b6f1a74d | ||
|
|
f63c8c39b1 | ||
|
|
a6732b9c93 | ||
|
|
e8b5fd31a5 | ||
|
|
352d70e859 | ||
|
|
89c22c6091 | ||
|
|
e837934bec | ||
|
|
f0dc260c9b | ||
|
|
94cdd0f407 | ||
|
|
9b912fc837 | ||
|
|
d7bdbd3e18 | ||
|
|
4afa4032d7 | ||
|
|
582ab559c7 |
No files matched your search
@@ -0,0 +1,10 @@
|
||||
(function (root, factory) {
|
||||
const filterCommands = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
|
||||
if (root) root.filterCommands = filterCommands;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function filterCommands(commands, filter) {
|
||||
const term = String(filter || '').toLowerCase();
|
||||
return commands.filter(command => command.name.toLowerCase().includes(term));
|
||||
};
|
||||
});
|
||||
+33
-43
@@ -106,7 +106,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</details>
|
||||
<details class="panel" data-panel-key="widgets" open>
|
||||
<summary><h2>Live Widgets</h2></summary>
|
||||
<div id="widgets" class="stack"></div>
|
||||
<div id="widgets" class="stack">
|
||||
<div class="widget">
|
||||
<h3>Live clock</h3>
|
||||
<div class="small" id="widget-clock">—</div>
|
||||
</div>
|
||||
<div class="widget">
|
||||
<h3>Repo mix</h3>
|
||||
<div class="small" id="repo-mix">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
@@ -150,6 +159,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
@@ -184,7 +195,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
async function load() {
|
||||
setStatus('Loading…');
|
||||
try {
|
||||
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
liveMode = true;
|
||||
@@ -205,7 +216,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
|
||||
|
||||
paintDeltas(data.deltas || []);
|
||||
setStatus('Live · updated just now');
|
||||
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
|
||||
setClock();
|
||||
} catch (e) {
|
||||
console.error('context failed', e);
|
||||
@@ -253,18 +264,18 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
async function loadEventStream() {
|
||||
setEventStreamStatus('Updating…');
|
||||
try {
|
||||
const res = await fetch('/api/v1/events', { headers: { Accept: 'application/json' } });
|
||||
const res = await fetch('api/v1/events', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
paintEventStream(await res.json());
|
||||
setEventStreamStatus('Updated ' + fmt(new Date()));
|
||||
} catch (e) {
|
||||
qs('#gitea-events').innerHTML = '<div class="muted">Event stream unavailable.</div>';
|
||||
setEventStreamStatus('Update failed · showing last activity');
|
||||
}
|
||||
}
|
||||
|
||||
async function tickWidgets() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
const data = await res.json();
|
||||
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
|
||||
} catch (e) {
|
||||
@@ -318,14 +329,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#md-input').addEventListener('input', renderMD);
|
||||
function renderMD() {
|
||||
const raw = qs('#md-input').value || '';
|
||||
const html = raw.replace(/^#{1,6}\s.*$/gm, (m)=>{ const lvl=m.match(/^(#{1,6})/)[1].length; return '<h'+lvl+'>'+m.replace(/^#{1,6}\s/,'')+'</h'+lvl+'>'; })
|
||||
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g,'<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g,'<code>$1</code>')
|
||||
.replace(/^\-\s?(.+)$/gm,'<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s,'<ul>$1</ul>')
|
||||
.replace(/\n/g,'<br>');
|
||||
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + html + '</div>';
|
||||
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
|
||||
}
|
||||
|
||||
/* Commands */
|
||||
@@ -337,11 +341,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
];
|
||||
function renderCommands(filter) {
|
||||
const el = qs('#cmd-results');
|
||||
const term = String(filter||'').toLowerCase();
|
||||
const items = commands.filter(c => c.name.toLowerCase().includes(term));
|
||||
const items = filterCommands(commands, filter);
|
||||
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
|
||||
el.querySelectorAll('.cmd-item').forEach((item) => {
|
||||
item.addEventListener('click', () => { commands[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
|
||||
item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
|
||||
});
|
||||
}
|
||||
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
||||
@@ -358,33 +361,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
tickWidgets();
|
||||
|
||||
/* Widgets */
|
||||
const widgetOrder = ['countdown','repoAtAGlance'];
|
||||
const widgetRegistry = {
|
||||
countdown: () => ({ title: 'Live clock', html: '<div class="small" id="widget-clock">—</div>' }),
|
||||
repoAtAGlance: async () => {
|
||||
const el = qs('#repo-mix');
|
||||
if (!el) return { title:'Repo mix', html:'<div class="small">Unavailable</div>' };
|
||||
try {
|
||||
const res = await fetch('/api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
const data = await res.json();
|
||||
const repos = data.repos || [];
|
||||
const issues = (data.issues||[]).filter(i=>i.state==='open');
|
||||
const prs = data.pull_requests || [];
|
||||
el.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + prs.length + '</div></div>';
|
||||
} catch (e) { el.innerHTML = '<div class="muted">Widget unavailable.</div>'; }
|
||||
return { title:'Repo mix', html:'<div class="small" id="repo-mix">Loading…</div>' };
|
||||
},
|
||||
};
|
||||
const widgetsEl = qs('#widgets');
|
||||
function paintWidgets() {
|
||||
widgetsEl.innerHTML = '';
|
||||
widgetOrder.forEach(async k => {
|
||||
const w = await widgetRegistry[k]();
|
||||
const node = document.createElement('div'); node.className='widget'; node.innerHTML = '<h3>' + escapeHtml(w.title) + '</h3>' + w.html;
|
||||
widgetsEl.appendChild(node);
|
||||
});
|
||||
async function updateRepoMix() {
|
||||
const el = qs('#repo-mix');
|
||||
try {
|
||||
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
const data = await res.json();
|
||||
const repos = data.repos || [];
|
||||
const issues = (data.issues||[]).filter(i=>i.state==='open');
|
||||
const prs = data.pull_requests || [];
|
||||
el.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + prs.length + '</div></div>';
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="muted">Widget unavailable.</div>';
|
||||
}
|
||||
}
|
||||
paintWidgets();
|
||||
updateRepoMix();
|
||||
|
||||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||
setInterval(widgetTick, 1000);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
(function (root, factory) {
|
||||
const renderMarkdown = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = renderMarkdown;
|
||||
root.renderMarkdown = renderMarkdown;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
function escapeHtml(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character]);
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
return escapeHtml(raw)
|
||||
.replace(/^#{1,6}\s.*$/gm, (line) => {
|
||||
const level = line.match(/^(#{1,6})/)[1].length;
|
||||
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
|
||||
})
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/\n/g, '<br>');
|
||||
};
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
-5
@@ -42,10 +42,27 @@ async def activity_events() -> list[dict]:
|
||||
events = await fetch(f"users/{user['login']}/activities/feeds?limit=20")
|
||||
return [
|
||||
{
|
||||
"type": event.get("op_type", "activity"),
|
||||
"actor": event.get("act_user") or {},
|
||||
"repo": event.get("repo") or {},
|
||||
"created_at": event.get("created", ""),
|
||||
"type": (
|
||||
event.get("op_type")
|
||||
if isinstance(event.get("op_type"), str) and event.get("op_type")
|
||||
else "activity"
|
||||
),
|
||||
"actor": (
|
||||
event.get("act_user")
|
||||
if isinstance(event.get("act_user"), dict)
|
||||
else {}
|
||||
),
|
||||
"repo": (
|
||||
event.get("repo")
|
||||
if isinstance(event.get("repo"), dict)
|
||||
else {}
|
||||
),
|
||||
"created_at": (
|
||||
event.get("created")
|
||||
if isinstance(event.get("created"), str)
|
||||
else ""
|
||||
),
|
||||
}
|
||||
for event in events
|
||||
for event in (events or [])
|
||||
if isinstance(event, dict)
|
||||
]
|
||||
+103
-19
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -13,6 +14,9 @@ from src.views import router as frontend_router
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard")
|
||||
CONTEXT_TIMEOUT_SECONDS = 5.0
|
||||
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -22,6 +26,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
app.include_router(frontend_router)
|
||||
|
||||
|
||||
@@ -35,15 +40,28 @@ def health() -> dict[str, str]:
|
||||
async def readiness():
|
||||
"""Return readiness after verifying the configured Gitea connection."""
|
||||
try:
|
||||
user = await current_user()
|
||||
user = await asyncio.wait_for(
|
||||
current_user(), timeout=READINESS_TIMEOUT_SECONDS
|
||||
)
|
||||
if not isinstance(user, dict) or not user.get("login"):
|
||||
raise ValueError("Gitea current-user response did not include a login")
|
||||
except Exception as exc:
|
||||
timed_out = isinstance(exc, TimeoutError)
|
||||
error_message = (
|
||||
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
|
||||
if timed_out
|
||||
else str(exc)
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": str(exc),
|
||||
"error": error_message,
|
||||
},
|
||||
status_code=503,
|
||||
headers={
|
||||
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
|
||||
} if timed_out else None,
|
||||
)
|
||||
return {
|
||||
"status": "ready",
|
||||
@@ -59,6 +77,15 @@ async def context() -> JSONResponse:
|
||||
asyncio.gather(current_user(), repos(), issues(), pull_requests()),
|
||||
timeout=CONTEXT_TIMEOUT_SECONDS,
|
||||
)
|
||||
if not isinstance(user_data, dict):
|
||||
raise ValueError("Gitea current-user response was not an object")
|
||||
if not all(field in user_data for field in ("id", "login")):
|
||||
raise ValueError("Gitea current-user response did not include id and login")
|
||||
if any(
|
||||
data is not None and not isinstance(data, list)
|
||||
for data in (repo_data, issues_data, prs_data)
|
||||
):
|
||||
raise ValueError("Gitea collection response was not a list")
|
||||
except Exception as e:
|
||||
error_message = (
|
||||
f"Gitea context request timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"
|
||||
@@ -79,33 +106,90 @@ async def context() -> JSONResponse:
|
||||
}, headers={
|
||||
"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))
|
||||
} if isinstance(e, TimeoutError) else None)
|
||||
user_model = User(id=user_data["id"], login=user_data["login"], full_name=user_data.get("full_name", ""), email=user_data.get("email", ""))
|
||||
user_model = User(id=user_data["id"], login=user_data["login"], full_name=user_data.get("full_name") or "", email=user_data.get("email") or "")
|
||||
repo_models = [
|
||||
Repo(id=r["id"], name=r["name"], full_name=r["full_name"], description=r.get("description", ""), url=r["html_url"], updated_at=r.get("updated_at", ""))
|
||||
for r in repo_data[:50]
|
||||
Repo(id=r["id"], name=r["name"], full_name=r["full_name"], description=r.get("description") or "", url=r["html_url"], updated_at=r.get("updated_at", ""))
|
||||
for r in (repo_data or [])[:50]
|
||||
if isinstance(r, dict)
|
||||
and all(field in r for field in ("id", "name", "full_name", "html_url"))
|
||||
]
|
||||
issue_models = [
|
||||
Issue(id=i["id"], number=i["number"], title=i["title"], state=i["state"], labels=[l.get("name", "") for l in i.get("labels", [])], assignees=[a.get("login", "") for a in i.get("assignees", [])], url=i["html_url"])
|
||||
for i in issues_data[:50]
|
||||
Issue(
|
||||
id=i["id"],
|
||||
number=i["number"],
|
||||
title=i["title"],
|
||||
state=i["state"],
|
||||
labels=[
|
||||
label.get("name", "")
|
||||
for label in (i.get("labels") or [])
|
||||
if isinstance(label, dict)
|
||||
],
|
||||
assignees=[
|
||||
assignee.get("login", "")
|
||||
for assignee in (i.get("assignees") or [])
|
||||
if isinstance(assignee, dict)
|
||||
],
|
||||
url=i["html_url"],
|
||||
)
|
||||
for i in (issues_data or [])[:50]
|
||||
if isinstance(i, dict)
|
||||
and all(
|
||||
field in i
|
||||
for field in ("id", "number", "title", "state", "html_url")
|
||||
)
|
||||
]
|
||||
pr_models = [
|
||||
PullRequest(id=p["id"], number=p["number"], title=p["title"], state=p["state"], user=p.get("user", {}).get("login", ""), url=p["html_url"])
|
||||
for p in prs_data[:50]
|
||||
PullRequest(
|
||||
id=p["id"],
|
||||
number=p["number"],
|
||||
title=p["title"],
|
||||
state=p["state"],
|
||||
user=(
|
||||
p["user"].get("login", "")
|
||||
if isinstance(p.get("user"), dict)
|
||||
else ""
|
||||
),
|
||||
url=p["html_url"],
|
||||
)
|
||||
for p in (prs_data or [])[:50]
|
||||
if isinstance(p, dict)
|
||||
and all(
|
||||
field in p
|
||||
for field in ("id", "number", "title", "state", "html_url")
|
||||
)
|
||||
]
|
||||
ctx = compute(user_model, repo_models, issue_models, pr_models)
|
||||
return JSONResponse({
|
||||
"user": ctx.user.dict(),
|
||||
"repos": [r.dict() for r in ctx.repos],
|
||||
"issues": [i.dict() for i in ctx.issues],
|
||||
"pull_requests": [p.dict() for p in ctx.pull_requests],
|
||||
"view": ctx.view,
|
||||
"deltas": [d.dict() for d in ctx.deltas],
|
||||
})
|
||||
return JSONResponse(ctx.model_dump())
|
||||
|
||||
|
||||
@app.get("/api/v1/events")
|
||||
async def event_stream() -> list[dict]:
|
||||
return await activity_events()
|
||||
async def event_stream():
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
activity_events(), timeout=EVENT_STREAM_TIMEOUT_SECONDS
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": (
|
||||
"Gitea event stream request timed out after "
|
||||
f"{EVENT_STREAM_TIMEOUT_SECONDS:g}s"
|
||||
)
|
||||
},
|
||||
status_code=503,
|
||||
headers={
|
||||
"Retry-After": str(
|
||||
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
|
||||
)
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Gitea event stream is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={
|
||||
"Retry-After": str(
|
||||
max(1, math.ceil(EVENT_STREAM_TIMEOUT_SECONDS))
|
||||
)
|
||||
},
|
||||
)
|
||||
+8
-8
@@ -36,17 +36,17 @@ class PullRequest(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class SuggestionDelta(BaseModel):
|
||||
panel: str
|
||||
action: str
|
||||
target: str = ""
|
||||
priority: str = "medium"
|
||||
|
||||
|
||||
class ViewContext(BaseModel):
|
||||
user: User
|
||||
repos: list[Repo] = []
|
||||
issues: list[Issue] = []
|
||||
pull_requests: list[PullRequest] = []
|
||||
view: str = "dashboard"
|
||||
deltas: list[dict] = []
|
||||
|
||||
|
||||
class SuggestionDelta(BaseModel):
|
||||
panel: str
|
||||
action: str
|
||||
target: str = ""
|
||||
priority: str = "medium"
|
||||
deltas: list[SuggestionDelta] = []
|
||||
+4
-1
@@ -1,9 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard() -> str:
|
||||
return open("frontend/index.html").read()
|
||||
return DASHBOARD_FILE.read_text()
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
|
||||
|
||||
|
||||
def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
html = DASHBOARD_HTML.read_text()
|
||||
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
|
||||
|
||||
assert api_paths
|
||||
assert {
|
||||
urljoin("https://forge.alexanderwhitestone.com/dashboard/", path)
|
||||
for path in api_paths
|
||||
} == {
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/context",
|
||||
"https://forge.alexanderwhitestone.com/dashboard/api/v1/events",
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
import subprocess
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.sources = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "script":
|
||||
source = dict(attrs).get("src")
|
||||
if source:
|
||||
self.sources.append(source)
|
||||
|
||||
|
||||
def test_filtered_command_runs_the_matching_action():
|
||||
script = f"""
|
||||
const filterCommands = require({json.dumps(str(COMMANDS))});
|
||||
let action = '';
|
||||
const commands = [
|
||||
{{ name: 'Open whiteboard', run: () => {{ action = 'whiteboard'; }} }},
|
||||
{{ name: 'Refresh now', run: () => {{ action = 'refresh'; }} }},
|
||||
];
|
||||
const matches = filterCommands(commands, 'refresh');
|
||||
if (matches.length !== 1) throw new Error(`expected one match, got ${{matches.length}}`);
|
||||
matches[0].run();
|
||||
if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_command_script_resolves_inside_dashboard_subpath():
|
||||
parser = ScriptSourceParser()
|
||||
parser.feed((FRONTEND / "index.html").read_text())
|
||||
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
|
||||
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/", command_source
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -30,4 +31,359 @@ async def test_context_returns_fallback_when_gitea_exceeds_deadline(monkeypatch)
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert b'"repos":[]' in response.body
|
||||
assert b'"error":"Gitea context request timed out after 0.01s"' in response.body
|
||||
assert cancelled.is_set()
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_issue_collections(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def issue_collection():
|
||||
return [{
|
||||
"id": 50,
|
||||
"number": 50,
|
||||
"title": "Nullable metadata",
|
||||
"state": "open",
|
||||
"labels": None,
|
||||
"assignees": None,
|
||||
"html_url": "https://forge.example/issues/50",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", issue_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
issue = json.loads(response.body)["issues"][0]
|
||||
|
||||
assert issue["labels"] == []
|
||||
assert issue["assignees"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_skips_malformed_nested_issue_metadata(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def issue_collection():
|
||||
return [{
|
||||
"id": 85,
|
||||
"number": 85,
|
||||
"title": "Malformed nested metadata",
|
||||
"state": "open",
|
||||
"labels": [None, {"name": "bug"}, "invalid"],
|
||||
"assignees": [None, {"login": "timmy"}, "invalid"],
|
||||
"html_url": "https://forge.example/issues/85",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", issue_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
issue = json.loads(response.body)["issues"][0]
|
||||
|
||||
assert issue["labels"] == ["bug"]
|
||||
assert issue["assignees"] == ["timmy"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_repository_description(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def repo_collection():
|
||||
return [{
|
||||
"id": 54,
|
||||
"name": "dashboard",
|
||||
"full_name": "stackchain/dashboard",
|
||||
"description": None,
|
||||
"html_url": "https://forge.example/stackchain/dashboard",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", repo_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
repository = json.loads(response.body)["repos"][0]
|
||||
|
||||
assert repository["description"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_pull_request_author(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def pull_request_collection():
|
||||
return [{
|
||||
"id": 56,
|
||||
"number": 56,
|
||||
"title": "Nullable author",
|
||||
"state": "open",
|
||||
"user": None,
|
||||
"html_url": "https://forge.example/pulls/56",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
|
||||
|
||||
response = await main.context()
|
||||
pull_request = json.loads(response.body)["pull_requests"][0]
|
||||
|
||||
assert pull_request["user"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_malformed_pull_request_author(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
async def pull_request_collection():
|
||||
return [{
|
||||
"id": 87,
|
||||
"number": 87,
|
||||
"title": "Malformed author",
|
||||
"state": "open",
|
||||
"user": "unknown",
|
||||
"html_url": "https://forge.example/pulls/87",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
|
||||
|
||||
response = await main.context()
|
||||
pull_request = json.loads(response.body)["pull_requests"][0]
|
||||
|
||||
assert pull_request["user"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_null_collection_payloads(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def null_collection():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", null_collection)
|
||||
monkeypatch.setattr(main, "issues", null_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", null_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert context["repos"] == []
|
||||
assert context["issues"] == []
|
||||
assert context["pull_requests"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_returns_fallback_for_malformed_collection_payloads(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def malformed_collection():
|
||||
return {"message": "unexpected upstream shape"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", malformed_collection)
|
||||
monkeypatch.setattr(main, "issues", malformed_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", malformed_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert context["repos"] == []
|
||||
assert context["issues"] == []
|
||||
assert context["pull_requests"] == []
|
||||
assert context["error"] == "Gitea collection response was not a list"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_normalizes_nullable_user_profile_fields(monkeypatch):
|
||||
async def user():
|
||||
return {
|
||||
"id": 1,
|
||||
"login": "timmy",
|
||||
"full_name": None,
|
||||
"email": None,
|
||||
}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert context["user"]["full_name"] == ""
|
||||
assert context["user"]["email"] == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_returns_fallback_for_null_current_user_payload(monkeypatch):
|
||||
async def null_user():
|
||||
return None
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", null_user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert context["repos"] == []
|
||||
assert context["error"] == "Gitea current-user response was not an object"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_returns_fallback_for_incomplete_current_user_payload(monkeypatch):
|
||||
async def incomplete_user():
|
||||
return {"id": 1}
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", incomplete_user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
context = json.loads(response.body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert context["repos"] == []
|
||||
assert context["error"] == "Gitea current-user response did not include id and login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_skips_malformed_repository_entries(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def repository_collection():
|
||||
return [
|
||||
{"id": 79, "name": "missing-required-fields"},
|
||||
{
|
||||
"id": 1,
|
||||
"name": "dashboard",
|
||||
"full_name": "stackchain/dashboard",
|
||||
"description": "Operations dashboard",
|
||||
"html_url": "https://forge.example/stackchain/dashboard",
|
||||
},
|
||||
]
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", repository_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
repositories = json.loads(response.body)["repos"]
|
||||
|
||||
assert [repository["name"] for repository in repositories] == ["dashboard"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_skips_malformed_issue_entries(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def issue_collection():
|
||||
return [
|
||||
{"id": 81, "title": "missing-required-fields"},
|
||||
{
|
||||
"id": 1,
|
||||
"number": 81,
|
||||
"title": "Valid issue",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/dashboard/issues/81",
|
||||
},
|
||||
]
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", issue_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_collection)
|
||||
|
||||
response = await main.context()
|
||||
issues = json.loads(response.body)["issues"]
|
||||
|
||||
assert [issue["title"] for issue in issues] == ["Valid issue"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_skips_malformed_pull_request_entries(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def pull_request_collection():
|
||||
return [
|
||||
{"id": 83, "title": "missing-required-fields"},
|
||||
{
|
||||
"id": 1,
|
||||
"number": 83,
|
||||
"title": "Valid pull request",
|
||||
"state": "open",
|
||||
"html_url": "https://forge.example/stackchain/stackchain-dashboard/pulls/83",
|
||||
},
|
||||
]
|
||||
|
||||
async def empty_collection():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty_collection)
|
||||
monkeypatch.setattr(main, "issues", empty_collection)
|
||||
monkeypatch.setattr(main, "pull_requests", pull_request_collection)
|
||||
|
||||
response = await main.context()
|
||||
pull_requests = json.loads(response.body)["pull_requests"]
|
||||
|
||||
assert [pull_request["title"] for pull_request in pull_requests] == [
|
||||
"Valid pull request"
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -28,6 +30,13 @@ def test_event_stream_reports_when_activity_was_refreshed():
|
||||
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
|
||||
|
||||
|
||||
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
|
||||
html = DASHBOARD.read_text()
|
||||
|
||||
assert "setEventStreamStatus('Update failed · showing last activity')" in html
|
||||
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_stream_returns_recent_authenticated_gitea_activity(monkeypatch):
|
||||
expected = [{"type": "create", "actor": {"login": "timmy"}}]
|
||||
@@ -42,6 +51,45 @@ async def test_event_stream_returns_recent_authenticated_gitea_activity(monkeypa
|
||||
assert response == expected
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_stream_returns_retryable_response_when_gitea_exceeds_deadline(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hanging_activity_events():
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "EVENT_STREAM_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main, "activity_events", hanging_activity_events)
|
||||
|
||||
response = await main.event_stream()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert json.loads(response.body) == {
|
||||
"error": "Gitea event stream request timed out after 0.01s"
|
||||
}
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_event_stream_returns_retryable_response_when_gitea_request_fails(monkeypatch):
|
||||
async def failing_activity_events():
|
||||
raise ConnectionError("connection refused")
|
||||
|
||||
monkeypatch.setattr(main, "activity_events", failing_activity_events)
|
||||
|
||||
response = await main.event_stream()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "5"
|
||||
assert json.loads(response.body) == {
|
||||
"error": "Gitea event stream is temporarily unavailable"
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
|
||||
paths = []
|
||||
@@ -70,3 +118,71 @@ async def test_activity_events_fetches_feed_for_authenticated_user(monkeypatch):
|
||||
"created_at": "2026-08-05T03:00:00Z",
|
||||
}]
|
||||
assert paths == ["users/timmy/activities/feeds?limit=20"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_skips_malformed_feed_entries(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return [
|
||||
None,
|
||||
{
|
||||
"op_type": "push",
|
||||
"act_user": {"login": "timmy"},
|
||||
"repo": {"full_name": "stackchain/stackchain-dashboard"},
|
||||
"created": "2026-08-06T08:00:00Z",
|
||||
},
|
||||
"invalid",
|
||||
]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
result = await gitea_proxy.activity_events()
|
||||
|
||||
assert result == [{
|
||||
"type": "push",
|
||||
"actor": {"login": "timmy"},
|
||||
"repo": {"full_name": "stackchain/stackchain-dashboard"},
|
||||
"created_at": "2026-08-06T08:00:00Z",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_normalizes_null_feed_payload(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.activity_events() == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_activity_events_normalizes_malformed_nested_metadata(monkeypatch):
|
||||
async def fake_current_user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def fake_fetch(path):
|
||||
return [{
|
||||
"op_type": None,
|
||||
"act_user": "unknown",
|
||||
"repo": None,
|
||||
"created": None,
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "current_user", fake_current_user)
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.activity_events() == [{
|
||||
"type": "activity",
|
||||
"actor": {},
|
||||
"repo": {},
|
||||
"created_at": "",
|
||||
}]
|
||||
@@ -1,3 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
@@ -37,3 +40,46 @@ async def test_readiness_endpoint_returns_503_when_gitea_is_unavailable(monkeypa
|
||||
assert response.status_code == 503
|
||||
assert b'"status":"not_ready"' in response.body
|
||||
assert b'"error":"connection refused"' in response.body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_returns_503_for_null_current_user_payload(monkeypatch):
|
||||
async def null_user():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "current_user", null_user)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert json.loads(response.body) == {
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": "Gitea current-user response did not include a login",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def hanging_user():
|
||||
try:
|
||||
await asyncio.sleep(0.05)
|
||||
return {"login": "too-late"}
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(main, "READINESS_TIMEOUT_SECONDS", 0.01, raising=False)
|
||||
monkeypatch.setattr(main, "current_user", hanging_user)
|
||||
|
||||
response = await main.readiness()
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert json.loads(response.body) == {
|
||||
"status": "not_ready",
|
||||
"service": "stackchain-dashboard",
|
||||
"error": "Gitea readiness check timed out after 0.01s",
|
||||
}
|
||||
assert cancelled.is_set()
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
import subprocess
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parent.parent / "frontend"
|
||||
RENDERER = FRONTEND / "markdown.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.sources = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "script":
|
||||
source = dict(attrs).get("src")
|
||||
if source:
|
||||
self.sources.append(source)
|
||||
|
||||
|
||||
def test_markdown_renderer_escapes_raw_html_before_rendering_heading():
|
||||
payload = "# <img src=x onerror=alert(document.domain)>"
|
||||
script = (
|
||||
f"const render = require({json.dumps(str(RENDERER))});"
|
||||
f"process.stdout.write(render({json.dumps(payload)}));"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.stdout == (
|
||||
"<h1><img src=x onerror=alert(document.domain)></h1>"
|
||||
)
|
||||
|
||||
|
||||
def test_markdown_script_resolves_inside_dashboard_subpath():
|
||||
parser = ScriptSourceParser()
|
||||
parser.feed((FRONTEND / "index.html").read_text())
|
||||
markdown_source = next(
|
||||
source for source in parser.sources if source.endswith("markdown.js")
|
||||
)
|
||||
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/", markdown_source
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js"
|
||||
@@ -0,0 +1,18 @@
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_python_bytecode_is_not_tracked():
|
||||
tracked = subprocess.run(
|
||||
["git", "ls-files"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.splitlines()
|
||||
|
||||
bytecode = [
|
||||
path
|
||||
for path in tracked
|
||||
if "__pycache__" in path.split("/") or path.endswith((".pyc", ".pyo"))
|
||||
]
|
||||
|
||||
assert bytecode == []
|
||||
@@ -0,0 +1,12 @@
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_outside_repository_working_directory(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
html = await dashboard()
|
||||
|
||||
assert "Stackchain Dashboard" in html
|
||||
@@ -0,0 +1,18 @@
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_repo_mix_placeholder_before_javascript_runs():
|
||||
html = await dashboard()
|
||||
server_rendered_html = html.split("<script>", 1)[0]
|
||||
|
||||
assert '<div class="small" id="repo-mix">Loading…</div>' in server_rendered_html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_marks_fallback_context_as_degraded_instead_of_live():
|
||||
html = await dashboard()
|
||||
|
||||
assert "data.error ? 'Degraded · ' + data.error : 'Live · updated just now'" in html
|
||||
Reference in new issue
Block a user