ftmocks-utils 1.3.4 → 1.3.6

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,2 +1,89 @@
1
1
  # ftmocks-utils
2
+
2
3
  Util functions for FtMocks
4
+
5
+ ## Usage: initiatePlaywrightRoutes
6
+
7
+ `initiatePlaywrightRoutes` sets up Playwright network route mocks for your tests.
8
+
9
+ ### Example Test
10
+
11
+ ```js
12
+ import { test, expect } from "@playwright/test";
13
+ import { initiatePlaywrightRoutes } from "ftmocks-utils";
14
+
15
+ test("Sample test case", async ({ page }) => {
16
+ // Initiate Playwright routes with custom directories and patterns
17
+ await initiatePlaywrightRoutes(
18
+ page,
19
+ {
20
+ MOCK_DIR: "../ftmocks",
21
+ FALLBACK_DIR: "../public",
22
+ },
23
+ "Sample test case",
24
+ "**/*" // Pattern(s) to intercept; you can use a string or array of patterns
25
+ );
26
+
27
+ await page.goto("https://example-test.com/");
28
+
29
+ // Now your requests will be mocked as per your ftmocks setup
30
+ // Add your test steps and assertions here
31
+ });
32
+ ```
33
+
34
+ **Parameters**:
35
+
36
+ - `page`: Playwright page object.
37
+ - `options`: Object with configuration. At minimum, provide `MOCK_DIR` (required). `FALLBACK_DIR` is optional.
38
+ - `testName`: (string) Name of this test, so ftmocks can find the right mock data.
39
+ - `patterns`: (string or array) Glob patterns for requests to intercept.
40
+
41
+ Make sure your `MOCK_DIR` points to the directory where your FtMocks records are saved.
42
+
43
+ See more API documentation at [ftmocks.com](https://ftmocks.com) or in the main FtMocks repository.
44
+
45
+ ## Usage: recordPlaywrightRoutes
46
+
47
+ `recordPlaywrightRoutes` allows you to record network requests and responses from a Playwright test session and save them as FtMocks mocks. This is useful for setting up new mocks or updating existing ones with actual traffic.
48
+
49
+ ### Example Usage
50
+
51
+ ```js
52
+ import { test } from "@playwright/test";
53
+ import { recordPlaywrightRoutes } from "ftmocks-utils";
54
+
55
+ test("Record API interactions", async ({ page }) => {
56
+ await recordPlaywrightRoutes(
57
+ page,
58
+ {
59
+ MOCK_DIR: "../ftmocks",
60
+ FALLBACK_DIR: "../public",
61
+ },
62
+ {
63
+ testName: "Recorded test",
64
+ mockPath: "**/*", // Intercept all requests by default
65
+ pattern: "^/api/.*", // Only record requests matching this regex pattern (e.g., API endpoints)
66
+ avoidDuplicatesInTheTest: true, // Skip duplicates within a single test recording
67
+ avoidDuplicatesWithDefaultMocks: true, // Skip duplicates with default mocks
68
+ }
69
+ );
70
+
71
+ await page.goto("https://your-app-under-test.com/");
72
+ // Interact with your page as needed; API requests will be recorded
73
+ });
74
+ ```
75
+
76
+ **Parameters**:
77
+
78
+ - `page`: Playwright page object.
79
+ - `ftmocksConifg`: Object, must contain at minimum `MOCK_DIR`. `FALLBACK_DIR` is optional.
80
+ - `config`: Object containing recording options:
81
+ - `testName`: (string) Name of the test, used for saving the mock data.
82
+ - `mockPath`: (string|array) Glob pattern(s) for requests to intercept.
83
+ - `pattern`: (string) Regex string; only requests matching this will be recorded.
84
+ - `avoidDuplicatesInTheTest`: (boolean) Skip duplicate entries during this run.
85
+ - `avoidDuplicatesWithDefaultMocks`: (boolean) Skip recording if identical default mocks are present.
86
+
87
+ After running the test, FtMocks-compatible mock files will be saved to the specified folder for easy reuse.
88
+
89
+ See more API documentation and advanced usage at [ftmocks.com](https://ftmocks.com).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftmocks-utils",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "Util functions for FtMocks",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -134,6 +134,82 @@ const getTestByName = async (ftmocksConifg, testName) => {
134
134
  }
135
135
  };
136
136
 
137
+ /**
138
+ * Creates a map from an array of objects using `id` property as key.
139
+ * @param {Array<object>} arr - The array of objects to map.
140
+ * @returns {Object} Map of id -> object
141
+ */
142
+ function createIdMap(arr) {
143
+ if (!Array.isArray(arr)) return {};
144
+ return arr.reduce((acc, obj) => {
145
+ if (obj && obj.id !== undefined && obj.id !== null) {
146
+ acc[obj.id] = obj;
147
+ }
148
+ return acc;
149
+ }, {});
150
+ }
151
+
152
+ /**
153
+ * Creates a map where the key is in the format '[method]-[pathname]' and the value is the mock's id.
154
+ * @param {Array<object>} mocks - Array of mock objects. Each should have fileContent with method and url, and an id.
155
+ * @returns {Object} Map of '[method]-[pathname]' -> mock.id
156
+ */
157
+ function createMethodPathnameIdMap(mocks) {
158
+ if (!Array.isArray(mocks)) return {};
159
+ const map = {};
160
+ for (const mock of mocks) {
161
+ if (
162
+ mock &&
163
+ mock.id !== undefined &&
164
+ mock.fileContent &&
165
+ typeof mock.fileContent.method === "string" &&
166
+ typeof mock.fileContent.url === "string"
167
+ ) {
168
+ const urlObj = (() => {
169
+ try {
170
+ return new URL(mock.fileContent.url, "http://localhost");
171
+ } catch (e) {
172
+ // fallback: treat as path only
173
+ return { pathname: mock.fileContent.url };
174
+ }
175
+ })();
176
+ const key = `${mock.fileContent.method.toUpperCase()}-${urlObj.pathname}`;
177
+ if (!map[key]) {
178
+ map[key] = [mock.id];
179
+ } else {
180
+ map[key].push(mock.id);
181
+ }
182
+ }
183
+ }
184
+ return map;
185
+ }
186
+
187
+ /**
188
+ * Returns a unique key for the given mock in the format '[METHOD]-[pathname]'.
189
+ * If the URL can't be parsed, uses the raw URL as pathname.
190
+ * @param {object} mock - The mock object with at least fileContent.method and fileContent.url
191
+ * @returns {string|null} The key as '[METHOD]-[pathname]', or null if invalid input
192
+ */
193
+ function getMockKey(options) {
194
+ if (
195
+ !options ||
196
+ !options.method ||
197
+ !options.url ||
198
+ typeof options.method !== "string" ||
199
+ typeof options.url !== "string"
200
+ ) {
201
+ return null;
202
+ }
203
+ let pathname;
204
+ try {
205
+ const urlObj = new URL(options.url, "http://localhost");
206
+ pathname = urlObj.pathname;
207
+ } catch (_) {
208
+ pathname = options.url;
209
+ }
210
+ return `${options.method.toUpperCase()}-${pathname}`;
211
+ }
212
+
137
213
  module.exports = {
138
214
  charDifference,
139
215
  nameToFolder,
@@ -146,4 +222,7 @@ module.exports = {
146
222
  getHeaders,
147
223
  countFilesInDirectory,
148
224
  getTestByName,
225
+ createIdMap,
226
+ createMethodPathnameIdMap,
227
+ getMockKey,
149
228
  };
package/src/file-utils.js CHANGED
@@ -7,8 +7,43 @@ const saveIfItIsFile = async (route, testName, ftmocksConifg) => {
7
7
 
8
8
  // Check if URL contains file extension like .js, .png, .css etc
9
9
  const fileExtMatch = urlObj.pathname.match(/\.[a-zA-Z0-9]+$/);
10
- if (fileExtMatch) {
11
- const fileExt = fileExtMatch[0];
10
+ // Check mime type if extension is not present
11
+ let fileExt = null;
12
+ if (!fileExtMatch) {
13
+ // Try to get extension from content-type header
14
+ const response = await route.fetch();
15
+ const contentType = response.headers()["content-type"];
16
+ if (contentType) {
17
+ // Map common mime types to extensions
18
+ const mimeToExt = {
19
+ "image/png": ".png",
20
+ "image/jpeg": ".jpg",
21
+ "image/jpg": ".jpg",
22
+ "image/gif": ".gif",
23
+ "image/webp": ".webp",
24
+ "image/svg+xml": ".svg",
25
+ "application/javascript": ".js",
26
+ "application/x-javascript": ".js",
27
+ "text/javascript": ".js",
28
+ "text/css": ".css",
29
+ "font/woff": ".woff",
30
+ "font/woff2": ".woff2",
31
+ "font/ttf": ".ttf",
32
+ "audio/mpeg": ".mp3",
33
+ "audio/wav": ".wav",
34
+ "video/mp4": ".mp4",
35
+ "application/pdf": ".pdf",
36
+ };
37
+ // Remove any charset, etc.
38
+ const mime = contentType.split(";")[0].trim();
39
+ if (mimeToExt[mime]) {
40
+ fileExt = mimeToExt[mime];
41
+ }
42
+ }
43
+ } else {
44
+ fileExt = fileExtMatch[0];
45
+ }
46
+ if (fileExt) {
12
47
  // Create directory path matching URL structure
13
48
  const dirPath = path.join(
14
49
  getMockDir(ftmocksConifg),
@@ -21,15 +56,14 @@ const saveIfItIsFile = async (route, testName, ftmocksConifg) => {
21
56
  fs.mkdirSync(dirPath, { recursive: true });
22
57
 
23
58
  // Save file with original name
24
- const fileName = path.basename(urlObj.pathname);
59
+ const fileName = `${id}${fileExt}`;
25
60
  const filePath = path.join(dirPath, fileName);
26
61
 
27
62
  const response = await route.fetch();
28
63
  const buffer = await response.body();
29
64
  fs.writeFileSync(filePath, buffer);
30
65
 
31
- await route.continue();
32
- return true;
66
+ return fileName;
33
67
  }
34
68
  return false;
35
69
  };
@@ -1,6 +1,12 @@
1
1
  const { compareMockToFetchRequest } = require("./compare-utils");
2
2
  const { getCompareRankMockToFetchRequest } = require("./rank-compare-utils");
3
- const { getMockDir, nameToFolder } = require("./common-utils");
3
+ const {
4
+ getMockDir,
5
+ nameToFolder,
6
+ createIdMap,
7
+ createMethodPathnameIdMap,
8
+ getMockKey,
9
+ } = require("./common-utils");
4
10
  const path = require("path");
5
11
  const fs = require("fs");
6
12
 
@@ -14,11 +20,23 @@ function getMatchingMockData({
14
20
  mode,
15
21
  }) {
16
22
  let served = false;
23
+ const testMockIdMap = createIdMap(testMockData);
17
24
  let matchedMocks =
18
25
  testMockData?.filter((mock) => {
19
26
  if (mock.fileContent.waitForPrevious && !served) {
20
27
  return false;
21
28
  }
29
+ if (mock.fileContent.waitFor) {
30
+ const waitForMocks = mock.fileContent.waitFor.filter(
31
+ (waitForMockId) => {
32
+ const waitForMock = testMockIdMap[waitForMockId];
33
+ return waitForMock && !waitForMock.fileContent.served;
34
+ }
35
+ );
36
+ if (waitForMocks.length > 0) {
37
+ return false;
38
+ }
39
+ }
22
40
  served = mock.fileContent.served;
23
41
  return compareMockToFetchRequest(mock, { url, options });
24
42
  }) || [];
@@ -89,6 +107,107 @@ function getMatchingMockData({
89
107
  return foundMock ? foundMock.fileContent : null;
90
108
  }
91
109
 
110
+ function getMatchingMockDataV2({
111
+ testMockData,
112
+ defaultMockData,
113
+ url,
114
+ options,
115
+ testConfig,
116
+ testName,
117
+ mode,
118
+ }) {
119
+ const testMockIdMap = createIdMap(testMockData);
120
+ const testMockMethodPathnameMap = createMethodPathnameIdMap(testMockData);
121
+ const defaultMockIdMap = createIdMap(defaultMockData);
122
+ const defaultMockMethodPathnameMap =
123
+ createMethodPathnameIdMap(defaultMockData);
124
+ const key = getMockKey(options);
125
+ let matchedMocks = testMockMethodPathnameMap[key] || [];
126
+ let defaultMatchedMocks = [];
127
+ const nonWaitForMocks = matchedMocks.filter((mockId) => {
128
+ const mock = testMockIdMap[mockId];
129
+ if (mock.fileContent.waitFor) {
130
+ const waitForMocks = mock.fileContent.waitFor.filter((waitForMockId) => {
131
+ const waitForMock = testMockIdMap[waitForMockId];
132
+ return waitForMock && !waitForMock.fileContent.served;
133
+ });
134
+ if (waitForMocks.length > 0) {
135
+ return false;
136
+ }
137
+ }
138
+ return true;
139
+ });
140
+ const nonServedMocks = nonWaitForMocks.filter((mockId) => {
141
+ const mock = testMockIdMap[mockId];
142
+ return !mock.fileContent.served;
143
+ });
144
+ let foundMock = null;
145
+ if (nonServedMocks.length > 0) {
146
+ foundMock = testMockIdMap[nonServedMocks[0]];
147
+ } else if (nonWaitForMocks.length > 0) {
148
+ foundMock = testMockIdMap[nonWaitForMocks[nonWaitForMocks.length - 1]];
149
+ }
150
+ if (!foundMock) {
151
+ defaultMatchedMocks = defaultMockMethodPathnameMap[key] || [];
152
+ if (defaultMatchedMocks.length > 0) {
153
+ foundMock = defaultMockIdMap[defaultMatchedMocks[0]];
154
+ }
155
+ }
156
+ if (!foundMock && mode !== "strict") {
157
+ const mockRanks = {};
158
+ matchedMocks.forEach((mockId) => {
159
+ const mock = testMockIdMap[mockId];
160
+ const rank = getCompareRankMockToFetchRequest(mock, {
161
+ url,
162
+ options,
163
+ });
164
+ if (rank > 0) {
165
+ mockRanks[mock.id] = rank;
166
+ }
167
+ });
168
+ defaultMatchedMocks.forEach((mockId) => {
169
+ const mock = defaultMockIdMap[mockId];
170
+ const rank = getCompareRankMockToFetchRequest(mock, {
171
+ url,
172
+ options,
173
+ });
174
+ if (rank > 0) {
175
+ mockRanks[mock.id] = rank;
176
+ }
177
+ });
178
+ // Sort by rank to find the best match
179
+ const sortedRanks = Object.entries(mockRanks).sort((a, b) => a[1] - b[1]);
180
+ if (sortedRanks.length > 0) {
181
+ const bestMockId = sortedRanks?.[0]?.[0];
182
+ if (bestMockId) {
183
+ foundMock = testMockIdMap[bestMockId] || defaultMockIdMap[bestMockId];
184
+ }
185
+ }
186
+ }
187
+ // updating stats to mock file
188
+ if (foundMock) {
189
+ let mockFilePath = path.join(
190
+ getMockDir(testConfig),
191
+ `test_${nameToFolder(testName)}`,
192
+ `mock_${foundMock.id}.json`
193
+ );
194
+ if (!fs.existsSync(mockFilePath)) {
195
+ mockFilePath = path.join(
196
+ getMockDir(testConfig),
197
+ "defaultMocks",
198
+ `mock_${foundMock.id}.json`
199
+ );
200
+ }
201
+ foundMock.fileContent.served = true;
202
+ fs.writeFileSync(
203
+ mockFilePath,
204
+ JSON.stringify(foundMock.fileContent, null, 2)
205
+ );
206
+ }
207
+ return foundMock ? foundMock.fileContent : null;
208
+ }
209
+
92
210
  module.exports = {
93
211
  getMatchingMockData,
212
+ getMatchingMockDataV2,
94
213
  };
@@ -14,6 +14,8 @@ const crypto = require("crypto");
14
14
  const fs = require("fs");
15
15
 
16
16
  let logger = null;
17
+ const DEFAULT_EXCLUDED_HEADERS =
18
+ "cookie,set-cookie,authorization,www-authenticate";
17
19
 
18
20
  async function initiatePlaywrightRoutes(
19
21
  page,
@@ -204,6 +206,22 @@ async function initiatePlaywrightRoutes(
204
206
  });
205
207
  }
206
208
 
209
+ const excludeHeaders = (headers, ftmocksConifg) => {
210
+ const excludedHeaders =
211
+ ftmocksConifg.EXCLUDED_HEADERS || DEFAULT_EXCLUDED_HEADERS;
212
+ if (!excludedHeaders) {
213
+ return headers;
214
+ }
215
+ excludedHeaders.split(",").forEach((header) => {
216
+ Object.keys(headers).forEach((key) => {
217
+ if (key.toLowerCase() === header.toLowerCase()) {
218
+ delete headers[key];
219
+ }
220
+ });
221
+ });
222
+ return headers;
223
+ };
224
+
207
225
  async function recordPlaywrightRoutes(
208
226
  page,
209
227
  ftmocksConifg,
@@ -231,16 +249,21 @@ async function recordPlaywrightRoutes(
231
249
  await createTest(ftmocksConifg, config.testName);
232
250
  }
233
251
 
234
- if (await saveIfItIsFile(route, config.testName, ftmocksConifg)) {
235
- return;
236
- }
252
+ const fileName = await saveIfItIsFile(
253
+ route,
254
+ config.testName,
255
+ ftmocksConifg
256
+ );
237
257
 
238
258
  const mockData = {
239
259
  url: urlObj.pathname + urlObj.search,
240
260
  time: new Date().toString(),
241
261
  method: route.request().method(),
242
262
  request: {
243
- headers: await route.request().headers(),
263
+ headers: excludeHeaders(
264
+ await route.request().headers(),
265
+ ftmocksConifg
266
+ ),
244
267
  queryString: Array.from(urlObj.searchParams.entries()).map(
245
268
  ([name, value]) => ({
246
269
  name,
@@ -255,9 +278,10 @@ async function recordPlaywrightRoutes(
255
278
  : null,
256
279
  },
257
280
  response: {
281
+ file: fileName,
258
282
  status: (await route.fetch()).status(),
259
283
  headers: (await route.fetch()).headers(),
260
- content: await (await route.fetch()).text(),
284
+ content: fileName ? null : await (await route.fetch()).text(),
261
285
  },
262
286
  id: crypto.randomUUID(),
263
287
  served: false,