@specific.dev/spectest 0.9.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/index.ts CHANGED
@@ -26,8 +26,9 @@ import type { OpTag, Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
26
26
  // `field` — a provenance-preserving, null-safe selector: a `null`/`undefined`
27
27
  // leaf read off a wrapped op result is raw and untagged, so an `expect(...)` on
28
28
  // it renders detached from its source op; `field` tags from the container so the
29
- // assertion still nests. (Base64-decoded values lose provenance the same way;
30
- // that case is the `.base64Decoded()` provenance transform on `expect(...)`.)
29
+ // assertion still nests. (A decoded value loses provenance the same way — that
30
+ // case is the `.transform(label, fn)` method every wrapped value carries, which
31
+ // runs the still-tagged value through `fn` and re-wraps the result.)
31
32
  // To recover a raw value, call `.unwrap()` on it — spectest op results are
32
33
  // always wrapped (in every context), so the method is always there; there is no
33
34
  // `unwrap(x)` free function. See the `.unwrap()` discipline section of `spectest docs`.
@@ -1344,52 +1345,7 @@ interface Matchers {
1344
1345
  toHaveLength(n: number): void;
1345
1346
  }
1346
1347
 
1347
- /**
1348
- * Provenance-preserving transforms. Each returns a fresh {@link Expectation}
1349
- * bound to the *decoded* value but still carrying the originating op's tag (its
1350
- * path extended by a marker like `<base64>`), so the eventual assertion nests
1351
- * under the source read in the timeline. This is what lets a value that must be
1352
- * decoded through a plain function — `Buffer.from(x,"base64").toString()`,
1353
- * `decodeURIComponent(x)` — be asserted on with the *full* matcher vocabulary
1354
- * (`toHaveLength`, `toContain`, `toMatch`, …) without dropping to `expectRaw`,
1355
- * which severs the link. Transforms chain (`.base64Decoded().urlDecoded()`),
1356
- * and the matcher you finish with may be negated (`.base64Decoded().not.…`).
1357
- *
1358
- * They supersede the old `toBeBase64Of` matcher, which was equality-only:
1359
- * `expect(secret.data?.X).base64Decoded().toBe("plain")` is the direct
1360
- * replacement, and `.toHaveLength(32)` / `.toContain("redis://")` / `.toMatch(re)`
1361
- * are the cases it couldn't express.
1362
- */
1363
- interface Transforms {
1364
- /** Interpret the value as a base64 string and decode it (UTF-8), mirroring
1365
- * `Buffer.from(x, "base64").toString()`. Throws-as-failed-assertion if the
1366
- * value isn't a string. */
1367
- base64Decoded(): Expectation;
1368
- /** URL-decode the value, mirroring `decodeURIComponent(x)`. Chains after
1369
- * `base64Decoded()` for base64-then-URL-encoded values. */
1370
- urlDecoded(): Expectation;
1371
- /** Parse the value as JSON (`JSON.parse(x)`) and assert on the result with the
1372
- * full matcher vocabulary — `toEqual` against the decoded object/array,
1373
- * `toContain`/`toHaveLength` against a decoded array. Throws-as-failed-assertion
1374
- * if the value isn't a string or isn't valid JSON. Chains after other
1375
- * transforms (e.g. `base64Decoded().jsonDecoded()` for a base64-wrapped JSON
1376
- * payload). The optional type parameter `T` annotates the decoded shape at the
1377
- * call site (`jsonDecoded<User>()`) — it casts the parsed value, but, like the
1378
- * matchers, is not enforced at runtime. */
1379
- jsonDecoded<T = unknown>(): Expectation;
1380
- /**
1381
- * Generic escape hatch: apply an arbitrary `fn` to the raw value and assert on
1382
- * the result, keeping provenance. `label` is a plain word (`"json"`,
1383
- * `"decompressed"`) appended to the op path so the timeline shows what was
1384
- * derived; the UI brackets it (`<json>`) to mark it as a derived step, so do
1385
- * **not** include the brackets yourself. Sugar like {@link base64Decoded} is
1386
- * built on this. If `fn` throws, the next assertion records as a failure
1387
- * describing the transform error.
1388
- */
1389
- transform(label: string, fn: (value: unknown) => unknown): Expectation;
1390
- }
1391
-
1392
- export interface Expectation extends Matchers, Transforms {
1348
+ export interface Expectation extends Matchers {
1393
1349
  not: Matchers;
1394
1350
  }
1395
1351
 
@@ -1495,31 +1451,6 @@ function buildCore(
1495
1451
  });
1496
1452
  if (!passed) throw new ExpectationError(msg);
1497
1453
  };
1498
- // Apply `fn` to the raw value and rebuild against the result, extending the
1499
- // op path by a derived-step marker so the decoded value still nests under the
1500
- // source read. `label` is a plain word ("base64", "json"); the `<…>` marker
1501
- // syntax that distinguishes a transform from a real property read in the
1502
- // timeline is added here, so it lives in one place and never leaks into the
1503
- // API surface — the same convention `inspect.ts` uses for array methods
1504
- // (`<find>`, `<map>`). A throw becomes a `pendingError` on the returned
1505
- // Expectation rather than escaping — a malformed decode renders as a failed
1506
- // assertion in the timeline, not an unhandled exception. Propagates an
1507
- // existing `pendingError` untouched.
1508
- const applyTransform = (label: string, fn: (value: unknown) => unknown): Expectation => {
1509
- const marker = `<${label}>`;
1510
- const nextTag: OpTag | undefined = tag
1511
- ? { sourceSeq: tag.sourceSeq, path: [...tag.path, marker] }
1512
- : undefined;
1513
- if (pendingError !== undefined) {
1514
- return buildCore(undefined, nextTag, false, message, pendingError);
1515
- }
1516
- try {
1517
- return buildCore(fn(actual), nextTag, false, message);
1518
- } catch (err) {
1519
- const detail = err instanceof Error ? err.message : String(err);
1520
- return buildCore(undefined, nextTag, false, message, `${marker}: ${detail}`);
1521
- }
1522
- };
1523
1454
  return {
1524
1455
  toBe(expected) {
1525
1456
  const exp = readRaw(expected);
@@ -1628,25 +1559,6 @@ function buildCore(
1628
1559
  { actual: len, pathSuffix: "length" },
1629
1560
  );
1630
1561
  },
1631
- transform(label, fn) {
1632
- return applyTransform(label, fn);
1633
- },
1634
- base64Decoded() {
1635
- return applyTransform("base64", (v) => {
1636
- if (typeof v !== "string") {
1637
- throw new Error(`base64Decoded expects a string, got ${typeof v}`);
1638
- }
1639
- return Buffer.from(v, "base64").toString();
1640
- });
1641
- },
1642
- urlDecoded() {
1643
- return applyTransform("urldecode", (v) => {
1644
- if (typeof v !== "string") {
1645
- throw new Error(`urlDecoded expects a string, got ${typeof v}`);
1646
- }
1647
- return decodeURIComponent(v);
1648
- });
1649
- },
1650
1562
  get not(): Matchers {
1651
1563
  // Re-pass the already-unwrapped value, tag and message so the tag and raw
1652
1564
  // label are preserved for the negated branch's AssertionEvent.
package/src/inspect.ts CHANGED
@@ -248,6 +248,12 @@ function wrapObject<T extends object>(
248
248
  return () => readRaw(target);
249
249
  }
250
250
  }
251
+ // `.transform(label, fn)` — same shape/guard as `unwrap`: only intercept
252
+ // when the underlying object has no own `transform`, so a real data field
253
+ // named `transform` is never shadowed.
254
+ if (prop === "transform" && !(prop in (target as object))) {
255
+ return makeTransform(readRaw(target), sourceSeq, path);
256
+ }
251
257
  // Hide thenable-ness from `await`. We must never accidentally
252
258
  // implement `then`, or `await fetch(...)` would resolve to the
253
259
  // wrong thing if we ever wrapped a Promise (we don't, but be safe).
@@ -298,6 +304,34 @@ function wrapObject<T extends object>(
298
304
  return new Proxy(raw, handler);
299
305
  }
300
306
 
307
+ // The `.transform(label, fn)` method every wrapper (carrier + object/array
308
+ // proxy) carries — the value-level analogue of `.unwrap()`. It runs the raw
309
+ // value through `fn` and re-`wrap`s the result with `path` extended by a
310
+ // `<label>` derived-step marker, so the decoded value keeps the source op's
311
+ // provenance (`sourceSeq`) and stays navigable: a later `expect(...)` on it, or
312
+ // on a subfield, still nests under the originating op (`secret.data.config.<json>.tier`).
313
+ // `label` is a plain word; the `<…>` brackets are added here so they never leak
314
+ // into the call site (the same convention array methods use, `<find>`/`<map>`).
315
+ // A throw in `fn` (a non-string value, malformed base64/JSON) is rethrown
316
+ // prefixed with `<label>:`, failing the test at the bad value.
317
+ function makeTransform(
318
+ raw: unknown,
319
+ sourceSeq: number | undefined,
320
+ path: readonly string[],
321
+ ): (label: string, fn: (raw: never) => unknown) => unknown {
322
+ return (label, fn) => {
323
+ const nextPath = [...path, `<${label}>`];
324
+ let result: unknown;
325
+ try {
326
+ result = (fn as (r: unknown) => unknown)(raw);
327
+ } catch (err) {
328
+ const detail = err instanceof Error ? err.message : String(err);
329
+ throw new Error(`<${label}>: ${detail}`);
330
+ }
331
+ return wrap(result, sourceSeq, nextPath);
332
+ };
333
+ }
334
+
301
335
  // Runtime shape of a primitive carrier: the public `Carrier<T>` surface
302
336
  // (`unwrap()`) plus the internal provenance symbols. Kept private so the
303
337
  // exported `Carrier<T>` stays clean.
@@ -316,6 +350,7 @@ function makeCarrier<T>(
316
350
  [OP_TAG]: tag,
317
351
  [UNWRAP]: raw,
318
352
  unwrap: () => raw,
353
+ transform: makeTransform(raw, sourceSeq, path) as unknown as Carrier<T>["transform"],
319
354
  // Coercion sinks recover the raw primitive instead of inheriting
320
355
  // Object.prototype's defaults ("[object Object]" / NaN / {}). A
321
356
  // carrier interpolated into a string, fed to arithmetic, `==`, or
@@ -338,10 +373,10 @@ function makeCarrier<T>(
338
373
  // passes them through raw. So `expect(row.deleted_at).toBe(null)` reaches
339
374
  // `expect()` as a bare `null` with no tag and renders as a disconnected
340
375
  // top-level row. `field` recovers the link by tagging from the *container*.
341
- // (Base64-decoded values lose their tag the same way, via the transform; that
342
- // case is handled by the `.base64Decoded()` provenance transform in index.ts,
343
- // which decodes the still-tagged value internally and rebuilds the Expectation
344
- // against the result.)
376
+ // (A decoded value loses its tag the same way, through the decode; the
377
+ // `.transform(label, fn)` method every wrapper carries (see `makeTransform`)
378
+ // recovers it — it runs the still-tagged value through `fn` and re-`wrap`s the
379
+ // result with the source path extended by a `<label>` marker.)
345
380
  // ───────────────────────────────────────────────────────────────────────────
346
381
 
347
382
  /**
@@ -410,6 +445,22 @@ export function field(value: unknown, ...path: Array<string | number>): unknown
410
445
  export interface Carrier<T> {
411
446
  /** Recover the raw underlying value. */
412
447
  unwrap(): T;
448
+ /**
449
+ * Run the raw value through `fn` and get back a provenance-carrying handle to
450
+ * the result — for asserting on a *decoded* value (base64, JSON, JWT, …) while
451
+ * keeping its link to the op that produced it. The op path is extended by a
452
+ * `<label>` marker, so a later `expect(...)` on the result still nests under
453
+ * the source op. `label` is a plain word (`"base64"`, `"json"`); the UI adds
454
+ * the `<…>` brackets, so don't include them yourself. `fn` receives the raw
455
+ * value (typed `T`), so no cast is needed. `R` types the result. A throw in
456
+ * `fn` is rethrown prefixed with `<label>:`, failing the test at the value.
457
+ *
458
+ * ```ts
459
+ * expect(secret.data.url.transform("base64", (s) => Buffer.from(s, "base64").toString()))
460
+ * .toContain("redis://");
461
+ * ```
462
+ */
463
+ transform<R = unknown>(label: string, fn: (raw: T) => R): Wrapped<R>;
413
464
  /** Coerces to the raw value (arithmetic, `==`). */
414
465
  valueOf(): T;
415
466
  /** Renders the raw value (template interpolation). */
@@ -475,6 +526,9 @@ export type WrappedObject<T> = {
475
526
  } & {
476
527
  /** Recover the fully raw value (nested leaves unwrapped too). */
477
528
  unwrap(): T;
529
+ /** Run the raw object through `fn`, keeping provenance (op path + `<label>`),
530
+ * and get back a navigable handle to the result. See {@link Carrier.transform}. */
531
+ transform<R = unknown>(label: string, fn: (raw: T) => R): Wrapped<R>;
478
532
  };
479
533
 
480
534
  /**
@@ -491,6 +545,9 @@ export interface WrappedArray<U> {
491
545
  readonly [index: number]: Wrapped<U>;
492
546
  /** Recover the raw array (elements unwrapped). */
493
547
  unwrap(): U[];
548
+ /** Run the raw array through `fn`, keeping provenance (op path + `<label>`),
549
+ * and get back a navigable handle to the result. See {@link Carrier.transform}. */
550
+ transform<R = unknown>(label: string, fn: (raw: U[]) => R): Wrapped<R>;
494
551
  at(index: number): Wrapped<U> | undefined;
495
552
  find(
496
553
  predicate: (value: U, index: number, obj: U[]) => unknown,