pulse-js-framework 1.7.5 → 1.7.8

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.
@@ -0,0 +1,633 @@
1
+ /**
2
+ * Pulse CLI - Documentation Test
3
+ *
4
+ * Tests documentation without external dependencies:
5
+ * - JavaScript syntax validation (via vm.Script)
6
+ * - Import resolution verification
7
+ * - HTTP response testing via dev server
8
+ * - .pulse file compilation check
9
+ */
10
+
11
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
12
+ import { join, dirname, relative } from 'path';
13
+ import { fileURLToPath } from 'url';
14
+ import { createServer } from 'http';
15
+ import vm from 'vm';
16
+ import { compile } from '../compiler/index.js';
17
+ import { log } from './logger.js';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const root = join(__dirname, '..');
21
+ const docsDir = join(root, 'docs');
22
+
23
+ // Directories to skip during file collection
24
+ const SKIP_DIRS = ['node_modules', '.git', 'dist', 'build', '.next', '.nuxt', 'coverage'];
25
+
26
+ /**
27
+ * Collect all JS files recursively
28
+ */
29
+ function collectJsFiles(dir, files = []) {
30
+ if (!existsSync(dir)) return files;
31
+
32
+ for (const entry of readdirSync(dir)) {
33
+ // Skip common non-source directories
34
+ if (SKIP_DIRS.includes(entry)) continue;
35
+
36
+ const fullPath = join(dir, entry);
37
+ const stat = statSync(fullPath);
38
+
39
+ if (stat.isDirectory()) {
40
+ collectJsFiles(fullPath, files);
41
+ } else if (entry.endsWith('.js')) {
42
+ files.push(fullPath);
43
+ }
44
+ }
45
+
46
+ return files;
47
+ }
48
+
49
+ /**
50
+ * Collect all .pulse files recursively
51
+ */
52
+ function collectPulseFiles(dir, files = []) {
53
+ if (!existsSync(dir)) return files;
54
+
55
+ for (const entry of readdirSync(dir)) {
56
+ // Skip common non-source directories
57
+ if (SKIP_DIRS.includes(entry)) continue;
58
+
59
+ const fullPath = join(dir, entry);
60
+ const stat = statSync(fullPath);
61
+
62
+ if (stat.isDirectory()) {
63
+ collectPulseFiles(fullPath, files);
64
+ } else if (entry.endsWith('.pulse')) {
65
+ files.push(fullPath);
66
+ }
67
+ }
68
+
69
+ return files;
70
+ }
71
+
72
+ /**
73
+ * Validate JavaScript syntax
74
+ * Uses vm.Script but ignores ESM-specific errors (import/export)
75
+ * since vm.Script doesn't support ESM natively
76
+ */
77
+ function validateJsSyntax(filePath) {
78
+ const content = readFileSync(filePath, 'utf-8');
79
+ const errors = [];
80
+
81
+ try {
82
+ // Transform ESM to CommonJS-like for syntax checking only
83
+ // This allows us to catch real syntax errors while ignoring ESM syntax
84
+ const transformedContent = content
85
+ // Remove import statements
86
+ .replace(/^import\s+.*$/gm, '// import removed')
87
+ // Replace "export default {" with "const _ = {"
88
+ .replace(/^export\s+default\s+\{/gm, 'const __default__ = {')
89
+ // Replace "export default function" with "function"
90
+ .replace(/^export\s+default\s+function/gm, 'function')
91
+ // Replace "export default class" with "class"
92
+ .replace(/^export\s+default\s+class/gm, 'class')
93
+ // Replace "export default expression" (covers other cases)
94
+ .replace(/^export\s+default\s+/gm, 'const __default__ = ')
95
+ // Replace "export const/let/var" with just "const/let/var"
96
+ .replace(/^export\s+(const|let|var)\s+/gm, '$1 ')
97
+ // Replace "export function" with "function"
98
+ .replace(/^export\s+function\s+/gm, 'function ')
99
+ // Replace "export class" with "class"
100
+ .replace(/^export\s+class\s+/gm, 'class ')
101
+ // Replace "export async function" with "async function"
102
+ .replace(/^export\s+async\s+function\s+/gm, 'async function ')
103
+ // Remove named export statements "export { ... }"
104
+ .replace(/^export\s*\{[^}]*\}.*$/gm, '// export removed');
105
+
106
+ new vm.Script(transformedContent, { filename: filePath });
107
+ } catch (error) {
108
+ // Ignore ESM-specific errors that slip through
109
+ const esmErrors = [
110
+ 'Cannot use import statement',
111
+ "Unexpected token 'export'",
112
+ 'Cannot use import.meta',
113
+ "Unexpected token 'import'"
114
+ ];
115
+
116
+ const isEsmError = esmErrors.some(msg => error.message.includes(msg));
117
+
118
+ if (!isEsmError) {
119
+ errors.push({
120
+ file: filePath,
121
+ line: error.lineNumber || null,
122
+ column: error.columnNumber || null,
123
+ message: error.message
124
+ });
125
+ }
126
+ }
127
+
128
+ return errors;
129
+ }
130
+
131
+ /**
132
+ * Extract real import paths from JavaScript file
133
+ * Filters out imports inside template strings, comments, and code examples
134
+ */
135
+ function extractImports(content, filePath) {
136
+ const imports = [];
137
+
138
+ // Check if this is a documentation page file (contains code examples)
139
+ const isDocsPage = filePath.includes('pages') && filePath.includes('Page.js');
140
+
141
+ // Remove template strings and their contents to avoid false positives
142
+ // Template strings often contain code examples with fake imports
143
+ let cleanContent = content;
144
+
145
+ if (isDocsPage) {
146
+ // For docs pages, only extract imports at the top of the file (real imports)
147
+ // Stop when we hit the first function declaration or export
148
+ const lines = content.split('\n');
149
+ const importLines = [];
150
+
151
+ for (const line of lines) {
152
+ const trimmed = line.trim();
153
+ // Stop at first non-import statement (excluding comments and empty lines)
154
+ if (trimmed && !trimmed.startsWith('import') && !trimmed.startsWith('//') && !trimmed.startsWith('/*')) {
155
+ break;
156
+ }
157
+ if (trimmed.startsWith('import')) {
158
+ importLines.push(line);
159
+ }
160
+ }
161
+
162
+ cleanContent = importLines.join('\n');
163
+ }
164
+
165
+ // Static imports: import x from './path'
166
+ const staticImportRegex = /import\s+(?:[\w{}\s,*]+\s+from\s+)?['"]([^'"]+)['"]/g;
167
+ let match;
168
+ while ((match = staticImportRegex.exec(cleanContent)) !== null) {
169
+ imports.push(match[1]);
170
+ }
171
+
172
+ // Only check dynamic imports for non-docs pages to avoid code examples
173
+ if (!isDocsPage) {
174
+ const dynamicImportRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
175
+ while ((match = dynamicImportRegex.exec(cleanContent)) !== null) {
176
+ imports.push(match[1]);
177
+ }
178
+ }
179
+
180
+ return imports;
181
+ }
182
+
183
+ /**
184
+ * Resolve import path relative to file
185
+ */
186
+ function resolveImport(importPath, fromFile) {
187
+ // Skip external modules (no ./ or ../)
188
+ if (!importPath.startsWith('.') && !importPath.startsWith('/')) {
189
+ return null; // External module, skip
190
+ }
191
+
192
+ const fromDir = dirname(fromFile);
193
+ let resolved = join(fromDir, importPath);
194
+
195
+ // Add .js extension if missing
196
+ if (!resolved.endsWith('.js') && !resolved.endsWith('.pulse')) {
197
+ if (existsSync(resolved + '.js')) {
198
+ resolved += '.js';
199
+ } else if (existsSync(resolved + '/index.js')) {
200
+ resolved = join(resolved, 'index.js');
201
+ }
202
+ }
203
+
204
+ return resolved;
205
+ }
206
+
207
+ /**
208
+ * Validate imports in a JavaScript file
209
+ */
210
+ function validateImports(filePath) {
211
+ const content = readFileSync(filePath, 'utf-8');
212
+ const imports = extractImports(content, filePath);
213
+ const errors = [];
214
+
215
+ for (const importPath of imports) {
216
+ const resolved = resolveImport(importPath, filePath);
217
+
218
+ // Skip external modules
219
+ if (resolved === null) continue;
220
+
221
+ // Skip runtime imports (resolved at runtime)
222
+ if (importPath.includes('/runtime/')) continue;
223
+
224
+ // Skip pulse-js-framework imports
225
+ if (importPath.includes('pulse-js-framework')) continue;
226
+
227
+ if (!existsSync(resolved)) {
228
+ errors.push({
229
+ file: filePath,
230
+ importPath,
231
+ resolved,
232
+ message: `Import not found: ${importPath}`
233
+ });
234
+ }
235
+ }
236
+
237
+ return errors;
238
+ }
239
+
240
+ /**
241
+ * Compile and validate .pulse files
242
+ */
243
+ function validatePulseFile(filePath) {
244
+ const content = readFileSync(filePath, 'utf-8');
245
+ const errors = [];
246
+
247
+ try {
248
+ const result = compile(content, {
249
+ sourceMap: false,
250
+ sourceFileName: filePath
251
+ });
252
+
253
+ if (!result.success) {
254
+ for (const error of result.errors) {
255
+ errors.push({
256
+ file: filePath,
257
+ line: error.line || null,
258
+ column: error.column || null,
259
+ message: error.message
260
+ });
261
+ }
262
+ }
263
+ } catch (error) {
264
+ errors.push({
265
+ file: filePath,
266
+ message: error.message
267
+ });
268
+ }
269
+
270
+ return errors;
271
+ }
272
+
273
+ /**
274
+ * Make HTTP request (native, no dependencies)
275
+ */
276
+ function httpGet(url, timeout = 5000) {
277
+ return new Promise((resolve, reject) => {
278
+ const urlObj = new URL(url);
279
+ const http = urlObj.protocol === 'https:'
280
+ ? require('https')
281
+ : require('http');
282
+
283
+ const req = http.get(url, { timeout }, (res) => {
284
+ let data = '';
285
+ res.on('data', chunk => data += chunk);
286
+ res.on('end', () => resolve({ status: res.statusCode, data }));
287
+ });
288
+
289
+ req.on('error', reject);
290
+ req.on('timeout', () => {
291
+ req.destroy();
292
+ reject(new Error('Request timeout'));
293
+ });
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Start a minimal test server for docs
299
+ */
300
+ function startTestServer(port = 0) {
301
+ return new Promise((resolve, reject) => {
302
+ const server = createServer((req, res) => {
303
+ // Disable keep-alive for test server
304
+ res.setHeader('Connection', 'close');
305
+
306
+ const url = new URL(req.url, `http://localhost:${port}`);
307
+ let pathname = url.pathname;
308
+
309
+ if (pathname === '/') pathname = '/index.html';
310
+
311
+ const filePath = join(docsDir, pathname);
312
+
313
+ if (existsSync(filePath) && statSync(filePath).isFile()) {
314
+ const content = readFileSync(filePath);
315
+ const ext = pathname.split('.').pop();
316
+ const mimeTypes = {
317
+ 'html': 'text/html',
318
+ 'js': 'application/javascript',
319
+ 'css': 'text/css',
320
+ 'json': 'application/json'
321
+ };
322
+ res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' });
323
+ res.end(content);
324
+ } else {
325
+ res.writeHead(404);
326
+ res.end('Not Found');
327
+ }
328
+ });
329
+
330
+ // Disable keep-alive timeout
331
+ server.keepAliveTimeout = 0;
332
+
333
+ server.listen(port, '127.0.0.1', () => {
334
+ const addr = server.address();
335
+ resolve({ server, port: addr.port });
336
+ });
337
+
338
+ server.on('error', reject);
339
+ });
340
+ }
341
+
342
+ /**
343
+ * Test HTTP responses for documentation pages
344
+ */
345
+ async function testHttpResponses(port, routes) {
346
+ const errors = [];
347
+ const http = await import('http');
348
+
349
+ for (const route of routes) {
350
+ const url = `http://127.0.0.1:${port}${route}`;
351
+
352
+ try {
353
+ const result = await new Promise((resolve, reject) => {
354
+ const req = http.get(url, { timeout: 5000 }, (res) => {
355
+ let data = '';
356
+ res.on('data', chunk => data += chunk);
357
+ res.on('end', () => resolve({ status: res.statusCode, data }));
358
+ });
359
+ req.on('error', reject);
360
+ req.on('timeout', () => {
361
+ req.destroy();
362
+ reject(new Error('Timeout'));
363
+ });
364
+ });
365
+
366
+ if (result.status !== 200) {
367
+ errors.push({
368
+ route,
369
+ status: result.status,
370
+ message: `HTTP ${result.status} for ${route}`
371
+ });
372
+ }
373
+ } catch (error) {
374
+ errors.push({
375
+ route,
376
+ message: `Failed to fetch ${route}: ${error.message}`
377
+ });
378
+ }
379
+ }
380
+
381
+ return errors;
382
+ }
383
+
384
+ /**
385
+ * Run all documentation tests
386
+ * @param {Object} options
387
+ * @param {boolean} options.verbose - Show detailed output
388
+ * @param {boolean} options.httpTest - Run HTTP tests (starts server)
389
+ * @returns {Promise<{success: boolean, errors: Array}>}
390
+ */
391
+ export async function runDocsTest(options = {}) {
392
+ const { verbose = false, httpTest = true } = options;
393
+ const allErrors = [];
394
+ let passed = 0;
395
+ let failed = 0;
396
+
397
+ log.info('');
398
+ log.info('Documentation Tests');
399
+ log.info('='.repeat(50));
400
+
401
+ // 1. Validate JavaScript syntax
402
+ log.info('');
403
+ log.info('Validating JavaScript syntax...');
404
+
405
+ const jsFiles = collectJsFiles(join(docsDir, 'src'));
406
+
407
+ for (const file of jsFiles) {
408
+ const relPath = relative(docsDir, file);
409
+ const errors = validateJsSyntax(file);
410
+
411
+ if (errors.length > 0) {
412
+ failed++;
413
+ log.error(` ✗ ${relPath}`);
414
+ for (const err of errors) {
415
+ log.error(` ${err.message}`);
416
+ allErrors.push({ type: 'syntax', ...err });
417
+ }
418
+ } else {
419
+ passed++;
420
+ if (verbose) log.info(` ✓ ${relPath}`);
421
+ }
422
+ }
423
+
424
+ log.info(` Syntax: ${passed} passed, ${failed} failed`);
425
+
426
+ // 2. Validate imports
427
+ log.info('');
428
+ log.info('Validating imports...');
429
+
430
+ let importPassed = 0;
431
+ let importFailed = 0;
432
+
433
+ for (const file of jsFiles) {
434
+ const relPath = relative(docsDir, file);
435
+ const errors = validateImports(file);
436
+
437
+ if (errors.length > 0) {
438
+ importFailed++;
439
+ log.error(` ✗ ${relPath}`);
440
+ for (const err of errors) {
441
+ log.error(` ${err.message}`);
442
+ allErrors.push({ type: 'import', ...err });
443
+ }
444
+ } else {
445
+ importPassed++;
446
+ if (verbose) log.info(` ✓ ${relPath}`);
447
+ }
448
+ }
449
+
450
+ log.info(` Imports: ${importPassed} passed, ${importFailed} failed`);
451
+
452
+ // 3. Check runtime files for package imports (won't work in browser)
453
+ log.info('');
454
+ log.info('Validating runtime imports (browser compatibility)...');
455
+
456
+ const runtimeDir = join(root, 'runtime');
457
+ const runtimeFiles = collectJsFiles(runtimeDir);
458
+ let runtimePassed = 0;
459
+ let runtimeFailed = 0;
460
+
461
+ for (const file of runtimeFiles) {
462
+ const relPath = relative(root, file);
463
+ const content = readFileSync(file, 'utf-8');
464
+ const errors = [];
465
+
466
+ // Find package imports (not starting with . / or /)
467
+ const importRegex = /^import\s+(?:[\w{}\s,*]+\s+from\s+)?['"]([^'"]+)['"]/gm;
468
+ let match;
469
+ while ((match = importRegex.exec(content)) !== null) {
470
+ const importPath = match[1];
471
+ // Package import = doesn't start with . or /
472
+ if (!importPath.startsWith('.') && !importPath.startsWith('/')) {
473
+ errors.push({
474
+ file,
475
+ importPath,
476
+ message: `Package import "${importPath}" won't work in browser without bundler`
477
+ });
478
+ }
479
+ }
480
+
481
+ if (errors.length > 0) {
482
+ runtimeFailed++;
483
+ log.error(` ✗ ${relPath}`);
484
+ for (const err of errors) {
485
+ log.error(` ${err.message}`);
486
+ allErrors.push({ type: 'runtime', ...err });
487
+ }
488
+ } else {
489
+ runtimePassed++;
490
+ if (verbose) log.info(` ✓ ${relPath}`);
491
+ }
492
+ }
493
+
494
+ log.info(` Runtime: ${runtimePassed} passed, ${runtimeFailed} failed`);
495
+
496
+ // 4. Validate .pulse files (if any in docs)
497
+ const pulseFiles = collectPulseFiles(docsDir);
498
+
499
+ if (pulseFiles.length > 0) {
500
+ log.info('');
501
+ log.info('Validating .pulse files...');
502
+
503
+ let pulsePassed = 0;
504
+ let pulseFailed = 0;
505
+
506
+ for (const file of pulseFiles) {
507
+ const relPath = relative(docsDir, file);
508
+ const errors = validatePulseFile(file);
509
+
510
+ if (errors.length > 0) {
511
+ pulseFailed++;
512
+ log.error(` ✗ ${relPath}`);
513
+ for (const err of errors) {
514
+ log.error(` ${err.message}`);
515
+ allErrors.push({ type: 'pulse', ...err });
516
+ }
517
+ } else {
518
+ pulsePassed++;
519
+ if (verbose) log.info(` ✓ ${relPath}`);
520
+ }
521
+ }
522
+
523
+ log.info(` Pulse: ${pulsePassed} passed, ${pulseFailed} failed`);
524
+ }
525
+
526
+ // 4. HTTP response tests
527
+ if (httpTest) {
528
+ log.info('');
529
+ log.info('Testing HTTP responses...');
530
+
531
+ let serverInfo = null;
532
+
533
+ try {
534
+ serverInfo = await startTestServer();
535
+ const port = serverInfo.port;
536
+
537
+ // Test essential routes
538
+ const routes = [
539
+ '/index.html',
540
+ '/src/main.js',
541
+ '/src/state.js',
542
+ '/src/styles.js'
543
+ ];
544
+
545
+ const httpErrors = await testHttpResponses(port, routes);
546
+
547
+ if (httpErrors.length > 0) {
548
+ for (const err of httpErrors) {
549
+ log.error(` ✗ ${err.route}: ${err.message}`);
550
+ allErrors.push({ type: 'http', ...err });
551
+ }
552
+ log.info(` HTTP: ${routes.length - httpErrors.length} passed, ${httpErrors.length} failed`);
553
+ } else {
554
+ log.info(` HTTP: ${routes.length} passed, 0 failed`);
555
+ }
556
+ } catch (error) {
557
+ log.warn(` HTTP tests skipped: ${error.message}`);
558
+ } finally {
559
+ if (serverInfo?.server) {
560
+ // Force close all connections and server
561
+ await new Promise((resolve) => {
562
+ serverInfo.server.close(() => resolve());
563
+ // Force close after 1 second if not closed
564
+ setTimeout(resolve, 1000);
565
+ });
566
+ }
567
+ }
568
+ }
569
+
570
+ // 5. Compile examples .pulse files
571
+ const examplesDir = join(root, 'examples');
572
+ const examplePulseFiles = collectPulseFiles(examplesDir);
573
+
574
+ if (examplePulseFiles.length > 0) {
575
+ log.info('');
576
+ log.info('Validating example .pulse files...');
577
+
578
+ let examplePassed = 0;
579
+ let exampleFailed = 0;
580
+
581
+ for (const file of examplePulseFiles) {
582
+ const relPath = relative(root, file);
583
+ const errors = validatePulseFile(file);
584
+
585
+ if (errors.length > 0) {
586
+ exampleFailed++;
587
+ log.error(` ✗ ${relPath}`);
588
+ for (const err of errors) {
589
+ log.error(` ${err.message}`);
590
+ allErrors.push({ type: 'example', ...err });
591
+ }
592
+ } else {
593
+ examplePassed++;
594
+ if (verbose) log.info(` ✓ ${relPath}`);
595
+ }
596
+ }
597
+
598
+ log.info(` Examples: ${examplePassed} passed, ${exampleFailed} failed`);
599
+ }
600
+
601
+ // Summary
602
+ log.info('');
603
+ log.info('-'.repeat(50));
604
+
605
+ const success = allErrors.length === 0;
606
+
607
+ if (success) {
608
+ log.success('All documentation tests passed!');
609
+ } else {
610
+ log.error(`Documentation tests failed: ${allErrors.length} error(s)`);
611
+ }
612
+
613
+ log.info('');
614
+
615
+ return { success, errors: allErrors };
616
+ }
617
+
618
+ /**
619
+ * CLI entry point
620
+ */
621
+ export async function runDocsTestCli(args) {
622
+ const verbose = args.includes('--verbose') || args.includes('-v');
623
+ const noHttp = args.includes('--no-http');
624
+
625
+ const result = await runDocsTest({
626
+ verbose,
627
+ httpTest: !noHttp
628
+ });
629
+
630
+ if (!result.success) {
631
+ process.exit(1);
632
+ }
633
+ }