@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.
- package/.turbo/turbo-build$colon$ci.log +7 -7
- package/.turbo/turbo-test$colon$ci.log +15 -14
- package/CHANGELOG.md +26 -0
- package/README.md +7 -5
- package/coverage/clover.xml +395 -307
- package/coverage/coverage-final.json +3 -2
- package/coverage/index.html +19 -19
- package/coverage/src/codegen-bruno.ts.html +765 -177
- package/coverage/src/index.html +34 -19
- package/coverage/src/index.ts.html +658 -0
- package/coverage/tests/helpers.ts.html +16 -16
- package/coverage/tests/index.html +1 -1
- package/dist/codegen-bruno.d.ts +41 -3
- package/dist/codegen-bruno.d.ts.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +216 -85
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/codegen-bruno.ts +236 -40
- package/src/index.ts +89 -51
- package/tests/codegen-bruno.test.ts +164 -24
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
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
|
|
999
|
-
expect(parseManifest('not json')).toEqual([]);
|
|
1000
|
-
expect(parseManifest('
|
|
1001
|
-
expect(parseManifest('{
|
|
1002
|
-
expect(parseManifest('{"files":
|
|
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
|
-
|
|
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
|
-
|
|
1138
|
-
bruno:
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
|