@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/dist/steps.cjs ADDED
@@ -0,0 +1,689 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+ var promises = require('fs/promises');
5
+ var path = require('path');
6
+ var cucumber = require('@cucumber/cucumber');
7
+ var databricksTestkit = require('@fabricorg/databricks-testkit');
8
+ var databricks = require('@fabric-harness/databricks');
9
+
10
+ // src/steps.ts
11
+ var SECRET_ENV_RE = /(TOKEN|SECRET|PASSWORD|BEARER|KEY)/;
12
+ function redactSecrets(text, env = process.env) {
13
+ let out = text;
14
+ for (const [name, value] of Object.entries(env)) {
15
+ if (!value || value.length < 6) continue;
16
+ if (!SECRET_ENV_RE.test(name)) continue;
17
+ out = out.split(value).join("[REDACTED]");
18
+ }
19
+ return out;
20
+ }
21
+
22
+ // src/infer.ts
23
+ var INT_RE = /^-?\d+$/;
24
+ var FLOAT_RE = /^-?\d+\.\d+$/;
25
+ var TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?/;
26
+ var BOOL_RE = /^(true|false)$/i;
27
+ function inferFixture(table, raw) {
28
+ if (raw.length < 2) {
29
+ throw new Error(`Data table for ${table} needs a header row and at least one data row`);
30
+ }
31
+ const [header, ...body] = raw;
32
+ const types = header.map((_, col) => inferColumnType(body.map((row) => row[col] ?? "")));
33
+ const schema = header.map((name, i) => `"${name}" ${types[i]}`).join(", ");
34
+ const rows = body.map((row) => row.map((cell, i) => coerceCell(cell, types[i] ?? "STRING")));
35
+ return { table, schema, rows };
36
+ }
37
+ function coerceMatchRows(raw) {
38
+ if (raw.length < 2) {
39
+ throw new Error("Match table needs a header row and at least one data row");
40
+ }
41
+ const [header, ...body] = raw;
42
+ return body.map((row) => {
43
+ const out = {};
44
+ header.forEach((name, i) => {
45
+ out[name] = coerceCell(row[i] ?? "", inferColumnType([row[i] ?? ""]));
46
+ });
47
+ return out;
48
+ });
49
+ }
50
+ function inferColumnType(cells) {
51
+ const present = cells.filter((c) => c !== "");
52
+ if (present.length === 0) return "STRING";
53
+ if (present.every((c) => INT_RE.test(c))) return "BIGINT";
54
+ if (present.every((c) => FLOAT_RE.test(c) || INT_RE.test(c))) return "DOUBLE";
55
+ if (present.every((c) => TIMESTAMP_RE.test(c))) return "TIMESTAMP";
56
+ if (present.every((c) => BOOL_RE.test(c))) return "BOOLEAN";
57
+ return "STRING";
58
+ }
59
+ function coerceCell(cell, type) {
60
+ if (cell === "") return null;
61
+ if (type === "BIGINT" || type === "DOUBLE") return Number(cell);
62
+ if (type === "BOOLEAN") return /^true$/i.test(cell);
63
+ return cell;
64
+ }
65
+ async function resolveJobId(client, nameOrId) {
66
+ if (/^\d+$/.test(nameOrId)) return Number(nameOrId);
67
+ const response = await client.get(
68
+ "/api/2.1/jobs/list",
69
+ { name: nameOrId }
70
+ );
71
+ const match = (response.jobs ?? []).find((j) => j.settings?.name === nameOrId);
72
+ if (!match) throw new Error(`No Databricks job named '${nameOrId}' found`);
73
+ return match.job_id;
74
+ }
75
+ async function runJobToTerminal(client, nameOrId, options = {}) {
76
+ const jobId = await resolveJobId(client, nameOrId);
77
+ const submitted = await client.post("/api/2.1/jobs/run-now", {
78
+ job_id: jobId
79
+ });
80
+ const run = await databricks.waitForDatabricksRun(client, submitted.run_id, {
81
+ pollIntervalMs: options.pollIntervalMs,
82
+ timeoutMs: options.timeoutMs
83
+ });
84
+ const state = run.state;
85
+ return {
86
+ runId: submitted.run_id,
87
+ lifeCycleState: state?.life_cycle_state ?? "UNKNOWN",
88
+ resultState: state?.result_state ?? "UNKNOWN",
89
+ run
90
+ };
91
+ }
92
+ function parseDuration(value) {
93
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|s|minutes?|m)$/i);
94
+ if (!match) throw new Error(`Invalid duration '${value}'`);
95
+ const amount = Number(match[1]);
96
+ const unit = match[2]?.toLowerCase() ?? "";
97
+ if (unit === "ms" || unit.startsWith("millisecond")) return amount;
98
+ if (unit === "s" || unit.startsWith("second")) return amount * 1e3;
99
+ return amount * 6e4;
100
+ }
101
+ function parseTableIdentifier(value) {
102
+ const identifier = value.trim();
103
+ const part = "(?:[A-Za-z_][A-Za-z0-9_]*|`[^`]+`)";
104
+ if (!new RegExp(`^${part}(?:\\.${part}){0,2}$`).test(identifier)) {
105
+ throw new Error(`Invalid 1-3 part table identifier '${value}'`);
106
+ }
107
+ return identifier;
108
+ }
109
+ function registerParameterTypes() {
110
+ cucumber.defineParameterType({
111
+ name: "runState",
112
+ regexp: /SUCCESS|FAILED|CANCELED|TIMEDOUT/,
113
+ transformer: (value) => value
114
+ });
115
+ cucumber.defineParameterType({
116
+ name: "duration",
117
+ regexp: /\d+(?:\.\d+)?\s*(?:milliseconds?|ms|seconds?|s|minutes?|m)/,
118
+ transformer: parseDuration
119
+ });
120
+ cucumber.defineParameterType({
121
+ name: "table",
122
+ regexp: /(?:[A-Za-z_][A-Za-z0-9_]*|`[^`]+`)(?:\.(?:[A-Za-z_][A-Za-z0-9_]*|`[^`]+`)){0,2}/,
123
+ transformer: parseTableIdentifier
124
+ });
125
+ }
126
+ async function resolvePipelineId(client, nameOrId) {
127
+ if (/^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(nameOrId)) return nameOrId;
128
+ const response = await client.get(
129
+ "/api/2.0/pipelines",
130
+ { filter: `name LIKE '${nameOrId}'` }
131
+ );
132
+ const match = (response.statuses ?? []).find((p) => p.name === nameOrId);
133
+ if (!match) throw new Error(`No Databricks pipeline named '${nameOrId}' found`);
134
+ return match.pipeline_id;
135
+ }
136
+ async function startPipelineUpdate(client, pipelineId, options = {}) {
137
+ const response = await client.post(
138
+ `/api/2.0/pipelines/${encodeURIComponent(pipelineId)}/updates`,
139
+ { full_refresh: options.fullRefresh ?? false }
140
+ );
141
+ return response.update_id;
142
+ }
143
+
144
+ // src/profile.ts
145
+ var LIVE_ONLY_TAGS = [
146
+ "@live",
147
+ "@jobs",
148
+ "@dlt",
149
+ "@pipeline",
150
+ "@autoloader",
151
+ "@lakebase",
152
+ "@slow"
153
+ ];
154
+ function liveOnlyReason(tags, profile) {
155
+ if (profile === "live") return null;
156
+ const blocking = tags.filter((t) => LIVE_ONLY_TAGS.includes(t));
157
+ if (blocking.length === 0) return null;
158
+ return `requires a live workspace (${blocking.join(", ")}); running under DBX_TEST_PROFILE=local`;
159
+ }
160
+ var DatabricksWorld = class extends cucumber.World {
161
+ profile;
162
+ /** Rows from the last `When I run the SQL` / `When I query table` step. */
163
+ lastRows = null;
164
+ /** Error captured from the last statement, for `Then the statement fails ...`. */
165
+ lastError = null;
166
+ /** Terminal run record from the last `When I run job ...` step. */
167
+ lastRun = null;
168
+ /** Pipeline update handle from `When I start a full refresh of pipeline ...`. */
169
+ lastPipelineUpdate = null;
170
+ schemaOverride;
171
+ catalogOverride;
172
+ lastSql;
173
+ lastStatementId;
174
+ lakebasePool;
175
+ ctx;
176
+ activeCtx;
177
+ cleanups = [];
178
+ constructor(options) {
179
+ super(options);
180
+ this.profile = databricksTestkit.resolveProfile();
181
+ }
182
+ async context() {
183
+ if (this.activeCtx) return this.activeCtx;
184
+ if (!this.ctx) {
185
+ const env = this.catalogOverride ? { ...process.env, DBX_TEST_CATALOG: this.catalogOverride } : process.env;
186
+ this.ctx = await databricksTestkit.createExecutionContext({
187
+ profile: this.profile,
188
+ env,
189
+ schema: this.schemaOverride ?? this.parameters.schema,
190
+ onStatement: ({ sql, statementId }) => {
191
+ this.lastSql = sql;
192
+ this.lastStatementId = statementId;
193
+ }
194
+ });
195
+ }
196
+ return this.ctx;
197
+ }
198
+ /** Route subsequent steps through a scenario-owned secondary identity. */
199
+ useExecutionContext(context) {
200
+ if (this.activeCtx) throw new Error("A secondary execution context is already active");
201
+ this.activeCtx = context;
202
+ this.addCleanup(async () => {
203
+ await context.close();
204
+ this.activeCtx = void 0;
205
+ });
206
+ }
207
+ /** Register scenario-scoped cleanup. Cleanups run in reverse order. */
208
+ addCleanup(cleanup) {
209
+ this.cleanups.push(cleanup);
210
+ }
211
+ /** Resolve runner userdata from worldParameters first, then DBX_TEST_USERDATA_* env. */
212
+ userdata(name) {
213
+ const configured = this.parameters.userdata?.[name];
214
+ if (configured !== void 0) return configured;
215
+ return process.env[`DBX_TEST_USERDATA_${name.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`];
216
+ }
217
+ scopeTo(catalog, schema) {
218
+ if (this.ctx) {
219
+ throw new Error(
220
+ "Given catalog/schema must run before any step that touches the warehouse in the scenario"
221
+ );
222
+ }
223
+ this.catalogOverride = this.profile === "live" ? process.env.DBX_TEST_CATALOG ?? catalog : catalog;
224
+ this.schemaOverride = this.profile === "live" ? process.env.DBX_TEST_SCHEMA ?? schema : schema;
225
+ }
226
+ async dispose() {
227
+ const errors = [];
228
+ for (const cleanup of this.cleanups.splice(0).reverse()) {
229
+ try {
230
+ await cleanup();
231
+ } catch (error) {
232
+ errors.push(error);
233
+ }
234
+ }
235
+ try {
236
+ await this.ctx?.close();
237
+ } catch (error) {
238
+ errors.push(error);
239
+ }
240
+ this.ctx = void 0;
241
+ if (errors.length > 0) throw new AggregateError(errors, "Scenario cleanup failed");
242
+ }
243
+ };
244
+
245
+ // src/steps.ts
246
+ cucumber.setWorldConstructor(DatabricksWorld);
247
+ cucumber.setDefaultTimeout(6e4);
248
+ registerParameterTypes();
249
+ cucumber.BeforeAll(async () => {
250
+ if (databricksTestkit.resolveProfile() !== "live") return;
251
+ const context = await databricksTestkit.createExecutionContext({ profile: "live" });
252
+ try {
253
+ const rows = await context.query("SELECT 1 AS dbx_bdd_preflight");
254
+ if (Number(rows[0]?.dbx_bdd_preflight) !== 1) {
255
+ throw new Error("Databricks BDD preflight returned an unexpected value");
256
+ }
257
+ } finally {
258
+ await context.close();
259
+ }
260
+ });
261
+ cucumber.Before(function({ pickle }) {
262
+ const tags = pickle.tags.map((t) => t.name);
263
+ const reason = liveOnlyReason(tags, this.profile);
264
+ if (reason) return "skipped";
265
+ return void 0;
266
+ });
267
+ cucumber.After(async function() {
268
+ await this.dispose();
269
+ });
270
+ cucumber.AfterStep(async function({ result, pickleStep }) {
271
+ if (result.status !== "FAILED") return;
272
+ const evidence = {
273
+ step: pickleStep.text,
274
+ ...this.lastStatementId ? { statementId: this.lastStatementId } : {},
275
+ ...this.lastSql ? { sql: redactSecrets(this.lastSql).slice(0, 4e3) } : {}
276
+ };
277
+ await this.attach(JSON.stringify(evidence, null, 2), "application/json");
278
+ });
279
+ cucumber.Given("a Databricks workspace", async function() {
280
+ if (this.profile === "live") await this.context();
281
+ });
282
+ cucumber.Given("a Lakebase database", async function() {
283
+ if (this.profile !== "live") {
284
+ throw new Error("Lakebase steps require DBX_TEST_PROFILE=live and the @lakebase tag");
285
+ }
286
+ const provider = databricksTestkit.createLakebaseTestProvider(process.env);
287
+ this.lakebasePool = provider.getPool();
288
+ this.addCleanup(async () => {
289
+ this.lakebasePool = void 0;
290
+ await provider.close();
291
+ });
292
+ await this.lakebasePool.query("SELECT 1 AS dbx_bdd_lakebase");
293
+ });
294
+ cucumber.Given(
295
+ "catalog {string} and schema {string}",
296
+ function(catalog, schema) {
297
+ this.scopeTo(catalog, schema);
298
+ }
299
+ );
300
+ cucumber.Given(
301
+ "a table {string} with:",
302
+ async function(table, data) {
303
+ const ctx = await this.context();
304
+ await ctx.loadFixture(inferFixture(table, data.raw()));
305
+ }
306
+ );
307
+ cucumber.Given(
308
+ "a table {string} with schema {string} and rows:",
309
+ async function(table, schema, data) {
310
+ const ctx = await this.context();
311
+ await ctx.loadFixture({ table, schema, rows: data.hashes() });
312
+ }
313
+ );
314
+ cucumber.Given(
315
+ "an empty table {string} with schema {string}",
316
+ async function(table, schema) {
317
+ const ctx = await this.context();
318
+ await ctx.loadFixture({ table, schema, rows: [] });
319
+ }
320
+ );
321
+ cucumber.Given(
322
+ "a table {string} with schema {string} from fixture {string}",
323
+ async function(table, schema, ndjsonFile) {
324
+ const ctx = await this.context();
325
+ await ctx.loadFixture({ table, schema, ndjsonFile });
326
+ }
327
+ );
328
+ cucumber.Given(
329
+ "fixture files uploaded to volume {string}:",
330
+ async function(configuredVolume, data) {
331
+ if (this.profile !== "live") {
332
+ throw new Error("Volume upload steps require DBX_TEST_PROFILE=live and the @live tag");
333
+ }
334
+ const volumeValue = configuredVolume.startsWith("$") ? process.env[configuredVolume.slice(1)] : configuredVolume;
335
+ if (!volumeValue) throw new Error(`Volume '${configuredVolume}' is not configured`);
336
+ const root = databricksTestkit.normalizeVolumeRoot(volumeValue);
337
+ const rows = data.hashes();
338
+ const uploadPrefix = rows[0]?.prefix?.trim() ?? "";
339
+ if (uploadPrefix && !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(uploadPrefix)) {
340
+ throw new Error(`Invalid volume fixture prefix '${uploadPrefix}'`);
341
+ }
342
+ if (rows.some((row) => (row.prefix?.trim() ?? "") !== uploadPrefix)) {
343
+ throw new Error("All fixture rows in one upload step must use the same prefix");
344
+ }
345
+ const runPrefix = `${(process.env.DBX_TEST_ENV_NAME ?? "bdd").replace(/[^A-Za-z0-9_.-]/g, "_")}/${crypto.randomUUID()}`;
346
+ const directory = `${root}${uploadPrefix ? `/${uploadPrefix}` : ""}/${runPrefix}`;
347
+ await databricksTestkit.ensureVolumeDirectory(process.env, directory);
348
+ const paths = rows.map((row) => row.path).filter((path) => Boolean(path));
349
+ if (paths.length === 0) throw new Error("Fixture upload table requires a path column");
350
+ for (const source of paths) {
351
+ const absolute = path.resolve(process.cwd(), source);
352
+ const fromCwd = path.relative(process.cwd(), absolute);
353
+ if (fromCwd.startsWith("..") || path.isAbsolute(fromCwd)) {
354
+ throw new Error(`Fixture path escapes the feature workspace: ${source}`);
355
+ }
356
+ const destination = `${directory}/${path.basename(source)}`;
357
+ await databricksTestkit.uploadVolumeFile(process.env, destination, new Uint8Array(await promises.readFile(absolute)));
358
+ this.addCleanup(() => databricksTestkit.deleteVolumeFile(process.env, destination));
359
+ }
360
+ }
361
+ );
362
+ cucumber.Given(
363
+ "service principal {string} with grants:",
364
+ async function(principalKey, data) {
365
+ if (this.profile !== "live") {
366
+ throw new Error("Service-principal scenarios require DBX_TEST_PROFILE=live and @live");
367
+ }
368
+ const envPrefix = `DBX_TEST_PRINCIPAL_${principalKey.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`;
369
+ const clientId = process.env[`${envPrefix}_CLIENT_ID`];
370
+ const clientSecret = process.env[`${envPrefix}_CLIENT_SECRET`];
371
+ const principalName = process.env[`${envPrefix}_NAME`] ?? principalKey;
372
+ if (!clientId || !clientSecret) {
373
+ throw new Error(`Missing ${envPrefix}_CLIENT_ID and ${envPrefix}_CLIENT_SECRET`);
374
+ }
375
+ const primary = await this.context();
376
+ const grants = data.hashes().map((row) => normalizeGrant(row));
377
+ if (grants.length === 0) throw new Error("Grant table contains no rows");
378
+ const quotedPrincipal = `\`${principalName.replaceAll("`", "``")}\``;
379
+ for (const grant of grants) {
380
+ await primary.exec(
381
+ `GRANT ${grant.privileges.join(", ")} ON ${grant.securableType} ${grant.securable} TO ${quotedPrincipal}`
382
+ );
383
+ }
384
+ this.addCleanup(async () => {
385
+ for (const grant of [...grants].reverse()) {
386
+ await primary.exec(
387
+ `REVOKE ${grant.privileges.join(", ")} ON ${grant.securableType} ${grant.securable} FROM ${quotedPrincipal}`
388
+ );
389
+ }
390
+ });
391
+ const secondaryEnv = {
392
+ ...process.env,
393
+ DATABRICKS_BEARER: void 0,
394
+ DATABRICKS_TOKEN: void 0,
395
+ DATABRICKS_CLIENT_ID: clientId,
396
+ DATABRICKS_CLIENT_SECRET: clientSecret
397
+ };
398
+ const secondary = await databricksTestkit.createExecutionContext({
399
+ profile: "live",
400
+ env: secondaryEnv,
401
+ schema: this.schemaOverride ?? this.parameters.schema,
402
+ onStatement: ({ sql, statementId }) => {
403
+ this.lastSql = sql;
404
+ this.lastStatementId = statementId;
405
+ }
406
+ });
407
+ this.useExecutionContext(secondary);
408
+ }
409
+ );
410
+ cucumber.When("I run the SQL:", async function(sql) {
411
+ const ctx = await this.context();
412
+ this.lastSql = sql;
413
+ this.lastError = null;
414
+ this.lastRows = null;
415
+ try {
416
+ this.lastRows = await ctx.query(sql);
417
+ } catch (error) {
418
+ this.lastError = error instanceof Error ? error : new Error(String(error));
419
+ }
420
+ });
421
+ cucumber.When("I query Lakebase:", async function(sql) {
422
+ if (!this.lakebasePool) {
423
+ throw new Error("No Lakebase database in this scenario \u2014 add Given a Lakebase database");
424
+ }
425
+ this.lastSql = sql;
426
+ this.lastError = null;
427
+ this.lastRows = null;
428
+ try {
429
+ const result = await this.lakebasePool.query(sql);
430
+ this.lastRows = result.rows;
431
+ } catch (error) {
432
+ this.lastError = error instanceof Error ? error : new Error(String(error));
433
+ }
434
+ });
435
+ cucumber.When("I query table {string}", async function(table) {
436
+ const ctx = await this.context();
437
+ this.lastRows = await ctx.query(`SELECT * FROM ${ctx.qual(table)}`);
438
+ });
439
+ cucumber.When("I run job {string}", async function(nameOrId) {
440
+ const client = databricksTestkit.createClientFromEnv(process.env);
441
+ const result = await runJobToTerminal(client, nameOrId);
442
+ this.lastRun = { result_state: result.resultState, life_cycle_state: result.lifeCycleState };
443
+ });
444
+ cucumber.When("I submit notebook {string}", async function(notebookPath) {
445
+ const client = databricksTestkit.createClientFromEnv(process.env);
446
+ await databricksTestkit.notebookSubmitCheck().run({
447
+ env: { ...process.env, DBX_TEST_NOTEBOOK_PATH: notebookPath },
448
+ warehouse: await this.context(),
449
+ client
450
+ });
451
+ this.lastRun = { result_state: "SUCCESS", life_cycle_state: "TERMINATED" };
452
+ });
453
+ cucumber.When("I run dbt build for {string}", async function(project) {
454
+ const client = databricksTestkit.createClientFromEnv(process.env);
455
+ await databricksTestkit.dbtBuildCheck().run({
456
+ env: { ...process.env, DBX_TEST_DBT_PROJECT_DIR: project },
457
+ warehouse: await this.context(),
458
+ client
459
+ });
460
+ this.lastRun = { result_state: "SUCCESS", life_cycle_state: "TERMINATED" };
461
+ });
462
+ cucumber.When(
463
+ "I start a full refresh of pipeline {string}",
464
+ async function(nameOrId) {
465
+ const isolated = process.env.DBX_TEST_AUTOLOADER_PIPELINE_ID;
466
+ const requested = nameOrId.startsWith("$") ? process.env[nameOrId.slice(1)] : nameOrId;
467
+ if (!isolated || !requested || requested !== isolated) {
468
+ throw new Error(
469
+ "Full refresh is restricted to DBX_TEST_AUTOLOADER_PIPELINE_ID; production pipelines are never full-refreshed by BDD tests"
470
+ );
471
+ }
472
+ const client = databricksTestkit.createClientFromEnv(process.env);
473
+ const pipelineId = await resolvePipelineId(client, requested);
474
+ const updateId = await startPipelineUpdate(client, pipelineId, { fullRefresh: true });
475
+ this.lastPipelineUpdate = { pipelineId, updateId };
476
+ }
477
+ );
478
+ cucumber.When(
479
+ "I start a refresh of pipeline {string}",
480
+ async function(nameOrId) {
481
+ const requested = nameOrId.startsWith("$") ? process.env[nameOrId.slice(1)] : nameOrId;
482
+ if (!requested) throw new Error(`Pipeline '${nameOrId}' is not configured`);
483
+ const client = databricksTestkit.createClientFromEnv(process.env);
484
+ const pipelineId = await resolvePipelineId(client, requested);
485
+ const updateId = await startPipelineUpdate(client, pipelineId, { fullRefresh: false });
486
+ this.lastPipelineUpdate = { pipelineId, updateId };
487
+ }
488
+ );
489
+ cucumber.Then(
490
+ "table {string} has {int} rows",
491
+ async function(table, n) {
492
+ await databricksTestkit.expectTable(await this.context(), table).toHaveRowCount(n);
493
+ }
494
+ );
495
+ cucumber.Then(
496
+ "table {string} contains rows matching:",
497
+ async function(table, data) {
498
+ await databricksTestkit.expectTable(await this.context(), table).toContainRows(coerceMatchRows(data.raw()));
499
+ }
500
+ );
501
+ cucumber.Then(
502
+ "table {string} contains exactly:",
503
+ async function(table, data) {
504
+ const ctx = await this.context();
505
+ const expected = coerceMatchRows(data.raw());
506
+ const header = (data.raw()[0] ?? []).join(", ");
507
+ const rows = await ctx.query(`SELECT ${header} FROM ${ctx.qual(table)}`);
508
+ if (rows.length !== expected.length) {
509
+ throw new Error(`Expected exactly ${expected.length} rows in ${table}, found ${rows.length}`);
510
+ }
511
+ await databricksTestkit.expectTable(ctx, table).toContainRows(expected);
512
+ }
513
+ );
514
+ cucumber.Then(
515
+ "table {string} matches golden {string} ordered by {string}",
516
+ async function(table, golden, orderBy) {
517
+ await databricksTestkit.expectTable(await this.context(), table).toMatchGolden(golden, { orderBy });
518
+ }
519
+ );
520
+ cucumber.Then(
521
+ "table {string} matches schema contract {string}",
522
+ async function(table, contractFile) {
523
+ const absolute = path.resolve(process.cwd(), contractFile);
524
+ const fromCwd = path.relative(process.cwd(), absolute);
525
+ if (fromCwd.startsWith("..") || path.isAbsolute(fromCwd)) {
526
+ throw new Error(`Schema contract path escapes the feature workspace: ${contractFile}`);
527
+ }
528
+ const parsed = JSON.parse(await promises.readFile(absolute, "utf8"));
529
+ const columns = Array.isArray(parsed) ? parsed : parsed.columns;
530
+ if (!columns || columns.length === 0) {
531
+ throw new Error(`Schema contract ${contractFile} contains no columns`);
532
+ }
533
+ const ctx = await this.context();
534
+ const rows = await ctx.query(`DESCRIBE TABLE ${ctx.qual(table)}`);
535
+ databricksTestkit.assertDeltaContract(table, rows, columns);
536
+ }
537
+ );
538
+ cucumber.Then("the result has {int} rows", function(n) {
539
+ requireRows(this);
540
+ if (this.lastRows?.length !== n) {
541
+ throw new Error(`Expected ${n} result rows, got ${this.lastRows?.length}`);
542
+ }
543
+ });
544
+ cucumber.Then("the result contains rows matching:", function(data) {
545
+ requireRows(this);
546
+ const rows = this.lastRows ?? [];
547
+ for (const partial of coerceMatchRows(data.raw())) {
548
+ const hit = rows.some(
549
+ (row) => Object.entries(partial).every(([key, value]) => cellEqual(row[key], value))
550
+ );
551
+ if (!hit) {
552
+ throw new Error(
553
+ `Result does not contain a row matching ${JSON.stringify(partial)} \u2014 rows: ${JSON.stringify(rows.slice(0, 5))}`
554
+ );
555
+ }
556
+ }
557
+ });
558
+ cucumber.Then("the result matches golden {string}", async function(golden) {
559
+ requireRows(this);
560
+ await databricksTestkit.compareToGolden(golden, this.lastRows ?? [], "result");
561
+ });
562
+ cucumber.Then("the statement fails with {string}", function(fragment) {
563
+ if (!this.lastError)
564
+ throw new Error(`Expected the statement to fail with '${fragment}', but it succeeded`);
565
+ if (!this.lastError.message.toLowerCase().includes(fragment.toLowerCase())) {
566
+ throw new Error(`Statement failed, but with a different error: ${this.lastError.message}`);
567
+ }
568
+ });
569
+ cucumber.Then("the statement fails with permission denied", function() {
570
+ if (!this.lastError) throw new Error("Expected a permission error, but the statement succeeded");
571
+ if (!/permission.?denied|insufficient.?priv|403|unauthoriz/i.test(this.lastError.message)) {
572
+ throw new Error(`Statement failed, but not with a permission error: ${this.lastError.message}`);
573
+ }
574
+ });
575
+ cucumber.Then(
576
+ "no rows were rescued in table {string}",
577
+ async function(table) {
578
+ const ctx = await this.context();
579
+ const rows = await ctx.query(
580
+ `SELECT COUNT(*) AS n FROM ${ctx.qual(table)} WHERE _rescued_data IS NOT NULL`
581
+ );
582
+ const n = Number(rows[0]?.n ?? Number.NaN);
583
+ if (n !== 0) {
584
+ throw new Error(`${n} rescued rows in ${table} \u2014 the ingestion contract drifted`);
585
+ }
586
+ }
587
+ );
588
+ cucumber.Then(
589
+ "configured table {string} has at least {int} rows",
590
+ async function(configuredTable, minimum) {
591
+ const table = resolveConfiguredValue(configuredTable, "table");
592
+ parseTableIdentifier(table);
593
+ const rows = await (await this.context()).query(`SELECT COUNT(*) AS n FROM ${table}`);
594
+ const count = Number(rows[0]?.n ?? Number.NaN);
595
+ if (!Number.isFinite(count) || count < minimum) {
596
+ throw new Error(`Expected at least ${minimum} rows in ${table}, found ${count}`);
597
+ }
598
+ }
599
+ );
600
+ cucumber.Then(
601
+ "no rows were rescued in configured table {string}",
602
+ async function(configuredTable) {
603
+ const table = resolveConfiguredValue(configuredTable, "table");
604
+ parseTableIdentifier(table);
605
+ const rows = await (await this.context()).query(
606
+ `SELECT COUNT(*) AS n FROM ${table} WHERE _rescued_data IS NOT NULL`
607
+ );
608
+ const count = Number(rows[0]?.n ?? Number.NaN);
609
+ if (count !== 0) throw new Error(`${count} rescued rows in ${table}`);
610
+ }
611
+ );
612
+ cucumber.Then(
613
+ "row count of {string} is within {int}% of {int}",
614
+ async function(table, pct, target) {
615
+ const ctx = await this.context();
616
+ const rows = await ctx.query(`SELECT COUNT(*) AS n FROM ${ctx.qual(table)}`);
617
+ const n = Number(rows[0]?.n ?? Number.NaN);
618
+ const tolerance = target * pct / 100;
619
+ if (Math.abs(n - target) > tolerance) {
620
+ throw new Error(`Row count ${n} of ${table} is outside ${target} \xB1 ${tolerance}`);
621
+ }
622
+ }
623
+ );
624
+ cucumber.Then("the job run reaches {runState}", function(state) {
625
+ if (!this.lastRun) throw new Error("No job run in this scenario \u2014 add a When I run job step");
626
+ if (this.lastRun.result_state !== state) {
627
+ throw new Error(`Job run ended in ${this.lastRun.result_state}, expected ${state}`);
628
+ }
629
+ });
630
+ cucumber.Then(
631
+ "the pipeline update completes within {duration}",
632
+ async function(durationMs) {
633
+ if (!this.lastPipelineUpdate) {
634
+ throw new Error("No pipeline update in this scenario \u2014 add a When I start ... pipeline step");
635
+ }
636
+ const client = databricksTestkit.createClientFromEnv(process.env);
637
+ await databricksTestkit.waitForPipelineUpdate(
638
+ client,
639
+ this.lastPipelineUpdate.pipelineId,
640
+ this.lastPipelineUpdate.updateId,
641
+ { timeoutMs: durationMs }
642
+ );
643
+ }
644
+ );
645
+ function requireRows(world) {
646
+ if (world.lastError) {
647
+ throw new Error(`The last statement failed: ${world.lastError.message}`);
648
+ }
649
+ if (world.lastRows === null) {
650
+ throw new Error("No result captured \u2014 add a When I run the SQL / I query table step first");
651
+ }
652
+ }
653
+ function cellEqual(a, b) {
654
+ if (a === b) return true;
655
+ if (typeof a === "number" && typeof b === "number") return Math.abs(a - b) < 1e-9;
656
+ if (typeof a === "string" && typeof b === "string") {
657
+ return a.replace("T", " ").replace(/(\.\d+)?Z?$/, "") === b.replace("T", " ").replace(/(\.\d+)?Z?$/, "");
658
+ }
659
+ return false;
660
+ }
661
+ function resolveConfiguredValue(value, label) {
662
+ if (!value.startsWith("$")) return value;
663
+ const resolved = process.env[value.slice(1)];
664
+ if (!resolved) throw new Error(`${label} '${value}' is not configured`);
665
+ return resolved;
666
+ }
667
+ var ALLOWED_PRIVILEGES = /* @__PURE__ */ new Set([
668
+ "ALL PRIVILEGES",
669
+ "USE CATALOG",
670
+ "USE SCHEMA",
671
+ "SELECT",
672
+ "MODIFY",
673
+ "READ VOLUME",
674
+ "WRITE VOLUME"
675
+ ]);
676
+ function normalizeGrant(row) {
677
+ const securableType = (row.securable_type ?? "SCHEMA").trim().toUpperCase();
678
+ if (!["CATALOG", "SCHEMA", "TABLE", "VOLUME"].includes(securableType)) {
679
+ throw new Error(`Unsupported securable_type '${securableType}'`);
680
+ }
681
+ const securable = parseTableIdentifier(row.securable ?? "");
682
+ const privileges = (row.privilege ?? row.privileges ?? "").split(",").map((value) => value.trim().toUpperCase()).filter(Boolean);
683
+ if (privileges.length === 0 || privileges.some((value) => !ALLOWED_PRIVILEGES.has(value))) {
684
+ throw new Error(`Unsupported or empty privileges '${row.privilege ?? row.privileges ?? ""}'`);
685
+ }
686
+ return { securableType, securable, privileges };
687
+ }
688
+ //# sourceMappingURL=steps.cjs.map
689
+ //# sourceMappingURL=steps.cjs.map