@contractkit/plugin-typescript 0.34.1 → 0.36.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 +5 -5
- package/.turbo/turbo-test$colon$ci.log +25 -18
- package/CHANGELOG.md +56 -0
- package/README.md +4 -3
- package/dist/codegen-mcp.d.ts +8 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts +13 -5
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +370 -91
- package/dist/index.js.map +1 -1
- package/dist/server-framework-fastify.d.ts +8 -0
- package/dist/server-framework-fastify.d.ts.map +1 -0
- package/dist/server-framework-koa.d.ts +7 -0
- package/dist/server-framework-koa.d.ts.map +1 -0
- package/dist/server-framework.d.ts +101 -0
- package/dist/server-framework.d.ts.map +1 -0
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/src/codegen-mcp.ts +11 -25
- package/src/codegen-operation.ts +146 -77
- package/src/index.ts +37 -5
- package/src/server-framework-fastify.ts +128 -0
- package/src/server-framework-koa.ts +114 -0
- package/src/server-framework.ts +129 -0
- package/tests/codegen-mcp.test.ts +12 -0
- package/tests/codegen-operation-framework.test.ts +184 -0
- package/tests/codegen-operation.test.ts +74 -0
- package/tests/codegen-server.test.ts +56 -0
- package/tests/server-framework-fastify.test.ts +148 -0
- package/tests/server-framework-koa.test.ts +115 -0
- package/tests/server-framework.test.ts +25 -0
|
@@ -37,6 +37,13 @@ function inputs(opRoots = [opRoot([opRoute('/users', [opOperation('get')])], '/p
|
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Inputs carrying an MCP-exposed operation, which is what makes the mcp sub-generator emit a router. */
|
|
41
|
+
function mcpInputs() {
|
|
42
|
+
return inputs([
|
|
43
|
+
opRoot([opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])], '/project/contracts/users.ck'),
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
41
48
|
|
|
42
49
|
describe('createTypescriptPlugin (server)', () => {
|
|
@@ -224,6 +231,55 @@ describe('createTypescriptPlugin (server)', () => {
|
|
|
224
231
|
});
|
|
225
232
|
});
|
|
226
233
|
|
|
234
|
+
describe('framework', () => {
|
|
235
|
+
it('emits a Koa router when no framework is configured', async () => {
|
|
236
|
+
const plugin = createTypescriptPlugin({ server: {} }, '/project');
|
|
237
|
+
const ctx = makeCtx('/project');
|
|
238
|
+
await plugin.generateTargets!(inputs(), ctx);
|
|
239
|
+
const [content] = [...ctx.emitted.values()];
|
|
240
|
+
expect(content).toContain("from '@maroonedsoftware/koa'");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('emits byte-identical output when koa is named explicitly', async () => {
|
|
244
|
+
const runWith = async (server: Record<string, unknown>) => {
|
|
245
|
+
const ctx = makeCtx('/project');
|
|
246
|
+
await createTypescriptPlugin({ server }, '/project').generateTargets!(inputs(), ctx);
|
|
247
|
+
return [...ctx.emitted.values()].join('\n');
|
|
248
|
+
};
|
|
249
|
+
expect(await runWith({ framework: 'koa' })).toEqual(await runWith({}));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('rejects a framework that has no adapter', async () => {
|
|
253
|
+
const plugin = createTypescriptPlugin({ server: { framework: 'express' as never } }, '/project');
|
|
254
|
+
await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(
|
|
255
|
+
/server\.framework 'express' is not supported — expected one of: koa/,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('rejects the unknown framework before any other config complaint', async () => {
|
|
260
|
+
// Both rules are broken here; the framework is the one reported, since a router for a
|
|
261
|
+
// framework that does not exist cannot be generated whatever the other settings say.
|
|
262
|
+
const plugin = createTypescriptPlugin({ server: { framework: 'express' as never, validateResponses: true } }, '/project');
|
|
263
|
+
await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(/server\.framework/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('renders mcp.router.ts for the configured framework', async () => {
|
|
267
|
+
const plugin = createTypescriptPlugin({ server: { framework: 'koa' }, mcp: {} }, '/project');
|
|
268
|
+
const ctx = makeCtx('/project');
|
|
269
|
+
await plugin.generateTargets!(mcpInputs(), ctx);
|
|
270
|
+
const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('mcp.router.ts'))?.[1];
|
|
271
|
+
expect(router).toContain("from '@maroonedsoftware/koa'");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('still emits a Koa mcp.router.ts when there is no server sub-config', async () => {
|
|
275
|
+
const plugin = createTypescriptPlugin({ mcp: {} }, '/project');
|
|
276
|
+
const ctx = makeCtx('/project');
|
|
277
|
+
await plugin.generateTargets!(mcpInputs(), ctx);
|
|
278
|
+
const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('mcp.router.ts'))?.[1];
|
|
279
|
+
expect(router).toContain("from '@maroonedsoftware/koa'");
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
227
283
|
describe('validateResponses', () => {
|
|
228
284
|
const userRoot = () =>
|
|
229
285
|
opRoot(
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { FASTIFY_SERVER_FRAMEWORK as fastify } from '../src/server-framework-fastify.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pinned method by method, the way the Koa adapter is. These strings are the contract with
|
|
6
|
+
* `@maroonedsoftware/fastify`, so a change to any of them should fail here, naming the piece,
|
|
7
|
+
* rather than only as a snapshot diff in another package.
|
|
8
|
+
*/
|
|
9
|
+
describe('FASTIFY_SERVER_FRAMEWORK', () => {
|
|
10
|
+
it('is named fastify', () => {
|
|
11
|
+
expect(fastify.name).toBe('fastify');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('imports', () => {
|
|
15
|
+
it('emits one line naming every symbol the body uses', () => {
|
|
16
|
+
expect(fastify.imports(() => true)).toEqual([
|
|
17
|
+
"import { ServerKitRouter, bodyParserMiddleware, requirePolicy, requireSignature, requestMediaType } from '@maroonedsoftware/fastify';",
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('narrows to the symbols the body actually references', () => {
|
|
22
|
+
expect(fastify.imports(s => s === 'requestMediaType')).toEqual(["import { requestMediaType } from '@maroonedsoftware/fastify';"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('emits nothing when the body references none of them', () => {
|
|
26
|
+
expect(fastify.imports(() => false)).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('declares the router', () => {
|
|
31
|
+
expect(fastify.routerDeclaration('UsersRouter')).toBe('export const UsersRouter = ServerKitRouter();');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders a path parameter with a colon', () => {
|
|
35
|
+
expect(fastify.pathParam('userId')).toBe(':userId');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reserves the two identifiers its handler signature binds', () => {
|
|
39
|
+
expect(fastify.handlerLocals).toEqual(['request', 'reply']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('routeOpen', () => {
|
|
43
|
+
it('omits the middleware list when there is none', () => {
|
|
44
|
+
expect(fastify.routeOpen('UsersRouter', 'get', '/users', [])).toBe("UsersRouter.get('/users', async (request, reply) => {");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('places the middleware between the path and the handler', () => {
|
|
48
|
+
expect(fastify.routeOpen('UsersRouter', 'post', '/users', ['requirePolicy()', "bodyParserMiddleware(['json'])"])).toBe(
|
|
49
|
+
"UsersRouter.post('/users', requirePolicy(), bodyParserMiddleware(['json']), async (request, reply) => {",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('closes a route', () => {
|
|
55
|
+
expect(fastify.routeClose()).toEqual(['});']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders the route middleware factories, which take the same arguments as Koa\'s', () => {
|
|
59
|
+
expect(fastify.middleware.policy('')).toBe('requirePolicy()');
|
|
60
|
+
expect(fastify.middleware.policy("{ policy: 'admin' }")).toBe("requirePolicy({ policy: 'admin' })");
|
|
61
|
+
expect(fastify.middleware.bodyParser("'json', 'multipart'")).toBe("bodyParserMiddleware(['json', 'multipart'])");
|
|
62
|
+
expect(fastify.middleware.signature("'slack'")).toBe("requireSignature('slack')");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('reads the request off the request object, which is the context', () => {
|
|
66
|
+
expect(fastify.request).toEqual({
|
|
67
|
+
params: 'request.params',
|
|
68
|
+
query: 'request.query',
|
|
69
|
+
headers: 'request.headers',
|
|
70
|
+
parsedBody: 'request.parsedBody',
|
|
71
|
+
// A call rather than a property: the raw header carries `; charset=…`, which matches no
|
|
72
|
+
// declared MIME literal.
|
|
73
|
+
contentType: 'requestMediaType(request)',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('resolves a service from the request container', () => {
|
|
78
|
+
expect(fastify.resolveService('PaymentService')).toBe('request.container.get(PaymentService)');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('response', () => {
|
|
82
|
+
it('sets the status through reply', () => {
|
|
83
|
+
expect(fastify.response.status('200')).toBe('reply.status(200);');
|
|
84
|
+
expect(fastify.response.status('result.status')).toBe('reply.status(result.status);');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('sets a header as a single statement, so the optional guard can wrap it', () => {
|
|
88
|
+
expect(fastify.response.header('x-request-id', 'String(result.headers["xRequestId"])')).toBe(
|
|
89
|
+
'reply.header(\'x-request-id\', String(result.headers["xRequestId"]));',
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('sets the content type', () => {
|
|
94
|
+
expect(fastify.response.type("'application/json'")).toBe("reply.type('application/json');");
|
|
95
|
+
expect(fastify.response.type('result.contentType')).toBe('reply.type(result.contentType);');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns the send, including for a response with no body', () => {
|
|
99
|
+
expect(fastify.response.send('result')).toEqual(['return reply.send(result);']);
|
|
100
|
+
// Unlike Koa: a handler that neither returns a body nor calls send leaves the request hanging.
|
|
101
|
+
expect(fastify.response.send(undefined)).toEqual(['return reply.send();']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('closes a status case with nothing, since the case already returned', () => {
|
|
105
|
+
expect(fastify.response.caseEnd()).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('mcpRouter', () => {
|
|
110
|
+
const out = fastify.mcpRouter({ path: '/mcp' });
|
|
111
|
+
|
|
112
|
+
it('mounts the dispatcher at the given path', () => {
|
|
113
|
+
expect(fastify.mcpRouter({ path: '/tools' })).toContain("router.post('/tools',");
|
|
114
|
+
expect(out).toContain("from '@maroonedsoftware/fastify'");
|
|
115
|
+
expect(out).toContain('const dispatcher = request.container.get(McpDispatcher);');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('names the exported router type rather than inferring it', () => {
|
|
119
|
+
expect(out).toContain('export function mountMcp(router: ServerKitRouterType): void {');
|
|
120
|
+
// And therefore never imports the factory value, which `noUnusedLocals` would reject.
|
|
121
|
+
expect(out).toContain("import { type ServerKitRouterType, bodyParserMiddleware, requireSignature, requestHeader } from '@maroonedsoftware/fastify';");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('hijacks the reply for a stateful session, which is Fastify\'s ctx.respond = false', () => {
|
|
125
|
+
expect(out).toContain('reply.hijack();');
|
|
126
|
+
expect(out).toContain('req: request.raw,');
|
|
127
|
+
expect(out).toContain('res: reply.raw,');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reads the parsed body, not Fastify\'s own request.body', () => {
|
|
131
|
+
expect(out).toContain('body: request.parsedBody,');
|
|
132
|
+
expect(out).not.toContain('request.body,');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('passes undefined rather than the empty string for an absent session id', () => {
|
|
136
|
+
expect(out).toContain("sessionId: requestHeader(request, 'mcp-session-id') || undefined,");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('answers a notification with a bodyless 202', () => {
|
|
140
|
+
expect(out).toContain('reply.status(202);');
|
|
141
|
+
expect(out).toContain('return reply.send();');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('escapes the backticks in its own doc comment rather than closing the template', () => {
|
|
145
|
+
expect(out).toContain('Bind `registerMcpTools` to the `McpToolHandlerMap` token.');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { KOA_SERVER_FRAMEWORK as koa } from '../src/server-framework-koa.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Koa adapter is the reference implementation: every string here is one the generator emitted
|
|
6
|
+
* inline before the seam existed. Pinning them individually means a change to any one of them shows
|
|
7
|
+
* up as a failure here, naming the piece, rather than only as a snapshot diff two packages away.
|
|
8
|
+
*/
|
|
9
|
+
describe('KOA_SERVER_FRAMEWORK', () => {
|
|
10
|
+
it('is named koa', () => {
|
|
11
|
+
expect(koa.name).toBe('koa');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('imports', () => {
|
|
15
|
+
it('emits one line naming every symbol the body uses', () => {
|
|
16
|
+
expect(koa.imports(() => true)).toEqual([
|
|
17
|
+
"import { ServerKitRouter, bodyParserMiddleware, requirePolicy, requireSignature } from '@maroonedsoftware/koa';",
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('narrows to the symbols the body actually references', () => {
|
|
22
|
+
expect(koa.imports(s => s === 'requirePolicy')).toEqual(["import { requirePolicy } from '@maroonedsoftware/koa';"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('emits nothing when the body references none of them', () => {
|
|
26
|
+
expect(koa.imports(() => false)).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('declares the router', () => {
|
|
31
|
+
expect(koa.routerDeclaration('UsersRouter')).toBe('export const UsersRouter = ServerKitRouter();');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders a path parameter with a colon', () => {
|
|
35
|
+
expect(koa.pathParam('userId')).toBe(':userId');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('routeOpen', () => {
|
|
39
|
+
it('omits the middleware list when there is none', () => {
|
|
40
|
+
expect(koa.routeOpen('UsersRouter', 'get', '/users', [])).toBe("UsersRouter.get('/users', async ctx => {");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('places the middleware between the path and the handler', () => {
|
|
44
|
+
expect(koa.routeOpen('UsersRouter', 'post', '/users', ['requirePolicy()', "bodyParserMiddleware(['json'])"])).toBe(
|
|
45
|
+
"UsersRouter.post('/users', requirePolicy(), bodyParserMiddleware(['json']), async ctx => {",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('closes a route', () => {
|
|
51
|
+
expect(koa.routeClose()).toEqual(['});']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('renders the route middleware factories', () => {
|
|
55
|
+
expect(koa.middleware.policy('')).toBe('requirePolicy()');
|
|
56
|
+
expect(koa.middleware.policy("{ policy: 'admin' }")).toBe("requirePolicy({ policy: 'admin' })");
|
|
57
|
+
expect(koa.middleware.bodyParser("'json', 'multipart'")).toBe("bodyParserMiddleware(['json', 'multipart'])");
|
|
58
|
+
expect(koa.middleware.signature("'slack'")).toBe("requireSignature('slack')");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('reads the request off the context', () => {
|
|
62
|
+
expect(koa.request).toEqual({
|
|
63
|
+
params: 'ctx.params',
|
|
64
|
+
query: 'ctx.query',
|
|
65
|
+
headers: 'ctx.headers',
|
|
66
|
+
parsedBody: 'ctx.parsedBody',
|
|
67
|
+
contentType: 'ctx.request.type',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('resolves a service from the request container', () => {
|
|
72
|
+
expect(koa.resolveService('PaymentService')).toBe('ctx.container.get(PaymentService)');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('response', () => {
|
|
76
|
+
it('assigns the status', () => {
|
|
77
|
+
expect(koa.response.status('200')).toBe('ctx.status = 200;');
|
|
78
|
+
expect(koa.response.status('result.status')).toBe('ctx.status = result.status;');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('sets a header as a single statement, so the optional guard can wrap it', () => {
|
|
82
|
+
expect(koa.response.header('x-request-id', 'String(result.headers["xRequestId"])')).toBe(
|
|
83
|
+
'ctx.set(\'x-request-id\', String(result.headers["xRequestId"]));',
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('assigns the content type', () => {
|
|
88
|
+
expect(koa.response.type("'application/json'")).toBe("ctx.type = 'application/json';");
|
|
89
|
+
expect(koa.response.type('result.contentType')).toBe('ctx.type = result.contentType;');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('assigns the body, and writes nothing at all when there is none', () => {
|
|
93
|
+
expect(koa.response.send('result')).toEqual(['ctx.body = result;']);
|
|
94
|
+
// Koa ends the response on its own once the handler resolves, so a 204 needs no statement.
|
|
95
|
+
expect(koa.response.send(undefined)).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('closes a status case with a break', () => {
|
|
99
|
+
expect(koa.response.caseEnd()).toEqual(['break;']);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('mcpRouter', () => {
|
|
104
|
+
it('mounts the dispatcher at the given path', () => {
|
|
105
|
+
const out = koa.mcpRouter({ path: '/tools' });
|
|
106
|
+
expect(out).toContain("router.post('/tools',");
|
|
107
|
+
expect(out).toContain("import { ServerKitRouter, bodyParserMiddleware, requireSignature } from '@maroonedsoftware/koa';");
|
|
108
|
+
expect(out).toContain('const dispatcher = ctx.container.get(McpDispatcher);');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('escapes the backticks in its own doc comment rather than closing the template', () => {
|
|
112
|
+
expect(koa.mcpRouter({ path: '/mcp' })).toContain('Bind `registerMcpTools` to the `McpToolHandlerMap` token.');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolveServerFramework, SERVER_FRAMEWORK_NAMES, SERVER_FRAMEWORKS, DEFAULT_SERVER_FRAMEWORK_NAME } from '../src/server-framework.js';
|
|
3
|
+
|
|
4
|
+
describe('resolveServerFramework', () => {
|
|
5
|
+
it('defaults to koa when the config names none', () => {
|
|
6
|
+
expect(resolveServerFramework(undefined).name).toBe('koa');
|
|
7
|
+
expect(DEFAULT_SERVER_FRAMEWORK_NAME).toBe('koa');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('resolves a supported name', () => {
|
|
11
|
+
expect(resolveServerFramework('koa').name).toBe('koa');
|
|
12
|
+
expect(resolveServerFramework('fastify').name).toBe('fastify');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects an unsupported name, naming what is supported', () => {
|
|
16
|
+
expect(() => resolveServerFramework('express')).toThrow(/server\.framework 'express' is not supported/);
|
|
17
|
+
expect(() => resolveServerFramework('express')).toThrow(/expected one of: koa/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('has an adapter for every declared name', () => {
|
|
21
|
+
for (const name of SERVER_FRAMEWORK_NAMES) {
|
|
22
|
+
expect(SERVER_FRAMEWORKS[name]?.name).toBe(name);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|