mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 08:57:12 +00:00
366 lines
11 KiB
JavaScript
366 lines
11 KiB
JavaScript
import fs from 'node:fs';
|
|
import { spawn } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { clearRuntimeState, readRuntimeState, writeRuntimeState } from './runtime-state.mjs';
|
|
|
|
const truthy = (value) =>
|
|
['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
|
|
|
|
const trim = (value) => String(value || '').trim();
|
|
|
|
function repoRootFromEnv(env = process.env) {
|
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
return trim(env.PULSE_E2E_REPO_ROOT) || path.resolve(scriptDir, '..', '..', '..');
|
|
}
|
|
|
|
function hotDevBgScriptPath(env = process.env) {
|
|
return path.join(repoRootFromEnv(env), 'scripts', 'hot-dev-bg.sh');
|
|
}
|
|
|
|
function managedVerifyLockPath(env = process.env) {
|
|
return trim(env.HOT_DEV_VERIFY_LOCK_FILE) || path.join(repoRootFromEnv(env), 'tmp', 'hot-dev.verify.lock');
|
|
}
|
|
|
|
function hotDevBrowserURL(env = process.env) {
|
|
const host = trim(env.FRONTEND_DEV_HOST) || '127.0.0.1';
|
|
const port = trim(env.FRONTEND_DEV_PORT) || '5173';
|
|
return `http://${host}:${port}`;
|
|
}
|
|
|
|
function hotDevBackendURL(env = process.env) {
|
|
const host = trim(env.PULSE_DEV_API_HOST) || '127.0.0.1';
|
|
const port = trim(env.PULSE_DEV_API_PORT) || '7655';
|
|
return `http://${host}:${port}`;
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, { ...options });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout?.on('data', (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr?.on('data', (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on('error', reject);
|
|
child.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
async function runHotDevBg(args, env = process.env) {
|
|
const mergedEnv = { ...process.env, ...env };
|
|
return run(hotDevBgScriptPath(mergedEnv), args, {
|
|
cwd: repoRootFromEnv(mergedEnv),
|
|
env: mergedEnv,
|
|
});
|
|
}
|
|
|
|
function statusReportsRunning(output) {
|
|
return output.includes('[hot-dev-bg] Running');
|
|
}
|
|
|
|
export function managedVerifyLockActive(env = process.env) {
|
|
try {
|
|
const raw = fs.readFileSync(managedVerifyLockPath(env), 'utf8');
|
|
const ownerPid = Number.parseInt(
|
|
raw
|
|
.split('\n')
|
|
.find((line) => line.startsWith('pid='))?.slice(4) || '',
|
|
10,
|
|
);
|
|
if (!Number.isInteger(ownerPid) || ownerPid <= 0) {
|
|
return false;
|
|
}
|
|
process.kill(ownerPid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function shouldRestartManagedDevRuntimeForVerification({
|
|
env = process.env,
|
|
wasRunning,
|
|
}) {
|
|
return Boolean(wasRunning) && managedVerifyLockActive(env);
|
|
}
|
|
|
|
async function managedRuntimeStatusOutput(env = process.env) {
|
|
const status = await runHotDevBg(['status'], env);
|
|
if (status.code !== 0) {
|
|
throw new Error(`hot-dev-bg status failed: ${status.stderr || status.stdout}`);
|
|
}
|
|
|
|
return `${status.stdout}${status.stderr}`;
|
|
}
|
|
|
|
function managedSupervisorPidFromStatus(output) {
|
|
const match = output.match(/\[hot-dev-bg\] Running \(pid: (\d+)\)/);
|
|
return match?.[1] || '';
|
|
}
|
|
|
|
function managedListenerPidFromStatus(output, port) {
|
|
const escapedPort = String(port).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const match = output.match(new RegExp(`\\[hot-dev-bg\\] Port ${escapedPort}: managed listener pid=(\\d+)`));
|
|
return match?.[1] || '';
|
|
}
|
|
|
|
async function managedBackendPid(env = process.env) {
|
|
const statusOutput = await managedRuntimeStatusOutput(env);
|
|
return managedListenerPidFromStatus(statusOutput, trim(env.PULSE_DEV_API_PORT) || '7655');
|
|
}
|
|
|
|
async function managedHotDevOwnerPid(env = process.env) {
|
|
const statusOutput = await managedRuntimeStatusOutput(env);
|
|
const supervisorPid = managedSupervisorPidFromStatus(statusOutput);
|
|
if (!supervisorPid) {
|
|
throw new Error(`Managed dev runtime is not running:\n${statusOutput}`);
|
|
}
|
|
|
|
const psResult = await run(
|
|
'ps',
|
|
['-axo', 'pid=,ppid=,pgid=,command='],
|
|
{ env: { ...process.env, ...env } },
|
|
);
|
|
if (psResult.code !== 0) {
|
|
throw new Error(`ps failed while locating managed hot-dev owner: ${psResult.stderr || psResult.stdout}`);
|
|
}
|
|
|
|
const ownerLines = `${psResult.stdout}${psResult.stderr}`
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((line) => {
|
|
const match = line.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/);
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
const [, , ppid, pgid, command] = match;
|
|
if (!command.includes('/scripts/hot-dev.sh')) {
|
|
return false;
|
|
}
|
|
return ppid === supervisorPid || pgid === supervisorPid;
|
|
})
|
|
.sort((left, right) => {
|
|
const leftPid = Number(left.match(/^(\d+)/)?.[1] || '0');
|
|
const rightPid = Number(right.match(/^(\d+)/)?.[1] || '0');
|
|
return leftPid - rightPid;
|
|
});
|
|
|
|
const ownerLine = ownerLines[0] || '';
|
|
if (!ownerLine) {
|
|
throw new Error(
|
|
`Managed hot-dev owner process was not found under supervisor ${supervisorPid}:\n${psResult.stdout}`,
|
|
);
|
|
}
|
|
|
|
const ownerMatch = ownerLine.match(/^(\d+)\s+/);
|
|
return ownerMatch?.[1] || '';
|
|
}
|
|
|
|
async function ensureHealthyStatus(env = process.env) {
|
|
const output = await managedRuntimeStatusOutput(env);
|
|
const requiredMarkers = [
|
|
'[hot-dev-bg] Frontend shell HTTP: 200',
|
|
'[hot-dev-bg] Frontend proxy /api/health: 200',
|
|
'[hot-dev-bg] Backend /api/health: 200',
|
|
];
|
|
for (const marker of requiredMarkers) {
|
|
if (!output.includes(marker)) {
|
|
throw new Error(`Managed dev runtime is not healthy enough for browser proof:\n${output}`);
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
async function waitForStableManagedRuntime({
|
|
env = process.env,
|
|
timeoutMs = 60_000,
|
|
sampleIntervalMs = 2_000,
|
|
requiredStableSamples = 2,
|
|
} = {}) {
|
|
const startedAt = Date.now();
|
|
let stableSamples = 0;
|
|
let previousSnapshot = null;
|
|
let lastError = null;
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
try {
|
|
const statusOutput = await ensureHealthyStatus(env);
|
|
const supervisorPid = managedSupervisorPidFromStatus(statusOutput);
|
|
const frontendPid = managedListenerPidFromStatus(
|
|
statusOutput,
|
|
trim(env.FRONTEND_DEV_PORT) || '5173',
|
|
);
|
|
const backendPid = managedListenerPidFromStatus(
|
|
statusOutput,
|
|
trim(env.PULSE_DEV_API_PORT) || '7655',
|
|
);
|
|
const ownerPid = await managedHotDevOwnerPid(env);
|
|
const snapshot = {
|
|
supervisorPid,
|
|
ownerPid,
|
|
frontendPid,
|
|
backendPid,
|
|
};
|
|
|
|
if (!supervisorPid || !ownerPid || !frontendPid || !backendPid) {
|
|
throw new Error(`Managed dev runtime is healthy but missing stable pid markers:\n${statusOutput}`);
|
|
}
|
|
|
|
if (
|
|
previousSnapshot &&
|
|
previousSnapshot.supervisorPid === snapshot.supervisorPid &&
|
|
previousSnapshot.ownerPid === snapshot.ownerPid &&
|
|
previousSnapshot.frontendPid === snapshot.frontendPid &&
|
|
previousSnapshot.backendPid === snapshot.backendPid
|
|
) {
|
|
stableSamples += 1;
|
|
} else {
|
|
stableSamples = 1;
|
|
}
|
|
|
|
previousSnapshot = snapshot;
|
|
if (stableSamples >= requiredStableSamples) {
|
|
return snapshot;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
stableSamples = 0;
|
|
previousSnapshot = null;
|
|
}
|
|
|
|
await sleep(sampleIntervalMs);
|
|
}
|
|
|
|
const reason =
|
|
lastError instanceof Error
|
|
? lastError.message
|
|
: String(lastError || 'runtime never reached a stable healthy state');
|
|
throw new Error(
|
|
`Managed dev runtime did not stabilize within ${timeoutMs}ms: ${reason}`,
|
|
);
|
|
}
|
|
|
|
export async function startManagedDevRuntime({
|
|
env = process.env,
|
|
logger = console,
|
|
} = {}) {
|
|
await clearRuntimeState(env);
|
|
|
|
const statusBefore = await runHotDevBg(['status'], env);
|
|
if (statusBefore.code !== 0) {
|
|
throw new Error(`hot-dev-bg status failed before start: ${statusBefore.stderr || statusBefore.stdout}`);
|
|
}
|
|
const wasRunning = statusReportsRunning(`${statusBefore.stdout}${statusBefore.stderr}`);
|
|
const shouldRestartForVerification = shouldRestartManagedDevRuntimeForVerification({
|
|
env,
|
|
wasRunning,
|
|
});
|
|
|
|
const startArgs = [shouldRestartForVerification ? 'restart' : 'start'];
|
|
if (truthy(env.PULSE_E2E_HOT_DEV_TAKEOVER)) {
|
|
startArgs.push('--takeover');
|
|
}
|
|
if (shouldRestartForVerification) {
|
|
logger.log('[integration] Restarting managed dev runtime before browser verification to pick up current source changes');
|
|
}
|
|
const startResult = await runHotDevBg(startArgs, env);
|
|
if (startResult.code !== 0) {
|
|
throw new Error(`hot-dev-bg ${startArgs[0]} failed: ${startResult.stderr || startResult.stdout}`);
|
|
}
|
|
|
|
await waitForStableManagedRuntime({ env });
|
|
|
|
const runtimeState = {
|
|
managedDevRuntime: true,
|
|
baseURL: hotDevBrowserURL(env),
|
|
browserURL: hotDevBrowserURL(env),
|
|
backendURL: hotDevBackendURL(env),
|
|
startedByHarness: !wasRunning,
|
|
};
|
|
await writeRuntimeState(runtimeState, env);
|
|
logger.log(
|
|
`[integration] Managed dev runtime ready at ${runtimeState.browserURL} (backend ${runtimeState.backendURL})`,
|
|
);
|
|
return runtimeState;
|
|
}
|
|
|
|
export async function restartManagedDevRuntimeBackend({
|
|
env = process.env,
|
|
} = {}) {
|
|
const beforeRuntime = await waitForStableManagedRuntime({ env });
|
|
const beforePid = beforeRuntime.backendPid;
|
|
const result = await runHotDevBg(['backend-restart'], env);
|
|
if (result.code !== 0) {
|
|
throw new Error(`hot-dev-bg backend-restart failed: ${result.stderr || result.stdout}`);
|
|
}
|
|
const stableRuntime = await waitForStableManagedRuntime({ env });
|
|
const afterPid = stableRuntime.backendPid;
|
|
if (!beforePid || !afterPid || beforePid === afterPid) {
|
|
throw new Error(`Managed backend restart did not replace the backend listener (before=${beforePid || 'missing'}, after=${afterPid || 'missing'})`);
|
|
}
|
|
return { beforePid, afterPid };
|
|
}
|
|
|
|
export async function killManagedDevRuntimeOwnerProcess({
|
|
env = process.env,
|
|
} = {}) {
|
|
const beforeRuntime = await waitForStableManagedRuntime({ env });
|
|
const beforeOwnerPid = beforeRuntime.ownerPid;
|
|
if (!beforeOwnerPid) {
|
|
throw new Error('Managed hot-dev owner pid could not be resolved');
|
|
}
|
|
|
|
const killResult = await run(
|
|
'kill',
|
|
['-KILL', beforeOwnerPid],
|
|
{ env: { ...process.env, ...env } },
|
|
);
|
|
if (killResult.code !== 0) {
|
|
throw new Error(`kill -KILL ${beforeOwnerPid} failed: ${killResult.stderr || killResult.stdout}`);
|
|
}
|
|
|
|
const stableRuntime = await waitForStableManagedRuntime({
|
|
env,
|
|
timeoutMs: 90_000,
|
|
sampleIntervalMs: 3_000,
|
|
requiredStableSamples: 3,
|
|
});
|
|
const afterOwnerPid = stableRuntime.ownerPid;
|
|
if (!afterOwnerPid || beforeOwnerPid === afterOwnerPid) {
|
|
throw new Error(`Managed hot-dev owner was not replaced after kill (before=${beforeOwnerPid}, after=${afterOwnerPid || 'missing'})`);
|
|
}
|
|
return { beforeOwnerPid, afterOwnerPid };
|
|
}
|
|
|
|
export async function stopManagedDevRuntime({
|
|
env = process.env,
|
|
logger = console,
|
|
} = {}) {
|
|
const runtimeState = await readRuntimeState(env);
|
|
if (!runtimeState?.managedDevRuntime) {
|
|
await clearRuntimeState(env);
|
|
return false;
|
|
}
|
|
|
|
if (runtimeState.startedByHarness) {
|
|
const result = await runHotDevBg(['stop'], env);
|
|
if (result.code !== 0) {
|
|
throw new Error(`hot-dev-bg stop failed: ${result.stderr || result.stdout}`);
|
|
}
|
|
logger.log('[integration] Stopped managed dev runtime started by harness');
|
|
}
|
|
|
|
await clearRuntimeState(env);
|
|
return true;
|
|
}
|