@contractkit/plugin-bruno 1.2.0 → 1.4.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.
@@ -1,5 +1,14 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { generateOpenCollection, sanitizePath, MANIFEST_FILENAME, parseManifest, mergePluginFile } from '../src/codegen-bruno.js';
2
+ import {
3
+ generateOpenCollection,
4
+ generateOpenCollectionIncremental,
5
+ emptyManifest,
6
+ sanitizePath,
7
+ MANIFEST_FILENAME,
8
+ parseManifest,
9
+ mergePluginFile,
10
+ } from '../src/codegen-bruno.js';
11
+ import { validateBrunoExtension } from '../src/index.js';
3
12
  import {
4
13
  opRoot,
5
14
  opRoute,
@@ -986,7 +995,7 @@ describe('generateOpenCollection', () => {
986
995
  const files = generateOpenCollection([root], { collectionName: 'API' });
987
996
  const manifest = files.find(f => f.relativePath === MANIFEST_FILENAME);
988
997
  expect(manifest).toBeDefined();
989
- const tracked = parseManifest(manifest!.content);
998
+ const tracked = parseManifest(manifest!.content).files;
990
999
  expect(tracked).toContain('opencollection.yml');
991
1000
  expect(tracked).toContain('environments/local.yml');
992
1001
  expect(tracked).toContain('users/folder.yml');
@@ -995,11 +1004,19 @@ describe('generateOpenCollection', () => {
995
1004
  expect(tracked).toContain(MANIFEST_FILENAME);
996
1005
  });
997
1006
 
998
- it('parseManifest returns [] for malformed input', () => {
999
- expect(parseManifest('not json')).toEqual([]);
1000
- expect(parseManifest('{}')).toEqual([]);
1001
- expect(parseManifest('{"files": "nope"}')).toEqual([]);
1002
- expect(parseManifest('{"files": [1, 2, 3]}')).toEqual([]);
1007
+ it('parseManifest returns an empty manifest for malformed input', () => {
1008
+ expect(parseManifest('not json').files).toEqual([]);
1009
+ expect(parseManifest('not json').units).toEqual({});
1010
+ expect(parseManifest('{}').files).toEqual([]);
1011
+ expect(parseManifest('{"files": "nope"}').files).toEqual([]);
1012
+ expect(parseManifest('{"files": [1, 2, 3]}').files).toEqual([]);
1013
+ });
1014
+
1015
+ it('parseManifest accepts v1 manifest shape and returns no per-op entries', () => {
1016
+ const v1 = JSON.stringify({ files: ['opencollection.yml', 'users/get-users.yml'] });
1017
+ const parsed = parseManifest(v1);
1018
+ expect(parsed.files).toEqual(['opencollection.yml', 'users/get-users.yml']);
1019
+ expect(parsed.units).toEqual({});
1003
1020
  });
1004
1021
 
1005
1022
  // ─── randomExamples ───────────────────────────────────────────────────
@@ -1116,7 +1133,7 @@ describe('plugin file merges', () => {
1116
1133
  opRoute('/users', [
1117
1134
  opOperation('get', {
1118
1135
  responses: [opResponse(200, 'User')],
1119
- pluginFiles: { bruno: 'runtime:\n script:\n req: |\n console.log("pre");\n' },
1136
+ pluginExtensions: { bruno: { template: 'runtime:\n script:\n req: |\n console.log("pre");\n' } },
1120
1137
  }),
1121
1138
  ]),
1122
1139
  ]);
@@ -1134,17 +1151,19 @@ describe('plugin file merges', () => {
1134
1151
  opRoute('/users', [
1135
1152
  opOperation('get', {
1136
1153
  responses: [opResponse(200)],
1137
- pluginFiles: {
1138
- bruno: [
1139
- 'runtime:',
1140
- ' assertions:',
1141
- ' - expression: res.status',
1142
- ' operator: eq',
1143
- ' value: "200"',
1144
- ' - expression: res.headers["x-request-id"]',
1145
- ' operator: isDefined',
1146
- ' value: ""',
1147
- ].join('\n'),
1154
+ pluginExtensions: {
1155
+ bruno: {
1156
+ template: [
1157
+ 'runtime:',
1158
+ ' assertions:',
1159
+ ' - expression: res.status',
1160
+ ' operator: eq',
1161
+ ' value: "200"',
1162
+ ' - expression: res.headers["x-request-id"]',
1163
+ ' operator: isDefined',
1164
+ ' value: ""',
1165
+ ].join('\n'),
1166
+ },
1148
1167
  },
1149
1168
  }),
1150
1169
  ]),
@@ -1161,7 +1180,7 @@ describe('plugin file merges', () => {
1161
1180
  opRoute('/users', [
1162
1181
  opOperation('get', {
1163
1182
  responses: [opResponse(200)],
1164
- pluginFiles: { bruno: 'runtime:\n script:\n req: |\n // pre\n' },
1183
+ pluginExtensions: { bruno: { template: 'runtime:\n script:\n req: |\n // pre\n' } },
1165
1184
  }),
1166
1185
  ]),
1167
1186
  ]);
@@ -1172,13 +1191,13 @@ describe('plugin file merges', () => {
1172
1191
  expect(req.content).toContain('url:');
1173
1192
  });
1174
1193
 
1175
- it('leaves generated content unchanged when pluginFiles is absent', () => {
1194
+ it('leaves generated content unchanged when pluginExtensions is absent', () => {
1176
1195
  const withoutOverride = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200)] })])]);
1177
1196
  const withOverride = opRoot([
1178
1197
  opRoute('/users', [
1179
1198
  opOperation('get', {
1180
1199
  responses: [opResponse(200)],
1181
- pluginFiles: {},
1200
+ pluginExtensions: {},
1182
1201
  }),
1183
1202
  ]),
1184
1203
  ]);
@@ -1193,7 +1212,7 @@ describe('plugin file merges', () => {
1193
1212
  opRoute('/users', [
1194
1213
  opOperation('get', {
1195
1214
  responses: [opResponse(200)],
1196
- pluginFiles: { bruno: 'just a scalar string' },
1215
+ pluginExtensions: { bruno: { template: 'just a scalar string' } },
1197
1216
  }),
1198
1217
  ]),
1199
1218
  ]);
@@ -1206,7 +1225,7 @@ describe('plugin file merges', () => {
1206
1225
  const root = opRoot([
1207
1226
  opRoute('/users', [
1208
1227
  opOperation('get', {
1209
- pluginFiles: { bruno: 'info:\n name: Custom Name\n' },
1228
+ pluginExtensions: { bruno: { template: 'info:\n name: Custom Name\n' } },
1210
1229
  }),
1211
1230
  ]),
1212
1231
  ]);
@@ -1253,3 +1272,124 @@ describe('mergePluginFile', () => {
1253
1272
  });
1254
1273
  });
1255
1274
 
1275
+ describe('validateBrunoExtension', () => {
1276
+ it('accepts an empty object', () => {
1277
+ expect(validateBrunoExtension({})).toBeUndefined();
1278
+ });
1279
+
1280
+ it('accepts { template: <string> }', () => {
1281
+ expect(validateBrunoExtension({ template: 'runtime: {}' })).toBeUndefined();
1282
+ });
1283
+
1284
+ it('rejects a non-object value', () => {
1285
+ const result = validateBrunoExtension('foo') as { errors: string[] };
1286
+ expect(result.errors[0]).toContain('expected an object');
1287
+ });
1288
+
1289
+ it('rejects an array value', () => {
1290
+ const result = validateBrunoExtension(['x']) as { errors: string[] };
1291
+ expect(result.errors[0]).toContain('array');
1292
+ });
1293
+
1294
+ it('rejects template that is not a string', () => {
1295
+ const result = validateBrunoExtension({ template: 7 }) as { errors: string[] };
1296
+ expect(result.errors[0]).toContain("'template' must be a string");
1297
+ });
1298
+
1299
+ it('rejects unknown fields', () => {
1300
+ const result = validateBrunoExtension({ template: 'x', other: 'y' }) as { errors: string[] };
1301
+ expect(result.errors[0]).toContain("unknown field 'other'");
1302
+ });
1303
+ });
1304
+
1305
+ describe('generateOpenCollectionIncremental', () => {
1306
+ function rootWithTwoOps() {
1307
+ return opRoot([opRoute('/users', [opOperation('get'), opOperation('post')])], 'users.op');
1308
+ }
1309
+
1310
+ it('produces the same files as a full regen on the first run (empty manifest)', () => {
1311
+ const root = rootWithTwoOps();
1312
+ const full = generateOpenCollection([root], { collectionName: 'API' });
1313
+ const incremental = generateOpenCollectionIncremental([root], { collectionName: 'API' }, emptyManifest());
1314
+ expect(incremental.skippedOpCount).toBe(0);
1315
+ expect(incremental.deletedPaths).toEqual([]);
1316
+ // Both runs should write the same set of paths.
1317
+ expect(new Set(incremental.filesToWrite.map(f => f.relativePath))).toEqual(new Set(full.map(f => f.relativePath)));
1318
+ });
1319
+
1320
+ it('skips re-rendering ops whose fingerprint matches the prior manifest', () => {
1321
+ const root = rootWithTwoOps();
1322
+ const first = generateOpenCollectionIncremental([root], { collectionName: 'API' }, emptyManifest());
1323
+ const second = generateOpenCollectionIncremental([root], { collectionName: 'API' }, first.manifest);
1324
+
1325
+ expect(second.skippedOpCount).toBe(2);
1326
+ // Only global files (collection root + env file + folder.yml) and the manifest are re-emitted.
1327
+ const opFiles = second.filesToWrite.filter(f => f.relativePath.endsWith('.yml') && f.relativePath.startsWith('users/') && f.relativePath !== 'users/folder.yml');
1328
+ expect(opFiles).toEqual([]);
1329
+ expect(second.deletedPaths).toEqual([]);
1330
+ });
1331
+
1332
+ it('re-renders only the op whose request shape changed', () => {
1333
+ const rootA = opRoot(
1334
+ [opRoute('/users', [opOperation('get'), opOperation('post', { request: opRequest(scalarType('string')) })])],
1335
+ 'users.op',
1336
+ );
1337
+ const first = generateOpenCollectionIncremental([rootA], { collectionName: 'API' }, emptyManifest());
1338
+
1339
+ // Change only the POST body type — GET op should remain cached.
1340
+ const rootB = opRoot(
1341
+ [opRoute('/users', [opOperation('get'), opOperation('post', { request: opRequest(scalarType('int')) })])],
1342
+ 'users.op',
1343
+ );
1344
+ const second = generateOpenCollectionIncremental([rootB], { collectionName: 'API' }, first.manifest);
1345
+
1346
+ expect(second.skippedOpCount).toBe(1);
1347
+ const writtenOpPaths = second.filesToWrite.map(f => f.relativePath).filter(p => p.startsWith('users/') && p !== 'users/folder.yml');
1348
+ expect(writtenOpPaths).toEqual(['users/post-users.yml']);
1349
+ });
1350
+
1351
+ it('invalidates ops that reference a model whose definition changed', () => {
1352
+ const v1 = model('User', [field('name', scalarType('string'))]);
1353
+ const v2 = model('User', [field('name', scalarType('string')), field('email', scalarType('string'))]);
1354
+ const route = () => opRoute('/users', [opOperation('post', { request: opRequest('User') })]);
1355
+ const root = opRoot([route()], 'users.op');
1356
+
1357
+ const first = generateOpenCollectionIncremental([root], { collectionName: 'API', contractRoots: [contractRoot([v1])] }, emptyManifest());
1358
+ const second = generateOpenCollectionIncremental([root], { collectionName: 'API', contractRoots: [contractRoot([v2])] }, first.manifest);
1359
+
1360
+ expect(second.skippedOpCount).toBe(0);
1361
+ expect(second.filesToWrite.map(f => f.relativePath)).toContain('users/post-users.yml');
1362
+ });
1363
+
1364
+ it('regenerates an op whose previously-emitted file is missing on disk', () => {
1365
+ const root = rootWithTwoOps();
1366
+ const first = generateOpenCollectionIncremental([root], { collectionName: 'API' }, emptyManifest());
1367
+ // Simulate that users/get-users.yml was deleted.
1368
+ const second = generateOpenCollectionIncremental(
1369
+ [root],
1370
+ { collectionName: 'API' },
1371
+ first.manifest,
1372
+ relPath => relPath !== 'users/get-users.yml',
1373
+ );
1374
+ expect(second.skippedOpCount).toBe(1);
1375
+ expect(second.filesToWrite.map(f => f.relativePath)).toContain('users/get-users.yml');
1376
+ });
1377
+
1378
+ it('lists removed ops in deletedPaths', () => {
1379
+ const before = opRoot([opRoute('/users', [opOperation('get'), opOperation('post')])], 'users.op');
1380
+ const after = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
1381
+ const first = generateOpenCollectionIncremental([before], { collectionName: 'API' }, emptyManifest());
1382
+ const second = generateOpenCollectionIncremental([after], { collectionName: 'API' }, first.manifest);
1383
+ expect(second.deletedPaths).toContain('users/post-users.yml');
1384
+ expect(second.skippedOpCount).toBe(1); // get-users still cached
1385
+ });
1386
+
1387
+ it('treats a v1 manifest as a full cache miss but still cleans up its tracked files', () => {
1388
+ const root = rootWithTwoOps();
1389
+ const v1Manifest = parseManifest(JSON.stringify({ files: ['legacy/old-file.yml', 'opencollection.yml'] }));
1390
+ const result = generateOpenCollectionIncremental([root], { collectionName: 'API' }, v1Manifest);
1391
+ expect(result.skippedOpCount).toBe(0);
1392
+ expect(result.deletedPaths).toContain('legacy/old-file.yml');
1393
+ });
1394
+ });
1395
+