@databricks-solutions/lakebase-scm-utils 0.3.2 → 0.3.4
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/index.cjs +2 -2
- package/dist/scripts/index.js +2 -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 +2 -2
- package/dist/scripts/lakebase/doctor.cli.js +2 -2
- 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 +2 -2
- package/dist/scripts/lakebase/index.js +2 -2
- 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/templates/project/client/consort-vitest-reporter.ts +96 -0
- package/templates/project/client/package.json +1 -0
- package/templates/project/client/playwright.config.ts +18 -3
- 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/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/database.py +6 -6
- package/templates/project/python/app/main.py +52 -0
- package/templates/project/python/app/repositories/__init__.py +8 -0
- package/templates/project/python/app/repositories/session.py +28 -0
- package/templates/project/python/pyproject.toml +1 -0
- package/templates/project/python/tests/conftest.py +132 -3
|
@@ -1,10 +1,85 @@
|
|
|
1
|
-
// Vitest
|
|
2
|
-
//
|
|
1
|
+
// Vitest setup: extends expect() with jest-dom matchers and clears the DOM between tests.
|
|
2
|
+
//
|
|
3
|
+
// Kit principle — never mock when a real counterpart exists: component tests hit the REAL backend
|
|
4
|
+
// over the REAL `/api` boundary. jsdom has no origin, so a relative `fetch("/api/stock")` throws
|
|
5
|
+
// "Failed to parse URL"; this shim prefixes relative `/api` + `/health` with the backend the global
|
|
6
|
+
// setup (`vitest.globalSetup.ts`) booted and published to `.consort/.vitest-backend`. The component
|
|
7
|
+
// still only speaks HTTP — it never touches the DB. When no backend was booted (no base file) the
|
|
8
|
+
// shim is inert, so a pure-presentation component test is unaffected.
|
|
3
9
|
import "@testing-library/jest-dom/vitest";
|
|
4
|
-
import { afterEach, vi } from "vitest";
|
|
10
|
+
import { afterEach, beforeEach, vi } from "vitest";
|
|
5
11
|
import { cleanup } from "@testing-library/react";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { captureTablesFromRoute } from "../../consort-capture";
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
const TEST_ID = /@(T\d+(?:-[a-z0-9-]+)?)/;
|
|
17
|
+
|
|
18
|
+
// The @T<id> of the test currently running, so the fetch shim can tag each /api request with it (the
|
|
19
|
+
// backend attributes the rows the request RETRIEVES/STORED to this test — app/consort_capture.py).
|
|
20
|
+
let currentTestId: string | null = null;
|
|
21
|
+
beforeEach((ctx: { task?: { name?: string } }) => {
|
|
22
|
+
currentTestId = TEST_ID.exec(ctx?.task?.name ?? "")?.[1] ?? null;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function backendBase(): string {
|
|
26
|
+
let d = process.cwd();
|
|
27
|
+
for (let i = 0; i < 8; i++) {
|
|
28
|
+
try {
|
|
29
|
+
return readFileSync(join(d, ".consort", ".vitest-backend"), "utf8").trim();
|
|
30
|
+
} catch {
|
|
31
|
+
/* walk up to the project root */
|
|
32
|
+
}
|
|
33
|
+
const up = dirname(d);
|
|
34
|
+
if (up === d) break;
|
|
35
|
+
d = up;
|
|
36
|
+
}
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BACKEND_BASE = backendBase();
|
|
41
|
+
if (BACKEND_BASE) {
|
|
42
|
+
const realFetch = globalThis.fetch.bind(globalThis);
|
|
43
|
+
const isApi = (u: string): boolean => u.startsWith("/api") || u.startsWith("/health");
|
|
44
|
+
const reroute = (url: string): string => (isApi(url) ? BACKEND_BASE + url : url);
|
|
45
|
+
// Add the X-Consort-Test-Id header to each /api request so the backend's per-test capture can key
|
|
46
|
+
// the rows this request reads/writes to the running test.
|
|
47
|
+
const tag = (url: string, init?: RequestInit): RequestInit | undefined => {
|
|
48
|
+
if (!currentTestId || !isApi(url)) return init;
|
|
49
|
+
const headers = new Headers(init?.headers);
|
|
50
|
+
headers.set("X-Consort-Test-Id", currentTestId);
|
|
51
|
+
return { ...init, headers };
|
|
52
|
+
};
|
|
53
|
+
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
54
|
+
if (typeof input === "string") return realFetch(reroute(input), tag(input, init));
|
|
55
|
+
if (input instanceof URL) {
|
|
56
|
+
const u = input.toString();
|
|
57
|
+
return realFetch(reroute(u), tag(u, init));
|
|
58
|
+
}
|
|
59
|
+
if (input instanceof Request && input.url.startsWith("/")) {
|
|
60
|
+
const r = new Request(reroute(input.url), input);
|
|
61
|
+
if (currentTestId && isApi(input.url)) r.headers.set("X-Consort-Test-Id", currentTestId);
|
|
62
|
+
return realFetch(r, init);
|
|
63
|
+
}
|
|
64
|
+
return realFetch(input, init);
|
|
65
|
+
}) as typeof fetch;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Per-test table-state capture lives HERE (the worker), not in the reporter: `BACKEND_BASE` is
|
|
69
|
+
// resolved in the worker after globalSetup booted the backend (same read the shim above proved
|
|
70
|
+
// works), whereas the reporter runs in the MAIN process where that base did not resolve — so the
|
|
71
|
+
// reporter's capture silently no-op'd. Running it in afterEach is also version-independent (always
|
|
72
|
+
// fires) and has the current test in context. Captures the DB state THIS component test left, keyed
|
|
73
|
+
// by its @T<id>, via the app's snapshot route (shared writer with the pytest/Playwright reporters).
|
|
74
|
+
afterEach(async (ctx: { task?: { name?: string; file?: { filepath?: string } } }) => {
|
|
8
75
|
cleanup();
|
|
9
76
|
vi.restoreAllMocks();
|
|
77
|
+
if (!BACKEND_BASE) return;
|
|
78
|
+
const id = TEST_ID.exec(ctx?.task?.name ?? "")?.[1];
|
|
79
|
+
if (!id) return;
|
|
80
|
+
try {
|
|
81
|
+
await captureTablesFromRoute(BACKEND_BASE, id, ctx?.task?.file?.filepath ?? "");
|
|
82
|
+
} catch {
|
|
83
|
+
/* best-effort: a snapshot write must never fail the test */
|
|
84
|
+
}
|
|
10
85
|
});
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"noUnusedLocals": true,
|
|
16
16
|
"noUnusedParameters": true,
|
|
17
17
|
"noFallthroughCasesInSwitch": true,
|
|
18
|
-
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
|
18
|
+
"types": ["vitest/globals", "@testing-library/jest-dom", "node"]
|
|
19
19
|
},
|
|
20
20
|
"include": ["src", "tests/setup.ts"]
|
|
21
21
|
}
|
|
@@ -33,6 +33,14 @@ export default defineConfig({
|
|
|
33
33
|
environment: "jsdom",
|
|
34
34
|
globals: true,
|
|
35
35
|
setupFiles: "./tests/setup.ts",
|
|
36
|
+
// Boot a REAL backend (migrated, against the paired branch) for the whole component run, so
|
|
37
|
+
// component tests hit the real `/api` service — never a mock (kit principle). The fetch shim in
|
|
38
|
+
// tests/setup.ts routes relative `/api` to it. Torn down after. See vitest.globalSetup.ts.
|
|
39
|
+
globalSetup: "./vitest.globalSetup.ts",
|
|
40
|
+
// Keep Vitest's own `default` reporter for humans, and add the Consort per-test reporter:
|
|
41
|
+
// it appends each `@T<id>`-named test to `.consort/test-run.jsonl` as it finishes, so the
|
|
42
|
+
// dashboard lights client tests live alongside the backend (pytest) and E2E (Playwright) ones.
|
|
43
|
+
reporters: ["default", "./consort-vitest-reporter.ts"],
|
|
36
44
|
include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
|
|
37
45
|
exclude: [...configDefaults.exclude, "tests/e2e/**"],
|
|
38
46
|
css: false,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Vitest global setup — boot a REAL backend for the component suite.
|
|
2
|
+
//
|
|
3
|
+
// Kit principle: never mock when a real counterpart exists. Client component tests must hit the
|
|
4
|
+
// REAL backend over the REAL `/api` boundary (which hits the real paired-branch DB), exactly like
|
|
5
|
+
// production — the client never gets direct DB access. This mirrors `playwright.config.ts`'s backend
|
|
6
|
+
// webServer: migrate the branch, serve the app on a free 127.0.0.1 port, wait for health, and
|
|
7
|
+
// publish the base URL to `.consort/.vitest-backend` for the fetch shim in `tests/setup.ts`. Torn
|
|
8
|
+
// down after the run. Language-aware (python / node / java), same detection as the Playwright config.
|
|
9
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { createServer } from "node:net";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const ROOT = join(process.cwd(), ".."); // vitest runs from client/
|
|
15
|
+
const CLIENT = join(ROOT, "client");
|
|
16
|
+
const BASE_FILE = join(ROOT, ".consort", ".vitest-backend");
|
|
17
|
+
|
|
18
|
+
// True iff the project has at least one component test (`*.test.tsx|ts` under client/src or
|
|
19
|
+
// client/tests, excluding the Playwright `tests/e2e/` dir). When there are none, booting a backend
|
|
20
|
+
// is pure waste and an avoidable failure surface — so the setup is a no-op (returns early). This is
|
|
21
|
+
// what keeps a component-less project (e.g. one whose UI is all Playwright E2E) from paying for, or
|
|
22
|
+
// failing on, a backend boot it never uses.
|
|
23
|
+
function hasComponentTests(): boolean {
|
|
24
|
+
const stack: string[] = [join(CLIENT, "src"), join(CLIENT, "tests")];
|
|
25
|
+
while (stack.length) {
|
|
26
|
+
const dir = stack.pop()!;
|
|
27
|
+
let entries: string[];
|
|
28
|
+
try {
|
|
29
|
+
entries = readdirSync(dir);
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
const abs = join(dir, e);
|
|
35
|
+
if (abs.replace(/\\/g, "/").includes("/tests/e2e")) continue; // Playwright's, not Vitest's
|
|
36
|
+
let st;
|
|
37
|
+
try {
|
|
38
|
+
st = statSync(abs);
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (st.isDirectory()) stack.push(abs);
|
|
43
|
+
else if (/\.test\.(ts|tsx|js|jsx)$/.test(e)) return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function freePort(): Promise<number> {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const srv = createServer();
|
|
52
|
+
srv.on("error", reject);
|
|
53
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
54
|
+
const addr = srv.address();
|
|
55
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
56
|
+
srv.close(() => resolve(port));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// migrate-before-serve command + health URL for the detected stack (mirrors the Playwright config).
|
|
62
|
+
function backend(port: number): { shell: string; health: string } {
|
|
63
|
+
if (existsSync(join(ROOT, "pom.xml"))) {
|
|
64
|
+
return {
|
|
65
|
+
shell: `./mvnw -q flyway:migrate && ./mvnw -q spring-boot:run -Dspring-boot.run.arguments=--server.port=${port}`,
|
|
66
|
+
health: `http://127.0.0.1:${port}/actuator/health`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
existsSync(join(ROOT, "package.json")) &&
|
|
71
|
+
!existsSync(join(ROOT, "pyproject.toml")) &&
|
|
72
|
+
!existsSync(join(ROOT, "requirements.txt"))
|
|
73
|
+
) {
|
|
74
|
+
return { shell: `npm run migrate && PORT=${port} npm run start`, health: `http://127.0.0.1:${port}/health` };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
shell: `uv run alembic upgrade head && uv run uvicorn app.main:app --host 127.0.0.1 --port ${port}`,
|
|
78
|
+
health: `http://127.0.0.1:${port}/health`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let proc: ChildProcess | undefined;
|
|
83
|
+
|
|
84
|
+
export default async function setup(ctx?: { provide?: (k: string, v: unknown) => void }): Promise<() => Promise<void>> {
|
|
85
|
+
if (!hasComponentTests()) {
|
|
86
|
+
// No component tests → nothing to serve a backend for. No-op teardown.
|
|
87
|
+
return async () => {};
|
|
88
|
+
}
|
|
89
|
+
const port = await freePort();
|
|
90
|
+
const base = `http://127.0.0.1:${port}`;
|
|
91
|
+
const { shell, health } = backend(port);
|
|
92
|
+
proc = spawn("bash", ["-c", shell], { cwd: ROOT, stdio: "ignore", env: { ...process.env } });
|
|
93
|
+
|
|
94
|
+
const deadline = Date.now() + 120_000;
|
|
95
|
+
for (;;) {
|
|
96
|
+
try {
|
|
97
|
+
const r = await fetch(health);
|
|
98
|
+
if (r.ok) break;
|
|
99
|
+
} catch {
|
|
100
|
+
/* backend not up yet */
|
|
101
|
+
}
|
|
102
|
+
if (Date.now() > deadline) {
|
|
103
|
+
if (proc && !proc.killed) proc.kill("SIGTERM");
|
|
104
|
+
throw new Error(`vitest: backend did not become healthy at ${health} within 120s`);
|
|
105
|
+
}
|
|
106
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
mkdirSync(join(ROOT, ".consort"), { recursive: true });
|
|
110
|
+
writeFileSync(BASE_FILE, base); // the fetch shim reads this (pool-agnostic)
|
|
111
|
+
ctx?.provide?.("consortApiBase", base);
|
|
112
|
+
|
|
113
|
+
return async () => {
|
|
114
|
+
try {
|
|
115
|
+
rmSync(BASE_FILE);
|
|
116
|
+
} catch {
|
|
117
|
+
/* already gone */
|
|
118
|
+
}
|
|
119
|
+
if (proc && !proc.killed) proc.kill("SIGTERM");
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Consort per-test capture writers — FRAMEWORK-AGNOSTIC (no Playwright, no Vitest import).
|
|
2
|
+
//
|
|
3
|
+
// These are the shared, pure Node helpers the three test reporters use to stream per-test signals to
|
|
4
|
+
// `.consort/`: the status trail (`test-run.jsonl`) the dashboard tails to light bubbles, and the
|
|
5
|
+
// per-test table snapshots (`table-snapshots.jsonl`) the dashboard reads for its table view. They
|
|
6
|
+
// live in their OWN module (not in `consort-test-reporter.ts`) precisely because that file imports
|
|
7
|
+
// `@playwright/test/reporter` for the Playwright Reporter class — and the Vitest client compile
|
|
8
|
+
// (`tests/setup.ts` captures table state in an afterEach) must NOT drag Playwright's types through
|
|
9
|
+
// the client's bundler-resolution tsconfig. Importing these from here keeps the client Playwright-free.
|
|
10
|
+
//
|
|
11
|
+
// Pure observability: everything is wrapped so a write can NEVER fail a test/suite.
|
|
12
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
/** Walk up from `start` to the project's `.consort` dir (tests run from root OR client/, so cwd
|
|
16
|
+
* varies); fall back to `<cwd>/.consort` (created on write) if none is found. */
|
|
17
|
+
export function findConsortDir(start: string): string {
|
|
18
|
+
let d = start;
|
|
19
|
+
for (let i = 0; i < 8; i++) {
|
|
20
|
+
if (existsSync(join(d, ".consort"))) return join(d, ".consort");
|
|
21
|
+
const up = dirname(d);
|
|
22
|
+
if (up === d) break;
|
|
23
|
+
d = up;
|
|
24
|
+
}
|
|
25
|
+
return join(start, ".consort");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TestRunStatus = "running" | "green" | "red" | "skipped";
|
|
29
|
+
|
|
30
|
+
export function emitTestRun(id: string, status: TestRunStatus, absFile: string, kind: string): void {
|
|
31
|
+
try {
|
|
32
|
+
const dir = findConsortDir(process.cwd());
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
const root = dirname(dir);
|
|
35
|
+
const file = (absFile || "").replace(`${root}/`, "");
|
|
36
|
+
appendFileSync(
|
|
37
|
+
join(dir, "test-run.jsonl"),
|
|
38
|
+
JSON.stringify({ ts: new Date().toISOString(), test_id: id, status, file, kind }) + "\n",
|
|
39
|
+
);
|
|
40
|
+
} catch {
|
|
41
|
+
/* best-effort: a per-test reporter write must never fail the suite */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Append a per-test TABLE snapshot (one line per table) to `.consort/table-snapshots.jsonl`, keyed
|
|
46
|
+
* by test id — parallel to `emitTestRun`. Best-effort. */
|
|
47
|
+
export function emitTableSnapshot(id: string, absFile: string, table: string, rows: unknown[], rowCount: number | null): void {
|
|
48
|
+
try {
|
|
49
|
+
const dir = findConsortDir(process.cwd());
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
const root = dirname(dir);
|
|
52
|
+
const file = (absFile || "").replace(`${root}/`, "");
|
|
53
|
+
appendFileSync(
|
|
54
|
+
join(dir, "table-snapshots.jsonl"),
|
|
55
|
+
JSON.stringify({ ts: new Date().toISOString(), test_id: id, file, table, rows, row_count: rowCount }) + "\n",
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
/* best-effort */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Capture the DB state a test left — for the dashboard's per-test table view — by asking the app's
|
|
63
|
+
* read-only `/api/_consort/table-snapshot` route (the client never touches the DB; the app, which the
|
|
64
|
+
* test hit over `/api`, reports its rows). Writes one `emitTableSnapshot` per table, keyed by test id.
|
|
65
|
+
* Shared by the Playwright (e2e) AND Vitest (component) paths — both drive the DB THROUGH the app.
|
|
66
|
+
* Best-effort + time-boxed: a missing route (prod app) or a slow/unreachable endpoint just skips it. */
|
|
67
|
+
export async function captureTablesFromRoute(baseURL: string, id: string, absFile: string): Promise<void> {
|
|
68
|
+
if (!baseURL) return;
|
|
69
|
+
try {
|
|
70
|
+
// Time-box with Promise.race, NOT an AbortController signal: this runs in BOTH Node (Playwright /
|
|
71
|
+
// pytest reporters) and jsdom (Vitest component afterEach), and a jsdom-realm AbortSignal is not an
|
|
72
|
+
// instance of the AbortSignal Node's undici fetch expects ("Expected signal to be an instance of
|
|
73
|
+
// AbortSignal") — it throws there and the capture silently no-ops. A dangling GET on timeout is a
|
|
74
|
+
// harmless read-only best-effort call.
|
|
75
|
+
const res = await Promise.race([
|
|
76
|
+
fetch(`${baseURL.replace(/\/$/, "")}/api/_consort/table-snapshot`),
|
|
77
|
+
new Promise<Response>((_, reject) => setTimeout(() => reject(new Error("snapshot timeout")), 4000)),
|
|
78
|
+
]);
|
|
79
|
+
if (!res.ok) return;
|
|
80
|
+
const tables = (await res.json()) as Record<string, { rows?: unknown[]; row_count?: number | null }>;
|
|
81
|
+
for (const [table, data] of Object.entries(tables)) {
|
|
82
|
+
emitTableSnapshot(id, absFile, table, data.rows ?? [], data.row_count ?? null);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* route absent (prod) / unreachable / timed out — skip the snapshot */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -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
|
|
@@ -118,9 +118,9 @@ class Base(DeclarativeBase):
|
|
|
118
118
|
pass
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
121
|
+
# The request-scoped session DEPENDENCY (`get_session`) lives in the repository layer,
|
|
122
|
+
# at `app/repositories/session.py` — NOT here. This module is pure infrastructure (engine,
|
|
123
|
+
# SessionLocal, Base); keeping the per-request session door out of it is what lets a
|
|
124
|
+
# service-backed feature's boundary stay off infrastructure (the layering contract). A route
|
|
125
|
+
# that needs a session depends on the session a SERVICE exposes (sourced from the repository
|
|
126
|
+
# layer), never `from app.database import ...` a session dependency.
|
|
@@ -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.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Repository layer: the ONLY layer that touches the ORM / database session.
|
|
2
|
+
|
|
3
|
+
Each feature's repositories (data access for one aggregate) live here, alongside
|
|
4
|
+
the shared request-session door `session.get_session`. Business logic lives in the
|
|
5
|
+
service layer above; persistence lives only here. A service-backed feature's
|
|
6
|
+
boundary (routes) therefore never imports the DB session from infrastructure
|
|
7
|
+
(`app.database`) — it depends on a session the service exposes, sourced here.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Request-scoped database session — the repository layer's session door.
|
|
2
|
+
|
|
3
|
+
The ONE place a request obtains a SQLAlchemy `Session`. It lives in the REPOSITORY
|
|
4
|
+
layer (not infrastructure) on purpose: the layering contract is boundary -> service
|
|
5
|
+
-> repository, and ONLY the repository (and the engine in `app.database`) touches the
|
|
6
|
+
session. So a service-backed feature's route NEVER imports the session from
|
|
7
|
+
`app.database`; instead a service exposes `get_session` (re-exporting it) and the route
|
|
8
|
+
depends on that. A simple, non-layered feature may depend on `get_session` directly —
|
|
9
|
+
it is still outside infrastructure, so the boundary stays clean either way.
|
|
10
|
+
|
|
11
|
+
The engine + `SessionLocal` are built once in `app.database` (runtime-minted Lakebase
|
|
12
|
+
token per connection); this module only hands out and closes a session.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
|
|
17
|
+
from sqlalchemy.orm import Session
|
|
18
|
+
|
|
19
|
+
from app.database import SessionLocal
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_session() -> Iterator[Session]:
|
|
23
|
+
"""Yield a database session and always close it (FastAPI `Depends` / `with` use)."""
|
|
24
|
+
db = SessionLocal()
|
|
25
|
+
try:
|
|
26
|
+
yield db
|
|
27
|
+
finally:
|
|
28
|
+
db.close()
|
|
@@ -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
|
]
|