diff --git a/cypress.config.ts b/cypress.config.ts index 87e26ec521..cbe64083c9 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,25 +1,36 @@ import { defineConfig } from 'cypress'; import fkill from 'fkill'; -import { runChainlit } from './cypress/support/run'; +import { runChainlit, stopChainlit } from './cypress/support/run'; export const CHAINLIT_APP_PORT = 8000; +const signals = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGBREAK'] as const; +type ExitSignal = (typeof signals)[number]; + +const signalMap: Record = { + SIGTERM: 15, + SIGINT: 2, + SIGHUP: 1, + SIGBREAK: 21 +}; + async function killChainlit() { - await fkill(`:${CHAINLIT_APP_PORT}`, { - force: true, - silent: true - }); -} + const stoppedTracked = await stopChainlit(); -['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGBREAK'].forEach((signal) => { - process.on(signal, () => { - (async () => { - await killChainlit(); // Ensure Chainlit is killed on exit + if (!stoppedTracked) { + try { + await fkill(`:${CHAINLIT_APP_PORT}`, { force: true, silent: true }); + } catch { + // best-effort cleanup only + } + } +} - const signalMap = { SIGTERM: 15, SIGINT: 2, SIGHUP: 1, SIGBREAK: 21 }; - process.exit(128 + (signalMap[signal] || 0)); - })(); +signals.forEach((signal) => { + process.on(signal, async () => { + await killChainlit(); // Ensure Chainlit is killed on exit + process.exit(128 + signalMap[signal]); }); }); @@ -36,7 +47,7 @@ export default defineConfig({ experimentalInteractiveRunEvents: true, async setupNodeEvents(on, config) { await killChainlit(); // Fallback to ensure no previous instance is running - await runChainlit(); // Start Chainlit before running tests as Cypress require + await runChainlit(); // Start Chainlit before running tests as Cypress requires on('before:spec', async (spec) => { await killChainlit(); @@ -57,15 +68,10 @@ export default defineConfig({ return null; }, restartChainlit(spec: Cypress.Spec) { - return new Promise((resolve) => { - killChainlit().then(() => { - runChainlit(spec).then(() => { - setTimeout(() => { - resolve(null); - }, 1000); - }); - }); - }); + return killChainlit() + .then(() => runChainlit(spec)) + .then(() => new Promise((r) => setTimeout(r, 1000))) + .then(() => null); } }); diff --git a/cypress/support/run.ts b/cypress/support/run.ts index d05bea079b..9d9e3385dc 100644 --- a/cypress/support/run.ts +++ b/cypress/support/run.ts @@ -1,10 +1,65 @@ import { - ChildProcessWithoutNullStreams, - SpawnOptionsWithoutStdio, + type ChildProcessWithoutNullStreams, + type SpawnOptionsWithoutStdio, spawn -} from 'child_process'; -import { access } from 'fs/promises'; -import { dirname, join } from 'path'; +} from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +let currentChainlit: ChildProcessWithoutNullStreams | null = null; + +/** + * Kill a process tree on Windows using taskkill /T /F + */ +async function taskkillTree(pid: number): Promise { + await new Promise((resolve) => { + const p = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { + windowsHide: true, + stdio: 'ignore' + }); + + p.on('exit', () => resolve()); + p.on('error', () => resolve()); // best-effort + }); + + // Give Windows time to release the port + await sleep(750); +} + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function stopChainlit(): Promise { + const proc = currentChainlit; + if (!proc?.pid) return false; + + const pid = proc.pid; + + if (process.platform === 'win32') { + // Windows: kill entire process tree + await taskkillTree(pid); + } else { + // POSIX: kill process group (requires detached: true) + try { + process.kill(-pid, 'SIGTERM'); + } catch { + // ignore + } + + await sleep(1500); + + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // ignore + } + } + + currentChainlit = null; + + return true; +} export const runChainlit = async ( spec: Cypress.Spec | null = null @@ -12,24 +67,24 @@ export const runChainlit = async ( const CHAILIT_DIR = join(process.cwd(), 'backend', 'chainlit'); const SAMPLE_DIR = join(CHAILIT_DIR, 'sample'); - return new Promise((resolve, reject) => { - const testDir = spec ? dirname(spec.absolute) : SAMPLE_DIR; - const entryPointFileName = spec - ? spec.name.startsWith('async') - ? 'main_async.py' - : spec.name.startsWith('sync') - ? 'main_sync.py' - : 'main.py' - : 'hello.py'; - - const entryPointPath = join(testDir, entryPointFileName); - - if (!access(entryPointPath)) { - return reject( - new Error(`Entry point file does not exist: ${entryPointPath}`) - ); - } + const testDir = spec ? dirname(spec.absolute) : SAMPLE_DIR; + const entryPointFileName = spec + ? spec.name.startsWith('async') + ? 'main_async.py' + : spec.name.startsWith('sync') + ? 'main_sync.py' + : 'main.py' + : 'hello.py'; + const entryPointPath = join(testDir, entryPointFileName); + + try { + await access(entryPointPath); + } catch { + throw new Error(`Entry point file does not exist: ${entryPointPath}`); + } + + return new Promise((resolve, reject) => { const command = 'uv'; const args = [ @@ -47,11 +102,14 @@ export const runChainlit = async ( env: { ...process.env, CHAINLIT_APP_ROOT: testDir - } + }, + detached: true }; const chainlit = spawn(command, args, options); + currentChainlit = chainlit; + chainlit.stdout.on('data', (data) => { const output = data.toString(); if (output.includes('Your app is available at')) { @@ -64,11 +122,11 @@ export const runChainlit = async ( }); chainlit.on('error', (error) => { - reject(error.message); + reject(error); }); - chainlit.on('exit', function (code) { - reject('Chainlit process exited with code ' + code); + chainlit.on('exit', (code) => { + reject(new Error('Chainlit process exited with code ' + code)); }); }); };