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.
Files changed (35) hide show
  1. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/PKG-INFO +7 -1
  2. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/README.md +6 -0
  3. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/pyproject.toml +1 -1
  4. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/web/app.py +51 -0
  5. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/web/templates.py +282 -39
  6. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_web_app.py +87 -1
  7. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/.gitignore +0 -0
  8. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/LICENSE +0 -0
  9. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/examples/example_dag.py +0 -0
  10. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/__init__.py +0 -0
  11. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
  12. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
  13. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/config.py +0 -0
  14. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/layout.py +0 -0
  15. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/models.py +0 -0
  16. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/plugin.py +0 -0
  17. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
  18. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/producer/archiving_parser.py +0 -0
  19. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
  20. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/sources/base.py +0 -0
  21. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/sources/filesystem.py +0 -0
  22. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/version.py +0 -0
  23. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/web/__init__.py +0 -0
  24. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/src/airflow_pytest_plugin/web/__main__.py +0 -0
  25. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/conftest.py +0 -0
  26. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_archiving_parser.py +0 -0
  27. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_authz_integration.py +0 -0
  28. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_compat.py +0 -0
  29. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_config.py +0 -0
  30. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_filesystem_source.py +0 -0
  31. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_layout.py +0 -0
  32. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_models.py +0 -0
  33. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_package.py +0 -0
  34. {airflow_pytest_plugin-0.2.0 → airflow_pytest_plugin-0.2.1}/tests/test_plugin.py +0 -0
  35. {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.0
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
  ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
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
+ ![Pytest Reports — failed tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/failures.png)
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
  ![Pytest Reports — a single run](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/detail.png)
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
+ ![Pytest Reports — failed tests](https://raw.githubusercontent.com/IKrysanov/airflow-pytest-plugin/main/docs/screenshots/failures.png)
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"}` |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "airflow-pytest-plugin"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "View airflow-pytest-operator results in the Airflow 3 web UI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- .d-seq { color: var(--muted); font-weight: 600; font-size: 14px;
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
- .case-table table { width: max-content; min-width: 100%; }
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
- <input id="f-dag" class="field" data-i18n-ph="filterDag" data-i18n-al="filterDagAl"
341
- list="dags" autocomplete="off" />
342
- <input id="f-run" class="field" data-i18n-ph="filterRun" data-i18n-al="filterRunAl"
343
- list="runs" autocomplete="off" />
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
- return '<div class="kpi"><div class="label">' + esc(c.label) + '</div>'
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 dags = {}, runs = {};
1114
- allReports.forEach(function (r) { dags[r.dag_id] = 1; runs[r.run_id] = 1; });
1115
- function fill(id, keys) {
1116
- document.getElementById(id).innerHTML = Object.keys(keys).sort().map(function (k) {
1117
- return '<option value="' + esc(k) + '"></option>';
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
- fill("dags", dags); fill("runs", runs);
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
- var out = '<div class="af-links">'
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 () { if (lastFocus && lastFocus.focus) lastFocus.focus(); });
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