@owlmeans/test-integration 0.1.18-rc.3 → 0.1.18-rc.30

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
@@ -4,7 +4,8 @@ Env-gated harness for integration tests of packages that talk to external servic
4
4
 
5
5
  Helpers exported here:
6
6
 
7
- - `mongoGate()`, `redisGate()`, `s3Gate()`, `kubeGate()` — read the env keys documented in `.env.example` and return `{ skip, reason?, env }`. A `tests/context.ts` uses these to decide whether to register the corresponding service in the real test context.
7
+ - `mongoGate()`, `redisGate()`, `postgresGate()`, `s3Gate()`, `kubeGate()`, `smtpGate()` — read the env keys documented in `.env.example` and return `{ skip, reason?, env }`. A `tests/context.ts` uses these to decide whether to register the corresponding service in the real test context.
8
+ - The three datastore gates (`mongoGate`, `redisGate`, `postgresGate`) also require a host in the connection string to accept a TCP connect. A set variable pointing at nothing — a port-forward that is not running — closes the gate with a printed reason naming the variable and `host:port`, instead of failing the suite on a refused connection. The probe is synchronous, so gates keep being read at module scope.
8
9
  - `randomNamespace(prefix)` — short random suffix to keep DB names, key prefixes, and S3 prefixes unique per test run so suites can safely run in parallel.
9
10
  - `registerCleanup(fn)` + `runCleanups()` — opt-in `afterAll` cleanup queue.
10
11
 
@@ -18,7 +19,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
18
19
  your project's skill store (`.agents/skills/`):
19
20
 
20
21
  ```sh
21
- npx @owlmeans/agent-skills
22
+ npx @owlmeans/agent-skills@^0.1.18-rc.42
22
23
  ```
23
24
 
24
25
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/test-integration",
4
- "version": "0.1.18-rc.0",
5
- "generatedAt": "2026-08-16T22:20:50.504Z",
4
+ "version": "0.1.18-rc.30",
5
+ "generatedAt": "2026-09-29T08:01:58.123Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,41 +1,108 @@
1
1
  ---
2
2
  name: testing-integration
3
- description: Category-C integration tests for OwlMeans Common packages that talk to external services (PostgreSQL, MongoDB, Redis, S3, Kubernetes). Env-gated, no mocks, per-suite namespaces, conditional context provisioning. Auto-invoked when writing tests in postgres*, mongo*, redis*, kluster, storage*, image-resource, server-api, server-app, queue.
3
+ description: Category-C integration tests for OwlMeans Common packages that talk to external services (PostgreSQL, MongoDB, Redis, S3, Kubernetes, SMTP). Env-gated, no mocks, per-suite namespaces, conditional context provisioning. Auto-invoked when writing tests in postgres*, mongo*, redis*, kluster, llm, storage-resource, mailer-smtp, server-mailer-mailgun, server-api, server-app.
4
4
  ---
5
5
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
6
6
 
7
7
  # Integration Tests — Category C
8
8
 
9
+ **Install:** `"@owlmeans/test-integration": "^0.1.18-rc.30"` in `devDependencies`
10
+
9
11
  Category C applies to packages that integrate with external services: `postgres`,
10
- `postgres-resource`, `mongo`, `mongo-resource`, `redis`, `redis-resource`, `kluster`,
11
- `storage-common`, `storage-resource`, `image-resource`, `server-api`, `server-app`, `queue`,
12
- `llm` (live inference providers). These tests **never** mock the external service. They run
13
- only when the corresponding env vars are set, and they self-skip cleanly when those variables
14
- are missing.
12
+ `postgres-resource`, `mongo`, `mongo-resource`, `redis`, `redis-resource`, `redis-queue`,
13
+ `kluster`, `storage-resource`, `mailer-smtp`, `server-mailer-mailgun`, `server-api`, `planning-postgres`,
14
+ `server-app`, `llm` (live inference providers). These tests **never** mock the external service.
15
+ They run only when the corresponding env vars are set, and they self-skip cleanly when those
16
+ variables are missing.
17
+
18
+ `queue`, `resource`, `mailer`, `storage-common` and `image-resource` stay in category A: types,
19
+ AJV schemas and error translation only, so their specs need nothing running and there is no gate
20
+ they could open. What decides the category is what a package's own code does at runtime, not what
21
+ its manifest lists — a `*-resource` package that issues real commands through a client the db
22
+ service hands it is category C even when it names no driver of its own.
15
23
 
16
24
  ## Helpers from `@owlmeans/test-integration`
17
25
 
18
26
  | Helper | Purpose |
19
27
  |---|---|
20
- | `postgresGate()`, `mongoGate()`, `redisGate()`, `s3Gate()`, `kubeGate()` | Read env, return `{ skip, reason?, env }`. |
21
- | `randomNamespace(prefix, len?)` | Schema / DB / key / object-prefix namespacing for parallel-safe runs. |
22
- | `registerCleanup(fn)` + `runCleanups()` | Process-global LIFO queue. **Single-spec-file packages only** — see below. |
28
+ | `postgresGate()`, `mongoGate()`, `redisGate()`, `s3Gate()`, `kubeGate()`, `smtpGate()` | Read env (and, for the three datastores, probe the address — below), return `IntegrationGate<E>` = `{ skip, reason?, env }`. |
29
+ | `IntegrationGate<E>` | `{ skip: boolean, reason?: string, env: Partial<E> }`. `env` holds only the variables that were actually populated. `skip` is a plain `boolean`, not a discriminant, so `env` stays `Partial<E>` on the open branch too — a suite reads a required variable through a cast: `gate.env.MONGO_URL as string`. |
30
+ | `PostgresEnv`, `MongoEnv`, `RedisEnv`, `S3Env`, `KubeEnv`, `SmtpEnv` | The variable set each gate reads, so `tests/context.ts` types its exported gate. |
31
+ | `randomNamespace(prefix, len?)` | Schema / DB / key / object-prefix namespacing for parallel-safe runs. Default 6 hex chars. |
32
+ | `registerCleanup(fn)` + `runCleanups()` | Process-global LIFO queue. Only where **one** spec file of the package provisions anything — see below. |
33
+
34
+ Each gate fails closed on its **required** variables and treats the rest as optional:
35
+
36
+ | Gate | Required | Optional |
37
+ |---|---|---|
38
+ | `mongoGate()` | `MONGO_URL` | `MONGO_TEST_DB_PREFIX` |
39
+ | `postgresGate()` | `POSTGRES_URL` | `POSTGRES_TEST_DB_PREFIX` |
40
+ | `redisGate()` | `REDIS_URL` | `REDIS_TEST_KEY_PREFIX` |
41
+ | `s3Gate()` | `S3_ENDPOINT`, `S3_KEY`, `S3_SECRET`, `S3_TEST_BUCKET` | `S3_REGION` |
42
+ | `kubeGate()` | `KUBE_CONFIG`, `KUBE_TEST_OK` | — |
43
+ | `smtpGate()` | `SMTP_HOST`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `SMTP_TEST_TO` | `SMTP_PORT`, `SMTP_SECURE` |
44
+
45
+ `SMTP_TEST_TO` is required rather than optional because an open SMTP gate sends **real mail** — the
46
+ suite must never guess a recipient.
47
+
48
+ ### A datastore gate also asks whether the server answers
49
+
50
+ `mongoGate()`, `redisGate()` and `postgresGate()` open only when their connection string is set
51
+ **and** one of the hosts in it accepts a TCP connect (default ports 27017 / 6379 / 5432). A local
52
+ `.env` keeps the port-forward address long after the forward is gone, and a gate that trusted the
53
+ variable alone left `beforeAll` to spend its whole budget on `ECONNREFUSED` and fail the suite. An
54
+ unreachable address therefore closes the gate like an empty variable does, and says so loudly — one
55
+ `console.warn` per address, naming the variable and `host:port` (never the URL, which carries the
56
+ credential), and the same text as `gate.reason`:
57
+
58
+ ```text
59
+ @owlmeans/test-integration: skipping — MONGO_URL is set, but nothing answers at 127.0.0.1:27017 (ECONNREFUSED) — start the service (or its port-forward) to run these specs
60
+ ```
23
61
 
24
- The driver libraries (`pg`, `mongodb`, `ioredis`, `@aws-sdk/client-s3`,
25
- `@kubernetes/client-node`) stay where they already live — in the consuming integration
26
- packages — and are imported directly in those packages' `tests/`.
62
+ The probe is synchronous (a child process running a bounded connect, 1.5 s per address), because
63
+ Bun decides `test` vs `test.skip` synchronously and every consumer reads its gate at module scope —
64
+ so no `tests/context.ts` changes to get it. It is TCP only: no driver and no login, so a wrong
65
+ password still opens the gate and fails in the suite, where it belongs. It answers "reachable" when
66
+ it cannot tell — an SRV (`mongodb+srv://`) or unix-socket address, a string it cannot parse, a probe
67
+ that cannot spawn — which is exactly the behaviour a gate had before it probed. `s3Gate()`,
68
+ `kubeGate()` and `smtpGate()` do not probe: they name remote services, not a local forward.
69
+
70
+ An optional variable is only in `env` when it was actually populated, which is why a suite reads a
71
+ defaulted one — `POSTGRES_TEST_DB_PREFIX ?? 'omt'` — straight from `process.env` instead: calling
72
+ any gate has already run `loadEnv`, so the `.env` values are in `process.env` by then.
73
+
74
+ **Six services, six gates — a category-C package outside that set writes its own.** There is no
75
+ gate for an LLM provider or for the Mailgun HTTP API, and none should be added to
76
+ `@owlmeans/test-integration` for a provider only one package talks to. Declare it in that
77
+ package's `tests/context.ts` with `makeGates` from `@owlmeans/test` instead, listing the variables
78
+ the provider needs; the result skips exactly the same way. `@owlmeans/llm` is the worked example.
79
+
80
+ Every driver stays where it already lives — in the package that integrates with the service, not
81
+ in a test package: `pg` (`@owlmeans/postgres`), `mongodb` (`@owlmeans/mongo`), `ioredis`
82
+ (`@owlmeans/redis`), `bullmq` (`@owlmeans/redis-queue`), `@aws-sdk/client-s3`
83
+ (`@owlmeans/storage-resource`), `@kubernetes/client-node` (`@owlmeans/kluster`) and `nodemailer`
84
+ (`@owlmeans/mailer-smtp`). A suite that must reach the service outside the package's own
85
+ service — to seed a fixture, or to assert on what really landed — imports that driver directly in
86
+ its `tests/`. `@owlmeans/server-mailer-mailgun` has no driver: it posts to the Mailgun HTTP API
87
+ with the global `fetch`, so a suite there imports nothing beyond the package under test and
88
+ asserts on the request it builds and the API's reply.
27
89
 
28
90
  ## Env contract
29
91
 
30
- `/.env.example` documents every required variable, grouped by service, with comments. The
31
- single `.env` at the monorepo root supplies real values for local runs. CI sets them via
32
- secrets. **Empty value = skip** — never a failure.
92
+ One `.env.example` at the workspace root documents every variable, grouped by service, with
93
+ comments; the `.env` beside it supplies real values for local runs and `loadEnv` (from
94
+ `@owlmeans/test`) picks it up. CI sets the same variables from secrets. **Empty value = skip, and
95
+ so is a datastore address nothing answers at** — never a failure. A developer's `.env` may keep the
96
+ port-forward address permanently: with the forward up the suites run, without it they skip.
33
97
 
34
- When a required variable is empty:
98
+ When a required variable is empty, or a datastore gate's server does not answer:
35
99
  1. The corresponding gate reports `skip: true`.
36
100
  2. The per-package `tests/context.ts` does **not** register the dependent service in the real context.
37
101
  3. Specs that touch that service self-skip with the reason printed.
38
102
 
103
+ A CI job that provisions the service beside its secrets must therefore check the service is up
104
+ before the tests, or read the run's skip count: an outage there skips rather than fails.
105
+
39
106
  **Never print a credential.** Pipe it straight from its source into the env var the gate
40
107
  reads — the value must never reach a log, a report, or a committed file:
41
108
 
@@ -45,26 +112,26 @@ POSTGRES_URL="$(<build it from the secret file, without echoing>)" bun test ./te
45
112
 
46
113
  ### Where the real credentials come from
47
114
 
48
- The dev-cluster secrets are the source of truth; `../viable/.env.dev.secrets` holds the same
49
- values under kebab-case keys (`mongo-secret`, `redis-secret`, `s3-storage`, …) for
50
- `kubectl create secret --from-env-file`. Read the cluster secret directly rather than transcribing:
115
+ Read each secret from wherever it actually lives — a cluster secret, a vault, the provider's own
116
+ console — and pipe it into the variable in one step. Never transcribe a credential into a file,
117
+ a fixture or this guidance.
51
118
 
52
119
  ```bash
53
- kubectl get secret mongo-owlmeans-mongo-main-db-admin-admin -o jsonpath='{.data.password}' | base64 -d
120
+ kubectl get secret <name> -o jsonpath='{.data.password}' | base64 -d
54
121
  ```
55
122
 
56
123
  Two things that will bite:
57
124
 
58
- - **URL-escape the password before putting it in a connection string.** These generated passwords
125
+ - **URL-escape the password before putting it in a connection string.** Generated passwords
59
126
  contain reserved characters; an unescaped one fails with `MongoParseError: Password contains
60
127
  unescaped characters`. Escape with `urllib.parse.quote(pw, safe='')`.
61
- - **The port-forward may already be running** — the slots share one cluster, so
62
- `bind: address already in use` on 27017/5432/6379 usually means another checkout forwards the
63
- same service. Reuse it; never kill another environment's process.
128
+ - **A port-forward may already be running.** `bind: address already in use` on 27017/5432/6379
129
+ usually means another checkout or another developer already forwards that service. Reuse the
130
+ forward; never kill a process you did not start.
64
131
 
65
132
  The suites namespace every database/schema (`MONGO_TEST_DB_PREFIX`, default `omt`) and drop it on
66
- completion, so they are safe against the shared cluster. Confirm nothing was left behind after a
67
- run that aborted mid-way.
133
+ completion, so they are safe against an instance shared with anything else. Confirm nothing was
134
+ left behind after a run that aborted mid-way.
68
135
 
69
136
  ## Where the specs live
70
137
 
@@ -73,19 +140,31 @@ A resource package (`*-resource`) owns the `Resource<T>` contract; the db packag
73
140
  **depends on** the resource package.
74
141
 
75
142
  - **Unit specs** — schema compilation, identifier derivation, placeholder resolution, error
76
- translation — live in the **resource** package. No gate, no service.
77
- - **Integration specs** that build a real `ServerContext` live in the **db** package. Putting
78
- them in the resource package would need a devDependency on its own dependent, which is a
79
- cycle and risks breaking `publish.ts`'s topological batching.
143
+ translation — live in the **resource** package and gate on nothing: they leave the process
144
+ alone. `@owlmeans/postgres-resource` is entirely this shape.
145
+ - **Raw-driver specs** belong there too when the subject is the round trip and not the service.
146
+ `@owlmeans/mongo-resource` opens `mongoGate()`, derives a namespaced database with
147
+ `randomNamespace`, registers a cleanup that drops it, and drives `mongodb` directly with the
148
+ driver as its own devDependency — legitimate precisely because it never constructs the db
149
+ service. It is also the one package that may use the global cleanup queue, because only that
150
+ one spec file of the three provisions anything.
151
+ - **Integration specs that build a real `ServerContext`** live in the **db** package. It already
152
+ depends on the resource package, so the pair it boots is the real one; putting them in the
153
+ resource package would make a contract dev-depend on its own implementation just to run its
154
+ specs.
80
155
 
81
156
  ## Per-suite namespaces, not the global cleanup queue
82
157
 
83
- Bun runs **every spec file of a package in one process** with one shared module registry. A
84
- process-global `registerCleanup`/`runCleanups()` therefore misfires: the first file to reach
85
- `afterAll` drops the namespaces of the files still to come. Use `registerCleanup` only in a
86
- package with a single spec file.
158
+ Bun runs **every spec file of a package in one process** with one shared module registry, and the
159
+ queue is one array for that whole process. A process-global `registerCleanup`/`runCleanups()`
160
+ therefore misfires as soon as two spec files provision: the first to reach `afterAll` drains the
161
+ entire queue and drops the namespaces of the files still to come. The queue is safe only where a
162
+ **single** spec file provisions anything and that same file calls `runCleanups()`. That is the
163
+ shape `@owlmeans/mongo-resource` has — one gated round-trip spec registering one dropped database,
164
+ beside two ungated spec files that translate criteria and references in memory and open nothing.
87
165
 
88
- Give each spec file its own namespace and its own teardown instead:
166
+ Any package with a second provisioning spec file gives each spec file its own namespace and its
167
+ own teardown instead:
89
168
 
90
169
  ```ts
91
170
  // tests/context.ts
@@ -142,10 +221,12 @@ export const capabilities: PgCapabilities = gate.skip
142
221
 
143
222
  Specs then branch on it once, at the top: `const it = gate.skip || !capabilities.bootstrap ? test.skip : test`.
144
223
 
145
- ## Module-scope declarations survive a context switch
224
+ ## Module-scope declarations outlive a `boot()`
146
225
 
147
- Migration registries and table declarations are keyed by resource **alias** at module scope so
148
- they outlive `reinitializeContext`. That means they also outlive a `boot()` — deliberately.
226
+ Migration registries and table declarations are keyed by resource **alias** at module scope, so a
227
+ maker that runs more than once for the same alias re-declares the same entries and loses nothing.
228
+ A `boot()` builds a whole new context, but the declarations belong to the module registry rather
229
+ than to any context, so they carry across every boot in the process — deliberately.
149
230
  Never reset them inside a shared `boot()` helper: a `.migration()` registered before the boot
150
231
  would be silently dropped. A spec that wants a clean slate calls `resetDeclarations(alias)`
151
232
  itself, which is what a spec simulating a restarted process should have to say out loud.
@@ -192,13 +273,25 @@ Add `"./tests/**/*"` to the package's `tsconfig.json` `exclude` so `tsc -b` neve
192
273
  specs into `build/`, and declare `@owlmeans/test-integration` plus `@types/bun` as
193
274
  devDependencies.
194
275
 
195
- The Bun runtime auto-loads `<root>/.env` when the test command runs from a workspace package.
196
- If you need to override, `bun test --env-file=../../.env` works. Long-running specs may need a
197
- higher timeout — set it locally in a per-package `bunfig.toml`:
276
+ **Nothing auto-loads the workspace `.env`.** Bun reads a `.env` beside the working directory, so
277
+ `bun test` inside a package never sees the one at the workspace root. The gates work because
278
+ `@owlmeans/test`'s `loadEnv` does the loading itself and every gate calls it before reading a
279
+ variable: it walks up from the working directory for a `bun.lock`, or for a `package.json`
280
+ sitting beside a directory named `packages`, and reads the `.env` it finds there. A variable
281
+ already set to a non-empty value always wins, so `MONGO_URL=… bun test ./tests` still overrides
282
+ the file. A workspace shaped any other way names the file itself — `loadEnv({ file })` at the top
283
+ of `tests/context.ts`, before the gates run.
284
+
285
+ Bun's per-test timeout is 5s, and **`bunfig.toml`'s `[test] timeout` does not raise it** — bun
286
+ 1.4.0 reads the section (`preload` in it works) and ignores that key. Give a long-running spec its
287
+ own budget as the third argument to `test`, or raise the whole run on the command line:
288
+
289
+ ```ts
290
+ test('round-trips through a real service', async () => { /* … */ }, 60_000)
291
+ ```
198
292
 
199
- ```toml
200
- [test]
201
- timeout = 60000
293
+ ```sh
294
+ bun test --timeout=60000 ./tests
202
295
  ```
203
296
 
204
297
  ## Bun assertion gotcha
@@ -221,7 +314,8 @@ expect(pgErrorToResourceError(circular)).toBe(circular) // not: expect(() =>
221
314
  service holds no leftovers matching the prefix.
222
315
  - **Cover SKILL.md cases first.**
223
316
  - **Max 3-4 tests per method/function.**
224
- - **CI runs C only when secrets are set.** Locally, an empty `.env` produces a clean skip.
317
+ - **CI runs C only when secrets are set.** Locally, an empty `.env` — or a set one whose forward is
318
+ down — produces a clean, printed skip.
225
319
 
226
320
  ## When the env is partially populated
227
321
 
@@ -1 +1 @@
1
- {"version":3,"file":"gates.d.ts","sourceRoot":"","sources":["../src/gates.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,eAAe,CAAC,CAAC;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;CAChB;AAoBD,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,oBAAoB,EAAE,MAAM,CAAA;CAC7B;AAED,eAAO,MAAM,SAAS,QAAO,eAAe,CAAC,QAAQ,CACiB,CAAA;AAEtE,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,qBAAqB,EAAE,MAAM,CAAA;CAC9B;AAED,eAAO,MAAM,SAAS,QAAO,eAAe,CAAC,QAAQ,CACkB,CAAA;AAEvE,MAAM,WAAW,KAAK;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,cAAc,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,eAAO,MAAM,MAAM,QAAO,eAAe,CAAC,KAAK,CAI5C,CAAA;AAEH,MAAM,WAAW,OAAO;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,eAAO,MAAM,QAAQ,QAAO,eAAe,CAAC,OAAO,CACU,CAAA;AAE7D,MAAM,WAAW,WAAW;IAC1B,YAAY,EAAE,MAAM,CAAA;IACpB,uBAAuB,EAAE,MAAM,CAAA;CAChC;AAED,eAAO,MAAM,YAAY,QAAO,eAAe,CAAC,WAAW,CACoB,CAAA;AAE/E,MAAM,WAAW,OAAO;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,2EAA2E;IAC3E,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,eAAO,MAAM,QAAQ,QAAO,eAAe,CAAC,OAAO,CAIhD,CAAA"}
1
+ {"version":3,"file":"gates.d.ts","sourceRoot":"","sources":["../src/gates.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,eAAe,CAAC,CAAC;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;CAChB;AAqCD,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,oBAAoB,EAAE,MAAM,CAAA;CAC7B;AAED,eAAO,MAAM,SAAS,QAAO,eAAe,CAAC,QAAQ,CAC2D,CAAA;AAEhH,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAA;IACjB,qBAAqB,EAAE,MAAM,CAAA;CAC9B;AAED,eAAO,MAAM,SAAS,QAAO,eAAe,CAAC,QAAQ,CAC2D,CAAA;AAEhH,MAAM,WAAW,KAAK;IACpB,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,cAAc,EAAE,MAAM,CAAA;IACtB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,eAAO,MAAM,MAAM,QAAO,eAAe,CAAC,KAAK,CAI5C,CAAA;AAEH,MAAM,WAAW,OAAO;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,eAAO,MAAM,QAAQ,QAAO,eAAe,CAAC,OAAO,CACU,CAAA;AAE7D,MAAM,WAAW,WAAW;IAC1B,YAAY,EAAE,MAAM,CAAA;IACpB,uBAAuB,EAAE,MAAM,CAAA;CAChC;AAED,eAAO,MAAM,YAAY,QAAO,eAAe,CAAC,WAAW,CACgE,CAAA;AAE3H,MAAM,WAAW,OAAO;IACtB,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,2EAA2E;IAC3E,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,eAAO,MAAM,QAAQ,QAAO,eAAe,CAAC,OAAO,CAIhD,CAAA"}
package/build/gates.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { hasEnv, requireEnv } from '@owlmeans/test';
2
+ import { unreachableReason } from './reachability.js';
2
3
  const collect = (keys) => {
3
4
  const env = {};
4
5
  for (const k of keys) {
@@ -7,17 +8,22 @@ const collect = (keys) => {
7
8
  }
8
9
  return env;
9
10
  };
10
- const toIntegrationGate = (required, optional = []) => {
11
+ const toIntegrationGate = (required, optional = [], reachable) => {
11
12
  const gate = requireEnv(required);
12
13
  const env = collect([...required, ...optional]);
13
- if ('ok' in gate)
14
- return { skip: false, env };
15
- return { skip: true, reason: gate.reason, env };
14
+ if (!('ok' in gate))
15
+ return { skip: true, reason: gate.reason, env };
16
+ if (reachable != null) {
17
+ const reason = unreachableReason(reachable.key, process.env[reachable.key], reachable.defaultPort);
18
+ if (reason != null)
19
+ return { skip: true, reason, env };
20
+ }
21
+ return { skip: false, env };
16
22
  };
17
- export const mongoGate = () => toIntegrationGate(['MONGO_URL'], ['MONGO_TEST_DB_PREFIX']);
18
- export const redisGate = () => toIntegrationGate(['REDIS_URL'], ['REDIS_TEST_KEY_PREFIX']);
23
+ export const mongoGate = () => toIntegrationGate(['MONGO_URL'], ['MONGO_TEST_DB_PREFIX'], { key: 'MONGO_URL', defaultPort: 27017 });
24
+ export const redisGate = () => toIntegrationGate(['REDIS_URL'], ['REDIS_TEST_KEY_PREFIX'], { key: 'REDIS_URL', defaultPort: 6379 });
19
25
  export const s3Gate = () => toIntegrationGate(['S3_ENDPOINT', 'S3_KEY', 'S3_SECRET', 'S3_TEST_BUCKET'], ['S3_REGION']);
20
26
  export const kubeGate = () => toIntegrationGate(['KUBE_CONFIG', 'KUBE_TEST_OK']);
21
- export const postgresGate = () => toIntegrationGate(['POSTGRES_URL'], ['POSTGRES_TEST_DB_PREFIX']);
27
+ export const postgresGate = () => toIntegrationGate(['POSTGRES_URL'], ['POSTGRES_TEST_DB_PREFIX'], { key: 'POSTGRES_URL', defaultPort: 5432 });
22
28
  export const smtpGate = () => toIntegrationGate(['SMTP_HOST', 'SMTP_USER', 'SMTP_PASSWORD', 'SMTP_FROM', 'SMTP_TEST_TO'], ['SMTP_PORT', 'SMTP_SECURE']);
23
29
  //# sourceMappingURL=gates.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"gates.js","sourceRoot":"","sources":["../src/gates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAQnD,MAAM,OAAO,GAAG,CAAI,IAA0B,EAAc,EAAE;IAC5D,MAAM,GAAG,GAA2B,EAAE,CAAA;IACtC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,MAAM,CAAC,CAAC,CAAC;YAAE,GAAG,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAW,CAAA;IAClD,CAAC;IACD,OAAO,GAAiB,CAAA;AAC1B,CAAC,CAAA;AAED,MAAM,iBAAiB,GAAG,CACxB,QAA8B,EAC9B,QAAQ,GAAyB,EAAE,EACf,EAAE;IACtB,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAA;IACjC,MAAM,GAAG,GAAG,OAAO,CAAI,CAAC,GAAG,QAAQ,EAAE,GAAG,QAAQ,CAAC,CAAC,CAAA;IAClD,IAAI,IAAI,IAAI,IAAI;QAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,CAAA;IAC7C,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAAA;AACjD,CAAC,CAAA;AAOD,MAAM,CAAC,MAAM,SAAS,GAAG,GAA8B,EAAE,CACvD,iBAAiB,CAAW,CAAC,WAAW,CAAC,EAAE,CAAC,sBAAsB,CAAC,CAAC,CAAA;AAOtE,MAAM,CAAC,MAAM,SAAS,GAAG,GAA8B,EAAE,CACvD,iBAAiB,CAAW,CAAC,WAAW,CAAC,EAAE,CAAC,uBAAuB,CAAC,CAAC,CAAA;AAUvE,MAAM,CAAC,MAAM,MAAM,GAAG,GAA2B,EAAE,CACjD,iBAAiB,CACf,CAAC,aAAa,EAAE,QAAQ,EAAE,WAAW,EAAE,gBAAgB,CAAC,EACxD,CAAC,WAAW,CAAC,CACd,CAAA;AAOH,MAAM,CAAC,MAAM,QAAQ,GAAG,GAA6B,EAAE,CACrD,iBAAiB,CAAU,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,CAAA;AAO7D,MAAM,CAAC,MAAM,YAAY,GAAG,GAAiC,EAAE,CAC7D,iBAAiB,CAAc,CAAC,cAAc,CAAC,EAAE,CAAC,yBAAyB,CAAC,CAAC,CAAA;AAa/E,MAAM,CAAC,MAAM,QAAQ,GAAG,GAA6B,EAAE,CACrD,iBAAiB,CACf,CAAC,WAAW,EAAE,WAAW,EAAE,eAAe,EAAE,WAAW,EAAE,cAAc,CAAC,EACxE,CAAC,WAAW,EAAE,aAAa,CAAC,CAC7B,CAAA"}
1
+ {"version":3,"file":"gates.js","sourceRoot":"","sources":["../src/gates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AAQrD,MAAM,OAAO,GAAG,CAAI,IAA0B,EAAc,EAAE;IAC5D,MAAM,GAAG,GAA2B,EAAE,CAAA;IACtC,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,MAAM,CAAC,CAAC,CAAC;YAAE,GAAG,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAW,CAAA;IAClD,CAAC;IACD,OAAO,GAAiB,CAAA;AAC1B,CAAC,CAAA;AAcD,MAAM,iBAAiB,GAAG,CACxB,QAA8B,EAC9B,QAAQ,GAAyB,EAAE,EACnC,SAAwB,EACJ,EAAE;IACtB,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAA;IACjC,MAAM,GAAG,GAAG,OAAO,CAAI,CAAC,GAAG,QAAQ,EAAE,GAAG,QAAQ,CAAC,CAAC,CAAA;IAClD,IAAI,CAAC,CAAC,IAAI,IAAI,IAAI,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CAAA;IACpE,IAAI,SAAS,IAAI,IAAI,EAAE,CAAC;QACtB,MAAM,MAAM,GAAG,iBAAiB,CAAC,SAAS,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,CAAW,EAAE,SAAS,CAAC,WAAW,CAAC,CAAA;QAC5G,IAAI,MAAM,IAAI,IAAI;YAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAA;IACxD,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,CAAA;AAC7B,CAAC,CAAA;AAOD,MAAM,CAAC,MAAM,SAAS,GAAG,GAA8B,EAAE,CACvD,iBAAiB,CAAW,CAAC,WAAW,CAAC,EAAE,CAAC,sBAAsB,CAAC,EAAE,EAAE,GAAG,EAAE,WAAW,EAAE,WAAW,EAAE,KAAK,EAAE,CAAC,CAAA;AAOhH,MAAM,CAAC,MAAM,SAAS,GAAG,GAA8B,EAAE,CACvD,iBAAiB,CAAW,CAAC,WAAW,CAAC,EAAE,CAAC,uBAAuB,CAAC,EAAE,EAAE,GAAG,EAAE,WAAW,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAA;AAUhH,MAAM,CAAC,MAAM,MAAM,GAAG,GAA2B,EAAE,CACjD,iBAAiB,CACf,CAAC,aAAa,EAAE,QAAQ,EAAE,WAAW,EAAE,gBAAgB,CAAC,EACxD,CAAC,WAAW,CAAC,CACd,CAAA;AAOH,MAAM,CAAC,MAAM,QAAQ,GAAG,GAA6B,EAAE,CACrD,iBAAiB,CAAU,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,CAAA;AAO7D,MAAM,CAAC,MAAM,YAAY,GAAG,GAAiC,EAAE,CAC7D,iBAAiB,CAAc,CAAC,cAAc,CAAC,EAAE,CAAC,yBAAyB,CAAC,EAAE,EAAE,GAAG,EAAE,cAAc,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAA;AAa3H,MAAM,CAAC,MAAM,QAAQ,GAAG,GAA6B,EAAE,CACrD,iBAAiB,CACf,CAAC,WAAW,EAAE,WAAW,EAAE,eAAe,EAAE,WAAW,EAAE,cAAc,CAAC,EACxE,CAAC,WAAW,EAAE,aAAa,CAAC,CAC7B,CAAA"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Whether the server a connection string names answers at all — asked synchronously, because
3
+ * Bun decides `test` vs `test.skip` synchronously and every gate is read at module scope.
4
+ *
5
+ * A populated variable says where the service should be, not that it is there: a developer's
6
+ * `.env` keeps the port-forward address long after the forward is gone, and a suite that trusts
7
+ * it spends its whole `beforeAll` budget on a refused connection and FAILS. Only a TCP connect is
8
+ * asked — never a login — so no driver is needed here and no credential leaves the variable.
9
+ */
10
+ export interface ProbeTarget {
11
+ host: string;
12
+ port: number;
13
+ }
14
+ /**
15
+ * Every `host:port` in the authority of a connection string, or `null` when the string names
16
+ * nothing a TCP connect can check — an SRV record, a unix socket, or a shape this cannot read.
17
+ * `null` leaves the gate exactly as the variable alone would have it.
18
+ */
19
+ export declare const parseTargets: (url: string, defaultPort: number) => ProbeTarget[] | null;
20
+ /**
21
+ * `null` when at least one target accepts a TCP connection, otherwise the reason none did.
22
+ *
23
+ * A probe that cannot run at all also answers `null`: not knowing is not evidence the service is
24
+ * down, and the suite then behaves exactly as it did before probes existed.
25
+ */
26
+ export declare const probeTargets: (targets: ProbeTarget[]) => string | null;
27
+ /**
28
+ * The skip reason for a populated connection-string variable whose server does not answer, or
29
+ * `null` when it answers or cannot be probed. Printed once per variable and address, so a closed
30
+ * gate is never mistaken for a suite that has nothing to run.
31
+ */
32
+ export declare const unreachableReason: (variable: string, url: string, defaultPort: number) => string | null;
33
+ //# sourceMappingURL=reachability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reachability.d.ts","sourceRoot":"","sources":["../src/reachability.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AAEH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb;AAWD;;;;GAIG;AACH,eAAO,MAAM,YAAY,QAAS,MAAM,eAAe,MAAM,KAAG,WAAW,EAAE,GAAG,IA6B/E,CAAA;AA0BD;;;;;GAKG;AACH,eAAO,MAAM,YAAY,YAAa,WAAW,EAAE,KAAG,MAAM,GAAG,IAkB9D,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,aAAc,MAAM,OAAO,MAAM,eAAe,MAAM,KAAG,MAAM,GAAG,IAiB/F,CAAA"}
@@ -0,0 +1,109 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ /** How long one connect may take before the address counts as unreachable. */
3
+ const CONNECT_TIMEOUT_MS = 1_500;
4
+ /** The probe runs in a child process; this covers its own startup on a loaded machine. */
5
+ const SPAWN_GRACE_MS = 5_000;
6
+ const answers = new Map();
7
+ const warned = new Set();
8
+ /**
9
+ * Every `host:port` in the authority of a connection string, or `null` when the string names
10
+ * nothing a TCP connect can check — an SRV record, a unix socket, or a shape this cannot read.
11
+ * `null` leaves the gate exactly as the variable alone would have it.
12
+ */
13
+ export const parseTargets = (url, defaultPort) => {
14
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url);
15
+ if (scheme == null || scheme[1].toLowerCase().endsWith('+srv')) {
16
+ return null;
17
+ }
18
+ const rest = url.slice(scheme[0].length);
19
+ const end = rest.search(/[/?#]/);
20
+ const authority = end < 0 ? rest : rest.slice(0, end);
21
+ const hosts = authority.slice(authority.lastIndexOf('@') + 1);
22
+ const targets = [];
23
+ for (const entry of hosts.split(',')) {
24
+ const match = /^(\[[^\]]+\]|[^:]*)(?::(\d+))?$/.exec(entry);
25
+ if (match == null) {
26
+ return null;
27
+ }
28
+ let host;
29
+ try {
30
+ host = decodeURIComponent(match[1]).replace(/^\[|\]$/g, '');
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ if (host === '' || host.includes('/')) {
36
+ return null;
37
+ }
38
+ targets.push({ host, port: match[2] != null ? parseInt(match[2], 10) : defaultPort });
39
+ }
40
+ return targets.length > 0 ? targets : null;
41
+ };
42
+ // Runs under whichever runtime runs the suite (`process.execPath`), so it is plain CommonJS
43
+ // that both `bun -e` and `node -e` evaluate.
44
+ const PROBE_SCRIPT = `
45
+ const net = require('net')
46
+ const [targets, timeout] = JSON.parse(process.env.OWLMEANS_TEST_PROBE)
47
+ let pending = targets.length
48
+ let last = ''
49
+ for (const target of targets) {
50
+ let settled = false
51
+ const socket = net.connect({ host: target.host, port: target.port })
52
+ const fail = code => {
53
+ if (settled) return
54
+ settled = true
55
+ socket.destroy()
56
+ last = code
57
+ if (--pending === 0) { process.stdout.write(last); process.exit(1) }
58
+ }
59
+ socket.setTimeout(timeout)
60
+ socket.once('connect', () => process.exit(0))
61
+ socket.once('timeout', () => fail('ETIMEDOUT'))
62
+ socket.once('error', error => fail(error.code || String(error)))
63
+ }
64
+ `;
65
+ /**
66
+ * `null` when at least one target accepts a TCP connection, otherwise the reason none did.
67
+ *
68
+ * A probe that cannot run at all also answers `null`: not knowing is not evidence the service is
69
+ * down, and the suite then behaves exactly as it did before probes existed.
70
+ */
71
+ export const probeTargets = (targets) => {
72
+ const key = targets.map(({ host, port }) => `${host}:${port}`).join(',');
73
+ if (answers.has(key)) {
74
+ return answers.get(key) ?? null;
75
+ }
76
+ const result = spawnSync(process.execPath, ['-e', PROBE_SCRIPT], {
77
+ env: { ...process.env, OWLMEANS_TEST_PROBE: JSON.stringify([targets, CONNECT_TIMEOUT_MS]) },
78
+ stdio: ['ignore', 'pipe', 'ignore'],
79
+ timeout: CONNECT_TIMEOUT_MS + SPAWN_GRACE_MS,
80
+ encoding: 'utf8',
81
+ });
82
+ const answer = result.error == null && result.status === 1
83
+ ? `nothing answers at ${key} (${result.stdout.trim() !== '' ? result.stdout.trim() : 'unreachable'})`
84
+ : null;
85
+ answers.set(key, answer);
86
+ return answer;
87
+ };
88
+ /**
89
+ * The skip reason for a populated connection-string variable whose server does not answer, or
90
+ * `null` when it answers or cannot be probed. Printed once per variable and address, so a closed
91
+ * gate is never mistaken for a suite that has nothing to run.
92
+ */
93
+ export const unreachableReason = (variable, url, defaultPort) => {
94
+ const targets = parseTargets(url, defaultPort);
95
+ if (targets == null) {
96
+ return null;
97
+ }
98
+ const unreachable = probeTargets(targets);
99
+ if (unreachable == null) {
100
+ return null;
101
+ }
102
+ const reason = `${variable} is set, but ${unreachable} — start the service (or its port-forward) to run these specs`;
103
+ if (!warned.has(reason)) {
104
+ warned.add(reason);
105
+ console.warn(`@owlmeans/test-integration: skipping — ${reason}`);
106
+ }
107
+ return reason;
108
+ };
109
+ //# sourceMappingURL=reachability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reachability.js","sourceRoot":"","sources":["../src/reachability.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/test-integration",
3
- "version": "0.1.18-rc.3",
3
+ "version": "0.1.18-rc.30",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -21,11 +21,11 @@
21
21
  }
22
22
  },
23
23
  "dependencies": {
24
- "@owlmeans/test": "^0.1.18-rc.3"
24
+ "@owlmeans/test": "^0.1.18-rc.33"
25
25
  },
26
26
  "devDependencies": {
27
- "@owlmeans/dep-config": "^0.1.18-rc.3",
28
- "@types/bun": "^1.3.14",
27
+ "@owlmeans/dep-config": "^0.1.18-rc.6",
28
+ "@types/bun": "^1.4.0",
29
29
  "nodemon": "^3.1.14",
30
30
  "typescript": "^7.0.2"
31
31
  },
package/src/gates.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { hasEnv, requireEnv } from '@owlmeans/test'
2
+ import { unreachableReason } from './reachability.js'
2
3
 
3
4
  export interface IntegrationGate<E> {
4
5
  skip: boolean
@@ -14,14 +15,31 @@ const collect = <E>(keys: (keyof E & string)[]): Partial<E> => {
14
15
  return env as Partial<E>
15
16
  }
16
17
 
18
+ /**
19
+ * A connection-string variable whose server must answer a TCP connect for the gate to open.
20
+ *
21
+ * Only for a service a developer reaches through a local address that can go away — a
22
+ * port-forward, a container. A populated variable pointing at nothing then closes the gate with
23
+ * a printed reason instead of failing every suite behind it on a refused connection.
24
+ */
25
+ interface Reachable<E> {
26
+ key: keyof E & string
27
+ defaultPort: number
28
+ }
29
+
17
30
  const toIntegrationGate = <E>(
18
31
  required: (keyof E & string)[],
19
- optional: (keyof E & string)[] = []
32
+ optional: (keyof E & string)[] = [],
33
+ reachable?: Reachable<E>
20
34
  ): IntegrationGate<E> => {
21
35
  const gate = requireEnv(required)
22
36
  const env = collect<E>([...required, ...optional])
23
- if ('ok' in gate) return { skip: false, env }
24
- return { skip: true, reason: gate.reason, env }
37
+ if (!('ok' in gate)) return { skip: true, reason: gate.reason, env }
38
+ if (reachable != null) {
39
+ const reason = unreachableReason(reachable.key, process.env[reachable.key] as string, reachable.defaultPort)
40
+ if (reason != null) return { skip: true, reason, env }
41
+ }
42
+ return { skip: false, env }
25
43
  }
26
44
 
27
45
  export interface MongoEnv {
@@ -30,7 +48,7 @@ export interface MongoEnv {
30
48
  }
31
49
 
32
50
  export const mongoGate = (): IntegrationGate<MongoEnv> =>
33
- toIntegrationGate<MongoEnv>(['MONGO_URL'], ['MONGO_TEST_DB_PREFIX'])
51
+ toIntegrationGate<MongoEnv>(['MONGO_URL'], ['MONGO_TEST_DB_PREFIX'], { key: 'MONGO_URL', defaultPort: 27017 })
34
52
 
35
53
  export interface RedisEnv {
36
54
  REDIS_URL: string
@@ -38,7 +56,7 @@ export interface RedisEnv {
38
56
  }
39
57
 
40
58
  export const redisGate = (): IntegrationGate<RedisEnv> =>
41
- toIntegrationGate<RedisEnv>(['REDIS_URL'], ['REDIS_TEST_KEY_PREFIX'])
59
+ toIntegrationGate<RedisEnv>(['REDIS_URL'], ['REDIS_TEST_KEY_PREFIX'], { key: 'REDIS_URL', defaultPort: 6379 })
42
60
 
43
61
  export interface S3Env {
44
62
  S3_ENDPOINT: string
@@ -68,7 +86,7 @@ export interface PostgresEnv {
68
86
  }
69
87
 
70
88
  export const postgresGate = (): IntegrationGate<PostgresEnv> =>
71
- toIntegrationGate<PostgresEnv>(['POSTGRES_URL'], ['POSTGRES_TEST_DB_PREFIX'])
89
+ toIntegrationGate<PostgresEnv>(['POSTGRES_URL'], ['POSTGRES_TEST_DB_PREFIX'], { key: 'POSTGRES_URL', defaultPort: 5432 })
72
90
 
73
91
  export interface SmtpEnv {
74
92
  SMTP_HOST: string
@@ -0,0 +1,135 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ /**
4
+ * Whether the server a connection string names answers at all — asked synchronously, because
5
+ * Bun decides `test` vs `test.skip` synchronously and every gate is read at module scope.
6
+ *
7
+ * A populated variable says where the service should be, not that it is there: a developer's
8
+ * `.env` keeps the port-forward address long after the forward is gone, and a suite that trusts
9
+ * it spends its whole `beforeAll` budget on a refused connection and FAILS. Only a TCP connect is
10
+ * asked — never a login — so no driver is needed here and no credential leaves the variable.
11
+ */
12
+
13
+ export interface ProbeTarget {
14
+ host: string
15
+ port: number
16
+ }
17
+
18
+ /** How long one connect may take before the address counts as unreachable. */
19
+ const CONNECT_TIMEOUT_MS = 1_500
20
+
21
+ /** The probe runs in a child process; this covers its own startup on a loaded machine. */
22
+ const SPAWN_GRACE_MS = 5_000
23
+
24
+ const answers = new Map<string, string | null>()
25
+ const warned = new Set<string>()
26
+
27
+ /**
28
+ * Every `host:port` in the authority of a connection string, or `null` when the string names
29
+ * nothing a TCP connect can check — an SRV record, a unix socket, or a shape this cannot read.
30
+ * `null` leaves the gate exactly as the variable alone would have it.
31
+ */
32
+ export const parseTargets = (url: string, defaultPort: number): ProbeTarget[] | null => {
33
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url)
34
+ if (scheme == null || scheme[1].toLowerCase().endsWith('+srv')) {
35
+ return null
36
+ }
37
+ const rest = url.slice(scheme[0].length)
38
+ const end = rest.search(/[/?#]/)
39
+ const authority = end < 0 ? rest : rest.slice(0, end)
40
+ const hosts = authority.slice(authority.lastIndexOf('@') + 1)
41
+
42
+ const targets: ProbeTarget[] = []
43
+ for (const entry of hosts.split(',')) {
44
+ const match = /^(\[[^\]]+\]|[^:]*)(?::(\d+))?$/.exec(entry)
45
+ if (match == null) {
46
+ return null
47
+ }
48
+ let host: string
49
+ try {
50
+ host = decodeURIComponent(match[1]).replace(/^\[|\]$/g, '')
51
+ } catch {
52
+ return null
53
+ }
54
+ if (host === '' || host.includes('/')) {
55
+ return null
56
+ }
57
+ targets.push({ host, port: match[2] != null ? parseInt(match[2], 10) : defaultPort })
58
+ }
59
+
60
+ return targets.length > 0 ? targets : null
61
+ }
62
+
63
+ // Runs under whichever runtime runs the suite (`process.execPath`), so it is plain CommonJS
64
+ // that both `bun -e` and `node -e` evaluate.
65
+ const PROBE_SCRIPT = `
66
+ const net = require('net')
67
+ const [targets, timeout] = JSON.parse(process.env.OWLMEANS_TEST_PROBE)
68
+ let pending = targets.length
69
+ let last = ''
70
+ for (const target of targets) {
71
+ let settled = false
72
+ const socket = net.connect({ host: target.host, port: target.port })
73
+ const fail = code => {
74
+ if (settled) return
75
+ settled = true
76
+ socket.destroy()
77
+ last = code
78
+ if (--pending === 0) { process.stdout.write(last); process.exit(1) }
79
+ }
80
+ socket.setTimeout(timeout)
81
+ socket.once('connect', () => process.exit(0))
82
+ socket.once('timeout', () => fail('ETIMEDOUT'))
83
+ socket.once('error', error => fail(error.code || String(error)))
84
+ }
85
+ `
86
+
87
+ /**
88
+ * `null` when at least one target accepts a TCP connection, otherwise the reason none did.
89
+ *
90
+ * A probe that cannot run at all also answers `null`: not knowing is not evidence the service is
91
+ * down, and the suite then behaves exactly as it did before probes existed.
92
+ */
93
+ export const probeTargets = (targets: ProbeTarget[]): string | null => {
94
+ const key = targets.map(({ host, port }) => `${host}:${port}`).join(',')
95
+ if (answers.has(key)) {
96
+ return answers.get(key) ?? null
97
+ }
98
+
99
+ const result = spawnSync(process.execPath, ['-e', PROBE_SCRIPT], {
100
+ env: { ...process.env, OWLMEANS_TEST_PROBE: JSON.stringify([targets, CONNECT_TIMEOUT_MS]) },
101
+ stdio: ['ignore', 'pipe', 'ignore'],
102
+ timeout: CONNECT_TIMEOUT_MS + SPAWN_GRACE_MS,
103
+ encoding: 'utf8',
104
+ })
105
+ const answer = result.error == null && result.status === 1
106
+ ? `nothing answers at ${key} (${result.stdout.trim() !== '' ? result.stdout.trim() : 'unreachable'})`
107
+ : null
108
+ answers.set(key, answer)
109
+
110
+ return answer
111
+ }
112
+
113
+ /**
114
+ * The skip reason for a populated connection-string variable whose server does not answer, or
115
+ * `null` when it answers or cannot be probed. Printed once per variable and address, so a closed
116
+ * gate is never mistaken for a suite that has nothing to run.
117
+ */
118
+ export const unreachableReason = (variable: string, url: string, defaultPort: number): string | null => {
119
+ const targets = parseTargets(url, defaultPort)
120
+ if (targets == null) {
121
+ return null
122
+ }
123
+ const unreachable = probeTargets(targets)
124
+ if (unreachable == null) {
125
+ return null
126
+ }
127
+
128
+ const reason = `${variable} is set, but ${unreachable} — start the service (or its port-forward) to run these specs`
129
+ if (!warned.has(reason)) {
130
+ warned.add(reason)
131
+ console.warn(`@owlmeans/test-integration: skipping — ${reason}`)
132
+ }
133
+
134
+ return reason
135
+ }