@warp-drive/holodeck 0.0.1 → 0.1.0-alpha.14

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,15 +1,15 @@
1
1
  <p align="center">
2
2
  <img
3
3
  class="project-logo"
4
- src="./logos/NCC-1701-a-blue.svg#gh-light-mode-only"
4
+ src="./logos/warp-drive-logo-dark.svg#gh-light-mode-only"
5
5
  alt="WarpDrive"
6
- width="120px"
6
+ width="200px"
7
7
  title="WarpDrive" />
8
8
  <img
9
9
  class="project-logo"
10
- src="./logos/NCC-1701-a.svg#gh-dark-mode-only"
10
+ src="./logos/warp-drive-logo-gold.svg#gh-dark-mode-only"
11
11
  alt="WarpDrive"
12
- width="120px"
12
+ width="200px"
13
13
  title="WarpDrive" />
14
14
  </p>
15
15
 
@@ -163,10 +163,10 @@ const MockHost = `https://${window.location.hostname}:${Number(window.location.p
163
163
  setConfig({ host: MockHost });
164
164
 
165
165
  QUnit.hooks.beforeEach(function (assert) {
166
- setTestId(assert.test.testId);
166
+ setTestId(this, assert.test.testId);
167
167
  });
168
168
  QUnit.hooks.afterEach(function (assert) {
169
- setTestId(null);
169
+ setTestId(this, null);
170
170
  });
171
171
  ```
172
172
 
@@ -249,7 +249,7 @@ await launch({
249
249
  img.project-logo {
250
250
  padding: 0 5em 1em 5em;
251
251
  width: 100px;
252
- border-bottom: 2px solid #0969da;
252
+ border-bottom: 2px solid #bbb;
253
253
  margin: 0 auto;
254
254
  display: block;
255
255
  }
@@ -265,7 +265,7 @@ await launch({
265
265
  display: inline-block;
266
266
  padding: .2rem 0;
267
267
  color: #000;
268
- border-bottom: 3px solid #0969da;
268
+ border-bottom: 3px solid #bbb;
269
269
  }
270
270
 
271
271
  details > details {
@@ -0,0 +1,58 @@
1
+ import type { Handler, NextFn } from "@warp-drive/core/request";
2
+ import type { RequestContext, StructuredDataDocument } from "@warp-drive/core/types/request";
3
+ import type { MinimumAdapterInterface } from "@warp-drive/legacy/compat";
4
+ import type { Store } from "@warp-drive/legacy/store";
5
+ import type { ScaffoldGenerator } from "./mock.js";
6
+ /**
7
+ * @public
8
+ */
9
+ export declare function setConfig({ host }: {
10
+ host: string;
11
+ }): void;
12
+ /**
13
+ * @public
14
+ */
15
+ export declare function setTestId(context: object, str: string | null): void;
16
+ /**
17
+ * @public
18
+ */
19
+ export declare function setIsRecording(value: boolean): void;
20
+ /**
21
+ * @public
22
+ */
23
+ export declare function getIsRecording(): boolean;
24
+ /**
25
+ * A request handler that intercepts requests and routes them through
26
+ * the Holodeck mock server.
27
+ *
28
+ * This handler modifies the request URL to include test identifiers
29
+ * and manages request counts for accurate mocking.
30
+ *
31
+ * Requires that the test context be configured with a testId using `setTestId`.
32
+ *
33
+ * @param owner - the test context object used to retrieve the test ID.
34
+ */
35
+ export declare class MockServerHandler implements Handler {
36
+ owner: object;
37
+ constructor(owner: object);
38
+ request<T>(context: RequestContext, next: NextFn<T>): Promise<StructuredDataDocument<T>>;
39
+ }
40
+ interface HasAdapterForFn {
41
+ adapterFor(this: Store, modelName: string): MinimumAdapterInterface;
42
+ adapterFor(this: Store, modelName: string, _allowMissing?: true): MinimumAdapterInterface | undefined;
43
+ }
44
+ /**
45
+ * Creates an adapterFor function that wraps the provided adapterFor function
46
+ * to override the adapter's _fetchRequest method to route requests through
47
+ * the Holodeck mock server.
48
+ *
49
+ * @param owner - The test context object used to retrieve the test ID.
50
+ */
51
+ export declare function createAdapterFor(owner: object, store: HasAdapterForFn): HasAdapterForFn["adapterFor"];
52
+ /**
53
+ * Mock a request by sending the scaffold to the mock server.
54
+ *
55
+ * @public
56
+ */
57
+ export declare function mock(owner: object, generate: ScaffoldGenerator, isRecording?: boolean): Promise<void>;
58
+ export {};
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @public
3
+ */
4
+ export interface Scaffold {
5
+ status: number;
6
+ statusText?: string;
7
+ headers: Record<string, string>;
8
+ body: Record<string, string> | string | null;
9
+ method: string;
10
+ url: string;
11
+ response: Record<string, unknown>;
12
+ }
13
+ /**
14
+ * @public
15
+ */
16
+ export type ScaffoldGenerator = () => Scaffold;
17
+ /**
18
+ * @public
19
+ */
20
+ export type ResponseGenerator = () => Record<string, unknown>;
21
+ /**
22
+ * Sets up Mocking for a GET request on the mock server
23
+ * for the supplied url.
24
+ *
25
+ * The response body is generated by the supplied response function.
26
+ *
27
+ * Available options:
28
+ * - status: the status code to return (default: 200)
29
+ * - headers: the headers to return (default: {})
30
+ * - body: the body to match against for the request (default: null)
31
+ * - RECORD: whether to record the request (default: false)
32
+ *
33
+ * @param url the url to mock, relative to the mock server host (e.g. `users/1`)
34
+ * @param response a function which generates the response to return
35
+ * @param options status, headers for the response, body to match against for the request, and whether to record the request
36
+ * @return
37
+ */
38
+ export declare function GET(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
39
+ RECORD?: boolean;
40
+ }): Promise<void>;
41
+ /**
42
+ * Mock a POST request
43
+ */
44
+ export declare function POST(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
45
+ RECORD?: boolean;
46
+ }): Promise<void>;
47
+ /**
48
+ * mock a PUT request
49
+ */
50
+ export declare function PUT(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
51
+ RECORD?: boolean;
52
+ }): Promise<void>;
53
+ /**
54
+ * mock a PATCH request
55
+ *
56
+ */
57
+ export declare function PATCH(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
58
+ RECORD?: boolean;
59
+ }): Promise<void>;
60
+ /**
61
+ * mock a DELETE request
62
+ */
63
+ export declare function DELETE(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
64
+ RECORD?: boolean;
65
+ }): Promise<void>;
package/dist/index.js CHANGED
@@ -1,10 +1,26 @@
1
+ import { SHOULD_RECORD } from '@warp-drive/core/build-config/env';
2
+
3
+ /**
4
+ * @module
5
+ * @mergeModuleWith <project>
6
+ */
1
7
  const TEST_IDS = new WeakMap();
2
8
  let HOST = 'https://localhost:1135/';
9
+
10
+ /**
11
+ * @public
12
+ */
13
+
3
14
  function setConfig({
4
15
  host
5
16
  }) {
6
17
  HOST = host.endsWith('/') ? host : `${host}/`;
7
18
  }
19
+
20
+ /**
21
+ * @public
22
+ */
23
+
8
24
  function setTestId(context, str) {
9
25
  if (str && TEST_IDS.has(context)) {
10
26
  throw new Error(`MockServerHandler is already configured with a testId.`);
@@ -12,37 +28,72 @@ function setTestId(context, str) {
12
28
  if (str) {
13
29
  TEST_IDS.set(context, {
14
30
  id: str,
15
- request: 0,
16
- mock: 0
31
+ mock: {
32
+ GET: {},
33
+ PUT: {},
34
+ PATCH: {},
35
+ DELETE: {},
36
+ POST: {},
37
+ QUERY: {},
38
+ OPTIONS: {},
39
+ HEAD: {},
40
+ CONNECT: {},
41
+ TRACE: {}
42
+ },
43
+ request: {
44
+ GET: {},
45
+ PUT: {},
46
+ PATCH: {},
47
+ DELETE: {},
48
+ POST: {},
49
+ QUERY: {},
50
+ OPTIONS: {},
51
+ HEAD: {},
52
+ CONNECT: {},
53
+ TRACE: {}
54
+ }
17
55
  });
18
56
  } else {
19
57
  TEST_IDS.delete(context);
20
58
  }
21
59
  }
22
- let IS_RECORDING = false;
60
+ const shouldRecord = SHOULD_RECORD ? true : false;
61
+ let IS_RECORDING = null;
62
+
63
+ /**
64
+ * @public
65
+ */
23
66
  function setIsRecording(value) {
24
- IS_RECORDING = Boolean(value);
67
+ IS_RECORDING = value === null ? value : Boolean(value);
25
68
  }
69
+
70
+ /**
71
+ * @public
72
+ */
26
73
  function getIsRecording() {
27
- return IS_RECORDING;
74
+ return IS_RECORDING === null ? shouldRecord : IS_RECORDING;
28
75
  }
76
+
77
+ /**
78
+ * A request handler that intercepts requests and routes them through
79
+ * the Holodeck mock server.
80
+ *
81
+ * This handler modifies the request URL to include test identifiers
82
+ * and manages request counts for accurate mocking.
83
+ *
84
+ * Requires that the test context be configured with a testId using `setTestId`.
85
+ *
86
+ * @param owner - the test context object used to retrieve the test ID.
87
+ */
29
88
  class MockServerHandler {
30
89
  constructor(owner) {
31
90
  this.owner = owner;
32
91
  }
33
92
  async request(context, next) {
34
- const test = TEST_IDS.get(this.owner);
35
- if (!test) {
36
- throw new Error(`MockServerHandler is not configured with a testId. Use setTestId to set the testId for each test`);
37
- }
38
- const request = Object.assign({}, context.request);
39
- const isRecording = request.url.endsWith('/__record');
40
- const firstChar = request.url.includes('?') ? '&' : '?';
41
- const queryForTest = `${firstChar}__xTestId=${test.id}&__xTestRequestNumber=${isRecording ? test.mock++ : test.request++}`;
42
- request.url = request.url + queryForTest;
43
- request.mode = 'cors';
44
- request.credentials = 'omit';
45
- request.referrerPolicy = '';
93
+ const {
94
+ request,
95
+ queryForTest
96
+ } = setupHolodeckFetch(this.owner, Object.assign({}, context.request));
46
97
  try {
47
98
  const future = next(request);
48
99
  context.setStream(future.getStream());
@@ -55,17 +106,102 @@ class MockServerHandler {
55
106
  }
56
107
  }
57
108
  }
58
- async function mock(owner, generate, isRecording) {
109
+ function setupHolodeckFetch(owner, request) {
59
110
  const test = TEST_IDS.get(owner);
60
111
  if (!test) {
61
- throw new Error(`Cannot call "mock" before configuring a testId. Use setTestId to set the testId for each test`);
112
+ throw new Error(`MockServerHandler is not configured with a testId. Use setTestId to set the testId for each test`);
113
+ }
114
+ const url = request.url;
115
+ const firstChar = url.includes('?') ? '&' : '?';
116
+ const method = request.method?.toUpperCase() ?? 'GET';
117
+
118
+ // enable custom methods
119
+ if (!test.request[method]) {
120
+ // eslint-disable-next-line no-console
121
+ console.log(`⚠️ Using custom HTTP method ${method} for response to request ${url}`);
122
+ test.request[method] = {};
62
123
  }
63
- const testMockNum = test.mock++;
124
+ if (!(url in test.request[method])) {
125
+ test.request[method][url] = 0;
126
+ }
127
+ const queryForTest = `${firstChar}__xTestId=${test.id}&__xTestRequestNumber=${test.request[method][url]++}`;
128
+ request.url = url + queryForTest;
129
+ request.mode = 'cors';
130
+ request.credentials = 'omit';
131
+ request.referrerPolicy = '';
132
+ return {
133
+ request,
134
+ queryForTest
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Creates an adapterFor function that wraps the provided adapterFor function
140
+ * to override the adapter's _fetchRequest method to route requests through
141
+ * the Holodeck mock server.
142
+ *
143
+ * @param owner - The test context object used to retrieve the test ID.
144
+ */
145
+ function createAdapterFor(owner, store) {
146
+ // eslint-disable-next-line @typescript-eslint/unbound-method
147
+ const adapterFor = store.adapterFor;
148
+ return function holodeckAdapterFor(modelName, _allowMissing) {
149
+ const adapter = adapterFor.call(this, modelName, _allowMissing);
150
+ if (adapter) {
151
+ if (!adapter.hasOverriddenFetch) {
152
+ adapter.hasOverriddenFetch = true;
153
+ adapter.useFetch = true;
154
+ const originalFetch = adapter._fetchRequest?.bind(adapter);
155
+ adapter._fetchRequest = function (options) {
156
+ if (!originalFetch) {
157
+ throw new Error(`Adapter ${String(modelName)} does not implement _fetchRequest`);
158
+ }
159
+ const {
160
+ request
161
+ } = setupHolodeckFetch(owner, options);
162
+ return originalFetch(request);
163
+ };
164
+ }
165
+ }
166
+ return adapter;
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Mock a request by sending the scaffold to the mock server.
172
+ *
173
+ * @public
174
+ */
175
+ async function mock(owner, generate, isRecording) {
64
176
  if (getIsRecording() || isRecording) {
177
+ const test = TEST_IDS.get(owner);
178
+ if (!test) {
179
+ throw new Error(`Cannot call "mock" before configuring a testId. Use setTestId to set the testId for each test`);
180
+ }
181
+ const requestToMock = generate();
182
+ const {
183
+ url: mockUrl,
184
+ method
185
+ } = requestToMock;
186
+ if (!mockUrl || !method) {
187
+ throw new Error(`MockError: Cannot mock a request without providing a URL and Method`);
188
+ }
189
+ const mockMethod = method?.toUpperCase() ?? 'GET';
190
+
191
+ // enable custom methods
192
+ if (!test.mock[mockMethod]) {
193
+ // eslint-disable-next-line no-console
194
+ console.log(`⚠️ Using custom HTTP method ${mockMethod} for response to request ${mockUrl}`);
195
+ test.mock[mockMethod] = {};
196
+ }
197
+ if (!(mockUrl in test.mock[mockMethod])) {
198
+ test.mock[mockMethod][mockUrl] = 0;
199
+ }
200
+ const testMockNum = test.mock[mockMethod][mockUrl]++;
65
201
  const url = `${HOST}__record?__xTestId=${test.id}&__xTestRequestNumber=${testMockNum}`;
66
202
  await fetch(url, {
67
203
  method: 'POST',
68
- body: JSON.stringify(generate()),
204
+ body: JSON.stringify(requestToMock),
69
205
  mode: 'cors',
70
206
  credentials: 'omit',
71
207
  referrerPolicy: ''
@@ -73,5 +209,4 @@ async function mock(owner, generate, isRecording) {
73
209
  }
74
210
  }
75
211
 
76
- export { MockServerHandler, getIsRecording, mock, setConfig, setIsRecording, setTestId };
77
- //# sourceMappingURL=index.js.map
212
+ export { MockServerHandler, createAdapterFor, getIsRecording, mock, setConfig, setIsRecording, setTestId };
package/dist/mock.js CHANGED
@@ -1,5 +1,17 @@
1
1
  import { mock, getIsRecording } from './index.js';
2
2
 
3
+ /**
4
+ * @public
5
+ */
6
+
7
+ /**
8
+ * @public
9
+ */
10
+
11
+ /**
12
+ * @public
13
+ */
14
+
3
15
  /**
4
16
  * Sets up Mocking for a GET request on the mock server
5
17
  * for the supplied url.
@@ -28,11 +40,81 @@ function GET(owner, url, response, options) {
28
40
  response: response()
29
41
  }), getIsRecording() || (options?.RECORD ?? false));
30
42
  }
31
- function POST() {}
32
- function PUT() {}
33
- function PATCH() {}
34
- function DELETE() {}
35
- function QUERY() {}
43
+ const STATUS_TEXT_FOR = new Map([[200, 'OK'], [201, 'Created'], [202, 'Accepted'], [203, 'Non-Authoritative Information'], [204, 'No Content'], [205, 'Reset Content'], [206, 'Partial Content'], [207, 'Multi-Status'], [208, 'Already Reported'], [226, 'IM Used'], [300, 'Multiple Choices'], [301, 'Moved Permanently'], [302, 'Found'], [303, 'See Other'], [304, 'Not Modified'], [307, 'Temporary Redirect'], [308, 'Permanent Redirect'], [400, 'Bad Request'], [401, 'Unauthorized'], [402, 'Payment Required'], [403, 'Forbidden'], [404, 'Not Found'], [405, 'Method Not Allowed'], [406, 'Not Acceptable'], [407, 'Proxy Authentication Required'], [408, 'Request Timeout'], [409, 'Conflict'], [410, 'Gone'], [411, 'Length Required'], [412, 'Precondition Failed'], [413, 'Payload Too Large'], [414, 'URI Too Long'], [415, 'Unsupported Media Type'], [416, 'Range Not Satisfiable'], [417, 'Expectation Failed'], [419, 'Page Expired'], [420, 'Enhance Your Calm'], [421, 'Misdirected Request'], [422, 'Unprocessable Entity'], [423, 'Locked'], [424, 'Failed Dependency'], [425, 'Too Early'], [426, 'Upgrade Required'], [428, 'Precondition Required'], [429, 'Too Many Requests'], [430, 'Request Header Fields Too Large'], [431, 'Request Header Fields Too Large'], [450, 'Blocked By Windows Parental Controls'], [451, 'Unavailable For Legal Reasons'], [500, 'Internal Server Error'], [501, 'Not Implemented'], [502, 'Bad Gateway'], [503, 'Service Unavailable'], [504, 'Gateway Timeout'], [505, 'HTTP Version Not Supported'], [506, 'Variant Also Negotiates'], [507, 'Insufficient Storage'], [508, 'Loop Detected'], [509, 'Bandwidth Limit Exceeded'], [510, 'Not Extended'], [511, 'Network Authentication Required']]);
44
+
45
+ /**
46
+ * Mock a POST request
47
+ */
48
+ function POST(owner, url, response, options) {
49
+ return mock(owner, () => {
50
+ const body = response();
51
+ const status = options?.status ?? (body ? 201 : 204);
52
+ return {
53
+ status: status,
54
+ statusText: options?.statusText ?? STATUS_TEXT_FOR.get(status) ?? '',
55
+ headers: options?.headers ?? {},
56
+ body: options?.body ?? null,
57
+ method: 'POST',
58
+ url,
59
+ response: body
60
+ };
61
+ }, getIsRecording() || (options?.RECORD ?? false));
62
+ }
63
+
64
+ /**
65
+ * mock a PUT request
66
+ */
67
+ function PUT(owner, url, response, options) {
68
+ return mock(owner, () => {
69
+ const body = response();
70
+ const status = options?.status ?? (body ? 200 : 204);
71
+ return {
72
+ status: status,
73
+ statusText: options?.statusText ?? STATUS_TEXT_FOR.get(status) ?? '',
74
+ headers: options?.headers ?? {},
75
+ body: options?.body ?? null,
76
+ method: 'PUT',
77
+ url,
78
+ response: body
79
+ };
80
+ }, getIsRecording() || (options?.RECORD ?? false));
81
+ }
82
+ /**
83
+ * mock a PATCH request
84
+ *
85
+ */
86
+ function PATCH(owner, url, response, options) {
87
+ return mock(owner, () => {
88
+ const body = response();
89
+ const status = options?.status ?? (body ? 200 : 204);
90
+ return {
91
+ status: status,
92
+ statusText: options?.statusText ?? STATUS_TEXT_FOR.get(status) ?? '',
93
+ headers: options?.headers ?? {},
94
+ body: options?.body ?? null,
95
+ method: 'PATCH',
96
+ url,
97
+ response: body
98
+ };
99
+ }, getIsRecording() || (options?.RECORD ?? false));
100
+ }
101
+ /**
102
+ * mock a DELETE request
103
+ */
104
+ function DELETE(owner, url, response, options) {
105
+ return mock(owner, () => {
106
+ const body = response();
107
+ const status = options?.status ?? (body ? 200 : 204);
108
+ return {
109
+ status: status,
110
+ statusText: options?.statusText ?? STATUS_TEXT_FOR.get(status) ?? '',
111
+ headers: options?.headers ?? {},
112
+ body: options?.body ?? null,
113
+ method: 'DELETE',
114
+ url,
115
+ response: body
116
+ };
117
+ }, getIsRecording() || (options?.RECORD ?? false));
118
+ }
36
119
 
37
- export { DELETE, GET, PATCH, POST, PUT, QUERY };
38
- //# sourceMappingURL=mock.js.map
120
+ export { DELETE, GET, PATCH, POST, PUT };
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@warp-drive/holodeck",
3
3
  "description": "⚡️ Simple, Fast HTTP Mocking for Tests",
4
- "version": "0.0.1",
4
+ "version": "0.1.0-alpha.14",
5
5
  "license": "MIT",
6
6
  "author": "Chris Thoburn <runspired@users.noreply.github.com>",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+ssh://git@github.com:emberjs/data.git",
9
+ "url": "git+ssh://git@github.com:warp-drive-data/warp-drive.git",
10
10
  "directory": "packages/holodeck"
11
11
  },
12
- "homepage": "https://github.com/emberjs/data",
13
- "bugs": "https://github.com/emberjs/data/issues",
12
+ "homepage": "https://github.com/warp-drive-data/warp-drive",
13
+ "bugs": "https://github.com/warp-drive-data/warp-drive/issues",
14
14
  "engines": {
15
15
  "node": ">= 18.20.8"
16
16
  },
@@ -21,9 +21,9 @@
21
21
  "extends": "../../package.json"
22
22
  },
23
23
  "dependencies": {
24
- "chalk": "^5.3.0",
25
- "hono": "^4.7.6",
26
- "@hono/node-server": "^1.14.0"
24
+ "chalk": "^5.5.0",
25
+ "hono": "^4.9.4",
26
+ "@hono/node-server": "^1.19.0"
27
27
  },
28
28
  "type": "module",
29
29
  "files": [
@@ -32,25 +32,36 @@
32
32
  "README.md",
33
33
  "LICENSE.md",
34
34
  "server",
35
+ "declarations",
35
36
  "logos"
36
37
  ],
37
38
  "bin": {
38
39
  "ensure-cert": "./server/ensure-cert.js"
39
40
  },
40
41
  "peerDependencies": {
41
- "@ember-data/request": "5.4.1",
42
- "@warp-drive/core-types": "5.4.1"
42
+ "@warp-drive/utilities": "5.8.0-alpha.14",
43
+ "@warp-drive/legacy": "5.8.0-alpha.14",
44
+ "@warp-drive/core": "5.8.0-alpha.14"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@warp-drive/utilities": {
48
+ "optional": true
49
+ },
50
+ "@warp-drive/legacy": {
51
+ "optional": true
52
+ }
43
53
  },
44
54
  "devDependencies": {
45
- "@babel/core": "^7.26.10",
46
- "@babel/plugin-transform-typescript": "^7.27.0",
47
- "@babel/preset-env": "^7.26.9",
48
- "@babel/preset-typescript": "^7.27.0",
49
- "@babel/runtime": "^7.27.0",
50
- "@ember-data/request": "5.4.1",
51
- "@warp-drive/core-types": "5.4.1",
52
- "@warp-drive/internal-config": "5.4.1",
53
- "vite": "^5.4.15"
55
+ "@babel/core": "^7.28.3",
56
+ "@babel/plugin-transform-typescript": "^7.28.0",
57
+ "@babel/preset-env": "^7.28.3",
58
+ "@babel/preset-typescript": "^7.27.1",
59
+ "@babel/runtime": "^7.28.3",
60
+ "@warp-drive/utilities": "5.8.0-alpha.14",
61
+ "@warp-drive/legacy": "5.8.0-alpha.14",
62
+ "@warp-drive/core": "5.8.0-alpha.14",
63
+ "@warp-drive/internal-config": "5.8.0-alpha.14",
64
+ "vite": "^7.1.3"
54
65
  },
55
66
  "exports": {
56
67
  ".": {
@@ -58,14 +69,17 @@
58
69
  "bun": "./server/index.js",
59
70
  "deno": "./server/index.js",
60
71
  "browser": {
72
+ "types": "./declarations/index.d.ts",
61
73
  "default": "./dist/index.js"
62
74
  },
63
75
  "import": {
76
+ "types": "./declarations/index.d.ts",
64
77
  "default": "./dist/index.js"
65
78
  },
66
79
  "default": "./server/index.js"
67
80
  },
68
81
  "./mock": {
82
+ "types": "./declarations/mock.d.ts",
69
83
  "default": "./dist/mock.js"
70
84
  }
71
85
  },
@@ -11,6 +11,10 @@ function getShellConfigFilePath() {
11
11
  return path.join(homedir(), '.zshrc');
12
12
  case '/bin/bash':
13
13
  return path.join(homedir(), '.bashrc');
14
+ case '/opt/homebrew/bin/fish':
15
+ case '/usr/local/bin/fish':
16
+ case '/bin/fish':
17
+ return path.join(homedir(), '.config', 'fish', 'config.fish');
14
18
  default:
15
19
  throw Error(
16
20
  `Unable to determine configuration file for shell: ${shell}. Manual SSL Cert Setup Required for Holodeck.`
package/server/index.js CHANGED
@@ -11,6 +11,7 @@ import fs from 'node:fs';
11
11
  import zlib from 'node:zlib';
12
12
  import { homedir } from 'os';
13
13
  import path from 'path';
14
+ import { threadId, parentPort } from 'node:worker_threads';
14
15
 
15
16
  const isBun = typeof Bun !== 'undefined';
16
17
  const DEBUG =
@@ -106,11 +107,21 @@ function generateFilepath(options) {
106
107
  const { body } = options;
107
108
  const bodyHash = body ? crypto.createHash('md5').update(JSON.stringify(body)).digest('hex') : null;
108
109
  const cacheDir = generateFileDir(options);
109
- return `${cacheDir}/${bodyHash ? `${bodyHash}-` : 'res'}`;
110
+ return `${cacheDir}/${bodyHash ? bodyHash : 'res'}`;
110
111
  }
112
+
113
+ /*
114
+ Generate a human scannable file name for the test assets to be stored in,
115
+ the `.mock-cache` directory should be checked-in to the codebase.
116
+ */
111
117
  function generateFileDir(options) {
112
118
  const { projectRoot, testId, url, method, testRequestNumber } = options;
113
- return `${projectRoot}/.mock-cache/${testId}/${method}-${testRequestNumber}-${url}`;
119
+ const normalizedUrl = url.startsWith('/') ? url.slice(1) : url;
120
+ // make path look nice but not be a sub-directory
121
+ // using alternative `/`-like characters would be nice but results in odd encoding
122
+ // on disk path
123
+ const pathUrl = normalizedUrl.replaceAll('/', '_');
124
+ return `${projectRoot}/.mock-cache/${testId}/${method}::${pathUrl}::${testRequestNumber}`;
114
125
  }
115
126
 
116
127
  async function replayRequest(context, cacheKey) {
@@ -131,34 +142,54 @@ async function replayRequest(context, cacheKey) {
131
142
  status: '400',
132
143
  code: 'MOCK_NOT_FOUND',
133
144
  title: 'Mock not found',
134
- detail: `No meta was found for ${context.req.method} ${context.req.url}. You may need to record a mock for this request.`,
145
+ detail: `No meta was found for ${context.req.method} ${context.req.url}. The expected cacheKey was ${cacheKey}. You may need to record a mock for this request.`,
135
146
  },
136
147
  ],
137
148
  })
138
149
  );
139
150
  }
140
151
 
141
- const bodyPath = `${cacheKey}.body.br`;
142
- const bodyInit =
143
- metaJson.status !== 204 && metaJson.status < 500
144
- ? isBun
145
- ? Bun.file(bodyPath)
146
- : fs.createReadStream(bodyPath)
147
- : '';
148
-
149
- const headers = new Headers(metaJson.headers || {});
150
- // @ts-expect-error - createReadStream is supported in node
151
- const response = new Response(bodyInit, {
152
- status: metaJson.status,
153
- statusText: metaJson.statusText,
154
- headers,
155
- });
152
+ try {
153
+ const bodyPath = `${cacheKey}.body.br`;
154
+ const bodyInit =
155
+ metaJson.status !== 204 && metaJson.status < 500
156
+ ? isBun
157
+ ? Bun.file(bodyPath)
158
+ : fs.createReadStream(bodyPath)
159
+ : '';
160
+
161
+ const headers = new Headers(metaJson.headers || {});
162
+ // @ts-expect-error - createReadStream is supported in node
163
+ const response = new Response(bodyInit, {
164
+ status: metaJson.status,
165
+ statusText: metaJson.statusText,
166
+ headers,
167
+ });
156
168
 
157
- if (metaJson.status > 400) {
158
- throw new HTTPException(metaJson.status, { res: response, message: metaJson.statusText });
159
- }
169
+ if (metaJson.status > 400) {
170
+ throw new HTTPException(metaJson.status, { res: response, message: metaJson.statusText });
171
+ }
160
172
 
161
- return response;
173
+ return response;
174
+ } catch (e) {
175
+ if (e instanceof HTTPException) {
176
+ throw e;
177
+ }
178
+ context.header('Content-Type', 'application/vnd.api+json');
179
+ context.status(500);
180
+ return context.body(
181
+ JSON.stringify({
182
+ errors: [
183
+ {
184
+ status: '500',
185
+ code: 'MOCK_SERVER_ERROR',
186
+ title: 'Mock Replay Failed',
187
+ detail: `Failed to create the response for ${context.req.method} ${context.req.url}.\n\n\n${e.message}\n${e.stack}`,
188
+ },
189
+ ],
190
+ })
191
+ );
192
+ }
162
193
  }
163
194
 
164
195
  function createTestHandler(projectRoot) {
@@ -208,7 +239,7 @@ function createTestHandler(projectRoot) {
208
239
  );
209
240
  }
210
241
 
211
- if (req.method === 'POST' || niceUrl === '__record') {
242
+ if (req.method === 'POST' && niceUrl === '__record') {
212
243
  const payload = await req.json();
213
244
  const { url, headers, method, status, statusText, body, response } = payload;
214
245
  const cacheKey = generateFilepath({
@@ -216,7 +247,7 @@ function createTestHandler(projectRoot) {
216
247
  testId,
217
248
  url,
218
249
  method,
219
- body: body ? JSON.stringify(body) : null,
250
+ body,
220
251
  testRequestNumber,
221
252
  });
222
253
  const compressedResponse = compress(JSON.stringify(response));
@@ -252,24 +283,34 @@ function createTestHandler(projectRoot) {
252
283
  fs.writeFileSync(`${cacheKey}.body.br`, compressedResponse);
253
284
  }
254
285
 
255
- context.status(204);
256
- return context.body(null);
286
+ context.status(201);
287
+ return context.body(
288
+ JSON.stringify({
289
+ message: `Recorded ${method} ${url} for test ${testId} request #${testRequestNumber}`,
290
+ cacheKey,
291
+ cacheDir,
292
+ })
293
+ );
257
294
  } else {
258
- const body = req.body;
295
+ const body = req.raw.body ? await req.text() : null;
259
296
  const cacheKey = generateFilepath({
260
297
  projectRoot,
261
298
  testId,
262
299
  url: niceUrl,
263
300
  method: req.method,
264
- body: body ? JSON.stringify(body) : null,
301
+ body: body ? body : null,
265
302
  testRequestNumber,
266
303
  });
267
304
  return replayRequest(context, cacheKey);
268
305
  }
269
306
  } catch (e) {
270
307
  if (e instanceof HTTPException) {
308
+ console.log(`HTTPException Encountered`);
309
+ console.error(e);
271
310
  throw e;
272
311
  }
312
+ console.log(`500 MOCK_SERVER_ERROR Encountered`);
313
+ console.error(e);
273
314
  context.header('Content-Type', 'application/vnd.api+json');
274
315
  context.status(500);
275
316
  return context.body(
@@ -302,6 +343,7 @@ export function startNodeServer() {
302
343
  export function startWorker() {
303
344
  // listen for launch message
304
345
  globalThis.onmessage = async (event) => {
346
+ console.log('starting holodeck worker');
305
347
  const { options } = event.data;
306
348
 
307
349
  const { server } = await _createServer(options);
@@ -317,6 +359,16 @@ export function startWorker() {
317
359
  };
318
360
  }
319
361
 
362
+ async function waitForLog(server, logMessage) {
363
+ for await (const chunk of server.stdout) {
364
+ process.stdout.write(chunk);
365
+ const txt = new TextDecoder().decode(chunk);
366
+ if (txt.includes(logMessage)) {
367
+ return;
368
+ }
369
+ }
370
+ }
371
+
320
372
  /*
321
373
  { port?: number, projectRoot: string }
322
374
  */
@@ -324,14 +376,16 @@ export async function createServer(options, useBun = false) {
324
376
  if (!useBun) {
325
377
  const CURRENT_FILE = new URL(import.meta.url).pathname;
326
378
  const START_FILE = path.join(CURRENT_FILE, '../start-node.js');
327
- const server = Bun.spawn(['node', '--experimental-default-type=module', START_FILE, JSON.stringify(options)], {
328
- env: process.env,
379
+ const server = Bun.spawn(['node', START_FILE, JSON.stringify(options)], {
380
+ env: Object.assign({}, process.env, { FORCE_COLOR: 1 }),
329
381
  cwd: process.cwd(),
330
382
  stdin: 'inherit',
331
- stdout: 'inherit',
383
+ stdout: 'pipe',
332
384
  stderr: 'inherit',
333
385
  });
334
386
 
387
+ await waitForLog(server, 'Serving Holodeck HTTP Mocks');
388
+
335
389
  return {
336
390
  terminate() {
337
391
  server.kill();
@@ -340,6 +394,7 @@ export async function createServer(options, useBun = false) {
340
394
  };
341
395
  }
342
396
 
397
+ console.log('starting holodeck worker');
343
398
  const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
344
399
 
345
400
  worker.postMessage({
@@ -347,7 +402,15 @@ export async function createServer(options, useBun = false) {
347
402
  options,
348
403
  });
349
404
 
350
- return worker;
405
+ return new Promise((resolve) => {
406
+ // @ts-expect-error
407
+ worker.onmessage((v) => {
408
+ console.log('worker message received', v);
409
+ if (v.data === 'launched') {
410
+ resolve(worker);
411
+ }
412
+ });
413
+ });
351
414
  }
352
415
 
353
416
  async function _createServer(options) {
@@ -383,9 +446,13 @@ async function _createServer(options) {
383
446
  });
384
447
 
385
448
  console.log(
386
- `\tMock server running at ${chalk.yellow('https://') + chalk.magenta((options.hostname ?? 'localhost') + ':') + chalk.yellow(options.port ?? DEFAULT_PORT)}`
449
+ `\tServing Holodeck HTTP Mocks from ${chalk.yellow('https://') + chalk.magenta((options.hostname ?? 'localhost') + ':') + chalk.yellow(options.port ?? DEFAULT_PORT)}\n`
387
450
  );
388
451
 
452
+ if (typeof threadId === 'number' && threadId !== 0) {
453
+ parentPort.postMessage('launched');
454
+ }
455
+
389
456
  return { app, server };
390
457
  }
391
458
 
package/dist/index.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import type { Handler, NextFn, RequestContext, RequestInfo, StructuredDataDocument } from '@ember-data/request';\n\nimport type { ScaffoldGenerator } from './mock';\n\nconst TEST_IDS = new WeakMap<object, { id: string; request: number; mock: number }>();\n\nlet HOST = 'https://localhost:1135/';\nexport function setConfig({ host }: { host: string }) {\n HOST = host.endsWith('/') ? host : `${host}/`;\n}\n\nexport function setTestId(context: object, str: string | null) {\n if (str && TEST_IDS.has(context)) {\n throw new Error(`MockServerHandler is already configured with a testId.`);\n }\n if (str) {\n TEST_IDS.set(context, { id: str, request: 0, mock: 0 });\n } else {\n TEST_IDS.delete(context);\n }\n}\n\nlet IS_RECORDING = false;\nexport function setIsRecording(value: boolean) {\n IS_RECORDING = Boolean(value);\n}\nexport function getIsRecording() {\n return IS_RECORDING;\n}\n\nexport class MockServerHandler implements Handler {\n declare owner: object;\n constructor(owner: object) {\n this.owner = owner;\n }\n async request<T>(context: RequestContext, next: NextFn<T>): Promise<StructuredDataDocument<T>> {\n const test = TEST_IDS.get(this.owner);\n if (!test) {\n throw new Error(\n `MockServerHandler is not configured with a testId. Use setTestId to set the testId for each test`\n );\n }\n\n const request: RequestInfo = Object.assign({}, context.request);\n const isRecording = request.url!.endsWith('/__record');\n const firstChar = request.url!.includes('?') ? '&' : '?';\n const queryForTest = `${firstChar}__xTestId=${test.id}&__xTestRequestNumber=${\n isRecording ? test.mock++ : test.request++\n }`;\n request.url = request.url + queryForTest;\n\n request.mode = 'cors';\n request.credentials = 'omit';\n request.referrerPolicy = '';\n\n try {\n const future = next(request);\n context.setStream(future.getStream());\n return await future;\n } catch (e) {\n if (e instanceof Error && !(e instanceof DOMException)) {\n e.message = e.message.replace(queryForTest, '');\n }\n throw e;\n }\n }\n}\n\nexport async function mock(owner: object, generate: ScaffoldGenerator, isRecording?: boolean) {\n const test = TEST_IDS.get(owner);\n if (!test) {\n throw new Error(`Cannot call \"mock\" before configuring a testId. Use setTestId to set the testId for each test`);\n }\n const testMockNum = test.mock++;\n if (getIsRecording() || isRecording) {\n const port = window.location.port ? `:${window.location.port}` : '';\n const url = `${HOST}__record?__xTestId=${test.id}&__xTestRequestNumber=${testMockNum}`;\n await fetch(url, {\n method: 'POST',\n body: JSON.stringify(generate()),\n mode: 'cors',\n credentials: 'omit',\n referrerPolicy: '',\n });\n }\n}\n"],"names":["TEST_IDS","WeakMap","HOST","setConfig","host","endsWith","setTestId","context","str","has","Error","set","id","request","mock","delete","IS_RECORDING","setIsRecording","value","Boolean","getIsRecording","MockServerHandler","constructor","owner","next","test","get","Object","assign","isRecording","url","firstChar","includes","queryForTest","mode","credentials","referrerPolicy","future","setStream","getStream","e","DOMException","message","replace","generate","testMockNum","fetch","method","body","JSON","stringify"],"mappings":"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"}
package/dist/mock.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"mock.js","sources":["../src/mock.ts"],"sourcesContent":["import { getIsRecording, mock } from '.';\n\nexport interface Scaffold {\n status: number;\n statusText?: string;\n headers: Record<string, string>;\n body: Record<string, string> | string | null;\n method: string;\n url: string;\n response: Record<string, unknown>;\n}\n\nexport type ScaffoldGenerator = () => Scaffold;\nexport type ResponseGenerator = () => Record<string, unknown>;\n\n/**\n * Sets up Mocking for a GET request on the mock server\n * for the supplied url.\n *\n * The response body is generated by the supplied response function.\n *\n * Available options:\n * - status: the status code to return (default: 200)\n * - headers: the headers to return (default: {})\n * - body: the body to match against for the request (default: null)\n * - RECORD: whether to record the request (default: false)\n *\n * @param url the url to mock, relative to the mock server host (e.g. `users/1`)\n * @param response a function which generates the response to return\n * @param options status, headers for the response, body to match against for the request, and whether to record the request\n * @return\n */\nexport function GET(\n owner: object,\n url: string,\n response: ResponseGenerator,\n options?: Partial<Omit<Scaffold, 'response' | 'url' | 'method'>> & { RECORD?: boolean }\n): Promise<void> {\n return mock(\n owner,\n () => ({\n status: options?.status ?? 200,\n statusText: options?.statusText ?? 'OK',\n headers: options?.headers ?? {},\n body: options?.body ?? null,\n method: 'GET',\n url,\n response: response(),\n }),\n getIsRecording() || (options?.RECORD ?? false)\n );\n}\nexport function POST() {}\nexport function PUT() {}\nexport function PATCH() {}\nexport function DELETE() {}\nexport function QUERY() {}\n"],"names":["GET","owner","url","response","options","mock","status","statusText","headers","body","method","getIsRecording","RECORD","POST","PUT","PATCH","DELETE","QUERY"],"mappings":";;AAeA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,SAASA,GAAGA,CACjBC,KAAa,EACbC,GAAW,EACXC,QAA2B,EAC3BC,OAAuF,EACxE;AACf,EAAA,OAAOC,IAAI,CACTJ,KAAK,EACL,OAAO;AACLK,IAAAA,MAAM,EAAEF,OAAO,EAAEE,MAAM,IAAI,GAAG;AAC9BC,IAAAA,UAAU,EAAEH,OAAO,EAAEG,UAAU,IAAI,IAAI;AACvCC,IAAAA,OAAO,EAAEJ,OAAO,EAAEI,OAAO,IAAI,EAAE;AAC/BC,IAAAA,IAAI,EAAEL,OAAO,EAAEK,IAAI,IAAI,IAAI;AAC3BC,IAAAA,MAAM,EAAE,KAAK;IACbR,GAAG;IACHC,QAAQ,EAAEA,QAAQ;AACpB,GAAC,CAAC,EACFQ,cAAc,EAAE,KAAKP,OAAO,EAAEQ,MAAM,IAAI,KAAK,CAC/C,CAAC;AACH;AACO,SAASC,IAAIA,GAAG;AAChB,SAASC,GAAGA,GAAG;AACf,SAASC,KAAKA,GAAG;AACjB,SAASC,MAAMA,GAAG;AAClB,SAASC,KAAKA,GAAG;;;;"}