--- description: 为 Label Studio 编写和更新 Cypress 集成测试 globs: alwaysApply: false --- ## Label Studio 的 Cypress 测试生成规则 ### 项目结构和组织 **测试文件结构:** - 测试应放置在 `web/libs/editor/tests/integration/e2e/` 中,采用语义化文件夹组织 - 遵循现有文件夹结构:`core/`、`image_segmentation/`、`control_tags/`、`audio/`、`video/`、`timeseries/`、`relations/`、`outliner/`、`bulk_mode/`、`config/`、`drafts/`、`linking_modes/`、`ner/`、`sync/`、`view_all/` - 测试文件应以 `.cy.ts` 扩展名结尾 - 测试数据应放置在 `web/libs/editor/tests/integration/data/` 中,遵循相同的文件夹结构 **文件命名约定:** - 使用反映被测试功能的描述性名称 - 使用 kebab-case 命名文件(例如 `audio-regions.cy.ts`、`image-segmentation.cy.ts`) - 将相关测试分组到逻辑文件夹中 ### 导入标准 **必需的导入:** 始终从集中的辅助库导入辅助函数: ```typescript import { LabelStudio, ImageView, Sidebar, Labels, Hotkeys } from "@humansignal/frontend-test/helpers/LSF"; ``` **测试数据导入:** 使用相对路径从数据文件夹导入测试数据: ```typescript import { configName, dataName, resultName } from "../../data/folder_name/file_name"; ``` **可用的辅助函数:** - `LabelStudio` - 核心初始化和控制 - `ImageView` - 图像交互和绘制 - `VideoView` - 视频播放和交互 - `AudioView` - 音频播放和区域 - `Sidebar` - 大纲视图和区域管理 - `Labels` - 标签选择和管理 - `Hotkeys` - 跨平台键盘快捷键(Mac/PC 兼容性) - `Taxonomy`、`Choices`、`DateTime`、`Number`、`Rating`、`Textarea` - 控制标签辅助函数 - `Relations` - 关系管理 - `ToolBar` - 工具栏交互 - `Modals` - 模态对话框处理 - `Tooltip` - 工具提示验证 ### 测试结构标准 **基本测试结构:** ```typescript describe("功能名称 - 特定区域", () => { it("应该执行特定操作", () => { // 测试实现 }); }); ``` **嵌套 Describes:** 使用嵌套的 describe 块进行逻辑分组: ```typescript describe("图像分割", () => { describe("矩形工具", () => { it("应该绘制矩形", () => { // 测试实现 }); }); }); ``` ### LabelStudio 初始化模式 **简单初始化:** ```typescript LabelStudio.init({ config: configString, task: { id: 1, annotations: [{ id: 1001, result: [] }], predictions: [], data: { image: "url" }, }, }); ``` **流式 API 初始化(推荐):** ```typescript LabelStudio.params() .config(configString) .data(dataObject) .withResult(expectedResult) .init(); ``` **带附加参数:** ```typescript LabelStudio.params() .config(config) .data(data) .withResult([]) .withInterface("panel") .withEventListener("eventName", handlerFunction) .withParam("customParam", value) .init(); ``` ### 必需的测试准备步骤 **始终包含:** 1. LabelStudio 初始化 2. 等待对象就绪:`LabelStudio.waitForObjectsReady();` 3. (可选,通常 waitForObjectsReady 就足够了)等待媒体加载(用于图像/视频/音频):`ImageView.waitForImage();` 4. 初始状态验证:`Sidebar.hasNoRegions();` 5. (可选,如果可能)操作后的某些状态验证,例如:`Sidebar.hasRegions(count);` ### 交互模式 **图像交互:** ```typescript // 等待图像加载 ImageView.waitForImage(); // 选择工具 ImageView.selectRectangleToolByButton(); ImageView.selectPolygonToolByButton(); // 绘制操作 ImageView.drawRect(x, y, width, height); ImageView.drawRectRelative(0.1, 0.1, 0.4, 0.8); // 推荐 // 点击交互 ImageView.clickAt(x, y); ImageView.clickAtRelative(0.5, 0.5); // 推荐 // 截图比较 ImageView.capture("screenshot_name"); ImageView.canvasShouldChange("screenshot_name", threshold); ``` **标签管理:** ```typescript // 绘制前选择标签 Labels.select("标签名称"); // 验证标签选择 Labels.isSelected("标签名称"); ``` **侧边栏操作:** ```typescript // 区域验证 Sidebar.hasRegions(count); Sidebar.hasNoRegions(); Sidebar.hasSelectedRegions(count); // 区域操作 Sidebar.toggleRegionVisibility(index); Sidebar.toggleRegionSelection(index); ``` ### 断言模式 **标准 Cypress 断言:** ```typescript cy.get(selector).should("be.visible"); cy.get(selector).should("have.text", "期望文本"); cy.get(selector).should("have.class", "class-name"); ``` **自定义辅助断言:** ```typescript Sidebar.hasRegions(expectedCount); Sidebar.hasSelectedRegions(expectedCount); ImageView.canvasShouldChange("screenshot", threshold); ``` **访问 Window 对象:** ```typescript cy.window().then((win) => { expect(win.Htx.annotationStore.selected.names.get("image")).to.exist; }); ``` ### 测试数据结构 **配置格式:** ```typescript export const configName = ` `; ``` **数据格式:** ```typescript export const dataName = { image: "https://htx-pub.s3.us-east-1.amazonaws.com/examples/images/example.jpg", text: "用于处理的示例文本", }; ``` **结果格式:** ```typescript export const resultName = [ { id: "unique_id", type: "rectanglelabels", value: { x: 10.5, y: 15.2, width: 25.8, height: 30.1, rectanglelabels: ["Planet"] }, origin: "manual", to_name: "img", from_name: "tag", } ]; ``` ### 功能标志管理 **设置功能标志:** ```typescript // 导航前 LabelStudio.setFeatureFlagsOnPageLoad({ featureName: true, }); // 导航后(通常不需要) LabelStudio.setFeatureFlags({ featureName: true, }); // 验证 LabelStudio.featureFlag("featureName").should("be.true"); ``` ### 错误处理和重试策略 **对于不稳定的测试:** ```typescript const suiteConfig = { retries: { runMode: 3, openMode: 0, }, }; describe("测试套件名称", suiteConfig, () => { // 这里是测试 }); ``` ### 日志和调试 **始终包含描述性日志:** ```typescript cy.log("使用图像分割配置初始化 LSF"); cy.log("在相对位置绘制矩形"); cy.log("验证区域已创建"); ``` ### 性能考虑 **使用相对坐标:** - 优先使用 `*Relative()` 方法而非绝对坐标 - 使用 `ImageView.drawRectRelative()` 而非 `ImageView.drawRect()` **高效等待:** - 使用 `LabelStudio.waitForObjectsReady()` 进行初始化检查 - 使用 UI 状态检查确保操作已完成且 UI 已准备好进行下一个操作 - **避免使用 `cy.wait(milliseconds)` - 改用基于事件或基于状态的等待** **等待最佳实践:** ```typescript // ❌ 错误 - 任意时间等待不可靠且缓慢 cy.wait(500); // 不知道操作是否真正完成 cy.wait(300); // 在慢速机器上可能太短,在快速机器上太长 // ✅ 正确 - 等待特定的 UI 状态变化 Sidebar.hasRegions(1); // 等待区域出现 Labels.isSelected("标签名称"); // 等待标签选择 cy.get(".loading-spinner").should("not.exist"); // 等待加载完成 // ✅ 正确 - 等待元素状态变化 cy.get("[data-testid='submit-button']").should("be.enabled"); cy.get(".htx-timeseries-channel svg").should("be.visible"); cy.get(".region").should("have.class", "selected"); // ✅ 正确 - 等待网络请求(必要时) cy.intercept("POST", "/api/annotations").as("saveAnnotation"); cy.get("[data-testid='save-button']").click(); cy.wait("@saveAnnotation"); // 等待特定的网络调用 // ✅ 正确 - 等待动画完成 cy.get(".modal").should("have.class", "fade-in-complete"); cy.get(".tooltip").should("be.visible").and("not.have.class", "animating"); ``` **何时基于时间的等待可能可以接受:** - 非常短的等待(50ms 或更少)用于 UI 防抖 - 单个动画帧等待(16-32ms)用于快速重渲染 - 当没有完成事件可用时等待 CSS 动画 - 解决已知的浏览器时序问题(记录为临时修复) **使用常量而非魔法数字:** ```typescript // 导入时序常量 import { SINGLE_FRAME_TIMEOUT, TWO_FRAMES_TIMEOUT } from "../utils/constants"; // ✅ 正确 - 使用常量表示帧时序 cy.wait(SINGLE_FRAME_TIMEOUT); // 等待单个动画帧(60fps) cy.wait(TWO_FRAMES_TIMEOUT); // 等待 1-2 个动画帧用于快速但更复杂的重渲染 // ❌ 错误 - 魔法数字不清楚 cy.wait(16); // 16 是什么?为什么是 16? cy.wait(32); // 32 是什么?为什么是 32? // ✅ 正确 - 使用意图明确的常量 ImageView.clickAt(100, 100); cy.wait(SINGLE_FRAME_TIMEOUT); // 允许画布完成重绘 // 对于其他时序需求可接受,需要文档说明 cy.wait(50); // 允许防抖输入稳定 cy.wait(100); // TODO: 替换为适当的动画完成检查 ``` **截图比较:** ```typescript // 操作前捕获 ImageView.capture("before_action"); // 执行操作 Labels.select("Label"); ImageView.drawRectRelative(0.2, 0.2, 0.6, 0.6); // 验证视觉变化 ImageView.canvasShouldChange("before_action", 0.1); ``` - 仅在必要时使用,因为它们可能会减慢测试速度 ### 常见测试模式 **基本绘制测试:** ```typescript it("应该绘制矩形区域", () => { LabelStudio.params() .config(imageConfig) .data(imageData) .withResult([]) .init(); LabelStudio.waitForObjectsReady(); ImageView.waitForImage(); Sidebar.hasNoRegions(); Labels.select("标签名称"); ImageView.drawRectRelative(0.1, 0.1, 0.4, 0.8); Sidebar.hasRegions(1); }); ``` **控制标签交互测试:** ```typescript it("应该选择分类选项", () => { LabelStudio.params() .config(taxonomyConfig) .data(simpleData) .withResult([]) .init(); Taxonomy.open(); Taxonomy.findItem("选项 1").click(); Taxonomy.hasSelected("选项 1"); }); ``` **状态验证测试:** ```typescript it("应该在交互后保持状态", () => { LabelStudio.params() .config(config) .data(data) .withResult(existingResult) .init(); LabelStudio.waitForObjectsReady(); Sidebar.hasRegions(1); // 执行操作 cy.contains("button", "更新").click(); // 验证状态 LabelStudio.serialize().then((results) => { expect(results).to.have.length(1); expect(results[0].value).to.deep.equal(expectedValue); }); }); ``` ### 无障碍性和用户体验 **包含适当的 ARIA 标签:** ```typescript cy.get('[aria-label="rectangle-tool"]').click(); ``` **测试键盘交互:** ```typescript // 始终优先使用 Hotkeys 辅助函数以实现跨平台兼容性 Hotkeys.undo(); // 自动处理 Ctrl+Z/Cmd+Z Hotkeys.redo(); // 自动处理 Ctrl+Shift+Z/Cmd+Shift+Z Hotkeys.deleteRegion(); // Backspace Hotkeys.deleteAllRegions(); // Ctrl+Backspace/Cmd+Backspace Hotkeys.unselectAllRegions(); // Escape // 其他可用的特定辅助函数 ImageView.zoomIn(); // 放大 Labels.selectWithHotkey("1"); // 通过快捷键选择标签 // 直接 Cypress 命令(在大多数情况下避免 - 仅作为最后手段使用) cy.get("body").type("{esc}"); // ❌ 优先使用 Hotkeys.unselectAllRegions() cy.get("body").type("{ctrl}{+}"); // ❌ 不跨平台,优先使用辅助函数 cy.get("body").type("{cmd}{+}"); // ❌ 平台特定,优先使用辅助函数 ``` **重要:** 在大多数情况下,你应该**避免**直接使用 Cypress 键盘命令(`cy.get("body").type()`)。改用辅助函数,因为: - `Hotkeys` 辅助函数自动处理 Mac(Cmd)与 PC(Ctrl)的差异 - 辅助函数提供更好的抽象,更易于维护 - 辅助函数不太容易出现跨平台问题 - 仅在没有辅助函数存在且需要非常特定的键盘交互时使用直接命令 ### 创建新辅助函数 **辅助函数架构模式:** Label Studio 使用两种主要模式创建辅助函数: 1. **静态对象模式**(用于单例组件): ```typescript export const ComponentName = { get root() { return cy.get(".component-selector"); }, get subElement() { return this.root.find(".sub-element"); }, performAction() { cy.log("在 ComponentName 上执行操作"); this.root.click(); }, assertState(expectedValue: string) { this.subElement.should("contain.text", expectedValue); } }; ``` 2. **基于类的模式**(用于可参数化的组件): ```typescript class ComponentHelper { private get _baseRootSelector() { return ".component-base"; } private _rootSelector: string; constructor(rootSelector: string) { this._rootSelector = rootSelector.replace(/^\&/, this._baseRootSelector); } get root() { return cy.get(this._rootSelector); } performAction() { cy.log(`在 ${this._rootSelector} 上执行操作`); this.root.click(); } } // 导出单例和工厂 const ComponentName = new ComponentHelper("&:eq(0)"); const useComponentName = (rootSelector: string) => { return new ComponentHelper(rootSelector); }; export { ComponentName, useComponentName }; ``` **辅助函数创建规则:** 1. **文件放置:** - 将 UI 辅助函数放在 `web/libs/frontend-test/src/helpers/LSF/` - 将工具函数放在 `web/libs/frontend-test/src/helpers/utils/` - 文件名使用 PascalCase(例如 `MyComponent.ts`) 2. **工具 vs 辅助函数决策:** - **提取到 `utils/`** 如果函数: - 不使用 Cypress 命令(`cy.*`) - 不特定于任何 UI 组件 - 执行通用计算、解析或数据操作 - 可以独立进行单元测试 - **保留在辅助类中** 如果函数: - 使用 Cypress 命令进行 UI 交互 - 特定于 UI 组件 - 管理元素状态或用户交互 ```typescript // ✅ 正确 - 提取到 utils/SVGTransformUtils.ts export class SVGTransformUtils { static parseTransformString(transformStr: string): DOMMatrix { // 纯工具 - 无 Cypress,无 UI 依赖 const matrix = new DOMMatrix(); // ... 实现 return matrix; } } // ❌ 错误 - 不要将工具放在 UI 辅助函数中 class TimeSeriesHelper { // 这应该在工具中 private parseTransformString(transformStr: string): DOMMatrix { ... } } ``` 3. **命名约定:** - 使用与 UI 组件匹配的描述性名称 - 可参数化的辅助函数前缀使用 `use`(例如 `useChoices`) - 使用一致的方法命名: - `get propertyName()` 用于元素获取器 - `performAction()` 用于操作 - `assertState()` 用于断言 - `hasState()` 用于布尔检查 4. **辅助函数内容指南:** - **应该包含**:可复用的 UI 交互、元素获取器、简单状态验证 - **不应该包含**:复杂的多步骤场景、完整的测试工作流、一次性业务逻辑 5. **必需元素:** - 始终包含 `get root()` 作为主元素获取器 - 使用语义化子元素获取器(例如 `get submitButton()`) - 使用 `cy.log()` 包含描述性日志 6. **选择器最佳实践:** - 尽可能使用 ARIA 属性而非其他方法 7. **方法类型:** - **获取器**:返回 Cypress 元素以供进一步链接 - **操作**:执行用户交互(点击、输入等) - **断言**:使用 `.should()` 验证预期状态 - **简单工具**:特定 UI 操作的辅助方法(不是通用工具) **完整辅助函数示例:** ```typescript import TriggerOptions = Cypress.TriggerOptions; class NewComponentHelper { private get _baseRootSelector() { return ".lsf-new-component"; } private _rootSelector: string; constructor(rootSelector: string) { this._rootSelector = rootSelector.replace(/^\&/, this._baseRootSelector); } get root() { return cy.get(this._rootSelector); } get input() { return this.root.find('input[type="text"]'); } get submitButton() { return this.root.find('[aria-label="submit"]'); } get items() { return this.root.find('.item'); } fillInput(text: string) { cy.log(`填充输入:${text}`); this.input.clear().type(text); } selectItem(index: number) { cy.log(`选择索引为 ${index} 的项目`); this.items.eq(index).click(); } findItem(text: string) { return this.items.contains(text); } submit() { cy.log("提交表单"); this.submitButton.click(); } hasItem(text: string) { this.findItem(text).should("be.visible"); } hasNoItems() { this.items.should("not.exist"); } hasSelectedItem(text: string) { this.findItem(text).should("have.class", "selected"); } } // 导出模式 const NewComponent = new NewComponentHelper("&:eq(0)"); const useNewComponent = (rootSelector: string) => { return new NewComponentHelper(rootSelector); }; export { NewComponent, useNewComponent }; ``` **将辅助函数添加到索引:** 创建辅助函数后,将其添加到索引文件: ```typescript // 在 web/libs/frontend-test/src/helpers/LSF/index.ts 中 export { NewComponent, useNewComponent } from "./NewComponent"; ``` ### 最佳实践总结 1. **始终使用语义化文件夹组织** 2. **从集中位置导入辅助函数** 3. **使用流式 API 进行 LabelStudio 初始化** 4. **包含适当的等待机制** 5. **使用相对坐标进行响应式测试** 6. **添加描述性日志** 7. **单独构建测试数据** 8. **包含正面和负面测试用例** 9. **在可用时使用辅助方法而非原始 Cypress 命令** 10. **避免直接使用 Cypress 键盘命令 - 使用 `Hotkeys` 辅助函数实现跨平台兼容性** 11. **遵循现有命名约定** 12. **为常见 UI 模式创建可复用的辅助函数** 13. **对可参数化的组件使用基于类的模式** 14. **在辅助函数中包含全面的错误处理** 15. **使用 JSDoc 注释记录复杂的辅助方法** 16. **在生产测试中使用辅助函数之前先测试它们** 17. **当工具函数不使用 Cypress 时,将其提取到单独的 utils 文件** 18. **将复杂的测试场景保留在测试文件中,而不是辅助函数中 - 辅助函数应该简单且可复用** 19. **避免使用 cy.wait(time) - 使用基于事件或基于状态的等待以获得更可靠的测试**