@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
@@ -20,6 +20,8 @@ import {
20
20
  import {
21
21
  checkDatabricksAuth,
22
22
  databricksAuthPrereqMessage,
23
+ checkGitHubWorkflowScope,
24
+ githubWorkflowScopePrereqMessage,
23
25
  withLakebaseRollback,
24
26
  validateCreateInputs,
25
27
  dirIsEmpty,
@@ -289,6 +291,19 @@ export async function createProject(
289
291
  throw new Error(databricksAuthPrereqMessage(host, auth.reason));
290
292
  }
291
293
 
294
+ // ── GitHub token `workflow` scope precondition ────────────────
295
+ // The scaffold's final push commits .github/workflows/*, which GitHub rejects
296
+ // unless the token carries the `workflow` OAuth scope. Check it HERE, before any
297
+ // GitHub repo or Lakebase project exists, so a missing scope fails in seconds with
298
+ // a fix instead of at the final push after all provisioning (which then rolls back).
299
+ if (useGithub) {
300
+ report("Checking GitHub token scopes...");
301
+ const scope = await checkGitHubWorkflowScope();
302
+ if (!scope.ok) {
303
+ throw new Error(githubWorkflowScopePrereqMessage(scope.reason));
304
+ }
305
+ }
306
+
292
307
  // ── Step 1+2: GitHub repo + clone, OR local-only setup ────────
293
308
  if (useGithub) {
294
309
  report("Creating GitHub repository...", fullRepoName);
@@ -14,6 +14,7 @@ import { listBranches } from "./branch-utils.js";
14
14
  import { verifyHooks } from "./project-verify.js";
15
15
  import { detectLanguage } from "./schema-migrate.js";
16
16
  import { detectWorkflowDrift } from "./workflow-drift.js";
17
+ import { checkGitHubScopes } from "../github/repo.js";
17
18
 
18
19
  export type CheckStatus = "ok" | "warn" | "fail" | "skip";
19
20
 
@@ -593,6 +594,53 @@ async function checkGitRemote(projectDir: string): Promise<CheckResult> {
593
594
  }
594
595
  }
595
596
 
597
+ /**
598
+ * Probe whether the GitHub token carries the `workflow` scope needed to push
599
+ * `.github/workflows/*`. A missing scope is exactly why a scaffold push (or any
600
+ * later workflow edit) gets rejected at the very end and rolls back. Best-effort:
601
+ * no token, OR a fine-grained / GitHub App token (no visible OAuth scopes), OR
602
+ * an API error → `skip`, never a false `fail`. `probe` is injectable for tests.
603
+ */
604
+ export async function checkGitHubTokenScope(
605
+ probe: () => Promise<{ granted: string[] | null; missing: string[] }> = () =>
606
+ checkGitHubScopes(["workflow"])
607
+ ): Promise<CheckResult> {
608
+ try {
609
+ const { granted, missing } = await probe();
610
+ if (granted === null) {
611
+ return {
612
+ name: "github-token-scope",
613
+ status: "skip",
614
+ message:
615
+ "GitHub token scopes not visible (fine-grained PAT / App token) - `workflow` scope unverified",
616
+ };
617
+ }
618
+ if (missing.includes("workflow")) {
619
+ return {
620
+ name: "github-token-scope",
621
+ status: "fail",
622
+ message:
623
+ "GitHub token lacks the `workflow` scope - pushes touching .github/workflows/* will be rejected",
624
+ detail: { granted },
625
+ hint: "Grant it with `gh auth refresh -h github.com -s workflow`, re-sign in to GitHub in VS Code and approve the workflow scope, or set GITHUB_TOKEN to a PAT that includes `workflow`.",
626
+ };
627
+ }
628
+ return {
629
+ name: "github-token-scope",
630
+ status: "ok",
631
+ message: "GitHub token has the `workflow` scope",
632
+ detail: { granted },
633
+ };
634
+ } catch (err) {
635
+ return {
636
+ name: "github-token-scope",
637
+ status: "skip",
638
+ message: "GitHub token scope check skipped (no token resolved, or GitHub unreachable)",
639
+ detail: { error: (err as Error).message },
640
+ };
641
+ }
642
+ }
643
+
596
644
  function checkLanguage(projectDir: string): CheckResult {
597
645
  try {
598
646
  const lang = detectLanguage(projectDir);
@@ -732,6 +780,7 @@ export async function runDoctor(args: DoctorArgs = {}): Promise<DoctorReport> {
732
780
  host
733
781
  );
734
782
  const gitRemote = await checkGitRemote(projectDir);
783
+ const githubTokenScope = await checkGitHubTokenScope();
735
784
  const language = checkLanguage(projectDir);
736
785
  const hooks = checkHooks(projectDir);
737
786
  const workflowDrift = checkWorkflowDrift(projectDir);
@@ -763,6 +812,7 @@ export async function runDoctor(args: DoctorArgs = {}): Promise<DoctorReport> {
763
812
  configProfile,
764
813
  lakebaseProject,
765
814
  gitRemote,
815
+ githubTokenScope,
766
816
  language,
767
817
  hooks,
768
818
  workflowDrift,
@@ -0,0 +1,96 @@
1
+ // Consort per-test "green as it runs" reporter for Vitest (client component/hook tests).
2
+ //
3
+ // Mirrors the Playwright reporter: a test whose name carries `@T<id>` streams its lifecycle to
4
+ // `.consort/test-run.jsonl` so the Consort dashboard lights that bubble. Reuses the single
5
+ // `emitTestRun` writer from the shared Playwright reporter one directory up (`../consort-test-reporter`)
6
+ // — one writer, one JSONL shape, one `.consort` resolver.
7
+ //
8
+ // Recent Vitest (the "Reported Tasks" reporter API, v2.1+) fires per-test hooks, so we stream
9
+ // `running` the moment a test starts and the final status when it ends. OLDER Vitest does not fire
10
+ // those hooks — so `onFinished` is a BATCH BACKSTOP that emits any test the per-test hooks did not
11
+ // already handle (tracked in `handled`), giving a correct end-of-run signal on any version. Typed
12
+ // structurally (not against vitest's own types) so a vitest major bump can't break the build;
13
+ // everything is wrapped so a reporter error can never fail the suite.
14
+ import { emitTestRun, type TestRunStatus } from "../consort-capture";
15
+
16
+ const TEST_ID = /@(T\d+(?:-[a-z0-9-]+)?)/;
17
+
18
+ // NOTE: per-test TABLE-STATE capture does NOT live here — it is in `tests/setup.ts`'s afterEach,
19
+ // which runs in the WORKER process where the booted backend's base URL (`.consort/.vitest-backend`)
20
+ // actually resolves. This reporter runs in the MAIN process, where that base did not resolve, so a
21
+ // capture here silently no-op'd. This reporter is purely the per-test STATUS stream (test-run.jsonl).
22
+
23
+ function idOf(name: string | undefined): string | undefined {
24
+ return TEST_ID.exec(name ?? "")?.[1];
25
+ }
26
+
27
+ function stateToStatus(state: string | undefined): TestRunStatus {
28
+ return state === "pass" || state === "passed" ? "green" : state === "skip" || state === "skipped" ? "skipped" : "red";
29
+ }
30
+
31
+ // --- Modern "Reported Tasks" shapes (duck-typed) ---------------------------------------------
32
+ interface ReportedTestCase {
33
+ name?: string;
34
+ module?: { moduleId?: string; id?: string };
35
+ result?: (() => { state?: string } | undefined) | { state?: string };
36
+ }
37
+
38
+ function moduleIdOf(tc: ReportedTestCase): string {
39
+ return tc.module?.moduleId ?? tc.module?.id ?? "";
40
+ }
41
+
42
+ function resultStateOf(tc: ReportedTestCase): string | undefined {
43
+ const r = typeof tc.result === "function" ? tc.result() : tc.result;
44
+ return r?.state;
45
+ }
46
+
47
+ // --- Legacy onFinished task tree (duck-typed) ------------------------------------------------
48
+ interface VitestTaskLike {
49
+ type?: string;
50
+ name?: string;
51
+ mode?: string;
52
+ result?: { state?: string };
53
+ tasks?: VitestTaskLike[];
54
+ filepath?: string;
55
+ }
56
+
57
+ export default class ConsortVitestReporter {
58
+ // Keys (`file\0name`) already emitted by the per-test hooks, so the onFinished backstop skips
59
+ // them on recent Vitest and only fires for versions where the per-test hooks never ran.
60
+ private handled = new Set<string>();
61
+
62
+ onTestCaseReady(testCase: ReportedTestCase): void {
63
+ const id = idOf(testCase.name);
64
+ if (id) emitTestRun(id, "running", moduleIdOf(testCase), "client");
65
+ }
66
+
67
+ onTestCaseResult(testCase: ReportedTestCase): void {
68
+ const id = idOf(testCase.name);
69
+ if (!id) return;
70
+ const file = moduleIdOf(testCase);
71
+ this.handled.add(`${file}\u0000${testCase.name ?? ""}`);
72
+ emitTestRun(id, stateToStatus(resultStateOf(testCase)), file, "client");
73
+ }
74
+
75
+ onFinished(files: VitestTaskLike[] = []): void {
76
+ try {
77
+ for (const f of files) this.walk(f, f.filepath ?? "");
78
+ } catch {
79
+ /* best-effort: a per-test reporter write must never fail the suite */
80
+ }
81
+ }
82
+
83
+ private walk(task: VitestTaskLike, file: string): void {
84
+ const isLeaf = task.type === "test" || task.type === "custom" || (!task.tasks && !!task.result);
85
+ if (isLeaf) {
86
+ const id = idOf(task.name);
87
+ if (!id) return;
88
+ if (this.handled.has(`${file}\u0000${task.name ?? ""}`)) return; // already streamed by the per-test hooks
89
+ const st = task.result?.state;
90
+ const status: TestRunStatus = st === "pass" ? "green" : st === "skip" || task.mode === "skip" ? "skipped" : "red";
91
+ emitTestRun(id, status, file, "client");
92
+ return;
93
+ }
94
+ for (const child of task.tasks ?? []) this.walk(child, file);
95
+ }
96
+ }
@@ -23,6 +23,7 @@
23
23
  "@testing-library/jest-dom": "^6.4.8",
24
24
  "@testing-library/react": "^16.0.0",
25
25
  "@testing-library/user-event": "^14.5.2",
26
+ "@types/node": "^20.14.0",
26
27
  "@types/react": "^18.3.3",
27
28
  "@types/react-dom": "^18.3.0",
28
29
  "@vitejs/plugin-react": "^4.3.1",
@@ -101,7 +101,12 @@ export default defineConfig({
101
101
  workers: 1,
102
102
  forbidOnly: !!process.env.CI,
103
103
  retries: process.env.CI ? 2 : 0,
104
- reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
104
+ // `../consort-test-reporter.ts` (shared with the root config) appends each `@T<id>`-tagged test
105
+ // to `.consort/test-run.jsonl` as it finishes, for the dashboard's per-test live signal. It walks
106
+ // up from cwd to the project `.consort`, so running from client/ still finds the right ledger.
107
+ reporter: process.env.CI
108
+ ? [["list"], ["html", { open: "never" }], ["../consort-test-reporter.ts"]]
109
+ : [["list"], ["../consort-test-reporter.ts"]],
105
110
  use: {
106
111
  baseURL: CLIENT_URL,
107
112
  trace: "on-first-retry",
@@ -1,4 +1,4 @@
1
- import { test, expect } from "@playwright/test";
1
+ import { test, expect } from "./consort-fixtures";
2
2
 
3
3
  // The reachability pattern proven end-to-end: a feature page is reachable by
4
4
  // NAVIGATING the real app (a navbar link / a route), not just by mounting the
@@ -0,0 +1,23 @@
1
+ // Consort e2e test fixture: tag every spec's browser requests with the X-Consort-Test-Id header,
2
+ // derived from the test title's @T<id>. The backend's per-test capture (app/consort_capture.py) uses
3
+ // it to attribute the rows this spec RETRIEVES/STORES — the same per-test key the Vitest fetch shim
4
+ // and the pytest client fixture set, so the dashboard's per-test table view works for e2e specs too.
5
+ //
6
+ // Import { test, expect } from THIS module in e2e specs instead of "@playwright/test" — nothing else
7
+ // changes. (The header rides through Vite's /api proxy to the backend; it's inert in production.)
8
+ import { test as base } from "@playwright/test";
9
+
10
+ // Re-export EVERYTHING from @playwright/test (expect, devices, types like APIRequestContext, …) so a
11
+ // spec imports solely from here; the local `test` below overrides the re-exported one (an explicit
12
+ // export shadows a star re-export of the same name).
13
+ export * from "@playwright/test";
14
+
15
+ const TEST_ID = /@(T\d+(?:-[a-z0-9-]+)?)/;
16
+
17
+ export const test = base.extend({
18
+ page: async ({ page }, use, testInfo) => {
19
+ const id = TEST_ID.exec(testInfo.title)?.[1];
20
+ if (id) await page.setExtraHTTPHeaders({ "X-Consort-Test-Id": id });
21
+ await use(page);
22
+ },
23
+ });
@@ -1,4 +1,4 @@
1
- import { test, expect } from "@playwright/test";
1
+ import { test, expect } from "./consort-fixtures";
2
2
 
3
3
  // The one behavior a fresh scaffold can prove end-to-end: the SPA loads, calls
4
4
  // the backend /health through the Vite proxy, and shows the result as an
@@ -1,10 +1,85 @@
1
- // Vitest global setup: extends expect() with jest-dom matchers and clears the
2
- // DOM + mocks between tests so each test starts from a known state.
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
- afterEach(() => {
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
+ }
@@ -148,11 +148,6 @@ jobs:
148
148
  # the CI branch is provisioned and fail fast. `--if-present` no-ops a
149
149
  # client with no `test` script. The client's Playwright E2E runs later
150
150
  # (its own step, gated on client/playwright.config.ts).
151
- - name: Run client unit tests
152
- if: hashFiles('client/package.json') != ''
153
- working-directory: client
154
- run: npm run test --if-present
155
-
156
151
  # The official action installs to a user-writable path (no sudo). It's
157
152
  # a no-op if a compatible databricks CLI is already on PATH.
158
153
  - name: Install Databricks CLI
@@ -384,6 +379,20 @@ jobs:
384
379
  --pretty
385
380
  echo "Migrations completed."
386
381
 
382
+ - name: Run client unit tests
383
+ # The kit's client/vitest.globalSetup.ts boots a REAL backend over the /api
384
+ # boundary (never mock when a real counterpart exists), so the component suite
385
+ # NEEDS the CI Lakebase branch + DATABASE_URL. Run it AFTER datasource config +
386
+ # migrations, not before. No-op when there are no component tests (globalSetup
387
+ # returns early); skipped when there is no CI Lakebase branch (url empty).
388
+ if: hashFiles('client/package.json') != '' && steps.lakebase-db.outputs.url != ''
389
+ working-directory: client
390
+ env:
391
+ DATABASE_URL: ${{ env.DATABASE_URL }}
392
+ DB_USERNAME: ${{ env.DB_USERNAME }}
393
+ DB_PASSWORD: ${{ env.DB_PASSWORD }}
394
+ run: npm run test --if-present
395
+
387
396
  - name: Run tests
388
397
  env:
389
398
  DATABASE_URL: ${{ env.DATABASE_URL }}
@@ -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
+ }