@databricks-solutions/lakebase-scm-utils 0.3.3 → 0.3.5

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 (63) hide show
  1. package/dist/scripts/github/index.cjs +14 -0
  2. package/dist/scripts/github/index.d.cts +19 -1
  3. package/dist/scripts/github/index.d.ts +19 -1
  4. package/dist/scripts/github/index.js +13 -0
  5. package/dist/scripts/index.cjs +79 -2
  6. package/dist/scripts/index.d.cts +2 -2
  7. package/dist/scripts/index.d.ts +2 -2
  8. package/dist/scripts/index.js +76 -2
  9. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
  10. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
  11. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
  12. package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
  13. package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
  14. package/dist/scripts/lakebase/detect-language.cli.js +2 -2
  15. package/dist/scripts/lakebase/doctor.cli.cjs +15664 -7598
  16. package/dist/scripts/lakebase/doctor.cli.js +8243 -106
  17. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  18. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  19. package/dist/scripts/lakebase/index.cjs +172 -95
  20. package/dist/scripts/lakebase/index.d.cts +22 -1
  21. package/dist/scripts/lakebase/index.d.ts +22 -1
  22. package/dist/scripts/lakebase/index.js +170 -95
  23. package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
  24. package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
  25. package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
  26. package/dist/scripts/lakebase/new-migration.cli.js +2 -2
  27. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  28. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  29. package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
  30. package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
  31. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
  32. package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
  33. package/dist/scripts/lakebase/scm-doctor.cli.cjs +2 -2
  34. package/dist/scripts/lakebase/scm-doctor.cli.js +2 -2
  35. package/dist/scripts/lakebase/scm-merge.cli.cjs +2 -2
  36. package/dist/scripts/lakebase/scm-merge.cli.js +2 -2
  37. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
  38. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
  39. package/dist/scripts/util/index.cjs +2 -2
  40. package/dist/scripts/util/index.js +2 -2
  41. package/package.json +1 -1
  42. package/scripts/github/repo.ts +37 -1
  43. package/scripts/lakebase/create-preflight.ts +45 -0
  44. package/scripts/lakebase/create-project.ts +15 -0
  45. package/scripts/lakebase/doctor.ts +50 -0
  46. package/templates/project/client/consort-vitest-reporter.ts +96 -0
  47. package/templates/project/client/package.json +1 -0
  48. package/templates/project/client/playwright.config.ts +6 -1
  49. package/templates/project/client/tests/e2e/about.spec.ts +1 -1
  50. package/templates/project/client/tests/e2e/consort-fixtures.ts +23 -0
  51. package/templates/project/client/tests/e2e/home.spec.ts +1 -1
  52. package/templates/project/client/tests/setup.ts +79 -4
  53. package/templates/project/client/tsconfig.json +1 -1
  54. package/templates/project/client/vite.config.ts +8 -0
  55. package/templates/project/client/vitest.globalSetup.ts +121 -0
  56. package/templates/project/common/.github/workflows/pr.yml +14 -5
  57. package/templates/project/common/consort-capture.ts +87 -0
  58. package/templates/project/common/consort-test-reporter.ts +45 -0
  59. package/templates/project/common/playwright.config.ts +6 -1
  60. package/templates/project/python/app/consort_capture.py +130 -0
  61. package/templates/project/python/app/main.py +52 -0
  62. package/templates/project/python/pyproject.toml +1 -0
  63. package/templates/project/python/tests/conftest.py +132 -3
@@ -0,0 +1,45 @@
1
+ // Consort per-test "green as it runs" reporter for Playwright.
2
+ //
3
+ // Appends one line to `.consort/test-run.jsonl` as each test tagged `@T<id>` in its title finishes,
4
+ // so the dashboard tails it and lights that test live — even under story cadence (one whole-suite
5
+ // run), where otherwise every test would appear to flip at once. The `file` disambiguates a reused
6
+ // test id across features (F1 and F2 may both carry a T37); the dashboard keys by (scenario_file + id).
7
+ //
8
+ // The shared, framework-agnostic capture writers (emitTestRun / emitTableSnapshot /
9
+ // captureTablesFromRoute / findConsortDir) live in `./consort-capture` — this file is the ONLY one
10
+ // that imports `@playwright/test/reporter`, so the Vitest/client compile never pulls in Playwright.
11
+ // Re-exported here for back-compat with importers that still reach through this module.
12
+ //
13
+ // Pure observability: everything is wrapped so a reporter write can NEVER fail the suite. Tag a test
14
+ // by putting its test-list id in the title, e.g. `test("home table lists stock @T37", …)`.
15
+ import type { FullConfig, Reporter, TestCase, TestResult } from "@playwright/test/reporter";
16
+ import { emitTestRun, captureTablesFromRoute } from "./consort-capture";
17
+
18
+ export { findConsortDir, emitTestRun, emitTableSnapshot, captureTablesFromRoute, type TestRunStatus } from "./consort-capture";
19
+
20
+ const TEST_ID = /@(T\d+(?:-[a-z0-9-]+)?)/;
21
+
22
+ export default class ConsortTestReporter implements Reporter {
23
+ private baseURL = "";
24
+
25
+ // The app's read-only snapshot route lives at the app origin; E2E specs hit the DB THROUGH the
26
+ // app, so after each spec we ask the app what rows it left (the client never touches the DB).
27
+ onBegin(config: FullConfig): void {
28
+ this.baseURL = config.projects?.[0]?.use?.baseURL ?? "";
29
+ }
30
+
31
+ // Emit `running` the moment a tagged spec starts, so the dashboard shows it in-flight (amber)
32
+ // before it resolves — the dashboard's last-write-wins reduce makes running -> green compose.
33
+ onTestBegin(test: TestCase): void {
34
+ const id = TEST_ID.exec(test.title)?.[1];
35
+ if (id) emitTestRun(id, "running", test.location?.file ?? "", "client");
36
+ }
37
+
38
+ async onTestEnd(test: TestCase, result: TestResult): Promise<void> {
39
+ const id = TEST_ID.exec(test.title)?.[1];
40
+ if (!id) return;
41
+ const status = result.status === "passed" ? "green" : result.status === "skipped" ? "skipped" : "red";
42
+ emitTestRun(id, status, test.location?.file ?? "", "client");
43
+ await captureTablesFromRoute(this.baseURL, id, test.location?.file ?? "");
44
+ }
45
+ }
@@ -37,7 +37,12 @@ export default defineConfig({
37
37
  // network jitter against a freshly-provisioned paired branch endpoint.
38
38
  retries: process.env.CI ? 1 : 0,
39
39
  workers: process.env.CI ? 2 : undefined,
40
- reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : "list",
40
+ // `./consort-test-reporter.ts` appends each `@T<id>`-tagged test to `.consort/test-run.jsonl` as
41
+ // it finishes, so the Consort dashboard lights per-test progress live (see that file). It rides
42
+ // alongside the human reporters and never replaces them.
43
+ reporter: process.env.CI
44
+ ? [["html", { open: "never" }], ["list"], ["./consort-test-reporter.ts"]]
45
+ : [["list"], ["./consort-test-reporter.ts"]],
41
46
  use: {
42
47
  baseURL,
43
48
  trace: "on-first-retry",
@@ -0,0 +1,130 @@
1
+ """Per-test RETRIEVED/STORED row capture (dev/test/CI only) for the Consort dashboard.
2
+
3
+ The dashboard's per-test table view wants the rows each TEST actually touched — not the whole table.
4
+ Every test (pytest / Playwright / Vitest component) exercises the DB THROUGH this app over `/api`, so
5
+ this app is the one place that sees every query. Each test tags its requests with an
6
+ `X-Consort-Test-Id: T<id>` header; a middleware stashes that id in a contextvar for the request, and
7
+ two SQLAlchemy events record the rows that request touched, keyed by the test id:
8
+
9
+ * RETRIEVED — the ORM `load` event fires as each row is materialized into a mapped instance
10
+ (covers every `select(Model)` read path).
11
+ * STORED — the `after_cursor_execute` event fires for INSERT/UPDATE/DELETE; the written values
12
+ live in the statement's bound parameters (the app writes via `insert(...).returning()`
13
+ executed as a statement, which `after_flush`/unit-of-work would NOT see).
14
+
15
+ Each touched row is appended to `.consort/test-interactions.jsonl` as
16
+ `{ts, test_id, table, op: "read"|"write", row}`. Guarded by the PRESENCE of a `.consort/` dir (dev/
17
+ test/CI checkout, not a packaged prod app) exactly like the snapshot route — so this is simply absent
18
+ in a real deploy. Everything is wrapped so a capture write can NEVER fail a request.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextvars
24
+ import json
25
+ import os
26
+ import re
27
+ import threading
28
+ from datetime import datetime, timezone
29
+ from typing import Any
30
+
31
+ from sqlalchemy import event
32
+ from sqlalchemy.engine import Engine
33
+ from sqlalchemy.orm import DeclarativeBase
34
+
35
+ # The current request's test id (from the X-Consort-Test-Id header), and the per-request buffer of
36
+ # touched rows. ContextVars propagate into Starlette's sync-endpoint threadpool, so the SQLAlchemy
37
+ # events (which fire on the DB call within the handler) see the id the middleware set.
38
+ _TEST_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar("consort_test_id", default=None)
39
+ _BUFFER: contextvars.ContextVar[list[dict[str, Any]] | None] = contextvars.ContextVar("consort_buffer", default=None)
40
+
41
+ _WRITE_LOCK = threading.Lock()
42
+ _ROWS_CAP = 200 # per (test, table, op) within one request — keep the log bounded
43
+ _DML = re.compile(r"^\s*(INSERT|UPDATE|DELETE)\b", re.IGNORECASE)
44
+ _TABLE = re.compile(r'(?:INTO|UPDATE|FROM)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?', re.IGNORECASE)
45
+
46
+
47
+ def _jsonable(v: Any) -> Any:
48
+ if isinstance(v, datetime):
49
+ return v.isoformat()
50
+ try:
51
+ json.dumps(v)
52
+ return v
53
+ except (TypeError, ValueError):
54
+ return str(v)
55
+
56
+
57
+ def _record(table: str, op: str, row: dict[str, Any]) -> None:
58
+ """Append a touched row to the current request's buffer (no-op outside a tagged request)."""
59
+ buf = _BUFFER.get()
60
+ if buf is None:
61
+ return
62
+ # Bound the buffer per (table, op) so a big list read can't explode the log.
63
+ n = sum(1 for e in buf if e["table"] == table and e["op"] == op)
64
+ if n >= _ROWS_CAP:
65
+ return
66
+ buf.append({"table": table, "op": op, "row": {k: _jsonable(v) for k, v in row.items()}})
67
+
68
+
69
+ def _flush(path: str, test_id: str, buf: list[dict[str, Any]]) -> None:
70
+ if not buf:
71
+ return
72
+ ts = datetime.now(timezone.utc).isoformat()
73
+ lines = "".join(
74
+ json.dumps({"ts": ts, "test_id": test_id, "table": e["table"], "op": e["op"], "row": e["row"]}) + "\n"
75
+ for e in buf
76
+ )
77
+ try:
78
+ with _WRITE_LOCK:
79
+ with open(path, "a", encoding="utf-8") as fh:
80
+ fh.write(lines)
81
+ except OSError:
82
+ pass # best-effort: a capture write must never fail a request
83
+
84
+
85
+ def install(app: Any, engine: Engine, base: type[DeclarativeBase]) -> None:
86
+ """Wire the middleware + SQLAlchemy events. No-op unless a `.consort/` dir is present."""
87
+ project_root = os.path.join(os.path.dirname(__file__), os.pardir)
88
+ consort_dir = os.path.join(project_root, ".consort")
89
+ if not os.path.isdir(consort_dir):
90
+ return
91
+ interactions_path = os.path.join(consort_dir, "test-interactions.jsonl")
92
+
93
+ @app.middleware("http")
94
+ async def _consort_tag_request(request: Any, call_next: Any) -> Any:
95
+ test_id = request.headers.get("x-consort-test-id")
96
+ id_token = _TEST_ID.set(test_id)
97
+ buf_token = _BUFFER.set([] if test_id else None)
98
+ try:
99
+ return await call_next(request)
100
+ finally:
101
+ buf = _BUFFER.get()
102
+ if test_id and buf:
103
+ _flush(interactions_path, test_id, buf)
104
+ _BUFFER.reset(buf_token)
105
+ _TEST_ID.reset(id_token)
106
+
107
+ @event.listens_for(base, "load", propagate=True)
108
+ def _consort_on_load(target: Any, _context: Any) -> None: # RETRIEVED
109
+ if _TEST_ID.get() is None:
110
+ return
111
+ try:
112
+ table = target.__tablename__
113
+ row = {c.key: getattr(target, c.key) for c in target.__table__.columns}
114
+ _record(table, "read", row)
115
+ except Exception: # noqa: BLE001 — capture must never break a load
116
+ pass
117
+
118
+ @event.listens_for(engine, "after_cursor_execute")
119
+ def _consort_on_execute(_conn, _cursor, statement, parameters, _context, _executemany) -> None: # STORED
120
+ if _TEST_ID.get() is None or not statement or not _DML.match(statement):
121
+ return
122
+ try:
123
+ m = _TABLE.search(statement)
124
+ table = m.group(1) if m else "?"
125
+ params_list = parameters if isinstance(parameters, (list, tuple)) else [parameters]
126
+ for p in params_list:
127
+ if isinstance(p, dict) and p:
128
+ _record(table, "write", dict(p))
129
+ except Exception: # noqa: BLE001
130
+ pass
@@ -14,6 +14,58 @@ def health():
14
14
  return {"status": "ok"}
15
15
 
16
16
 
17
+ # ── Consort dashboard table-snapshot (dev/test/CI only) ─────────────────────
18
+ # A READ-ONLY peek at the current rows of each public table, for the Consort
19
+ # dashboard's per-test table view. E2E (Playwright) specs mutate the DB THROUGH
20
+ # the app, so the app is the one process already connected to the branch — the
21
+ # E2E reporter calls this after each spec to record the DB state that spec left.
22
+ # Guarded by the PRESENCE of a `.consort/` dir at the project root: present in a
23
+ # dev/test/CI checkout but NOT in a packaged production app, so the route is
24
+ # simply absent in a real deploy — no env flag, no per-server wiring. Read-only,
25
+ # row-capped, identifier-validated.
26
+ _PROJECT_ROOT = os.path.join(os.path.dirname(__file__), os.pardir)
27
+ if os.path.isdir(os.path.join(_PROJECT_ROOT, ".consort")):
28
+ import re as _re
29
+
30
+ from sqlalchemy import text as _sql_text
31
+
32
+ from app.database import engine as _snap_engine
33
+
34
+ _SNAP_IDENT = _re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
35
+ _SNAP_CAP = 50
36
+
37
+ @app.get("/api/_consort/table-snapshot")
38
+ def _consort_table_snapshot():
39
+ """Current rows of every public app table (except alembic_version), capped. Read-only."""
40
+ out: dict[str, dict] = {}
41
+ with _snap_engine.connect() as conn:
42
+ names = [
43
+ r[0]
44
+ for r in conn.execute(
45
+ _sql_text(
46
+ "select tablename from pg_tables where schemaname='public' "
47
+ "and tablename <> 'alembic_version' order by 1"
48
+ )
49
+ )
50
+ ]
51
+ for name in names:
52
+ if not _SNAP_IDENT.fullmatch(name):
53
+ continue
54
+ rows = conn.execute(_sql_text(f'SELECT * FROM "{name}" ORDER BY 1 DESC LIMIT {_SNAP_CAP}')).mappings().all()
55
+ total = conn.execute(_sql_text(f'SELECT count(*) FROM "{name}"')).scalar()
56
+ out[name] = {"rows": [dict(r) for r in rows], "row_count": total}
57
+ return out
58
+
59
+ # Per-test RETRIEVED/STORED row capture: records the rows each test's `/api` requests read/wrote
60
+ # (keyed by the X-Consort-Test-Id header) to `.consort/test-interactions.jsonl`, for the
61
+ # dashboard's per-test table view. Middleware + SQLAlchemy ORM `load`/DML events; same dev/test/CI
62
+ # guard as the snapshot route above. See app/consort_capture.py.
63
+ from app import consort_capture as _consort_capture
64
+ from app.database import Base as _consort_base
65
+
66
+ _consort_capture.install(app, _snap_engine, _consort_base)
67
+
68
+
17
69
  # ── Register API routes ABOVE this line ─────────────────────────────────────
18
70
  # Everything below serves the built React SPA (client/dist) in production, so a
19
71
  # single-process deploy (e.g. a Databricks App) serves both the API and the UI.
@@ -33,4 +33,5 @@ dev = [
33
33
  # downgrade/upgrade cannot corrupt the shared verify DB for sibling tests.
34
34
  markers = [
35
35
  "migration: schema-mutating migration-reversibility test; runs on an isolated ephemeral branch",
36
+ "testid: the test-list id (e.g. @pytest.mark.testid(\"T37\")) this test realizes, for the dashboard's per-test live signal",
36
37
  ]
@@ -8,9 +8,19 @@ from app.main import app
8
8
 
9
9
 
10
10
  @pytest.fixture()
11
- def client():
12
- """FastAPI TestClient for making HTTP requests."""
13
- return TestClient(app)
11
+ def client(request):
12
+ """FastAPI TestClient for making HTTP requests.
13
+
14
+ Tagged with the test's ``@pytest.mark.testid`` via the ``X-Consort-Test-Id`` header, so the
15
+ backend's per-test capture (``app/consort_capture.py``) attributes the rows each request
16
+ RETRIEVES/STORES to this test — exactly as the Vitest fetch shim and the Playwright fixture do.
17
+ (Direct ``db_session`` setup/asserts bypass the app and are not captured, by design: the capture
18
+ is what the test did THROUGH the app's ``/api``.)"""
19
+ c = TestClient(app)
20
+ test_id = _consort_testid(request.node)
21
+ if test_id:
22
+ c.headers["X-Consort-Test-Id"] = test_id
23
+ return c
14
24
 
15
25
 
16
26
  @pytest.fixture()
@@ -54,3 +64,122 @@ def _restore_migration_head_after_each_test():
54
64
  # Everything else (a real schema error) propagates and fails loudly, which
55
65
  # is the correct behavior for a genuinely broken restore.
56
66
  print(f"WARNING: migration-head restore failed during teardown: {exc}")
67
+
68
+
69
+ # --- Per-test "green as it runs" signal for the dashboard -------------------------------------
70
+ # Each test tagged `@pytest.mark.testid("T37")` streams its lifecycle to `.consort/test-run.jsonl`:
71
+ # `running` when it STARTS (pytest_runtest_setup), then `green`/`red`/`skipped` when it FINISHES
72
+ # (pytest_runtest_makereport, the CALL phase). A dashboard tails that file and lights each bubble
73
+ # the moment it changes, so even a single whole-suite run (story cadence) shows per-test progress —
74
+ # pending -> running -> green — not an all-at-once batch flip. The `file` disambiguates a reused id
75
+ # across features (F1 and F2 may both have a T37); the reader keys by (scenario_file + id) and takes
76
+ # the LAST write per key, so running -> green composes. Observability only.
77
+
78
+
79
+ def _consort_testid(item):
80
+ """The test's ``@pytest.mark.testid(...)`` id, or ``None`` when it carries no marker."""
81
+ return next((m.args[0] for m in item.iter_markers("testid") if m.args), None)
82
+
83
+
84
+ def _consort_emit_test_run(test_id, status, item):
85
+ """Append one per-test line to ``.consort/test-run.jsonl``. Best-effort with a SCOPED except
86
+ (the only errors this body can raise: file I/O, JSON serialization) — never a blanket
87
+ ``except Exception``, which the kit's deterministic broad-integrity-except test-smell gate
88
+ correctly flags even in a conftest. A reporter write must never break the suite."""
89
+ try:
90
+ import json
91
+ from datetime import datetime, timezone
92
+ from pathlib import Path
93
+
94
+ root = Path(__file__).resolve().parent.parent
95
+ rel = str(Path(str(item.fspath))).replace(f"{root}/", "")
96
+ out = root / ".consort"
97
+ out.mkdir(parents=True, exist_ok=True)
98
+ line = json.dumps(
99
+ {"ts": datetime.now(timezone.utc).isoformat(), "test_id": test_id, "status": status, "file": rel, "kind": "behavior"}
100
+ )
101
+ with open(out / "test-run.jsonl", "a", encoding="utf-8") as fh:
102
+ fh.write(line + "\n")
103
+ except (OSError, ValueError, TypeError):
104
+ pass
105
+
106
+
107
+ def _consort_capture_tables(test_id, item):
108
+ """Append a per-test TABLE snapshot to ``.consort/table-snapshots.jsonl``, keyed by test id: the
109
+ rows of each public app table AS THIS TEST LEFT THEM, captured on the CALL phase (before teardown
110
+ prunes). The dashboard shows the table 'as of' the running/selected test — same corpus/one-reader
111
+ path as the test-status view. Read-only + row-capped. A ``@migration`` test runs on its OWN
112
+ isolated ephemeral branch and drops/recreates the table, so its snapshot is flagged ``isolated``.
113
+ Best-effort with a SCOPED except (DB errors + file I/O + JSON) — never a blanket catch, never
114
+ breaks the suite."""
115
+ try:
116
+ import json
117
+ import re
118
+ from datetime import datetime, timezone
119
+ from pathlib import Path
120
+
121
+ from sqlalchemy import text
122
+ from sqlalchemy.exc import SQLAlchemyError
123
+
124
+ from app.database import engine
125
+
126
+ isolated = any(True for _ in item.iter_markers("migration"))
127
+ ident = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
128
+ root = Path(__file__).resolve().parent.parent
129
+ rel = str(Path(str(item.fspath))).replace(f"{root}/", "")
130
+ out = root / ".consort"
131
+ out.mkdir(parents=True, exist_ok=True)
132
+ ts = datetime.now(timezone.utc).isoformat()
133
+ lines = []
134
+ try:
135
+ with engine.connect() as conn:
136
+ names = [
137
+ r[0]
138
+ for r in conn.execute(
139
+ text(
140
+ "select tablename from pg_tables where schemaname='public' "
141
+ "and tablename <> 'alembic_version' order by 1"
142
+ )
143
+ )
144
+ ]
145
+ for name in names:
146
+ if not ident.fullmatch(name):
147
+ continue
148
+ rows = [dict(r) for r in conn.execute(text(f'SELECT * FROM "{name}" ORDER BY 1 DESC LIMIT 50')).mappings()]
149
+ total = conn.execute(text(f'SELECT count(*) FROM "{name}"')).scalar()
150
+ lines.append(
151
+ json.dumps(
152
+ {"ts": ts, "test_id": test_id, "file": rel, "table": name, "rows": rows, "row_count": total, "isolated": isolated},
153
+ default=str,
154
+ )
155
+ )
156
+ except SQLAlchemyError:
157
+ return # branch unreachable / mid-migration — skip this snapshot, never fail the test
158
+ if lines:
159
+ with open(out / "table-snapshots.jsonl", "a", encoding="utf-8") as fh:
160
+ fh.write("\n".join(lines) + "\n")
161
+ except (OSError, ValueError, TypeError):
162
+ pass
163
+
164
+
165
+ def pytest_runtest_setup(item):
166
+ """Emit ``running`` the moment a tagged test starts, so the dashboard shows it in-flight."""
167
+ test_id = _consort_testid(item)
168
+ if test_id:
169
+ _consort_emit_test_run(test_id, "running", item)
170
+
171
+
172
+ @pytest.hookimpl(wrapper=True)
173
+ def pytest_runtest_makereport(item, call):
174
+ """Emit the FINAL status on the CALL phase, and capture the per-test table snapshot (before
175
+ teardown prunes). A ``makereport`` wrapper (NOT the ``logreport`` hook, whose ``TestReport``
176
+ carries no test node) so the ``testid`` marker is reachable off ``item``; always returns the
177
+ real report unchanged so pytest's own reporting is untouched."""
178
+ report = yield
179
+ if report.when == "call":
180
+ test_id = _consort_testid(item)
181
+ if test_id:
182
+ status = "green" if report.passed else "red" if report.failed else "skipped"
183
+ _consort_emit_test_run(test_id, status, item)
184
+ _consort_capture_tables(test_id, item)
185
+ return report