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

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,23 @@
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
+ export declare function setConfig({ host }: {
7
+ host: string;
8
+ }): void;
9
+ export declare function setTestId(context: object, str: string | null): void;
10
+ export declare function setIsRecording(value: boolean): void;
11
+ export declare function getIsRecording(): boolean;
12
+ export declare class MockServerHandler implements Handler {
13
+ owner: object;
14
+ constructor(owner: object);
15
+ request<T>(context: RequestContext, next: NextFn<T>): Promise<StructuredDataDocument<T>>;
16
+ }
17
+ interface AdapterForFn {
18
+ adapterFor(this: Store, modelName: string): MinimumAdapterInterface;
19
+ adapterFor(this: Store, modelName: string, _allowMissing?: true): MinimumAdapterInterface | undefined;
20
+ }
21
+ export declare function createAdapterFor(owner: object, fn: AdapterForFn): AdapterForFn;
22
+ export declare function mock(owner: object, generate: ScaffoldGenerator, isRecording?: boolean): Promise<void>;
23
+ export {};
@@ -0,0 +1,43 @@
1
+ export interface Scaffold {
2
+ status: number;
3
+ statusText?: string;
4
+ headers: Record<string, string>;
5
+ body: Record<string, string> | string | null;
6
+ method: string;
7
+ url: string;
8
+ response: Record<string, unknown>;
9
+ }
10
+ export type ScaffoldGenerator = () => Scaffold;
11
+ export type ResponseGenerator = () => Record<string, unknown>;
12
+ /**
13
+ * Sets up Mocking for a GET request on the mock server
14
+ * for the supplied url.
15
+ *
16
+ * The response body is generated by the supplied response function.
17
+ *
18
+ * Available options:
19
+ * - status: the status code to return (default: 200)
20
+ * - headers: the headers to return (default: {})
21
+ * - body: the body to match against for the request (default: null)
22
+ * - RECORD: whether to record the request (default: false)
23
+ *
24
+ * @param url the url to mock, relative to the mock server host (e.g. `users/1`)
25
+ * @param response a function which generates the response to return
26
+ * @param options status, headers for the response, body to match against for the request, and whether to record the request
27
+ * @return
28
+ */
29
+ export declare function GET(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
30
+ RECORD?: boolean;
31
+ }): Promise<void>;
32
+ export declare function POST(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
33
+ RECORD?: boolean;
34
+ }): Promise<void>;
35
+ export declare function PUT(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
36
+ RECORD?: boolean;
37
+ }): Promise<void>;
38
+ export declare function PATCH(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
39
+ RECORD?: boolean;
40
+ }): Promise<void>;
41
+ export declare function DELETE(owner: object, url: string, response: ResponseGenerator, options?: Partial<Omit<Scaffold, "response" | "url" | "method">> & {
42
+ RECORD?: boolean;
43
+ }): Promise<void>;
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { SHOULD_RECORD } from '@warp-drive/core/build-config/env';
2
+
1
3
  const TEST_IDS = new WeakMap();
2
4
  let HOST = 'https://localhost:1135/';
3
5
  function setConfig({
@@ -12,37 +14,52 @@ function setTestId(context, str) {
12
14
  if (str) {
13
15
  TEST_IDS.set(context, {
14
16
  id: str,
15
- request: 0,
16
- mock: 0
17
+ mock: {
18
+ GET: {},
19
+ PUT: {},
20
+ PATCH: {},
21
+ DELETE: {},
22
+ POST: {},
23
+ QUERY: {},
24
+ OPTIONS: {},
25
+ HEAD: {},
26
+ CONNECT: {},
27
+ TRACE: {}
28
+ },
29
+ request: {
30
+ GET: {},
31
+ PUT: {},
32
+ PATCH: {},
33
+ DELETE: {},
34
+ POST: {},
35
+ QUERY: {},
36
+ OPTIONS: {},
37
+ HEAD: {},
38
+ CONNECT: {},
39
+ TRACE: {}
40
+ }
17
41
  });
18
42
  } else {
19
43
  TEST_IDS.delete(context);
20
44
  }
21
45
  }
22
- let IS_RECORDING = false;
46
+ const shouldRecord = SHOULD_RECORD ? true : false;
47
+ let IS_RECORDING = null;
23
48
  function setIsRecording(value) {
24
- IS_RECORDING = Boolean(value);
49
+ IS_RECORDING = value === null ? value : Boolean(value);
25
50
  }
26
51
  function getIsRecording() {
27
- return IS_RECORDING;
52
+ return IS_RECORDING === null ? shouldRecord : IS_RECORDING;
28
53
  }
29
54
  class MockServerHandler {
30
55
  constructor(owner) {
31
56
  this.owner = owner;
32
57
  }
33
58
  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 = '';
59
+ const {
60
+ request,
61
+ queryForTest
62
+ } = setupHolodeckFetch(this.owner, Object.assign({}, context.request));
46
63
  try {
47
64
  const future = next(request);
48
65
  context.setStream(future.getStream());
@@ -55,17 +72,86 @@ class MockServerHandler {
55
72
  }
56
73
  }
57
74
  }
58
- async function mock(owner, generate, isRecording) {
75
+ function setupHolodeckFetch(owner, request) {
59
76
  const test = TEST_IDS.get(owner);
60
77
  if (!test) {
61
- throw new Error(`Cannot call "mock" before configuring a testId. Use setTestId to set the testId for each test`);
78
+ throw new Error(`MockServerHandler is not configured with a testId. Use setTestId to set the testId for each test`);
62
79
  }
63
- const testMockNum = test.mock++;
80
+ const url = request.url;
81
+ const firstChar = url.includes('?') ? '&' : '?';
82
+ const method = request.method?.toUpperCase() ?? 'GET';
83
+
84
+ // enable custom methods
85
+ if (!test.request[method]) {
86
+ // eslint-disable-next-line no-console
87
+ console.log(`⚠️ Using custom HTTP method ${method} for response to request ${url}`);
88
+ test.request[method] = {};
89
+ }
90
+ if (!(url in test.request[method])) {
91
+ test.request[method][url] = 0;
92
+ }
93
+ const queryForTest = `${firstChar}__xTestId=${test.id}&__xTestRequestNumber=${test.request[method][url]++}`;
94
+ request.url = url + queryForTest;
95
+ request.mode = 'cors';
96
+ request.credentials = 'omit';
97
+ request.referrerPolicy = '';
98
+ return {
99
+ request,
100
+ queryForTest
101
+ };
102
+ }
103
+ function createAdapterFor(owner, fn) {
104
+ return function holodeckAdapterFor(modelName, _allowMissing) {
105
+ const adapter = fn.adapterFor.call(this, modelName, _allowMissing);
106
+ if (adapter) {
107
+ if (!adapter.hasOverriddenFetch) {
108
+ adapter.hasOverriddenFetch = true;
109
+ adapter.useFetch = true;
110
+ const originalFetch = adapter._fetchRequest?.bind(adapter);
111
+ adapter._fetchRequest = function (options) {
112
+ if (!originalFetch) {
113
+ throw new Error(`Adapter ${String(modelName)} does not implement _fetchRequest`);
114
+ }
115
+ const {
116
+ request
117
+ } = setupHolodeckFetch(owner, options);
118
+ return originalFetch(request);
119
+ };
120
+ }
121
+ }
122
+ return adapter;
123
+ };
124
+ }
125
+ async function mock(owner, generate, isRecording) {
64
126
  if (getIsRecording() || isRecording) {
127
+ const test = TEST_IDS.get(owner);
128
+ if (!test) {
129
+ throw new Error(`Cannot call "mock" before configuring a testId. Use setTestId to set the testId for each test`);
130
+ }
131
+ const requestToMock = generate();
132
+ const {
133
+ url: mockUrl,
134
+ method
135
+ } = requestToMock;
136
+ if (!mockUrl || !method) {
137
+ throw new Error(`MockError: Cannot mock a request without providing a URL and Method`);
138
+ }
139
+ const mockMethod = method?.toUpperCase() ?? 'GET';
140
+
141
+ // enable custom methods
142
+ if (!test.mock[mockMethod]) {
143
+ // eslint-disable-next-line no-console
144
+ console.log(`⚠️ Using custom HTTP method ${mockMethod} for response to request ${mockUrl}`);
145
+ test.mock[mockMethod] = {};
146
+ }
147
+ if (!(mockUrl in test.mock[mockMethod])) {
148
+ test.mock[mockMethod][mockUrl] = 0;
149
+ }
150
+ const testMockNum = test.mock[mockMethod][mockUrl]++;
65
151
  const url = `${HOST}__record?__xTestId=${test.id}&__xTestRequestNumber=${testMockNum}`;
66
152
  await fetch(url, {
67
153
  method: 'POST',
68
- body: JSON.stringify(generate()),
154
+ body: JSON.stringify(requestToMock),
69
155
  mode: 'cors',
70
156
  credentials: 'omit',
71
157
  referrerPolicy: ''
@@ -73,5 +159,4 @@ async function mock(owner, generate, isRecording) {
73
159
  }
74
160
  }
75
161
 
76
- export { MockServerHandler, getIsRecording, mock, setConfig, setIsRecording, setTestId };
77
- //# sourceMappingURL=index.js.map
162
+ export { MockServerHandler, createAdapterFor, getIsRecording, mock, setConfig, setIsRecording, setTestId };
package/dist/mock.js CHANGED
@@ -28,11 +28,66 @@ function GET(owner, url, response, options) {
28
28
  response: response()
29
29
  }), getIsRecording() || (options?.RECORD ?? false));
30
30
  }
31
- function POST() {}
32
- function PUT() {}
33
- function PATCH() {}
34
- function DELETE() {}
35
- function QUERY() {}
31
+ 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']]);
32
+ function POST(owner, url, response, options) {
33
+ return mock(owner, () => {
34
+ const body = response();
35
+ const status = options?.status ?? (body ? 201 : 204);
36
+ return {
37
+ status: status,
38
+ statusText: options?.statusText ?? STATUS_TEXT_FOR.get(status) ?? '',
39
+ headers: options?.headers ?? {},
40
+ body: options?.body ?? null,
41
+ method: 'POST',
42
+ url,
43
+ response: body
44
+ };
45
+ }, getIsRecording() || (options?.RECORD ?? false));
46
+ }
47
+ function PUT(owner, url, response, options) {
48
+ return mock(owner, () => {
49
+ const body = response();
50
+ const status = options?.status ?? (body ? 200 : 204);
51
+ return {
52
+ status: status,
53
+ statusText: options?.statusText ?? STATUS_TEXT_FOR.get(status) ?? '',
54
+ headers: options?.headers ?? {},
55
+ body: options?.body ?? null,
56
+ method: 'PUT',
57
+ url,
58
+ response: body
59
+ };
60
+ }, getIsRecording() || (options?.RECORD ?? false));
61
+ }
62
+ function PATCH(owner, url, response, options) {
63
+ return mock(owner, () => {
64
+ const body = response();
65
+ const status = options?.status ?? (body ? 200 : 204);
66
+ return {
67
+ status: status,
68
+ statusText: options?.statusText ?? STATUS_TEXT_FOR.get(status) ?? '',
69
+ headers: options?.headers ?? {},
70
+ body: options?.body ?? null,
71
+ method: 'PATCH',
72
+ url,
73
+ response: body
74
+ };
75
+ }, getIsRecording() || (options?.RECORD ?? false));
76
+ }
77
+ function DELETE(owner, url, response, options) {
78
+ return mock(owner, () => {
79
+ const body = response();
80
+ const status = options?.status ?? (body ? 200 : 204);
81
+ return {
82
+ status: status,
83
+ statusText: options?.statusText ?? STATUS_TEXT_FOR.get(status) ?? '',
84
+ headers: options?.headers ?? {},
85
+ body: options?.body ?? null,
86
+ method: 'DELETE',
87
+ url,
88
+ response: body
89
+ };
90
+ }, getIsRecording() || (options?.RECORD ?? false));
91
+ }
36
92
 
37
- export { DELETE, GET, PATCH, POST, PUT, QUERY };
38
- //# sourceMappingURL=mock.js.map
93
+ 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.13",
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.13",
43
+ "@warp-drive/legacy": "5.8.0-alpha.13",
44
+ "@warp-drive/core": "5.8.0-alpha.13"
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.13",
61
+ "@warp-drive/legacy": "5.8.0-alpha.13",
62
+ "@warp-drive/core": "5.8.0-alpha.13",
63
+ "@warp-drive/internal-config": "5.8.0-alpha.13",
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
  },
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;;;;"}