@fabricorg/databricks-bdd 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fabric
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @fabricorg/databricks-bdd
2
+
3
+ Gherkin (cucumber-js) step library for testing Databricks artifacts
4
+ (ADR-0006). The same `.feature` file runs hermetically on DuckDB under
5
+ `DBX_TEST_PROFILE=local` and against a real SQL warehouse under
6
+ `DBX_TEST_PROFILE=live` (gated by `DBX_TEST_LIVE=1`). Live-only scenarios are
7
+ tagged (`@live`, `@jobs`, `@dlt`, `@lakebase`, `@slow`) and auto-skip locally.
8
+
9
+ ```gherkin
10
+ Scenario: Seed a table and assert its contents
11
+ Given a table "conversions" with:
12
+ | subject_id | event_name | value | at |
13
+ | u1 | purchase | 49.0 | 2026-07-01T12:00:00Z |
14
+ Then table "conversions" has 1 rows
15
+ And table "conversions" contains rows matching:
16
+ | subject_id | value |
17
+ | u1 | 49.0 |
18
+ ```
19
+
20
+ ## Setup
21
+
22
+ `cucumber.mjs` in your package:
23
+
24
+ ```js
25
+ export default {
26
+ paths: ['features/**/*.feature'],
27
+ import: ['features/support/steps.ts'], // your steps; import the library from there
28
+ format: [
29
+ 'progress',
30
+ ['junit', 'reports/bdd-junit.xml'],
31
+ ['html', 'reports/bdd.html'],
32
+ ['rerun', 'reports/rerun.txt'],
33
+ ['@fabricorg/databricks-bdd/evidence-formatter', 'reports/evidence.json'],
34
+ ],
35
+ };
36
+ ```
37
+
38
+ `features/support/steps.ts`:
39
+
40
+ ```ts
41
+ import '@fabricorg/databricks-bdd/steps'; // registers the world, hooks, and step library
42
+ // ... your own domain steps, typed against DatabricksWorld
43
+ ```
44
+
45
+ Run with the tsx loader: `NODE_OPTIONS="--import tsx" cucumber-js`. Or embed
46
+ in vitest via `runFeatures({ cwd })` from the package root export.
47
+
48
+ ## Step library
49
+
50
+ Given: `a Databricks workspace` · `catalog {string} and schema {string}` ·
51
+ `a table {string} with:` (types inferred from cells) ·
52
+ `a table {string} with schema {string} and rows:` ·
53
+ `an empty table {string} with schema {string}` ·
54
+ `a table {string} with schema {string} from fixture {string}` (NDJSON).
55
+ Live setup also includes `a Lakebase database`, scenario-scoped Volume fixture
56
+ uploads, and secondary service-principal grant contexts with automatic cleanup.
57
+
58
+ When: `I run the SQL:` (docstring; errors are captured, not thrown) ·
59
+ `I query table {string}` · `I run job {string}` (live) ·
60
+ `I start a full refresh of pipeline {string}` (isolated live pipeline only) ·
61
+ `I submit notebook {string}` · `I run dbt build for {string}`.
62
+
63
+ Then: `table {string} has {int} rows` · `table {string} contains rows matching:` ·
64
+ `table {string} contains exactly:` · `table {string} matches golden {string} ordered by {string}` ·
65
+ `the result has {int} rows` · `the result contains rows matching:` ·
66
+ `the result matches golden {string}` · `the statement fails with {string}` ·
67
+ `the statement fails with permission denied` · `no rows were rescued in table {string}` ·
68
+ `row count of {string} is within {int}% of {int}` · `the job run reaches {runState}` ·
69
+ `the pipeline update completes within {duration}` · schema-contract assertions.
70
+
71
+ The World supplies Behave-style scenario cleanup, typed userdata, run-level
72
+ live preflight, redacted failure attachments, custom parameter types, Scenario
73
+ Outlines, serial live execution, and parallel local execution. Generate the
74
+ complete catalog, including unused definitions, with `pnpm steps:catalog`.
75
+
76
+ Keep feature text engine-neutral — the same files must stay executable by a
77
+ future pytest-bdd step library (ADR-0006).
78
+
79
+ Product-domain example (aggregates, variants, SRM guardrail):
80
+ `apps/api/features/` in this repo. Docs:
81
+ `apps/docs/content/docs/testing/bdd.mdx`.
@@ -0,0 +1,199 @@
1
+ import { World } from '@cucumber/cucumber';
2
+ import { resolveProfile, createExecutionContext } from '@fabricorg/databricks-testkit';
3
+ export { waitForPipelineUpdate } from '@fabricorg/databricks-testkit';
4
+ import { waitForDatabricksRun } from '@fabric-harness/databricks';
5
+
6
+ // src/world.ts
7
+ var DatabricksWorld = class extends World {
8
+ profile;
9
+ /** Rows from the last `When I run the SQL` / `When I query table` step. */
10
+ lastRows = null;
11
+ /** Error captured from the last statement, for `Then the statement fails ...`. */
12
+ lastError = null;
13
+ /** Terminal run record from the last `When I run job ...` step. */
14
+ lastRun = null;
15
+ /** Pipeline update handle from `When I start a full refresh of pipeline ...`. */
16
+ lastPipelineUpdate = null;
17
+ schemaOverride;
18
+ catalogOverride;
19
+ lastSql;
20
+ lastStatementId;
21
+ lakebasePool;
22
+ ctx;
23
+ activeCtx;
24
+ cleanups = [];
25
+ constructor(options) {
26
+ super(options);
27
+ this.profile = resolveProfile();
28
+ }
29
+ async context() {
30
+ if (this.activeCtx) return this.activeCtx;
31
+ if (!this.ctx) {
32
+ const env = this.catalogOverride ? { ...process.env, DBX_TEST_CATALOG: this.catalogOverride } : process.env;
33
+ this.ctx = await createExecutionContext({
34
+ profile: this.profile,
35
+ env,
36
+ schema: this.schemaOverride ?? this.parameters.schema,
37
+ onStatement: ({ sql, statementId }) => {
38
+ this.lastSql = sql;
39
+ this.lastStatementId = statementId;
40
+ }
41
+ });
42
+ }
43
+ return this.ctx;
44
+ }
45
+ /** Route subsequent steps through a scenario-owned secondary identity. */
46
+ useExecutionContext(context) {
47
+ if (this.activeCtx) throw new Error("A secondary execution context is already active");
48
+ this.activeCtx = context;
49
+ this.addCleanup(async () => {
50
+ await context.close();
51
+ this.activeCtx = void 0;
52
+ });
53
+ }
54
+ /** Register scenario-scoped cleanup. Cleanups run in reverse order. */
55
+ addCleanup(cleanup) {
56
+ this.cleanups.push(cleanup);
57
+ }
58
+ /** Resolve runner userdata from worldParameters first, then DBX_TEST_USERDATA_* env. */
59
+ userdata(name) {
60
+ const configured = this.parameters.userdata?.[name];
61
+ if (configured !== void 0) return configured;
62
+ return process.env[`DBX_TEST_USERDATA_${name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`];
63
+ }
64
+ scopeTo(catalog, schema) {
65
+ if (this.ctx) {
66
+ throw new Error(
67
+ "Given catalog/schema must run before any step that touches the warehouse in the scenario"
68
+ );
69
+ }
70
+ this.catalogOverride = this.profile === "live" ? process.env.DBX_TEST_CATALOG ?? catalog : catalog;
71
+ this.schemaOverride = this.profile === "live" ? process.env.DBX_TEST_SCHEMA ?? schema : schema;
72
+ }
73
+ async dispose() {
74
+ const errors = [];
75
+ for (const cleanup of this.cleanups.splice(0).reverse()) {
76
+ try {
77
+ await cleanup();
78
+ } catch (error) {
79
+ errors.push(error);
80
+ }
81
+ }
82
+ try {
83
+ await this.ctx?.close();
84
+ } catch (error) {
85
+ errors.push(error);
86
+ }
87
+ this.ctx = void 0;
88
+ if (errors.length > 0) throw new AggregateError(errors, "Scenario cleanup failed");
89
+ }
90
+ };
91
+
92
+ // src/profile.ts
93
+ var LIVE_ONLY_TAGS = [
94
+ "@live",
95
+ "@jobs",
96
+ "@dlt",
97
+ "@pipeline",
98
+ "@autoloader",
99
+ "@lakebase",
100
+ "@slow"
101
+ ];
102
+ function liveOnlyReason(tags, profile) {
103
+ if (profile === "live") return null;
104
+ const blocking = tags.filter((t) => LIVE_ONLY_TAGS.includes(t));
105
+ if (blocking.length === 0) return null;
106
+ return `requires a live workspace (${blocking.join(", ")}); running under DBX_TEST_PROFILE=local`;
107
+ }
108
+
109
+ // src/infer.ts
110
+ var INT_RE = /^-?\d+$/;
111
+ var FLOAT_RE = /^-?\d+\.\d+$/;
112
+ var TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?/;
113
+ var BOOL_RE = /^(true|false)$/i;
114
+ function inferFixture(table, raw) {
115
+ if (raw.length < 2) {
116
+ throw new Error(`Data table for ${table} needs a header row and at least one data row`);
117
+ }
118
+ const [header, ...body] = raw;
119
+ const types = header.map((_, col) => inferColumnType(body.map((row) => row[col] ?? "")));
120
+ const schema = header.map((name, i) => `"${name}" ${types[i]}`).join(", ");
121
+ const rows = body.map((row) => row.map((cell, i) => coerceCell(cell, types[i] ?? "STRING")));
122
+ return { table, schema, rows };
123
+ }
124
+ function coerceMatchRows(raw) {
125
+ if (raw.length < 2) {
126
+ throw new Error("Match table needs a header row and at least one data row");
127
+ }
128
+ const [header, ...body] = raw;
129
+ return body.map((row) => {
130
+ const out = {};
131
+ header.forEach((name, i) => {
132
+ out[name] = coerceCell(row[i] ?? "", inferColumnType([row[i] ?? ""]));
133
+ });
134
+ return out;
135
+ });
136
+ }
137
+ function inferColumnType(cells) {
138
+ const present = cells.filter((c) => c !== "");
139
+ if (present.length === 0) return "STRING";
140
+ if (present.every((c) => INT_RE.test(c))) return "BIGINT";
141
+ if (present.every((c) => FLOAT_RE.test(c) || INT_RE.test(c))) return "DOUBLE";
142
+ if (present.every((c) => TIMESTAMP_RE.test(c))) return "TIMESTAMP";
143
+ if (present.every((c) => BOOL_RE.test(c))) return "BOOLEAN";
144
+ return "STRING";
145
+ }
146
+ function coerceCell(cell, type) {
147
+ if (cell === "") return null;
148
+ if (type === "BIGINT" || type === "DOUBLE") return Number(cell);
149
+ if (type === "BOOLEAN") return /^true$/i.test(cell);
150
+ return cell;
151
+ }
152
+ async function resolveJobId(client, nameOrId) {
153
+ if (/^\d+$/.test(nameOrId)) return Number(nameOrId);
154
+ const response = await client.get(
155
+ "/api/2.1/jobs/list",
156
+ { name: nameOrId }
157
+ );
158
+ const match = (response.jobs ?? []).find((j) => j.settings?.name === nameOrId);
159
+ if (!match) throw new Error(`No Databricks job named '${nameOrId}' found`);
160
+ return match.job_id;
161
+ }
162
+ async function runJobToTerminal(client, nameOrId, options = {}) {
163
+ const jobId = await resolveJobId(client, nameOrId);
164
+ const submitted = await client.post("/api/2.1/jobs/run-now", {
165
+ job_id: jobId
166
+ });
167
+ const run = await waitForDatabricksRun(client, submitted.run_id, {
168
+ pollIntervalMs: options.pollIntervalMs,
169
+ timeoutMs: options.timeoutMs
170
+ });
171
+ const state = run.state;
172
+ return {
173
+ runId: submitted.run_id,
174
+ lifeCycleState: state?.life_cycle_state ?? "UNKNOWN",
175
+ resultState: state?.result_state ?? "UNKNOWN",
176
+ run
177
+ };
178
+ }
179
+ async function resolvePipelineId(client, nameOrId) {
180
+ if (/^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(nameOrId)) return nameOrId;
181
+ const response = await client.get(
182
+ "/api/2.0/pipelines",
183
+ { filter: `name LIKE '${nameOrId}'` }
184
+ );
185
+ const match = (response.statuses ?? []).find((p) => p.name === nameOrId);
186
+ if (!match) throw new Error(`No Databricks pipeline named '${nameOrId}' found`);
187
+ return match.pipeline_id;
188
+ }
189
+ async function startPipelineUpdate(client, pipelineId, options = {}) {
190
+ const response = await client.post(
191
+ `/api/2.0/pipelines/${encodeURIComponent(pipelineId)}/updates`,
192
+ { full_refresh: options.fullRefresh ?? false }
193
+ );
194
+ return response.update_id;
195
+ }
196
+
197
+ export { DatabricksWorld, LIVE_ONLY_TAGS, coerceMatchRows, inferFixture, liveOnlyReason, resolveJobId, resolvePipelineId, runJobToTerminal, startPipelineUpdate };
198
+ //# sourceMappingURL=chunk-EW5VNRZP.js.map
199
+ //# sourceMappingURL=chunk-EW5VNRZP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/world.ts","../src/profile.ts","../src/infer.ts","../src/jobs.ts","../src/pipelines.ts"],"names":[],"mappings":"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","file":"chunk-EW5VNRZP.js","sourcesContent":["import { type IWorldOptions, World } from '@cucumber/cucumber';\nimport {\n type ExecutionContext,\n type TestProfile,\n createExecutionContext,\n resolveProfile,\n} from '@fabricorg/databricks-testkit';\n\nexport interface DatabricksWorldParameters {\n /** Default schema for the execution context; overridable per scenario via the catalog/schema step. */\n schema?: string;\n /** Arbitrary typed runner configuration, comparable to behave `-D` userdata. */\n userdata?: Record<string, string | number | boolean>;\n}\n\ntype Cleanup = () => void | Promise<void>;\n\n/**\n * Cucumber World holding one ExecutionContext per scenario. The context is\n * created lazily on first use so `Given catalog ... and schema ...` can run\n * first; it is closed by the After hook registered in ./steps.ts.\n */\nexport class DatabricksWorld extends World<DatabricksWorldParameters> {\n readonly profile: TestProfile;\n /** Rows from the last `When I run the SQL` / `When I query table` step. */\n lastRows: Record<string, unknown>[] | null = null;\n /** Error captured from the last statement, for `Then the statement fails ...`. */\n lastError: Error | null = null;\n /** Terminal run record from the last `When I run job ...` step. */\n lastRun: Record<string, unknown> | null = null;\n /** Pipeline update handle from `When I start a full refresh of pipeline ...`. */\n lastPipelineUpdate: { pipelineId: string; updateId: string } | null = null;\n schemaOverride: string | undefined;\n catalogOverride: string | undefined;\n lastSql: string | undefined;\n lastStatementId: string | undefined;\n lakebasePool:\n | { query<T extends Record<string, unknown>>(sql: string): Promise<{ rows: T[] }> }\n | undefined;\n private ctx: ExecutionContext | undefined;\n private activeCtx: ExecutionContext | undefined;\n private readonly cleanups: Cleanup[] = [];\n\n constructor(options: IWorldOptions<DatabricksWorldParameters>) {\n super(options);\n this.profile = resolveProfile();\n }\n\n async context(): Promise<ExecutionContext> {\n if (this.activeCtx) return this.activeCtx;\n if (!this.ctx) {\n const env = this.catalogOverride\n ? { ...process.env, DBX_TEST_CATALOG: this.catalogOverride }\n : process.env;\n this.ctx = await createExecutionContext({\n profile: this.profile,\n env,\n schema: this.schemaOverride ?? this.parameters.schema,\n onStatement: ({ sql, statementId }) => {\n this.lastSql = sql;\n this.lastStatementId = statementId;\n },\n });\n }\n return this.ctx;\n }\n\n /** Route subsequent steps through a scenario-owned secondary identity. */\n useExecutionContext(context: ExecutionContext): void {\n if (this.activeCtx) throw new Error('A secondary execution context is already active');\n this.activeCtx = context;\n this.addCleanup(async () => {\n await context.close();\n this.activeCtx = undefined;\n });\n }\n\n /** Register scenario-scoped cleanup. Cleanups run in reverse order. */\n addCleanup(cleanup: Cleanup): void {\n this.cleanups.push(cleanup);\n }\n\n /** Resolve runner userdata from worldParameters first, then DBX_TEST_USERDATA_* env. */\n userdata(name: string): string | number | boolean | undefined {\n const configured = this.parameters.userdata?.[name];\n if (configured !== undefined) return configured;\n return process.env[`DBX_TEST_USERDATA_${name.replace(/[^A-Za-z0-9]/g, '_').toUpperCase()}`];\n }\n\n scopeTo(catalog: string, schema: string): void {\n if (this.ctx) {\n throw new Error(\n 'Given catalog/schema must run before any step that touches the warehouse in the scenario',\n );\n }\n // Feature files stay portable and readable with illustrative names. A live\n // runner may redirect every scenario into its provisioned scratch scope.\n this.catalogOverride =\n this.profile === 'live' ? (process.env.DBX_TEST_CATALOG ?? catalog) : catalog;\n this.schemaOverride =\n this.profile === 'live' ? (process.env.DBX_TEST_SCHEMA ?? schema) : schema;\n }\n\n async dispose(): Promise<void> {\n const errors: unknown[] = [];\n for (const cleanup of this.cleanups.splice(0).reverse()) {\n try {\n await cleanup();\n } catch (error) {\n errors.push(error);\n }\n }\n try {\n await this.ctx?.close();\n } catch (error) {\n errors.push(error);\n }\n this.ctx = undefined;\n if (errors.length > 0) throw new AggregateError(errors, 'Scenario cleanup failed');\n }\n}\n","import type { TestProfile } from '@fabricorg/databricks-testkit';\n\n/**\n * Tags that require a live workspace (or a long budget). Scenarios carrying\n * any of them are skipped under the `local` profile — see ADR-0006.\n */\nexport const LIVE_ONLY_TAGS = [\n '@live',\n '@jobs',\n '@dlt',\n '@pipeline',\n '@autoloader',\n '@lakebase',\n '@slow',\n] as const;\n\n/** Returns a skip reason when the scenario cannot run under the given profile, else null. */\nexport function liveOnlyReason(tags: readonly string[], profile: TestProfile): string | null {\n if (profile === 'live') return null;\n const blocking = tags.filter((t) => (LIVE_ONLY_TAGS as readonly string[]).includes(t));\n if (blocking.length === 0) return null;\n return `requires a live workspace (${blocking.join(', ')}); running under DBX_TEST_PROFILE=local`;\n}\n","import type { TableFixture } from '@fabricorg/databricks-testkit';\n\nconst INT_RE = /^-?\\d+$/;\nconst FLOAT_RE = /^-?\\d+\\.\\d+$/;\nconst TIMESTAMP_RE = /^\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}(:\\d{2})?/;\nconst BOOL_RE = /^(true|false)$/i;\n\n/**\n * Turn a Gherkin data table (header row + string cells) into a TableFixture,\n * inferring portable column types from the values: BIGINT, DOUBLE,\n * TIMESTAMP, BOOLEAN, else STRING. Empty cells become NULL.\n */\nexport function inferFixture(table: string, raw: readonly (readonly string[])[]): TableFixture {\n if (raw.length < 2) {\n throw new Error(`Data table for ${table} needs a header row and at least one data row`);\n }\n const [header, ...body] = raw as [readonly string[], ...(readonly string[])[]];\n const types = header.map((_, col) => inferColumnType(body.map((row) => row[col] ?? '')));\n const schema = header.map((name, i) => `\"${name}\" ${types[i]}`).join(', ');\n const rows = body.map((row) => row.map((cell, i) => coerceCell(cell, types[i] ?? 'STRING')));\n return { table, schema, rows };\n}\n\n/** Coerce data-table string cells to typed values for row matching (no header row). */\nexport function coerceMatchRows(raw: readonly (readonly string[])[]): Record<string, unknown>[] {\n if (raw.length < 2) {\n throw new Error('Match table needs a header row and at least one data row');\n }\n const [header, ...body] = raw as [readonly string[], ...(readonly string[])[]];\n return body.map((row) => {\n const out: Record<string, unknown> = {};\n header.forEach((name, i) => {\n out[name] = coerceCell(row[i] ?? '', inferColumnType([row[i] ?? '']));\n });\n return out;\n });\n}\n\nfunction inferColumnType(cells: readonly string[]): string {\n const present = cells.filter((c) => c !== '');\n if (present.length === 0) return 'STRING';\n if (present.every((c) => INT_RE.test(c))) return 'BIGINT';\n if (present.every((c) => FLOAT_RE.test(c) || INT_RE.test(c))) return 'DOUBLE';\n if (present.every((c) => TIMESTAMP_RE.test(c))) return 'TIMESTAMP';\n if (present.every((c) => BOOL_RE.test(c))) return 'BOOLEAN';\n return 'STRING';\n}\n\nfunction coerceCell(cell: string, type: string): unknown {\n if (cell === '') return null;\n if (type === 'BIGINT' || type === 'DOUBLE') return Number(cell);\n if (type === 'BOOLEAN') return /^true$/i.test(cell);\n return cell;\n}\n","import { type DatabricksRestClient, waitForDatabricksRun } from '@fabric-harness/databricks';\nimport type { WorkspaceClientLike } from '@fabricorg/databricks-testkit';\n\nexport interface RunJobOptions {\n pollIntervalMs?: number;\n timeoutMs?: number;\n}\n\nexport interface JobRunResult {\n runId: number;\n lifeCycleState: string;\n resultState: string;\n run: Record<string, unknown>;\n}\n\n/** Resolve a job by numeric id or by exact name via /api/2.1/jobs/list. */\nexport async function resolveJobId(client: WorkspaceClientLike, nameOrId: string): Promise<number> {\n if (/^\\d+$/.test(nameOrId)) return Number(nameOrId);\n const response = await client.get<{ jobs?: { job_id: number; settings?: { name?: string } }[] }>(\n '/api/2.1/jobs/list',\n { name: nameOrId },\n );\n const match = (response.jobs ?? []).find((j) => j.settings?.name === nameOrId);\n if (!match) throw new Error(`No Databricks job named '${nameOrId}' found`);\n return match.job_id;\n}\n\n/** run-now a job and poll its run to a terminal state. */\nexport async function runJobToTerminal(\n client: WorkspaceClientLike,\n nameOrId: string,\n options: RunJobOptions = {},\n): Promise<JobRunResult> {\n const jobId = await resolveJobId(client, nameOrId);\n const submitted = await client.post<{ run_id: number }>('/api/2.1/jobs/run-now', {\n job_id: jobId,\n });\n const run = await waitForDatabricksRun(client as DatabricksRestClient, submitted.run_id, {\n pollIntervalMs: options.pollIntervalMs,\n timeoutMs: options.timeoutMs,\n });\n const state = (run as { state?: { life_cycle_state?: string; result_state?: string } }).state;\n return {\n runId: submitted.run_id,\n lifeCycleState: state?.life_cycle_state ?? 'UNKNOWN',\n resultState: state?.result_state ?? 'UNKNOWN',\n run,\n };\n}\n","import {\n type PollOptions,\n type WorkspaceClientLike,\n waitForPipelineUpdate,\n} from '@fabricorg/databricks-testkit';\n\nexport type PipelineWaitOptions = PollOptions;\n\n/** Resolve a DLT/Lakeflow pipeline by id or exact name via /api/2.0/pipelines. */\nexport async function resolvePipelineId(\n client: WorkspaceClientLike,\n nameOrId: string,\n): Promise<string> {\n if (/^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(nameOrId)) return nameOrId;\n const response = await client.get<{ statuses?: { pipeline_id: string; name?: string }[] }>(\n '/api/2.0/pipelines',\n { filter: `name LIKE '${nameOrId}'` },\n );\n const match = (response.statuses ?? []).find((p) => p.name === nameOrId);\n if (!match) throw new Error(`No Databricks pipeline named '${nameOrId}' found`);\n return match.pipeline_id;\n}\n\n/** Start a pipeline update; returns the update id to poll. */\nexport async function startPipelineUpdate(\n client: WorkspaceClientLike,\n pipelineId: string,\n options: { fullRefresh?: boolean } = {},\n): Promise<string> {\n const response = await client.post<{ update_id: string }>(\n `/api/2.0/pipelines/${encodeURIComponent(pipelineId)}/updates`,\n { full_refresh: options.fullRefresh ?? false },\n );\n return response.update_id;\n}\n\n/** Poll an update to a terminal state; throws on timeout or non-COMPLETED terminal states. */\nexport { waitForPipelineUpdate };\n"]}
@@ -0,0 +1,80 @@
1
+ import { Formatter } from '@cucumber/cucumber';
2
+
3
+ // src/evidence-formatter.ts
4
+ var STATUS_RANK = ["UNKNOWN", "PASSED", "SKIPPED", "PENDING", "UNDEFINED", "AMBIGUOUS", "FAILED"];
5
+ var EvidenceFormatter = class extends Formatter {
6
+ pickles = /* @__PURE__ */ new Map();
7
+ testCases = /* @__PURE__ */ new Map();
8
+ // testCaseId → pickleId
9
+ attempts = /* @__PURE__ */ new Map();
10
+ constructor(options) {
11
+ super(options);
12
+ options.eventBroadcaster.on("envelope", (envelope) => this.onEnvelope(envelope));
13
+ }
14
+ onEnvelope(envelope) {
15
+ if (envelope.pickle) this.pickles.set(envelope.pickle.id, envelope.pickle);
16
+ if (envelope.testCase) this.testCases.set(envelope.testCase.id, envelope.testCase.pickleId);
17
+ if (envelope.testCaseStarted) {
18
+ this.attempts.set(envelope.testCaseStarted.id, {
19
+ pickleId: this.testCases.get(envelope.testCaseStarted.testCaseId) ?? "",
20
+ status: "UNKNOWN",
21
+ durationMs: 0
22
+ });
23
+ }
24
+ if (envelope.testStepFinished) {
25
+ const attempt = this.attempts.get(envelope.testStepFinished.testCaseStartedId);
26
+ if (attempt) {
27
+ const result = envelope.testStepFinished.testStepResult;
28
+ if (STATUS_RANK.indexOf(result.status) > STATUS_RANK.indexOf(attempt.status)) {
29
+ attempt.status = result.status;
30
+ if (result.message) attempt.error = redactSecrets(result.message);
31
+ }
32
+ const d = result.duration;
33
+ if (d) attempt.durationMs += d.seconds * 1e3 + d.nanos / 1e6;
34
+ }
35
+ }
36
+ if (envelope.testRunFinished) this.finish(envelope.testRunFinished);
37
+ }
38
+ finish(finished) {
39
+ const scenarios = [];
40
+ const summary = {};
41
+ for (const attempt of this.attempts.values()) {
42
+ const pickle = this.pickles.get(attempt.pickleId);
43
+ const status = attempt.status.toLowerCase();
44
+ summary[status] = (summary[status] ?? 0) + 1;
45
+ scenarios.push({
46
+ name: pickle?.name ?? attempt.pickleId,
47
+ uri: pickle?.uri ?? "",
48
+ tags: (pickle?.tags ?? []).map((t) => t.name),
49
+ status,
50
+ durationMs: Math.round(attempt.durationMs),
51
+ ...attempt.error ? { error: attempt.error } : {}
52
+ });
53
+ }
54
+ const report = {
55
+ schemaVersion: 1,
56
+ kind: "databricks-bdd",
57
+ profile: process.env.DBX_TEST_PROFILE ?? "local",
58
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
59
+ success: finished.success ?? !scenarios.some((s) => s.status === "failed"),
60
+ summary,
61
+ scenarios
62
+ };
63
+ this.log(`${JSON.stringify(report, null, 2)}
64
+ `);
65
+ }
66
+ };
67
+ var SECRET_ENV_RE = /(TOKEN|SECRET|PASSWORD|BEARER|KEY)/;
68
+ function redactSecrets(text, env = process.env) {
69
+ let out = text;
70
+ for (const [name, value] of Object.entries(env)) {
71
+ if (!value || value.length < 6) continue;
72
+ if (!SECRET_ENV_RE.test(name)) continue;
73
+ out = out.split(value).join("[REDACTED]");
74
+ }
75
+ return out;
76
+ }
77
+
78
+ export { EvidenceFormatter, redactSecrets };
79
+ //# sourceMappingURL=chunk-LFEU3ZHD.js.map
80
+ //# sourceMappingURL=chunk-LFEU3ZHD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/evidence-formatter.ts"],"names":[],"mappings":"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","file":"chunk-LFEU3ZHD.js","sourcesContent":["import { Formatter, type IFormatterOptions } from '@cucumber/cucumber';\n\n/**\n * Evidence formatter: schema-versioned, secret-redacted JSON summary of a\n * BDD run, shared in spirit with the live check runner's evidence output so\n * nightly runs double as audit artifacts. Wire it up in cucumber config:\n *\n * format: [['@fabricorg/databricks-bdd/evidence-formatter', 'reports/evidence.json']]\n */\nexport interface EvidenceScenario {\n name: string;\n uri: string;\n tags: string[];\n status: string;\n durationMs: number;\n error?: string;\n}\n\nexport interface EvidenceReport {\n schemaVersion: 1;\n kind: 'databricks-bdd';\n profile: string;\n finishedAt: string;\n success: boolean;\n summary: Record<string, number>;\n scenarios: EvidenceScenario[];\n}\n\ninterface Envelope {\n pickle?: { id: string; name: string; uri: string; tags?: { name: string }[] };\n testCase?: { id: string; pickleId: string };\n testCaseStarted?: { id: string; testCaseId: string };\n testStepFinished?: {\n testCaseStartedId: string;\n testStepResult: {\n status: string;\n message?: string;\n duration?: { seconds: number; nanos: number };\n };\n };\n testRunFinished?: { success?: boolean; timestamp?: { seconds: number; nanos: number } };\n}\n\nconst STATUS_RANK = ['UNKNOWN', 'PASSED', 'SKIPPED', 'PENDING', 'UNDEFINED', 'AMBIGUOUS', 'FAILED'];\n\nexport default class EvidenceFormatter extends Formatter {\n private readonly pickles = new Map<string, NonNullable<Envelope['pickle']>>();\n private readonly testCases = new Map<string, string>(); // testCaseId → pickleId\n private readonly attempts = new Map<\n string,\n { pickleId: string; status: string; durationMs: number; error?: string }\n >();\n\n constructor(options: IFormatterOptions) {\n super(options);\n options.eventBroadcaster.on('envelope', (envelope: Envelope) => this.onEnvelope(envelope));\n }\n\n private onEnvelope(envelope: Envelope): void {\n if (envelope.pickle) this.pickles.set(envelope.pickle.id, envelope.pickle);\n if (envelope.testCase) this.testCases.set(envelope.testCase.id, envelope.testCase.pickleId);\n if (envelope.testCaseStarted) {\n this.attempts.set(envelope.testCaseStarted.id, {\n pickleId: this.testCases.get(envelope.testCaseStarted.testCaseId) ?? '',\n status: 'UNKNOWN',\n durationMs: 0,\n });\n }\n if (envelope.testStepFinished) {\n const attempt = this.attempts.get(envelope.testStepFinished.testCaseStartedId);\n if (attempt) {\n const result = envelope.testStepFinished.testStepResult;\n if (STATUS_RANK.indexOf(result.status) > STATUS_RANK.indexOf(attempt.status)) {\n attempt.status = result.status;\n if (result.message) attempt.error = redactSecrets(result.message);\n }\n const d = result.duration;\n if (d) attempt.durationMs += d.seconds * 1000 + d.nanos / 1e6;\n }\n }\n if (envelope.testRunFinished) this.finish(envelope.testRunFinished);\n }\n\n private finish(finished: NonNullable<Envelope['testRunFinished']>): void {\n const scenarios: EvidenceScenario[] = [];\n const summary: Record<string, number> = {};\n for (const attempt of this.attempts.values()) {\n const pickle = this.pickles.get(attempt.pickleId);\n const status = attempt.status.toLowerCase();\n summary[status] = (summary[status] ?? 0) + 1;\n scenarios.push({\n name: pickle?.name ?? attempt.pickleId,\n uri: pickle?.uri ?? '',\n tags: (pickle?.tags ?? []).map((t) => t.name),\n status,\n durationMs: Math.round(attempt.durationMs),\n ...(attempt.error ? { error: attempt.error } : {}),\n });\n }\n const report: EvidenceReport = {\n schemaVersion: 1,\n kind: 'databricks-bdd',\n profile: process.env.DBX_TEST_PROFILE ?? 'local',\n finishedAt: new Date().toISOString(),\n success: finished.success ?? !scenarios.some((s) => s.status === 'failed'),\n summary,\n scenarios,\n };\n this.log(`${JSON.stringify(report, null, 2)}\\n`);\n }\n}\n\nconst SECRET_ENV_RE = /(TOKEN|SECRET|PASSWORD|BEARER|KEY)/;\n\n/** Replace values of secret-looking env vars anywhere they appear in text. */\nexport function redactSecrets(\n text: string,\n env: Record<string, string | undefined> = process.env,\n): string {\n let out = text;\n for (const [name, value] of Object.entries(env)) {\n if (!value || value.length < 6) continue;\n if (!SECRET_ENV_RE.test(name)) continue;\n out = out.split(value).join('[REDACTED]');\n }\n return out;\n}\n"]}
@@ -0,0 +1,42 @@
1
+ import { defineParameterType } from '@cucumber/cucumber';
2
+
3
+ // src/parameter-types.ts
4
+ var RUN_STATES = ["SUCCESS", "FAILED", "CANCELED", "TIMEDOUT"];
5
+ function parseDuration(value) {
6
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|s|minutes?|m)$/i);
7
+ if (!match) throw new Error(`Invalid duration '${value}'`);
8
+ const amount = Number(match[1]);
9
+ const unit = match[2]?.toLowerCase() ?? "";
10
+ if (unit === "ms" || unit.startsWith("millisecond")) return amount;
11
+ if (unit === "s" || unit.startsWith("second")) return amount * 1e3;
12
+ return amount * 6e4;
13
+ }
14
+ function parseTableIdentifier(value) {
15
+ const identifier = value.trim();
16
+ const part = "(?:[A-Za-z_][A-Za-z0-9_]*|`[^`]+`)";
17
+ if (!new RegExp(`^${part}(?:\\.${part}){0,2}$`).test(identifier)) {
18
+ throw new Error(`Invalid 1-3 part table identifier '${value}'`);
19
+ }
20
+ return identifier;
21
+ }
22
+ function registerParameterTypes() {
23
+ defineParameterType({
24
+ name: "runState",
25
+ regexp: /SUCCESS|FAILED|CANCELED|TIMEDOUT/,
26
+ transformer: (value) => value
27
+ });
28
+ defineParameterType({
29
+ name: "duration",
30
+ regexp: /\d+(?:\.\d+)?\s*(?:milliseconds?|ms|seconds?|s|minutes?|m)/,
31
+ transformer: parseDuration
32
+ });
33
+ defineParameterType({
34
+ name: "table",
35
+ regexp: /(?:[A-Za-z_][A-Za-z0-9_]*|`[^`]+`)(?:\.(?:[A-Za-z_][A-Za-z0-9_]*|`[^`]+`)){0,2}/,
36
+ transformer: parseTableIdentifier
37
+ });
38
+ }
39
+
40
+ export { RUN_STATES, parseDuration, parseTableIdentifier, registerParameterTypes };
41
+ //# sourceMappingURL=chunk-YNOLRERF.js.map
42
+ //# sourceMappingURL=chunk-YNOLRERF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/parameter-types.ts"],"names":[],"mappings":";;;AAEO,IAAM,UAAA,GAAa,CAAC,SAAA,EAAW,QAAA,EAAU,YAAY,UAAU;AAG/D,SAAS,cAAc,KAAA,EAAuB;AACnD,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,IAAA,EAAK,CAAE,MAAM,+DAA+D,CAAA;AAChG,EAAA,IAAI,CAAC,KAAA,EAAO,MAAM,IAAI,KAAA,CAAM,CAAA,kBAAA,EAAqB,KAAK,CAAA,CAAA,CAAG,CAAA;AACzD,EAAA,MAAM,MAAA,GAAS,MAAA,CAAO,KAAA,CAAM,CAAC,CAAC,CAAA;AAC9B,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,CAAC,CAAA,EAAG,aAAY,IAAK,EAAA;AACxC,EAAA,IAAI,SAAS,IAAA,IAAQ,IAAA,CAAK,UAAA,CAAW,aAAa,GAAG,OAAO,MAAA;AAC5D,EAAA,IAAI,SAAS,GAAA,IAAO,IAAA,CAAK,WAAW,QAAQ,CAAA,SAAU,MAAA,GAAS,GAAA;AAC/D,EAAA,OAAO,MAAA,GAAS,GAAA;AAClB;AAEO,SAAS,qBAAqB,KAAA,EAAuB;AAC1D,EAAA,MAAM,UAAA,GAAa,MAAM,IAAA,EAAK;AAC9B,EAAA,MAAM,IAAA,GAAO,oCAAA;AACb,EAAA,IAAI,CAAC,IAAI,MAAA,CAAO,CAAA,CAAA,EAAI,IAAI,CAAA,MAAA,EAAS,IAAI,CAAA,OAAA,CAAS,CAAA,CAAE,IAAA,CAAK,UAAU,CAAA,EAAG;AAChE,IAAA,MAAM,IAAI,KAAA,CAAM,CAAA,mCAAA,EAAsC,KAAK,CAAA,CAAA,CAAG,CAAA;AAAA,EAChE;AACA,EAAA,OAAO,UAAA;AACT;AAEO,SAAS,sBAAA,GAA+B;AAC7C,EAAA,mBAAA,CAAoB;AAAA,IAClB,IAAA,EAAM,UAAA;AAAA,IACN,MAAA,EAAQ,kCAAA;AAAA,IACR,WAAA,EAAa,CAAC,KAAA,KAA4B;AAAA,GAC3C,CAAA;AAED,EAAA,mBAAA,CAAoB;AAAA,IAClB,IAAA,EAAM,UAAA;AAAA,IACN,MAAA,EAAQ,4DAAA;AAAA,IACR,WAAA,EAAa;AAAA,GACd,CAAA;AAED,EAAA,mBAAA,CAAoB;AAAA,IAClB,IAAA,EAAM,OAAA;AAAA,IACN,MAAA,EAAQ,iFAAA;AAAA,IACR,WAAA,EAAa;AAAA,GACd,CAAA;AACH","file":"chunk-YNOLRERF.js","sourcesContent":["import { defineParameterType } from '@cucumber/cucumber';\n\nexport const RUN_STATES = ['SUCCESS', 'FAILED', 'CANCELED', 'TIMEDOUT'] as const;\nexport type RunState = (typeof RUN_STATES)[number];\n\nexport function parseDuration(value: string): number {\n const match = value.trim().match(/^(\\d+(?:\\.\\d+)?)\\s*(milliseconds?|ms|seconds?|s|minutes?|m)$/i);\n if (!match) throw new Error(`Invalid duration '${value}'`);\n const amount = Number(match[1]);\n const unit = match[2]?.toLowerCase() ?? '';\n if (unit === 'ms' || unit.startsWith('millisecond')) return amount;\n if (unit === 's' || unit.startsWith('second')) return amount * 1_000;\n return amount * 60_000;\n}\n\nexport function parseTableIdentifier(value: string): string {\n const identifier = value.trim();\n const part = '(?:[A-Za-z_][A-Za-z0-9_]*|`[^`]+`)';\n if (!new RegExp(`^${part}(?:\\\\.${part}){0,2}$`).test(identifier)) {\n throw new Error(`Invalid 1-3 part table identifier '${value}'`);\n }\n return identifier;\n}\n\nexport function registerParameterTypes(): void {\n defineParameterType({\n name: 'runState',\n regexp: /SUCCESS|FAILED|CANCELED|TIMEDOUT/,\n transformer: (value: string): RunState => value as RunState,\n });\n\n defineParameterType({\n name: 'duration',\n regexp: /\\d+(?:\\.\\d+)?\\s*(?:milliseconds?|ms|seconds?|s|minutes?|m)/,\n transformer: parseDuration,\n });\n\n defineParameterType({\n name: 'table',\n regexp: /(?:[A-Za-z_][A-Za-z0-9_]*|`[^`]+`)(?:\\.(?:[A-Za-z_][A-Za-z0-9_]*|`[^`]+`)){0,2}/,\n transformer: parseTableIdentifier,\n });\n}\n"]}
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var cucumber = require('@cucumber/cucumber');
6
+
7
+ // src/evidence-formatter.ts
8
+ var STATUS_RANK = ["UNKNOWN", "PASSED", "SKIPPED", "PENDING", "UNDEFINED", "AMBIGUOUS", "FAILED"];
9
+ var EvidenceFormatter = class extends cucumber.Formatter {
10
+ pickles = /* @__PURE__ */ new Map();
11
+ testCases = /* @__PURE__ */ new Map();
12
+ // testCaseId → pickleId
13
+ attempts = /* @__PURE__ */ new Map();
14
+ constructor(options) {
15
+ super(options);
16
+ options.eventBroadcaster.on("envelope", (envelope) => this.onEnvelope(envelope));
17
+ }
18
+ onEnvelope(envelope) {
19
+ if (envelope.pickle) this.pickles.set(envelope.pickle.id, envelope.pickle);
20
+ if (envelope.testCase) this.testCases.set(envelope.testCase.id, envelope.testCase.pickleId);
21
+ if (envelope.testCaseStarted) {
22
+ this.attempts.set(envelope.testCaseStarted.id, {
23
+ pickleId: this.testCases.get(envelope.testCaseStarted.testCaseId) ?? "",
24
+ status: "UNKNOWN",
25
+ durationMs: 0
26
+ });
27
+ }
28
+ if (envelope.testStepFinished) {
29
+ const attempt = this.attempts.get(envelope.testStepFinished.testCaseStartedId);
30
+ if (attempt) {
31
+ const result = envelope.testStepFinished.testStepResult;
32
+ if (STATUS_RANK.indexOf(result.status) > STATUS_RANK.indexOf(attempt.status)) {
33
+ attempt.status = result.status;
34
+ if (result.message) attempt.error = redactSecrets(result.message);
35
+ }
36
+ const d = result.duration;
37
+ if (d) attempt.durationMs += d.seconds * 1e3 + d.nanos / 1e6;
38
+ }
39
+ }
40
+ if (envelope.testRunFinished) this.finish(envelope.testRunFinished);
41
+ }
42
+ finish(finished) {
43
+ const scenarios = [];
44
+ const summary = {};
45
+ for (const attempt of this.attempts.values()) {
46
+ const pickle = this.pickles.get(attempt.pickleId);
47
+ const status = attempt.status.toLowerCase();
48
+ summary[status] = (summary[status] ?? 0) + 1;
49
+ scenarios.push({
50
+ name: pickle?.name ?? attempt.pickleId,
51
+ uri: pickle?.uri ?? "",
52
+ tags: (pickle?.tags ?? []).map((t) => t.name),
53
+ status,
54
+ durationMs: Math.round(attempt.durationMs),
55
+ ...attempt.error ? { error: attempt.error } : {}
56
+ });
57
+ }
58
+ const report = {
59
+ schemaVersion: 1,
60
+ kind: "databricks-bdd",
61
+ profile: process.env.DBX_TEST_PROFILE ?? "local",
62
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
63
+ success: finished.success ?? !scenarios.some((s) => s.status === "failed"),
64
+ summary,
65
+ scenarios
66
+ };
67
+ this.log(`${JSON.stringify(report, null, 2)}
68
+ `);
69
+ }
70
+ };
71
+ var SECRET_ENV_RE = /(TOKEN|SECRET|PASSWORD|BEARER|KEY)/;
72
+ function redactSecrets(text, env = process.env) {
73
+ let out = text;
74
+ for (const [name, value] of Object.entries(env)) {
75
+ if (!value || value.length < 6) continue;
76
+ if (!SECRET_ENV_RE.test(name)) continue;
77
+ out = out.split(value).join("[REDACTED]");
78
+ }
79
+ return out;
80
+ }
81
+
82
+ exports.default = EvidenceFormatter;
83
+ exports.redactSecrets = redactSecrets;
84
+ //# sourceMappingURL=evidence-formatter.cjs.map
85
+ //# sourceMappingURL=evidence-formatter.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/evidence-formatter.ts"],"names":["Formatter"],"mappings":"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","file":"evidence-formatter.cjs","sourcesContent":["import { Formatter, type IFormatterOptions } from '@cucumber/cucumber';\n\n/**\n * Evidence formatter: schema-versioned, secret-redacted JSON summary of a\n * BDD run, shared in spirit with the live check runner's evidence output so\n * nightly runs double as audit artifacts. Wire it up in cucumber config:\n *\n * format: [['@fabricorg/databricks-bdd/evidence-formatter', 'reports/evidence.json']]\n */\nexport interface EvidenceScenario {\n name: string;\n uri: string;\n tags: string[];\n status: string;\n durationMs: number;\n error?: string;\n}\n\nexport interface EvidenceReport {\n schemaVersion: 1;\n kind: 'databricks-bdd';\n profile: string;\n finishedAt: string;\n success: boolean;\n summary: Record<string, number>;\n scenarios: EvidenceScenario[];\n}\n\ninterface Envelope {\n pickle?: { id: string; name: string; uri: string; tags?: { name: string }[] };\n testCase?: { id: string; pickleId: string };\n testCaseStarted?: { id: string; testCaseId: string };\n testStepFinished?: {\n testCaseStartedId: string;\n testStepResult: {\n status: string;\n message?: string;\n duration?: { seconds: number; nanos: number };\n };\n };\n testRunFinished?: { success?: boolean; timestamp?: { seconds: number; nanos: number } };\n}\n\nconst STATUS_RANK = ['UNKNOWN', 'PASSED', 'SKIPPED', 'PENDING', 'UNDEFINED', 'AMBIGUOUS', 'FAILED'];\n\nexport default class EvidenceFormatter extends Formatter {\n private readonly pickles = new Map<string, NonNullable<Envelope['pickle']>>();\n private readonly testCases = new Map<string, string>(); // testCaseId → pickleId\n private readonly attempts = new Map<\n string,\n { pickleId: string; status: string; durationMs: number; error?: string }\n >();\n\n constructor(options: IFormatterOptions) {\n super(options);\n options.eventBroadcaster.on('envelope', (envelope: Envelope) => this.onEnvelope(envelope));\n }\n\n private onEnvelope(envelope: Envelope): void {\n if (envelope.pickle) this.pickles.set(envelope.pickle.id, envelope.pickle);\n if (envelope.testCase) this.testCases.set(envelope.testCase.id, envelope.testCase.pickleId);\n if (envelope.testCaseStarted) {\n this.attempts.set(envelope.testCaseStarted.id, {\n pickleId: this.testCases.get(envelope.testCaseStarted.testCaseId) ?? '',\n status: 'UNKNOWN',\n durationMs: 0,\n });\n }\n if (envelope.testStepFinished) {\n const attempt = this.attempts.get(envelope.testStepFinished.testCaseStartedId);\n if (attempt) {\n const result = envelope.testStepFinished.testStepResult;\n if (STATUS_RANK.indexOf(result.status) > STATUS_RANK.indexOf(attempt.status)) {\n attempt.status = result.status;\n if (result.message) attempt.error = redactSecrets(result.message);\n }\n const d = result.duration;\n if (d) attempt.durationMs += d.seconds * 1000 + d.nanos / 1e6;\n }\n }\n if (envelope.testRunFinished) this.finish(envelope.testRunFinished);\n }\n\n private finish(finished: NonNullable<Envelope['testRunFinished']>): void {\n const scenarios: EvidenceScenario[] = [];\n const summary: Record<string, number> = {};\n for (const attempt of this.attempts.values()) {\n const pickle = this.pickles.get(attempt.pickleId);\n const status = attempt.status.toLowerCase();\n summary[status] = (summary[status] ?? 0) + 1;\n scenarios.push({\n name: pickle?.name ?? attempt.pickleId,\n uri: pickle?.uri ?? '',\n tags: (pickle?.tags ?? []).map((t) => t.name),\n status,\n durationMs: Math.round(attempt.durationMs),\n ...(attempt.error ? { error: attempt.error } : {}),\n });\n }\n const report: EvidenceReport = {\n schemaVersion: 1,\n kind: 'databricks-bdd',\n profile: process.env.DBX_TEST_PROFILE ?? 'local',\n finishedAt: new Date().toISOString(),\n success: finished.success ?? !scenarios.some((s) => s.status === 'failed'),\n summary,\n scenarios,\n };\n this.log(`${JSON.stringify(report, null, 2)}\\n`);\n }\n}\n\nconst SECRET_ENV_RE = /(TOKEN|SECRET|PASSWORD|BEARER|KEY)/;\n\n/** Replace values of secret-looking env vars anywhere they appear in text. */\nexport function redactSecrets(\n text: string,\n env: Record<string, string | undefined> = process.env,\n): string {\n let out = text;\n for (const [name, value] of Object.entries(env)) {\n if (!value || value.length < 6) continue;\n if (!SECRET_ENV_RE.test(name)) continue;\n out = out.split(value).join('[REDACTED]');\n }\n return out;\n}\n"]}
@@ -0,0 +1,38 @@
1
+ import { Formatter, IFormatterOptions } from '@cucumber/cucumber';
2
+
3
+ /**
4
+ * Evidence formatter: schema-versioned, secret-redacted JSON summary of a
5
+ * BDD run, shared in spirit with the live check runner's evidence output so
6
+ * nightly runs double as audit artifacts. Wire it up in cucumber config:
7
+ *
8
+ * format: [['@fabricorg/databricks-bdd/evidence-formatter', 'reports/evidence.json']]
9
+ */
10
+ interface EvidenceScenario {
11
+ name: string;
12
+ uri: string;
13
+ tags: string[];
14
+ status: string;
15
+ durationMs: number;
16
+ error?: string;
17
+ }
18
+ interface EvidenceReport {
19
+ schemaVersion: 1;
20
+ kind: 'databricks-bdd';
21
+ profile: string;
22
+ finishedAt: string;
23
+ success: boolean;
24
+ summary: Record<string, number>;
25
+ scenarios: EvidenceScenario[];
26
+ }
27
+ declare class EvidenceFormatter extends Formatter {
28
+ private readonly pickles;
29
+ private readonly testCases;
30
+ private readonly attempts;
31
+ constructor(options: IFormatterOptions);
32
+ private onEnvelope;
33
+ private finish;
34
+ }
35
+ /** Replace values of secret-looking env vars anywhere they appear in text. */
36
+ declare function redactSecrets(text: string, env?: Record<string, string | undefined>): string;
37
+
38
+ export { type EvidenceReport, type EvidenceScenario, EvidenceFormatter as default, redactSecrets };