@fabricorg/databricks-testkit 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 +21 -0
- package/README.md +82 -0
- package/dist/chunk-7JVDOSBO.js +94 -0
- package/dist/chunk-7JVDOSBO.js.map +1 -0
- package/dist/chunk-F3KU6VZS.js +56 -0
- package/dist/chunk-F3KU6VZS.js.map +1 -0
- package/dist/chunk-FBADA7LQ.js +195 -0
- package/dist/chunk-FBADA7LQ.js.map +1 -0
- package/dist/chunk-MLBZCHRM.js +37 -0
- package/dist/chunk-MLBZCHRM.js.map +1 -0
- package/dist/duckdb-context-FX23RUBV.js +4 -0
- package/dist/duckdb-context-FX23RUBV.js.map +1 -0
- package/dist/fixtures-CAWhzIHu.d.cts +27 -0
- package/dist/fixtures-CAWhzIHu.d.ts +27 -0
- package/dist/index.cjs +1152 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +301 -0
- package/dist/index.d.ts +301 -0
- package/dist/index.js +690 -0
- package/dist/index.js.map +1 -0
- package/dist/parity.cjs +411 -0
- package/dist/parity.cjs.map +1 -0
- package/dist/parity.d.cts +17 -0
- package/dist/parity.d.ts +17 -0
- package/dist/parity.js +45 -0
- package/dist/parity.js.map +1 -0
- package/dist/workspace-context-AIUYISUS.js +4 -0
- package/dist/workspace-context-AIUYISUS.js.map +1 -0
- package/package.json +66 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import { compareToGolden } from './chunk-MLBZCHRM.js';
|
|
2
|
+
export { compareToGolden } from './chunk-MLBZCHRM.js';
|
|
3
|
+
export { createDuckDbContext, normalizeRow } from './chunk-7JVDOSBO.js';
|
|
4
|
+
import { createClientFromEnv, createWorkspaceContext, resolveTokenProvider } from './chunk-FBADA7LQ.js';
|
|
5
|
+
export { createClientFromEnv, createWorkspaceContext, parseStatementRows, resolveTokenProvider, toNamedParameters } from './chunk-FBADA7LQ.js';
|
|
6
|
+
export { parseFixtureColumns, resolveFixtureRows } from './chunk-F3KU6VZS.js';
|
|
7
|
+
import { mkdir, writeFile, readFile } from 'fs/promises';
|
|
8
|
+
import { dirname, resolve, basename } from 'path';
|
|
9
|
+
import { waitForDatabricksRun } from '@fabric-harness/databricks';
|
|
10
|
+
export { waitForDatabricksRun, waitForDatabricksStatement } from '@fabric-harness/databricks';
|
|
11
|
+
import { LakebaseDatabaseProvider, exchangeLakebaseCredential } from '@fabricorg/experiments-db-pool';
|
|
12
|
+
import { randomUUID } from 'crypto';
|
|
13
|
+
|
|
14
|
+
// src/context.ts
|
|
15
|
+
function resolveProfile(env = process.env) {
|
|
16
|
+
const profile = env.DBX_TEST_PROFILE ?? "local";
|
|
17
|
+
if (profile !== "local" && profile !== "live") {
|
|
18
|
+
throw new Error(`DBX_TEST_PROFILE must be 'local' or 'live', got '${profile}'`);
|
|
19
|
+
}
|
|
20
|
+
if (profile === "live" && env.DBX_TEST_LIVE !== "1") {
|
|
21
|
+
throw new Error(
|
|
22
|
+
"DBX_TEST_PROFILE=live requires DBX_TEST_LIVE=1 (live suites are always gated)"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return profile;
|
|
26
|
+
}
|
|
27
|
+
async function createExecutionContext(options = {}) {
|
|
28
|
+
const env = options.env ?? process.env;
|
|
29
|
+
const profile = options.profile ?? resolveProfile(env);
|
|
30
|
+
if (profile === "local") {
|
|
31
|
+
const { createDuckDbContext: createDuckDbContext2 } = await import('./duckdb-context-FX23RUBV.js');
|
|
32
|
+
return createDuckDbContext2({ schema: options.schema });
|
|
33
|
+
}
|
|
34
|
+
const { createWorkspaceContext: createWorkspaceContext2 } = await import('./workspace-context-AIUYISUS.js');
|
|
35
|
+
return createWorkspaceContext2({
|
|
36
|
+
env,
|
|
37
|
+
schema: options.schema,
|
|
38
|
+
...options.onStatement ? { onStatement: options.onStatement } : {}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/assert.ts
|
|
43
|
+
function expectTable(ctx, table) {
|
|
44
|
+
const target = ctx.qual(table);
|
|
45
|
+
return {
|
|
46
|
+
async toHaveRowCount(expected) {
|
|
47
|
+
const rows = await ctx.query(`SELECT COUNT(*) AS n FROM ${target}`);
|
|
48
|
+
const actual = Number(rows[0]?.n ?? Number.NaN);
|
|
49
|
+
if (actual !== expected) {
|
|
50
|
+
throw new Error(`Expected ${target} to have ${expected} rows, found ${actual}`);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
/** Every partial row must match at least one table row on its own keys. */
|
|
54
|
+
async toContainRows(partials) {
|
|
55
|
+
const rows = await ctx.query(`SELECT * FROM ${target}`);
|
|
56
|
+
for (const partial of partials) {
|
|
57
|
+
const hit = rows.some(
|
|
58
|
+
(row) => Object.entries(partial).every(([key, value]) => looselyEqual(row[key], value))
|
|
59
|
+
);
|
|
60
|
+
if (!hit) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Expected ${target} to contain a row matching ${JSON.stringify(partial)}; have ${rows.length} rows: ${JSON.stringify(rows.slice(0, 5))}${rows.length > 5 ? " \u2026" : ""}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
/**
|
|
68
|
+
* Compare `SELECT * ORDER BY <orderBy>` against a committed golden JSON
|
|
69
|
+
* file. Set DBX_TEST_UPDATE_GOLDEN=1 to (re)write the golden instead —
|
|
70
|
+
* a reviewable, versioned event.
|
|
71
|
+
*/
|
|
72
|
+
async toMatchGolden(goldenPath, options = {}) {
|
|
73
|
+
const orderBy = options.orderBy ? ` ORDER BY ${options.orderBy}` : "";
|
|
74
|
+
const rows = await ctx.query(`SELECT * FROM ${target}${orderBy}`);
|
|
75
|
+
await compareToGolden(goldenPath, rows, `table ${target}`);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function expectQueryToMatchGolden(ctx, sql, params, goldenPath) {
|
|
80
|
+
const rows = await ctx.query(sql, params);
|
|
81
|
+
await compareToGolden(goldenPath, rows, "query");
|
|
82
|
+
}
|
|
83
|
+
function looselyEqual(a, b) {
|
|
84
|
+
if (a === b) return true;
|
|
85
|
+
if (typeof a === "number" && typeof b === "number") return Math.abs(a - b) < 1e-9;
|
|
86
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
87
|
+
return a.replace("T", " ").replace(/(\.\d+)?Z?$/, "") === b.replace("T", " ").replace(/(\.\d+)?Z?$/, "");
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/mock-client.ts
|
|
93
|
+
function createMockDatabricksClient() {
|
|
94
|
+
const queues = { GET: [], POST: [] };
|
|
95
|
+
const calls = [];
|
|
96
|
+
function take(method, path) {
|
|
97
|
+
const index = queues[method].findIndex((q) => path.startsWith(q.pathPrefix));
|
|
98
|
+
if (index === -1) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`MockDatabricksClient: no queued ${method} response for ${path}. Queued prefixes: ${queues[method].map((q) => q.pathPrefix).join(", ") || "(none)"}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return queues[method].splice(index, 1)[0];
|
|
104
|
+
}
|
|
105
|
+
const client = {
|
|
106
|
+
async get(path, query) {
|
|
107
|
+
calls.push({ method: "GET", path, query });
|
|
108
|
+
const queued = take("GET", path);
|
|
109
|
+
if (queued.error) throw queued.error;
|
|
110
|
+
return queued.response;
|
|
111
|
+
},
|
|
112
|
+
async post(path, body, query) {
|
|
113
|
+
calls.push({ method: "POST", path, body, query });
|
|
114
|
+
const queued = take("POST", path);
|
|
115
|
+
if (queued.error) throw queued.error;
|
|
116
|
+
return queued.response;
|
|
117
|
+
},
|
|
118
|
+
queue(method, pathPrefix, response) {
|
|
119
|
+
queues[method].push({ pathPrefix, response });
|
|
120
|
+
return client;
|
|
121
|
+
},
|
|
122
|
+
queueError(method, pathPrefix, error) {
|
|
123
|
+
queues[method].push({ pathPrefix, error });
|
|
124
|
+
return client;
|
|
125
|
+
},
|
|
126
|
+
calls,
|
|
127
|
+
callsTo(method, pathPrefix) {
|
|
128
|
+
return calls.filter((c) => c.method === method && c.path.startsWith(pathPrefix));
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
return client;
|
|
132
|
+
}
|
|
133
|
+
function defineLiveSuite(spec) {
|
|
134
|
+
return { run: (runtime) => runLiveSuite(spec, runtime) };
|
|
135
|
+
}
|
|
136
|
+
async function runLiveSuite(spec, runtime = {}) {
|
|
137
|
+
const env = spec.env ?? process.env;
|
|
138
|
+
if (env.DBX_TEST_LIVE !== "1") return void 0;
|
|
139
|
+
const required2 = [...spec.required ?? []];
|
|
140
|
+
const known = new Set(spec.checks.map((check) => check.id));
|
|
141
|
+
for (const id of required2) {
|
|
142
|
+
if (!known.has(id)) throw new Error(`Required live check '${id}' is not defined`);
|
|
143
|
+
}
|
|
144
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
145
|
+
const ownedWarehouse = runtime.warehouse === void 0;
|
|
146
|
+
const client = runtime.client ?? createClientFromEnv(env);
|
|
147
|
+
const warehouse = runtime.warehouse ?? await createWorkspaceContext({ env, client });
|
|
148
|
+
const results = [];
|
|
149
|
+
try {
|
|
150
|
+
for (const check of spec.checks) {
|
|
151
|
+
if (check.configured && !check.configured(env)) {
|
|
152
|
+
results.push({
|
|
153
|
+
id: check.id,
|
|
154
|
+
description: check.description,
|
|
155
|
+
status: "not-configured",
|
|
156
|
+
durationMs: 0
|
|
157
|
+
});
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const before = performance.now();
|
|
161
|
+
try {
|
|
162
|
+
const details = await check.run({ env, warehouse, client });
|
|
163
|
+
results.push({
|
|
164
|
+
id: check.id,
|
|
165
|
+
description: check.description,
|
|
166
|
+
status: "pass",
|
|
167
|
+
durationMs: Math.round(performance.now() - before),
|
|
168
|
+
...details === void 0 ? {} : { details: redactValue(details, env) }
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
results.push({
|
|
172
|
+
id: check.id,
|
|
173
|
+
description: check.description,
|
|
174
|
+
status: "fail",
|
|
175
|
+
durationMs: Math.round(performance.now() - before),
|
|
176
|
+
error: redactText(error instanceof Error ? error.message : String(error), env)
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
if (ownedWarehouse) await warehouse.close();
|
|
182
|
+
}
|
|
183
|
+
const success = required2.every(
|
|
184
|
+
(id) => results.find((result) => result.id === id)?.status === "pass"
|
|
185
|
+
);
|
|
186
|
+
const evidence = {
|
|
187
|
+
schemaVersion: 1,
|
|
188
|
+
kind: "databricks-live-suite",
|
|
189
|
+
startedAt,
|
|
190
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
191
|
+
success,
|
|
192
|
+
required: required2,
|
|
193
|
+
results
|
|
194
|
+
};
|
|
195
|
+
if (spec.evidencePath)
|
|
196
|
+
await writeReport(spec.evidencePath, `${JSON.stringify(evidence, null, 2)}
|
|
197
|
+
`);
|
|
198
|
+
if (spec.junitPath) await writeReport(spec.junitPath, renderJUnit(evidence));
|
|
199
|
+
return evidence;
|
|
200
|
+
}
|
|
201
|
+
async function writeReport(path, contents) {
|
|
202
|
+
await mkdir(dirname(path), { recursive: true });
|
|
203
|
+
await writeFile(path, contents, "utf8");
|
|
204
|
+
}
|
|
205
|
+
function redactText(text, env) {
|
|
206
|
+
let output = text;
|
|
207
|
+
for (const [name, value] of Object.entries(env)) {
|
|
208
|
+
if (!value || value.length < 6 || !/(TOKEN|SECRET|PASSWORD|BEARER|KEY)/i.test(name)) continue;
|
|
209
|
+
output = output.split(value).join("[REDACTED]");
|
|
210
|
+
}
|
|
211
|
+
return output;
|
|
212
|
+
}
|
|
213
|
+
function redactValue(value, env) {
|
|
214
|
+
return JSON.parse(redactText(JSON.stringify(value), env));
|
|
215
|
+
}
|
|
216
|
+
function xml(value) {
|
|
217
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
218
|
+
}
|
|
219
|
+
function renderJUnit(evidence) {
|
|
220
|
+
const failures = evidence.results.filter((result) => result.status === "fail").length;
|
|
221
|
+
const skipped = evidence.results.filter((result) => result.status === "not-configured").length;
|
|
222
|
+
const seconds = evidence.results.reduce((sum, result) => sum + result.durationMs, 0) / 1e3;
|
|
223
|
+
const cases = evidence.results.map((result) => {
|
|
224
|
+
const body = result.status === "fail" ? `<failure message="${xml(result.error ?? "check failed")}"/>` : result.status === "not-configured" ? '<skipped message="not configured"/>' : "";
|
|
225
|
+
return ` <testcase classname="databricks-live" name="${xml(result.id)}" time="${result.durationMs / 1e3}">${body}</testcase>`;
|
|
226
|
+
}).join("\n");
|
|
227
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
228
|
+
<testsuite name="databricks-live" tests="${evidence.results.length}" failures="${failures}" skipped="${skipped}" time="${seconds}">
|
|
229
|
+
${cases}
|
|
230
|
+
</testsuite>
|
|
231
|
+
`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/pollers.ts
|
|
235
|
+
var TERMINAL_PIPELINE_STATES = /* @__PURE__ */ new Set(["COMPLETED", "FAILED", "CANCELED"]);
|
|
236
|
+
async function waitForPipelineUpdate(client, pipelineId, updateId, options = {}) {
|
|
237
|
+
const deadline = Date.now() + (options.timeoutMs ?? 9e5);
|
|
238
|
+
const interval = options.pollIntervalMs ?? 1e4;
|
|
239
|
+
const path = `/api/2.0/pipelines/${encodeURIComponent(pipelineId)}/updates/${encodeURIComponent(updateId)}`;
|
|
240
|
+
let update = await client.get(path);
|
|
241
|
+
while (!TERMINAL_PIPELINE_STATES.has(update.update?.state ?? "") && Date.now() < deadline) {
|
|
242
|
+
if (options.signal?.aborted) throw new Error(`Pipeline update ${updateId} was aborted`);
|
|
243
|
+
await abortableDelay(interval, options.signal);
|
|
244
|
+
update = await client.get(path);
|
|
245
|
+
}
|
|
246
|
+
const state = update.update?.state ?? "UNKNOWN";
|
|
247
|
+
if (state !== "COMPLETED") {
|
|
248
|
+
throw new Error(`Pipeline update ${updateId} ended in ${state} (wanted COMPLETED)`);
|
|
249
|
+
}
|
|
250
|
+
return update;
|
|
251
|
+
}
|
|
252
|
+
function abortableDelay(ms, signal) {
|
|
253
|
+
return new Promise((resolve2, reject) => {
|
|
254
|
+
if (signal?.aborted) {
|
|
255
|
+
reject(new Error("Polling aborted"));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const timer = setTimeout(resolve2, ms);
|
|
259
|
+
signal?.addEventListener(
|
|
260
|
+
"abort",
|
|
261
|
+
() => {
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
reject(new Error("Polling aborted"));
|
|
264
|
+
},
|
|
265
|
+
{ once: true }
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// src/live-checks.ts
|
|
271
|
+
function sqlRoundTripCheck() {
|
|
272
|
+
return {
|
|
273
|
+
id: "sql",
|
|
274
|
+
description: "SQL Statement Execution round-trip",
|
|
275
|
+
async run({ warehouse }) {
|
|
276
|
+
const rows = await warehouse.query("SELECT 1 AS dbx_test_ok");
|
|
277
|
+
if (Number(rows[0]?.dbx_test_ok) !== 1)
|
|
278
|
+
throw new Error("SQL round-trip returned an unexpected value");
|
|
279
|
+
return { rows: rows.length };
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function assertDeltaContract(table, rows, columns) {
|
|
284
|
+
for (const expected of columns) {
|
|
285
|
+
const actual = rows.find((row) => String(row.col_name ?? row.column_name) === expected.name);
|
|
286
|
+
if (!actual) throw new Error(`Delta contract missing column '${expected.name}' in ${table}`);
|
|
287
|
+
const actualType = String(actual.data_type ?? actual.column_type ?? "").toLowerCase();
|
|
288
|
+
if (expected.type && actualType !== expected.type.toLowerCase()) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Delta column ${expected.name} has type ${actualType}, expected ${expected.type}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function deltaContractCheck(tableEnv = "DBX_TEST_DELTA_TABLE", columns = []) {
|
|
296
|
+
return {
|
|
297
|
+
id: "delta-contract",
|
|
298
|
+
description: "Delta table schema contract",
|
|
299
|
+
configured: (env) => Boolean(env[tableEnv]),
|
|
300
|
+
async run({ env, warehouse }) {
|
|
301
|
+
const table = env[tableEnv];
|
|
302
|
+
const rows = await warehouse.query(`DESCRIBE TABLE ${table}`);
|
|
303
|
+
assertDeltaContract(table, rows, columns);
|
|
304
|
+
return { table, columns: rows.length };
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function unityCatalogAccessCheck() {
|
|
309
|
+
return {
|
|
310
|
+
id: "uc-grants",
|
|
311
|
+
description: "Unity Catalog allow/deny access assertions",
|
|
312
|
+
configured: (env) => Boolean(env.DBX_TEST_UC_ALLOWED_TABLE && env.DBX_TEST_UC_DENIED_TABLE),
|
|
313
|
+
async run({ env, warehouse }) {
|
|
314
|
+
await warehouse.query(`SELECT * FROM ${env.DBX_TEST_UC_ALLOWED_TABLE} LIMIT 1`);
|
|
315
|
+
try {
|
|
316
|
+
await warehouse.query(`SELECT * FROM ${env.DBX_TEST_UC_DENIED_TABLE} LIMIT 1`);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (/permission.?denied|insufficient.?priv|403|unauthoriz/i.test(String(error))) {
|
|
319
|
+
return { allowed: env.DBX_TEST_UC_ALLOWED_TABLE, denied: env.DBX_TEST_UC_DENIED_TABLE };
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
throw new Error(`Expected access to ${env.DBX_TEST_UC_DENIED_TABLE} to be denied`);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function jobRunCheck() {
|
|
328
|
+
return {
|
|
329
|
+
id: "jobs",
|
|
330
|
+
description: "Databricks Job run-now reaches SUCCESS",
|
|
331
|
+
configured: (env) => Boolean(env.DBX_TEST_JOB_ID),
|
|
332
|
+
async run({ env, client }) {
|
|
333
|
+
const jobId = Number(env.DBX_TEST_JOB_ID);
|
|
334
|
+
if (!Number.isFinite(jobId)) throw new Error("DBX_TEST_JOB_ID must be numeric");
|
|
335
|
+
const submitted = await client.post("/api/2.1/jobs/run-now", {
|
|
336
|
+
job_id: jobId
|
|
337
|
+
});
|
|
338
|
+
const run = await waitForDatabricksRun(client, submitted.run_id, {
|
|
339
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
340
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
341
|
+
});
|
|
342
|
+
const result = run.state?.result_state;
|
|
343
|
+
if (result !== "SUCCESS") throw new Error(`Job ${jobId} ended in ${result ?? "UNKNOWN"}`);
|
|
344
|
+
return { jobId, runId: submitted.run_id };
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function pipelineRefreshCheck() {
|
|
349
|
+
return {
|
|
350
|
+
id: "pipeline",
|
|
351
|
+
description: "Lakeflow telemetry pipeline refresh reaches COMPLETED",
|
|
352
|
+
configured: (env) => Boolean(env.DBX_TEST_PIPELINE_ID),
|
|
353
|
+
async run({ env, client }) {
|
|
354
|
+
const pipelineId = env.DBX_TEST_PIPELINE_ID;
|
|
355
|
+
const submitted = await client.post(
|
|
356
|
+
`/api/2.0/pipelines/${encodeURIComponent(pipelineId)}/updates`,
|
|
357
|
+
{ full_refresh: false }
|
|
358
|
+
);
|
|
359
|
+
if (!submitted.update_id) {
|
|
360
|
+
throw new Error(`Pipeline ${pipelineId} did not return an update_id`);
|
|
361
|
+
}
|
|
362
|
+
await waitForPipelineUpdate(client, pipelineId, submitted.update_id, {
|
|
363
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
364
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
365
|
+
});
|
|
366
|
+
return { pipelineId, updateId: submitted.update_id };
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function notebookSubmitCheck() {
|
|
371
|
+
return {
|
|
372
|
+
id: "notebook",
|
|
373
|
+
description: "Databricks notebook submit run reaches SUCCESS",
|
|
374
|
+
configured: (env) => Boolean(env.DBX_TEST_NOTEBOOK_PATH && env.DBX_TEST_CLUSTER_ID),
|
|
375
|
+
async run({ env, client }) {
|
|
376
|
+
if (!env.DBX_TEST_NOTEBOOK_PATH || !env.DBX_TEST_CLUSTER_ID) {
|
|
377
|
+
throw new Error("Notebook submit requires DBX_TEST_NOTEBOOK_PATH and DBX_TEST_CLUSTER_ID");
|
|
378
|
+
}
|
|
379
|
+
const submitted = await client.post("/api/2.1/jobs/runs/submit", {
|
|
380
|
+
run_name: `fabric-experiments-notebook-${Date.now()}`,
|
|
381
|
+
tasks: [
|
|
382
|
+
{
|
|
383
|
+
task_key: "notebook",
|
|
384
|
+
existing_cluster_id: env.DBX_TEST_CLUSTER_ID,
|
|
385
|
+
notebook_task: { notebook_path: env.DBX_TEST_NOTEBOOK_PATH }
|
|
386
|
+
}
|
|
387
|
+
]
|
|
388
|
+
});
|
|
389
|
+
if (!submitted.run_id) throw new Error("Notebook submit returned no run_id");
|
|
390
|
+
const run = await waitForDatabricksRun(client, submitted.run_id, {
|
|
391
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
392
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
393
|
+
});
|
|
394
|
+
const result = run.state?.result_state;
|
|
395
|
+
if (result !== "SUCCESS") throw new Error(`Notebook run ended in ${result ?? "UNKNOWN"}`);
|
|
396
|
+
return { runId: submitted.run_id, notebookPath: env.DBX_TEST_NOTEBOOK_PATH };
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function dbtBuildCheck() {
|
|
401
|
+
return {
|
|
402
|
+
id: "dbt",
|
|
403
|
+
description: "Databricks dbt task reaches SUCCESS",
|
|
404
|
+
configured: (env) => Boolean(env.DBX_TEST_DBT_GIT_URL && env.DATABRICKS_WAREHOUSE_ID),
|
|
405
|
+
async run({ env, client }) {
|
|
406
|
+
if (!env.DBX_TEST_DBT_GIT_URL || !env.DATABRICKS_WAREHOUSE_ID) {
|
|
407
|
+
throw new Error("dbt build requires DBX_TEST_DBT_GIT_URL and DATABRICKS_WAREHOUSE_ID");
|
|
408
|
+
}
|
|
409
|
+
const submitted = await client.post("/api/2.1/jobs/runs/submit", {
|
|
410
|
+
run_name: `fabric-experiments-dbt-${Date.now()}`,
|
|
411
|
+
git_source: {
|
|
412
|
+
git_url: env.DBX_TEST_DBT_GIT_URL,
|
|
413
|
+
git_provider: env.DBX_TEST_DBT_GIT_PROVIDER ?? "gitHub",
|
|
414
|
+
git_branch: env.DBX_TEST_DBT_GIT_BRANCH ?? "main"
|
|
415
|
+
},
|
|
416
|
+
tasks: [
|
|
417
|
+
{
|
|
418
|
+
task_key: "dbt_build",
|
|
419
|
+
dbt_task: {
|
|
420
|
+
commands: ["dbt deps", "dbt build"],
|
|
421
|
+
warehouse_id: env.DATABRICKS_WAREHOUSE_ID,
|
|
422
|
+
...env.DBX_TEST_DBT_PROJECT_DIR ? { project_directory: env.DBX_TEST_DBT_PROJECT_DIR } : {}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
]
|
|
426
|
+
});
|
|
427
|
+
if (!submitted.run_id) throw new Error("dbt submit returned no run_id");
|
|
428
|
+
const run = await waitForDatabricksRun(client, submitted.run_id, {
|
|
429
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
430
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
431
|
+
});
|
|
432
|
+
const result = run.state?.result_state;
|
|
433
|
+
if (result !== "SUCCESS") throw new Error(`dbt run ended in ${result ?? "UNKNOWN"}`);
|
|
434
|
+
return { runId: submitted.run_id, gitUrl: env.DBX_TEST_DBT_GIT_URL };
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function customLiveCheck(id, description, run, configured) {
|
|
439
|
+
return { id, description, run, ...configured ? { configured } : {} };
|
|
440
|
+
}
|
|
441
|
+
function required(env, name) {
|
|
442
|
+
const value = env[name]?.trim();
|
|
443
|
+
if (!value) throw new Error(`${name} is required`);
|
|
444
|
+
return value;
|
|
445
|
+
}
|
|
446
|
+
function createLakebaseTestProvider(env, options = {}) {
|
|
447
|
+
return new LakebaseDatabaseProvider({
|
|
448
|
+
host: required(env, "DBX_TEST_LAKEBASE_HOST"),
|
|
449
|
+
database: required(env, "DBX_TEST_LAKEBASE_DATABASE"),
|
|
450
|
+
user: required(env, "DBX_TEST_LAKEBASE_USER"),
|
|
451
|
+
workspaceHost: required(env, "DATABRICKS_HOST"),
|
|
452
|
+
endpoint: required(env, "DBX_TEST_LAKEBASE_ENDPOINT"),
|
|
453
|
+
token: resolveTokenProvider(required(env, "DATABRICKS_HOST"), env, options.fetchImpl),
|
|
454
|
+
fetchImpl: options.fetchImpl,
|
|
455
|
+
poolFactory: options.poolFactory,
|
|
456
|
+
max: 1
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
async function workspaceToken(env, fetchImpl) {
|
|
460
|
+
const host = required(env, "DATABRICKS_HOST");
|
|
461
|
+
const provider = resolveTokenProvider(host, env, fetchImpl);
|
|
462
|
+
return typeof provider === "string" ? provider : provider();
|
|
463
|
+
}
|
|
464
|
+
function lakebaseCredentialCheck(options = {}) {
|
|
465
|
+
return {
|
|
466
|
+
id: "lakebase-credential",
|
|
467
|
+
description: "Lakebase OAuth credential exchange",
|
|
468
|
+
configured: (env) => Boolean(env.DBX_TEST_LAKEBASE_ENDPOINT),
|
|
469
|
+
async run({ env }) {
|
|
470
|
+
const credential = await exchangeLakebaseCredential({
|
|
471
|
+
workspaceHost: required(env, "DATABRICKS_HOST"),
|
|
472
|
+
endpoint: required(env, "DBX_TEST_LAKEBASE_ENDPOINT"),
|
|
473
|
+
workspaceToken: await workspaceToken(env, options.fetchImpl),
|
|
474
|
+
fetchImpl: options.fetchImpl
|
|
475
|
+
});
|
|
476
|
+
if (credential.expiresAt <= Date.now()) {
|
|
477
|
+
throw new Error("Lakebase credential is already expired");
|
|
478
|
+
}
|
|
479
|
+
return { expiresAt: new Date(credential.expiresAt).toISOString() };
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function lakebaseRoundTripCheck(options = {}) {
|
|
484
|
+
return {
|
|
485
|
+
id: "lakebase",
|
|
486
|
+
description: "Lakebase provider query and credential refresh round-trip",
|
|
487
|
+
configured: (env) => Boolean(
|
|
488
|
+
env.DBX_TEST_LAKEBASE_ENDPOINT && env.DBX_TEST_LAKEBASE_HOST && env.DBX_TEST_LAKEBASE_DATABASE && env.DBX_TEST_LAKEBASE_USER
|
|
489
|
+
),
|
|
490
|
+
async run({ env }) {
|
|
491
|
+
const provider = createLakebaseTestProvider(env, options);
|
|
492
|
+
try {
|
|
493
|
+
const first = await provider.getPool().query("SELECT 1 AS dbx_test_ok");
|
|
494
|
+
if (Number(first.rows[0]?.dbx_test_ok) !== 1) {
|
|
495
|
+
throw new Error("Lakebase round-trip returned an unexpected value");
|
|
496
|
+
}
|
|
497
|
+
await provider.refreshCredentials();
|
|
498
|
+
const second = await provider.getPool().query("SELECT 1 AS dbx_test_ok");
|
|
499
|
+
if (Number(second.rows[0]?.dbx_test_ok) !== 1) {
|
|
500
|
+
throw new Error("Lakebase post-refresh round-trip returned an unexpected value");
|
|
501
|
+
}
|
|
502
|
+
return { queries: 2, refreshed: true };
|
|
503
|
+
} finally {
|
|
504
|
+
await provider.close();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/databricks-http.ts
|
|
511
|
+
async function databricksFetch(env, pathOrUrl, init = {}, fetchImpl = fetch) {
|
|
512
|
+
const host = env.DATABRICKS_HOST?.replace(/\/$/, "");
|
|
513
|
+
if (!host) throw new Error("DATABRICKS_HOST is required");
|
|
514
|
+
const provider = resolveTokenProvider(host, env, fetchImpl);
|
|
515
|
+
const token = typeof provider === "string" ? provider : await provider();
|
|
516
|
+
const headers = new Headers(init.headers);
|
|
517
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
518
|
+
const response = await fetchImpl(
|
|
519
|
+
/^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${host}${pathOrUrl}`,
|
|
520
|
+
{
|
|
521
|
+
...init,
|
|
522
|
+
headers
|
|
523
|
+
}
|
|
524
|
+
);
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
const body = await response.text();
|
|
527
|
+
throw new Error(
|
|
528
|
+
`Databricks request failed: ${response.status} ${response.statusText}: ${body.slice(0, 500)}`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return response;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/platform-checks.ts
|
|
535
|
+
function csv(value) {
|
|
536
|
+
return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
537
|
+
}
|
|
538
|
+
function secretScopeCheck() {
|
|
539
|
+
return {
|
|
540
|
+
id: "secrets",
|
|
541
|
+
description: "Databricks secret scope contains expected keys",
|
|
542
|
+
configured: (env) => Boolean(env.DBX_TEST_SECRET_SCOPE && env.DBX_TEST_SECRET_KEYS),
|
|
543
|
+
async run({ env, client }) {
|
|
544
|
+
const scope = env.DBX_TEST_SECRET_SCOPE;
|
|
545
|
+
const expected = csv(env.DBX_TEST_SECRET_KEYS);
|
|
546
|
+
const response = await client.get("/api/2.0/secrets/list", {
|
|
547
|
+
scope
|
|
548
|
+
});
|
|
549
|
+
const actual = new Set((response.secrets ?? []).map((secret) => secret.key).filter(Boolean));
|
|
550
|
+
const missing = expected.filter((key) => !actual.has(key));
|
|
551
|
+
if (missing.length > 0) {
|
|
552
|
+
throw new Error(`Secret scope ${scope} is missing keys: ${missing.join(", ")}`);
|
|
553
|
+
}
|
|
554
|
+
return { scope, expectedKeys: expected.length };
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function appHealthCheck(options = {}) {
|
|
559
|
+
return {
|
|
560
|
+
id: "app",
|
|
561
|
+
description: "Databricks App is running, bound, and healthy",
|
|
562
|
+
configured: (env) => Boolean(env.DBX_TEST_APP_NAME),
|
|
563
|
+
async run({ env, client }) {
|
|
564
|
+
const name = env.DBX_TEST_APP_NAME;
|
|
565
|
+
const app = await client.get(`/api/2.0/apps/${encodeURIComponent(name)}`);
|
|
566
|
+
if (app.app_status?.state !== "RUNNING") {
|
|
567
|
+
throw new Error(`Databricks App ${name} is ${app.app_status?.state ?? "UNKNOWN"}`);
|
|
568
|
+
}
|
|
569
|
+
const expected = csv(env.DBX_TEST_APP_RESOURCES);
|
|
570
|
+
const actual = new Set(
|
|
571
|
+
(app.resources ?? []).map((resource) => resource.name).filter(Boolean)
|
|
572
|
+
);
|
|
573
|
+
const missing = expected.filter((resource) => !actual.has(resource));
|
|
574
|
+
if (missing.length > 0) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
`Databricks App ${name} is missing resource bindings: ${missing.join(", ")}`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
const appUrl = env.DBX_TEST_APP_URL ?? app.url ?? app.app_url;
|
|
580
|
+
if (!appUrl) throw new Error(`Databricks App ${name} returned no URL`);
|
|
581
|
+
const healthUrl = new URL("/api/healthz", appUrl).toString();
|
|
582
|
+
const response = await databricksFetch(env, healthUrl, {}, options.fetchImpl);
|
|
583
|
+
const health = await response.json();
|
|
584
|
+
if (health.ok !== true && health.status !== "ok") {
|
|
585
|
+
throw new Error(`Databricks App ${name} health probe did not report ok`);
|
|
586
|
+
}
|
|
587
|
+
return { name, bindings: actual.size, health: "ok" };
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
function normalizeVolumeRoot(value) {
|
|
592
|
+
const normalized = value.replace(/\/$/, "");
|
|
593
|
+
if (!/^\/Volumes\/[^/]+\/[^/]+\/[^/]+$/.test(normalized)) {
|
|
594
|
+
throw new Error("DBX_TEST_VOLUME must be /Volumes/<catalog>/<schema>/<volume>");
|
|
595
|
+
}
|
|
596
|
+
return normalized;
|
|
597
|
+
}
|
|
598
|
+
async function ensureVolumeDirectory(env, directory, fetchImpl) {
|
|
599
|
+
await databricksFetch(env, `/api/2.0/fs/directories${directory}`, { method: "PUT" }, fetchImpl);
|
|
600
|
+
}
|
|
601
|
+
async function uploadVolumeFile(env, path, body, fetchImpl) {
|
|
602
|
+
await databricksFetch(
|
|
603
|
+
env,
|
|
604
|
+
`/api/2.0/fs/files${path}`,
|
|
605
|
+
{ method: "PUT", body },
|
|
606
|
+
fetchImpl
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
async function deleteVolumeFile(env, path, fetchImpl) {
|
|
610
|
+
await databricksFetch(env, `/api/2.0/fs/files${path}`, { method: "DELETE" }, fetchImpl);
|
|
611
|
+
}
|
|
612
|
+
function volumeIOCheck(options = {}) {
|
|
613
|
+
return {
|
|
614
|
+
id: "volume-io",
|
|
615
|
+
description: "Unity Catalog volume Files API round-trip",
|
|
616
|
+
configured: (env) => Boolean(env.DBX_TEST_VOLUME),
|
|
617
|
+
async run({ env }) {
|
|
618
|
+
const root = normalizeVolumeRoot(env.DBX_TEST_VOLUME);
|
|
619
|
+
const prefix = (env.DBX_TEST_ENV_NAME ?? "fx-live").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
620
|
+
const id = (options.randomId ?? randomUUID)();
|
|
621
|
+
const directory = `${root}/${prefix}`;
|
|
622
|
+
const file = `${directory}/probe-${id}.txt`;
|
|
623
|
+
const filePath = `/api/2.0/fs/files${file}`;
|
|
624
|
+
const body = new TextEncoder().encode(`fabric-experiments-volume-probe:${id}`);
|
|
625
|
+
await ensureVolumeDirectory(env, directory, options.fetchImpl);
|
|
626
|
+
try {
|
|
627
|
+
await uploadVolumeFile(env, file, body, options.fetchImpl);
|
|
628
|
+
const downloaded = await databricksFetch(env, filePath, {}, options.fetchImpl);
|
|
629
|
+
const actual = new Uint8Array(await downloaded.arrayBuffer());
|
|
630
|
+
if (actual.length !== body.length || actual.some((value, index) => value !== body[index])) {
|
|
631
|
+
throw new Error("Volume round-trip returned different bytes");
|
|
632
|
+
}
|
|
633
|
+
return { volume: root, bytes: body.length };
|
|
634
|
+
} finally {
|
|
635
|
+
await deleteVolumeFile(env, file, options.fetchImpl).catch(() => void 0);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function autoLoaderIngestionCheck(options = {}) {
|
|
641
|
+
return {
|
|
642
|
+
id: "autoloader",
|
|
643
|
+
description: "Isolated Auto Loader fixture ingestion reaches Delta without rescued rows",
|
|
644
|
+
configured: (env) => Boolean(
|
|
645
|
+
env.DBX_TEST_AUTOLOADER_PIPELINE_ID && env.DBX_TEST_AUTOLOADER_TABLE && env.DBX_TEST_AUTOLOADER_FIXTURE && env.DBX_TEST_VOLUME
|
|
646
|
+
),
|
|
647
|
+
async run({ env, client, warehouse }) {
|
|
648
|
+
const pipelineId = env.DBX_TEST_AUTOLOADER_PIPELINE_ID;
|
|
649
|
+
if (pipelineId === env.DBX_TEST_PIPELINE_ID) {
|
|
650
|
+
throw new Error("DBX_TEST_AUTOLOADER_PIPELINE_ID must not be the production pipeline");
|
|
651
|
+
}
|
|
652
|
+
const root = normalizeVolumeRoot(env.DBX_TEST_VOLUME);
|
|
653
|
+
const fixturePath = env.DBX_TEST_AUTOLOADER_FIXTURE;
|
|
654
|
+
const body = options.fixtureBody ?? new Uint8Array(await readFile(resolve(fixturePath)));
|
|
655
|
+
const id = (options.randomId ?? randomUUID)();
|
|
656
|
+
const runName = (env.DBX_TEST_ENV_NAME ?? "autoloader").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
657
|
+
const directory = `${root}/exposures/${runName}/${id}`;
|
|
658
|
+
const destination = `${directory}/${basename(fixturePath)}`;
|
|
659
|
+
await ensureVolumeDirectory(env, directory, options.fetchImpl);
|
|
660
|
+
try {
|
|
661
|
+
await uploadVolumeFile(env, destination, body, options.fetchImpl);
|
|
662
|
+
const submitted = await client.post(
|
|
663
|
+
`/api/2.0/pipelines/${encodeURIComponent(pipelineId)}/updates`,
|
|
664
|
+
{ full_refresh: true }
|
|
665
|
+
);
|
|
666
|
+
if (!submitted.update_id) throw new Error("Auto Loader pipeline returned no update_id");
|
|
667
|
+
await waitForPipelineUpdate(client, pipelineId, submitted.update_id, {
|
|
668
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
669
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
670
|
+
});
|
|
671
|
+
const table = env.DBX_TEST_AUTOLOADER_TABLE;
|
|
672
|
+
const rows = await warehouse.query(
|
|
673
|
+
`SELECT COUNT(*) AS n, SUM(CASE WHEN _rescued_data IS NOT NULL THEN 1 ELSE 0 END) AS rescued FROM ${table}`
|
|
674
|
+
);
|
|
675
|
+
const count = Number(rows[0]?.n ?? 0);
|
|
676
|
+
const rescued = Number(rows[0]?.rescued ?? 0);
|
|
677
|
+
if (count < 1) throw new Error(`Auto Loader produced no rows in ${table}`);
|
|
678
|
+
if (rescued !== 0)
|
|
679
|
+
throw new Error(`Auto Loader produced ${rescued} rescued rows in ${table}`);
|
|
680
|
+
return { pipelineId, table, rows: count, rescued };
|
|
681
|
+
} finally {
|
|
682
|
+
await deleteVolumeFile(env, destination, options.fetchImpl).catch(() => void 0);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export { appHealthCheck, assertDeltaContract, autoLoaderIngestionCheck, createExecutionContext, createLakebaseTestProvider, createMockDatabricksClient, customLiveCheck, dbtBuildCheck, defineLiveSuite, deleteVolumeFile, deltaContractCheck, ensureVolumeDirectory, expectQueryToMatchGolden, expectTable, jobRunCheck, lakebaseCredentialCheck, lakebaseRoundTripCheck, normalizeVolumeRoot, notebookSubmitCheck, pipelineRefreshCheck, renderJUnit, resolveProfile, runLiveSuite, secretScopeCheck, sqlRoundTripCheck, unityCatalogAccessCheck, uploadVolumeFile, volumeIOCheck, waitForPipelineUpdate };
|
|
689
|
+
//# sourceMappingURL=index.js.map
|
|
690
|
+
//# sourceMappingURL=index.js.map
|