@glubean/sdk 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/configure.d.ts +141 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +535 -0
- package/dist/configure.js.map +1 -0
- package/dist/data.d.ts +232 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +543 -0
- package/dist/data.js.map +1 -0
- package/dist/expect.d.ts +511 -0
- package/dist/expect.d.ts.map +1 -0
- package/dist/expect.js +763 -0
- package/dist/expect.js.map +1 -0
- package/dist/index.d.ts +718 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1015 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +39 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +52 -0
- package/dist/internal.js.map +1 -0
- package/dist/plugin.d.ts +56 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +57 -0
- package/dist/plugin.js.map +1 -0
- package/dist/types.d.ts +1971 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +54 -0
- package/dist/types.js.map +1 -0
- package/package.json +40 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1015 @@
|
|
|
1
|
+
import { registerTest } from "./internal.js";
|
|
2
|
+
import { toArray } from "./data.js";
|
|
3
|
+
/**
|
|
4
|
+
* Glubean SDK spec version.
|
|
5
|
+
*
|
|
6
|
+
* This defines the API contract between the SDK, Scanner, and Runner.
|
|
7
|
+
* - Major version: Breaking changes
|
|
8
|
+
* - Minor version: New features (backward compatible)
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { SPEC_VERSION } from "@glubean/sdk";
|
|
13
|
+
* console.log("SDK spec version:", SPEC_VERSION);
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export const SPEC_VERSION = "2.0";
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Note: Registry functions (getRegistry, clearRegistry) have been moved to
|
|
19
|
+
// internal.ts to keep the public API clean. Import from "@glubean/sdk/internal"
|
|
20
|
+
// if you need them (for scanner or testing purposes only).
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// New Builder API
|
|
24
|
+
// =============================================================================
|
|
25
|
+
/**
|
|
26
|
+
* Builder class for creating tests with a fluent API.
|
|
27
|
+
*
|
|
28
|
+
* @template S The state type for multi-step tests
|
|
29
|
+
* @template Ctx The context type (defaults to TestContext; augmented by test.extend())
|
|
30
|
+
*
|
|
31
|
+
* @example Simple test (quick mode)
|
|
32
|
+
* ```ts
|
|
33
|
+
* export const login = test("login", async (ctx) => {
|
|
34
|
+
* ctx.assert(true, "works");
|
|
35
|
+
* });
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* @example Multi-step test (builder mode)
|
|
39
|
+
* ```ts
|
|
40
|
+
* export const checkout = test("checkout")
|
|
41
|
+
* .meta({ tags: ["e2e"] })
|
|
42
|
+
* .setup(async (ctx) => ({ cart: await createCart() }))
|
|
43
|
+
* .step("Add to cart", async (ctx, state) => {
|
|
44
|
+
* await addItem(state.cart, "item-1");
|
|
45
|
+
* return state;
|
|
46
|
+
* })
|
|
47
|
+
* .step("Checkout", async (ctx, state) => {
|
|
48
|
+
* await checkout(state.cart);
|
|
49
|
+
* return state;
|
|
50
|
+
* })
|
|
51
|
+
* .teardown(async (ctx, state) => {
|
|
52
|
+
* await cleanup(state.cart);
|
|
53
|
+
* })
|
|
54
|
+
* .build();
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export class TestBuilder {
|
|
58
|
+
_meta;
|
|
59
|
+
_setup;
|
|
60
|
+
_teardown;
|
|
61
|
+
_steps = [];
|
|
62
|
+
_built = false;
|
|
63
|
+
_fixtures;
|
|
64
|
+
/**
|
|
65
|
+
* Marker property so the runner can detect un-built TestBuilder exports
|
|
66
|
+
* without importing the SDK. The runner checks this string to auto-build.
|
|
67
|
+
*/
|
|
68
|
+
__glubean_type = "builder";
|
|
69
|
+
constructor(id, fixtures) {
|
|
70
|
+
this._meta = { id, name: id };
|
|
71
|
+
this._fixtures = fixtures;
|
|
72
|
+
// Auto-finalize (register) after all synchronous chaining completes.
|
|
73
|
+
// Module top-level code is synchronous, so by the time the microtask
|
|
74
|
+
// fires, all .step() / .meta() / .setup() / .teardown() calls are done.
|
|
75
|
+
queueMicrotask(() => this._finalize());
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Set additional metadata for the test.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* test("my-test")
|
|
83
|
+
* .meta({ tags: ["smoke"], description: "A smoke test" })
|
|
84
|
+
* .step(...)
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
meta(meta) {
|
|
88
|
+
this._meta = { ...this._meta, ...meta };
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Mark this test as focused.
|
|
93
|
+
*
|
|
94
|
+
* Focused tests are intended for local debugging sessions. When any tests in
|
|
95
|
+
* a run are marked as `only`, non-focused tests may be excluded by discovery
|
|
96
|
+
* tooling/orchestrators. If `skip` is also set on the same test, `skip`
|
|
97
|
+
* still wins during run selection.
|
|
98
|
+
*/
|
|
99
|
+
only() {
|
|
100
|
+
this._meta = { ...this._meta, only: true };
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Mark this test as skipped.
|
|
105
|
+
*
|
|
106
|
+
* Skip takes precedence over `only` when both are present.
|
|
107
|
+
*/
|
|
108
|
+
skip() {
|
|
109
|
+
this._meta = { ...this._meta, skip: true };
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Set the setup function that runs before all steps.
|
|
114
|
+
* The returned state is passed to all steps and teardown.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* test("auth")
|
|
119
|
+
* .setup(async (ctx) => {
|
|
120
|
+
* const baseUrl = ctx.vars.require("BASE_URL");
|
|
121
|
+
* const apiKey = ctx.secrets.require("API_KEY");
|
|
122
|
+
* const { token } = await ctx.http.post(`${baseUrl}/auth/token`, {
|
|
123
|
+
* headers: { "X-API-Key": apiKey },
|
|
124
|
+
* }).json();
|
|
125
|
+
* return { token };
|
|
126
|
+
* })
|
|
127
|
+
* .step("verify", async (ctx, { token }) => { ... })
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
setup(fn) {
|
|
131
|
+
this._setup = fn;
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Set the teardown function that runs after all steps (even on failure).
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* test("db-test")
|
|
140
|
+
* .setup(async (ctx) => ({ conn: await connect() }))
|
|
141
|
+
* .step(...)
|
|
142
|
+
* .teardown(async (ctx, { conn }) => {
|
|
143
|
+
* await conn.close();
|
|
144
|
+
* })
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
teardown(fn) {
|
|
148
|
+
this._teardown = fn;
|
|
149
|
+
return this;
|
|
150
|
+
}
|
|
151
|
+
step(name, optionsOrFn, maybeFn) {
|
|
152
|
+
const fn = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn;
|
|
153
|
+
const options = typeof optionsOrFn === "function" ? {} : optionsOrFn;
|
|
154
|
+
this._steps.push({
|
|
155
|
+
meta: { name, ...options },
|
|
156
|
+
fn: fn,
|
|
157
|
+
});
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Apply a builder transform function for step composition.
|
|
162
|
+
*
|
|
163
|
+
* Reusable step sequences are just plain functions that take a builder
|
|
164
|
+
* and return a builder. `.use()` applies such a function to the current
|
|
165
|
+
* chain, preserving state flow.
|
|
166
|
+
*
|
|
167
|
+
* @param fn Transform function that receives this builder and returns a (possibly re-typed) builder
|
|
168
|
+
*
|
|
169
|
+
* @example Reusable step sequence
|
|
170
|
+
* ```ts
|
|
171
|
+
* // Define once — just a function
|
|
172
|
+
* const withAuth = (b: TestBuilder<unknown>) => b
|
|
173
|
+
* .step("login", async (ctx) => {
|
|
174
|
+
* const data = await ctx.http.post("/login", { json: creds }).json<{ token: string }>();
|
|
175
|
+
* return { token: data.token };
|
|
176
|
+
* });
|
|
177
|
+
*
|
|
178
|
+
* // Reuse across tests
|
|
179
|
+
* export const testA = test("test-a").use(withAuth).step("act", async (ctx, { token }) => { ... });
|
|
180
|
+
* export const testB = test("test-b").use(withAuth).step("verify", async (ctx, { token }) => { ... });
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
use(fn) {
|
|
184
|
+
return fn(this);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Apply a builder transform and tag all newly added steps with a group ID.
|
|
188
|
+
*
|
|
189
|
+
* Works exactly like `.use()`, but every step added by `fn` is marked with
|
|
190
|
+
* `group` metadata for visual grouping in reports and dashboards.
|
|
191
|
+
*
|
|
192
|
+
* @param id Group identifier (displayed in reports as a section header)
|
|
193
|
+
* @param fn Transform function that adds steps to the builder
|
|
194
|
+
*
|
|
195
|
+
* @example Reusable steps with grouping
|
|
196
|
+
* ```ts
|
|
197
|
+
* const withAuth = (b: TestBuilder<unknown>) => b
|
|
198
|
+
* .step("login", async (ctx) => ({ token: "..." }))
|
|
199
|
+
* .step("verify", async (ctx, { token }) => ({ token, verified: true }));
|
|
200
|
+
*
|
|
201
|
+
* export const checkout = test("checkout")
|
|
202
|
+
* .group("auth", withAuth)
|
|
203
|
+
* .step("pay", async (ctx, { token }) => { ... });
|
|
204
|
+
*
|
|
205
|
+
* // Report output:
|
|
206
|
+
* // checkout
|
|
207
|
+
* // ├─ [auth]
|
|
208
|
+
* // │ ├─ login ✓
|
|
209
|
+
* // │ └─ verify ✓
|
|
210
|
+
* // └─ pay ✓
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* @example Inline grouping (no reuse, just organization)
|
|
214
|
+
* ```ts
|
|
215
|
+
* export const e2e = test("e2e")
|
|
216
|
+
* .group("setup", b => b
|
|
217
|
+
* .step("seed db", async (ctx) => ({ dbId: "..." }))
|
|
218
|
+
* .step("create user", async (ctx, { dbId }) => ({ dbId, userId: "..." }))
|
|
219
|
+
* )
|
|
220
|
+
* .step("verify", async (ctx, { dbId, userId }) => { ... });
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
group(id, fn) {
|
|
224
|
+
const before = this._steps.length;
|
|
225
|
+
const result = fn(this);
|
|
226
|
+
for (let i = before; i < this._steps.length; i++) {
|
|
227
|
+
this._steps[i].meta.group = id;
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Finalize and register the test in the global registry.
|
|
233
|
+
* Called automatically via microtask if not explicitly invoked via build().
|
|
234
|
+
* Idempotent — safe to call multiple times.
|
|
235
|
+
* @internal
|
|
236
|
+
*/
|
|
237
|
+
_finalize() {
|
|
238
|
+
if (this._built)
|
|
239
|
+
return;
|
|
240
|
+
this._built = true;
|
|
241
|
+
registerTest({
|
|
242
|
+
id: this._meta.id,
|
|
243
|
+
name: this._meta.name || this._meta.id,
|
|
244
|
+
type: "steps",
|
|
245
|
+
tags: toArray(this._meta.tags),
|
|
246
|
+
description: this._meta.description,
|
|
247
|
+
steps: this._steps.map((s) => ({
|
|
248
|
+
name: s.meta.name,
|
|
249
|
+
...(s.meta.group ? { group: s.meta.group } : {}),
|
|
250
|
+
})),
|
|
251
|
+
hasSetup: !!this._setup,
|
|
252
|
+
hasTeardown: !!this._teardown,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Build and register the test. Returns a plain `Test<S>` object.
|
|
257
|
+
*
|
|
258
|
+
* **Optional** — if omitted, the builder auto-finalizes via microtask
|
|
259
|
+
* after all synchronous chaining completes, and the runner will
|
|
260
|
+
* auto-detect the builder export. Calling `.build()` explicitly is
|
|
261
|
+
* still supported for backward compatibility.
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* ```ts
|
|
265
|
+
* // With .build() (explicit — backward compatible)
|
|
266
|
+
* export const myTest = test("my-test")
|
|
267
|
+
* .step("step-1", async (ctx) => { ... })
|
|
268
|
+
* .build();
|
|
269
|
+
*
|
|
270
|
+
* // Without .build() (auto-finalized — recommended)
|
|
271
|
+
* export const myTest = test("my-test")
|
|
272
|
+
* .step("step-1", async (ctx) => { ... });
|
|
273
|
+
* ```
|
|
274
|
+
*/
|
|
275
|
+
build() {
|
|
276
|
+
this._finalize();
|
|
277
|
+
return {
|
|
278
|
+
meta: this._meta,
|
|
279
|
+
type: "steps",
|
|
280
|
+
setup: this._setup,
|
|
281
|
+
teardown: this._teardown,
|
|
282
|
+
steps: this._steps,
|
|
283
|
+
...(this._fixtures ? { fixtures: this._fixtures } : {}),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
export function test(idOrMeta, fn) {
|
|
288
|
+
const meta = typeof idOrMeta === "string"
|
|
289
|
+
? { id: idOrMeta, name: idOrMeta }
|
|
290
|
+
: { name: idOrMeta.id, ...idOrMeta };
|
|
291
|
+
// Normalize tags to string[]
|
|
292
|
+
if (meta.tags) {
|
|
293
|
+
meta.tags = toArray(meta.tags);
|
|
294
|
+
}
|
|
295
|
+
// Quick mode: test("id", fn) -> returns Test directly
|
|
296
|
+
if (fn) {
|
|
297
|
+
const testDef = {
|
|
298
|
+
meta,
|
|
299
|
+
type: "simple",
|
|
300
|
+
fn,
|
|
301
|
+
};
|
|
302
|
+
// Register to global registry
|
|
303
|
+
registerTest({
|
|
304
|
+
id: meta.id,
|
|
305
|
+
name: meta.name || meta.id,
|
|
306
|
+
type: "simple",
|
|
307
|
+
tags: toArray(meta.tags),
|
|
308
|
+
description: meta.description,
|
|
309
|
+
});
|
|
310
|
+
return testDef;
|
|
311
|
+
}
|
|
312
|
+
// Builder mode: test("id") -> returns TestBuilder
|
|
313
|
+
const builder = new TestBuilder(meta.id);
|
|
314
|
+
if (typeof idOrMeta !== "string") {
|
|
315
|
+
builder.meta(idOrMeta);
|
|
316
|
+
}
|
|
317
|
+
return builder;
|
|
318
|
+
}
|
|
319
|
+
// =============================================================================
|
|
320
|
+
// Data-Driven API (test.each)
|
|
321
|
+
// =============================================================================
|
|
322
|
+
/**
|
|
323
|
+
* Interpolate `$key` placeholders in a template string with data values.
|
|
324
|
+
* Supports `$index` for the row index and `$key` for any key in the data object.
|
|
325
|
+
*
|
|
326
|
+
* @internal
|
|
327
|
+
*/
|
|
328
|
+
function interpolateTemplate(template, data, index) {
|
|
329
|
+
let result = template.replace(/\$index/g, String(index));
|
|
330
|
+
for (const [key, value] of Object.entries(data)) {
|
|
331
|
+
result = result.replaceAll(`$${key}`, String(value));
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Resolve baseMeta from string or TestMeta input.
|
|
337
|
+
* @internal
|
|
338
|
+
*/
|
|
339
|
+
function resolveBaseMeta(idOrMeta) {
|
|
340
|
+
return typeof idOrMeta === "string" ? { id: idOrMeta, name: idOrMeta } : { name: idOrMeta.id, ...idOrMeta };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Builder for data-driven tests with multi-step workflow support.
|
|
344
|
+
*
|
|
345
|
+
* Created by `test.each(table)(idTemplate)` (without a callback).
|
|
346
|
+
* Provides the same fluent `.step()` / `.setup()` / `.teardown()` API
|
|
347
|
+
* as `TestBuilder`, but each step/setup/teardown also receives the
|
|
348
|
+
* data row for the current test.
|
|
349
|
+
*
|
|
350
|
+
* On finalization, creates one `Test` per row in the table, each with
|
|
351
|
+
* full step definitions visible in `glubean scan` metadata and dashboards.
|
|
352
|
+
*
|
|
353
|
+
* @template S The state type for multi-step tests
|
|
354
|
+
* @template T The data row type
|
|
355
|
+
*
|
|
356
|
+
* @example
|
|
357
|
+
* ```ts
|
|
358
|
+
* export const userFlows = test.each([
|
|
359
|
+
* { userId: 1 },
|
|
360
|
+
* { userId: 2 },
|
|
361
|
+
* ])("user-flow-$userId")
|
|
362
|
+
* .step("fetch user", async (ctx, state, { userId }) => {
|
|
363
|
+
* const res = await ctx.http.get(`/users/${userId}`);
|
|
364
|
+
* ctx.assert(res.ok, "user exists");
|
|
365
|
+
* return { user: await res.json() };
|
|
366
|
+
* })
|
|
367
|
+
* .step("verify posts", async (ctx, { user }) => {
|
|
368
|
+
* const res = await ctx.http.get(`/users/${user.id}/posts`);
|
|
369
|
+
* ctx.assert(res.ok, "posts accessible");
|
|
370
|
+
* });
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
export class EachBuilder {
|
|
374
|
+
_baseMeta;
|
|
375
|
+
_table;
|
|
376
|
+
_setup;
|
|
377
|
+
_teardown;
|
|
378
|
+
_steps = [];
|
|
379
|
+
_built = false;
|
|
380
|
+
_fixtures;
|
|
381
|
+
/**
|
|
382
|
+
* Marker property so the runner and scanner can detect EachBuilder exports.
|
|
383
|
+
*/
|
|
384
|
+
__glubean_type = "each-builder";
|
|
385
|
+
constructor(baseMeta, table, fixtures) {
|
|
386
|
+
this._baseMeta = baseMeta;
|
|
387
|
+
this._table = table;
|
|
388
|
+
this._fixtures = fixtures;
|
|
389
|
+
// Auto-finalize after all synchronous chaining completes.
|
|
390
|
+
queueMicrotask(() => this._finalize());
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Set additional metadata for all generated tests.
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* ```ts
|
|
397
|
+
* test.each(table)("user-$userId")
|
|
398
|
+
* .meta({ tags: ["smoke"], timeout: 10000 })
|
|
399
|
+
* .step("fetch", async (ctx, state, row) => { ... });
|
|
400
|
+
* ```
|
|
401
|
+
*/
|
|
402
|
+
meta(meta) {
|
|
403
|
+
this._baseMeta = { ...this._baseMeta, ...meta };
|
|
404
|
+
return this;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Mark all generated tests from this data set as focused.
|
|
408
|
+
* If `skip` is also set, skipped tests are still excluded.
|
|
409
|
+
*/
|
|
410
|
+
only() {
|
|
411
|
+
this._baseMeta = { ...this._baseMeta, only: true };
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Mark all generated tests from this data set as skipped.
|
|
416
|
+
* Skip takes precedence over `only` when both are present.
|
|
417
|
+
*/
|
|
418
|
+
skip() {
|
|
419
|
+
this._baseMeta = { ...this._baseMeta, skip: true };
|
|
420
|
+
return this;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Set the setup function. Receives context and data row, returns state.
|
|
424
|
+
*
|
|
425
|
+
* @example
|
|
426
|
+
* ```ts
|
|
427
|
+
* test.each(table)("id-$key")
|
|
428
|
+
* .setup(async (ctx, row) => {
|
|
429
|
+
* const api = ctx.http.extend({ headers: { "X-User": row.userId } });
|
|
430
|
+
* return { api };
|
|
431
|
+
* })
|
|
432
|
+
* .step("use api", async (ctx, { api }) => { ... });
|
|
433
|
+
* ```
|
|
434
|
+
*/
|
|
435
|
+
setup(fn) {
|
|
436
|
+
this._setup = fn;
|
|
437
|
+
return this;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Set the teardown function. Runs after all steps (even on failure).
|
|
441
|
+
*
|
|
442
|
+
* @example
|
|
443
|
+
* ```ts
|
|
444
|
+
* test.each(table)("user-$userId")
|
|
445
|
+
* .setup(async (ctx, row) => ({ token: await login(ctx, row) }))
|
|
446
|
+
* .step("test", async (ctx, { token }) => { ... })
|
|
447
|
+
* .teardown(async (ctx, state, row) => {
|
|
448
|
+
* await ctx.http.post("/logout", { body: { token: state.token } });
|
|
449
|
+
* });
|
|
450
|
+
* ```
|
|
451
|
+
*/
|
|
452
|
+
teardown(fn) {
|
|
453
|
+
this._teardown = fn;
|
|
454
|
+
return this;
|
|
455
|
+
}
|
|
456
|
+
step(name, optionsOrFn, maybeFn) {
|
|
457
|
+
const fn = typeof optionsOrFn === "function" ? optionsOrFn : maybeFn;
|
|
458
|
+
const options = typeof optionsOrFn === "function" ? {} : optionsOrFn;
|
|
459
|
+
this._steps.push({
|
|
460
|
+
meta: { name, ...options },
|
|
461
|
+
fn,
|
|
462
|
+
});
|
|
463
|
+
return this;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Apply a builder transform function for step composition.
|
|
467
|
+
*
|
|
468
|
+
* Works the same as `TestBuilder.use()` — reusable step sequences
|
|
469
|
+
* are plain functions that take a builder and return a builder.
|
|
470
|
+
*
|
|
471
|
+
* @param fn Transform function that receives this builder and returns a (possibly re-typed) builder
|
|
472
|
+
*
|
|
473
|
+
* @example
|
|
474
|
+
* ```ts
|
|
475
|
+
* const withVerify = (b: EachBuilder<{ id: string }, { userId: number }>) => b
|
|
476
|
+
* .step("verify", async (ctx, { id }, row) => {
|
|
477
|
+
* ctx.expect(id).toBeTruthy();
|
|
478
|
+
* });
|
|
479
|
+
*
|
|
480
|
+
* export const users = test.each(table)("user-$userId")
|
|
481
|
+
* .setup(async (ctx, row) => ({ id: String(row.userId) }))
|
|
482
|
+
* .use(withVerify);
|
|
483
|
+
* ```
|
|
484
|
+
*/
|
|
485
|
+
use(fn) {
|
|
486
|
+
return fn(this);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Apply a builder transform and tag all newly added steps with a group ID.
|
|
490
|
+
*
|
|
491
|
+
* Works the same as `TestBuilder.group()` — steps added by `fn` are marked
|
|
492
|
+
* with `group` metadata for visual grouping in reports.
|
|
493
|
+
*
|
|
494
|
+
* @param id Group identifier (displayed in reports as a section header)
|
|
495
|
+
* @param fn Transform function that adds steps to the builder
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* ```ts
|
|
499
|
+
* export const users = test.each(table)("user-$userId")
|
|
500
|
+
* .group("setup", b => b
|
|
501
|
+
* .step("init", async (ctx, state, row) => ({ id: String(row.userId) }))
|
|
502
|
+
* )
|
|
503
|
+
* .step("verify", async (ctx, { id }) => { ... });
|
|
504
|
+
* ```
|
|
505
|
+
*/
|
|
506
|
+
group(id, fn) {
|
|
507
|
+
const before = this._steps.length;
|
|
508
|
+
const result = fn(this);
|
|
509
|
+
for (let i = before; i < this._steps.length; i++) {
|
|
510
|
+
this._steps[i].meta.group = id;
|
|
511
|
+
}
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Get the filtered table (apply filter callback if present).
|
|
516
|
+
* @internal
|
|
517
|
+
*/
|
|
518
|
+
_filteredTable() {
|
|
519
|
+
const filter = this._baseMeta.filter;
|
|
520
|
+
if (!filter)
|
|
521
|
+
return this._table;
|
|
522
|
+
return this._table.filter((row, index) => filter(row, index));
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Compute tags for a specific row (static tags + tagFields).
|
|
526
|
+
* @internal
|
|
527
|
+
*/
|
|
528
|
+
_tagsForRow(row) {
|
|
529
|
+
const staticTags = toArray(this._baseMeta.tags);
|
|
530
|
+
const tagFieldNames = toArray(this._baseMeta.tagFields);
|
|
531
|
+
const dynamicTags = tagFieldNames
|
|
532
|
+
.map((field) => {
|
|
533
|
+
const value = row[field];
|
|
534
|
+
return value != null ? `${field}:${value}` : null;
|
|
535
|
+
})
|
|
536
|
+
.filter((t) => t !== null);
|
|
537
|
+
return [...staticTags, ...dynamicTags];
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Finalize and register all tests in the global registry.
|
|
541
|
+
* Called automatically via microtask if not explicitly invoked via build().
|
|
542
|
+
* Idempotent — safe to call multiple times.
|
|
543
|
+
* @internal
|
|
544
|
+
*/
|
|
545
|
+
_finalize() {
|
|
546
|
+
if (this._built)
|
|
547
|
+
return;
|
|
548
|
+
this._built = true;
|
|
549
|
+
const stepMetas = this._steps.map((s) => ({
|
|
550
|
+
name: s.meta.name,
|
|
551
|
+
...(s.meta.group ? { group: s.meta.group } : {}),
|
|
552
|
+
}));
|
|
553
|
+
const table = this._filteredTable();
|
|
554
|
+
const isPick = table.length > 0 && "_pick" in table[0];
|
|
555
|
+
for (let i = 0; i < table.length; i++) {
|
|
556
|
+
const row = table[i];
|
|
557
|
+
const id = interpolateTemplate(this._baseMeta.id, row, i);
|
|
558
|
+
const name = this._baseMeta.name ? interpolateTemplate(this._baseMeta.name, row, i) : id;
|
|
559
|
+
registerTest({
|
|
560
|
+
id,
|
|
561
|
+
name,
|
|
562
|
+
type: "steps",
|
|
563
|
+
tags: this._tagsForRow(row),
|
|
564
|
+
description: this._baseMeta.description,
|
|
565
|
+
steps: stepMetas,
|
|
566
|
+
hasSetup: !!this._setup,
|
|
567
|
+
hasTeardown: !!this._teardown,
|
|
568
|
+
...(isPick ? { groupId: this._baseMeta.id } : {}),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Build and register all tests. Returns a `Test[]` array.
|
|
574
|
+
*
|
|
575
|
+
* **Optional** — if omitted, the builder auto-finalizes via microtask
|
|
576
|
+
* and the runner will auto-detect the EachBuilder export.
|
|
577
|
+
*/
|
|
578
|
+
build() {
|
|
579
|
+
this._finalize();
|
|
580
|
+
const table = this._filteredTable();
|
|
581
|
+
return table.map((row, index) => {
|
|
582
|
+
const id = interpolateTemplate(this._baseMeta.id, row, index);
|
|
583
|
+
const name = this._baseMeta.name ? interpolateTemplate(this._baseMeta.name, row, index) : id;
|
|
584
|
+
const meta = {
|
|
585
|
+
...this._baseMeta,
|
|
586
|
+
id,
|
|
587
|
+
name,
|
|
588
|
+
tags: this._tagsForRow(row),
|
|
589
|
+
};
|
|
590
|
+
const setup = this._setup;
|
|
591
|
+
const teardown = this._teardown;
|
|
592
|
+
return {
|
|
593
|
+
meta,
|
|
594
|
+
type: "steps",
|
|
595
|
+
setup: setup ? ((ctx) => setup(ctx, row)) : undefined,
|
|
596
|
+
teardown: teardown
|
|
597
|
+
? ((ctx, state) => teardown(ctx, state, row))
|
|
598
|
+
: undefined,
|
|
599
|
+
steps: this._steps.map((s) => ({
|
|
600
|
+
meta: s.meta,
|
|
601
|
+
fn: ((ctx, state) => s.fn(ctx, state, row)),
|
|
602
|
+
})),
|
|
603
|
+
...(this._fixtures ? { fixtures: this._fixtures } : {}),
|
|
604
|
+
};
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Data-driven test generation.
|
|
610
|
+
*
|
|
611
|
+
* Creates one independent test per row in the data table.
|
|
612
|
+
* Each test gets its own ID (from template interpolation), runs independently,
|
|
613
|
+
* and reports its own pass/fail status.
|
|
614
|
+
*
|
|
615
|
+
* Use `$key` in the ID/name template to interpolate values from the data row.
|
|
616
|
+
* Use `$index` for the row index (0-based).
|
|
617
|
+
*
|
|
618
|
+
* Supports two modes:
|
|
619
|
+
*
|
|
620
|
+
* 1. **Simple mode** — pass a callback to get `Test[]` (single-function tests).
|
|
621
|
+
* 2. **Builder mode** — omit the callback to get an `EachBuilder` with
|
|
622
|
+
* `.step()` / `.setup()` / `.teardown()` support for multi-step workflows.
|
|
623
|
+
*
|
|
624
|
+
* @example Simple mode (backward compatible)
|
|
625
|
+
* ```ts
|
|
626
|
+
* import { test } from "@glubean/sdk";
|
|
627
|
+
*
|
|
628
|
+
* export const statusTests = test.each([
|
|
629
|
+
* { id: 1, expected: 200 },
|
|
630
|
+
* { id: 999, expected: 404 },
|
|
631
|
+
* ])("get-user-$id", async (ctx, { id, expected }) => {
|
|
632
|
+
* const res = await ctx.http.get(`${ctx.vars.require("BASE_URL")}/users/${id}`, {
|
|
633
|
+
* throwHttpErrors: false,
|
|
634
|
+
* });
|
|
635
|
+
* ctx.expect(res.status).toBe(expected);
|
|
636
|
+
* });
|
|
637
|
+
* ```
|
|
638
|
+
*
|
|
639
|
+
* @example Builder mode (multi-step per data row)
|
|
640
|
+
* ```ts
|
|
641
|
+
* export const userFlows = test.each([
|
|
642
|
+
* { userId: 1 },
|
|
643
|
+
* { userId: 2 },
|
|
644
|
+
* ])("user-flow-$userId")
|
|
645
|
+
* .step("fetch user", async (ctx, _state, { userId }) => {
|
|
646
|
+
* const res = await ctx.http.get(`/users/${userId}`);
|
|
647
|
+
* ctx.assert(res.ok, "user exists");
|
|
648
|
+
* return { user: await res.json() };
|
|
649
|
+
* })
|
|
650
|
+
* .step("verify posts", async (ctx, { user }) => {
|
|
651
|
+
* const res = await ctx.http.get(`/users/${user.id}/posts`);
|
|
652
|
+
* ctx.assert(res.ok, "posts accessible");
|
|
653
|
+
* });
|
|
654
|
+
* ```
|
|
655
|
+
*
|
|
656
|
+
* @param table Array of data rows. Each row produces one test.
|
|
657
|
+
* @returns A function that accepts an ID template and optional test function
|
|
658
|
+
*/
|
|
659
|
+
// =============================================================================
|
|
660
|
+
// Extended Test (test.extend)
|
|
661
|
+
// =============================================================================
|
|
662
|
+
/** Keys that cannot be used as extension names (they shadow core TestContext). */
|
|
663
|
+
const EXTEND_RESERVED_KEYS = new Set(["vars", "secrets", "http"]);
|
|
664
|
+
/**
|
|
665
|
+
* Select examples from a named map based on the GLUBEAN_PICK env var
|
|
666
|
+
* or random selection. Shared between `test.pick` and extended test `.pick()`.
|
|
667
|
+
*
|
|
668
|
+
* @internal
|
|
669
|
+
*/
|
|
670
|
+
function selectPickExamples(examples, count) {
|
|
671
|
+
const keys = Object.keys(examples);
|
|
672
|
+
if (keys.length === 0) {
|
|
673
|
+
throw new Error("test.pick requires at least one example");
|
|
674
|
+
}
|
|
675
|
+
let pickedEnv;
|
|
676
|
+
try {
|
|
677
|
+
pickedEnv = typeof process !== "undefined" ? process.env["GLUBEAN_PICK"] : undefined;
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
pickedEnv = undefined;
|
|
681
|
+
}
|
|
682
|
+
if (pickedEnv) {
|
|
683
|
+
const trimmed = pickedEnv.trim();
|
|
684
|
+
if (trimmed === "all" || trimmed === "*") {
|
|
685
|
+
return keys.map((k) => ({ ...examples[k], _pick: k }));
|
|
686
|
+
}
|
|
687
|
+
const pickedKeys = trimmed
|
|
688
|
+
.split(",")
|
|
689
|
+
.map((k) => k.trim())
|
|
690
|
+
.filter((k) => k.length > 0);
|
|
691
|
+
const hasGlob = pickedKeys.some((k) => k.includes("*"));
|
|
692
|
+
let validKeys;
|
|
693
|
+
if (hasGlob) {
|
|
694
|
+
const patterns = pickedKeys.map((p) => globToRegExp(p));
|
|
695
|
+
validKeys = keys.filter((k) => patterns.some((re) => re.test(k)));
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
validKeys = pickedKeys.filter((k) => k in examples);
|
|
699
|
+
}
|
|
700
|
+
if (validKeys.length > 0) {
|
|
701
|
+
return validKeys.map((k) => ({ ...examples[k], _pick: k }));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// Random selection fallback
|
|
705
|
+
const shuffled = [...keys].sort(() => Math.random() - 0.5);
|
|
706
|
+
const picked = shuffled.slice(0, Math.min(count, keys.length));
|
|
707
|
+
return picked.map((k) => ({ ...examples[k], _pick: k }));
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Create an extended test function with fixture definitions.
|
|
711
|
+
*
|
|
712
|
+
* @internal
|
|
713
|
+
*/
|
|
714
|
+
function createExtendedTest(allFixtures) {
|
|
715
|
+
// Validate no reserved keys
|
|
716
|
+
for (const key of Object.keys(allFixtures)) {
|
|
717
|
+
if (EXTEND_RESERVED_KEYS.has(key)) {
|
|
718
|
+
throw new Error(`Cannot extend test context with reserved key "${key}". ` +
|
|
719
|
+
`Reserved keys: ${[...EXTEND_RESERVED_KEYS].join(", ")}.`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// The callable part — quick mode and builder mode
|
|
723
|
+
function extTest(idOrMeta, fn) {
|
|
724
|
+
if (fn) {
|
|
725
|
+
// Quick mode
|
|
726
|
+
const meta = typeof idOrMeta === "string"
|
|
727
|
+
? { id: idOrMeta, name: idOrMeta }
|
|
728
|
+
: { name: idOrMeta.id, ...idOrMeta };
|
|
729
|
+
if (meta.tags)
|
|
730
|
+
meta.tags = toArray(meta.tags);
|
|
731
|
+
const testDef = {
|
|
732
|
+
meta,
|
|
733
|
+
type: "simple",
|
|
734
|
+
fn: fn,
|
|
735
|
+
fixtures: allFixtures,
|
|
736
|
+
};
|
|
737
|
+
registerTest({
|
|
738
|
+
id: meta.id,
|
|
739
|
+
name: meta.name || meta.id,
|
|
740
|
+
type: "simple",
|
|
741
|
+
tags: toArray(meta.tags),
|
|
742
|
+
description: meta.description,
|
|
743
|
+
});
|
|
744
|
+
return testDef;
|
|
745
|
+
}
|
|
746
|
+
// Builder mode
|
|
747
|
+
const id = typeof idOrMeta === "string" ? idOrMeta : idOrMeta.id;
|
|
748
|
+
const builder = new TestBuilder(id, allFixtures);
|
|
749
|
+
if (typeof idOrMeta !== "string") {
|
|
750
|
+
builder.meta(idOrMeta);
|
|
751
|
+
}
|
|
752
|
+
return builder;
|
|
753
|
+
}
|
|
754
|
+
// .extend() — chained extension
|
|
755
|
+
extTest.extend = (extensions) => {
|
|
756
|
+
return createExtendedTest({
|
|
757
|
+
...allFixtures,
|
|
758
|
+
...extensions,
|
|
759
|
+
});
|
|
760
|
+
};
|
|
761
|
+
// .each() — data-driven with fixtures
|
|
762
|
+
extTest.each = (table) => {
|
|
763
|
+
return ((idOrMeta, fn) => {
|
|
764
|
+
const baseMeta = resolveBaseMeta(idOrMeta);
|
|
765
|
+
if (!fn) {
|
|
766
|
+
return new EachBuilder(baseMeta, table, allFixtures);
|
|
767
|
+
}
|
|
768
|
+
// Simple mode with fixtures
|
|
769
|
+
const filteredTable = baseMeta.filter
|
|
770
|
+
? table.filter((row, i) => baseMeta.filter(row, i))
|
|
771
|
+
: table;
|
|
772
|
+
const tagFieldNames = toArray(baseMeta.tagFields);
|
|
773
|
+
const staticTags = toArray(baseMeta.tags);
|
|
774
|
+
const isPick = filteredTable.length > 0 && "_pick" in filteredTable[0];
|
|
775
|
+
return filteredTable.map((row, index) => {
|
|
776
|
+
const id = interpolateTemplate(baseMeta.id, row, index);
|
|
777
|
+
const name = baseMeta.name ? interpolateTemplate(baseMeta.name, row, index) : id;
|
|
778
|
+
const dynamicTags = tagFieldNames
|
|
779
|
+
.map((field) => {
|
|
780
|
+
const value = row[field];
|
|
781
|
+
return value != null ? `${field}:${value}` : null;
|
|
782
|
+
})
|
|
783
|
+
.filter((t) => t !== null);
|
|
784
|
+
const allTags = [...staticTags, ...dynamicTags];
|
|
785
|
+
const meta = {
|
|
786
|
+
...baseMeta,
|
|
787
|
+
id,
|
|
788
|
+
name,
|
|
789
|
+
tags: allTags.length > 0 ? allTags : undefined,
|
|
790
|
+
};
|
|
791
|
+
const testDef = {
|
|
792
|
+
meta,
|
|
793
|
+
type: "simple",
|
|
794
|
+
fn: (async (ctx) => await fn(ctx, row)),
|
|
795
|
+
fixtures: allFixtures,
|
|
796
|
+
};
|
|
797
|
+
registerTest({
|
|
798
|
+
id: meta.id,
|
|
799
|
+
name: meta.name || meta.id,
|
|
800
|
+
type: "simple",
|
|
801
|
+
tags: allTags.length > 0 ? allTags : undefined,
|
|
802
|
+
description: meta.description,
|
|
803
|
+
...(isPick ? { groupId: baseMeta.id } : {}),
|
|
804
|
+
});
|
|
805
|
+
return testDef;
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
};
|
|
809
|
+
// .pick() — example selection with fixtures
|
|
810
|
+
extTest.pick = (examples, count = 1) => {
|
|
811
|
+
const selected = selectPickExamples(examples, count);
|
|
812
|
+
return extTest.each(selected);
|
|
813
|
+
};
|
|
814
|
+
return extTest;
|
|
815
|
+
}
|
|
816
|
+
(function (test) {
|
|
817
|
+
function only(idOrMeta, fn) {
|
|
818
|
+
const baseMeta = typeof idOrMeta === "string" ? { id: idOrMeta, name: idOrMeta } : idOrMeta;
|
|
819
|
+
const metaWithOnly = { ...baseMeta, only: true };
|
|
820
|
+
return fn ? test(metaWithOnly, fn) : test(metaWithOnly);
|
|
821
|
+
}
|
|
822
|
+
test.only = only;
|
|
823
|
+
function skip(idOrMeta, fn) {
|
|
824
|
+
const baseMeta = typeof idOrMeta === "string" ? { id: idOrMeta, name: idOrMeta } : idOrMeta;
|
|
825
|
+
const metaWithSkip = { ...baseMeta, skip: true };
|
|
826
|
+
return fn ? test(metaWithSkip, fn) : test(metaWithSkip);
|
|
827
|
+
}
|
|
828
|
+
test.skip = skip;
|
|
829
|
+
function each(table) {
|
|
830
|
+
return ((idOrMeta, fn) => {
|
|
831
|
+
const baseMeta = resolveBaseMeta(idOrMeta);
|
|
832
|
+
// Builder mode: no callback → return EachBuilder
|
|
833
|
+
if (!fn) {
|
|
834
|
+
return new EachBuilder(baseMeta, table);
|
|
835
|
+
}
|
|
836
|
+
// Apply filter if present
|
|
837
|
+
const filteredTable = baseMeta.filter
|
|
838
|
+
? table.filter((row, index) => baseMeta.filter(row, index))
|
|
839
|
+
: table;
|
|
840
|
+
const tagFieldNames = toArray(baseMeta.tagFields);
|
|
841
|
+
const staticTags = toArray(baseMeta.tags);
|
|
842
|
+
const isPick = filteredTable.length > 0 && "_pick" in filteredTable[0];
|
|
843
|
+
// Simple mode: with callback → return Test[]
|
|
844
|
+
return filteredTable.map((row, index) => {
|
|
845
|
+
const id = interpolateTemplate(baseMeta.id, row, index);
|
|
846
|
+
const name = baseMeta.name ? interpolateTemplate(baseMeta.name, row, index) : id;
|
|
847
|
+
// Compute tags: static tags + dynamic tagFields
|
|
848
|
+
const dynamicTags = tagFieldNames
|
|
849
|
+
.map((field) => {
|
|
850
|
+
const value = row[field];
|
|
851
|
+
return value != null ? `${field}:${value}` : null;
|
|
852
|
+
})
|
|
853
|
+
.filter((t) => t !== null);
|
|
854
|
+
const allTags = [...staticTags, ...dynamicTags];
|
|
855
|
+
const meta = {
|
|
856
|
+
...baseMeta,
|
|
857
|
+
id,
|
|
858
|
+
name,
|
|
859
|
+
tags: allTags.length > 0 ? allTags : undefined,
|
|
860
|
+
};
|
|
861
|
+
const testDef = {
|
|
862
|
+
meta,
|
|
863
|
+
type: "simple",
|
|
864
|
+
fn: async (ctx) => await fn(ctx, row),
|
|
865
|
+
};
|
|
866
|
+
registerTest({
|
|
867
|
+
id: meta.id,
|
|
868
|
+
name: meta.name || meta.id,
|
|
869
|
+
type: "simple",
|
|
870
|
+
tags: allTags.length > 0 ? allTags : undefined,
|
|
871
|
+
description: meta.description,
|
|
872
|
+
...(isPick ? { groupId: baseMeta.id } : {}),
|
|
873
|
+
});
|
|
874
|
+
return testDef;
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
test.each = each;
|
|
879
|
+
/**
|
|
880
|
+
* Example-selection API — randomly picks N examples from a named map.
|
|
881
|
+
*
|
|
882
|
+
* `test.pick` is a thin wrapper over `test.each`. It selects a subset of
|
|
883
|
+
* examples from a `Record<string, T>`, injects a `_pick` field containing
|
|
884
|
+
* the example key name, and delegates to `test.each`.
|
|
885
|
+
*
|
|
886
|
+
* Because the return value is identical to `test.each`, all `test.each`
|
|
887
|
+
* options (`filter`, `tagFields`, `tags`) work transparently with `test.pick`.
|
|
888
|
+
*
|
|
889
|
+
* **Default behavior (no CLI override):** randomly selects `count` examples
|
|
890
|
+
* (default 1). This provides lightweight fuzz / smoke-test coverage.
|
|
891
|
+
*
|
|
892
|
+
* **CLI override:** `--pick key1,key2` (or env var `GLUBEAN_PICK`) selects
|
|
893
|
+
* specific examples by name, overriding random selection.
|
|
894
|
+
*
|
|
895
|
+
* **Run all:** `--pick all` or `--pick '*'` runs every example.
|
|
896
|
+
* Recommended for CI where you want full coverage.
|
|
897
|
+
*
|
|
898
|
+
* **Glob patterns:** `--pick 'us-*'` selects all keys matching the pattern.
|
|
899
|
+
* Useful when examples are grouped by prefix (e.g. regions, tenants).
|
|
900
|
+
*
|
|
901
|
+
* **VSCode integration:** CodeLens buttons let users click a specific
|
|
902
|
+
* example to run, which passes `--pick <key>` under the hood.
|
|
903
|
+
*
|
|
904
|
+
* Use `$_pick` in the ID template to include the example key in the test ID.
|
|
905
|
+
*
|
|
906
|
+
* @param examples A named map of example data rows
|
|
907
|
+
* @param count Number of examples to randomly select (default 1)
|
|
908
|
+
* @returns Same as `test.each` — a function accepting ID template and callback
|
|
909
|
+
*
|
|
910
|
+
* @example Inline examples
|
|
911
|
+
* ```ts
|
|
912
|
+
* export const createUser = test.pick({
|
|
913
|
+
* "normal": { name: "Alice", age: 25 },
|
|
914
|
+
* "edge-case": { name: "", age: -1 },
|
|
915
|
+
* "admin": { name: "Admin", role: "admin" },
|
|
916
|
+
* })("create-user-$_pick", async (ctx, example) => {
|
|
917
|
+
* await ctx.http.post("/api/users", { json: example });
|
|
918
|
+
* });
|
|
919
|
+
* ```
|
|
920
|
+
*
|
|
921
|
+
* @example With filter and tagFields (inherited from test.each)
|
|
922
|
+
* ```ts
|
|
923
|
+
* export const regionTests = test.pick(allRegions)({
|
|
924
|
+
* id: "region-$_pick",
|
|
925
|
+
* tagFields: ["currency", "_pick"],
|
|
926
|
+
* filter: (row) => row.currency === "USD",
|
|
927
|
+
* }, async (ctx, data) => {
|
|
928
|
+
* const res = await ctx.http.get(data.endpoint);
|
|
929
|
+
* ctx.expect(res).toHaveStatus(200);
|
|
930
|
+
* });
|
|
931
|
+
* ```
|
|
932
|
+
*
|
|
933
|
+
* @example CLI usage
|
|
934
|
+
* ```bash
|
|
935
|
+
* glubean run file.ts # random example (default)
|
|
936
|
+
* glubean run file.ts --pick normal # specific example
|
|
937
|
+
* glubean run file.ts --pick normal,admin # multiple examples
|
|
938
|
+
* glubean run file.ts --pick all # every example (CI)
|
|
939
|
+
* glubean run file.ts --pick 'us-*' # glob pattern
|
|
940
|
+
* ```
|
|
941
|
+
*/
|
|
942
|
+
function pick(examples, count = 1) {
|
|
943
|
+
const selected = selectPickExamples(examples, count);
|
|
944
|
+
return test.each(selected);
|
|
945
|
+
}
|
|
946
|
+
test.pick = pick;
|
|
947
|
+
/**
|
|
948
|
+
* Create an extended `test` function with augmented context.
|
|
949
|
+
*
|
|
950
|
+
* Inspired by Playwright's `test.extend()`. Returns a new test function
|
|
951
|
+
* where `ctx` includes the resolved fixture properties alongside the
|
|
952
|
+
* base `TestContext` methods.
|
|
953
|
+
*
|
|
954
|
+
* @example Define shared fixtures
|
|
955
|
+
* ```ts
|
|
956
|
+
* // tests/fixtures.ts
|
|
957
|
+
* import { test as base } from "@glubean/sdk";
|
|
958
|
+
*
|
|
959
|
+
* export const test = base.extend({
|
|
960
|
+
* auth: (ctx) => createAuth(ctx.vars.require("AUTH_URL")),
|
|
961
|
+
* db: async (ctx, use) => {
|
|
962
|
+
* const db = await connect(ctx.vars.require("DB_URL"));
|
|
963
|
+
* await use(db);
|
|
964
|
+
* await db.disconnect();
|
|
965
|
+
* },
|
|
966
|
+
* });
|
|
967
|
+
* ```
|
|
968
|
+
*
|
|
969
|
+
* @example Use in tests
|
|
970
|
+
* ```ts
|
|
971
|
+
* // tests/users.test.ts
|
|
972
|
+
* import { test } from "./fixtures.js";
|
|
973
|
+
*
|
|
974
|
+
* export const myTest = test("my-test", async (ctx) => {
|
|
975
|
+
* ctx.auth; // full autocomplete
|
|
976
|
+
* ctx.db; // full autocomplete
|
|
977
|
+
* });
|
|
978
|
+
* ```
|
|
979
|
+
*
|
|
980
|
+
* @example Chained extend
|
|
981
|
+
* ```ts
|
|
982
|
+
* import { test as withAuth } from "./auth-fixtures.js";
|
|
983
|
+
* export const test = withAuth.extend({ db: ... });
|
|
984
|
+
* ```
|
|
985
|
+
*/
|
|
986
|
+
function extend(extensions) {
|
|
987
|
+
return createExtendedTest(extensions);
|
|
988
|
+
}
|
|
989
|
+
test.extend = extend;
|
|
990
|
+
})(test || (test = {}));
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
// Internal helpers
|
|
993
|
+
// ---------------------------------------------------------------------------
|
|
994
|
+
/**
|
|
995
|
+
* Convert a simple glob pattern (with `*` wildcards) to a RegExp.
|
|
996
|
+
* Only `*` is supported (matches any sequence of characters).
|
|
997
|
+
* @internal
|
|
998
|
+
*/
|
|
999
|
+
function globToRegExp(pattern) {
|
|
1000
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1001
|
+
const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
|
|
1002
|
+
return new RegExp(regexStr);
|
|
1003
|
+
}
|
|
1004
|
+
// Re-export all types for user convenience
|
|
1005
|
+
export * from "./types.js";
|
|
1006
|
+
// Re-export data loaders for convenience
|
|
1007
|
+
// Users can also import from "@glubean/sdk/data" directly
|
|
1008
|
+
export { fromCsv, fromDir, fromJsonl, fromYaml, toArray } from "./data.js";
|
|
1009
|
+
// Re-export configure API
|
|
1010
|
+
export { configure, resolveTemplate } from "./configure.js";
|
|
1011
|
+
// Re-export plugin utilities
|
|
1012
|
+
export { definePlugin } from "./plugin.js";
|
|
1013
|
+
// Re-export assertion utilities
|
|
1014
|
+
export { Expectation, ExpectFailError } from "./expect.js";
|
|
1015
|
+
//# sourceMappingURL=index.js.map
|