slicejs-web-framework 3.4.3 → 3.4.4

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 (107) hide show
  1. package/.opencode/opencode.json +14 -14
  2. package/Slice/Components/Structural/ContextManager/ContextManager.js +422 -422
  3. package/Slice/Components/Structural/ContextManager/ContextManagerDebugger.js +468 -468
  4. package/Slice/Components/Structural/Controller/Controller.js +1271 -1198
  5. package/Slice/Components/Structural/Debugger/Debugger.js +1503 -1503
  6. package/Slice/Components/Structural/EventManager/EventManager.js +366 -366
  7. package/Slice/Components/Structural/Logger/Logger.js +225 -225
  8. package/Slice/Components/Structural/Router/Router.js +775 -775
  9. package/Slice/Components/Structural/StylesManager/StylesManager.js +82 -82
  10. package/Slice/Components/Structural/StylesManager/ThemeManager/ThemeManager.js +106 -106
  11. package/Slice/Slice.js +650 -627
  12. package/Slice/tests/build-singleton.test.js +244 -244
  13. package/Slice/tests/bundle-v2-runtime-contract.test.js +738 -738
  14. package/Slice/tests/context-patch-use.test.js +95 -95
  15. package/Slice/tests/fixtures/real-runtime-loader.mjs +21 -21
  16. package/Slice/tests/public-env-typed-accessors.test.js +66 -66
  17. package/api/index.js +281 -281
  18. package/api/utils/publicEnvResolver.js +123 -123
  19. package/package.json +44 -44
  20. package/slicejs-cli/.github/workflows/ci.yml +43 -0
  21. package/slicejs-cli/LICENSE +21 -0
  22. package/slicejs-cli/assets/Slice.js-logo.png +0 -0
  23. package/slicejs-cli/client.js +740 -0
  24. package/slicejs-cli/commands/Print.js +164 -0
  25. package/slicejs-cli/commands/Validations.js +92 -0
  26. package/slicejs-cli/commands/build/build.js +40 -0
  27. package/slicejs-cli/commands/buildProduction/buildProduction.js +577 -0
  28. package/slicejs-cli/commands/bundle/bundle.js +236 -0
  29. package/slicejs-cli/commands/createComponent/VisualComponentTemplate.js +55 -0
  30. package/slicejs-cli/commands/createComponent/createComponent.js +128 -0
  31. package/slicejs-cli/commands/deleteComponent/deleteComponent.js +81 -0
  32. package/slicejs-cli/commands/doctor/doctor.js +516 -0
  33. package/slicejs-cli/commands/getComponent/getComponent.js +695 -0
  34. package/slicejs-cli/commands/init/init.js +473 -0
  35. package/slicejs-cli/commands/listComponents/listComponents.js +172 -0
  36. package/slicejs-cli/commands/startServer/startServer.js +240 -0
  37. package/slicejs-cli/commands/startServer/watchServer.js +66 -0
  38. package/slicejs-cli/commands/types/types.js +583 -0
  39. package/slicejs-cli/commands/utils/LocalCliDelegation.js +53 -0
  40. package/slicejs-cli/commands/utils/PackageManager.js +148 -0
  41. package/slicejs-cli/commands/utils/PathHelper.js +75 -0
  42. package/slicejs-cli/commands/utils/VersionChecker.js +169 -0
  43. package/slicejs-cli/commands/utils/bundling/BundleGenerator.js +2525 -0
  44. package/slicejs-cli/commands/utils/bundling/DependencyAnalyzer.js +933 -0
  45. package/slicejs-cli/commands/utils/loadConfig.js +31 -0
  46. package/slicejs-cli/commands/utils/sliceScripts.js +48 -0
  47. package/slicejs-cli/commands/utils/updateManager.js +471 -0
  48. package/slicejs-cli/package.json +71 -0
  49. package/slicejs-cli/playwright.config.js +51 -0
  50. package/slicejs-cli/pnpm-lock.yaml +1629 -0
  51. package/slicejs-cli/tests/build-command-integration.test.js +87 -0
  52. package/slicejs-cli/tests/build-mangling-e2e.test.js +290 -0
  53. package/slicejs-cli/tests/build-production-e2e.test.js +140 -0
  54. package/slicejs-cli/tests/builder-edge-cases.test.js +322 -0
  55. package/slicejs-cli/tests/bundle-generate-e2e.test.js +115 -0
  56. package/slicejs-cli/tests/bundle-generator.test.js +691 -0
  57. package/slicejs-cli/tests/bundle-v2-register-output.test.js +470 -0
  58. package/slicejs-cli/tests/bundling-dependency-edges.test.js +127 -0
  59. package/slicejs-cli/tests/bundling-imports-unit.test.js +267 -0
  60. package/slicejs-cli/tests/client-launcher-contract.test.js +211 -0
  61. package/slicejs-cli/tests/commands-component-crud.test.js +128 -0
  62. package/slicejs-cli/tests/commands-doctor.test.js +152 -0
  63. package/slicejs-cli/tests/commands-version-checker.test.js +37 -0
  64. package/slicejs-cli/tests/component-registry-parse.test.js +34 -0
  65. package/slicejs-cli/tests/dependency-analyzer.test.js +24 -0
  66. package/slicejs-cli/tests/e2e/bundles.spec.js +91 -0
  67. package/slicejs-cli/tests/e2e/dependency-scenarios.spec.js +56 -0
  68. package/slicejs-cli/tests/e2e/fixtures/components/Service/FetchManager/FetchManager.js +136 -0
  69. package/slicejs-cli/tests/e2e/fixtures/components/Service/IndexedDbManager/IndexedDbManager.js +149 -0
  70. package/slicejs-cli/tests/e2e/fixtures/components/Service/LocalStorageManager/LocalStorageManager.js +45 -0
  71. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Button/Button.css +106 -0
  72. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Button/Button.html +5 -0
  73. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Button/Button.js +158 -0
  74. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Link/Link.js +33 -0
  75. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Loading/Loading.css +56 -0
  76. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Loading/Loading.html +83 -0
  77. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Loading/Loading.js +164 -0
  78. package/slicejs-cli/tests/e2e/fixtures/components/Visual/MultiRoute/MultiRoute.js +167 -0
  79. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Navbar/Navbar.css +116 -0
  80. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Navbar/Navbar.html +44 -0
  81. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Navbar/Navbar.js +180 -0
  82. package/slicejs-cli/tests/e2e/fixtures/components/Visual/NotFound/NotFound.js +20 -0
  83. package/slicejs-cli/tests/e2e/fixtures/components/Visual/Route/Route.js +181 -0
  84. package/slicejs-cli/tests/e2e/fixtures/components/registry.json +12 -0
  85. package/slicejs-cli/tests/e2e/fixtures/vendor-components.mjs +65 -0
  86. package/slicejs-cli/tests/e2e/navigation.spec.js +44 -0
  87. package/slicejs-cli/tests/e2e/render.spec.js +34 -0
  88. package/slicejs-cli/tests/e2e/serve.mjs +264 -0
  89. package/slicejs-cli/tests/e2e/shared-deps.spec.js +61 -0
  90. package/slicejs-cli/tests/e2e/unminified.spec.js +33 -0
  91. package/slicejs-cli/tests/e2e-serve.test.js +148 -0
  92. package/slicejs-cli/tests/fixtures/components.js +8 -0
  93. package/slicejs-cli/tests/fixtures/sliceConfig.json +74 -0
  94. package/slicejs-cli/tests/getcomponent.test.js +435 -0
  95. package/slicejs-cli/tests/helpers/setup.js +102 -0
  96. package/slicejs-cli/tests/init-command-contract.test.js +101 -0
  97. package/slicejs-cli/tests/init-e2e.test.js +82 -0
  98. package/slicejs-cli/tests/init-project-isolation.test.js +98 -0
  99. package/slicejs-cli/tests/local-cli-delegation.test.js +81 -0
  100. package/slicejs-cli/tests/package-manager.test.js +127 -0
  101. package/slicejs-cli/tests/path-helper.test.js +206 -0
  102. package/slicejs-cli/tests/perf-budget.test.js +84 -0
  103. package/slicejs-cli/tests/postinstall-command.test.js +103 -0
  104. package/slicejs-cli/tests/property-mangling-regression.test.js +182 -0
  105. package/slicejs-cli/tests/types-breakage.test.js +491 -0
  106. package/slicejs-cli/tests/types-generator-errors.test.js +361 -0
  107. package/slicejs-cli/tests/types-generator.test.js +348 -0
@@ -0,0 +1,33 @@
1
+ import { test, expect } from '@playwright/test';
2
+
3
+ // Runs against a second server built with E2E_MINIFY=false, so the raw
4
+ // (un-minified, un-obfuscated) bundle output is exercised in the browser.
5
+ function trackErrors(page) {
6
+ const errors = [];
7
+ page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
8
+ page.on('pageerror', (e) => errors.push(String(e)));
9
+ return errors;
10
+ }
11
+
12
+ test.describe('unminified production build', () => {
13
+ test('boots and renders the home page', async ({ page }) => {
14
+ const errors = trackErrors(page);
15
+ await page.goto('/');
16
+
17
+ await expect(page.locator('slice-app-shell')).toBeAttached();
18
+ await expect(page.locator('slice-home-section h1.home__title')).toHaveText(
19
+ /Welcome to your Slice app/
20
+ );
21
+
22
+ expect(errors, `console errors:\n${errors.join('\n')}`).toEqual([]);
23
+ });
24
+
25
+ test('resolves the vendor-shared dependency with unminified bundles', async ({ page }) => {
26
+ const errors = trackErrors(page);
27
+ await page.goto('/services');
28
+
29
+ await expect(page.locator('slice-servicespage')).toHaveAttribute('data-shared-tag', 'shared-kit-v1');
30
+
31
+ expect(errors, `console errors:\n${errors.join('\n')}`).toEqual([]);
32
+ });
33
+ });
@@ -0,0 +1,148 @@
1
+ import { test, describe } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import fs from 'fs-extra';
7
+ import { parse } from '@babel/parser';
8
+ import { withTestProject } from './helpers/setup.js';
9
+ import build from '../commands/build/build.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const FRAMEWORK_SLICE_JS = path.resolve(
13
+ __dirname,
14
+ '../node_modules/slicejs-web-framework/Slice/Slice.js'
15
+ );
16
+
17
+ const CONTENT_TYPES = {
18
+ '.js': 'application/javascript; charset=utf-8',
19
+ '.json': 'application/json; charset=utf-8',
20
+ '.html': 'text/html; charset=utf-8',
21
+ '.css': 'text/css; charset=utf-8',
22
+ };
23
+
24
+ // Minimal static server that mirrors the production serving contract the
25
+ // framework's api/index.js implements (index/SPA fallback, /slice-env.json,
26
+ // /Slice/Slice.js from the framework package, and dist static files), without
27
+ // pulling in express. Enough to assert that a production build is servable.
28
+ function startServer(distDir) {
29
+ const server = http.createServer(async (req, res) => {
30
+ try {
31
+ const pathname = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
32
+
33
+ if (pathname === '/slice-env.json') {
34
+ res.setHeader('Content-Type', CONTENT_TYPES['.json']);
35
+ res.end(JSON.stringify({ mode: 'production', env: {} }));
36
+ return;
37
+ }
38
+ if (pathname === '/Slice/Slice.js') {
39
+ const body = await fs.readFile(FRAMEWORK_SLICE_JS).catch(() => null);
40
+ if (!body) { res.statusCode = 404; res.end('Slice.js not found'); return; }
41
+ res.setHeader('Content-Type', CONTENT_TYPES['.js']);
42
+ res.end(body);
43
+ return;
44
+ }
45
+
46
+ const filePath = path.join(distDir, pathname);
47
+ if (!filePath.startsWith(distDir)) { res.statusCode = 403; res.end('forbidden'); return; }
48
+
49
+ const stat = await fs.stat(filePath).catch(() => null);
50
+ if (stat && stat.isFile()) {
51
+ res.setHeader('Content-Type', CONTENT_TYPES[path.extname(filePath)] || 'application/octet-stream');
52
+ res.end(await fs.readFile(filePath));
53
+ return;
54
+ }
55
+
56
+ // SPA fallback -> App/index.html
57
+ const index = await fs.readFile(path.join(distDir, 'App', 'index.html')).catch(() => null);
58
+ if (index) { res.setHeader('Content-Type', CONTENT_TYPES['.html']); res.end(index); return; }
59
+ res.statusCode = 404;
60
+ res.end('not found');
61
+ } catch (error) {
62
+ res.statusCode = 500;
63
+ res.end(String(error));
64
+ }
65
+ });
66
+
67
+ return new Promise((resolve) => {
68
+ server.listen(0, '127.0.0.1', () => {
69
+ resolve({ server, port: server.address().port });
70
+ });
71
+ });
72
+ }
73
+
74
+ function closeServer(server) {
75
+ return new Promise((resolve) => server.close(resolve));
76
+ }
77
+
78
+ describe('end-to-end: production build is correctly servable', () => {
79
+ test('build() output serves the app shell, framework runtime and valid bundles', async () => {
80
+ await withTestProject(async (root) => {
81
+ const ok = await build({ minify: false, obfuscate: false });
82
+ assert.equal(ok, true, 'build should succeed');
83
+
84
+ const distDir = path.join(root, 'dist');
85
+ const { server, port } = await startServer(distDir);
86
+ const base = `http://127.0.0.1:${port}`;
87
+
88
+ try {
89
+ // 1. The HTML shell is served and mounts the app + entry module.
90
+ const indexRes = await fetch(`${base}/`);
91
+ assert.equal(indexRes.status, 200);
92
+ const indexHtml = await indexRes.text();
93
+ assert.match(indexHtml, /id="app"/);
94
+ assert.match(indexHtml, /\/App\/index\.js/);
95
+
96
+ // 2. The entry module loads and bootstraps the framework runtime.
97
+ const entryRes = await fetch(`${base}/App/index.js`);
98
+ assert.equal(entryRes.status, 200);
99
+ assert.match(entryRes.headers.get('content-type') || '', /javascript/);
100
+ assert.match(await entryRes.text(), /\/Slice\/Slice\.js/);
101
+
102
+ // 3. The framework runtime itself is reachable.
103
+ const sliceRes = await fetch(`${base}/Slice/Slice.js`);
104
+ assert.equal(sliceRes.status, 200);
105
+
106
+ // 4. Config + runtime mode endpoints.
107
+ const cfgRes = await fetch(`${base}/sliceConfig.json`);
108
+ assert.equal(cfgRes.status, 200);
109
+ const cfg = await cfgRes.json();
110
+ assert.ok(cfg.paths?.components, 'sliceConfig exposes component paths');
111
+
112
+ const envRes = await fetch(`${base}/slice-env.json`);
113
+ assert.equal((await envRes.json()).mode, 'production');
114
+
115
+ // 5. The bundle manifest is served and well-formed.
116
+ const manifestRes = await fetch(`${base}/bundles/bundle.config.json`);
117
+ assert.equal(manifestRes.status, 200);
118
+ const manifest = await manifestRes.json();
119
+ assert.equal(manifest.production, true);
120
+ assert.equal(manifest.format, 'v2');
121
+
122
+ // 6. Every emitted bundle is served as JS and is syntactically valid.
123
+ const bundlesDir = path.join(distDir, 'bundles');
124
+ const bundleFiles = (await fs.readdir(bundlesDir)).filter(
125
+ (f) => f.startsWith('slice-bundle.') && f.endsWith('.js')
126
+ );
127
+ assert.ok(bundleFiles.length > 0, 'at least one bundle is produced');
128
+ for (const file of bundleFiles) {
129
+ const res = await fetch(`${base}/bundles/${file}`);
130
+ assert.equal(res.status, 200, `${file} should be served`);
131
+ assert.match(res.headers.get('content-type') || '', /javascript/);
132
+ const code = await res.text();
133
+ assert.doesNotThrow(
134
+ () => parse(code, { sourceType: 'module', plugins: ['jsx'] }),
135
+ `${file} is not valid JS`
136
+ );
137
+ }
138
+
139
+ // 7. Unknown client routes fall back to the SPA shell.
140
+ const spaRes = await fetch(`${base}/some/client/route`);
141
+ assert.equal(spaRes.status, 200);
142
+ assert.match(await spaRes.text(), /id="app"/);
143
+ } finally {
144
+ await closeServer(server);
145
+ }
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,8 @@
1
+ const components = {
2
+ "Button": "Visual",
3
+ "Link": "Visual",
4
+ "Loading": "Visual",
5
+ "Navbar": "Visual",
6
+ "NotFound": "Visual",
7
+ "FetchManager": "Service"
8
+ }; export default components;
@@ -0,0 +1,74 @@
1
+ {
2
+ "server": {
3
+ "port": 3001,
4
+ "host": "localhost"
5
+ },
6
+ "debugger": {
7
+ "enabled": false,
8
+ "click": "right"
9
+ },
10
+ "events": {
11
+ "enabled": true,
12
+ "ui": {
13
+ "enabled": true,
14
+ "shortcut": "alt+shift+e"
15
+ }
16
+ },
17
+ "context": {
18
+ "enabled": true,
19
+ "ui": {
20
+ "enabled": true,
21
+ "shortcut": "alt+shift+c"
22
+ }
23
+ },
24
+ "stylesManager": {
25
+ "requestedStyles": ["sliceStyles"]
26
+ },
27
+ "themeManager": {
28
+ "enabled": true,
29
+ "defaultTheme": "Slice",
30
+ "saveThemeLocally": false,
31
+ "useBrowserTheme": false
32
+ },
33
+ "logger": {
34
+ "enabled": true,
35
+ "showLogs": {
36
+ "console": {
37
+ "error": true,
38
+ "warning": true,
39
+ "info": false
40
+ }
41
+ }
42
+ },
43
+ "paths": {
44
+ "components": {
45
+ "AppComponents": {
46
+ "path": "/Components/AppComponents",
47
+ "type": "Visual"
48
+ },
49
+ "Visual": {
50
+ "path": "/Components/Visual",
51
+ "type": "Visual"
52
+ },
53
+ "Service": {
54
+ "path": "/Components/Service",
55
+ "type": "Service"
56
+ }
57
+ },
58
+ "themes": "/Themes",
59
+ "styles": "/Styles",
60
+ "routesFile": "/routes.js"
61
+ },
62
+ "router": {
63
+ "defaultRoute": "/"
64
+ },
65
+ "loading": {
66
+ "enabled": true
67
+ },
68
+ "publicFolders": [
69
+ "/Themes",
70
+ "/Styles",
71
+ "/assets",
72
+ "/images"
73
+ ]
74
+ }
@@ -0,0 +1,435 @@
1
+ import { test, describe, before, after, mock } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import { createTestProject, cleanupTestProject, withTestProject } from './helpers/setup.js';
6
+
7
+ describe('runConcurrent', () => {
8
+ let runConcurrent;
9
+
10
+ before(async () => {
11
+ ({ runConcurrent } = await import('../commands/getComponent/getComponent.js'));
12
+ });
13
+
14
+ test('runs all items', async () => {
15
+ const results = [];
16
+ const worker = async (item) => { results.push(item); };
17
+ await runConcurrent([1, 2, 3], worker, 5);
18
+ assert.deepEqual(results.sort(), [1, 2, 3]);
19
+ });
20
+
21
+ test('respects concurrency limit', async () => {
22
+ let concurrent = 0;
23
+ let maxConcurrent = 0;
24
+ const worker = async (item) => {
25
+ concurrent++;
26
+ maxConcurrent = Math.max(maxConcurrent, concurrent);
27
+ await new Promise(r => setTimeout(r, 10));
28
+ concurrent--;
29
+ };
30
+ await runConcurrent([1, 2, 3, 4, 5], worker, 2);
31
+ assert.equal(maxConcurrent <= 2, true);
32
+ });
33
+
34
+ test('handles empty array', async () => {
35
+ const worker = async () => {};
36
+ await runConcurrent([], worker, 3);
37
+ assert.ok(true);
38
+ });
39
+
40
+ test('propagates worker errors', async () => {
41
+ const worker = async () => { throw new Error('worker fail'); };
42
+ await assert.rejects(() => runConcurrent([1], worker, 3), /worker fail/);
43
+ });
44
+
45
+ test('default concurrency is 3', async () => {
46
+ let maxConcurrent = 0;
47
+ let running = 0;
48
+ const worker = async () => {
49
+ running++;
50
+ maxConcurrent = Math.max(maxConcurrent, running);
51
+ await new Promise(r => setTimeout(r, 10));
52
+ running--;
53
+ };
54
+ await runConcurrent([1, 2, 3, 4, 5], worker);
55
+ assert.equal(maxConcurrent <= 3, true);
56
+ });
57
+ });
58
+
59
+ describe('loadConfig', () => {
60
+ test('returns config object when sliceConfig.json exists', async () => {
61
+ await withTestProject(async (tmpDir) => {
62
+ const { loadConfig } = await import('../commands/getComponent/getComponent.js');
63
+ const config = await loadConfig();
64
+ assert.notEqual(config, null);
65
+ assert.equal(typeof config, 'object');
66
+ assert.equal(config.server?.port, 3001);
67
+ });
68
+ });
69
+
70
+ test('returns null when sliceConfig.json is missing', async () => {
71
+ const tmpDir = await createTestProject();
72
+ try {
73
+ process.env.INIT_CWD = tmpDir;
74
+ const fixtureConfig = new URL('../tests/fixtures/sliceConfig.json', import.meta.url);
75
+ const fs = await import('node:fs/promises');
76
+ const path = await import('node:path');
77
+ const destPath = path.join(tmpDir, 'src', 'sliceConfig.json');
78
+ try { await fs.unlink(destPath); } catch {}
79
+ const { loadConfig } = await import('../commands/getComponent/getComponent.js');
80
+ const config = await loadConfig();
81
+ assert.equal(config, null);
82
+ } finally {
83
+ delete process.env.INIT_CWD;
84
+ await cleanupTestProject(tmpDir);
85
+ }
86
+ });
87
+ });
88
+
89
+ describe('ComponentRegistry.getAvailableComponents', () => {
90
+ let ComponentRegistry;
91
+
92
+ before(async () => {
93
+ ({ ComponentRegistry } = await import('../commands/getComponent/getComponent.js'));
94
+ });
95
+
96
+ const makeRegistry = (entries) => {
97
+ const registry = new ComponentRegistry();
98
+ registry.componentsRegistry = { ...entries };
99
+ return registry;
100
+ };
101
+
102
+ test('returns empty object when registry not loaded', () => {
103
+ const registry = new ComponentRegistry();
104
+ const result = registry.getAvailableComponents();
105
+ assert.deepEqual(result, {});
106
+ });
107
+
108
+ test('routing components (Route, MultiRoute, Link) return only .js', () => {
109
+ const registry = makeRegistry({
110
+ Route: 'Visual',
111
+ MultiRoute: 'Visual',
112
+ Link: 'Visual'
113
+ });
114
+ const result = registry.getAvailableComponents('Visual');
115
+ assert.deepEqual(result.Route.files, ['Route.js']);
116
+ assert.deepEqual(result.MultiRoute.files, ['MultiRoute.js']);
117
+ assert.deepEqual(result.Link.files, ['Link.js']);
118
+ });
119
+
120
+ test('NotFound returns .js, .html, .css like other visual components', () => {
121
+ const registry = makeRegistry({
122
+ NotFound: 'Visual',
123
+ Button: 'Visual',
124
+ Navbar: 'Visual'
125
+ });
126
+ const result = registry.getAvailableComponents('Visual');
127
+ assert.deepEqual(result.NotFound.files, ['NotFound.js', 'NotFound.html', 'NotFound.css']);
128
+ assert.deepEqual(result.Button.files, ['Button.js', 'Button.html', 'Button.css']);
129
+ assert.deepEqual(result.Navbar.files, ['Navbar.js', 'Navbar.html', 'Navbar.css']);
130
+ });
131
+
132
+ test('Service components return only .js', () => {
133
+ const registry = makeRegistry({
134
+ FetchManager: 'Service'
135
+ });
136
+ const result = registry.getAvailableComponents('Service');
137
+ assert.deepEqual(result.FetchManager.files, ['FetchManager.js']);
138
+ });
139
+
140
+ test('filters by category - Visual', () => {
141
+ const registry = makeRegistry({
142
+ Button: 'Visual',
143
+ FetchManager: 'Service'
144
+ });
145
+ const result = registry.getAvailableComponents('Visual');
146
+ assert.ok(result.Button);
147
+ assert.ok(!result.FetchManager);
148
+ });
149
+
150
+ test('filters by category - Service', () => {
151
+ const registry = makeRegistry({
152
+ Button: 'Visual',
153
+ FetchManager: 'Service'
154
+ });
155
+ const result = registry.getAvailableComponents('Service');
156
+ assert.ok(!result.Button);
157
+ assert.ok(result.FetchManager);
158
+ });
159
+
160
+ test('returns all categories when no filter', () => {
161
+ const registry = makeRegistry({
162
+ Button: 'Visual',
163
+ FetchManager: 'Service'
164
+ });
165
+ const result = registry.getAvailableComponents();
166
+ assert.ok(result.Button);
167
+ assert.ok(result.FetchManager);
168
+ });
169
+ });
170
+
171
+ describe('fetchWithRetry', () => {
172
+ let fetchWithRetry;
173
+ let originalFetch;
174
+
175
+ before(async () => {
176
+ ({ fetchWithRetry } = await import('../commands/getComponent/getComponent.js'));
177
+ });
178
+
179
+ after(() => {
180
+ if (originalFetch) globalThis.fetch = originalFetch;
181
+ });
182
+
183
+ test('succeeds on first attempt', async () => {
184
+ originalFetch = globalThis.fetch;
185
+ globalThis.fetch = mock.fn(async () => ({
186
+ ok: true,
187
+ text: async () => 'response body'
188
+ }));
189
+ const result = await fetchWithRetry('https://example.com');
190
+ assert.equal(result, 'response body');
191
+ assert.equal(globalThis.fetch.mock.calls.length, 1);
192
+ globalThis.fetch = originalFetch;
193
+ });
194
+
195
+ test('retries and eventually succeeds', async () => {
196
+ let attempts = 0;
197
+ originalFetch = globalThis.fetch;
198
+ globalThis.fetch = mock.fn(async () => {
199
+ attempts++;
200
+ if (attempts < 3) throw new Error('network error');
201
+ return { ok: true, text: async () => 'success after retry' };
202
+ });
203
+ const result = await fetchWithRetry('https://example.com', 3, 5);
204
+ assert.equal(result, 'success after retry');
205
+ assert.equal(attempts, 3);
206
+ globalThis.fetch = originalFetch;
207
+ });
208
+
209
+ test('fails after exhausting retries', async () => {
210
+ originalFetch = globalThis.fetch;
211
+ globalThis.fetch = mock.fn(async () => { throw new Error('persistent error'); });
212
+ await assert.rejects(() => fetchWithRetry('https://example.com', 2, 5), /persistent error/);
213
+ globalThis.fetch = originalFetch;
214
+ });
215
+
216
+ test('rejects non-ok response', async () => {
217
+ originalFetch = globalThis.fetch;
218
+ globalThis.fetch = mock.fn(async () => ({
219
+ ok: false,
220
+ status: 404,
221
+ statusText: 'Not Found'
222
+ }));
223
+ await assert.rejects(() => fetchWithRetry('https://example.com', 2, 5), /HTTP 404/);
224
+ globalThis.fetch = originalFetch;
225
+ });
226
+ });
227
+
228
+ describe('filterOfficialComponents', () => {
229
+ let ComponentRegistry;
230
+
231
+ before(async () => {
232
+ ({ ComponentRegistry } = await import('../commands/getComponent/getComponent.js'));
233
+ });
234
+
235
+ test('includes Visual and Service components', () => {
236
+ const registry = new ComponentRegistry();
237
+ const result = registry.filterOfficialComponents({
238
+ Button: 'Visual',
239
+ FetchManager: 'Service'
240
+ });
241
+ assert.deepEqual(result, { Button: 'Visual', FetchManager: 'Service' });
242
+ });
243
+
244
+ test('excludes AppComponents and other categories', () => {
245
+ const registry = new ComponentRegistry();
246
+ const result = registry.filterOfficialComponents({
247
+ Button: 'Visual',
248
+ AppShell: 'AppComponents',
249
+ SomeOther: 'Unknown'
250
+ });
251
+ assert.deepEqual(result, { Button: 'Visual' });
252
+ });
253
+
254
+ test('returns empty object for empty input', () => {
255
+ const registry = new ComponentRegistry();
256
+ const result = registry.filterOfficialComponents({});
257
+ assert.deepEqual(result, {});
258
+ });
259
+
260
+ test('returns empty when no Visual or Service components', () => {
261
+ const registry = new ComponentRegistry();
262
+ const result = registry.filterOfficialComponents({
263
+ AppShell: 'AppComponents',
264
+ Custom: 'Other'
265
+ });
266
+ assert.deepEqual(result, {});
267
+ });
268
+ });
269
+
270
+ describe('findComponentInRegistry', () => {
271
+ let ComponentRegistry;
272
+
273
+ before(async () => {
274
+ ({ ComponentRegistry } = await import('../commands/getComponent/getComponent.js'));
275
+ });
276
+
277
+ const makeRegistry = (entries) => {
278
+ const registry = new ComponentRegistry();
279
+ registry.componentsRegistry = { ...entries };
280
+ return registry;
281
+ };
282
+
283
+ test('finds component by name (case-insensitive)', () => {
284
+ const registry = makeRegistry({ Button: 'Visual' });
285
+ const result = registry.findComponentInRegistry('button');
286
+ assert.deepEqual(result, { name: 'Button', category: 'Visual' });
287
+ });
288
+
289
+ test('returns null for non-existent component', () => {
290
+ const registry = makeRegistry({ Button: 'Visual' });
291
+ const result = registry.findComponentInRegistry('NonExistent');
292
+ assert.equal(result, null);
293
+ });
294
+
295
+ test('returns null when registry not loaded', () => {
296
+ const registry = new ComponentRegistry();
297
+ const result = registry.findComponentInRegistry('Button');
298
+ assert.equal(result, null);
299
+ });
300
+
301
+ test('finds component with exact case', () => {
302
+ const registry = makeRegistry({ FetchManager: 'Service' });
303
+ const result = registry.findComponentInRegistry('FetchManager');
304
+ assert.deepEqual(result, { name: 'FetchManager', category: 'Service' });
305
+ });
306
+ });
307
+
308
+ describe('getLocalComponents', () => {
309
+ let ComponentRegistry;
310
+
311
+ before(async () => {
312
+ ({ ComponentRegistry } = await import('../commands/getComponent/getComponent.js'));
313
+ });
314
+
315
+ test('returns components from local components.js', async () => {
316
+ await withTestProject(async (tmpDir) => {
317
+ const registry = new ComponentRegistry();
318
+ const result = await registry.getLocalComponents();
319
+ assert.deepEqual(result, { Button: 'Visual' });
320
+ }, { visualComponents: ['Button'] });
321
+ });
322
+
323
+ test('returns empty object when no components.js exists', async () => {
324
+ const tmpDir = await createTestProject();
325
+ try {
326
+ process.env.INIT_CWD = tmpDir;
327
+ const componentsPath = path.join(tmpDir, 'src', 'Components', 'components.js');
328
+ await fs.remove(componentsPath);
329
+ const registry = new ComponentRegistry();
330
+ const result = await registry.getLocalComponents();
331
+ assert.deepEqual(result, {});
332
+ } finally {
333
+ delete process.env.INIT_CWD;
334
+ await cleanupTestProject(tmpDir);
335
+ }
336
+ });
337
+
338
+ test('returns empty object when components.js has invalid format', async () => {
339
+ const tmpDir = await createTestProject();
340
+ try {
341
+ process.env.INIT_CWD = tmpDir;
342
+ const componentsPath = path.join(tmpDir, 'src', 'Components', 'components.js');
343
+ await fs.writeFile(componentsPath, 'not valid javascript', 'utf8');
344
+ const registry = new ComponentRegistry();
345
+ const result = await registry.getLocalComponents();
346
+ assert.deepEqual(result, {});
347
+ } finally {
348
+ delete process.env.INIT_CWD;
349
+ await cleanupTestProject(tmpDir);
350
+ }
351
+ });
352
+ });
353
+
354
+ describe('updateLocalRegistrySafe', () => {
355
+ let ComponentRegistry;
356
+
357
+ before(async () => {
358
+ ({ ComponentRegistry } = await import('../commands/getComponent/getComponent.js'));
359
+ });
360
+
361
+ test('registers a new component in components.js', async () => {
362
+ await withTestProject(async (tmpDir) => {
363
+ const registry = new ComponentRegistry();
364
+ await registry.updateLocalRegistrySafe('NewComp', 'Visual');
365
+ const components = await registry.getLocalComponents();
366
+ assert.equal(components.NewComp, 'Visual');
367
+ }, { visualComponents: [] });
368
+ });
369
+
370
+ test('does not duplicate existing component', async () => {
371
+ await withTestProject(async (tmpDir) => {
372
+ const registry = new ComponentRegistry();
373
+ await registry.updateLocalRegistrySafe('Button', 'Visual');
374
+ const components = await registry.getLocalComponents();
375
+ assert.equal(components.Button, 'Visual');
376
+ assert.deepEqual(Object.keys(components).filter(k => k === 'Button'), ['Button']);
377
+ }, { visualComponents: ['Button'] });
378
+ });
379
+ });
380
+
381
+ describe('findUpdatableComponents', () => {
382
+ let ComponentRegistry;
383
+
384
+ before(async () => {
385
+ ({ ComponentRegistry } = await import('../commands/getComponent/getComponent.js'));
386
+ });
387
+
388
+ test('returns empty when no local components match remote registry', async () => {
389
+ await withTestProject(async (tmpDir) => {
390
+ const registry = new ComponentRegistry();
391
+ registry.componentsRegistry = { SomeRemote: 'Visual' };
392
+ const result = await registry.findUpdatableComponents();
393
+ assert.deepEqual(result, []);
394
+ }, { visualComponents: ['Button'] });
395
+ });
396
+
397
+ test('returns components that exist both locally and remotely', async () => {
398
+ await withTestProject(async (tmpDir) => {
399
+ const registry = new ComponentRegistry();
400
+ registry.componentsRegistry = { Button: 'Visual', FetchManager: 'Service' };
401
+ const result = await registry.findUpdatableComponents();
402
+ assert.equal(result.length, 1);
403
+ assert.equal(result[0].name, 'Button');
404
+ assert.equal(result[0].category, 'Visual');
405
+ }, { visualComponents: ['Button'] });
406
+ });
407
+ });
408
+
409
+ describe('updateLocalRegistrySafe concurrency', () => {
410
+ let ComponentRegistry;
411
+
412
+ before(async () => {
413
+ ({ ComponentRegistry } = await import('../commands/getComponent/getComponent.js'));
414
+ });
415
+
416
+ test('concurrent registrations do not corrupt or drop entries in components.js', async () => {
417
+ await withTestProject(async (tmpDir) => {
418
+ const registry = new ComponentRegistry();
419
+ const names = ['Button', 'Link', 'Loading', 'MultiRoute', 'Navbar', 'NotFound', 'Route'];
420
+
421
+ // Fire all read-modify-write cycles at once — without the registry lock
422
+ // this loses updates (last-writer-wins) or reads a half-written file.
423
+ await Promise.all(names.map(n => registry.updateLocalRegistrySafe(n, 'Visual')));
424
+
425
+ const componentsPath = path.join(tmpDir, 'src', 'Components', 'components.js');
426
+ const content = await fs.readFile(componentsPath, 'utf8');
427
+ const match = content.match(/const components = ({[\s\S]*?});/);
428
+ assert.ok(match, 'components.js must stay parseable');
429
+ const parsed = JSON.parse(match[1]);
430
+ for (const n of names) {
431
+ assert.equal(parsed[n], 'Visual', `${n} must be registered`);
432
+ }
433
+ });
434
+ });
435
+ });