@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,76 @@
1
+ import { RequirementFacet } from '@flighthq/types/contract';
2
+ /**
3
+ * The render-state options field that collects each facet's implementations.
4
+ *
5
+ * A catalog row names a facet, a kind and an implementation, but not the options field that holds it —
6
+ * deliberately, because a row is a fact about ownership and should not encode one consumer's option
7
+ * shape. This table is where the plugin decides.
8
+ *
9
+ * `document.format` maps to `nodeRenderers` for a render backend: a document's content kinds are the
10
+ * nodes the importer produces, so that is the field a renderer needs them in.
11
+ */
12
+ export const REQUIREMENT_OPTION_FIELDS = Object.freeze({
13
+ [RequirementFacet.DocumentFormat]: 'nodeRenderers',
14
+ [RequirementFacet.SceneBlendMode]: 'blendRealizations',
15
+ [RequirementFacet.SceneMaterialKind]: 'materialRenderers',
16
+ [RequirementFacet.SceneModifierKind]: 'modifierSnippets',
17
+ [RequirementFacet.SceneNodeKind]: 'nodeRenderers',
18
+ [RequirementFacet.SceneShapeCommand]: 'canvasShapeCommands',
19
+ [RequirementFacet.SceneTextureSourceKind]: 'textureResolvers',
20
+ });
21
+ /**
22
+ * The kind-keyed fields each backend's options type ACTUALLY declares, taken from the types package.
23
+ *
24
+ * Backend sets are not interchangeable and the differences are real: `GlRenderStateOptions` has
25
+ * `blendRealizations`, `pbrExtensions` and `customEffectShaders`; `WgpuRenderStateOptions` has NONE of
26
+ * those three. Emitting one into the other would produce a fragment that typechecks nowhere and
27
+ * silently does nothing when spread, so routing is per backend and a row naming a field its backend
28
+ * does not accept is REPORTED rather than emitted.
29
+ *
30
+ * All four backends now take ONE options object, and every set below is derived from the options type
31
+ * that backend actually declares rather than from a guess about which fields it ought to have.
32
+ * `CanvasRenderStateOptions` carries the registries and texture resolvers directly, so a canvas
33
+ * fragment spreads into `createCanvasRenderState(options)` like every other backend's. DOM's registry
34
+ * fields are seeded into the runtime at construction. Neither canvas nor DOM declares
35
+ * `blendRealizations`, and DOM declares no material or effect tables, so a row naming one is reported
36
+ * rather than emitted into a field that does not exist.
37
+ */
38
+ export const BACKEND_OPTION_FIELDS = Object.freeze({
39
+ // Derived from the landed CanvasRenderStateOptions: it extends RenderStateOptions and
40
+ // CanvasRenderOptions and adds effects and materialRenderers, so these are its kind-keyed fields.
41
+ canvas: new Set(['canvasShapeCommands', 'effectPaddingResolvers', 'effects', 'materialRenderers', 'nodeRenderers']),
42
+ // D3: DomRenderOptions declares registry fields, seeded into the runtime at construction.
43
+ dom: new Set(['canvasShapeCommands', 'effectPaddingResolvers', 'nodeRenderers', 'textureResolvers']),
44
+ gl: new Set([
45
+ 'blendRealizations',
46
+ 'canvasShapeCommands',
47
+ 'customEffectShaders',
48
+ 'customMaterialShaders',
49
+ 'effectPaddingResolvers',
50
+ 'effects',
51
+ 'materialRenderers',
52
+ 'modifierSnippets',
53
+ 'nodeRenderers',
54
+ 'pbrExtensions',
55
+ 'textureResolvers',
56
+ 'velocityWriters',
57
+ ]),
58
+ wgpu: new Set([
59
+ 'canvasShapeCommands',
60
+ 'customMaterialShaders',
61
+ 'effectPaddingResolvers',
62
+ 'effects',
63
+ 'materialRenderers',
64
+ 'modifierSnippets',
65
+ 'nodeRenderers',
66
+ 'textureResolvers',
67
+ 'velocityWriters',
68
+ ]),
69
+ });
70
+ /** The parser options field each content format spreads its ordered handler list into. */
71
+ export const PARSER_HANDLER_FIELDS = Object.freeze({
72
+ '.awd': 'blocks',
73
+ '.awd2': 'blocks',
74
+ '.swf': 'tags',
75
+ });
76
+ //# sourceMappingURL=requirementOptionFields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"requirementOptionFields.js","sourceRoot":"","sources":["../src/requirementOptionFields.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAE5D;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAqC,MAAM,CAAC,MAAM,CAAC;IACvF,CAAC,gBAAgB,CAAC,cAAc,CAAC,EAAE,eAAe;IAClD,CAAC,gBAAgB,CAAC,cAAc,CAAC,EAAE,mBAAmB;IACtD,CAAC,gBAAgB,CAAC,iBAAiB,CAAC,EAAE,mBAAmB;IACzD,CAAC,gBAAgB,CAAC,iBAAiB,CAAC,EAAE,kBAAkB;IACxD,CAAC,gBAAgB,CAAC,aAAa,CAAC,EAAE,eAAe;IACjD,CAAC,gBAAgB,CAAC,iBAAiB,CAAC,EAAE,qBAAqB;IAC3D,CAAC,gBAAgB,CAAC,sBAAsB,CAAC,EAAE,kBAAkB;CAC9D,CAAC,CAAC;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAkD,MAAM,CAAC,MAAM,CAAC;IAChG,sFAAsF;IACtF,kGAAkG;IAClG,MAAM,EAAE,IAAI,GAAG,CAAC,CAAC,qBAAqB,EAAE,wBAAwB,EAAE,SAAS,EAAE,mBAAmB,EAAE,eAAe,CAAC,CAAC;IACnH,0FAA0F;IAC1F,GAAG,EAAE,IAAI,GAAG,CAAC,CAAC,qBAAqB,EAAE,wBAAwB,EAAE,eAAe,EAAE,kBAAkB,CAAC,CAAC;IACpG,EAAE,EAAE,IAAI,GAAG,CAAC;QACV,mBAAmB;QACnB,qBAAqB;QACrB,qBAAqB;QACrB,uBAAuB;QACvB,wBAAwB;QACxB,SAAS;QACT,mBAAmB;QACnB,kBAAkB;QAClB,eAAe;QACf,eAAe;QACf,kBAAkB;QAClB,iBAAiB;KAClB,CAAC;IACF,IAAI,EAAE,IAAI,GAAG,CAAC;QACZ,qBAAqB;QACrB,uBAAuB;QACvB,wBAAwB;QACxB,SAAS;QACT,mBAAmB;QACnB,kBAAkB;QAClB,eAAe;QACf,kBAAkB;QAClB,iBAAiB;KAClB,CAAC;CACH,CAAC,CAAC;AAEH,0FAA0F;AAC1F,MAAM,CAAC,MAAM,qBAAqB,GAAqC,MAAM,CAAC,MAAM,CAAC;IACnF,MAAM,EAAE,QAAQ;IAChB,OAAO,EAAE,QAAQ;IACjB,MAAM,EAAE,MAAM;CACf,CAAC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@flighthq/vite-plugin-manifest",
3
+ "version": "0.5.1-edge.1685.dc0e23a",
4
+ "author": "Joshua Granick and other contributors",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/flighthq/flight.git",
9
+ "directory": "packages/vite-plugin-manifest"
10
+ },
11
+ "type": "module",
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./contract": {
20
+ "types": "./dist/contract.d.ts",
21
+ "default": "./dist/contract.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src/**/*.test.ts",
27
+ "!dist/**/*.test.js",
28
+ "!dist/**/*.test.d.ts",
29
+ "!dist/**/*.test.js.map",
30
+ "!dist/**/*.test.d.ts.map"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc -b && tsx ../../scripts/bundle-node-package.ts",
34
+ "clean": "tsc -b --clean",
35
+ "test": "vitest run --config vitest.config.ts",
36
+ "test:watch": "vitest --watch --config vitest.config.ts",
37
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
38
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
39
+ },
40
+ "dependencies": {
41
+ "@flighthq/log": "0.5.1-edge.1685.dc0e23a",
42
+ "@flighthq/requirement-codegen": "0.5.1-edge.1685.dc0e23a",
43
+ "@flighthq/scene3d-formats": "0.5.1-edge.1685.dc0e23a",
44
+ "@flighthq/swf": "0.5.1-edge.1685.dc0e23a",
45
+ "@flighthq/types": "0.5.1-edge.1685.dc0e23a"
46
+ },
47
+ "devDependencies": {
48
+ "@flighthq/compression": "*",
49
+ "@flighthq/render": "*",
50
+ "@flighthq/requirement": "*",
51
+ "@flighthq/scene2d-dom": "*",
52
+ "esbuild": "*",
53
+ "typescript": "^5.3.0",
54
+ "vite": "*"
55
+ },
56
+ "description": "Vite plugin serving generated Flight registration source from analyzed content",
57
+ "sideEffects": false,
58
+ "peerDependencies": {
59
+ "vite": "^7.0.0"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "vite": {
63
+ "optional": false
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,106 @@
1
+ import { deflateSync } from 'node:zlib';
2
+
3
+ import { sdkHostDecompressDeflate } from '@flighthq/compression/contract';
4
+ import { RequirementFacet } from '@flighthq/types/contract';
5
+
6
+ import { DEFAULT_CONTENT_ANALYZERS } from './contentAnalyzers';
7
+
8
+ // Regression for the downstream report: a CWS SWF and a deflate AWD analyzed to an EMPTY requirement
9
+ // set with no diagnostic, which is a bundle missing every handler the content needed. isReadable is
10
+ // what separates "requires nothing" from "could not be read".
11
+ describe('compressed content readability', () => {
12
+ it('reports a CWS SWF as unreadable without a deflate capability, and readable with one', () => {
13
+ const compressed = compressedSwf();
14
+ expect(DEFAULT_CONTENT_ANALYZERS['.swf'].isReadable(compressed, NO_DECOMPRESSORS)).toBe(false);
15
+ expect(
16
+ DEFAULT_CONTENT_ANALYZERS['.swf'].isReadable(compressed, { deflate: sdkHostDecompressDeflate, lzma: null }),
17
+ ).toBe(true);
18
+ });
19
+
20
+ it('still reads what a compressed SWF requires once a decompressor is supplied', () => {
21
+ const set = DEFAULT_CONTENT_ANALYZERS['.swf'].analyze(compressedSwf(), {
22
+ deflate: sdkHostDecompressDeflate,
23
+ lzma: null,
24
+ });
25
+ expect(set.requirements.map((requirement) => requirement.key)).toEqual(['DefineShape']);
26
+ });
27
+
28
+ it('reports an uncompressed file as readable, so the probe does not reject good content', () => {
29
+ expect(DEFAULT_CONTENT_ANALYZERS['.swf'].isReadable(createSwfWithDefineShape(), NO_DECOMPRESSORS)).toBe(true);
30
+ expect(DEFAULT_CONTENT_ANALYZERS['.awd'].isReadable(createAwd2WithCamera(), NO_DECOMPRESSORS)).toBe(true);
31
+ });
32
+
33
+ it('reports a truncated file as unreadable rather than as requiring nothing', () => {
34
+ expect(DEFAULT_CONTENT_ANALYZERS['.swf'].isReadable(new Uint8Array([0x46]), NO_DECOMPRESSORS)).toBe(false);
35
+ expect(DEFAULT_CONTENT_ANALYZERS['.awd'].isReadable(new Uint8Array([0x41]), NO_DECOMPRESSORS)).toBe(false);
36
+ });
37
+ });
38
+
39
+ function createSwfWithDefineShape(): Uint8Array {
40
+ const body = new Uint8Array([0x00, 0x00, 0x18, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00]);
41
+ const file = new Uint8Array(8 + body.length);
42
+ file.set([0x46, 0x57, 0x53, 9], 0);
43
+ new DataView(file.buffer).setUint32(4, file.length, true);
44
+ file.set(body, 8);
45
+ return file;
46
+ }
47
+
48
+ function createAwd2WithCamera(): Uint8Array {
49
+ const file = new Uint8Array(12 + 11);
50
+ file.set([0x41, 0x57, 0x44, 2, 1, 0, 0, 0], 0);
51
+ const view = new DataView(file.buffer);
52
+ view.setUint32(8, 11, true);
53
+ view.setUint32(12, 1, true);
54
+ file[16] = 0;
55
+ file[17] = 42;
56
+ file[18] = 0;
57
+ view.setUint32(19, 0, true);
58
+ return file;
59
+ }
60
+
61
+ const NO_DECOMPRESSORS = { deflate: null, lzma: null };
62
+
63
+ describe('DEFAULT_CONTENT_ANALYZERS', () => {
64
+ it('covers the formats Flight analyzes, keyed by lowercase extension', () => {
65
+ expect(Object.keys(DEFAULT_CONTENT_ANALYZERS).sort()).toEqual(['.awd', '.awd2', '.swf']);
66
+ });
67
+
68
+ it('is frozen, so one project cannot mutate the table another build reads', () => {
69
+ expect(Object.isFrozen(DEFAULT_CONTENT_ANALYZERS)).toBe(true);
70
+ });
71
+
72
+ it('delegates to the SWF analyzer rather than reimplementing the walk', () => {
73
+ const set = DEFAULT_CONTENT_ANALYZERS['.swf'].analyze(createSwfWithDefineShape(), NO_DECOMPRESSORS);
74
+ expect(set.covers).toEqual([RequirementFacet.DocumentFormat]);
75
+ expect(set.requirements).toEqual([{ facet: RequirementFacet.DocumentFormat, key: 'DefineShape' }]);
76
+ });
77
+
78
+ it('accepts either AWD suffix, resolving both to the same analyzer', () => {
79
+ const fromAwd = DEFAULT_CONTENT_ANALYZERS['.awd'].analyze(createAwd2WithCamera(), NO_DECOMPRESSORS);
80
+ const fromAwd2 = DEFAULT_CONTENT_ANALYZERS['.awd2'].analyze(createAwd2WithCamera(), NO_DECOMPRESSORS);
81
+ expect(fromAwd2.requirements).toEqual(fromAwd.requirements);
82
+ });
83
+
84
+ it('delegates to the AWD2 analyzer rather than reimplementing the walk', () => {
85
+ const set = DEFAULT_CONTENT_ANALYZERS['.awd'].analyze(createAwd2WithCamera(), NO_DECOMPRESSORS);
86
+ expect(set.covers).toEqual([RequirementFacet.DocumentFormat]);
87
+ expect(set.requirements).toEqual([{ facet: RequirementFacet.DocumentFormat, key: 'Camera' }]);
88
+ });
89
+
90
+ it('reports an empty but covered set for content it cannot read', () => {
91
+ const set = DEFAULT_CONTENT_ANALYZERS['.swf'].analyze(new Uint8Array(), NO_DECOMPRESSORS);
92
+ expect(set.requirements).toEqual([]);
93
+ expect(set.covers).toEqual([RequirementFacet.DocumentFormat]);
94
+ });
95
+ });
96
+
97
+ // A real zlib-compressed SWF: 'CWS', version, total uncompressed length, then the deflated body.
98
+ function compressedSwf(): Uint8Array {
99
+ const uncompressed = createSwfWithDefineShape();
100
+ const body = new Uint8Array(deflateSync(Buffer.from(uncompressed.subarray(8))));
101
+ const file = new Uint8Array(8 + body.length);
102
+ file.set(uncompressed.subarray(0, 8), 0);
103
+ file[0] = 0x43; // 'C' — zlib-compressed container
104
+ file.set(body, 8);
105
+ return file;
106
+ }
@@ -0,0 +1,33 @@
1
+ import { mergeRenderOptions } from '@flighthq/render/contract';
2
+ import { mergeSwfParseOptions } from '@flighthq/swf/contract';
3
+ import type { RenderStateOptions, SwfParseOptions } from '@flighthq/types/contract';
4
+
5
+ // A build that imports two documents gets two fragments per backend, and composes them with the merge
6
+ // function the OWNING package exports — not with a generic merge living here. These tests pin that
7
+ // composition end to end: the fragments are the shape the plugin emits, and the result is what an
8
+ // application spreads into createRenderState / parseSwf.
9
+ describe('multi-file composition', () => {
10
+ it('unions node renderers from two documents instead of letting the last file win', () => {
11
+ const firstDocument: RenderStateOptions = { nodeRenderers: new Map([['ShowFrame', 'showFrameImpl' as never]]) };
12
+ const secondDocument: RenderStateOptions = { nodeRenderers: new Map([['DefineShape', 'shapeImpl' as never]]) };
13
+ const composed = mergeRenderOptions(firstDocument, secondDocument);
14
+ expect([...composed.nodeRenderers!.keys()]).toEqual(['ShowFrame', 'DefineShape']);
15
+ });
16
+
17
+ it('concatenates parser handlers across documents, preserving import order', () => {
18
+ const firstDocument: SwfParseOptions = { tags: ['showFrameHandler' as never] };
19
+ const secondDocument: SwfParseOptions = { tags: ['shapeHandler' as never] };
20
+ expect(mergeSwfParseOptions(firstDocument, secondDocument).tags).toEqual(['showFrameHandler', 'shapeHandler']);
21
+ });
22
+
23
+ it('keeps a document whose fragment is empty from erasing another document’s contribution', () => {
24
+ const populated: RenderStateOptions = { nodeRenderers: new Map([['ShowFrame', 'impl' as never]]) };
25
+ expect([...mergeRenderOptions(populated, {}).nodeRenderers!.keys()]).toEqual(['ShowFrame']);
26
+ expect([...mergeRenderOptions({}, populated).nodeRenderers!.keys()]).toEqual(['ShowFrame']);
27
+ });
28
+
29
+ it('composes three documents in one call, in argument order', () => {
30
+ const composed = mergeSwfParseOptions({ tags: ['a' as never] }, { tags: ['b' as never] }, { tags: ['c' as never] });
31
+ expect(composed.tags).toEqual(['a', 'b', 'c']);
32
+ });
33
+ });
@@ -0,0 +1,179 @@
1
+ import {
2
+ generateManifestModuleSource,
3
+ MANIFEST_BACKEND_EXPORTS,
4
+ MANIFEST_PARSER_BACKEND,
5
+ } from './manifestModuleSource';
6
+
7
+ describe('generateManifestModuleSource', () => {
8
+ it('exports every backend fragment plus parserOptions, even when a backend needs nothing', () => {
9
+ const source = generateManifestModuleSource([row('gl', 'scene.node-kind', 'Shape')], '.swf').source;
10
+ for (const name of ['canvasOptions', 'domOptions', 'glOptions', 'wgpuOptions', 'parserOptions']) {
11
+ expect(source).toContain(`export const ${name} =`);
12
+ }
13
+ expect(source).toContain('export const canvasOptions = {};');
14
+ });
15
+
16
+ it('spreads a scene kind into the matching options field as a Map', () => {
17
+ const source = generateManifestModuleSource([row('gl', 'scene.node-kind', 'Shape')], '.swf').source;
18
+ expect(source).toContain('export const glOptions = {');
19
+ expect(source).toContain('nodeRenderers: new Map([');
20
+ expect(source).toContain("['Shape', glShapeImpl],");
21
+ });
22
+
23
+ it('writes a precise named import for every referenced symbol', () => {
24
+ const source = generateManifestModuleSource(
25
+ [row('gl', 'scene.node-kind', 'Shape'), row('wgpu', 'scene.material-kind', 'Standard')],
26
+ '.swf',
27
+ ).source;
28
+ const imports = source.split('\n').filter((line) => line.startsWith('import '));
29
+ expect(imports).toEqual([
30
+ "import { glShapeImpl } from '@acme/gl';",
31
+ "import { wgpuStandardImpl } from '@acme/wgpu';",
32
+ ]);
33
+ expect(source).not.toContain('import *');
34
+ });
35
+
36
+ // CanvasRenderOptions and DomRenderOptions declare NO kind-keyed fields on base: canvas takes its
37
+ // registries as a separate constructor parameter and DOM accepts none. So a row aimed at either is
38
+ // reported rather than emitted into a field that does not exist. Pending an API ruling, this pins
39
+ // what the plugin does today instead of pretending the fragment would spread.
40
+ it.each(['canvas', 'dom', 'gl', 'wgpu'])('emits a nodeRenderers fragment for %s', (backend) => {
41
+ const result = generateManifestModuleSource([row(backend, 'scene.node-kind', 'Shape')], '.swf');
42
+ expect(result.source).toContain(`export const ${backend}Options = {\n nodeRenderers: new Map([`);
43
+ expect(result.problems).toEqual([]);
44
+ });
45
+
46
+ // The backend sets are NOT interchangeable, and these are the real differences on base.
47
+ it('reports materialRenderers aimed at dom, which DomRenderOptions does not declare', () => {
48
+ const result = generateManifestModuleSource([row('dom', 'scene.material-kind', 'Standard')], '.swf');
49
+ expect(result.source).toContain('export const domOptions = {};');
50
+ expect(result.problems).toEqual([
51
+ 'backend dom has no materialRenderers field: dropped scene.material-kind Standard',
52
+ ]);
53
+ });
54
+
55
+ it('reports blendRealizations aimed at canvas, which CanvasRenderStateOptions does not declare', () => {
56
+ const result = generateManifestModuleSource([row('canvas', 'scene.blend-mode', 'Multiply')], '.swf');
57
+ expect(result.problems).toEqual([
58
+ 'backend canvas has no blendRealizations field: dropped scene.blend-mode Multiply',
59
+ ]);
60
+ });
61
+
62
+ it('keeps each backend in its own fragment', () => {
63
+ const source = generateManifestModuleSource(
64
+ [row('gl', 'scene.node-kind', 'Shape'), row('wgpu', 'scene.node-kind', 'Shape')],
65
+ '.swf',
66
+ ).source;
67
+ expect(source).toContain("export const glOptions = {\n nodeRenderers: new Map([\n ['Shape', glShapeImpl],");
68
+ expect(source).toContain("export const wgpuOptions = {\n nodeRenderers: new Map([\n ['Shape', wgpuShapeImpl],");
69
+ });
70
+
71
+ it('routes parser-backend rows to parserOptions under the format\u2019s own field', () => {
72
+ expect(
73
+ generateManifestModuleSource([row(MANIFEST_PARSER_BACKEND, 'document.format', 'DefineShape')], '.swf').source,
74
+ ).toContain('export const parserOptions = {\n tags: [');
75
+ expect(
76
+ generateManifestModuleSource([row(MANIFEST_PARSER_BACKEND, 'document.format', 'Camera')], '.awd2').source,
77
+ ).toContain('export const parserOptions = {\n blocks: [');
78
+ });
79
+
80
+ it('sends the same facet to a render fragment or to the parser by the row\u2019s backend', () => {
81
+ const source = generateManifestModuleSource(
82
+ [row(MANIFEST_PARSER_BACKEND, 'document.format', 'DefineShape'), row('gl', 'document.format', 'DefineShape')],
83
+ '.swf',
84
+ ).source;
85
+ expect(source).toContain('export const parserOptions = {\n tags: [\n parserDefineShapeImpl,');
86
+ expect(source).toContain(
87
+ "export const glOptions = {\n nodeRenderers: new Map([\n ['DefineShape', glDefineShapeImpl],",
88
+ );
89
+ });
90
+
91
+ it('preserves catalog order for handlers, because a handler list is ordered', () => {
92
+ const source = generateManifestModuleSource(
93
+ [
94
+ row(MANIFEST_PARSER_BACKEND, 'document.format', 'Second'),
95
+ row(MANIFEST_PARSER_BACKEND, 'document.format', 'First'),
96
+ ],
97
+ '.swf',
98
+ ).source;
99
+ // Measured inside the fragment: the import lines above it are sorted, so a whole-file indexOf
100
+ // would be testing the import order instead of the handler order.
101
+ const fragment = source.slice(source.indexOf('export const parserOptions'));
102
+ expect(fragment.indexOf('parserSecondImpl')).toBeLessThan(fragment.indexOf('parserFirstImpl'));
103
+ });
104
+
105
+ it('sorts map entries by kind so the same rows always emit the same bytes', () => {
106
+ const a = row('gl', 'scene.node-kind', 'Alpha');
107
+ const b = row('gl', 'scene.node-kind', 'Beta');
108
+ expect(generateManifestModuleSource([a, b], '.swf').source).toBe(
109
+ generateManifestModuleSource([b, a], '.swf').source,
110
+ );
111
+ });
112
+
113
+ it('REPORTS an unmapped facet rather than skipping it silently', () => {
114
+ const result = generateManifestModuleSource([row('gl', 'compression.kind', 'Deflate')], '.swf');
115
+ expect(result.source).toContain('export const glOptions = {};');
116
+ expect(result.source).not.toContain('glDeflateImpl');
117
+ expect(result.problems).toEqual([
118
+ 'facet compression.kind has no render-state options field: dropped Deflate for gl',
119
+ ]);
120
+ });
121
+
122
+ it.each(['compression.kind', 'physics2d.joint-kind', 'scene.resource-mime-type'])(
123
+ 'reports %s, the facets with no render-state field',
124
+ (facet) => {
125
+ const result = generateManifestModuleSource([row('gl', facet, 'Thing')], '.swf');
126
+ expect(result.problems).toHaveLength(1);
127
+ expect(result.problems[0]).toContain(facet);
128
+ },
129
+ );
130
+
131
+ it('does not leak a GL-only field into wgpu, and says so', () => {
132
+ const result = generateManifestModuleSource(
133
+ [row('gl', 'scene.blend-mode', 'Multiply'), row('wgpu', 'scene.blend-mode', 'Multiply')],
134
+ '.swf',
135
+ );
136
+ expect(result.source).toContain('export const glOptions = {\n blendRealizations: new Map([');
137
+ expect(result.source).toContain('export const wgpuOptions = {};');
138
+ expect(result.problems).toEqual(['backend wgpu has no blendRealizations field: dropped scene.blend-mode Multiply']);
139
+ expect(result.source).not.toContain('wgpuMultiplyImpl');
140
+ });
141
+
142
+ it('reports a row naming a backend it does not know', () => {
143
+ const result = generateManifestModuleSource([row('metal', 'scene.node-kind', 'Shape')], '.swf');
144
+ expect(result.problems).toEqual(['unknown backend metal: dropped scene.node-kind Shape']);
145
+ });
146
+
147
+ it('emits a valid module with no imports when nothing resolved', () => {
148
+ const source = generateManifestModuleSource([], '.swf').source;
149
+ expect(source).not.toContain('import ');
150
+ expect(source).toContain('export const parserOptions = {};');
151
+ });
152
+ });
153
+
154
+ describe('MANIFEST_BACKEND_EXPORTS', () => {
155
+ it('names one flat export per backend', () => {
156
+ expect(MANIFEST_BACKEND_EXPORTS).toEqual({
157
+ canvas: 'canvasOptions',
158
+ dom: 'domOptions',
159
+ gl: 'glOptions',
160
+ wgpu: 'wgpuOptions',
161
+ });
162
+ });
163
+ });
164
+
165
+ function row(backend: string, facet: string, kind: string) {
166
+ const symbol = `${backend}${kind}Impl`;
167
+ return {
168
+ entry: {
169
+ backend,
170
+ facet: facet as never,
171
+ implementationImport: `@acme/${backend}`,
172
+ implementationSymbol: symbol,
173
+ kind,
174
+ registrarImport: `@acme/${backend}`,
175
+ registrarSymbol: `register${kind}`,
176
+ },
177
+ kind,
178
+ };
179
+ }