@esmx/core 3.0.0-rc.116 → 3.0.0-rc.118

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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -3
  3. package/README.zh-CN.md +20 -6
  4. package/dist/app.mjs +4 -2
  5. package/dist/cli/cli.d.ts +1 -0
  6. package/dist/cli/cli.mjs +28 -1
  7. package/dist/cli/validate.d.ts +15 -0
  8. package/dist/cli/validate.mjs +139 -0
  9. package/dist/cli/validate.test.d.ts +1 -0
  10. package/dist/cli/validate.test.mjs +216 -0
  11. package/dist/core.d.ts +31 -0
  12. package/dist/core.mjs +71 -5
  13. package/dist/declaration/index.d.ts +47 -0
  14. package/dist/declaration/index.mjs +63 -0
  15. package/dist/declaration/index.test.d.ts +1 -0
  16. package/dist/declaration/index.test.mjs +202 -0
  17. package/dist/declaration/lower.d.ts +11 -0
  18. package/dist/declaration/lower.mjs +78 -0
  19. package/dist/declaration/lower.test.d.ts +1 -0
  20. package/dist/declaration/lower.test.mjs +333 -0
  21. package/dist/declaration/reader.d.ts +28 -0
  22. package/dist/declaration/reader.mjs +55 -0
  23. package/dist/declaration/reinit.e2e.test.d.ts +1 -0
  24. package/dist/declaration/reinit.e2e.test.mjs +117 -0
  25. package/dist/declaration/resolver.d.ts +59 -0
  26. package/dist/declaration/resolver.mjs +430 -0
  27. package/dist/declaration/resolver.test.d.ts +1 -0
  28. package/dist/declaration/resolver.test.mjs +1005 -0
  29. package/dist/declaration/schema.d.ts +89 -0
  30. package/dist/declaration/schema.mjs +282 -0
  31. package/dist/declaration/schema.test.d.ts +1 -0
  32. package/dist/declaration/schema.test.mjs +101 -0
  33. package/dist/declaration/semver.d.ts +22 -0
  34. package/dist/declaration/semver.mjs +229 -0
  35. package/dist/declaration/semver.test.d.ts +1 -0
  36. package/dist/declaration/semver.test.mjs +87 -0
  37. package/dist/declaration/test-fixtures.d.ts +18 -0
  38. package/dist/declaration/test-fixtures.mjs +69 -0
  39. package/dist/declaration/types.d.ts +58 -0
  40. package/dist/declaration/types.mjs +21 -0
  41. package/dist/index.d.ts +4 -1
  42. package/dist/index.mjs +10 -0
  43. package/dist/manifest-json.d.ts +61 -0
  44. package/dist/manifest-json.mjs +68 -5
  45. package/dist/manifest-json.test.d.ts +1 -0
  46. package/dist/manifest-json.test.mjs +196 -0
  47. package/dist/module-config.d.ts +45 -5
  48. package/dist/module-config.mjs +60 -49
  49. package/dist/module-config.test.mjs +227 -91
  50. package/dist/pack-config.d.ts +2 -9
  51. package/dist/render-context.mjs +22 -5
  52. package/dist/utils/import-map.d.ts +23 -3
  53. package/dist/utils/import-map.mjs +38 -1
  54. package/dist/utils/import-map.test.mjs +228 -0
  55. package/package.json +9 -6
  56. package/src/app.ts +5 -2
  57. package/src/cli/cli.ts +44 -1
  58. package/src/cli/validate.test.ts +251 -0
  59. package/src/cli/validate.ts +196 -0
  60. package/src/core.ts +84 -5
  61. package/src/declaration/index.test.ts +223 -0
  62. package/src/declaration/index.ts +135 -0
  63. package/src/declaration/lower.test.ts +372 -0
  64. package/src/declaration/lower.ts +135 -0
  65. package/src/declaration/reader.ts +93 -0
  66. package/src/declaration/reinit.e2e.test.ts +148 -0
  67. package/src/declaration/resolver.test.ts +1111 -0
  68. package/src/declaration/resolver.ts +638 -0
  69. package/src/declaration/schema.test.ts +118 -0
  70. package/src/declaration/schema.ts +339 -0
  71. package/src/declaration/semver.test.ts +101 -0
  72. package/src/declaration/semver.ts +278 -0
  73. package/src/declaration/test-fixtures.ts +96 -0
  74. package/src/declaration/types.ts +71 -0
  75. package/src/index.ts +28 -1
  76. package/src/manifest-json.test.ts +236 -0
  77. package/src/manifest-json.ts +166 -5
  78. package/src/module-config.test.ts +266 -105
  79. package/src/module-config.ts +130 -58
  80. package/src/pack-config.ts +2 -9
  81. package/src/render-context.ts +34 -6
  82. package/src/utils/import-map.test.ts +261 -0
  83. package/src/utils/import-map.ts +92 -6
@@ -0,0 +1,196 @@
1
+ import { styleText } from 'node:util';
2
+ import type { Diagnostic, ResolvedMount, SupplyEntry } from '../declaration';
3
+ import { resolveDeclaration } from '../declaration';
4
+
5
+ export const VALIDATE_HELP = `Usage: esmx validate [--json]
6
+
7
+ Build-free dry run of the RFC 0001 module protocol: reads the package.json
8
+ "esmx" declaration in the current directory and runs the resolution layer
9
+ (phases 1-2: consumption graph + supply merge) without building anything.
10
+
11
+ Exit code is non-zero only when an error-severity diagnostic is found;
12
+ warnings alone exit 0. A package without an "esmx" field uses the legacy
13
+ entry.node.ts config and is not an error.
14
+
15
+ Options:
16
+ --json Emit a machine-readable envelope on stdout and nothing else:
17
+ {
18
+ "diagnostics": [ { "code", "check"?, "module", "package"?,
19
+ "found"?, "required"?, "message", "fix" } ],
20
+ "supply": { "<package>": { "groups": [ { "major",
21
+ "provider", "version" } ] } },
22
+ "mounts": { "<module>": { "name", "root",
23
+ "artifactDir", "built" } }
24
+ }
25
+ Two providers of one (package, major) is an E_DUP_PROVIDER error
26
+ (a shared dependency must have a single owner).
27
+ Legacy packages emit { "protocol": "legacy", "diagnostics": [] }.
28
+ --help Show this message.`;
29
+
30
+ export interface RunValidateOptions {
31
+ json?: boolean;
32
+ }
33
+
34
+ export interface RunValidateResult {
35
+ exitCode: number;
36
+ /** Full stdout payload (JSON envelope or human report). */
37
+ output: string;
38
+ }
39
+
40
+ interface ValidateEnvelopeEntry {
41
+ code: string;
42
+ check?: string;
43
+ module: string;
44
+ package?: string;
45
+ found?: string;
46
+ required?: string;
47
+ message: string;
48
+ fix: string;
49
+ }
50
+
51
+ function toEnvelopeEntry(diagnostic: Diagnostic): ValidateEnvelopeEntry {
52
+ const entry: ValidateEnvelopeEntry = {
53
+ code: diagnostic.code,
54
+ module: diagnostic.module,
55
+ message: diagnostic.message,
56
+ fix: diagnostic.fix
57
+ };
58
+ if (diagnostic.check !== undefined) {
59
+ entry.check = diagnostic.check;
60
+ }
61
+ if (diagnostic.package !== undefined) {
62
+ entry.package = diagnostic.package;
63
+ }
64
+ if (diagnostic.found !== undefined) {
65
+ entry.found = diagnostic.found;
66
+ }
67
+ if (diagnostic.required !== undefined) {
68
+ entry.required = diagnostic.required;
69
+ }
70
+ return entry;
71
+ }
72
+
73
+ function formatHumanDiagnostic(diagnostic: Diagnostic): string {
74
+ const color = diagnostic.severity === 'error' ? 'red' : 'yellow';
75
+ const location = diagnostic.package
76
+ ? `${diagnostic.module} → ${diagnostic.package}`
77
+ : diagnostic.module;
78
+ const lines = [styleText(color, `[${diagnostic.code}]`) + ` ${location}`];
79
+ if (diagnostic.check) {
80
+ lines.push(` check: ${diagnostic.check}`);
81
+ }
82
+ if (diagnostic.found !== undefined || diagnostic.required !== undefined) {
83
+ lines.push(
84
+ ` found: ${diagnostic.found ?? '—'} / required: ${diagnostic.required ?? '—'}`
85
+ );
86
+ }
87
+ lines.push(` ${diagnostic.message}`);
88
+ lines.push(` fix: ${diagnostic.fix}`);
89
+ return lines.join('\n');
90
+ }
91
+
92
+ function formatHumanReport(
93
+ diagnostics: Diagnostic[],
94
+ supply: Record<string, SupplyEntry>,
95
+ mounts: Record<string, ResolvedMount>
96
+ ): string {
97
+ const sections: string[] = [];
98
+ const errorCount = diagnostics.filter((d) => d.severity === 'error').length;
99
+ const warningCount = diagnostics.length - errorCount;
100
+
101
+ if (diagnostics.length === 0) {
102
+ sections.push(styleText('green', '✓ Declaration is valid.'));
103
+ } else {
104
+ sections.push(diagnostics.map(formatHumanDiagnostic).join('\n'));
105
+ sections.push(`${errorCount} error(s), ${warningCount} warning(s).`);
106
+ }
107
+
108
+ const supplyEntries = Object.entries(supply);
109
+ sections.push(
110
+ supplyEntries.length === 0
111
+ ? 'Supply: (empty)'
112
+ : [
113
+ 'Supply:',
114
+ ...supplyEntries.flatMap(([pkg, entry]) =>
115
+ entry.groups.map(
116
+ (group) =>
117
+ ` ${pkg} → ${group.provider}@${group.version ?? 'unresolved'}${
118
+ entry.groups.length > 1
119
+ ? ` (major ${group.major})`
120
+ : ''
121
+ }`
122
+ )
123
+ )
124
+ ].join('\n')
125
+ );
126
+
127
+ const mountEntries = Object.entries(mounts);
128
+ sections.push(
129
+ mountEntries.length === 0
130
+ ? 'Mounts: (empty)'
131
+ : [
132
+ 'Mounts:',
133
+ ...mountEntries.map(
134
+ ([name, mount]) =>
135
+ ` ${name} → ${mount.artifactDir} (${mount.built ? 'built' : 'not built'})`
136
+ )
137
+ ].join('\n')
138
+ );
139
+
140
+ return sections.join('\n\n');
141
+ }
142
+
143
+ /**
144
+ * `esmx validate` core (RFC 0001 §10 Phase 5, the agent verification loop).
145
+ * Pure with respect to process state: never writes to stdout/stderr and
146
+ * never exits — the CLI case owns printing and the exit code.
147
+ */
148
+ export async function runValidate(
149
+ rootDir: string,
150
+ options: RunValidateOptions = {}
151
+ ): Promise<RunValidateResult> {
152
+ const resolved = resolveDeclaration(rootDir);
153
+ if (!resolved) {
154
+ if (options.json) {
155
+ return {
156
+ exitCode: 0,
157
+ output: JSON.stringify(
158
+ { protocol: 'legacy', diagnostics: [] },
159
+ null,
160
+ 4
161
+ )
162
+ };
163
+ }
164
+ return {
165
+ exitCode: 0,
166
+ output: [
167
+ 'No "esmx" field in package.json — this module uses the legacy entry.node.ts config.',
168
+ 'That is fine: the new module protocol is opt-in. To adopt it, add an "esmx" field to package.json (see the module protocol docs).'
169
+ ].join('\n')
170
+ };
171
+ }
172
+
173
+ const { diagnostics, supply } = resolved;
174
+ const mounts = resolved.mounts;
175
+ const hasError = diagnostics.some((d) => d.severity === 'error');
176
+ const exitCode = hasError ? 1 : 0;
177
+
178
+ if (options.json) {
179
+ return {
180
+ exitCode,
181
+ output: JSON.stringify(
182
+ {
183
+ diagnostics: diagnostics.map(toEnvelopeEntry),
184
+ supply,
185
+ mounts
186
+ },
187
+ null,
188
+ 4
189
+ )
190
+ };
191
+ }
192
+ return {
193
+ exitCode,
194
+ output: formatHumanReport(diagnostics, supply, mounts)
195
+ };
196
+ }
package/src/core.ts CHANGED
@@ -8,6 +8,7 @@ import type { ImportMap, ScopesMap, SpecifierMap } from '@esmx/import';
8
8
 
9
9
  import serialize from 'serialize-javascript';
10
10
  import { type App, createApp } from './app';
11
+ import { resolveModuleOptions } from './declaration';
11
12
  import { getManifestList, type ManifestJson } from './manifest-json';
12
13
  import {
13
14
  type ModuleConfig,
@@ -396,13 +397,14 @@ export class Esmx {
396
397
  throw new Error('Cannot be initialized repeatedly');
397
398
  }
398
399
 
399
- const { name } = await this.readJson(
400
+ const packageJson = await this.readJson<Record<string, unknown>>(
400
401
  path.resolve(this.root, 'package.json')
401
402
  );
403
+ const name = String(packageJson.name);
402
404
  const moduleConfig = parseModuleConfig(
403
405
  name,
404
406
  this.root,
405
- this._options.modules
407
+ resolveModuleOptions(this.root, packageJson, this._options.modules)
406
408
  );
407
409
  const packConfig = parsePackConfig(this._options.packs);
408
410
  this._readied = {
@@ -472,6 +474,78 @@ export class Esmx {
472
474
  return true;
473
475
  }
474
476
 
477
+ /**
478
+ * Atomic generational relink (RFC 0001 §9).
479
+ *
480
+ * In-process remotes are not hot-swappable: the server import map and the
481
+ * module loader are captured once when the app is created. When a mounted
482
+ * remote republishes (new resolved versions, new wiring), the composer
483
+ * adopts it by relinking — building a NEW generation (fresh cache,
484
+ * re-resolved module config, rebuilt app capturing a new import map +
485
+ * loader) and switching to it atomically.
486
+ *
487
+ * The previous generation keeps serving throughout the rebuild; the new
488
+ * app is installed only once it is fully built, then the previous app's
489
+ * resources are released. If the rebuild fails, the previous generation
490
+ * is fully restored (rollback) and the error propagates — a failed relink
491
+ * never takes the running server down.
492
+ *
493
+ * Scope: the production render path (`start`/`preview`). Dev rebuilds its
494
+ * own app. Cross-compiler dev-watch invalidation remains future work.
495
+ *
496
+ * @returns true once the new generation is live.
497
+ * @throws {NotReadyError} when called before {@link init}.
498
+ *
499
+ * @example
500
+ * ```ts
501
+ * // After a mounted remote republishes:
502
+ * await esmx.reinit(); // new generation live, old requests drained
503
+ * ```
504
+ */
505
+ public async reinit(): Promise<boolean> {
506
+ const previous = this.readied;
507
+
508
+ const packageJson = await this.readJson<Record<string, unknown>>(
509
+ path.resolve(this.root, 'package.json')
510
+ );
511
+ const name = String(packageJson.name);
512
+ const moduleConfig = parseModuleConfig(
513
+ name,
514
+ this.root,
515
+ resolveModuleOptions(this.root, packageJson, this._options.modules)
516
+ );
517
+ const packConfig = parsePackConfig(this._options.packs);
518
+
519
+ // Install the new generation's config + fresh cache, but keep serving
520
+ // on the previous app until the new one is built. App-creation reads
521
+ // `this.readied`, so the rebuilt app captures the new import map.
522
+ this._readied = {
523
+ command: previous.command,
524
+ app: previous.app,
525
+ moduleConfig,
526
+ packConfig,
527
+ cache: createCache(this.isProd)
528
+ };
529
+
530
+ try {
531
+ const command = previous.command;
532
+ const devApp = this._options.devApp || defaultDevApp;
533
+ const app: App = [COMMAND.dev, COMMAND.build].includes(command)
534
+ ? await devApp(this)
535
+ : await createApp(this, command);
536
+ this.readied.app = app;
537
+ } catch (error) {
538
+ // Rollback: restore the previous generation untouched.
539
+ this._readied = previous;
540
+ throw error;
541
+ }
542
+
543
+ if (previous.app?.destroy) {
544
+ await previous.app.destroy();
545
+ }
546
+ return true;
547
+ }
548
+
475
549
  /**
476
550
  * Execute the application's build process.
477
551
  *
@@ -632,7 +706,7 @@ export class Esmx {
632
706
  await this._options.postBuild?.(this);
633
707
  return true;
634
708
  } catch (e) {
635
- console.error(e);
709
+ console.error('[@esmx/core] postBuild hook failed:', e);
636
710
  return false;
637
711
  }
638
712
  }
@@ -965,17 +1039,20 @@ export class Esmx {
965
1039
  src: string;
966
1040
  filepath: string;
967
1041
  code: string;
1042
+ integrity: Record<string, string> | null;
968
1043
  }
969
1044
  : {
970
1045
  src: null;
971
1046
  filepath: null;
972
1047
  code: string;
1048
+ integrity: Record<string, string> | null;
973
1049
  }
974
1050
  > {
975
1051
  return this.readied.cache(
976
1052
  `getImportMap-${mode}`,
977
1053
  async (): Promise<any> => {
978
1054
  const importmap = await this.getImportMap('client');
1055
+ const { integrity } = importmap;
979
1056
  const { basePathPlaceholder } = this;
980
1057
  let filepath: string | null = null;
981
1058
  if (this._importmapHash === null) {
@@ -1023,7 +1100,8 @@ document.head.appendChild(script);
1023
1100
  return {
1024
1101
  src,
1025
1102
  filepath,
1026
- code: `<script data-base="${basePathPlaceholder}" src="${src}"></script>`
1103
+ code: `<script data-base="${basePathPlaceholder}" src="${src}"></script>`,
1104
+ integrity: integrity ?? null
1027
1105
  };
1028
1106
  }
1029
1107
  if (basePathPlaceholder) {
@@ -1041,7 +1119,8 @@ document.head.appendChild(script);
1041
1119
  return {
1042
1120
  src: null,
1043
1121
  filepath: null,
1044
- code: `<script type="importmap">${serialize(importmap, { isJSON: true, unsafe: true })}</script>`
1122
+ code: `<script type="importmap">${serialize(importmap, { isJSON: true, unsafe: true })}</script>`,
1123
+ integrity: integrity ?? null
1045
1124
  };
1046
1125
  }
1047
1126
  );
@@ -0,0 +1,223 @@
1
+ import path from 'node:path';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import type { ModuleConfig } from '../module-config';
4
+ import { resolveDeclaration, resolveModuleOptions } from './index';
5
+ import {
6
+ createFixtureRoot,
7
+ removeFixtureRoot,
8
+ writeFixturePackage
9
+ } from './test-fixtures';
10
+
11
+ const fixtureRoots: string[] = [];
12
+
13
+ async function fixtureRoot(): Promise<string> {
14
+ const root = await createFixtureRoot();
15
+ fixtureRoots.push(root);
16
+ return root;
17
+ }
18
+
19
+ afterEach(async () => {
20
+ vi.restoreAllMocks();
21
+ await Promise.all(fixtureRoots.splice(0).map(removeFixtureRoot));
22
+ });
23
+
24
+ describe('resolveDeclaration', () => {
25
+ it('should return null when the package has no esmx field', async () => {
26
+ const root = await fixtureRoot();
27
+ const appDir = writeFixturePackage(root, {
28
+ dir: 'legacy',
29
+ packageJson: { name: 'legacy', version: '1.0.0' }
30
+ });
31
+
32
+ expect(resolveDeclaration(appDir)).toBeNull();
33
+ });
34
+
35
+ it('should compose reader, resolver and lowering', async () => {
36
+ const root = await fixtureRoot();
37
+ writeFixturePackage(root, {
38
+ dir: 'app/node_modules/shared',
39
+ packageJson: {
40
+ name: 'shared',
41
+ version: '1.0.0',
42
+ esmx: { provides: ['vue'] }
43
+ },
44
+ built: true
45
+ });
46
+ writeFixturePackage(root, {
47
+ dir: 'app/node_modules/shared/node_modules/vue',
48
+ packageJson: { name: 'vue', version: '3.4.21' }
49
+ });
50
+ const appDir = writeFixturePackage(root, {
51
+ dir: 'app',
52
+ packageJson: {
53
+ name: 'app',
54
+ version: '1.0.0',
55
+ dependencies: { vue: '^3.4.0' },
56
+ esmx: {
57
+ entry: {
58
+ client: './src/entry.client.ts',
59
+ server: './src/entry.server.ts'
60
+ },
61
+ uses: ['shared']
62
+ }
63
+ }
64
+ });
65
+
66
+ const result = resolveDeclaration(appDir);
67
+
68
+ expect(result).not.toBeNull();
69
+ expect(result?.supply.vue).toEqual({
70
+ groups: [{ major: 3, provider: 'shared', version: '3.4.21' }]
71
+ });
72
+ expect(result?.config).toEqual({
73
+ links: {
74
+ shared: path.join(appDir, 'node_modules/shared/dist')
75
+ },
76
+ imports: { vue: 'shared/vue' }
77
+ });
78
+ expect(
79
+ result?.diagnostics.filter((d) => d.severity === 'error')
80
+ ).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe('resolveModuleOptions', () => {
85
+ it('should keep the legacy path untouched when there is no esmx field', async () => {
86
+ const root = await fixtureRoot();
87
+ const appDir = writeFixturePackage(root, {
88
+ dir: 'legacy',
89
+ packageJson: { name: 'legacy', version: '1.0.0' }
90
+ });
91
+ const modules: ModuleConfig = {
92
+ imports: { vue: 'shared/vue' },
93
+ exports: ['pkg:vue']
94
+ };
95
+
96
+ const result = resolveModuleOptions(
97
+ appDir,
98
+ { name: 'legacy' },
99
+ modules
100
+ );
101
+
102
+ expect(result).toBe(modules);
103
+ });
104
+
105
+ it('should throw E_PROTOCOL_IN_BEHAVIOR when esmx coexists with protocol fields in options.modules', async () => {
106
+ const root = await fixtureRoot();
107
+ const appDir = writeFixturePackage(root, {
108
+ dir: 'app',
109
+ packageJson: {
110
+ name: 'app',
111
+ version: '1.0.0',
112
+ esmx: { provides: ['vue'] }
113
+ }
114
+ });
115
+
116
+ expect(() =>
117
+ resolveModuleOptions(
118
+ appDir,
119
+ { name: 'app', esmx: { provides: ['vue'] } },
120
+ { imports: { vue: 'shared/vue' } }
121
+ )
122
+ ).toThrowError(/E_PROTOCOL_IN_BEHAVIOR/);
123
+ });
124
+
125
+ it('should allow links-only options.modules as environment overrides', async () => {
126
+ const root = await fixtureRoot();
127
+ writeFixturePackage(root, {
128
+ dir: 'shared',
129
+ packageJson: {
130
+ name: 'shared',
131
+ version: '1.0.0',
132
+ esmx: { provides: ['vue'] }
133
+ },
134
+ built: true
135
+ });
136
+ writeFixturePackage(root, {
137
+ dir: 'shared/node_modules/vue',
138
+ packageJson: { name: 'vue', version: '3.4.21' }
139
+ });
140
+ const appDir = writeFixturePackage(root, {
141
+ dir: 'app',
142
+ packageJson: {
143
+ name: 'app',
144
+ version: '1.0.0',
145
+ dependencies: { vue: '^3.4.0' },
146
+ esmx: {
147
+ entry: {
148
+ client: './src/entry.client.ts',
149
+ server: './src/entry.server.ts'
150
+ },
151
+ uses: ['shared']
152
+ }
153
+ }
154
+ });
155
+
156
+ const result = resolveModuleOptions(
157
+ appDir,
158
+ { name: 'app', esmx: {} },
159
+ { links: { shared: '../shared/dist' } }
160
+ );
161
+
162
+ expect(result).toEqual({
163
+ links: { shared: path.join(root, 'shared/dist') },
164
+ imports: { vue: 'shared/vue' }
165
+ });
166
+ });
167
+
168
+ it('should throw a clear error listing error diagnostics', async () => {
169
+ const root = await fixtureRoot();
170
+ const appDir = writeFixturePackage(root, {
171
+ dir: 'app',
172
+ packageJson: {
173
+ name: 'app',
174
+ version: '1.0.0',
175
+ esmx: {
176
+ entry: { client: './src/entry.client.ts' },
177
+ uses: ['ghost']
178
+ }
179
+ }
180
+ });
181
+
182
+ expect(() =>
183
+ resolveModuleOptions(appDir, { name: 'app', esmx: {} })
184
+ ).toThrowError(/E_NOT_LINKED[\s\S]*ghost/);
185
+ });
186
+
187
+ it('should print warnings instead of throwing', async () => {
188
+ const root = await fixtureRoot();
189
+ writeFixturePackage(root, {
190
+ dir: 'app/node_modules/base',
191
+ packageJson: {
192
+ name: 'base',
193
+ version: '1.0.0',
194
+ dependencies: { vue: '^3.4.0' },
195
+ esmx: { provides: ['vue'] }
196
+ },
197
+ built: true
198
+ });
199
+ writeFixturePackage(root, {
200
+ dir: 'app/node_modules/base/node_modules/vue',
201
+ packageJson: { name: 'vue', version: '3.4.21' }
202
+ });
203
+ const appDir = writeFixturePackage(root, {
204
+ dir: 'app',
205
+ packageJson: {
206
+ name: 'app',
207
+ version: '1.0.0',
208
+ esmx: {
209
+ entry: { client: './src/entry.client.ts' },
210
+ uses: ['base']
211
+ }
212
+ }
213
+ });
214
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
215
+
216
+ const result = resolveModuleOptions(appDir, { name: 'app', esmx: {} });
217
+
218
+ expect(result?.imports).toEqual({ vue: 'base/vue' });
219
+ expect(warn).toHaveBeenCalledWith(
220
+ expect.stringContaining('W_NO_RANGE')
221
+ );
222
+ });
223
+ });