@testspectra/cli 1.1.8-rc.30 → 1.1.8-rc.33
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/dist/.tsbuildinfo +1 -0
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +22 -0
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.js +109 -0
- package/dist/config/schema.d.ts +53 -0
- package/dist/config/schema.js +2 -0
- package/dist/index.js +2 -0
- package/dist/lifecycle/__tests__/lifecycle-engine.test.js +290 -0
- package/dist/lifecycle/hook-discovery.d.ts +24 -0
- package/dist/lifecycle/hook-discovery.js +60 -0
- package/dist/lifecycle/hook-dispatcher.d.ts +34 -0
- package/dist/lifecycle/hook-dispatcher.js +190 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.js +3 -0
- package/dist/lifecycle/parallel-grouping.d.ts +27 -0
- package/dist/lifecycle/parallel-grouping.js +151 -0
- package/dist/utils/__tests__/dev-server-manager.test.d.ts +1 -0
- package/dist/utils/__tests__/dev-server-manager.test.js +72 -0
- package/dist/utils/dev-server-manager.d.ts +40 -0
- package/dist/utils/dev-server-manager.js +257 -0
- package/package.json +9 -7
- package/templates/default/package.json +2 -2
- package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db +0 -0
- package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-shm +0 -0
- package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-wal +0 -0
- package/templates/nx/.nx/workspace-data/d/daemon.log +6405 -1012
- package/templates/nx/.nx/workspace-data/d/server-process.json +3 -0
- package/templates/nx/.nx/workspace-data/file-map.json +170 -170
- package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
- package/templates/nx/.nx/workspace-data/parsed-lock-file.json +31 -31
- package/templates/nx/.nx/workspace-data/project-graph.json +33 -33
- package/templates/nx/package.json +2 -2
- package/templates/nx/spectra.config.ts +13 -0
- package/templates/react-component/package.json +3 -3
- package/bin/.testspectra-runner.hash +0 -1
- package/dist/commands/__tests__/doctor-scope.test.js +0 -24
- /package/dist/{commands/__tests__/doctor-scope.test.d.ts → lifecycle/__tests__/lifecycle-engine.test.d.ts} +0 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
/**
|
|
5
|
+
* Detects actual CPU quota available to the process, accounting for
|
|
6
|
+
* Docker and Kubernetes cgroup v1 and cgroup v2 container limits.
|
|
7
|
+
*/
|
|
8
|
+
export function detectContainerCpuQuota() {
|
|
9
|
+
try {
|
|
10
|
+
// 1. Check cgroup v2 (Unified Hierarchy): /sys/fs/cgroup/cpu.max
|
|
11
|
+
// Format: "<quota> <period>" e.g. "200000 100000" or "max 100000"
|
|
12
|
+
if (fs.existsSync('/sys/fs/cgroup/cpu.max')) {
|
|
13
|
+
const content = fs.readFileSync('/sys/fs/cgroup/cpu.max', 'utf-8').trim();
|
|
14
|
+
const [quotaStr, periodStr] = content.split(/\s+/);
|
|
15
|
+
if (quotaStr && quotaStr !== 'max' && periodStr) {
|
|
16
|
+
const quota = parseFloat(quotaStr);
|
|
17
|
+
const period = parseFloat(periodStr);
|
|
18
|
+
if (quota > 0 && period > 0) {
|
|
19
|
+
return Math.max(0.5, quota / period);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// 2. Check cgroup v1: /sys/fs/cgroup/cpu/cpu.cfs_quota_us and cpu.cfs_period_us
|
|
24
|
+
if (fs.existsSync('/sys/fs/cgroup/cpu/cpu.cfs_quota_us') && fs.existsSync('/sys/fs/cgroup/cpu/cpu.cfs_period_us')) {
|
|
25
|
+
const quota = parseFloat(fs.readFileSync('/sys/fs/cgroup/cpu/cpu.cfs_quota_us', 'utf-8').trim());
|
|
26
|
+
const period = parseFloat(fs.readFileSync('/sys/fs/cgroup/cpu/cpu.cfs_period_us', 'utf-8').trim());
|
|
27
|
+
if (quota > 0 && period > 0) {
|
|
28
|
+
return Math.max(0.5, quota / period);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
// 3. Fallback to host CPU count
|
|
34
|
+
if (typeof os.availableParallelism === 'function') {
|
|
35
|
+
try {
|
|
36
|
+
return os.availableParallelism();
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
}
|
|
40
|
+
return os.cpus()?.length || 2;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Detects available memory in bytes, respecting container cgroup memory limits
|
|
44
|
+
* to prevent Linux OOM (Out of Memory) kills.
|
|
45
|
+
*/
|
|
46
|
+
export function detectAvailableMemoryBytes() {
|
|
47
|
+
try {
|
|
48
|
+
// 1. Check cgroup v2: /sys/fs/cgroup/memory.max and /sys/fs/cgroup/memory.current
|
|
49
|
+
if (fs.existsSync('/sys/fs/cgroup/memory.max')) {
|
|
50
|
+
const maxStr = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf-8').trim();
|
|
51
|
+
if (maxStr !== 'max') {
|
|
52
|
+
const limit = parseFloat(maxStr);
|
|
53
|
+
let usage = 0;
|
|
54
|
+
if (fs.existsSync('/sys/fs/cgroup/memory.current')) {
|
|
55
|
+
usage = parseFloat(fs.readFileSync('/sys/fs/cgroup/memory.current', 'utf-8').trim()) || 0;
|
|
56
|
+
}
|
|
57
|
+
if (limit > 0) {
|
|
58
|
+
return Math.max(0, limit - usage);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 2. Check cgroup v1: /sys/fs/cgroup/memory/memory.limit_in_bytes
|
|
63
|
+
if (fs.existsSync('/sys/fs/cgroup/memory/memory.limit_in_bytes')) {
|
|
64
|
+
const limit = parseFloat(fs.readFileSync('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'utf-8').trim());
|
|
65
|
+
// cgroup v1 sets a huge number (e.g. 9223372036854771712) when no limit is enforced
|
|
66
|
+
if (limit > 0 && limit < 1e15) {
|
|
67
|
+
let usage = 0;
|
|
68
|
+
if (fs.existsSync('/sys/fs/cgroup/memory/memory.usage_in_bytes')) {
|
|
69
|
+
usage = parseFloat(fs.readFileSync('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'utf-8').trim()) || 0;
|
|
70
|
+
}
|
|
71
|
+
return Math.max(0, limit - usage);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch { }
|
|
76
|
+
// 3. Fallback to host available memory (estimating reclaimable cache/buffer on modern OS)
|
|
77
|
+
try {
|
|
78
|
+
const free = os.freemem();
|
|
79
|
+
const total = os.totalmem();
|
|
80
|
+
return Math.max(free, total * 0.4);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return 1024 * 1024 * 1024; // 1 GB fallback
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Dynamically computes optimal worker concurrency respecting:
|
|
88
|
+
* 1. Explicit user concurrency flag (--concurrency <N> or config)
|
|
89
|
+
* 2. Real container cgroups CPU quota & host CPU count
|
|
90
|
+
* 3. Low-CPU safety ratio (clamping <=2 CPUs to 1 worker, 3-4 CPUs to 2 workers)
|
|
91
|
+
* 4. Memory-aware guard (~600MB RAM budget per Chromium instance to prevent OOM kills)
|
|
92
|
+
* 5. Target items count & maximum worker cap
|
|
93
|
+
*/
|
|
94
|
+
export function calculateWorkerCount(targetItems, options) {
|
|
95
|
+
const opts = typeof options === 'number' ? { maxWorkersCap: options } : options || {};
|
|
96
|
+
const itemCount = targetItems.length;
|
|
97
|
+
if (itemCount === 0)
|
|
98
|
+
return 1;
|
|
99
|
+
// 1. Explicit User Concurrency has absolute priority
|
|
100
|
+
if (opts.userConcurrency && opts.userConcurrency > 0) {
|
|
101
|
+
return Math.min(opts.userConcurrency, itemCount);
|
|
102
|
+
}
|
|
103
|
+
// 2. Determine CPU Quota (cgroups aware)
|
|
104
|
+
const cpuCount = opts.cpuQuotaOverride !== undefined ? opts.cpuQuotaOverride : detectContainerCpuQuota();
|
|
105
|
+
// 3. Low-CPU Safety Ratio:
|
|
106
|
+
// - CPU <= 2: Max 1 worker (prevents severe thrashing on 2-vCPU CI runners)
|
|
107
|
+
// - CPU 3-4: Max 2 workers
|
|
108
|
+
// - CPU > 4: cpuCount - 2 (leaves 2 cores for OS/IDE/DevTools)
|
|
109
|
+
let cpuBasedWorkers = 1;
|
|
110
|
+
if (cpuCount <= 2) {
|
|
111
|
+
cpuBasedWorkers = 1;
|
|
112
|
+
}
|
|
113
|
+
else if (cpuCount <= 4) {
|
|
114
|
+
cpuBasedWorkers = 2;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
cpuBasedWorkers = Math.max(1, Math.floor(cpuCount - 2));
|
|
118
|
+
}
|
|
119
|
+
// 4. Memory-Aware Guard (Anti-OOM):
|
|
120
|
+
// Allocate ~600MB per Chromium browser instance
|
|
121
|
+
const MEMORY_PER_WORKER_BYTES = 600 * 1024 * 1024;
|
|
122
|
+
const availableMemBytes = opts.memoryBytesOverride !== undefined ? opts.memoryBytesOverride : detectAvailableMemoryBytes();
|
|
123
|
+
const memBasedWorkers = Math.max(1, Math.floor(availableMemBytes / MEMORY_PER_WORKER_BYTES));
|
|
124
|
+
// 5. Apply Maximum Cap and Item Count Clamping
|
|
125
|
+
const maxCap = opts.maxWorkersCap || 6;
|
|
126
|
+
return Math.max(1, Math.min(cpuBasedWorkers, memBasedWorkers, maxCap, itemCount));
|
|
127
|
+
}
|
|
128
|
+
export function groupSpecs(specPaths, mode = 'suite') {
|
|
129
|
+
if (mode === 'testcase') {
|
|
130
|
+
return [...specPaths];
|
|
131
|
+
}
|
|
132
|
+
// Group by suite directory
|
|
133
|
+
const suiteGroups = new Map();
|
|
134
|
+
for (const specPath of specPaths) {
|
|
135
|
+
const normalized = specPath.replace(/\\/g, '/');
|
|
136
|
+
const parts = normalized.split('/');
|
|
137
|
+
let suiteKey = 'default';
|
|
138
|
+
const specsIdx = parts.findIndex((p) => p === 'specs' || p === 'suites');
|
|
139
|
+
if (specsIdx !== -1 && specsIdx + 1 < parts.length) {
|
|
140
|
+
suiteKey = parts.slice(0, specsIdx + 2).join('/');
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
suiteKey = path.dirname(normalized);
|
|
144
|
+
}
|
|
145
|
+
if (!suiteGroups.has(suiteKey)) {
|
|
146
|
+
suiteGroups.set(suiteKey, []);
|
|
147
|
+
}
|
|
148
|
+
suiteGroups.get(suiteKey).push(specPath);
|
|
149
|
+
}
|
|
150
|
+
return Array.from(suiteGroups.values());
|
|
151
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { DevServerManager } from '../dev-server-manager.js';
|
|
4
|
+
describe('DevServerManager', () => {
|
|
5
|
+
it('parses command strings into binary and argv array without shell', () => {
|
|
6
|
+
expect(DevServerManager.parseCommand('pnpm --filter sso dev')).toEqual({
|
|
7
|
+
bin: 'pnpm',
|
|
8
|
+
args: ['--filter', 'sso', 'dev'],
|
|
9
|
+
});
|
|
10
|
+
expect(DevServerManager.parseCommand('nx run auth-e2e:serve --port=5174')).toEqual({
|
|
11
|
+
bin: 'nx',
|
|
12
|
+
args: ['run', 'auth-e2e:serve', '--port=5174'],
|
|
13
|
+
});
|
|
14
|
+
expect(DevServerManager.parseCommand('node "./scripts/serve.js" --mode "dev test"')).toEqual({
|
|
15
|
+
bin: 'node',
|
|
16
|
+
args: ['./scripts/serve.js', '--mode', 'dev test'],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
it('probes listening HTTP server and detects readiness', async () => {
|
|
20
|
+
const server = http.createServer((_req, res) => {
|
|
21
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
22
|
+
res.end('OK');
|
|
23
|
+
});
|
|
24
|
+
await new Promise((resolve) => {
|
|
25
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
26
|
+
});
|
|
27
|
+
const addr = server.address();
|
|
28
|
+
const url = `http://127.0.0.1:${addr.port}`;
|
|
29
|
+
const isReady = await DevServerManager.probeUrl(url, 1000);
|
|
30
|
+
expect(isReady).toBe(true);
|
|
31
|
+
const isNonExistentReady = await DevServerManager.probeUrl('http://127.0.0.1:49999', 200);
|
|
32
|
+
expect(isNonExistentReady).toBe(false);
|
|
33
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
34
|
+
});
|
|
35
|
+
it('reuses existing running server without spawning new process', async () => {
|
|
36
|
+
const server = http.createServer((_req, res) => {
|
|
37
|
+
res.writeHead(200);
|
|
38
|
+
res.end('OK');
|
|
39
|
+
});
|
|
40
|
+
await new Promise((resolve) => {
|
|
41
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
42
|
+
});
|
|
43
|
+
const addr = server.address();
|
|
44
|
+
const url = `http://127.0.0.1:${addr.port}`;
|
|
45
|
+
const manager = new DevServerManager();
|
|
46
|
+
const result = await manager.ensureServer({
|
|
47
|
+
name: 'test-server',
|
|
48
|
+
command: 'echo "should not run"',
|
|
49
|
+
url,
|
|
50
|
+
reuseExistingServer: true,
|
|
51
|
+
});
|
|
52
|
+
expect(result.reused).toBe(true);
|
|
53
|
+
expect(result.name).toBe('test-server');
|
|
54
|
+
expect(result.url).toBe(url);
|
|
55
|
+
await manager.stopAll();
|
|
56
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
57
|
+
});
|
|
58
|
+
it('filters dev servers by target host filter', async () => {
|
|
59
|
+
const manager = new DevServerManager();
|
|
60
|
+
const spy = vi.spyOn(manager, 'ensureServer').mockResolvedValue({
|
|
61
|
+
name: 'sso',
|
|
62
|
+
url: 'https://sso.local:5174',
|
|
63
|
+
reused: true,
|
|
64
|
+
});
|
|
65
|
+
await manager.startAll([
|
|
66
|
+
{ name: 'sso', command: 'pnpm sso:dev', url: 'https://sso.local:5174' },
|
|
67
|
+
{ name: 'fam', command: 'pnpm fam:dev', url: 'https://fam.local:5175' },
|
|
68
|
+
], 'sso');
|
|
69
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ name: 'sso' }), expect.any(String));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
import type { DevServerConfig } from '../config/schema.js';
|
|
3
|
+
export interface RunningDevServer {
|
|
4
|
+
name: string;
|
|
5
|
+
url: string;
|
|
6
|
+
process?: ChildProcess;
|
|
7
|
+
reused: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare class DevServerManager {
|
|
10
|
+
private spawnedProcesses;
|
|
11
|
+
private cleanupRegistered;
|
|
12
|
+
constructor();
|
|
13
|
+
/**
|
|
14
|
+
* Safely splits a shell command string into binary executable and argument array without shell expansion.
|
|
15
|
+
*/
|
|
16
|
+
static parseCommand(command: string): {
|
|
17
|
+
bin: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Probes a URL to verify if the server is actively listening and responsive.
|
|
22
|
+
* Performs a rapid TCP connection test followed by an HTTP/HTTPS request.
|
|
23
|
+
*/
|
|
24
|
+
static probeUrl(targetUrl: string, timeoutMs?: number): Promise<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Ensures an individual dev server is running. If already responding and reuseExistingServer !== false,
|
|
27
|
+
* reuses the instance. Otherwise, spawns the process and polls until ready.
|
|
28
|
+
*/
|
|
29
|
+
ensureServer(config: DevServerConfig, rootCwd?: string): Promise<RunningDevServer>;
|
|
30
|
+
/**
|
|
31
|
+
* Starts all matching dev servers in parallel.
|
|
32
|
+
*/
|
|
33
|
+
startAll(configs?: DevServerConfig[], targetHostFilter?: string, rootCwd?: string): Promise<RunningDevServer[]>;
|
|
34
|
+
/**
|
|
35
|
+
* Safely terminates all spawned dev server child processes.
|
|
36
|
+
*/
|
|
37
|
+
stopAll(): Promise<void>;
|
|
38
|
+
private killProcess;
|
|
39
|
+
private registerExitHooks;
|
|
40
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import spawn from 'cross-spawn';
|
|
6
|
+
export class DevServerManager {
|
|
7
|
+
spawnedProcesses = new Map();
|
|
8
|
+
cleanupRegistered = false;
|
|
9
|
+
constructor() {
|
|
10
|
+
this.registerExitHooks();
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Safely splits a shell command string into binary executable and argument array without shell expansion.
|
|
14
|
+
*/
|
|
15
|
+
static parseCommand(command) {
|
|
16
|
+
const trimmed = command.trim();
|
|
17
|
+
if (!trimmed) {
|
|
18
|
+
throw new Error('Dev server command cannot be empty');
|
|
19
|
+
}
|
|
20
|
+
const tokens = [];
|
|
21
|
+
let current = '';
|
|
22
|
+
let inQuotes = false;
|
|
23
|
+
let quoteChar = '';
|
|
24
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
25
|
+
const char = trimmed[i];
|
|
26
|
+
if ((char === '"' || char === "'") && (i === 0 || trimmed[i - 1] !== '\\')) {
|
|
27
|
+
if (inQuotes && char === quoteChar) {
|
|
28
|
+
inQuotes = false;
|
|
29
|
+
quoteChar = '';
|
|
30
|
+
}
|
|
31
|
+
else if (!inQuotes) {
|
|
32
|
+
inQuotes = true;
|
|
33
|
+
quoteChar = char;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
current += char;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else if (/\s/.test(char) && !inQuotes) {
|
|
40
|
+
if (current.length > 0) {
|
|
41
|
+
tokens.push(current);
|
|
42
|
+
current = '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
current += char;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (current.length > 0) {
|
|
50
|
+
tokens.push(current);
|
|
51
|
+
}
|
|
52
|
+
const bin = tokens[0];
|
|
53
|
+
const args = tokens.slice(1);
|
|
54
|
+
return { bin, args };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Probes a URL to verify if the server is actively listening and responsive.
|
|
58
|
+
* Performs a rapid TCP connection test followed by an HTTP/HTTPS request.
|
|
59
|
+
*/
|
|
60
|
+
static async probeUrl(targetUrl, timeoutMs = 1500) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = new URL(targetUrl);
|
|
63
|
+
const isHttps = parsed.protocol === 'https:';
|
|
64
|
+
const port = parsed.port ? parseInt(parsed.port, 10) : isHttps ? 443 : 80;
|
|
65
|
+
const hostname = parsed.hostname;
|
|
66
|
+
// 1. TCP Probe (Fast check)
|
|
67
|
+
const tcpReachable = await new Promise((resolve) => {
|
|
68
|
+
const socket = net.createConnection({ host: hostname, port, timeout: timeoutMs }, () => {
|
|
69
|
+
socket.destroy();
|
|
70
|
+
resolve(true);
|
|
71
|
+
});
|
|
72
|
+
socket.on('error', () => {
|
|
73
|
+
socket.destroy();
|
|
74
|
+
resolve(false);
|
|
75
|
+
});
|
|
76
|
+
socket.on('timeout', () => {
|
|
77
|
+
socket.destroy();
|
|
78
|
+
resolve(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
if (!tcpReachable) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
// 2. HTTP/HTTPS Probe (Verify application readiness, bypass self-signed certs)
|
|
85
|
+
return await new Promise((resolve) => {
|
|
86
|
+
const transport = isHttps ? https : http;
|
|
87
|
+
const req = transport.request(targetUrl, {
|
|
88
|
+
method: 'HEAD',
|
|
89
|
+
timeout: timeoutMs,
|
|
90
|
+
rejectUnauthorized: false,
|
|
91
|
+
}, (res) => {
|
|
92
|
+
// Any valid HTTP response means server is alive and accepting traffic
|
|
93
|
+
res.resume();
|
|
94
|
+
resolve(true);
|
|
95
|
+
});
|
|
96
|
+
req.on('error', () => resolve(false));
|
|
97
|
+
req.on('timeout', () => {
|
|
98
|
+
req.destroy();
|
|
99
|
+
resolve(false);
|
|
100
|
+
});
|
|
101
|
+
req.end();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Ensures an individual dev server is running. If already responding and reuseExistingServer !== false,
|
|
110
|
+
* reuses the instance. Otherwise, spawns the process and polls until ready.
|
|
111
|
+
*/
|
|
112
|
+
async ensureServer(config, rootCwd = process.cwd()) {
|
|
113
|
+
const serverName = config.name || config.url;
|
|
114
|
+
const timeoutMs = config.timeout ?? 60000;
|
|
115
|
+
const reuse = config.reuseExistingServer !== false;
|
|
116
|
+
// Check if server is already running
|
|
117
|
+
if (reuse) {
|
|
118
|
+
const isRunning = await DevServerManager.probeUrl(config.url, 1000);
|
|
119
|
+
if (isRunning) {
|
|
120
|
+
console.log(`\x1b[36m[TestSpectra DevServer]\x1b[0m ⚡ Reusing active server \x1b[1m"${serverName}"\x1b[0m at \x1b[33m${config.url}\x1b[0m`);
|
|
121
|
+
return {
|
|
122
|
+
name: serverName,
|
|
123
|
+
url: config.url,
|
|
124
|
+
reused: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
console.log(`\x1b[36m[TestSpectra DevServer]\x1b[0m 🚀 Launching dev server \x1b[1m"${serverName}"\x1b[0m (\x1b[90m${config.command}\x1b[0m)...`);
|
|
129
|
+
const { bin, args } = DevServerManager.parseCommand(config.command);
|
|
130
|
+
const execCwd = config.cwd ? path.resolve(rootCwd, config.cwd) : rootCwd;
|
|
131
|
+
const logBuffer = [];
|
|
132
|
+
const pushLog = (data) => {
|
|
133
|
+
const lines = data.toString().split('\n');
|
|
134
|
+
for (const line of lines) {
|
|
135
|
+
if (line.trim()) {
|
|
136
|
+
logBuffer.push(line);
|
|
137
|
+
if (logBuffer.length > 50)
|
|
138
|
+
logBuffer.shift();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const child = spawn(bin, args, {
|
|
143
|
+
cwd: execCwd,
|
|
144
|
+
env: { ...process.env, ...config.env },
|
|
145
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
+
detached: process.platform !== 'win32',
|
|
147
|
+
});
|
|
148
|
+
child.stdout?.on('data', pushLog);
|
|
149
|
+
child.stderr?.on('data', pushLog);
|
|
150
|
+
this.spawnedProcesses.set(serverName, {
|
|
151
|
+
child,
|
|
152
|
+
name: serverName,
|
|
153
|
+
url: config.url,
|
|
154
|
+
logBuffer,
|
|
155
|
+
});
|
|
156
|
+
const startTime = Date.now();
|
|
157
|
+
let isReady = false;
|
|
158
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
159
|
+
if (child.exitCode !== null) {
|
|
160
|
+
const errorLogs = logBuffer.slice(-20).join('\n');
|
|
161
|
+
throw new Error(`Dev server "${serverName}" terminated prematurely with exit code ${child.exitCode}.\n` +
|
|
162
|
+
`Last output:\n${errorLogs || '(no output)'}`);
|
|
163
|
+
}
|
|
164
|
+
isReady = await DevServerManager.probeUrl(config.url, 500);
|
|
165
|
+
if (isReady) {
|
|
166
|
+
const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
167
|
+
console.log(`\x1b[32m[TestSpectra DevServer] ✓ Server "${serverName}" ready at ${config.url} (${durationSec}s)\x1b[0m`);
|
|
168
|
+
return {
|
|
169
|
+
name: serverName,
|
|
170
|
+
url: config.url,
|
|
171
|
+
process: child,
|
|
172
|
+
reused: false,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
176
|
+
}
|
|
177
|
+
// Timeout exceeded — dump logs and kill child
|
|
178
|
+
this.killProcess(child);
|
|
179
|
+
this.spawnedProcesses.delete(serverName);
|
|
180
|
+
const errorLogs = logBuffer.slice(-25).join('\n');
|
|
181
|
+
throw new Error(`Dev server "${serverName}" timed out after ${timeoutMs}ms waiting for ${config.url}.\n` +
|
|
182
|
+
`Last output:\n${errorLogs || '(no output)'}`);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Starts all matching dev servers in parallel.
|
|
186
|
+
*/
|
|
187
|
+
async startAll(configs = [], targetHostFilter, rootCwd = process.cwd()) {
|
|
188
|
+
if (!configs || configs.length === 0) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
let targetConfigs = configs;
|
|
192
|
+
if (targetHostFilter) {
|
|
193
|
+
const matched = configs.filter((c) => c.name === targetHostFilter);
|
|
194
|
+
if (matched.length > 0) {
|
|
195
|
+
targetConfigs = matched;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return Promise.all(targetConfigs.map((cfg) => this.ensureServer(cfg, rootCwd)));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Safely terminates all spawned dev server child processes.
|
|
202
|
+
*/
|
|
203
|
+
async stopAll() {
|
|
204
|
+
if (this.spawnedProcesses.size === 0)
|
|
205
|
+
return;
|
|
206
|
+
for (const entry of this.spawnedProcesses.values()) {
|
|
207
|
+
try {
|
|
208
|
+
this.killProcess(entry.child);
|
|
209
|
+
}
|
|
210
|
+
catch { }
|
|
211
|
+
}
|
|
212
|
+
this.spawnedProcesses.clear();
|
|
213
|
+
}
|
|
214
|
+
killProcess(child) {
|
|
215
|
+
if (child.pid && !child.killed) {
|
|
216
|
+
if (process.platform !== 'win32') {
|
|
217
|
+
try {
|
|
218
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
try {
|
|
222
|
+
child.kill('SIGTERM');
|
|
223
|
+
}
|
|
224
|
+
catch { }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
try {
|
|
229
|
+
child.kill('SIGTERM');
|
|
230
|
+
}
|
|
231
|
+
catch { }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
registerExitHooks() {
|
|
236
|
+
if (this.cleanupRegistered)
|
|
237
|
+
return;
|
|
238
|
+
this.cleanupRegistered = true;
|
|
239
|
+
const cleanup = () => {
|
|
240
|
+
for (const entry of this.spawnedProcesses.values()) {
|
|
241
|
+
try {
|
|
242
|
+
this.killProcess(entry.child);
|
|
243
|
+
}
|
|
244
|
+
catch { }
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
process.once('exit', cleanup);
|
|
248
|
+
process.once('SIGINT', () => {
|
|
249
|
+
cleanup();
|
|
250
|
+
process.exit(130);
|
|
251
|
+
});
|
|
252
|
+
process.once('SIGTERM', () => {
|
|
253
|
+
cleanup();
|
|
254
|
+
process.exit(143);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testspectra/cli",
|
|
3
|
-
"version": "1.1.8-rc.
|
|
3
|
+
"version": "1.1.8-rc.33",
|
|
4
4
|
"description": "TestSpectra Cross-Platform Test Runner CLI",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -26,11 +26,12 @@
|
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@clack/prompts": "^1.7.0",
|
|
29
|
-
"@testspectra/matchers": "^1.1.8-rc.
|
|
30
|
-
"@testspectra/react": "^1.1.8-rc.
|
|
31
|
-
"@testspectra/skills": "^1.1.8-rc.
|
|
29
|
+
"@testspectra/matchers": "^1.1.8-rc.33",
|
|
30
|
+
"@testspectra/react": "^1.1.8-rc.33",
|
|
31
|
+
"@testspectra/skills": "^1.1.8-rc.33",
|
|
32
32
|
"chalk": "^5.3.0",
|
|
33
33
|
"commander": "^12.1.0",
|
|
34
|
+
"cross-spawn": "^7.0.6",
|
|
34
35
|
"dotenv": "^16.4.5",
|
|
35
36
|
"ora": "^8.0.1",
|
|
36
37
|
"typescript": "^5.6.3",
|
|
@@ -38,11 +39,12 @@
|
|
|
38
39
|
"zod": "^3.23.8"
|
|
39
40
|
},
|
|
40
41
|
"optionalDependencies": {
|
|
41
|
-
"@testspectra/cli-darwin-arm64": "1.1.8-rc.
|
|
42
|
-
"@testspectra/cli-linux-x64": "1.1.8-rc.
|
|
43
|
-
"@testspectra/cli-win32-x64": "1.1.8-rc.
|
|
42
|
+
"@testspectra/cli-darwin-arm64": "1.1.8-rc.33",
|
|
43
|
+
"@testspectra/cli-linux-x64": "1.1.8-rc.33",
|
|
44
|
+
"@testspectra/cli-win32-x64": "1.1.8-rc.33"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
47
|
+
"@types/cross-spawn": "^6.0.6",
|
|
46
48
|
"@types/node": "^20.14.0",
|
|
47
49
|
"@wdio/globals": "^9.24.0",
|
|
48
50
|
"@wdio/mocha-framework": "^9.24.0",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"postinstall": "spectra sync-types"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@testspectra/cli": "^1.1.8-rc.
|
|
21
|
-
"@testspectra/matchers": "^1.1.8-rc.
|
|
20
|
+
"@testspectra/cli": "^1.1.8-rc.33",
|
|
21
|
+
"@testspectra/matchers": "^1.1.8-rc.33",
|
|
22
22
|
"@types/node": "^20.14.0",
|
|
23
23
|
"@wdio/cli": "^9.2.8",
|
|
24
24
|
"@wdio/local-runner": "^9.2.8",
|
|
Binary file
|
|
Binary file
|