@flighthq/vite-plugin-manifest 0.5.1-edge.1685.dc0e23a

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.
@@ -0,0 +1,212 @@
1
+ import { mkdtemp, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { deflateSync } from 'node:zlib';
5
+
6
+ import { sdkHostDecompressDeflate } from '@flighthq/compression/contract';
7
+ import { createRequirementSet } from '@flighthq/requirement/contract';
8
+ import { RequirementFacet } from '@flighthq/types/contract';
9
+
10
+ import { MANIFEST_PARSER_BACKEND } from './manifestModuleSource';
11
+ import { createManifestPlugin, MANIFEST_QUERY_SUFFIX } from './manifestPlugin';
12
+
13
+ describe('createManifestPlugin', () => {
14
+ it('claims only ?manifest imports, resolving them against the importer directory', async () => {
15
+ const plugin = createManifestPlugin({ catalog: { entries: [] } });
16
+ expect(plugin.resolveId('./asset.swf')).toBeNull();
17
+ expect(plugin.resolveId('./styles.css')).toBeNull();
18
+ const resolved = plugin.resolveId(`./asset.swf${MANIFEST_QUERY_SUFFIX}`, '/project/src/main.ts')!;
19
+ expect(resolved.startsWith('\0flight-manifest:')).toBe(true);
20
+ expect(resolved.endsWith('/project/src/asset.swf')).toBe(true);
21
+ });
22
+
23
+ it('loads nothing for an id it did not resolve', async () => {
24
+ const plugin = createManifestPlugin({ catalog: { entries: [] } });
25
+ expect(await plugin.load('/project/src/main.ts')).toBeNull();
26
+ });
27
+
28
+ it('serves fragments for the one file imported, resolved across every backend', async () => {
29
+ const { dir, plugin } = await fixture();
30
+ const source = (await load(plugin, dir, 'a.swf'))!;
31
+ expect(source).toContain("import { glDefineShape } from '@acme/gl';");
32
+ expect(source).toContain("import { wgpuDefineShape } from '@acme/wgpu';");
33
+ expect(source).toContain(
34
+ "export const glOptions = {\n nodeRenderers: new Map([\n ['DefineShape', glDefineShape],",
35
+ );
36
+ expect(source).toContain(
37
+ "export const wgpuOptions = {\n nodeRenderers: new Map([\n ['DefineShape', wgpuDefineShape],",
38
+ );
39
+ // canvas and dom carry no kind-keyed options fields on base, so their fragments are empty and
40
+ // still exported — an application can spread every fragment unconditionally.
41
+ expect(source).toContain('export const canvasOptions = {};');
42
+ expect(source).toContain('export const domOptions = {};');
43
+ });
44
+
45
+ it('reads the exact file imported, so two documents give different modules', async () => {
46
+ const { dir, plugin } = await fixture();
47
+ await writeFile(join(dir, 'b.swf'), Buffer.from(createSwf(TAG_SET_BACKGROUND_COLOR)));
48
+ const a = await load(plugin, dir, 'a.swf');
49
+ const b = await load(plugin, dir, 'b.swf');
50
+ expect(a).not.toBe(b);
51
+ expect(a).toContain("['DefineShape', wgpuDefineShape]");
52
+ expect(b).toContain('export const wgpuOptions = {};');
53
+ });
54
+
55
+ it('invalidates per imported source, leaving other files cached', async () => {
56
+ const { dir, plugin } = await fixture();
57
+ await writeFile(join(dir, 'b.swf'), Buffer.from(createSwf(TAG_DEFINE_SHAPE)));
58
+ const beforeA = await load(plugin, dir, 'a.swf');
59
+ const beforeB = await load(plugin, dir, 'b.swf');
60
+
61
+ // Rewrite a.swf to content with no DefineShape, then invalidate ONLY a.swf.
62
+ await writeFile(join(dir, 'a.swf'), Buffer.from(createSwf(TAG_SET_BACKGROUND_COLOR)));
63
+ await writeFile(join(dir, 'b.swf'), Buffer.from(createSwf(TAG_SET_BACKGROUND_COLOR)));
64
+ plugin.handleHotUpdate({ file: join(dir, 'a.swf') });
65
+
66
+ expect(await load(plugin, dir, 'a.swf')).not.toBe(beforeA);
67
+ // b.swf changed on disk too, but nothing invalidated it, so its cached module must stand.
68
+ expect(await load(plugin, dir, 'b.swf')).toBe(beforeB);
69
+ });
70
+
71
+ it('reports an unsupported format and still serves a spreadable empty module', async () => {
72
+ const { dir, diagnostics, plugin } = await fixture();
73
+ await writeFile(join(dir, 'notes.txt'), 'not content');
74
+ const source = (await load(plugin, dir, 'notes.txt'))!;
75
+ expect(diagnostics.some((m) => m.includes('unsupported content format'))).toBe(true);
76
+ expect(source).toContain('export const canvasOptions = {};');
77
+ expect(source).toContain('export const parserOptions = {};');
78
+ });
79
+
80
+ // Downstream report: a CWS SWF and a deflate AWD produced `parserOptions = {}` with NO diagnostic,
81
+ // because nothing is "unresolved" when nothing was read. That is a bundle missing every handler the
82
+ // content needed — exactly what onDiagnostic exists to prevent.
83
+ it('REPORTS compressed content it cannot decode, instead of emitting an empty manifest silently', async () => {
84
+ const dir = await mkdtemp(join(tmpdir(), 'flight-vite-'));
85
+ await writeFile(join(dir, 'a.swf'), Buffer.from(compressedSwf()));
86
+ const diagnostics: string[] = [];
87
+ const plugin = createManifestPlugin({
88
+ catalog: { entries: [] },
89
+ onDiagnostic: (message) => diagnostics.push(message),
90
+ });
91
+ const source = (await load(plugin, dir, 'a.swf'))!;
92
+ expect(source).toContain('export const parserOptions = {};');
93
+ expect(diagnostics.some((m) => m.includes('unreadable content') && m.includes('deflate/lzma'))).toBe(true);
94
+ });
95
+
96
+ it('reads that same compressed file once a deflate capability is supplied', async () => {
97
+ const dir = await mkdtemp(join(tmpdir(), 'flight-vite-'));
98
+ await writeFile(join(dir, 'a.swf'), Buffer.from(compressedSwf()));
99
+ const diagnostics: string[] = [];
100
+ const plugin = createManifestPlugin({
101
+ catalog: {
102
+ entries: [entry(MANIFEST_PARSER_BACKEND, RequirementFacet.DocumentFormat, 'DefineShape', 'parserDefineShape')],
103
+ },
104
+ deflate: sdkHostDecompressDeflate,
105
+ onDiagnostic: (message) => diagnostics.push(message),
106
+ });
107
+ const source = (await load(plugin, dir, 'a.swf'))!;
108
+ expect(source).toContain('parserDefineShape,');
109
+ expect(diagnostics.filter((m) => m.includes('unreadable content'))).toEqual([]);
110
+ });
111
+
112
+ it('reports an unreadable file rather than failing the build', async () => {
113
+ const { dir, diagnostics, plugin } = await fixture();
114
+ expect(await load(plugin, dir, 'missing.swf')).toContain('export const glOptions = {};');
115
+ expect(diagnostics.some((m) => m.includes('analyzer failed'))).toBe(true);
116
+ });
117
+
118
+ it('reports a requirement no catalog entry satisfies', async () => {
119
+ const dir = await mkdtemp(join(tmpdir(), 'flight-vite-'));
120
+ await writeFile(join(dir, 'a.swf'), Buffer.from(createSwf(TAG_DEFINE_SHAPE)));
121
+ const diagnostics: string[] = [];
122
+ const plugin = createManifestPlugin({
123
+ catalog: { entries: [] },
124
+ onDiagnostic: (message) => diagnostics.push(message),
125
+ });
126
+ await load(plugin, dir, 'a.swf');
127
+ expect(diagnostics.some((m) => m.includes('no catalog entry for document.format DefineShape'))).toBe(true);
128
+ });
129
+
130
+ it('drives resolution through an EXTERNAL catalog, since the built-in catalog ships empty', async () => {
131
+ const { dir, plugin } = await fixture();
132
+ // BUILT_IN_REQUIREMENT_CATALOG_ENTRIES is deliberately empty on base, so every row that reaches a
133
+ // fragment came from the catalog the caller passed in. This pins that the external path is the
134
+ // real one rather than incidental.
135
+ expect(await load(plugin, dir, 'a.swf')).toContain("['DefineShape', glDefineShape]");
136
+
137
+ const empty = createManifestPlugin({ catalog: { entries: [] }, onDiagnostic: () => {} });
138
+ const source = (await load(empty, dir, 'a.swf'))!;
139
+ expect(source).toContain('export const glOptions = {};');
140
+ expect(source).not.toContain('glDefineShape');
141
+ });
142
+
143
+ it('reports every requirement no backend in the external catalog can satisfy', async () => {
144
+ const dir = await mkdtemp(join(tmpdir(), 'flight-vite-'));
145
+ await writeFile(join(dir, 'a.swf'), Buffer.from(createSwf(TAG_DEFINE_SHAPE)));
146
+ const diagnostics: string[] = [];
147
+ const plugin = createManifestPlugin({
148
+ catalog: { entries: [] },
149
+ onDiagnostic: (message) => diagnostics.push(message),
150
+ });
151
+ await load(plugin, dir, 'a.swf');
152
+ expect(diagnostics.some((m) => m.includes('no catalog entry for document.format DefineShape'))).toBe(true);
153
+ });
154
+ });
155
+
156
+ const TAG_DEFINE_SHAPE = 2;
157
+ const TAG_SET_BACKGROUND_COLOR = 9;
158
+
159
+ function entry(backend: string, facet: string, kind: string, symbol: string) {
160
+ return {
161
+ backend,
162
+ facet: facet as never,
163
+ implementationImport: `@acme/${backend}`,
164
+ implementationSymbol: symbol,
165
+ kind,
166
+ registrarImport: `@acme/${backend}`,
167
+ registrarSymbol: `register${kind}`,
168
+ };
169
+ }
170
+
171
+ async function fixture() {
172
+ const dir = await mkdtemp(join(tmpdir(), 'flight-vite-'));
173
+ await writeFile(join(dir, 'a.swf'), Buffer.from(createSwf(TAG_DEFINE_SHAPE)));
174
+ const diagnostics: string[] = [];
175
+ const plugin = createManifestPlugin({
176
+ catalog: {
177
+ entries: [
178
+ entry('wgpu', RequirementFacet.DocumentFormat, 'DefineShape', 'wgpuDefineShape'),
179
+ entry('gl', RequirementFacet.DocumentFormat, 'DefineShape', 'glDefineShape'),
180
+ entry(MANIFEST_PARSER_BACKEND, RequirementFacet.DocumentFormat, 'DefineShape', 'parserDefineShape'),
181
+ ],
182
+ },
183
+ onDiagnostic: (message) => diagnostics.push(message),
184
+ });
185
+ return { diagnostics, dir, plugin };
186
+ }
187
+
188
+ async function load(plugin: ReturnType<typeof createManifestPlugin>, dir: string, name: string) {
189
+ const id = plugin.resolveId(`./${name}${MANIFEST_QUERY_SUFFIX}`, join(dir, 'main.ts'))!;
190
+ return plugin.load(id);
191
+ }
192
+
193
+ // A minimal SWF carrying exactly one tag of the given code, built inline so the tests need no fixture.
194
+ function createSwf(code: number): Uint8Array {
195
+ const tagHeader = code << 6;
196
+ const body = new Uint8Array([0x00, 0x00, 0x18, 0x01, 0x00, tagHeader & 0xff, (tagHeader >> 8) & 0xff, 0x00, 0x00]);
197
+ const file = new Uint8Array(8 + body.length);
198
+ file.set([0x46, 0x57, 0x53, 9], 0);
199
+ new DataView(file.buffer).setUint32(4, file.length, true);
200
+ file.set(body, 8);
201
+ return file;
202
+ }
203
+
204
+ function compressedSwf(): Uint8Array {
205
+ const uncompressed = createSwf(TAG_DEFINE_SHAPE);
206
+ const body = new Uint8Array(deflateSync(Buffer.from(uncompressed.subarray(8))));
207
+ const file = new Uint8Array(8 + body.length);
208
+ file.set(uncompressed.subarray(0, 8), 0);
209
+ file[0] = 0x43;
210
+ file.set(body, 8);
211
+ return file;
212
+ }
@@ -0,0 +1,172 @@
1
+ import { mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { RequirementFacet } from '@flighthq/types/contract';
6
+ import { build } from 'vite';
7
+
8
+ import { MANIFEST_PARSER_BACKEND } from './manifestModuleSource';
9
+ import { createManifestPlugin, MANIFEST_QUERY_SUFFIX } from './manifestPlugin';
10
+
11
+ // Tree shaking is a property of a BUNDLER, not of the emitted string. Reading the generated source
12
+ // only shows what was offered; these tests run a real Vite production build and read what survived.
13
+ const BUILD_TIMEOUT_MS = 60_000;
14
+
15
+ describe('vite build', () => {
16
+ it(
17
+ 'keeps only the backend fragment the entry imported, dropping the other implementations',
18
+ async () => {
19
+ const dir = await project();
20
+ await writeFile(
21
+ join(dir, 'src', 'main.js'),
22
+ `import { glOptions } from './asset.swf${MANIFEST_QUERY_SUFFIX}';\nglobalThis.out = glOptions;\n`,
23
+ );
24
+ const bundle = await bundleOf(dir);
25
+
26
+ expect(bundle).toContain('GL_IMPLEMENTATION_MARKER');
27
+ expect(bundle).not.toContain('CANVAS_IMPLEMENTATION_MARKER');
28
+ expect(bundle).not.toContain('WGPU_IMPLEMENTATION_MARKER');
29
+ expect(bundle).not.toContain('PARSER_IMPLEMENTATION_MARKER');
30
+ },
31
+ BUILD_TIMEOUT_MS,
32
+ );
33
+
34
+ it(
35
+ 'keeps only the parser fragment when that is what the entry imported',
36
+ async () => {
37
+ const dir = await project();
38
+ await writeFile(
39
+ join(dir, 'src', 'main.js'),
40
+ `import { parserOptions } from './asset.swf${MANIFEST_QUERY_SUFFIX}';\nglobalThis.out = parserOptions;\n`,
41
+ );
42
+ const bundle = await bundleOf(dir);
43
+
44
+ expect(bundle).toContain('PARSER_IMPLEMENTATION_MARKER');
45
+ expect(bundle).not.toContain('GL_IMPLEMENTATION_MARKER');
46
+ expect(bundle).not.toContain('CANVAS_IMPLEMENTATION_MARKER');
47
+ },
48
+ BUILD_TIMEOUT_MS,
49
+ );
50
+
51
+ it(
52
+ 'keeps every implementation an entry actually imports, across two backends',
53
+ async () => {
54
+ const dir = await project();
55
+ await writeFile(
56
+ join(dir, 'src', 'main.js'),
57
+ `import { glOptions, wgpuOptions } from './asset.swf${MANIFEST_QUERY_SUFFIX}';\n` +
58
+ 'globalThis.out = [glOptions, wgpuOptions];\n',
59
+ );
60
+ const bundle = await bundleOf(dir);
61
+
62
+ expect(bundle).toContain('GL_IMPLEMENTATION_MARKER');
63
+ expect(bundle).toContain('WGPU_IMPLEMENTATION_MARKER');
64
+ expect(bundle).not.toContain('PARSER_IMPLEMENTATION_MARKER');
65
+ },
66
+ BUILD_TIMEOUT_MS,
67
+ );
68
+
69
+ it(
70
+ 'produces a spreadable fragment carrying the kind the content required',
71
+ async () => {
72
+ const dir = await project();
73
+ await writeFile(
74
+ join(dir, 'src', 'main.js'),
75
+ `import { glOptions } from './asset.swf${MANIFEST_QUERY_SUFFIX}';\nglobalThis.out = glOptions;\n`,
76
+ );
77
+ const bundle = await bundleOf(dir);
78
+ // The fragment reaches the bundle as a real Map literal keyed by the requirement's kind, which is
79
+ // what lets an application spread it straight into createGlRenderState.
80
+ expect(bundle).toContain('DefineShape');
81
+ expect(bundle).toContain('nodeRenderers');
82
+ },
83
+ BUILD_TIMEOUT_MS,
84
+ );
85
+
86
+ it(
87
+ 'retains BOTH canvas and gl when the entry imports both fragments',
88
+ async () => {
89
+ const dir = await project();
90
+ await writeFile(
91
+ join(dir, 'src', 'main.js'),
92
+ `import { canvasOptions, glOptions } from './asset.swf${MANIFEST_QUERY_SUFFIX}';\n` +
93
+ 'globalThis.out = [canvasOptions, glOptions];\n',
94
+ );
95
+ const bundle = await bundleOf(dir);
96
+
97
+ // A canvas fragment is a Partial<CanvasRenderStateOptions> spread straight into
98
+ // createCanvasRenderState(options), so canvas carries real implementations and both survive.
99
+ // wgpu and the parser do not, which is what makes this a retention test rather than a tautology.
100
+ expect(bundle).toContain('CANVAS_IMPLEMENTATION_MARKER');
101
+ expect(bundle).toContain('GL_IMPLEMENTATION_MARKER');
102
+ expect(bundle).not.toContain('WGPU_IMPLEMENTATION_MARKER');
103
+ expect(bundle).not.toContain('PARSER_IMPLEMENTATION_MARKER');
104
+ },
105
+ BUILD_TIMEOUT_MS,
106
+ );
107
+ });
108
+
109
+ async function bundleOf(dir: string): Promise<string> {
110
+ await build({
111
+ build: {
112
+ minify: false,
113
+ outDir: join(dir, 'dist'),
114
+ rollupOptions: { input: join(dir, 'src', 'main.js') },
115
+ write: true,
116
+ },
117
+ configFile: false,
118
+ logLevel: 'silent',
119
+ plugins: [createManifestPlugin({ catalog: { entries: catalogEntries(dir) }, onDiagnostic: () => {} })],
120
+ root: dir,
121
+ });
122
+ const outDir = join(dir, 'dist');
123
+ const files = (await readdir(outDir, { recursive: true, withFileTypes: true }))
124
+ .filter((e) => e.isFile() && e.name.endsWith('.js'))
125
+ .map((e) => join(e.parentPath ?? outDir, e.name));
126
+ const contents = await Promise.all(files.map((file) => readFile(file, 'utf8')));
127
+ return contents.join('\n');
128
+ }
129
+
130
+ function catalogEntries(dir: string) {
131
+ const impl = (backend: string) => join(dir, 'src', `${backend}Impl.js`);
132
+ return [
133
+ entry('canvas', RequirementFacet.DocumentFormat, 'DefineShape', 'canvasDefineShape', impl('canvas')),
134
+ entry('gl', RequirementFacet.DocumentFormat, 'DefineShape', 'glDefineShape', impl('gl')),
135
+ entry('wgpu', RequirementFacet.DocumentFormat, 'DefineShape', 'wgpuDefineShape', impl('wgpu')),
136
+ entry(MANIFEST_PARSER_BACKEND, RequirementFacet.DocumentFormat, 'DefineShape', 'parserDefineShape', impl('parser')),
137
+ ];
138
+ }
139
+
140
+ function entry(backend: string, facet: string, kind: string, symbol: string, module: string) {
141
+ return {
142
+ backend,
143
+ facet: facet as never,
144
+ implementationImport: module,
145
+ implementationSymbol: symbol,
146
+ kind,
147
+ registrarImport: module,
148
+ registrarSymbol: `register${kind}`,
149
+ };
150
+ }
151
+
152
+ async function project(): Promise<string> {
153
+ const dir = await mkdtemp(join(tmpdir(), 'flight-vite-build-'));
154
+ await mkdir(join(dir, 'src'), { recursive: true });
155
+ await writeFile(join(dir, 'src', 'asset.swf'), Buffer.from(createSwfWithDefineShape()));
156
+ for (const backend of ['canvas', 'gl', 'wgpu', 'parser']) {
157
+ await writeFile(
158
+ join(dir, 'src', `${backend}Impl.js`),
159
+ `export const ${backend}DefineShape = '${backend.toUpperCase()}_IMPLEMENTATION_MARKER';\n`,
160
+ );
161
+ }
162
+ return dir;
163
+ }
164
+
165
+ function createSwfWithDefineShape(): Uint8Array {
166
+ const body = new Uint8Array([0x00, 0x00, 0x18, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00]);
167
+ const file = new Uint8Array(8 + body.length);
168
+ file.set([0x46, 0x57, 0x53, 9], 0);
169
+ new DataView(file.buffer).setUint32(4, file.length, true);
170
+ file.set(body, 8);
171
+ return file;
172
+ }
@@ -0,0 +1,114 @@
1
+ import { mergeDomRenderOptions } from '@flighthq/scene2d-dom/contract';
2
+ import { mergeSwfParseOptions } from '@flighthq/swf/contract';
3
+ import type {
4
+ Awd2ParseOptions,
5
+ CanvasRenderStateOptions,
6
+ DomRenderOptions,
7
+ GlRenderStateOptions,
8
+ Kind,
9
+ NodeRenderer,
10
+ SwfParseOptions,
11
+ SwfTagHandler,
12
+ WgpuRenderStateOptions,
13
+ } from '@flighthq/types/contract';
14
+
15
+ // THE POINT OF THIS FILE. The generated module is plain JS, so nothing in the emitted text proves a
16
+ // fragment is spreadable into the API it names. These fixtures are TYPED: each declares a fragment in
17
+ // exactly the shape the emitter produces and checks it against the real options type.
18
+ //
19
+ // The check is `Spreads<Fragment, Target>`, not a bare spread. Spreading a VARIABLE into a typed
20
+ // binding does NOT trigger TypeScript's excess-property check — only a direct object literal does — so
21
+ // `const o: CanvasRenderOptions = { ...fragment }` compiles happily even when the fragment carries a
22
+ // field the target never declared. I verified that hole before writing this, and the key-subset
23
+ // constraint below is what actually closes it: a fragment naming an unknown field resolves to `never`
24
+ // and fails to compile.
25
+ type Spreads<Fragment, Target> = keyof Fragment extends keyof Target ? true : never;
26
+ describe('typed consumer fixtures', () => {
27
+ it('spreads glOptions into GlRenderStateOptions', () => {
28
+ const glOptions = {
29
+ nodeRenderers: new Map<Kind, NodeRenderer>([['ShowFrame', RENDERER]]),
30
+ blendRealizations: new Map(),
31
+ };
32
+ const fits: Spreads<typeof glOptions, GlRenderStateOptions> = true;
33
+ const options: GlRenderStateOptions = { ...glOptions, pixelRatio: 2 };
34
+ expect(fits).toBe(true);
35
+ expect(options.nodeRenderers!.get('ShowFrame')).toBe(RENDERER);
36
+ expect(options.blendRealizations).toBeDefined();
37
+ });
38
+
39
+ it('spreads wgpuOptions into WgpuRenderStateOptions', () => {
40
+ const wgpuOptions = { nodeRenderers: new Map<Kind, NodeRenderer>([['ShowFrame', RENDERER]]) };
41
+ const fits: Spreads<typeof wgpuOptions, WgpuRenderStateOptions> = true;
42
+ const options: WgpuRenderStateOptions = { ...wgpuOptions };
43
+ expect(fits).toBe(true);
44
+ expect(options.nodeRenderers!.get('ShowFrame')).toBe(RENDERER);
45
+ });
46
+
47
+ it('spreads parserOptions into SwfParseOptions, and composes across documents', () => {
48
+ const first: Partial<SwfParseOptions> = { tags: [TAG_HANDLER] };
49
+ const second: Partial<SwfParseOptions> = { tags: [TAG_HANDLER] };
50
+ const options: SwfParseOptions = mergeSwfParseOptions(first, second);
51
+ expect(options.tags).toHaveLength(2);
52
+ });
53
+
54
+ it('spreads parserOptions into Awd2ParseOptions', () => {
55
+ const parserOptions = { blocks: [] as Awd2ParseOptions['blocks'][number][] };
56
+ const fits: Spreads<typeof parserOptions, Awd2ParseOptions> = true;
57
+ const options: Awd2ParseOptions = { ...parserOptions };
58
+ expect(fits).toBe(true);
59
+ expect(options.blocks).toEqual([]);
60
+ });
61
+
62
+ // Canvas takes ONE options object now, like every other backend: registries and texture resolvers
63
+ // live inside CanvasRenderStateOptions, so the fragment spreads straight into
64
+ // createCanvasRenderState(options) with no separate registries argument.
65
+ it('spreads canvasOptions directly into CanvasRenderStateOptions', () => {
66
+ const canvasOptions = {
67
+ nodeRenderers: new Map<Kind, NodeRenderer>([['ShowFrame', RENDERER]]),
68
+ effects: new Map(),
69
+ };
70
+ const fits: Spreads<typeof canvasOptions, CanvasRenderStateOptions> = true;
71
+ const options: CanvasRenderStateOptions = { ...canvasOptions, pixelRatio: 2 };
72
+ expect(fits).toBe(true);
73
+ expect(options.nodeRenderers!.get('ShowFrame')).toBe(RENDERER);
74
+ expect(options.pixelRatio).toBe(2);
75
+ });
76
+
77
+ it('rejects a canvas fragment naming a field CanvasRenderStateOptions does not have', () => {
78
+ const rejected: Spreads<{ blendRealizations: Map<Kind, unknown> }, CanvasRenderStateOptions> extends never
79
+ ? true
80
+ : false = true;
81
+ expect(rejected).toBe(true);
82
+ });
83
+
84
+ // D3 — DomRenderOptions now declares registry fields, so a DOM fragment spreads into the options
85
+ // parameter and the constructor seeds the runtime registries from it.
86
+ it('spreads domOptions into DomRenderOptions', () => {
87
+ const domOptions = {
88
+ nodeRenderers: new Map<Kind, NodeRenderer>([['ShowFrame', RENDERER]]),
89
+ textureResolvers: new Map(),
90
+ };
91
+ const fits: Spreads<typeof domOptions, DomRenderOptions> = true;
92
+ const options: Partial<DomRenderOptions> = { ...domOptions, roundPixels: true };
93
+ expect(fits).toBe(true);
94
+ expect(options.nodeRenderers!.get('ShowFrame')).toBe(RENDERER);
95
+ expect(options.roundPixels).toBe(true);
96
+ });
97
+
98
+ it('rejects a DOM fragment naming a field DomRenderOptions does not have', () => {
99
+ const rejected: Spreads<{ materialRenderers: Map<Kind, unknown> }, DomRenderOptions> extends never ? true : false =
100
+ true;
101
+ expect(rejected).toBe(true);
102
+ });
103
+
104
+ it('composes DOM fragments with named semantics, not a generic merge', () => {
105
+ const merged = mergeDomRenderOptions(
106
+ { nodeRenderers: new Map<Kind, NodeRenderer>([['ShowFrame', RENDERER]]) },
107
+ { nodeRenderers: new Map<Kind, NodeRenderer>([['DefineShape', RENDERER]]) },
108
+ );
109
+ expect([...merged.nodeRenderers!.keys()]).toEqual(['ShowFrame', 'DefineShape']);
110
+ });
111
+ });
112
+
113
+ const RENDERER = { createData: () => null, submit: () => {} } as NodeRenderer;
114
+ const TAG_HANDLER = { code: 1, read: () => {} } as unknown as SwfTagHandler;