@unrift/core 0.3.0 → 0.4.0

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.
Files changed (51) hide show
  1. package/bin/unrift.mjs +2 -1
  2. package/dist/esm/cli.js +5 -0
  3. package/dist/esm/expect.d.ts +12 -0
  4. package/dist/esm/expect.d.ts.map +1 -1
  5. package/dist/esm/index.d.ts +2 -0
  6. package/dist/esm/index.d.ts.map +1 -1
  7. package/dist/esm/index.js +2 -0
  8. package/dist/esm/matchers.d.ts.map +1 -1
  9. package/dist/esm/matchers.js +161 -1
  10. package/dist/esm/mock/auto.d.ts +27 -0
  11. package/dist/esm/mock/auto.d.ts.map +1 -0
  12. package/dist/esm/mock/auto.js +108 -0
  13. package/dist/esm/mock/fetch.d.ts +47 -0
  14. package/dist/esm/mock/fetch.d.ts.map +1 -0
  15. package/dist/esm/mock/fetch.js +149 -0
  16. package/dist/esm/mock/fs.d.ts +54 -0
  17. package/dist/esm/mock/fs.d.ts.map +1 -0
  18. package/dist/esm/mock/fs.js +125 -0
  19. package/dist/esm/mock/index.d.ts +70 -0
  20. package/dist/esm/mock/index.d.ts.map +1 -0
  21. package/dist/esm/mock/index.js +70 -0
  22. package/dist/esm/mock/module-loader.d.ts +66 -0
  23. package/dist/esm/mock/module-loader.d.ts.map +1 -0
  24. package/dist/esm/mock/module-loader.js +123 -0
  25. package/dist/esm/mock/module.d.ts +99 -0
  26. package/dist/esm/mock/module.d.ts.map +1 -0
  27. package/dist/esm/mock/module.js +198 -0
  28. package/dist/esm/mock/register-loader.d.ts +17 -0
  29. package/dist/esm/mock/register-loader.d.ts.map +1 -0
  30. package/dist/esm/mock/register-loader.js +45 -0
  31. package/dist/esm/mock/spy.d.ts +73 -0
  32. package/dist/esm/mock/spy.d.ts.map +1 -0
  33. package/dist/esm/mock/spy.js +307 -0
  34. package/dist/esm/mock/stub.d.ts +26 -0
  35. package/dist/esm/mock/stub.d.ts.map +1 -0
  36. package/dist/esm/mock/stub.js +82 -0
  37. package/dist/esm/mock/timers.d.ts +51 -0
  38. package/dist/esm/mock/timers.d.ts.map +1 -0
  39. package/dist/esm/mock/timers.js +581 -0
  40. package/dist/esm/runner.d.ts.map +1 -1
  41. package/dist/esm/runner.js +13 -8
  42. package/dist/esm/test.d.ts.map +1 -1
  43. package/dist/esm/test.js +12 -5
  44. package/dist/esm/utils/compareEngine.js +3 -3
  45. package/dist/esm/utils/discoverTestFiles.js +1 -1
  46. package/dist/esm/utils/helpers.d.ts +10 -0
  47. package/dist/esm/utils/helpers.d.ts.map +1 -1
  48. package/dist/esm/utils/helpers.js +13 -0
  49. package/dist/esm/utils/toThrow.d.ts.map +1 -1
  50. package/dist/esm/utils/toThrow.js +2 -1
  51. package/package.json +4 -3
@@ -0,0 +1,54 @@
1
+ /**
2
+ * File system mocking - `mock.fs()`.
3
+ *
4
+ * Backed by `memfs` today (private implementation detail behind the
5
+ * `FakeFsBackend` interface). The plan is to swap memfs for a hand-rolled
6
+ * package later; consumers of this module should only depend on the public
7
+ * `FakeFsHandle` surface, not on memfs types directly.
8
+ *
9
+ * Interception is layered on Tier A module mocking - `mock.doMock` is
10
+ * called for `node:fs` and `node:fs/promises`, so the fake takes effect
11
+ * when test code does `await import("node:fs")` or `await import("node:fs/promises")`.
12
+ * Static `import { readFileSync } from "node:fs"` is NOT intercepted in
13
+ * Tier A (planned for Tier B). See the docs page for the explicit limit
14
+ * list and workarounds.
15
+ */
16
+ /**
17
+ * `Volume.fromJSON()` accepts a path-to-content map. `null` represents an
18
+ * empty directory; strings are file contents.
19
+ */
20
+ export type FakeFsState = Record<string, string | null>;
21
+ /** Public handle returned by `mock.fs()`. */
22
+ export interface FakeFsHandle {
23
+ /** Dump the current state as a path-to-content map. */
24
+ toJSON(): FakeFsState;
25
+ /** Replace the current state with the given map. */
26
+ fromJSON(state: FakeFsState): void;
27
+ /** Read a file's contents, or null if it doesn't exist. */
28
+ read(path: string): string | null;
29
+ /** Write a file, creating parent directories as needed. */
30
+ write(path: string, content: string): void;
31
+ /** Check whether a path exists in the fake fs. */
32
+ exists(path: string): boolean;
33
+ /** Delete a file. Returns true if something was removed. */
34
+ delete(path: string): boolean;
35
+ /** List entries in a directory. Returns an empty array if missing. */
36
+ list(dir: string): string[];
37
+ /**
38
+ * Unpatch node:fs and node:fs/promises for this handle's interception.
39
+ * Idempotent, and a no-op if a later mock.fs() call has already superseded
40
+ * this handle - so holding an old handle for snapshotting can't tear down
41
+ * the current volume's interception.
42
+ */
43
+ restore(): void;
44
+ }
45
+ /**
46
+ * Install an in-memory file system as `node:fs` and `node:fs/promises`
47
+ * for the duration of the test. Subsequent `await import("node:fs")`
48
+ * calls return the fake.
49
+ *
50
+ * Calling `mock.fs()` while a fake is already installed swaps in a fresh
51
+ * volume - the previous handle's references stop receiving updates.
52
+ */
53
+ export declare function fs(initial?: FakeFsState): FakeFsHandle;
54
+ //# sourceMappingURL=fs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs.d.ts","sourceRoot":"","sources":["../../../src/mock/fs.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAOH;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;AAqFxD,6CAA6C;AAC7C,MAAM,WAAW,YAAY;IAC3B,uDAAuD;IACvD,MAAM,IAAI,WAAW,CAAC;IACtB,oDAAoD;IACpD,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IACnC,2DAA2D;IAC3D,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAClC,2DAA2D;IAC3D,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,kDAAkD;IAClD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9B,4DAA4D;IAC5D,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9B,sEAAsE;IACtE,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC5B;;;;;OAKG;IACH,OAAO,IAAI,IAAI,CAAC;CACjB;AAeD;;;;;;;GAOG;AACH,wBAAgB,EAAE,CAAC,OAAO,CAAC,EAAE,WAAW,GAAG,YAAY,CAkCtD"}
@@ -0,0 +1,125 @@
1
+ /**
2
+ * File system mocking - `mock.fs()`.
3
+ *
4
+ * Backed by `memfs` today (private implementation detail behind the
5
+ * `FakeFsBackend` interface). The plan is to swap memfs for a hand-rolled
6
+ * package later; consumers of this module should only depend on the public
7
+ * `FakeFsHandle` surface, not on memfs types directly.
8
+ *
9
+ * Interception is layered on Tier A module mocking - `mock.doMock` is
10
+ * called for `node:fs` and `node:fs/promises`, so the fake takes effect
11
+ * when test code does `await import("node:fs")` or `await import("node:fs/promises")`.
12
+ * Static `import { readFileSync } from "node:fs"` is NOT intercepted in
13
+ * Tier A (planned for Tier B). See the docs page for the explicit limit
14
+ * list and workarounds.
15
+ */
16
+ import { Volume, createFsFromVolume } from "memfs";
17
+ import { doMock, unmock } from "./module.js";
18
+ import { activeRestorers } from "./spy.js";
19
+ /**
20
+ * The current implementation factory. Swap this single line when replacing
21
+ * memfs with a hand-rolled backend - no other code changes needed.
22
+ */
23
+ function createBackend(initial) {
24
+ const vol = Volume.fromJSON(initial ?? {});
25
+ const fsExports = createFsFromVolume(vol);
26
+ // memfs's createFsFromVolume returns the fs surface; .promises is on it.
27
+ const fsPromisesExports = fsExports
28
+ .promises;
29
+ return {
30
+ fsExports,
31
+ fsPromisesExports,
32
+ toJSON: () => vol.toJSON(),
33
+ fromJSON: (state) => {
34
+ vol.reset();
35
+ vol.fromJSON(state);
36
+ },
37
+ readFile: (path) => {
38
+ try {
39
+ return vol.readFileSync(path, "utf8");
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ },
45
+ writeFile: (path, content) => {
46
+ // memfs throws if the parent directory doesn't exist; create it for
47
+ // ergonomic test code.
48
+ const lastSlash = path.lastIndexOf("/");
49
+ if (lastSlash > 0) {
50
+ vol.mkdirSync(path.slice(0, lastSlash), { recursive: true });
51
+ }
52
+ vol.writeFileSync(path, content);
53
+ },
54
+ exists: (path) => vol.existsSync(path),
55
+ remove: (path) => {
56
+ try {
57
+ vol.unlinkSync(path);
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ },
64
+ list: (dir) => {
65
+ try {
66
+ return vol.readdirSync(dir);
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ },
72
+ };
73
+ }
74
+ // Process-global singleton: only one fake fs interception is active at a time.
75
+ // Safe under sequential per-file execution + afterEach(restoreAll); parallel
76
+ // per-file execution (see ISSUES.md) would need per-worker isolation.
77
+ let activeBackend;
78
+ const restoreFakeFs = () => {
79
+ if (!activeBackend)
80
+ return;
81
+ unmock("node:fs");
82
+ unmock("node:fs/promises");
83
+ activeBackend = undefined;
84
+ activeRestorers.delete(restoreFakeFs);
85
+ };
86
+ /**
87
+ * Install an in-memory file system as `node:fs` and `node:fs/promises`
88
+ * for the duration of the test. Subsequent `await import("node:fs")`
89
+ * calls return the fake.
90
+ *
91
+ * Calling `mock.fs()` while a fake is already installed swaps in a fresh
92
+ * volume - the previous handle's references stop receiving updates.
93
+ */
94
+ export function fs(initial) {
95
+ // Tear down any existing fake before installing the new one so the
96
+ // module-mocking registrations don't stack.
97
+ if (activeBackend) {
98
+ unmock("node:fs");
99
+ unmock("node:fs/promises");
100
+ }
101
+ const backend = createBackend(initial);
102
+ activeBackend = backend;
103
+ doMock("node:fs", () => backend.fsExports);
104
+ doMock("node:fs/promises", () => backend.fsPromisesExports);
105
+ activeRestorers.add(restoreFakeFs);
106
+ const handle = {
107
+ toJSON: () => backend.toJSON(),
108
+ fromJSON: (state) => backend.fromJSON(state),
109
+ read: (path) => backend.readFile(path),
110
+ write: (path, content) => backend.writeFile(path, content),
111
+ exists: (path) => backend.exists(path),
112
+ delete: (path) => backend.remove(path),
113
+ list: (dir) => backend.list(dir),
114
+ // Only tear down the interception if THIS handle still owns it. A later
115
+ // mock.fs() call orphans this handle - its volume lives on for snapshot
116
+ // inspection, but restoring it must not rip out the newer volume's
117
+ // interception. On an orphaned handle this is a no-op (also makes restore
118
+ // idempotent: a second call finds activeBackend already cleared).
119
+ restore: () => {
120
+ if (activeBackend === backend)
121
+ restoreFakeFs();
122
+ },
123
+ };
124
+ return handle;
125
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `mock` namespace - single entry point for all mocking utilities.
3
+ *
4
+ * Both forms are supported:
5
+ * import { mock } from "unrift";
6
+ * mock.fn();
7
+ * mock.spyOn(obj, "method");
8
+ *
9
+ * import { spy, spyOn } from "unrift";
10
+ * spy();
11
+ *
12
+ * They produce identical objects.
13
+ */
14
+ import { spy, spyOn, isSpy, clearAllMocks, resetAllMocks, restoreAllMocks } from "./spy";
15
+ import { mockObject, mockClass } from "./auto";
16
+ import { stub, mockGlobal } from "./stub";
17
+ import { mockFetch, mockFetchOnce } from "./fetch";
18
+ import { useFakeTimers, useRealTimers, advanceTimersByTime, advanceTimersByTimeAsync, runAllTimers, runAllTimersAsync, runOnlyPendingTimers, runOnlyPendingTimersAsync, getTimerCount, setSystemTime, getRealSystemTime } from "./timers";
19
+ import { doMock, unmock, listMockedSpecs, fromModule } from "./module";
20
+ import { fs as mockFs } from "./fs";
21
+ export { spy, spyOn, isSpy, mockObject, mockClass, stub, mockGlobal, mockFetch, mockFetchOnce, useFakeTimers, useRealTimers, advanceTimersByTime, advanceTimersByTimeAsync, runAllTimers, runAllTimersAsync, runOnlyPendingTimers, runOnlyPendingTimersAsync, getTimerCount, setSystemTime, getRealSystemTime, doMock, unmock, listMockedSpecs, fromModule, mockFs, clearAllMocks, resetAllMocks, restoreAllMocks, };
22
+ export type { Spy, MockState, MockResult, AnyFn } from "./spy";
23
+ export type { MockFetch, FetchMatcher, MockResponseInit, MockFetchResponse, } from "./fetch";
24
+ export type { UseFakeTimersOptions, FakeableApi } from "./timers";
25
+ export type { LoaderMessage } from "./module";
26
+ export type { FakeFsHandle, FakeFsState } from "./fs";
27
+ /**
28
+ * Unified mock namespace. Mirrors Jest / Vitest naming so existing test
29
+ * code reads naturally:
30
+ *
31
+ * mock.fn(impl?) // spy()
32
+ * mock.spyOn(obj, key)
33
+ * mock.object(obj) // auto-spy every method
34
+ * mock.class(Ctor) // auto-spy every method on instances
35
+ * mock.global(key, value) // stub on globalThis
36
+ * mock.stub(target, key, value)
37
+ * mock.clearAll()
38
+ * mock.resetAll()
39
+ * mock.restoreAll()
40
+ */
41
+ export declare const mock: {
42
+ readonly fn: typeof spy;
43
+ readonly spyOn: typeof spyOn;
44
+ readonly object: typeof mockObject;
45
+ readonly class: typeof mockClass;
46
+ readonly global: typeof mockGlobal;
47
+ readonly stub: typeof stub;
48
+ readonly fetch: import("./fetch").MockFetch;
49
+ readonly fetchOnce: typeof mockFetchOnce;
50
+ readonly useFakeTimers: typeof useFakeTimers;
51
+ readonly useRealTimers: typeof useRealTimers;
52
+ readonly advanceTimersByTime: typeof advanceTimersByTime;
53
+ readonly advanceTimersByTimeAsync: typeof advanceTimersByTimeAsync;
54
+ readonly runAllTimers: typeof runAllTimers;
55
+ readonly runAllTimersAsync: typeof runAllTimersAsync;
56
+ readonly runOnlyPendingTimers: typeof runOnlyPendingTimers;
57
+ readonly runOnlyPendingTimersAsync: typeof runOnlyPendingTimersAsync;
58
+ readonly getTimerCount: typeof getTimerCount;
59
+ readonly setSystemTime: typeof setSystemTime;
60
+ readonly getRealSystemTime: typeof getRealSystemTime;
61
+ readonly doMock: typeof doMock;
62
+ readonly unmock: typeof unmock;
63
+ readonly fromModule: typeof fromModule;
64
+ readonly listMockedSpecs: typeof listMockedSpecs;
65
+ readonly fs: typeof mockFs;
66
+ readonly clearAll: typeof clearAllMocks;
67
+ readonly resetAll: typeof resetAllMocks;
68
+ readonly restoreAll: typeof restoreAllMocks;
69
+ };
70
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/mock/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EACL,GAAG,EACH,KAAK,EACL,KAAK,EACL,aAAa,EACb,aAAa,EACb,eAAe,EAChB,MAAM,OAAO,CAAC;AACf,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AAC/C,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EACL,aAAa,EACb,aAAa,EACb,mBAAmB,EACnB,wBAAwB,EACxB,YAAY,EACZ,iBAAiB,EACjB,oBAAoB,EACpB,yBAAyB,EACzB,aAAa,EACb,aAAa,EACb,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACvE,OAAO,EAAE,EAAE,IAAI,MAAM,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,EACL,GAAG,EACH,KAAK,EACL,KAAK,EACL,UAAU,EACV,SAAS,EACT,IAAI,EACJ,UAAU,EACV,SAAS,EACT,aAAa,EACb,aAAa,EACb,aAAa,EACb,mBAAmB,EACnB,wBAAwB,EACxB,YAAY,EACZ,iBAAiB,EACjB,oBAAoB,EACpB,yBAAyB,EACzB,aAAa,EACb,aAAa,EACb,iBAAiB,EACjB,MAAM,EACN,MAAM,EACN,eAAe,EACf,UAAU,EACV,MAAM,EACN,aAAa,EACb,aAAa,EACb,eAAe,GAChB,CAAC;AAEF,YAAY,EAAE,GAAG,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAC/D,YAAY,EACV,SAAS,EACT,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,oBAAoB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAClE,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC9C,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,MAAM,CAAC;AAEtD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCP,CAAC"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `mock` namespace - single entry point for all mocking utilities.
3
+ *
4
+ * Both forms are supported:
5
+ * import { mock } from "unrift";
6
+ * mock.fn();
7
+ * mock.spyOn(obj, "method");
8
+ *
9
+ * import { spy, spyOn } from "unrift";
10
+ * spy();
11
+ *
12
+ * They produce identical objects.
13
+ */
14
+ import { spy, spyOn, isSpy, clearAllMocks, resetAllMocks, restoreAllMocks, } from "./spy.js";
15
+ import { mockObject, mockClass } from "./auto.js";
16
+ import { stub, mockGlobal } from "./stub.js";
17
+ import { mockFetch, mockFetchOnce } from "./fetch.js";
18
+ import { useFakeTimers, useRealTimers, advanceTimersByTime, advanceTimersByTimeAsync, runAllTimers, runAllTimersAsync, runOnlyPendingTimers, runOnlyPendingTimersAsync, getTimerCount, setSystemTime, getRealSystemTime, } from "./timers.js";
19
+ import { doMock, unmock, listMockedSpecs, fromModule } from "./module.js";
20
+ import { fs as mockFs } from "./fs.js";
21
+ export { spy, spyOn, isSpy, mockObject, mockClass, stub, mockGlobal, mockFetch, mockFetchOnce, useFakeTimers, useRealTimers, advanceTimersByTime, advanceTimersByTimeAsync, runAllTimers, runAllTimersAsync, runOnlyPendingTimers, runOnlyPendingTimersAsync, getTimerCount, setSystemTime, getRealSystemTime, doMock, unmock, listMockedSpecs, fromModule, mockFs, clearAllMocks, resetAllMocks, restoreAllMocks, };
22
+ /**
23
+ * Unified mock namespace. Mirrors Jest / Vitest naming so existing test
24
+ * code reads naturally:
25
+ *
26
+ * mock.fn(impl?) // spy()
27
+ * mock.spyOn(obj, key)
28
+ * mock.object(obj) // auto-spy every method
29
+ * mock.class(Ctor) // auto-spy every method on instances
30
+ * mock.global(key, value) // stub on globalThis
31
+ * mock.stub(target, key, value)
32
+ * mock.clearAll()
33
+ * mock.resetAll()
34
+ * mock.restoreAll()
35
+ */
36
+ export const mock = {
37
+ fn: spy,
38
+ spyOn,
39
+ object: mockObject,
40
+ class: mockClass,
41
+ global: mockGlobal,
42
+ stub,
43
+ fetch: mockFetch,
44
+ fetchOnce: mockFetchOnce,
45
+ useFakeTimers,
46
+ useRealTimers,
47
+ advanceTimersByTime,
48
+ advanceTimersByTimeAsync,
49
+ runAllTimers,
50
+ runAllTimersAsync,
51
+ runOnlyPendingTimers,
52
+ runOnlyPendingTimersAsync,
53
+ getTimerCount,
54
+ setSystemTime,
55
+ getRealSystemTime,
56
+ // Tier A module mocking - dynamic imports of external specs only.
57
+ // The hoisted `mock.module(spec, factory)` for static imports is
58
+ // planned for Tier B and intentionally not exposed yet.
59
+ doMock,
60
+ unmock,
61
+ fromModule,
62
+ listMockedSpecs,
63
+ // File system mocking - backed by memfs, layered on Tier A module
64
+ // mocking. Works with `await import("node:fs")`; static imports of
65
+ // node:fs are not intercepted yet (planned for Tier B).
66
+ fs: mockFs,
67
+ clearAll: clearAllMocks,
68
+ resetAll: resetAllMocks,
69
+ restoreAll: restoreAllMocks,
70
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Node module customization hook for unrift module mocking (Tier A).
3
+ *
4
+ * Registered by `bin/unrift.mjs` via `module.register()`. Runs in a
5
+ * worker thread (Node 20.6+ / 18.19+ behavior). Communicates with the
6
+ * main thread via a `MessagePort` to learn which specs have been mocked.
7
+ *
8
+ * Why we use `receiveMessageOnPort()` instead of `port.on("message")`:
9
+ * the resolve hook runs synchronously from the perspective of the
10
+ * import pipeline, but Worker `MessagePort` listeners are async.
11
+ * `receiveMessageOnPort()` (Node 18.7+) drains pending messages
12
+ * synchronously so registrations posted from the main thread are
13
+ * visible by the time the corresponding `await import(spec)` reaches
14
+ * us.
15
+ *
16
+ * The generated synthetic module source runs back in the MAIN thread
17
+ * (Node's loader output is evaluated in the importing thread, not the
18
+ * loader thread). That source reads exports from `globalThis.__unriftMocks`,
19
+ * which the main thread populates in `mock.doMock()`.
20
+ *
21
+ * Source compiled by tsc and shipped at `dist/esm/mock/module-loader.js`.
22
+ */
23
+ import { type MessagePort } from "node:worker_threads";
24
+ interface InitializeData {
25
+ port: MessagePort;
26
+ }
27
+ /**
28
+ * Generate ESM source for a mocked spec. The source executes in the
29
+ * MAIN thread (not the loader thread) - that's where the actual mock
30
+ * object lives, keyed in `globalThis.__unriftMocks`.
31
+ *
32
+ * Pure function so future tiers can reuse this for hoisted (Tier B)
33
+ * and relative (Tier C) mocks without rewriting the emitter.
34
+ */
35
+ export declare function generateMockSource(spec: string, exportKeys: readonly string[]): string;
36
+ /** Node hook: called once when the loader is registered. */
37
+ export declare function initialize(data: InitializeData): void;
38
+ interface ResolveContext {
39
+ conditions: readonly string[];
40
+ importAttributes?: Record<string, string>;
41
+ parentURL?: string;
42
+ }
43
+ interface ResolveResult {
44
+ url: string;
45
+ format?: string;
46
+ shortCircuit?: boolean;
47
+ importAttributes?: Record<string, string>;
48
+ }
49
+ type DefaultResolve = (specifier: string, context: ResolveContext) => ResolveResult | Promise<ResolveResult>;
50
+ /** Node hook: short-circuit mocked specs to a `unrift-mock:` URL. */
51
+ export declare function resolve(specifier: string, context: ResolveContext, nextResolve: DefaultResolve): Promise<ResolveResult>;
52
+ interface LoadContext {
53
+ format?: string;
54
+ importAttributes?: Record<string, string>;
55
+ conditions?: readonly string[];
56
+ }
57
+ interface LoadResult {
58
+ format: string;
59
+ source?: string | ArrayBuffer | Uint8Array;
60
+ shortCircuit?: boolean;
61
+ }
62
+ type DefaultLoad = (url: string, context: LoadContext) => LoadResult | Promise<LoadResult>;
63
+ /** Node hook: emit synthetic ESM source for mocked specs. */
64
+ export declare function load(url: string, context: LoadContext, nextLoad: DefaultLoad): Promise<LoadResult>;
65
+ export {};
66
+ //# sourceMappingURL=module-loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module-loader.d.ts","sourceRoot":"","sources":["../../../src/mock/module-loader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAwB,KAAK,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAQ7E,UAAU,cAAc;IACtB,IAAI,EAAE,WAAW,CAAC;CACnB;AAmCD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,SAAS,MAAM,EAAE,GAC5B,MAAM,CAuBR;AAED,4DAA4D;AAC5D,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI,CAErD;AAED,UAAU,cAAc;IACtB,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9B,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,aAAa;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED,KAAK,cAAc,GAAG,CACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,KACpB,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;AAE5C,qEAAqE;AACrE,wBAAsB,OAAO,CAC3B,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,EACvB,WAAW,EAAE,cAAc,GAC1B,OAAO,CAAC,aAAa,CAAC,CAexB;AAED,UAAU,WAAW;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAChC;AAED,UAAU,UAAU;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,GAAG,WAAW,GAAG,UAAU,CAAC;IAC3C,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,KAAK,WAAW,GAAG,CACjB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,KACjB,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;AAEtC,6DAA6D;AAC7D,wBAAsB,IAAI,CACxB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,EACpB,QAAQ,EAAE,WAAW,GACpB,OAAO,CAAC,UAAU,CAAC,CA0BrB"}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Node module customization hook for unrift module mocking (Tier A).
3
+ *
4
+ * Registered by `bin/unrift.mjs` via `module.register()`. Runs in a
5
+ * worker thread (Node 20.6+ / 18.19+ behavior). Communicates with the
6
+ * main thread via a `MessagePort` to learn which specs have been mocked.
7
+ *
8
+ * Why we use `receiveMessageOnPort()` instead of `port.on("message")`:
9
+ * the resolve hook runs synchronously from the perspective of the
10
+ * import pipeline, but Worker `MessagePort` listeners are async.
11
+ * `receiveMessageOnPort()` (Node 18.7+) drains pending messages
12
+ * synchronously so registrations posted from the main thread are
13
+ * visible by the time the corresponding `await import(spec)` reaches
14
+ * us.
15
+ *
16
+ * The generated synthetic module source runs back in the MAIN thread
17
+ * (Node's loader output is evaluated in the importing thread, not the
18
+ * loader thread). That source reads exports from `globalThis.__unriftMocks`,
19
+ * which the main thread populates in `mock.doMock()`.
20
+ *
21
+ * Source compiled by tsc and shipped at `dist/esm/mock/module-loader.js`.
22
+ */
23
+ import { receiveMessageOnPort } from "node:worker_threads";
24
+ import { MOCKS_GLOBAL_KEY, MOCK_URL_PREFIX, } from "./module.js";
25
+ // Loader-thread state. Populated by `initialize()` and updated by
26
+ // drainMessages() on every resolve() call.
27
+ let loaderPort;
28
+ const registered = new Map();
29
+ /**
30
+ * Pull every pending message off the port synchronously. Called at the
31
+ * top of every resolve() and load() hook so the loader's view of the
32
+ * registry is always current.
33
+ */
34
+ function drainMessages() {
35
+ if (!loaderPort)
36
+ return;
37
+ let received = receiveMessageOnPort(loaderPort);
38
+ while (received !== undefined) {
39
+ const message = received.message;
40
+ if (message.type === "register") {
41
+ registered.set(message.spec, {
42
+ registrationId: message.registrationId,
43
+ exportKeys: message.exportKeys,
44
+ });
45
+ }
46
+ else if (message.type === "unregister") {
47
+ registered.delete(message.spec);
48
+ }
49
+ received = receiveMessageOnPort(loaderPort);
50
+ }
51
+ }
52
+ /**
53
+ * Generate ESM source for a mocked spec. The source executes in the
54
+ * MAIN thread (not the loader thread) - that's where the actual mock
55
+ * object lives, keyed in `globalThis.__unriftMocks`.
56
+ *
57
+ * Pure function so future tiers can reuse this for hoisted (Tier B)
58
+ * and relative (Tier C) mocks without rewriting the emitter.
59
+ */
60
+ export function generateMockSource(spec, exportKeys) {
61
+ const specJson = JSON.stringify(spec);
62
+ const lines = [
63
+ `// Generated by unrift module mocking - do not edit.`,
64
+ `const __mocks = globalThis[${JSON.stringify(MOCKS_GLOBAL_KEY)}];`,
65
+ `if (!__mocks || !__mocks.has(${specJson})) {`,
66
+ ` throw new Error(`,
67
+ ` "Unrift module mock registry is missing the entry for " + ${specJson} + ". " +`,
68
+ ` "This usually means mock.unmock() ran between registration and import."`,
69
+ ` );`,
70
+ `}`,
71
+ `const __exports = __mocks.get(${specJson});`,
72
+ ];
73
+ for (const key of exportKeys) {
74
+ if (key === "default") {
75
+ lines.push(`export default __exports.default;`);
76
+ }
77
+ else {
78
+ lines.push(`export const ${key} = __exports[${JSON.stringify(key)}];`);
79
+ }
80
+ }
81
+ return lines.join("\n");
82
+ }
83
+ /** Node hook: called once when the loader is registered. */
84
+ export function initialize(data) {
85
+ loaderPort = data.port;
86
+ }
87
+ /** Node hook: short-circuit mocked specs to a `unrift-mock:` URL. */
88
+ export async function resolve(specifier, context, nextResolve) {
89
+ drainMessages();
90
+ const entry = registered.get(specifier);
91
+ if (entry) {
92
+ // Embed the registration ID in the URL so Node's URL-keyed module
93
+ // cache treats each re-mock of the same spec as a fresh module.
94
+ return {
95
+ url: `${MOCK_URL_PREFIX}${specifier}?v=${entry.registrationId}`,
96
+ format: "module",
97
+ shortCircuit: true,
98
+ };
99
+ }
100
+ return nextResolve(specifier, context);
101
+ }
102
+ /** Node hook: emit synthetic ESM source for mocked specs. */
103
+ export async function load(url, context, nextLoad) {
104
+ if (url.startsWith(MOCK_URL_PREFIX)) {
105
+ drainMessages();
106
+ // URL shape: `unrift-mock:<spec>?v=<registrationId>`
107
+ const withoutPrefix = url.slice(MOCK_URL_PREFIX.length);
108
+ const queryIdx = withoutPrefix.indexOf("?");
109
+ const spec = queryIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, queryIdx);
110
+ const entry = registered.get(spec);
111
+ if (!entry) {
112
+ // The user called mock.unmock() between resolve() and load().
113
+ // Fall through to the real module so the import still works.
114
+ return nextLoad(spec, context);
115
+ }
116
+ return {
117
+ format: "module",
118
+ source: generateMockSource(spec, entry.exportKeys),
119
+ shortCircuit: true,
120
+ };
121
+ }
122
+ return nextLoad(url, context);
123
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Module mocking - main-thread registry and public API (Tier A).
3
+ *
4
+ * Tier A scope:
5
+ * - `mock.doMock(spec, factory)` registers a mock for an external module
6
+ * specifier (bare package name or `node:*` built-in).
7
+ * - Subsequent `await import(spec)` calls return the factory's result.
8
+ * - Static imports are NOT supported in Tier A; see the docs / ISSUES.md
9
+ * for the planned Tier B (hoisted `mock.module`) work.
10
+ *
11
+ * Architecture:
12
+ * - bin/unrift.mjs registers `module-loader.js` via `module.register()` and
13
+ * pipes a MessagePort into the loader thread.
14
+ * - The main-side port is stashed on `globalThis.__unriftLoaderPort` before
15
+ * the test runner starts.
16
+ * - `doMock()` invokes the factory once, caches the result on
17
+ * `globalThis.__unriftMocks`, and posts a `register` message with the
18
+ * spec + export-key list to the loader.
19
+ * - The loader (see module-loader.ts) drains pending messages on every
20
+ * resolve() and short-circuits matched specs to a synthetic module that
21
+ * reads back from `globalThis.__unriftMocks`.
22
+ *
23
+ * Future tiers will reuse the same registry shape and IPC protocol -
24
+ * adding `register-hoisted` (Tier B) and `register-relative` (Tier C)
25
+ * message types without breaking the existing surface.
26
+ */
27
+ /**
28
+ * Sentinel field name on globalThis. Loader-generated synthetic source
29
+ * reads back through this same name, so it must stay stable.
30
+ */
31
+ export declare const MOCKS_GLOBAL_KEY = "__unriftMocks";
32
+ /** Sentinel field name for the IPC port set by bin/unrift.mjs. */
33
+ export declare const LOADER_PORT_GLOBAL_KEY = "__unriftLoaderPort";
34
+ /** URL prefix the loader uses for mocked modules. Public so tests can assert. */
35
+ export declare const MOCK_URL_PREFIX = "unrift-mock:";
36
+ /**
37
+ * Message contract between main thread and loader thread. Stable across
38
+ * future tiers - new message types extend this union.
39
+ *
40
+ * `registrationId` is a monotonically increasing number embedded in the
41
+ * synthetic mock URL. It defeats Node's URL-keyed module cache so that
42
+ * re-mocking the same spec in a later test yields a fresh module (rather
43
+ * than returning the previous test's cached mock).
44
+ */
45
+ export type LoaderMessage = {
46
+ type: "register";
47
+ spec: string;
48
+ registrationId: number;
49
+ exportKeys: readonly string[];
50
+ } | {
51
+ type: "unregister";
52
+ spec: string;
53
+ };
54
+ type ModuleFactory = () => Record<string, unknown>;
55
+ /**
56
+ * Register a mock for `spec`. Subsequent `await import(spec)` calls will
57
+ * resolve to the factory's return value instead of the real module.
58
+ *
59
+ * Tier A constraints (planned to be lifted in Tier B and beyond):
60
+ * - Only **dynamic** imports (`await import(spec)`) hit the mock; static
61
+ * `import { x } from "spec"` is hoisted by ESM above this call and
62
+ * will see the real module.
63
+ * - Only **external** specifiers work - bare package names (`"react"`)
64
+ * and Node built-ins (`"node:fs"`). Relative specs (`"./bar"`) are
65
+ * bundled inline by esbuild and can't be intercepted at runtime.
66
+ * - The factory is invoked **once** at registration time. Its return
67
+ * value is reused for every subsequent import.
68
+ *
69
+ * Throws if the Unrift CLI loader isn't attached (i.e., tests were run
70
+ * with raw `node` instead of `unrift`).
71
+ */
72
+ export declare function doMock(spec: string, factory: ModuleFactory): void;
73
+ /**
74
+ * Remove a previously registered mock. Subsequent `await import(spec)`
75
+ * calls fall through to the real module.
76
+ */
77
+ export declare function unmock(spec: string): void;
78
+ /** Returns the current set of mocked specs. Useful for tests / debugging. */
79
+ export declare function listMockedSpecs(): readonly string[];
80
+ /**
81
+ * Convenience helper: imports the real module, builds a version with every
82
+ * function export wrapped in a spy, registers it via `mock.doMock`, and
83
+ * returns the mocked exports.
84
+ *
85
+ * Non-function exports are passed through unchanged. Tests can customize
86
+ * individual spies with `.mockReturnValue`, `.mockImplementation`, etc.
87
+ *
88
+ * Subject to the same Tier A limits as `doMock` - only affects
89
+ * `await import(spec)` after the call. Static imports of `spec` see the
90
+ * real module.
91
+ *
92
+ * @example
93
+ * const fs = await mock.fromModule("node:fs");
94
+ * fs.readFileSync.mockReturnValue("fake content");
95
+ * // ... test code that does `await import("node:fs")` now gets the spies
96
+ */
97
+ export declare function fromModule<T extends Record<string, unknown>>(spec: string): Promise<T>;
98
+ export {};
99
+ //# sourceMappingURL=module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../../../src/mock/module.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AASH;;;GAGG;AACH,eAAO,MAAM,gBAAgB,kBAAkB,CAAC;AAEhD,kEAAkE;AAClE,eAAO,MAAM,sBAAsB,uBAAuB,CAAC;AAE3D,iFAAiF;AACjF,eAAO,MAAM,eAAe,iBAAiB,CAAC;AAE9C;;;;;;;;GAQG;AACH,MAAM,MAAM,aAAa,GACrB;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B,GACD;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzC,KAAK,aAAa,GAAG,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAwFnD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,IAAI,CAqCjE;AAED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAQzC;AAED,6EAA6E;AAC7E,wBAAgB,eAAe,IAAI,SAAS,MAAM,EAAE,CAEnD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,UAAU,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChE,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,CAAC,CAAC,CAyBZ"}