@crowdedkingdoms/construct 0.3.11-test.1 → 0.4.0-dev.1
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/README.md +14 -1
- package/node/live-draft.d.mts +17 -0
- package/node/live-draft.mjs +618 -0
- package/node/live-draft.test.mjs +273 -0
- package/node/vite-plugins.d.mts +11 -0
- package/node/vite-plugins.mjs +201 -5
- package/node/vite-plugins.test.mjs +51 -0
- package/package.json +11 -3
- package/src/engine/SceneRouter.test.ts +14 -0
- package/src/engine/SceneRouter.ts +10 -0
- package/src/platform/exec/worldHub.test.ts +10 -1
- package/src/platform/exec/worldHub.ts +29 -0
- package/src/platform/live/content.test.ts +139 -0
- package/src/platform/live/content.ts +284 -0
- package/src/platform/live/devServerDraftStore.ts +330 -0
- package/src/platform/live/draftLoader.test.ts +166 -0
- package/src/platform/live/draftLoader.ts +265 -0
- package/src/platform/live/draftModules.test.ts +195 -0
- package/src/platform/live/draftModules.ts +263 -0
- package/src/platform/live/draftServiceWorker.ts +176 -0
- package/src/platform/live/sdk.ts +50 -0
- package/src/platform/live/virtual.d.ts +5 -0
- package/src/platform/localLiveCode.ts +11 -0
- package/src/platform/studio/StudioService.ts +232 -17
- package/src/platform/studio/studioQuery.test.ts +65 -0
- package/src/platform/studio/studioQuery.ts +74 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosted preview for Crowdy Studio's game editor: the draft's content layer,
|
|
3
|
+
* loaded through the draft service worker and swapped into the running game.
|
|
4
|
+
*
|
|
5
|
+
* Started only for a player editing their own game on its hosted page (a
|
|
6
|
+
* dev server previews through Vite HMR instead). It registers the worker,
|
|
7
|
+
* answers the worker's questions from the draft store (the file list, a
|
|
8
|
+
* file's source, the export names of a host module), and imports a fresh
|
|
9
|
+
* generation of `src/game/content.ts` after each code change. Every outcome
|
|
10
|
+
* lands in the content host, which is what `preview_status` reads.
|
|
11
|
+
*
|
|
12
|
+
* Host modules come from `virtual:construct-draft-host`, a lazy registry the
|
|
13
|
+
* build writes (`constructDraftPlugin`): the framework, and every package the
|
|
14
|
+
* game's content imports. A draft can import only what that build bundles.
|
|
15
|
+
*/
|
|
16
|
+
import hostModules from 'virtual:construct-draft-host';
|
|
17
|
+
|
|
18
|
+
import { previewErrorOf, type GameContentHost, type PreviewError } from './content';
|
|
19
|
+
import { CONTENT_ENTRY, DRAFT_WORKER_FILE, draftModuleUrl } from './draftModules';
|
|
20
|
+
import type { DraftChangeEvent, DraftStore } from './sdk';
|
|
21
|
+
|
|
22
|
+
const CODE_FILE = /\.(ts|tsx|mts|js|mjs|json)$/;
|
|
23
|
+
const RELOAD_DEBOUNCE_MS = 250;
|
|
24
|
+
const CONTROL_TIMEOUT_MS = 5_000;
|
|
25
|
+
|
|
26
|
+
type HostNamespace = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
declare global {
|
|
29
|
+
var __constructDraftHost: ((specifier: string) => Promise<HostNamespace>) | undefined;
|
|
30
|
+
var __constructDraftEnv: Record<string, unknown> | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface WorkerQuestion {
|
|
34
|
+
type?: string;
|
|
35
|
+
request?: 'manifest' | 'source' | 'host';
|
|
36
|
+
generation?: number;
|
|
37
|
+
path?: string;
|
|
38
|
+
specifier?: string;
|
|
39
|
+
error?: PreviewError;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DraftLoaderOptions {
|
|
43
|
+
/** The game's base URL path, with a trailing slash. */
|
|
44
|
+
base: string;
|
|
45
|
+
store: DraftStore;
|
|
46
|
+
content: GameContentHost;
|
|
47
|
+
/** What `import.meta.env` reads as in a draft module (the page's own). */
|
|
48
|
+
env: Record<string, unknown>;
|
|
49
|
+
log?: (line: string) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadHost(specifier: string): Promise<HostNamespace> {
|
|
53
|
+
const load = hostModules[specifier];
|
|
54
|
+
if (!load) {
|
|
55
|
+
return Promise.reject(
|
|
56
|
+
new Error(
|
|
57
|
+
`${specifier} is not in this build of the game; a draft can import only what the published game bundles.`,
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return load() as Promise<HostNamespace>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function waitForControl(timeoutMs: number): Promise<void> {
|
|
65
|
+
if (navigator.serviceWorker.controller) return Promise.resolve();
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
navigator.serviceWorker.removeEventListener('controllerchange', done);
|
|
69
|
+
reject(
|
|
70
|
+
new Error(
|
|
71
|
+
'The draft service worker did not take over this page; reload it to preview the draft.',
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}, timeoutMs);
|
|
75
|
+
function done(): void {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
navigator.serviceWorker.removeEventListener('controllerchange', done);
|
|
78
|
+
resolve();
|
|
79
|
+
}
|
|
80
|
+
navigator.serviceWorker.addEventListener('controllerchange', done);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class DraftLoader {
|
|
85
|
+
private generation = 0;
|
|
86
|
+
private pendingRevision = 0;
|
|
87
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
88
|
+
private readonly workerErrors = new Map<number, PreviewError[]>();
|
|
89
|
+
private readonly offStore: () => void;
|
|
90
|
+
private readonly onMessage = (event: MessageEvent): void => {
|
|
91
|
+
void this.answer(event);
|
|
92
|
+
};
|
|
93
|
+
private disposed = false;
|
|
94
|
+
|
|
95
|
+
private constructor(
|
|
96
|
+
private readonly options: DraftLoaderOptions,
|
|
97
|
+
private readonly registration: ServiceWorkerRegistration,
|
|
98
|
+
) {
|
|
99
|
+
navigator.serviceWorker.addEventListener('message', this.onMessage);
|
|
100
|
+
navigator.serviceWorker.startMessages();
|
|
101
|
+
this.offStore = options.store.subscribe((event) => this.onDraftChange(event));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Register the worker, take over the page, and load the draft's content once. */
|
|
105
|
+
static async start(options: DraftLoaderOptions): Promise<DraftLoader> {
|
|
106
|
+
if (!('serviceWorker' in navigator)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
'This browser cannot run service workers, so the hosted game cannot preview the draft.',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
globalThis.__constructDraftHost = loadHost;
|
|
112
|
+
globalThis.__constructDraftEnv = options.env;
|
|
113
|
+
const registration = await navigator.serviceWorker.register(
|
|
114
|
+
`${options.base}${DRAFT_WORKER_FILE}`,
|
|
115
|
+
{
|
|
116
|
+
scope: options.base,
|
|
117
|
+
updateViaCache: 'none',
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
await navigator.serviceWorker.ready;
|
|
121
|
+
await waitForControl(CONTROL_TIMEOUT_MS);
|
|
122
|
+
const loader = new DraftLoader(options, registration);
|
|
123
|
+
loader.schedule(options.store.snapshot().revision, 0);
|
|
124
|
+
return loader;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Stop previewing; the worker unregisters, and the page keeps what it shows. */
|
|
128
|
+
dispose(): void {
|
|
129
|
+
if (this.disposed) return;
|
|
130
|
+
this.disposed = true;
|
|
131
|
+
if (this.timer) clearTimeout(this.timer);
|
|
132
|
+
this.offStore();
|
|
133
|
+
navigator.serviceWorker.removeEventListener('message', this.onMessage);
|
|
134
|
+
void this.registration.unregister();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private onDraftChange(event: DraftChangeEvent): void {
|
|
138
|
+
const { content } = this.options;
|
|
139
|
+
content.noteRevision(event.revision);
|
|
140
|
+
const reload =
|
|
141
|
+
event.source === 'reload' ||
|
|
142
|
+
event.source === 'rebase' ||
|
|
143
|
+
event.changes.some((change) => CODE_FILE.test(change.path));
|
|
144
|
+
if (reload) {
|
|
145
|
+
this.schedule(event.revision, RELOAD_DEBOUNCE_MS);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (event.changes.length > 0) {
|
|
149
|
+
content.settleUnloaded(
|
|
150
|
+
event.revision,
|
|
151
|
+
`${event.changes.map((change) => change.path).join(', ')}: not code the running game loads; nothing to reload.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private schedule(revision: number, delayMs: number): void {
|
|
157
|
+
if (this.disposed) return;
|
|
158
|
+
this.pendingRevision = Math.max(this.pendingRevision, revision);
|
|
159
|
+
if (this.timer) clearTimeout(this.timer);
|
|
160
|
+
this.timer = setTimeout(() => {
|
|
161
|
+
this.timer = null;
|
|
162
|
+
void this.load();
|
|
163
|
+
}, delayMs);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private paths(): string[] {
|
|
167
|
+
return this.options.store
|
|
168
|
+
.snapshot()
|
|
169
|
+
.entries.filter((entry) => !entry.binary)
|
|
170
|
+
.map((entry) => entry.path);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async load(): Promise<void> {
|
|
174
|
+
const { base, content, log, store } = this.options;
|
|
175
|
+
// Not listed yet: the store announces its first load, which schedules this again.
|
|
176
|
+
if (store.snapshot().entries.length === 0) return;
|
|
177
|
+
const revision = this.pendingRevision;
|
|
178
|
+
const generation = ++this.generation;
|
|
179
|
+
if (!this.paths().includes(CONTENT_ENTRY)) {
|
|
180
|
+
content.fail(
|
|
181
|
+
[
|
|
182
|
+
{
|
|
183
|
+
path: CONTENT_ENTRY,
|
|
184
|
+
message: `This game has no ${CONTENT_ENTRY}, so its hosted page cannot preview the draft. Update the game from the template.`,
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
revision,
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let module: unknown;
|
|
192
|
+
try {
|
|
193
|
+
module = await import(/* @vite-ignore */ draftModuleUrl(base, generation, CONTENT_ENTRY));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (generation !== this.generation) return;
|
|
196
|
+
const reported = this.workerErrors.get(generation);
|
|
197
|
+
content.fail(reported?.length ? reported : [previewErrorOf(error)], revision);
|
|
198
|
+
return;
|
|
199
|
+
} finally {
|
|
200
|
+
this.workerErrors.delete(generation);
|
|
201
|
+
}
|
|
202
|
+
// A newer generation is loading or loaded; this one is already stale.
|
|
203
|
+
if (generation !== this.generation || this.disposed) return;
|
|
204
|
+
const ok = await content.replace(module, revision);
|
|
205
|
+
log?.(
|
|
206
|
+
`draft preview: generation ${generation} (revision ${revision}) ${ok ? 'loaded' : 'failed'}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async answer(event: MessageEvent): Promise<void> {
|
|
211
|
+
if (
|
|
212
|
+
event.source !== this.registration.active &&
|
|
213
|
+
event.source !== navigator.serviceWorker.controller
|
|
214
|
+
)
|
|
215
|
+
return;
|
|
216
|
+
const question = (event.data ?? {}) as WorkerQuestion;
|
|
217
|
+
if (question.type === 'construct-draft-error') {
|
|
218
|
+
if (question.generation !== undefined && question.error) {
|
|
219
|
+
const errors = this.workerErrors.get(question.generation) ?? [];
|
|
220
|
+
errors.push(question.error);
|
|
221
|
+
this.workerErrors.set(question.generation, errors);
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (question.type !== 'construct-draft') return;
|
|
226
|
+
const port = event.ports[0];
|
|
227
|
+
if (!port) return;
|
|
228
|
+
try {
|
|
229
|
+
switch (question.request) {
|
|
230
|
+
case 'manifest':
|
|
231
|
+
port.postMessage({ ok: true, paths: this.paths() });
|
|
232
|
+
return;
|
|
233
|
+
case 'source': {
|
|
234
|
+
const path = String(question.path ?? '');
|
|
235
|
+
const result = await this.options.store.read([path]);
|
|
236
|
+
const file = result.files.find((candidate) => candidate.path === path);
|
|
237
|
+
port.postMessage(
|
|
238
|
+
file
|
|
239
|
+
? { ok: true, source: file.content }
|
|
240
|
+
: {
|
|
241
|
+
ok: false,
|
|
242
|
+
message: `${path} is ${result.binary.includes(path) ? 'not a text file' : 'not in the draft'}.`,
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case 'host': {
|
|
248
|
+
const namespace = await loadHost(String(question.specifier ?? ''));
|
|
249
|
+
port.postMessage({ ok: true, names: Object.keys(namespace) });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
default:
|
|
253
|
+
port.postMessage({
|
|
254
|
+
ok: false,
|
|
255
|
+
message: `Unknown draft request ${String(question.request)}.`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
port.postMessage({
|
|
260
|
+
ok: false,
|
|
261
|
+
message: error instanceof Error ? error.message : String(error),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DraftTransformError,
|
|
5
|
+
draftModuleUrl,
|
|
6
|
+
errorModuleSource,
|
|
7
|
+
hostShimSource,
|
|
8
|
+
parseDraftUrl,
|
|
9
|
+
resolveSpecifier,
|
|
10
|
+
transformDraftModule,
|
|
11
|
+
} from './draftModules';
|
|
12
|
+
|
|
13
|
+
const BASE = '/the-construct/';
|
|
14
|
+
const PATHS = new Set([
|
|
15
|
+
'src/game/content.ts',
|
|
16
|
+
'src/game/programs.ts',
|
|
17
|
+
'src/scenes/race/RaceScene.ts',
|
|
18
|
+
'src/scenes/race/index.ts',
|
|
19
|
+
'model/catalog.mjs',
|
|
20
|
+
'model/tracks.json',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
describe('parseDraftUrl', () => {
|
|
24
|
+
it('reads module, host and missing URLs under the base, and nothing else', () => {
|
|
25
|
+
expect(parseDraftUrl('/the-construct/__draft/g3/src/game/content.ts', BASE)).toEqual({
|
|
26
|
+
kind: 'module',
|
|
27
|
+
generation: 3,
|
|
28
|
+
path: 'src/game/content.ts',
|
|
29
|
+
});
|
|
30
|
+
expect(parseDraftUrl('/the-construct/__draft/host/three.js', BASE)).toEqual({
|
|
31
|
+
kind: 'host',
|
|
32
|
+
specifier: 'three',
|
|
33
|
+
});
|
|
34
|
+
expect(
|
|
35
|
+
parseDraftUrl(
|
|
36
|
+
`/the-construct/__draft/host/${encodeURIComponent('@crowdedkingdoms/construct/engine/GameScene')}.js`,
|
|
37
|
+
BASE,
|
|
38
|
+
),
|
|
39
|
+
).toEqual({ kind: 'host', specifier: '@crowdedkingdoms/construct/engine/GameScene' });
|
|
40
|
+
expect(parseDraftUrl('/the-construct/assets/index.js', BASE)).toBeNull();
|
|
41
|
+
expect(parseDraftUrl('/the-construct/dsh/index.html', BASE)).toBeNull();
|
|
42
|
+
expect(parseDraftUrl('/the-construct/__draft/g1/../../secrets', BASE)).toBeNull();
|
|
43
|
+
expect(parseDraftUrl('/the-construct/__draft/g1/src/%2E%2E/x.ts', BASE)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('round-trips a module URL', () => {
|
|
47
|
+
const url = draftModuleUrl(BASE, 7, 'src/scenes/race/RaceScene.ts');
|
|
48
|
+
expect(parseDraftUrl(url, BASE)).toEqual({
|
|
49
|
+
kind: 'module',
|
|
50
|
+
generation: 7,
|
|
51
|
+
path: 'src/scenes/race/RaceScene.ts',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('resolveSpecifier', () => {
|
|
57
|
+
it('resolves relative, alias and root paths the way Vite does here', () => {
|
|
58
|
+
expect(resolveSpecifier('./programs', 'src/game/content.ts', PATHS)).toEqual({
|
|
59
|
+
kind: 'draft',
|
|
60
|
+
path: 'src/game/programs.ts',
|
|
61
|
+
});
|
|
62
|
+
expect(resolveSpecifier('@/scenes/race', 'src/game/content.ts', PATHS)).toEqual({
|
|
63
|
+
kind: 'draft',
|
|
64
|
+
path: 'src/scenes/race/index.ts',
|
|
65
|
+
});
|
|
66
|
+
expect(resolveSpecifier('../../model/catalog.mjs', 'src/game/programs.ts', PATHS)).toEqual({
|
|
67
|
+
kind: 'draft',
|
|
68
|
+
path: 'model/catalog.mjs',
|
|
69
|
+
});
|
|
70
|
+
expect(resolveSpecifier('/model/tracks.json', 'src/game/programs.ts', PATHS)).toEqual({
|
|
71
|
+
kind: 'draft',
|
|
72
|
+
path: 'model/tracks.json',
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('sends bare and framework imports to the host, and reports what is missing', () => {
|
|
77
|
+
expect(resolveSpecifier('three', 'src/scenes/race/RaceScene.ts', PATHS)).toEqual({
|
|
78
|
+
kind: 'host',
|
|
79
|
+
specifier: 'three',
|
|
80
|
+
});
|
|
81
|
+
expect(
|
|
82
|
+
resolveSpecifier('@crowdedkingdoms/construct/engine/GameScene', 'src/game/content.ts', PATHS),
|
|
83
|
+
).toEqual({
|
|
84
|
+
kind: 'host',
|
|
85
|
+
specifier: '@crowdedkingdoms/construct/engine/GameScene',
|
|
86
|
+
});
|
|
87
|
+
expect(resolveSpecifier('./nope', 'src/game/content.ts', PATHS)).toEqual({ kind: 'missing' });
|
|
88
|
+
expect(resolveSpecifier('../../../outside', 'src/game/content.ts', PATHS)).toEqual({
|
|
89
|
+
kind: 'missing',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('transformDraftModule', () => {
|
|
95
|
+
it('strips types, keeps line numbers, and rewrites every import', async () => {
|
|
96
|
+
const source = [
|
|
97
|
+
"import type { GameScene } from '@crowdedkingdoms/construct/engine/GameScene';",
|
|
98
|
+
"import { Scene } from 'three';",
|
|
99
|
+
"import { PROGRAMS } from './programs';",
|
|
100
|
+
'export const count: number = PROGRAMS.length;',
|
|
101
|
+
"export const later = () => import('@/scenes/race');",
|
|
102
|
+
'export const dev = import.meta.env.DEV;',
|
|
103
|
+
'if (import.meta.hot) import.meta.hot.accept();',
|
|
104
|
+
'export const scene: GameScene | null = new Scene() as unknown as GameScene;',
|
|
105
|
+
].join('\n');
|
|
106
|
+
const code = await transformDraftModule({
|
|
107
|
+
source,
|
|
108
|
+
path: 'src/game/content.ts',
|
|
109
|
+
generation: 2,
|
|
110
|
+
base: BASE,
|
|
111
|
+
paths: PATHS,
|
|
112
|
+
});
|
|
113
|
+
expect(code).not.toContain('GameScene/');
|
|
114
|
+
expect(code).toMatch(/from ['"]\/the-construct\/__draft\/host\/three\.js['"]/);
|
|
115
|
+
expect(code).toMatch(/from ['"]\/the-construct\/__draft\/g2\/src\/game\/programs\.ts['"]/);
|
|
116
|
+
expect(code).toContain('import("/the-construct/__draft/g2/src/scenes/race/index.ts")');
|
|
117
|
+
expect(code).toContain('globalThis.__constructDraftEnv.DEV');
|
|
118
|
+
expect(code).toContain('if ((void 0))');
|
|
119
|
+
expect(code).not.toMatch(/: number|as unknown/);
|
|
120
|
+
expect(code.split('\n')).toHaveLength(source.split('\n').length);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('serves JSON as a default export and refuses other files', async () => {
|
|
124
|
+
const json = await transformDraftModule({
|
|
125
|
+
source: '{"laps": 3}',
|
|
126
|
+
path: 'model/tracks.json',
|
|
127
|
+
generation: 1,
|
|
128
|
+
base: BASE,
|
|
129
|
+
paths: PATHS,
|
|
130
|
+
});
|
|
131
|
+
expect(json).toBe('export default {"laps":3};\n');
|
|
132
|
+
await expect(
|
|
133
|
+
transformDraftModule({
|
|
134
|
+
source: 'body{}',
|
|
135
|
+
path: 'src/style.css',
|
|
136
|
+
generation: 1,
|
|
137
|
+
base: BASE,
|
|
138
|
+
paths: PATHS,
|
|
139
|
+
}),
|
|
140
|
+
).rejects.toThrow(/cannot be previewed/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('reports a syntax error with its file and line', async () => {
|
|
144
|
+
const failure = transformDraftModule({
|
|
145
|
+
source: 'export const a = 1;\nexport const b = ;\n',
|
|
146
|
+
path: 'src/game/programs.ts',
|
|
147
|
+
generation: 1,
|
|
148
|
+
base: BASE,
|
|
149
|
+
paths: PATHS,
|
|
150
|
+
});
|
|
151
|
+
await expect(failure).rejects.toBeInstanceOf(DraftTransformError);
|
|
152
|
+
await failure.catch((error: DraftTransformError) => {
|
|
153
|
+
expect(error.error).toMatchObject({ path: 'src/game/programs.ts', line: 2 });
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('points an unknown draft import at a module that explains itself', async () => {
|
|
158
|
+
const code = await transformDraftModule({
|
|
159
|
+
source: "import { x } from './gone';\nexport default x;\n",
|
|
160
|
+
path: 'src/game/content.ts',
|
|
161
|
+
generation: 1,
|
|
162
|
+
base: BASE,
|
|
163
|
+
paths: PATHS,
|
|
164
|
+
});
|
|
165
|
+
const url = /['"]([^'"]+missing[^'"]+)['"]/.exec(code)?.[1] ?? '';
|
|
166
|
+
expect(parseDraftUrl(url, BASE)).toEqual({
|
|
167
|
+
kind: 'missing',
|
|
168
|
+
importer: 'src/game/content.ts',
|
|
169
|
+
specifier: './gone',
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('module sources', () => {
|
|
175
|
+
it('re-exports host names, reserved and default ones included', () => {
|
|
176
|
+
const code = hostShimSource('three', ['Scene', 'default', 'delete']);
|
|
177
|
+
expect(code).toContain('globalThis.__constructDraftHost("three")');
|
|
178
|
+
expect(code).toContain('export { __e0 as Scene };');
|
|
179
|
+
expect(code).toContain('export { __e1 as default };');
|
|
180
|
+
expect(code).toContain('export { __e2 as "delete" };');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('throws with the location and keeps it for the preview status', () => {
|
|
184
|
+
const code = errorModuleSource({
|
|
185
|
+
path: 'src/a.ts',
|
|
186
|
+
line: 3,
|
|
187
|
+
column: 2,
|
|
188
|
+
message: 'Unexpected token',
|
|
189
|
+
});
|
|
190
|
+
expect(code).toContain('"src/a.ts:3:2: Unexpected token"');
|
|
191
|
+
expect(code).toContain(
|
|
192
|
+
'error.preview = {"path":"src/a.ts","line":3,"column":2,"message":"Unexpected token"}',
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|