qapytest 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qapytest/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """QAPyTest is a powerful package for QA specialists built on top of Pytest."""
2
+
3
+ from qapytest._attach import attach
4
+ from qapytest._graphql import GraphQLClient
5
+ from qapytest._http import HttpClient
6
+ from qapytest._json_validation import validate_json
7
+ from qapytest._redis import RedisClient
8
+ from qapytest._soft_assert import soft_assert
9
+ from qapytest._sql import SqlClient
10
+ from qapytest._step import step
11
+
12
+ __all__ = [
13
+ "GraphQLClient",
14
+ "HttpClient",
15
+ "RedisClient",
16
+ "SqlClient",
17
+ "attach",
18
+ "soft_assert",
19
+ "step",
20
+ "validate_json",
21
+ ]
@@ -0,0 +1,51 @@
1
+ {% macro render_log_tree(log_list, test_index, attachment_counter={'value': 0}, assert_counter={'value': 0}) %}
2
+ {% if log_list %}
3
+ <ul>
4
+ {% for entry in log_list %}
5
+ {% if entry.type == 'step' %}
6
+ <li class="{{ 'step-passed' if entry.passed else 'step-failed' }}">
7
+ {{ '✔︎' if entry.passed else '✖︎' }} <strong>Step:</strong> {{ entry.message|e }}
8
+ {# --- Recursive Call --- #}
9
+ {{ render_log_tree(entry.children, test_index, attachment_counter, assert_counter) }}
10
+ </li>
11
+ {% elif entry.type == 'assert' %}
12
+ {% set _ = assert_counter.update({'value': assert_counter.value + 1}) %}
13
+ <li class="{{ 'assert-passed' if entry.passed else 'assert-failed' }}">
14
+ {% set assert_id = 'assert-details-' ~ test_index ~ '-' ~ assert_counter.value %}
15
+ {% set has_details = entry.get('details') %}
16
+
17
+ <span class="assert-label{% if has_details %} clickable{% endif %}"
18
+ {% if has_details %}data-toggle-target="#{{ assert_id }}" role="button" tabindex="0"{% endif %}>
19
+ {{ '✔︎' if entry.passed else '✖︎' }} {{ entry.label|e }}
20
+ {% if has_details %}<span class="details-icon">ℹ️</span>{% endif %}
21
+ </span>
22
+
23
+ {% if has_details %}
24
+ <div class="assert-details" id="{{ assert_id }}" style="display: none;">
25
+ {% if entry.details is sequence and entry.details is not string %}
26
+ <div class="details-content">
27
+ {% for detail_line in entry.details %}
28
+ <div>{{ detail_line|e }}</div>
29
+ {% endfor %}
30
+ </div>
31
+ {% else %}
32
+ <div class="details-content">
33
+ {% for detail_line in entry.details.split('\n') %}
34
+ <div>{{ detail_line|e }}</div>
35
+ {% endfor %}
36
+ </div>
37
+ {% endif %}
38
+ </div>
39
+ {% endif %}
40
+ </li>
41
+ {% elif entry.type == 'attachment' %}
42
+ {% set _ = attachment_counter.update({'value': attachment_counter.value + 1}) %}
43
+ {% set modal_id = 'attachment-modal-' ~ test_index ~ '-' ~ attachment_counter.value %}
44
+ <li>
45
+ <button class="details-btn" type="button" data-modal-target="#{{ modal_id }}">📎 {{ entry.label|e }}</button>
46
+ </li>
47
+ {% endif %}
48
+ {% endfor %}
49
+ </ul>
50
+ {% endif %}
51
+ {% endmacro %}
@@ -0,0 +1,141 @@
1
+ <!doctype html>
2
+ <html lang="en" data-initial-theme="{{ theme|e }}">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>{{ title|e }}</title>
7
+ <style>{{ css_content }}</style>
8
+ </head>
9
+ <body>
10
+ <header class="header">
11
+ <div class="container">
12
+ <div class="title">{{ title|e }}</div>
13
+ <button id="theme-toggle" class="theme-toggle" title="Toggle theme">
14
+ <span class="icon-dark">🌙</span><span class="icon-light">☀️</span>
15
+ </button>
16
+ <section class="cards">
17
+ <div class="card"><div class="label">Start</div><div class="value">{{ fmt_datetime(session_start) }}</div></div>
18
+ <div class="card"><div class="label">Finish</div><div class="value">{{ fmt_datetime(session_finish) }}</div></div>
19
+ <div class="card"><div class="label">Time (s)</div><div class="value">{{ fmt_seconds(stats.duration_total) }}</div></div>
20
+ <div class="card total"><div class="label">Pass Rate</div><div class="value">{{ stats.pass_rate }}</div></div>
21
+ </section>
22
+ </div>
23
+ </header>
24
+ <main class="container">
25
+ <section class="cards">
26
+ <div class="card total" data-filter="all"><div class="label">Total</div><div class="value">{{ stats.total }}</div></div>
27
+ <div class="card passed" data-filter="passed"><div class="label">Passed</div><div class="value">{{ stats.get("passed", 0) }}</div></div>
28
+ <div class="card failed" data-filter="failed"><div class="label">Failed</div><div class="value">{{ stats.get("failed", 0) }}</div></div>
29
+ <div class="card skipped" data-filter="skipped"><div class="label">Skipped</div><div class="value">{{ stats.get("skipped", 0) }}</div></div>
30
+ <div class="card xfailed" data-filter="xfailed"><div class="label">XFAIL</div><div class="value">{{ stats.get("xfailed", 0) }}</div></div>
31
+ <div class="card xpassed" data-filter="xpassed"><div class="label">XPASS</div><div class="value">{{ stats.get("xpassed", 0) }}</div></div>
32
+ <div class="card error" data-filter="error"><div class="label">Errors</div><div class="value">{{ stats.get("error", 0) }}</div></div>
33
+ </section>
34
+ <div class="search"><input id="search" type="search" placeholder="Search by name, file, component..."/></div>
35
+ <div class="table-wrap">
36
+ <table class="table">
37
+ <thead>
38
+ <tr>
39
+ <th>#</th>
40
+ <th>Test</th>
41
+ <th>Status</th>
42
+ <th>Component</th>
43
+ <th>Duration (s)</th>
44
+ </tr>
45
+ </thead>
46
+ <tbody>
47
+ {% for result in results %}
48
+ {% set details_id = 'details-' ~ loop.index %}
49
+ {% set test_index = loop.index %}
50
+ {% set params_str = parse_params_from_nodeid(result.nodeid) %}
51
+ {% set search_text = [result.nodeid, result.path, result.title, result.components|join(' ')]|join(' ')|e %}
52
+ <tr class="test-row" data-status="{{ result.outcome }}" data-text="{{ search_text }}" role="button" tabindex="0" aria-expanded="false" aria-controls="{{ details_id }}">
53
+ <td class="small">{{ loop.index }}</td>
54
+ <td>
55
+ <div class="mono">{{ result.title|e }}</div>
56
+ {% if params_str %}<div class="small">Parametrize: {{ params_str|e }}</div>{% endif %}
57
+ </td>
58
+ <td><span class="status {{ result.outcome }}">{{ result.outcome }}</span></td>
59
+ <td class="small">{{ (result.components or [])|join(', ')|e }}</td>
60
+ <td>{{ fmt_seconds(result.duration) }}</td>
61
+ </tr>
62
+ <tr class="details-row" id="{{ details_id }}" hidden>
63
+ <td colspan="5">
64
+ <div class="details">
65
+ <div class="details-actions">
66
+ {% if result.details.captured_logs and result.details.captured_logs.strip() %}
67
+ <button class="details-btn" type="button" data-modal-target="#logs-modal-{{ details_id }}"><span>📜</span> Logs</button>
68
+ {% endif %}
69
+ <button class="details-btn copy-btn" type="button" data-copy="#grid-{{ details_id }}" title="Copy details"><span>📋</span> Copy</button>
70
+ </div>
71
+ <div class="grid" id="grid-{{ details_id }}">
72
+ <div class="k">NodeID</div><div class="v mono">{{ result.nodeid|e }}</div>
73
+ <div class="k">File</div><div class="v mono">{{ result.path|e }}</div>
74
+ {% if result.details.headline %}
75
+ <div class="k">Result</div><div class="v"><span class="status {{ result.outcome }}">{{ result.details.headline|e }}</span></div>
76
+ {% endif %}
77
+ {% if result.execution_log %}
78
+ <div class="k">Execution Log</div><div class="v mono">
79
+ {% import '_log_tree.html.jinja2' as log_helpers %}
80
+ {{ log_helpers.render_log_tree(result.execution_log, test_index, {'value': 0}, {'value': 0}) }}
81
+ </div>
82
+ {% endif %}
83
+ {% if result.details.longrepr and not result.get('soft_assert_only', False) %}
84
+ <div class="k">Details</div><div class="v"><pre class="mono">{{ result.details.longrepr|e }}</pre></div>
85
+ {% endif %}
86
+ {% if result.details.captured_stdout %}
87
+ <div class="k">Output</div><div class="v"><pre class="mono">{{ result.details.captured_stdout|e }}</pre></div>
88
+ {% endif %}
89
+ </div>
90
+ </div>
91
+ </td>
92
+ </tr>
93
+ {% endfor %}
94
+ </tbody>
95
+ </table>
96
+ </div>
97
+ </main>
98
+
99
+ {# --- Modals --- #}
100
+ {% for result in results %}
101
+ {% set details_id = 'details-' ~ loop.index %}
102
+ {% set test_index = loop.index %}
103
+ {# Render modals for logs #}
104
+ {% if result.details.captured_logs and result.details.captured_logs.strip() %}
105
+ <div class="modal" id="logs-modal-{{ details_id }}" hidden>
106
+ <div class="modal-overlay" data-modal-close></div>
107
+ <div class="modal-content">
108
+ <div class="modal-header">
109
+ <h3 class="mono">{{ result.title|e }}</h3>
110
+ <button class="modal-close" data-modal-close>&times;</button>
111
+ </div>
112
+ <div class="modal-body"><pre><code>{{ result.details.captured_logs|e }}</code></pre></div>
113
+ </div>
114
+ </div>
115
+ {% endif %}
116
+
117
+ {# Render modals for attachments #}
118
+ {% for attachment in result.attachments %}
119
+ {% set modal_id = 'attachment-modal-' ~ test_index ~ '-' ~ loop.index %}
120
+ <div class="modal" id="{{ modal_id }}" hidden>
121
+ <div class="modal-overlay" data-modal-close></div>
122
+ <div class="modal-content">
123
+ <div class="modal-header">
124
+ <h3>{{ attachment.label|e }}</h3>
125
+ <button class="modal-close" data-modal-close>&times;</button>
126
+ </div>
127
+ <div class="modal-body">
128
+ {% if attachment.content_type == "image" %}
129
+ <img src="{{ attachment.data }}" alt="{{ attachment.label|e }}">
130
+ {% else %}
131
+ <pre><code>{{ attachment.data|e }}</code></pre>
132
+ {% endif %}
133
+ </div>
134
+ </div>
135
+ </div>
136
+ {% endfor %}
137
+ {% endfor %}
138
+
139
+ <script>{{ js_content }}</script>
140
+ </body>
141
+ </html>
@@ -0,0 +1,442 @@
1
+ (function () {
2
+ const initialTheme = document.documentElement.getAttribute('data-initial-theme') || 'auto';
3
+
4
+ function applyTheme(theme) {
5
+ if (theme === 'auto') {
6
+ const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
7
+ document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
8
+ } else {
9
+ document.documentElement.setAttribute('data-theme', theme);
10
+ }
11
+ }
12
+
13
+ function setupTheme() {
14
+ const savedTheme = sessionStorage.getItem('report-theme');
15
+ if (savedTheme) {
16
+ applyTheme(savedTheme);
17
+ } else {
18
+ applyTheme(initialTheme);
19
+ }
20
+
21
+ const themeToggle = document.getElementById('theme-toggle');
22
+ if (themeToggle) {
23
+ themeToggle.addEventListener('click', () => {
24
+ const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
25
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
26
+ sessionStorage.setItem('report-theme', newTheme);
27
+ applyTheme(newTheme);
28
+ });
29
+ }
30
+
31
+ try {
32
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
33
+ const saved = sessionStorage.getItem('report-theme');
34
+ if (!saved || saved === 'auto') {
35
+ applyTheme('auto');
36
+ }
37
+ });
38
+ } catch (e) { console.error("matchMedia listener not supported", e); }
39
+ }
40
+
41
+ function init() {
42
+ const q = (sel, el = document) => el.querySelector(sel);
43
+ const qa = (sel, el = document) => Array.from(el.querySelectorAll(sel));
44
+ const filters = new Set();
45
+ let currentlyOpenId = null;
46
+
47
+ function toggleRow(tr, multi) {
48
+ const id = tr.getAttribute('aria-controls');
49
+ if (!id) return;
50
+ const panel = document.getElementById(id);
51
+ const expanded = tr.getAttribute('aria-expanded') === 'true';
52
+ if (!multi && currentlyOpenId && currentlyOpenId !== id) {
53
+ const prevTr = document.querySelector(`tr.test-row[aria-controls="${currentlyOpenId}"]`);
54
+ if (prevTr) {
55
+ prevTr.setAttribute('aria-expanded', 'false');
56
+ const prevPanel = document.getElementById(currentlyOpenId);
57
+ if (prevPanel) prevPanel.hidden = true;
58
+ }
59
+ }
60
+ tr.setAttribute('aria-expanded', !expanded);
61
+ if (panel) panel.hidden = expanded;
62
+ currentlyOpenId = expanded ? null : id;
63
+ }
64
+ qa('tbody tr.test-row').forEach(tr => {
65
+ tr.addEventListener('click', (e) => toggleRow(tr, e.altKey === true));
66
+ tr.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleRow(tr, e.altKey === true); } });
67
+ });
68
+
69
+ const searchInput = q('#search');
70
+ let searchTerm = '';
71
+ if (searchInput) {
72
+ searchInput.addEventListener('input', (e) => {
73
+ searchTerm = e.target.value.toLowerCase();
74
+ applyFilters();
75
+ });
76
+ }
77
+
78
+ const filterCards = qa('.card[data-filter]');
79
+ filterCards.forEach(card => {
80
+ card.addEventListener('click', () => {
81
+ const filter = card.dataset.filter;
82
+ if (filter === 'all') {
83
+ filters.clear();
84
+ } else {
85
+ if (filters.has(filter)) {
86
+ filters.delete(filter);
87
+ } else {
88
+ filters.add(filter);
89
+ }
90
+ }
91
+ applyFilters();
92
+ updateCardStyles();
93
+ });
94
+ });
95
+
96
+ function applyFilters() {
97
+ qa('tbody tr.test-row').forEach(row => {
98
+ const status = row.dataset.status;
99
+ const text = (row.dataset.text || '').toLowerCase();
100
+ const statusMatch = filters.size === 0 || filters.has(status);
101
+ const searchMatch = !searchTerm || text.includes(searchTerm);
102
+ const shouldShow = statusMatch && searchMatch;
103
+ row.style.display = shouldShow ? '' : 'none';
104
+ const detailsRow = document.getElementById(row.getAttribute('aria-controls'));
105
+ if (detailsRow) {
106
+ detailsRow.style.display = shouldShow ? '' : 'none';
107
+ }
108
+ });
109
+ }
110
+
111
+ function updateCardStyles() {
112
+ let activeCardFound = false;
113
+ filterCards.forEach(card => {
114
+ const filter = card.dataset.filter;
115
+ if (filters.has(filter)) {
116
+ card.classList.add('active');
117
+ activeCardFound = true;
118
+ } else {
119
+ card.classList.remove('active');
120
+ }
121
+ });
122
+ const allCard = q('.card[data-filter="all"]');
123
+ if (allCard) {
124
+ if (!activeCardFound) {
125
+ allCard.classList.add('active');
126
+ } else {
127
+ allCard.classList.remove('active');
128
+ }
129
+ }
130
+ }
131
+
132
+ function bindCopyButtons() {
133
+ qa('.copy-btn').forEach(btn => {
134
+ const sel = btn.getAttribute('data-copy');
135
+ btn.addEventListener('click', async (e) => {
136
+ e.stopPropagation();
137
+ const el = sel ? document.querySelector(sel) : null;
138
+ if (!el) return;
139
+
140
+ const text = formatTestDetails(el);
141
+ try {
142
+ await navigator.clipboard.writeText(text);
143
+ const prevHtml = btn.innerHTML;
144
+ btn.innerHTML = '<span>✓</span>';
145
+ btn.classList.add('copied');
146
+ setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = prevHtml; }, 900);
147
+ } catch (_e) { }
148
+ });
149
+ });
150
+ }
151
+
152
+ function formatTestDetails(gridElement) {
153
+ const lines = [];
154
+ const gridItems = qa('.k, .v', gridElement);
155
+
156
+ for (let i = 0; i < gridItems.length; i += 2) {
157
+ const key = gridItems[i];
158
+ const value = gridItems[i + 1];
159
+
160
+ if (!key || !value) continue;
161
+
162
+ const keyText = key.textContent.trim();
163
+ const valueElement = value;
164
+
165
+ if (keyText === 'Execution Log') {
166
+ lines.push(`${keyText}:`);
167
+ const logText = formatExecutionLog(valueElement);
168
+ if (logText) {
169
+ lines.push(logText);
170
+ }
171
+ } else {
172
+ const valueText = valueElement.textContent.trim();
173
+ lines.push(`${keyText}: ${valueText}`);
174
+ }
175
+ lines.push('');
176
+ }
177
+
178
+ return lines.join('\n').trim();
179
+ }
180
+
181
+ function formatExecutionLog(logContainer) {
182
+ const lines = [];
183
+
184
+ function processLogItems(container, indent = '') {
185
+ const items = qa('li', container);
186
+ items.forEach(item => {
187
+ const parentUl = item.parentElement;
188
+ if (parentUl && parentUl !== container && parentUl.closest('li') && parentUl.closest('li').parentElement === container) {
189
+ return;
190
+ }
191
+
192
+ let text = '';
193
+
194
+ if (item.classList.contains('step-passed') || item.classList.contains('step-failed')) {
195
+ const stepIcon = item.classList.contains('step-passed') ? '✔︎' : '✖︎';
196
+
197
+ const strongElement = item.querySelector('strong');
198
+ if (strongElement && strongElement.nextSibling) {
199
+ const stepText = strongElement.nextSibling.textContent?.trim() || '';
200
+ text = `${stepIcon} ${stepText}`;
201
+ } else {
202
+ const fullText = item.textContent || '';
203
+ const cleaned = fullText.replace(/^[✔︎✖︎]\s*Step:\s*/, '').replace(/\s*[✔︎✖︎].*$/, '').trim();
204
+ const lines = cleaned.split('\n').filter(line => line.trim());
205
+ text = `${stepIcon} ${lines[0] || ''}`;
206
+ }
207
+ }
208
+ else if (item.classList.contains('assert-passed') || item.classList.contains('assert-failed')) {
209
+ const assertIcon = item.classList.contains('assert-passed') ? '✔︎' : '✖︎';
210
+ const assertLabel = item.querySelector('.assert-label');
211
+
212
+ if (assertLabel) {
213
+ let labelText = '';
214
+ for (const node of assertLabel.childNodes) {
215
+ if (node.nodeType === Node.TEXT_NODE) {
216
+ labelText += node.textContent;
217
+ }
218
+ }
219
+ labelText = labelText.replace(/^[✔︎✖︎\s]*/, '').trim();
220
+ text = `${assertIcon} ${labelText}`;
221
+
222
+ const detailsDiv = item.querySelector('.assert-details');
223
+ if (detailsDiv) {
224
+ const detailsContent = detailsDiv.querySelector('.details-content');
225
+ if (detailsContent) {
226
+ const detailLines = Array.from(detailsContent.children)
227
+ .map(child => child.textContent.trim())
228
+ .filter(line => line)
229
+ .join(' ');
230
+ if (detailLines) {
231
+ const maxLength = 150;
232
+ const truncatedDetails = detailLines.length > maxLength
233
+ ? detailLines.substring(0, maxLength) + '...'
234
+ : detailLines;
235
+ text += ` (${truncatedDetails})`;
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ if (text) {
243
+ const maxIndentLevel = 3;
244
+ const currentIndentLevel = indent.length / 2;
245
+ const actualIndent = currentIndentLevel <= maxIndentLevel ? indent : ' ';
246
+ lines.push(actualIndent + text);
247
+ }
248
+
249
+ const nestedUl = qa('ul', item).find(ul => ul.parentElement === item);
250
+ if (nestedUl) {
251
+ const nextIndent = indent.length < 6 ? indent + ' ' : indent;
252
+ processLogItems(nestedUl, nextIndent);
253
+ }
254
+ });
255
+ }
256
+
257
+ const topLevelUl = qa('ul', logContainer)[0];
258
+ if (topLevelUl) {
259
+ processLogItems(topLevelUl);
260
+ }
261
+
262
+ return lines.join('\n');
263
+ }
264
+
265
+ function bindModalButtons() {
266
+ qa('[data-modal-target]').forEach(button => {
267
+ button.addEventListener('click', (e) => {
268
+ e.stopPropagation();
269
+ const modal = document.querySelector(button.dataset.modalTarget);
270
+ if (modal) {
271
+ modal.hidden = false;
272
+ document.body.style.overflow = 'hidden';
273
+
274
+ const modalBody = modal.querySelector('.modal-body');
275
+ const preElement = modalBody.querySelector('pre code');
276
+ if (preElement && modal.id.includes('logs-modal')) {
277
+ formatLogsAsTable(preElement);
278
+ }
279
+ }
280
+ });
281
+ });
282
+
283
+ qa('[data-modal-close]').forEach(element => {
284
+ element.addEventListener('click', (e) => {
285
+ e.stopPropagation();
286
+ const modal = element.closest('.modal');
287
+ if (modal) {
288
+ modal.hidden = true;
289
+ document.body.style.overflow = '';
290
+ }
291
+ });
292
+ });
293
+ }
294
+
295
+ function formatLogsAsTable(codeElement) {
296
+ const logText = codeElement.textContent;
297
+ const lines = logText.split('\n');
298
+
299
+ if (lines.length === 0) return;
300
+
301
+ const table = document.createElement('table');
302
+ table.className = 'logs-table';
303
+
304
+ let i = 0;
305
+ while (i < lines.length) {
306
+ const line = lines[i].trim();
307
+ if (!line) {
308
+ i++;
309
+ continue;
310
+ }
311
+
312
+ const row = document.createElement('tr');
313
+
314
+ const logMatch = line.match(/^(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL|FATAL)\s+(.+?):(.+?):(\d+)\s+(.*)$/i);
315
+
316
+ if (logMatch) {
317
+ const [, level, logger, file, lineNum, message] = logMatch;
318
+
319
+ let fullMessage = message;
320
+ let j = i + 1;
321
+ while (j < lines.length) {
322
+ const nextLine = lines[j].trim();
323
+ if (!nextLine) {
324
+ j++;
325
+ continue;
326
+ }
327
+
328
+ const nextLogMatch = nextLine.match(/^(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL|FATAL)\s+/i);
329
+ if (nextLogMatch) {
330
+ break;
331
+ }
332
+
333
+ fullMessage += '\n' + lines[j];
334
+ j++;
335
+ }
336
+
337
+ const levelCell = document.createElement('td');
338
+ levelCell.className = `log-level ${level.toUpperCase()}`;
339
+ levelCell.textContent = level.toUpperCase();
340
+ row.appendChild(levelCell);
341
+
342
+ const loggerCell = document.createElement('td');
343
+ loggerCell.className = 'log-logger';
344
+ loggerCell.textContent = logger;
345
+ loggerCell.title = `${logger}:${file}:${lineNum}`;
346
+ row.appendChild(loggerCell);
347
+
348
+ const messageCell = document.createElement('td');
349
+ messageCell.className = 'log-message';
350
+
351
+ if (fullMessage.includes('\n')) {
352
+ messageCell.style.whiteSpace = 'pre-wrap';
353
+ messageCell.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
354
+ }
355
+
356
+ messageCell.textContent = fullMessage;
357
+ row.appendChild(messageCell);
358
+
359
+ i = j;
360
+ } else {
361
+ const simpleMatch = line.match(/^(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL|FATAL)\s+(.*)$/i);
362
+
363
+ if (simpleMatch) {
364
+ const [, level, rest] = simpleMatch;
365
+
366
+ const levelCell = document.createElement('td');
367
+ levelCell.className = `log-level ${level.toUpperCase()}`;
368
+ levelCell.textContent = level.toUpperCase();
369
+ row.appendChild(levelCell);
370
+
371
+ const loggerCell = document.createElement('td');
372
+ loggerCell.className = 'log-logger';
373
+ loggerCell.textContent = '';
374
+ row.appendChild(loggerCell);
375
+
376
+ const messageCell = document.createElement('td');
377
+ messageCell.className = 'log-message';
378
+ messageCell.textContent = rest;
379
+ row.appendChild(messageCell);
380
+
381
+ i++;
382
+ } else {
383
+ i++;
384
+ continue;
385
+ }
386
+ }
387
+
388
+ table.appendChild(row);
389
+ }
390
+
391
+ const preElement = codeElement.parentElement;
392
+ preElement.parentElement.replaceChild(table, preElement);
393
+ }
394
+
395
+ window.addEventListener('keydown', (e) => {
396
+ if (e.key === 'Escape') {
397
+ const openModal = q('.modal:not([hidden])');
398
+ if (openModal) {
399
+ openModal.hidden = true;
400
+ document.body.style.overflow = '';
401
+ }
402
+ }
403
+ });
404
+
405
+ function bindAssertToggles() {
406
+ qa('[data-toggle-target]').forEach(element => {
407
+ element.addEventListener('click', (e) => {
408
+ e.stopPropagation();
409
+ const targetSelector = element.dataset.toggleTarget;
410
+ const target = document.querySelector(targetSelector);
411
+ if (target) {
412
+ const isHidden = target.style.display === 'none' || !target.style.display;
413
+ target.style.display = isHidden ? 'block' : 'none';
414
+ }
415
+ });
416
+
417
+ element.addEventListener('keydown', (e) => {
418
+ if (e.key === 'Enter' || e.key === ' ') {
419
+ e.preventDefault();
420
+ e.stopPropagation();
421
+ element.click();
422
+ }
423
+ });
424
+ });
425
+ }
426
+
427
+ updateCardStyles();
428
+ bindCopyButtons();
429
+ bindModalButtons();
430
+ bindAssertToggles();
431
+ }
432
+
433
+ if (document.readyState === 'loading') {
434
+ document.addEventListener('DOMContentLoaded', () => {
435
+ setupTheme();
436
+ init();
437
+ }, { once: true });
438
+ } else {
439
+ setupTheme();
440
+ init();
441
+ }
442
+ })();