airflow-pytest-plugin 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/PKG-INFO +7 -1
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/README.md +6 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/pyproject.toml +1 -1
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/web/app.py +51 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/web/templates.py +282 -39
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_web_app.py +87 -1
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/.gitignore +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/LICENSE +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/examples/example_dag.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/__init__.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/config.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/layout.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/models.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/plugin.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/producer/archiving_parser.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/sources/base.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/sources/filesystem.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/version.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/web/__init__.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/web/__main__.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/conftest.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_archiving_parser.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_authz_integration.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_compat.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_config.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_filesystem_source.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_layout.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_models.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_package.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_plugin.py +0 -0
- {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: airflow-pytest-plugin
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: View airflow-pytest-operator results in the Airflow 3 web UI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/IKrysanov/airflow-pytest-plugin
|
|
6
6
|
Project-URL: Repository, https://github.com/IKrysanov/airflow-pytest-plugin
|
|
@@ -107,6 +107,11 @@ expand:
|
|
|
107
107
|
|
|
108
108
|

|
|
109
109
|
|
|
110
|
+
**Failed tests** — clicking the *Failures* KPI lists every failed/errored case
|
|
111
|
+
across the visible runs (paginated); a row jumps to that run:
|
|
112
|
+
|
|
113
|
+

|
|
114
|
+
|
|
110
115
|
---
|
|
111
116
|
|
|
112
117
|
## Install
|
|
@@ -208,6 +213,7 @@ runtime. Endpoints (relative to the mount):
|
|
|
208
213
|
| `GET /` | the single-page viewer (HTML) |
|
|
209
214
|
| `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
|
|
210
215
|
| `GET /api/reports/{report_id}` | one report with per-case rows |
|
|
216
|
+
| `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
|
|
211
217
|
| `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
|
|
212
218
|
| `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
|
|
213
219
|
| `GET /api/health` | `{"status": "ok"}` |
|
|
@@ -63,6 +63,11 @@ expand:
|
|
|
63
63
|
|
|
64
64
|

|
|
65
65
|
|
|
66
|
+
**Failed tests** — clicking the *Failures* KPI lists every failed/errored case
|
|
67
|
+
across the visible runs (paginated); a row jumps to that run:
|
|
68
|
+
|
|
69
|
+

|
|
70
|
+
|
|
66
71
|
---
|
|
67
72
|
|
|
68
73
|
## Install
|
|
@@ -164,6 +169,7 @@ runtime. Endpoints (relative to the mount):
|
|
|
164
169
|
| `GET /` | the single-page viewer (HTML) |
|
|
165
170
|
| `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
|
|
166
171
|
| `GET /api/reports/{report_id}` | one report with per-case rows |
|
|
172
|
+
| `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases across the visible runs |
|
|
167
173
|
| `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
|
|
168
174
|
| `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
|
|
169
175
|
| `GET /api/health` | `{"status": "ok"}` |
|
{airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/web/app.py
RENAMED
|
@@ -35,6 +35,9 @@ if TYPE_CHECKING:
|
|
|
35
35
|
#: An authorizer: ``(dag_id, user) -> bool``. ``user`` is ``None`` standalone.
|
|
36
36
|
Authorizer = Callable[[str, Any], bool]
|
|
37
37
|
|
|
38
|
+
#: Upper bound on failed cases returned by /api/failures, to bound the payload.
|
|
39
|
+
_FAILURES_CAP = 5000
|
|
40
|
+
|
|
38
41
|
|
|
39
42
|
def _no_user() -> None:
|
|
40
43
|
"""Standalone-mode user dependency: there is no Airflow user."""
|
|
@@ -169,6 +172,54 @@ def create_app(
|
|
|
169
172
|
},
|
|
170
173
|
)
|
|
171
174
|
|
|
175
|
+
@app.get("/api/failures")
|
|
176
|
+
def failures(
|
|
177
|
+
dag_id: str | None = None,
|
|
178
|
+
run_id: str | None = None,
|
|
179
|
+
task_id: str | None = None,
|
|
180
|
+
user: Any = Depends(user_dep), # noqa: B008 - FastAPI dependency idiom
|
|
181
|
+
) -> JSONResponse:
|
|
182
|
+
"""Every failed/errored case across the visible runs (newest run first).
|
|
183
|
+
|
|
184
|
+
Filters mirror the list view; the client paginates the flat result. Capped
|
|
185
|
+
to keep the payload bounded -- ``capped`` flags truncation.
|
|
186
|
+
"""
|
|
187
|
+
task_q = (task_id or "").lower()
|
|
188
|
+
items: list[dict[str, Any]] = []
|
|
189
|
+
capped = False
|
|
190
|
+
for s in src.list_summaries(dag_id=dag_id, run_id=run_id):
|
|
191
|
+
if task_q and task_q not in s.ref.task_id.lower():
|
|
192
|
+
continue
|
|
193
|
+
if (s.failed + s.errors) <= 0 or not read_auth(s.ref.dag_id, user):
|
|
194
|
+
continue
|
|
195
|
+
detail = src.get_detail(s.ref)
|
|
196
|
+
if detail is None:
|
|
197
|
+
continue
|
|
198
|
+
for c in detail.cases:
|
|
199
|
+
if c.outcome not in ("failed", "error"):
|
|
200
|
+
continue
|
|
201
|
+
items.append(
|
|
202
|
+
{
|
|
203
|
+
"id": s.ref.token,
|
|
204
|
+
"dag_id": s.ref.dag_id,
|
|
205
|
+
"task_id": s.ref.task_id,
|
|
206
|
+
"run_id": s.ref.run_id,
|
|
207
|
+
"created_at": s.created_at,
|
|
208
|
+
"node_id": c.node_id,
|
|
209
|
+
"outcome": c.outcome,
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
if len(items) >= _FAILURES_CAP:
|
|
213
|
+
capped = True
|
|
214
|
+
break
|
|
215
|
+
return JSONResponse(
|
|
216
|
+
{
|
|
217
|
+
"failures": items[:_FAILURES_CAP],
|
|
218
|
+
"total": len(items[:_FAILURES_CAP]),
|
|
219
|
+
"capped": capped,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
172
223
|
@app.get("/icon.svg")
|
|
173
224
|
def icon() -> Response:
|
|
174
225
|
return Response(_flask_svg(_ICON_LIGHT), media_type="image/svg+xml")
|
|
@@ -81,13 +81,25 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
81
81
|
.controls { display: flex; align-items: center; gap: 10px;
|
|
82
82
|
flex: 1 1 auto; min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
|
|
83
83
|
input, button { font: inherit; color: var(--fg); }
|
|
84
|
+
/* Each filter input + its custom suggestions dropdown live in a positioned wrap;
|
|
85
|
+
the wrap carries the adaptive flex sizing, the input fills it. */
|
|
86
|
+
.field-wrap { position: relative; display: flex; flex: 1 1 150px; min-width: 0; max-width: 200px; }
|
|
84
87
|
.field {
|
|
85
88
|
height: 36px; background: var(--surface-2); border: 1px solid var(--border);
|
|
86
|
-
border-radius: 8px; padding: 0 11px;
|
|
87
|
-
flex: 1 1 150px; min-width: 0; max-width: 200px;
|
|
89
|
+
border-radius: 8px; padding: 0 11px; flex: 1 1 auto; min-width: 0; width: 100%;
|
|
88
90
|
}
|
|
89
91
|
#refresh { flex: 0 0 auto; }
|
|
90
92
|
.field:focus { outline: 2px solid var(--ring); outline-offset: 1px; border-color: var(--primary); }
|
|
93
|
+
/* Suggestions: a tidy dropdown anchored under the input (replaces the native
|
|
94
|
+
<datalist>, whose popup escapes the iframe with a detached system shadow). */
|
|
95
|
+
.suggest {
|
|
96
|
+
position: absolute; top: calc(100% + 5px); left: 0; right: 0; z-index: 60;
|
|
97
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
98
|
+
box-shadow: var(--shadow); max-height: 260px; overflow-y: auto; padding: 4px;
|
|
99
|
+
}
|
|
100
|
+
.suggest .opt { padding: 7px 9px; border-radius: 6px; cursor: pointer; font-size: 13px;
|
|
101
|
+
color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
102
|
+
.suggest .opt:hover, .suggest .opt.active { background: var(--surface-2); }
|
|
91
103
|
.btn {
|
|
92
104
|
height: 36px; background: var(--surface-2); border: 1px solid var(--border);
|
|
93
105
|
border-radius: 8px; padding: 0 13px; cursor: pointer; white-space: nowrap;
|
|
@@ -98,6 +110,8 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
98
110
|
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
|
|
99
111
|
.btn.primary:hover { filter: brightness(1.08); }
|
|
100
112
|
.icon-btn { height: auto; padding: 7px; }
|
|
113
|
+
/* Allure button: same height as the neighbouring icon buttons (32px), only wider. */
|
|
114
|
+
#d-allure { height: 32px; padding: 0 10px; gap: 6px; font-size: 12.5px; }
|
|
101
115
|
|
|
102
116
|
main { padding: 18px 20px 40px; max-width: 1600px; margin: 0 auto; }
|
|
103
117
|
|
|
@@ -105,6 +119,9 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
105
119
|
gap: 12px; margin-bottom: 18px; }
|
|
106
120
|
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
107
121
|
padding: 14px 16px; box-shadow: var(--shadow); }
|
|
122
|
+
.kpi.clickable { cursor: pointer; transition: background .12s, border-color .12s; }
|
|
123
|
+
.kpi.clickable:hover { background: var(--surface-2); border-color: var(--muted); }
|
|
124
|
+
.kpi.clickable:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
|
|
108
125
|
.kpi .label { font-size: 12px; color: var(--muted); text-transform: uppercase;
|
|
109
126
|
letter-spacing: .04em; }
|
|
110
127
|
.kpi .value { font-size: 26px; font-weight: 700; margin-top: 4px; }
|
|
@@ -269,7 +286,8 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
269
286
|
.dlg-head { display: flex; align-items: center; gap: 10px; padding: 16px 20px;
|
|
270
287
|
border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); }
|
|
271
288
|
.dlg-head h2 { margin: 0; font-size: 15px; font-weight: 650; overflow-wrap: anywhere; }
|
|
272
|
-
|
|
289
|
+
/* Same size + line box as the title so the run number sits on its baseline. */
|
|
290
|
+
.d-seq { color: var(--muted); font-weight: 600; font-size: 15px;
|
|
273
291
|
font-variant-numeric: tabular-nums; flex: 0 0 auto; }
|
|
274
292
|
.d-seq:empty { display: none; }
|
|
275
293
|
.dlg-body { padding: 18px 20px 22px; max-height: 72vh; overflow: auto; }
|
|
@@ -284,8 +302,21 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
284
302
|
horizontally to its full width so it never overlaps the Time column. */
|
|
285
303
|
.case-node { display: inline-block; white-space: nowrap; }
|
|
286
304
|
.case-table { overflow-x: auto; }
|
|
287
|
-
|
|
305
|
+
/* border-collapse:separate so the frozen (sticky) outcome column keeps its OWN
|
|
306
|
+
borders -- with collapse, a sticky cell's borders belong to the table and get
|
|
307
|
+
painted over, so the column's row lines vanished and the header doubled up.
|
|
308
|
+
Each cell carries only border-bottom (+ the divider on the first cell), so the
|
|
309
|
+
lines are single and aligned across all columns. */
|
|
310
|
+
.case-table table { width: max-content; min-width: 100%;
|
|
311
|
+
border-collapse: separate; border-spacing: 0; }
|
|
288
312
|
.case.clickable[aria-expanded="true"] { background: var(--surface-2); }
|
|
313
|
+
.case-table thead th:first-child,
|
|
314
|
+
.case-table tr.case > td:first-child { position: sticky; left: 0;
|
|
315
|
+
border-right: 1px solid var(--border); }
|
|
316
|
+
.case-table tr.case > td:first-child { background: var(--surface); z-index: 1; }
|
|
317
|
+
.case-table tr.case.clickable:hover > td:first-child,
|
|
318
|
+
.case-table tr.case[aria-expanded="true"] > td:first-child { background: var(--surface-2); }
|
|
319
|
+
.case-table thead th:first-child { z-index: 3; }
|
|
289
320
|
.chev { display: inline-flex; color: var(--muted); transition: transform .15s; }
|
|
290
321
|
.case[aria-expanded="true"] .chev { transform: rotate(90deg); }
|
|
291
322
|
.case-exp > td { padding: 0 12px 12px; border-bottom: 1px solid var(--border); }
|
|
@@ -297,7 +328,7 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
297
328
|
@media (max-width: 680px) {
|
|
298
329
|
.header-inner { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 12px; }
|
|
299
330
|
.controls { width: 100%; flex-wrap: nowrap; }
|
|
300
|
-
.controls .field { flex: 1 1 0; min-width: 0; max-width: none; }
|
|
331
|
+
.controls .field-wrap { flex: 1 1 0; min-width: 0; max-width: none; }
|
|
301
332
|
#refresh { flex: 0 0 auto; }
|
|
302
333
|
main { padding: 12px 12px 32px; }
|
|
303
334
|
th, td { padding: 8px 9px; }
|
|
@@ -337,10 +368,21 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
337
368
|
<div class="header-inner">
|
|
338
369
|
<span class="brand" data-i18n="brand">Pytest Reports</span>
|
|
339
370
|
<div class="controls">
|
|
340
|
-
<
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
371
|
+
<span class="field-wrap">
|
|
372
|
+
<input id="f-dag" class="field" data-i18n-ph="filterDag" data-i18n-al="filterDagAl"
|
|
373
|
+
autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" />
|
|
374
|
+
<div class="suggest" id="sg-dag" role="listbox" hidden></div>
|
|
375
|
+
</span>
|
|
376
|
+
<span class="field-wrap">
|
|
377
|
+
<input id="f-task" class="field" data-i18n-ph="filterTask" data-i18n-al="filterTaskAl"
|
|
378
|
+
autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" />
|
|
379
|
+
<div class="suggest" id="sg-task" role="listbox" hidden></div>
|
|
380
|
+
</span>
|
|
381
|
+
<span class="field-wrap">
|
|
382
|
+
<input id="f-run" class="field" data-i18n-ph="filterRun" data-i18n-al="filterRunAl"
|
|
383
|
+
autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" />
|
|
384
|
+
<div class="suggest" id="sg-run" role="listbox" hidden></div>
|
|
385
|
+
</span>
|
|
344
386
|
<button id="refresh" class="btn primary" type="button">
|
|
345
387
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
346
388
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
@@ -349,8 +391,6 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
349
391
|
<span data-i18n="refresh">Refresh</span>
|
|
350
392
|
</button>
|
|
351
393
|
</div>
|
|
352
|
-
<datalist id="dags"></datalist>
|
|
353
|
-
<datalist id="runs"></datalist>
|
|
354
394
|
</div>
|
|
355
395
|
</header>
|
|
356
396
|
|
|
@@ -374,6 +414,13 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
374
414
|
<h2 id="d-title">Report</h2>
|
|
375
415
|
<span class="grow" style="flex:1"></span>
|
|
376
416
|
<span id="d-copied" class="copied" hidden data-i18n="copied">Copied</span>
|
|
417
|
+
<button id="d-allure" class="btn" type="button" hidden data-i18n-al="downloadAllure">
|
|
418
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
419
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
420
|
+
<path d="M12 3v12M7 10l5 5 5-5M5 21h14"/>
|
|
421
|
+
</svg>
|
|
422
|
+
<span data-i18n="downloadAllure">Allure results</span>
|
|
423
|
+
</button>
|
|
377
424
|
<button id="d-copy" class="btn icon-btn" type="button" data-i18n-al="copyLink" title="Copy link">
|
|
378
425
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
379
426
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
@@ -408,6 +455,20 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
408
455
|
</div>
|
|
409
456
|
</dialog>
|
|
410
457
|
|
|
458
|
+
<dialog id="failures" aria-labelledby="fl-title">
|
|
459
|
+
<div class="dlg-head">
|
|
460
|
+
<h2 id="fl-title" data-i18n="failuresTitle">Failed tests</h2>
|
|
461
|
+
<span class="grow" style="flex:1"></span>
|
|
462
|
+
<button id="fl-close" class="btn icon-btn" type="button" data-i18n-al="closeReport">
|
|
463
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
464
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
465
|
+
<path d="M18 6 6 18M6 6l12 12"/>
|
|
466
|
+
</svg>
|
|
467
|
+
</button>
|
|
468
|
+
</div>
|
|
469
|
+
<div class="dlg-body" id="fl-body"></div>
|
|
470
|
+
</dialog>
|
|
471
|
+
|
|
411
472
|
<div id="bulk-bar" class="bulk-bar" hidden></div>
|
|
412
473
|
|
|
413
474
|
<script>
|
|
@@ -418,8 +479,8 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
418
479
|
var I18N = {
|
|
419
480
|
en: {
|
|
420
481
|
title: "Pytest Reports", brand: "Pytest Reports", refresh: "Refresh",
|
|
421
|
-
filterDag: "filter dag_id", filterRun: "filter run_id",
|
|
422
|
-
filterDagAl: "Filter by dag_id", filterRunAl: "Filter by run_id",
|
|
482
|
+
filterDag: "filter dag_id", filterTask: "filter task_id", filterRun: "filter run_id",
|
|
483
|
+
filterDagAl: "Filter by dag_id", filterTaskAl: "Filter by task_id", filterRunAl: "Filter by run_id",
|
|
423
484
|
history: "Recent runs", copyLink: "Copy link", copied: "Copied",
|
|
424
485
|
closeReport: "Close report",
|
|
425
486
|
cId: "ID", cStatus: "Status", cDag: "DAG", cTask: "Task", cRun: "Run", cTry: "Try",
|
|
@@ -435,7 +496,10 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
435
496
|
noMatch: "No reports match the current filter.",
|
|
436
497
|
noReports: "No reports found yet. Run a PytestOperator task with ArchivingResultParser to populate this view.",
|
|
437
498
|
noCases: "No matching cases.", tryWord: "try",
|
|
499
|
+
failuresTitle: "Failed tests", noFailures: "No failed tests.",
|
|
500
|
+
failCapped: "Showing the first {n} failures.",
|
|
438
501
|
loadFail: "Failed to load reports: ", reportFail: "Failed to load report: ",
|
|
502
|
+
failuresFail: "Failed to load failures: ",
|
|
439
503
|
deleteReport: "Delete report", deleteTitle: "Delete report?",
|
|
440
504
|
deleteTitleN: "Delete {n} reports?",
|
|
441
505
|
deleteConfirm: "This permanently removes the report and its files everywhere.",
|
|
@@ -450,8 +514,8 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
450
514
|
},
|
|
451
515
|
ru: {
|
|
452
516
|
title: "Pytest Reports", brand: "Pytest-отчёты", refresh: "Обновить",
|
|
453
|
-
filterDag: "фильтр dag_id", filterRun: "фильтр run_id",
|
|
454
|
-
filterDagAl: "Фильтр по dag_id", filterRunAl: "Фильтр по run_id",
|
|
517
|
+
filterDag: "фильтр dag_id", filterTask: "фильтр task_id", filterRun: "фильтр run_id",
|
|
518
|
+
filterDagAl: "Фильтр по dag_id", filterTaskAl: "Фильтр по task_id", filterRunAl: "Фильтр по run_id",
|
|
455
519
|
history: "Последние прогоны", copyLink: "Копировать ссылку", copied: "Скопировано",
|
|
456
520
|
closeReport: "Закрыть отчёт",
|
|
457
521
|
cId: "ID", cStatus: "Статус", cDag: "DAG", cTask: "Задача", cRun: "Запуск", cTry: "Попытка",
|
|
@@ -467,7 +531,10 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
467
531
|
noMatch: "Нет отчётов под текущий фильтр.",
|
|
468
532
|
noReports: "Отчётов пока нет. Запусти задачу PytestOperator с ArchivingResultParser, чтобы они появились здесь.",
|
|
469
533
|
noCases: "Нет подходящих тестов.", tryWord: "попытка",
|
|
534
|
+
failuresTitle: "Проваленные тесты", noFailures: "Проваленных тестов нет.",
|
|
535
|
+
failCapped: "Показаны первые {n} падений.",
|
|
470
536
|
loadFail: "Не удалось загрузить отчёты: ", reportFail: "Не удалось загрузить отчёт: ",
|
|
537
|
+
failuresFail: "Не удалось загрузить падения: ",
|
|
471
538
|
deleteReport: "Удалить отчёт", deleteTitle: "Удалить отчёт?",
|
|
472
539
|
deleteTitleN: "Удалить отчётов: {n}?",
|
|
473
540
|
deleteConfirm: "Отчёт и его файлы будут удалены безвозвратно — везде.",
|
|
@@ -695,13 +762,24 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
695
762
|
{ label: t("kRuns"), value: runs },
|
|
696
763
|
{ label: t("kPassingRuns"), value: ok + " / " + runs, cls: ok === runs ? "c-pass" : "" },
|
|
697
764
|
{ label: t("kTests"), value: tests },
|
|
698
|
-
{ label: t("kFailures"), value: failures, cls: failures ? "c-fail" : "c-pass"
|
|
765
|
+
{ label: t("kFailures"), value: failures, cls: failures ? "c-fail" : "c-pass",
|
|
766
|
+
id: "kpi-failures", click: failures > 0 },
|
|
699
767
|
];
|
|
700
768
|
kpisEl.hidden = false;
|
|
701
769
|
kpisEl.innerHTML = cards.map(function (c) {
|
|
702
|
-
|
|
770
|
+
var attrs = (c.id ? ' id="' + c.id + '"' : "")
|
|
771
|
+
+ (c.click ? ' role="button" tabindex="0"' : "");
|
|
772
|
+
return '<div class="kpi' + (c.click ? " clickable" : "") + '"' + attrs
|
|
773
|
+
+ '><div class="label">' + esc(c.label) + '</div>'
|
|
703
774
|
+ '<div class="value ' + (c.cls || "") + '">' + esc(c.value) + "</div></div>";
|
|
704
775
|
}).join("");
|
|
776
|
+
var fk = document.getElementById("kpi-failures");
|
|
777
|
+
if (fk && failures > 0) {
|
|
778
|
+
fk.addEventListener("click", openFailures);
|
|
779
|
+
fk.addEventListener("keydown", function (e) {
|
|
780
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openFailures(); }
|
|
781
|
+
});
|
|
782
|
+
}
|
|
705
783
|
}
|
|
706
784
|
|
|
707
785
|
function renderLegend() {
|
|
@@ -1100,24 +1178,69 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1100
1178
|
}
|
|
1101
1179
|
function applyFilter(keepPage) {
|
|
1102
1180
|
var dag = document.getElementById("f-dag").value.trim();
|
|
1181
|
+
var task = document.getElementById("f-task").value.trim();
|
|
1103
1182
|
var run = document.getElementById("f-run").value.trim();
|
|
1104
1183
|
reports = allReports.filter(function (r) {
|
|
1105
|
-
return matchesIn(r.dag_id, dag) && matchesIn(r.run_id, run);
|
|
1184
|
+
return matchesIn(r.dag_id, dag) && matchesIn(r.task_id, task) && matchesIn(r.run_id, run);
|
|
1106
1185
|
});
|
|
1107
1186
|
assignSeq();
|
|
1108
1187
|
// Filter resets to the newest runs / first page; a delete keeps the user's place.
|
|
1109
1188
|
if (!keepPage) { chartScroll = null; listPage = 0; }
|
|
1110
1189
|
renderKpis(); renderChart(); renderList();
|
|
1111
1190
|
}
|
|
1191
|
+
var suggestVals = { dag_id: [], task_id: [], run_id: [] };
|
|
1112
1192
|
function populateSuggestions() {
|
|
1113
|
-
var
|
|
1114
|
-
allReports.forEach(function (
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1193
|
+
var d = {}, ta = {}, r = {};
|
|
1194
|
+
allReports.forEach(function (x) { d[x.dag_id] = 1; ta[x.task_id] = 1; r[x.run_id] = 1; });
|
|
1195
|
+
suggestVals = {
|
|
1196
|
+
dag_id: Object.keys(d).sort(), task_id: Object.keys(ta).sort(), run_id: Object.keys(r).sort(),
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
// Tidy autocomplete that drops neatly under the input. The native <datalist>
|
|
1200
|
+
// popup renders outside the sandboxed iframe with a detached system shadow.
|
|
1201
|
+
function bindSuggest(inputId, boxId, field) {
|
|
1202
|
+
var input = document.getElementById(inputId), box = document.getElementById(boxId);
|
|
1203
|
+
var active = -1;
|
|
1204
|
+
function opts() { return [].slice.call(box.querySelectorAll(".opt")); }
|
|
1205
|
+
function close() {
|
|
1206
|
+
box.hidden = true; box.innerHTML = ""; active = -1;
|
|
1207
|
+
input.setAttribute("aria-expanded", "false");
|
|
1208
|
+
}
|
|
1209
|
+
function render() {
|
|
1210
|
+
var q = input.value.trim().toLowerCase();
|
|
1211
|
+
var matches = (suggestVals[field] || []).filter(function (v) {
|
|
1212
|
+
var lv = v.toLowerCase(); return lv.indexOf(q) !== -1 && lv !== q;
|
|
1213
|
+
}).slice(0, 50);
|
|
1214
|
+
if (!matches.length) { close(); return; }
|
|
1215
|
+
box.innerHTML = matches.map(function (v) {
|
|
1216
|
+
return '<div class="opt" role="option">' + esc(v) + "</div>";
|
|
1118
1217
|
}).join("");
|
|
1218
|
+
box.hidden = false; active = -1; input.setAttribute("aria-expanded", "true");
|
|
1219
|
+
box.querySelectorAll(".opt").forEach(function (o) {
|
|
1220
|
+
o.addEventListener("mousedown", function (e) { // mousedown beats the input's blur
|
|
1221
|
+
e.preventDefault();
|
|
1222
|
+
input.value = o.textContent; close(); applyFilter();
|
|
1223
|
+
});
|
|
1224
|
+
});
|
|
1119
1225
|
}
|
|
1120
|
-
|
|
1226
|
+
input.addEventListener("input", render);
|
|
1227
|
+
input.addEventListener("focus", render);
|
|
1228
|
+
input.addEventListener("blur", function () { setTimeout(close, 120); });
|
|
1229
|
+
input.addEventListener("keydown", function (e) {
|
|
1230
|
+
if (e.key === "Escape") { close(); return; }
|
|
1231
|
+
var o = opts();
|
|
1232
|
+
if (e.key === "ArrowDown") { active = Math.min(active + 1, o.length - 1); }
|
|
1233
|
+
else if (e.key === "ArrowUp") { active = Math.max(active - 1, 0); }
|
|
1234
|
+
else if (e.key === "Enter") {
|
|
1235
|
+
if (active >= 0 && o[active]) {
|
|
1236
|
+
e.preventDefault(); input.value = o[active].textContent; close(); applyFilter();
|
|
1237
|
+
}
|
|
1238
|
+
return;
|
|
1239
|
+
} else { return; }
|
|
1240
|
+
e.preventDefault();
|
|
1241
|
+
o.forEach(function (x, i) { x.classList.toggle("active", i === active); });
|
|
1242
|
+
if (o[active]) o[active].scrollIntoView({ block: "nearest" });
|
|
1243
|
+
});
|
|
1121
1244
|
}
|
|
1122
1245
|
|
|
1123
1246
|
var dlg = document.getElementById("detail");
|
|
@@ -1168,19 +1291,13 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1168
1291
|
return '<a class="af-link" href="' + esc(href) + '" target="_top" rel="noopener">'
|
|
1169
1292
|
+ ext + esc(label) + "</a>";
|
|
1170
1293
|
}
|
|
1171
|
-
|
|
1172
|
-
+ link(dag, t("afDag")) + link(run, t("afRun")) + link(ti, t("afTask"));
|
|
1173
|
-
if (m.has_allure) {
|
|
1174
|
-
var dl = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"'
|
|
1175
|
-
+ ' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
|
|
1176
|
-
+ '<path d="M12 3v12M7 10l5 5 5-5M5 21h14"/></svg>';
|
|
1177
|
-
out += '<button type="button" class="af-link" data-allure="' + esc(m.id) + '">'
|
|
1178
|
-
+ dl + esc(t("downloadAllure")) + "</button>";
|
|
1179
|
-
}
|
|
1180
|
-
return out + "</div>";
|
|
1294
|
+
return '<div class="af-links">'
|
|
1295
|
+
+ link(dag, t("afDag")) + link(run, t("afRun")) + link(ti, t("afTask")) + "</div>";
|
|
1181
1296
|
}
|
|
1182
1297
|
|
|
1183
1298
|
function openInAirflow(href) {
|
|
1299
|
+
closeDetail(); // dismiss the modal...
|
|
1300
|
+
setParentDim(false); // ...and drop its full-screen dim now (don't wait for the close event)
|
|
1184
1301
|
try {
|
|
1185
1302
|
var top = window.top;
|
|
1186
1303
|
if (top && top !== window.self && top.location
|
|
@@ -1220,6 +1337,7 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1220
1337
|
// Run number (#N), matching the chart bar and the list ID column.
|
|
1221
1338
|
var rec = reports.filter(function (x) { return x.id === currentId; })[0];
|
|
1222
1339
|
document.getElementById("d-seq").textContent = rec && rec.seq ? "#" + rec.seq : "";
|
|
1340
|
+
document.getElementById("d-allure").hidden = !m.has_allure;
|
|
1223
1341
|
|
|
1224
1342
|
var kpis = [
|
|
1225
1343
|
[t("kPassed"), m.passed, "c-pass"], [t("kFailed"), m.failed, "c-fail"],
|
|
@@ -1269,9 +1387,6 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1269
1387
|
openInAirflow(a.getAttribute("href"));
|
|
1270
1388
|
});
|
|
1271
1389
|
});
|
|
1272
|
-
dBody.querySelectorAll("[data-allure]").forEach(function (b) {
|
|
1273
|
-
b.addEventListener("click", function () { downloadAllure(b.getAttribute("data-allure")); });
|
|
1274
|
-
});
|
|
1275
1390
|
dBody.querySelectorAll(".dseg").forEach(function (seg) {
|
|
1276
1391
|
seg.addEventListener("click", function () {
|
|
1277
1392
|
var s = seg.getAttribute("data-status");
|
|
@@ -1307,6 +1422,7 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1307
1422
|
dTitle.textContent = t("loading");
|
|
1308
1423
|
if (typeof dlg.showModal === "function") { if (!dlg.open) dlg.showModal(); }
|
|
1309
1424
|
else dlg.setAttribute("open", "");
|
|
1425
|
+
updateParentDim();
|
|
1310
1426
|
setReportParam(id);
|
|
1311
1427
|
fetch(API + "reports/" + encodeURIComponent(id))
|
|
1312
1428
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
|
@@ -1360,6 +1476,38 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1360
1476
|
d.addEventListener("click", function (e) { if (startedOutside && outside(e)) closeFn(); });
|
|
1361
1477
|
}
|
|
1362
1478
|
|
|
1479
|
+
// Dim the WHOLE Airflow window (nav included) behind a modal, like Airflow's
|
|
1480
|
+
// own dialogs. Our <dialog> ::backdrop only covers the iframe, so when embedded
|
|
1481
|
+
// we drop a full-screen overlay into the parent and lift our iframe above it --
|
|
1482
|
+
// the iframe's own ::backdrop still dims the page around the dialog, so the dim
|
|
1483
|
+
// is seamless across both. Standalone needs nothing (::backdrop is the window).
|
|
1484
|
+
function setParentDim(on) {
|
|
1485
|
+
var fe = window.frameElement; // our iframe in the parent (same-origin only)
|
|
1486
|
+
if (!fe) return;
|
|
1487
|
+
try {
|
|
1488
|
+
var pdoc = fe.ownerDocument, ID = "apx-modal-dim";
|
|
1489
|
+
var ov = pdoc.getElementById(ID);
|
|
1490
|
+
if (on) {
|
|
1491
|
+
if (!ov) {
|
|
1492
|
+
ov = pdoc.createElement("div");
|
|
1493
|
+
ov.id = ID;
|
|
1494
|
+
ov.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2147483646;";
|
|
1495
|
+
(pdoc.body || pdoc.documentElement).appendChild(ov);
|
|
1496
|
+
}
|
|
1497
|
+
fe.style.position = "relative";
|
|
1498
|
+
fe.style.zIndex = "2147483647";
|
|
1499
|
+
} else {
|
|
1500
|
+
if (ov && ov.parentNode) ov.parentNode.removeChild(ov);
|
|
1501
|
+
fe.style.zIndex = "";
|
|
1502
|
+
fe.style.position = "";
|
|
1503
|
+
}
|
|
1504
|
+
} catch (e) { /* cross-origin parent: skip */ }
|
|
1505
|
+
}
|
|
1506
|
+
function updateParentDim() {
|
|
1507
|
+
setParentDim((dlg && dlg.open) || (confirmDlg && confirmDlg.open)
|
|
1508
|
+
|| (failuresDlg && failuresDlg.open));
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1363
1511
|
// Copy a deep-link to this report.
|
|
1364
1512
|
function copyLink() {
|
|
1365
1513
|
if (!currentId) return;
|
|
@@ -1387,11 +1535,17 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1387
1535
|
|
|
1388
1536
|
document.getElementById("d-close").addEventListener("click", closeDetail);
|
|
1389
1537
|
document.getElementById("d-copy").addEventListener("click", copyLink);
|
|
1538
|
+
document.getElementById("d-allure").addEventListener("click", function () {
|
|
1539
|
+
if (currentId) downloadAllure(currentId);
|
|
1540
|
+
});
|
|
1390
1541
|
document.getElementById("d-delete").addEventListener("click", function () {
|
|
1391
1542
|
if (currentId) openConfirm([currentId], dTitle.textContent);
|
|
1392
1543
|
});
|
|
1393
1544
|
dlg.addEventListener("cancel", function () { detail = null; currentId = null; setReportParam(null); });
|
|
1394
|
-
dlg.addEventListener("close", function () {
|
|
1545
|
+
dlg.addEventListener("close", function () {
|
|
1546
|
+
updateParentDim();
|
|
1547
|
+
if (lastFocus && lastFocus.focus) lastFocus.focus();
|
|
1548
|
+
});
|
|
1395
1549
|
closeOnBackdrop(dlg, closeDetail);
|
|
1396
1550
|
|
|
1397
1551
|
var confirmDlg = document.getElementById("confirm");
|
|
@@ -1404,6 +1558,7 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1404
1558
|
document.getElementById("c-name").textContent = label || "";
|
|
1405
1559
|
if (typeof confirmDlg.showModal === "function") { if (!confirmDlg.open) confirmDlg.showModal(); }
|
|
1406
1560
|
else confirmDlg.setAttribute("open", "");
|
|
1561
|
+
updateParentDim();
|
|
1407
1562
|
}
|
|
1408
1563
|
function closeConfirm() {
|
|
1409
1564
|
pendingDelete = [];
|
|
@@ -1440,15 +1595,103 @@ _INDEX_HTML = r"""<!DOCTYPE html>
|
|
|
1440
1595
|
document.getElementById("c-cancel").addEventListener("click", closeConfirm);
|
|
1441
1596
|
document.getElementById("c-ok").addEventListener("click", doDelete);
|
|
1442
1597
|
confirmDlg.addEventListener("cancel", function () { pendingDelete = []; });
|
|
1598
|
+
confirmDlg.addEventListener("close", updateParentDim);
|
|
1443
1599
|
closeOnBackdrop(confirmDlg, closeConfirm);
|
|
1444
1600
|
|
|
1601
|
+
// Failed-tests modal: clicking the FAILURES KPI lists every failed/errored case
|
|
1602
|
+
// across the visible runs, paginated client-side at FAIL_PAGE per page.
|
|
1603
|
+
var failuresDlg = document.getElementById("failures");
|
|
1604
|
+
var flBody = document.getElementById("fl-body");
|
|
1605
|
+
var FAIL_PAGE = 100;
|
|
1606
|
+
var failuresData = [], failPage = 0, failCapped = false;
|
|
1607
|
+
|
|
1608
|
+
function filterQuery() {
|
|
1609
|
+
var q = new URLSearchParams();
|
|
1610
|
+
var dag = document.getElementById("f-dag").value.trim();
|
|
1611
|
+
var task = document.getElementById("f-task").value.trim();
|
|
1612
|
+
var run = document.getElementById("f-run").value.trim();
|
|
1613
|
+
if (dag) q.set("dag_id", dag);
|
|
1614
|
+
if (task) q.set("task_id", task);
|
|
1615
|
+
if (run) q.set("run_id", run);
|
|
1616
|
+
var s = q.toString();
|
|
1617
|
+
return s ? "?" + s : "";
|
|
1618
|
+
}
|
|
1619
|
+
function openFailures() {
|
|
1620
|
+
if (typeof failuresDlg.showModal === "function") { if (!failuresDlg.open) failuresDlg.showModal(); }
|
|
1621
|
+
else failuresDlg.setAttribute("open", "");
|
|
1622
|
+
updateParentDim();
|
|
1623
|
+
flBody.innerHTML = '<div class="state"><div class="skeleton" style="width:40%;margin:0 auto"></div></div>';
|
|
1624
|
+
fetch(API + "failures" + filterQuery())
|
|
1625
|
+
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
|
1626
|
+
.then(function (d) {
|
|
1627
|
+
failuresData = d.failures || []; failCapped = !!d.capped; failPage = 0; renderFailures();
|
|
1628
|
+
})
|
|
1629
|
+
.catch(function (e) {
|
|
1630
|
+
flBody.innerHTML = '<div class="state c-fail">' + esc(t("failuresFail") + e.message) + "</div>";
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
function closeFailures() {
|
|
1634
|
+
if (failuresDlg.open) failuresDlg.close(); else failuresDlg.removeAttribute("open");
|
|
1635
|
+
}
|
|
1636
|
+
function renderFailures() {
|
|
1637
|
+
if (!failuresData.length) {
|
|
1638
|
+
flBody.innerHTML = '<div class="state">' + esc(t("noFailures")) + "</div>";
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
var pages = Math.ceil(failuresData.length / FAIL_PAGE);
|
|
1642
|
+
failPage = Math.max(0, Math.min(failPage, pages - 1));
|
|
1643
|
+
var slice = failuresData.slice(failPage * FAIL_PAGE, failPage * FAIL_PAGE + FAIL_PAGE);
|
|
1644
|
+
var rows = slice.map(function (f) {
|
|
1645
|
+
var kind = f.outcome === "error" ? "error" : "fail";
|
|
1646
|
+
var rec = reports.filter(function (x) { return x.id === f.id; })[0];
|
|
1647
|
+
var run = esc(f.dag_id) + " · " + esc(f.task_id) + (rec && rec.seq ? " · #" + rec.seq : "");
|
|
1648
|
+
return '<tr class="case clickable" tabindex="0" data-id="' + esc(f.id)
|
|
1649
|
+
+ '" data-outcome="' + esc(f.outcome) + '">'
|
|
1650
|
+
+ "<td>" + badge(kind, outcomeLabel(f.outcome)) + "</td>"
|
|
1651
|
+
+ '<td><span class="case-node mono">' + esc(f.node_id) + "</span></td>"
|
|
1652
|
+
+ '<td class="muted">' + run + "</td></tr>";
|
|
1653
|
+
}).join("");
|
|
1654
|
+
var cap = failCapped
|
|
1655
|
+
? '<div class="state muted">' + esc(t("failCapped").replace("{n}", failuresData.length)) + "</div>" : "";
|
|
1656
|
+
var pager = pages > 1
|
|
1657
|
+
? '<div class="pager"><button type="button" class="nav-btn" id="fl-prev"'
|
|
1658
|
+
+ (failPage <= 0 ? " disabled" : "") + ' aria-label="' + esc(t("prevPage")) + '">‹</button>'
|
|
1659
|
+
+ "<span>" + esc(t("page")) + " " + (failPage + 1) + " / " + pages + "</span>"
|
|
1660
|
+
+ '<button type="button" class="nav-btn" id="fl-next"'
|
|
1661
|
+
+ (failPage >= pages - 1 ? " disabled" : "") + ' aria-label="' + esc(t("nextPage")) + '">›</button></div>'
|
|
1662
|
+
: "";
|
|
1663
|
+
flBody.innerHTML = '<div class="card table-wrap case-table"><table><thead><tr>'
|
|
1664
|
+
+ "<th>" + esc(t("hOutcome")) + "</th><th>" + esc(t("hTest")) + "</th><th>"
|
|
1665
|
+
+ esc(t("cRun")) + "</th></tr></thead><tbody>" + rows + "</tbody></table></div>" + cap + pager;
|
|
1666
|
+
flBody.querySelectorAll(".case").forEach(function (tr) {
|
|
1667
|
+
var open = function () {
|
|
1668
|
+
openDetail(tr.getAttribute("data-id")); // resets filter to "all"...
|
|
1669
|
+
filter = tr.getAttribute("data-outcome"); // ...then land on the failing cases
|
|
1670
|
+
closeFailures();
|
|
1671
|
+
};
|
|
1672
|
+
tr.addEventListener("click", open);
|
|
1673
|
+
tr.addEventListener("keydown", function (e) {
|
|
1674
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); }
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
var p = document.getElementById("fl-prev"), n = document.getElementById("fl-next");
|
|
1678
|
+
if (p) p.addEventListener("click", function () { if (failPage > 0) { failPage--; renderFailures(); } });
|
|
1679
|
+
if (n) n.addEventListener("click", function () { if (failPage < pages - 1) { failPage++; renderFailures(); } });
|
|
1680
|
+
}
|
|
1681
|
+
document.getElementById("fl-close").addEventListener("click", closeFailures);
|
|
1682
|
+
failuresDlg.addEventListener("close", updateParentDim);
|
|
1683
|
+
closeOnBackdrop(failuresDlg, closeFailures);
|
|
1684
|
+
|
|
1445
1685
|
document.getElementById("refresh").addEventListener("click", load);
|
|
1446
1686
|
document.addEventListener("pointermove", chartDragMove);
|
|
1447
1687
|
document.addEventListener("pointerup", chartDragEnd);
|
|
1448
1688
|
document.addEventListener("pointercancel", chartDragEnd);
|
|
1449
|
-
["f-dag", "f-run"].forEach(function (id) {
|
|
1689
|
+
["f-dag", "f-task", "f-run"].forEach(function (id) {
|
|
1450
1690
|
document.getElementById(id).addEventListener("input", applyFilter);
|
|
1451
1691
|
});
|
|
1692
|
+
bindSuggest("f-dag", "sg-dag", "dag_id");
|
|
1693
|
+
bindSuggest("f-task", "sg-task", "task_id");
|
|
1694
|
+
bindSuggest("f-run", "sg-run", "run_id");
|
|
1452
1695
|
// Re-render the chart on resize so bars re-snap to the new pixel grid.
|
|
1453
1696
|
var _rsTimer;
|
|
1454
1697
|
window.addEventListener("resize", function () {
|
|
@@ -23,7 +23,7 @@ import pytest
|
|
|
23
23
|
from airflow_pytest_plugin.compat import airflow_auth_available
|
|
24
24
|
from airflow_pytest_plugin.models import ReportRef
|
|
25
25
|
from airflow_pytest_plugin.sources import FileSystemReportSource
|
|
26
|
-
from conftest import write_allure, write_report
|
|
26
|
+
from conftest import write_allure, write_report, write_report_xml
|
|
27
27
|
|
|
28
28
|
fastapi = pytest.importorskip("fastapi")
|
|
29
29
|
from fastapi.testclient import TestClient # noqa: E402
|
|
@@ -96,6 +96,11 @@ def test_index_has_feature_markers(client):
|
|
|
96
96
|
"assignSeq",
|
|
97
97
|
"closeOnBackdrop",
|
|
98
98
|
"PAGE_SIZE",
|
|
99
|
+
'id="f-task"',
|
|
100
|
+
'id="d-allure"',
|
|
101
|
+
"openFailures",
|
|
102
|
+
"kpi-failures",
|
|
103
|
+
"setParentDim",
|
|
99
104
|
"forbidden",
|
|
100
105
|
):
|
|
101
106
|
assert marker in html
|
|
@@ -262,3 +267,84 @@ def test_list_exposes_has_allure(reports_root):
|
|
|
262
267
|
write_allure(reports_root, ref)
|
|
263
268
|
c = TestClient(make_app(reports_root))
|
|
264
269
|
assert c.get("/api/reports").json()["reports"][0]["has_allure"] is True
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_failures_lists_failed_and_error_cases(reports_root):
|
|
273
|
+
write_report(
|
|
274
|
+
reports_root,
|
|
275
|
+
ReportRef("dag", "run", "task", 1),
|
|
276
|
+
passed=2,
|
|
277
|
+
failed=3,
|
|
278
|
+
errors=1,
|
|
279
|
+
skipped=1,
|
|
280
|
+
)
|
|
281
|
+
write_report(
|
|
282
|
+
reports_root, ReportRef("dag2", "run", "task", 1), passed=4
|
|
283
|
+
) # excluded
|
|
284
|
+
c = TestClient(make_app(reports_root))
|
|
285
|
+
d = c.get("/api/failures").json()
|
|
286
|
+
assert d["total"] == 4 and d["capped"] is False
|
|
287
|
+
assert sorted({f["outcome"] for f in d["failures"]}) == ["error", "failed"]
|
|
288
|
+
assert all(
|
|
289
|
+
f["dag_id"] == "dag" for f in d["failures"]
|
|
290
|
+
) # the all-pass run is skipped
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_failures_respects_task_filter(reports_root):
|
|
294
|
+
write_report(reports_root, ReportRef("dag", "run", "alpha", 1), passed=1, failed=2)
|
|
295
|
+
write_report(reports_root, ReportRef("dag", "run", "beta", 1), passed=1, failed=3)
|
|
296
|
+
c = TestClient(make_app(reports_root))
|
|
297
|
+
d = c.get("/api/failures?task_id=alpha").json()
|
|
298
|
+
assert d["total"] == 2 and all(f["task_id"] == "alpha" for f in d["failures"])
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_failures_hides_unreadable_runs(reports_root):
|
|
302
|
+
write_report(reports_root, ReportRef("seen", "r", "t", 1), passed=1, failed=2)
|
|
303
|
+
write_report(reports_root, ReportRef("hidden", "r", "t", 1), passed=1, failed=2)
|
|
304
|
+
c = TestClient(
|
|
305
|
+
make_app(reports_root, read_authorizer=lambda dag_id, user: dag_id != "hidden")
|
|
306
|
+
)
|
|
307
|
+
d = c.get("/api/failures").json()
|
|
308
|
+
assert {f["dag_id"] for f in d["failures"]} == {"seen"}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_failures_empty_when_all_pass(reports_root):
|
|
312
|
+
write_report(reports_root, ReportRef("dag", "run", "task", 1), passed=5)
|
|
313
|
+
c = TestClient(make_app(reports_root))
|
|
314
|
+
assert c.get("/api/failures").json() == {
|
|
315
|
+
"failures": [],
|
|
316
|
+
"total": 0,
|
|
317
|
+
"capped": False,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_failures_skips_runs_with_unreadable_xml(reports_root):
|
|
322
|
+
# meta marks the run as failing, but its junit.xml can't be parsed -> no cases.
|
|
323
|
+
write_report_xml(
|
|
324
|
+
reports_root,
|
|
325
|
+
ReportRef("dag", "r", "t", 1),
|
|
326
|
+
"<not-valid-xml",
|
|
327
|
+
summary={
|
|
328
|
+
"total": 2,
|
|
329
|
+
"passed": 0,
|
|
330
|
+
"failed": 2,
|
|
331
|
+
"errors": 0,
|
|
332
|
+
"skipped": 0,
|
|
333
|
+
"success": False,
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
c = TestClient(make_app(reports_root))
|
|
337
|
+
assert c.get("/api/failures").json() == {
|
|
338
|
+
"failures": [],
|
|
339
|
+
"total": 0,
|
|
340
|
+
"capped": False,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_failures_capped(reports_root, monkeypatch):
|
|
345
|
+
import airflow_pytest_plugin.web.app as app_mod
|
|
346
|
+
|
|
347
|
+
monkeypatch.setattr(app_mod, "_FAILURES_CAP", 3)
|
|
348
|
+
write_report(reports_root, ReportRef("dag", "r", "t", 1), passed=1, failed=5)
|
|
349
|
+
d = TestClient(make_app(reports_root)).get("/api/failures").json()
|
|
350
|
+
assert d["capped"] is True and d["total"] == 3 and len(d["failures"]) == 3
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/config.py
RENAMED
|
File without changes
|
{airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/layout.py
RENAMED
|
File without changes
|
{airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/models.py
RENAMED
|
File without changes
|
{airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/plugin.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/version.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|