Cypress App 前端架构详解:Vite + Vue 3 驱动的桌面端界面包开发指南
【免费下载链接】cypressFast, easy and reliable testing for anything that runs in a browser.项目地址: https://gitcode.com/GitHub_Trending/cy/cypress
@packages/app(位于 packages/app)是 Cypress 桌面应用的前端界面包:从启动面板(launchpad)到 Spec 运行器(runner)的整套 UI 均由它承载,它决定了用户在打开 Cypress 后看到、点击和操作的一切。本指南以 packages/app/README.md 为主线,结合该包源码,梳理本地开发工作流、run/open双模式差异、基于文件结构自动生成的路由体系,以及与旧 webpack 模块共存的关键技巧——读完即可在 Cypress 仓库中定位、调试并扩展 App 的任意界面。
App 包的定位与技术栈
App 包是纯前端工程("private": true,产物输出到dist),它不直接向用户发布,而是被打包进 Cypress 桌面应用中。从其 package.json 可以看清整套技术栈:Vue 3(vue@3.2.47)搭配 Vue Router 4、Pinia 状态管理、urql(@urql/vue)驱动 GraphQL、Tailwind CSS,并由 Vite(vite@^8.0.9)负责构建;路由自动生成依赖vite-plugin-pages@0.32.1与vite-plugin-vue-layouts@0.11.0,类型检查使用vue-tsc。
包内代码按职责划分清晰(见 src 目录结构):
src/pages:路由级别的页面组件(如Specs.vue、Debug.vue、Runs.vue、Settings.vue、Specs/Runner.vue),以及捕获未知路径的[...all].vue;src/runner:运行器相关的大量子组件,如SpecRunnerContainerOpenMode.vue、SpecRunnerContainerRunMode.vue、事件管理、自动化相关 UI;src/router:路由器装配逻辑;src/store:Pinia/MobX 状态仓库(如spec-dirty-data-store、run-all-specs-store);src/layouts:布局组件(配合vite-plugin-vue-layouts)。
本地开发工作流
仓库采用 monorepo,App 的日常开发并不直接在此包内启动完整应用,而是"在 Cypress 中运行 Cypress"(cypress-in-cypress),步骤为:
- 在仓库根目录执行
yarn watch(或按需使用yarn dev),构建并监视各依赖包; - 在
packages/app内执行yarn cypress:open; - 窗口会先打开启动面板(launchpad);
- 选择Component Testing或E2E Testing两种测试类型;
- 打开 Chrome(或其它浏览器);
- 此时看到的就是由 Vite 驱动的新版 App 界面。
从 package.json 的脚本可以看出这套流程的底层细节:cypress:open会通过cypress:run-cypress-in-cypress注入一批环境变量(例如CYPRESS_INTERNAL_E2E_TESTING_SELF_PARENT_PROJECT=1、HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS=http://localhost:4455、CYPRESS_REMOTE_DEBUGGING_PORT=6666、TZ=America/New_York),再执行gulp open --project .。注意start与watch脚本本身会直接提示"去仓库根目录运行yarn dev或yarn watch",因为 monorepo 下该包并不独立可启动。
开发期使用的内部 Vite 选项
开发时值得关注 CONTRIBUTING.md 中记录的CYPRESS_INTERNAL_VITE_DEV环境变量:当它被设置时,Cypress 会为 App 在指定端口拉起 Vite dev server(文档记载的默认端口为3333),让 Vue 组件获得 Vite 的即时热更新体验;此外它也可为 launchpad 启动 dev server(默认端口3001)。这类变量属于 Cypress 内部开发通道,普通用户安装的 Cypress 不受影响。
run与open:同一 UI 内核的两种模式
Cypress 对外呈现两种模式,README 中对此设计动机交代得很清楚:
run模式:跑在 CI 等无人值守环境,必须"尽可能轻、尽可能快",因此 UI 极度精简,只渲染"runner"部分——命令日志(command log)、Spec Runner 头部、以及 AUT(Application Under Test)iframe;open模式:交互式体验,展示完整 Cypress App——顶部导航、侧边导航、Spec 列表等,允许切换测试类型、查看 Cypress Cloud 的最近运行、修改设置等。它是由 GraphQL 与 urql 驱动的;run模式刻意不依赖 GraphQL,以保证性能。
两种模式"由同一套逻辑组装、但使用不同的组件",两者的分派点就在 Runner.vue。其中开放模式容器SpecRunnerContainerOpenMode接收一个gqlprop(README 中称之为<SpecRunnerOpenMode>收到gql),而运行模式容器SpecRunnerContainerRunMode则不接收:
<SpecRunnerContainerRunMode v-if="isRunMode" :run-mode-specs="specs" /> <SpecRunnerContainerOpenMode v-else-if="query.data.value?.currentProject?.specs" :gql="query.data.value" />isRunMode来自 @packages/frontend-shared 的工具函数。Runner.vue的<script setup>部分进一步展示了"run 模式省掉 GraphQL"的具体实现手法:
- 页面级 query 与两条订阅(
SpecPageContainer_specsChange、Runner_ConfigChange)都通过 urql 发起,但传入pause: isRunMode——run 模式下 urql不会发出任何请求; - 注释明确说明:run 模式之所以可以把 GraphQL 整个关掉,是因为运行中不会新增或删除 spec,因此只需在服务端渲染初始 HTML 时把 spec 列表挂到
window.__RUN_MODE_SPECS__,这里直接读取即可。
这带来一个工程结论:open 模式下 App 的数据几乎全部来自 GraphQL 层,而 run 模式的数据通道是启动时注入的静态快照加 socket 事件,两者的数据模型在同一Runner.vue中完成了隔离。
模式相关的顶层渲染差异
隔离并不止于 runner 容器。App.vue 顶部也基于isRunMode做了分支:run 模式下不会挂载CloudViewerAndProject与LoginConnectModals(它们涉及 Cypress Cloud 相关的 GraphQL 数据与登录弹窗),注释写着 "avoiding graphql in run mode"。也就是说,整个 App 树从根部就开始避免把 GraphQL 依赖拉进 run 模式。
从 spec 列表到一次运行:watchSpecs 机制
无论是哪种模式,选中/切换 spec 后都要通知底层运行器。两套 Container 都调用了 unifiedRunner.ts 暴露的useUnifiedRunner():其watchSpecs(specs)会以响应式方式监视route.query.file(用getPathForPlatform做平台路径归一化),当它变化时解析出活动 spec、按需设置测试过滤器(mode=debug时先通过TestsForRunmutation 取测试列表),最后写入specStore.setActiveSpec(...);若 URL 指向的 spec 不在列表中,则setActiveSpec(null),上层容器便会显示 "Error, no spec matched!"。
配置热更新:改了 cypress.config 就自动重跑
Runner.vue还演示了订阅(subscription)在 open 模式下的实际用途:监听Runner_ConfigChange。当用户修改cypress.config.js后,收到configChange.serveConfig的服务端配置,先更新window.__CYPRESS_CONFIG__,再通过eventManager.runSpec(isRerun)以新配置重跑当前 spec。实现中对"事件管理器尚未就绪"做了兜底——catch 后静默,等待 spec 即将执行时自然使用新配置。而specsChange订阅则在 spec 文件增删时实时刷新列表,无需整页刷新。
路由架构:几乎全部由文件结构自动生成
App 的路由"极少需要手工维护",因为其设计目标是:路由由src/pages下的页面级 Vue 组件文件结构自动生成,路由配置与组件"就近放置"(co-located)。README 之所以专门用一节讲清楚这套机制,是为了将来改动路由时"有据可依"。
核心链路如下:
- vite.config.mjs 中注册
Pages({ extensions: ['vue'] })与Layouts()(来自vite-plugin-vue-layouts),构建时扫描src/pages; - 生成结果以
virtual:generated-pages/virtual:generated-layouts这两个虚拟模块暴露; - router.ts 用标准 Vue Router API 装配:
import { createRouter as _createRouter, createWebHashHistory } from 'vue-router' import generatedRoutes from 'virtual:generated-pages' import { setupLayouts } from 'virtual:generated-layouts' export const createRouter = () => { const routes = setupLayouts(generatedRoutes) // ... const router = _createRouter({ history: createWebHashHistory(), routes, }) return router }几个值得注意的实现细节(均来自 router.ts 源码):
- 使用hash 模式(
createWebHashHistory()),适合被嵌入桌面 shell 的应用场景; - 通过
setupLayouts把布局(src/layouts/default.vue)自动包裹到各路由上; - 若某个生成路由的
meta.default为真,则自动补一条path: '/'到它的重定向,让首页直达"默认页"; - 额外注册了一条
/redirect路由,用于"从 App 外部跳转到指定页面并携带参数":它读取 query 中的name与 URL 编码后的 JSONparams,再重定向到对应命名路由。router.ts 的注释指出该用法对应服务端changeUrlToDebug这类"打开后直达某页"的场景(例如调试、通知跳转),参数会以 URL encoded JSON 形式出现; - 通过
installStudioExitNavigationGuard安装 Studio 退出守卫:存在未保存改动时拦截导航(配合useSpecDirtyDataStore()),避免用户在 Cypress Studio 编辑过程中意外离开丢失内容。
<route>块与extendRoute
传统上写进router.ts的路由配置,在这里直接写在页面组件顶部的<route>块里,例如Runner.vue底部:
<route> { name: 'SpecRunner', meta: { header: false, navBarExpandedAllowed: false } } </route>配置被"就近放在组件内"(co-located),这是整套路由设计最大的优点。注意<route>块默认按JSON5解析,因此某些需要函数作为值的 Vue Router 配置无法直接写在其中;README 提示:万一将来需要,可在 vite.config.mjs 添加 Pages 插件的地方通过extendRoute用常规 TypeScript 改写生成的 route 对象。pages/[...all].vue 就是<route>块的另一个实例:它通过meta把自身标记为 404/错误页(error: true、标题 "404"),模板中枚举其余路由生成"你似乎迷路了"的导航链接。
404 与路由兜底
src/pages/[...all].vue利用vite-plugin-pages的 rest 参数文件命名约定([...all])兜住一切未匹配路径。它用useRouter().getRoutes()过滤掉错误/特定布局的路由后,把可用路由渲染成链接列表,引导用户回到正确页面。
namevspath:何时用哪一个
由于路由名默认取自文件名,而路由名又会在 UI 中用作标题,README 总结了该仓库实际遵循的两条约定:
- 需要"面向用户的显示名",或打算在代码里稳定引用该路由时,用
<route>块里的name覆盖默认名称; - 推送带
params的路由对象时必须用name(见下文),否则通常直接用path。
带name+params的 push 示例(用于让某个"无法运行的 spec"页面展示不可运行状态):
router.push({ name: 'Specs', params: { unrunnable: 'path/to/unrunnableSpec.cy.ts', } })带path+query的 push 示例(用于选定要运行的 spec 文件):
router.push({ path: '/specs/runner', query: { file: 'path/of/spec/toRun.cy.ts' } })上述对象既可直接传给router.push,也可作为router-link的toprop。SpecRunnerContainerOpenMode.vue 中的实际代码印证了这一约定:当route.name === 'SpecRunner'且带filequery 却找不到活动 spec 时,它会router.push({ name: 'Specs', params: { unrunnable: queryFile } }),用 name+params 把"不可运行"这一瞬态状态传递出去。
paramsvsquery:状态应放哪里
params与query的取舍是这套代码库中容易混淆的点,README 给出了明确判断标准,也澄清了术语陷阱:
params用于"临时状态":从 App 其它地方跳转过来时应展示、但刷新页面或点击后退后就应消失的状态。典型例子:新建 spec 后通过 "View this Spec" 链接进入时,AUT 上会浮现一条提示横幅——它只在"刚刚创建完"这个狭窄场景有意义,因此用路由 param 承载,既能在需要时打开,又无需放进 store 再手动清理;query用于"需要跨刷新存活的状态":把值放进 URL query,刷新或复制粘贴链接后依然保留。这是给路由附加状态最常见的方式(例如Runner.vue中通过route.query.file选择当前 spec,刷新后依然能定位到同一文件)。
关于术语,README 特意强调:Vue Router 的params并不等于URL 里的 query 参数;params本可用于插值形成 URL 路径的动态段,只是本仓库尚未使用该特性。正因为params与path存在"插值"关联,Vue Router 才要求在to/push 中同时携带params时必须使用路由的name;若同时指定path与params,Vue 只会警告(而非报错),且params会被静默忽略——这是新手最容易踩的坑。
与旧的 webpack 模块共存:window.UnifiedRunner桥接层
App 全面拥抱 Vite,但仓库里仍有一批无法直接交给 Vite 的老模块——README 点名了@packages/reporter、@packages/driver、@packages/runner。它们或因循环依赖、或因没有兼容的 ESM 构建,无法在 Vite 的依赖预构建与 ESM 生态中顺利工作。这是 monorepo 技术栈迁移中最现实的约束。
解决方案是把旧代码用 webpack 打成一份 bundle,暴露到window.UnifiedRunner命名空间下,App 侧通过 injectBundle.ts 以动态注入的方式加载:
export const dfd = Promise.withResolvers<void>() export function injectBundle (namespace: string) { const script = document.createElement('script') script.src = `/${namespace}/runner/cypress_runner.js` script.type = 'text/javascript' const link = document.createElement('link') link.href = `/${namespace}/runner/cypress_runner.css` script.onload = () => { dfd.resolve() } document.head.appendChild(script) document.head.appendChild(link) }注入脚本加载完成时用dfd.resolve()通知等待方,保证后续逻辑在 runner bundle 就绪后才继续。
类型层面,index.d.ts 为window.UnifiedRunner声明了结构(因此代码中能获得类型提示与静态检查):它包含被 webpack 单独打包的 eventManager(getEventManager)、config、DOM/highlight/CypressJQuery、Reporter,以及React/ReactDOM 的引用。index.d.ts 的注释解释了后者为何存在:Reporter 与 Driver 用的是 React,若 App 侧再引入 React 会与 Vue 产生冲突(React 的 JSX 类型是"环境级"的,无法安全共处),所以干脆从 webpack bundle 中取得"唯一的一份 React",避免出现两份 React 副本引发的运行时混乱。
初始化与生命周期:useUnifiedRunner
unifiedRunner.ts 是 App 侧对接桥接层的统一入口:mount 时调用UnifiedRunnerAPI.initialize()、卸载时调用UnifiedRunnerAPI.teardown(),并对外暴露只读的initialized标志与watchSpecs。run/open 两个 Container 都复用它,从而把"加载、销毁统一 runner"的生命周期收敛到一处。
集成守则
README 给出了明确的边界纪律,这也是维护 App 时最重要的工程约束:
尽量不要从本包直接 import 那些基于旧 webpack 的模块;若需要消费其中的代码,应把它们加入 webpack bundle 根(README 指定的 bundle root 为
@packages/runner下的main.tsx),挂到window.UnifiedRunner上,再通过它消费;同时尽量在index.d.ts中补充对应类型。
继续深入
- App 大量 UI 复用了共享设计系统,组件级约定与通用工具见 frontend-shared 包说明;
- 双模式分派、GraphQL 订阅与配置热重跑的完整实现,可通读 Runner.vue;
- 路由装配、
/redirect与 Studio 导航守卫见 router.ts; - 顶层模式分支(run 模式剔除 Cloud 组件)见 App.vue;
- 内部开发用 Vite 选项见 CONTRIBUTING.md 的 Internal Vite Options 小节;
- 修改该包自身代码时的构建与类型检查命令(
vite build、vue-tsc --noEmit、cypress:run:e2e等)集中在 package.json 的 scripts 中。
【免费下载链接】cypressFast, easy and reliable testing for anything that runs in a browser.项目地址: https://gitcode.com/GitHub_Trending/cy/cypress
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考