@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 +3 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/testing-integration/SKILL.md +139 -45
- package/build/gates.d.ts.map +1 -1
- package/build/gates.js +13 -7
- package/build/gates.js.map +1 -1
- package/build/reachability.d.ts +33 -0
- package/build/reachability.d.ts.map +1 -0
- package/build/reachability.js +109 -0
- package/build/reachability.js.map +1 -0
- package/package.json +4 -4
- package/src/gates.ts +24 -6
- package/src/reachability.ts +135 -0
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
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/test-integration",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-
|
|
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
|
|
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`, `
|
|
11
|
-
`
|
|
12
|
-
`llm` (live inference providers). These tests **never** mock the external service.
|
|
13
|
-
only when the corresponding env vars are set, and they self-skip cleanly when those
|
|
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
|
-
| `
|
|
22
|
-
| `
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
secrets. **Empty value = skip
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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.**
|
|
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
|
-
- **
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
77
|
-
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
84
|
-
process-global `registerCleanup`/`runCleanups()`
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
```
|
|
200
|
-
|
|
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`
|
|
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
|
|
package/build/gates.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gates.d.ts","sourceRoot":"","sources":["../src/gates.ts"],"names":[],"mappings":"
|
|
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:
|
|
15
|
-
|
|
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
|
package/build/gates.js.map
CHANGED
|
@@ -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;
|
|
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
|
+
"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.
|
|
24
|
+
"@owlmeans/test": "^0.1.18-rc.33"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@owlmeans/dep-config": "^0.1.18-rc.
|
|
28
|
-
"@types/bun": "^1.
|
|
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:
|
|
24
|
-
|
|
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
|
+
}
|