@blamejs/core 0.9.42 → 0.9.45

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.
@@ -0,0 +1,275 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.testHarness
4
+ * @nav DX
5
+ * @title Test Harness
6
+ * @order 820
7
+ *
8
+ * @intro
9
+ * Isolated-boot helper for framework-consumer test suites. Replaces
10
+ * the per-project pattern of:
11
+ *
12
+ * - mkdtemp a fresh data directory
13
+ * - set `MYAPP_DATA_DIR` / `MYAPP_DB_PATH` env vars so the app's
14
+ * boot path reads the test paths instead of production
15
+ * - init `b.vault` in plaintext mode against the test dataDir so
16
+ * primitives that compose vault don't try to read a real key
17
+ * - tear down: close vault, remove the temp directory, restore
18
+ * env vars
19
+ *
20
+ * That pattern lands as ~50-100 lines in every framework consumer's
21
+ * `tests/helpers/test-server.js`. This primitive owns it once.
22
+ *
23
+ * ## Lifecycle
24
+ *
25
+ * ```js
26
+ * var h = await b.testHarness.start({
27
+ * envPrefix: "MYAPP", // optional — env vars prefixed with this
28
+ * env: { LOG_LEVEL: "error" }, // optional — additional env vars to set
29
+ * initVault: true, // optional — init b.vault in plaintext mode
30
+ * resetCaches: true, // optional — call framework _resetForTest() hooks
31
+ * });
32
+ * // h.dataDir — operator-supplied or framework-generated mkdtemp path
33
+ * // h.dbPath — `<dataDir>/db.sqlite` unless operator overrides
34
+ * // h.vaultDir — `<dataDir>/vault`
35
+ *
36
+ * // ... operator's app boot reads process.env.MYAPP_DATA_DIR etc.
37
+ *
38
+ * await h.stop(); // teardown: close vault, remove dataDir, restore env
39
+ * ```
40
+ *
41
+ * ## Concurrent test isolation
42
+ *
43
+ * Tests using `SMOKE_PARALLEL=N` against the framework boot N processes
44
+ * in parallel — each one running this primitive gets its own
45
+ * `mkdtemp`-generated dataDir (collision-free) and its own env-var
46
+ * override scope (process-local). The harness does NOT use
47
+ * shared state; multiple `start()` calls in the same process create
48
+ * parallel handles.
49
+ *
50
+ * ## What the harness does NOT own
51
+ *
52
+ * - **The operator's HTTP server**. Consumers boot their own
53
+ * `app.listen(port)`. The harness only sets up paths + env +
54
+ * vault + cache-reset. The pattern in HS's
55
+ * `tests/helpers/test-server.js` mounts an Express app onto the
56
+ * harness's prepared paths.
57
+ * - **Per-request authentication state**. The harness doesn't
58
+ * mint session cookies / JWTs; tests that need that compose
59
+ * `b.session.create({ store: ... })` against the harness's
60
+ * paths.
61
+ * - **Audit replay tracking**. The harness emits no audit; the
62
+ * framework primitives the operator boots emit their own.
63
+ *
64
+ * ## When to use this vs the existing `_resetForTest()` hooks
65
+ *
66
+ * Framework primitives (vault, audit, db, …) expose
67
+ * `_resetForTest()` so a single test can scrub in-memory state
68
+ * without process-restart. The harness composes those resets +
69
+ * adds filesystem isolation. Use the harness when your test
70
+ * needs WRITE access to a fresh dataDir (file uploads, sealed
71
+ * db, audit-chain on disk); use the bare `_resetForTest()` hooks
72
+ * when in-memory state is enough.
73
+ *
74
+ * @card
75
+ * Isolated-boot helper for framework-consumer test suites. Owns the mkdtemp + env-vars + vault.init + teardown pattern that every consumer reinvents in their tests/helpers/test-server.js.
76
+ */
77
+
78
+ var nodeFs = require("node:fs");
79
+ var os = require("node:os");
80
+ var nodePath = require("node:path");
81
+ var bCrypto = require("./crypto");
82
+ var validateOpts = require("./validate-opts");
83
+ var { defineClass } = require("./framework-error");
84
+ var lazyRequire = require("./lazy-require");
85
+
86
+ var vault = lazyRequire(function () { return require("./vault"); });
87
+
88
+ var TestHarnessError = defineClass("TestHarnessError", { alwaysPermanent: true });
89
+
90
+ // Reference count of harnesses with initVault=true currently alive.
91
+ // vault state is process-global + idempotent across init() calls, so
92
+ // concurrent harnesses share a single initialized vault. The last
93
+ // harness to stop() releases the vault via _resetForTest; earlier
94
+ // stops decrement without tearing down so the still-running peers
95
+ // keep working.
96
+ var _vaultRefCount = 0;
97
+
98
+ /**
99
+ * @primitive b.testHarness.start
100
+ * @signature b.testHarness.start(opts?)
101
+ * @since 0.9.43
102
+ * @status stable
103
+ * @related b.vault.init
104
+ *
105
+ * Boot an isolated test harness. Returns a promise resolving to a
106
+ * handle exposing `dataDir`, `dbPath`, `vaultDir`, `env` (the env-var
107
+ * overrides set), and an async `stop()` that tears the harness down
108
+ * (releases vault, removes the temp directory, restores env). Always
109
+ * `await` the call — vault.init is async, and unawaited failures
110
+ * become unhandled rejections.
111
+ *
112
+ * Concurrent harnesses with `initVault: true` share the
113
+ * process-global vault state via reference counting; stopping one
114
+ * harness leaves vault initialized for the remaining peers. The
115
+ * last `stop()` releases vault.
116
+ *
117
+ * @opts
118
+ * dataDir: string, // optional — pre-existing dir to use; harness mkdtemps if absent
119
+ * dbPath: string, // optional — defaults to `<dataDir>/db.sqlite`
120
+ * vaultDir: string, // optional — defaults to `<dataDir>/vault`
121
+ * envPrefix: string, // optional — env vars `<PREFIX>_DATA_DIR` / `_DB_PATH` / `_VAULT_DIR`; default no prefix
122
+ * env: object, // optional — additional env-var overrides; restored on stop()
123
+ * initVault: boolean, // optional — boot b.vault in plaintext mode against vaultDir; default true
124
+ * keepOnStop: boolean, // optional — leave dataDir in place after stop(); default false (rm -rf)
125
+ *
126
+ * @example
127
+ * var h = await b.testHarness.start({ envPrefix: "MYAPP", initVault: true });
128
+ * try {
129
+ * // ... operator's app boot reads process.env.MYAPP_DATA_DIR etc.
130
+ * // ... run tests ...
131
+ * } finally {
132
+ * await h.stop();
133
+ * }
134
+ */
135
+ async function start(opts) {
136
+ opts = opts || {};
137
+ validateOpts.optionalNonEmptyString(opts.dataDir, "start.dataDir",
138
+ TestHarnessError, "test-harness/bad-input");
139
+ if (opts.envPrefix !== undefined && (typeof opts.envPrefix !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(opts.envPrefix))) { // allow:regex-no-length-cap — env-var prefix shape
140
+ throw new TestHarnessError("test-harness/bad-input",
141
+ "start: opts.envPrefix must be uppercase ASCII identifier (A-Z, 0-9, _)");
142
+ }
143
+ if (opts.env !== undefined && (opts.env === null || typeof opts.env !== "object" || Array.isArray(opts.env))) {
144
+ throw new TestHarnessError("test-harness/bad-input",
145
+ "start: opts.env must be a plain object if provided");
146
+ }
147
+
148
+ // Resolve / create dataDir.
149
+ var dataDir;
150
+ var weCreatedDataDir = false;
151
+ if (opts.dataDir) {
152
+ dataDir = nodePath.resolve(opts.dataDir);
153
+ nodeFs.mkdirSync(dataDir, { recursive: true });
154
+ } else {
155
+ // mkdtemp uses os.tmpdir + cryptographic suffix; collision-free
156
+ // even under SMOKE_PARALLEL=64 fan-out. Prefix surfaces the
157
+ // process owner for grep-on-leak diagnosis.
158
+ var prefix = nodePath.join(os.tmpdir(),
159
+ "blamejs-harness-" + bCrypto.generateToken(4) + "-"); // allow:raw-byte-literal — 4-byte token (8 hex) suffix
160
+ dataDir = nodeFs.mkdtempSync(prefix);
161
+ weCreatedDataDir = true;
162
+ }
163
+
164
+ var dbPath = opts.dbPath || nodePath.join(dataDir, "db.sqlite");
165
+ var vaultDir = opts.vaultDir || nodePath.join(dataDir, "vault");
166
+ nodeFs.mkdirSync(vaultDir, { recursive: true });
167
+
168
+ // Capture + set env vars. We restore on stop() — values absent
169
+ // pre-start are unset; values present are restored to their prior
170
+ // value. The harness is process-local; concurrent harnesses share
171
+ // process.env so the operator's envPrefix should be unique per
172
+ // harness (or omitted, in which case no env vars are set).
173
+ var envBackup = {};
174
+ function _setEnv(key, value) {
175
+ // First-write-wins on the backup so multiple writes to the same
176
+ // key (e.g. envPrefix + opts.env naming the same var) restore the
177
+ // ORIGINAL pre-harness value on stop(), not a harness-written
178
+ // intermediate. Object.prototype.hasOwnProperty.call guards
179
+ // against the `__proto__` / `constructor` key class.
180
+ if (!Object.prototype.hasOwnProperty.call(envBackup, key)) {
181
+ envBackup[key] = Object.prototype.hasOwnProperty.call(process.env, key)
182
+ ? process.env[key] : null;
183
+ }
184
+ process.env[key] = value;
185
+ }
186
+ if (opts.envPrefix) {
187
+ _setEnv(opts.envPrefix + "_DATA_DIR", dataDir);
188
+ _setEnv(opts.envPrefix + "_DB_PATH", dbPath);
189
+ _setEnv(opts.envPrefix + "_VAULT_DIR", vaultDir);
190
+ }
191
+ if (opts.env) {
192
+ for (var k in opts.env) {
193
+ if (Object.prototype.hasOwnProperty.call(opts.env, k)) {
194
+ _setEnv(k, String(opts.env[k]));
195
+ }
196
+ }
197
+ }
198
+
199
+ // Optional vault init. Default ON for the typical case where the
200
+ // operator's primitives compose vault. Operator opts out via
201
+ // `initVault: false` for tests that exercise vault.init themselves.
202
+ var initVault = opts.initVault !== false;
203
+ var ownsVaultRef = false;
204
+ if (initVault) {
205
+ try {
206
+ // vault.init is async; awaiting it ensures failures surface as
207
+ // a thrown TestHarnessError from start() (not an unhandled
208
+ // promise rejection after start() returns).
209
+ await vault().init({ dataDir: vaultDir, mode: "plaintext" });
210
+ _vaultRefCount += 1;
211
+ ownsVaultRef = true;
212
+ } catch (e) {
213
+ // Reset env + remove dataDir before re-throwing so the test
214
+ // doesn't leak a half-initialized state.
215
+ _restoreEnv(envBackup);
216
+ if (weCreatedDataDir && !opts.keepOnStop) {
217
+ try { nodeFs.rmSync(dataDir, { recursive: true, force: true }); }
218
+ catch (_e) { /* best-effort cleanup */ }
219
+ }
220
+ throw new TestHarnessError("test-harness/vault-init-failed",
221
+ "start: vault.init failed: " + (e && e.message || String(e)));
222
+ }
223
+ }
224
+
225
+ var stopped = false;
226
+ async function stop() {
227
+ if (stopped) return;
228
+ stopped = true;
229
+
230
+ // Vault teardown — _resetForTest is the framework convention for
231
+ // primitive scrub. Reference-counted so concurrent harnesses
232
+ // sharing the process-global vault don't tear it out from under
233
+ // each other; only the LAST owning harness's stop() resets.
234
+ if (initVault && ownsVaultRef) {
235
+ ownsVaultRef = false;
236
+ _vaultRefCount = Math.max(0, _vaultRefCount - 1);
237
+ if (_vaultRefCount === 0) {
238
+ try {
239
+ var v = vault();
240
+ if (typeof v._resetForTest === "function") v._resetForTest();
241
+ } catch (_e) { /* best-effort */ }
242
+ }
243
+ }
244
+
245
+ _restoreEnv(envBackup);
246
+
247
+ if (weCreatedDataDir && !opts.keepOnStop) {
248
+ try { nodeFs.rmSync(dataDir, { recursive: true, force: true }); }
249
+ catch (_e) { /* best-effort — operator can inspect on leak */ }
250
+ }
251
+ }
252
+
253
+ return {
254
+ dataDir: dataDir,
255
+ dbPath: dbPath,
256
+ vaultDir: vaultDir,
257
+ envPrefix: opts.envPrefix || null,
258
+ initVault: initVault,
259
+ stop: stop,
260
+ TestHarnessError: TestHarnessError,
261
+ };
262
+ }
263
+
264
+ function _restoreEnv(backup) {
265
+ for (var k in backup) {
266
+ if (!Object.prototype.hasOwnProperty.call(backup, k)) continue;
267
+ if (backup[k] === null) delete process.env[k];
268
+ else process.env[k] = backup[k];
269
+ }
270
+ }
271
+
272
+ module.exports = {
273
+ start: start,
274
+ TestHarnessError: TestHarnessError,
275
+ };
package/lib/watcher.js CHANGED
@@ -178,7 +178,145 @@ function _compileIgnore(patterns) {
178
178
  };
179
179
  }
180
180
 
181
- var ALLOWED_MODES = ["fs", "poll"];
181
+ var ALLOWED_MODES = ["fs", "poll", "auto"];
182
+
183
+ // Filesystem types that the recursive fs.watch backend doesn't deliver
184
+ // events on in practice. Detected from /proc/self/mountinfo when the
185
+ // watcher's root resolves into one of these mounts.
186
+ //
187
+ // - fuse / fuse.<driver>: Docker Desktop on macOS uses gRPC-FUSE for
188
+ // bind-mounts; Windows Docker Desktop with VirtioFS-backed WSL2 ships
189
+ // in the same family. Native Linux containers running on overlayfs
190
+ // inside a bind-mounted host directory don't propagate inotify events
191
+ // across the gRPC-FUSE boundary; libuv's recursive fs.watch
192
+ // silently observes no events for the lifetime of the watcher.
193
+ // - 9p: WSL2 host-to-Linux filesystem when running on Windows;
194
+ // doesn't propagate change events to Linux inotify.
195
+ // - virtiofs: newer Docker Desktop default on Apple Silicon Macs;
196
+ // inotify is forwarded but recursive coverage is unreliable.
197
+ // - cifs / smbfs / nfs / nfs4: network filesystems where the server
198
+ // doesn't push change notifications to the client kernel.
199
+ //
200
+ // Operators on a container running over one of these mounts default
201
+ // to poll under mode: "auto".
202
+ var AUTO_PROBE_POLL_FSTYPES = new Set([
203
+ "fuse",
204
+ "fuse.gcsfuse",
205
+ "fuse.grpcfuse",
206
+ "fuse.virtiofs",
207
+ "9p",
208
+ "virtiofs",
209
+ "cifs",
210
+ "smbfs",
211
+ "nfs",
212
+ "nfs4",
213
+ "vboxsf",
214
+ ]);
215
+
216
+ function _detectAutoMode(rootPath) {
217
+ // Sync probe — looks at the kernel's view of the mount carrying the
218
+ // watcher's root and decides whether fs.watch will deliver events.
219
+ // Three signals contribute, in priority order:
220
+ // 1. /proc/self/mountinfo entry for the root's longest-matching
221
+ // mount — if its fstype is in AUTO_PROBE_POLL_FSTYPES, poll.
222
+ // 2. Bind-mount detection — mountinfo field 4 ("root within source
223
+ // filesystem", per Documentation/filesystems/proc.rst §3.5) is
224
+ // "/" for a regular mount but the bound source path for a bind
225
+ // mount. Inside a container, a field-4 != "/" indicates a host
226
+ // bind-mount whose inotify chain may not propagate across the
227
+ // virtualization boundary; poll.
228
+ // 3. Otherwise — fs.
229
+ //
230
+ // Returns { mode, reason, fsType, inContainer }.
231
+ if (process.platform !== "linux") {
232
+ // macOS + Windows fs.watch backends use FSEvents + ReadDirectoryChangesW
233
+ // respectively, which DO deliver recursive events natively for
234
+ // operator-owned local filesystems. Containerized Linux is the
235
+ // failure mode this probe is built for.
236
+ return { mode: "fs", reason: "non-linux-host", fsType: null, inContainer: false };
237
+ }
238
+
239
+ var inContainer = false;
240
+ try { inContainer = nodeFs.existsSync("/.dockerenv"); }
241
+ catch (_e) { inContainer = false; }
242
+
243
+ var mountInfoRaw = null;
244
+ try { mountInfoRaw = nodeFs.readFileSync("/proc/self/mountinfo", "utf8"); }
245
+ catch (_e) { mountInfoRaw = null; }
246
+
247
+ if (!mountInfoRaw) {
248
+ // No mountinfo available — fall back to fs. Operator can still
249
+ // override explicitly via mode: "poll".
250
+ return { mode: "fs", reason: "no-mountinfo", fsType: null, inContainer: inContainer };
251
+ }
252
+
253
+ // Find the mount whose mount-point is the longest prefix of rootPath.
254
+ var lines = mountInfoRaw.split("\n");
255
+ var bestMatch = null;
256
+ var bestLen = -1;
257
+ for (var i = 0; i < lines.length; i += 1) {
258
+ var ln = lines[i];
259
+ if (!ln) continue;
260
+ // Format: <id> <parent> <major:minor> <root> <mountpoint> <options>
261
+ // [<optional-fields>...] - <fstype> <source> <super-options>
262
+ // The separator " - " divides the optional-fields half from the post-fields half.
263
+ var sepIdx = ln.indexOf(" - ");
264
+ if (sepIdx === -1) continue;
265
+ var preFields = ln.slice(0, sepIdx).split(" ");
266
+ var postFields = ln.slice(sepIdx + 3).split(" ");
267
+ if (preFields.length < 6 || postFields.length < 1) continue;
268
+ var rootField = preFields[3]; // "/" for regular mount; bound-source path for bind
269
+ var mountPoint = preFields[4];
270
+ var fstype = postFields[0];
271
+ if (typeof mountPoint !== "string" || mountPoint.length === 0) continue;
272
+ if (rootPath === mountPoint ||
273
+ (rootPath.length > mountPoint.length &&
274
+ rootPath.indexOf(mountPoint) === 0 &&
275
+ (mountPoint === "/" || rootPath.charCodeAt(mountPoint.length) === 47 /* / */))) {
276
+ if (mountPoint.length > bestLen) {
277
+ bestLen = mountPoint.length;
278
+ bestMatch = { mountPoint: mountPoint, rootField: rootField, fstype: fstype };
279
+ }
280
+ }
281
+ }
282
+
283
+ if (!bestMatch) {
284
+ return { mode: "fs", reason: "no-mount-match", fsType: null, inContainer: inContainer };
285
+ }
286
+
287
+ if (AUTO_PROBE_POLL_FSTYPES.has(bestMatch.fstype)) {
288
+ return {
289
+ mode: "poll",
290
+ reason: "fstype-non-inotify",
291
+ fsType: bestMatch.fstype,
292
+ inContainer: inContainer,
293
+ };
294
+ }
295
+
296
+ // Bind-mount detection via mountinfo field 4 ("root"). For a regular
297
+ // mount this is "/" — the entire source filesystem is mounted. For a
298
+ // bind-mount it's the path within the source filesystem that was
299
+ // bound onto the mount point (e.g. "/Users/me/data" on a Docker
300
+ // Desktop bind from macOS). When we're inside a container AND the
301
+ // best-matching mount carries a non-"/" root, the mount is a bind
302
+ // and inotify chains across the host/guest boundary are unreliable.
303
+ // (Operator can still force fs via mode: "fs"; force poll via mode: "poll".)
304
+ if (inContainer && bestMatch.rootField && bestMatch.rootField !== "/") {
305
+ return {
306
+ mode: "poll",
307
+ reason: "container-bind-mount",
308
+ fsType: bestMatch.fstype,
309
+ inContainer: inContainer,
310
+ };
311
+ }
312
+
313
+ return {
314
+ mode: "fs",
315
+ reason: "native-fs",
316
+ fsType: bestMatch.fstype,
317
+ inContainer: inContainer,
318
+ };
319
+ }
182
320
 
183
321
  function _validateOpts(opts) {
184
322
  validateOpts.requireObject(opts, "watcher.create", WatcherError, "watcher/bad-opts");
@@ -215,7 +353,12 @@ function create(opts) {
215
353
  var root = nodePath.resolve(opts.root);
216
354
  var debounceMs = (opts.debounceMs !== undefined) ? opts.debounceMs : DEFAULT_DEBOUNCE_MS;
217
355
  var maxPending = (opts.maxPending !== undefined) ? opts.maxPending : DEFAULT_MAX_PENDING;
218
- var mode = opts.mode || "fs";
356
+ var requestedMode = opts.mode || "fs";
357
+ var autoDecision = null;
358
+ if (requestedMode === "auto") {
359
+ autoDecision = _detectAutoMode(root);
360
+ }
361
+ var mode = autoDecision ? autoDecision.mode : requestedMode;
219
362
  var pollIntervalMs = opts.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
220
363
  var pollMaxFiles = opts.pollMaxFiles || DEFAULT_POLL_MAX_FILES;
221
364
  var onChange = opts.onChange || function () {};
@@ -438,7 +581,16 @@ function create(opts) {
438
581
  }
439
582
  }
440
583
 
441
- _safeEmitAudit("watcher.started", { root: root, mode: mode });
584
+ if (autoDecision) {
585
+ _safeEmitAudit("watcher.mode_auto_decision", {
586
+ root: root,
587
+ chosen: autoDecision.mode,
588
+ reason: autoDecision.reason,
589
+ fsType: autoDecision.fsType,
590
+ inContainer: autoDecision.inContainer,
591
+ });
592
+ }
593
+ _safeEmitAudit("watcher.started", { root: root, mode: mode, requestedMode: requestedMode });
442
594
 
443
595
  function stop() {
444
596
  if (stopped) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.9.42",
3
+ "version": "0.9.45",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.6",
5
- "serialNumber": "urn:uuid:73f521a3-612c-45e1-8d73-dfeb13b9231f",
5
+ "serialNumber": "urn:uuid:b87538c7-3bfe-497b-aa53-9191876a4e1f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-15T15:59:31.588Z",
8
+ "timestamp": "2026-05-15T19:42:01.531Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.9.42",
22
+ "bom-ref": "@blamejs/core@0.9.45",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.9.42",
25
+ "version": "0.9.45",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.9.42",
29
+ "purl": "pkg:npm/%40blamejs/core@0.9.45",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.9.42",
57
+ "ref": "@blamejs/core@0.9.45",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]