code-coordinator 0.5.46__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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"""Framework driver adapters for ``coord acceptance`` (#944,
|
|
2
|
+
docs/ORACLE_LOOP.md).
|
|
3
|
+
|
|
4
|
+
``coord acceptance`` is a thin, framework-agnostic orchestrator; this module
|
|
5
|
+
is the one seam that varies per medium — TUI (quadraui ``TuiDriver``), CLI
|
|
6
|
+
(pytest), web (Playwright), native, etc. Each driver knows how to *run* a
|
|
7
|
+
repo's declared acceptance suite and *parse* its raw output into a
|
|
8
|
+
normalized list of ``{"id": str, "status": "pass"|"fail"|"skip", "message":
|
|
9
|
+
str}`` dicts (``cli-pytest`` additionally carries ``"expected"``/``"got"``
|
|
10
|
+
on a failing test — see :func:`parse_pytest_junit_xml`). ``tui-tuidriver``,
|
|
11
|
+
``cli-pytest`` (#1125), and ``web-playwright`` (#1539) are implemented;
|
|
12
|
+
other ``kind`` values are declared in ``coordinator.yml`` (see
|
|
13
|
+
:class:`coord.config.AcceptanceConfig`) but rejected here with a clear "not
|
|
14
|
+
yet implemented" error until their issues land (native).
|
|
15
|
+
|
|
16
|
+
``cli-pytest`` parses pytest's built-in ``--junit-xml`` report (a core
|
|
17
|
+
pytest flag, not a plugin — no extra dependency required in the driven
|
|
18
|
+
repo, unlike ``pytest-json-report``/``pytest-reportlog``) rather than
|
|
19
|
+
stdout, since junit-xml already carries a structured per-test
|
|
20
|
+
pass/fail/skip verdict plus each failure's message.
|
|
21
|
+
|
|
22
|
+
``web-playwright`` parses Playwright Test's built-in ``--reporter=json``
|
|
23
|
+
report rather than its built-in ``--reporter=junit`` one — see
|
|
24
|
+
:func:`parse_playwright_json_report` for why (short version: junit
|
|
25
|
+
collapses retries into one opaque CDATA blob with no per-attempt status,
|
|
26
|
+
which loses the "did this flake" signal this driver exists to capture; json
|
|
27
|
+
keeps a ``results[]`` entry per attempt).
|
|
28
|
+
|
|
29
|
+
:func:`run_driver` also runs a driver's optional ``setup:`` provisioning
|
|
30
|
+
command (#1733, ``AcceptanceDriverConfig.setup``) once before its suite —
|
|
31
|
+
e.g. ``npm ci`` for ``web-playwright``, which otherwise fails with a bare
|
|
32
|
+
``exit 127`` (playwright not found) the first time it runs against ``coord
|
|
33
|
+
acceptance record``'s throwaway, dependency-less worktree.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import subprocess
|
|
42
|
+
import tempfile
|
|
43
|
+
import xml.etree.ElementTree as ET
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
# Driver kinds this module knows how to run. Keep in sync with the adapters
|
|
48
|
+
# implemented below — a kind can be *declared* in coordinator.yml ahead of its
|
|
49
|
+
# adapter landing, but running it must fail loudly rather than silently no-op.
|
|
50
|
+
SUPPORTED_KINDS = ("tui-tuidriver", "cli-pytest", "web-playwright")
|
|
51
|
+
|
|
52
|
+
# libtest's ``--format json`` per-line test-event stream (`cargo test -- -Z
|
|
53
|
+
# unstable-options --format json`) event -> our normalized status.
|
|
54
|
+
_LIBTEST_EVENT_STATUS = {"ok": "pass", "failed": "fail", "ignored": "skip"}
|
|
55
|
+
|
|
56
|
+
# A junit-xml <failure>/<error> "message" attribute for a plain
|
|
57
|
+
# ``assert got == expected`` AssertionError has ``assert <got> ==
|
|
58
|
+
# <expected>`` on its first line (typically prefixed with the exception
|
|
59
|
+
# class, e.g. ``AssertionError: assert 'a' == 'b'``) — this is the common
|
|
60
|
+
# shape a cli-pytest test comparing actual CLI stdout to a `*.out` mock
|
|
61
|
+
# produces. Anything else (multi-line diffs, non-equality asserts, a raised
|
|
62
|
+
# exception with no ``assert``) is left unparsed rather than guessed at.
|
|
63
|
+
_ASSERT_EQ_RE = re.compile(r"assert\s+(.*?)\s+==\s+(.*)$")
|
|
64
|
+
|
|
65
|
+
# Playwright's JSON reporter bakes ANSI color/style codes (SGR sequences)
|
|
66
|
+
# straight into `error.message` regardless of whether stdout is a tty or
|
|
67
|
+
# `NO_COLOR`/`FORCE_COLOR=0` is set (verified empirically against 1.61 — the
|
|
68
|
+
# assertion diff formatter colors unconditionally) — strip them so a stored
|
|
69
|
+
# verdict message doesn't carry raw escape bytes.
|
|
70
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
71
|
+
|
|
72
|
+
# Playwright JSON reporter's per-test `status` (already reconciled against
|
|
73
|
+
# retries and `expectedStatus` — e.g. a `test.fail()`-annotated test that
|
|
74
|
+
# fails as expected is "expected", not "unexpected") -> our normalized
|
|
75
|
+
# status. "flaky" (failed at least once, then passed within `retries`) is a
|
|
76
|
+
# "pass" for gating purposes but callers should still care it happened — see
|
|
77
|
+
# :func:`parse_playwright_json_report`, which folds that into the message.
|
|
78
|
+
_PLAYWRIGHT_STATUS = {
|
|
79
|
+
"expected": "pass",
|
|
80
|
+
"flaky": "pass",
|
|
81
|
+
"unexpected": "fail",
|
|
82
|
+
"skipped": "skip",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DriverError(Exception):
|
|
87
|
+
"""Raised when a driver can't run its suite or the ``kind`` is unknown."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class DriverResult:
|
|
92
|
+
"""The outcome of running one driver invocation."""
|
|
93
|
+
|
|
94
|
+
exit_code: int
|
|
95
|
+
tests: list[dict] = field(default_factory=list)
|
|
96
|
+
raw_output: str = ""
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def ok(self) -> bool:
|
|
100
|
+
"""True when the run command itself exited 0.
|
|
101
|
+
|
|
102
|
+
This is distinct from "all tests passed" — a driver can exit 0 while
|
|
103
|
+
reporting individual test failures (cargo's own exit code already
|
|
104
|
+
reflects failures, but a hand-rolled ``run:`` wrapper might not), so
|
|
105
|
+
callers should judge pass/fail from ``tests`` rather than this alone.
|
|
106
|
+
"""
|
|
107
|
+
return self.exit_code == 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_run_command(run_command: str, *, ms: str | None = None) -> str:
|
|
111
|
+
"""Substitute the ``{ms}`` template in *run_command* with *ms* (the
|
|
112
|
+
``ms-NN`` milestone dirname — see :func:`coord.acceptance.ms_dirname`),
|
|
113
|
+
e.g. ``"pytest tests/acceptance/{ms}"`` -> ``"pytest
|
|
114
|
+
tests/acceptance/ms-37"``.
|
|
115
|
+
|
|
116
|
+
Left unsubstituted when *ms* is ``None`` — callers that aren't scoping to
|
|
117
|
+
a milestone (or a driver's ``run:`` that never references ``{ms}`` at
|
|
118
|
+
all, e.g. today's single-driver ``tui-tuidriver`` configs) pass the
|
|
119
|
+
command through unchanged.
|
|
120
|
+
"""
|
|
121
|
+
if ms is None:
|
|
122
|
+
return run_command
|
|
123
|
+
return run_command.replace("{ms}", ms)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_driver(
|
|
127
|
+
kind: str, run_command: str, cwd: str, *, timeout: int = 900, ms: str | None = None,
|
|
128
|
+
setup_command: str = "",
|
|
129
|
+
) -> DriverResult:
|
|
130
|
+
"""Execute *run_command* in *cwd* and parse its output for *kind*.
|
|
131
|
+
|
|
132
|
+
Raises :class:`DriverError` for an unsupported *kind* or a timeout. A
|
|
133
|
+
non-zero exit from the command is NOT raised — it's folded into the
|
|
134
|
+
returned :class:`DriverResult` so callers can still inspect whatever
|
|
135
|
+
partial JSON the suite printed before dying.
|
|
136
|
+
|
|
137
|
+
*ms*, when given, renders the ``{ms}`` template in *run_command* first
|
|
138
|
+
(see :func:`render_run_command`).
|
|
139
|
+
|
|
140
|
+
*setup_command* (#1733, ``AcceptanceDriverConfig.setup``), when
|
|
141
|
+
non-empty, runs ONCE in *cwd* before *run_command* — the provisioning
|
|
142
|
+
step a driver needs a bare checkout doesn't provide (e.g. ``npm ci`` for
|
|
143
|
+
``web-playwright`` in ``coord acceptance record``'s throwaway,
|
|
144
|
+
dependency-less worktree). Unlike a non-zero *run_command* exit, a
|
|
145
|
+
failing *setup_command* DOES raise :class:`DriverError` — immediately,
|
|
146
|
+
before *run_command* ever executes — with a message that names it as a
|
|
147
|
+
provisioning failure so it isn't mistaken for a test failure or folded
|
|
148
|
+
into a driver's own "wrote no report" crash message.
|
|
149
|
+
"""
|
|
150
|
+
if kind not in SUPPORTED_KINDS:
|
|
151
|
+
raise DriverError(
|
|
152
|
+
f"acceptance driver kind {kind!r} is not implemented yet "
|
|
153
|
+
f"(supported: {', '.join(SUPPORTED_KINDS)}). The native adapter "
|
|
154
|
+
"lands in a later oracle-loop issue — see docs/ORACLE_LOOP.md."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if setup_command:
|
|
158
|
+
_run_setup(setup_command, cwd, timeout=timeout)
|
|
159
|
+
|
|
160
|
+
run_command = render_run_command(run_command, ms=ms)
|
|
161
|
+
|
|
162
|
+
if kind == "cli-pytest":
|
|
163
|
+
return _run_cli_pytest(run_command, cwd, timeout=timeout)
|
|
164
|
+
if kind == "web-playwright":
|
|
165
|
+
return _run_web_playwright(run_command, cwd, timeout=timeout)
|
|
166
|
+
return _run_generic(run_command, cwd, timeout=timeout)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _run_setup(setup_command: str, cwd: str, *, timeout: int) -> None:
|
|
170
|
+
"""Run a driver's ``setup:`` provisioning command (#1733) in *cwd*,
|
|
171
|
+
before its suite ever runs.
|
|
172
|
+
|
|
173
|
+
Raises :class:`DriverError` — distinctly worded as a provisioning
|
|
174
|
+
failure, not a test failure — for a non-zero exit, a timeout, or the
|
|
175
|
+
command failing to start at all. Callers must not proceed to
|
|
176
|
+
``run_command`` when this raises: a driver whose dependencies never
|
|
177
|
+
installed cannot produce a meaningful verdict.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
proc = subprocess.run(
|
|
181
|
+
setup_command,
|
|
182
|
+
shell=True,
|
|
183
|
+
cwd=cwd,
|
|
184
|
+
capture_output=True,
|
|
185
|
+
text=True,
|
|
186
|
+
timeout=timeout,
|
|
187
|
+
)
|
|
188
|
+
except subprocess.TimeoutExpired as e:
|
|
189
|
+
raise DriverError(
|
|
190
|
+
f"acceptance driver provisioning timed out after {timeout}s: "
|
|
191
|
+
f"{setup_command!r}"
|
|
192
|
+
) from e
|
|
193
|
+
except OSError as e:
|
|
194
|
+
raise DriverError(
|
|
195
|
+
f"acceptance driver provisioning failed to start: {setup_command!r}: {e}"
|
|
196
|
+
) from e
|
|
197
|
+
|
|
198
|
+
if proc.returncode != 0:
|
|
199
|
+
stderr_tail = "\n".join((proc.stderr or "").splitlines()[-20:])
|
|
200
|
+
raise DriverError(
|
|
201
|
+
f"acceptance driver provisioning failed (exit {proc.returncode}): "
|
|
202
|
+
f"{setup_command!r}\n{stderr_tail}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _run_generic(run_command: str, cwd: str, *, timeout: int) -> DriverResult:
|
|
207
|
+
"""The ``tui-tuidriver`` (and any future stdout-native) shape: the
|
|
208
|
+
command itself is responsible for printing structured verdicts to
|
|
209
|
+
stdout — this just runs it and hands the raw stdout to
|
|
210
|
+
:func:`parse_test_output`."""
|
|
211
|
+
try:
|
|
212
|
+
proc = subprocess.run(
|
|
213
|
+
run_command,
|
|
214
|
+
shell=True,
|
|
215
|
+
cwd=cwd,
|
|
216
|
+
capture_output=True,
|
|
217
|
+
text=True,
|
|
218
|
+
timeout=timeout,
|
|
219
|
+
)
|
|
220
|
+
except subprocess.TimeoutExpired as e:
|
|
221
|
+
raise DriverError(
|
|
222
|
+
f"acceptance run command timed out after {timeout}s: {run_command!r}"
|
|
223
|
+
) from e
|
|
224
|
+
except OSError as e:
|
|
225
|
+
raise DriverError(f"acceptance run command failed to start: {e}") from e
|
|
226
|
+
|
|
227
|
+
tests = parse_test_output(proc.stdout)
|
|
228
|
+
return DriverResult(
|
|
229
|
+
exit_code=proc.returncode,
|
|
230
|
+
tests=tests,
|
|
231
|
+
raw_output=(proc.stdout or "") + (proc.stderr or ""),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _run_cli_pytest(run_command: str, cwd: str, *, timeout: int) -> DriverResult:
|
|
236
|
+
"""The ``cli-pytest`` shape: append pytest's own built-in
|
|
237
|
+
``--junit-xml=<path>`` (a core pytest flag — no extra plugin required in
|
|
238
|
+
the driven repo) so structured per-test verdicts are always produced
|
|
239
|
+
regardless of what *run_command* itself prints, then parse that XML
|
|
240
|
+
report with :func:`parse_pytest_junit_xml`.
|
|
241
|
+
"""
|
|
242
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
243
|
+
report_path = Path(tmp_dir) / "coord-acceptance-junit.xml"
|
|
244
|
+
full_command = f"{run_command} --junit-xml={report_path}"
|
|
245
|
+
try:
|
|
246
|
+
proc = subprocess.run(
|
|
247
|
+
full_command,
|
|
248
|
+
shell=True,
|
|
249
|
+
cwd=cwd,
|
|
250
|
+
capture_output=True,
|
|
251
|
+
text=True,
|
|
252
|
+
timeout=timeout,
|
|
253
|
+
)
|
|
254
|
+
except subprocess.TimeoutExpired as e:
|
|
255
|
+
raise DriverError(
|
|
256
|
+
f"acceptance run command timed out after {timeout}s: {full_command!r}"
|
|
257
|
+
) from e
|
|
258
|
+
except OSError as e:
|
|
259
|
+
raise DriverError(f"acceptance run command failed to start: {e}") from e
|
|
260
|
+
|
|
261
|
+
report_text = report_path.read_text() if report_path.exists() else ""
|
|
262
|
+
tests = parse_pytest_junit_xml(report_text)
|
|
263
|
+
return DriverResult(
|
|
264
|
+
exit_code=proc.returncode,
|
|
265
|
+
tests=tests,
|
|
266
|
+
raw_output=(proc.stdout or "") + (proc.stderr or ""),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _run_web_playwright(run_command: str, cwd: str, *, timeout: int) -> DriverResult:
|
|
271
|
+
"""The ``web-playwright`` shape: force Playwright Test's built-in
|
|
272
|
+
``json`` reporter to a known path via ``--reporter=json`` plus the
|
|
273
|
+
``PLAYWRIGHT_JSON_OUTPUT_FILE`` env var it honors (the json-reporter
|
|
274
|
+
twin of the documented ``PLAYWRIGHT_JUNIT_OUTPUT_NAME``) — so a
|
|
275
|
+
structured report is always produced at a path we control regardless of
|
|
276
|
+
what reporters the driven repo's own ``playwright.config.ts`` declares,
|
|
277
|
+
the same trick :func:`_run_cli_pytest` plays with ``--junit-xml``.
|
|
278
|
+
|
|
279
|
+
Unlike ``_run_cli_pytest`` (which treats "no report file" as a benign
|
|
280
|
+
zero-tests result — see its own crash test), a missing or corrupt
|
|
281
|
+
report here always raises :class:`DriverError`. Per #1539: "a crashed
|
|
282
|
+
run ... must surface as a DriverError or an explicit failure — never as
|
|
283
|
+
an empty pass list." Playwright can die before the json reporter ever
|
|
284
|
+
flushes (a bad config file, a browser that never launches, `--grep`
|
|
285
|
+
matching nothing without `--pass-with-no-tests`) and an empty list from
|
|
286
|
+
that is indistinguishable from "the suite legitimately has zero tests
|
|
287
|
+
right now" — exactly the silent-green failure mode this driver must not
|
|
288
|
+
produce.
|
|
289
|
+
"""
|
|
290
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
291
|
+
report_path = Path(tmp_dir) / "coord-acceptance-playwright.json"
|
|
292
|
+
full_command = f"{run_command} --reporter=json"
|
|
293
|
+
env = {**os.environ, "PLAYWRIGHT_JSON_OUTPUT_FILE": str(report_path)}
|
|
294
|
+
try:
|
|
295
|
+
proc = subprocess.run(
|
|
296
|
+
full_command,
|
|
297
|
+
shell=True,
|
|
298
|
+
cwd=cwd,
|
|
299
|
+
capture_output=True,
|
|
300
|
+
text=True,
|
|
301
|
+
timeout=timeout,
|
|
302
|
+
env=env,
|
|
303
|
+
)
|
|
304
|
+
except subprocess.TimeoutExpired as e:
|
|
305
|
+
raise DriverError(
|
|
306
|
+
f"acceptance run command timed out after {timeout}s: {full_command!r}"
|
|
307
|
+
) from e
|
|
308
|
+
except OSError as e:
|
|
309
|
+
raise DriverError(f"acceptance run command failed to start: {e}") from e
|
|
310
|
+
|
|
311
|
+
if not report_path.exists():
|
|
312
|
+
stderr_tail = "\n".join((proc.stderr or "").splitlines()[-20:])
|
|
313
|
+
raise DriverError(
|
|
314
|
+
f"web-playwright run wrote no report (exit {proc.returncode}): "
|
|
315
|
+
f"{full_command!r}\n{stderr_tail}"
|
|
316
|
+
)
|
|
317
|
+
tests = parse_playwright_json_report(report_path.read_text())
|
|
318
|
+
return DriverResult(
|
|
319
|
+
exit_code=proc.returncode,
|
|
320
|
+
tests=tests,
|
|
321
|
+
raw_output=(proc.stdout or "") + (proc.stderr or ""),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def parse_test_output(output: str) -> list[dict]:
|
|
326
|
+
"""Parse a driver's stdout into normalized ``{"id", "status", "message"}``.
|
|
327
|
+
|
|
328
|
+
Two shapes are recognized:
|
|
329
|
+
|
|
330
|
+
1. A single JSON blob whose whole stdout is one object of the form
|
|
331
|
+
``{"tests": [{"id": ..., "status": "pass"|"fail"|"skip", "message":
|
|
332
|
+
...}, ...]}`` — the direct contract for a driver that already speaks
|
|
333
|
+
it natively.
|
|
334
|
+
2. libtest's JSON-lines test-event stream (``cargo test -- -Z
|
|
335
|
+
unstable-options --format json``): one JSON object per line, only
|
|
336
|
+
``{"type": "test", "event": "ok"|"failed"|"ignored", "name": ...}``
|
|
337
|
+
lines carry a verdict. Non-JSON lines (cargo build progress,
|
|
338
|
+
warnings) and ``"type": "suite"``/``"type": "bench"`` lines are
|
|
339
|
+
skipped.
|
|
340
|
+
|
|
341
|
+
Unparsable input returns an empty list rather than raising — a failed
|
|
342
|
+
parse is surfaced by the caller as "0 tests found", not a crash.
|
|
343
|
+
"""
|
|
344
|
+
stripped = (output or "").strip()
|
|
345
|
+
if stripped.startswith("{"):
|
|
346
|
+
blob = _try_json(stripped)
|
|
347
|
+
if isinstance(blob, dict) and isinstance(blob.get("tests"), list):
|
|
348
|
+
tests: list[dict] = []
|
|
349
|
+
for t in blob["tests"]:
|
|
350
|
+
if not isinstance(t, dict) or "id" not in t or "status" not in t:
|
|
351
|
+
continue
|
|
352
|
+
tests.append({
|
|
353
|
+
"id": str(t["id"]),
|
|
354
|
+
"status": str(t["status"]),
|
|
355
|
+
"message": str(t.get("message", "")),
|
|
356
|
+
})
|
|
357
|
+
return tests
|
|
358
|
+
|
|
359
|
+
tests = []
|
|
360
|
+
for line in (output or "").splitlines():
|
|
361
|
+
line = line.strip()
|
|
362
|
+
if not line or not line.startswith("{"):
|
|
363
|
+
continue
|
|
364
|
+
obj = _try_json(line)
|
|
365
|
+
if not isinstance(obj, dict) or obj.get("type") != "test":
|
|
366
|
+
continue
|
|
367
|
+
event = obj.get("event")
|
|
368
|
+
name = obj.get("name")
|
|
369
|
+
if not name or event not in _LIBTEST_EVENT_STATUS:
|
|
370
|
+
continue
|
|
371
|
+
entry = {"id": str(name), "status": _LIBTEST_EVENT_STATUS[event], "message": ""}
|
|
372
|
+
stdout_msg = obj.get("stdout")
|
|
373
|
+
if stdout_msg:
|
|
374
|
+
entry["message"] = str(stdout_msg)
|
|
375
|
+
tests.append(entry)
|
|
376
|
+
return tests
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def parse_pytest_junit_xml(xml_text: str) -> list[dict]:
|
|
380
|
+
"""Parse pytest's built-in ``--junit-xml=<path>`` report (a core pytest
|
|
381
|
+
flag — no extra plugin required in the driven repo, unlike
|
|
382
|
+
``pytest-json-report``/``pytest-reportlog``) into normalized ``{"id",
|
|
383
|
+
"status", "message", "expected", "got"}`` dicts — the same ``id``/
|
|
384
|
+
``status`` shape :func:`parse_test_output` returns for
|
|
385
|
+
``tui-tuidriver``, so :func:`coord.acceptance.build_verdict` /
|
|
386
|
+
``_scoped_verdict`` / :func:`coord.acceptance.load_manifest` work
|
|
387
|
+
unchanged regardless of which driver kind produced the verdicts.
|
|
388
|
+
|
|
389
|
+
Each ``<testcase classname="..." name="...">`` becomes one entry with
|
|
390
|
+
``id = "{classname}::{name}"``. A ``<failure>`` or ``<error>`` child
|
|
391
|
+
means ``"fail"``; a ``<skipped>`` child means ``"skip"``; otherwise
|
|
392
|
+
``"pass"``. ``"expected"``/``"got"`` are populated only for a failing
|
|
393
|
+
test, and only when the failure's ``message`` attribute's first line is
|
|
394
|
+
pytest's own plain ``assert <got> == <expected>`` rendering (the shape a
|
|
395
|
+
cli-pytest test comparing actual CLI stdout to a ``*.out`` mock
|
|
396
|
+
produces) — anything else (a raised exception, a multi-line diff with no
|
|
397
|
+
single ``==``) leaves them empty rather than guessing.
|
|
398
|
+
|
|
399
|
+
Unparsable / empty input returns an empty list rather than raising —
|
|
400
|
+
mirrors :func:`parse_test_output`'s "0 tests found, not a crash"
|
|
401
|
+
contract.
|
|
402
|
+
"""
|
|
403
|
+
text = (xml_text or "").strip()
|
|
404
|
+
if not text:
|
|
405
|
+
return []
|
|
406
|
+
try:
|
|
407
|
+
root = ET.fromstring(text)
|
|
408
|
+
except ET.ParseError:
|
|
409
|
+
return []
|
|
410
|
+
|
|
411
|
+
tests = []
|
|
412
|
+
for testcase in root.iter("testcase"):
|
|
413
|
+
classname = testcase.get("classname", "")
|
|
414
|
+
name = testcase.get("name", "")
|
|
415
|
+
if not name:
|
|
416
|
+
continue
|
|
417
|
+
nodeid = f"{classname}::{name}" if classname else name
|
|
418
|
+
|
|
419
|
+
failure = testcase.find("failure")
|
|
420
|
+
if failure is None:
|
|
421
|
+
failure = testcase.find("error")
|
|
422
|
+
skipped = testcase.find("skipped")
|
|
423
|
+
|
|
424
|
+
entry = {
|
|
425
|
+
"id": nodeid, "status": "pass", "message": "",
|
|
426
|
+
"expected": "", "got": "",
|
|
427
|
+
}
|
|
428
|
+
if failure is not None:
|
|
429
|
+
entry["status"] = "fail"
|
|
430
|
+
message = failure.get("message", "") or (failure.text or "")
|
|
431
|
+
entry["message"] = message
|
|
432
|
+
first_line = message.splitlines()[0] if message else ""
|
|
433
|
+
m = _ASSERT_EQ_RE.search(first_line)
|
|
434
|
+
if m:
|
|
435
|
+
entry["got"] = m.group(1).strip()
|
|
436
|
+
entry["expected"] = m.group(2).strip()
|
|
437
|
+
elif skipped is not None:
|
|
438
|
+
entry["status"] = "skip"
|
|
439
|
+
entry["message"] = skipped.get("message", "") or (skipped.text or "")
|
|
440
|
+
tests.append(entry)
|
|
441
|
+
return tests
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def parse_playwright_json_report(json_text: str) -> list[dict]:
|
|
445
|
+
"""Parse Playwright Test's built-in ``--reporter=json`` report (a core
|
|
446
|
+
reporter, not a plugin — no extra npm dependency required in the driven
|
|
447
|
+
repo) into normalized ``{"id", "status", "message"}`` dicts.
|
|
448
|
+
|
|
449
|
+
**Why json, not junit** (Playwright ships both as built-ins): junit
|
|
450
|
+
collapses every retry attempt of one test into a single ``<testcase>``
|
|
451
|
+
with no per-attempt status — a test that failed once then passed on
|
|
452
|
+
retry renders as a plain, silent pass, identical to a test that never
|
|
453
|
+
failed at all. That's a real gap verified against Playwright 1.61
|
|
454
|
+
output, not a hypothetical: see the recorded
|
|
455
|
+
``tests/fixtures/playwright/retry_then_pass.json`` fixture and its junit
|
|
456
|
+
sibling this module's tests compare it against. Since #1539 explicitly
|
|
457
|
+
calls out retries as signal ("flake is signal for this program"), junit
|
|
458
|
+
can't carry the contract this driver needs. json's per-test ``results``
|
|
459
|
+
array keeps one entry per attempt (with its own ``status`` and
|
|
460
|
+
``errors``), and its per-test ``projectName`` disambiguates the same
|
|
461
|
+
test title run under multiple ``projects:`` entries — junit only
|
|
462
|
+
distinguishes projects via a ``<testsuite hostname="...">`` attribute
|
|
463
|
+
shared by every testcase in that project, not the id.
|
|
464
|
+
|
|
465
|
+
**id shape**: ``"[{project}] {file} › {describe path} › {test title}"``
|
|
466
|
+
— stable across reruns (no timestamps/durations), unique across
|
|
467
|
+
multiple ``projects:`` in one config (see above), and matches the
|
|
468
|
+
``[project] › file:line › describe › title`` shape Playwright's own
|
|
469
|
+
``list`` reporter prints, so it's recognizable when cross-referencing a
|
|
470
|
+
human's terminal output.
|
|
471
|
+
|
|
472
|
+
**status**: taken from Playwright's own reconciled per-test ``status``
|
|
473
|
+
(``"expected"``/``"flaky"``/``"unexpected"``/``"skipped"``) rather than
|
|
474
|
+
re-deriving it from the raw per-attempt results — that field already
|
|
475
|
+
accounts for retries *and* ``test.fail()``-style "expected to fail"
|
|
476
|
+
annotations, so re-implementing it here would just be a worse copy.
|
|
477
|
+
``"expected"``/``"flaky"`` -> ``"pass"`` (flaky is still a pass for
|
|
478
|
+
gating, but the message says so — see below), ``"unexpected"`` ->
|
|
479
|
+
``"fail"``, ``"skipped"`` (covers both ``test.skip()`` and
|
|
480
|
+
``test.fixme()``) -> ``"skip"``.
|
|
481
|
+
|
|
482
|
+
**message**: the last attempt's error text for a ``"fail"``; for a
|
|
483
|
+
``"flaky"`` pass, a summary noting the flake plus the first failed
|
|
484
|
+
attempt's error (the signal #1539 asks this driver to preserve); the
|
|
485
|
+
annotation ``description`` (the ``fixme`` reason, when given) for a
|
|
486
|
+
``"skip"``; empty for a clean pass. Error text has Playwright's
|
|
487
|
+
baked-in ANSI color codes stripped (see :data:`_ANSI_RE` — verified
|
|
488
|
+
these survive even with ``NO_COLOR``/piped-non-tty stdout, so stripping
|
|
489
|
+
is mandatory, not a courtesy).
|
|
490
|
+
|
|
491
|
+
**Never silently empty on a crash** — unlike this module's other parse_*
|
|
492
|
+
functions, which return ``[]`` on unparsable input to keep "0 tests
|
|
493
|
+
found" from ever raising. #1539 requires the opposite here: "a crashed
|
|
494
|
+
run ... must surface as a DriverError ... never as an empty pass list."
|
|
495
|
+
So this raises :class:`DriverError` for: empty/whitespace-only input (a
|
|
496
|
+
truncated-to-nothing or never-written report); invalid JSON (a report
|
|
497
|
+
cut off mid-write, e.g. the process was killed before flushing —
|
|
498
|
+
``tests/fixtures/playwright/truncated.json`` is a real report truncated
|
|
499
|
+
this way); a JSON body missing the ``"suites"`` list (wrong shape
|
|
500
|
+
entirely); and zero tests parsed *while Playwright's own top-level
|
|
501
|
+
``"errors"`` is non-empty* — verified empirically to be exactly how a
|
|
502
|
+
thrown ``globalSetup`` hook or a ``--grep`` matching nothing report
|
|
503
|
+
(``tests/fixtures/playwright/global_setup_crash.json``): a
|
|
504
|
+
well-formed, zero-test report that must not be mistaken for "the suite
|
|
505
|
+
is just empty right now". Zero tests with an empty top-level
|
|
506
|
+
``"errors"`` (Playwright's own ``--pass-with-no-tests`` opt-in) is left
|
|
507
|
+
as a plain ``[]`` — callers (:func:`coord.acceptance.build_verdict`)
|
|
508
|
+
already treat a zero-test list as not-green rather than a false "all
|
|
509
|
+
green", which is the actual guarantee #1539 is protecting.
|
|
510
|
+
"""
|
|
511
|
+
text = (json_text or "").strip()
|
|
512
|
+
if not text:
|
|
513
|
+
raise DriverError(
|
|
514
|
+
"web-playwright report is empty — the run crashed before "
|
|
515
|
+
"writing a report"
|
|
516
|
+
)
|
|
517
|
+
try:
|
|
518
|
+
report = json.loads(text)
|
|
519
|
+
except json.JSONDecodeError as e:
|
|
520
|
+
raise DriverError(
|
|
521
|
+
f"web-playwright report is not valid JSON (truncated or "
|
|
522
|
+
f"corrupted run?): {e}"
|
|
523
|
+
) from e
|
|
524
|
+
if not isinstance(report, dict) or not isinstance(report.get("suites"), list):
|
|
525
|
+
raise DriverError(
|
|
526
|
+
"web-playwright report has an unrecognized shape (missing a "
|
|
527
|
+
"'suites' list) — this reporter version may be incompatible"
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
tests: list[dict] = []
|
|
531
|
+
for suite in report["suites"]:
|
|
532
|
+
if isinstance(suite, dict):
|
|
533
|
+
tests.extend(_playwright_specs(suite, []))
|
|
534
|
+
|
|
535
|
+
if not tests and report.get("errors"):
|
|
536
|
+
first = report["errors"][0] if isinstance(report["errors"], list) else report["errors"]
|
|
537
|
+
detail = first.get("message", "") if isinstance(first, dict) else str(first)
|
|
538
|
+
raise DriverError(
|
|
539
|
+
f"web-playwright run produced zero tests and reported a "
|
|
540
|
+
f"top-level error (bad config, browser launch failure, or a "
|
|
541
|
+
f"run: command matching no tests): {_strip_ansi(detail)}"
|
|
542
|
+
)
|
|
543
|
+
return tests
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _playwright_specs(suite: dict, ancestors: list[str]) -> list[dict]:
|
|
547
|
+
"""Recursively walk one Playwright json-report suite tree, returning one
|
|
548
|
+
normalized dict per ``(spec, project)`` pair.
|
|
549
|
+
|
|
550
|
+
A suite nests: the outermost suite per spec file (``title == file``,
|
|
551
|
+
skipped from the id's describe-path since it's redundant with the
|
|
552
|
+
``file`` already in the id), then one nested suite per ``describe()``
|
|
553
|
+
block, down to leaf ``specs`` (one per ``test()``/``it()``).
|
|
554
|
+
"""
|
|
555
|
+
title = suite.get("title", "")
|
|
556
|
+
is_file_suite = bool(suite.get("file")) and title == suite.get("file")
|
|
557
|
+
path = ancestors if is_file_suite else [*ancestors, title]
|
|
558
|
+
|
|
559
|
+
tests: list[dict] = []
|
|
560
|
+
for spec in suite.get("specs") or []:
|
|
561
|
+
if isinstance(spec, dict):
|
|
562
|
+
tests.extend(_playwright_spec_entries(spec, path))
|
|
563
|
+
for sub in suite.get("suites") or []:
|
|
564
|
+
if isinstance(sub, dict):
|
|
565
|
+
tests.extend(_playwright_specs(sub, path))
|
|
566
|
+
return tests
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _playwright_spec_entries(spec: dict, ancestors: list[str]) -> list[dict]:
|
|
570
|
+
"""One normalized entry per project a leaf ``spec`` (a single
|
|
571
|
+
``test()``) ran under."""
|
|
572
|
+
spec_title = spec.get("title", "")
|
|
573
|
+
title_path = " › ".join([*ancestors, spec_title]) if spec_title else " › ".join(ancestors)
|
|
574
|
+
file = spec.get("file", "")
|
|
575
|
+
|
|
576
|
+
entries = []
|
|
577
|
+
for t in spec.get("tests") or []:
|
|
578
|
+
if not isinstance(t, dict):
|
|
579
|
+
continue
|
|
580
|
+
project = t.get("projectName", "")
|
|
581
|
+
nodeid = f"[{project}] {file} › {title_path}" if project else f"{file} › {title_path}"
|
|
582
|
+
raw_status = t.get("status")
|
|
583
|
+
status = _PLAYWRIGHT_STATUS.get(raw_status, "fail")
|
|
584
|
+
results = t.get("results") or []
|
|
585
|
+
|
|
586
|
+
message = ""
|
|
587
|
+
if status == "fail":
|
|
588
|
+
message = _playwright_error_text(results[-1]) if results else ""
|
|
589
|
+
elif raw_status == "flaky":
|
|
590
|
+
failed = [r for r in results if r.get("status") not in ("passed", "skipped")]
|
|
591
|
+
first_failure = _playwright_error_text(failed[0]) if failed else ""
|
|
592
|
+
message = f"flaky: passed after {len(failed)} failed attempt(s)"
|
|
593
|
+
if first_failure:
|
|
594
|
+
message += f" — first failure: {first_failure}"
|
|
595
|
+
elif status == "skip":
|
|
596
|
+
message = _playwright_skip_reason(t.get("annotations") or [])
|
|
597
|
+
|
|
598
|
+
entries.append({"id": nodeid, "status": status, "message": message})
|
|
599
|
+
return entries
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _playwright_error_text(result: dict) -> str:
|
|
603
|
+
"""The (ANSI-stripped) error message(s) of one ``results[]`` attempt."""
|
|
604
|
+
if not isinstance(result, dict):
|
|
605
|
+
return ""
|
|
606
|
+
errors = result.get("errors") or []
|
|
607
|
+
parts = [str(e.get("message", "")) for e in errors if isinstance(e, dict) and e.get("message")]
|
|
608
|
+
return _strip_ansi("\n".join(parts))
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _playwright_skip_reason(annotations: list) -> str:
|
|
612
|
+
"""The ``fixme``/``skip`` annotation's ``description`` (the reason
|
|
613
|
+
string a caller passed, e.g. ``test.fixme(true, "blocked on #1541")``),
|
|
614
|
+
or ``""`` when a bare ``test.skip()``/``.skip(true)`` gave no reason —
|
|
615
|
+
mirrors :func:`parse_pytest_junit_xml`'s "empty message when no reason
|
|
616
|
+
given" convention.
|
|
617
|
+
"""
|
|
618
|
+
for a in annotations:
|
|
619
|
+
if isinstance(a, dict) and a.get("type") in ("skip", "fixme") and a.get("description"):
|
|
620
|
+
return str(a["description"])
|
|
621
|
+
return ""
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _strip_ansi(text: str) -> str:
|
|
625
|
+
return _ANSI_RE.sub("", text or "")
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _try_json(text: str):
|
|
629
|
+
try:
|
|
630
|
+
return json.loads(text)
|
|
631
|
+
except (json.JSONDecodeError, TypeError):
|
|
632
|
+
return None
|