@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/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