Chainlit 项目 E2E 测试并行化研究报告
【免费下载链接】chainlitBuild Conversational AI in minutes ⚡️项目地址: https://gitcode.com/GitHub_Trending/ch/chainlit
<output_article>
Chainlit E2E 测试并行化改造指南:从严格串行到多分片执行
导读
本文基于 Chainlit 仓库中的研究文档 e2e-parallel-execution.md,系统梳理该仓库 E2E 测试当前"严格串行"的架构根因、Cypress 并行化能力边界,以及两条可行的并行化改造路径。文中所有结论均结合当前仓库中的 cypress.config.ts、run.ts、config.py、e2e-tests.yaml 等真实源码交叉验证——事实上,研究文档提出的"Strategy A:CI 矩阵分片"方案已在当前仓库落地。读完本文,你将掌握 E2E 测试并行化的瓶颈分析思路、分片方案选型依据,以及 Chainlit 仓库的具体落地形态。
当前架构:严格串行及其四个结构性根因
研究文档明确指出:在当前架构下,E2E 测试无法并行运行。这不是配置开关的问题,而是由四个相互叠加的结构性设计共同决定的。
1. 单一硬编码端口8000
每个测试后端都启动在端口8000上。该端口定义在 backend/chainlit/config.py:
DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8000它被RunSettings模型作为默认值消费(config.py):
class RunSettings(BaseModel): module_name: Optional[str] = None host: str = DEFAULT_HOST port: int = DEFAULT_PORT ...而启动测试后端的 cypress/support/run.ts 在调用uv run chainlit run时并未传递--port参数,因此每个后端都绑定到默认端口:
const command = 'uv'; const args = [ '--project', CHAILIT_DIR, 'run', 'chainlit', 'run', entryPointPath, '-h', '--ci' ];同样的硬编码也出现在 cypress.config.ts 中,并被用作baseUrl:
export const CHAINLIT_APP_PORT = 8000; // ... baseUrl: `http://127.0.0.1:${CHAINLIT_APP_PORT}`,2. 每个 spec 文件的 "kill → 启动 → 测试 → kill" 生命周期
cypress.config.ts 通过 Cypress 的setupNodeEvents注册了严格的进程编排:
on('before:spec', async (spec) => { await killChainlit(); await runChainlit(spec); }); on('after:spec', async () => { await killChainlit(); });其中killChainlit()是基于端口的进程终止(cypress.config.ts):
async function killChainlit() { await fkill(`:${CHAINLIT_APP_PORT}`, { force: true, silent: true }); }这里使用fkill(:8000)按端口号杀死进程。研究文档特别指出其风险:如果同时存在两个测试运行器,其中一个会把另一个的后端进程杀掉。这正是串行架构的典型特征——进程生命周期与端口强耦合,天然排斥并发。
此外,配置中还注册了SIGTERM/SIGINT/SIGHUP/SIGBREAK信号处理(cypress.config.ts),确保测试进程退出时也能兜底清理 Chainlit 后端。
3. 每个 spec 拥有独立的 Chainlit 应用
仓库的 E2E 测试采用"一测试一应用"的组织方式:每个测试目录cypress/e2e/<test_name>/下都包含:
main.py—— 该测试专用的 Chainlit 应用;.chainlit/config.toml—— 该测试的独立配置。
run.ts 通过spec.absolute定位测试目录,并以环境变量的方式把目录注入 Chainlit:
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);启动时的环境变量注入(run.ts):
const options: SpawnOptionsWithoutStdio = { env: { ...process.env, CHAINLIT_APP_ROOT: testDir } };由于每个测试的main.py是完全不同的后端应用,服务端无法复用,必须在 spec 之间重启。
4. Cypress 按 spec 串行执行
标准的cypress run在单个浏览器进程中一次处理一个 spec 文件。仓库的 npm 脚本也印证了这一点(package.json):
"test:e2e": "cypress run"研究文档指出:Cypress Cloud 提供跨 CI 机器的并行能力,但当时项目并未启用——脚本中没有--parallel或--record标志。
特例:测试中途重启(data_layer)
并非所有测试都遵循"每 spec 一次生命周期"。data_layerspec 会在单个测试内部调用cy.task('restartChainlit', Cypress.spec)来杀进程并重新拉起后端,用于验证线程(thread)在服务端重启后的持久化行为(cypress/e2e/data_layer/spec.cy.ts):
it('Verifies thread continuation after server restart and new thread creation', () => { cy.task('restartChainlit', Cypress.spec).then(() => { cy.section('Before server restart'); // ... 登录、开始对话、验证反馈与线程队列 }); cy.task('restartChainlit', Cypress.spec).then(() => { cy.section('After server restart'); verifyContinueThread(); // ... }); });对应的restartChainlittask 实现在 cypress.config.ts,同样依赖killChainlit() → runChainlit(spec)的端口级编排:
restartChainlit(spec: Cypress.Spec) { return new Promise((resolve) => { killChainlit().then(() => { runChainlit(spec).then(() => { setTimeout(() => resolve(null), 1000); }); }); }); }测试依赖的线程数据以thread_history.pickle文件形式落盘在测试目录(见 cypress/e2e/data_layer/main.py),spec 通过cleanupThreadHistory在用例前后清理该文件(spec.cy.ts)。这类"共享文件系统"的副作用,是并行化时必须隔离的隐藏依赖。
Cypress 的并行化能力边界
内置--parallel依赖 Cypress Cloud
Cypress并不免费提供开箱即用的本地并行。官方用法是:
cypress run --record --parallel关键事实:
--parallel必须与--record搭配使用,后者会把结果上报到 Cypress Cloud(付费服务);- Cypress Cloud 扮演编排者角色:它基于历史运行时长,用负载均衡策略把 spec 文件动态分配给可用的 CI 机器;
- 因此 Cypress 官方并行是跨机器的多机并行,而非本地的多进程并行。
Cypress 与 Playwright 的并行能力对比
| 特性 | Cypress | Playwright |
|---|---|---|
| 本地并行 | 不支持 | 内置(--workers=4) |
| CI 分片 | 通过 Cypress Cloud(付费) | 内置(--shard=1/4) |
| 编排方式 | 基于历史时长的动态分配 | 手动均分 |
免费的 Cypress 并行替代方案
| 工具 | 说明 |
|---|---|
| 手动 CI matrix 分片 | 用 GitHub Actions matrix 任务配合--spec把 spec 文件拆分到多台机器 |
| sorry-cypress | 开源自托管的 Cypress Cloud 替代品,支持--parallel |
| cypress-split | 通过SPLIT/SPLIT_INDEX环境变量在 CI 机器间拆分 spec 的插件 |
| currents.dev | 提供免费额度的 Cypress Cloud 替代服务 |
注:以上外部工具仅作能力概述,具体接入方式请以各工具官方文档为准。当前仓库实际选用的是cypress-split(见下文落地现状)。
并行化改造的瓶颈与对策
研究文档将并行化的障碍与解决方案整理如下:
| 瓶颈 | 解决方案 |
|---|---|
硬编码端口8000 | 为每个 worker 分配唯一端口(如8000 + workerIndex),向chainlit run传入--port,并动态配置baseUrl |
基于端口的进程终止(fkill(:8000)) | 改为基于 PID 的进程管理——保存spawn()返回的子进程 PID 并直接 kill |
| 单一 Cypress 浏览器进程 | 使用 Cypress Cloud--parallel、sorry-cypress、cypress-split,或在 CI matrix 任务间分片 |
共享文件系统(.chainlit/目录) | 目录本身已按测试隔离;但像data_layer写入的thread_history.pickle等临时文件必须保持每个 worker 隔离 |
Strategy A:CI Matrix 分片(最简单)
把约 50 个 spec 文件按 N 组拆分到独立的 GitHub Actions runner 上:
- 在 workflow 中增加 matrix 维度,为每个 runner 分配一组 spec;
- 每个 runner 仍可使用端口
8000(因为机器彼此独立); - 无需修改 Cypress 配置或
run.ts——只需用--spec传入子集。
这条路径的吸引力在于:跨机器天然隔离端口与进程,改动面最小。
Strategy B:同机并行(工作量更大)
- 让
CHAINLIT_APP_PORT动态化——按 worker 从环境变量读取; - 用 PID 追踪(
spawn返回的child.pid)替换fkill(:port); - 用 cypress-split 或 sorry-cypress 把 spec 分发到多个 Cypress 进程,每个进程拥有各自的
baseUrl; - 每个 Cypress 进程通过独立的
CYPRESS_BASE_URL指向自己专属的后端端口。
同机并行的难点在于前两步:必须同时解决端口冲突与端口级杀进程两个耦合点,改造面大、回归风险高。
当前仓库的落地现状:Strategy A + cypress-split
研究文档之后,仓库实际推进到了实施阶段。证据在 .github/workflows/e2e-tests.yaml:
jobs: prepare: runs-on: ubuntu-slim outputs: indexes: ${{ steps.shard-indexes.outputs.indexes }} steps: - id: shard-indexes name: Compute shard indexes run: | json=$(jq -nc --argjson n "${{ inputs.e2e_parallel_shards }}" '[range(1; $n + 1)]') echo "indexes=$json" >> "$GITHUB_OUTPUT" e2e-tests: needs: prepare strategy: matrix: os: [ubuntu-latest, windows-latest] containers: ${{ fromJSON(needs.prepare.outputs.indexes) }}工作流通过e2e_parallel_shards输入(默认5)动态生成分片索引,在ubuntu-latest与windows-latest两个 OS 上各起 5 个并行分片。运行测试时注入 cypress-split 需要的环境变量:
env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} SPLIT: ${{ inputs.e2e_parallel_shards }} SPLIT_INDEX1: ${{ matrix.containers }} run: pnpm test:e2e这正是Strategy A(CI matrix 分片)+ cypress-split(spec 自动切分)的组合形态:
- 每个 runner 是独立机器,端口
8000冲突天然不存在; cypress-split在测试运行前把全部 spec 按SPLIT/SPLIT_INDEX动态分配给各分片,无需手工维护--spec子集;- Cypress 配置侧已接入插件(cypress.config.ts):
import cypressSplit from 'cypress-split'; // ... cypressSplit(on, config);对应依赖在 package.json 中声明:"cypress-split": "^1.24.31"。
并行分片下的配套工程:Cypress 二进制缓存
并行分片会放大依赖安装成本(5 个分片 × 2 个 OS 都要装 Cypress),因此 e2e-tests.yaml 使用CYPRESS_CACHE_FOLDER把 Linux / Windows 默认不同的 Cypress 缓存目录统一为仓库工作区下的单一路径,配合actions/cache跨分片复用:
env: # Single path for actions/cache on Linux + Windows (default Cypress dirs differ by OS). CYPRESS_CACHE_FOLDER: ${{ github.workspace }}/.cypress-cache steps: - name: Cache Cypress binary uses: actions/cache@v5 with: path: .cypress-cache key: cypress-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}这与另一份研究文档 ci-cache-optimization.md 中"用actions/cache替换 cypress action、统一跨 OS 缓存路径"的 P1 建议一脉相承。前端与 Python 依赖的缓存则分别由 .github/actions/pnpm-node-install/action.yaml(actions/setup-node@v6.3.0+cache: 'pnpm')与 .github/actions/uv-python-install/action.yaml(astral-sh/setup-uv@v8.0.0+enable-cache: true)提供,与 CI 顶层编排 ci.yaml 配合。
结论与选型建议
回顾整条改造路径,可以提炼出三点可迁移的经验:
- 先识别"串行架构"的结构性耦合。Chainlit 仓库的串行并非单一原因,而是端口、进程生命周期、应用隔离、Cypress 执行模型四个因素叠加。逐一拆解后才能评估并行化的真实成本。
- 跨机器分片是低风险首选。Strategy A 以"机器隔离"规避了端口与 PID 管理两大难题,仅引入
--spec/cypress-split 的切分逻辑,是投入产出比最高的起步方案——当前仓库正是沿此路径落地,并以CYPRESS_CACHE_FOLDER统一了多分片下的二进制缓存。 - 同机并行需要更深的工程改造。若未来需要在单机上压缩时间(如本地开发或小型 runner),就必须同时落地动态端口、PID 级进程管理、多 Cypress 进程与独立
baseUrl,并额外隔离thread_history.pickle这类共享文件系统副作用。
研究文档 e2e-parallel-execution.md 的完整分析(含 Cypress/Playwright 对比与免费替代工具矩阵)是理解上述改造的原始依据;感兴趣的读者可继续对照 cypress.config.ts、cypress/support/run.ts 与 .github/workflows/e2e-tests.yaml 追踪从研究到实施的完整演进。
</output_article>
【免费下载链接】chainlitBuild Conversational AI in minutes ⚡️项目地址: https://gitcode.com/GitHub_Trending/ch/chainlit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考