@specific.dev/spectest 0.34.0 → 0.35.1

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.
@@ -101,7 +101,14 @@ export declare function aws(opts?: AwsOptions): {
101
101
  };
102
102
  env: {
103
103
  AWS_DEFAULT_REGION: string;
104
+ LAMBDA_EXECUTOR: string;
105
+ LAMBDA_STRICT: string;
106
+ LAMBDA_DOCKER_NETWORK: string;
104
107
  };
108
+ volumes: {
109
+ source: string;
110
+ target: string;
111
+ }[];
105
112
  ports: number[];
106
113
  readyCheck: {
107
114
  type: "http";
@@ -19,9 +19,37 @@
19
19
  // pinned rather than floated. It replaced LocalStack, which folded its
20
20
  // Apache-2.0 Community edition into one paid product in March 2026 (current
21
21
  // images demand a LOCALSTACK_AUTH_TOKEN). MiniStack also suits fork-per-test
22
- // far better: ~270 MB against ~1 GB, a ~2 s boot, and Lambda on an in-process
23
- // warm worker pool instead of sibling containers — so no docker socket is
24
- // bind-mounted and there is no host-architecture workaround on Graviton.
22
+ // far better: ~270 MB against ~1 GB and a ~2 s boot.
23
+ //
24
+ // Lambda runs in MiniStack's **docker** executor, never its default `local`
25
+ // one, because `local` has no runtime fidelity at all: it maps `python*` to
26
+ // its own `sys.executable` and `nodejs*` to its own `node`, so the declared
27
+ // `Runtime` selects only the language. A function declared `python3.11` runs
28
+ // on the emulator image's CPython 3.13/musl, and one declared `nodejs20.x`
29
+ // runs on Node 24 — which is why a native wheel built for the real target
30
+ // fails to import ("No module named 'pydantic_core._pydantic_core'"). The
31
+ // docker executor runs the handler under AWS's own Runtime Interface
32
+ // Emulator in `public.ecr.aws/lambda/<runtime>`, giving the real
33
+ // interpreter, glibc, `/var/task` and `AWS_EXECUTION_ENV`. Measured on
34
+ // aarch64: it is also ~7x FASTER per warm invoke (7 ms against 53 ms) at
35
+ // ~25-40 MiB per function container.
36
+ //
37
+ // None of this is configurable, on purpose — a knob here is a knob between
38
+ // "behaves like AWS" and "does not".
39
+ //
40
+ // The cost is a bind-mount of the VM's own docker socket, which the daemon
41
+ // already supports (`ensureVolumes` treats an existing non-directory source
42
+ // as a thing to mount as-is). The RIE containers are ordinary VM containers
43
+ // on `spectest-net`, so they ride snapshots and forks like any service: a
44
+ // per-test fork inherits its parent's already-warm function.
45
+ //
46
+ // Landmine: the runtime images are pulled from `public.ecr.aws`, which the
47
+ // in-VM dockerd reaches DIRECTLY. Its `registry-mirrors` only ever applies
48
+ // to Docker Hub, so the zot mirror (which does carry public.ecr.aws, on
49
+ // :5004) is NOT consulted. MiniStack's own `MINISTACK_IMAGE_PREFIX` cannot
50
+ // close that gap either: it prepends rather than replacing the registry
51
+ // host. Routing these pulls through zot needs a pre-pull of
52
+ // `spectest-host:5004/lambda/<rt>` retagged to the `public.ecr.aws` name.
25
53
  //
26
54
  // Like `email()`'s mail server, the product name stays out of every
27
55
  // user-facing surface (docs, examples, error messages). Users get "the AWS
@@ -73,6 +101,13 @@ const AWS_HOST_PATTERNS = [
73
101
  "*.amazonaws.com.cn",
74
102
  ...AWS_CN_REGIONS.map((r) => `*.${r}.amazonaws.com.cn`),
75
103
  ];
104
+ /** The VM's own docker socket, bind-mounted so the emulator can start the
105
+ * official AWS runtime containers. This is the VM's dockerd, inside the
106
+ * hermetic environment — the same daemon that runs the services. */
107
+ const DOCKER_SOCKET = "/var/run/docker.sock";
108
+ /** The network the function containers join, so a handler reaches the other
109
+ * services and the AWS endpoints by the same names a test uses. */
110
+ const SPECTEST_NETWORK = "spectest-net";
76
111
  /** Region the emulator's own tools default to — the AWS CLI refuses to run
77
112
  * without one. Only the *container's* default; it constrains nothing about
78
113
  * the app, and every region is served regardless. A `setup` hook that drives
@@ -144,7 +179,18 @@ export function aws(opts = {}) {
144
179
  const lambdas = opts.lambdas ?? {};
145
180
  const service = {
146
181
  image: { type: "registry", reference: AWS_IMAGE },
147
- env: { AWS_DEFAULT_REGION: CONTAINER_DEFAULT_REGION },
182
+ env: {
183
+ AWS_DEFAULT_REGION: CONTAINER_DEFAULT_REGION,
184
+ // Run every function in the official AWS runtime image rather than in
185
+ // the emulator's own interpreter — see the note at the top of the file.
186
+ LAMBDA_EXECUTOR: "docker",
187
+ // No silent fall-back to the low-fidelity executor. If the runtime
188
+ // container cannot start, say so instead of running the handler on the
189
+ // wrong interpreter and failing later, somewhere else.
190
+ LAMBDA_STRICT: "1",
191
+ LAMBDA_DOCKER_NETWORK: SPECTEST_NETWORK,
192
+ },
193
+ volumes: [{ source: DOCKER_SOCKET, target: DOCKER_SOCKET }],
148
194
  ports: [AWS_PORT],
149
195
  readyCheck: {
150
196
  type: "http",
@@ -226,9 +272,10 @@ async function deployLambda(service, projectRoot, name, spec) {
226
272
  Handler: spec.handler ?? "index.handler",
227
273
  Role: spec.role ?? DEFAULT_ROLE_ARN,
228
274
  Timeout: spec.timeoutSecs ?? 30,
229
- // Fixed, and not an option: the emulated cloud runs handlers in a shared
230
- // worker pool rather than a memory-capped sandbox, so the number changes
231
- // nothing about how a function behaves here.
275
+ // Fixed, and not an option: the emulated cloud reports this number back
276
+ // as `AWS_LAMBDA_FUNCTION_MEMORY_SIZE` but never caps the runtime
277
+ // container at it, so the number changes nothing about how a function
278
+ // behaves here.
232
279
  MemorySize: MEMORY_MB,
233
280
  ...(spec.env ? { Environment: { Variables: spec.env } } : {}),
234
281
  };
package/dist/index.d.ts CHANGED
@@ -1390,16 +1390,43 @@ export declare function expect(actual: Locator, message?: string): LocatorAssert
1390
1390
  export declare function expect(actual: Browser, message?: string): BrowserAssertion;
1391
1391
  export declare function expect(actual: Provenanced, message?: string): Expectation;
1392
1392
  /**
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.)
1393
+ * Assert on a value with **no provenance** — a computed number, a frame read
1394
+ * off a raw `WebSocket` you opened yourself, anything that never flowed from a
1395
+ * recorded op. `message` is required (it's the second argument) and reads as
1396
+ * the natural follow-on to "assert …" (e.g.
1397
+ * `expectRaw(id, "id matches the generated value")`); it renders as the
1398
+ * assertion's label in the CLI/dashboard ("ASSERT <message>") since a raw
1399
+ * assertion has no op to nest under. (`expect`'s own `message` is optional;
1400
+ * here it is mandatory, since the label is the only human-meaningful summary a
1401
+ * raw assertion has.)
1402
+ *
1403
+ * **This is an escape hatch, and reaching for it is almost always a mistake.**
1404
+ * A raw assertion is *deliberately* unlinked: it renders as a disconnected
1405
+ * top-level row with no op above it, so whoever reads the failure can't see the
1406
+ * request, query, or command the value came from. Using it to get past
1407
+ * `expect`'s compile-time provenance gate is an anti-pattern — the gate rejects
1408
+ * a value precisely because its provenance was destroyed on the way in, and
1409
+ * this doesn't restore it, it just accepts the loss.
1410
+ *
1411
+ * Nearly every real use is a *wrapped* value that got flattened by `.unwrap()`,
1412
+ * `String(x)`, `JSON.parse(x)`, `x.length`, or a home-grown coercion helper.
1413
+ * Keep it wrapped instead:
1414
+ *
1415
+ * ```ts
1416
+ * // ✗ flattened, then asserted raw — the link to the op is gone
1417
+ * expectRaw(JSON.parse(res.body.unwrap()).tier, "tier is pro").toBe("pro");
1418
+ * expectRaw(rows.length, "one row").toBe(1);
1419
+ *
1420
+ * // ✓ same checks, still nested under the http / db step
1421
+ * expect(res.body.transform<{ tier: string }>("json", (s) => JSON.parse(s)).tier).toBe("pro");
1422
+ * expect(rows).toHaveLength(1);
1423
+ * ```
1424
+ *
1425
+ * `.transform(label, fn)` carries the source op's tag through a decode,
1426
+ * `toHaveLength` reads a length without severing it, and a nullish leaf read
1427
+ * inline in `expect(...)` (or via `field`) recovers its own tag. If the value
1428
+ * came from a `fetch`/db/exec/browser/fake/kube op at any point, there is a
1429
+ * wrapped way to assert on it — see the `/tests` docs page.
1403
1430
  */
1404
1431
  export declare function expectRaw(actual: unknown, message: string): Expectation;
1405
1432
  /** `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.34.0",
3
+ "version": "0.35.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",