@owlmeans/test-integration 0.1.18-rc.22 → 0.1.18-rc.24

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@^0.1.18-rc.28
22
+ npx @owlmeans/agent-skills@^0.1.18-rc.31
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.22",
5
- "generatedAt": "2026-09-17T08:16:11.217Z",
4
+ "version": "0.1.18-rc.24",
5
+ "generatedAt": "2026-09-21T21:55:20.669Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -6,7 +6,7 @@ description: Category-C integration tests for OwlMeans Common packages that talk
6
6
 
7
7
  # Integration Tests — Category C
8
8
 
9
- **Install:** `"@owlmeans/test-integration": "^0.1.18-rc.22"` in `devDependencies`
9
+ **Install:** `"@owlmeans/test-integration": "^0.1.18-rc.24"` in `devDependencies`
10
10
 
11
11
  Category C applies to packages that integrate with external services: `postgres`,
12
12
  `postgres-resource`, `mongo`, `mongo-resource`, `redis`, `redis-resource`, `redis-queue`,
@@ -25,7 +25,7 @@ service hands it is category C even when it names no driver of its own.
25
25
 
26
26
  | Helper | Purpose |
27
27
  |---|---|
28
- | `postgresGate()`, `mongoGate()`, `redisGate()`, `s3Gate()`, `kubeGate()`, `smtpGate()` | Read env, return `IntegrationGate<E>` = `{ skip, reason?, env }`. |
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
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
30
  | `PostgresEnv`, `MongoEnv`, `RedisEnv`, `S3Env`, `KubeEnv`, `SmtpEnv` | The variable set each gate reads, so `tests/context.ts` types its exported gate. |
31
31
  | `randomNamespace(prefix, len?)` | Schema / DB / key / object-prefix namespacing for parallel-safe runs. Default 6 hex chars. |
@@ -45,6 +45,28 @@ Each gate fails closed on its **required** variables and treats the rest as opti
45
45
  `SMTP_TEST_TO` is required rather than optional because an open SMTP gate sends **real mail** — the
46
46
  suite must never guess a recipient.
47
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
+ ```
61
+
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
+
48
70
  An optional variable is only in `env` when it was actually populated, which is why a suite reads a
49
71
  defaulted one — `POSTGRES_TEST_DB_PREFIX ?? 'omt'` — straight from `process.env` instead: calling
50
72
  any gate has already run `loadEnv`, so the `.env` values are in `process.env` by then.
@@ -69,14 +91,18 @@ asserts on the request it builds and the API's reply.
69
91
 
70
92
  One `.env.example` at the workspace root documents every variable, grouped by service, with
71
93
  comments; the `.env` beside it supplies real values for local runs and `loadEnv` (from
72
- `@owlmeans/test`) picks it up. CI sets the same variables from secrets. **Empty value = skip** —
73
- never a failure.
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.
74
97
 
75
- When a required variable is empty:
98
+ When a required variable is empty, or a datastore gate's server does not answer:
76
99
  1. The corresponding gate reports `skip: true`.
77
100
  2. The per-package `tests/context.ts` does **not** register the dependent service in the real context.
78
101
  3. Specs that touch that service self-skip with the reason printed.
79
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
+
80
106
  **Never print a credential.** Pipe it straight from its source into the env var the gate
81
107
  reads — the value must never reach a log, a report, or a committed file:
82
108
 
@@ -288,7 +314,8 @@ expect(pgErrorToResourceError(circular)).toBe(circular) // not: expect(() =>
288
314
  service holds no leftovers matching the prefix.
289
315
  - **Cover SKILL.md cases first.**
290
316
  - **Max 3-4 tests per method/function.**
291
- - **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.
292
319
 
293
320
  ## When the env is partially populated
294
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.22",
3
+ "version": "0.1.18-rc.24",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -21,7 +21,7 @@
21
21
  }
22
22
  },
23
23
  "dependencies": {
24
- "@owlmeans/test": "^0.1.18-rc.24"
24
+ "@owlmeans/test": "^0.1.18-rc.27"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@owlmeans/dep-config": "^0.1.18-rc.6",
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
+ }