@usebruno/filestore 0.12.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 (48) 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/esm/index.js +1 -1
  6. package/dist/esm/index.js.map +1 -1
  7. package/dist/esm/workers/worker-script.js +1 -1
  8. package/dist/esm/workers/worker-script.js.map +1 -1
  9. package/dist/formats/bru/utils/redact-large-text-blocks.d.ts.map +1 -1
  10. package/dist/formats/yml/common/omit-headers.d.ts +3 -0
  11. package/dist/formats/yml/common/omit-headers.d.ts.map +1 -0
  12. package/dist/formats/yml/common/scripts.d.ts +3 -5
  13. package/dist/formats/yml/common/scripts.d.ts.map +1 -1
  14. package/dist/formats/yml/items/parseGrpcRequest.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/stringifyGrpcRequest.d.ts.map +1 -1
  18. package/dist/formats/yml/items/stringifyHttpRequest.d.ts.map +1 -1
  19. package/dist/formats/yml/items/stringifyWebsocketRequest.d.ts.map +1 -1
  20. package/dist/formats/yml/parseCollection.d.ts.map +1 -1
  21. package/dist/formats/yml/parseEnvironment.d.ts.map +1 -1
  22. package/dist/formats/yml/stringifyCollection.d.ts.map +1 -1
  23. package/dist/formats/yml/stringifyEnvironment.d.ts.map +1 -1
  24. package/dist/formats/yml/stringifyFolder.d.ts.map +1 -1
  25. package/package.json +8 -8
  26. package/src/formats/bru/tests/redact-large-text-blocks.spec.js +60 -0
  27. package/src/formats/bru/utils/redact-large-text-blocks.ts +4 -0
  28. package/src/formats/yml/common/omit-headers.spec.ts +16 -0
  29. package/src/formats/yml/common/omit-headers.ts +12 -0
  30. package/src/formats/yml/common/scripts.spec.ts +155 -0
  31. package/src/formats/yml/common/scripts.ts +55 -29
  32. package/src/formats/yml/grpc-round-trip.spec.ts +110 -0
  33. package/src/formats/yml/items/parseGraphQLRequest.ts +2 -2
  34. package/src/formats/yml/items/parseGrpcRequest.ts +14 -6
  35. package/src/formats/yml/items/parseHttpRequest.ts +8 -2
  36. package/src/formats/yml/items/stringifyGraphQLRequest.ts +5 -9
  37. package/src/formats/yml/items/stringifyGrpcRequest.ts +2 -1
  38. package/src/formats/yml/items/stringifyHttpRequest.ts +11 -9
  39. package/src/formats/yml/items/stringifyWebsocketRequest.ts +3 -1
  40. package/src/formats/yml/parseCollection.ts +5 -9
  41. package/src/formats/yml/parseEnvironment.spec.ts +49 -0
  42. package/src/formats/yml/parseEnvironment.ts +6 -0
  43. package/src/formats/yml/script.spec.ts +159 -0
  44. package/src/formats/yml/stringifyCollection.ts +7 -10
  45. package/src/formats/yml/stringifyEnvironment.spec.ts +58 -0
  46. package/src/formats/yml/stringifyEnvironment.ts +6 -0
  47. package/src/formats/yml/stringifyFolder.ts +3 -1
  48. package/src/maxRedirects.spec.ts +144 -0
@@ -10,7 +10,7 @@ import { toBrunoScripts } from '../common/scripts';
10
10
  import { toBrunoAssertions } from '../common/assertions';
11
11
  import { uuid, ensureString } from '../../../utils';
12
12
  import { utils } from '@usebruno/common';
13
- const { toBool, toNumber } = utils;
13
+ const { toBool, toMaxRedirects } = utils;
14
14
 
15
15
  const parseGraphQLRequest = (ocRequest: GraphQLRequest): BrunoItem => {
16
16
  const info = ocRequest.info;
@@ -123,7 +123,7 @@ const parseGraphQLRequest = (ocRequest: GraphQLRequest): BrunoItem => {
123
123
  }
124
124
 
125
125
  settings.followRedirects = toBool(ocRequest.settings.followRedirects, true);
126
- settings.maxRedirects = toNumber(ocRequest.settings.maxRedirects, 5);
126
+ settings.maxRedirects = toMaxRedirects(ocRequest.settings.maxRedirects);
127
127
  settings.forwardAuthorizationHeader = toBool(ocRequest.settings.forwardAuthorizationHeader, true);
128
128
 
129
129
  brunoItem.settings = settings;
@@ -51,8 +51,10 @@ const parseGrpcRequest = (ocRequest: GrpcRequest): BrunoItem => {
51
51
  grpc: []
52
52
  },
53
53
  script: {
54
- req: null,
55
- res: null
54
+ beforeCallStart: null,
55
+ beforeMessageSend: null,
56
+ afterMessageReceive: null,
57
+ afterCallEnd: null
56
58
  },
57
59
  vars: {
58
60
  req: [],
@@ -80,11 +82,17 @@ const parseGrpcRequest = (ocRequest: GrpcRequest): BrunoItem => {
80
82
  // scripts
81
83
  const scripts = toBrunoScripts(runtime?.scripts);
82
84
  if (scripts?.script && brunoRequest.script) {
83
- if (scripts.script.req) {
84
- brunoRequest.script.req = scripts.script.req;
85
+ if (scripts.script.beforeCallStart) {
86
+ brunoRequest.script.beforeCallStart = scripts.script.beforeCallStart;
85
87
  }
86
- if (scripts.script.res) {
87
- brunoRequest.script.res = scripts.script.res;
88
+ if (scripts.script.beforeMessageSend) {
89
+ brunoRequest.script.beforeMessageSend = scripts.script.beforeMessageSend;
90
+ }
91
+ if (scripts.script.afterMessageReceive) {
92
+ brunoRequest.script.afterMessageReceive = scripts.script.afterMessageReceive;
93
+ }
94
+ if (scripts.script.afterCallEnd) {
95
+ brunoRequest.script.afterCallEnd = scripts.script.afterCallEnd;
88
96
  }
89
97
  }
90
98
  if (scripts?.tests) {
@@ -10,9 +10,10 @@ import { toBrunoPostResponseVariables } from '../common/actions';
10
10
  import { toBrunoScripts } from '../common/scripts';
11
11
  import { toBrunoAssertions } from '../common/assertions';
12
12
  import { toBrunoApp } from '../common/app';
13
+ import { normalizeOmitHeaders } from '../common/omit-headers';
13
14
  import { uuid, ensureString } from '../../../utils';
14
15
  import { utils } from '@usebruno/common';
15
- const { toBool, toNumber } = utils;
16
+ const { toBool, toMaxRedirects } = utils;
16
17
 
17
18
  const parseHttpRequest = (ocRequest: HttpRequest): BrunoItem => {
18
19
  const info = ocRequest.info;
@@ -126,9 +127,14 @@ const parseHttpRequest = (ocRequest: HttpRequest): BrunoItem => {
126
127
  }
127
128
 
128
129
  settings.followRedirects = toBool(ocRequest.settings.followRedirects, true);
129
- settings.maxRedirects = toNumber(ocRequest.settings.maxRedirects, 5);
130
+ settings.maxRedirects = toMaxRedirects(ocRequest.settings.maxRedirects);
130
131
  settings.forwardAuthorizationHeader = toBool(ocRequest.settings.forwardAuthorizationHeader, true);
131
132
 
133
+ const omitHeaders = normalizeOmitHeaders(ocRequest.settings.omitHeaders);
134
+ if (omitHeaders) {
135
+ settings.omitHeaders = omitHeaders;
136
+ }
137
+
132
138
  brunoItem.settings = settings;
133
139
  }
134
140
 
@@ -8,7 +8,7 @@ import type { Assertion } from '@opencollection/types/common/assertions';
8
8
  import type { Action } from '@opencollection/types/common/actions';
9
9
  import type { HttpRequestParam, HttpRequestHeader } from '@opencollection/types/requests/http';
10
10
  import { stringifyYml } from '../utils';
11
- import { isNonEmptyString, isNumber } from '../../../utils';
11
+ import { isNonEmptyString } from '../../../utils';
12
12
  import { toOpenCollectionAuth } from '../common/auth';
13
13
  import { toOpenCollectionHttpHeaders } from '../common/headers';
14
14
  import { toOpenCollectionParams } from '../common/params';
@@ -16,7 +16,8 @@ import { toOpenCollectionVariables } from '../common/variables';
16
16
  import { toOpenCollectionActions } from '../common/actions';
17
17
  import { toOpenCollectionScripts } from '../common/scripts';
18
18
  import { toOpenCollectionAssertions } from '../common/assertions';
19
- import { resolveTimeoutSetting } from '@usebruno/common/utils';
19
+ import { resolveTimeoutSetting, toMaxRedirects } from '@usebruno/common/utils';
20
+ import { HTTP_SCRIPT_KEYS } from '@usebruno/common';
20
21
 
21
22
  const stringifyGraphQLRequest = (item: BrunoItem): string => {
22
23
  try {
@@ -97,7 +98,7 @@ const stringifyGraphQLRequest = (item: BrunoItem): string => {
97
98
  }
98
99
 
99
100
  // scripts
100
- const scripts: Scripts | undefined = toOpenCollectionScripts(brunoRequest);
101
+ const scripts: Scripts | undefined = toOpenCollectionScripts(brunoRequest, HTTP_SCRIPT_KEYS);
101
102
  if (scripts) {
102
103
  runtime.scripts = scripts;
103
104
  hasRuntime = true;
@@ -143,12 +144,7 @@ const stringifyGraphQLRequest = (item: BrunoItem): string => {
143
144
  settings.followRedirects = true;
144
145
  }
145
146
 
146
- const maxRedirects = httpSettings?.maxRedirects;
147
- if (isNumber(maxRedirects)) {
148
- settings.maxRedirects = maxRedirects;
149
- } else {
150
- settings.maxRedirects = 5;
151
- }
147
+ settings.maxRedirects = toMaxRedirects(httpSettings?.maxRedirects);
152
148
 
153
149
  settings.forwardAuthorizationHeader = httpSettings?.forwardAuthorizationHeader ?? true;
154
150
 
@@ -12,6 +12,7 @@ import { toOpenCollectionAuth } from '../common/auth';
12
12
  import { toOpenCollectionVariables } from '../common/variables';
13
13
  import { toOpenCollectionScripts } from '../common/scripts';
14
14
  import { toOpenCollectionAssertions } from '../common/assertions';
15
+ import { GRPC_SCRIPT_KEYS } from '@usebruno/common';
15
16
 
16
17
  const stringifyGrpcRequest = (item: BrunoItem): string => {
17
18
  try {
@@ -103,7 +104,7 @@ const stringifyGrpcRequest = (item: BrunoItem): string => {
103
104
  }
104
105
 
105
106
  // scripts
106
- const scripts: Scripts | undefined = toOpenCollectionScripts(brunoRequest);
107
+ const scripts: Scripts | undefined = toOpenCollectionScripts(brunoRequest, GRPC_SCRIPT_KEYS);
107
108
  if (scripts) {
108
109
  runtime.scripts = scripts;
109
110
  hasRuntime = true;
@@ -17,8 +17,10 @@ import { toOpenCollectionVariables } from '../common/variables';
17
17
  import { toOpenCollectionActions } from '../common/actions';
18
18
  import { toOpenCollectionScripts } from '../common/scripts';
19
19
  import { toOpenCollectionAssertions } from '../common/assertions';
20
- import { isNumber, isNonEmptyString } from '../../../utils';
21
- import { resolveTimeoutSetting } from '@usebruno/common/utils';
20
+ import { normalizeOmitHeaders } from '../common/omit-headers';
21
+ import { isNonEmptyString } from '../../../utils';
22
+ import { resolveTimeoutSetting, toMaxRedirects } from '@usebruno/common/utils';
23
+ import { HTTP_SCRIPT_KEYS } from '@usebruno/common';
22
24
 
23
25
  const stringifyHttpRequest = (item: BrunoItem): string => {
24
26
  try {
@@ -85,7 +87,7 @@ const stringifyHttpRequest = (item: BrunoItem): string => {
85
87
  }
86
88
 
87
89
  // scripts
88
- const scripts: Scripts | undefined = toOpenCollectionScripts(brunoRequest);
90
+ const scripts: Scripts | undefined = toOpenCollectionScripts(brunoRequest, HTTP_SCRIPT_KEYS);
89
91
  if (scripts) {
90
92
  runtime.scripts = scripts;
91
93
  hasRuntime = true;
@@ -131,15 +133,15 @@ const stringifyHttpRequest = (item: BrunoItem): string => {
131
133
  settings.followRedirects = true;
132
134
  }
133
135
 
134
- const maxRedirects = httpSettings?.maxRedirects;
135
- if (isNumber(maxRedirects)) {
136
- settings.maxRedirects = maxRedirects;
137
- } else {
138
- settings.maxRedirects = 5;
139
- }
136
+ settings.maxRedirects = toMaxRedirects(httpSettings?.maxRedirects);
140
137
 
141
138
  settings.forwardAuthorizationHeader = httpSettings?.forwardAuthorizationHeader ?? true;
142
139
 
140
+ const omitHeaders = normalizeOmitHeaders(httpSettings?.omitHeaders);
141
+ if (omitHeaders) {
142
+ settings.omitHeaders = omitHeaders;
143
+ }
144
+
143
145
  ocRequest.settings = settings;
144
146
 
145
147
  // examples
@@ -11,6 +11,7 @@ import { toOpenCollectionAuth } from '../common/auth';
11
11
  import { toOpenCollectionHttpHeaders } from '../common/headers';
12
12
  import { toOpenCollectionVariables } from '../common/variables';
13
13
  import { toOpenCollectionScripts } from '../common/scripts';
14
+ import { HTTP_SCRIPT_KEYS } from '@usebruno/common';
14
15
 
15
16
  const stringifyWebsocketRequest = (item: BrunoItem): string => {
16
17
  try {
@@ -92,7 +93,8 @@ const stringifyWebsocketRequest = (item: BrunoItem): string => {
92
93
  }
93
94
 
94
95
  // scripts
95
- const scripts: Scripts | undefined = toOpenCollectionScripts(brunoRequest);
96
+ // TODO: Modify to WS scripts once WS scripts are implemented.
97
+ const scripts: Scripts | undefined = toOpenCollectionScripts(brunoRequest, HTTP_SCRIPT_KEYS);
96
98
  if (scripts) {
97
99
  runtime.scripts = scripts;
98
100
  hasRuntime = true;
@@ -1,5 +1,6 @@
1
1
  import type { OpenCollection } from '@opencollection/types';
2
2
  import type { FolderRoot } from '@usebruno/schema-types/collection/folder';
3
+ import { normalizeOpenApiSyncConfigs } from '@usebruno/common';
3
4
  import { parseYml } from './utils';
4
5
  import { toBrunoAuth } from './common/auth';
5
6
  import { toBrunoHttpHeaders } from './common/headers';
@@ -70,15 +71,10 @@ const parseCollection = (ymlString: string): ParsedCollection => {
70
71
  flow: brunoExtensions.scripts.flow
71
72
  };
72
73
  }
73
- if (Array.isArray(brunoExtensions?.openapi) && brunoExtensions.openapi.length > 0) {
74
- brunoConfig.openapi = brunoExtensions.openapi.map((entry: any) => ({
75
- sourceUrl: entry.sourceUrl,
76
- groupBy: entry.groupBy,
77
- ...(entry.lastSyncDate && { lastSyncDate: entry.lastSyncDate }),
78
- ...(entry.specHash && { specHash: entry.specHash }),
79
- autoCheck: entry.autoCheck !== false,
80
- autoCheckInterval: entry.autoCheckInterval || 5
81
- }));
74
+
75
+ const openApiEntries = normalizeOpenApiSyncConfigs(brunoExtensions?.openapi);
76
+ if (openApiEntries.length > 0) {
77
+ brunoConfig.openapi = openApiEntries;
82
78
  }
83
79
 
84
80
  // protobuf
@@ -96,4 +96,53 @@ variables:
96
96
  expect(env.name).toBe('Untitled Environment');
97
97
  expect(env.variables).toEqual([]);
98
98
  });
99
+
100
+ describe('extends', () => {
101
+ it('parses the parent environment name', () => {
102
+ const env = parseEnvironment(`name: prod
103
+ extends: base
104
+ variables: []
105
+ `);
106
+
107
+ expect(env.extends).toBe('base');
108
+ });
109
+
110
+ it('leaves extends absent for an environment that does not declare it', () => {
111
+ const env = parseEnvironment(`name: prod
112
+ variables: []
113
+ `);
114
+
115
+ expect(env).not.toHaveProperty('extends');
116
+ });
117
+
118
+ it('ignores a parent reference that names nothing', () => {
119
+ const env = parseEnvironment(`name: prod
120
+ extends: " "
121
+ variables: []
122
+ `);
123
+
124
+ expect(env).not.toHaveProperty('extends');
125
+ });
126
+
127
+ it('parses a list of parents unresolved so that a save does not delete it', () => {
128
+ const env = parseEnvironment(`name: prod
129
+ extends:
130
+ - base
131
+ - shared
132
+ variables: []
133
+ `);
134
+
135
+ expect(env.extends).toEqual(['base', 'shared']);
136
+ });
137
+
138
+ it('ignores a parent reference that is neither a name nor a list', () => {
139
+ const env = parseEnvironment(`name: prod
140
+ extends:
141
+ parent: base
142
+ variables: []
143
+ `);
144
+
145
+ expect(env).not.toHaveProperty('extends');
146
+ });
147
+ });
99
148
  });
@@ -10,6 +10,7 @@ import { parseYml } from './utils';
10
10
  import { uuid, ensureString } from '../../utils';
11
11
  import { SECRET_PROVIDER_IDENTIFIER_FIELDS } from '../../constants';
12
12
  import { isTypedValue, fromOpenCollectionTypedValue } from './common/datatype';
13
+ import { validatedEnvironmentExtendsFrom } from '@usebruno/common/utils';
13
14
 
14
15
  const isSecretVariable = (v: Variable | SecretVariable): v is SecretVariable => {
15
16
  return 'secret' in v && v.secret === true;
@@ -106,6 +107,11 @@ const parseEnvironment = (ymlString: string): BrunoEnvironment => {
106
107
  color: ocEnvironment.color || null
107
108
  };
108
109
 
110
+ const ocEnvironmentExtendsFrom = validatedEnvironmentExtendsFrom(ocEnvironment.extends);
111
+ if (ocEnvironmentExtendsFrom) {
112
+ brunoEnvironment.extends = ocEnvironmentExtendsFrom;
113
+ }
114
+
109
115
  const externalSecrets = toBrunoExternalSecrets((ocEnvironment as any).externalSecrets);
110
116
  if (externalSecrets) {
111
117
  brunoEnvironment.externalSecrets = externalSecrets;
@@ -0,0 +1,159 @@
1
+ import stringifyItem from './stringifyItem';
2
+ import parseItem from './parseItem';
3
+ import stringifyCollection from './stringifyCollection';
4
+ import parseCollection from './parseCollection';
5
+ import stringifyFolder from './stringifyFolder';
6
+ import parseFolder from './parseFolder';
7
+
8
+ const GRPC_SLOTS = {
9
+ beforeCallStart: 'before()',
10
+ afterCallEnd: 'after()',
11
+ beforeMessageSend: 'beforeSend()',
12
+ afterMessageReceive: 'afterReceive()'
13
+ };
14
+
15
+ const ALL_SLOTS = {
16
+ req: 'pre()',
17
+ res: 'post()',
18
+ ...GRPC_SLOTS
19
+ };
20
+
21
+ const HTTP_TYPES = ['before-request', 'after-response'];
22
+ const GRPC_TYPES = [
23
+ 'grpc:before-call-start',
24
+ 'grpc:after-call-end',
25
+ 'grpc:before-message-send',
26
+ 'grpc:after-message-receive'
27
+ ];
28
+
29
+ const expectScriptTypes = (yml: string, written: string[], dropped: string[]) => {
30
+ written.forEach((type) => expect(yml).toContain(`type: ${type}`));
31
+ dropped.forEach((type) => expect(yml).not.toContain(`type: ${type}`));
32
+ };
33
+
34
+ const itemWithAllSlots = (type: string, request: Record<string, any>) => ({
35
+ uid: 'i1',
36
+ type,
37
+ name: 'r',
38
+ seq: 1,
39
+ request: {
40
+ auth: { mode: 'none' },
41
+ headers: [],
42
+ script: { ...ALL_SLOTS },
43
+ tests: null,
44
+ vars: { req: [], res: [] },
45
+ ...request
46
+ }
47
+ }) as any;
48
+
49
+ const rootWithScript = (script: Record<string, string | null>) => ({
50
+ meta: { name: 'my-folder', seq: 1 },
51
+ request: {
52
+ headers: [],
53
+ auth: { mode: 'none' },
54
+ script,
55
+ tests: null,
56
+ vars: { req: [], res: [] }
57
+ },
58
+ docs: null
59
+ }) as any;
60
+
61
+ describe('script — request items', () => {
62
+ const httpFamily = [
63
+ [
64
+ 'http',
65
+ itemWithAllSlots('http-request', {
66
+ url: 'https://example.com',
67
+ method: 'GET',
68
+ params: [],
69
+ body: { mode: 'none' }
70
+ })
71
+ ],
72
+ [
73
+ 'graphql',
74
+ itemWithAllSlots('graphql-request', {
75
+ url: 'https://example.com/graphql',
76
+ method: 'POST',
77
+ params: [],
78
+ body: { mode: 'graphql', graphql: { query: '{ hi }', variables: '' } }
79
+ })
80
+ ],
81
+ // TODO: Move it to a separate test block, once ws scripts are added.
82
+ [
83
+ 'websocket',
84
+ itemWithAllSlots('ws-request', {
85
+ url: 'wss://example.com',
86
+ body: { mode: 'ws', ws: [{ name: 'message 1', content: '{}' }] }
87
+ })
88
+ ]
89
+ ] as [string, any][];
90
+
91
+ it.each(httpFamily)('a %s request writes req/res and drops the grpc hooks', (_name, item) => {
92
+ const yml = stringifyItem(item);
93
+
94
+ expectScriptTypes(yml, HTTP_TYPES, GRPC_TYPES);
95
+ expect(yml).not.toContain('before()');
96
+ expect(yml).not.toContain('after()');
97
+ expect(yml).not.toContain('beforeSend()');
98
+ expect(yml).not.toContain('afterReceive()');
99
+
100
+ expect(parseItem(yml).request!.script).toEqual({ req: 'pre()', res: 'post()' });
101
+ });
102
+
103
+ it('a grpc request writes the lifecycle hooks and drops req/res', () => {
104
+ const item = itemWithAllSlots('grpc-request', {
105
+ url: 'grpc://localhost:50051',
106
+ method: '/hello.Greeter/SayHello',
107
+ methodType: 'unary',
108
+ body: { mode: 'grpc', grpc: [{ name: 'message 1', content: '{}' }] },
109
+ assertions: []
110
+ });
111
+
112
+ const yml = stringifyItem(item);
113
+
114
+ expectScriptTypes(yml, GRPC_TYPES, HTTP_TYPES);
115
+ expect(yml).not.toContain('pre()');
116
+ expect(yml).not.toContain('post()');
117
+
118
+ expect(parseItem(yml).request!.script).toEqual({
119
+ beforeCallStart: 'before()',
120
+ afterCallEnd: 'after()',
121
+ beforeMessageSend: 'beforeSend()',
122
+ afterMessageReceive: 'afterReceive()'
123
+ });
124
+ });
125
+ });
126
+
127
+ describe('script — collection and folder roots', () => {
128
+ it('a collection root writes req/res and drops the grpc hooks', () => {
129
+ const yml = stringifyCollection(rootWithScript({ ...ALL_SLOTS }), { name: 'c' });
130
+
131
+ expectScriptTypes(yml, HTTP_TYPES, GRPC_TYPES);
132
+
133
+ expect(parseCollection(yml).collectionRoot.request!.script).toEqual({ req: 'pre()', res: 'post()' });
134
+ });
135
+
136
+ it('a folder root writes req/res and drops the grpc hooks', () => {
137
+ const yml = stringifyFolder(rootWithScript({ ...ALL_SLOTS }));
138
+
139
+ expectScriptTypes(yml, HTTP_TYPES, GRPC_TYPES);
140
+
141
+ expect(parseFolder(yml).request!.script).toEqual({ req: 'pre()', res: 'post()' });
142
+ });
143
+
144
+ it('a collection root carrying only grpc hooks writes no scripts at all', () => {
145
+ const yml = stringifyCollection(rootWithScript({ ...GRPC_SLOTS }), { name: 'c' });
146
+
147
+ expect(yml).not.toContain('scripts:');
148
+
149
+ expect(parseCollection(yml).collectionRoot.request?.script).toBeUndefined();
150
+ });
151
+
152
+ it('a folder root carrying only grpc hooks writes no scripts at all', () => {
153
+ const yml = stringifyFolder(rootWithScript({ ...GRPC_SLOTS }));
154
+
155
+ expect(yml).not.toContain('scripts:');
156
+
157
+ expect(parseFolder(yml).request!.script).toEqual({ req: null, res: null });
158
+ });
159
+ });
@@ -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
@@ -209,7 +211,8 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
209
211
 
210
212
  // scripts
211
213
  if (hasRequestScripts(collectionRoot)) {
212
- 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);
213
216
  if (ocScripts) {
214
217
  oc.request.scripts = ocScripts;
215
218
  }
@@ -280,18 +283,12 @@ const stringifyCollection = (collectionRoot: any, brunoConfig: any): string => {
280
283
  }
281
284
 
282
285
  // bruno-specific extensions
283
- if (Array.isArray(brunoConfig.openapi) && brunoConfig.openapi.length > 0) {
286
+ const openApiEntries = normalizeOpenApiSyncConfigs(brunoConfig.openapi);
287
+ if (openApiEntries.length > 0) {
284
288
  if (!oc.extensions.bruno) {
285
289
  oc.extensions.bruno = {};
286
290
  }
287
- (oc.extensions.bruno as any).openapi = brunoConfig.openapi.map((entry: any) => ({
288
- sourceUrl: entry.sourceUrl,
289
- groupBy: entry.groupBy,
290
- ...(entry.lastSyncDate && { lastSyncDate: entry.lastSyncDate }),
291
- ...(entry.specHash && { specHash: entry.specHash }),
292
- autoCheck: entry.autoCheck !== false,
293
- autoCheckInterval: entry.autoCheckInterval || 5
294
- }));
291
+ (oc.extensions.bruno as any).openapi = openApiEntries;
295
292
  }
296
293
 
297
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
  }
@@ -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;
@@ -88,7 +89,8 @@ const stringifyFolder = (folderRoot: FolderRoot): string => {
88
89
 
89
90
  // scripts
90
91
  if (hasRequestScripts(folderRoot)) {
91
- 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);
92
94
  if (ocScripts) {
93
95
  ocFolder.request.scripts = ocScripts;
94
96
  }