@sproutboat/runtime 0.36.0 → 0.38.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.
@@ -157,3 +157,139 @@ test("before 2026-10-31 without settings, the outcome has only ack and retry", a
157
157
  expect(legacy).toEqual(["dlq", true, 5000]);
158
158
  expect(out).toEqual({ ack: [], retry: ["a"] });
159
159
  });
160
+
161
+ // ---- 2026-11-05 ----
162
+
163
+ const failure = (p) =>
164
+ p.then(
165
+ () => "accepted",
166
+ (e) => e.name + ": " + e.message,
167
+ );
168
+
169
+ test("2026-11-05: send() checks arguments and delays as workerd does", async () => {
170
+ const { QUEUE } = env("2026-11-05");
171
+ expect(await failure(QUEUE.send())).toBe(
172
+ "TypeError: Failed to execute 'send' on 'WorkerQueue': parameter 1 is not of type 'JsValue'.",
173
+ );
174
+ expect(await failure(QUEUE.send(1, "x"))).toBe(
175
+ "TypeError: Failed to execute 'send' on 'WorkerQueue': parameter 2 is not of type 'SendOptions'.",
176
+ );
177
+ expect(await failure(QUEUE.send(1, null))).toBe("accepted");
178
+ expect(await failure(QUEUE.send(1, { delaySeconds: -1 }))).toStartWith("Error: Queue send failed: delaySeconds");
179
+ expect(await failure(QUEUE.send(1, { delaySeconds: 86401 }))).toStartWith("Error: Queue send failed: delaySeconds");
180
+ expect(await failure(QUEUE.send(1, { delaySeconds: 86400 }))).toBe("accepted");
181
+ expect(await failure(QUEUE.send(1, { delaySeconds: 1.5 }))).toBe("accepted");
182
+ expect(await failure(QUEUE.sendBatch([{ body: 1 }], { delaySeconds: 86401 }))).toStartWith(
183
+ "Error: Queue send failed",
184
+ );
185
+ expect(await failure(QUEUE.sendBatch([{ body: 1, delaySeconds: -5 }]))).toStartWith("Error: Queue send failed");
186
+ expect(await failure(QUEUE.sendBatch([1, 2]))).toBe(
187
+ "TypeError: Incorrect type: the provided value is not of type 'MessageSendRequest'.",
188
+ );
189
+ expect(await failure(QUEUE.send(new Uint8Array([1]).buffer, { contentType: "bytes" }))).toBe(
190
+ 'TypeError: Content Type "bytes" requires a value of type ArrayBufferView, but received: object',
191
+ );
192
+ expect(await failure(QUEUE.send(new Uint8Array([1]), { contentType: "bytes" }))).toBe("accepted");
193
+ });
194
+
195
+ test("before 2026-11-05 the same arguments are accepted or answer as before", async () => {
196
+ const { QUEUE } = env("2026-11-01");
197
+ expect(await failure(QUEUE.send(1, { delaySeconds: 86401 }))).toBe("accepted");
198
+ expect(await failure(QUEUE.send(1, "x"))).toBe("accepted");
199
+ expect(await failure(QUEUE.sendBatch([{ body: 1, delaySeconds: -5 }]))).toBe("accepted");
200
+ expect(await failure(QUEUE.send(new Uint8Array([1]).buffer, { contentType: "bytes" }))).toBe("accepted");
201
+ expect(await failure(QUEUE.send())).toBe("TypeError: Message body cannot be undefined");
202
+ expect(sent.find((r) => r.delaySeconds === 86401)).toBeDefined();
203
+ });
204
+
205
+ const roundTrip = async (compat, value) => {
206
+ const { QUEUE } = env(compat);
207
+ await QUEUE.send(value, { contentType: "v8" });
208
+ const body = sent[sent.length - 1].body;
209
+ let seen;
210
+ await run(
211
+ {
212
+ queue(batch) {
213
+ seen = batch.messages[0];
214
+ },
215
+ },
216
+ { queue: "QUEUE", messages: [message("a", body)] },
217
+ ctx,
218
+ );
219
+ return { body, seen };
220
+ };
221
+
222
+ test("2026-11-05: the v8 content type keeps shared and circular references, holes and invalid dates", async () => {
223
+ const shared = { z: 1 };
224
+ const circular = { n: 1 };
225
+ circular.self = circular;
226
+ const holes = [1];
227
+ holes[3] = 4;
228
+ const { body, seen } = await roundTrip("2026-11-05", {
229
+ a: shared,
230
+ b: shared,
231
+ c: circular,
232
+ holes,
233
+ d: new Date(NaN),
234
+ e: new TypeError("boom"),
235
+ m: new Map([[shared, new Set([shared])]]),
236
+ });
237
+ expect(body.startsWith("#sbq1:v8r:")).toBe(true);
238
+ const v = seen.body;
239
+ expect(v.a).toBe(v.b);
240
+ expect(v.c.self).toBe(v.c);
241
+ expect(v.holes.length).toBe(4);
242
+ expect(1 in v.holes).toBe(false);
243
+ expect(v.holes[3]).toBe(4);
244
+ expect(Number.isNaN(v.d.getTime())).toBe(true);
245
+ expect(v.e).toBeInstanceOf(TypeError);
246
+ expect(v.e.message).toBe("boom");
247
+ const [key, set] = [...v.m][0];
248
+ expect(key).toBe(v.a);
249
+ expect([...set][0]).toBe(v.a);
250
+ expect(Object.getOwnPropertyDescriptor(seen, "body").writable).toBe(false);
251
+ expect(Object.getOwnPropertyDescriptor(seen, "id").writable).toBe(false);
252
+ });
253
+
254
+ test("before 2026-11-05 the v8 content type is the earlier codec, and v8r messages decode at any date", async () => {
255
+ const { body, seen } = await roundTrip("2026-11-01", { d: new Date(1000), m: new Map([[1, 2]]) });
256
+ expect(body.startsWith("#sbq1:v8:")).toBe(true);
257
+ expect(seen.body.m.get(1)).toBe(2);
258
+ const circular = {};
259
+ circular.self = circular;
260
+ const later = await roundTrip("2026-11-05", circular);
261
+ globalThis.__sbCompat = "2026-10-31";
262
+ let again;
263
+ await run(
264
+ {
265
+ queue(batch) {
266
+ again = batch.messages[0].body;
267
+ },
268
+ },
269
+ { queue: "QUEUE", messages: [message("a", later.body)] },
270
+ ctx,
271
+ );
272
+ expect(again.self).toBe(again);
273
+ expect(Object.getOwnPropertyDescriptor(seen, "body").writable).toBe(true);
274
+ });
275
+
276
+ test("2026-11-05: a consumer announces its queue to the store before the handler runs", async () => {
277
+ globalThis.__sbCompat = "2026-11-05";
278
+ sent = [];
279
+ let at;
280
+ await run(
281
+ {
282
+ queue() {
283
+ at = sent.length;
284
+ },
285
+ },
286
+ { queue: "QUEUE", messages: [message("a", "1")] },
287
+ ctx,
288
+ );
289
+ expect(sent[0]).toMatchObject({ op: "queue.consumer", queue: "QUEUE" });
290
+ expect(at).toBe(1);
291
+ globalThis.__sbCompat = "2026-11-01";
292
+ sent = [];
293
+ await run({ queue() {} }, { queue: "QUEUE", messages: [message("a", "1")] }, ctx);
294
+ expect(sent).toEqual([]);
295
+ });
@@ -0,0 +1,62 @@
1
+ # crypto.subtle, native half
2
+
3
+ `crypto.subtle` follows workerd's surface from compatibility date `__SB_SUBTLE_DATE` (a
4
+ placeholder constant at the top of the new block in `native-fetch-prelude.js`). The JavaScript
5
+ side (key objects, algorithm and usage checks, JWK, DOMException names) lives in the prelude;
6
+ this directory is the C it calls.
7
+
8
+ ## Where the primitives come from
9
+
10
+ Nothing here implements a primitive. The rule is that anything with a vetted C implementation
11
+ uses it.
12
+
13
+ | Need | Source | Licence |
14
+ | --- | --- | --- |
15
+ | SHA-1/256/384/512, HMAC, HKDF | BearSSL 0.6 | MIT |
16
+ | RSA (private and public operations, key generation, PKCS#1 v1.5 padding), RSA-OAEP | BearSSL 0.6 | MIT |
17
+ | ECDSA and ECDH on P-256, P-384, P-521, EC key generation | BearSSL 0.6 | MIT |
18
+ | AES (constant-time), CBC, CTR, GCM | BearSSL 0.6 | MIT |
19
+ | Ed25519, X25519 | Monocypher 4.0.2, vendored in `vendor/` | BSD-2-Clause or CC0-1.0 |
20
+
21
+ BearSSL is already linked into every embedded sprout (standalone binaries and sproutd) for
22
+ TLS, so this adds no dependency there. It has no Ed25519 or X25519 signing, which is why
23
+ Monocypher is vendored: one small library, audited (Cure53, 2017), written for constant time,
24
+ public domain or BSD. The two `.c` files and two headers are verbatim from the 4.0.2 release
25
+ (sha256 of the tarball `38d07179738c0c90677dba3ceb7a7b8496bcfea758ba1a53e803fed30ae0879c`);
26
+ `assemble.ts` wraps each in preprocessor aliases so none of its short names (`u8`, `fe`, `FOR`,
27
+ `MIN`) leak into the shared translation unit.
28
+
29
+ What is written here is composition and checking, each of it tested against BoringSSL (Bun's
30
+ WebCrypto) in `subtle.test.ts`:
31
+
32
+ - RSASSA-PSS encoding and verification (EMSA-PSS with MGF1), since BearSSL has the RSA
33
+ primitive but no PSS.
34
+ - PBKDF2 (RFC 8018) over BearSSL's HMAC.
35
+ - The DER for SPKI and PKCS#8, and the PKCS#8 RSA reader. BearSSL's own PKCS#8 decoder
36
+ recomputes the public exponent and fails for primes that are 1 mod 4, which OpenSSL and
37
+ BoringSSL keys often have, so the structure is read directly and BearSSL gets the CRT parts.
38
+ - Argument and point validation (an EC public point is accepted only if it is on the curve).
39
+
40
+ ## Constant time
41
+
42
+ Secret-dependent work is BearSSL's (RSA private exponentiation, ECDSA signing, AES) and
43
+ Monocypher's (Ed25519 signing, X25519), which are written for constant time. Comparisons of
44
+ MACs and signatures use a constant-time compare. Verification of public data (RSA, ECDSA,
45
+ PSS check) is not secret-dependent. ECDSA signatures are deterministic (RFC 6979, BearSSL's
46
+ HMAC-DRBG), which WebCrypto permits.
47
+
48
+ ## Layout
49
+
50
+ - `subtle.c`: the C; one entry point, `sbc_op`.
51
+ - `vendor/`: Monocypher, verbatim, with its licence.
52
+ - `assemble.ts`: builds the block that is spliced into `transport-embedded.js` between the
53
+ `SUBTLE:BEGIN` and `SUBTLE:END` markers. Run `bun run gen:subtle` in `packages/runtime` after
54
+ editing `subtle.c`.
55
+ - `driver.c`, `subtle.test.ts`: compile the same C with the host `cc` and BearSSL from the CLI
56
+ cache, and compare with Bun's WebCrypto.
57
+
58
+ ## Transports
59
+
60
+ Only the embedded transport has BearSSL and Monocypher. On the broker transport
61
+ `__sbSubtleRaw` returns an empty string and the prelude throws `NotSupportedError` for
62
+ anything that needs the native half (HMAC and digest never do).
@@ -0,0 +1,152 @@
1
+ // Builds the C that crypto.subtle compiles into an embedded sprout: Monocypher (vendored verbatim in
2
+ // vendor/) plus subtle.c, adapted for the one place it is compiled.
3
+ //
4
+ // Porffor splits inline C into a shared header and per-unit implementation files, and the splitter
5
+ // is a small scanner, not a C parser. Three things follow, and this file handles each:
6
+ // - A comment that opens a top-level chunk makes the chunk look like a variable and breaks the
7
+ // split, so every comment is removed (the licence travels in vendor/ and the README).
8
+ // - A top-level non-const static function becomes a global symbol in its unit, so every file-scope
9
+ // name Monocypher declares static is aliased to a prefixed one by the preprocessor, for the span
10
+ // of the file. Its short typedef names (u8, fe) and macros (FOR, MIN) are aliased or undefined
11
+ // the same way, so none of them reaches the rest of the translation unit, which also holds
12
+ // Porffor's generated code and SQLite glue.
13
+ // - Preprocessor lines are copied to both halves in order, so the aliases hold on both sides.
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+
16
+ const here = (name: string) => new URL(name, import.meta.url);
17
+ const read = (name: string) => readFileSync(here(name), "utf8");
18
+
19
+ /** Monocypher's internal typedef names. Each is aliased by the preprocessor across the whole block. */
20
+ const INTERNAL_NAMES = ["i8", "u8", "i16", "u32", "i32", "i64", "u64", "fe", "ge", "ge_cached", "ge_precomp", "blk"];
21
+
22
+ export const SUBTLE_BEGIN =
23
+ "// SUBTLE:BEGIN (generated by `bun run gen:subtle`; edit src/subtle/subtle.c, not this block)";
24
+ export const SUBTLE_END = "// SUBTLE:END";
25
+
26
+ /** Removes C comments, leaving strings and character constants alone. */
27
+ export function stripComments(source: string): string {
28
+ let out = "";
29
+ for (let i = 0; i < source.length;) {
30
+ const c = source[i];
31
+ if (c === '"' || c === "'") {
32
+ let j = i + 1;
33
+ while (source[j] !== c) j += source[j] === "\\" ? 2 : 1;
34
+ out += source.slice(i, j + 1);
35
+ i = j + 1;
36
+ } else if (c === "/" && source[i + 1] === "/") {
37
+ while (i < source.length && source[i] !== "\n") i++;
38
+ } else if (c === "/" && source[i + 1] === "*") {
39
+ i = source.indexOf("*/", i + 2) + 2;
40
+ out += " ";
41
+ } else {
42
+ out += c;
43
+ i++;
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ const tidy = (source: string) =>
50
+ stripComments(source)
51
+ .split("\n")
52
+ .map((line) => line.replace(/\s+$/, ""))
53
+ .filter((line, index, all) => line !== "" || (index > 0 && all[index - 1] !== ""))
54
+ .join("\n");
55
+
56
+ export function assembleSubtleC(): string {
57
+ // The C++ guards (a namespace, extern "C") would swallow the rest of the block in the splitter.
58
+ const strip = (name: string) =>
59
+ tidy(read(name))
60
+ .replace(/^#include "monocypher[^"]*"\s*$/gm, "")
61
+ .replace(/^#ifdef (?:MONOCYPHER_CPP_NAMESPACE|__cplusplus)\n[\s\S]*?^#endif\n/gm, "");
62
+ // Each vendored .c file was written to be its own translation unit: its macros end with it, and
63
+ // its static names get a prefix of their own (both define `hash_reduce`, differently).
64
+ const unit = (name: string, index: number) => {
65
+ const body = strip(name);
66
+ const macros = [...new Set([...body.matchAll(/^#define\s+(\w+)/gm)].map((m) => m[1]))];
67
+ const statics = [
68
+ ...new Set([...body.matchAll(/^static\b([^;{(=]*?)\b(\w+)\s*(?:\(|\[|=|;)/gm)].map((m) => m[2])),
69
+ ].filter((n) => !INTERNAL_NAMES.includes(n));
70
+ return [
71
+ ...statics.map((n) => `#define ${n} sbm${index}_${n}`),
72
+ body,
73
+ ...macros.map((n) => `#undef ${n}`),
74
+ ...statics.map((n) => `#undef ${n}`),
75
+ ].join("\n");
76
+ };
77
+ const headers = ["vendor/monocypher.h", "vendor/monocypher-ed25519.h"].map(strip).join("\n");
78
+ const alias = INTERNAL_NAMES.map((n) => `#define ${n} sbm_${n}`).join("\n");
79
+ const unalias = INTERNAL_NAMES.map((n) => `#undef ${n}`).join("\n");
80
+ return [
81
+ "#include <bearssl.h>",
82
+ "// Monocypher 4.0.2 (BSD-2-Clause OR CC0-1.0), Loup Vaillant: vendor/monocypher*.[ch] without comments.",
83
+ alias,
84
+ headers,
85
+ unit("vendor/monocypher.c", 1),
86
+ unit("vendor/monocypher-ed25519.c", 2),
87
+ unalias,
88
+ "// End of Monocypher.",
89
+ tidy(read("subtle.c")),
90
+ ].join("\n");
91
+ }
92
+
93
+ /**
94
+ * One call into sbc_op. `op` is a decimal string; a, b, c, d are byte strings (latin1, one char per
95
+ * byte). Returns a byte string whose first byte is the status (0 ok, 1 DataError, 2 OperationError,
96
+ * 3 NotSupportedError) and whose rest is the result. Sits inside the markers so that pruning the
97
+ * block (wrap.ts, for a handler that never touches crypto.subtle) removes it with the C it calls.
98
+ */
99
+ const SUBTLE_RAW = `// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
100
+ function __sbSubtleRaw(op, a, b, c, d) {
101
+ let out = "";
102
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c\`...\` is inline C the compiler consumes, not a JS expression.
103
+ Porffor.c\`
104
+ const char* __os; size_t __osl; char* __oso = 0;
105
+ porf_native_fetch_read_value(op, &__os, &__osl, &__oso);
106
+ char __ob[8]; size_t __ok = __osl < 7 ? __osl : 7;
107
+ memcpy(__ob, __os, __ok); __ob[__ok] = 0;
108
+ if (__oso) free(__oso);
109
+ int __op = atoi(__ob);
110
+
111
+ const char* __pa = ""; size_t __pal = 0; porf_native_fetch_read_raw_bytes(a, &__pa, &__pal);
112
+ const char* __pb = ""; size_t __pbl = 0; porf_native_fetch_read_raw_bytes(b, &__pb, &__pbl);
113
+ const char* __pc = ""; size_t __pcl = 0; porf_native_fetch_read_raw_bytes(c, &__pc, &__pcl);
114
+ const char* __pd = ""; size_t __pdl = 0; porf_native_fetch_read_raw_bytes(d, &__pd, &__pdl);
115
+ unsigned char* __res = 0; size_t __resl = 0;
116
+ int __rc = sbc_op(__op, (const unsigned char*)__pa, __pal, (const unsigned char*)__pb, __pbl,
117
+ (const unsigned char*)__pc, __pcl, (const unsigned char*)__pd, __pdl, &__res, &__resl);
118
+ unsigned char* __w = (unsigned char*)malloc(__resl + 1);
119
+ if (__w) {
120
+ __w[0] = (unsigned char)__rc;
121
+ if (__resl) memcpy(__w + 1, __res, __resl);
122
+ out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__w, __resl + 1), 195);
123
+ free(__w);
124
+ }
125
+ free(__res);
126
+ \`;
127
+ return out;
128
+ }
129
+ `;
130
+
131
+ /** The prelude with the generated block (re)written between its markers. */
132
+ export function spliceSubtle(prelude: string): string {
133
+ const start = prelude.indexOf(SUBTLE_BEGIN);
134
+ const end = prelude.indexOf(SUBTLE_END);
135
+ if (start < 0 || end < start) throw new Error("native-fetch-prelude.js has no SUBTLE markers");
136
+ const block = assembleSubtleC();
137
+ if (/`|\$\{/.test(block)) throw new Error("subtle C contains a backtick or ${: it is spliced into a JS template");
138
+ return (
139
+ prelude.slice(0, start) +
140
+ SUBTLE_BEGIN +
141
+ "\n// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.\nPorffor.c`\n" +
142
+ block +
143
+ "\n`;\n" +
144
+ SUBTLE_RAW +
145
+ prelude.slice(end)
146
+ );
147
+ }
148
+
149
+ if (import.meta.main) {
150
+ const path = new URL("../native-fetch-prelude.js", import.meta.url);
151
+ writeFileSync(path, spliceSubtle(readFileSync(path, "utf8")));
152
+ }
@@ -0,0 +1,33 @@
1
+ /* Test driver for subtle.c: reads "op a b c d" lines (hex, "-" for empty), prints "rc hex". */
2
+ #include <stdio.h>
3
+ #include <stdlib.h>
4
+ #include <string.h>
5
+ #include <fcntl.h>
6
+ #include <unistd.h>
7
+ #include <stdint.h>
8
+ #include <bearssl.h>
9
+ #include "assembled.c"
10
+
11
+ static unsigned char* unhex(const char* s, size_t* n) {
12
+ if (!strcmp(s, "-")) { *n = 0; return (unsigned char*)calloc(1, 1); }
13
+ size_t l = strlen(s) / 2; unsigned char* o = (unsigned char*)malloc(l + 1);
14
+ for (size_t i = 0; i < l; i++) { unsigned v; sscanf(s + 2 * i, "%2x", &v); o[i] = (unsigned char)v; }
15
+ *n = l; return o;
16
+ }
17
+ int main(void) {
18
+ static char line[1 << 20];
19
+ while (fgets(line, sizeof line, stdin)) {
20
+ char* t[5]; int k = 0;
21
+ for (char* p = strtok(line, " \n"); p && k < 5; p = strtok(0, " \n")) t[k++] = p;
22
+ if (k < 5) continue;
23
+ size_t l[4]; unsigned char* a[4];
24
+ for (int i = 0; i < 4; i++) a[i] = unhex(t[i + 1], &l[i]);
25
+ unsigned char* out; size_t ol;
26
+ int rc = sbc_op(atoi(t[0]), a[0], l[0], a[1], l[1], a[2], l[2], a[3], l[3], &out, &ol);
27
+ printf("%d ", rc);
28
+ for (size_t i = 0; i < ol; i++) printf("%02x", out[i]);
29
+ printf("\n"); fflush(stdout);
30
+ free(out); for (int i = 0; i < 4; i++) free(a[i]);
31
+ }
32
+ return 0;
33
+ }
@@ -0,0 +1,53 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { preludePath, pruneSubtle, wrapNativeFetchHandler } from "../wrap";
4
+
5
+ const prelude = readFileSync(preludePath, "utf8");
6
+ const handler = (body: string) => `export default { async fetch() { ${body} return new Response("x"); } };`;
7
+
8
+ test("pruning removes the workerd-style crypto.subtle, its C and its glue, and nothing else", () => {
9
+ const pruned = pruneSubtle(prelude);
10
+ for (const gone of [
11
+ "__sbSubtleNext",
12
+ "__sbSubtleLegacy",
13
+ "SUBTLE:",
14
+ "const __SB_SUBTLE_DATE",
15
+ "sbc_op",
16
+ "crypto_ed25519_sign",
17
+ "__sbSubtleRaw",
18
+ "class DigestStream",
19
+ ])
20
+ expect(pruned).not.toContain(gone);
21
+ for (const kept of ["globalThis.crypto.subtle = {", "sb_sha256_block", "__sbHmacRaw"]) expect(pruned).toContain(kept);
22
+ expect(pruned.length).toBeLessThan(prelude.length - 150_000);
23
+ // What is left is still JavaScript.
24
+ new Bun.Transpiler().transformSync(pruned);
25
+ });
26
+
27
+ test("auto keeps the code for a handler that names crypto.subtle, DigestStream or CryptoKey", () => {
28
+ const wrap = (source: string, mode: "auto" | "always") =>
29
+ wrapNativeFetchHandler(
30
+ source,
31
+ prelude,
32
+ {},
33
+ undefined,
34
+ undefined,
35
+ "2026-11-06",
36
+ "app",
37
+ undefined,
38
+ "embedded",
39
+ undefined,
40
+ mode,
41
+ );
42
+ expect(wrap(handler("await crypto.subtle.digest('SHA-256', new Uint8Array(1));"), "auto")).toContain(
43
+ "__sbSubtleNext",
44
+ );
45
+ expect(wrap(handler("new crypto.DigestStream('SHA-1');"), "auto")).toContain("__sbSubtleNext");
46
+ expect(wrap(handler("if (k instanceof CryptoKey) {}"), "auto")).toContain("__sbSubtleNext");
47
+ expect(wrap(handler(""), "auto")).not.toContain("__sbSubtleNext");
48
+ expect(wrap(handler(""), "always")).toContain("__sbSubtleNext");
49
+ });
50
+
51
+ test("a prelude without the markers is an error, not a silent no-op", () => {
52
+ expect(() => pruneSubtle("function f() {}")).toThrow(/no \/\/ SUBTLE-JS:BEGIN/);
53
+ });