@specific.dev/spectest 0.11.0 → 0.13.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 +1 -1
- package/src/components/index.ts +5 -0
- package/src/components/s3.ts +132 -0
- package/src/index.ts +51 -20
- package/src/inspect.ts +1 -1
- package/src/recorder.ts +7 -5
- package/src/s3.ts +7 -7
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -11,6 +11,11 @@ export {
|
|
|
11
11
|
type PostgresOptions,
|
|
12
12
|
type PostgresHelpers,
|
|
13
13
|
} from "./postgres.js";
|
|
14
|
+
export {
|
|
15
|
+
s3,
|
|
16
|
+
type S3Options,
|
|
17
|
+
type S3Helpers,
|
|
18
|
+
} from "./s3.js";
|
|
14
19
|
// `SQL` / `SqlClient` now live in the core SDK (`@specific.dev/spectest`),
|
|
15
20
|
// next to `expect` and `fetch` — re-exported here so existing
|
|
16
21
|
// `from ".../components"` imports keep resolving.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ServiceDefinition } from "../index.js";
|
|
2
|
+
import { S3Client, type S3ClientLike } from "../s3.js";
|
|
3
|
+
|
|
4
|
+
export interface S3Options {
|
|
5
|
+
/** HTTP port the S3 API is served on. Default `9090`. */
|
|
6
|
+
port?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Buckets to create on first boot. The backing server's own `initialBuckets`
|
|
9
|
+
* env is unreliable across versions, so the component creates them in `setup`
|
|
10
|
+
* (a plain CreateBucket `PUT`) — captured into the warm snapshot, never
|
|
11
|
+
* re-run on warm starts/forks.
|
|
12
|
+
*/
|
|
13
|
+
buckets?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Default bucket the `helpers.client` targets (so `client.file("k")` resolves
|
|
16
|
+
* without a per-call `{ bucket }`). Defaults to the first entry of `buckets`.
|
|
17
|
+
*/
|
|
18
|
+
bucket?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Hostnames to additionally serve the store at over **HTTPS** (CA-trusted),
|
|
21
|
+
* via the daemon's TLS-terminating reverse proxy. Optional — defaults to none;
|
|
22
|
+
* the store is always reachable at `http://<key>:<port>` regardless. Set this
|
|
23
|
+
* only when the app under test hardcodes a specific prod S3 endpoint it can't
|
|
24
|
+
* be told to override (`https://<bucket>.s3.amazonaws.com`, a provider's
|
|
25
|
+
* storage host): the daemon mints a cert for each host and proxies to the
|
|
26
|
+
* store's plain HTTP. The store ignores SigV4, so the Host rewrite is harmless.
|
|
27
|
+
*/
|
|
28
|
+
hosts?: string[];
|
|
29
|
+
/** Access key id the helper client signs with. The store ignores SigV4, so
|
|
30
|
+
* any non-empty value works; default `"s3"`. */
|
|
31
|
+
accessKeyId?: string;
|
|
32
|
+
/** Secret key the helper client signs with (ignored by the store). Default
|
|
33
|
+
* `"s3"`. */
|
|
34
|
+
secretAccessKey?: string;
|
|
35
|
+
/** Region for the helper client. Default `"us-east-1"`. */
|
|
36
|
+
region?: string;
|
|
37
|
+
/** Extra environment variables forwarded to the container. */
|
|
38
|
+
env?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Helpers an `s3(...)` service exposes on `ctx.svc.<name>`. */
|
|
42
|
+
export interface S3Helpers {
|
|
43
|
+
/** An instrumented {@link S3Client} pointed at this store (path-style) — each
|
|
44
|
+
* object op lands on the test event log and its result comes back
|
|
45
|
+
* provenance-wrapped, so `expect(...)` on a read links to the op. */
|
|
46
|
+
client: S3ClientLike;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Backing image, pinned internally (not user-configurable). adobe/s3mock 5.1.0
|
|
50
|
+
// is the current release; avoid exactly 5.0.0, which crashed at startup on
|
|
51
|
+
// Java 25 (`NoClassDefFoundError: KotlinBuiltIns$2`) — fixed in 5.1.0.
|
|
52
|
+
const S3_IMAGE = "adobe/s3mock:5.1.0";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A ready-to-use, S3-compatible object store (backed by
|
|
56
|
+
* [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
|
|
57
|
+
* client on `ctx.svc.<key>.client`. Drop into `environment.services`:
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* import { s3 } from "@specific.dev/spectest/components";
|
|
61
|
+
*
|
|
62
|
+
* services: {
|
|
63
|
+
* storage: s3({ buckets: ["uploads"] }),
|
|
64
|
+
* },
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* Tests get an instrumented S3 client at `ctx.svc.<key>.client`:
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* await ctx.svc.storage.client.write("hello.txt", "hi there");
|
|
71
|
+
* const body = await ctx.svc.storage.client.file("hello.txt").text();
|
|
72
|
+
* expect(body).toBe("hi there"); // nests under the s3 read step
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* The store skips SigV4 validation and is path-style only (what S3-compatible
|
|
76
|
+
* clients use), so it stands in for AWS S3, R2, Spaces, etc. without credential
|
|
77
|
+
* plumbing. If — and only if — the app under test hardcodes a specific prod S3
|
|
78
|
+
* endpoint, pass `hosts` to also serve the store there over a CA-trusted cert:
|
|
79
|
+
*
|
|
80
|
+
* ```ts
|
|
81
|
+
* // app hardcodes https://files.example.com — serve it there too:
|
|
82
|
+
* storage: s3({ hosts: ["files.example.com"] }),
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function s3(opts: S3Options = {}) {
|
|
86
|
+
const port = opts.port ?? 9090;
|
|
87
|
+
const buckets = opts.buckets ?? [];
|
|
88
|
+
const defaultBucket = opts.bucket ?? buckets[0];
|
|
89
|
+
const accessKeyId = opts.accessKeyId ?? "s3";
|
|
90
|
+
const secretAccessKey = opts.secretAccessKey ?? "s3";
|
|
91
|
+
const region = opts.region ?? "us-east-1";
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
image: { type: "registry", reference: S3_IMAGE },
|
|
95
|
+
ports: [port],
|
|
96
|
+
...(opts.env ? { env: opts.env } : {}),
|
|
97
|
+
...(opts.hosts
|
|
98
|
+
? { tls: opts.hosts.map((hostname) => ({ hostname, port })) }
|
|
99
|
+
: {}),
|
|
100
|
+
// GET / returns the ListBuckets XML with 200 once the store is up.
|
|
101
|
+
readyCheck: { type: "http" as const, port, path: "/", timeoutSecs: 60 },
|
|
102
|
+
helpers: ({ name }: { name: string }): S3Helpers => ({
|
|
103
|
+
client: new S3Client({
|
|
104
|
+
endpoint: `http://${name}:${port}`,
|
|
105
|
+
region,
|
|
106
|
+
accessKeyId,
|
|
107
|
+
secretAccessKey,
|
|
108
|
+
...(defaultBucket ? { bucket: defaultBucket } : {}),
|
|
109
|
+
// Path-style (Bun's default) — the store is path-style only.
|
|
110
|
+
}),
|
|
111
|
+
}),
|
|
112
|
+
...(buckets.length > 0
|
|
113
|
+
? {
|
|
114
|
+
setup: async ({ name }: { name: string }) => {
|
|
115
|
+
for (const bucket of buckets) {
|
|
116
|
+
// CreateBucket is a plain `PUT` on the bucket root; the store
|
|
117
|
+
// returns 200 (or 409 if it already exists — idempotent enough to
|
|
118
|
+
// ignore).
|
|
119
|
+
const res = await fetch(`http://${name}:${port}/${bucket}`, {
|
|
120
|
+
method: "PUT",
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok && res.status !== 409) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`s3: failed to create bucket ${bucket}: ${res.status}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
131
|
+
} satisfies ServiceDefinition<S3Helpers>;
|
|
132
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ export { field } from "./inspect.js";
|
|
|
42
42
|
// assertions stay tracked. See `sql.ts` / `redis.ts` / `s3.ts`.
|
|
43
43
|
export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
|
|
44
44
|
export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
|
|
45
|
-
export { S3Client, type S3ClientLike, type S3File, type
|
|
45
|
+
export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
|
|
46
46
|
|
|
47
47
|
export type {
|
|
48
48
|
Browser,
|
|
@@ -1350,6 +1350,8 @@ interface Matchers {
|
|
|
1350
1350
|
toBeFalsy(): void;
|
|
1351
1351
|
toBeGreaterThan(n: number): void;
|
|
1352
1352
|
toBeLessThan(n: number): void;
|
|
1353
|
+
toBeGreaterThanOrEqual(n: number): void;
|
|
1354
|
+
toBeLessThanOrEqual(n: number): void;
|
|
1353
1355
|
toContain(expected: unknown): void;
|
|
1354
1356
|
toMatch(re: RegExp): void;
|
|
1355
1357
|
toHaveLength(n: number): void;
|
|
@@ -1359,12 +1361,19 @@ export interface Expectation extends Matchers {
|
|
|
1359
1361
|
not: Matchers;
|
|
1360
1362
|
}
|
|
1361
1363
|
|
|
1362
|
-
export function expect(actual: Provenanced): Expectation {
|
|
1363
|
-
// The parameter is typed to the {@link Provenanced} family so a raw
|
|
1364
|
-
// (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
|
|
1365
|
-
// assertion that reaches the timeline this way carries a provenance link
|
|
1366
|
-
// to the op that produced it. Assert on a genuinely raw value with
|
|
1367
|
-
// `expectRaw(
|
|
1364
|
+
export function expect(actual: Provenanced, message?: string): Expectation {
|
|
1365
|
+
// The first parameter is typed to the {@link Provenanced} family so a raw
|
|
1366
|
+
// value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
|
|
1367
|
+
// every assertion that reaches the timeline this way carries a provenance link
|
|
1368
|
+
// back to the op that produced it. Assert on a genuinely raw value with
|
|
1369
|
+
// `expectRaw(value, message)` instead.
|
|
1370
|
+
//
|
|
1371
|
+
// `message` is an **optional** human label for the assertion. The UI already
|
|
1372
|
+
// renders the target, matcher, and expected value, so a message that just
|
|
1373
|
+
// restates them is noise — supply one *only* when the check's intent isn't
|
|
1374
|
+
// obvious from those alone (e.g.
|
|
1375
|
+
// `expect(res.status, "blocked once the rate limit trips").toBe(429)`). When
|
|
1376
|
+
// present it leads the assertion's summary, the same way `expectRaw`'s does.
|
|
1368
1377
|
//
|
|
1369
1378
|
// A `null`/`undefined` read off a wrapped op result reaches here untagged
|
|
1370
1379
|
// (a symbol can't ride on nullish). `adoptNullishTag` recovers the tag from
|
|
@@ -1372,20 +1381,22 @@ export function expect(actual: Provenanced): Expectation {
|
|
|
1372
1381
|
// `expect(dep.status.readyReplicas).toBeFalsy()` nests under its op just like
|
|
1373
1382
|
// a non-nullish read. Done once here (not in `buildMatchers`) so the `.not`
|
|
1374
1383
|
// re-pass reuses the same tagged holder instead of re-consuming the note.
|
|
1375
|
-
return buildMatchers(adoptNullishTag(actual), false);
|
|
1384
|
+
return buildMatchers(adoptNullishTag(actual), false, message);
|
|
1376
1385
|
}
|
|
1377
1386
|
|
|
1378
1387
|
/**
|
|
1379
1388
|
* Assert on a value with **no provenance** — a computed number, a raw
|
|
1380
1389
|
* WebSocket frame, anything that didn't flow from a recorded op. `message` is
|
|
1381
|
-
* required and reads as the natural follow-on to
|
|
1382
|
-
* `expectRaw("id matches the generated value"
|
|
1383
|
-
* assertion's label in the CLI/dashboard ("ASSERT <message>")
|
|
1384
|
-
* assertion has no op to nest under. Prefer `expect(...)` whenever
|
|
1385
|
-
* carries provenance — only reach for this when the type gate would
|
|
1386
|
-
* reject the value.
|
|
1390
|
+
* required (it's the second argument) and reads as the natural follow-on to
|
|
1391
|
+
* "assert …" (e.g. `expectRaw(id, "id matches the generated value")`); it
|
|
1392
|
+
* renders as the assertion's label in the CLI/dashboard ("ASSERT <message>")
|
|
1393
|
+
* since a raw assertion has no op to nest under. Prefer `expect(...)` whenever
|
|
1394
|
+
* the value carries provenance — only reach for this when the type gate would
|
|
1395
|
+
* (rightly) reject the value. (`expect`'s own `message` is optional; here it is
|
|
1396
|
+
* mandatory, since the label is the only human-meaningful summary a raw
|
|
1397
|
+
* assertion has.)
|
|
1387
1398
|
*/
|
|
1388
|
-
export function expectRaw(
|
|
1399
|
+
export function expectRaw(actual: unknown, message: string): Expectation {
|
|
1389
1400
|
// Force the raw form so no stray tag is read even if a wrapped value is
|
|
1390
1401
|
// passed: an `expectRaw` assertion is *deliberately* unlinked, rendered at
|
|
1391
1402
|
// top level under its own message rather than nested beneath an op.
|
|
@@ -1425,7 +1436,7 @@ function buildCore(
|
|
|
1425
1436
|
cond: boolean,
|
|
1426
1437
|
msg: string,
|
|
1427
1438
|
expectedFor?: unknown,
|
|
1428
|
-
opts?: { actual?: unknown
|
|
1439
|
+
opts?: { actual?: unknown },
|
|
1429
1440
|
): void => {
|
|
1430
1441
|
// A failed upstream transform short-circuits every matcher to a failure —
|
|
1431
1442
|
// there is no meaningful value to match, and negation can't rescue a value
|
|
@@ -1455,9 +1466,7 @@ function buildCore(
|
|
|
1455
1466
|
error: passed ? undefined : msg,
|
|
1456
1467
|
message,
|
|
1457
1468
|
sourceSeq: tag?.sourceSeq,
|
|
1458
|
-
path: tag
|
|
1459
|
-
? [...tag.path, ...(opts?.pathSuffix ? [opts.pathSuffix] : [])]
|
|
1460
|
-
: undefined,
|
|
1469
|
+
path: tag ? [...tag.path] : undefined,
|
|
1461
1470
|
});
|
|
1462
1471
|
if (!passed) throw new ExpectationError(msg);
|
|
1463
1472
|
};
|
|
@@ -1516,6 +1525,22 @@ function buildCore(
|
|
|
1516
1525
|
n,
|
|
1517
1526
|
);
|
|
1518
1527
|
},
|
|
1528
|
+
toBeGreaterThanOrEqual(n) {
|
|
1529
|
+
run(
|
|
1530
|
+
"toBeGreaterThanOrEqual",
|
|
1531
|
+
typeof actual === "number" && actual >= n,
|
|
1532
|
+
`expected ${fmt(actual)}${negated ? " not" : ""} to be >= ${n}`,
|
|
1533
|
+
n,
|
|
1534
|
+
);
|
|
1535
|
+
},
|
|
1536
|
+
toBeLessThanOrEqual(n) {
|
|
1537
|
+
run(
|
|
1538
|
+
"toBeLessThanOrEqual",
|
|
1539
|
+
typeof actual === "number" && actual <= n,
|
|
1540
|
+
`expected ${fmt(actual)}${negated ? " not" : ""} to be <= ${n}`,
|
|
1541
|
+
n,
|
|
1542
|
+
);
|
|
1543
|
+
},
|
|
1519
1544
|
toContain(expected) {
|
|
1520
1545
|
const exp = readRaw(expected);
|
|
1521
1546
|
let contained = false;
|
|
@@ -1560,13 +1585,19 @@ function buildCore(
|
|
|
1560
1585
|
typeof (actual as { length?: unknown }).length === "number"
|
|
1561
1586
|
? ((actual as { length: number }).length as number)
|
|
1562
1587
|
: undefined;
|
|
1588
|
+
// The provenance `path` stays the container's own path — `.length` is
|
|
1589
|
+
// the matcher's internal read, not a property access the author wrote,
|
|
1590
|
+
// so it doesn't belong in the path (recording it produced a redundant
|
|
1591
|
+
// "length toHaveLength N" in the UI, since the matcher name already says
|
|
1592
|
+
// "length"). `actual` is still the length number: that's what's worth
|
|
1593
|
+
// showing on failure, and the `toHaveLength` matcher disambiguates it.
|
|
1563
1594
|
run(
|
|
1564
1595
|
"toHaveLength",
|
|
1565
1596
|
len === n,
|
|
1566
1597
|
`expected ${fmt(actual)}${negated ? " not" : ""} to have length ${n}` +
|
|
1567
1598
|
(len === undefined ? " (value has no length)" : ` (got ${len})`),
|
|
1568
1599
|
n,
|
|
1569
|
-
{ actual: len
|
|
1600
|
+
{ actual: len },
|
|
1570
1601
|
);
|
|
1571
1602
|
},
|
|
1572
1603
|
get not(): Matchers {
|
package/src/inspect.ts
CHANGED
|
@@ -482,7 +482,7 @@ export interface Carrier<T> {
|
|
|
482
482
|
* tag, but `adoptNullishTag` recovers its provenance at runtime, so
|
|
483
483
|
* `expect(rows[0]?.text)` and `expect(dep.status.readyReplicas)` stay on
|
|
484
484
|
* `expect`. To assert on a value with no provenance (a computed number, a raw
|
|
485
|
-
* WebSocket frame), use `expectRaw(
|
|
485
|
+
* WebSocket frame), use `expectRaw(value, message)` instead.
|
|
486
486
|
*/
|
|
487
487
|
export type Provenanced = { unwrap(): unknown } | null | undefined;
|
|
488
488
|
|
package/src/recorder.ts
CHANGED
|
@@ -84,11 +84,13 @@ export interface AssertionEvent extends BaseEvent {
|
|
|
84
84
|
/** Failure message produced by the matcher (only when `passed` is false). */
|
|
85
85
|
error?: string;
|
|
86
86
|
/**
|
|
87
|
-
* Author-supplied label for a raw assertion
|
|
88
|
-
* `expectRaw(
|
|
89
|
-
* ("ASSERT <message>") and serves as
|
|
90
|
-
* assertion has no provenance `path`.
|
|
91
|
-
* `expect(
|
|
87
|
+
* Author-supplied label for the assertion. **Required** for a raw assertion
|
|
88
|
+
* (`expectRaw(value, message)`), where it renders as the summary
|
|
89
|
+
* ("ASSERT <message>") and serves as the diff-alignment key since a raw
|
|
90
|
+
* assertion has no provenance `path`. **Optional** for a provenance-linked
|
|
91
|
+
* `expect(value, message)`, where it leads the summary as a clarifying note
|
|
92
|
+
* alongside the rendered matcher/target. Absent when `expect(...)` was called
|
|
93
|
+
* without a message.
|
|
92
94
|
*/
|
|
93
95
|
message?: string;
|
|
94
96
|
/**
|
package/src/s3.ts
CHANGED
|
@@ -79,9 +79,9 @@ export interface S3ClientLike {
|
|
|
79
79
|
[m: string]: (...args: any[]) => any;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/** Options accepted by the {@link S3Client} constructor — Bun's
|
|
83
|
-
*
|
|
84
|
-
export interface
|
|
82
|
+
/** Options accepted by the {@link S3Client} constructor — Bun's `S3Options`,
|
|
83
|
+
* passed through to `Bun.S3Client` verbatim. */
|
|
84
|
+
export interface S3ClientOptions {
|
|
85
85
|
accessKeyId?: string;
|
|
86
86
|
secretAccessKey?: string;
|
|
87
87
|
sessionToken?: string;
|
|
@@ -92,12 +92,12 @@ export interface S3Options {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
interface BunGlobal {
|
|
95
|
-
S3Client: new (options?:
|
|
95
|
+
S3Client: new (options?: S3ClientOptions) => RawS3Client;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
interface S3Constructor {
|
|
99
|
-
new (options?:
|
|
100
|
-
(options?:
|
|
99
|
+
new (options?: S3ClientOptions): S3ClientLike;
|
|
100
|
+
(options?: S3ClientOptions): S3ClientLike;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
const MAX_PREVIEW = 512;
|
|
@@ -106,7 +106,7 @@ const MAX_PREVIEW = 512;
|
|
|
106
106
|
* Open an instrumented S3 client. Usable with or without `new`. Requires the
|
|
107
107
|
* Bun runtime — it runs inside the spectest daemon.
|
|
108
108
|
*/
|
|
109
|
-
export const S3Client = function S3Client(options?:
|
|
109
|
+
export const S3Client = function S3Client(options?: S3ClientOptions): S3ClientLike {
|
|
110
110
|
const bun = (globalThis as unknown as { Bun?: BunGlobal }).Bun;
|
|
111
111
|
if (!bun?.S3Client) {
|
|
112
112
|
throw new Error(
|