Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 29 additions & 23 deletions cypress.config.ts
Original file line number Diff line number Diff line change
@@ -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<ExitSignal, number> = {
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]);
});
});

Expand All @@ -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();
Expand All @@ -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);
}
});

Expand Down
110 changes: 84 additions & 26 deletions cypress/support/run.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,90 @@
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<void> {
await new Promise<void>((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<boolean> {
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
): Promise<ChildProcessWithoutNullStreams> => {
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 = [
Expand All @@ -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')) {
Expand All @@ -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));
});
});
};
Loading