@owlmeans/test 0.1.18-rc.9 → 0.1.18

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
@@ -1,14 +1,130 @@
1
1
  # @owlmeans/test
2
2
 
3
- Foundation helpers shared by the OwlMeans test packages and individual package test suites.
3
+ Foundation helpers shared by the OwlMeans test packages and individual package test suites: the
4
+ repo-root `.env` loader, environment gates that let a spec self-skip with a printed reason, and a
5
+ JSON fixture loader. Every package's `tests/` uses it directly or through one of the harness
6
+ packages built on it. It holds no auth mocks, no service gates and no browser driver — those are
7
+ `@owlmeans/test-auth` (category B), `@owlmeans/test-integration` (category C) and
8
+ `@owlmeans/test-ui` (category D). Tests run on `bun:test`; this package adds no runner. The
9
+ package's skills are `testing-overview` (which category a package is in) and `testing-unit`.
4
10
 
5
- - `loadEnv(opts?)` — locate `<monorepo-root>/.env`, parse it, merge into `process.env` (idempotent; no-op when Bun already loaded the file via `--env-file`).
6
- - `requireEnv(keys)` — returns `{ ok: true }` or `{ skip: true, reason }` so callers can `test.skip(...)` when an integration variable is missing.
7
- - `hasEnv(key)` — boolean shorthand for conditional service provisioning in `tests/context.ts`.
8
- - `makeGates(spec)` — builds a frozen record of `{ <name>: { skip, reason } }` so a per-package `tests/context.ts` exposes one source of truth and individual specs can self-skip.
9
- - `loadFixture<T>(relPath)` — JSON fixture loader rooted at the consuming package's `tests/fixtures/`.
11
+ ## Installation
10
12
 
11
- Tests must be located at `packages/<pkg>/tests/`, named `*.spec.ts`. See the `testing-overview` skill for the full strategy.
13
+ ```bash
14
+ bun add -d @owlmeans/test@^0.1.18
15
+ ```
16
+
17
+ ## Concepts
18
+
19
+ - **Env file** — `<monorepo-root>/.env`, found by walking up from `process.cwd()` to a directory
20
+ holding `bun.lock`, or a `package.json` beside a `packages/` directory. Merged into `process.env`
21
+ once per process; a variable already set to a non-empty value wins. A missing file is not an error.
22
+ - **Gate** — `EnvGate`: `{ ok: true }` when every required variable is set, otherwise
23
+ `{ skip: true, reason }` naming the missing ones.
24
+ - **Gate map** — `makeGates({ name: [envKey, …] })`, a frozen record built once in a suite's
25
+ `tests/context.ts`; specs read it to choose `test` or `test.skip`.
26
+ - **Fixture** — a JSON file under the consuming package's `tests/`, read relative to it.
27
+
28
+ ## Usage
29
+
30
+ ### Declare the suite's gates
31
+
32
+ ```typescript
33
+ // tests/context.ts
34
+ import { makeGates } from '@owlmeans/test'
35
+
36
+ export const gates = makeGates({
37
+ openrouter: ['OPENROUTER_SECRET'],
38
+ anthropic: ['ANTHROPIC_SECRET'],
39
+ })
40
+ ```
41
+
42
+ ### Self-skip a spec
43
+
44
+ Bun picks `test` or `test.skip` synchronously, so decide from the gate at module scope:
45
+
46
+ ```typescript
47
+ // tests/inference.spec.ts
48
+ import { describe, expect, test } from 'bun:test'
49
+ import { isSkip } from '@owlmeans/test'
50
+ import { gates } from './context.js'
51
+
52
+ const gate = gates.openrouter
53
+ const it = isSkip(gate) ? test.skip : test
54
+
55
+ describe('llm — openrouter', () => {
56
+ it(`answers a prompt${isSkip(gate) ? ` (${gate.reason})` : ''}`, async () => {
57
+ /* exercise the real provider */
58
+ })
59
+ })
60
+ ```
61
+
62
+ ### Read the environment directly
63
+
64
+ ```typescript
65
+ import { envHelper } from '@owlmeans/test'
66
+
67
+ envHelper.loadEnv() // idempotent; `{ force: true }` re-reads
68
+ envHelper.loadEnv({ file: '/path/to/other/.env' }) // a workspace shaped differently
69
+
70
+ if (envHelper.hasEnv('MONGO_URL')) {
71
+ /* provision the optional service in tests/context.ts */
72
+ }
73
+
74
+ const gate = envHelper.requireEnv(['SMTP_HOST', 'SMTP_PORT'])
75
+ ```
76
+
77
+ ### Load a fixture
78
+
79
+ ```typescript
80
+ import { loadFixture } from '@owlmeans/test'
81
+
82
+ const profile = loadFixture<ProfileRecord>('fixtures/profile.json') // <package>/tests/fixtures/profile.json
83
+ ```
84
+
85
+ ## API
86
+
87
+ | Symbol | Kind | Purpose |
88
+ |---|---|---|
89
+ | `envHelper` (`createEnvHelper()`) | helper | The env loader and gates below |
90
+ | `envHelper.loadEnv(opts?)` | member | Locate `<monorepo-root>/.env`, parse it, merge into `process.env` (idempotent; no-op for variables Bun already loaded via `--env-file`) |
91
+ | `envHelper.hasEnv(key)` | member | `loadEnv()`, then whether the variable is set and non-empty |
92
+ | `envHelper.requireEnv(keys)` | member | `{ ok: true }` or `{ skip: true, reason }` so callers can `test.skip(...)` when an integration variable is missing |
93
+ | `makeGates(spec)` | function | Frozen record of `{ <name>: EnvGate }` — one source of truth in `tests/context.ts` |
94
+ | `isSkip(gate)` | function | Type guard narrowing an `EnvGate` to `{ skip: true, reason }` |
95
+ | `loadFixture<T>(relPath)` | function | JSON fixture loader rooted at the consuming package's `tests/` |
96
+
97
+ | Type | Purpose |
98
+ |---|---|
99
+ | `EnvGate`, `EnvOk`, `EnvSkip` | Gate outcome union and its branches |
100
+ | `GateSpec`, `Gates<S>` | `{ name: string[] }` input and the frozen gate map it produces |
101
+ | `LoadEnvOptions` | `{ force?, file? }` |
102
+ | `EnvHelper` | The helper's interface |
103
+
104
+ ## Test layout
105
+
106
+ Tests are located at `packages/<pkg>/tests/`, named `*.spec.ts`, and run with `bun test ./tests`
107
+ from the package root. List `"./tests/**/*"` in the package `tsconfig.json` `exclude` so `tsc -b`
108
+ never compiles specs. One real context per package lives in `tests/context.ts`; specs import its
109
+ helper instead of building contexts themselves.
110
+
111
+ ## Common pitfalls
112
+
113
+ - Awaiting anything before choosing `test` / `test.skip` — the choice must be a value already in hand.
114
+ - A missing variable must skip, never fail; do not throw from a gate.
115
+ - `loadFixture` resolves against `process.cwd()`, which `bun test` sets to the package root; running
116
+ specs from another directory breaks the path.
117
+ - A workspace without `bun.lock` and without a `packages/` directory beside its `package.json` is not
118
+ found by `loadEnv` — pass `file` explicitly.
119
+ - Do not add mocks here; auth mocks belong in `@owlmeans/test-auth`, and anything else is an
120
+ integration test.
121
+
122
+ ## Related packages
123
+
124
+ - [`@owlmeans/test-auth`](../test-auth) — auth/authz mocks for category-B unit tests
125
+ - [`@owlmeans/test-integration`](../test-integration) — service gates, namespaces and cleanup for category-C tests
126
+ - [`@owlmeans/test-ui`](../test-ui) — Playwright-driven component acceptance for category D
127
+ - [`@owlmeans/context`](../context) — the real context a suite's `tests/context.ts` builds
12
128
 
13
129
  <!-- owlmeans:agent-guidance:start -->
14
130
  ## Agent guidance
@@ -18,7 +134,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
18
134
  your project's skill store (`.agents/skills/`):
19
135
 
20
136
  ```sh
21
- npx @owlmeans/agent-skills@^0.1.18-rc.13
137
+ npx @owlmeans/agent-skills@^0.1.18
22
138
  ```
23
139
 
24
140
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/test",
4
- "version": "0.1.18-rc.9",
5
- "generatedAt": "2026-09-10T18:54:42.490Z",
4
+ "version": "0.1.18",
5
+ "generatedAt": "2026-10-28T00:31:34.720Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,9 +8,17 @@ description: Decision matrix for which testing strategy to use per package in th
8
8
 
9
9
  Every package falls into exactly one of four testing categories. The category drives which `@owlmeans/test*` package(s) the tests depend on, where tests live, and what mocking is allowed.
10
10
 
11
- `@owlmeans/test` is the base every category builds on — env loading, env gates and fixture loading
12
- (`testing-unit` documents its exports). `@owlmeans/test-auth`, `@owlmeans/test-integration` and
13
- `@owlmeans/test-ui` each add one category's helpers on top of it.
11
+ `@owlmeans/test` is the base every category builds on; each other harness package adds one
12
+ category's helpers on top of it:
13
+
14
+ | Package | Main exports | Documented in |
15
+ |---|---|---|
16
+ | `@owlmeans/test` | `loadEnv`, `requireEnv`, `hasEnv`, `makeGates`, `loadFixture` | `testing-unit` |
17
+ | `@owlmeans/test-auth` | `makeFixtureKeyPair`, `makeMemoryTrustedResource`, `makeMockGuard`, `withAuth`, `signMockEnvelope`, `makeBearer`, fixtures `SUPERUSER`/`USER`/`SERVICE` | `testing-auth-unit` |
18
+ | `@owlmeans/test-integration` | `mongoGate`, `redisGate`, `s3Gate`, `kubeGate`, `randomNamespace`, `registerCleanup`, `runCleanups` | `testing-integration` |
19
+ | `@owlmeans/test-ui` | `launchBrowser`, `closeBrowser`, `withPage`, `mountComponent`, starter `harness/index.html` — on the `playwright` library, not `@playwright/test` | `testing-ui` |
20
+
21
+ New auth mocks belong in `@owlmeans/test-auth`, never in a package's own `tests/`.
14
22
 
15
23
  ## Categories
16
24
 
@@ -23,13 +31,13 @@ Every package falls into exactly one of four testing categories. The category dr
23
31
 
24
32
  ## Package → category map
25
33
 
26
- All 101 packages, one category each.
34
+ All 108 packages, one category each.
27
35
 
28
- - **A** (50): `agent`, `agent-common`, `agent-skills`, `api`, `api-config`, `api-config-client`, `api-config-server`, `astro`, `basic-ids`, `client-config`, `client-context`, `client-entrypoint`, `client-flow`, `client-job`, `client-resource`, `client-route`, `client-socket`, `config`, `consent`, `context`, `create-app`, `entrypoint`, `error`, `flow`, `i18n`, `iam`, `image-resource`, `llm-common`, `mailer`, `payment`, `queue`, `resource`, `route`, `router`, `server-auth-identity`, `server-context`, `server-entrypoint`, `server-iam`, `server-job`, `server-route`, `server-config`, `server-socket`, `server-wl`, `socket`, `state`, `static-resource`, `storage-common`, `web-db`, `web-gtm`, `web-router-react-router`
36
+ - **A** (56): `agent`, `agent-common`, `agent-skills`, `api`, `api-config`, `api-config-client`, `api-config-server`, `astro`, `basic-ids`, `bot`, `bot-agent`, `client-config`, `client-context`, `client-entrypoint`, `client-flow`, `client-job`, `client-resource`, `client-route`, `client-socket`, `common-inquiry`, `config`, `consent`, `context`, `create-app`, `dep-config`, `entrypoint`, `error`, `flow`, `i18n`, `iam`, `image-resource`, `llm-common`, `mailer`, `payment`, `queue`, `resource`, `route`, `router`, `server-auth-identity`, `server-bot`, `server-context`, `server-entrypoint`, `server-iam`, `server-job`, `server-route`, `server-config`, `server-socket`, `server-wl`, `socket`, `state`, `static-resource`, `storage-common`, `web-db`, `web-gtm`, `web-inquiry`, `web-router-react-router`
29
37
  - **B** (20): `auth`, `auth-common`, `auth-otp`, `basic-envelope`, `basic-keys`, `client-auth`, `client-did`, `client-iam`, `client-payment`, `did`, `mui-oidc-rp`, `oidc`, `server-auth`, `server-auth-otp`, `server-oidc-provider`, `server-oidc-rp`, `web-auth`, `web-oidc-provider`, `web-oidc-rp`, `wled`
30
- - **C** (14): `kluster`, `llm` (live inference providers), `mailer-smtp`, `mongo`, `mongo-resource`, `postgres`, `postgres-resource`, `redis`, `redis-queue`, `redis-resource`, `server-api`, `server-app`, `server-mailer-mailgun`, `storage-resource`
31
- - **D** (11): `client`, `client-i18n`, `client-panel`, `client-wl`, `mui-panel`, `web-client`, `web-consent`, `web-flow`, `web-panel`, `web-router`, `web-wl`
32
- - **None** (6): `dep-config`, `_tpl`, and the four harness packages themselves — `test`, `test-auth`, `test-integration`, `test-ui`
38
+ - **C** (16): `bot-postgres`, `kluster`, `llm` (live inference providers), `mailer-smtp`, `mongo`, `mongo-resource`, `planning-postgres`, `postgres`, `postgres-resource`, `redis`, `redis-queue`, `redis-resource`, `server-api`, `server-app`, `server-mailer-mailgun`, `storage-resource`
39
+ - **D** (12): `client`, `client-i18n`, `client-panel`, `client-wl`, `mui-panel`, `web-bot`, `web-client`, `web-consent`, `web-flow`, `web-panel`, `web-router`, `web-wl`
40
+ - **None** (5): `_tpl`, and the four harness packages themselves — `test`, `test-auth`, `test-integration`, `test-ui`
33
41
 
34
42
  A **contract** package and its **backend** split across categories. `queue`, `resource`,
35
43
  `storage-common` and `image-resource` are A — types, AJV schemas, error classes and declarations,
@@ -41,11 +49,16 @@ while one whose code issues real commands against the service is C even when it
41
49
  `redis-resource` imports `ioredis` for types alone and drives the client the db service hands it;
42
50
  `mongo-resource` carries its driver as a devDependency, for its specs only. Both are C.
43
51
 
52
+ `server-bot` is A although its platforms are external services: they are reached only through the
53
+ plugin seam, and its own API-faithful mock transport (a shipped feature, not a test mock) or a
54
+ spec's `fetch` answering a platform's documented envelopes stands in for them; its store is any
55
+ `Resource` (static resources in its specs). Its Postgres store, `bot-postgres`, is C.
56
+
44
57
  `web-router` is D, not A: it drives the History API, so its routing behaviour is only observable in
45
58
  a browser and its specs mount a real harness in chromium. A client-side package stays in A when it
46
59
  ships no component at all — `client-config`, `client-context`, `client-entrypoint`, `client-flow`,
47
- `client-job`, `client-resource`, `client-route`, `client-socket`, `web-db`, `web-gtm` and
48
- `web-router-react-router` are services, models and adapters, so `bun test` alone covers them.
60
+ `client-job`, `client-resource`, `client-route`, `client-socket`, `web-db`, `web-gtm`, `web-inquiry`
61
+ and `web-router-react-router` are services, models and adapters, so `bun test` alone covers them.
49
62
 
50
63
  A new package takes the category its own behaviour implies: an external service it cannot fake ⇒ C,
51
64
  a rendered React surface ⇒ D, an authenticated identity it must stand in for ⇒ B, otherwise A.
@@ -6,7 +6,7 @@ description: Category-A unit tests for OwlMeans Common packages — no mocks, re
6
6
 
7
7
  # Unit Tests — Category A (no mocks)
8
8
 
9
- **Install:** `"@owlmeans/test": "^0.1.18-rc.9"` in `devDependencies`
9
+ **Install:** `"@owlmeans/test": "^0.1.18"` in `devDependencies`
10
10
 
11
11
  Apply this skill when adding tests to packages in category A (see `testing-overview`). The list includes core abstractions (`context`, `config`, `error`, `entrypoint`, `route`, `resource`, …) and platform-agnostic services (`api`, `state`, `flow`, `i18n`, `client-flow`, `client-socket`, `client-job`, `server-route`, `server-context`, `web-db`, …).
12
12
 
package/build/env.d.ts CHANGED
@@ -1,15 +1,4 @@
1
- export interface EnvOk {
2
- ok: true;
3
- }
4
- export interface EnvSkip {
5
- skip: true;
6
- reason: string;
7
- }
8
- export type EnvGate = EnvOk | EnvSkip;
9
- export declare const loadEnv: (opts?: {
10
- force?: boolean;
11
- file?: string;
12
- }) => void;
13
- export declare const hasEnv: (key: string) => boolean;
14
- export declare const requireEnv: (keys: string[]) => EnvGate;
1
+ import type { EnvHelper } from './types.js';
2
+ export declare const createEnvHelper: () => EnvHelper;
3
+ export declare const envHelper: EnvHelper;
15
4
  //# sourceMappingURL=env.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,KAAK;IACpB,EAAE,EAAE,IAAI,CAAA;CACT;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,IAAI,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,MAAM,OAAO,GAAG,KAAK,GAAG,OAAO,CAAA;AAkCrC,eAAO,MAAM,OAAO,UAAW;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,KAAG,IAanE,CAAA;AAED,eAAO,MAAM,MAAM,QAAS,MAAM,KAAG,OAIpC,CAAA;AAED,eAAO,MAAM,UAAU,SAAU,MAAM,EAAE,KAAG,OAK3C,CAAA"}
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAW,SAAS,EAAkB,MAAM,YAAY,CAAA;AAKpE,eAAO,MAAM,eAAe,QAAO,SA4DlC,CAAA;AAED,eAAO,MAAM,SAAS,WAAoB,CAAA"}
package/build/env.js CHANGED
@@ -1,63 +1,68 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
+ // Process-wide on purpose: the file is merged into `process.env`, which every helper instance shares.
3
4
  let loaded = false;
4
- const findRoot = (start) => {
5
- let dir = start;
6
- for (let i = 0; i < 10; i++) {
7
- if (existsSync(resolve(dir, 'bun.lock')) || existsSync(resolve(dir, 'package.json')) && existsSync(resolve(dir, 'packages'))) {
8
- return dir;
5
+ export const createEnvHelper = () => {
6
+ const findRoot = (start) => {
7
+ let dir = start;
8
+ for (let i = 0; i < 10; i++) {
9
+ if (existsSync(resolve(dir, 'bun.lock')) || existsSync(resolve(dir, 'package.json')) && existsSync(resolve(dir, 'packages'))) {
10
+ return dir;
11
+ }
12
+ const parent = dirname(dir);
13
+ if (parent === dir)
14
+ return null;
15
+ dir = parent;
9
16
  }
10
- const parent = dirname(dir);
11
- if (parent === dir)
12
- return null;
13
- dir = parent;
14
- }
15
- return null;
16
- };
17
- const parseEnv = (text) => {
18
- const out = {};
19
- for (const raw of text.split(/\r?\n/)) {
20
- const line = raw.trim();
21
- if (line === '' || line.startsWith('#'))
22
- continue;
23
- const eq = line.indexOf('=');
24
- if (eq < 1)
25
- continue;
26
- const key = line.slice(0, eq).trim();
27
- let val = line.slice(eq + 1).trim();
28
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
29
- val = val.slice(1, -1);
17
+ return null;
18
+ };
19
+ const parseEnv = (text) => {
20
+ const out = {};
21
+ for (const raw of text.split(/\r?\n/)) {
22
+ const line = raw.trim();
23
+ if (line === '' || line.startsWith('#'))
24
+ continue;
25
+ const eq = line.indexOf('=');
26
+ if (eq < 1)
27
+ continue;
28
+ const key = line.slice(0, eq).trim();
29
+ let val = line.slice(eq + 1).trim();
30
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
31
+ val = val.slice(1, -1);
32
+ }
33
+ out[key] = val;
30
34
  }
31
- out[key] = val;
32
- }
33
- return out;
34
- };
35
- export const loadEnv = (opts) => {
36
- if (loaded && opts?.force !== true)
37
- return;
38
- loaded = true;
39
- const root = findRoot(process.cwd());
40
- const envPath = opts?.file ?? (root != null ? resolve(root, '.env') : null);
41
- if (envPath == null || !existsSync(envPath))
42
- return;
43
- const text = readFileSync(envPath, 'utf8');
44
- const parsed = parseEnv(text);
45
- for (const [k, v] of Object.entries(parsed)) {
46
- if (process.env[k] == null || process.env[k] === '') {
47
- process.env[k] = v;
35
+ return out;
36
+ };
37
+ const loadEnv = (opts) => {
38
+ if (loaded && opts?.force !== true)
39
+ return;
40
+ loaded = true;
41
+ const root = findRoot(process.cwd());
42
+ const envPath = opts?.file ?? (root != null ? resolve(root, '.env') : null);
43
+ if (envPath == null || !existsSync(envPath))
44
+ return;
45
+ const text = readFileSync(envPath, 'utf8');
46
+ const parsed = parseEnv(text);
47
+ for (const [k, v] of Object.entries(parsed)) {
48
+ if (process.env[k] == null || process.env[k] === '') {
49
+ process.env[k] = v;
50
+ }
48
51
  }
49
- }
50
- };
51
- export const hasEnv = (key) => {
52
- loadEnv();
53
- const v = process.env[key];
54
- return v != null && v !== '';
55
- };
56
- export const requireEnv = (keys) => {
57
- loadEnv();
58
- const missing = keys.filter(k => !hasEnv(k));
59
- if (missing.length === 0)
60
- return { ok: true };
61
- return { skip: true, reason: `Missing env: ${missing.join(', ')}` };
52
+ };
53
+ const hasEnv = (key) => {
54
+ loadEnv();
55
+ const v = process.env[key];
56
+ return v != null && v !== '';
57
+ };
58
+ const requireEnv = (keys) => {
59
+ loadEnv();
60
+ const missing = keys.filter(k => !hasEnv(k));
61
+ if (missing.length === 0)
62
+ return { ok: true };
63
+ return { skip: true, reason: `Missing env: ${missing.join(', ')}` };
64
+ };
65
+ return { loadEnv, hasEnv, requireEnv };
62
66
  };
67
+ export const envHelper = createEnvHelper();
63
68
  //# sourceMappingURL=env.js.map
package/build/env.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"env.js","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"env.js","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"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"}
package/build/gates.d.ts CHANGED
@@ -1,8 +1,4 @@
1
- import type { EnvGate } from './env.js';
2
- export type GateSpec = Record<string, string[]>;
3
- export type Gates<S extends GateSpec> = {
4
- readonly [K in keyof S]: EnvGate;
5
- };
1
+ import type { EnvGate, GateSpec, Gates } from './types.js';
6
2
  /**
7
3
  * Build a frozen record of gates from a spec of `{ name: [envKey, ...] }`.
8
4
  * Use in `tests/context.ts` so a single source of truth describes which
@@ -1 +1 @@
1
- {"version":3,"file":"gates.d.ts","sourceRoot":"","sources":["../src/gates.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAA;AAEvC,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;AAE/C,MAAM,MAAM,KAAK,CAAC,CAAC,SAAS,QAAQ,IAAI;IAAE,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,OAAO;CAAE,CAAA;AAE5E;;;;;GAKG;AACH,eAAO,MAAM,SAAS,GAAI,CAAC,SAAS,QAAQ,QAAQ,CAAC,KAAG,KAAK,CAAC,CAAC,CAM9D,CAAA;AAED,eAAO,MAAM,MAAM,SAAU,OAAO,KAAG,IAAI,IAAI;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAClC,CAAA"}
1
+ {"version":3,"file":"gates.d.ts","sourceRoot":"","sources":["../src/gates.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,YAAY,CAAA;AAE1D;;;;;GAKG;AACH,eAAO,MAAM,SAAS,GAAI,CAAC,SAAS,QAAQ,QAAQ,CAAC,KAAG,KAAK,CAAC,CAAC,CAM9D,CAAA;AAED,eAAO,MAAM,MAAM,SAAU,OAAO,KAAG,IAAI,IAAI;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAClC,CAAA"}
package/build/gates.js CHANGED
@@ -1,4 +1,4 @@
1
- import { requireEnv } from './env.js';
1
+ import { envHelper } from './env.js';
2
2
  /**
3
3
  * Build a frozen record of gates from a spec of `{ name: [envKey, ...] }`.
4
4
  * Use in `tests/context.ts` so a single source of truth describes which
@@ -8,7 +8,7 @@ import { requireEnv } from './env.js';
8
8
  export const makeGates = (spec) => {
9
9
  const out = {};
10
10
  for (const [name, keys] of Object.entries(spec)) {
11
- out[name] = requireEnv(keys);
11
+ out[name] = envHelper.requireEnv(keys);
12
12
  }
13
13
  return Object.freeze(out);
14
14
  };
@@ -1 +1 @@
1
- {"version":3,"file":"gates.js","sourceRoot":"","sources":["../src/gates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAA;AAOrC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAqB,IAAO,EAAY,EAAE;IACjE,MAAM,GAAG,GAAG,EAA6B,CAAA;IACzC,KAAK,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAChD,GAAG,CAAC,IAAI,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,CAAA;IAC9B,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,GAAG,CAAa,CAAA;AACvC,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,IAAa,EAA0C,EAAE,CAC7E,IAAwB,CAAC,IAAI,KAAK,IAAI,CAAA"}
1
+ {"version":3,"file":"gates.js","sourceRoot":"","sources":["../src/gates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AAGpC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAqB,IAAO,EAAY,EAAE;IACjE,MAAM,GAAG,GAAG,EAA6B,CAAA;IACzC,KAAK,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAChD,GAAG,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,UAAU,CAAC,IAAI,CAAC,CAAA;IACxC,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,GAAG,CAAa,CAAA;AACvC,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,IAAa,EAA0C,EAAE,CAC7E,IAAwB,CAAC,IAAI,KAAK,IAAI,CAAA"}
package/build/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './env.js';
2
2
  export * from './gates.js';
3
3
  export * from './fixtures.js';
4
+ export type * from './types.js';
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAA;AACxB,cAAc,YAAY,CAAA;AAC1B,cAAc,eAAe,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAA;AACxB,cAAc,YAAY,CAAA;AAC1B,cAAc,eAAe,CAAA;AAC7B,mBAAmB,YAAY,CAAA"}
@@ -0,0 +1,31 @@
1
+ export interface EnvOk {
2
+ ok: true;
3
+ }
4
+ export interface EnvSkip {
5
+ skip: true;
6
+ reason: string;
7
+ }
8
+ export type EnvGate = EnvOk | EnvSkip;
9
+ export type GateSpec = Record<string, string[]>;
10
+ export type Gates<S extends GateSpec> = {
11
+ readonly [K in keyof S]: EnvGate;
12
+ };
13
+ export interface LoadEnvOptions {
14
+ /** Re-read the file even when it was already loaded once in this process. */
15
+ force?: boolean;
16
+ /** The file to read instead of `<monorepo-root>/.env`. */
17
+ file?: string;
18
+ }
19
+ /** The test environment: `<monorepo-root>/.env` merged into `process.env`, and gates over it. */
20
+ export interface EnvHelper {
21
+ /**
22
+ * Locate `<monorepo-root>/.env`, parse it and merge it into `process.env` — a variable that is
23
+ * already set and non-empty wins. Idempotent per process unless `force` is passed.
24
+ */
25
+ loadEnv: (opts?: LoadEnvOptions) => void;
26
+ /** Whether a variable is set to a non-empty value (after {@link EnvHelper.loadEnv}). */
27
+ hasEnv: (key: string) => boolean;
28
+ /** `{ ok: true }` when every key is set, otherwise `{ skip: true, reason }` naming the missing ones. */
29
+ requireEnv: (keys: string[]) => EnvGate;
30
+ }
31
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,KAAK;IACpB,EAAE,EAAE,IAAI,CAAA;CACT;AAED,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,IAAI,CAAA;IACV,MAAM,EAAE,MAAM,CAAA;CACf;AAGD,MAAM,MAAM,OAAO,GAAG,KAAK,GAAG,OAAO,CAAA;AAGrC,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;AAG/C,MAAM,MAAM,KAAK,CAAC,CAAC,SAAS,QAAQ,IAAI;IAAE,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,OAAO;CAAE,CAAA;AAE5E,MAAM,WAAW,cAAc;IAC7B,6EAA6E;IAC7E,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,0DAA0D;IAC1D,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,iGAAiG;AACjG,MAAM,WAAW,SAAS;IACxB;;;OAGG;IACH,OAAO,EAAE,CAAC,IAAI,CAAC,EAAE,cAAc,KAAK,IAAI,CAAA;IACxC,wFAAwF;IACxF,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;IAChC,wGAAwG;IACxG,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAA;CACxC"}
package/build/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/test",
3
- "version": "0.1.18-rc.9",
3
+ "version": "0.1.18",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -21,11 +21,11 @@
21
21
  }
22
22
  },
23
23
  "dependencies": {
24
- "@owlmeans/context": "^0.1.18-rc.9"
24
+ "@owlmeans/context": "^0.1.18"
25
25
  },
26
26
  "devDependencies": {
27
- "@owlmeans/dep-config": "^0.1.18-rc.6",
28
- "@types/bun": "^1.4.0",
27
+ "@owlmeans/dep-config": "^0.1.18",
28
+ "@types/bun": "^1.4.2",
29
29
  "nodemon": "^3.1.14",
30
30
  "typescript": "^7.0.2"
31
31
  },
package/src/env.ts CHANGED
@@ -1,73 +1,70 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, resolve } from 'node:path'
3
+ import type { EnvGate, EnvHelper, LoadEnvOptions } from './types.js'
3
4
 
4
- export interface EnvOk {
5
- ok: true
6
- }
7
-
8
- export interface EnvSkip {
9
- skip: true
10
- reason: string
11
- }
12
-
13
- export type EnvGate = EnvOk | EnvSkip
14
-
5
+ // Process-wide on purpose: the file is merged into `process.env`, which every helper instance shares.
15
6
  let loaded = false
16
7
 
17
- const findRoot = (start: string): string | null => {
18
- let dir = start
19
- for (let i = 0; i < 10; i++) {
20
- if (existsSync(resolve(dir, 'bun.lock')) || existsSync(resolve(dir, 'package.json')) && existsSync(resolve(dir, 'packages'))) {
21
- return dir
8
+ export const createEnvHelper = (): EnvHelper => {
9
+ const findRoot = (start: string): string | null => {
10
+ let dir = start
11
+ for (let i = 0; i < 10; i++) {
12
+ if (existsSync(resolve(dir, 'bun.lock')) || existsSync(resolve(dir, 'package.json')) && existsSync(resolve(dir, 'packages'))) {
13
+ return dir
14
+ }
15
+ const parent = dirname(dir)
16
+ if (parent === dir) return null
17
+ dir = parent
22
18
  }
23
- const parent = dirname(dir)
24
- if (parent === dir) return null
25
- dir = parent
19
+ return null
26
20
  }
27
- return null
28
- }
29
21
 
30
- const parseEnv = (text: string): Record<string, string> => {
31
- const out: Record<string, string> = {}
32
- for (const raw of text.split(/\r?\n/)) {
33
- const line = raw.trim()
34
- if (line === '' || line.startsWith('#')) continue
35
- const eq = line.indexOf('=')
36
- if (eq < 1) continue
37
- const key = line.slice(0, eq).trim()
38
- let val = line.slice(eq + 1).trim()
39
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
40
- val = val.slice(1, -1)
22
+ const parseEnv = (text: string): Record<string, string> => {
23
+ const out: Record<string, string> = {}
24
+ for (const raw of text.split(/\r?\n/)) {
25
+ const line = raw.trim()
26
+ if (line === '' || line.startsWith('#')) continue
27
+ const eq = line.indexOf('=')
28
+ if (eq < 1) continue
29
+ const key = line.slice(0, eq).trim()
30
+ let val = line.slice(eq + 1).trim()
31
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
32
+ val = val.slice(1, -1)
33
+ }
34
+ out[key] = val
41
35
  }
42
- out[key] = val
36
+ return out
43
37
  }
44
- return out
45
- }
46
38
 
47
- export const loadEnv = (opts?: { force?: boolean, file?: string }): void => {
48
- if (loaded && opts?.force !== true) return
49
- loaded = true
50
- const root = findRoot(process.cwd())
51
- const envPath = opts?.file ?? (root != null ? resolve(root, '.env') : null)
52
- if (envPath == null || !existsSync(envPath)) return
53
- const text = readFileSync(envPath, 'utf8')
54
- const parsed = parseEnv(text)
55
- for (const [k, v] of Object.entries(parsed)) {
56
- if (process.env[k] == null || process.env[k] === '') {
57
- process.env[k] = v
39
+ const loadEnv = (opts?: LoadEnvOptions): void => {
40
+ if (loaded && opts?.force !== true) return
41
+ loaded = true
42
+ const root = findRoot(process.cwd())
43
+ const envPath = opts?.file ?? (root != null ? resolve(root, '.env') : null)
44
+ if (envPath == null || !existsSync(envPath)) return
45
+ const text = readFileSync(envPath, 'utf8')
46
+ const parsed = parseEnv(text)
47
+ for (const [k, v] of Object.entries(parsed)) {
48
+ if (process.env[k] == null || process.env[k] === '') {
49
+ process.env[k] = v
50
+ }
58
51
  }
59
52
  }
60
- }
61
53
 
62
- export const hasEnv = (key: string): boolean => {
63
- loadEnv()
64
- const v = process.env[key]
65
- return v != null && v !== ''
66
- }
54
+ const hasEnv = (key: string): boolean => {
55
+ loadEnv()
56
+ const v = process.env[key]
57
+ return v != null && v !== ''
58
+ }
67
59
 
68
- export const requireEnv = (keys: string[]): EnvGate => {
69
- loadEnv()
70
- const missing = keys.filter(k => !hasEnv(k))
71
- if (missing.length === 0) return { ok: true }
72
- return { skip: true, reason: `Missing env: ${missing.join(', ')}` }
60
+ const requireEnv = (keys: string[]): EnvGate => {
61
+ loadEnv()
62
+ const missing = keys.filter(k => !hasEnv(k))
63
+ if (missing.length === 0) return { ok: true }
64
+ return { skip: true, reason: `Missing env: ${missing.join(', ')}` }
65
+ }
66
+
67
+ return { loadEnv, hasEnv, requireEnv }
73
68
  }
69
+
70
+ export const envHelper = createEnvHelper()
package/src/gates.ts CHANGED
@@ -1,9 +1,5 @@
1
- import { requireEnv } from './env.js'
2
- import type { EnvGate } from './env.js'
3
-
4
- export type GateSpec = Record<string, string[]>
5
-
6
- export type Gates<S extends GateSpec> = { readonly [K in keyof S]: EnvGate }
1
+ import { envHelper } from './env.js'
2
+ import type { EnvGate, GateSpec, Gates } from './types.js'
7
3
 
8
4
  /**
9
5
  * Build a frozen record of gates from a spec of `{ name: [envKey, ...] }`.
@@ -14,7 +10,7 @@ export type Gates<S extends GateSpec> = { readonly [K in keyof S]: EnvGate }
14
10
  export const makeGates = <S extends GateSpec>(spec: S): Gates<S> => {
15
11
  const out = {} as Record<string, EnvGate>
16
12
  for (const [name, keys] of Object.entries(spec)) {
17
- out[name] = requireEnv(keys)
13
+ out[name] = envHelper.requireEnv(keys)
18
14
  }
19
15
  return Object.freeze(out) as Gates<S>
20
16
  }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './env.js'
2
2
  export * from './gates.js'
3
3
  export * from './fixtures.js'
4
+ export type * from './types.js'
package/src/types.ts ADDED
@@ -0,0 +1,37 @@
1
+ export interface EnvOk {
2
+ ok: true
3
+ }
4
+
5
+ export interface EnvSkip {
6
+ skip: true
7
+ reason: string
8
+ }
9
+
10
+ // Kept as a type: a union of the two gate outcomes.
11
+ export type EnvGate = EnvOk | EnvSkip
12
+
13
+ // Kept as a type: a record over arbitrary gate names (an implicit index signature).
14
+ export type GateSpec = Record<string, string[]>
15
+
16
+ // Kept as a type: mapped over the spec's gate names.
17
+ export type Gates<S extends GateSpec> = { readonly [K in keyof S]: EnvGate }
18
+
19
+ export interface LoadEnvOptions {
20
+ /** Re-read the file even when it was already loaded once in this process. */
21
+ force?: boolean
22
+ /** The file to read instead of `<monorepo-root>/.env`. */
23
+ file?: string
24
+ }
25
+
26
+ /** The test environment: `<monorepo-root>/.env` merged into `process.env`, and gates over it. */
27
+ export interface EnvHelper {
28
+ /**
29
+ * Locate `<monorepo-root>/.env`, parse it and merge it into `process.env` — a variable that is
30
+ * already set and non-empty wins. Idempotent per process unless `force` is passed.
31
+ */
32
+ loadEnv: (opts?: LoadEnvOptions) => void
33
+ /** Whether a variable is set to a non-empty value (after {@link EnvHelper.loadEnv}). */
34
+ hasEnv: (key: string) => boolean
35
+ /** `{ ok: true }` when every key is set, otherwise `{ skip: true, reason }` naming the missing ones. */
36
+ requireEnv: (keys: string[]) => EnvGate
37
+ }