@noego/app 0.0.10 โ 0.0.12
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.
- package/.claude/settings.local.json +9 -11
- package/package.json +1 -1
- package/src/args.js +17 -2
- package/src/cli.js +6 -0
- package/src/commands/dev.js +118 -19
- package/src/commands/test-live.js +187 -0
- package/src/config.js +7 -0
- package/src/runtime/runtime.js +4 -1
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"permissions": {
|
|
3
3
|
"allow": [
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"Bash(
|
|
13
|
-
"Bash(tee:*)",
|
|
14
|
-
"mcp__chrome-devtools__list_console_messages"
|
|
4
|
+
"Bash(cat:*)",
|
|
5
|
+
"Bash(curl:*)",
|
|
6
|
+
"Bash(noego dev)",
|
|
7
|
+
"WebSearch",
|
|
8
|
+
"WebFetch(domain:mattkentzia.com)",
|
|
9
|
+
"Bash(node --test:*)",
|
|
10
|
+
"WebFetch(domain:svelte.dev)",
|
|
11
|
+
"WebFetch(domain:github.com)",
|
|
12
|
+
"Bash(tree:*)"
|
|
15
13
|
],
|
|
16
14
|
"deny": [],
|
|
17
15
|
"ask": []
|
package/package.json
CHANGED
package/src/args.js
CHANGED
|
@@ -27,11 +27,17 @@ const FLAG_MAP = new Map([
|
|
|
27
27
|
['mode', 'mode'],
|
|
28
28
|
['verbose', 'verbose'],
|
|
29
29
|
['help', 'help'],
|
|
30
|
-
['version', 'version']
|
|
30
|
+
['version', 'version'],
|
|
31
|
+
['ci-server', 'testServer'],
|
|
32
|
+
['ci-test', 'testCommand'],
|
|
33
|
+
['ci-status', 'testStatus'],
|
|
34
|
+
['ci-port', 'testPort'],
|
|
35
|
+
['ci-timeout', 'testTimeout'],
|
|
36
|
+
['ci-visible', 'testVisible']
|
|
31
37
|
]);
|
|
32
38
|
|
|
33
39
|
const MULTI_VALUE_FLAGS = new Set(['sqlGlob', 'assets', 'clientExclude', 'watchPath']);
|
|
34
|
-
const BOOLEAN_FLAGS = new Set(['watch', 'splitServe', 'verbose']);
|
|
40
|
+
const BOOLEAN_FLAGS = new Set(['watch', 'splitServe', 'verbose', 'testVisible']);
|
|
35
41
|
|
|
36
42
|
export function parseCliArgs(argv) {
|
|
37
43
|
const result = {
|
|
@@ -129,6 +135,15 @@ export function printHelpAndExit({ stdout = process.stdout } = {}) {
|
|
|
129
135
|
app dev [options]
|
|
130
136
|
app serve [options]
|
|
131
137
|
app preview [options]
|
|
138
|
+
app ci [options]
|
|
139
|
+
|
|
140
|
+
CI Testing Options:
|
|
141
|
+
--ci-server <cmd> Server start command (default: npm run dev)
|
|
142
|
+
--ci-test <cmd> Test command (default: npm run test:live)
|
|
143
|
+
--ci-status <path> Health check endpoint (default: /api/status)
|
|
144
|
+
--ci-port <number> Server port (default: random 4000-8000)
|
|
145
|
+
--ci-timeout <seconds> Health check timeout (default: 60)
|
|
146
|
+
--ci-visible Run browser in visible mode
|
|
132
147
|
|
|
133
148
|
Options (shared):
|
|
134
149
|
--root <dir> Project root (default: .)
|
package/src/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { runBuild } from './commands/build.js';
|
|
|
8
8
|
import { runServe } from './commands/serve.js';
|
|
9
9
|
import { runPreview } from './commands/preview.js';
|
|
10
10
|
import { runDev } from './commands/dev.js';
|
|
11
|
+
import { runTestLive } from './commands/test-live.js';
|
|
11
12
|
|
|
12
13
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
13
14
|
try {
|
|
@@ -43,6 +44,11 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
43
44
|
await runPreview(config);
|
|
44
45
|
break;
|
|
45
46
|
}
|
|
47
|
+
case 'ci': {
|
|
48
|
+
const config = await loadBuildConfig(options, { cwd: process.cwd() });
|
|
49
|
+
await runTestLive(config);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
46
52
|
default:
|
|
47
53
|
throw cliError(`Unknown command "${command}"`);
|
|
48
54
|
}
|
package/src/commands/dev.js
CHANGED
|
@@ -449,58 +449,132 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
449
449
|
let frontendRestartCount = 0;
|
|
450
450
|
let isShuttingDown = false;
|
|
451
451
|
|
|
452
|
+
// Crash restart tracking
|
|
453
|
+
const MAX_CRASH_RESTARTS = 3;
|
|
454
|
+
const CRASH_RESTART_DELAY = 2000; // 2 seconds between crash restarts
|
|
455
|
+
const STABILITY_THRESHOLD = 30000; // Reset crash counter if running 30+ seconds
|
|
456
|
+
let backendCrashRestarts = 0;
|
|
457
|
+
let frontendCrashRestarts = 0;
|
|
458
|
+
let backendStartTime = 0;
|
|
459
|
+
let frontendStartTime = 0;
|
|
460
|
+
|
|
452
461
|
const startBackend = () => {
|
|
453
462
|
backendRestartCount++;
|
|
463
|
+
backendStartTime = Date.now();
|
|
454
464
|
logger.info(`๐ [RESTART #${backendRestartCount}] Starting backend on port ${backendPort}...`);
|
|
455
465
|
const backendEnv = {
|
|
456
466
|
...baseEnv,
|
|
457
467
|
NOEGO_SERVICE: 'backend',
|
|
458
468
|
NOEGO_PORT: String(backendPort)
|
|
459
469
|
};
|
|
460
|
-
|
|
470
|
+
|
|
461
471
|
backendProc = spawn(tsxExecutable, tsxArgs, {
|
|
462
472
|
cwd: context.config.rootDir,
|
|
463
473
|
env: backendEnv,
|
|
464
474
|
stdio: 'inherit',
|
|
465
475
|
detached: false
|
|
466
476
|
});
|
|
467
|
-
|
|
468
|
-
backendProc.on('exit', (code) => {
|
|
477
|
+
|
|
478
|
+
backendProc.on('exit', (code, signal) => {
|
|
479
|
+
if (isShuttingDown) return;
|
|
480
|
+
|
|
481
|
+
// Check if process was stable (ran for a while) - reset crash counter
|
|
482
|
+
const runDuration = Date.now() - backendStartTime;
|
|
483
|
+
if (runDuration > STABILITY_THRESHOLD) {
|
|
484
|
+
backendCrashRestarts = 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
469
487
|
if (code !== null && code !== 0) {
|
|
470
|
-
logger.error(`
|
|
488
|
+
logger.error(`[BACKEND] Exited with code ${code}, signal ${signal}`);
|
|
489
|
+
|
|
490
|
+
// Auto-restart on crash if under limit
|
|
491
|
+
if (backendCrashRestarts < MAX_CRASH_RESTARTS) {
|
|
492
|
+
backendCrashRestarts++;
|
|
493
|
+
logger.warn(`[BACKEND] Crash detected. Auto-restart ${backendCrashRestarts}/${MAX_CRASH_RESTARTS} in ${CRASH_RESTART_DELAY}ms...`);
|
|
494
|
+
setTimeout(() => {
|
|
495
|
+
if (!isShuttingDown) {
|
|
496
|
+
startBackend();
|
|
497
|
+
}
|
|
498
|
+
}, CRASH_RESTART_DELAY);
|
|
499
|
+
} else {
|
|
500
|
+
logger.error(`[BACKEND] Exceeded max crash restarts (${MAX_CRASH_RESTARTS}). Shutting down...`);
|
|
501
|
+
shutdown('backend-exceeded-restarts', 1, 'backend-crash');
|
|
502
|
+
}
|
|
471
503
|
}
|
|
472
504
|
});
|
|
505
|
+
|
|
506
|
+
backendProc.on('error', (err) => {
|
|
507
|
+
logger.error(`[BACKEND] Spawn error: ${err.message}`);
|
|
508
|
+
});
|
|
473
509
|
};
|
|
474
|
-
|
|
510
|
+
|
|
475
511
|
const startFrontend = () => {
|
|
476
512
|
frontendRestartCount++;
|
|
513
|
+
frontendStartTime = Date.now();
|
|
477
514
|
logger.info(`๐ [RESTART #${frontendRestartCount}] Starting frontend on port ${frontendPort}...`);
|
|
478
515
|
const frontendEnv = {
|
|
479
516
|
...baseEnv,
|
|
480
517
|
NOEGO_SERVICE: 'frontend',
|
|
481
518
|
NOEGO_PORT: String(frontendPort)
|
|
482
519
|
};
|
|
483
|
-
|
|
520
|
+
|
|
484
521
|
frontendProc = spawn(tsxExecutable, tsxArgs, {
|
|
485
522
|
cwd: context.config.rootDir,
|
|
486
523
|
env: frontendEnv,
|
|
487
524
|
stdio: 'inherit',
|
|
488
525
|
detached: false
|
|
489
526
|
});
|
|
490
|
-
|
|
491
|
-
frontendProc.on('exit', (code) => {
|
|
527
|
+
|
|
528
|
+
frontendProc.on('exit', (code, signal) => {
|
|
529
|
+
if (isShuttingDown) return;
|
|
530
|
+
|
|
531
|
+
// Check if process was stable (ran for a while) - reset crash counter
|
|
532
|
+
const runDuration = Date.now() - frontendStartTime;
|
|
533
|
+
if (runDuration > STABILITY_THRESHOLD) {
|
|
534
|
+
frontendCrashRestarts = 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
492
537
|
if (code !== null && code !== 0) {
|
|
493
|
-
logger.error(`
|
|
538
|
+
logger.error(`[FRONTEND] Exited with code ${code}, signal ${signal}`);
|
|
539
|
+
|
|
540
|
+
// Auto-restart on crash if under limit
|
|
541
|
+
if (frontendCrashRestarts < MAX_CRASH_RESTARTS) {
|
|
542
|
+
frontendCrashRestarts++;
|
|
543
|
+
logger.warn(`[FRONTEND] Crash detected. Auto-restart ${frontendCrashRestarts}/${MAX_CRASH_RESTARTS} in ${CRASH_RESTART_DELAY}ms...`);
|
|
544
|
+
setTimeout(() => {
|
|
545
|
+
if (!isShuttingDown) {
|
|
546
|
+
startFrontend();
|
|
547
|
+
}
|
|
548
|
+
}, CRASH_RESTART_DELAY);
|
|
549
|
+
} else {
|
|
550
|
+
logger.error(`[FRONTEND] Exceeded max crash restarts (${MAX_CRASH_RESTARTS}). Shutting down...`);
|
|
551
|
+
shutdown('frontend-exceeded-restarts', 1, 'frontend-crash');
|
|
552
|
+
}
|
|
494
553
|
}
|
|
495
554
|
});
|
|
555
|
+
|
|
556
|
+
frontendProc.on('error', (err) => {
|
|
557
|
+
logger.error(`[FRONTEND] Spawn error: ${err.message}`);
|
|
558
|
+
});
|
|
496
559
|
};
|
|
497
|
-
|
|
560
|
+
|
|
498
561
|
const stopBackend = () =>
|
|
499
562
|
new Promise((resolve) => {
|
|
500
563
|
if (!backendProc) return resolve();
|
|
501
564
|
logger.info(`โน๏ธ Stopping backend (preparing for restart #${backendRestartCount + 1})...`);
|
|
502
|
-
const
|
|
565
|
+
const pid = backendProc.pid;
|
|
566
|
+
let exited = false;
|
|
567
|
+
|
|
568
|
+
const to = setTimeout(() => {
|
|
569
|
+
if (!exited && pid) {
|
|
570
|
+
logger.warn(`โ ๏ธ Backend didn't exit gracefully, force killing process tree...`);
|
|
571
|
+
killProcessTree(pid, 'SIGKILL');
|
|
572
|
+
}
|
|
573
|
+
resolve();
|
|
574
|
+
}, 2000);
|
|
575
|
+
|
|
503
576
|
backendProc.once('exit', () => {
|
|
577
|
+
exited = true;
|
|
504
578
|
clearTimeout(to);
|
|
505
579
|
resolve();
|
|
506
580
|
});
|
|
@@ -515,8 +589,19 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
515
589
|
new Promise((resolve) => {
|
|
516
590
|
if (!frontendProc) return resolve();
|
|
517
591
|
logger.info(`โน๏ธ Stopping frontend (preparing for restart #${frontendRestartCount + 1})...`);
|
|
518
|
-
const
|
|
592
|
+
const pid = frontendProc.pid;
|
|
593
|
+
let exited = false;
|
|
594
|
+
|
|
595
|
+
const to = setTimeout(() => {
|
|
596
|
+
if (!exited && pid) {
|
|
597
|
+
logger.warn(`โ ๏ธ Frontend didn't exit gracefully, force killing process tree...`);
|
|
598
|
+
killProcessTree(pid, 'SIGKILL');
|
|
599
|
+
}
|
|
600
|
+
resolve();
|
|
601
|
+
}, 2000);
|
|
602
|
+
|
|
519
603
|
frontendProc.once('exit', () => {
|
|
604
|
+
exited = true;
|
|
520
605
|
clearTimeout(to);
|
|
521
606
|
resolve();
|
|
522
607
|
});
|
|
@@ -527,10 +612,11 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
527
612
|
}
|
|
528
613
|
});
|
|
529
614
|
|
|
530
|
-
async function shutdown(signal = 'SIGTERM', exitCode = 0) {
|
|
615
|
+
async function shutdown(signal = 'SIGTERM', exitCode = 0, source = 'unknown') {
|
|
531
616
|
if (isShuttingDown) return;
|
|
532
617
|
isShuttingDown = true;
|
|
533
618
|
|
|
619
|
+
logger.info(`[SHUTDOWN] source=${source}, signal=${signal}, exitCode=${exitCode}`);
|
|
534
620
|
logger.info(`Shutting down split-serve processes (signal: ${signal})...`);
|
|
535
621
|
|
|
536
622
|
try {
|
|
@@ -555,7 +641,7 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
555
641
|
}
|
|
556
642
|
logger.error('Watcher error:', error);
|
|
557
643
|
logger.error('File watching failed; shutting down dev server.');
|
|
558
|
-
await shutdown('watcherError', 1);
|
|
644
|
+
await shutdown('watcherError', 1, 'watcher-error');
|
|
559
645
|
}
|
|
560
646
|
|
|
561
647
|
const handleFileChange = async (reason, file) => {
|
|
@@ -597,10 +683,12 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
597
683
|
// Restart appropriate service(s)
|
|
598
684
|
if (restartBackend) {
|
|
599
685
|
await stopBackend();
|
|
686
|
+
await new Promise(r => setTimeout(r, 100)); // Small delay to ensure port release
|
|
600
687
|
startBackend();
|
|
601
688
|
}
|
|
602
689
|
if (restartFrontend) {
|
|
603
690
|
await stopFrontend();
|
|
691
|
+
await new Promise(r => setTimeout(r, 100)); // Small delay to ensure port release
|
|
604
692
|
startFrontend();
|
|
605
693
|
}
|
|
606
694
|
|
|
@@ -675,8 +763,8 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
675
763
|
await import(runtimeEntryPath);
|
|
676
764
|
|
|
677
765
|
// Handle graceful shutdown signals
|
|
678
|
-
process.on('SIGINT', () => shutdown('SIGINT', 0));
|
|
679
|
-
process.on('SIGTERM', () => shutdown('SIGTERM', 0));
|
|
766
|
+
process.on('SIGINT', () => shutdown('SIGINT', 0, 'signal-handler'));
|
|
767
|
+
process.on('SIGTERM', () => shutdown('SIGTERM', 0, 'signal-handler'));
|
|
680
768
|
|
|
681
769
|
// Handle process exit - this ensures children are killed even if parent crashes
|
|
682
770
|
process.on('exit', () => {
|
|
@@ -693,13 +781,13 @@ async function runSplitServeWithWatch(context, tsxExecutable, tsxArgs, baseEnv,
|
|
|
693
781
|
// Handle uncaught exceptions
|
|
694
782
|
process.on('uncaughtException', async (error) => {
|
|
695
783
|
logger.error('Uncaught exception:', error);
|
|
696
|
-
await shutdown('uncaughtException', 1);
|
|
784
|
+
await shutdown('uncaughtException', 1, 'uncaught-exception');
|
|
697
785
|
});
|
|
698
786
|
|
|
699
787
|
// Handle unhandled promise rejections
|
|
700
788
|
process.on('unhandledRejection', async (reason, promise) => {
|
|
701
789
|
logger.error('Unhandled rejection at:', promise, 'reason:', reason);
|
|
702
|
-
await shutdown('unhandledRejection', 1);
|
|
790
|
+
await shutdown('unhandledRejection', 1, 'unhandled-rejection');
|
|
703
791
|
});
|
|
704
792
|
|
|
705
793
|
// Keep process alive for file watching
|
|
@@ -807,8 +895,19 @@ async function runWithRestart(context, tsxExecutable, tsxArgs, env, watcher, log
|
|
|
807
895
|
const stop = () =>
|
|
808
896
|
new Promise((resolve) => {
|
|
809
897
|
if (!child) return resolve();
|
|
810
|
-
const
|
|
898
|
+
const pid = child.pid;
|
|
899
|
+
let exited = false;
|
|
900
|
+
|
|
901
|
+
const to = setTimeout(() => {
|
|
902
|
+
if (!exited && pid) {
|
|
903
|
+
logger.warn(`โ ๏ธ Process didn't exit gracefully, force killing process tree...`);
|
|
904
|
+
killProcessTree(pid, 'SIGKILL');
|
|
905
|
+
}
|
|
906
|
+
resolve();
|
|
907
|
+
}, 2000);
|
|
908
|
+
|
|
811
909
|
child.once('exit', () => {
|
|
910
|
+
exited = true;
|
|
812
911
|
clearTimeout(to);
|
|
813
912
|
resolve();
|
|
814
913
|
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { createBuildContext } from '../build/context.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Polls health check endpoint until server is ready
|
|
6
|
+
*/
|
|
7
|
+
async function waitForServer(port, statusPath, timeoutSec, logger) {
|
|
8
|
+
const url = `http://localhost:${port}${statusPath}`;
|
|
9
|
+
const maxAttempts = timeoutSec;
|
|
10
|
+
|
|
11
|
+
logger.info(`Waiting for server at ${url}...`);
|
|
12
|
+
|
|
13
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
16
|
+
|
|
17
|
+
if (response.ok) {
|
|
18
|
+
const data = await response.json();
|
|
19
|
+
|
|
20
|
+
// For /status/deep - check both status and database
|
|
21
|
+
if (statusPath.includes('deep')) {
|
|
22
|
+
if (data.status === 'OK' && data.database === 'connected') {
|
|
23
|
+
logger.info('Server is ready!');
|
|
24
|
+
await new Promise(r => setTimeout(r, 2000)); // Extra 2s for full init
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
} else if (data.status === 'OK' || data.status === 'ok') {
|
|
28
|
+
logger.info('Server is ready!');
|
|
29
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
// Connection refused or timeout - server not ready yet
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (attempt < maxAttempts) {
|
|
38
|
+
await new Promise(r => setTimeout(r, 1000)); // Wait 1s between attempts
|
|
39
|
+
if (attempt % 5 === 0) {
|
|
40
|
+
logger.info(`Still waiting... (${attempt}/${maxAttempts})`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Spawns a command and returns a process handle
|
|
50
|
+
*/
|
|
51
|
+
function spawnCommand(command, env, logger) {
|
|
52
|
+
logger.info(`Spawning: ${command}`);
|
|
53
|
+
|
|
54
|
+
const child = spawn(command, {
|
|
55
|
+
shell: true,
|
|
56
|
+
stdio: 'inherit',
|
|
57
|
+
env: { ...process.env, ...env },
|
|
58
|
+
detached: process.platform !== 'win32', // Process group for Unix
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return child;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Kills process tree (cross-platform)
|
|
66
|
+
*/
|
|
67
|
+
function killProcessTree(pid, logger) {
|
|
68
|
+
try {
|
|
69
|
+
if (process.platform === 'win32') {
|
|
70
|
+
spawn('taskkill', ['/pid', pid.toString(), '/T', '/F'], { stdio: 'ignore' });
|
|
71
|
+
} else {
|
|
72
|
+
process.kill(-pid, 'SIGKILL'); // Negative PID kills process group
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logger.debug(`Process ${pid} already terminated`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Main test:live command implementation
|
|
81
|
+
*/
|
|
82
|
+
export async function runTestLive(config) {
|
|
83
|
+
const context = createBuildContext(config);
|
|
84
|
+
const { logger } = context;
|
|
85
|
+
|
|
86
|
+
// Parse options - no defaults, must be specified
|
|
87
|
+
const serverCmd = config.testServer;
|
|
88
|
+
const testCmd = config.testCommand;
|
|
89
|
+
const statusPath = config.testStatus;
|
|
90
|
+
const port = config.testPort || Math.floor(Math.random() * 4000) + 4000;
|
|
91
|
+
const timeout = config.testTimeout || 60;
|
|
92
|
+
const visible = config.testVisible || false;
|
|
93
|
+
|
|
94
|
+
// Validate required options
|
|
95
|
+
if (!serverCmd) {
|
|
96
|
+
logger.error('โ Missing required option: --ci-server');
|
|
97
|
+
logger.info('Example: noego ci --ci-server "npm run dev" --ci-test "npm run test:live" --ci-status "/api/status"');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!testCmd) {
|
|
102
|
+
logger.error('โ Missing required option: --ci-test');
|
|
103
|
+
logger.info('Example: noego ci --ci-server "npm run dev" --ci-test "npm run test:live" --ci-status "/api/status"');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!statusPath) {
|
|
108
|
+
logger.error('โ Missing required option: --ci-status');
|
|
109
|
+
logger.info('Example: noego ci --ci-server "npm run dev" --ci-test "npm run test:live" --ci-status "/api/status"');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
logger.info('๐งช Live Test Runner');
|
|
114
|
+
logger.info(` Server: ${serverCmd}`);
|
|
115
|
+
logger.info(` Tests: ${testCmd}`);
|
|
116
|
+
logger.info(` Port: ${port}`);
|
|
117
|
+
logger.info(` Health: ${statusPath}`);
|
|
118
|
+
|
|
119
|
+
let serverProcess = null;
|
|
120
|
+
let exitCode = 1;
|
|
121
|
+
|
|
122
|
+
const cleanup = async () => {
|
|
123
|
+
if (serverProcess && !serverProcess.killed) {
|
|
124
|
+
logger.info('Shutting down server...');
|
|
125
|
+
killProcessTree(serverProcess.pid, logger);
|
|
126
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Handle interrupts
|
|
131
|
+
process.on('SIGINT', async () => {
|
|
132
|
+
logger.info('Interrupted by user');
|
|
133
|
+
await cleanup();
|
|
134
|
+
process.exit(130);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
process.on('SIGTERM', async () => {
|
|
138
|
+
await cleanup();
|
|
139
|
+
process.exit(143);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// Start server
|
|
144
|
+
serverProcess = spawnCommand(serverCmd, { PORT: port.toString() }, logger);
|
|
145
|
+
|
|
146
|
+
// Wait for health check
|
|
147
|
+
logger.info('Waiting for server to be ready...');
|
|
148
|
+
const ready = await waitForServer(port, statusPath, timeout, logger);
|
|
149
|
+
|
|
150
|
+
if (!ready) {
|
|
151
|
+
logger.error(`โ Server failed to become healthy within ${timeout} seconds`);
|
|
152
|
+
await cleanup();
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Run tests
|
|
157
|
+
logger.info('โ
Server ready, running tests...');
|
|
158
|
+
const testEnv = {
|
|
159
|
+
PORT: port.toString(),
|
|
160
|
+
HEADLESS: visible ? 'false' : 'true',
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const testProcess = spawnCommand(testCmd, testEnv, logger);
|
|
164
|
+
|
|
165
|
+
// Wait for tests to complete
|
|
166
|
+
exitCode = await new Promise((resolve) => {
|
|
167
|
+
testProcess.on('exit', (code) => resolve(code || 0));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (exitCode === 0) {
|
|
171
|
+
logger.info('โ
Tests passed!');
|
|
172
|
+
} else {
|
|
173
|
+
logger.error(`โ Tests failed with exit code ${exitCode}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
} catch (err) {
|
|
177
|
+
logger.error('Test runner failed:', err.message);
|
|
178
|
+
if (err.stack) {
|
|
179
|
+
logger.debug(err.stack);
|
|
180
|
+
}
|
|
181
|
+
exitCode = 1;
|
|
182
|
+
|
|
183
|
+
} finally {
|
|
184
|
+
await cleanup();
|
|
185
|
+
process.exit(exitCode);
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/config.js
CHANGED
|
@@ -71,6 +71,13 @@ export async function loadBuildConfig(cliOptions = {}, { cwd = process.cwd() } =
|
|
|
71
71
|
...config,
|
|
72
72
|
rootDir: config.root,
|
|
73
73
|
verbose: cliOptions.verbose || false,
|
|
74
|
+
// CI test options from CLI
|
|
75
|
+
testServer: cliOptions.testServer,
|
|
76
|
+
testCommand: cliOptions.testCommand,
|
|
77
|
+
testStatus: cliOptions.testStatus,
|
|
78
|
+
testPort: cliOptions.testPort,
|
|
79
|
+
testTimeout: cliOptions.testTimeout,
|
|
80
|
+
testVisible: cliOptions.testVisible,
|
|
74
81
|
layout,
|
|
75
82
|
server: config.server ? {
|
|
76
83
|
rootDir: config.server.main_abs ? path.dirname(config.server.main_abs) : config.root,
|
package/src/runtime/runtime.js
CHANGED
|
@@ -97,12 +97,15 @@ async function setupProxyFirst(app, backendPort, config) {
|
|
|
97
97
|
return resolve(cached.canHandle);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// Configurable timeout - default 500ms is enough for GC pauses and startup
|
|
101
|
+
const checkTimeout = parseInt(process.env.NOEGO_BACKEND_CHECK_TIMEOUT) || 500;
|
|
102
|
+
|
|
100
103
|
const options = {
|
|
101
104
|
hostname: 'localhost',
|
|
102
105
|
port: backendPort,
|
|
103
106
|
path: pathname,
|
|
104
107
|
method: 'GET', // Use GET instead of HEAD since some backends don't support HEAD
|
|
105
|
-
timeout:
|
|
108
|
+
timeout: checkTimeout,
|
|
106
109
|
headers: {
|
|
107
110
|
'X-Proxy-Check': 'true' // Indicate this is just a check
|
|
108
111
|
}
|