@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
@@ -1,6 +1,7 @@
1
1
  import { assert, describe, test } from "vitest";
2
2
  import {
3
3
  compressImportMap,
4
+ createClientImportMap,
4
5
  createImportMap,
5
6
  createImportsMap,
6
7
  createScopesMap,
@@ -1584,3 +1585,230 @@ describe("compressImportMap", () => {
1584
1585
  });
1585
1586
  });
1586
1587
  });
1588
+ describe("createClientImportMap integrity", () => {
1589
+ const getFile = (name, file) => `/${name}/${file}`;
1590
+ const getScope = (name, scope) => scope;
1591
+ test("omits integrity when no manifest provides it", () => {
1592
+ const manifests = [
1593
+ {
1594
+ name: "module-a",
1595
+ exports: {
1596
+ index: {
1597
+ name: "index",
1598
+ pkg: false,
1599
+ file: "src/index.mjs",
1600
+ identifier: "module-a"
1601
+ }
1602
+ },
1603
+ scopes: {}
1604
+ }
1605
+ ];
1606
+ const result = createClientImportMap({ manifests, getFile, getScope });
1607
+ assert.isUndefined(result.integrity);
1608
+ });
1609
+ test("converts relative integrity paths to absolute URL paths", () => {
1610
+ const manifests = [
1611
+ {
1612
+ name: "ssr-micro-vue2",
1613
+ exports: {
1614
+ routes: {
1615
+ name: "routes",
1616
+ pkg: false,
1617
+ file: "src/routes.xxx.mjs",
1618
+ identifier: "ssr-micro-vue2/routes"
1619
+ }
1620
+ },
1621
+ scopes: {},
1622
+ integrity: {
1623
+ "src/routes.xxx.mjs": "sha384-abc"
1624
+ }
1625
+ }
1626
+ ];
1627
+ const result = createClientImportMap({ manifests, getFile, getScope });
1628
+ assert.deepEqual(result.integrity, {
1629
+ "/ssr-micro-vue2/src/routes.xxx.mjs": "sha384-abc"
1630
+ });
1631
+ });
1632
+ test("merges integrity across multiple manifests", () => {
1633
+ const manifests = [
1634
+ {
1635
+ name: "module-a",
1636
+ exports: {},
1637
+ scopes: {},
1638
+ integrity: { "src/a.mjs": "sha384-a" }
1639
+ },
1640
+ {
1641
+ name: "module-b",
1642
+ exports: {},
1643
+ scopes: {},
1644
+ integrity: { "src/b.mjs": "sha384-b" }
1645
+ }
1646
+ ];
1647
+ const result = createClientImportMap({ manifests, getFile, getScope });
1648
+ assert.deepEqual(result.integrity, {
1649
+ "/module-a/src/a.mjs": "sha384-a",
1650
+ "/module-b/src/b.mjs": "sha384-b"
1651
+ });
1652
+ });
1653
+ });
1654
+ describe("createClientImportMap code-split chunk scopes", () => {
1655
+ const getFile = (name, file) => `/${name}/${file}`;
1656
+ const getScope = (name, scope) => `/${name}${scope}`;
1657
+ const makeVueModule = (name, hash) => ({
1658
+ name,
1659
+ exports: {
1660
+ entry: {
1661
+ name: "entry",
1662
+ pkg: false,
1663
+ file: `src/entry.client.${hash}.mjs`,
1664
+ identifier: `${name}/src/entry.client`
1665
+ },
1666
+ vue: {
1667
+ name: "vue",
1668
+ pkg: true,
1669
+ file: `vue.${hash}.mjs`,
1670
+ identifier: `${name}/vue`
1671
+ }
1672
+ },
1673
+ scopes: { "": { vue: `${name}/vue` } },
1674
+ // A Vite-style code-split chunk that is NOT an export.
1675
+ chunks: { routesChunk: { js: `chunks/routes.${hash}.mjs` } }
1676
+ });
1677
+ test("scopes a module code-split chunk to its own externals", () => {
1678
+ const manifests = [
1679
+ makeVueModule("app-a", "aaa"),
1680
+ makeVueModule("app-b", "bbb")
1681
+ ];
1682
+ const result = createClientImportMap({ manifests, getFile, getScope });
1683
+ assert.isUndefined(result.imports?.vue);
1684
+ assert.equal(
1685
+ result.scopes?.["/app-a/chunks/routes.aaa.mjs"]?.vue,
1686
+ "/app-a/vue.aaa.mjs"
1687
+ );
1688
+ assert.equal(
1689
+ result.scopes?.["/app-b/chunks/routes.bbb.mjs"]?.vue,
1690
+ "/app-b/vue.bbb.mjs"
1691
+ );
1692
+ });
1693
+ test("skips a chunk scope when a global import already resolves it", () => {
1694
+ const result = createClientImportMap({
1695
+ manifests: [makeVueModule("solo", "aaa")],
1696
+ getFile,
1697
+ getScope
1698
+ });
1699
+ assert.equal(result.imports?.vue, "/solo/vue.aaa.mjs");
1700
+ assert.isUndefined(result.scopes?.["/solo/chunks/routes.aaa.mjs"]);
1701
+ });
1702
+ test("is a no-op for all-in-one manifests without code-split chunks", () => {
1703
+ const allInOne = {
1704
+ name: "rspack-app",
1705
+ exports: {
1706
+ entry: {
1707
+ name: "entry",
1708
+ pkg: false,
1709
+ file: "src/entry.client.zzz.mjs",
1710
+ identifier: "rspack-app/src/entry.client"
1711
+ },
1712
+ vue: {
1713
+ name: "vue",
1714
+ pkg: true,
1715
+ file: "vue.zzz.mjs",
1716
+ identifier: "rspack-app/vue"
1717
+ }
1718
+ },
1719
+ scopes: { "": { vue: "rspack-app/vue" } }
1720
+ // no `chunks` — rspack all-in-one output
1721
+ };
1722
+ const result = createClientImportMap({
1723
+ manifests: [allInOne, makeVueModule("app-b", "bbb")],
1724
+ getFile,
1725
+ getScope
1726
+ });
1727
+ const invented = Object.keys(result.scopes ?? {}).filter(
1728
+ (k) => k.startsWith("/rspack-app/chunks/")
1729
+ );
1730
+ assert.deepEqual(invented, []);
1731
+ });
1732
+ test("applies all of a module externals to its chunk", () => {
1733
+ const make = (name, hash) => ({
1734
+ name,
1735
+ exports: {
1736
+ vue: {
1737
+ name: "vue",
1738
+ pkg: true,
1739
+ file: `vue.${hash}.mjs`,
1740
+ identifier: `${name}/vue`
1741
+ },
1742
+ pinia: {
1743
+ name: "pinia",
1744
+ pkg: true,
1745
+ file: `pinia.${hash}.mjs`,
1746
+ identifier: `${name}/pinia`
1747
+ }
1748
+ },
1749
+ scopes: { "": { vue: `${name}/vue`, pinia: `${name}/pinia` } },
1750
+ chunks: { storeChunk: { js: `chunks/store.${hash}.mjs` } }
1751
+ });
1752
+ const result = createClientImportMap({
1753
+ manifests: [make("app-a", "aaa"), make("app-b", "bbb")],
1754
+ getFile,
1755
+ getScope
1756
+ });
1757
+ const scope = result.scopes?.["/app-a/chunks/store.aaa.mjs"];
1758
+ assert.equal(scope?.vue, "/app-a/vue.aaa.mjs");
1759
+ assert.equal(scope?.pinia, "/app-a/pinia.aaa.mjs");
1760
+ });
1761
+ });
1762
+ describe("multi-version coexistence (scope isolation)", () => {
1763
+ const layeredManifests = [
1764
+ {
1765
+ name: "base",
1766
+ exports: {
1767
+ vue: {
1768
+ name: "vue",
1769
+ pkg: true,
1770
+ file: "vue.base.mjs",
1771
+ identifier: "base/vue"
1772
+ }
1773
+ },
1774
+ scopes: { "": { vue: "base/vue" } },
1775
+ chunks: { "base@src/app.ts": { js: "app.base.mjs" } },
1776
+ integrity: {
1777
+ "vue.base.mjs": "sha384-base",
1778
+ "app.base.mjs": "sha384-base-app"
1779
+ }
1780
+ },
1781
+ {
1782
+ name: "vue-base",
1783
+ exports: {
1784
+ vue: {
1785
+ name: "vue",
1786
+ pkg: true,
1787
+ file: "vue.winner.mjs",
1788
+ identifier: "vue-base/vue"
1789
+ }
1790
+ },
1791
+ scopes: { "": { vue: "vue-base/vue" } },
1792
+ integrity: { "vue.winner.mjs": "sha384-winner" }
1793
+ }
1794
+ ];
1795
+ const getFile = (name, file) => `/${name}/${file}`;
1796
+ const getScope = (name, scope) => `/${name}${scope}`;
1797
+ test("each copy stays isolated in its own scope", () => {
1798
+ const result = createImportMap({
1799
+ manifests: layeredManifests,
1800
+ getFile,
1801
+ getScope
1802
+ });
1803
+ assert.equal(result.imports["base/vue"], "/base/vue.base.mjs");
1804
+ assert.equal(
1805
+ result.imports["vue-base/vue"],
1806
+ "/vue-base/vue.winner.mjs"
1807
+ );
1808
+ assert.equal(result.scopes["/base/"]?.vue, "/base/vue.base.mjs");
1809
+ assert.equal(
1810
+ result.scopes["/vue-base/"]?.vue,
1811
+ "/vue-base/vue.winner.mjs"
1812
+ );
1813
+ });
1814
+ });
package/package.json CHANGED
@@ -59,12 +59,12 @@
59
59
  "build": "unbuild"
60
60
  },
61
61
  "dependencies": {
62
- "@esmx/import": "workspace:*",
62
+ "@esmx/import": "3.0.0-rc.118",
63
63
  "@types/serialize-javascript": "^5.0.4",
64
64
  "es-module-lexer": "^1.7.0",
65
65
  "find": "^0.3.0",
66
66
  "send": "^1.2.0",
67
- "serialize-javascript": "^6.0.2"
67
+ "serialize-javascript": "^7.0.5"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@biomejs/biome": "2.3.7",
@@ -72,12 +72,12 @@
72
72
  "@types/node": "^24.0.0",
73
73
  "@types/send": "^1.2.1",
74
74
  "@types/write": "^2.0.4",
75
- "@vitest/coverage-v8": "3.2.4",
75
+ "@vitest/coverage-v8": "3.2.6",
76
76
  "typescript": "5.9.3",
77
77
  "unbuild": "3.6.1",
78
- "vitest": "3.2.4"
78
+ "vitest": "3.2.6"
79
79
  },
80
- "version": "3.0.0-rc.116",
80
+ "version": "3.0.0-rc.118",
81
81
  "type": "module",
82
82
  "private": false,
83
83
  "exports": {
@@ -100,5 +100,8 @@
100
100
  "template",
101
101
  "public"
102
102
  ],
103
- "gitHead": "2e16b175552400382913b445ae7e112b78c422f5"
103
+ "gitHead": "2fdbd62470f64e6e45568550941c78cb259e4917",
104
+ "publishConfig": {
105
+ "access": "public"
106
+ }
104
107
  }
package/src/app.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { pathToFileURL } from 'node:url';
2
2
  import { createLoaderImport } from '@esmx/import';
3
3
  import type { COMMAND, Esmx } from './core';
4
+ import { DEFAULT_MODULE_ENTRY } from './module-config';
4
5
  import {
5
6
  RenderContext,
6
7
  type RenderContextOptions,
@@ -123,10 +124,12 @@ async function createStartRender(esmx: Esmx) {
123
124
  const baseURL = pathToFileURL(esmx.resolvePath('dist/server')) as URL;
124
125
  const importMap = await esmx.getImportMap('server');
125
126
  const loaderImport = createLoaderImport(baseURL, importMap);
127
+ const serverEntry =
128
+ esmx.moduleConfig.entry.server ?? DEFAULT_MODULE_ENTRY.server;
126
129
 
127
130
  return async (options?: RenderContextOptions): Promise<RenderContext> => {
128
131
  const rc = new RenderContext(esmx, options);
129
- const result = await loaderImport(`${esmx.name}/src/entry.server`);
132
+ const result = await loaderImport(`${esmx.name}/${serverEntry.name}`);
130
133
  const serverRender: ServerRenderHandle = result[rc.entryName];
131
134
  if (typeof serverRender === 'function') {
132
135
  await serverRender(rc);
@@ -138,7 +141,7 @@ async function createStartRender(esmx: Esmx) {
138
141
  function createErrorRender(esmx: Esmx) {
139
142
  return (options?: RenderContextOptions) => {
140
143
  throw new Error(
141
- `App instance is only available in production and can only execute built artifacts.`
144
+ `[@esmx/core] esmx.render() is only available in production. The current process is in dev mode and has no built artifacts. Run \`esmx build\` first, then start the server with NODE_ENV=production node dist/index.mjs.`
142
145
  );
143
146
  };
144
147
  }
package/src/cli/cli.ts CHANGED
@@ -4,6 +4,7 @@ import pkg from '../../package.json' with { type: 'json' };
4
4
 
5
5
  import { COMMAND, Esmx, type EsmxOptions } from '../core';
6
6
  import { resolveImportPath } from '../utils/resolve-path';
7
+ import { runValidate, VALIDATE_HELP } from './validate';
7
8
 
8
9
  async function getSrcOptions(): Promise<EsmxOptions> {
9
10
  return import(resolveImportPath(process.cwd(), './src/entry.node.ts')).then(
@@ -30,6 +31,22 @@ async function getDistOptions(): Promise<EsmxOptions> {
30
31
  }
31
32
 
32
33
  export async function cli(command: string) {
34
+ if (command === 'validate') {
35
+ // Handled before the banner: `--json` must keep stdout pure JSON.
36
+ const flags = process.argv.slice(3);
37
+ if (flags.includes('--help')) {
38
+ console.log(VALIDATE_HELP);
39
+ return;
40
+ }
41
+ const result = await runValidate(process.cwd(), {
42
+ json: flags.includes('--json')
43
+ });
44
+ console.log(result.output);
45
+ if (result.exitCode !== 0) {
46
+ process.exit(result.exitCode);
47
+ }
48
+ return;
49
+ }
33
50
  console.log(`🔥 ${styleText('yellow', 'Esmx')} v${pkg.version}
34
51
  `);
35
52
  if (
@@ -111,7 +128,7 @@ export function resolve(
111
128
  nextResolve: Function
112
129
  ) {
113
130
  if (
114
- context?.parentURL.endsWith('.ts') &&
131
+ context?.parentURL?.endsWith('.ts') &&
115
132
  specifier.startsWith('.') &&
116
133
  !specifier.endsWith('.ts')
117
134
  ) {
@@ -119,3 +136,29 @@ export function resolve(
119
136
  }
120
137
  return nextResolve(specifier, context);
121
138
  }
139
+
140
+ /**
141
+ * Style assets imported via standard `import './x.css'` are part of esmx's
142
+ * federation contract (manifest's `chunks[*].css[]` — see G section). On the
143
+ * cli/build path Node's native loader hits these when reading the user's
144
+ * `entry.node.ts` config (which transitively imports a remote's source). They
145
+ * have no server-side behaviour — emit a no-op ESM module so the loader chain
146
+ * continues cleanly. Mirrors `@esmx/import`'s VM-linker hook.
147
+ */
148
+ const STYLE_ASSET_RE =
149
+ /\.(?:css|scss|sass|less|stylus|styl|pcss|postcss)(?:\?.*)?$/i;
150
+
151
+ export async function load(
152
+ url: string,
153
+ context: Record<string, any>,
154
+ nextLoad: Function
155
+ ) {
156
+ if (STYLE_ASSET_RE.test(url)) {
157
+ return {
158
+ format: 'module',
159
+ shortCircuit: true,
160
+ source: `export default ${JSON.stringify(url)}; export const href = ${JSON.stringify(url)};`
161
+ };
162
+ }
163
+ return nextLoad(url, context);
164
+ }
@@ -0,0 +1,251 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import url from 'node:url';
4
+ import util from 'node:util';
5
+
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ import {
9
+ createFixtureRoot,
10
+ removeFixtureRoot,
11
+ writeFixturePackage
12
+ } from '../declaration/test-fixtures';
13
+ import { runValidate } from './validate';
14
+
15
+ const fixtureRoots: string[] = [];
16
+
17
+ async function fixtureRoot(): Promise<string> {
18
+ const root = await createFixtureRoot();
19
+ fixtureRoots.push(root);
20
+ return root;
21
+ }
22
+
23
+ afterEach(async () => {
24
+ vi.restoreAllMocks();
25
+ await Promise.all(fixtureRoots.splice(0).map(removeFixtureRoot));
26
+ });
27
+
28
+ async function legacyFixture(): Promise<string> {
29
+ const root = await fixtureRoot();
30
+ return writeFixturePackage(root, {
31
+ dir: 'legacy',
32
+ packageJson: { name: 'legacy', version: '1.0.0' }
33
+ });
34
+ }
35
+
36
+ /** App using `base`, which provides vue but app declares no range → W_NO_RANGE. */
37
+ async function warningOnlyFixture(): Promise<string> {
38
+ const root = await fixtureRoot();
39
+ writeFixturePackage(root, {
40
+ dir: 'app/node_modules/base',
41
+ packageJson: {
42
+ name: 'base',
43
+ version: '1.0.0',
44
+ dependencies: { vue: '^3.4.0' },
45
+ esmx: { provides: ['vue'] }
46
+ },
47
+ built: true
48
+ });
49
+ writeFixturePackage(root, {
50
+ dir: 'app/node_modules/base/node_modules/vue',
51
+ packageJson: { name: 'vue', version: '3.4.21' }
52
+ });
53
+ return writeFixturePackage(root, {
54
+ dir: 'app',
55
+ packageJson: {
56
+ name: 'app',
57
+ version: '1.0.0',
58
+ esmx: {
59
+ entry: { client: './src/entry.client.ts' },
60
+ uses: ['base']
61
+ }
62
+ }
63
+ });
64
+ }
65
+
66
+ /** Layered merge (Gate 2): vue-base wins over base → one election. */
67
+ async function dupProviderFixture(): Promise<string> {
68
+ const root = await fixtureRoot();
69
+ writeFixturePackage(root, {
70
+ dir: 'node_modules/base',
71
+ packageJson: {
72
+ name: 'base',
73
+ version: '1.0.0',
74
+ dependencies: { vue: '^3.4.0' },
75
+ esmx: { provides: ['vue'] }
76
+ },
77
+ built: true
78
+ });
79
+ writeFixturePackage(root, {
80
+ dir: 'node_modules/base/node_modules/vue',
81
+ packageJson: { name: 'vue', version: '3.4.21' }
82
+ });
83
+ writeFixturePackage(root, {
84
+ dir: 'node_modules/vue-base',
85
+ packageJson: {
86
+ name: 'vue-base',
87
+ version: '1.0.0',
88
+ dependencies: { vue: '^3.4.0' },
89
+ esmx: { uses: ['base'], provides: ['vue'] }
90
+ },
91
+ built: true
92
+ });
93
+ writeFixturePackage(root, {
94
+ dir: 'node_modules/vue-base/node_modules/vue',
95
+ packageJson: { name: 'vue', version: '3.5.13' }
96
+ });
97
+ return writeFixturePackage(root, {
98
+ dir: 'app',
99
+ packageJson: {
100
+ name: 'app',
101
+ version: '1.0.0',
102
+ dependencies: { vue: '^3.4.0' },
103
+ esmx: { uses: ['vue-base'] }
104
+ }
105
+ });
106
+ }
107
+
108
+ async function notLinkedFixture(): Promise<string> {
109
+ const root = await fixtureRoot();
110
+ return writeFixturePackage(root, {
111
+ dir: 'app',
112
+ packageJson: {
113
+ name: 'app',
114
+ version: '1.0.0',
115
+ esmx: {
116
+ entry: { client: './src/entry.client.ts' },
117
+ uses: ['ghost']
118
+ }
119
+ }
120
+ });
121
+ }
122
+
123
+ describe('runValidate', () => {
124
+ it('should exit 0 with protocol legacy for a package without esmx field', async () => {
125
+ const appDir = await legacyFixture();
126
+
127
+ const jsonResult = await runValidate(appDir, { json: true });
128
+ const humanResult = await runValidate(appDir);
129
+
130
+ expect(jsonResult.exitCode).toBe(0);
131
+ expect(JSON.parse(jsonResult.output)).toEqual({
132
+ protocol: 'legacy',
133
+ diagnostics: []
134
+ });
135
+ expect(humanResult.exitCode).toBe(0);
136
+ expect(humanResult.output).toContain('entry.node.ts');
137
+ expect(humanResult.output).toContain('opt-in');
138
+ });
139
+
140
+ it('should exit 0 when the declaration only has warnings', async () => {
141
+ const appDir = await warningOnlyFixture();
142
+
143
+ const result = await runValidate(appDir, { json: true });
144
+
145
+ const envelope = JSON.parse(result.output);
146
+ expect(result.exitCode).toBe(0);
147
+ expect(envelope.diagnostics).toHaveLength(1);
148
+ expect(envelope.diagnostics[0].code).toBe('W_NO_RANGE');
149
+ expect(envelope.supply.vue).toEqual({
150
+ groups: [{ major: 3, provider: 'base', version: '3.4.21' }]
151
+ });
152
+ expect(Object.keys(envelope.mounts)).toEqual(['base']);
153
+ expect(envelope.mounts.base.built).toBe(true);
154
+ });
155
+
156
+ it('should exit non-zero for E_NOT_LINKED with the exact envelope entry shape', async () => {
157
+ const appDir = await notLinkedFixture();
158
+
159
+ const result = await runValidate(appDir, { json: true });
160
+
161
+ const envelope = JSON.parse(result.output);
162
+ expect(result.exitCode).toBe(1);
163
+ const entry = envelope.diagnostics.find(
164
+ (d: { code: string }) => d.code === 'E_NOT_LINKED'
165
+ );
166
+ expect(entry).toBeDefined();
167
+ expect(entry.module).toBe('app');
168
+ expect(typeof entry.message).toBe('string');
169
+ expect(typeof entry.fix).toBe('string');
170
+ expect(entry).not.toHaveProperty('severity');
171
+ });
172
+
173
+ it('should exit non-zero and surface E_DUP_PROVIDER in the JSON envelope', async () => {
174
+ const appDir = await dupProviderFixture();
175
+
176
+ const result = await runValidate(appDir, { json: true });
177
+
178
+ const envelope = JSON.parse(result.output);
179
+ expect(result.exitCode).toBe(1);
180
+ const entry = envelope.diagnostics.find(
181
+ (d: { code: string }) => d.code === 'E_DUP_PROVIDER'
182
+ );
183
+ expect(entry).toBeDefined();
184
+ expect(entry.module).toBe('vue-base');
185
+ expect(entry.package).toBe('vue');
186
+ expect(entry.found).toBe('base, vue-base');
187
+ expect(envelope).not.toHaveProperty('elections');
188
+ });
189
+
190
+ it('should print E_DUP_PROVIDER in the human report', async () => {
191
+ const appDir = await dupProviderFixture();
192
+
193
+ const result = await runValidate(appDir);
194
+
195
+ const plain = util.stripVTControlCharacters(result.output);
196
+ expect(plain).toContain('[E_DUP_PROVIDER]');
197
+ expect(plain).toContain('vue-base → vue');
198
+ expect(plain).toContain('base, vue-base');
199
+ });
200
+
201
+ it('should report diagnostics, supply and mounts in human mode', async () => {
202
+ const appDir = await warningOnlyFixture();
203
+
204
+ const result = await runValidate(appDir);
205
+
206
+ const plain = util.stripVTControlCharacters(result.output);
207
+ expect(result.exitCode).toBe(0);
208
+ expect(plain).toContain('[W_NO_RANGE]');
209
+ expect(plain).toContain('fix:');
210
+ expect(plain).toContain('vue → base@3.4.21');
211
+ expect(plain).toMatch(/base → .*dist \(built\)/);
212
+ });
213
+
214
+ it('should produce pure JSON output without writing to the console', async () => {
215
+ const appDir = await notLinkedFixture();
216
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {});
217
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
218
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
219
+
220
+ const result = await runValidate(appDir, { json: true });
221
+
222
+ expect(() => JSON.parse(result.output)).not.toThrow();
223
+ expect(log).not.toHaveBeenCalled();
224
+ expect(warn).not.toHaveBeenCalled();
225
+ expect(error).not.toHaveBeenCalled();
226
+ });
227
+ });
228
+
229
+ const distCli = path.resolve(
230
+ path.dirname(url.fileURLToPath(import.meta.url)),
231
+ '../../dist/cli/index.mjs'
232
+ );
233
+
234
+ // Spawn-based smoke test: only meaningful against a built CLI; building
235
+ // inside tests is out of scope, so skip when dist is absent.
236
+ describe.skipIf(!fs.existsSync(distCli))('esmx validate (spawned CLI)', () => {
237
+ it('should emit only the JSON envelope on stdout and exit non-zero on errors', async () => {
238
+ const childProcess = await import('node:child_process');
239
+ const appDir = await notLinkedFixture();
240
+
241
+ const spawned = childProcess.spawnSync(
242
+ process.execPath,
243
+ [distCli, 'validate', '--json'],
244
+ { cwd: appDir, encoding: 'utf-8' }
245
+ );
246
+
247
+ expect(spawned.status).toBe(1);
248
+ const envelope = JSON.parse(spawned.stdout);
249
+ expect(envelope.diagnostics[0].code).toBe('E_NOT_LINKED');
250
+ });
251
+ });