slicejs-cli 3.5.0 → 3.5.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.
Files changed (46) hide show
  1. package/.github/workflows/ci.yml +43 -0
  2. package/commands/createComponent/createComponent.js +6 -2
  3. package/commands/deleteComponent/deleteComponent.js +4 -0
  4. package/commands/doctor/doctor.js +9 -0
  5. package/commands/utils/bundling/BundleGenerator.js +271 -38
  6. package/package.json +4 -1
  7. package/playwright.config.js +51 -0
  8. package/tests/build-command-integration.test.js +87 -0
  9. package/tests/build-production-e2e.test.js +140 -0
  10. package/tests/builder-edge-cases.test.js +322 -0
  11. package/tests/bundle-generate-e2e.test.js +115 -0
  12. package/tests/bundling-dependency-edges.test.js +127 -0
  13. package/tests/bundling-imports-unit.test.js +267 -0
  14. package/tests/commands-component-crud.test.js +102 -0
  15. package/tests/commands-doctor.test.js +80 -0
  16. package/tests/commands-version-checker.test.js +37 -0
  17. package/tests/component-registry-parse.test.js +1 -1
  18. package/tests/e2e/bundles.spec.js +91 -0
  19. package/tests/e2e/dependency-scenarios.spec.js +56 -0
  20. package/tests/e2e/fixtures/components/Service/FetchManager/FetchManager.js +136 -0
  21. package/tests/e2e/fixtures/components/Service/IndexedDbManager/IndexedDbManager.js +149 -0
  22. package/tests/e2e/fixtures/components/Service/LocalStorageManager/LocalStorageManager.js +45 -0
  23. package/tests/e2e/fixtures/components/Visual/Button/Button.css +106 -0
  24. package/tests/e2e/fixtures/components/Visual/Button/Button.html +5 -0
  25. package/tests/e2e/fixtures/components/Visual/Button/Button.js +158 -0
  26. package/tests/e2e/fixtures/components/Visual/Link/Link.js +33 -0
  27. package/tests/e2e/fixtures/components/Visual/Loading/Loading.css +56 -0
  28. package/tests/e2e/fixtures/components/Visual/Loading/Loading.html +83 -0
  29. package/tests/e2e/fixtures/components/Visual/Loading/Loading.js +164 -0
  30. package/tests/e2e/fixtures/components/Visual/MultiRoute/MultiRoute.js +167 -0
  31. package/tests/e2e/fixtures/components/Visual/Navbar/Navbar.css +116 -0
  32. package/tests/e2e/fixtures/components/Visual/Navbar/Navbar.html +44 -0
  33. package/tests/e2e/fixtures/components/Visual/Navbar/Navbar.js +180 -0
  34. package/tests/e2e/fixtures/components/Visual/NotFound/NotFound.js +20 -0
  35. package/tests/e2e/fixtures/components/Visual/Route/Route.js +181 -0
  36. package/tests/e2e/fixtures/components/registry.json +12 -0
  37. package/tests/e2e/fixtures/vendor-components.mjs +65 -0
  38. package/tests/e2e/navigation.spec.js +44 -0
  39. package/tests/e2e/render.spec.js +34 -0
  40. package/tests/e2e/serve.mjs +264 -0
  41. package/tests/e2e/shared-deps.spec.js +61 -0
  42. package/tests/e2e/unminified.spec.js +33 -0
  43. package/tests/e2e-serve.test.js +148 -0
  44. package/tests/helpers/setup.js +6 -1
  45. package/tests/perf-budget.test.js +86 -0
  46. package/tests/types-generator.test.js +2 -0
@@ -0,0 +1,140 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'fs-extra';
4
+ import path from 'node:path';
5
+ import { parse } from '@babel/parser';
6
+ import { withTestProject } from './helpers/setup.js';
7
+ import buildProduction from '../commands/buildProduction/buildProduction.js';
8
+
9
+ const MODULE_URL = import.meta.url;
10
+
11
+ async function writeSrc(root, rel, content) {
12
+ const p = path.join(root, 'src', rel);
13
+ await fs.ensureDir(path.dirname(p));
14
+ await fs.writeFile(p, content, 'utf8');
15
+ return p;
16
+ }
17
+
18
+ describe('buildProduction end-to-end', () => {
19
+ test('produces a dist/ that preserves the Slice.js critical files', async () => {
20
+ await withTestProject(async (root) => {
21
+ const ok = await buildProduction({ minify: false });
22
+ assert.equal(ok, true, 'build should succeed on the starter project');
23
+
24
+ const dist = path.join(root, 'dist');
25
+ assert.ok(await fs.pathExists(path.join(dist, 'sliceConfig.json')));
26
+ assert.ok(await fs.pathExists(path.join(dist, 'Components', 'components.js')));
27
+ assert.ok(await fs.pathExists(path.join(dist, 'App', 'index.js')));
28
+ });
29
+ });
30
+
31
+ test('sliceConfig.json is copied verbatim (never minified)', async () => {
32
+ await withTestProject(async (root) => {
33
+ await buildProduction({ minify: true });
34
+ const srcCfg = await fs.readFile(path.join(root, 'src', 'sliceConfig.json'), 'utf8');
35
+ const distCfg = await fs.readFile(path.join(root, 'dist', 'sliceConfig.json'), 'utf8');
36
+ assert.equal(distCfg, srcCfg);
37
+ });
38
+ });
39
+
40
+ test('components.js keeps its registry structure after minification', async () => {
41
+ await withTestProject(async (root) => {
42
+ await buildProduction({ minify: true });
43
+ const built = await fs.readFile(path.join(root, 'dist', 'Components', 'components.js'), 'utf8');
44
+ assert.match(built, /const components/);
45
+ assert.match(built, /export default/);
46
+ assert.doesNotThrow(() => parse(built, { sourceType: 'module' }));
47
+ });
48
+ });
49
+
50
+ test('--no-minify copies JS byte-for-byte', async () => {
51
+ await withTestProject(async (root) => {
52
+ const original = 'export function probe() {\n return 1 + 1;\n}\n';
53
+ await writeSrc(root, 'App/probe.js', original);
54
+ await buildProduction({ minify: false });
55
+ const built = await fs.readFile(path.join(root, 'dist', 'App', 'probe.js'), 'utf8');
56
+ assert.equal(built, original);
57
+ });
58
+ });
59
+
60
+ test('minification preserves reserved Slice identifiers', async () => {
61
+ await withTestProject(async (root) => {
62
+ await writeSrc(
63
+ root,
64
+ 'App/probe.js',
65
+ `export function probe() {\n` +
66
+ ` const aVeryLongLocalNameThatShouldBeMangled = slice.build('Foo');\n` +
67
+ ` return aVeryLongLocalNameThatShouldBeMangled;\n` +
68
+ `}\nclass Controller {}\n`
69
+ );
70
+ await buildProduction({ minify: true });
71
+ const built = await fs.readFile(path.join(root, 'dist', 'App', 'probe.js'), 'utf8');
72
+ assert.match(built, /slice\.build/, 'reserved global "slice" must survive minification');
73
+ assert.match(built, /Controller/, 'reserved class name "Controller" must survive');
74
+ assert.doesNotThrow(() => parse(built, { sourceType: 'module' }));
75
+ });
76
+ });
77
+
78
+ test('CSS is minified (whitespace collapsed)', async () => {
79
+ await withTestProject(async (root) => {
80
+ await writeSrc(root, 'Styles/probe.css', '.a {\n color: red;\n margin: 0;\n}\n');
81
+ await buildProduction({ minify: true });
82
+ const built = await fs.readFile(path.join(root, 'dist', 'Styles', 'probe.css'), 'utf8');
83
+ assert.ok(built.length < 30, `expected minified css, got: ${JSON.stringify(built)}`);
84
+ assert.match(built, /\.a\{/);
85
+ });
86
+ });
87
+
88
+ test('HTML minification preserves slice-* attributes', async () => {
89
+ await withTestProject(async (root) => {
90
+ await writeSrc(
91
+ root,
92
+ 'App/probe.html',
93
+ '<!DOCTYPE html>\n<html>\n <body>\n <div slice-id="my-component" >hi</div>\n </body>\n</html>\n'
94
+ );
95
+ await buildProduction({ minify: true });
96
+ const built = await fs.readFile(path.join(root, 'dist', 'App', 'probe.html'), 'utf8');
97
+ assert.match(built, /slice-id="my-component"/, 'slice-* attribute must be preserved');
98
+ });
99
+ });
100
+
101
+ describe('clean / skip-clean semantics', () => {
102
+ test('a stale dist file is removed by default', async () => {
103
+ await withTestProject(async (root) => {
104
+ const stale = path.join(root, 'dist', 'STALE_ARTIFACT.txt');
105
+ await fs.ensureDir(path.dirname(stale));
106
+ await fs.writeFile(stale, 'old');
107
+ await buildProduction({ minify: false });
108
+ assert.equal(await fs.pathExists(stale), false, 'stale dist file should be cleaned');
109
+ });
110
+ });
111
+
112
+ test('--skip-clean keeps a stale dist file', async () => {
113
+ await withTestProject(async (root) => {
114
+ const stale = path.join(root, 'dist', 'STALE_ARTIFACT.txt');
115
+ await fs.ensureDir(path.dirname(stale));
116
+ await fs.writeFile(stale, 'old');
117
+ await buildProduction({ minify: false, skipClean: true });
118
+ assert.equal(await fs.pathExists(stale), true, 'stale dist file should survive --skip-clean');
119
+ });
120
+ });
121
+ });
122
+
123
+ test('build fails (returns false) when a critical file is missing', async () => {
124
+ await withTestProject(async (root) => {
125
+ await fs.remove(path.join(root, 'src', 'App', 'index.js'));
126
+ const ok = await buildProduction({ minify: false });
127
+ assert.equal(ok, false, 'missing App/index.js must abort the build');
128
+ });
129
+ });
130
+
131
+ test('bundle.config inside a bundles/ folder is renamed to bundle.build.config', async () => {
132
+ await withTestProject(async (root) => {
133
+ await writeSrc(root, 'bundles/bundle.config.json', '{"production":true}');
134
+ await buildProduction({ minify: false });
135
+ const dist = path.join(root, 'dist', 'bundles');
136
+ assert.equal(await fs.pathExists(path.join(dist, 'bundle.build.config.json')), true);
137
+ assert.equal(await fs.pathExists(path.join(dist, 'bundle.config.json')), false);
138
+ });
139
+ });
140
+ });
@@ -0,0 +1,322 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parse } from '@babel/parser';
4
+ import BundleGenerator from '../commands/utils/bundling/BundleGenerator.js';
5
+
6
+ const MODULE_URL = import.meta.url;
7
+
8
+ function makeGenerator(sliceConfig = {}) {
9
+ const gen = new BundleGenerator(MODULE_URL, null, {});
10
+ gen.sliceConfig = sliceConfig;
11
+ return gen;
12
+ }
13
+
14
+ function evalSnippet(code) {
15
+ // eslint-disable-next-line no-new-func
16
+ return new Function(code)();
17
+ }
18
+
19
+ function parsesAsModule(code) {
20
+ parse(code, { sourceType: 'module', plugins: ['jsx'] });
21
+ }
22
+
23
+ // Run the dependency block the bundler would emit for a single shared module
24
+ // and return the resulting exports object so we can assert runtime behaviour.
25
+ function depExports(content, moduleName = 'shared/util.js') {
26
+ const gen = makeGenerator();
27
+ const block = gen.buildV2DependencyModuleBlockFromModules([{ name: moduleName, content }]);
28
+ return { block, exports: evalSnippet(`${block}\nreturn __sliceDepExports0;`) };
29
+ }
30
+
31
+ describe('transformDependencyContent — declaration forms', () => {
32
+ test('export function (sync) is callable', () => {
33
+ const { exports: d } = depExports('export function fn() { return 7; }');
34
+ assert.equal(d.fn(), 7);
35
+ });
36
+
37
+ test('export async function keeps its async-ness', () => {
38
+ const { exports: d } = depExports('export async function fn() { return 1; }');
39
+ assert.equal(d.fn.constructor.name, 'AsyncFunction');
40
+ });
41
+
42
+ test('export generator function yields', () => {
43
+ const { exports: d } = depExports('export function* gen() { yield 42; }');
44
+ assert.equal(d.gen().next().value, 42);
45
+ });
46
+
47
+ test('export class is constructable', () => {
48
+ const { exports: d } = depExports('export class Box { value() { return 8; } }');
49
+ assert.equal(new d.Box().value(), 8);
50
+ });
51
+
52
+ test('multiple declarators in one statement', () => {
53
+ const { exports: d } = depExports('export const a = 1, b = 2;');
54
+ assert.deepEqual([d.a, d.b], [1, 2]);
55
+ });
56
+
57
+ test('export without initializer keeps the key (undefined)', () => {
58
+ const { exports: d } = depExports('export let pending;');
59
+ assert.ok('pending' in d);
60
+ assert.equal(d.pending, undefined);
61
+ });
62
+ });
63
+
64
+ describe('transformDependencyContent — destructuring exports', () => {
65
+ test('nested object destructuring', () => {
66
+ const { exports: d } = depExports('const o = { a: { b: 7 } };\nexport const { a: { b } } = o;');
67
+ assert.equal(d.b, 7);
68
+ });
69
+
70
+ test('array destructuring', () => {
71
+ const { exports: d } = depExports('const arr = [10, 20];\nexport const [x, y] = arr;');
72
+ assert.deepEqual([d.x, d.y], [10, 20]);
73
+ });
74
+
75
+ test('destructuring with a default value', () => {
76
+ const { exports: d } = depExports('export const { a = 5 } = {};');
77
+ assert.equal(d.a, 5);
78
+ });
79
+
80
+ test('rest element in destructuring', () => {
81
+ const { exports: d } = depExports('const o = { a: 1, b: 2, c: 3 };\nexport const { a, ...rest } = o;');
82
+ assert.equal(d.a, 1);
83
+ assert.deepEqual(d.rest, { b: 2, c: 3 });
84
+ });
85
+ });
86
+
87
+ describe('transformDependencyContent — default exports', () => {
88
+ test('default named function exposes .default and the basename fallback key', () => {
89
+ const { exports: d } = depExports('export default function build() { return 3; }', 'shared/util.js');
90
+ assert.equal(d.default(), 3);
91
+ assert.equal(d.utilData, d.default);
92
+ });
93
+
94
+ test('default anonymous class is constructable', () => {
95
+ const { exports: d } = depExports('export default class { constructor() { this.v = 9; } }');
96
+ assert.equal(new d.default().v, 9);
97
+ });
98
+
99
+ test('default object literal', () => {
100
+ const { exports: d } = depExports('export default { k: 1 };');
101
+ assert.deepEqual(d.default, { k: 1 });
102
+ });
103
+ });
104
+
105
+ describe('transformDependencyContent — specifier & re-export forms', () => {
106
+ test('mixed aliased + plain specifiers', () => {
107
+ const { exports: d } = depExports('const a = 1;\nconst b = 2;\nexport { a as alpha, b };');
108
+ assert.equal(d.alpha, 1);
109
+ assert.equal(d.b, 2);
110
+ });
111
+
112
+ test('re-export from another module is dropped (not leaked)', () => {
113
+ const { block } = depExports("export { a } from './other.js';");
114
+ assert.ok(!/from\s+['"]\.\/other\.js['"]/.test(block), 're-export leaked into the bundle');
115
+ });
116
+
117
+ test('export * is dropped (not leaked)', () => {
118
+ const { block } = depExports("export * from './other.js';");
119
+ assert.ok(!/export\s*\*/.test(block) && !/\.\/other\.js/.test(block));
120
+ });
121
+ });
122
+
123
+ describe('transformDependencyContent — robustness', () => {
124
+ test('the word "export" inside a string is never transformed', () => {
125
+ const { block, exports: d } = depExports('const s = "export const x = 1";\nexport const real = 2;');
126
+ assert.equal(d.real, 2);
127
+ assert.ok(block.includes('"export const x = 1"'), 'string literal was mangled');
128
+ assert.equal(d.x, undefined);
129
+ });
130
+
131
+ test('a module with no exports is left runnable', () => {
132
+ const { exports: d } = depExports('const internalOnly = 5;');
133
+ assert.deepEqual(d, {});
134
+ });
135
+
136
+ test('imports inside a dependency module are stripped', () => {
137
+ const { block } = depExports(
138
+ "import dep from './dep.js';\nimport 'polyfill';\nimport * as ns from './ns.js';\nexport const y = 1;"
139
+ );
140
+ assert.ok(!/\bimport\b/.test(block), 'an import leaked into the dependency block');
141
+ });
142
+
143
+ test('every transformed dependency block is valid module JS', () => {
144
+ const samples = [
145
+ 'export const a = 1, b = 2;',
146
+ 'export function f() {}',
147
+ 'export class C {}',
148
+ 'export default class { }',
149
+ 'const o = {a:1};\nexport const { a } = o;',
150
+ 'export { x as default };\nconst x = 1;',
151
+ ];
152
+ for (const content of samples) {
153
+ const gen = makeGenerator();
154
+ const block = gen.buildV2DependencyModuleBlockFromModules([{ name: 'm/x.js', content }]);
155
+ assert.doesNotThrow(() => parsesAsModule(block), `block invalid for: ${content}`);
156
+ }
157
+ });
158
+
159
+ test('TypeScript-ish content falls back without throwing', () => {
160
+ const gen = makeGenerator();
161
+ // TS type annotations are not understood by the fallback regex; the point
162
+ // here is only that the fallback path is reached without throwing.
163
+ const out = gen.transformDependencyContent('export const y: number = 2;', '__d', 'm/x.ts');
164
+ assert.equal(typeof out, 'string');
165
+ });
166
+ });
167
+
168
+ describe('cleanJavaScript', () => {
169
+ test('exposes the component class globally and returns it', () => {
170
+ const gen = makeGenerator();
171
+ const { code } = gen.cleanJavaScript('export default class Button extends HTMLElement {}', 'Button');
172
+ assert.match(code, /window\.Button = Button;/);
173
+ assert.match(code, /return Button;/);
174
+ // The cleaned code is a factory *body* (ends in `return Button;`), so allow
175
+ // a top-level return when checking it is otherwise syntactically valid.
176
+ assert.doesNotThrow(() =>
177
+ parse(code, { sourceType: 'script', allowReturnOutsideFunction: true })
178
+ );
179
+ });
180
+
181
+ test('guards customElements.define against duplicate registration', () => {
182
+ const gen = makeGenerator();
183
+ const { code } = gen.cleanJavaScript(
184
+ "class El extends HTMLElement {}\ncustomElements.define('my-el', El);",
185
+ 'El'
186
+ );
187
+ assert.match(code, /if \(!customElements\.get\('my-el'\)\)/);
188
+ });
189
+
190
+ test('strips relative imports and hoists public-folder imports', () => {
191
+ const gen = makeGenerator({ publicFolders: ['/assets'] });
192
+ const { code, hoistedImports } = gen.cleanJavaScript(
193
+ "import './local.js';\nimport lib from '/assets/lib.js';\nclass C {}\nreturn C;",
194
+ 'C'
195
+ );
196
+ assert.ok(!code.includes('./local.js'));
197
+ assert.equal(hoistedImports.length, 1);
198
+ assert.match(hoistedImports[0], /\/assets\/lib\.js/);
199
+ });
200
+ });
201
+
202
+ describe('toSafeIdentifier — injectivity sweep', () => {
203
+ test('a batch of tricky names all map to distinct, valid identifiers', () => {
204
+ const gen = makeGenerator();
205
+ const names = ['my-btn', 'my_btn', 'my.btn', 'my btn', 'btn', '1btn', 'Ünïcode', 'a-b', 'a_b'];
206
+ const ids = names.map((n) => gen.toSafeIdentifier(n));
207
+ assert.equal(new Set(ids).size, names.length, 'two names collided to the same identifier');
208
+ for (const id of ids) {
209
+ assert.doesNotThrow(() => parse(`const ${id} = 1;`));
210
+ }
211
+ });
212
+ });
213
+
214
+ describe('generateBundleFileContent — broader cases', () => {
215
+ function component(name, extra = {}) {
216
+ return {
217
+ name,
218
+ category: 'Visual',
219
+ categoryType: 'Visual',
220
+ js: 'const C = class {};\nreturn C;',
221
+ hoistedImports: [],
222
+ html: '',
223
+ css: '',
224
+ externalDependencies: {},
225
+ size: 100,
226
+ ...extra,
227
+ };
228
+ }
229
+
230
+ test('an empty component list still emits a valid registerAll module', () => {
231
+ const gen = makeGenerator();
232
+ const code = gen.generateBundleFileContent('slice-bundle.critical.js', 'critical', []);
233
+ assert.doesNotThrow(() => parsesAsModule(code));
234
+ assert.match(code, /export async function registerAll/);
235
+ });
236
+
237
+ test('a unicode component name produces a valid bundle', () => {
238
+ const gen = makeGenerator();
239
+ const code = gen.generateBundleFileContent('slice-bundle.critical.js', 'critical', [component('Ünïcode')]);
240
+ assert.doesNotThrow(() => parsesAsModule(code));
241
+ });
242
+
243
+ test('a component with default + named dependencies parses and binds both', () => {
244
+ const gen = makeGenerator();
245
+ const comp = component('Widget', {
246
+ externalDependencies: {
247
+ 'shared/util.js': {
248
+ content: 'export default () => 1;\nexport const helper = () => 2;',
249
+ bindings: [
250
+ { type: 'default', importedName: 'default', localName: 'main' },
251
+ { type: 'named', importedName: 'helper', localName: 'helper' },
252
+ ],
253
+ },
254
+ },
255
+ });
256
+ const code = gen.generateBundleFileContent('slice-bundle.critical.js', 'critical', [comp]);
257
+ assert.doesNotThrow(() => parsesAsModule(code));
258
+ assert.match(code, /const main = __sliceResolveDefaultExport\(/);
259
+ assert.match(code, /const helper = SLICE_BUNDLE_DEPENDENCIES\["shared\/util\.js"\]\.helper;/);
260
+ });
261
+
262
+ // IIFE scoping: two dependency modules that each declare the same private
263
+ // (non-exported) top-level binding must NOT collide at bundle scope.
264
+ test('dependency modules with a colliding private helper name stay isolated', () => {
265
+ const gen = makeGenerator();
266
+ const mk = (name, depName, depContent) =>
267
+ component(name, {
268
+ externalDependencies: {
269
+ [depName]: {
270
+ content: depContent,
271
+ bindings: [{ type: 'named', importedName: 'x', localName: 'x' }],
272
+ },
273
+ },
274
+ });
275
+ const code = gen.generateBundleFileContent('slice-bundle.critical.js', 'critical', [
276
+ mk('A', 'a/util.js', 'const helper = 1;\nexport const x = helper;'),
277
+ mk('B', 'b/util.js', 'const helper = 2;\nexport const x = helper;'),
278
+ ]);
279
+ assert.doesNotThrow(() => parsesAsModule(code));
280
+ // And each dependency keeps its own value for the shared export name.
281
+ assert.match(code, /SLICE_BUNDLE_DEPENDENCIES\["a\/util\.js"\]/);
282
+ assert.match(code, /SLICE_BUNDLE_DEPENDENCIES\["b\/util\.js"\]/);
283
+ });
284
+
285
+ test('two IIFE-scoped dependencies resolve to independent values at runtime', () => {
286
+ const gen = makeGenerator();
287
+ const block = gen.buildV2DependencyModuleBlockFromModules([
288
+ { name: 'a/util.js', content: 'const helper = 1;\nexport const x = helper;' },
289
+ { name: 'b/util.js', content: 'const helper = 2;\nexport const x = helper;' },
290
+ ]);
291
+ const deps = evalSnippet(`${block}\nreturn SLICE_BUNDLE_DEPENDENCIES;`);
292
+ assert.equal(deps['a/util.js'].x, 1);
293
+ assert.equal(deps['b/util.js'].x, 2);
294
+ });
295
+ });
296
+
297
+ describe('classifyImport / stripImports — more edge cases', () => {
298
+ test('dynamic import() expressions are preserved', () => {
299
+ const gen = makeGenerator({ publicFolders: [] });
300
+ const code = "const m = import('./lazy.js');\nconst v = 1;";
301
+ const out = gen.stripImports(code, { collectHoistedImports: true });
302
+ assert.match(out.code, /import\('\.\/lazy\.js'\)/);
303
+ });
304
+
305
+ test('windows-style backslashes in a public import are normalized and kept', () => {
306
+ const gen = makeGenerator({ publicFolders: ['/assets'] });
307
+ const r = gen.classifyImport('/assets\\lib\\x.js', gen.getConfiguredPublicFolders());
308
+ assert.equal(r.keep, true);
309
+ });
310
+
311
+ test('a public import with a query string is kept', () => {
312
+ const gen = makeGenerator({ publicFolders: ['/assets'] });
313
+ const r = gen.classifyImport('/assets/lib.js?v=2', gen.getConfiguredPublicFolders());
314
+ assert.equal(r.keep, true);
315
+ });
316
+
317
+ test('publicFolders configured without a leading slash still match', () => {
318
+ const gen = makeGenerator({ publicFolders: ['assets'] });
319
+ const r = gen.classifyImport('/assets/lib.js', gen.getConfiguredPublicFolders());
320
+ assert.equal(r.keep, true);
321
+ });
322
+ });
@@ -0,0 +1,115 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'fs-extra';
4
+ import path from 'node:path';
5
+ import { parse } from '@babel/parser';
6
+ import { withTestProject } from './helpers/setup.js';
7
+ import DependencyAnalyzer from '../commands/utils/bundling/DependencyAnalyzer.js';
8
+ import BundleGenerator from '../commands/utils/bundling/BundleGenerator.js';
9
+
10
+ const MODULE_URL = import.meta.url;
11
+
12
+ function parsesAsModule(code) {
13
+ parse(code, { sourceType: 'module', plugins: ['jsx'] });
14
+ }
15
+
16
+ describe('bundle generation end-to-end (real starter project)', () => {
17
+ test('generate() emits parseable v2 bundles that honour the runtime contract', async () => {
18
+ await withTestProject(async (projectRoot) => {
19
+ const analyzer = new DependencyAnalyzer(MODULE_URL);
20
+ const analysisData = await analyzer.analyze();
21
+
22
+ const generator = new BundleGenerator(MODULE_URL, analysisData, {
23
+ minify: false,
24
+ obfuscate: false,
25
+ output: 'src',
26
+ });
27
+ const result = await generator.generate();
28
+
29
+ assert.ok(result.files.length > 0, 'at least one bundle file should be produced');
30
+
31
+ const bundlesDir = path.join(projectRoot, 'src', 'bundles');
32
+ const written = (await fs.readdir(bundlesDir)).filter(
33
+ (f) => f.startsWith('slice-bundle.') && f.endsWith('.js')
34
+ );
35
+ assert.ok(written.length > 0, 'bundle files should be on disk');
36
+
37
+ for (const file of written) {
38
+ const code = await fs.readFile(path.join(bundlesDir, file), 'utf8');
39
+ // Oracle #1: the emitted bundle must be syntactically valid ES module JS.
40
+ assert.doesNotThrow(() => parsesAsModule(code), `bundle ${file} is not valid JS`);
41
+ // Oracle #2: every v2 bundle must export the runtime contract symbols.
42
+ assert.match(code, /export const SLICE_BUNDLE_META\b/, `${file} missing SLICE_BUNDLE_META`);
43
+ assert.match(code, /export async function registerAll\b/, `${file} missing registerAll`);
44
+ }
45
+ });
46
+ });
47
+
48
+ test('generateBundleConfig produces a well-formed v2 config', async () => {
49
+ await withTestProject(async () => {
50
+ const analyzer = new DependencyAnalyzer(MODULE_URL);
51
+ const analysisData = await analyzer.analyze();
52
+ const generator = new BundleGenerator(MODULE_URL, analysisData, { output: 'src' });
53
+ const result = await generator.generate();
54
+
55
+ const cfg = result.config;
56
+ assert.equal(cfg.production, true);
57
+ assert.equal(cfg.format, 'v2');
58
+ assert.ok(cfg.stats, 'config carries stats');
59
+ assert.equal(typeof cfg.generated, 'string');
60
+ });
61
+ });
62
+ });
63
+
64
+ describe('generateBundleFileContent — controlled inputs', () => {
65
+ function makeGenerator() {
66
+ const gen = new BundleGenerator(MODULE_URL, null, {});
67
+ gen.sliceConfig = {};
68
+ return gen;
69
+ }
70
+
71
+ function component(name, extra = {}) {
72
+ return {
73
+ name,
74
+ category: 'Visual',
75
+ categoryType: 'Visual',
76
+ js: 'const C = class {};\nreturn C;',
77
+ hoistedImports: [],
78
+ html: '<div></div>',
79
+ css: '.x{}',
80
+ externalDependencies: {},
81
+ size: 100,
82
+ ...extra,
83
+ };
84
+ }
85
+
86
+ test('single component bundle is valid JS', () => {
87
+ const gen = makeGenerator();
88
+ const code = gen.generateBundleFileContent('slice-bundle.critical.js', 'critical', [component('Button')]);
89
+ assert.doesNotThrow(() => parsesAsModule(code));
90
+ });
91
+
92
+ test('two component names that differ only by a non-word char must not break the bundle', () => {
93
+ // my-btn and my_btn both sanitize to SliceComponent_my_btn -> two `const`
94
+ // declarations with the same name -> SyntaxError in the emitted bundle.
95
+ const gen = makeGenerator();
96
+ const code = gen.generateBundleFileContent('slice-bundle.critical.js', 'critical', [
97
+ component('my-btn'),
98
+ component('my_btn'),
99
+ ]);
100
+ assert.doesNotThrow(
101
+ () => parsesAsModule(code),
102
+ 'distinct component names collided into a duplicate identifier'
103
+ );
104
+ });
105
+
106
+ test('hoisted public imports appear at the top of the bundle', () => {
107
+ const gen = makeGenerator();
108
+ const comp = component('Button', {
109
+ hoistedImports: ["import lib from '/assets/lib.js';"],
110
+ });
111
+ const code = gen.generateBundleFileContent('slice-bundle.critical.js', 'critical', [comp]);
112
+ assert.match(code, /^import lib from '\/assets\/lib\.js';/);
113
+ assert.doesNotThrow(() => parsesAsModule(code));
114
+ });
115
+ });