@usebruno/filestore 0.11.0 → 0.13.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 (88) 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/common/omit-headers.d.ts +3 -0
  13. package/dist/formats/yml/common/omit-headers.d.ts.map +1 -0
  14. package/dist/formats/yml/common/scripts.d.ts +3 -5
  15. package/dist/formats/yml/common/scripts.d.ts.map +1 -1
  16. package/dist/formats/yml/index.d.ts +3 -1
  17. package/dist/formats/yml/index.d.ts.map +1 -1
  18. package/dist/formats/yml/items/parseGraphQLRequest.d.ts.map +1 -1
  19. package/dist/formats/yml/items/parseGrpcRequest.d.ts.map +1 -1
  20. package/dist/formats/yml/items/parseHttpRequest.d.ts.map +1 -1
  21. package/dist/formats/yml/items/stringifyGraphQLRequest.d.ts.map +1 -1
  22. package/dist/formats/yml/items/stringifyGrpcRequest.d.ts.map +1 -1
  23. package/dist/formats/yml/items/stringifyHttpRequest.d.ts.map +1 -1
  24. package/dist/formats/yml/items/stringifyWebsocketRequest.d.ts.map +1 -1
  25. package/dist/formats/yml/mockServerTypes.d.ts +95 -0
  26. package/dist/formats/yml/mockServerTypes.d.ts.map +1 -0
  27. package/dist/formats/yml/parseCollection.d.ts.map +1 -1
  28. package/dist/formats/yml/parseEnvironment.d.ts.map +1 -1
  29. package/dist/formats/yml/parseFolder.d.ts.map +1 -1
  30. package/dist/formats/yml/parseMockServer.d.ts +4 -0
  31. package/dist/formats/yml/parseMockServer.d.ts.map +1 -0
  32. package/dist/formats/yml/stringifyCollection.d.ts.map +1 -1
  33. package/dist/formats/yml/stringifyEnvironment.d.ts.map +1 -1
  34. package/dist/formats/yml/stringifyFolder.d.ts.map +1 -1
  35. package/dist/formats/yml/stringifyMockServer.d.ts +4 -0
  36. package/dist/formats/yml/stringifyMockServer.d.ts.map +1 -0
  37. package/dist/index.d.ts +14 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/types.d.ts +1 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/workers/WorkerQueue/index.d.ts +3 -2
  42. package/dist/workers/WorkerQueue/index.d.ts.map +1 -1
  43. package/dist/workers/index.d.ts +4 -0
  44. package/dist/workers/index.d.ts.map +1 -1
  45. package/package.json +8 -8
  46. package/src/constants.ts +5 -1
  47. package/src/formats/bru/tests/redact-large-text-blocks.spec.js +220 -1
  48. package/src/formats/bru/utils/redact-large-text-blocks.ts +46 -1
  49. package/src/formats/yml/common/omit-headers.spec.ts +16 -0
  50. package/src/formats/yml/common/omit-headers.ts +12 -0
  51. package/src/formats/yml/common/scripts.spec.ts +155 -0
  52. package/src/formats/yml/common/scripts.ts +55 -29
  53. package/src/formats/yml/grpc-round-trip.spec.ts +110 -0
  54. package/src/formats/yml/index.ts +5 -1
  55. package/src/formats/yml/items/parseGraphQLRequest.ts +6 -16
  56. package/src/formats/yml/items/parseGrpcRequest.ts +14 -6
  57. package/src/formats/yml/items/parseHttpRequest.ts +10 -14
  58. package/src/formats/yml/items/stringifyGraphQLRequest.ts +8 -14
  59. package/src/formats/yml/items/stringifyGrpcRequest.ts +2 -1
  60. package/src/formats/yml/items/stringifyHttpRequest.ts +13 -13
  61. package/src/formats/yml/items/stringifyWebsocketRequest.ts +3 -1
  62. package/src/formats/yml/mockServerTypes.ts +95 -0
  63. package/src/formats/yml/parseCollection.spec.ts +123 -0
  64. package/src/formats/yml/parseCollection.ts +23 -14
  65. package/src/formats/yml/parseEnvironment.spec.ts +49 -0
  66. package/src/formats/yml/parseEnvironment.ts +6 -0
  67. package/src/formats/yml/parseFolder.spec.ts +18 -0
  68. package/src/formats/yml/parseFolder.ts +5 -1
  69. package/src/formats/yml/parseMockServer.spec.ts +180 -0
  70. package/src/formats/yml/parseMockServer.ts +113 -0
  71. package/src/formats/yml/script.spec.ts +159 -0
  72. package/src/formats/yml/stringifyCollection.spec.ts +146 -0
  73. package/src/formats/yml/stringifyCollection.ts +29 -19
  74. package/src/formats/yml/stringifyEnvironment.spec.ts +58 -0
  75. package/src/formats/yml/stringifyEnvironment.ts +6 -0
  76. package/src/formats/yml/stringifyFolder.spec.ts +23 -0
  77. package/src/formats/yml/stringifyFolder.ts +10 -3
  78. package/src/formats/yml/stringifyMockServer.spec.ts +177 -0
  79. package/src/formats/yml/stringifyMockServer.ts +129 -0
  80. package/src/formats/yml/tests/defaultEnvironment.spec.js +50 -0
  81. package/src/formats/yml/tests/stringifyEnvironment.spec.ts +26 -0
  82. package/src/formats/yml/utils.ts +1 -1
  83. package/src/index.ts +34 -1
  84. package/src/maxRedirects.spec.ts +144 -0
  85. package/src/types.ts +1 -0
  86. package/src/workers/WorkerQueue/index.ts +10 -2
  87. package/src/workers/index.ts +25 -1
  88. package/src/workers/worker-script.ts +45 -21
@@ -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,
@@ -5,6 +5,7 @@ import type { ClientCertificate, PemCertificate, Pkcs12Certificate } from '@open
5
5
  import type { Variable } from '@opencollection/types/common/variables';
6
6
  import type { Action } from '@opencollection/types/common/actions';
7
7
  import type { Scripts } from '@opencollection/types/common/scripts';
8
+ import { normalizeOpenApiSyncConfigs } from '@usebruno/common';
8
9
  import { stringifyYml } from './utils';
9
10
  import { toOpenCollectionAuth } from './common/auth';
10
11
  import { toOpenCollectionHttpHeaders } from './common/headers';
@@ -12,6 +13,7 @@ import { toOpenCollectionVariables } from './common/variables';
12
13
  import { toOpenCollectionActions } from './common/actions';
13
14
  import { toOpenCollectionScripts } from './common/scripts';
14
15
  import type { Auth } from '@opencollection/types/common/auth';
16
+ import { HTTP_SCRIPT_KEYS } from '@usebruno/common';
15
17
 
16
18
  const hasCollectionConfig = (brunoConfig: any): boolean => {
17
19
  // protobuf
@@ -61,7 +63,8 @@ const hasRequestScripts = (collectionRoot: any): boolean => {
61
63
 
62
64
  const hasPresets = (brunoConfig: any): boolean => {
63
65
  return brunoConfig?.presets?.requestType?.length
64
- || brunoConfig?.presets?.requestUrl?.length;
66
+ || brunoConfig?.presets?.requestUrl?.length
67
+ || brunoConfig?.presets?.defaultEnvironment?.length;
65
68
  };
66
69
 
67
70
  const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
@@ -148,7 +151,8 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
148
151
  type: 'pem',
149
152
  certificateFilePath: cert.certFilePath,
150
153
  privateKeyFilePath: cert.keyFilePath,
151
- ...(cert.passphrase && { passphrase: cert.passphrase })
154
+ ...(cert.passphrase && { passphrase: cert.passphrase }),
155
+ ...(cert.disabled === true && { disabled: true })
152
156
  };
153
157
  return pemCert;
154
158
  } else if (cert.type === 'pfx') {
@@ -156,7 +160,8 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
156
160
  domain: cert.domain,
157
161
  type: 'pkcs12',
158
162
  pkcs12FilePath: cert.pfxFilePath,
159
- ...(cert.passphrase && { passphrase: cert.passphrase })
163
+ ...(cert.passphrase && { passphrase: cert.passphrase }),
164
+ ...(cert.disabled === true && { disabled: true })
160
165
  };
161
166
  return pkcs12Cert;
162
167
  } else {
@@ -206,7 +211,8 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
206
211
 
207
212
  // scripts
208
213
  if (hasRequestScripts(collectionRoot)) {
209
- const ocScripts: Scripts | undefined = toOpenCollectionScripts(collectionRoot.request);
214
+ // TODO: Widen scope to include GRPC scripts once Collection/Folder level inheritance is added to gRPC.
215
+ const ocScripts: Scripts | undefined = toOpenCollectionScripts(collectionRoot.request, HTTP_SCRIPT_KEYS);
210
216
  if (ocScripts) {
211
217
  oc.request.scripts = ocScripts;
212
218
  }
@@ -241,6 +247,7 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
241
247
  }
242
248
 
243
249
  if (hasPresets(brunoConfig)) {
250
+ const presetsExtension: any = {};
244
251
  const presetsRequest: any = {};
245
252
  if (brunoConfig.presets.requestType?.length) {
246
253
  presetsRequest.type = brunoConfig.presets.requestType;
@@ -248,37 +255,40 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
248
255
  if (brunoConfig.presets.requestUrl?.length) {
249
256
  presetsRequest.url = brunoConfig.presets.requestUrl;
250
257
  }
251
- brunoExtension.presets = {
252
- request: presetsRequest
253
- };
258
+ if (Object.keys(presetsRequest).length) {
259
+ presetsExtension.request = presetsRequest;
260
+ }
261
+ if (brunoConfig.presets.defaultEnvironment?.length) {
262
+ presetsExtension.defaultEnvironment = brunoConfig.presets.defaultEnvironment;
263
+ }
264
+ brunoExtension.presets = presetsExtension;
254
265
  }
255
266
 
256
267
  oc.extensions.bruno = brunoExtension;
257
268
  }
258
269
 
259
270
  // bruno-specific script extensions
271
+ const brunoScripts: Record<string, unknown> = {};
260
272
  if (brunoConfig.scripts?.additionalContextRoots?.length) {
273
+ brunoScripts.additionalContextRoots = brunoConfig.scripts.additionalContextRoots;
274
+ }
275
+ if (brunoConfig.scripts?.flow === 'sandwich' || brunoConfig.scripts?.flow === 'sequential') {
276
+ brunoScripts.flow = brunoConfig.scripts.flow;
277
+ }
278
+ if (Object.keys(brunoScripts).length > 0) {
261
279
  if (!oc.extensions.bruno) {
262
280
  oc.extensions.bruno = {};
263
281
  }
264
- (oc.extensions.bruno as any).scripts = {
265
- additionalContextRoots: brunoConfig.scripts.additionalContextRoots
266
- };
282
+ (oc.extensions.bruno as any).scripts = brunoScripts;
267
283
  }
268
284
 
269
285
  // bruno-specific extensions
270
- if (Array.isArray(brunoConfig.openapi) && brunoConfig.openapi.length > 0) {
286
+ const openApiEntries = normalizeOpenApiSyncConfigs(brunoConfig.openapi);
287
+ if (openApiEntries.length > 0) {
271
288
  if (!oc.extensions.bruno) {
272
289
  oc.extensions.bruno = {};
273
290
  }
274
- (oc.extensions.bruno as any).openapi = brunoConfig.openapi.map((entry: any) => ({
275
- sourceUrl: entry.sourceUrl,
276
- groupBy: entry.groupBy,
277
- ...(entry.lastSyncDate && { lastSyncDate: entry.lastSyncDate }),
278
- ...(entry.specHash && { specHash: entry.specHash }),
279
- autoCheck: entry.autoCheck !== false,
280
- autoCheckInterval: entry.autoCheckInterval || 5
281
- }));
291
+ (oc.extensions.bruno as any).openapi = openApiEntries;
282
292
  }
283
293
 
284
294
  return stringifyYml(oc);
@@ -88,4 +88,62 @@ describe('stringifyEnvironment', () => {
88
88
  const reparsed = parseEnvironment(stringifyEnvironment(env));
89
89
  expect(reparsed.color).toBe('#ff0000');
90
90
  });
91
+
92
+ describe('extends', () => {
93
+ it('round-trips the parent environment name', () => {
94
+ const env = {
95
+ uid: 'env-uid',
96
+ name: 'prod',
97
+ extends: 'base',
98
+ variables: [{ uid: 'u1', name: 'host', value: 'prod.example.com', type: 'text', enabled: true, secret: false }]
99
+ } as any;
100
+
101
+ const reparsed = parseEnvironment(stringifyEnvironment(env));
102
+
103
+ expect(reparsed.extends).toBe('base');
104
+ expect(reparsed.variables[0]).toMatchObject({ name: 'host', value: 'prod.example.com' });
105
+ });
106
+
107
+ it('omits extends when no parent is set', () => {
108
+ const env = { uid: 'env-uid', name: 'prod', variables: [] } as any;
109
+
110
+ expect(stringifyEnvironment(env)).not.toContain('extends');
111
+ });
112
+
113
+ it('omits extends when the parent names nothing', () => {
114
+ const env = { uid: 'env-uid', name: 'prod', extends: ' ', variables: [] } as any;
115
+
116
+ expect(stringifyEnvironment(env)).not.toContain('extends');
117
+ });
118
+
119
+ it('round-trips a list of parents', () => {
120
+ const env = { uid: 'env-uid', name: 'prod', extends: ['base', 'shared'], variables: [] } as any;
121
+
122
+ const reparsed = parseEnvironment(stringifyEnvironment(env));
123
+
124
+ expect(reparsed.extends).toEqual(['base', 'shared']);
125
+ });
126
+
127
+ it('omits extends when the list of parents is empty', () => {
128
+ const env = { uid: 'env-uid', name: 'prod', extends: [], variables: [] } as any;
129
+
130
+ expect(stringifyEnvironment(env)).not.toContain('extends');
131
+ });
132
+
133
+ it('never writes the inheritedFrom provenance of a resolved variable', () => {
134
+ const env = {
135
+ uid: 'env-uid',
136
+ name: 'prod',
137
+ extends: 'base',
138
+ variables: [
139
+ { uid: 'u1', name: 'host', value: 'base.example.com', type: 'text', enabled: true, secret: false, inheritedFrom: 'base' }
140
+ ]
141
+ } as any;
142
+
143
+ const yml = stringifyEnvironment(env);
144
+
145
+ expect(yml).not.toContain('inheritedFrom');
146
+ expect(parseEnvironment(yml).variables[0]).not.toHaveProperty('inheritedFrom');
147
+ });
148
+ });
91
149
  });
@@ -10,6 +10,7 @@ import { stringifyYml } from './utils';
10
10
  import { ensureString } from '../../utils';
11
11
  import { SECRET_PROVIDER_IDENTIFIER_FIELDS } from '../../constants';
12
12
  import { hasTypedMetadata, toOpenCollectionTypedValue, serializeVariableValue } from './common/datatype';
13
+ import { validatedEnvironmentExtendsFrom } from '@usebruno/common/utils';
13
14
 
14
15
  export const toOpenCollectionEnvironmentVariables = (variables: (BrunoEnvironmentVariable | BrunoSecretEnvironmentVariable)[]): (Variable | SecretVariable)[] | undefined => {
15
16
  if (!variables?.length) {
@@ -90,6 +91,11 @@ const stringifyEnvironment = (environment: BrunoEnvironment): string => {
90
91
  name: environment.name
91
92
  };
92
93
 
94
+ const environmentExtendsFrom = validatedEnvironmentExtendsFrom(environment.extends);
95
+ if (environmentExtendsFrom) {
96
+ (ocEnvironment as any).extends = environmentExtendsFrom;
97
+ }
98
+
93
99
  if (environment.color) {
94
100
  ocEnvironment.color = environment.color;
95
101
  }
@@ -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
+ });
@@ -12,6 +12,7 @@ import { toOpenCollectionVariables } from './common/variables';
12
12
  import { toOpenCollectionActions } from './common/actions';
13
13
  import { toOpenCollectionScripts } from './common/scripts';
14
14
  import { stringifyYml } from './utils';
15
+ import { HTTP_SCRIPT_KEYS } from '@usebruno/common';
15
16
 
16
17
  const hasRequestDefaults = (folderRoot: FolderRoot): boolean => {
17
18
  const requestDefaults = folderRoot?.request;
@@ -40,9 +41,14 @@ const stringifyFolder = (folderRoot: FolderRoot): string => {
40
41
  // info block
41
42
  const info: FolderInfo = {
42
43
  name: folderRoot.meta?.name || 'Untitled Folder',
43
- type: 'folder',
44
- seq: folderRoot.meta?.seq || 1
44
+ type: 'folder'
45
45
  };
46
+ // Only write seq when the folder actually has a numeric one. Defaulting to 1 would
47
+ // force every seq-less folder into position 1 on disk and break alphabetical fallback.
48
+ const seq = folderRoot.meta?.seq;
49
+ if (typeof seq === 'number' && Number.isFinite(seq)) {
50
+ info.seq = seq;
51
+ }
46
52
  ocFolder.info = info;
47
53
 
48
54
  // request defaults
@@ -83,7 +89,8 @@ const stringifyFolder = (folderRoot: FolderRoot): string => {
83
89
 
84
90
  // scripts
85
91
  if (hasRequestScripts(folderRoot)) {
86
- const ocScripts: Scripts | undefined = toOpenCollectionScripts(folderRoot?.request);
92
+ // TODO: Widen scope to include GRPC scripts once Collection/Folder level inheritance is added to GRPC.
93
+ const ocScripts: Scripts | undefined = toOpenCollectionScripts(folderRoot?.request, HTTP_SCRIPT_KEYS);
87
94
  if (ocScripts) {
88
95
  ocFolder.request.scripts = ocScripts;
89
96
  }
@@ -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
+ });