@forgeax/engine-import 0.1.2
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 +202 -0
- package/README.md +226 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/decode-image-owner.test-d.d.ts +2 -0
- package/dist/__tests__/decode-image-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/import-contract-migration.test.d.ts +2 -0
- package/dist/__tests__/import-contract-migration.test.d.ts.map +1 -0
- package/dist/__tests__/import-dependencies.test.d.ts +2 -0
- package/dist/__tests__/import-dependencies.test.d.ts.map +1 -0
- package/dist/__tests__/import-finalizer-digest-retry.test.d.ts +2 -0
- package/dist/__tests__/import-finalizer-digest-retry.test.d.ts.map +1 -0
- package/dist/__tests__/import-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/import-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/import.unit.test.d.ts +2 -0
- package/dist/__tests__/import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-import-product.unit.test.d.ts +2 -0
- package/dist/__tests__/material-import-product.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-morph-v3.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-morph-v3.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack.integration.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack.integration.test.d.ts.map +1 -0
- package/dist/__tests__/source-overrides-import-context.unit.test.d.ts +2 -0
- package/dist/__tests__/source-overrides-import-context.unit.test.d.ts.map +1 -0
- package/dist/import-product.d.ts +36 -0
- package/dist/import-product.d.ts.map +1 -0
- package/dist/import-runner.d.ts +133 -0
- package/dist/import-runner.d.ts.map +1 -0
- package/dist/importer-registry.d.ts +40 -0
- package/dist/importer-registry.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1607 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mesh-bin.d.ts +19 -0
- package/dist/mesh-bin.d.ts.map +1 -0
- package/dist/scriptable-pack-output-producers.d.ts +6 -0
- package/dist/scriptable-pack-output-producers.d.ts.map +1 -0
- package/dist/scriptable-pack-staged-snapshot.d.ts +22 -0
- package/dist/scriptable-pack-staged-snapshot.d.ts.map +1 -0
- package/dist/scriptable-pack.d.ts +153 -0
- package/dist/scriptable-pack.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/__tests__/decode-image-owner.test-d.ts +53 -0
- package/src/__tests__/import-contract-migration.test.ts +190 -0
- package/src/__tests__/import-dependencies.test.ts +65 -0
- package/src/__tests__/import-finalizer-digest-retry.test.ts +113 -0
- package/src/__tests__/import-local-artifacts.test.ts +100 -0
- package/src/__tests__/import.unit.test.ts +429 -0
- package/src/__tests__/material-import-product.unit.test.ts +56 -0
- package/src/__tests__/mesh-bin-contract.test.ts +95 -0
- package/src/__tests__/mesh-bin-morph-v3.test.ts +44 -0
- package/src/__tests__/mesh-bin.test.ts +102 -0
- package/src/__tests__/scriptable-pack-output-producers.unit.test.ts +190 -0
- package/src/__tests__/scriptable-pack-production-producers.unit.test.ts +429 -0
- package/src/__tests__/scriptable-pack-staged-snapshot.unit.test.ts +258 -0
- package/src/__tests__/scriptable-pack.integration.test.ts +435 -0
- package/src/__tests__/source-overrides-import-context.unit.test.ts +92 -0
- package/src/import-product.ts +195 -0
- package/src/import-runner.ts +720 -0
- package/src/importer-registry.ts +80 -0
- package/src/index.ts +103 -0
- package/src/mesh-bin.ts +229 -0
- package/src/scriptable-pack-output-producers.ts +466 -0
- package/src/scriptable-pack-staged-snapshot.ts +161 -0
- package/src/scriptable-pack.ts +615 -0
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeax/engine-import",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"description": "Build-time asset import runner + ImporterRegistry (the build-time half of the import/load split). Dispatches a *.meta.json sidecar by its top-level `importer` string key to a registered Importer, enforces the GUID import-stable iron law, and writes the DDC (.pack.json / .bin). Build-time only — never bundled into the player runtime (AC-06). Mirrors the LoaderRegistry (runtime) + Console Registry register/get/fail-fast injection shape.",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.mjs",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@forgeax/engine-ecs": "0.1.2",
|
|
26
|
+
"@forgeax/engine-geometry": "0.1.2",
|
|
27
|
+
"@forgeax/engine-pack": "0.1.2",
|
|
28
|
+
"@forgeax/engine-scene": "0.1.2",
|
|
29
|
+
"@forgeax/engine-types": "0.1.2",
|
|
30
|
+
"@noble/hashes": "^2.2.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^20.14.0"
|
|
34
|
+
},
|
|
35
|
+
"forgeax": {
|
|
36
|
+
"metrics": {
|
|
37
|
+
"bundle-size": {
|
|
38
|
+
"enabled": false,
|
|
39
|
+
"reason": "build-time-only package (import runner + ImporterRegistry); never enters the player runtime bundle (AC-06), so no shipped-size budget applies"
|
|
40
|
+
},
|
|
41
|
+
"fps": {
|
|
42
|
+
"enabled": false,
|
|
43
|
+
"reason": "build-time package; no runtime canvas or frame loop"
|
|
44
|
+
},
|
|
45
|
+
"bench": {
|
|
46
|
+
"enabled": false,
|
|
47
|
+
"reason": "import runner is one-shot build-time work (not a hot path); no perf-critical bench in this package"
|
|
48
|
+
},
|
|
49
|
+
"gate": {
|
|
50
|
+
"enabled": false,
|
|
51
|
+
"reason": "no standalone binary gate; package-level gates declared in CI yaml directly"
|
|
52
|
+
},
|
|
53
|
+
"spike-report": {
|
|
54
|
+
"enabled": false,
|
|
55
|
+
"reason": "not a spike package"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsup",
|
|
61
|
+
"test": "vitest run"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { ImportContext, ImportRunnerFs as PublicImportRunnerFs } from '@forgeax/engine-import';
|
|
3
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
4
|
+
import type { ImportRunnerFs } from '../import-runner.js';
|
|
5
|
+
|
|
6
|
+
type DecodeImage = ImportContext['decodeImage'];
|
|
7
|
+
type RunnerDecodeImage = NonNullable<ImportRunnerFs['decodeImage']>;
|
|
8
|
+
type DecodeImageMime = Parameters<DecodeImage>[1];
|
|
9
|
+
type DecodeImageResult = Awaited<ReturnType<DecodeImage>>;
|
|
10
|
+
|
|
11
|
+
const readSource: ImportRunnerFs['readSource'] = async () => ({
|
|
12
|
+
ok: true,
|
|
13
|
+
value: new Uint8Array(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
declare const hostDecodeImage: DecodeImage;
|
|
17
|
+
|
|
18
|
+
const runnerSource = readFileSync(new URL('../import-runner.ts', import.meta.url), 'utf8');
|
|
19
|
+
|
|
20
|
+
function assertOptionalHostInjection(): void {
|
|
21
|
+
const withoutHostDecoder = {
|
|
22
|
+
readSource,
|
|
23
|
+
} satisfies PublicImportRunnerFs;
|
|
24
|
+
|
|
25
|
+
const withHostDecoder = {
|
|
26
|
+
readSource,
|
|
27
|
+
decodeImage: hostDecodeImage,
|
|
28
|
+
} satisfies PublicImportRunnerFs;
|
|
29
|
+
|
|
30
|
+
void withoutHostDecoder;
|
|
31
|
+
void withHostDecoder;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
void assertOptionalHostInjection;
|
|
35
|
+
|
|
36
|
+
describe('ImportRunnerFs decodeImage owner', () => {
|
|
37
|
+
it('derives the public callback and result envelope from ImportContext', () => {
|
|
38
|
+
expectTypeOf<RunnerDecodeImage>().toEqualTypeOf<DecodeImage>();
|
|
39
|
+
expectTypeOf<PublicImportRunnerFs>().toEqualTypeOf<ImportRunnerFs>();
|
|
40
|
+
expectTypeOf<Awaited<ReturnType<RunnerDecodeImage>>>().toEqualTypeOf<DecodeImageResult>();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('preserves the PNG/JPEG/TGA input contract and optional host injection', () => {
|
|
44
|
+
expectTypeOf<DecodeImageMime>().toEqualTypeOf<'image/png' | 'image/jpeg' | 'image/x-tga'>();
|
|
45
|
+
expectTypeOf<ImportRunnerFs['decodeImage']>().toEqualTypeOf<DecodeImage | undefined>();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps both runner views derived from the authority', () => {
|
|
49
|
+
expect(runnerSource).toContain("decodeImage?: ImportContext['decodeImage'];");
|
|
50
|
+
expect(runnerSource).toContain("const decodeImage: ImportContext['decodeImage'] =");
|
|
51
|
+
expect(runnerSource).not.toContain("mimeType: 'image/png' | 'image/jpeg'");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { Asset, AssetRelation, ImportedAsset, ImportResult } from '@forgeax/engine-types';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { type RunImportMeta, runImport } from '../import-runner.js';
|
|
6
|
+
import { ImporterRegistry } from '../importer-registry.js';
|
|
7
|
+
|
|
8
|
+
const GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45c';
|
|
9
|
+
const DEPENDENCY_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45d';
|
|
10
|
+
const MESH = {
|
|
11
|
+
kind: 'mesh',
|
|
12
|
+
vertices: new Float32Array(),
|
|
13
|
+
indices: new Uint16Array(),
|
|
14
|
+
attributes: {},
|
|
15
|
+
} as unknown as Asset;
|
|
16
|
+
|
|
17
|
+
const relation: AssetRelation = {
|
|
18
|
+
from: { type: 'asset', id: GUID },
|
|
19
|
+
to: { type: 'asset', id: DEPENDENCY_GUID },
|
|
20
|
+
type: 'references',
|
|
21
|
+
provenance: { provider: 'fixture-producer', version: '2.0.0' },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function registry(): ImporterRegistry {
|
|
25
|
+
const registry = new ImporterRegistry();
|
|
26
|
+
registry.register({
|
|
27
|
+
key: 'fixture',
|
|
28
|
+
import: async (): Promise<ImportResult> => ({
|
|
29
|
+
ok: true,
|
|
30
|
+
value: {
|
|
31
|
+
assets: [
|
|
32
|
+
{
|
|
33
|
+
guid: GUID,
|
|
34
|
+
kind: 'mesh',
|
|
35
|
+
payload: MESH,
|
|
36
|
+
refs: [{ guid: DEPENDENCY_GUID }],
|
|
37
|
+
artifacts: {},
|
|
38
|
+
} satisfies ImportedAsset,
|
|
39
|
+
],
|
|
40
|
+
sourceDependencies: [],
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
return registry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readFs() {
|
|
48
|
+
return {
|
|
49
|
+
readSource: async () => ({ ok: true as const, value: new Uint8Array([1]) }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function multiMeta(
|
|
54
|
+
overrides: readonly (Partial<RunImportMeta['subAssets'][number]> | undefined)[] = [],
|
|
55
|
+
): RunImportMeta {
|
|
56
|
+
const entries = [
|
|
57
|
+
{ guid: GUID, sourceKey: 'scene/main', sourceIndex: 0, kind: 'mesh' },
|
|
58
|
+
{ guid: DEPENDENCY_GUID, sourceKey: 'scene/detail', sourceIndex: 1, kind: 'mesh' },
|
|
59
|
+
];
|
|
60
|
+
return {
|
|
61
|
+
importer: 'fixture',
|
|
62
|
+
source: 'models/fixture.glb',
|
|
63
|
+
subAssets: entries.map((entry, index) => ({ ...entry, ...(overrides[index] ?? {}) })),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function meta(facts: Record<string, unknown> = {}): RunImportMeta {
|
|
68
|
+
return {
|
|
69
|
+
importer: 'fixture',
|
|
70
|
+
source: 'models/fixture.glb',
|
|
71
|
+
...facts,
|
|
72
|
+
subAssets: [
|
|
73
|
+
{
|
|
74
|
+
guid: GUID,
|
|
75
|
+
sourceKey: 'scene/main',
|
|
76
|
+
sourceIndex: 0,
|
|
77
|
+
kind: 'mesh',
|
|
78
|
+
relations: [relation],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
} as unknown as RunImportMeta;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('import runner producer fact propagation', () => {
|
|
85
|
+
it('rejects sourceIndex-only declarations before reading source or invoking importer', async () => {
|
|
86
|
+
let reads = 0;
|
|
87
|
+
const keyedMeta = multiMeta();
|
|
88
|
+
const sourceIndexOnlyMeta: RunImportMeta = {
|
|
89
|
+
...keyedMeta,
|
|
90
|
+
subAssets: keyedMeta.subAssets.map(
|
|
91
|
+
({ sourceKey: _sourceKey, ...declaration }) => declaration,
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
const result = await runImport(sourceIndexOnlyMeta, registry(), {
|
|
95
|
+
readSource: async () => {
|
|
96
|
+
reads += 1;
|
|
97
|
+
return { ok: true as const, value: new Uint8Array([1]) };
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(result.ok).toBe(false);
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
expect(result.error.code).toBe('source-validation-failed');
|
|
104
|
+
expect(result.error.detail).toMatchObject({
|
|
105
|
+
diagnostics: expect.arrayContaining([
|
|
106
|
+
expect.objectContaining({ code: 'source-key-required' }),
|
|
107
|
+
]),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
expect(reads).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('rejects duplicate, empty, and malformed sourceKey before Meta publication', async () => {
|
|
114
|
+
for (const override of [
|
|
115
|
+
{ sourceKey: 'scene/main' },
|
|
116
|
+
{ sourceKey: ' ' },
|
|
117
|
+
{ sourceKey: 7 as unknown as string },
|
|
118
|
+
]) {
|
|
119
|
+
const result = await runImport(multiMeta([undefined, override]), registry(), readFs());
|
|
120
|
+
expect(result.ok).toBe(false);
|
|
121
|
+
if (!result.ok) expect(result.error.code).toBe('source-validation-failed');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('preserves complete producer facts on the DDC row matched by GUID', async () => {
|
|
126
|
+
const facts = {
|
|
127
|
+
packageId: 'package/fixture',
|
|
128
|
+
provenance: { provider: 'fixture-producer', version: '2.0.0', source: 'fixture.glb' },
|
|
129
|
+
revision: { digest: 'sha256:fixture', observedAt: 42, rootId: 'root-fixture' },
|
|
130
|
+
diagnostics: [
|
|
131
|
+
{
|
|
132
|
+
code: 'fixture-warning',
|
|
133
|
+
severity: 'warning',
|
|
134
|
+
hint: 'reimport the fixture when its source changes',
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
const result = await runImport(meta(facts), registry(), readFs());
|
|
139
|
+
|
|
140
|
+
expect(result.ok).toBe(true);
|
|
141
|
+
if (!result.ok || 'skipped' in result.value) return;
|
|
142
|
+
expect(result.value.pack).toMatchObject(facts);
|
|
143
|
+
expect(result.value.pack.assets).toEqual([
|
|
144
|
+
expect.objectContaining({
|
|
145
|
+
guid: GUID,
|
|
146
|
+
sourceKey: 'scene/main',
|
|
147
|
+
sourceIndex: 0,
|
|
148
|
+
relations: [relation],
|
|
149
|
+
}),
|
|
150
|
+
]);
|
|
151
|
+
expect(result.value.cookProducts).toEqual([
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
guid: GUID,
|
|
154
|
+
payload: MESH,
|
|
155
|
+
refs: [DEPENDENCY_GUID],
|
|
156
|
+
artifacts: {},
|
|
157
|
+
receipt: expect.objectContaining({
|
|
158
|
+
guid: GUID,
|
|
159
|
+
origin: 'sourceMeta',
|
|
160
|
+
status: 'succeeded',
|
|
161
|
+
}),
|
|
162
|
+
}),
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('keeps optional producer facts absent when the declaration has no evidence', async () => {
|
|
167
|
+
const result = await runImport(meta(), registry(), readFs());
|
|
168
|
+
|
|
169
|
+
expect(result.ok).toBe(true);
|
|
170
|
+
if (!result.ok || 'skipped' in result.value) return;
|
|
171
|
+
expect(result.value.pack).not.toHaveProperty('packageId');
|
|
172
|
+
expect(result.value.pack).not.toHaveProperty('provenance');
|
|
173
|
+
expect(result.value.pack).not.toHaveProperty('revision');
|
|
174
|
+
expect(result.value.pack).not.toHaveProperty('diagnostics');
|
|
175
|
+
expect(result.value.pack.assets[0]).toMatchObject({
|
|
176
|
+
guid: GUID,
|
|
177
|
+
sourceKey: 'scene/main',
|
|
178
|
+
sourceIndex: 0,
|
|
179
|
+
relations: [relation],
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('removes package-global artifact and kind-specific bin contracts from the runner', () => {
|
|
184
|
+
const source = readFileSync(resolve(import.meta.dirname, '..', 'import-runner.ts'), 'utf8');
|
|
185
|
+
expect(source).not.toContain('RunImportOk.bins');
|
|
186
|
+
expect(source).not.toContain('readonly bins');
|
|
187
|
+
expect(source).not.toContain("a.kind === 'texture'");
|
|
188
|
+
expect(source).not.toContain("a.kind === 'mesh'");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Asset, ImportContext, Importer, ImportResult } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type RunImportMeta, runImport } from '../import-runner.js';
|
|
4
|
+
import { ImporterRegistry } from '../importer-registry.js';
|
|
5
|
+
|
|
6
|
+
const GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45c';
|
|
7
|
+
const MESH = {
|
|
8
|
+
kind: 'mesh',
|
|
9
|
+
vertices: new Float32Array(),
|
|
10
|
+
indices: new Uint16Array(),
|
|
11
|
+
attributes: {},
|
|
12
|
+
} as unknown as Asset;
|
|
13
|
+
|
|
14
|
+
function meta(): RunImportMeta {
|
|
15
|
+
return {
|
|
16
|
+
importer: 'fixture',
|
|
17
|
+
source: 'ui/main.html',
|
|
18
|
+
subAssets: [{ guid: GUID, sourceIndex: 0, kind: 'mesh' }],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('import runner dependency observation', () => {
|
|
23
|
+
it('records normalized source and sibling reads exactly once, including retries', async () => {
|
|
24
|
+
const reads: string[] = [];
|
|
25
|
+
const importer: Importer = {
|
|
26
|
+
key: 'fixture',
|
|
27
|
+
async import(ctx: ImportContext): Promise<ImportResult> {
|
|
28
|
+
await ctx.readSibling('./main.css');
|
|
29
|
+
const missing = await ctx.readSibling('icons/missing.svg');
|
|
30
|
+
expect(missing.ok).toBe(false);
|
|
31
|
+
await ctx.readSibling('icons/missing.svg');
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
value: {
|
|
35
|
+
assets: [{ guid: GUID, kind: 'mesh', payload: MESH, refs: [], artifacts: {} }],
|
|
36
|
+
sourceDependencies: [],
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const registry = new ImporterRegistry();
|
|
42
|
+
registry.register(importer);
|
|
43
|
+
const result = await runImport(meta(), registry, {
|
|
44
|
+
readSource: async (source) => {
|
|
45
|
+
reads.push(source);
|
|
46
|
+
if (source.endsWith('missing.svg')) return { ok: false, error: new Error('ENOENT') };
|
|
47
|
+
return { ok: true, value: new Uint8Array([1]) };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect(result.ok).toBe(true);
|
|
51
|
+
if (result.ok && 'product' in result.value) {
|
|
52
|
+
expect(result.value.product.sourceDependencies).toEqual([
|
|
53
|
+
'ui/main.html',
|
|
54
|
+
'ui/main.css',
|
|
55
|
+
'ui/icons/missing.svg',
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
expect(reads).toEqual([
|
|
59
|
+
'ui/main.html',
|
|
60
|
+
'ui/./main.css',
|
|
61
|
+
'ui/icons/missing.svg',
|
|
62
|
+
'ui/icons/missing.svg',
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ImportContext, ImportResult } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type RunImportMeta, runImport } from '../import-runner.js';
|
|
4
|
+
import { ImporterRegistry } from '../importer-registry.js';
|
|
5
|
+
|
|
6
|
+
const GUID = '019f0000-0000-7000-8000-000000000071';
|
|
7
|
+
const SOURCE = 'assets/m71.fixture';
|
|
8
|
+
|
|
9
|
+
const meta: RunImportMeta = {
|
|
10
|
+
importer: 'm71-fixture',
|
|
11
|
+
source: SOURCE,
|
|
12
|
+
subAssets: [{ guid: GUID, sourceIndex: 0, sourceKey: 'fixture:main', kind: 'reel-game-blob' }],
|
|
13
|
+
importSettings: { revision: 'm71' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function createRegistry(): ImporterRegistry {
|
|
17
|
+
const registry = new ImporterRegistry();
|
|
18
|
+
registry.register({
|
|
19
|
+
key: meta.importer,
|
|
20
|
+
import: async (ctx: ImportContext): Promise<ImportResult<unknown>> => {
|
|
21
|
+
const source = await ctx.readSource();
|
|
22
|
+
if (!source.ok) throw new Error('fixture source was not readable');
|
|
23
|
+
return {
|
|
24
|
+
ok: true,
|
|
25
|
+
value: {
|
|
26
|
+
assets: [
|
|
27
|
+
{
|
|
28
|
+
guid: GUID,
|
|
29
|
+
kind: 'reel-game-blob',
|
|
30
|
+
payload: { kind: 'reel-game-blob', title: 'M71 digest fixture' },
|
|
31
|
+
refs: [],
|
|
32
|
+
artifacts: {
|
|
33
|
+
body: {
|
|
34
|
+
mediaType: 'application/x-forgeax-m71',
|
|
35
|
+
bytes: new Uint8Array([11, 71, 1, 9]),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
sourceDependencies: [],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return registry;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createFs(reads: { count: number }) {
|
|
49
|
+
const sourceBytes = new Uint8Array([77, 55, 49, 0]);
|
|
50
|
+
return {
|
|
51
|
+
readSource: async (path: string) => {
|
|
52
|
+
reads.count += 1;
|
|
53
|
+
return path === SOURCE
|
|
54
|
+
? { ok: true as const, value: sourceBytes }
|
|
55
|
+
: { ok: false as const, error: new Error(`unexpected source path ${path}`) };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('public import finalizer digest recovery', () => {
|
|
61
|
+
it('returns a structured finalization error and retries idempotently on the same registry', async () => {
|
|
62
|
+
const registry = createRegistry();
|
|
63
|
+
const reads = { count: 0 };
|
|
64
|
+
const fs = createFs(reads);
|
|
65
|
+
const subtle = globalThis.crypto?.subtle;
|
|
66
|
+
if (subtle === undefined) throw new Error('Web Crypto subtle is unavailable');
|
|
67
|
+
const originalDigest = subtle.digest;
|
|
68
|
+
const publishedDigests = new Set<string>();
|
|
69
|
+
|
|
70
|
+
subtle.digest = async () => {
|
|
71
|
+
throw new Error('M71 injected subtle.digest refusal');
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let failed: Awaited<ReturnType<typeof runImport>>;
|
|
75
|
+
try {
|
|
76
|
+
failed = await runImport(meta, registry, fs);
|
|
77
|
+
} finally {
|
|
78
|
+
subtle.digest = originalDigest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(failed.ok).toBe(false);
|
|
82
|
+
expect(failed).not.toHaveProperty('value');
|
|
83
|
+
if (!failed.ok) {
|
|
84
|
+
expect(failed.error.code).toBe('import-internal-error');
|
|
85
|
+
expect(failed.error.expected).toContain('finalization');
|
|
86
|
+
expect(failed.error.detail).toEqual({
|
|
87
|
+
reason: expect.stringMatching(/finalization.*digest.*M71 injected subtle\.digest refusal/),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const repaired = await runImport(meta, registry, fs);
|
|
92
|
+
expect(repaired.ok).toBe(true);
|
|
93
|
+
if (!repaired.ok || 'skipped' in repaired.value)
|
|
94
|
+
throw new Error('repair must publish a product');
|
|
95
|
+
expect(repaired.value.cookProducts).toHaveLength(1);
|
|
96
|
+
expect(repaired.value.pack.assets).toHaveLength(1);
|
|
97
|
+
const repairedDigest = repaired.value.cookProducts[0]?.digest;
|
|
98
|
+
if (repairedDigest === undefined) throw new Error('repair must produce a digest');
|
|
99
|
+
publishedDigests.add(repairedDigest);
|
|
100
|
+
|
|
101
|
+
const third = await runImport(meta, registry, fs);
|
|
102
|
+
expect(third.ok).toBe(true);
|
|
103
|
+
if (!third.ok || 'skipped' in third.value)
|
|
104
|
+
throw new Error('idempotent run must publish a product');
|
|
105
|
+
expect(third.value.cookProducts[0]?.digest).toBe(repairedDigest);
|
|
106
|
+
expect(third.value.pack).toEqual(repaired.value.pack);
|
|
107
|
+
publishedDigests.add(third.value.cookProducts[0]?.digest ?? '');
|
|
108
|
+
|
|
109
|
+
expect(publishedDigests.size).toBe(1);
|
|
110
|
+
expect(registry.registeredImporters()).toEqual([meta.importer]);
|
|
111
|
+
expect(reads.count).toBe(6);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ImportContext, ImportResult } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type RunImportMeta, runImport } from '../import-runner.js';
|
|
4
|
+
import { ImporterRegistry } from '../importer-registry.js';
|
|
5
|
+
|
|
6
|
+
const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45c';
|
|
7
|
+
const TEXTURE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d45d';
|
|
8
|
+
|
|
9
|
+
const meta: RunImportMeta = {
|
|
10
|
+
importer: 'local-artifacts-fixture',
|
|
11
|
+
source: 'fixture.source',
|
|
12
|
+
subAssets: [
|
|
13
|
+
{ guid: MESH_GUID, sourceIndex: 0, sourceKey: 'fixture:mesh', kind: 'mesh' },
|
|
14
|
+
{ guid: TEXTURE_GUID, sourceIndex: 1, sourceKey: 'fixture:texture', kind: 'texture' },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function registryFor(value: unknown): ImporterRegistry {
|
|
19
|
+
const registry = new ImporterRegistry();
|
|
20
|
+
registry.register({
|
|
21
|
+
key: meta.importer,
|
|
22
|
+
import: async (_ctx: ImportContext): Promise<ImportResult> =>
|
|
23
|
+
({ ok: true, value }) as ImportResult,
|
|
24
|
+
});
|
|
25
|
+
return registry;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('asset-local ImportProduct contract', () => {
|
|
29
|
+
it('keeps artifact ownership on each asset and preserves same keys across assets', async () => {
|
|
30
|
+
const value = {
|
|
31
|
+
assets: [
|
|
32
|
+
{
|
|
33
|
+
guid: MESH_GUID,
|
|
34
|
+
kind: 'mesh',
|
|
35
|
+
payload: { kind: 'mesh', vertices: [] },
|
|
36
|
+
refs: [],
|
|
37
|
+
artifacts: {
|
|
38
|
+
body: {
|
|
39
|
+
mediaType: 'application/x-forgeax-mesh',
|
|
40
|
+
assetCodec: { name: 'mesh-binary', version: '4' },
|
|
41
|
+
bytes: new Uint8Array([1]),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
guid: TEXTURE_GUID,
|
|
47
|
+
kind: 'texture',
|
|
48
|
+
payload: { kind: 'texture', width: 1, height: 1 },
|
|
49
|
+
refs: [],
|
|
50
|
+
artifacts: {
|
|
51
|
+
body: {
|
|
52
|
+
mediaType: 'image/ktx2',
|
|
53
|
+
assetCodec: { name: 'basis', profile: 'uastc' },
|
|
54
|
+
bytes: new Uint8Array([2]),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
sourceDependencies: ['fixture.source'],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const result = await runImport(meta, registryFor(value), {
|
|
63
|
+
readSource: async () => ({ ok: true as const, value: new Uint8Array([0]) }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result.ok).toBe(true);
|
|
67
|
+
if (result.ok && 'product' in result.value) {
|
|
68
|
+
expect(result.value).not.toHaveProperty('bins');
|
|
69
|
+
expect(result.value.product).not.toHaveProperty('artifacts');
|
|
70
|
+
expect(result.value.pack.assets[0]?.artifacts).toHaveProperty('body');
|
|
71
|
+
expect(result.value.pack.assets[1]?.artifacts).toHaveProperty('body');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects a package-global artifact list instead of accepting an ownerless body', async () => {
|
|
76
|
+
const firstSubAsset = meta.subAssets[0];
|
|
77
|
+
if (firstSubAsset === undefined) throw new Error('fixture must declare a sub-asset');
|
|
78
|
+
const result = await runImport(
|
|
79
|
+
{ ...meta, subAssets: [firstSubAsset] },
|
|
80
|
+
registryFor({
|
|
81
|
+
assets: [
|
|
82
|
+
{
|
|
83
|
+
guid: MESH_GUID,
|
|
84
|
+
kind: 'mesh',
|
|
85
|
+
payload: { kind: 'mesh', vertices: [] },
|
|
86
|
+
refs: [],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
artifacts: [
|
|
90
|
+
{ path: 'mesh.bin', mimeType: 'application/octet-stream', bytes: new Uint8Array([1]) },
|
|
91
|
+
],
|
|
92
|
+
sourceDependencies: ['fixture.source'],
|
|
93
|
+
}),
|
|
94
|
+
{ readSource: async () => ({ ok: true as const, value: new Uint8Array([0]) }) },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(result.ok).toBe(false);
|
|
98
|
+
if (!result.ok) expect(result.error.code).toBe('import-internal-error');
|
|
99
|
+
});
|
|
100
|
+
});
|