slicejs-cli 2.9.5 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,8 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import fs from 'fs-extra';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
3
6
  import BundleGenerator from '../commands/utils/bundling/BundleGenerator.js';
4
7
 
5
8
  const createComponent = (name, deps = []) => ({
@@ -36,3 +39,168 @@ test('computeBundleIntegrity changes with component dependencies', () => {
36
39
  // Assert
37
40
  assert.notEqual(baseIntegrity, changedIntegrity);
38
41
  });
42
+
43
+ test('generateBundleConfig outputs V2 manifest fields', () => {
44
+ const generator = new BundleGenerator(import.meta.url, {
45
+ components: [],
46
+ routes: [],
47
+ metrics: {
48
+ totalComponents: 0,
49
+ totalRoutes: 0,
50
+ sharedPercentage: 0,
51
+ totalSize: 0
52
+ }
53
+ });
54
+
55
+ const config = generator.generateBundleConfig(null);
56
+
57
+ assert.equal(config.format, 'v2');
58
+ assert.ok(config.generated);
59
+ assert.ok(config.bundles);
60
+ assert.ok(['enabled', 'disabled'].includes(config.loadingPolicy));
61
+ });
62
+
63
+ test('loading policy is enabled when sliceConfig loading.enabled is true', () => {
64
+ const generator = new BundleGenerator(import.meta.url, {
65
+ components: [],
66
+ routes: [],
67
+ metrics: {
68
+ totalComponents: 0,
69
+ totalRoutes: 0,
70
+ sharedPercentage: 0,
71
+ totalSize: 0
72
+ },
73
+ sliceConfig: { loading: { enabled: true } }
74
+ });
75
+
76
+ const config = generator.generateBundleConfig(null);
77
+ assert.equal(config.loadingPolicy, 'enabled');
78
+ });
79
+
80
+ test('loading policy falls back to project sliceConfig when analysisData lacks sliceConfig', async () => {
81
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'slice-bundle-test-'));
82
+ const srcDir = path.join(tempRoot, 'src');
83
+ const previousInitCwd = process.env.INIT_CWD;
84
+
85
+ await fs.ensureDir(srcDir);
86
+ await fs.writeJson(path.join(srcDir, 'sliceConfig.json'), {
87
+ loading: { enabled: true }
88
+ });
89
+
90
+ process.env.INIT_CWD = tempRoot;
91
+
92
+ try {
93
+ const generator = new BundleGenerator(import.meta.url, {
94
+ components: [],
95
+ routes: [],
96
+ metrics: {
97
+ totalComponents: 0,
98
+ totalRoutes: 0,
99
+ sharedPercentage: 0,
100
+ totalSize: 0
101
+ }
102
+ });
103
+
104
+ const config = generator.generateBundleConfig(null);
105
+ assert.equal(config.loadingPolicy, 'enabled');
106
+ } finally {
107
+ if (previousInitCwd === undefined) {
108
+ delete process.env.INIT_CWD;
109
+ } else {
110
+ process.env.INIT_CWD = previousInitCwd;
111
+ }
112
+ await fs.remove(tempRoot);
113
+ }
114
+ });
115
+
116
+ test('loading enabled always includes Loading component in critical bundle', () => {
117
+ const loading = {
118
+ ...createComponent('Loading'),
119
+ routes: new Set(),
120
+ size: 100000
121
+ };
122
+ const home = {
123
+ ...createComponent('HomePage'),
124
+ routes: new Set(['/'])
125
+ };
126
+
127
+ const generator = new BundleGenerator(import.meta.url, {
128
+ components: [loading, home],
129
+ routes: [{ path: '/', component: 'HomePage' }],
130
+ metrics: {
131
+ totalComponents: 2,
132
+ totalRoutes: 1,
133
+ sharedPercentage: 0,
134
+ totalSize: 100010
135
+ },
136
+ sliceConfig: { loading: { enabled: true } }
137
+ });
138
+
139
+ generator.identifyCriticalComponents();
140
+
141
+ assert.ok(generator.bundles.critical.components.some((component) => component.name === 'Loading'));
142
+ assert.equal(generator.generateBundleConfig().loadingPolicy, 'enabled');
143
+ });
144
+
145
+ test('shared-core is wired as dependency for affected route bundles', () => {
146
+ const generator = new BundleGenerator(import.meta.url, {
147
+ components: [],
148
+ routes: [],
149
+ metrics: {
150
+ totalComponents: 0,
151
+ totalRoutes: 0,
152
+ sharedPercentage: 0,
153
+ totalSize: 0
154
+ }
155
+ });
156
+
157
+ generator.config.minSharedUsage = 2;
158
+
159
+ generator.bundles.routes = {
160
+ alpha: {
161
+ path: '/alpha',
162
+ components: [createComponent('SharedWidget'), createComponent('AlphaOnly')],
163
+ size: 20,
164
+ file: 'slice-bundle.alpha.js'
165
+ },
166
+ beta: {
167
+ path: '/beta',
168
+ components: [createComponent('SharedWidget'), createComponent('BetaOnly')],
169
+ size: 20,
170
+ file: 'slice-bundle.beta.js'
171
+ }
172
+ };
173
+
174
+ generator.extractSharedComponents(new Set());
175
+ const config = generator.generateBundleConfig();
176
+
177
+ assert.ok(config.bundles.routes['shared-core']);
178
+ assert.deepEqual(config.bundles.routes.alpha.dependencies, ['critical', 'shared-core']);
179
+ assert.deepEqual(config.bundles.routes.beta.dependencies, ['critical', 'shared-core']);
180
+ assert.deepEqual(config.routeBundles['/alpha'], ['critical', 'shared-core', 'alpha']);
181
+ assert.deepEqual(config.routeBundles['/beta'], ['critical', 'shared-core', 'beta']);
182
+ });
183
+
184
+ test('rebalance merge preserves and merges route path metadata deterministically', () => {
185
+ const generator = new BundleGenerator(import.meta.url, {
186
+ components: [],
187
+ routes: [],
188
+ metrics: {
189
+ totalComponents: 0,
190
+ totalRoutes: 0,
191
+ sharedPercentage: 0,
192
+ totalSize: 0
193
+ }
194
+ });
195
+
196
+ const bundles = {
197
+ alpha: { path: '/alpha', components: [createComponent('Alpha')], size: 10, file: 'slice-bundle.alpha.js' },
198
+ beta: { paths: ['/beta', '/beta-alt'], components: [createComponent('Beta')], size: 10, file: 'slice-bundle.beta.js' },
199
+ gamma: { path: '/gamma', components: [createComponent('Gamma')], size: 10, file: 'slice-bundle.gamma.js' }
200
+ };
201
+
202
+ generator.rebalanceBundlesByBudget(bundles, { maxBundleSize: 99999, maxRequests: 2 });
203
+
204
+ assert.equal(Object.keys(bundles).length, 2);
205
+ assert.deepEqual(bundles.beta.paths, ['/beta', '/beta-alt', '/gamma']);
206
+ });
@@ -0,0 +1,141 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import BundleGenerator from '../commands/utils/bundling/BundleGenerator.js';
4
+
5
+ test('generated V2 bundle includes meta and registerAll exports', () => {
6
+ const generator = new BundleGenerator(import.meta.url, {
7
+ components: [],
8
+ routes: [],
9
+ metrics: {
10
+ totalComponents: 0,
11
+ totalRoutes: 0,
12
+ sharedPercentage: 0,
13
+ totalSize: 0
14
+ }
15
+ }, { output: 'src' });
16
+
17
+ const source = generator.generateBundleFileContent(
18
+ 'slice-bundle.test.js',
19
+ 'route',
20
+ [{ name: 'Button', category: 'Visual', categoryType: 'Visual', dependencies: new Set(), size: 100, js: '', html: '', css: '' }],
21
+ '/test'
22
+ );
23
+
24
+ assert.match(source, /export const SLICE_BUNDLE_META/);
25
+ assert.match(source, /export async function registerAll\(/);
26
+ });
27
+
28
+ test('generateBundleFileContent deduplicates components by name', () => {
29
+ const generator = new BundleGenerator(import.meta.url, {
30
+ components: [],
31
+ routes: [],
32
+ metrics: {
33
+ totalComponents: 0,
34
+ totalRoutes: 0,
35
+ sharedPercentage: 0,
36
+ totalSize: 0
37
+ }
38
+ }, { output: 'src' });
39
+
40
+ const source = generator.generateBundleFileContent(
41
+ 'slice-bundle.test.js',
42
+ 'route',
43
+ [
44
+ { name: 'Button', category: 'Visual', categoryType: 'Visual', dependencies: new Set(), size: 100, js: '', html: '', css: '' },
45
+ { name: 'Button', category: 'Visual', categoryType: 'Visual', dependencies: new Set(), size: 100, js: '', html: '', css: '' }
46
+ ],
47
+ '/test'
48
+ );
49
+
50
+ assert.equal((source.match(/const SLICE_CLASS_FACTORY_SliceComponent_Button/g) || []).length, 1);
51
+ assert.equal((source.match(/if \(!controller\.classes\.has\("Button"\)\) \{\s*controller\.classes\.set\("Button", SLICE_CLASS_FACTORY_SliceComponent_Button\(\)\);\s*\}/g) || []).length, 1);
52
+ });
53
+
54
+ test('registerAll guards class, template, style, and category writes', () => {
55
+ const generator = new BundleGenerator(import.meta.url, {
56
+ components: [],
57
+ routes: [],
58
+ metrics: {
59
+ totalComponents: 0,
60
+ totalRoutes: 0,
61
+ sharedPercentage: 0,
62
+ totalSize: 0
63
+ }
64
+ }, { output: 'src' });
65
+
66
+ const source = generator.generateBundleFileContent(
67
+ 'slice-bundle.test.js',
68
+ 'route',
69
+ [{ name: 'Button', category: 'Visual', categoryType: 'Visual', dependencies: new Set(), size: 100, js: '', html: '<button>ok</button>', css: '.btn{}' }],
70
+ '/test'
71
+ );
72
+
73
+ assert.match(source, /if \(!controller\.classes\.has\("Button"\)\) \{\s*controller\.classes\.set\("Button", SLICE_CLASS_FACTORY_SliceComponent_Button\(\)\);\s*\}/);
74
+ assert.match(source, /if \(!controller\.templates\.has\("Button"\)\) \{\s*controller\.templates\.set\("Button", __templateElement_SliceComponent_Button\);\s*\}/);
75
+ assert.match(source, /if \(!stylesManager\.__sliceRegisteredComponentStyles\) \{\s*stylesManager\.__sliceRegisteredComponentStyles = new Set\(\);\s*\}/);
76
+ assert.match(source, /if \(!stylesManager\.__sliceRegisteredComponentStyles\.has\("Button"\)\) \{\s*stylesManager\.registerComponentStyles\("Button", ".*"\);\s*stylesManager\.__sliceRegisteredComponentStyles\.add\("Button"\);\s*\}/);
77
+ assert.match(source, /if \(!controller\.componentCategories\.has\("Button"\)\) \{\s*controller\.componentCategories\.set\("Button", "Visual"\);\s*\}/);
78
+ });
79
+
80
+ test('registerAll stores templates as template elements', () => {
81
+ const generator = new BundleGenerator(import.meta.url, {
82
+ components: [],
83
+ routes: [],
84
+ metrics: {
85
+ totalComponents: 0,
86
+ totalRoutes: 0,
87
+ sharedPercentage: 0,
88
+ totalSize: 0
89
+ }
90
+ }, { output: 'src' });
91
+
92
+ const source = generator.generateBundleFileContent(
93
+ 'slice-bundle.test.js',
94
+ 'route',
95
+ [{ name: 'DocumentationPage', category: 'AppComponents', categoryType: 'Visual', dependencies: new Set(), size: 100, js: '', html: '<div>docs</div>', css: '' }],
96
+ '/docs'
97
+ );
98
+
99
+ assert.match(source, /const __templateElement_SliceComponent_DocumentationPage = document\.createElement\('template'\);/);
100
+ assert.match(source, /__templateElement_SliceComponent_DocumentationPage\.innerHTML = "<div>docs<\/div>";/);
101
+ assert.match(source, /controller\.templates\.set\("DocumentationPage", __templateElement_SliceComponent_DocumentationPage\);/);
102
+ });
103
+
104
+ test('bundle output inlines dependency modules and binds imported symbols in class factories', () => {
105
+ const generator = new BundleGenerator(import.meta.url, {
106
+ components: [],
107
+ routes: [],
108
+ metrics: {
109
+ totalComponents: 0,
110
+ totalRoutes: 0,
111
+ sharedPercentage: 0,
112
+ totalSize: 0
113
+ }
114
+ }, { output: 'src' });
115
+
116
+ const source = generator.generateBundleFileContent(
117
+ 'slice-bundle.test.js',
118
+ 'route',
119
+ [{
120
+ name: 'DocumentationPage',
121
+ category: 'AppComponents',
122
+ categoryType: 'Visual',
123
+ dependencies: new Set(),
124
+ size: 100,
125
+ js: 'class DocumentationPage extends HTMLElement { connectedCallback(){ return documentationRoutes.length; } }\nwindow.DocumentationPage = DocumentationPage;\nreturn DocumentationPage;',
126
+ html: '',
127
+ css: '',
128
+ externalDependencies: {
129
+ 'App/documentationRoutes.js': {
130
+ content: 'export const documentationRoutes = ["/docs"];',
131
+ bindings: [{ type: 'named', importedName: 'documentationRoutes', localName: 'documentationRoutes' }]
132
+ }
133
+ }
134
+ }],
135
+ '/docs'
136
+ );
137
+
138
+ assert.match(source, /const SLICE_BUNDLE_DEPENDENCIES = \{\};/);
139
+ assert.match(source, /SLICE_BUNDLE_DEPENDENCIES\["App\/documentationRoutes\.js"\] = __sliceDepExports0;/);
140
+ assert.match(source, /const documentationRoutes = SLICE_BUNDLE_DEPENDENCIES\["App\/documentationRoutes\.js"\]\.documentationRoutes;/);
141
+ });
@@ -0,0 +1,211 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parse } from '@babel/parser';
7
+ import babelTraverse from '@babel/traverse';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const clientPath = path.join(__dirname, '..', 'client.js');
11
+ const source = fs.readFileSync(clientPath, 'utf-8');
12
+ const ast = parse(source, {
13
+ sourceType: 'module',
14
+ plugins: []
15
+ });
16
+ const traverse = babelTraverse.default || babelTraverse;
17
+
18
+ function walk(node, visit) {
19
+ if (!node || typeof node !== 'object') {
20
+ return;
21
+ }
22
+
23
+ visit(node);
24
+
25
+ for (const value of Object.values(node)) {
26
+ if (Array.isArray(value)) {
27
+ for (const item of value) {
28
+ walk(item, visit);
29
+ }
30
+ continue;
31
+ }
32
+
33
+ walk(value, visit);
34
+ }
35
+ }
36
+
37
+ function getImportedBindings(fromSource) {
38
+ const bindings = new Map();
39
+
40
+ for (const statement of ast.program.body) {
41
+ if (statement.type !== 'ImportDeclaration') {
42
+ continue;
43
+ }
44
+
45
+ if (statement.source.value !== fromSource) {
46
+ continue;
47
+ }
48
+
49
+ for (const specifier of statement.specifiers) {
50
+ if (specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier') {
51
+ bindings.set(specifier.imported.name, specifier.local.name);
52
+ }
53
+ }
54
+ }
55
+
56
+ return bindings;
57
+ }
58
+
59
+ function getBoundImportedCallPositions(fromSource, importedName) {
60
+ const positions = [];
61
+ const importedBindings = getImportedBindings(fromSource);
62
+ const localName = importedBindings.get(importedName);
63
+
64
+ if (!localName) {
65
+ return positions;
66
+ }
67
+
68
+ traverse(ast, {
69
+ CallExpression(callPath) {
70
+ if (callPath.node.callee.type !== 'Identifier') {
71
+ return;
72
+ }
73
+
74
+ if (callPath.node.callee.name !== localName) {
75
+ return;
76
+ }
77
+
78
+ const binding = callPath.scope.getBinding(localName);
79
+ if (!binding) {
80
+ return;
81
+ }
82
+
83
+ if (binding.path.node.type !== 'ImportSpecifier') {
84
+ return;
85
+ }
86
+
87
+ if (binding.path.parent.type !== 'ImportDeclaration') {
88
+ return;
89
+ }
90
+
91
+ if (binding.path.parent.source.value !== fromSource) {
92
+ return;
93
+ }
94
+
95
+ if (binding.path.node.imported.type !== 'Identifier') {
96
+ return;
97
+ }
98
+
99
+ if (binding.path.node.imported.name !== importedName) {
100
+ return;
101
+ }
102
+
103
+ positions.push(callPath.node.start);
104
+ }
105
+ });
106
+
107
+ return positions;
108
+ }
109
+
110
+ function getCommandRegistrationPositions() {
111
+ const positions = [];
112
+
113
+ walk(ast.program, (node) => {
114
+ if (node.type !== 'CallExpression') {
115
+ return;
116
+ }
117
+
118
+ if (
119
+ node.callee.type === 'MemberExpression' &&
120
+ !node.callee.computed &&
121
+ node.callee.property.type === 'Identifier' &&
122
+ node.callee.property.name === 'command'
123
+ ) {
124
+ positions.push(node.start);
125
+ }
126
+ });
127
+
128
+ return positions;
129
+ }
130
+
131
+ const launcherModulePath = './commands/utils/LocalCliDelegation.js';
132
+ const commandRegistrationPositions = getCommandRegistrationPositions();
133
+ const firstCommandRegistrationPos = Math.min(...commandRegistrationPositions);
134
+
135
+ test('client imports LocalCliDelegation utility', () => {
136
+ const importedBindings = getImportedBindings(launcherModulePath);
137
+
138
+ assert.ok(
139
+ importedBindings.size > 0,
140
+ 'Contract clause failed: client.js must import launcher helpers from ./commands/utils/LocalCliDelegation.js'
141
+ );
142
+
143
+ for (const requiredImport of [
144
+ 'isLocalDelegationDisabled',
145
+ 'findNearestLocalCliEntry',
146
+ 'shouldDelegateToLocalCli'
147
+ ]) {
148
+ assert.ok(
149
+ importedBindings.has(requiredImport),
150
+ `Contract clause failed: client.js must import ${requiredImport} from ${launcherModulePath}`
151
+ );
152
+ }
153
+ });
154
+
155
+ test('client checks SLICE_NO_LOCAL_DELEGATION behavior before command runtime', () => {
156
+ const isDisabledCalls = getBoundImportedCallPositions(
157
+ launcherModulePath,
158
+ 'isLocalDelegationDisabled'
159
+ );
160
+
161
+ assert.ok(
162
+ isDisabledCalls.length > 0,
163
+ 'Contract clause failed: client.js must call imported isLocalDelegationDisabled() in launcher path'
164
+ );
165
+
166
+ assert.ok(
167
+ commandRegistrationPositions.length > 0,
168
+ 'Contract clause failed: client.js must define at least one .command(...) registration before launcher ordering checks'
169
+ );
170
+
171
+ assert.ok(
172
+ isDisabledCalls.some((pos) => pos < firstCommandRegistrationPos),
173
+ 'Contract clause failed: isLocalDelegationDisabled() must be evaluated before command registration/runtime wiring'
174
+ );
175
+ });
176
+
177
+ test('client performs local candidate resolution and delegation decision', () => {
178
+ const findNearestCalls = getBoundImportedCallPositions(
179
+ launcherModulePath,
180
+ 'findNearestLocalCliEntry'
181
+ );
182
+ const shouldDelegateCalls = getBoundImportedCallPositions(
183
+ launcherModulePath,
184
+ 'shouldDelegateToLocalCli'
185
+ );
186
+
187
+ assert.ok(
188
+ findNearestCalls.length > 0,
189
+ 'Contract clause failed: client.js must call imported findNearestLocalCliEntry() to resolve local CLI candidate in launcher path'
190
+ );
191
+
192
+ assert.ok(
193
+ shouldDelegateCalls.length > 0,
194
+ 'Contract clause failed: client.js must call imported shouldDelegateToLocalCli() to gate delegation in launcher path'
195
+ );
196
+
197
+ assert.ok(
198
+ commandRegistrationPositions.length > 0,
199
+ 'Contract clause failed: client.js must define at least one .command(...) registration before launcher ordering checks'
200
+ );
201
+
202
+ assert.ok(
203
+ findNearestCalls.some((pos) => pos < firstCommandRegistrationPos),
204
+ 'Contract clause failed: findNearestLocalCliEntry() must execute before command registration/runtime wiring'
205
+ );
206
+
207
+ assert.ok(
208
+ shouldDelegateCalls.some((pos) => pos < firstCommandRegistrationPos),
209
+ 'Contract clause failed: shouldDelegateToLocalCli() must execute before command registration/runtime wiring'
210
+ );
211
+ });