@specific.dev/spectest 0.35.0 → 0.36.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.
@@ -1,3 +1,4 @@
1
+ import type { ServiceSetupContext } from "../index.js";
1
2
  import { type SqlClient } from "../sql.js";
2
3
  export interface PostgresOptions {
3
4
  /** Image tag for the official `postgres` image. Default `"18-alpine"`.
@@ -32,6 +33,28 @@ export interface PostgresOptions {
32
33
  args?: string[];
33
34
  /** Extra environment variables forwarded to the container. */
34
35
  env?: Record<string, string>;
36
+ /**
37
+ * One-shot hook that runs after the ready probe passes, before any
38
+ * dependent service starts and before any test. This is where schema
39
+ * and seed data go: the daemon captures the result into the
40
+ * warm-template snapshot, so it never runs again on a warm start or a
41
+ * fork.
42
+ *
43
+ * The argument is the full test context plus this service's `name` and
44
+ * its {@link PostgresHelpers}, so the instrumented client is already
45
+ * built:
46
+ *
47
+ * ```ts
48
+ * db: postgres({
49
+ * database: "todos", user: "todos", password: "todos",
50
+ * setup: async ({ helpers }) => {
51
+ * await helpers.client`CREATE TABLE todos (
52
+ * id SERIAL PRIMARY KEY, text TEXT NOT NULL)`;
53
+ * },
54
+ * }),
55
+ * ```
56
+ */
57
+ setup?: (args: ServiceSetupContext<PostgresHelpers>) => void | Promise<void>;
35
58
  }
36
59
  /** Helpers a `postgres(...)` service exposes on `ctx.svc.<name>`. */
37
60
  export interface PostgresHelpers {
@@ -67,6 +90,7 @@ export interface PostgresHelpers {
67
90
  * ```
68
91
  */
69
92
  export declare function postgres(opts: PostgresOptions): {
93
+ setup?: ((args: ServiceSetupContext<PostgresHelpers>) => void | Promise<void>) | undefined;
70
94
  env: {
71
95
  POSTGRES_DB: string;
72
96
  POSTGRES_USER: string;
@@ -54,5 +54,10 @@ export function postgres(opts) {
54
54
  `@${name}:${port}/${encodeURIComponent(opts.database)}`;
55
55
  return { client: new SQL(url, { label: name }) };
56
56
  },
57
+ // The user's hook, forwarded verbatim — postgres() declares none of
58
+ // its own. If it ever gains one, CHAIN the user's after it (the way
59
+ // `serviceGroup` chains a group hook after the primary part's) rather
60
+ // than letting either one win.
61
+ ...(opts.setup ? { setup: opts.setup } : {}),
57
62
  };
58
63
  }
package/dist/index.d.ts CHANGED
@@ -455,8 +455,17 @@ export interface ServiceDefinition<H extends Record<string, any> = Record<string
455
455
  }
456
456
  /** A services map — what users pass to `environment.services`. Entries
457
457
  * can be plain `ServiceConfig` literals or `ServiceDefinition`s that
458
- * carry a `helpers` factory (e.g. what `postgres(...)` returns). */
459
- export type ServicesMap = Record<string, ServiceConfig>;
458
+ * carry a `helpers` factory (e.g. what `postgres(...)` returns).
459
+ *
460
+ * The entry type is `ServiceDefinition`, not `ServiceConfig`: at runtime
461
+ * the map really does hold `helpers`/`setup` (the daemon reads them off
462
+ * each entry), and both are optional, so every plain `ServiceConfig`
463
+ * still satisfies it. Narrowing this to `ServiceConfig` — the wire type,
464
+ * which by definition carries no functions — made the natural way to
465
+ * hoist a services map out of `index.ts` (`… satisfies ServicesMap`) fail
466
+ * the typecheck on any service declaring a hook, while the same literal
467
+ * written inline passed. */
468
+ export type ServicesMap = Record<string, ServiceDefinition<any>>;
460
469
  /** Awaited return type of a service's `helpers` factory, or `never` if
461
470
  * the service doesn't ship one. */
462
471
  type HelpersOf<D> = D extends {
@@ -1390,16 +1399,43 @@ export declare function expect(actual: Locator, message?: string): LocatorAssert
1390
1399
  export declare function expect(actual: Browser, message?: string): BrowserAssertion;
1391
1400
  export declare function expect(actual: Provenanced, message?: string): Expectation;
1392
1401
  /**
1393
- * Assert on a value with **no provenance** — a computed number, a raw
1394
- * WebSocket frame, anything that didn't flow from a recorded op. `message` is
1395
- * required (it's the second argument) and reads as the natural follow-on to
1396
- * "assert …" (e.g. `expectRaw(id, "id matches the generated value")`); it
1397
- * renders as the assertion's label in the CLI/dashboard ("ASSERT <message>")
1398
- * since a raw assertion has no op to nest under. Prefer `expect(...)` whenever
1399
- * the value carries provenance — only reach for this when the type gate would
1400
- * (rightly) reject the value. (`expect`'s own `message` is optional; here it is
1401
- * mandatory, since the label is the only human-meaningful summary a raw
1402
- * assertion has.)
1402
+ * Assert on a value with **no provenance** — a computed number, a frame read
1403
+ * off a raw `WebSocket` you opened yourself, anything that never flowed from a
1404
+ * recorded op. `message` is required (it's the second argument) and reads as
1405
+ * the natural follow-on to "assert …" (e.g.
1406
+ * `expectRaw(id, "id matches the generated value")`); it renders as the
1407
+ * assertion's label in the CLI/dashboard ("ASSERT <message>") since a raw
1408
+ * assertion has no op to nest under. (`expect`'s own `message` is optional;
1409
+ * here it is mandatory, since the label is the only human-meaningful summary a
1410
+ * raw assertion has.)
1411
+ *
1412
+ * **This is an escape hatch, and reaching for it is almost always a mistake.**
1413
+ * A raw assertion is *deliberately* unlinked: it renders as a disconnected
1414
+ * top-level row with no op above it, so whoever reads the failure can't see the
1415
+ * request, query, or command the value came from. Using it to get past
1416
+ * `expect`'s compile-time provenance gate is an anti-pattern — the gate rejects
1417
+ * a value precisely because its provenance was destroyed on the way in, and
1418
+ * this doesn't restore it, it just accepts the loss.
1419
+ *
1420
+ * Nearly every real use is a *wrapped* value that got flattened by `.unwrap()`,
1421
+ * `String(x)`, `JSON.parse(x)`, `x.length`, or a home-grown coercion helper.
1422
+ * Keep it wrapped instead:
1423
+ *
1424
+ * ```ts
1425
+ * // ✗ flattened, then asserted raw — the link to the op is gone
1426
+ * expectRaw(JSON.parse(res.body.unwrap()).tier, "tier is pro").toBe("pro");
1427
+ * expectRaw(rows.length, "one row").toBe(1);
1428
+ *
1429
+ * // ✓ same checks, still nested under the http / db step
1430
+ * expect(res.body.transform<{ tier: string }>("json", (s) => JSON.parse(s)).tier).toBe("pro");
1431
+ * expect(rows).toHaveLength(1);
1432
+ * ```
1433
+ *
1434
+ * `.transform(label, fn)` carries the source op's tag through a decode,
1435
+ * `toHaveLength` reads a length without severing it, and a nullish leaf read
1436
+ * inline in `expect(...)` (or via `field`) recovers its own tag. If the value
1437
+ * came from a `fetch`/db/exec/browser/fake/kube op at any point, there is a
1438
+ * wrapped way to assert on it — see the `/tests` docs page.
1403
1439
  */
1404
1440
  export declare function expectRaw(actual: unknown, message: string): Expectation;
1405
1441
  /** `node:assert/strict` re-exported for users who prefer Node's built-in API. */
package/dist/index.js CHANGED
@@ -657,16 +657,43 @@ function buildBrowserMatchers(session, negated, message) {
657
657
  };
658
658
  }
659
659
  /**
660
- * Assert on a value with **no provenance** — a computed number, a raw
661
- * WebSocket frame, anything that didn't flow from a recorded op. `message` is
662
- * required (it's the second argument) and reads as the natural follow-on to
663
- * "assert …" (e.g. `expectRaw(id, "id matches the generated value")`); it
664
- * renders as the assertion's label in the CLI/dashboard ("ASSERT <message>")
665
- * since a raw assertion has no op to nest under. Prefer `expect(...)` whenever
666
- * the value carries provenance — only reach for this when the type gate would
667
- * (rightly) reject the value. (`expect`'s own `message` is optional; here it is
668
- * mandatory, since the label is the only human-meaningful summary a raw
669
- * assertion has.)
660
+ * Assert on a value with **no provenance** — a computed number, a frame read
661
+ * off a raw `WebSocket` you opened yourself, anything that never flowed from a
662
+ * recorded op. `message` is required (it's the second argument) and reads as
663
+ * the natural follow-on to "assert …" (e.g.
664
+ * `expectRaw(id, "id matches the generated value")`); it renders as the
665
+ * assertion's label in the CLI/dashboard ("ASSERT <message>") since a raw
666
+ * assertion has no op to nest under. (`expect`'s own `message` is optional;
667
+ * here it is mandatory, since the label is the only human-meaningful summary a
668
+ * raw assertion has.)
669
+ *
670
+ * **This is an escape hatch, and reaching for it is almost always a mistake.**
671
+ * A raw assertion is *deliberately* unlinked: it renders as a disconnected
672
+ * top-level row with no op above it, so whoever reads the failure can't see the
673
+ * request, query, or command the value came from. Using it to get past
674
+ * `expect`'s compile-time provenance gate is an anti-pattern — the gate rejects
675
+ * a value precisely because its provenance was destroyed on the way in, and
676
+ * this doesn't restore it, it just accepts the loss.
677
+ *
678
+ * Nearly every real use is a *wrapped* value that got flattened by `.unwrap()`,
679
+ * `String(x)`, `JSON.parse(x)`, `x.length`, or a home-grown coercion helper.
680
+ * Keep it wrapped instead:
681
+ *
682
+ * ```ts
683
+ * // ✗ flattened, then asserted raw — the link to the op is gone
684
+ * expectRaw(JSON.parse(res.body.unwrap()).tier, "tier is pro").toBe("pro");
685
+ * expectRaw(rows.length, "one row").toBe(1);
686
+ *
687
+ * // ✓ same checks, still nested under the http / db step
688
+ * expect(res.body.transform<{ tier: string }>("json", (s) => JSON.parse(s)).tier).toBe("pro");
689
+ * expect(rows).toHaveLength(1);
690
+ * ```
691
+ *
692
+ * `.transform(label, fn)` carries the source op's tag through a decode,
693
+ * `toHaveLength` reads a length without severing it, and a nullish leaf read
694
+ * inline in `expect(...)` (or via `field`) recovers its own tag. If the value
695
+ * came from a `fetch`/db/exec/browser/fake/kube op at any point, there is a
696
+ * wrapped way to assert on it — see the `/tests` docs page.
670
697
  */
671
698
  export function expectRaw(actual, message) {
672
699
  // Force the raw form so no stray tag is read even if a wrapped value is
package/dist/inspect.d.ts CHANGED
@@ -106,8 +106,14 @@ export interface Carrier<T> {
106
106
  * `null`/`undefined` are also admitted: a nullish leaf can't carry the symbol
107
107
  * tag, but `adoptNullishTag` recovers its provenance at runtime, so
108
108
  * `expect(rows[0]?.text)` and `expect(dep.status.readyReplicas)` stay on
109
- * `expect`. To assert on a value with no provenance (a computed number, a raw
110
- * WebSocket frame), use `expectRaw(value, message)` instead.
109
+ * `expect`.
110
+ *
111
+ * When this gate rejects a value, the fix is usually to stop flattening it —
112
+ * `.transform(label, fn)` instead of a decode-then-`.unwrap()`, `toHaveLength`
113
+ * instead of `.length` — not to switch to `expectRaw`, which accepts anything
114
+ * but renders the assertion unlinked. Reach for `expectRaw(value, message)`
115
+ * only when the value never flowed from a recorded op at all (a computed
116
+ * number, a frame off a raw WebSocket); see its own doc comment.
111
117
  */
112
118
  export type Provenanced = {
113
119
  unwrap(): unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",