4 const roots = Array.from(document.querySelectorAll("[data-dbx-selftest]"));
8 if (root.__dbxSelfTestBound) return;
9 root.__dbxSelfTestBound = true;
22 const q = selector => root.querySelector(selector);
23 const qa = selector => Array.from(root.querySelectorAll(selector));
25 catalog: root.dataset.catalogUrl,
26 start: root.dataset.startUrl,
27 execute: root.dataset.executeUrl,
28 finish: root.dataset.finishUrl,
29 browserResult: root.dataset.browserResultUrl,
30 run: root.dataset.runUrl,
31 download: root.dataset.downloadUrl
34 async function request(url, payload) {
35 const options = payload === undefined ? {} : {
37 headers: { "Content-Type": "application/json" },
38 body: JSON.stringify(payload)
40 const response = await fetch(url, options);
41 const raw = await response.text();
44 data = JSON.parse(raw);
47 .replace(/<[^>]*>/g, " ")
48 .replace(/ /gi, " ")
54 error: "Serverantwort ist kein JSON" + (detail ? ": " + detail : " (HTTP " + response.status + ").")
57 if (!response.ok || !data || !data.ok) {
58 throw new Error((data && (data.error || data.msg)) || ("HTTP " + response.status));
63 function element(tag, className, text) {
64 const node = document.createElement(tag);
65 if (className) node.className = className;
66 if (text !== undefined) node.textContent = String(text);
70 function filteredTests() {
71 const term = String(q("[data-selftest-search]")?.value || "").trim().toLowerCase();
72 const category = String(q("[data-selftest-category]")?.value || "");
73 return state.tests.filter(test => {
74 const hay = [test.name, test.category, test.relative_path, test.description].join(" ").toLowerCase();
75 return (!term || hay.includes(term)) && (!category || test.category === category);
79 function statusBadge(status) {
85 skipped: "Übersprungen",
86 aborted: "Abgebrochen",
87 interrupted: "Unterbrochen"
90 pending: "text-bg-secondary",
91 running: "text-bg-primary",
92 passed: "text-bg-success",
93 failed: "text-bg-danger",
94 skipped: "text-bg-warning",
95 aborted: "text-bg-secondary",
96 interrupted: "text-bg-warning"
98 return element("span", "badge dbx-selftest-status " + (classes[status] || classes.pending), labels[status] || status);
101 function renderCatalog() {
102 const host = q("[data-selftest-list]");
104 host.replaceChildren();
105 const tests = filteredTests();
107 host.append(element("div", "dbx-selftest-empty", "Keine passenden Tests gefunden."));
111 const table = element("table", "table table-sm table-hover align-middle dbx-selftest-table");
112 const thead = document.createElement("thead");
113 const headRow = document.createElement("tr");
114 ["", "Test", "Bereich", "Profil", "Status", ""].forEach(label => headRow.append(element("th", "", label)));
115 thead.append(headRow);
116 const tbody = document.createElement("tbody");
118 tests.forEach(test => {
119 const result = state.results.get(test.id);
120 const status = result ? result.status : (state.runningTestId === test.id ? "running" : "pending");
121 const row = element("tr", "dbx-selftest-row is-" + status);
122 row.dataset.testId = test.id;
124 const selectCell = document.createElement("td");
125 const checkbox = document.createElement("input");
126 checkbox.type = "checkbox";
127 checkbox.className = "form-check-input";
128 checkbox.checked = state.selected.has(test.id);
129 checkbox.dataset.selftestSelect = test.id;
130 checkbox.setAttribute("aria-label", test.name + " auswählen");
131 selectCell.append(checkbox);
133 const nameCell = document.createElement("td");
134 nameCell.append(element("span", "dbx-selftest-testname", test.name));
135 nameCell.append(element("span", "dbx-selftest-path", test.relative_path || test.description));
136 const categoryCell = element("td", "", test.category);
137 const tierCell = document.createElement("td");
138 tierCell.append(element("span", "badge " + (test.tier === "quick" ? "text-bg-info" : "text-bg-light"), test.tier === "quick" ? "Schnell" : "Komplett"));
139 const statusCell = document.createElement("td");
140 statusCell.append(statusBadge(status));
141 const actionCell = document.createElement("td");
142 const button = element("button", "btn btn-outline-primary btn-sm", "Einzeltest");
143 button.type = "button";
144 button.dataset.selftestSingle = test.id;
145 button.disabled = state.busy;
146 actionCell.append(button);
148 [selectCell, nameCell, categoryCell, tierCell, statusCell, actionCell].forEach(cell => row.append(cell));
151 table.append(thead, tbody);
155 function renderCategories() {
156 const select = q("[data-selftest-category]");
158 const selected = select.value;
159 select.replaceChildren(new Option("Alle Bereiche", ""));
160 Array.from(new Set(state.tests.map(test => test.category))).sort().forEach(category => {
161 select.append(new Option(category, category));
163 select.value = selected;
166 function updateProgress(run, currentName) {
167 const box = q("[data-selftest-progress]");
168 if (!box || !run) return;
170 const totals = run.totals || {};
171 const total = Number(totals.total || (run.test_ids || []).length || 0);
172 const completed = Number(totals.completed || 0);
173 const percent = total ? Math.round((completed / total) * 100) : 0;
174 q("[data-selftest-progress-count]").textContent = completed + " / " + total;
175 q("[data-selftest-progress-bar]").style.width = percent + "%";
176 q("[data-selftest-current]").textContent = currentName || (run.status === "running" ? "Nächster Test wird vorbereitet …" : runStatusLabel(run.status));
177 q("[data-selftest-progress-title]").textContent = run.profile === "quick" ? "Schnelltest" : "Kompletttest";
180 function runStatusLabel(status) {
181 return ({ passed: "Alle Tests bestanden.", failed: "Testlauf mit Fehlern abgeschlossen.", aborted: "Testlauf abgebrochen.", running: "Testlauf läuft." })[status] || status;
184 function setBusy(busy) {
186 qa("[data-selftest-run]").forEach(button => button.disabled = busy);
187 qa("[data-selftest-single]").forEach(button => button.disabled = busy);
188 const stop = q("[data-selftest-stop]");
191 stop.disabled = false;
195 function appendResult(result) {
196 state.results.set(result.test_id, result);
197 const wrap = q("[data-selftest-results-wrap]");
198 const host = q("[data-selftest-results]");
199 if (!wrap || !host) return;
201 const details = element("details", "dbx-selftest-result");
202 if (result.status === "failed") details.open = true;
203 const summary = document.createElement("summary");
204 summary.append(statusBadge(result.status));
205 summary.append(element("strong", "", result.name));
206 summary.append(element("span", "text-muted ms-auto", formatDuration(result.duration_ms)));
207 details.append(summary);
208 details.append(element("div", "px-3 pb-2 small", result.summary || ""));
209 details.append(element("pre", "", result.output || "Keine Ausgabe."));
210 host.append(details);
214 function renderRunResults(run) {
215 state.results.clear();
216 const host = q("[data-selftest-results]");
217 if (host) host.replaceChildren();
218 (run.results || []).forEach(appendResult);
222 function formatDuration(milliseconds) {
223 const value = Number(milliseconds || 0);
224 return value >= 1000 ? (value / 1000).toFixed(2) + " s" : value + " ms";
227 function renderHistory() {
228 const host = q("[data-selftest-history]");
230 host.replaceChildren();
231 if (!state.history.length) {
232 host.append(element("div", "dbx-selftest-empty", "Noch keine Testprotokolle vorhanden."));
235 state.history.forEach(run => {
236 const totals = run.totals || {};
237 const item = element("div", "dbx-selftest-history-item");
238 const info = document.createElement("div");
239 info.append(element("strong", "", run.profile === "quick" ? "Schnelltest" : "Kompletttest"));
240 info.append(element("div", "small text-muted", new Date(run.started_at).toLocaleString() + " · " + formatDuration(run.duration_ms)));
242 item.append(statusBadge(run.display_status || run.status));
243 item.append(element("span", "small", Number(totals.passed || 0) + " bestanden / " + Number(totals.failed || 0) + " Fehler"));
244 const actions = element("div", "d-flex gap-1");
245 const view = element("button", "btn btn-outline-secondary btn-sm", "Details");
246 view.type = "button";
247 view.dataset.selftestViewRun = run.id;
248 actions.append(view);
249 if (run.status === "running") {
250 const resume = element("button", "btn btn-outline-primary btn-sm", "Fortsetzen");
251 resume.type = "button";
252 resume.dataset.selftestResumeRun = run.id;
253 actions.append(resume);
255 const download = element("a", "btn btn-outline-secondary btn-sm", "JSON");
256 download.href = urls.download + "&run_id=" + encodeURIComponent(run.id);
257 actions.append(download);
258 item.append(actions);
263 function executeBrowserTest(test) {
264 return new Promise(resolve => {
265 const started = performance.now();
266 const startedAt = new Date().toISOString();
268 const frame = document.createElement("iframe");
270 frame.setAttribute("aria-hidden", "true");
271 document.body.append(frame);
272 const win = frame.contentWindow;
274 let deferred = false;
275 const timeoutMs = Math.min(120000, Math.max(5000, Number(test.timeout || 30) * 1000));
277 function serialize(value) {
278 if (typeof value === "string") return value;
279 try { return JSON.stringify(value); } catch (_) { return String(value); }
282 function finish(status, message, timedOut) {
286 if (message) output.push(String(message));
290 output: output.join("\n") || (status === "passed" ? "PASS Browser-JavaScript-Test" : "FAIL Browser-JavaScript-Test"),
291 duration_ms: Math.round(performance.now() - started),
292 started_at: startedAt,
293 timed_out: timedOut ? 1 : 0
297 const timer = setTimeout(() => finish("failed", "Zeitlimit des Browser-Tests überschritten.", true), timeoutMs);
298 const testUrl = new URL(test.relative_path, document.baseURI);
299 const directoryUrl = new URL("./", testUrl);
301 win.__dirname = directoryUrl.href;
302 win.require = function (name) {
303 if (name === "path") {
305 resolve: function () {
306 const parts = Array.from(arguments);
307 const base = parts.shift() || directoryUrl.href;
308 return new URL(parts.join("/"), String(base).replace(/\/?$/, "/")).href;
314 readFileSync: function (url) {
315 const xhr = new XMLHttpRequest();
316 xhr.open("GET", String(url), false);
318 if (xhr.status < 200 || xhr.status >= 300) {
319 throw new Error("Testquelle konnte nicht geladen werden: " + url + " (HTTP " + xhr.status + ")");
321 return xhr.responseText;
325 throw new Error("Browser-Test unterstützt require(\"" + name + "\") nicht.");
328 log: function () { output.push(Array.from(arguments).map(serialize).join(" ")); },
329 info: function () { output.push(Array.from(arguments).map(serialize).join(" ")); },
330 warn: function () { output.push("WARN: " + Array.from(arguments).map(serialize).join(" ")); },
331 error: function () { output.push("ERROR: " + Array.from(arguments).map(serialize).join(" ")); }
334 defer: function () { deferred = true; },
335 pass: function (message) { finish("passed", message || "PASS"); },
336 fail: function (error) { finish("failed", error && (error.stack || error.message) || error || "FAIL"); }
338 win.__dbxSelfTestReport = function (result) {
339 finish(result && result.status === "passed" ? "passed" : "failed", result && result.output, result && result.timed_out);
341 win.onerror = function (message, source, line, column, error) {
342 finish("failed", error && error.stack ? error.stack : String(message) + " (" + line + ":" + column + ")");
346 const script = frame.contentDocument.createElement("script");
347 script.src = testUrl.href;
348 script.onload = function () {
349 setTimeout(() => { if (!deferred) finish("passed"); }, 0);
351 script.onerror = function () {
352 finish("failed", "JavaScript-Test konnte nicht geladen oder geparst werden: " + test.relative_path);
354 frame.contentDocument.head.append(script);
358 async function executeRun(run) {
359 state.activeRun = run;
362 const completed = new Set((run.results || []).map(result => result.test_id));
364 for (const id of run.test_ids || []) {
365 if (completed.has(id)) continue;
366 if (state.stop) break;
367 const test = state.testById.get(id);
368 const testName = test ? test.name : "Test";
369 const testStarted = Date.now();
370 state.runningTestId = id;
372 updateProgress(run, "Läuft: " + testName + " (0 s)");
373 const activityPulse = window.setInterval(() => {
374 const seconds = Math.max(0, Math.floor((Date.now() - testStarted) / 1000));
375 const suffix = seconds >= 15 ? " · umfangreiche Prüfung" : "";
376 const current = q("[data-selftest-current]");
377 if (current) current.textContent = "Läuft: " + testName + " (" + seconds + " s)" + suffix;
381 if (test && test.type === "js" && test.execution === "browser") {
382 const browserResult = await executeBrowserTest(test);
383 data = await request(urls.browserResult, {
386 result: browserResult
389 data = await request(urls.execute, { run_id: run.id, test_id: id });
392 window.clearInterval(activityPulse);
393 state.runningTestId = null;
396 state.activeRun = run;
397 appendResult(data.result);
400 const finished = await request(urls.finish, { run_id: run.id, aborted: state.stop ? 1 : 0 });
401 state.activeRun = finished.run;
402 state.history = finished.history || state.history;
403 updateProgress(finished.run);
406 const host = q("[data-selftest-results]");
407 if (host) host.prepend(element("div", "alert alert-danger", error.message));
408 const current = q("[data-selftest-current]");
409 if (current) current.textContent = "Lauf unterbrochen: " + error.message;
416 async function startRun(profile, ids) {
417 if (state.busy) return;
418 // Bereits vor dem ersten Netzwerkzugriff sperren. Andernfalls
419 // koennen Doppelklicks mehrere identische Laeufe anlegen.
421 state.results.clear();
422 const host = q("[data-selftest-results]");
423 if (host) host.replaceChildren();
424 const wrap = q("[data-selftest-results-wrap]");
425 if (wrap) wrap.hidden = false;
427 const data = await request(urls.start, { profile: profile, test_ids: ids || [] });
428 await executeRun(data.run);
430 if (host) host.append(element("div", "alert alert-danger", error.message));
435 async function loadRun(id, resume) {
437 const data = await request(urls.run + "&run_id=" + encodeURIComponent(id));
438 renderRunResults(data.run);
439 if (resume && data.run.status === "running") await executeRun(data.run);
441 window.alert(error.message);
445 root.addEventListener("click", event => {
446 const runButton = event.target.closest("[data-selftest-run]");
448 const mode = runButton.dataset.selftestRun;
449 if (mode === "selected") {
450 const ids = qa("[data-selftest-select]:checked").map(input => input.dataset.selftestSelect);
451 if (!ids.length) return window.alert("Bitte mindestens einen Test auswählen.");
452 startRun("full", ids);
454 startRun(mode === "quick" ? "quick" : "full", []);
458 const single = event.target.closest("[data-selftest-single]");
460 startRun("full", [single.dataset.selftestSingle]);
463 const stop = event.target.closest("[data-selftest-stop]");
466 stop.disabled = true;
467 q("[data-selftest-current]").textContent = "Lauf wird nach dem aktuellen Test beendet …";
470 const view = event.target.closest("[data-selftest-view-run]");
471 if (view) loadRun(view.dataset.selftestViewRun, false);
472 const resume = event.target.closest("[data-selftest-resume-run]");
473 if (resume) loadRun(resume.dataset.selftestResumeRun, true);
476 q("[data-selftest-search]")?.addEventListener("input", renderCatalog);
477 q("[data-selftest-category]")?.addEventListener("change", renderCatalog);
478 q("[data-selftest-select-all]")?.addEventListener("change", event => {
479 qa("[data-selftest-select]").forEach(input => {
480 input.checked = event.target.checked;
481 if (input.checked) state.selected.add(input.dataset.selftestSelect);
482 else state.selected.delete(input.dataset.selftestSelect);
485 root.addEventListener("change", event => {
486 const input = event.target.closest("[data-selftest-select]");
488 if (input.checked) state.selected.add(input.dataset.selftestSelect);
489 else state.selected.delete(input.dataset.selftestSelect);
492 request(urls.catalog).then(data => {
493 state.tests = data.tests || [];
494 state.testById = new Map(state.tests.map(test => [test.id, test]));
495 state.selected = new Set(state.tests.map(test => test.id));
496 state.history = data.history || [];
501 q("[data-selftest-list]").replaceChildren(element("div", "alert alert-danger m-3", error.message));