@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.cjs
ADDED
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var databricks = require('@fabric-harness/databricks');
|
|
4
|
+
var promises = require('fs/promises');
|
|
5
|
+
var path = require('path');
|
|
6
|
+
var experimentsDbPool = require('@fabricorg/experiments-db-pool');
|
|
7
|
+
var crypto = require('crypto');
|
|
8
|
+
|
|
9
|
+
var __defProp = Object.defineProperty;
|
|
10
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
11
|
+
var __esm = (fn, res, err) => function __init() {
|
|
12
|
+
if (err) throw err[0];
|
|
13
|
+
try {
|
|
14
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
15
|
+
} catch (e) {
|
|
16
|
+
throw err = [e], e;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var __export = (target, all) => {
|
|
20
|
+
for (var name in all)
|
|
21
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// src/fixtures.ts
|
|
25
|
+
function parseFixtureColumns(schema) {
|
|
26
|
+
const columns = [];
|
|
27
|
+
let depth = 0;
|
|
28
|
+
let current = "";
|
|
29
|
+
const parts = [];
|
|
30
|
+
for (const ch of schema) {
|
|
31
|
+
if (ch === "(") depth++;
|
|
32
|
+
if (ch === ")") depth--;
|
|
33
|
+
if (ch === "," && depth === 0) {
|
|
34
|
+
parts.push(current);
|
|
35
|
+
current = "";
|
|
36
|
+
} else {
|
|
37
|
+
current += ch;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (current.trim()) parts.push(current);
|
|
41
|
+
for (const part of parts) {
|
|
42
|
+
const trimmed = part.trim();
|
|
43
|
+
const match = trimmed.match(/^("([^"]+)"|`([^`]+)`|(\w+))\s+(.+)$/s);
|
|
44
|
+
if (!match) throw new Error(`Unparseable fixture column: '${trimmed}'`);
|
|
45
|
+
columns.push({
|
|
46
|
+
name: match[2] ?? match[3] ?? match[4] ?? "",
|
|
47
|
+
type: (match[5] ?? "").trim()
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (columns.length === 0) throw new Error("Fixture schema declared no columns");
|
|
51
|
+
return columns;
|
|
52
|
+
}
|
|
53
|
+
async function resolveFixtureRows(fixture, columns) {
|
|
54
|
+
const raw = [...fixture.rows ?? []];
|
|
55
|
+
if (fixture.ndjsonFile) {
|
|
56
|
+
const fs = await import('fs/promises');
|
|
57
|
+
const text = await fs.readFile(fixture.ndjsonFile, "utf8");
|
|
58
|
+
for (const line of text.split("\n")) {
|
|
59
|
+
if (line.trim().length === 0) continue;
|
|
60
|
+
raw.push(JSON.parse(line));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return raw.map((row) => {
|
|
64
|
+
if (Array.isArray(row)) {
|
|
65
|
+
if (row.length !== columns.length) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Fixture row for ${fixture.table} has ${row.length} values, expected ${columns.length}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return [...row];
|
|
71
|
+
}
|
|
72
|
+
const record = row;
|
|
73
|
+
return columns.map((c) => c.name in record ? record[c.name] : null);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
var init_fixtures = __esm({
|
|
77
|
+
"src/fixtures.ts"() {
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// src/duckdb-context.ts
|
|
82
|
+
var duckdb_context_exports = {};
|
|
83
|
+
__export(duckdb_context_exports, {
|
|
84
|
+
createDuckDbContext: () => createDuckDbContext,
|
|
85
|
+
normalizeRow: () => normalizeRow
|
|
86
|
+
});
|
|
87
|
+
async function createDuckDbContext(options = {}) {
|
|
88
|
+
const factory = options.connectionFactory ?? defaultConnectionFactory;
|
|
89
|
+
const conn = await factory(options.path ?? ":memory:");
|
|
90
|
+
const schema = options.schema;
|
|
91
|
+
if (schema) await conn.run(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
|
|
92
|
+
const qual = (table) => schema ? `${schema}.${table}` : table;
|
|
93
|
+
return {
|
|
94
|
+
profile: "local",
|
|
95
|
+
qual,
|
|
96
|
+
async exec(sql, params) {
|
|
97
|
+
await conn.run(sql, params ? [...params] : void 0);
|
|
98
|
+
},
|
|
99
|
+
async query(sql, params) {
|
|
100
|
+
const reader = await conn.runAndReadAll(sql, params ? [...params] : void 0);
|
|
101
|
+
return reader.getRowObjects().map(normalizeRow);
|
|
102
|
+
},
|
|
103
|
+
async loadFixture(fixture) {
|
|
104
|
+
if (fixture.schema) {
|
|
105
|
+
const columns2 = parseFixtureColumns(fixture.schema);
|
|
106
|
+
const ddl = columns2.map((c) => `"${c.name}" ${portableType(c.type)}`).join(", ");
|
|
107
|
+
await conn.run(`CREATE TABLE IF NOT EXISTS ${qual(fixture.table)} (${ddl})`);
|
|
108
|
+
const rows2 = await resolveFixtureRows(fixture, columns2);
|
|
109
|
+
for (const row of rows2) {
|
|
110
|
+
const casts = columns2.map((c) => isTimestamp(c.type) ? "CAST(? AS TIMESTAMP)" : "?").join(", ");
|
|
111
|
+
await conn.run(`INSERT INTO ${qual(fixture.table)} VALUES (${casts})`, row);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const described = await conn.runAndReadAll(`DESCRIBE ${qual(fixture.table)}`);
|
|
116
|
+
const columns = described.getRowObjects().map((r) => ({ name: String(r.column_name), type: String(r.column_type) }));
|
|
117
|
+
const rows = await resolveFixtureRows(fixture, columns);
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
const casts = columns.map((c) => isTimestamp(c.type) ? "CAST(? AS TIMESTAMP)" : "?").join(", ");
|
|
120
|
+
await conn.run(`INSERT INTO ${qual(fixture.table)} VALUES (${casts})`, row);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
async close() {
|
|
124
|
+
conn.closeSync();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function portableType(type) {
|
|
129
|
+
return type.replace(/\bSTRING\b/gi, "VARCHAR");
|
|
130
|
+
}
|
|
131
|
+
function isTimestamp(type) {
|
|
132
|
+
return /TIMESTAMP/i.test(type);
|
|
133
|
+
}
|
|
134
|
+
function normalizeRow(row) {
|
|
135
|
+
const out = {};
|
|
136
|
+
for (const [key, value] of Object.entries(row)) {
|
|
137
|
+
out[key] = normalizeValue(value);
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
function normalizeValue(value) {
|
|
142
|
+
if (typeof value === "bigint") {
|
|
143
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(-Number.MAX_SAFE_INTEGER)) {
|
|
144
|
+
return value.toString();
|
|
145
|
+
}
|
|
146
|
+
return Number(value);
|
|
147
|
+
}
|
|
148
|
+
if (value !== null && typeof value === "object") {
|
|
149
|
+
const v = value;
|
|
150
|
+
if (typeof v.micros === "bigint") {
|
|
151
|
+
const millis = Number(v.micros / 1000n);
|
|
152
|
+
return new Date(millis).toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
|
153
|
+
}
|
|
154
|
+
if (typeof v.scale === "number" && typeof v.value === "bigint") {
|
|
155
|
+
return Number(v.value) / 10 ** v.scale;
|
|
156
|
+
}
|
|
157
|
+
return String(value);
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
async function defaultConnectionFactory(path) {
|
|
162
|
+
const moduleName = "@duckdb/node-api";
|
|
163
|
+
let mod;
|
|
164
|
+
try {
|
|
165
|
+
mod = await import(moduleName);
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Error(
|
|
168
|
+
"createDuckDbContext requires the optional peer @duckdb/node-api \u2014 run: pnpm add -D @duckdb/node-api"
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const instance = await mod.DuckDBInstance.create(path);
|
|
172
|
+
return await instance.connect();
|
|
173
|
+
}
|
|
174
|
+
var init_duckdb_context = __esm({
|
|
175
|
+
"src/duckdb-context.ts"() {
|
|
176
|
+
init_fixtures();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// src/workspace-context.ts
|
|
181
|
+
var workspace_context_exports = {};
|
|
182
|
+
__export(workspace_context_exports, {
|
|
183
|
+
createClientFromEnv: () => createClientFromEnv,
|
|
184
|
+
createWorkspaceContext: () => createWorkspaceContext,
|
|
185
|
+
parseStatementRows: () => parseStatementRows,
|
|
186
|
+
resolveTokenProvider: () => resolveTokenProvider,
|
|
187
|
+
toNamedParameters: () => toNamedParameters
|
|
188
|
+
});
|
|
189
|
+
async function createWorkspaceContext(options = {}) {
|
|
190
|
+
const env = options.env ?? process.env;
|
|
191
|
+
const warehouseId = resolveWarehouseId(env);
|
|
192
|
+
const catalog = env.DBX_TEST_CATALOG;
|
|
193
|
+
const schema = options.schema ?? env.DBX_TEST_SCHEMA;
|
|
194
|
+
const client = options.client ?? createClientFromEnv(env, options.fetchImpl);
|
|
195
|
+
const fixtureTables = /* @__PURE__ */ new Set();
|
|
196
|
+
const qual = (table) => [catalog, schema, table].filter((part) => Boolean(part)).join(".");
|
|
197
|
+
async function run(sql, params) {
|
|
198
|
+
const { statement, parameters } = toNamedParameters(sql, params ?? []);
|
|
199
|
+
const submitted = await client.post("/api/2.0/sql/statements", {
|
|
200
|
+
statement,
|
|
201
|
+
warehouse_id: warehouseId,
|
|
202
|
+
...catalog ? { catalog } : {},
|
|
203
|
+
...schema ? { schema } : {},
|
|
204
|
+
...parameters.length > 0 ? { parameters } : {},
|
|
205
|
+
wait_timeout: "30s",
|
|
206
|
+
on_wait_timeout: "CONTINUE"
|
|
207
|
+
});
|
|
208
|
+
options.onStatement?.({
|
|
209
|
+
sql,
|
|
210
|
+
...submitted.statement_id ? { statementId: submitted.statement_id } : {}
|
|
211
|
+
});
|
|
212
|
+
const state = submitted.status?.state;
|
|
213
|
+
let terminal = submitted;
|
|
214
|
+
if (state !== "SUCCEEDED" && state !== "FAILED" && state !== "CANCELED" && state !== "CLOSED") {
|
|
215
|
+
terminal = await databricks.waitForDatabricksStatement(
|
|
216
|
+
client,
|
|
217
|
+
submitted.statement_id ?? "",
|
|
218
|
+
{ pollIntervalMs: options.pollIntervalMs, timeoutMs: options.timeoutMs }
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (terminal.status?.state !== "SUCCEEDED") {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Databricks statement ${terminal.status?.state ?? "UNKNOWN"}: ${terminal.status?.error?.message ?? sql.slice(0, 200)}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return terminal;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
profile: "live",
|
|
230
|
+
qual,
|
|
231
|
+
async exec(sql, params) {
|
|
232
|
+
await run(sql, params);
|
|
233
|
+
},
|
|
234
|
+
async query(sql, params) {
|
|
235
|
+
const response = await run(sql, params);
|
|
236
|
+
return parseStatementRows(response);
|
|
237
|
+
},
|
|
238
|
+
async loadFixture(fixture) {
|
|
239
|
+
if (!schema) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
"Live fixtures require DBX_TEST_SCHEMA (or a schema option) so tests cannot replace unscoped tables"
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (!fixture.schema) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Live fixtures require explicit column DDL for ${fixture.table} (schema inference is local-only)`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const columns = parseFixtureColumns(fixture.schema);
|
|
250
|
+
const ddl = columns.map((c) => `\`${c.name}\` ${c.type}`).join(", ");
|
|
251
|
+
const target = qual(fixture.table);
|
|
252
|
+
await run(`CREATE OR REPLACE TABLE ${target} (${ddl}) USING DELTA`);
|
|
253
|
+
fixtureTables.add(target);
|
|
254
|
+
const rows = await resolveFixtureRows(fixture, columns);
|
|
255
|
+
for (const row of rows) {
|
|
256
|
+
const placeholders = columns.map((c) => /TIMESTAMP/i.test(c.type) ? "CAST(? AS TIMESTAMP)" : "?").join(", ");
|
|
257
|
+
await run(`INSERT INTO ${qual(fixture.table)} VALUES (${placeholders})`, row);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
async close() {
|
|
261
|
+
if (options.cleanupFixtures !== false && env.DBX_TEST_KEEP_FIXTURES !== "1") {
|
|
262
|
+
for (const table of [...fixtureTables].reverse()) {
|
|
263
|
+
await run(`DROP TABLE IF EXISTS ${table}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
fixtureTables.clear();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function toNamedParameters(sql, params) {
|
|
271
|
+
let out = "";
|
|
272
|
+
let index = 0;
|
|
273
|
+
let quote = null;
|
|
274
|
+
for (let i = 0; i < sql.length; i++) {
|
|
275
|
+
const ch = sql[i];
|
|
276
|
+
if (quote) {
|
|
277
|
+
out += ch;
|
|
278
|
+
if (ch === quote) quote = null;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
282
|
+
quote = ch;
|
|
283
|
+
out += ch;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (ch === "?") {
|
|
287
|
+
index += 1;
|
|
288
|
+
out += `:p${index}`;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
out += ch;
|
|
292
|
+
}
|
|
293
|
+
if (index !== params.length) {
|
|
294
|
+
throw new Error(`SQL has ${index} placeholders but ${params.length} params were provided`);
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
statement: out,
|
|
298
|
+
parameters: params.map((value, i) => ({
|
|
299
|
+
name: `p${i + 1}`,
|
|
300
|
+
value: value === null || value === void 0 ? null : String(value)
|
|
301
|
+
}))
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function parseStatementRows(response) {
|
|
305
|
+
const columns = [...response.manifest?.schema?.columns ?? []].sort(
|
|
306
|
+
(a, b) => (a.position ?? 0) - (b.position ?? 0)
|
|
307
|
+
);
|
|
308
|
+
const data = response.result?.data_array ?? [];
|
|
309
|
+
return data.map((tuple) => {
|
|
310
|
+
const row = {};
|
|
311
|
+
columns.forEach((column, i) => {
|
|
312
|
+
row[column.name] = convertValue(tuple[i] ?? null, column.type_name ?? "STRING");
|
|
313
|
+
});
|
|
314
|
+
return row;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
function convertValue(value, typeName) {
|
|
318
|
+
if (value === null) return null;
|
|
319
|
+
if (NUMERIC_TYPES.test(typeName)) return Number(value);
|
|
320
|
+
if (/^BOOLEAN/i.test(typeName)) return value === "true";
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
function resolveWarehouseId(env) {
|
|
324
|
+
if (env.DATABRICKS_WAREHOUSE_ID) return env.DATABRICKS_WAREHOUSE_ID;
|
|
325
|
+
const httpPath = env.DATABRICKS_HTTP_PATH;
|
|
326
|
+
const match = httpPath?.match(/\/warehouses\/([\w-]+)/);
|
|
327
|
+
if (match?.[1]) return match[1];
|
|
328
|
+
throw new Error(
|
|
329
|
+
"Live profile requires DATABRICKS_WAREHOUSE_ID or DATABRICKS_HTTP_PATH (/sql/1.0/warehouses/<id>)"
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
function createClientFromEnv(env, fetchImpl) {
|
|
333
|
+
const host = env.DATABRICKS_HOST;
|
|
334
|
+
if (!host) throw new Error("Live profile requires DATABRICKS_HOST");
|
|
335
|
+
return new databricks.DatabricksRestClient({
|
|
336
|
+
host,
|
|
337
|
+
token: resolveTokenProvider(host, env, fetchImpl),
|
|
338
|
+
...fetchImpl ? { fetchImpl } : {}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
function resolveTokenProvider(host, env, fetchImpl = fetch) {
|
|
342
|
+
if (env.DATABRICKS_BEARER) return env.DATABRICKS_BEARER;
|
|
343
|
+
const clientId = env.DATABRICKS_CLIENT_ID;
|
|
344
|
+
const clientSecret = env.DATABRICKS_CLIENT_SECRET;
|
|
345
|
+
if (clientId && clientSecret) {
|
|
346
|
+
let cached = null;
|
|
347
|
+
return async () => {
|
|
348
|
+
if (cached && Date.now() < cached.expiresAt) return cached.token;
|
|
349
|
+
const response = await fetchImpl(`${host.replace(/\/$/, "")}/oidc/v1/token`, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: {
|
|
352
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
353
|
+
authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`
|
|
354
|
+
},
|
|
355
|
+
body: new URLSearchParams({
|
|
356
|
+
grant_type: "client_credentials",
|
|
357
|
+
scope: env.DATABRICKS_SCOPE ?? "all-apis"
|
|
358
|
+
}).toString()
|
|
359
|
+
});
|
|
360
|
+
if (!response.ok) {
|
|
361
|
+
throw new Error(`OAuth M2M token exchange failed: ${response.status}`);
|
|
362
|
+
}
|
|
363
|
+
const body = await response.json();
|
|
364
|
+
cached = {
|
|
365
|
+
token: body.access_token,
|
|
366
|
+
expiresAt: Date.now() + ((body.expires_in ?? 3600) - 300) * 1e3
|
|
367
|
+
};
|
|
368
|
+
return body.access_token;
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (env.DATABRICKS_TOKEN) return env.DATABRICKS_TOKEN;
|
|
372
|
+
throw new Error(
|
|
373
|
+
"Live profile requires DATABRICKS_BEARER, DATABRICKS_CLIENT_ID/SECRET, or DATABRICKS_TOKEN"
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
var NUMERIC_TYPES;
|
|
377
|
+
var init_workspace_context = __esm({
|
|
378
|
+
"src/workspace-context.ts"() {
|
|
379
|
+
init_fixtures();
|
|
380
|
+
NUMERIC_TYPES = /^(INT|LONG|SHORT|BYTE|FLOAT|DOUBLE|DECIMAL)/i;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// src/context.ts
|
|
385
|
+
function resolveProfile(env = process.env) {
|
|
386
|
+
const profile = env.DBX_TEST_PROFILE ?? "local";
|
|
387
|
+
if (profile !== "local" && profile !== "live") {
|
|
388
|
+
throw new Error(`DBX_TEST_PROFILE must be 'local' or 'live', got '${profile}'`);
|
|
389
|
+
}
|
|
390
|
+
if (profile === "live" && env.DBX_TEST_LIVE !== "1") {
|
|
391
|
+
throw new Error(
|
|
392
|
+
"DBX_TEST_PROFILE=live requires DBX_TEST_LIVE=1 (live suites are always gated)"
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return profile;
|
|
396
|
+
}
|
|
397
|
+
async function createExecutionContext(options = {}) {
|
|
398
|
+
const env = options.env ?? process.env;
|
|
399
|
+
const profile = options.profile ?? resolveProfile(env);
|
|
400
|
+
if (profile === "local") {
|
|
401
|
+
const { createDuckDbContext: createDuckDbContext2 } = await Promise.resolve().then(() => (init_duckdb_context(), duckdb_context_exports));
|
|
402
|
+
return createDuckDbContext2({ schema: options.schema });
|
|
403
|
+
}
|
|
404
|
+
const { createWorkspaceContext: createWorkspaceContext2 } = await Promise.resolve().then(() => (init_workspace_context(), workspace_context_exports));
|
|
405
|
+
return createWorkspaceContext2({
|
|
406
|
+
env,
|
|
407
|
+
schema: options.schema,
|
|
408
|
+
...options.onStatement ? { onStatement: options.onStatement } : {}
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/index.ts
|
|
413
|
+
init_fixtures();
|
|
414
|
+
init_duckdb_context();
|
|
415
|
+
init_workspace_context();
|
|
416
|
+
|
|
417
|
+
// src/golden.ts
|
|
418
|
+
async function compareToGolden(goldenPath, actual, label, env = process.env) {
|
|
419
|
+
const fs = await import('fs/promises');
|
|
420
|
+
const serialized = `${JSON.stringify(actual, null, 2)}
|
|
421
|
+
`;
|
|
422
|
+
if (env.DBX_TEST_UPDATE_GOLDEN === "1") {
|
|
423
|
+
const { dirname: dirname2 } = await import('path');
|
|
424
|
+
await fs.mkdir(dirname2(goldenPath), { recursive: true });
|
|
425
|
+
await fs.writeFile(goldenPath, serialized, "utf8");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
let expected;
|
|
429
|
+
try {
|
|
430
|
+
expected = await fs.readFile(goldenPath, "utf8");
|
|
431
|
+
} catch {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`Golden file ${goldenPath} does not exist for ${label}. Run once with DBX_TEST_UPDATE_GOLDEN=1 to create it, then commit it.`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
if (normalize(expected) !== normalize(serialized)) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
`${label} diverges from golden ${goldenPath}.
|
|
439
|
+
Expected:
|
|
440
|
+
${expected}
|
|
441
|
+
Actual:
|
|
442
|
+
${serialized}
|
|
443
|
+
If the change is intentional, re-run with DBX_TEST_UPDATE_GOLDEN=1 and commit the diff.`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function normalize(json) {
|
|
448
|
+
return JSON.stringify(JSON.parse(json));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/assert.ts
|
|
452
|
+
function expectTable(ctx, table) {
|
|
453
|
+
const target = ctx.qual(table);
|
|
454
|
+
return {
|
|
455
|
+
async toHaveRowCount(expected) {
|
|
456
|
+
const rows = await ctx.query(`SELECT COUNT(*) AS n FROM ${target}`);
|
|
457
|
+
const actual = Number(rows[0]?.n ?? Number.NaN);
|
|
458
|
+
if (actual !== expected) {
|
|
459
|
+
throw new Error(`Expected ${target} to have ${expected} rows, found ${actual}`);
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
/** Every partial row must match at least one table row on its own keys. */
|
|
463
|
+
async toContainRows(partials) {
|
|
464
|
+
const rows = await ctx.query(`SELECT * FROM ${target}`);
|
|
465
|
+
for (const partial of partials) {
|
|
466
|
+
const hit = rows.some(
|
|
467
|
+
(row) => Object.entries(partial).every(([key, value]) => looselyEqual(row[key], value))
|
|
468
|
+
);
|
|
469
|
+
if (!hit) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
`Expected ${target} to contain a row matching ${JSON.stringify(partial)}; have ${rows.length} rows: ${JSON.stringify(rows.slice(0, 5))}${rows.length > 5 ? " \u2026" : ""}`
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
/**
|
|
477
|
+
* Compare `SELECT * ORDER BY <orderBy>` against a committed golden JSON
|
|
478
|
+
* file. Set DBX_TEST_UPDATE_GOLDEN=1 to (re)write the golden instead —
|
|
479
|
+
* a reviewable, versioned event.
|
|
480
|
+
*/
|
|
481
|
+
async toMatchGolden(goldenPath, options = {}) {
|
|
482
|
+
const orderBy = options.orderBy ? ` ORDER BY ${options.orderBy}` : "";
|
|
483
|
+
const rows = await ctx.query(`SELECT * FROM ${target}${orderBy}`);
|
|
484
|
+
await compareToGolden(goldenPath, rows, `table ${target}`);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
async function expectQueryToMatchGolden(ctx, sql, params, goldenPath) {
|
|
489
|
+
const rows = await ctx.query(sql, params);
|
|
490
|
+
await compareToGolden(goldenPath, rows, "query");
|
|
491
|
+
}
|
|
492
|
+
function looselyEqual(a, b) {
|
|
493
|
+
if (a === b) return true;
|
|
494
|
+
if (typeof a === "number" && typeof b === "number") return Math.abs(a - b) < 1e-9;
|
|
495
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
496
|
+
return a.replace("T", " ").replace(/(\.\d+)?Z?$/, "") === b.replace("T", " ").replace(/(\.\d+)?Z?$/, "");
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/mock-client.ts
|
|
502
|
+
function createMockDatabricksClient() {
|
|
503
|
+
const queues = { GET: [], POST: [] };
|
|
504
|
+
const calls = [];
|
|
505
|
+
function take(method, path) {
|
|
506
|
+
const index = queues[method].findIndex((q) => path.startsWith(q.pathPrefix));
|
|
507
|
+
if (index === -1) {
|
|
508
|
+
throw new Error(
|
|
509
|
+
`MockDatabricksClient: no queued ${method} response for ${path}. Queued prefixes: ${queues[method].map((q) => q.pathPrefix).join(", ") || "(none)"}`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
return queues[method].splice(index, 1)[0];
|
|
513
|
+
}
|
|
514
|
+
const client = {
|
|
515
|
+
async get(path, query) {
|
|
516
|
+
calls.push({ method: "GET", path, query });
|
|
517
|
+
const queued = take("GET", path);
|
|
518
|
+
if (queued.error) throw queued.error;
|
|
519
|
+
return queued.response;
|
|
520
|
+
},
|
|
521
|
+
async post(path, body, query) {
|
|
522
|
+
calls.push({ method: "POST", path, body, query });
|
|
523
|
+
const queued = take("POST", path);
|
|
524
|
+
if (queued.error) throw queued.error;
|
|
525
|
+
return queued.response;
|
|
526
|
+
},
|
|
527
|
+
queue(method, pathPrefix, response) {
|
|
528
|
+
queues[method].push({ pathPrefix, response });
|
|
529
|
+
return client;
|
|
530
|
+
},
|
|
531
|
+
queueError(method, pathPrefix, error) {
|
|
532
|
+
queues[method].push({ pathPrefix, error });
|
|
533
|
+
return client;
|
|
534
|
+
},
|
|
535
|
+
calls,
|
|
536
|
+
callsTo(method, pathPrefix) {
|
|
537
|
+
return calls.filter((c) => c.method === method && c.path.startsWith(pathPrefix));
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
return client;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/live-suite.ts
|
|
544
|
+
init_workspace_context();
|
|
545
|
+
function defineLiveSuite(spec) {
|
|
546
|
+
return { run: (runtime) => runLiveSuite(spec, runtime) };
|
|
547
|
+
}
|
|
548
|
+
async function runLiveSuite(spec, runtime = {}) {
|
|
549
|
+
const env = spec.env ?? process.env;
|
|
550
|
+
if (env.DBX_TEST_LIVE !== "1") return void 0;
|
|
551
|
+
const required2 = [...spec.required ?? []];
|
|
552
|
+
const known = new Set(spec.checks.map((check) => check.id));
|
|
553
|
+
for (const id of required2) {
|
|
554
|
+
if (!known.has(id)) throw new Error(`Required live check '${id}' is not defined`);
|
|
555
|
+
}
|
|
556
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
557
|
+
const ownedWarehouse = runtime.warehouse === void 0;
|
|
558
|
+
const client = runtime.client ?? createClientFromEnv(env);
|
|
559
|
+
const warehouse = runtime.warehouse ?? await createWorkspaceContext({ env, client });
|
|
560
|
+
const results = [];
|
|
561
|
+
try {
|
|
562
|
+
for (const check of spec.checks) {
|
|
563
|
+
if (check.configured && !check.configured(env)) {
|
|
564
|
+
results.push({
|
|
565
|
+
id: check.id,
|
|
566
|
+
description: check.description,
|
|
567
|
+
status: "not-configured",
|
|
568
|
+
durationMs: 0
|
|
569
|
+
});
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const before = performance.now();
|
|
573
|
+
try {
|
|
574
|
+
const details = await check.run({ env, warehouse, client });
|
|
575
|
+
results.push({
|
|
576
|
+
id: check.id,
|
|
577
|
+
description: check.description,
|
|
578
|
+
status: "pass",
|
|
579
|
+
durationMs: Math.round(performance.now() - before),
|
|
580
|
+
...details === void 0 ? {} : { details: redactValue(details, env) }
|
|
581
|
+
});
|
|
582
|
+
} catch (error) {
|
|
583
|
+
results.push({
|
|
584
|
+
id: check.id,
|
|
585
|
+
description: check.description,
|
|
586
|
+
status: "fail",
|
|
587
|
+
durationMs: Math.round(performance.now() - before),
|
|
588
|
+
error: redactText(error instanceof Error ? error.message : String(error), env)
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
} finally {
|
|
593
|
+
if (ownedWarehouse) await warehouse.close();
|
|
594
|
+
}
|
|
595
|
+
const success = required2.every(
|
|
596
|
+
(id) => results.find((result) => result.id === id)?.status === "pass"
|
|
597
|
+
);
|
|
598
|
+
const evidence = {
|
|
599
|
+
schemaVersion: 1,
|
|
600
|
+
kind: "databricks-live-suite",
|
|
601
|
+
startedAt,
|
|
602
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
603
|
+
success,
|
|
604
|
+
required: required2,
|
|
605
|
+
results
|
|
606
|
+
};
|
|
607
|
+
if (spec.evidencePath)
|
|
608
|
+
await writeReport(spec.evidencePath, `${JSON.stringify(evidence, null, 2)}
|
|
609
|
+
`);
|
|
610
|
+
if (spec.junitPath) await writeReport(spec.junitPath, renderJUnit(evidence));
|
|
611
|
+
return evidence;
|
|
612
|
+
}
|
|
613
|
+
async function writeReport(path$1, contents) {
|
|
614
|
+
await promises.mkdir(path.dirname(path$1), { recursive: true });
|
|
615
|
+
await promises.writeFile(path$1, contents, "utf8");
|
|
616
|
+
}
|
|
617
|
+
function redactText(text, env) {
|
|
618
|
+
let output = text;
|
|
619
|
+
for (const [name, value] of Object.entries(env)) {
|
|
620
|
+
if (!value || value.length < 6 || !/(TOKEN|SECRET|PASSWORD|BEARER|KEY)/i.test(name)) continue;
|
|
621
|
+
output = output.split(value).join("[REDACTED]");
|
|
622
|
+
}
|
|
623
|
+
return output;
|
|
624
|
+
}
|
|
625
|
+
function redactValue(value, env) {
|
|
626
|
+
return JSON.parse(redactText(JSON.stringify(value), env));
|
|
627
|
+
}
|
|
628
|
+
function xml(value) {
|
|
629
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
630
|
+
}
|
|
631
|
+
function renderJUnit(evidence) {
|
|
632
|
+
const failures = evidence.results.filter((result) => result.status === "fail").length;
|
|
633
|
+
const skipped = evidence.results.filter((result) => result.status === "not-configured").length;
|
|
634
|
+
const seconds = evidence.results.reduce((sum, result) => sum + result.durationMs, 0) / 1e3;
|
|
635
|
+
const cases = evidence.results.map((result) => {
|
|
636
|
+
const body = result.status === "fail" ? `<failure message="${xml(result.error ?? "check failed")}"/>` : result.status === "not-configured" ? '<skipped message="not configured"/>' : "";
|
|
637
|
+
return ` <testcase classname="databricks-live" name="${xml(result.id)}" time="${result.durationMs / 1e3}">${body}</testcase>`;
|
|
638
|
+
}).join("\n");
|
|
639
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
640
|
+
<testsuite name="databricks-live" tests="${evidence.results.length}" failures="${failures}" skipped="${skipped}" time="${seconds}">
|
|
641
|
+
${cases}
|
|
642
|
+
</testsuite>
|
|
643
|
+
`;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// src/pollers.ts
|
|
647
|
+
var TERMINAL_PIPELINE_STATES = /* @__PURE__ */ new Set(["COMPLETED", "FAILED", "CANCELED"]);
|
|
648
|
+
async function waitForPipelineUpdate(client, pipelineId, updateId, options = {}) {
|
|
649
|
+
const deadline = Date.now() + (options.timeoutMs ?? 9e5);
|
|
650
|
+
const interval = options.pollIntervalMs ?? 1e4;
|
|
651
|
+
const path = `/api/2.0/pipelines/${encodeURIComponent(pipelineId)}/updates/${encodeURIComponent(updateId)}`;
|
|
652
|
+
let update = await client.get(path);
|
|
653
|
+
while (!TERMINAL_PIPELINE_STATES.has(update.update?.state ?? "") && Date.now() < deadline) {
|
|
654
|
+
if (options.signal?.aborted) throw new Error(`Pipeline update ${updateId} was aborted`);
|
|
655
|
+
await abortableDelay(interval, options.signal);
|
|
656
|
+
update = await client.get(path);
|
|
657
|
+
}
|
|
658
|
+
const state = update.update?.state ?? "UNKNOWN";
|
|
659
|
+
if (state !== "COMPLETED") {
|
|
660
|
+
throw new Error(`Pipeline update ${updateId} ended in ${state} (wanted COMPLETED)`);
|
|
661
|
+
}
|
|
662
|
+
return update;
|
|
663
|
+
}
|
|
664
|
+
function abortableDelay(ms, signal) {
|
|
665
|
+
return new Promise((resolve2, reject) => {
|
|
666
|
+
if (signal?.aborted) {
|
|
667
|
+
reject(new Error("Polling aborted"));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const timer = setTimeout(resolve2, ms);
|
|
671
|
+
signal?.addEventListener(
|
|
672
|
+
"abort",
|
|
673
|
+
() => {
|
|
674
|
+
clearTimeout(timer);
|
|
675
|
+
reject(new Error("Polling aborted"));
|
|
676
|
+
},
|
|
677
|
+
{ once: true }
|
|
678
|
+
);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/live-checks.ts
|
|
683
|
+
function sqlRoundTripCheck() {
|
|
684
|
+
return {
|
|
685
|
+
id: "sql",
|
|
686
|
+
description: "SQL Statement Execution round-trip",
|
|
687
|
+
async run({ warehouse }) {
|
|
688
|
+
const rows = await warehouse.query("SELECT 1 AS dbx_test_ok");
|
|
689
|
+
if (Number(rows[0]?.dbx_test_ok) !== 1)
|
|
690
|
+
throw new Error("SQL round-trip returned an unexpected value");
|
|
691
|
+
return { rows: rows.length };
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
function assertDeltaContract(table, rows, columns) {
|
|
696
|
+
for (const expected of columns) {
|
|
697
|
+
const actual = rows.find((row) => String(row.col_name ?? row.column_name) === expected.name);
|
|
698
|
+
if (!actual) throw new Error(`Delta contract missing column '${expected.name}' in ${table}`);
|
|
699
|
+
const actualType = String(actual.data_type ?? actual.column_type ?? "").toLowerCase();
|
|
700
|
+
if (expected.type && actualType !== expected.type.toLowerCase()) {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`Delta column ${expected.name} has type ${actualType}, expected ${expected.type}`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function deltaContractCheck(tableEnv = "DBX_TEST_DELTA_TABLE", columns = []) {
|
|
708
|
+
return {
|
|
709
|
+
id: "delta-contract",
|
|
710
|
+
description: "Delta table schema contract",
|
|
711
|
+
configured: (env) => Boolean(env[tableEnv]),
|
|
712
|
+
async run({ env, warehouse }) {
|
|
713
|
+
const table = env[tableEnv];
|
|
714
|
+
const rows = await warehouse.query(`DESCRIBE TABLE ${table}`);
|
|
715
|
+
assertDeltaContract(table, rows, columns);
|
|
716
|
+
return { table, columns: rows.length };
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
function unityCatalogAccessCheck() {
|
|
721
|
+
return {
|
|
722
|
+
id: "uc-grants",
|
|
723
|
+
description: "Unity Catalog allow/deny access assertions",
|
|
724
|
+
configured: (env) => Boolean(env.DBX_TEST_UC_ALLOWED_TABLE && env.DBX_TEST_UC_DENIED_TABLE),
|
|
725
|
+
async run({ env, warehouse }) {
|
|
726
|
+
await warehouse.query(`SELECT * FROM ${env.DBX_TEST_UC_ALLOWED_TABLE} LIMIT 1`);
|
|
727
|
+
try {
|
|
728
|
+
await warehouse.query(`SELECT * FROM ${env.DBX_TEST_UC_DENIED_TABLE} LIMIT 1`);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
if (/permission.?denied|insufficient.?priv|403|unauthoriz/i.test(String(error))) {
|
|
731
|
+
return { allowed: env.DBX_TEST_UC_ALLOWED_TABLE, denied: env.DBX_TEST_UC_DENIED_TABLE };
|
|
732
|
+
}
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
throw new Error(`Expected access to ${env.DBX_TEST_UC_DENIED_TABLE} to be denied`);
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function jobRunCheck() {
|
|
740
|
+
return {
|
|
741
|
+
id: "jobs",
|
|
742
|
+
description: "Databricks Job run-now reaches SUCCESS",
|
|
743
|
+
configured: (env) => Boolean(env.DBX_TEST_JOB_ID),
|
|
744
|
+
async run({ env, client }) {
|
|
745
|
+
const jobId = Number(env.DBX_TEST_JOB_ID);
|
|
746
|
+
if (!Number.isFinite(jobId)) throw new Error("DBX_TEST_JOB_ID must be numeric");
|
|
747
|
+
const submitted = await client.post("/api/2.1/jobs/run-now", {
|
|
748
|
+
job_id: jobId
|
|
749
|
+
});
|
|
750
|
+
const run = await databricks.waitForDatabricksRun(client, submitted.run_id, {
|
|
751
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
752
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
753
|
+
});
|
|
754
|
+
const result = run.state?.result_state;
|
|
755
|
+
if (result !== "SUCCESS") throw new Error(`Job ${jobId} ended in ${result ?? "UNKNOWN"}`);
|
|
756
|
+
return { jobId, runId: submitted.run_id };
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
function pipelineRefreshCheck() {
|
|
761
|
+
return {
|
|
762
|
+
id: "pipeline",
|
|
763
|
+
description: "Lakeflow telemetry pipeline refresh reaches COMPLETED",
|
|
764
|
+
configured: (env) => Boolean(env.DBX_TEST_PIPELINE_ID),
|
|
765
|
+
async run({ env, client }) {
|
|
766
|
+
const pipelineId = env.DBX_TEST_PIPELINE_ID;
|
|
767
|
+
const submitted = await client.post(
|
|
768
|
+
`/api/2.0/pipelines/${encodeURIComponent(pipelineId)}/updates`,
|
|
769
|
+
{ full_refresh: false }
|
|
770
|
+
);
|
|
771
|
+
if (!submitted.update_id) {
|
|
772
|
+
throw new Error(`Pipeline ${pipelineId} did not return an update_id`);
|
|
773
|
+
}
|
|
774
|
+
await waitForPipelineUpdate(client, pipelineId, submitted.update_id, {
|
|
775
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
776
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
777
|
+
});
|
|
778
|
+
return { pipelineId, updateId: submitted.update_id };
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function notebookSubmitCheck() {
|
|
783
|
+
return {
|
|
784
|
+
id: "notebook",
|
|
785
|
+
description: "Databricks notebook submit run reaches SUCCESS",
|
|
786
|
+
configured: (env) => Boolean(env.DBX_TEST_NOTEBOOK_PATH && env.DBX_TEST_CLUSTER_ID),
|
|
787
|
+
async run({ env, client }) {
|
|
788
|
+
if (!env.DBX_TEST_NOTEBOOK_PATH || !env.DBX_TEST_CLUSTER_ID) {
|
|
789
|
+
throw new Error("Notebook submit requires DBX_TEST_NOTEBOOK_PATH and DBX_TEST_CLUSTER_ID");
|
|
790
|
+
}
|
|
791
|
+
const submitted = await client.post("/api/2.1/jobs/runs/submit", {
|
|
792
|
+
run_name: `fabric-experiments-notebook-${Date.now()}`,
|
|
793
|
+
tasks: [
|
|
794
|
+
{
|
|
795
|
+
task_key: "notebook",
|
|
796
|
+
existing_cluster_id: env.DBX_TEST_CLUSTER_ID,
|
|
797
|
+
notebook_task: { notebook_path: env.DBX_TEST_NOTEBOOK_PATH }
|
|
798
|
+
}
|
|
799
|
+
]
|
|
800
|
+
});
|
|
801
|
+
if (!submitted.run_id) throw new Error("Notebook submit returned no run_id");
|
|
802
|
+
const run = await databricks.waitForDatabricksRun(client, submitted.run_id, {
|
|
803
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
804
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
805
|
+
});
|
|
806
|
+
const result = run.state?.result_state;
|
|
807
|
+
if (result !== "SUCCESS") throw new Error(`Notebook run ended in ${result ?? "UNKNOWN"}`);
|
|
808
|
+
return { runId: submitted.run_id, notebookPath: env.DBX_TEST_NOTEBOOK_PATH };
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
function dbtBuildCheck() {
|
|
813
|
+
return {
|
|
814
|
+
id: "dbt",
|
|
815
|
+
description: "Databricks dbt task reaches SUCCESS",
|
|
816
|
+
configured: (env) => Boolean(env.DBX_TEST_DBT_GIT_URL && env.DATABRICKS_WAREHOUSE_ID),
|
|
817
|
+
async run({ env, client }) {
|
|
818
|
+
if (!env.DBX_TEST_DBT_GIT_URL || !env.DATABRICKS_WAREHOUSE_ID) {
|
|
819
|
+
throw new Error("dbt build requires DBX_TEST_DBT_GIT_URL and DATABRICKS_WAREHOUSE_ID");
|
|
820
|
+
}
|
|
821
|
+
const submitted = await client.post("/api/2.1/jobs/runs/submit", {
|
|
822
|
+
run_name: `fabric-experiments-dbt-${Date.now()}`,
|
|
823
|
+
git_source: {
|
|
824
|
+
git_url: env.DBX_TEST_DBT_GIT_URL,
|
|
825
|
+
git_provider: env.DBX_TEST_DBT_GIT_PROVIDER ?? "gitHub",
|
|
826
|
+
git_branch: env.DBX_TEST_DBT_GIT_BRANCH ?? "main"
|
|
827
|
+
},
|
|
828
|
+
tasks: [
|
|
829
|
+
{
|
|
830
|
+
task_key: "dbt_build",
|
|
831
|
+
dbt_task: {
|
|
832
|
+
commands: ["dbt deps", "dbt build"],
|
|
833
|
+
warehouse_id: env.DATABRICKS_WAREHOUSE_ID,
|
|
834
|
+
...env.DBX_TEST_DBT_PROJECT_DIR ? { project_directory: env.DBX_TEST_DBT_PROJECT_DIR } : {}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
]
|
|
838
|
+
});
|
|
839
|
+
if (!submitted.run_id) throw new Error("dbt submit returned no run_id");
|
|
840
|
+
const run = await databricks.waitForDatabricksRun(client, submitted.run_id, {
|
|
841
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
842
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
843
|
+
});
|
|
844
|
+
const result = run.state?.result_state;
|
|
845
|
+
if (result !== "SUCCESS") throw new Error(`dbt run ended in ${result ?? "UNKNOWN"}`);
|
|
846
|
+
return { runId: submitted.run_id, gitUrl: env.DBX_TEST_DBT_GIT_URL };
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function customLiveCheck(id, description, run, configured) {
|
|
851
|
+
return { id, description, run, ...configured ? { configured } : {} };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/lakebase-checks.ts
|
|
855
|
+
init_workspace_context();
|
|
856
|
+
function required(env, name) {
|
|
857
|
+
const value = env[name]?.trim();
|
|
858
|
+
if (!value) throw new Error(`${name} is required`);
|
|
859
|
+
return value;
|
|
860
|
+
}
|
|
861
|
+
function createLakebaseTestProvider(env, options = {}) {
|
|
862
|
+
return new experimentsDbPool.LakebaseDatabaseProvider({
|
|
863
|
+
host: required(env, "DBX_TEST_LAKEBASE_HOST"),
|
|
864
|
+
database: required(env, "DBX_TEST_LAKEBASE_DATABASE"),
|
|
865
|
+
user: required(env, "DBX_TEST_LAKEBASE_USER"),
|
|
866
|
+
workspaceHost: required(env, "DATABRICKS_HOST"),
|
|
867
|
+
endpoint: required(env, "DBX_TEST_LAKEBASE_ENDPOINT"),
|
|
868
|
+
token: resolveTokenProvider(required(env, "DATABRICKS_HOST"), env, options.fetchImpl),
|
|
869
|
+
fetchImpl: options.fetchImpl,
|
|
870
|
+
poolFactory: options.poolFactory,
|
|
871
|
+
max: 1
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
async function workspaceToken(env, fetchImpl) {
|
|
875
|
+
const host = required(env, "DATABRICKS_HOST");
|
|
876
|
+
const provider = resolveTokenProvider(host, env, fetchImpl);
|
|
877
|
+
return typeof provider === "string" ? provider : provider();
|
|
878
|
+
}
|
|
879
|
+
function lakebaseCredentialCheck(options = {}) {
|
|
880
|
+
return {
|
|
881
|
+
id: "lakebase-credential",
|
|
882
|
+
description: "Lakebase OAuth credential exchange",
|
|
883
|
+
configured: (env) => Boolean(env.DBX_TEST_LAKEBASE_ENDPOINT),
|
|
884
|
+
async run({ env }) {
|
|
885
|
+
const credential = await experimentsDbPool.exchangeLakebaseCredential({
|
|
886
|
+
workspaceHost: required(env, "DATABRICKS_HOST"),
|
|
887
|
+
endpoint: required(env, "DBX_TEST_LAKEBASE_ENDPOINT"),
|
|
888
|
+
workspaceToken: await workspaceToken(env, options.fetchImpl),
|
|
889
|
+
fetchImpl: options.fetchImpl
|
|
890
|
+
});
|
|
891
|
+
if (credential.expiresAt <= Date.now()) {
|
|
892
|
+
throw new Error("Lakebase credential is already expired");
|
|
893
|
+
}
|
|
894
|
+
return { expiresAt: new Date(credential.expiresAt).toISOString() };
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
function lakebaseRoundTripCheck(options = {}) {
|
|
899
|
+
return {
|
|
900
|
+
id: "lakebase",
|
|
901
|
+
description: "Lakebase provider query and credential refresh round-trip",
|
|
902
|
+
configured: (env) => Boolean(
|
|
903
|
+
env.DBX_TEST_LAKEBASE_ENDPOINT && env.DBX_TEST_LAKEBASE_HOST && env.DBX_TEST_LAKEBASE_DATABASE && env.DBX_TEST_LAKEBASE_USER
|
|
904
|
+
),
|
|
905
|
+
async run({ env }) {
|
|
906
|
+
const provider = createLakebaseTestProvider(env, options);
|
|
907
|
+
try {
|
|
908
|
+
const first = await provider.getPool().query("SELECT 1 AS dbx_test_ok");
|
|
909
|
+
if (Number(first.rows[0]?.dbx_test_ok) !== 1) {
|
|
910
|
+
throw new Error("Lakebase round-trip returned an unexpected value");
|
|
911
|
+
}
|
|
912
|
+
await provider.refreshCredentials();
|
|
913
|
+
const second = await provider.getPool().query("SELECT 1 AS dbx_test_ok");
|
|
914
|
+
if (Number(second.rows[0]?.dbx_test_ok) !== 1) {
|
|
915
|
+
throw new Error("Lakebase post-refresh round-trip returned an unexpected value");
|
|
916
|
+
}
|
|
917
|
+
return { queries: 2, refreshed: true };
|
|
918
|
+
} finally {
|
|
919
|
+
await provider.close();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// src/databricks-http.ts
|
|
926
|
+
init_workspace_context();
|
|
927
|
+
async function databricksFetch(env, pathOrUrl, init = {}, fetchImpl = fetch) {
|
|
928
|
+
const host = env.DATABRICKS_HOST?.replace(/\/$/, "");
|
|
929
|
+
if (!host) throw new Error("DATABRICKS_HOST is required");
|
|
930
|
+
const provider = resolveTokenProvider(host, env, fetchImpl);
|
|
931
|
+
const token = typeof provider === "string" ? provider : await provider();
|
|
932
|
+
const headers = new Headers(init.headers);
|
|
933
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
934
|
+
const response = await fetchImpl(
|
|
935
|
+
/^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${host}${pathOrUrl}`,
|
|
936
|
+
{
|
|
937
|
+
...init,
|
|
938
|
+
headers
|
|
939
|
+
}
|
|
940
|
+
);
|
|
941
|
+
if (!response.ok) {
|
|
942
|
+
const body = await response.text();
|
|
943
|
+
throw new Error(
|
|
944
|
+
`Databricks request failed: ${response.status} ${response.statusText}: ${body.slice(0, 500)}`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
return response;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// src/platform-checks.ts
|
|
951
|
+
function csv(value) {
|
|
952
|
+
return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
953
|
+
}
|
|
954
|
+
function secretScopeCheck() {
|
|
955
|
+
return {
|
|
956
|
+
id: "secrets",
|
|
957
|
+
description: "Databricks secret scope contains expected keys",
|
|
958
|
+
configured: (env) => Boolean(env.DBX_TEST_SECRET_SCOPE && env.DBX_TEST_SECRET_KEYS),
|
|
959
|
+
async run({ env, client }) {
|
|
960
|
+
const scope = env.DBX_TEST_SECRET_SCOPE;
|
|
961
|
+
const expected = csv(env.DBX_TEST_SECRET_KEYS);
|
|
962
|
+
const response = await client.get("/api/2.0/secrets/list", {
|
|
963
|
+
scope
|
|
964
|
+
});
|
|
965
|
+
const actual = new Set((response.secrets ?? []).map((secret) => secret.key).filter(Boolean));
|
|
966
|
+
const missing = expected.filter((key) => !actual.has(key));
|
|
967
|
+
if (missing.length > 0) {
|
|
968
|
+
throw new Error(`Secret scope ${scope} is missing keys: ${missing.join(", ")}`);
|
|
969
|
+
}
|
|
970
|
+
return { scope, expectedKeys: expected.length };
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
function appHealthCheck(options = {}) {
|
|
975
|
+
return {
|
|
976
|
+
id: "app",
|
|
977
|
+
description: "Databricks App is running, bound, and healthy",
|
|
978
|
+
configured: (env) => Boolean(env.DBX_TEST_APP_NAME),
|
|
979
|
+
async run({ env, client }) {
|
|
980
|
+
const name = env.DBX_TEST_APP_NAME;
|
|
981
|
+
const app = await client.get(`/api/2.0/apps/${encodeURIComponent(name)}`);
|
|
982
|
+
if (app.app_status?.state !== "RUNNING") {
|
|
983
|
+
throw new Error(`Databricks App ${name} is ${app.app_status?.state ?? "UNKNOWN"}`);
|
|
984
|
+
}
|
|
985
|
+
const expected = csv(env.DBX_TEST_APP_RESOURCES);
|
|
986
|
+
const actual = new Set(
|
|
987
|
+
(app.resources ?? []).map((resource) => resource.name).filter(Boolean)
|
|
988
|
+
);
|
|
989
|
+
const missing = expected.filter((resource) => !actual.has(resource));
|
|
990
|
+
if (missing.length > 0) {
|
|
991
|
+
throw new Error(
|
|
992
|
+
`Databricks App ${name} is missing resource bindings: ${missing.join(", ")}`
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
const appUrl = env.DBX_TEST_APP_URL ?? app.url ?? app.app_url;
|
|
996
|
+
if (!appUrl) throw new Error(`Databricks App ${name} returned no URL`);
|
|
997
|
+
const healthUrl = new URL("/api/healthz", appUrl).toString();
|
|
998
|
+
const response = await databricksFetch(env, healthUrl, {}, options.fetchImpl);
|
|
999
|
+
const health = await response.json();
|
|
1000
|
+
if (health.ok !== true && health.status !== "ok") {
|
|
1001
|
+
throw new Error(`Databricks App ${name} health probe did not report ok`);
|
|
1002
|
+
}
|
|
1003
|
+
return { name, bindings: actual.size, health: "ok" };
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
function normalizeVolumeRoot(value) {
|
|
1008
|
+
const normalized = value.replace(/\/$/, "");
|
|
1009
|
+
if (!/^\/Volumes\/[^/]+\/[^/]+\/[^/]+$/.test(normalized)) {
|
|
1010
|
+
throw new Error("DBX_TEST_VOLUME must be /Volumes/<catalog>/<schema>/<volume>");
|
|
1011
|
+
}
|
|
1012
|
+
return normalized;
|
|
1013
|
+
}
|
|
1014
|
+
async function ensureVolumeDirectory(env, directory, fetchImpl) {
|
|
1015
|
+
await databricksFetch(env, `/api/2.0/fs/directories${directory}`, { method: "PUT" }, fetchImpl);
|
|
1016
|
+
}
|
|
1017
|
+
async function uploadVolumeFile(env, path, body, fetchImpl) {
|
|
1018
|
+
await databricksFetch(
|
|
1019
|
+
env,
|
|
1020
|
+
`/api/2.0/fs/files${path}`,
|
|
1021
|
+
{ method: "PUT", body },
|
|
1022
|
+
fetchImpl
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
async function deleteVolumeFile(env, path, fetchImpl) {
|
|
1026
|
+
await databricksFetch(env, `/api/2.0/fs/files${path}`, { method: "DELETE" }, fetchImpl);
|
|
1027
|
+
}
|
|
1028
|
+
function volumeIOCheck(options = {}) {
|
|
1029
|
+
return {
|
|
1030
|
+
id: "volume-io",
|
|
1031
|
+
description: "Unity Catalog volume Files API round-trip",
|
|
1032
|
+
configured: (env) => Boolean(env.DBX_TEST_VOLUME),
|
|
1033
|
+
async run({ env }) {
|
|
1034
|
+
const root = normalizeVolumeRoot(env.DBX_TEST_VOLUME);
|
|
1035
|
+
const prefix = (env.DBX_TEST_ENV_NAME ?? "fx-live").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1036
|
+
const id = (options.randomId ?? crypto.randomUUID)();
|
|
1037
|
+
const directory = `${root}/${prefix}`;
|
|
1038
|
+
const file = `${directory}/probe-${id}.txt`;
|
|
1039
|
+
const filePath = `/api/2.0/fs/files${file}`;
|
|
1040
|
+
const body = new TextEncoder().encode(`fabric-experiments-volume-probe:${id}`);
|
|
1041
|
+
await ensureVolumeDirectory(env, directory, options.fetchImpl);
|
|
1042
|
+
try {
|
|
1043
|
+
await uploadVolumeFile(env, file, body, options.fetchImpl);
|
|
1044
|
+
const downloaded = await databricksFetch(env, filePath, {}, options.fetchImpl);
|
|
1045
|
+
const actual = new Uint8Array(await downloaded.arrayBuffer());
|
|
1046
|
+
if (actual.length !== body.length || actual.some((value, index) => value !== body[index])) {
|
|
1047
|
+
throw new Error("Volume round-trip returned different bytes");
|
|
1048
|
+
}
|
|
1049
|
+
return { volume: root, bytes: body.length };
|
|
1050
|
+
} finally {
|
|
1051
|
+
await deleteVolumeFile(env, file, options.fetchImpl).catch(() => void 0);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
function autoLoaderIngestionCheck(options = {}) {
|
|
1057
|
+
return {
|
|
1058
|
+
id: "autoloader",
|
|
1059
|
+
description: "Isolated Auto Loader fixture ingestion reaches Delta without rescued rows",
|
|
1060
|
+
configured: (env) => Boolean(
|
|
1061
|
+
env.DBX_TEST_AUTOLOADER_PIPELINE_ID && env.DBX_TEST_AUTOLOADER_TABLE && env.DBX_TEST_AUTOLOADER_FIXTURE && env.DBX_TEST_VOLUME
|
|
1062
|
+
),
|
|
1063
|
+
async run({ env, client, warehouse }) {
|
|
1064
|
+
const pipelineId = env.DBX_TEST_AUTOLOADER_PIPELINE_ID;
|
|
1065
|
+
if (pipelineId === env.DBX_TEST_PIPELINE_ID) {
|
|
1066
|
+
throw new Error("DBX_TEST_AUTOLOADER_PIPELINE_ID must not be the production pipeline");
|
|
1067
|
+
}
|
|
1068
|
+
const root = normalizeVolumeRoot(env.DBX_TEST_VOLUME);
|
|
1069
|
+
const fixturePath = env.DBX_TEST_AUTOLOADER_FIXTURE;
|
|
1070
|
+
const body = options.fixtureBody ?? new Uint8Array(await promises.readFile(path.resolve(fixturePath)));
|
|
1071
|
+
const id = (options.randomId ?? crypto.randomUUID)();
|
|
1072
|
+
const runName = (env.DBX_TEST_ENV_NAME ?? "autoloader").replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1073
|
+
const directory = `${root}/exposures/${runName}/${id}`;
|
|
1074
|
+
const destination = `${directory}/${path.basename(fixturePath)}`;
|
|
1075
|
+
await ensureVolumeDirectory(env, directory, options.fetchImpl);
|
|
1076
|
+
try {
|
|
1077
|
+
await uploadVolumeFile(env, destination, body, options.fetchImpl);
|
|
1078
|
+
const submitted = await client.post(
|
|
1079
|
+
`/api/2.0/pipelines/${encodeURIComponent(pipelineId)}/updates`,
|
|
1080
|
+
{ full_refresh: true }
|
|
1081
|
+
);
|
|
1082
|
+
if (!submitted.update_id) throw new Error("Auto Loader pipeline returned no update_id");
|
|
1083
|
+
await waitForPipelineUpdate(client, pipelineId, submitted.update_id, {
|
|
1084
|
+
timeoutMs: Number(env.DBX_TEST_TIMEOUT_MS ?? 9e5),
|
|
1085
|
+
pollIntervalMs: Number(env.DBX_TEST_POLL_INTERVAL_MS ?? 1e4)
|
|
1086
|
+
});
|
|
1087
|
+
const table = env.DBX_TEST_AUTOLOADER_TABLE;
|
|
1088
|
+
const rows = await warehouse.query(
|
|
1089
|
+
`SELECT COUNT(*) AS n, SUM(CASE WHEN _rescued_data IS NOT NULL THEN 1 ELSE 0 END) AS rescued FROM ${table}`
|
|
1090
|
+
);
|
|
1091
|
+
const count = Number(rows[0]?.n ?? 0);
|
|
1092
|
+
const rescued = Number(rows[0]?.rescued ?? 0);
|
|
1093
|
+
if (count < 1) throw new Error(`Auto Loader produced no rows in ${table}`);
|
|
1094
|
+
if (rescued !== 0)
|
|
1095
|
+
throw new Error(`Auto Loader produced ${rescued} rescued rows in ${table}`);
|
|
1096
|
+
return { pipelineId, table, rows: count, rescued };
|
|
1097
|
+
} finally {
|
|
1098
|
+
await deleteVolumeFile(env, destination, options.fetchImpl).catch(() => void 0);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
Object.defineProperty(exports, "waitForDatabricksRun", {
|
|
1105
|
+
enumerable: true,
|
|
1106
|
+
get: function () { return databricks.waitForDatabricksRun; }
|
|
1107
|
+
});
|
|
1108
|
+
Object.defineProperty(exports, "waitForDatabricksStatement", {
|
|
1109
|
+
enumerable: true,
|
|
1110
|
+
get: function () { return databricks.waitForDatabricksStatement; }
|
|
1111
|
+
});
|
|
1112
|
+
exports.appHealthCheck = appHealthCheck;
|
|
1113
|
+
exports.assertDeltaContract = assertDeltaContract;
|
|
1114
|
+
exports.autoLoaderIngestionCheck = autoLoaderIngestionCheck;
|
|
1115
|
+
exports.compareToGolden = compareToGolden;
|
|
1116
|
+
exports.createClientFromEnv = createClientFromEnv;
|
|
1117
|
+
exports.createDuckDbContext = createDuckDbContext;
|
|
1118
|
+
exports.createExecutionContext = createExecutionContext;
|
|
1119
|
+
exports.createLakebaseTestProvider = createLakebaseTestProvider;
|
|
1120
|
+
exports.createMockDatabricksClient = createMockDatabricksClient;
|
|
1121
|
+
exports.createWorkspaceContext = createWorkspaceContext;
|
|
1122
|
+
exports.customLiveCheck = customLiveCheck;
|
|
1123
|
+
exports.dbtBuildCheck = dbtBuildCheck;
|
|
1124
|
+
exports.defineLiveSuite = defineLiveSuite;
|
|
1125
|
+
exports.deleteVolumeFile = deleteVolumeFile;
|
|
1126
|
+
exports.deltaContractCheck = deltaContractCheck;
|
|
1127
|
+
exports.ensureVolumeDirectory = ensureVolumeDirectory;
|
|
1128
|
+
exports.expectQueryToMatchGolden = expectQueryToMatchGolden;
|
|
1129
|
+
exports.expectTable = expectTable;
|
|
1130
|
+
exports.jobRunCheck = jobRunCheck;
|
|
1131
|
+
exports.lakebaseCredentialCheck = lakebaseCredentialCheck;
|
|
1132
|
+
exports.lakebaseRoundTripCheck = lakebaseRoundTripCheck;
|
|
1133
|
+
exports.normalizeRow = normalizeRow;
|
|
1134
|
+
exports.normalizeVolumeRoot = normalizeVolumeRoot;
|
|
1135
|
+
exports.notebookSubmitCheck = notebookSubmitCheck;
|
|
1136
|
+
exports.parseFixtureColumns = parseFixtureColumns;
|
|
1137
|
+
exports.parseStatementRows = parseStatementRows;
|
|
1138
|
+
exports.pipelineRefreshCheck = pipelineRefreshCheck;
|
|
1139
|
+
exports.renderJUnit = renderJUnit;
|
|
1140
|
+
exports.resolveFixtureRows = resolveFixtureRows;
|
|
1141
|
+
exports.resolveProfile = resolveProfile;
|
|
1142
|
+
exports.resolveTokenProvider = resolveTokenProvider;
|
|
1143
|
+
exports.runLiveSuite = runLiveSuite;
|
|
1144
|
+
exports.secretScopeCheck = secretScopeCheck;
|
|
1145
|
+
exports.sqlRoundTripCheck = sqlRoundTripCheck;
|
|
1146
|
+
exports.toNamedParameters = toNamedParameters;
|
|
1147
|
+
exports.unityCatalogAccessCheck = unityCatalogAccessCheck;
|
|
1148
|
+
exports.uploadVolumeFile = uploadVolumeFile;
|
|
1149
|
+
exports.volumeIOCheck = volumeIOCheck;
|
|
1150
|
+
exports.waitForPipelineUpdate = waitForPipelineUpdate;
|
|
1151
|
+
//# sourceMappingURL=index.cjs.map
|
|
1152
|
+
//# sourceMappingURL=index.cjs.map
|