@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.
- package/dist/scripts/github/index.cjs +14 -0
- package/dist/scripts/github/index.d.cts +19 -1
- package/dist/scripts/github/index.d.ts +19 -1
- package/dist/scripts/github/index.js +13 -0
- package/dist/scripts/index.cjs +79 -2
- package/dist/scripts/index.d.cts +2 -2
- package/dist/scripts/index.d.ts +2 -2
- package/dist/scripts/index.js +76 -2
- package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
- package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
- package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
- package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
- package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
- package/dist/scripts/lakebase/detect-language.cli.js +2 -2
- package/dist/scripts/lakebase/doctor.cli.cjs +15664 -7598
- package/dist/scripts/lakebase/doctor.cli.js +8243 -106
- package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
- package/dist/scripts/lakebase/get-connection.cli.js +2 -2
- package/dist/scripts/lakebase/index.cjs +172 -95
- package/dist/scripts/lakebase/index.d.cts +22 -1
- package/dist/scripts/lakebase/index.d.ts +22 -1
- package/dist/scripts/lakebase/index.js +170 -95
- package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
- package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
- package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
- package/dist/scripts/lakebase/new-migration.cli.js +2 -2
- package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
- package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
- package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
- package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
- package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
- package/dist/scripts/lakebase/scm-doctor.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-doctor.cli.js +2 -2
- package/dist/scripts/lakebase/scm-merge.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-merge.cli.js +2 -2
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
- package/dist/scripts/util/index.cjs +2 -2
- package/dist/scripts/util/index.js +2 -2
- package/package.json +1 -1
- package/scripts/github/repo.ts +37 -1
- package/scripts/lakebase/create-preflight.ts +45 -0
- package/scripts/lakebase/create-project.ts +15 -0
- package/scripts/lakebase/doctor.ts +50 -0
- package/templates/project/client/consort-vitest-reporter.ts +96 -0
- package/templates/project/client/package.json +1 -0
- package/templates/project/client/playwright.config.ts +6 -1
- package/templates/project/client/tests/e2e/about.spec.ts +1 -1
- package/templates/project/client/tests/e2e/consort-fixtures.ts +23 -0
- package/templates/project/client/tests/e2e/home.spec.ts +1 -1
- package/templates/project/client/tests/setup.ts +79 -4
- package/templates/project/client/tsconfig.json +1 -1
- package/templates/project/client/vite.config.ts +8 -0
- package/templates/project/client/vitest.globalSetup.ts +121 -0
- package/templates/project/common/.github/workflows/pr.yml +14 -5
- package/templates/project/common/consort-capture.ts +87 -0
- package/templates/project/common/consort-test-reporter.ts +45 -0
- package/templates/project/common/playwright.config.ts +6 -1
- package/templates/project/python/app/consort_capture.py +130 -0
- package/templates/project/python/app/main.py +52 -0
- package/templates/project/python/pyproject.toml +1 -0
- 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
|
|
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
|
-
|
|
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
|