dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
selftest.js
Go to the documentation of this file.
1(function () {
2 "use strict";
3
4 const roots = Array.from(document.querySelectorAll("[data-dbx-selftest]"));
5 roots.forEach(init);
6
7 function init(root) {
8 if (root.__dbxSelfTestBound) return;
9 root.__dbxSelfTestBound = true;
10
11 const state = {
12 tests: [],
13 testById: new Map(),
14 results: new Map(),
15 history: [],
16 selected: new Set(),
17 busy: false,
18 stop: false,
19 activeRun: null,
20 runningTestId: null
21 };
22 const q = selector => root.querySelector(selector);
23 const qa = selector => Array.from(root.querySelectorAll(selector));
24 const urls = {
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
32 };
33
34 async function request(url, payload) {
35 const options = payload === undefined ? {} : {
36 method: "POST",
37 headers: { "Content-Type": "application/json" },
38 body: JSON.stringify(payload)
39 };
40 const response = await fetch(url, options);
41 const raw = await response.text();
42 let data;
43 try {
44 data = JSON.parse(raw);
45 } catch (_) {
46 const detail = raw
47 .replace(/<[^>]*>/g, " ")
48 .replace(/&nbsp;/gi, " ")
49 .replace(/\s+/g, " ")
50 .trim()
51 .slice(0, 500);
52 data = {
53 ok: 0,
54 error: "Serverantwort ist kein JSON" + (detail ? ": " + detail : " (HTTP " + response.status + ").")
55 };
56 }
57 if (!response.ok || !data || !data.ok) {
58 throw new Error((data && (data.error || data.msg)) || ("HTTP " + response.status));
59 }
60 return data;
61 }
62
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);
67 return node;
68 }
69
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);
76 });
77 }
78
79 function statusBadge(status) {
80 const labels = {
81 pending: "Offen",
82 running: "Läuft",
83 passed: "Bestanden",
84 failed: "Fehler",
85 skipped: "Übersprungen",
86 aborted: "Abgebrochen",
87 interrupted: "Unterbrochen"
88 };
89 const classes = {
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"
97 };
98 return element("span", "badge dbx-selftest-status " + (classes[status] || classes.pending), labels[status] || status);
99 }
100
101 function renderCatalog() {
102 const host = q("[data-selftest-list]");
103 if (!host) return;
104 host.replaceChildren();
105 const tests = filteredTests();
106 if (!tests.length) {
107 host.append(element("div", "dbx-selftest-empty", "Keine passenden Tests gefunden."));
108 return;
109 }
110
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");
117
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;
123
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);
132
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);
147
148 [selectCell, nameCell, categoryCell, tierCell, statusCell, actionCell].forEach(cell => row.append(cell));
149 tbody.append(row);
150 });
151 table.append(thead, tbody);
152 host.append(table);
153 }
154
155 function renderCategories() {
156 const select = q("[data-selftest-category]");
157 if (!select) return;
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));
162 });
163 select.value = selected;
164 }
165
166 function updateProgress(run, currentName) {
167 const box = q("[data-selftest-progress]");
168 if (!box || !run) return;
169 box.hidden = false;
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";
178 }
179
180 function runStatusLabel(status) {
181 return ({ passed: "Alle Tests bestanden.", failed: "Testlauf mit Fehlern abgeschlossen.", aborted: "Testlauf abgebrochen.", running: "Testlauf läuft." })[status] || status;
182 }
183
184 function setBusy(busy) {
185 state.busy = 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]");
189 if (stop) {
190 stop.hidden = !busy;
191 stop.disabled = false;
192 }
193 }
194
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;
200 wrap.hidden = false;
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);
211 renderCatalog();
212 }
213
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);
219 updateProgress(run);
220 }
221
222 function formatDuration(milliseconds) {
223 const value = Number(milliseconds || 0);
224 return value >= 1000 ? (value / 1000).toFixed(2) + " s" : value + " ms";
225 }
226
227 function renderHistory() {
228 const host = q("[data-selftest-history]");
229 if (!host) return;
230 host.replaceChildren();
231 if (!state.history.length) {
232 host.append(element("div", "dbx-selftest-empty", "Noch keine Testprotokolle vorhanden."));
233 return;
234 }
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)));
241 item.append(info);
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);
254 }
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);
259 host.append(item);
260 });
261 }
262
263 function executeBrowserTest(test) {
264 return new Promise(resolve => {
265 const started = performance.now();
266 const startedAt = new Date().toISOString();
267 const output = [];
268 const frame = document.createElement("iframe");
269 frame.hidden = true;
270 frame.setAttribute("aria-hidden", "true");
271 document.body.append(frame);
272 const win = frame.contentWindow;
273 let done = false;
274 let deferred = false;
275 const timeoutMs = Math.min(120000, Math.max(5000, Number(test.timeout || 30) * 1000));
276
277 function serialize(value) {
278 if (typeof value === "string") return value;
279 try { return JSON.stringify(value); } catch (_) { return String(value); }
280 }
281
282 function finish(status, message, timedOut) {
283 if (done) return;
284 done = true;
285 clearTimeout(timer);
286 if (message) output.push(String(message));
287 frame.remove();
288 resolve({
289 status: status,
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
294 });
295 }
296
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);
300
301 win.__dirname = directoryUrl.href;
302 win.require = function (name) {
303 if (name === "path") {
304 return {
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;
309 }
310 };
311 }
312 if (name === "fs") {
313 return {
314 readFileSync: function (url) {
315 const xhr = new XMLHttpRequest();
316 xhr.open("GET", String(url), false);
317 xhr.send(null);
318 if (xhr.status < 200 || xhr.status >= 300) {
319 throw new Error("Testquelle konnte nicht geladen werden: " + url + " (HTTP " + xhr.status + ")");
320 }
321 return xhr.responseText;
322 }
323 };
324 }
325 throw new Error("Browser-Test unterstützt require(\"" + name + "\") nicht.");
326 };
327 win.console = {
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(" ")); }
332 };
333 win.dbxSelfTest = {
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"); }
337 };
338 win.__dbxSelfTestReport = function (result) {
339 finish(result && result.status === "passed" ? "passed" : "failed", result && result.output, result && result.timed_out);
340 };
341 win.onerror = function (message, source, line, column, error) {
342 finish("failed", error && error.stack ? error.stack : String(message) + " (" + line + ":" + column + ")");
343 return true;
344 };
345
346 const script = frame.contentDocument.createElement("script");
347 script.src = testUrl.href;
348 script.onload = function () {
349 setTimeout(() => { if (!deferred) finish("passed"); }, 0);
350 };
351 script.onerror = function () {
352 finish("failed", "JavaScript-Test konnte nicht geladen oder geparst werden: " + test.relative_path);
353 };
354 frame.contentDocument.head.append(script);
355 });
356 }
357
358 async function executeRun(run) {
359 state.activeRun = run;
360 state.stop = false;
361 setBusy(true);
362 const completed = new Set((run.results || []).map(result => result.test_id));
363 try {
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;
371 renderCatalog();
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;
378 }, 1000);
379 let data;
380 try {
381 if (test && test.type === "js" && test.execution === "browser") {
382 const browserResult = await executeBrowserTest(test);
383 data = await request(urls.browserResult, {
384 run_id: run.id,
385 test_id: id,
386 result: browserResult
387 });
388 } else {
389 data = await request(urls.execute, { run_id: run.id, test_id: id });
390 }
391 } finally {
392 window.clearInterval(activityPulse);
393 state.runningTestId = null;
394 }
395 run = data.run;
396 state.activeRun = run;
397 appendResult(data.result);
398 updateProgress(run);
399 }
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);
404 renderHistory();
405 } catch (error) {
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;
410 renderCatalog();
411 } finally {
412 setBusy(false);
413 }
414 }
415
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.
420 setBusy(true);
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;
426 try {
427 const data = await request(urls.start, { profile: profile, test_ids: ids || [] });
428 await executeRun(data.run);
429 } catch (error) {
430 if (host) host.append(element("div", "alert alert-danger", error.message));
431 setBusy(false);
432 }
433 }
434
435 async function loadRun(id, resume) {
436 try {
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);
440 } catch (error) {
441 window.alert(error.message);
442 }
443 }
444
445 root.addEventListener("click", event => {
446 const runButton = event.target.closest("[data-selftest-run]");
447 if (runButton) {
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);
453 } else {
454 startRun(mode === "quick" ? "quick" : "full", []);
455 }
456 return;
457 }
458 const single = event.target.closest("[data-selftest-single]");
459 if (single) {
460 startRun("full", [single.dataset.selftestSingle]);
461 return;
462 }
463 const stop = event.target.closest("[data-selftest-stop]");
464 if (stop) {
465 state.stop = true;
466 stop.disabled = true;
467 q("[data-selftest-current]").textContent = "Lauf wird nach dem aktuellen Test beendet …";
468 return;
469 }
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);
474 });
475
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);
483 });
484 });
485 root.addEventListener("change", event => {
486 const input = event.target.closest("[data-selftest-select]");
487 if (!input) return;
488 if (input.checked) state.selected.add(input.dataset.selftestSelect);
489 else state.selected.delete(input.dataset.selftestSelect);
490 });
491
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 || [];
497 renderCategories();
498 renderCatalog();
499 renderHistory();
500 }).catch(error => {
501 q("[data-selftest-list]").replaceChildren(element("div", "alert alert-danger m-3", error.message));
502 });
503 }
504})();