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,127 @@
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() {
9
+ const gen = new BundleGenerator(MODULE_URL, null, {});
10
+ gen.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
+ // Build the dependency-exports object the bundler would emit for a single
20
+ // shared module, then read back its keys at runtime.
21
+ function evalDepExports(content, moduleName = 'shared/util.js') {
22
+ const gen = makeGenerator();
23
+ const block = gen.buildV2DependencyModuleBlockFromModules([{ name: moduleName, content }]);
24
+ // The block declares `const __sliceDepExports0 = {}` and fills it; expose it.
25
+ return evalSnippet(`${block}\nreturn typeof __sliceDepExports0 !== 'undefined' ? __sliceDepExports0 : {};`);
26
+ }
27
+
28
+ describe('shared dependency module transforms', () => {
29
+ test('plain named exports resolve', () => {
30
+ const d = evalDepExports('export const a = 1;\nexport const b = 2;');
31
+ assert.equal(d.a, 1);
32
+ assert.equal(d.b, 2);
33
+ });
34
+
35
+ test('multiline export list resolves every name', () => {
36
+ const d = evalDepExports('const a = 1;\nconst b = 2;\nconst c = 3;\nexport {\n a,\n b,\n c\n};');
37
+ assert.deepEqual([d.a, d.b, d.c], [1, 2, 3]);
38
+ });
39
+
40
+ test('export { x as default } exposes a usable default', () => {
41
+ // A consumer doing `import def from '...'` resolves default via the runtime
42
+ // helper, which first checks `.default`. So the exports object should carry
43
+ // a `default` (or the basename-Data fallback) for the aliased-default form.
44
+ const d = evalDepExports('const x = 7;\nexport { x as default };', 'shared/util.js');
45
+ const resolvable = d.default !== undefined || d.utilData !== undefined;
46
+ assert.ok(resolvable, 'aliased default export is not resolvable by the bundle');
47
+ });
48
+
49
+ test('destructuring export does not silently drop the binding', () => {
50
+ // `export const { a } = obj` is a valid ESM export; the consumer expects `a`.
51
+ const d = evalDepExports('const obj = { a: 5 };\nexport const { a } = obj;');
52
+ assert.equal(d.a, 5, 'destructured export binding was dropped');
53
+ });
54
+
55
+ test('exports that reference one another resolve at runtime', () => {
56
+ // A helper export referencing a constant export must still find it — the
57
+ // transform has to keep a usable local binding, not only the exports field.
58
+ const d = evalDepExports(
59
+ "export const TAG = 'x';\nexport function badge(label) { return '[' + TAG + '] ' + label; }"
60
+ );
61
+ assert.equal(d.TAG, 'x');
62
+ assert.equal(typeof d.badge, 'function');
63
+ assert.equal(d.badge('hi'), '[x] hi');
64
+ });
65
+
66
+ test('a transitive dependency is inlined and bound (topological order)', () => {
67
+ const gen = makeGenerator();
68
+ // mid imports leaf; pass mid FIRST so the topological sort must reorder.
69
+ const block = gen.buildV2DependencyModuleBlockFromModules([
70
+ {
71
+ name: 'shared/mid.js',
72
+ content: "import { LEAF } from './leaf.js';\nexport function midValue() { return 'mid:' + LEAF; }",
73
+ moduleImports: [
74
+ { depName: 'shared/leaf.js', bindings: [{ type: 'named', importedName: 'LEAF', localName: 'LEAF' }] },
75
+ ],
76
+ },
77
+ { name: 'shared/leaf.js', content: "export const LEAF = 'leaf-value';", moduleImports: [] },
78
+ ]);
79
+ const deps = evalSnippet(`${block}\nreturn SLICE_BUNDLE_DEPENDENCIES;`);
80
+ assert.equal(deps['shared/leaf.js'].LEAF, 'leaf-value');
81
+ assert.equal(deps['shared/mid.js'].midValue(), 'mid:leaf-value');
82
+ });
83
+ });
84
+
85
+ describe('transitive dependencies of a shared module', () => {
86
+ function bundleWithDependency(depName, depContent, bindings) {
87
+ const gen = makeGenerator();
88
+ const comp = {
89
+ name: 'Widget',
90
+ category: 'Visual',
91
+ categoryType: 'Visual',
92
+ js: 'const C = class {};\nreturn C;',
93
+ hoistedImports: [],
94
+ html: '',
95
+ css: '',
96
+ externalDependencies: { [depName]: { content: depContent, bindings } },
97
+ size: 100,
98
+ };
99
+ return gen.generateBundleFileContent('slice-bundle.critical.js', 'critical', [comp]);
100
+ }
101
+
102
+ test('a relative import inside a shared module must not leak into the bundle', () => {
103
+ const code = bundleWithDependency(
104
+ 'shared/helper.js',
105
+ "import deep from './deep.js';\nexport const helper = () => deep;",
106
+ [{ type: 'named', importedName: 'helper', localName: 'helper' }]
107
+ );
108
+ // Whatever the strategy, the emitted bundle must not contain an unresolved
109
+ // relative import — it would 404 / fail to resolve in production.
110
+ assert.ok(
111
+ !/import\s+[^;]*from\s+['"]\.\.?\//.test(code),
112
+ 'unresolved relative import leaked from a transitive dependency'
113
+ );
114
+ });
115
+
116
+ test('a bare import inside a shared module must not leak into the bundle', () => {
117
+ const code = bundleWithDependency(
118
+ 'shared/helper.js',
119
+ "import 'side-effect-polyfill';\nexport const helper = () => 1;",
120
+ [{ type: 'named', importedName: 'helper', localName: 'helper' }]
121
+ );
122
+ assert.ok(
123
+ !/import\s+['"][^'"]+['"]/.test(code),
124
+ 'unresolved bare import leaked from a transitive dependency'
125
+ );
126
+ });
127
+ });
@@ -0,0 +1,267 @@
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
+ // These tests exercise the cross-module import / variable handling of the
7
+ // bundler in isolation. They are written to assert *correct* behaviour, so a
8
+ // failure here documents a real gap/bug in the bundler rather than the test.
9
+
10
+ function makeGenerator(sliceConfig = {}) {
11
+ const gen = new BundleGenerator(import.meta.url, null, {});
12
+ gen.sliceConfig = sliceConfig;
13
+ return gen;
14
+ }
15
+
16
+ // Evaluate a generated JS snippet in a throwaway scope and return whatever the
17
+ // trailing `return` yields. Used to verify the *runtime* behaviour of the code
18
+ // the bundler emits (not just its shape).
19
+ function evalSnippet(code) {
20
+ // eslint-disable-next-line no-new-func
21
+ return new Function(code)();
22
+ }
23
+
24
+ describe('classifyImport', () => {
25
+ const gen = makeGenerator({ publicFolders: ['/assets', '/Themes'] });
26
+ const publicFolders = gen.getConfiguredPublicFolders();
27
+
28
+ test('relative imports are dropped silently (no warning)', () => {
29
+ const r = gen.classifyImport('./Foo.js', publicFolders);
30
+ assert.equal(r.keep, false);
31
+ assert.equal(r.warning, null);
32
+ });
33
+
34
+ test('parent-relative imports are dropped silently', () => {
35
+ const r = gen.classifyImport('../shared/util.js', publicFolders);
36
+ assert.equal(r.keep, false);
37
+ assert.equal(r.warning, null);
38
+ });
39
+
40
+ test('absolute imports inside publicFolders are kept', () => {
41
+ const r = gen.classifyImport('/assets/lib/x.js', publicFolders);
42
+ assert.equal(r.keep, true);
43
+ assert.equal(r.warning, null);
44
+ });
45
+
46
+ test('absolute import exactly matching a publicFolder is kept', () => {
47
+ const r = gen.classifyImport('/assets', publicFolders);
48
+ assert.equal(r.keep, true);
49
+ });
50
+
51
+ test('absolute imports outside publicFolders are dropped with warning', () => {
52
+ const r = gen.classifyImport('/secret/key.js', publicFolders);
53
+ assert.equal(r.keep, false);
54
+ assert.match(r.warning, /outside publicFolders/);
55
+ });
56
+
57
+ test('a publicFolder name must not match by prefix only (/assetsX)', () => {
58
+ const r = gen.classifyImport('/assetsX/x.js', publicFolders);
59
+ assert.equal(r.keep, false, '/assetsX should NOT be treated as inside /assets');
60
+ });
61
+
62
+ test('bare specifier imports are dropped with warning', () => {
63
+ const r = gen.classifyImport('lodash', publicFolders);
64
+ assert.equal(r.keep, false);
65
+ assert.match(r.warning, /bare import/);
66
+ });
67
+
68
+ test('non-string import path is handled defensively', () => {
69
+ const r = gen.classifyImport(undefined, publicFolders);
70
+ assert.equal(r.keep, false);
71
+ });
72
+ });
73
+
74
+ describe('stripImports', () => {
75
+ test('removes relative + bare imports, keeps body intact', () => {
76
+ const gen = makeGenerator({ publicFolders: [] });
77
+ const code = [
78
+ "import Foo from './Foo.js';",
79
+ "import { x } from 'pkg';",
80
+ 'const value = 42;',
81
+ 'export default value;',
82
+ ].join('\n');
83
+ const out = gen.stripImports(code, { collectHoistedImports: true });
84
+ assert.ok(!out.code.includes("from './Foo.js'"));
85
+ assert.ok(!out.code.includes("from 'pkg'"));
86
+ assert.ok(out.code.includes('const value = 42;'));
87
+ assert.deepEqual(out.hoistedImports, []);
88
+ });
89
+
90
+ test('hoists kept public-folder imports instead of inlining them', () => {
91
+ const gen = makeGenerator({ publicFolders: ['/assets'] });
92
+ const code = "import lib from '/assets/lib.js';\nconst a = lib;";
93
+ const out = gen.stripImports(code, { collectHoistedImports: true });
94
+ assert.equal(out.hoistedImports.length, 1);
95
+ assert.match(out.hoistedImports[0], /\/assets\/lib\.js/);
96
+ // The hoisted import must NOT remain inline in the body.
97
+ assert.ok(!out.code.includes("import lib from '/assets/lib.js'"));
98
+ });
99
+
100
+ test('code with no imports is returned unchanged', () => {
101
+ const gen = makeGenerator();
102
+ const code = 'const a = 1;\nconst b = 2;';
103
+ const out = gen.stripImports(code, { collectHoistedImports: true });
104
+ assert.equal(out.code, code);
105
+ });
106
+
107
+ test('falls back to regex scanner when Babel cannot parse', () => {
108
+ const gen = makeGenerator({ publicFolders: [] });
109
+ // `@decorator` + class field syntax with no plugin -> Babel throws -> fallback.
110
+ const code = "import x from './x.js';\nconst valid = 1;\nthis is not ::: valid js @@@";
111
+ // Should not throw; relative import must still be stripped.
112
+ const out = gen.stripImports(code, { collectHoistedImports: true });
113
+ assert.ok(!out.code.includes("from './x.js'"));
114
+ });
115
+ });
116
+
117
+ describe('extractLocalBindingsFromImportStatement', () => {
118
+ const gen = makeGenerator();
119
+
120
+ test('default import', () => {
121
+ assert.deepEqual(gen.extractLocalBindingsFromImportStatement("import Foo from 'x';"), ['Foo']);
122
+ });
123
+
124
+ test('named imports', () => {
125
+ assert.deepEqual(
126
+ gen.extractLocalBindingsFromImportStatement("import { a, b } from 'x';").sort(),
127
+ ['a', 'b']
128
+ );
129
+ });
130
+
131
+ test('aliased named import uses the local name', () => {
132
+ assert.deepEqual(gen.extractLocalBindingsFromImportStatement("import { a as b } from 'x';"), ['b']);
133
+ });
134
+
135
+ test('namespace import', () => {
136
+ assert.deepEqual(gen.extractLocalBindingsFromImportStatement("import * as NS from 'x';"), ['NS']);
137
+ });
138
+
139
+ test('mixed default + named', () => {
140
+ assert.deepEqual(
141
+ gen.extractLocalBindingsFromImportStatement("import Foo, { a, b as c } from 'x';").sort(),
142
+ ['Foo', 'a', 'c']
143
+ );
144
+ });
145
+
146
+ test('side-effect import has no bindings', () => {
147
+ assert.deepEqual(gen.extractLocalBindingsFromImportStatement("import 'x';"), []);
148
+ });
149
+ });
150
+
151
+ describe('validateHoistedImportCollisions', () => {
152
+ const gen = makeGenerator();
153
+
154
+ test('throws on the same binding from two different statements', () => {
155
+ assert.throws(
156
+ () => gen.validateHoistedImportCollisions([
157
+ "import Foo from '/assets/a.js';",
158
+ "import Foo from '/assets/b.js';",
159
+ ]),
160
+ /binding collision/
161
+ );
162
+ });
163
+
164
+ test('does not throw when the identical statement appears twice', () => {
165
+ assert.doesNotThrow(() => gen.validateHoistedImportCollisions([
166
+ "import Foo from '/assets/a.js';",
167
+ "import Foo from '/assets/a.js';",
168
+ ]));
169
+ });
170
+
171
+ test('throws when a hoisted binding collides with a reserved identifier', () => {
172
+ assert.throws(
173
+ () => gen.validateHoistedImportCollisions(
174
+ ["import SLICE_BUNDLE_META from '/assets/a.js';"],
175
+ new Set(['SLICE_BUNDLE_META'])
176
+ ),
177
+ /reserved identifier collision/
178
+ );
179
+ });
180
+ });
181
+
182
+ describe('transformDependencyContent', () => {
183
+ const gen = makeGenerator();
184
+
185
+ function transformAndEval(content, moduleName = 'shared/util.js') {
186
+ const transformed = gen.transformDependencyContent(content, '__d', moduleName);
187
+ return evalSnippet(`const __d = {};\n${transformed}\nreturn __d;`);
188
+ }
189
+
190
+ test('export const / let / var are attached to the exports object', () => {
191
+ const d = transformAndEval('export const A = 1;\nexport let B = 2;\nexport var C = 3;');
192
+ assert.equal(d.A, 1);
193
+ assert.equal(d.B, 2);
194
+ assert.equal(d.C, 3);
195
+ });
196
+
197
+ test('export function is attached and callable', () => {
198
+ const d = transformAndEval('export function fn() { return 7; }');
199
+ assert.equal(typeof d.fn, 'function');
200
+ assert.equal(d.fn(), 7);
201
+ });
202
+
203
+ test('export default maps to the <basename>Data fallback key', () => {
204
+ const d = transformAndEval('export default 99;', 'shared/util.js');
205
+ assert.equal(d.utilData, 99);
206
+ });
207
+
208
+ test('plain export { a, b } re-exports local bindings', () => {
209
+ const d = transformAndEval('const a = 10;\nconst b = 20;\nexport { a, b };');
210
+ assert.equal(d.a, 10);
211
+ assert.equal(d.b, 20);
212
+ });
213
+
214
+ test('aliased export { internal as Public } exposes the PUBLIC name', () => {
215
+ // A consumer writes `import { Public } from '...'`, so the exports object
216
+ // must carry `Public`, mapped to the value of `internal`.
217
+ const d = transformAndEval('const internal = 55;\nexport { internal as Public };');
218
+ assert.equal(d.Public, 55, 'exported alias name should be the public key');
219
+ });
220
+ });
221
+
222
+ describe('default export resolver (__sliceResolveDefaultExport)', () => {
223
+ const gen = makeGenerator();
224
+ function buildResolver() {
225
+ const lines = gen.getDefaultExportResolverLines().join('\n');
226
+ return evalSnippet(`${lines}\nreturn __sliceResolveDefaultExport;`);
227
+ }
228
+
229
+ test('returns .default when present', () => {
230
+ const resolve = buildResolver();
231
+ assert.equal(resolve({ default: 5, other: 9 }, 'm', null), 5);
232
+ });
233
+
234
+ test('returns the single non-default key when only one exists', () => {
235
+ const resolve = buildResolver();
236
+ assert.equal(resolve({ only: 42 }, 'm', null), 42);
237
+ });
238
+
239
+ test('honours the preferred key', () => {
240
+ const resolve = buildResolver();
241
+ assert.equal(resolve({ a: 1, utilData: 2 }, 'm', 'utilData'), 2);
242
+ });
243
+
244
+ test('primitive dependency is returned as-is', () => {
245
+ const resolve = buildResolver();
246
+ assert.equal(resolve(7, 'm', null), 7);
247
+ });
248
+ });
249
+
250
+ describe('toSafeIdentifier', () => {
251
+ const gen = makeGenerator();
252
+
253
+ test('produces a valid JS identifier', () => {
254
+ const id = gen.toSafeIdentifier('My-Component');
255
+ assert.doesNotThrow(() => parse(`const ${id} = 1;`));
256
+ });
257
+
258
+ test('distinct component names must not collide to the same identifier', () => {
259
+ // Two registered components differing only by a non-alphanumeric char would
260
+ // otherwise emit two `const <id>` declarations -> duplicate-decl syntax error.
261
+ assert.notEqual(
262
+ gen.toSafeIdentifier('my-btn'),
263
+ gen.toSafeIdentifier('my_btn'),
264
+ 'distinct names collapse to the same bundle identifier'
265
+ );
266
+ });
267
+ });
@@ -0,0 +1,102 @@
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 { withTestProject } from './helpers/setup.js';
6
+ import createComponent from '../commands/createComponent/createComponent.js';
7
+ import deleteComponent from '../commands/deleteComponent/deleteComponent.js';
8
+ import listComponentsReal from '../commands/listComponents/listComponents.js';
9
+
10
+ const visualDir = (root, name) =>
11
+ path.join(root, 'src', 'Components', 'Visual', name);
12
+
13
+ describe('component create', () => {
14
+ test('creates a Visual component with .js/.css/.html', async () => {
15
+ await withTestProject(async (root) => {
16
+ const ok = createComponent('Card', 'Visual');
17
+ assert.equal(ok, true);
18
+ const dir = visualDir(root, 'Card');
19
+ assert.ok(await fs.pathExists(path.join(dir, 'Card.js')));
20
+ assert.ok(await fs.pathExists(path.join(dir, 'Card.css')));
21
+ assert.ok(await fs.pathExists(path.join(dir, 'Card.html')));
22
+ });
23
+ });
24
+
25
+ test('creates a Service component with only a .js file', async () => {
26
+ await withTestProject(async (root) => {
27
+ const ok = createComponent('Api', 'Service');
28
+ assert.equal(ok, true);
29
+ const dir = path.join(root, 'src', 'Components', 'Service', 'Api');
30
+ assert.ok(await fs.pathExists(path.join(dir, 'Api.js')));
31
+ assert.equal(await fs.pathExists(path.join(dir, 'Api.css')), false);
32
+ });
33
+ });
34
+
35
+ test('rejects an invalid component name', async () => {
36
+ await withTestProject(() => {
37
+ assert.equal(createComponent('1Bad', 'Visual'), false);
38
+ assert.equal(createComponent('bad-name', 'Visual'), false);
39
+ });
40
+ });
41
+
42
+ test('rejects an unknown category', async () => {
43
+ await withTestProject(() => {
44
+ assert.equal(createComponent('Card', 'Nope'), false);
45
+ });
46
+ });
47
+
48
+ test('rejects when the component files already exist', async () => {
49
+ await withTestProject(() => {
50
+ assert.equal(createComponent('Card', 'Visual'), true);
51
+ // Second creation hits the on-disk existence guard.
52
+ assert.equal(createComponent('Card', 'Visual'), false);
53
+ });
54
+ });
55
+ });
56
+
57
+ describe('component delete', () => {
58
+ test('deletes an existing component directory', async () => {
59
+ await withTestProject(async (root) => {
60
+ createComponent('Card', 'Visual');
61
+ assert.ok(await fs.pathExists(visualDir(root, 'Card')));
62
+
63
+ const ok = deleteComponent('Card', 'Visual');
64
+ assert.equal(ok, true);
65
+ assert.equal(await fs.pathExists(visualDir(root, 'Card')), false);
66
+ });
67
+ });
68
+
69
+ test('returns false when the component does not exist', async () => {
70
+ await withTestProject(() => {
71
+ assert.equal(deleteComponent('DoesNotExist', 'Visual'), false);
72
+ });
73
+ });
74
+
75
+ // Components are PascalCase by convention: both create and delete normalize
76
+ // the initial to uppercase, so a lower-case name round-trips correctly.
77
+ test('create/delete round-trips a lower-case initial (PascalCase normalization)', async () => {
78
+ await withTestProject(async (root) => {
79
+ assert.equal(createComponent('card', 'Visual'), true);
80
+ assert.ok(await fs.pathExists(visualDir(root, 'Card')), 'folder is created in PascalCase');
81
+ assert.equal(deleteComponent('card', 'Visual'), true);
82
+ assert.equal(await fs.pathExists(visualDir(root, 'Card')), false);
83
+ });
84
+ });
85
+ });
86
+
87
+ describe('component list (registry regeneration)', () => {
88
+ test('regenerates components.js from the files on disk', async () => {
89
+ await withTestProject(async (root) => {
90
+ createComponent('Card', 'Visual');
91
+ listComponentsReal();
92
+
93
+ const registryPath = path.join(root, 'src', 'Components', 'components.js');
94
+ const content = await fs.readFile(registryPath, 'utf8');
95
+ const json = JSON.parse(content.match(/const components = ({[\s\S]*?});/)[1]);
96
+
97
+ assert.equal(json.Card, 'Visual');
98
+ // Starter AppComponents must still be present.
99
+ assert.equal(json.AppShell, 'AppComponents');
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,80 @@
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 { withTestProject } from './helpers/setup.js';
6
+ import {
7
+ checkNodeVersion,
8
+ checkDirectoryStructure,
9
+ checkConfig,
10
+ checkComponents,
11
+ } from '../commands/doctor/doctor.js';
12
+
13
+ describe('doctor checks', () => {
14
+ test('checkNodeVersion passes on the supported runtime', async () => {
15
+ const r = await checkNodeVersion();
16
+ assert.equal(r.pass, true);
17
+ assert.match(r.message, /Node\.js version/);
18
+ });
19
+
20
+ test('checkDirectoryStructure passes on the starter project', async () => {
21
+ await withTestProject(async () => {
22
+ const r = await checkDirectoryStructure();
23
+ assert.equal(r.pass, true);
24
+ });
25
+ });
26
+
27
+ test('checkDirectoryStructure fails when src/ is missing', async () => {
28
+ await withTestProject(async (root) => {
29
+ await fs.remove(path.join(root, 'src'));
30
+ const r = await checkDirectoryStructure();
31
+ assert.equal(r.pass, false);
32
+ assert.match(r.message, /Missing directories/);
33
+ assert.match(r.message, /src\//);
34
+ });
35
+ });
36
+
37
+ test('checkConfig passes for a valid sliceConfig.json', async () => {
38
+ await withTestProject(async () => {
39
+ const r = await checkConfig();
40
+ assert.equal(r.pass, true);
41
+ });
42
+ });
43
+
44
+ test('checkConfig fails on invalid JSON', async () => {
45
+ await withTestProject(async (root) => {
46
+ await fs.writeFile(path.join(root, 'src', 'sliceConfig.json'), '{ not valid json ');
47
+ const r = await checkConfig();
48
+ assert.equal(r.pass, false);
49
+ assert.match(r.message, /invalid JSON/);
50
+ });
51
+ });
52
+
53
+ test('checkConfig fails when paths.components is missing', async () => {
54
+ await withTestProject(async (root) => {
55
+ await fs.writeJson(path.join(root, 'src', 'sliceConfig.json'), { server: { port: 3000 } });
56
+ const r = await checkConfig();
57
+ assert.equal(r.pass, false);
58
+ assert.match(r.message, /missing paths\.components/);
59
+ });
60
+ });
61
+
62
+ test('checkComponents reports OK when every component folder has its .js', async () => {
63
+ await withTestProject(async () => {
64
+ const r = await checkComponents();
65
+ assert.equal(r.pass, true);
66
+ assert.match(r.message, /components checked/);
67
+ });
68
+ });
69
+
70
+ test('checkComponents warns when a component folder lacks its .js file', async () => {
71
+ await withTestProject(async (root) => {
72
+ // AppShell ships with AppShell.js; remove it to simulate a broken component.
73
+ const broken = path.join(root, 'src', 'Components', 'AppComponents', 'Broken');
74
+ await fs.ensureDir(broken); // directory with no Broken.js
75
+ const r = await checkComponents();
76
+ assert.equal(r.warn, true);
77
+ assert.match(r.message, /missing files/);
78
+ });
79
+ });
80
+ });
@@ -0,0 +1,37 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import versionChecker from '../commands/utils/VersionChecker.js';
4
+
5
+ describe('VersionChecker.compareVersions', () => {
6
+ test('detects an outdated version', () => {
7
+ assert.equal(versionChecker.compareVersions('1.0.0', '1.0.1'), 'outdated');
8
+ assert.equal(versionChecker.compareVersions('1.2.0', '2.0.0'), 'outdated');
9
+ });
10
+
11
+ test('detects a newer (local) version', () => {
12
+ assert.equal(versionChecker.compareVersions('2.0.0', '1.9.9'), 'newer');
13
+ });
14
+
15
+ test('detects an up-to-date version', () => {
16
+ assert.equal(versionChecker.compareVersions('3.5.0', '3.5.0'), 'current');
17
+ });
18
+
19
+ test('handles version strings of differing length', () => {
20
+ assert.equal(versionChecker.compareVersions('1.0', '1.0.0'), 'current');
21
+ assert.equal(versionChecker.compareVersions('1.0.0', '1.0.0.1'), 'outdated');
22
+ assert.equal(versionChecker.compareVersions('1.0.1', '1.0'), 'newer');
23
+ });
24
+
25
+ test('returns null when a version is missing', () => {
26
+ assert.equal(versionChecker.compareVersions(null, '1.0.0'), null);
27
+ assert.equal(versionChecker.compareVersions('1.0.0', undefined), null);
28
+ });
29
+ });
30
+
31
+ describe('VersionChecker.getCurrentVersions', () => {
32
+ test('reads the CLI version from the package.json (no network)', async () => {
33
+ const current = await versionChecker.getCurrentVersions();
34
+ assert.ok(current, 'should resolve current versions');
35
+ assert.match(current.cli, /^\d+\.\d+\.\d+/);
36
+ });
37
+ });
@@ -30,5 +30,5 @@ test('Validations componentExists with JSON.parse (no eval)', async () => {
30
30
  const validations = (await import('../commands/Validations.js')).default;
31
31
  assert.equal(validations.componentExists('Button'), true);
32
32
  assert.equal(validations.componentExists('NonExistent'), false);
33
- });
33
+ }, { visualComponents: ['Button'] });
34
34
  });