@flighthq/vite-plugin-manifest 0.5.1-next.1680.bdc44c6
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/LICENSE.md +9 -0
- package/README.md +17 -0
- package/dist/contentAnalyzers.d.ts +18 -0
- package/dist/contentAnalyzers.d.ts.map +1 -0
- package/dist/contentAnalyzers.js +18 -0
- package/dist/contentAnalyzers.js.map +1 -0
- package/dist/contract.d.ts +5 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/manifestModuleSource.d.ts +41 -0
- package/dist/manifestModuleSource.d.ts.map +1 -0
- package/dist/manifestModuleSource.js +125 -0
- package/dist/manifestModuleSource.js.map +1 -0
- package/dist/manifestPlugin.d.ts +45 -0
- package/dist/manifestPlugin.d.ts.map +1 -0
- package/dist/manifestPlugin.js +102 -0
- package/dist/manifestPlugin.js.map +1 -0
- package/dist/requirementOptionFields.d.ts +32 -0
- package/dist/requirementOptionFields.d.ts.map +1 -0
- package/dist/requirementOptionFields.js +76 -0
- package/dist/requirementOptionFields.js.map +1 -0
- package/package.json +64 -0
- package/src/contentAnalyzers.test.ts +59 -0
- package/src/manifestComposition.test.ts +33 -0
- package/src/manifestModuleSource.test.ts +179 -0
- package/src/manifestPlugin.test.ts +166 -0
- package/src/manifestTreeShaking.test.ts +172 -0
- package/src/manifestTypedConsumer.test.ts +114 -0
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flighthq/vite-plugin-manifest",
|
|
3
|
+
"version": "0.5.1-next.1680.bdc44c6",
|
|
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",
|
|
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-next.1680.bdc44c6",
|
|
42
|
+
"@flighthq/requirement-codegen": "0.5.1-next.1680.bdc44c6",
|
|
43
|
+
"@flighthq/scene3d-formats": "0.5.1-next.1680.bdc44c6",
|
|
44
|
+
"@flighthq/swf": "0.5.1-next.1680.bdc44c6",
|
|
45
|
+
"@flighthq/types": "0.5.1-next.1680.bdc44c6"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@flighthq/render": "*",
|
|
49
|
+
"@flighthq/requirement": "*",
|
|
50
|
+
"@flighthq/scene2d-dom": "*",
|
|
51
|
+
"typescript": "^5.3.0",
|
|
52
|
+
"vite": "*"
|
|
53
|
+
},
|
|
54
|
+
"description": "Vite plugin serving generated Flight registration source from analyzed content",
|
|
55
|
+
"sideEffects": false,
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"vite": "^7.0.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"vite": {
|
|
61
|
+
"optional": false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { RequirementFacet } from '@flighthq/types/contract';
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_CONTENT_ANALYZERS } from './contentAnalyzers';
|
|
4
|
+
|
|
5
|
+
describe('DEFAULT_CONTENT_ANALYZERS', () => {
|
|
6
|
+
it('covers the formats Flight analyzes, keyed by lowercase extension', () => {
|
|
7
|
+
expect(Object.keys(DEFAULT_CONTENT_ANALYZERS).sort()).toEqual(['.awd', '.awd2', '.swf']);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('is frozen, so one project cannot mutate the table another build reads', () => {
|
|
11
|
+
expect(Object.isFrozen(DEFAULT_CONTENT_ANALYZERS)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('delegates to the SWF analyzer rather than reimplementing the walk', () => {
|
|
15
|
+
const set = DEFAULT_CONTENT_ANALYZERS['.swf'](createSwfWithShowFrame());
|
|
16
|
+
expect(set.covers).toEqual([RequirementFacet.DocumentFormat]);
|
|
17
|
+
expect(set.requirements).toEqual([{ facet: RequirementFacet.DocumentFormat, key: 'ShowFrame' }]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('accepts either AWD suffix, resolving both to the same analyzer', () => {
|
|
21
|
+
const fromAwd = DEFAULT_CONTENT_ANALYZERS['.awd'](createAwd2WithCamera());
|
|
22
|
+
const fromAwd2 = DEFAULT_CONTENT_ANALYZERS['.awd2'](createAwd2WithCamera());
|
|
23
|
+
expect(fromAwd2.requirements).toEqual(fromAwd.requirements);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('delegates to the AWD2 analyzer rather than reimplementing the walk', () => {
|
|
27
|
+
const set = DEFAULT_CONTENT_ANALYZERS['.awd'](createAwd2WithCamera());
|
|
28
|
+
expect(set.covers).toEqual([RequirementFacet.DocumentFormat]);
|
|
29
|
+
expect(set.requirements).toEqual([{ facet: RequirementFacet.DocumentFormat, key: 'Camera' }]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reports an empty but covered set for content it cannot read', () => {
|
|
33
|
+
const set = DEFAULT_CONTENT_ANALYZERS['.swf'](new Uint8Array());
|
|
34
|
+
expect(set.requirements).toEqual([]);
|
|
35
|
+
expect(set.covers).toEqual([RequirementFacet.DocumentFormat]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function createSwfWithShowFrame(): Uint8Array {
|
|
40
|
+
const body = new Uint8Array([0x00, 0x00, 0x18, 0x01, 0x00, 0x40, 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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { createRequirementSet } from '@flighthq/requirement/contract';
|
|
6
|
+
import { RequirementFacet } from '@flighthq/types/contract';
|
|
7
|
+
|
|
8
|
+
import { MANIFEST_PARSER_BACKEND } from './manifestModuleSource';
|
|
9
|
+
import { createManifestPlugin, MANIFEST_QUERY_SUFFIX } from './manifestPlugin';
|
|
10
|
+
|
|
11
|
+
describe('createManifestPlugin', () => {
|
|
12
|
+
it('claims only ?manifest imports, resolving them against the importer directory', async () => {
|
|
13
|
+
const plugin = createManifestPlugin({ catalog: { entries: [] } });
|
|
14
|
+
expect(plugin.resolveId('./asset.swf')).toBeNull();
|
|
15
|
+
expect(plugin.resolveId('./styles.css')).toBeNull();
|
|
16
|
+
const resolved = plugin.resolveId(`./asset.swf${MANIFEST_QUERY_SUFFIX}`, '/project/src/main.ts')!;
|
|
17
|
+
expect(resolved.startsWith('\0flight-manifest:')).toBe(true);
|
|
18
|
+
expect(resolved.endsWith('/project/src/asset.swf')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('loads nothing for an id it did not resolve', async () => {
|
|
22
|
+
const plugin = createManifestPlugin({ catalog: { entries: [] } });
|
|
23
|
+
expect(await plugin.load('/project/src/main.ts')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('serves fragments for the one file imported, resolved across every backend', async () => {
|
|
27
|
+
const { dir, plugin } = await fixture();
|
|
28
|
+
const source = (await load(plugin, dir, 'a.swf'))!;
|
|
29
|
+
expect(source).toContain("import { glShowFrame } from '@acme/gl';");
|
|
30
|
+
expect(source).toContain("import { wgpuShowFrame } from '@acme/wgpu';");
|
|
31
|
+
expect(source).toContain("export const glOptions = {\n nodeRenderers: new Map([\n ['ShowFrame', glShowFrame],");
|
|
32
|
+
expect(source).toContain(
|
|
33
|
+
"export const wgpuOptions = {\n nodeRenderers: new Map([\n ['ShowFrame', wgpuShowFrame],",
|
|
34
|
+
);
|
|
35
|
+
// canvas and dom carry no kind-keyed options fields on base, so their fragments are empty and
|
|
36
|
+
// still exported — an application can spread every fragment unconditionally.
|
|
37
|
+
expect(source).toContain('export const canvasOptions = {};');
|
|
38
|
+
expect(source).toContain('export const domOptions = {};');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reads the exact file imported, so two documents give different modules', async () => {
|
|
42
|
+
const { dir, plugin } = await fixture();
|
|
43
|
+
await writeFile(join(dir, 'b.swf'), Buffer.from(createSwf(TAG_SET_BACKGROUND_COLOR)));
|
|
44
|
+
const a = await load(plugin, dir, 'a.swf');
|
|
45
|
+
const b = await load(plugin, dir, 'b.swf');
|
|
46
|
+
expect(a).not.toBe(b);
|
|
47
|
+
expect(a).toContain("['ShowFrame', wgpuShowFrame]");
|
|
48
|
+
expect(b).toContain('export const wgpuOptions = {};');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('invalidates per imported source, leaving other files cached', async () => {
|
|
52
|
+
const { dir, plugin } = await fixture();
|
|
53
|
+
await writeFile(join(dir, 'b.swf'), Buffer.from(createSwf(TAG_SHOW_FRAME)));
|
|
54
|
+
const beforeA = await load(plugin, dir, 'a.swf');
|
|
55
|
+
const beforeB = await load(plugin, dir, 'b.swf');
|
|
56
|
+
|
|
57
|
+
// Rewrite a.swf to content with no ShowFrame, then invalidate ONLY a.swf.
|
|
58
|
+
await writeFile(join(dir, 'a.swf'), Buffer.from(createSwf(TAG_SET_BACKGROUND_COLOR)));
|
|
59
|
+
await writeFile(join(dir, 'b.swf'), Buffer.from(createSwf(TAG_SET_BACKGROUND_COLOR)));
|
|
60
|
+
plugin.handleHotUpdate({ file: join(dir, 'a.swf') });
|
|
61
|
+
|
|
62
|
+
expect(await load(plugin, dir, 'a.swf')).not.toBe(beforeA);
|
|
63
|
+
// b.swf changed on disk too, but nothing invalidated it, so its cached module must stand.
|
|
64
|
+
expect(await load(plugin, dir, 'b.swf')).toBe(beforeB);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('reports an unsupported format and still serves a spreadable empty module', async () => {
|
|
68
|
+
const { dir, diagnostics, plugin } = await fixture();
|
|
69
|
+
await writeFile(join(dir, 'notes.txt'), 'not content');
|
|
70
|
+
const source = (await load(plugin, dir, 'notes.txt'))!;
|
|
71
|
+
expect(diagnostics.some((m) => m.includes('unsupported content format'))).toBe(true);
|
|
72
|
+
expect(source).toContain('export const canvasOptions = {};');
|
|
73
|
+
expect(source).toContain('export const parserOptions = {};');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('reports an unreadable file rather than failing the build', async () => {
|
|
77
|
+
const { dir, diagnostics, plugin } = await fixture();
|
|
78
|
+
expect(await load(plugin, dir, 'missing.swf')).toContain('export const glOptions = {};');
|
|
79
|
+
expect(diagnostics.some((m) => m.includes('analyzer failed'))).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('reports a requirement no catalog entry satisfies', async () => {
|
|
83
|
+
const dir = await mkdtemp(join(tmpdir(), 'flight-vite-'));
|
|
84
|
+
await writeFile(join(dir, 'a.swf'), Buffer.from(createSwf(TAG_SHOW_FRAME)));
|
|
85
|
+
const diagnostics: string[] = [];
|
|
86
|
+
const plugin = createManifestPlugin({
|
|
87
|
+
catalog: { entries: [] },
|
|
88
|
+
onDiagnostic: (message) => diagnostics.push(message),
|
|
89
|
+
});
|
|
90
|
+
await load(plugin, dir, 'a.swf');
|
|
91
|
+
expect(diagnostics.some((m) => m.includes('no catalog entry for document.format ShowFrame'))).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('drives resolution through an EXTERNAL catalog, since the built-in catalog ships empty', async () => {
|
|
95
|
+
const { dir, plugin } = await fixture();
|
|
96
|
+
// BUILT_IN_REQUIREMENT_CATALOG_ENTRIES is deliberately empty on base, so every row that reaches a
|
|
97
|
+
// fragment came from the catalog the caller passed in. This pins that the external path is the
|
|
98
|
+
// real one rather than incidental.
|
|
99
|
+
expect(await load(plugin, dir, 'a.swf')).toContain("['ShowFrame', glShowFrame]");
|
|
100
|
+
|
|
101
|
+
const empty = createManifestPlugin({ catalog: { entries: [] }, onDiagnostic: () => {} });
|
|
102
|
+
const source = (await load(empty, dir, 'a.swf'))!;
|
|
103
|
+
expect(source).toContain('export const glOptions = {};');
|
|
104
|
+
expect(source).not.toContain('glShowFrame');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('reports every requirement no backend in the external catalog can satisfy', async () => {
|
|
108
|
+
const dir = await mkdtemp(join(tmpdir(), 'flight-vite-'));
|
|
109
|
+
await writeFile(join(dir, 'a.swf'), Buffer.from(createSwf(TAG_SHOW_FRAME)));
|
|
110
|
+
const diagnostics: string[] = [];
|
|
111
|
+
const plugin = createManifestPlugin({
|
|
112
|
+
catalog: { entries: [] },
|
|
113
|
+
onDiagnostic: (message) => diagnostics.push(message),
|
|
114
|
+
});
|
|
115
|
+
await load(plugin, dir, 'a.swf');
|
|
116
|
+
expect(diagnostics.some((m) => m.includes('no catalog entry for document.format ShowFrame'))).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const TAG_SHOW_FRAME = 1;
|
|
121
|
+
const TAG_SET_BACKGROUND_COLOR = 9;
|
|
122
|
+
|
|
123
|
+
function entry(backend: string, facet: string, kind: string, symbol: string) {
|
|
124
|
+
return {
|
|
125
|
+
backend,
|
|
126
|
+
facet: facet as never,
|
|
127
|
+
implementationImport: `@acme/${backend}`,
|
|
128
|
+
implementationSymbol: symbol,
|
|
129
|
+
kind,
|
|
130
|
+
registrarImport: `@acme/${backend}`,
|
|
131
|
+
registrarSymbol: `register${kind}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function fixture() {
|
|
136
|
+
const dir = await mkdtemp(join(tmpdir(), 'flight-vite-'));
|
|
137
|
+
await writeFile(join(dir, 'a.swf'), Buffer.from(createSwf(TAG_SHOW_FRAME)));
|
|
138
|
+
const diagnostics: string[] = [];
|
|
139
|
+
const plugin = createManifestPlugin({
|
|
140
|
+
catalog: {
|
|
141
|
+
entries: [
|
|
142
|
+
entry('wgpu', RequirementFacet.DocumentFormat, 'ShowFrame', 'wgpuShowFrame'),
|
|
143
|
+
entry('gl', RequirementFacet.DocumentFormat, 'ShowFrame', 'glShowFrame'),
|
|
144
|
+
entry(MANIFEST_PARSER_BACKEND, RequirementFacet.DocumentFormat, 'ShowFrame', 'parserShowFrame'),
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
onDiagnostic: (message) => diagnostics.push(message),
|
|
148
|
+
});
|
|
149
|
+
return { diagnostics, dir, plugin };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function load(plugin: ReturnType<typeof createManifestPlugin>, dir: string, name: string) {
|
|
153
|
+
const id = plugin.resolveId(`./${name}${MANIFEST_QUERY_SUFFIX}`, join(dir, 'main.ts'))!;
|
|
154
|
+
return plugin.load(id);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A minimal SWF carrying exactly one tag of the given code, built inline so the tests need no fixture.
|
|
158
|
+
function createSwf(code: number): Uint8Array {
|
|
159
|
+
const tagHeader = code << 6;
|
|
160
|
+
const body = new Uint8Array([0x00, 0x00, 0x18, 0x01, 0x00, tagHeader & 0xff, (tagHeader >> 8) & 0xff, 0x00, 0x00]);
|
|
161
|
+
const file = new Uint8Array(8 + body.length);
|
|
162
|
+
file.set([0x46, 0x57, 0x53, 9], 0);
|
|
163
|
+
new DataView(file.buffer).setUint32(4, file.length, true);
|
|
164
|
+
file.set(body, 8);
|
|
165
|
+
return file;
|
|
166
|
+
}
|
|
@@ -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('ShowFrame');
|
|
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, 'ShowFrame', 'canvasShowFrame', impl('canvas')),
|
|
134
|
+
entry('gl', RequirementFacet.DocumentFormat, 'ShowFrame', 'glShowFrame', impl('gl')),
|
|
135
|
+
entry('wgpu', RequirementFacet.DocumentFormat, 'ShowFrame', 'wgpuShowFrame', impl('wgpu')),
|
|
136
|
+
entry(MANIFEST_PARSER_BACKEND, RequirementFacet.DocumentFormat, 'ShowFrame', 'parserShowFrame', 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(createSwfWithShowFrame()));
|
|
156
|
+
for (const backend of ['canvas', 'gl', 'wgpu', 'parser']) {
|
|
157
|
+
await writeFile(
|
|
158
|
+
join(dir, 'src', `${backend}Impl.js`),
|
|
159
|
+
`export const ${backend}ShowFrame = '${backend.toUpperCase()}_IMPLEMENTATION_MARKER';\n`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return dir;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function createSwfWithShowFrame(): Uint8Array {
|
|
166
|
+
const body = new Uint8Array([0x00, 0x00, 0x18, 0x01, 0x00, 0x40, 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
|
+
}
|