@contractkit/plugin-typescript 0.28.2 → 0.30.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/src/ts-render.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { ContractTypeNode, FieldNode, ScalarTypeNode } from '@contractkit/core';
2
2
 
3
+ /** Declaration emitted into generated files that reference the `json` scalar. */
3
4
  export const JSON_VALUE_TYPE_DECL = 'export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };';
4
5
 
6
+ /** Quote a property name unless it is already a valid bare TypeScript identifier. */
5
7
  export function quoteKey(name: string): string {
6
8
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `'${name}'`;
7
9
  }
@@ -31,12 +33,25 @@ export function headerNameToProperty(name: string): string {
31
33
 
32
34
  // ─── TypeScript type rendering ────────────────────────────────────────────
33
35
 
34
- export function renderTsType(type: ContractTypeNode): string {
36
+ /**
37
+ * Which runtime the emitted types describe. A few scalars have no single correct TypeScript
38
+ * type: `binary` is a `Blob` in a fetch-based client but a `Buffer` on a Node server. Everything
39
+ * else renders identically for both targets. Defaults to `'client'`.
40
+ */
41
+ export type TsRenderTarget = 'client' | 'server';
42
+
43
+ /**
44
+ * Render a contract type as a plain TypeScript type expression. Model refs render as their bare
45
+ * name; use `renderInputTsType` / `renderOutputTsType` to substitute Input/Output variants.
46
+ *
47
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
48
+ */
49
+ export function renderTsType(type: ContractTypeNode, target: TsRenderTarget = 'client'): string {
35
50
  switch (type.kind) {
36
51
  case 'scalar':
37
- return renderTsScalar(type.name);
52
+ return renderTsScalar(type.name, target);
38
53
  case 'array': {
39
- const inner = renderTsType(type.item);
54
+ const inner = renderTsType(type.item, target);
40
55
  const needsParens =
41
56
  type.item.kind === 'union' ||
42
57
  type.item.kind === 'discriminatedUnion' ||
@@ -45,31 +60,31 @@ export function renderTsType(type: ContractTypeNode): string {
45
60
  return needsParens ? `(${inner})[]` : `${inner}[]`;
46
61
  }
47
62
  case 'tuple':
48
- return `[${type.items.map(renderTsType).join(', ')}]`;
63
+ return `[${type.items.map(i => renderTsType(i, target)).join(', ')}]`;
49
64
  case 'record':
50
- return `Record<${renderTsType(type.key)}, ${renderTsType(type.value)}>`;
65
+ return `Record<${renderTsType(type.key, target)}, ${renderTsType(type.value, target)}>`;
51
66
  case 'enum':
52
67
  return type.values.map(v => `'${escapeSingleQuoted(v)}'`).join(' | ');
53
68
  case 'literal':
54
69
  return typeof type.value === 'string' ? `'${escapeSingleQuoted(type.value)}'` : String(type.value);
55
70
  case 'union':
56
- return type.members.map(renderTsType).join(' | ');
71
+ return type.members.map(m => renderTsType(m, target)).join(' | ');
57
72
  case 'discriminatedUnion':
58
- return type.members.map(renderTsType).join(' | ');
73
+ return type.members.map(m => renderTsType(m, target)).join(' | ');
59
74
  case 'intersection':
60
- return type.members.map(renderTsType).join(' & ');
75
+ return type.members.map(m => renderTsType(m, target)).join(' & ');
61
76
  case 'ref':
62
77
  return type.name;
63
78
  case 'lazy':
64
- return renderTsType(type.inner);
79
+ return renderTsType(type.inner, target);
65
80
  case 'inlineObject':
66
- return renderTsInlineObject(type.fields);
81
+ return renderTsInlineObject(type.fields, target);
67
82
  default:
68
83
  return 'unknown';
69
84
  }
70
85
  }
71
86
 
72
- function renderTsScalar(name: ScalarTypeNode['name']): string {
87
+ function renderTsScalar(name: ScalarTypeNode['name'], target: TsRenderTarget): string {
73
88
  switch (name) {
74
89
  case 'string':
75
90
  case 'email':
@@ -96,7 +111,8 @@ function renderTsScalar(name: ScalarTypeNode['name']): string {
96
111
  case 'object':
97
112
  return 'Record<string, unknown>';
98
113
  case 'binary':
99
- return 'Blob';
114
+ // Node servers hand the handler a Buffer (matching `_ZodBinary`); fetch clients get a Blob.
115
+ return target === 'server' ? 'Buffer' : 'Blob';
100
116
  case 'json':
101
117
  return 'JsonValue';
102
118
  default: {
@@ -106,10 +122,10 @@ function renderTsScalar(name: ScalarTypeNode['name']): string {
106
122
  }
107
123
  }
108
124
 
109
- function renderTsInlineObject(fields: FieldNode[]): string {
125
+ function renderTsInlineObject(fields: FieldNode[], target: TsRenderTarget): string {
110
126
  const entries = fields.map(f => {
111
127
  const opt = f.optional ? '?' : '';
112
- return `${quoteKey(f.name)}${opt}: ${renderTsType(f.type)}`;
128
+ return `${quoteKey(f.name)}${opt}: ${renderTsType(f.type, target)}`;
113
129
  });
114
130
  return `{ ${entries.join('; ')} }`;
115
131
  }
@@ -118,14 +134,16 @@ function renderTsInlineObject(fields: FieldNode[]): string {
118
134
  * Like renderTsType, but substitutes model refs with their Input variant
119
135
  * when the model has visibility modifiers. Used for request-side types
120
136
  * (body, params, query, headers).
137
+ *
138
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
121
139
  */
122
- export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>): string {
123
- if (!modelsWithInput || modelsWithInput.size === 0) return renderTsType(type);
140
+ export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>, target: TsRenderTarget = 'client'): string {
141
+ if (!modelsWithInput || modelsWithInput.size === 0) return renderTsType(type, target);
124
142
  switch (type.kind) {
125
143
  case 'ref':
126
144
  return modelsWithInput.has(type.name) ? `${type.name}Input` : type.name;
127
145
  case 'array': {
128
- const inner = renderInputTsType(type.item, modelsWithInput);
146
+ const inner = renderInputTsType(type.item, modelsWithInput, target);
129
147
  const needsParens =
130
148
  type.item.kind === 'union' ||
131
149
  type.item.kind === 'discriminatedUnion' ||
@@ -134,17 +152,17 @@ export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<
134
152
  return needsParens ? `(${inner})[]` : `${inner}[]`;
135
153
  }
136
154
  case 'intersection':
137
- return type.members.map(m => renderInputTsType(m, modelsWithInput)).join(' & ');
155
+ return type.members.map(m => renderInputTsType(m, modelsWithInput, target)).join(' & ');
138
156
  case 'union':
139
- return type.members.map(m => renderInputTsType(m, modelsWithInput)).join(' | ');
157
+ return type.members.map(m => renderInputTsType(m, modelsWithInput, target)).join(' | ');
140
158
  case 'discriminatedUnion':
141
- return type.members.map(m => renderInputTsType(m, modelsWithInput)).join(' | ');
159
+ return type.members.map(m => renderInputTsType(m, modelsWithInput, target)).join(' | ');
142
160
  case 'inlineObject':
143
- return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderInputTsType(f.type, modelsWithInput)}`).join('; ')} }`;
161
+ return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderInputTsType(f.type, modelsWithInput, target)}`).join('; ')} }`;
144
162
  case 'lazy':
145
- return renderInputTsType(type.inner, modelsWithInput);
163
+ return renderInputTsType(type.inner, modelsWithInput, target);
146
164
  default:
147
- return renderTsType(type);
165
+ return renderTsType(type, target);
148
166
  }
149
167
  }
150
168
 
@@ -153,14 +171,16 @@ export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<
153
171
  * (post-transform wire shape) when the model has format(output=...) or
154
172
  * transitively references one. Used for response-side types in routers
155
173
  * and SDK return types.
174
+ *
175
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
156
176
  */
157
- export function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>): string {
158
- if (!modelsWithOutput || modelsWithOutput.size === 0) return renderTsType(type);
177
+ export function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>, target: TsRenderTarget = 'client'): string {
178
+ if (!modelsWithOutput || modelsWithOutput.size === 0) return renderTsType(type, target);
159
179
  switch (type.kind) {
160
180
  case 'ref':
161
181
  return modelsWithOutput.has(type.name) ? `${type.name}Output` : type.name;
162
182
  case 'array': {
163
- const inner = renderOutputTsType(type.item, modelsWithOutput);
183
+ const inner = renderOutputTsType(type.item, modelsWithOutput, target);
164
184
  const needsParens =
165
185
  type.item.kind === 'union' ||
166
186
  type.item.kind === 'discriminatedUnion' ||
@@ -169,16 +189,16 @@ export function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Se
169
189
  return needsParens ? `(${inner})[]` : `${inner}[]`;
170
190
  }
171
191
  case 'intersection':
172
- return type.members.map(m => renderOutputTsType(m, modelsWithOutput)).join(' & ');
192
+ return type.members.map(m => renderOutputTsType(m, modelsWithOutput, target)).join(' & ');
173
193
  case 'union':
174
- return type.members.map(m => renderOutputTsType(m, modelsWithOutput)).join(' | ');
194
+ return type.members.map(m => renderOutputTsType(m, modelsWithOutput, target)).join(' | ');
175
195
  case 'discriminatedUnion':
176
- return type.members.map(m => renderOutputTsType(m, modelsWithOutput)).join(' | ');
196
+ return type.members.map(m => renderOutputTsType(m, modelsWithOutput, target)).join(' | ');
177
197
  case 'inlineObject':
178
- return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderOutputTsType(f.type, modelsWithOutput)}`).join('; ')} }`;
198
+ return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderOutputTsType(f.type, modelsWithOutput, target)}`).join('; ')} }`;
179
199
  case 'lazy':
180
- return renderOutputTsType(type.inner, modelsWithOutput);
200
+ return renderOutputTsType(type.inner, modelsWithOutput, target);
181
201
  default:
182
- return renderTsType(type);
202
+ return renderTsType(type, target);
183
203
  }
184
204
  }
@@ -101,6 +101,66 @@ describe('generateOperation', () => {
101
101
  const output = generateOp(root);
102
102
  expect(output).not.toContain('luxon');
103
103
  });
104
+
105
+ // Every conditional import must be justified by a reference in the generated body —
106
+ // an unused import trips `noUnusedLocals` and lint in the consuming project.
107
+ it('imports bodyParserMiddleware only when an operation has a request body', () => {
108
+ const withBody = generateOp(opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]));
109
+ expect(withBody).toContain('bodyParserMiddleware');
110
+
111
+ const withoutBody = generateOp(opRoot([opRoute('/users', [opOperation('get')])]));
112
+ expect(withoutBody).not.toContain('bodyParserMiddleware');
113
+ });
114
+
115
+ it('omits MultipartBody when a multipart body shares its shape with the other MIME types', () => {
116
+ // Structurally equal bodies collapse to a single parseAndValidate call, so nothing
117
+ // references MultipartBody even though the operation does declare multipart.
118
+ const root = opRoot([
119
+ opRoute('/upload', [
120
+ opOperation('post', {
121
+ request: opMultiRequest([
122
+ ['multipart/form-data', 'UploadForm'],
123
+ ['application/json', 'UploadForm'],
124
+ ]),
125
+ }),
126
+ ]),
127
+ ]);
128
+ const output = generateOp(root);
129
+ expect(output).not.toContain('MultipartBody');
130
+ });
131
+
132
+ it('imports MultipartBody when the multipart body is handled on its own', () => {
133
+ const root = opRoot([opRoute('/upload', [opOperation('post', { request: opMultiRequest([['multipart/form-data', 'UploadForm']]) })])]);
134
+ expect(generateOp(root)).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
135
+ });
136
+
137
+ it('leaves no import unreferenced in the generated body', () => {
138
+ const root = opRoot([
139
+ opRoute(
140
+ '/users/{id}',
141
+ [
142
+ opOperation('get', { security: SECURITY_NONE }),
143
+ opOperation('post', { request: opRequest('CreateUser'), signature: 'webhookKey' }),
144
+ ],
145
+ [opParam('id', scalarType('uuid'))],
146
+ ),
147
+ ]);
148
+ const output = generateOp(root);
149
+ const importLines = output.split('\n').filter(l => l.startsWith('import '));
150
+ expect(importLines.length).toBeGreaterThan(0);
151
+
152
+ const bodyText = output
153
+ .split('\n')
154
+ .filter(l => !l.startsWith('import '))
155
+ .join('\n');
156
+ for (const line of importLines) {
157
+ const named = line.match(/^import \{([^}]*)\}/);
158
+ if (!named) continue;
159
+ for (const symbol of named[1]!.split(',').map(s => s.trim().replace(/^type /, ''))) {
160
+ expect(bodyText, `${symbol} is imported but never used`).toMatch(new RegExp(`\\b${symbol}\\b`));
161
+ }
162
+ }
163
+ });
104
164
  });
105
165
 
106
166
  // ─── Handler signature ─────────────────────────────────────────
@@ -658,6 +718,77 @@ describe('generateOperation', () => {
658
718
  const output = generateOp(root, { modelsWithOutput: new Set(['AuthToken']) });
659
719
  expect(output).toContain('result: AuthTokenOutput[]');
660
720
  });
721
+
722
+ // Scalar response bodies used to emit the .ck scalar name verbatim (`result: binary`),
723
+ // which only happened to compile for `string`.
724
+ describe('scalar response bodies map to the server-side TypeScript type', () => {
725
+ const cases: Array<[string, string]> = [
726
+ ['binary', 'Buffer'],
727
+ ['int', 'number'],
728
+ ['number', 'number'],
729
+ ['bigint', 'bigint'],
730
+ ['boolean', 'boolean'],
731
+ ['string', 'string'],
732
+ ['uuid', 'string'],
733
+ ['email', 'string'],
734
+ ['url', 'string'],
735
+ ['datetime', 'DateTime'],
736
+ ['date', 'DateTime'],
737
+ ['time', 'DateTime'],
738
+ ['duration', 'Duration'],
739
+ ['interval', 'string'],
740
+ ['json', '_JsonValue'],
741
+ ['object', 'Record<string, unknown>'],
742
+ ['unknown', 'unknown'],
743
+ ['null', 'null'],
744
+ ];
745
+
746
+ for (const [scalar, tsType] of cases) {
747
+ it(`renders ${scalar} as ${tsType}`, () => {
748
+ const root = opRoot([
749
+ opRoute('/x', [
750
+ opOperation('get', {
751
+ responses: [opResponse(200, scalarType(scalar as never), 'application/octet-stream')],
752
+ }),
753
+ ]),
754
+ ]);
755
+ expect(generateOp(root)).toContain(`const result: ${tsType} = await service.list();`);
756
+ });
757
+ }
758
+
759
+ it('renders an array of binary as Buffer[]', () => {
760
+ const root = opRoot([
761
+ opRoute('/x', [opOperation('get', { responses: [opResponse(200, arrayType(scalarType('binary')), 'application/json')] })]),
762
+ ]);
763
+ expect(generateOp(root)).toContain('const result: Buffer[] = await service.list();');
764
+ });
765
+ });
766
+
767
+ describe('luxon imports cover every scalar that references a luxon class', () => {
768
+ it('imports Duration for a duration response body', () => {
769
+ const root = opRoot([opRoute('/x', [opOperation('get', { responses: [opResponse(200, scalarType('duration'), 'application/json')] })])]);
770
+ expect(generateOp(root)).toContain("import { Duration } from 'luxon';");
771
+ });
772
+
773
+ it('imports Interval and emits the _ZodInterval helper for an interval body', () => {
774
+ const root = opRoot([opRoute('/x', [opOperation('get', { responses: [opResponse(200, scalarType('interval'), 'application/json')] })])]);
775
+ const output = generateOp(root);
776
+ expect(output).toContain("import { Interval } from 'luxon';");
777
+ expect(output).toContain('const _ZodInterval =');
778
+ });
779
+
780
+ it('imports DateTime and Duration together when both are used', () => {
781
+ const root = opRoot([
782
+ opRoute('/x', [
783
+ opOperation('post', {
784
+ request: opRequest(scalarType('datetime')),
785
+ responses: [opResponse(200, scalarType('duration'), 'application/json')],
786
+ }),
787
+ ]),
788
+ ]);
789
+ expect(generateOp(root)).toContain("import { DateTime, Duration } from 'luxon';");
790
+ });
791
+ });
661
792
  });
662
793
 
663
794
  // ─── Service inference ────────────────────────────────────────
@@ -903,7 +1034,7 @@ describe('generateOp — route modifiers JSDoc', () => {
903
1034
  const root = opRoot([opRoute('/users', [op])]);
904
1035
  const out = generateOp(root);
905
1036
  expect(out).not.toContain('requireSignature');
906
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy }`);
1037
+ expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
907
1038
  });
908
1039
  });
909
1040
 
@@ -914,7 +1045,7 @@ describe('generateOp — route modifiers JSDoc', () => {
914
1045
  const op = opOperation('get');
915
1046
  const root = opRoot([opRoute('/users', [op])]);
916
1047
  const out = generateOp(root);
917
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware, requirePolicy }`);
1048
+ expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
918
1049
  expect(out).toContain(`requirePolicy()`);
919
1050
  });
920
1051
 
@@ -948,7 +1079,7 @@ describe('generateOp — route modifiers JSDoc', () => {
948
1079
  const op = opOperation('get', { security: SECURITY_NONE });
949
1080
  const root = opRoot([opRoute('/health', [op])]);
950
1081
  const out = generateOp(root);
951
- expect(out).toContain(`import { ServerKitRouter, bodyParserMiddleware }`);
1082
+ expect(out).toContain(`import { ServerKitRouter } from`);
952
1083
  expect(out).not.toContain('requirePolicy');
953
1084
  });
954
1085
 
@@ -98,6 +98,54 @@ describe('generatePlainTypes', () => {
98
98
  expect(output).toContain('o: Record<string, unknown>;');
99
99
  expect(output).toContain('bin: Blob;');
100
100
  });
101
+
102
+ // `binary` is the one scalar with no runtime-independent TypeScript type: a fetch client
103
+ // sees a Blob, a Koa handler sees the Buffer that _ZodBinary validates.
104
+ describe('binary follows the render target', () => {
105
+ const ctx = (target: 'client' | 'server') => ({
106
+ modelOutPaths: new Map<string, string>(),
107
+ currentOutPath: 'out.ts',
108
+ target,
109
+ });
110
+
111
+ it('renders Buffer for the server target', () => {
112
+ const root = contractRoot([model('M', [field('bin', scalarType('binary'))])]);
113
+ expect(generatePlainTypes(root, ctx('server'))).toContain('bin: Buffer;');
114
+ });
115
+
116
+ it('renders Blob for the client target', () => {
117
+ const root = contractRoot([model('M', [field('bin', scalarType('binary'))])]);
118
+ expect(generatePlainTypes(root, ctx('client'))).toContain('bin: Blob;');
119
+ });
120
+
121
+ it('defaults to the client target when unset', () => {
122
+ const root = contractRoot([model('M', [field('bin', scalarType('binary'))])]);
123
+ expect(generatePlainTypes(root)).toContain('bin: Blob;');
124
+ });
125
+
126
+ it('applies to nested and compound positions', () => {
127
+ const root = contractRoot([
128
+ model('M', [
129
+ field('list', arrayType(scalarType('binary'))),
130
+ field('nested', inlineObjectType([field('bin', scalarType('binary'))])),
131
+ field('map', recordType(scalarType('string'), scalarType('binary'))),
132
+ ]),
133
+ ]);
134
+ const output = generatePlainTypes(root, ctx('server'));
135
+ expect(output).toContain('list: Buffer[];');
136
+ expect(output).toContain('nested: { bin: Buffer };');
137
+ expect(output).toContain('map: Record<string, Buffer>;');
138
+ });
139
+
140
+ it('applies to type aliases and their Input variants', () => {
141
+ const root = contractRoot([
142
+ model('Blob1', [field('bin', scalarType('binary')), field('secret', scalarType('binary'), { visibility: 'readonly' })]),
143
+ ]);
144
+ const output = generatePlainTypes(root, { ...ctx('server'), modelsWithInput: new Set(['Blob1']) });
145
+ expect(output).toContain('bin: Buffer;');
146
+ expect(output).not.toContain('Blob;');
147
+ });
148
+ });
101
149
  });
102
150
 
103
151
  // ─── Compound types ───────────────────────────────────────────
@@ -120,6 +120,43 @@ describe('createTypescriptPlugin (server)', () => {
120
120
  expect(typeContent).not.toContain('z.');
121
121
  expect(typeContent).toContain('export interface User');
122
122
  });
123
+
124
+ it('renders binary as Buffer in server plain types', async () => {
125
+ const plugin = createTypescriptPlugin({ server: { output: { types: 'src/types/{filename}.ts' } } }, '/project');
126
+ const ctx = makeCtx('/project');
127
+ const contractRoots = [contractRoot([model('Upload', [field('data', scalarType('binary'))])], '/project/contracts/uploads.ck')];
128
+ await plugin.generateTargets!(inputs([], contractRoots as any), ctx);
129
+ const typeContent = [...ctx.emitted.values()][0]!;
130
+ expect(typeContent).toContain('data: Buffer;');
131
+ expect(typeContent).not.toContain('Blob');
132
+ });
133
+
134
+ it('renders binary as Blob in SDK plain types', async () => {
135
+ const plugin = createTypescriptPlugin({ sdk: { output: { types: 'src/types/{filename}.ts' } } }, '/project');
136
+ const ctx = makeCtx('/project');
137
+ const contractRoots = [contractRoot([model('Upload', [field('data', scalarType('binary'))])], '/project/contracts/uploads.ck')];
138
+ await plugin.generateTargets!(inputs([], contractRoots as any), ctx);
139
+ const typeContent = [...ctx.emitted.values()].find(c => c.includes('interface Upload'))!;
140
+ expect(typeContent).toContain('data: Blob;');
141
+ });
142
+
143
+ it('honors types.target on the standalone types sub-generator', async () => {
144
+ const contractRoots = [contractRoot([model('Upload', [field('data', scalarType('binary'))])], '/project/contracts/uploads.ck')];
145
+
146
+ const serverCtx = makeCtx('/project');
147
+ await createTypescriptPlugin({ types: { output: 'src/{filename}.types.ts', target: 'server' } }, '/project').generateTargets!(
148
+ inputs([], contractRoots as any),
149
+ serverCtx,
150
+ );
151
+ expect([...serverCtx.emitted.values()][0]!).toContain('data: Buffer;');
152
+
153
+ const defaultCtx = makeCtx('/project');
154
+ await createTypescriptPlugin({ types: { output: 'src/{filename}.types.ts' } }, '/project').generateTargets!(
155
+ inputs([], contractRoots as any),
156
+ defaultCtx,
157
+ );
158
+ expect([...defaultCtx.emitted.values()][0]!).toContain('data: Blob;');
159
+ });
123
160
  });
124
161
 
125
162
  describe('generated content', () => {