@specific.dev/spectest 0.129.0 → 0.131.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/README.md CHANGED
@@ -478,8 +478,11 @@ time, and ends when that step completes. Both `call(destination, { waitFor })`
478
478
  and `say(text, { waitFor })` poll application state, then wait for incoming
479
479
  silence. For `call`, polling starts after the call connects, allowing the greeting
480
480
  to finish within the call step. For `say`, polling starts after outgoing playback.
481
- Without `waitFor`, `call` ends when connected and `say` ends after outgoing playback. The quiet
482
- period (700 ms), poll interval (100 ms), and wait timeout (30 seconds) are
481
+ Without `waitFor`, `call` ends when connected and `say` ends after outgoing playback. The
482
+ wait has no timeout of its own: the test case's `timeoutMs` is the only ceiling,
483
+ and a bound on the reply belongs inside the predicate (for example a `ctx.poll`
484
+ with its own `timeoutMs`), where it appears in the timeline. The quiet
485
+ period (700 ms) and poll interval (100 ms) are
483
486
  internal defaults for now. `waitFor` may return a nonempty transcript string
484
487
  instead of `true`; after the silence wait, it appears as **Response** below **Caller** in an indented chat row beneath the waveform. Return `false` or `""` to keep waiting. Your test supplies the text
485
488
  from application state or a fake; an async callback can wait and then return it.
@@ -0,0 +1,33 @@
1
+ # Native S3 component runtime
2
+
3
+ GoFakeS3 is pinned by `go.mod`/`go.sum`. `s3.ts` embeds these assets into a constant
4
+ multi-stage Dockerfile, builds for the worker's native architecture, and ships
5
+ only the static binary and dependency licenses. The Go builder is pinned to a
6
+ multi-architecture digest. No project files or options enter the image build.
7
+
8
+ The entrypoint retains the component's port 9090, path-style addressing, no
9
+ automatic buckets, no signature/date validation, and permissive browser CORS.
10
+ The memory backend supports pagination and versioning. Full VM snapshots clone
11
+ its buckets, versions and pending multipart uploads; restarting the process
12
+ starts empty, matching S3Mock's default temporary store.
13
+
14
+ `metadata.patch` preserves Cache-Control, Content-Language and Expires on object
15
+ uploads, multipart initiation and copies. Upstream currently drops these headers
16
+ in `metadataHeaders`. The patch applies to the vendored, checksum-verified module;
17
+ a failed patch stops the build. Recheck it when changing the upstream pin.
18
+
19
+ From `sdk`, run the real-image contract on a Linux Docker host:
20
+
21
+ ```
22
+ SPECTEST_TEST_S3_DOCKER=1 bun test src/components/s3-docker.test.ts
23
+ SPECTEST_TEST_S3_DOCKER=1 SPECTEST_TEST_S3_BASELINE=1 bun test src/components/s3-docker.test.ts
24
+ bun test src/components/s3.test.ts src/components/internal-fetch.test.ts
25
+ bun run build
26
+ ```
27
+
28
+ The baseline uses S3Mock 5.1.0. The contract covers helpers and recording, setup,
29
+ credentials, HTTPS with preserved Host, browser CORS, unsigned/stale signatures,
30
+ metadata, copy/range requests, multipart, paginated listing, versioning, and
31
+ fresh-process lifecycle. It does not prove every S3 API is interchangeable:
32
+ GoFakeS3 lacks some S3Mock features, including tagging, lifecycle and retention.
33
+ Java/Spring/S3Mock-specific environment settings are no longer interpreted.
@@ -0,0 +1,11 @@
1
+ module spectest-s3
2
+
3
+ go 1.24
4
+
5
+ require github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153
6
+
7
+ require (
8
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
9
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
10
+ golang.org/x/tools v0.8.0 // indirect
11
+ )
@@ -0,0 +1,54 @@
1
+ github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
2
+ github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
3
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
4
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
5
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
6
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
7
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI=
8
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk=
9
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
10
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
11
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
12
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
13
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
14
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
15
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
16
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
17
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
18
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
19
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
20
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
21
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
22
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
23
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
24
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
25
+ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
26
+ github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
27
+ github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
28
+ github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
29
+ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
30
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
31
+ github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153 h1:ZaLBZJfUnKBlrPu0AX0T/i1N9BNU54j8sl9mCLzmpI0=
32
+ github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM=
33
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
34
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
35
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
36
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
37
+ github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
38
+ github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
39
+ github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
40
+ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
41
+ go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
42
+ go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
43
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
44
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
45
+ golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
46
+ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
47
+ golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
48
+ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
49
+ golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
50
+ golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
51
+ gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
52
+ gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
53
+ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
54
+ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -0,0 +1,77 @@
1
+ // The component's native entrypoint. Keep its defaults compatible with S3Mock;
2
+ // the upstream CLI enforces clock skew and has different browser CORS defaults.
3
+ package main
4
+
5
+ import (
6
+ "log"
7
+ "net/http"
8
+
9
+ "github.com/johannesboyne/gofakes3"
10
+ "github.com/johannesboyne/gofakes3/backend/s3mem"
11
+ )
12
+
13
+ func main() {
14
+ // VM snapshots include RAM: buckets, versions and in-flight multipart state
15
+ // fork together. A fresh process starts empty, as S3Mock did by default.
16
+ // Unlike the Bolt backend, s3mem also supports bucket versioning.
17
+ backend := s3mem.New()
18
+ server := gofakes3.New(backend,
19
+ gofakes3.WithTimeSkewLimit(0), // S3Mock accepts arbitrary/expired signatures.
20
+ gofakes3.WithHostBucket(false),
21
+ gofakes3.WithAutoBucket(false),
22
+ gofakes3.WithLogger(gofakes3.GlobalLog()),
23
+ ).Server()
24
+ log.Fatal(http.ListenAndServe(":9090", cors(server)))
25
+ }
26
+
27
+ // Preserve S3Mock's permissive browser uploads, including arbitrary metadata
28
+ // headers. GoFakeS3's default allow-list rejects custom x-amz-meta-* headers and
29
+ // only exposes ETag on preflights, hiding it from actual browser responses.
30
+ func cors(next http.Handler) http.Handler {
31
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32
+ origin := r.Header.Get("Origin")
33
+ w.Header().Add("Vary", "Origin")
34
+ w.Header().Add("Vary", "Access-Control-Request-Method")
35
+ w.Header().Add("Vary", "Access-Control-Request-Headers")
36
+ if origin != "" && r.Method == "OPTIONS" && r.Header.Get("Access-Control-Request-Method") != "" {
37
+ w.Header().Set("Access-Control-Allow-Origin", origin)
38
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
39
+ w.Header().Set("Access-Control-Allow-Methods", r.Header.Get("Access-Control-Request-Method"))
40
+ w.Header().Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers"))
41
+ w.WriteHeader(http.StatusOK)
42
+ return
43
+ }
44
+ writer := &corsWriter{ResponseWriter: w, hasOrigin: origin != ""}
45
+ next.ServeHTTP(writer, r)
46
+ if !writer.written {
47
+ writer.WriteHeader(http.StatusOK)
48
+ }
49
+ })
50
+ }
51
+
52
+ type corsWriter struct {
53
+ http.ResponseWriter
54
+ hasOrigin bool
55
+ written bool
56
+ }
57
+
58
+ func (w *corsWriter) WriteHeader(status int) {
59
+ if w.written {
60
+ return
61
+ }
62
+ w.written = true
63
+ if w.hasOrigin {
64
+ w.Header().Set("Access-Control-Allow-Origin", "*")
65
+ w.Header().Set("Access-Control-Expose-Headers", "*")
66
+ } else {
67
+ w.Header().Del("Access-Control-Allow-Origin")
68
+ }
69
+ w.ResponseWriter.WriteHeader(status)
70
+ }
71
+
72
+ func (w *corsWriter) Write(body []byte) (int, error) {
73
+ if !w.written {
74
+ w.WriteHeader(http.StatusOK)
75
+ }
76
+ return w.ResponseWriter.Write(body)
77
+ }
@@ -0,0 +1,12 @@
1
+ --- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
2
+ +++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
3
+ @@ -1191,6 +1191,9 @@
4
+ for hk, hv := range headers {
5
+ if strings.HasPrefix(hk, "X-Amz-") ||
6
+ hk == "Content-Type" ||
7
+ + hk == "Cache-Control" ||
8
+ + hk == "Content-Language" ||
9
+ + hk == "Expires" ||
10
+ hk == "Content-Disposition" ||
11
+ hk == "Content-Encoding" {
12
+ meta[hk] = hv[0]
@@ -1,8 +1,7 @@
1
1
  import { type S3ClientLike } from "../s3.js";
2
2
  export interface S3Options {
3
3
  /**
4
- * Buckets to create on first boot. The backing server's own `initialBuckets`
5
- * env is unreliable across versions, so the component creates them in `setup`
4
+ * Buckets to create on first boot. The component creates them in `setup`
6
5
  * (a plain CreateBucket `PUT`) — captured into the warm snapshot, never
7
6
  * re-run on warm starts/forks.
8
7
  */
@@ -42,7 +41,7 @@ export interface S3Helpers {
42
41
  }
43
42
  /**
44
43
  * A ready-to-use, S3-compatible object store (backed by
45
- * [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
44
+ * [GoFakeS3](https://github.com/johannesboyne/gofakes3)), with an instrumented S3
46
45
  * client on `ctx.svc.<key>.client`. Drop into `environment.services`:
47
46
  *
48
47
  * ```ts
@@ -90,8 +89,8 @@ export declare function s3(opts?: S3Options): {
90
89
  }[] | undefined;
91
90
  env?: Record<string, string> | undefined;
92
91
  image: {
93
- type: "registry";
94
- reference: string;
92
+ type: "dockerfile";
93
+ content: string;
95
94
  };
96
95
  ports: number[];
97
96
  };
@@ -1,21 +1,31 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { rawFetch } from "../harness/raw-fetch.js";
2
3
  import { S3Client } from "../s3.js";
3
- // Backing image, pinned internally (not user-configurable). adobe/s3mock 5.1.0
4
- // is the current release; avoid exactly 5.0.0, which crashed at startup on
5
- // Java 25 (`NoClassDefFoundError: KotlinBuiltIns$2`) — fixed in 5.1.0.
6
- const S3_IMAGE = "adobe/s3mock:5.1.0";
7
- // Port the store listens on, fixed like the image and deliberately NOT an
8
- // option. The backing server's HTTP port is baked into the image, and nothing
9
- // the component returns can move it — so a `port` option could only relabel
10
- // the ready check, the declared ports and the client endpoint while the
11
- // container kept listening here. That is exactly what it did until 0.78.0: a
12
- // project that passed one got 60 s of refused probes and an environment that
13
- // never booted, with a healthy container beside it. A service reaches the
14
- // store at `http://<key>:9090`.
4
+ // Build once per architecture/cache from pinned source: upstream's Docker Hub
5
+ // image is amd64-only. Only the static native server ships in the runtime image.
6
+ // Keep the Dockerfile independent of options so stores share the build cache.
7
+ const S3_DOCKERFILE = `
8
+ FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
9
+ RUN apk add --no-cache patch
10
+ WORKDIR /src
11
+ ${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
12
+ const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
13
+ return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
14
+ }).join("\n")}
15
+ RUN go mod vendor && patch -p1 < metadata.patch
16
+ RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
17
+ FROM scratch
18
+ COPY --from=build /s3-server /s3-server
19
+ COPY --from=build /src/vendor/github.com/johannesboyne/gofakes3/LICENSE /licenses/gofakes3
20
+ COPY --from=build /src/vendor/github.com/ryszard/goskiplist/LICENSE /licenses/goskiplist
21
+ EXPOSE 9090
22
+ ENTRYPOINT ["/s3-server"]
23
+ `;
24
+ // Public endpoint stays fixed across backing-server changes.
15
25
  const S3_PORT = 9090;
16
26
  /**
17
27
  * A ready-to-use, S3-compatible object store (backed by
18
- * [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
28
+ * [GoFakeS3](https://github.com/johannesboyne/gofakes3)), with an instrumented S3
19
29
  * client on `ctx.svc.<key>.client`. Drop into `environment.services`:
20
30
  *
21
31
  * ```ts
@@ -51,7 +61,7 @@ export function s3(opts = {}) {
51
61
  const secretAccessKey = opts.secretAccessKey ?? "s3";
52
62
  const region = opts.region ?? "us-east-1";
53
63
  return {
54
- image: { type: "registry", reference: S3_IMAGE },
64
+ image: { type: "dockerfile", content: S3_DOCKERFILE },
55
65
  ports: [S3_PORT],
56
66
  ...(opts.env ? { env: opts.env } : {}),
57
67
  ...(opts.hosts
@@ -271,12 +271,14 @@ def main():
271
271
  else:
272
272
  time.sleep(0.01)
273
273
 
274
- def wait(predicate, timeout, live=False):
275
- deadline = time.monotonic() + timeout
274
+ def wait(predicate, timeout=None, live=False):
275
+ # No timeout: run until satisfied, the call ends, or the SDK closes
276
+ # stdin (which happens at case timeout and cleanup).
277
+ deadline = None if timeout is None else time.monotonic() + timeout
276
278
  while not predicate():
277
279
  if live and call.disconnected:
278
280
  raise ValueError(f"SIP call ended ({call.status})")
279
- if time.monotonic() >= deadline:
281
+ if deadline is not None and time.monotonic() >= deadline:
280
282
  raise ValueError("SIP operation timed out")
281
283
  pump()
282
284
 
@@ -374,14 +376,14 @@ def main():
374
376
  if not call or call.disconnected or call.media is None:
375
377
  raise ValueError("No connected call; cannot wait for silence")
376
378
  silence = float(data["silenceMs"]) / 1000
377
- timeout = float(data["timeoutMs"]) / 1000
378
- if not math.isfinite(silence) or not math.isfinite(timeout) or silence <= 0 or timeout <= 0:
379
- raise ValueError("Silence duration and timeout must be positive")
379
+ if not math.isfinite(silence) or silence <= 0:
380
+ raise ValueError("Silence duration must be positive")
380
381
  # A fresh quiet window starts AFTER the application predicate
381
382
  # succeeds; quiet heard before that does not satisfy this wait.
383
+ # The wait is bounded only by the test case's own timeout.
382
384
  began = time.monotonic()
383
385
  wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
384
- timeout, live=True)
386
+ live=True)
385
387
  result["offset"] = call.recorder.offset
386
388
  elif action == "position":
387
389
  if not call:
package/dist/phone.js CHANGED
@@ -116,7 +116,9 @@ class PhoneSession {
116
116
  return Promise.reject(new Error("Phone is closed"));
117
117
  const id = ++this.nextId;
118
118
  return new Promise((resolve, reject) => {
119
- const timer = setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout);
119
+ const timer = Number.isFinite(timeout)
120
+ ? setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout)
121
+ : undefined;
120
122
  this.pending.set(id, { resolve, reject, timer, position });
121
123
  this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
122
124
  });
@@ -128,15 +130,15 @@ class PhoneSession {
128
130
  await this.request("open", { ...this.options, directory: this.directory }, 60_000);
129
131
  return this;
130
132
  }
133
+ // The predicate and the silence wait have no timeout of their own: a bound on
134
+ // the reply belongs inside the predicate (a poll with its own timeout), and the
135
+ // case timeout closes the phone, which aborts this wait.
131
136
  async waitForResponse(options) {
132
- const timeout = 30_000;
133
- const deadline = performance.now() + timeout;
134
137
  const controller = new AbortController();
135
138
  const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
136
139
  this.cancellation.signal.addEventListener("abort", cancel, { once: true });
137
140
  if (this.closed)
138
141
  cancel();
139
- const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
140
142
  const aborted = new Promise((_, reject) => {
141
143
  const rejectAbort = () => reject(controller.signal.reason);
142
144
  controller.signal.addEventListener("abort", rejectAbort, { once: true });
@@ -163,13 +165,11 @@ class PhoneSession {
163
165
  }
164
166
  }
165
167
  controller.signal.throwIfAborted();
166
- const remaining = Math.max(1, deadline - performance.now());
167
- const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
168
+ const reply = await this.request("wait_silence", { silenceMs: 700 }, Infinity);
168
169
  return { ...reply, response };
169
170
  })]);
170
171
  }
171
172
  finally {
172
- clearTimeout(timer);
173
173
  this.cancellation.signal.removeEventListener("abort", cancel);
174
174
  controller.abort();
175
175
  branch.finish(false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.129.0",
3
+ "version": "0.131.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,159 @@
1
+ // Linux opt-in: creates and removes its own containers/network/image.
2
+ // SPECTEST_TEST_S3_DOCKER=1 bun test src/components/s3-docker.test.ts
3
+ // Add SPECTEST_TEST_S3_BASELINE=1 to run the same contract against S3Mock 5.1.0.
4
+ import { expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { s3 } from "./s3.js";
9
+ import { runContainerArgs } from "../harness/container-run.js";
10
+ import { startRecording, stopRecording } from "../recorder.js";
11
+
12
+ async function run(args: string[]) {
13
+ const child = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
14
+ const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
15
+ return { exit, stdout, stderr };
16
+ }
17
+ async function checked(args: string[]) {
18
+ const result = await run(args);
19
+ if (result.exit) throw new Error(`${args.join(" ")}: ${result.stderr}\n${result.stdout}`);
20
+ return result.stdout.trim();
21
+ }
22
+ async function ready(endpoint: string) {
23
+ const start = performance.now();
24
+ while (performance.now() - start < 60_000) {
25
+ try { if ((await fetch(endpoint, { signal: AbortSignal.timeout(500) })).ok) return performance.now() - start; } catch {}
26
+ await Bun.sleep(20);
27
+ }
28
+ throw new Error(`S3 not ready: ${endpoint}`);
29
+ }
30
+
31
+ const enabled = process.env.SPECTEST_TEST_S3_DOCKER === "1";
32
+ const baseline = process.env.SPECTEST_TEST_S3_BASELINE === "1";
33
+ test.skipIf(!enabled)("S3 preserves the public component and object-storage contract", async () => {
34
+ const root = mkdtempSync(join(tmpdir(), "spectest-s3-"));
35
+ const name = `spectest-s3-${process.pid}-${Date.now()}`;
36
+ const tag = baseline ? "adobe/s3mock:5.1.0" : name;
37
+ const definition = s3({ buckets: ["uploads", "assets"], hosts: ["files.test"], env: { CUSTOM_S3_ENV: "forwarded" } });
38
+ let proxy: ReturnType<typeof Bun.serve> | undefined;
39
+ try {
40
+ await checked(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost", "-keyout", `${root}/key.pem`, "-out", `${root}/ca.pem`]);
41
+ if (baseline) await checked(["docker", "pull", tag]);
42
+ else {
43
+ writeFileSync(`${root}/Dockerfile`, definition.image.content);
44
+ await checked(["docker", "build", "-t", tag, root]);
45
+ }
46
+ await checked(["docker", "network", "create", name]);
47
+ // Exercise the real harness command, including its CA mounts, on scratch.
48
+ await checked(["docker", ...runContainerArgs({ svc: { ...definition, name }, tag, network: name, hostCacheName: "spectest-host", caPath: `${root}/ca.pem`, caBundle: `${root}/ca.pem` })]);
49
+ const ip = await checked(["docker", "inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", name]);
50
+ const endpoint = `http://${ip}:9090`;
51
+ console.log(`${baseline ? "S3Mock" : "GoFakeS3"} ready after ${Math.round(await ready(endpoint))} ms of probing`);
52
+ const info = JSON.parse(await checked(["docker", "inspect", name]))[0];
53
+ expect(info.Config.Env).toContain("CUSTOM_S3_ENV=forwarded");
54
+ // No implicit bucket, even when the helper has a default bucket configured.
55
+ expect(await (await fetch(endpoint)).text()).not.toContain("<Bucket>");
56
+ expect((await fetch(`${endpoint}/missing/key`, { method: "PUT", body: "x" })).status).toBe(404);
57
+ await definition.setup!({ name: ip });
58
+ await definition.setup!({ name: ip }); // idempotent CreateBucket
59
+
60
+ const client = definition.helpers({ name: ip }).client;
61
+ startRecording();
62
+ await client.write("hello.txt", "hello world", { type: "text/plain" });
63
+ expect((await client.file("hello.txt").text()).unwrap()).toBe("hello world");
64
+ const events = stopRecording();
65
+ expect(events.filter(event => event.kind === "s3").map(event => event.op)).toEqual(["write", "read"]);
66
+ expect(events.some(event => event.error)).toBe(false);
67
+ expect((await client.stat("hello.txt")).unwrap().size).toBe(11);
68
+ const other = s3({ bucket: "assets", accessKeyId: "custom", secretAccessKey: "secret", region: "eu-west-1" }).helpers({ name: ip }).client;
69
+ await other.write("custom.txt", "custom credentials");
70
+ expect((await other.file("custom.txt").text()).unwrap()).toBe("custom credentials");
71
+ await client.write("override.txt", "per-call bucket", { bucket: "assets" });
72
+ expect((await other.file("override.txt").text()).unwrap()).toBe("per-call bucket");
73
+
74
+ // Host-preserving TLS termination: the store must remain path-style.
75
+ proxy = Bun.serve({ hostname: "127.0.0.1", port: 0, tls: { key: Bun.file(`${root}/key.pem`), cert: Bun.file(`${root}/ca.pem`) }, async fetch(request) {
76
+ const url = new URL(request.url);
77
+ return fetch(endpoint + url.pathname + url.search, { method: request.method, headers: request.headers, body: request.body, redirect: "manual" });
78
+ } });
79
+ for (const tls of [false, true]) {
80
+ const transport = (url: string) => tls ? url.replace(endpoint, `https://localhost:${proxy!.port}`) : url;
81
+ const options = { headers: { Host: "files.test", Origin: "https://app.test" }, ...(tls ? { tls: { ca: await Bun.file(`${root}/ca.pem`).text() } } : {}) };
82
+ const put = await fetch(transport(client.presign("presigned.txt", { method: "PUT" })), { ...options, method: "PUT", body: "presigned content" });
83
+ expect(put.status).toBe(200);
84
+ expect(put.headers.get("access-control-expose-headers")).toBe("*");
85
+ expect(await (await fetch(transport(client.presign("presigned.txt")), options)).text()).toBe("presigned content");
86
+ const preflight = await fetch(transport(`${endpoint}/uploads/presigned.txt`), { ...options, method: "OPTIONS", headers: { ...options.headers, "Access-Control-Request-Method": "PUT", "Access-Control-Request-Headers": "content-type,x-amz-meta-custom" } });
87
+ expect(preflight.status).toBe(200);
88
+ expect(preflight.headers.get("access-control-allow-origin")).toBe("https://app.test");
89
+ expect(preflight.headers.get("access-control-allow-headers")).toContain("x-amz-meta-custom");
90
+ expect(preflight.headers.get("access-control-allow-credentials")).toBe("true");
91
+ }
92
+ // Arbitrary signatures/dates must not turn into auth failures after restore.
93
+ const stale = await fetch(`${endpoint}/uploads/hello.txt`, { headers: { "x-amz-date": "20000101T000000Z", Authorization: "AWS4-HMAC-SHA256 invalid" } });
94
+ expect(stale.status).toBe(200);
95
+ const range = await fetch(`${endpoint}/uploads/hello.txt`, { headers: { Range: "bytes=1-4" } });
96
+ expect(range.status).toBe(206);
97
+ expect(await range.text()).toBe("ello");
98
+ const metadata = { "Content-Type": "text/plain", "Content-Disposition": "attachment; filename=hello.txt", "Cache-Control": "max-age=600", "Content-Language": "en", "Expires": "Wed, 28 Oct 2026 00:00:00 GMT", "x-amz-meta-custom": "retained" };
99
+ expect((await fetch(`${endpoint}/uploads/metadata.txt`, { method: "PUT", headers: metadata, body: "metadata" })).status).toBe(200);
100
+ const head = await fetch(`${endpoint}/uploads/metadata.txt`, { method: "HEAD" });
101
+ for (const [key, value] of Object.entries(metadata)) expect(head.headers.get(key)).toBe(value);
102
+ expect((await fetch(`${endpoint}/uploads/copied.txt`, { method: "PUT", headers: { "x-amz-copy-source": "/uploads/metadata.txt" } })).status).toBe(200);
103
+ expect((await client.file("copied.txt").text()).unwrap()).toBe("metadata");
104
+
105
+ // Exercise both Bun's multipart writer and explicit multi-part S3 requests.
106
+ const bytes = new Uint8Array(12 * 1024 * 1024).fill(65);
107
+ await client.write("large.bin", bytes);
108
+ expect((await client.file("large.bin").arrayBuffer()).unwrap()).toEqual(bytes.buffer);
109
+ const start = await fetch(`${endpoint}/uploads/manual.bin?uploads`, { method: "POST" });
110
+ expect(start.status).toBe(200);
111
+ const id = (await start.text()).match(/<UploadId>([^<]+)/)![1]!;
112
+ const etags: string[] = [];
113
+ for (const [i, size] of [[1, 5 * 1024 * 1024], [2, 1234]]) {
114
+ const part = await fetch(`${endpoint}/uploads/manual.bin?partNumber=${i}&uploadId=${encodeURIComponent(id)}`, { method: "PUT", body: new Uint8Array(size).fill(i) });
115
+ expect(part.status).toBe(200);
116
+ etags.push(part.headers.get("etag")!);
117
+ }
118
+ const complete = await fetch(`${endpoint}/uploads/manual.bin?uploadId=${encodeURIComponent(id)}`, { method: "POST", body: `<CompleteMultipartUpload>${etags.map((etag, i) => `<Part><PartNumber>${i + 1}</PartNumber><ETag>${etag}</ETag></Part>`).join("")}</CompleteMultipartUpload>` });
119
+ expect(complete.status).toBe(200);
120
+ expect((await client.stat("manual.bin")).unwrap().size).toBe(5 * 1024 * 1024 + 1234);
121
+ await client.write("pages/one", "one");
122
+ await client.write("pages/two", "two");
123
+ const page = (await client.list({ prefix: "pages/", maxKeys: 1 })).unwrap();
124
+ expect(page.contents?.map(item => item.key)).toEqual(["pages/one"]);
125
+ expect(page.isTruncated).toBe(true);
126
+ const next = (await client.list({ prefix: "pages/", maxKeys: 1, continuationToken: page.nextContinuationToken })).unwrap();
127
+ expect(next.contents?.map(item => item.key)).toEqual(["pages/two"]);
128
+ await client.delete("pages/one");
129
+ expect((await client.exists("pages/one")).unwrap()).toBe(false);
130
+
131
+ // Versioning stays opt-in and old versions remain readable.
132
+ expect((await fetch(`${endpoint}/assets?versioning`, { method: "PUT", body: '<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>Enabled</Status></VersioningConfiguration>' })).status).toBe(200);
133
+ const first = await fetch(`${endpoint}/assets/versioned.txt`, { method: "PUT", body: "first" });
134
+ const version = first.headers.get("x-amz-version-id");
135
+ expect(version).toBeTruthy();
136
+ expect((await fetch(`${endpoint}/assets/versioned.txt`, { method: "PUT", body: "second" })).status).toBe(200);
137
+ expect(await (await fetch(`${endpoint}/assets/versioned.txt?versionId=${encodeURIComponent(version!)}`)).text()).toBe("first");
138
+ expect((await other.file("versioned.txt").text()).unwrap()).toBe("second");
139
+
140
+ await checked(["docker", "restart", name]);
141
+ await ready(endpoint);
142
+ // A process restart is different from a VM restore: the original S3Mock
143
+ // chose a fresh temporary store on every process start.
144
+ expect(await (await fetch(endpoint)).text()).not.toContain("<Bucket>");
145
+ await definition.setup!({ name: ip });
146
+ await client.write("after-restart.txt", "fresh store");
147
+ expect((await client.file("after-restart.txt").text()).unwrap()).toBe("fresh store");
148
+ } catch (error) {
149
+ console.error(await run(["docker", "logs", "--tail", "40", name]));
150
+ throw error;
151
+ } finally {
152
+ stopRecording();
153
+ proxy?.stop(true);
154
+ await run(["docker", "rm", "-f", "-v", name]);
155
+ await run(["docker", "network", "rm", name]);
156
+ if (!baseline) await run(["docker", "image", "rm", tag]);
157
+ rmSync(root, { recursive: true, force: true });
158
+ }
159
+ }, 600_000);
@@ -0,0 +1,33 @@
1
+ # Native S3 component runtime
2
+
3
+ GoFakeS3 is pinned by `go.mod`/`go.sum`. `s3.ts` embeds these assets into a constant
4
+ multi-stage Dockerfile, builds for the worker's native architecture, and ships
5
+ only the static binary and dependency licenses. The Go builder is pinned to a
6
+ multi-architecture digest. No project files or options enter the image build.
7
+
8
+ The entrypoint retains the component's port 9090, path-style addressing, no
9
+ automatic buckets, no signature/date validation, and permissive browser CORS.
10
+ The memory backend supports pagination and versioning. Full VM snapshots clone
11
+ its buckets, versions and pending multipart uploads; restarting the process
12
+ starts empty, matching S3Mock's default temporary store.
13
+
14
+ `metadata.patch` preserves Cache-Control, Content-Language and Expires on object
15
+ uploads, multipart initiation and copies. Upstream currently drops these headers
16
+ in `metadataHeaders`. The patch applies to the vendored, checksum-verified module;
17
+ a failed patch stops the build. Recheck it when changing the upstream pin.
18
+
19
+ From `sdk`, run the real-image contract on a Linux Docker host:
20
+
21
+ ```
22
+ SPECTEST_TEST_S3_DOCKER=1 bun test src/components/s3-docker.test.ts
23
+ SPECTEST_TEST_S3_DOCKER=1 SPECTEST_TEST_S3_BASELINE=1 bun test src/components/s3-docker.test.ts
24
+ bun test src/components/s3.test.ts src/components/internal-fetch.test.ts
25
+ bun run build
26
+ ```
27
+
28
+ The baseline uses S3Mock 5.1.0. The contract covers helpers and recording, setup,
29
+ credentials, HTTPS with preserved Host, browser CORS, unsigned/stale signatures,
30
+ metadata, copy/range requests, multipart, paginated listing, versioning, and
31
+ fresh-process lifecycle. It does not prove every S3 API is interchangeable:
32
+ GoFakeS3 lacks some S3Mock features, including tagging, lifecycle and retention.
33
+ Java/Spring/S3Mock-specific environment settings are no longer interpreted.
@@ -0,0 +1,11 @@
1
+ module spectest-s3
2
+
3
+ go 1.24
4
+
5
+ require github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153
6
+
7
+ require (
8
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
9
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
10
+ golang.org/x/tools v0.8.0 // indirect
11
+ )
@@ -0,0 +1,54 @@
1
+ github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
2
+ github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
3
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
4
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
5
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
6
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
7
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75 h1:S61/E3N01oral6B3y9hZ2E1iFDqCZPPOBoBQretCnBI=
8
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.75/go.mod h1:bDMQbkI1vJbNjnvJYpPTSNYBkI/VIv18ngWb/K84tkk=
9
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
10
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
11
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
12
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
13
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
14
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
15
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
16
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
17
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
18
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
19
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
20
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
21
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
22
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
23
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
24
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
25
+ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
26
+ github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
27
+ github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
28
+ github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
29
+ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
30
+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
31
+ github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153 h1:ZaLBZJfUnKBlrPu0AX0T/i1N9BNU54j8sl9mCLzmpI0=
32
+ github.com/johannesboyne/gofakes3 v1.2.1-0.20261011201219-0016ef2bf153/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM=
33
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
34
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
35
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
36
+ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
37
+ github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M=
38
+ github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
39
+ github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
40
+ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
41
+ go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
42
+ go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
43
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d h1:Ns9kd1Rwzw7t0BR8XMphenji4SmIoNZPn8zhYmaVKP8=
44
+ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3l++MlthCm+koNi0tcUCX3anayogF0Pa/sp24k=
45
+ golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
46
+ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
47
+ golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
48
+ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
49
+ golang.org/x/tools v0.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y=
50
+ golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
51
+ gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
52
+ gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
53
+ gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
54
+ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -0,0 +1,77 @@
1
+ // The component's native entrypoint. Keep its defaults compatible with S3Mock;
2
+ // the upstream CLI enforces clock skew and has different browser CORS defaults.
3
+ package main
4
+
5
+ import (
6
+ "log"
7
+ "net/http"
8
+
9
+ "github.com/johannesboyne/gofakes3"
10
+ "github.com/johannesboyne/gofakes3/backend/s3mem"
11
+ )
12
+
13
+ func main() {
14
+ // VM snapshots include RAM: buckets, versions and in-flight multipart state
15
+ // fork together. A fresh process starts empty, as S3Mock did by default.
16
+ // Unlike the Bolt backend, s3mem also supports bucket versioning.
17
+ backend := s3mem.New()
18
+ server := gofakes3.New(backend,
19
+ gofakes3.WithTimeSkewLimit(0), // S3Mock accepts arbitrary/expired signatures.
20
+ gofakes3.WithHostBucket(false),
21
+ gofakes3.WithAutoBucket(false),
22
+ gofakes3.WithLogger(gofakes3.GlobalLog()),
23
+ ).Server()
24
+ log.Fatal(http.ListenAndServe(":9090", cors(server)))
25
+ }
26
+
27
+ // Preserve S3Mock's permissive browser uploads, including arbitrary metadata
28
+ // headers. GoFakeS3's default allow-list rejects custom x-amz-meta-* headers and
29
+ // only exposes ETag on preflights, hiding it from actual browser responses.
30
+ func cors(next http.Handler) http.Handler {
31
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32
+ origin := r.Header.Get("Origin")
33
+ w.Header().Add("Vary", "Origin")
34
+ w.Header().Add("Vary", "Access-Control-Request-Method")
35
+ w.Header().Add("Vary", "Access-Control-Request-Headers")
36
+ if origin != "" && r.Method == "OPTIONS" && r.Header.Get("Access-Control-Request-Method") != "" {
37
+ w.Header().Set("Access-Control-Allow-Origin", origin)
38
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
39
+ w.Header().Set("Access-Control-Allow-Methods", r.Header.Get("Access-Control-Request-Method"))
40
+ w.Header().Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers"))
41
+ w.WriteHeader(http.StatusOK)
42
+ return
43
+ }
44
+ writer := &corsWriter{ResponseWriter: w, hasOrigin: origin != ""}
45
+ next.ServeHTTP(writer, r)
46
+ if !writer.written {
47
+ writer.WriteHeader(http.StatusOK)
48
+ }
49
+ })
50
+ }
51
+
52
+ type corsWriter struct {
53
+ http.ResponseWriter
54
+ hasOrigin bool
55
+ written bool
56
+ }
57
+
58
+ func (w *corsWriter) WriteHeader(status int) {
59
+ if w.written {
60
+ return
61
+ }
62
+ w.written = true
63
+ if w.hasOrigin {
64
+ w.Header().Set("Access-Control-Allow-Origin", "*")
65
+ w.Header().Set("Access-Control-Expose-Headers", "*")
66
+ } else {
67
+ w.Header().Del("Access-Control-Allow-Origin")
68
+ }
69
+ w.ResponseWriter.WriteHeader(status)
70
+ }
71
+
72
+ func (w *corsWriter) Write(body []byte) (int, error) {
73
+ if !w.written {
74
+ w.WriteHeader(http.StatusOK)
75
+ }
76
+ return w.ResponseWriter.Write(body)
77
+ }
@@ -0,0 +1,12 @@
1
+ --- a/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
2
+ +++ b/vendor/github.com/johannesboyne/gofakes3/gofakes3.go
3
+ @@ -1191,6 +1191,9 @@
4
+ for hk, hv := range headers {
5
+ if strings.HasPrefix(hk, "X-Amz-") ||
6
+ hk == "Content-Type" ||
7
+ + hk == "Cache-Control" ||
8
+ + hk == "Content-Language" ||
9
+ + hk == "Expires" ||
10
+ hk == "Content-Disposition" ||
11
+ hk == "Content-Encoding" {
12
+ meta[hk] = hv[0]
@@ -0,0 +1,36 @@
1
+ import { expect, test } from "bun:test";
2
+ import { s3 } from "./s3.js";
3
+
4
+ function presign(options: Parameters<typeof s3>[0], bucket?: string) {
5
+ return new URL(s3(options).helpers({ name: "storage" }).client.presign("hello.txt", bucket ? { bucket } : {}));
6
+ }
7
+
8
+ test("S3 keeps its endpoint, readiness, credential and region defaults", () => {
9
+ const service = s3();
10
+ expect(service.ports).toEqual([9090]);
11
+ expect(service.readyCheck).toEqual({ type: "http", port: 9090, path: "/", timeoutSecs: 60 });
12
+ expect(service.setup).toBeUndefined();
13
+ expect(service.env).toBeUndefined();
14
+ expect(service.tls).toBeUndefined();
15
+ const url = presign({}, "uploads");
16
+ expect(url.origin).toBe("http://storage:9090");
17
+ expect(url.pathname).toBe("/uploads/hello.txt");
18
+ expect(url.searchParams.get("X-Amz-Credential")).toMatch(/^s3\/\d+\/us-east-1\/s3\/aws4_request$/);
19
+ });
20
+
21
+ test("S3 keeps bucket precedence, caller credentials, region, env and TLS routes", () => {
22
+ const options = { buckets: ["uploads", "assets"], bucket: "assets", accessKeyId: "custom", secretAccessKey: "secret", region: "eu-west-1", hosts: ["files.test", "storage.test"], env: { CUSTOM: "value" } };
23
+ expect(presign({ buckets: options.buckets }).pathname).toBe("/uploads/hello.txt");
24
+ const url = presign(options);
25
+ expect(url.pathname).toBe("/assets/hello.txt");
26
+ expect(url.searchParams.get("X-Amz-Credential")).toMatch(/^custom\/\d+\/eu-west-1\/s3\/aws4_request$/);
27
+ expect(presign(options, "override").pathname).toBe("/override/hello.txt");
28
+ expect(presign({ ...options, secretAccessKey: "different" }).searchParams.get("X-Amz-Signature")).not.toBe(url.searchParams.get("X-Amz-Signature"));
29
+ expect(s3(options).env).toEqual(options.env);
30
+ expect(s3(options).tls).toEqual(options.hosts.map(hostname => ({ hostname, port: 9090 })));
31
+ // Selecting a helper default has never implicitly created that bucket.
32
+ expect(s3({ bucket: "not-created" }).setup).toBeUndefined();
33
+ expect(s3({ buckets: [] }).setup).toBeUndefined();
34
+ // Options must not create per-project copies of the same native image.
35
+ expect(s3(options).image).toEqual(s3().image);
36
+ });
@@ -1,11 +1,11 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { rawFetch } from "../harness/raw-fetch.js";
2
3
  import type { ServiceDefinition } from "../index.js";
3
4
  import { S3Client, type S3ClientLike } from "../s3.js";
4
5
 
5
6
  export interface S3Options {
6
7
  /**
7
- * Buckets to create on first boot. The backing server's own `initialBuckets`
8
- * env is unreliable across versions, so the component creates them in `setup`
8
+ * Buckets to create on first boot. The component creates them in `setup`
9
9
  * (a plain CreateBucket `PUT`) — captured into the warm snapshot, never
10
10
  * re-run on warm starts/forks.
11
11
  */
@@ -45,24 +45,33 @@ export interface S3Helpers {
45
45
  client: S3ClientLike;
46
46
  }
47
47
 
48
- // Backing image, pinned internally (not user-configurable). adobe/s3mock 5.1.0
49
- // is the current release; avoid exactly 5.0.0, which crashed at startup on
50
- // Java 25 (`NoClassDefFoundError: KotlinBuiltIns$2`) — fixed in 5.1.0.
51
- const S3_IMAGE = "adobe/s3mock:5.1.0";
48
+ // Build once per architecture/cache from pinned source: upstream's Docker Hub
49
+ // image is amd64-only. Only the static native server ships in the runtime image.
50
+ // Keep the Dockerfile independent of options so stores share the build cache.
51
+ const S3_DOCKERFILE = `
52
+ FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
53
+ RUN apk add --no-cache patch
54
+ WORKDIR /src
55
+ ${["go.mod", "go.sum", "main.go", "metadata.patch"].map((file) => {
56
+ const data = readFileSync(new URL(`./s3-runtime/${file}`, import.meta.url)).toString("base64");
57
+ return `RUN printf '%s' '${data}' | base64 -d > ${file}`;
58
+ }).join("\n")}
59
+ RUN go mod vendor && patch -p1 < metadata.patch
60
+ RUN CGO_ENABLED=0 go build -mod=vendor -trimpath -ldflags='-s -w' -o /s3-server .
61
+ FROM scratch
62
+ COPY --from=build /s3-server /s3-server
63
+ COPY --from=build /src/vendor/github.com/johannesboyne/gofakes3/LICENSE /licenses/gofakes3
64
+ COPY --from=build /src/vendor/github.com/ryszard/goskiplist/LICENSE /licenses/goskiplist
65
+ EXPOSE 9090
66
+ ENTRYPOINT ["/s3-server"]
67
+ `;
52
68
 
53
- // Port the store listens on, fixed like the image and deliberately NOT an
54
- // option. The backing server's HTTP port is baked into the image, and nothing
55
- // the component returns can move it — so a `port` option could only relabel
56
- // the ready check, the declared ports and the client endpoint while the
57
- // container kept listening here. That is exactly what it did until 0.78.0: a
58
- // project that passed one got 60 s of refused probes and an environment that
59
- // never booted, with a healthy container beside it. A service reaches the
60
- // store at `http://<key>:9090`.
69
+ // Public endpoint stays fixed across backing-server changes.
61
70
  const S3_PORT = 9090;
62
71
 
63
72
  /**
64
73
  * A ready-to-use, S3-compatible object store (backed by
65
- * [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
74
+ * [GoFakeS3](https://github.com/johannesboyne/gofakes3)), with an instrumented S3
66
75
  * client on `ctx.svc.<key>.client`. Drop into `environment.services`:
67
76
  *
68
77
  * ```ts
@@ -99,7 +108,7 @@ export function s3(opts: S3Options = {}) {
99
108
  const region = opts.region ?? "us-east-1";
100
109
 
101
110
  return {
102
- image: { type: "registry", reference: S3_IMAGE },
111
+ image: { type: "dockerfile", content: S3_DOCKERFILE },
103
112
  ports: [S3_PORT],
104
113
  ...(opts.env ? { env: opts.env } : {}),
105
114
  ...(opts.hosts
@@ -271,12 +271,14 @@ def main():
271
271
  else:
272
272
  time.sleep(0.01)
273
273
 
274
- def wait(predicate, timeout, live=False):
275
- deadline = time.monotonic() + timeout
274
+ def wait(predicate, timeout=None, live=False):
275
+ # No timeout: run until satisfied, the call ends, or the SDK closes
276
+ # stdin (which happens at case timeout and cleanup).
277
+ deadline = None if timeout is None else time.monotonic() + timeout
276
278
  while not predicate():
277
279
  if live and call.disconnected:
278
280
  raise ValueError(f"SIP call ended ({call.status})")
279
- if time.monotonic() >= deadline:
281
+ if deadline is not None and time.monotonic() >= deadline:
280
282
  raise ValueError("SIP operation timed out")
281
283
  pump()
282
284
 
@@ -374,14 +376,14 @@ def main():
374
376
  if not call or call.disconnected or call.media is None:
375
377
  raise ValueError("No connected call; cannot wait for silence")
376
378
  silence = float(data["silenceMs"]) / 1000
377
- timeout = float(data["timeoutMs"]) / 1000
378
- if not math.isfinite(silence) or not math.isfinite(timeout) or silence <= 0 or timeout <= 0:
379
- raise ValueError("Silence duration and timeout must be positive")
379
+ if not math.isfinite(silence) or silence <= 0:
380
+ raise ValueError("Silence duration must be positive")
380
381
  # A fresh quiet window starts AFTER the application predicate
381
382
  # succeeds; quiet heard before that does not satisfy this wait.
383
+ # The wait is bounded only by the test case's own timeout.
382
384
  began = time.monotonic()
383
385
  wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
384
- timeout, live=True)
386
+ live=True)
385
387
  result["offset"] = call.recorder.offset
386
388
  elif action == "position":
387
389
  if not call:
package/src/phone.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { afterAll, afterEach, beforeAll, expect, spyOn, test } from "bun:test";
1
+ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
2
2
  import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
@@ -55,10 +55,9 @@ createInterface({ input: process.stdin }).on("line", async line => {
55
55
  }
56
56
  }
57
57
  if (data.action === "wait_silence") {
58
- await Bun.sleep(Math.min(data.silenceMs, data.timeoutMs));
59
- result = data.silenceMs > data.timeoutMs
60
- ? {error:"SIP operation timed out"}
61
- : {offset:offset()};
58
+ if (data.timeoutMs !== undefined) throw new Error("wait_silence must not carry a timeout");
59
+ await Bun.sleep(data.silenceMs);
60
+ result = {offset:offset()};
62
61
  }
63
62
  if (data.action === "position") result = {offset:offset()};
64
63
  console.log(JSON.stringify({ id: data.id, ...result }));
@@ -363,17 +362,17 @@ test("waits for the predicate then a fresh silence window inside one serialized
363
362
  expect(recording!.duration - b.end).toBeGreaterThanOrEqual(.04);
364
363
  });
365
364
 
366
- test("predicate errors and hung predicates fail the say step without waiting for silence", async () => {
365
+ test("predicate errors fail the say step without waiting for silence, and a hung predicate waits until the phone closes", async () => {
367
366
  startRecording();
368
367
  const phone = await scope.open(options);
369
368
  await phone.call("123");
370
369
  await expect(phone.say("Throw", { waitFor: () => { throw new Error("Application failed"); } })).rejects.toThrow("Application failed");
371
- const nativeTimeout = globalThis.setTimeout;
372
- const timer = spyOn(globalThis, "setTimeout").mockImplementation(((fn: any, ms: number, ...args: any[]) =>
373
- nativeTimeout(fn, ms === 30_000 ? 40 : ms, ...args)) as typeof setTimeout);
374
- try {
375
- await expect(phone.say("Hang", { waitFor: () => new Promise(() => {}) })).rejects.toThrow("response wait timed out");
376
- } finally { timer.mockRestore(); }
370
+ // No response-wait timeout of its own: only closing the phone (case timeout
371
+ // or cleanup) ends a predicate that never returns.
372
+ const hung = phone.say("Hang", { waitFor: () => new Promise(() => {}) }).catch(error => error);
373
+ await Bun.sleep(200);
374
+ await phone.close();
375
+ expect(String(await hung)).toContain("closed");
377
376
  await scope.finish();
378
377
  const events = stopRecording() as any[];
379
378
  expect(events.slice(1).map(e => e.status)).toEqual(["failed", "failed"]);
package/src/phone.ts CHANGED
@@ -83,7 +83,7 @@ export async function releasePhoneRecording(id: string) {
83
83
  }
84
84
 
85
85
  class PhoneSession implements Phone {
86
- private pending = new Map<number, { resolve: (r: Reply) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout>; position?: (event: string, offset: number) => void }>();
86
+ private pending = new Map<number, { resolve: (r: Reply) => void; reject: (e: Error) => void; timer?: ReturnType<typeof setTimeout>; position?: (event: string, offset: number) => void }>();
87
87
  private nextId = 0;
88
88
  private closed = false;
89
89
  private failure?: Error;
@@ -138,7 +138,9 @@ class PhoneSession implements Phone {
138
138
  if (this.closed) return Promise.reject(new Error("Phone is closed"));
139
139
  const id = ++this.nextId;
140
140
  return new Promise((resolve, reject) => {
141
- const timer = setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout);
141
+ const timer = Number.isFinite(timeout)
142
+ ? setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout)
143
+ : undefined;
142
144
  this.pending.set(id, { resolve, reject, timer, position });
143
145
  this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
144
146
  });
@@ -149,14 +151,14 @@ class PhoneSession implements Phone {
149
151
  await this.request("open", { ...this.options, directory: this.directory }, 60_000);
150
152
  return this;
151
153
  }
154
+ // The predicate and the silence wait have no timeout of their own: a bound on
155
+ // the reply belongs inside the predicate (a poll with its own timeout), and the
156
+ // case timeout closes the phone, which aborts this wait.
152
157
  private async waitForResponse(options: CallOptions): Promise<Reply & { response?: string }> {
153
- const timeout = 30_000;
154
- const deadline = performance.now() + timeout;
155
158
  const controller = new AbortController();
156
159
  const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
157
160
  this.cancellation.signal.addEventListener("abort", cancel, { once: true });
158
161
  if (this.closed) cancel();
159
- const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
160
162
  const aborted = new Promise<never>((_, reject) => {
161
163
  const rejectAbort = () => reject(controller.signal.reason);
162
164
  controller.signal.addEventListener("abort", rejectAbort, { once: true });
@@ -181,12 +183,10 @@ class PhoneSession implements Phone {
181
183
  }
182
184
  }
183
185
  controller.signal.throwIfAborted();
184
- const remaining = Math.max(1, deadline - performance.now());
185
- const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
186
+ const reply = await this.request("wait_silence", { silenceMs: 700 }, Infinity);
186
187
  return { ...reply, response };
187
188
  })]);
188
189
  } finally {
189
- clearTimeout(timer);
190
190
  this.cancellation.signal.removeEventListener("abort", cancel);
191
191
  controller.abort();
192
192
  branch.finish(false);