@usebruno/filestore 0.11.0 → 0.12.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 (65) hide show
  1. package/dist/cjs/index.js +1 -1
  2. package/dist/cjs/index.js.map +1 -1
  3. package/dist/cjs/workers/worker-script.js +1 -1
  4. package/dist/cjs/workers/worker-script.js.map +1 -1
  5. package/dist/constants.d.ts +1 -1
  6. package/dist/constants.d.ts.map +1 -1
  7. package/dist/esm/index.js +1 -1
  8. package/dist/esm/index.js.map +1 -1
  9. package/dist/esm/workers/worker-script.js +1 -1
  10. package/dist/esm/workers/worker-script.js.map +1 -1
  11. package/dist/formats/bru/utils/redact-large-text-blocks.d.ts.map +1 -1
  12. package/dist/formats/yml/index.d.ts +3 -1
  13. package/dist/formats/yml/index.d.ts.map +1 -1
  14. package/dist/formats/yml/items/parseGraphQLRequest.d.ts.map +1 -1
  15. package/dist/formats/yml/items/parseHttpRequest.d.ts.map +1 -1
  16. package/dist/formats/yml/items/stringifyGraphQLRequest.d.ts.map +1 -1
  17. package/dist/formats/yml/items/stringifyHttpRequest.d.ts.map +1 -1
  18. package/dist/formats/yml/mockServerTypes.d.ts +95 -0
  19. package/dist/formats/yml/mockServerTypes.d.ts.map +1 -0
  20. package/dist/formats/yml/parseCollection.d.ts.map +1 -1
  21. package/dist/formats/yml/parseFolder.d.ts.map +1 -1
  22. package/dist/formats/yml/parseMockServer.d.ts +4 -0
  23. package/dist/formats/yml/parseMockServer.d.ts.map +1 -0
  24. package/dist/formats/yml/stringifyCollection.d.ts.map +1 -1
  25. package/dist/formats/yml/stringifyFolder.d.ts.map +1 -1
  26. package/dist/formats/yml/stringifyMockServer.d.ts +4 -0
  27. package/dist/formats/yml/stringifyMockServer.d.ts.map +1 -0
  28. package/dist/index.d.ts +14 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/types.d.ts +1 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/workers/WorkerQueue/index.d.ts +3 -2
  33. package/dist/workers/WorkerQueue/index.d.ts.map +1 -1
  34. package/dist/workers/index.d.ts +4 -0
  35. package/dist/workers/index.d.ts.map +1 -1
  36. package/package.json +5 -5
  37. package/src/constants.ts +5 -1
  38. package/src/formats/bru/tests/redact-large-text-blocks.spec.js +160 -1
  39. package/src/formats/bru/utils/redact-large-text-blocks.ts +42 -1
  40. package/src/formats/yml/index.ts +5 -1
  41. package/src/formats/yml/items/parseGraphQLRequest.ts +6 -16
  42. package/src/formats/yml/items/parseHttpRequest.ts +6 -16
  43. package/src/formats/yml/items/stringifyGraphQLRequest.ts +4 -6
  44. package/src/formats/yml/items/stringifyHttpRequest.ts +4 -6
  45. package/src/formats/yml/mockServerTypes.ts +95 -0
  46. package/src/formats/yml/parseCollection.spec.ts +123 -0
  47. package/src/formats/yml/parseCollection.ts +18 -5
  48. package/src/formats/yml/parseFolder.spec.ts +18 -0
  49. package/src/formats/yml/parseFolder.ts +5 -1
  50. package/src/formats/yml/parseMockServer.spec.ts +180 -0
  51. package/src/formats/yml/parseMockServer.ts +113 -0
  52. package/src/formats/yml/stringifyCollection.spec.ts +146 -0
  53. package/src/formats/yml/stringifyCollection.ts +22 -9
  54. package/src/formats/yml/stringifyFolder.spec.ts +23 -0
  55. package/src/formats/yml/stringifyFolder.ts +7 -2
  56. package/src/formats/yml/stringifyMockServer.spec.ts +177 -0
  57. package/src/formats/yml/stringifyMockServer.ts +129 -0
  58. package/src/formats/yml/tests/defaultEnvironment.spec.js +50 -0
  59. package/src/formats/yml/tests/stringifyEnvironment.spec.ts +26 -0
  60. package/src/formats/yml/utils.ts +1 -1
  61. package/src/index.ts +34 -1
  62. package/src/types.ts +1 -0
  63. package/src/workers/WorkerQueue/index.ts +10 -2
  64. package/src/workers/index.ts +25 -1
  65. package/src/workers/worker-script.ts +45 -21
@@ -0,0 +1,113 @@
1
+ import { parseYml } from './utils';
2
+ import { ensureString, isNonEmptyString } from '../../utils';
3
+ import { toBrunoHttpHeaders } from './common/headers';
4
+ import { toBrunoParams } from './common/params';
5
+ import { toBrunoBody } from './common/body';
6
+ import type {
7
+ MockServerFile,
8
+ MockRouteEntry,
9
+ BrunoMockServer,
10
+ BrunoMockRoute,
11
+ MockRuleCondition
12
+ } from './mockServerTypes';
13
+
14
+ const emptyRequestBody = () => ({
15
+ mode: 'none',
16
+ json: null,
17
+ text: null,
18
+ xml: null,
19
+ sparql: null,
20
+ formUrlEncoded: null,
21
+ multipartForm: null,
22
+ graphql: null,
23
+ file: null
24
+ });
25
+
26
+ const toBrunoRuleConditions = (conditions: MockRuleCondition[] | null | undefined) => {
27
+ if (!Array.isArray(conditions)) {
28
+ return [];
29
+ }
30
+
31
+ return conditions.map((condition) => ({
32
+ target: ensureString(condition?.target),
33
+ key: ensureString(condition?.key),
34
+ operator: ensureString(condition?.operator, 'equals'),
35
+ value: ensureString(condition?.value)
36
+ }));
37
+ };
38
+
39
+ const toBrunoMockRoute = (route: MockRouteEntry): BrunoMockRoute => {
40
+ const responseStatus = Number(route?.response?.status) || 200;
41
+ const responseStatusText = ensureString(route?.response?.statusText);
42
+
43
+ const brunoRoute: BrunoMockRoute = {
44
+ name: ensureString(route?.name, 'Untitled Mock Response'),
45
+ description: ensureString(route?.description),
46
+ request: {
47
+ url: ensureString(route?.request?.url, '/'),
48
+ method: ensureString(route?.request?.method, 'GET'),
49
+ headers: toBrunoHttpHeaders(route?.request?.headers) || [],
50
+ params: toBrunoParams(route?.request?.params) || [],
51
+ body: toBrunoBody(route?.request?.body) || emptyRequestBody()
52
+ },
53
+ response: {
54
+ status: responseStatus,
55
+ statusText: responseStatusText === 'OK' && responseStatus !== 200 ? '' : responseStatusText,
56
+ headers: toBrunoHttpHeaders(route?.response?.headers) || [],
57
+ body: {
58
+ type: ensureString(route?.response?.body?.type, 'json'),
59
+ content: ensureString(route?.response?.body?.data)
60
+ }
61
+ },
62
+ rules: {
63
+ operator: route?.rules?.operator === 'OR' ? 'OR' : 'AND',
64
+ conditions: toBrunoRuleConditions(route?.rules?.conditions)
65
+ }
66
+ };
67
+
68
+ const rawExample = route?.copiedFrom?.example;
69
+ const rawRequestPath = route?.copiedFrom?.requestPath;
70
+ const copiedFromExample = isNonEmptyString(rawExample) ? rawExample : null;
71
+ const copiedFromRequestPath = isNonEmptyString(rawRequestPath) ? rawRequestPath : null;
72
+
73
+ if (copiedFromExample || copiedFromRequestPath) {
74
+ brunoRoute.copiedFrom = {
75
+ exampleName: copiedFromExample,
76
+ requestPathname: copiedFromRequestPath
77
+ };
78
+ }
79
+
80
+ return brunoRoute;
81
+ };
82
+
83
+ const parseMockServer = (content: string): BrunoMockServer => {
84
+ const parsed: MockServerFile = parseYml(content);
85
+
86
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
87
+ throw new Error('Invalid mock server file');
88
+ }
89
+
90
+ const info = parsed.info || ({} as NonNullable<MockServerFile['info']>);
91
+ const mock = parsed.mock || ({} as NonNullable<MockServerFile['mock']>);
92
+
93
+ const brunoMockServer: BrunoMockServer = {
94
+ name: ensureString(info.name, 'Mock Server'),
95
+ port: Number(mock.port) || null,
96
+ delay: Number(mock.delay) || 0,
97
+ source: null,
98
+ routes: Array.isArray(parsed.routes) ? parsed.routes.map(toBrunoMockRoute) : []
99
+ };
100
+
101
+ const sourceType = mock.source?.type;
102
+ const sourcePath = mock.source?.path;
103
+ if ((sourceType === 'collection' || sourceType === 'spec') && isNonEmptyString(sourcePath)) {
104
+ brunoMockServer.source = {
105
+ type: sourceType,
106
+ path: sourcePath
107
+ };
108
+ }
109
+
110
+ return brunoMockServer;
111
+ };
112
+
113
+ export default parseMockServer;
@@ -48,6 +48,152 @@ describe('stringifyCollection — typed request.variables', () => {
48
48
  });
49
49
  });
50
50
 
51
+ describe('stringifyCollection — script execution flow', () => {
52
+ const baseRoot = {
53
+ meta: null,
54
+ request: { headers: [], auth: { mode: 'none' }, script: { req: null, res: null }, tests: null, vars: { req: [], res: [] } },
55
+ docs: null
56
+ } as any;
57
+
58
+ it('round-trips flow: sequential through the bruno extension', () => {
59
+ const yml = stringifyCollection(baseRoot, { name: 'c', scripts: { flow: 'sequential' } });
60
+ expect(yml).toMatch(/flow:\s*sequential/);
61
+ expect(parseCollection(yml).brunoConfig.scripts?.flow).toBe('sequential');
62
+ });
63
+
64
+ it('round-trips flow: sandwich through the bruno extension', () => {
65
+ const yml = stringifyCollection(baseRoot, { name: 'c', scripts: { flow: 'sandwich' } });
66
+ expect(parseCollection(yml).brunoConfig.scripts?.flow).toBe('sandwich');
67
+ });
68
+
69
+ it('writes no flow when the collection has none', () => {
70
+ const yml = stringifyCollection(baseRoot, { name: 'c' });
71
+ expect(yml).not.toMatch(/flow:/);
72
+ expect(parseCollection(yml).brunoConfig.scripts?.flow).toBeUndefined();
73
+ });
74
+
75
+ it('drops an unrecognized flow value on write', () => {
76
+ const yml = stringifyCollection(baseRoot, { name: 'c', scripts: { flow: 'parallel' } });
77
+ expect(yml).not.toMatch(/flow:/);
78
+ });
79
+
80
+ it('preserves additionalContextRoots alongside a written flow value', () => {
81
+ const yml = stringifyCollection(baseRoot, {
82
+ name: 'c',
83
+ scripts: { flow: 'sequential', additionalContextRoots: ['../libs'] }
84
+ });
85
+ const { brunoConfig } = parseCollection(yml);
86
+ expect(brunoConfig.scripts?.flow).toBe('sequential');
87
+ expect(brunoConfig.scripts?.additionalContextRoots).toEqual(['../libs']);
88
+ });
89
+ });
90
+
91
+ describe('stringifyCollection — client certificates', () => {
92
+ const baseRoot = {
93
+ meta: null,
94
+ request: { headers: [], auth: { mode: 'none' }, script: { req: null, res: null }, tests: null, vars: { req: [], res: [] } },
95
+ docs: null
96
+ } as any;
97
+
98
+ it('round-trips cert/pfx certificates', () => {
99
+ const brunoConfig = {
100
+ name: 'c',
101
+ clientCertificates: {
102
+ certs: [
103
+ {
104
+ domain: 'localhost',
105
+ type: 'cert',
106
+ certFilePath: './certs/client-cert.pem',
107
+ keyFilePath: './certs/client-key.pem',
108
+ passphrase: 'secret'
109
+ },
110
+ {
111
+ domain: 'example.com',
112
+ type: 'pfx',
113
+ pfxFilePath: './certs/client.pfx',
114
+ passphrase: ''
115
+ }
116
+ ]
117
+ }
118
+ };
119
+
120
+ const yml = stringifyCollection(baseRoot, brunoConfig);
121
+ const { brunoConfig: reparsed } = parseCollection(yml);
122
+
123
+ expect(reparsed.clientCertificates.certs).toEqual([
124
+ {
125
+ domain: 'localhost',
126
+ type: 'cert',
127
+ certFilePath: './certs/client-cert.pem',
128
+ keyFilePath: './certs/client-key.pem',
129
+ passphrase: 'secret'
130
+ },
131
+ {
132
+ domain: 'example.com',
133
+ type: 'pfx',
134
+ pfxFilePath: './certs/client.pfx',
135
+ passphrase: ''
136
+ }
137
+ ]);
138
+ });
139
+
140
+ it('writes disabled: true for disabled certs and round-trips it', () => {
141
+ const brunoConfig = {
142
+ name: 'c',
143
+ clientCertificates: {
144
+ certs: [
145
+ {
146
+ domain: 'localhost',
147
+ type: 'cert',
148
+ certFilePath: './certs/client-cert.pem',
149
+ keyFilePath: './certs/client-key.pem',
150
+ passphrase: 'secret',
151
+ disabled: true
152
+ },
153
+ {
154
+ domain: 'example.com',
155
+ type: 'pfx',
156
+ pfxFilePath: './certs/client.pfx',
157
+ passphrase: '',
158
+ disabled: true
159
+ }
160
+ ]
161
+ }
162
+ };
163
+
164
+ const yml = stringifyCollection(baseRoot, brunoConfig);
165
+
166
+ expect(yml).toMatch(/disabled:\s*true/);
167
+
168
+ const { brunoConfig: reparsed } = parseCollection(yml);
169
+ expect(reparsed.clientCertificates.certs[0].disabled).toBe(true);
170
+ expect(reparsed.clientCertificates.certs[1].disabled).toBe(true);
171
+ });
172
+
173
+ it('does not write disabled for enabled certs', () => {
174
+ const brunoConfig = {
175
+ name: 'c',
176
+ clientCertificates: {
177
+ certs: [
178
+ {
179
+ domain: 'example.com',
180
+ type: 'pfx',
181
+ pfxFilePath: './certs/client.pfx',
182
+ passphrase: ''
183
+ }
184
+ ]
185
+ }
186
+ };
187
+
188
+ const yml = stringifyCollection(baseRoot, brunoConfig);
189
+
190
+ expect(yml).not.toMatch(/disabled:/);
191
+
192
+ const { brunoConfig: reparsed } = parseCollection(yml);
193
+ expect(reparsed.clientCertificates.certs[0]).not.toHaveProperty('disabled');
194
+ });
195
+ });
196
+
51
197
  describe('stringifyCollection — writing the collection version', () => {
52
198
  const baseRoot = {
53
199
  meta: null,
@@ -61,7 +61,8 @@ const hasRequestScripts = (collectionRoot: any): boolean => {
61
61
 
62
62
  const hasPresets = (brunoConfig: any): boolean => {
63
63
  return brunoConfig?.presets?.requestType?.length
64
- || brunoConfig?.presets?.requestUrl?.length;
64
+ || brunoConfig?.presets?.requestUrl?.length
65
+ || brunoConfig?.presets?.defaultEnvironment?.length;
65
66
  };
66
67
 
67
68
  const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
@@ -148,7 +149,8 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
148
149
  type: 'pem',
149
150
  certificateFilePath: cert.certFilePath,
150
151
  privateKeyFilePath: cert.keyFilePath,
151
- ...(cert.passphrase && { passphrase: cert.passphrase })
152
+ ...(cert.passphrase && { passphrase: cert.passphrase }),
153
+ ...(cert.disabled === true && { disabled: true })
152
154
  };
153
155
  return pemCert;
154
156
  } else if (cert.type === 'pfx') {
@@ -156,7 +158,8 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
156
158
  domain: cert.domain,
157
159
  type: 'pkcs12',
158
160
  pkcs12FilePath: cert.pfxFilePath,
159
- ...(cert.passphrase && { passphrase: cert.passphrase })
161
+ ...(cert.passphrase && { passphrase: cert.passphrase }),
162
+ ...(cert.disabled === true && { disabled: true })
160
163
  };
161
164
  return pkcs12Cert;
162
165
  } else {
@@ -241,6 +244,7 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
241
244
  }
242
245
 
243
246
  if (hasPresets(brunoConfig)) {
247
+ const presetsExtension: any = {};
244
248
  const presetsRequest: any = {};
245
249
  if (brunoConfig.presets.requestType?.length) {
246
250
  presetsRequest.type = brunoConfig.presets.requestType;
@@ -248,22 +252,31 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
248
252
  if (brunoConfig.presets.requestUrl?.length) {
249
253
  presetsRequest.url = brunoConfig.presets.requestUrl;
250
254
  }
251
- brunoExtension.presets = {
252
- request: presetsRequest
253
- };
255
+ if (Object.keys(presetsRequest).length) {
256
+ presetsExtension.request = presetsRequest;
257
+ }
258
+ if (brunoConfig.presets.defaultEnvironment?.length) {
259
+ presetsExtension.defaultEnvironment = brunoConfig.presets.defaultEnvironment;
260
+ }
261
+ brunoExtension.presets = presetsExtension;
254
262
  }
255
263
 
256
264
  oc.extensions.bruno = brunoExtension;
257
265
  }
258
266
 
259
267
  // bruno-specific script extensions
268
+ const brunoScripts: Record<string, unknown> = {};
260
269
  if (brunoConfig.scripts?.additionalContextRoots?.length) {
270
+ brunoScripts.additionalContextRoots = brunoConfig.scripts.additionalContextRoots;
271
+ }
272
+ if (brunoConfig.scripts?.flow === 'sandwich' || brunoConfig.scripts?.flow === 'sequential') {
273
+ brunoScripts.flow = brunoConfig.scripts.flow;
274
+ }
275
+ if (Object.keys(brunoScripts).length > 0) {
261
276
  if (!oc.extensions.bruno) {
262
277
  oc.extensions.bruno = {};
263
278
  }
264
- (oc.extensions.bruno as any).scripts = {
265
- additionalContextRoots: brunoConfig.scripts.additionalContextRoots
266
- };
279
+ (oc.extensions.bruno as any).scripts = brunoScripts;
267
280
  }
268
281
 
269
282
  // bruno-specific extensions
@@ -45,3 +45,26 @@ describe('stringifyFolder — typed request.variables', () => {
45
45
  expect(reqVars[4].dataType).toBeUndefined();
46
46
  });
47
47
  });
48
+
49
+ describe('stringifyFolder — seq', () => {
50
+ it('omits seq when the folder has none', () => {
51
+ const folderRoot = { meta: { name: 'no-seq-folder' }, docs: null } as any;
52
+
53
+ const yml = stringifyFolder(folderRoot);
54
+ const { meta } = parseFolder(yml);
55
+
56
+ expect(yml).not.toMatch(/seq:/);
57
+ expect(meta).toEqual(expect.objectContaining({ name: 'no-seq-folder' }));
58
+ expect(meta!.seq).toBeUndefined();
59
+ });
60
+
61
+ it('preserves an explicit numeric seq', () => {
62
+ const folderRoot = { meta: { name: 'ordered-folder', seq: 3 }, docs: null } as any;
63
+
64
+ const yml = stringifyFolder(folderRoot);
65
+ const { meta } = parseFolder(yml);
66
+
67
+ expect(meta).toEqual(expect.objectContaining({ name: 'ordered-folder' }));
68
+ expect(meta!.seq).toBe(3);
69
+ });
70
+ });
@@ -40,9 +40,14 @@ const stringifyFolder = (folderRoot: FolderRoot): string => {
40
40
  // info block
41
41
  const info: FolderInfo = {
42
42
  name: folderRoot.meta?.name || 'Untitled Folder',
43
- type: 'folder',
44
- seq: folderRoot.meta?.seq || 1
43
+ type: 'folder'
45
44
  };
45
+ // Only write seq when the folder actually has a numeric one. Defaulting to 1 would
46
+ // force every seq-less folder into position 1 on disk and break alphabetical fallback.
47
+ const seq = folderRoot.meta?.seq;
48
+ if (typeof seq === 'number' && Number.isFinite(seq)) {
49
+ info.seq = seq;
50
+ }
46
51
  ocFolder.info = info;
47
52
 
48
53
  // request defaults
@@ -0,0 +1,177 @@
1
+ import stringifyMockServer from './stringifyMockServer';
2
+ import parseMockServer from './parseMockServer';
3
+ import type { BrunoMockServer } from './mockServerTypes';
4
+
5
+ const fullMockServer: BrunoMockServer = {
6
+ name: 'Dog API Mock',
7
+ port: 4001,
8
+ delay: 500,
9
+ source: {
10
+ type: 'collection',
11
+ path: '/Users/x/collections/dog-api'
12
+ },
13
+ routes: [
14
+ {
15
+ name: 'Get user 200',
16
+ description: 'Returns a user',
17
+ request: {
18
+ url: '/users/:id',
19
+ method: 'GET',
20
+ headers: [{ uid: 'h1', name: 'X-Client', value: 'bruno', enabled: true }],
21
+ params: [{ uid: 'p1', name: 'id', value: '1', type: 'path', enabled: true }],
22
+ body: { mode: 'json', json: '{"filter": true}' }
23
+ },
24
+ response: {
25
+ status: 200,
26
+ statusText: 'OK',
27
+ headers: [{ uid: 'rh1', name: 'Content-Type', value: 'application/json', enabled: true }],
28
+ body: {
29
+ type: 'json',
30
+ content: '{"id": 1}'
31
+ }
32
+ },
33
+ rules: {
34
+ operator: 'AND',
35
+ conditions: [
36
+ { target: 'header', key: 'Authorization', operator: 'equals', value: 'token' }
37
+ ]
38
+ },
39
+ copiedFrom: {
40
+ exampleName: 'Success',
41
+ requestPathname: '/Users/x/collections/dog-api/get-user.yml'
42
+ }
43
+ }
44
+ ]
45
+ };
46
+
47
+ describe('stringifyMockServer', () => {
48
+ it('writes the info block with type mock', () => {
49
+ const content = stringifyMockServer(fullMockServer);
50
+
51
+ expect(content).toContain('info:');
52
+ expect(content).toContain('name: Dog API Mock');
53
+ expect(content).toContain('type: mock');
54
+ });
55
+
56
+ it('writes port, delay and source under the mock block', () => {
57
+ const content = stringifyMockServer(fullMockServer);
58
+
59
+ expect(content).toContain('mock:');
60
+ expect(content).toContain('port: 4001');
61
+ expect(content).toContain('delay: 500');
62
+ expect(content).toContain('type: collection');
63
+ expect(content).toContain('path: /Users/x/collections/dog-api');
64
+ });
65
+
66
+ it('omits delay when zero and source when manual', () => {
67
+ const content = stringifyMockServer({
68
+ name: 'Manual Mock',
69
+ port: 4000,
70
+ delay: 0,
71
+ source: null,
72
+ routes: []
73
+ });
74
+
75
+ expect(content).not.toContain('delay:');
76
+ expect(content).not.toContain('source:');
77
+ expect(content).not.toContain('routes:');
78
+ });
79
+
80
+ it('omits rules when there are no conditions', () => {
81
+ const content = stringifyMockServer({
82
+ name: 'Mock',
83
+ port: 4000,
84
+ delay: 0,
85
+ source: null,
86
+ routes: [
87
+ {
88
+ name: 'Ping',
89
+ description: '',
90
+ request: { url: '/ping', method: 'GET', headers: [], params: [], body: { mode: 'none' } },
91
+ response: { status: 204, statusText: 'No Content', headers: [], body: { type: 'text', content: '' } },
92
+ rules: { operator: 'AND', conditions: [] }
93
+ }
94
+ ]
95
+ });
96
+
97
+ expect(content).not.toContain('rules:');
98
+ });
99
+
100
+ it('does not write uids anywhere', () => {
101
+ const content = stringifyMockServer(fullMockServer);
102
+ expect(content).not.toContain('uid');
103
+ });
104
+ });
105
+
106
+ describe('mock server round-trip', () => {
107
+ it('parse(stringify(x)) preserves all fields', () => {
108
+ const parsed = parseMockServer(stringifyMockServer(fullMockServer));
109
+
110
+ expect(parsed.name).toBe('Dog API Mock');
111
+ expect(parsed.port).toBe(4001);
112
+ expect(parsed.delay).toBe(500);
113
+ expect(parsed.source).toEqual({ type: 'collection', path: '/Users/x/collections/dog-api' });
114
+
115
+ expect(parsed.routes).toHaveLength(1);
116
+ const route = parsed.routes[0];
117
+ expect(route.name).toBe('Get user 200');
118
+ expect(route.description).toBe('Returns a user');
119
+ expect(route.request.url).toBe('/users/:id');
120
+ expect(route.request.method).toBe('GET');
121
+ expect(route.request.headers).toEqual([
122
+ expect.objectContaining({ name: 'X-Client', value: 'bruno', enabled: true })
123
+ ]);
124
+ expect(route.request.params).toEqual([
125
+ expect.objectContaining({ name: 'id', value: '1', type: 'path', enabled: true })
126
+ ]);
127
+ expect(route.request.body).toEqual(expect.objectContaining({ mode: 'json', json: '{"filter": true}' }));
128
+ expect(route.response.status).toBe(200);
129
+ expect(route.response.statusText).toBe('OK');
130
+ expect(route.response.headers).toEqual([
131
+ expect.objectContaining({ name: 'Content-Type', value: 'application/json' })
132
+ ]);
133
+ expect(route.response.body).toEqual({ type: 'json', content: '{"id": 1}' });
134
+ expect(route.rules).toEqual({
135
+ operator: 'AND',
136
+ conditions: [{ target: 'header', key: 'Authorization', operator: 'equals', value: 'token' }]
137
+ });
138
+ expect(route.copiedFrom).toEqual({
139
+ exampleName: 'Success',
140
+ requestPathname: '/Users/x/collections/dog-api/get-user.yml'
141
+ });
142
+ });
143
+
144
+ it('round-trips a spec-sourced server', () => {
145
+ const parsed = parseMockServer(stringifyMockServer({
146
+ name: 'Spec Mock',
147
+ port: 4002,
148
+ delay: 0,
149
+ source: { type: 'spec', path: '/Users/x/specs/petstore.yml' },
150
+ routes: []
151
+ }));
152
+
153
+ expect(parsed.source).toEqual({ type: 'spec', path: '/Users/x/specs/petstore.yml' });
154
+ expect(parsed.routes).toEqual([]);
155
+ });
156
+
157
+ it('round-trips multiline json response bodies byte-safely', () => {
158
+ const content = '{\n "id": 1,\n "name": "rex: \'good\' dog"\n}';
159
+ const parsed = parseMockServer(stringifyMockServer({
160
+ name: 'Mock',
161
+ port: 4000,
162
+ delay: 0,
163
+ source: null,
164
+ routes: [
165
+ {
166
+ name: 'Get dog',
167
+ description: '',
168
+ request: { url: '/dogs/1', method: 'GET', headers: [], params: [], body: { mode: 'none' } },
169
+ response: { status: 200, statusText: 'OK', headers: [], body: { type: 'json', content } },
170
+ rules: { operator: 'AND', conditions: [] }
171
+ }
172
+ ]
173
+ }));
174
+
175
+ expect(parsed.routes[0].response.body.content).toBe(content);
176
+ });
177
+ });
@@ -0,0 +1,129 @@
1
+ import { stringifyYml } from './utils';
2
+ import { isNonEmptyString } from '../../utils';
3
+ import { toOpenCollectionHttpHeaders, toOpenCollectionResponseHeaders } from './common/headers';
4
+ import { toOpenCollectionParams } from './common/params';
5
+ import { toOpenCollectionBody } from './common/body';
6
+ import type {
7
+ MockServerFile,
8
+ MockRouteEntry,
9
+ BrunoMockServer,
10
+ BrunoMockRoute
11
+ } from './mockServerTypes';
12
+
13
+ const toOcRuleConditions = (conditions: BrunoMockRoute['rules']['conditions']) => (
14
+ conditions.map((condition) => ({
15
+ target: condition.target || '',
16
+ key: condition.key || '',
17
+ operator: condition.operator || 'equals',
18
+ value: condition.value || ''
19
+ }))
20
+ );
21
+
22
+ const toOcMockRoute = (route: BrunoMockRoute): MockRouteEntry => {
23
+ const ocRoute: MockRouteEntry = {
24
+ name: route.name || 'Untitled Mock Response'
25
+ };
26
+
27
+ if (isNonEmptyString(route.description)) {
28
+ ocRoute.description = route.description;
29
+ }
30
+
31
+ ocRoute.request = {
32
+ method: route.request?.method || 'GET',
33
+ url: route.request?.url || '/'
34
+ };
35
+
36
+ const requestHeaders = toOpenCollectionHttpHeaders(route.request?.headers);
37
+ if (requestHeaders) {
38
+ ocRoute.request.headers = requestHeaders;
39
+ }
40
+
41
+ const requestParams = toOpenCollectionParams(route.request?.params);
42
+ if (requestParams) {
43
+ ocRoute.request.params = requestParams;
44
+ }
45
+
46
+ const requestBody = toOpenCollectionBody(route.request?.body);
47
+ if (requestBody !== undefined && !Array.isArray(requestBody)) {
48
+ ocRoute.request.body = requestBody;
49
+ }
50
+
51
+ ocRoute.response = {
52
+ status: Number(route.response?.status) || 200
53
+ };
54
+
55
+ if (isNonEmptyString(route.response?.statusText)) {
56
+ ocRoute.response.statusText = route.response.statusText;
57
+ }
58
+
59
+ const responseHeaders = toOpenCollectionResponseHeaders(route.response?.headers);
60
+ if (responseHeaders) {
61
+ ocRoute.response.headers = responseHeaders;
62
+ }
63
+
64
+ const responseBody = route.response?.body;
65
+ if (responseBody && responseBody.type) {
66
+ const content = responseBody.content;
67
+ ocRoute.response.body = {
68
+ type: responseBody.type,
69
+ data: typeof content === 'string' ? content : JSON.stringify(content, null, 2)
70
+ };
71
+ }
72
+
73
+ if (route.rules?.conditions?.length) {
74
+ ocRoute.rules = {
75
+ operator: route.rules.operator === 'OR' ? 'OR' : 'AND',
76
+ conditions: toOcRuleConditions(route.rules.conditions)
77
+ };
78
+ }
79
+
80
+ if (route.copiedFrom && (route.copiedFrom.exampleName || route.copiedFrom.requestPathname)) {
81
+ ocRoute.copiedFrom = {};
82
+ if (route.copiedFrom.exampleName) {
83
+ ocRoute.copiedFrom.example = route.copiedFrom.exampleName;
84
+ }
85
+ if (route.copiedFrom.requestPathname) {
86
+ ocRoute.copiedFrom.requestPath = route.copiedFrom.requestPathname;
87
+ }
88
+ }
89
+
90
+ return ocRoute;
91
+ };
92
+
93
+ const stringifyMockServer = (mockServer: BrunoMockServer): string => {
94
+ try {
95
+ const ocMockServer: MockServerFile = {
96
+ info: {
97
+ name: mockServer.name && mockServer.name.trim().length ? mockServer.name : 'Mock Server',
98
+ type: 'mock'
99
+ },
100
+ mock: {
101
+ port: Number(mockServer.port) || 4000
102
+ }
103
+ };
104
+
105
+ const delay = Number(mockServer.delay);
106
+ if (delay > 0) {
107
+ ocMockServer.mock!.delay = delay;
108
+ }
109
+
110
+ const source = mockServer.source;
111
+ if (source && (source.type === 'collection' || source.type === 'spec') && source.path) {
112
+ ocMockServer.mock!.source = {
113
+ type: source.type,
114
+ path: source.path
115
+ };
116
+ }
117
+
118
+ if (mockServer.routes?.length) {
119
+ ocMockServer.routes = mockServer.routes.map(toOcMockRoute);
120
+ }
121
+
122
+ return stringifyYml(ocMockServer);
123
+ } catch (error) {
124
+ console.error('Error stringifying mock server:', error);
125
+ throw error;
126
+ }
127
+ };
128
+
129
+ export default stringifyMockServer;