dash test runner
This commit is contained in:
@@ -212,6 +212,16 @@
|
||||
.console-level-info { color:var(--accent); }
|
||||
.console-level-warn { color:var(--warn); }
|
||||
.console-level-error { color:var(--bad); }
|
||||
.testing-controls { display:flex; gap:8px; margin-bottom:8px; }
|
||||
.testing-controls input { flex:1; background:var(--bg); border:1px solid var(--border);
|
||||
color:var(--fg); border-radius:6px; padding:4px 8px; font-size:12px; }
|
||||
.testing-error { border:1px solid var(--bad); border-radius:6px; color:var(--bad);
|
||||
padding:6px 9px; margin-bottom:8px; font-size:12px; }
|
||||
.testing-list { max-height:320px; overflow:auto; }
|
||||
.testing-row { display:flex; align-items:center; gap:8px; padding:3px 0;
|
||||
border-bottom:1px solid var(--border); font-size:12px; }
|
||||
.testing-row .testing-target { flex:1; word-break:break-all; font-family:monospace; }
|
||||
.testing-row button[disabled] { opacity:.45; cursor:not-allowed; }
|
||||
.status-pending { color:var(--warn); }
|
||||
.status-processing { color:var(--accent); }
|
||||
.status-failed { color:var(--bad); }
|
||||
@@ -233,6 +243,7 @@
|
||||
<button id="tab-chat" onclick="switchDashboardTab('chat')">Chat</button>
|
||||
<button id="tab-billing" style="display:none" onclick="switchDashboardTab('billing')">Billing</button>
|
||||
<button id="tab-admin" style="display:none" onclick="switchDashboardTab('admin')">Admin</button>
|
||||
<button id="tab-testing" style="display:none" onclick="switchDashboardTab('testing')">Testing</button>
|
||||
</nav>
|
||||
<main>
|
||||
<section data-tab="overview" id="account-section"><h2>Account</h2><div id="account">loading…</div></section>
|
||||
@@ -278,6 +289,23 @@
|
||||
<section data-tab="admin" data-admin-only><h2>Strikes / bans / forfeitures</h2><div id="fraud" class="empty">admin login required</div></section>
|
||||
<section data-tab="admin"><h2>Client balances</h2><div id="clients" class="empty">admin login required</div></section>
|
||||
<section data-tab="admin" class="wide"><h2>Console output</h2><div id="console" class="console empty">admin login required</div></section>
|
||||
<section data-tab="testing" data-admin-only>
|
||||
<h2>Test run status</h2>
|
||||
<div id="testing-error" class="testing-error" hidden></div>
|
||||
<div id="testing-status" class="empty">no test run yet</div>
|
||||
</section>
|
||||
<section data-tab="testing" data-admin-only>
|
||||
<h2>Tests & suites</h2>
|
||||
<div class="testing-controls">
|
||||
<input id="testing-filter" type="search" placeholder="filter tests…" oninput="renderTestTargets()" aria-label="Filter tests">
|
||||
<button class="small" type="button" onclick="reloadTestTargets()">re-collect</button>
|
||||
</div>
|
||||
<div id="testing-targets" class="empty">admin login required</div>
|
||||
</section>
|
||||
<section data-tab="testing" data-admin-only class="wide">
|
||||
<h2>Test output</h2>
|
||||
<div id="testing-log" class="console empty">no test output yet</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
"use strict";
|
||||
@@ -986,6 +1014,216 @@ function renderConsole(data) {
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// ---- testing tab (opt-in tracker test runner, dashboard-test-runner US-002) ----
|
||||
|
||||
let testCollection = { tests: [], suites: [] };
|
||||
let testRun = null;
|
||||
let testRunStarting = false;
|
||||
|
||||
const TEST_RUN_POLL_MS = 1500;
|
||||
const TEST_LOG_MAX_LINES = 400;
|
||||
|
||||
function testRunActive() {
|
||||
return testRunStarting || (testRun !== null && testRun.status === "running");
|
||||
}
|
||||
|
||||
function showTestingError(message) {
|
||||
const el = $("testing-error");
|
||||
if (!el) return;
|
||||
el.hidden = !message;
|
||||
el.textContent = message || "";
|
||||
}
|
||||
|
||||
// Non-2xx from the tracker carries {"error": ...}; 403 also covers the
|
||||
// disabled-by-default runner, so surface the server's own wording.
|
||||
function testingErrorText(result) {
|
||||
if (!result) return "test runner request failed";
|
||||
if (result.status === 0) return "test runner unreachable";
|
||||
return (result.data && result.data.error) || `test runner request failed (HTTP ${result.status})`;
|
||||
}
|
||||
|
||||
function fmtElapsed(seconds) {
|
||||
if (typeof seconds !== "number" || !isFinite(seconds) || seconds < 0) return "—";
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
return `${mins}m ${Math.floor(seconds % 60)}s`;
|
||||
}
|
||||
|
||||
function fmtTestTime(ts) {
|
||||
if (typeof ts !== "number" || !ts) return "—";
|
||||
return new Date(ts * 1000).toLocaleTimeString();
|
||||
}
|
||||
|
||||
function testStatusClass(status) {
|
||||
if (status === "running") return "status-processing";
|
||||
if (status === "passed") return "status-complete";
|
||||
if (status === "failed" || status === "timeout") return "status-failed";
|
||||
return "status-pending";
|
||||
}
|
||||
|
||||
function renderTestRunStatus() {
|
||||
const el = $("testing-status");
|
||||
if (!el) return;
|
||||
if (!testRun) {
|
||||
el.className = "empty";
|
||||
el.innerHTML = testRunStarting ? "starting test run…" : "no test run yet";
|
||||
return;
|
||||
}
|
||||
el.className = "";
|
||||
const status = String(testRun.status || "unknown");
|
||||
const exitCode = testRun.exit_code;
|
||||
const outcome = status === "running"
|
||||
? '<span class="status-processing">running</span>'
|
||||
: status === "passed"
|
||||
? '<span class="ok">success</span>'
|
||||
: `<span class="bad">failure</span>`;
|
||||
el.innerHTML = table(
|
||||
["target", "state", "outcome", "started", "ended", "elapsed", "exit code"],
|
||||
[[
|
||||
`<span class="testing-target">${esc(testRun.target || "—")}</span>`,
|
||||
`<span class="pill ${testStatusClass(status)}">${esc(status)}</span>`,
|
||||
outcome,
|
||||
fmtTestTime(testRun.started_at),
|
||||
fmtTestTime(testRun.finished_at),
|
||||
`<span class="num">${fmtElapsed(testRun.elapsed_seconds)}</span>`,
|
||||
exitCode === null || exitCode === undefined
|
||||
? '<span class="dim">—</span>'
|
||||
: `<span class="num ${exitCode === 0 ? "ok" : "bad"}">${esc(exitCode)}</span>`,
|
||||
]],
|
||||
);
|
||||
}
|
||||
|
||||
function renderTestLog(stdout, stderr) {
|
||||
const el = $("testing-log");
|
||||
if (!el) return;
|
||||
const lines = [];
|
||||
for (const line of String(stdout || "").split("\n")) {
|
||||
if (line !== "") lines.push({ text: line, err: false });
|
||||
}
|
||||
for (const line of String(stderr || "").split("\n")) {
|
||||
if (line !== "") lines.push({ text: line, err: true });
|
||||
}
|
||||
if (!lines.length) {
|
||||
el.className = "console empty";
|
||||
el.innerHTML = testRunActive() ? "waiting for test output…" : "no test output yet";
|
||||
return;
|
||||
}
|
||||
// Bounded view: the tracker already caps retained lines; cap the DOM too.
|
||||
const shown = lines.slice(-TEST_LOG_MAX_LINES);
|
||||
const pinned = el.scrollTop + el.clientHeight >= el.scrollHeight - 24;
|
||||
el.className = "console";
|
||||
el.innerHTML = shown.map(line =>
|
||||
`<div class="console-line${line.err ? " console-level-error" : ""}">${esc(line.text)}</div>`
|
||||
).join("");
|
||||
if (pinned) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function renderTestTargets() {
|
||||
const el = $("testing-targets");
|
||||
if (!el) return;
|
||||
const filter = ($("testing-filter")?.value || "").trim().toLowerCase();
|
||||
const suites = (testCollection.suites || [])
|
||||
.map(s => ({ id: s.id, label: `${s.name} (${(s.paths || []).join(", ")})`, suite: true }));
|
||||
const tests = (testCollection.tests || []).map(t => ({ id: t, label: t, suite: false }));
|
||||
const targets = [...suites, ...tests]
|
||||
.filter(t => !filter || t.label.toLowerCase().includes(filter));
|
||||
if (!targets.length) {
|
||||
el.className = "empty";
|
||||
el.innerHTML = filter ? "no targets match the filter" : "no tests collected";
|
||||
return;
|
||||
}
|
||||
const disabled = testRunActive() ? " disabled" : "";
|
||||
el.className = "testing-list";
|
||||
el.innerHTML = targets.map(t =>
|
||||
`<div class="testing-row">` +
|
||||
`<span class="testing-target">${t.suite ? '<span class="pill">suite</span> ' : ""}${esc(t.label)}</span>` +
|
||||
`<button class="small" type="button" data-test-target="${esc(t.id)}"${disabled}>Run</button>` +
|
||||
`</div>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
function renderTesting() {
|
||||
renderTestRunStatus();
|
||||
renderTestTargets();
|
||||
}
|
||||
|
||||
function applyTestStatus(result) {
|
||||
if (!result || !result.ok) {
|
||||
showTestingError(testingErrorText(result));
|
||||
return false;
|
||||
}
|
||||
testRun = result.data.run || null;
|
||||
renderTestRunStatus();
|
||||
renderTestLog(result.data.stdout, result.data.stderr);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runTest(target) {
|
||||
if (testRunActive()) return;
|
||||
showTestingError("");
|
||||
testRunStarting = true;
|
||||
renderTesting();
|
||||
const result = await apiCall("/v1/tests/run", "POST", { target });
|
||||
testRunStarting = false;
|
||||
if (!result.ok) {
|
||||
showTestingError(testingErrorText(result));
|
||||
renderTesting();
|
||||
return;
|
||||
}
|
||||
testRun = result.data.run || null;
|
||||
renderTesting();
|
||||
renderTestLog(result.data.stdout, result.data.stderr);
|
||||
}
|
||||
|
||||
async function loadTestTargets(refresh) {
|
||||
const result = await apiCall(`/v1/tests${refresh ? "?refresh=1" : ""}`);
|
||||
if (!result.ok) {
|
||||
showTestingError(testingErrorText(result));
|
||||
$("testing-targets").className = "empty";
|
||||
$("testing-targets").innerHTML = "test targets unavailable";
|
||||
return false;
|
||||
}
|
||||
testCollection = { tests: result.data.tests || [], suites: result.data.suites || [] };
|
||||
renderTestTargets();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reloadTestTargets() {
|
||||
showTestingError("");
|
||||
await loadTestTargets(true);
|
||||
}
|
||||
|
||||
async function fetchTestingTab() {
|
||||
if (!isAdmin) return;
|
||||
showTestingError("");
|
||||
const [, statusResult] = await Promise.all([
|
||||
loadTestTargets(false),
|
||||
apiCall("/v1/tests/status"),
|
||||
]);
|
||||
applyTestStatus(statusResult);
|
||||
renderTestTargets();
|
||||
}
|
||||
|
||||
// Auto-refresh status + log while a run is in flight (independent of the
|
||||
// selection/edit guards that pause the other panels — logs must keep flowing).
|
||||
async function pollTestRunIfActive() {
|
||||
if (dashboardTab !== "testing" || !isAdmin || !testRunActive()) return;
|
||||
applyTestStatus(await apiCall("/v1/tests/status"));
|
||||
// Re-enable the Run buttons on the transition out of "running".
|
||||
if (!testRunActive()) renderTestTargets();
|
||||
}
|
||||
|
||||
function bindTestingControls() {
|
||||
const list = $("testing-targets");
|
||||
if (!list) return;
|
||||
list.addEventListener("click", event => {
|
||||
const button = event.target.closest("[data-test-target]");
|
||||
if (!button || button.disabled) return;
|
||||
event.preventDefault();
|
||||
void runTest(button.getAttribute("data-test-target"));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- account panel (registration / login / balance / usage / API keys) ----
|
||||
|
||||
let sessionToken = localStorage.getItem("meshnet_session") || null;
|
||||
@@ -1247,12 +1485,12 @@ function bindChatSessionList() {
|
||||
}
|
||||
|
||||
function switchDashboardTab(name) {
|
||||
if (name === "admin" && !isAdmin) name = "overview";
|
||||
if ((name === "admin" || name === "testing") && !isAdmin) name = "overview";
|
||||
if (name === "billing" && !isLoggedIn) name = "overview";
|
||||
dashboardTab = name;
|
||||
document.body.classList.toggle("chat-tab-active", name === "chat");
|
||||
updateSectionVisibility();
|
||||
for (const tabName of ["overview", "chat", "billing", "admin"]) {
|
||||
for (const tabName of ["overview", "chat", "billing", "admin", "testing"]) {
|
||||
const button = $("tab-" + tabName);
|
||||
if (button) button.classList.toggle("active", tabName === dashboardTab);
|
||||
}
|
||||
@@ -1409,8 +1647,9 @@ function chatAuthToken() {
|
||||
function setAdminMode(enabled) {
|
||||
isAdmin = enabled;
|
||||
$("tab-admin").style.display = enabled ? "" : "none";
|
||||
$("tab-testing").style.display = enabled ? "" : "none";
|
||||
$("request-model-load").style.display = enabled ? "" : "none";
|
||||
if (!enabled && dashboardTab === "admin") {
|
||||
if (!enabled && (dashboardTab === "admin" || dashboardTab === "testing")) {
|
||||
switchDashboardTab("overview");
|
||||
} else {
|
||||
updateSectionVisibility();
|
||||
@@ -2072,6 +2311,7 @@ const TAB_FETCHERS = {
|
||||
chat: fetchChatTab,
|
||||
billing: fetchBillingTab,
|
||||
admin: fetchAdminTab,
|
||||
testing: fetchTestingTab,
|
||||
};
|
||||
|
||||
async function refreshActiveTab(force) {
|
||||
@@ -2098,6 +2338,7 @@ bindChatSessionList();
|
||||
bindChatModelSelect();
|
||||
initChatSessions();
|
||||
bindChatPromptShortcuts();
|
||||
bindTestingControls();
|
||||
(async () => {
|
||||
if (sessionToken) await loadAccountSummary(true);
|
||||
else renderAuthForms();
|
||||
@@ -2109,6 +2350,7 @@ bindChatPromptShortcuts();
|
||||
renderChatAuthHint();
|
||||
})();
|
||||
setInterval(pollCallWallIfIdle, CALL_WALL_POLL_MS);
|
||||
setInterval(pollTestRunIfActive, TEST_RUN_POLL_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user