diff --git a/backend/Dockerfile b/backend/Dockerfile index 17dc6ccb..be052c6e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -83,6 +83,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ golang.org/x/sync@v0.22.0 \ golang.org/x/text@v0.41.0 \ google.golang.org/grpc@v1.83.2; \ + go mod download all; \ go mod vendor; \ make \ VERSION=v2.3.3 \ diff --git a/docs/NAVIGATION_GROUPING.md b/docs/NAVIGATION_GROUPING.md new file mode 100644 index 00000000..a8227559 --- /dev/null +++ b/docs/NAVIGATION_GROUPING.md @@ -0,0 +1,132 @@ +# 二级导航实施与验收 + +依据用户提供的《FlowTest_二级菜单改造方案》和 HTML 交互原型,在 `d638257` 主干基线上实施。原型仅用于导航交互与视觉参考,生产页仍使用现有业务组件。 + +## 导航树与旧→新映射 + +26 个原 section 和页面名称全部保留,管理员看到全部入口,普通用户看到 24 个入口。质量总览与六个分类组成 7 个一级入口;分类采用 Ant Design `children` 子菜单,最多展开一个,不增加三级菜单或新页面。 + +项目页路径继续为 `/projects/:projectId/:section`。项目选择与历史全局业务路径的重定向继续沿用现有规则。下表全局入口使用固定路径。 + +| 原 section | 原页面名称 | 新分类 | 路径 | +| ----------------- | ------------ | -------------------------- | ----------------------------------------------- | +| dashboard | 质量总览 | 独立一级入口 | /projects/:projectId/dashboard(或 /dashboard) | +| apis | 接口管理 | 项目与接口 | /projects/:projectId/apis | +| services | 服务目录 | 项目与接口 | /projects/:projectId/services | +| request-targets | 请求目标 | 项目与接口 | /projects/:projectId/request-targets | +| protocols | 多协议工作台 | 项目与接口 | /projects/:projectId/protocols | +| settings | 项目管理 | 项目与接口 | /projects/:projectId/settings | +| workflows | 流程编排 | 测试设计 | /projects/:projectId/workflows | +| assets | 测试资产 | 测试设计 | /projects/:projectId/assets | +| data | 数据与 Mock | 测试设计 | /projects/:projectId/data | +| contracts | 契约中心 | 测试设计 | /projects/:projectId/contracts | +| test-engineering | 测试工程 | 测试设计 | /projects/:projectId/test-engineering | +| tasks | 任务执行 | 执行与环境 | /projects/:projectId/tasks | +| environments | 环境实验室 | 执行与环境 | /projects/:projectId/environments | +| performance | 性能实验室 | 执行与环境 | /projects/:projectId/performance | +| reports | 测试报告 | 质量分析 | /projects/:projectId/reports | +| impact | 影响分析 | 质量分析 | /projects/:projectId/impact | +| change-regression | 变更回归 | 质量分析 | /projects/:projectId/change-regression | +| quality | 质量中心 | 质量分析 | /projects/:projectId/quality | +| release | 发布门禁 | 质量分析 | /projects/:projectId/release | +| ai | AI 助手 | AI 与集成 | /projects/:projectId/ai | +| contexts | 上下文检查器 | AI 与集成 | /projects/:projectId/contexts | +| ai-changes | AI 变更集 | AI 与集成 | /projects/:projectId/ai-changes | +| mcp-changes | MCP 变更集 | AI 与集成 | /projects/:projectId/mcp-changes | +| organization | 组织治理 | 系统管理(全局) | /organization | +| fabric | 分布式执行面 | 系统管理(全局、仅管理员) | /execution-fabric | +| platform | 平台管理 | 系统管理(全局、仅管理员) | /platform | + +菜单先过滤叶子权限,再构建分类。组织治理继续向登录用户开放,系统管理分类不会因包含管理员页面而整体隐藏。菜单可见性不替代后端权限检查。 + +## 交互与状态边界 + +- 分类 key 使用 `nav:*`,与业务 section 分离。点击分类只控制展开,不触发 Link、WorkspaceTabs 或业务导航。直接打开/刷新深链、切换 Tab、前进后退和项目切换时,由 pathname 同步分类;query/hash 变化不会重置分类。 +- 用户主动关闭当前分类后,普通 render 不会立即重新展开。总览没有已存偏好时不展开分类;初次加载恢复合法已存分类;路由优先于过期缓存。 +- 展开 224px、收起 72px。收起时使用可点击及键盘操作的子菜单浮层,popup openKeys 与展开状态分离;恢复展开时打开当前页面分类。品牌和底部按钮固定在侧栏内部,菜单独立滚动。 +- `<992px` 使用 Drawer;选择叶子、Escape 或遮罩关闭后恢复导航按钮焦点。992–1279px 无偏好时默认收起,≥1280px 默认展开。响应式变化不回写用户桌面折叠偏好。 +- 偏好存于 `flowtest:navigation:v1:${userId}`,只接受布尔折叠值及合法分类 key。损坏 JSON、未知 key、浏览器禁止读写均安全降级。角色变化重新同步路由并过滤叶子;用户切换重新初始化侧栏。 +- 面包屑:项目页 `FlowTest → 项目 → 分类 → 页面`;全局页 `FlowTest → 系统管理 → 页面`;总览不伪造分类。分类文字不导航。 +- 全局 organization/platform/fabric 页选择项目,直接进入新项目 dashboard。业务页选择项目保留 section;清除项目回到 `/dashboard`。`pathFor` 为全局管理页面始终生成全局路径。 + +DraftSessionProvider 的用户 key、ApplicationRoutes 的项目/全局 key、WorkspaceTabs 的 userId+projectId 存储 key 均保持不变。侧栏状态不进入页面 key;未保存编辑不会因为展开/收起导航而卸载。全局搜索仍搜索项目与资产,不宣称支持菜单搜索。没有后端业务、数据库、脱敏策略或业务编辑器改造。 + +## 验收方式 + +表征测试先验证原导航链接与管理员/普通用户入口数量,再抽取共享配置。实现后增加导航状态、偏好、角色切换、项目选择边界单测;旧端到端场景通过真实展开分类后选择叶子,未强制保持隐藏子项可见。 + +| 验收编号 | 证据 | +| ------------------ | -------------------------------------------------------------------------------------------- | +| N01–N02、N04 | navigation-config / App 表征测试:26/24 项、原 label/href、无重复 | +| N03、N07、N12、N14 | ShellSidebar / navigation-preferences:URL/query/hash、草稿、render、存储损坏/禁止与角色变化 | +| N05–N06、N08 | grouped-navigation 浏览器:深链刷新、Tab/后退、浮层 Enter/选择/Escape | +| N09–N11 | ProjectProvider / App:业务 section 保留、项目边界、三类全局页选项目、无项目创建引导 | +| N13 | 既有工作流 focus/proposal 回归及导航 query 保留测试;不改变提案治理 | +| N15–N16 | grouped-navigation:1366×768、1440×900、1920×1080、1100 默认收起、390 抽屉与焦点恢复 | + +本地使用独立 `flowtest-grouped-navigation` Compose 项目、独立数据卷和 13020/18020 端口,加载本分支前端构建与后端代码,未修改原有运行栈。浏览器使用 Chromium;截图数据由测试生成。 + +### 首轮本地全量检查 + +- 后端 `uv run --no-sync ruff format --check .`:564 个文件符合格式。 +- 后端 `uv run --no-sync ruff check .`:通过。 +- 后端 `uv run --no-sync mypy app`:396 个源文件通过。 +- 后端 `uv run --no-sync pytest`:1449 passed、7 skipped,覆盖率 90.68%。使用已有依赖环境并指定 `PYTHONPATH=.` 加载本分支代码。 +- 前端 `pnpm format:check`、`pnpm lint`、`pnpm build`:通过。 +- 前端 `pnpm test:coverage`:88 个测试文件、420 个测试通过;语句 85.70%、分支 80.78%、函数 84.89%、行 87.93%。 + +- `FLOWTEST_E2E_BASE_URL=http://localhost:13020 pnpm exec playwright test e2e/grouped-navigation.spec.ts --project=chromium`:最终 7 个专项场景及认证 Setup 全部通过(8 passed),其中窄屏额外验证 320px 顶栏控件可达;前期另运行 `--grep 'menu scroll'` 验证 420px 高度下菜单独立滚动、品牌/底部按钮不移动且工作区不滚动(专项及 Setup 2 passed)。涵盖未保存节点输入、编辑器 DOM 实例保留和折叠总览图标的键盘导航。 + +### 实际页面截图 + +截图使用测试生成的项目/工作流,不包含公司 Swagger 或真实业务数据。原型的占位内容未进入产品。 + +![1366×768 桌面导航与未应用节点输入](navigation/navigation-1366.png) + +![1440×900 桌面导航与现有工作流编辑器](navigation/navigation-1440.png) + +![1920×1080 桌面导航](navigation/navigation-1920.png) + +![390×844 手机抽屉](navigation/navigation-mobile.png) + +### 工作流交互回归 + +`FLOWTEST_E2E_BASE_URL=http://localhost:13020 pnpm exec playwright test e2e/grouped-navigation.spec.ts e2e/workflow-editor-layout.spec.ts e2e/workflow-editor-interactions.spec.ts --project=chromium --grep-invert 'FORM/LIFE|ACC/FORM/PAL'`:16 passed,包括认证 Setup、6 个导航专项、6 个编辑交互和3 个工作区尺寸检查。路由往返后非法 JSON 请求会话保留、输入焦点保护、拖动与撤销、连线操作、全屏请求还原以及三个尺寸的画布几何检查均通过。 + +首轮本地命令没有启动执行 worker,因此没有重复两项发布/执行场景。后续视觉验收单独启动隔离 Celery worker 和 mock-target,覆盖真实发布、执行与历史快照;完整业务场景仍由 GitHub Compose CI 验证。 + +### 审阅与构建补充 + +首轮全量统计对应初始菜单实现;复审后 9 个相关测试文件 58 个测试通过,侧栏模式隔离后 7 个组件测试与 8 个专项浏览器场景通过。最终提交的完整门禁记录见 [PR #105](https://github.com/a3384379/FlowTest/pull/105)。 + +- 总览首次加载恢复合法的展开分类偏好,无偏好仍默认收起。具体业务页路由优先于缓存;从其他页面进入总览仍按路由规则关闭分类。补充了刷新与收起/展开恢复单测。 +- Menu 两种模式使用各自实例,隔离 Ant Design 模式切换后的迟到关闭事件。只有导航 Menu 随模式切换,业务页的 React key 和实例保持不变;多尺寸未应用输入/DOM 实例验收继续通过。 +- 首轮远程 Compose 在 containerd vendoring 阶段缺少 OpenTelemetry `go.sum` 校验条目,尚未执行浏览器验收。`backend/Dockerfile` 增加 `go mod download all`,补齐既有模块图的校验信息,原 containerd commit、Go 与 gRPC 版本声明保持不变。 +- 定向 containerd 构建通过,三个 binary 都通过原 `go version -m` gRPC v1.83.2 校验。本地 Docker Hub 元数据请求未完成,因此验证使用同一 SHA256 digest 的本地已下载 Go 镜像临时别名;提交的 Dockerfile 仍使用原 pinned digest。验证命令 `docker build -f /private/tmp/flowtest-nav-build-definition/Dockerfile --target containerd-builder -t flowtest-nav-containerd-builder backend`。 + +### 最终视觉回归补充 + +远程浏览器验收发现 V1 主路径还有三处旧的平铺菜单点击,已统一改用可见父分类/叶菜单 helper。原 Linux 截图包含旧侧栏,因此更新本次菜单外观涉及的基线,保留原 `maxDiffPixelRatio: 0.003`、动态字段 mask、画布几何和横向溢出断言。全屏配置、添加节点和专注模式没有菜单变化,其基线保留。 + +使用 Playwright v1.62.1 Noble Linux Chromium、localhost 入口和独立 Compose 项目运行 `pnpm exec playwright test e2e/workflow-editor-visual.spec.ts --project=chromium --update-snapshots`,随后取消更新模式再跑同一命令。每轮包含认证 Setup 与三种分辨率,各分辨率覆盖九种真实状态。生成后人工查看侧栏、画布、运行与历史快照;临时数据库、worker、截图 runner 与原本地部署隔离。GitHub Ubuntu runner 的最终比较仍以 PR 最新提交门禁为准。 + +最终本地前端全量为 88 个测试文件、421 个测试通过,分支覆盖率 80.74%;后端 1449 passed、7 skipped。远程前端首轮出现原有多协议测试销毁后的 React 异步异常,421 个断言均通过;同一提交重跑正常成功,本地该页面 17 个测试亦通过。没有忽略未处理异常或放宽测试配置。 + +## 修改文件 + +- `frontend/src/App.tsx`、`App.test.tsx`:接入侧栏与分类面包屑,更新原导航表征测试。 +- `frontend/src/features/navigation/navigation-config.tsx`、`navigation-config.test.tsx`:26 项共享标签、6 类、过滤与原路径。 +- `frontend/src/features/navigation/ShellSidebar.tsx`、`ShellSidebar.test.tsx`:桌面折叠/浮层、移动 Drawer、行/图标/键盘导航。 +- `frontend/src/features/navigation/use-navigation-state.ts`:路由同步、单分类、响应式与独立 popup 状态。 +- `frontend/src/features/navigation/navigation-preferences.ts`、`navigation-preferences.test.ts`:用户隔离及安全降级。 +- `frontend/src/features/projects/project-routing.ts`、`ProjectProvider.tsx`、`ProjectProvider.test.tsx`:全局页选择项目和 `pathFor` 边界。 +- `frontend/src/styles.css`:固定品牌/底栏、独立滚动、窄屏顶栏与 Drawer 配色。 +- `frontend/e2e/grouped-navigation.spec.ts`、`support/navigation.ts`:真实父/叶菜单路径与多尺寸验收。 +- `frontend/e2e/s14-management-workbench.spec.ts`、`s15-test-assets.spec.ts`、`s17-data-mock.spec.ts`、`s18-contract-automation.spec.ts`、`s19-quality-scale.spec.ts`、`s21-ai-review.spec.ts`、`s22-capability-sdk.spec.ts`、`s29-execution-fabric.spec.ts`、`s30-failure-intelligence.spec.ts`、`s31-release-gate.spec.ts`、`v1-acceptance.spec.ts`、`workflow-editor-interactions.spec.ts`:按可见父分类选择原页面。 +- `frontend/e2e/workflow-editor-visual.spec.ts-snapshots/*-chromium-linux.png`:人工核对后的菜单外观基线,保留工作流像素门禁。 +- `backend/Dockerfile`:必要的 containerd 构建校验信息补齐,独立构建修复提交。 +- 本文与 `docs/navigation/navigation-{1366,1440,1920,mobile}.png`:旧→新映射与实际页面证据。 + +## 已知边界 + +本次支持键盘与 Chromium 验收,不代表所有浏览器或真实安装环境已验收。编辑器窄屏内部布局继续遵循原阈值,不新增移动端业务编辑模式。菜单偏好存储不可用时只在内存中保持当前会话;无偏好下按照屏宽恢复。权限、项目草稿与提案的 Review/Apply/Publish/Execute 生命周期继续由现有业务规则控制。 diff --git a/docs/navigation/navigation-1366.png b/docs/navigation/navigation-1366.png new file mode 100644 index 00000000..6303e1d6 Binary files /dev/null and b/docs/navigation/navigation-1366.png differ diff --git a/docs/navigation/navigation-1440.png b/docs/navigation/navigation-1440.png new file mode 100644 index 00000000..5c95deff Binary files /dev/null and b/docs/navigation/navigation-1440.png differ diff --git a/docs/navigation/navigation-1920.png b/docs/navigation/navigation-1920.png new file mode 100644 index 00000000..c04400d1 Binary files /dev/null and b/docs/navigation/navigation-1920.png differ diff --git a/docs/navigation/navigation-mobile.png b/docs/navigation/navigation-mobile.png new file mode 100644 index 00000000..e7c2e22e Binary files /dev/null and b/docs/navigation/navigation-mobile.png differ diff --git a/frontend/e2e/grouped-navigation.spec.ts b/frontend/e2e/grouped-navigation.spec.ts new file mode 100644 index 00000000..a6a337de --- /dev/null +++ b/frontend/e2e/grouped-navigation.spec.ts @@ -0,0 +1,190 @@ +import { expect, test } from '@playwright/test' + +import { navigateMenu } from './support/navigation' +import { seedEditor } from './support/workflow-editor' + +for (const viewport of [ + { width: 1366, height: 768 }, + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, +]) { + test(`N03/N05/N07/N14/N15/N16:${viewport.width}×${viewport.height} navigation preserves editor`, async ({ + page, + }, testInfo) => { + await page.setViewportSize(viewport) + const fixture = await seedEditor(page) + const deepLink = `${fixture.root}/workflows?focus=${fixture.workflow.id}#navigation` + await page.goto(deepLink) + await expect(page.locator('.react-flow__node')).toHaveCount(3) + await page.reload() + await expect(page.locator('.react-flow__node')).toHaveCount(3) + const navigation = page.getByRole('navigation', { name: '功能导航' }) + const parent = navigation.getByRole('menuitem', { name: '测试设计', exact: true }) + await expect(parent).toHaveAttribute('aria-expanded', 'true') + await expect(navigation.getByRole('link', { name: '流程编排', exact: true })).toHaveAttribute( + 'aria-current', + 'page', + ) + await expect(page.locator('.page-breadcrumb')).toContainText('测试设计') + await page.locator('.react-flow__node[data-id=api]').click() + const name = page.getByRole('textbox', { name: '名称', exact: true }) + await name.fill('导航测试未应用草稿') + const editor = await page.getByTestId('workflow-workbench').elementHandle() + const beforeURL = page.url() + const tabs = await page.locator('.workspace-navigation-tabs').textContent() + await parent.click() + await expect(parent).toHaveAttribute('aria-expanded', 'false') + await navigation.getByRole('menuitem', { name: '项目与接口', exact: true }).click() + await expect(name).toHaveValue('导航测试未应用草稿') + expect(page.url()).toBe(beforeURL) + expect(await page.locator('.workspace-navigation-tabs').textContent()).toBe(tabs) + await page.getByRole('button', { name: '收起侧栏', exact: true }).click() + await expect(page.locator('.sidebar')).toHaveCSS('width', '72px') + await expect(name).toHaveValue('导航测试未应用草稿') + await page.getByRole('button', { name: '展开侧栏', exact: true }).click() + await expect(parent).toHaveAttribute('aria-expanded', 'true') + await expect(name).toHaveValue('导航测试未应用草稿') + expect(await editor!.evaluate((element) => element.isConnected)).toBe(true) + await expect(page.getByTestId('workflow-canvas-stage')).toBeVisible() + await expect(page.locator('.sidebar')).toHaveCSS('width', '224px') + const sidebar = await page.locator('.sidebar').boundingBox() + const header = await page.locator('.topbar').boundingBox() + const logout = await page.getByRole('button', { name: /退出$/ }).boundingBox() + expect(sidebar!.height).toBe(viewport.height) + expect(logout!.x + logout!.width).toBeLessThanOrEqual(viewport.width) + expect(header!.x).toBe(224) + await page.screenshot({ + path: testInfo.outputPath(`navigation-${viewport.width}.png`), + animations: 'disabled', + }) + }) +} + +test('N06/N08:collapsed popup supports keyboard selection, Escape, tabs and browser history', async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await seedEditor(page) + await navigateMenu(page, '服务目录') + await expect(page.getByRole('heading', { name: '服务目录', exact: true })).toBeVisible() + await page.goBack() + const parent = page + .getByRole('navigation', { name: '功能导航' }) + .getByRole('menuitem', { name: '测试设计', exact: true }) + await expect(parent).toHaveAttribute('aria-expanded', 'true') + await page.getByRole('button', { name: '收起侧栏', exact: true }).click() + await expect(page.locator('.sidebar')).toHaveCSS('width', '72px') + const popupParent = page + .getByRole('navigation', { name: '功能导航' }) + .getByRole('menuitem', { name: '项目与接口', exact: true }) + await popupParent.focus() + await page.keyboard.press('Enter') + await expect(popupParent).toHaveAttribute('aria-expanded', 'true') + await expect( + page.locator('.ant-menu-submenu-popup').getByRole('link', { name: '服务目录', exact: true }), + ).toBeVisible() + await page.keyboard.press('Escape') + await expect(popupParent).toHaveAttribute('aria-expanded', 'false') + await expect(page.locator('.ant-menu-submenu-popup')).toBeHidden() + await popupParent.focus() + await page.keyboard.press('Enter') + const link = page + .locator('.ant-menu-submenu-popup') + .getByRole('link', { name: '服务目录', exact: true }) + await link.focus() + await page.keyboard.press('Enter') + await expect(page.getByRole('heading', { name: '服务目录', exact: true })).toBeVisible() + const overview = page + .getByRole('navigation', { name: '功能导航' }) + .getByRole('menuitem', { name: '质量总览', exact: true }) + await overview.focus() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/dashboard$/) + await page.getByRole('tab', { name: /服务目录/ }).click() + await page.getByRole('button', { name: '展开侧栏', exact: true }).click() + await expect( + page + .getByRole('navigation', { name: '功能导航' }) + .getByRole('menuitem', { name: '项目与接口', exact: true }), + ).toHaveAttribute('aria-expanded', 'true') + await page.getByRole('tab', { name: /流程编排/ }).click() + await expect(parent).toHaveAttribute('aria-expanded', 'true') +}) + +test('N10:all global administration pages select a project dashboard', async ({ page }) => { + const fixture = await seedEditor(page) + for (const route of ['/organization', '/platform', '/execution-fabric']) { + await page.goto(route) + await expect(page.locator('.page-breadcrumb')).toContainText('系统管理') + await expect(page.locator('.page-breadcrumb')).not.toContainText('流程编辑验收') + const selector = page.locator('.global-project-select') + await selector.click() + const projectName = (await page.request + .get(`/api/v1/projects/${fixture.projectId}`, { headers: fixture.headers }) + .then((response) => response.json())) as { name: string } + await page.locator('.ant-select-dropdown').getByText(projectName.name, { exact: true }).click() + await expect(page).toHaveURL(new RegExp(`/projects/${fixture.projectId}/dashboard$`)) + } +}) + +test('N08/N16:responsive defaults and mobile drawer closure restore focus', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 1100, height: 800 }) + const fixture = await seedEditor(page) + await expect(page.getByRole('button', { name: '展开侧栏', exact: true })).toBeVisible() + await page.setViewportSize({ width: 1440, height: 900 }) + await expect(page.getByRole('button', { name: '收起侧栏', exact: true })).toBeVisible() + await page.getByRole('button', { name: '收起侧栏', exact: true }).click() + await page.setViewportSize({ width: 390, height: 844 }) + const trigger = page.getByRole('button', { name: '打开导航', exact: true }) + await trigger.click() + await page.keyboard.press('Escape') + await expect(trigger).toBeFocused() + await trigger.click() + await page.locator('.ant-drawer-mask').click({ position: { x: 300, y: 300 } }) + await expect(trigger).toBeFocused() + await navigateMenu(page, '服务目录') + await expect(page).toHaveURL(new RegExp(`${fixture.root}/services$`)) + await expect(trigger).toBeFocused() + await trigger.click() + await page.screenshot({ + path: testInfo.outputPath('navigation-mobile.png'), + animations: 'disabled', + }) + await page.keyboard.press('Escape') + await page.setViewportSize({ width: 320, height: 740 }) + const logout = page.getByRole('button', { name: /退出$/ }) + const accountBox = await logout.boundingBox() + expect(accountBox!.x + accountBox!.width).toBeLessThanOrEqual(320) + await expect(page.getByLabel('全局搜索')).toBeInViewport() + await expect(page.locator('.global-project-select')).toBeInViewport() + await page.setViewportSize({ width: 1920, height: 1080 }) + await expect(page.getByRole('button', { name: '展开侧栏', exact: true })).toBeVisible() +}) + +test('N15:menu scroll keeps brand and footer fixed without scrolling the workspace', async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 420 }) + await seedEditor(page) + const navigation = page.getByRole('navigation', { name: '功能导航' }) + await expect( + navigation.locator('.ant-menu-root > .ant-menu-item, .ant-menu-root > .ant-menu-submenu'), + ).toHaveCount(7) + const footer = page.locator('.shell-sidebar-footer') + const before = await footer.boundingBox() + const pageScroll = await page.evaluate(() => window.scrollY) + await navigation.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await expect(navigation.getByRole('menuitem', { name: '系统管理', exact: true })).toBeInViewport() + await expect(page.locator('.brand')).toBeInViewport() + await expect(footer).toBeInViewport() + const after = await footer.boundingBox() + expect(after!.y).toBeCloseTo(before!.y, 0) + expect(after!.x).toBe(before!.x) + expect(after!.height).toBe(before!.height) + expect(await page.evaluate(() => window.scrollY)).toBe(pageScroll) + expect(await navigation.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) +}) diff --git a/frontend/e2e/s14-management-workbench.spec.ts b/frontend/e2e/s14-management-workbench.spec.ts index e1dbe1fa..2f0872d6 100644 --- a/frontend/e2e/s14-management-workbench.spec.ts +++ b/frontend/e2e/s14-management-workbench.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test, type APIRequestContext } from '@playwright/test' import { administratorEmail, authenticate } from './support/auth' @@ -26,7 +27,7 @@ test('S14 团队、测试资产与 API 工作台主路径', async ({ page }, tes await renameApi(page, apiName, renamedApiName) await editApiVersion(page, renamedApiName) - await page.getByRole('link', { name: '项目管理' }).click() + await navigateMenu(page, '项目管理') await expect(page.getByRole('heading', { name: '项目治理' })).toBeVisible() await manageFolder(page, folderName, renamedFolder) await updateProjectConfiguration(page, suffix) diff --git a/frontend/e2e/s15-test-assets.spec.ts b/frontend/e2e/s15-test-assets.spec.ts index 834a62c9..c31eb8f1 100644 --- a/frontend/e2e/s15-test-assets.spec.ts +++ b/frontend/e2e/s15-test-assets.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test, @@ -24,7 +25,7 @@ test('S15 用例、套件、版本 Diff 与固定计划目标主路径', async ( await createSecondaryEnvironment(page, firstEnvironmentName) await createSecondaryEnvironment(page, secondEnvironmentName) await createPublishedWorkflow(page, workflowName) - await page.getByRole('link', { name: '测试资产' }).click() + await navigateMenu(page, '测试资产') await expect(page.getByRole('heading', { name: '测试资产' })).toBeVisible() await createCase(page, caseName, workflowName, firstEnvironmentName) @@ -130,7 +131,7 @@ async function publishCaseTwiceAndReviewDiff( } async function createSecondaryEnvironment(page: Page, name: string) { - await page.getByRole('link', { name: '接口管理' }).click() + await navigateMenu(page, '接口管理') await expect(page.getByRole('heading', { name: '接口管理' })).toBeVisible() await page.getByRole('button', { name: '新建环境' }).click() const dialog = page.getByRole('dialog', { name: '新建环境' }) @@ -161,7 +162,7 @@ async function createAndPublishSuite(page: Page, caseName: string, suiteName: st } async function createSuitePlan(page: Page, suiteName: string, planName: string) { - await page.getByRole('link', { name: '任务执行' }).click() + await navigateMenu(page, '任务执行') await expect(page.getByRole('heading', { name: '任务执行' })).toBeVisible() await page.getByRole('button', { name: '新建计划' }).click() const dialog = page.getByRole('dialog', { name: '新建测试计划' }) diff --git a/frontend/e2e/s17-data-mock.spec.ts b/frontend/e2e/s17-data-mock.spec.ts index 46f9ab00..28c4efa9 100644 --- a/frontend/e2e/s17-data-mock.spec.ts +++ b/frontend/e2e/s17-data-mock.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test, type APIRequestContext, type Page } from '@playwright/test' import { authenticate } from './support/auth' @@ -13,7 +14,7 @@ test('S17 Credential、只读节点拒绝与规则化 Mock 主路径', async ({ const accessToken = await accessTokenFromSession(page.request) const project = await firstProject(page.request, accessToken) - await page.getByRole('link', { name: '数据与 Mock' }).click() + await navigateMenu(page, '数据与 Mock') await expect(page.getByRole('heading', { name: '数据与 Mock' })).toBeVisible() const credential = await createCredential(page, suffix) await rejectUnsafeSqlWorkflow(page.request, accessToken, project.id, credential.id, suffix) diff --git a/frontend/e2e/s18-contract-automation.spec.ts b/frontend/e2e/s18-contract-automation.spec.ts index 53c410e9..93b2f85b 100644 --- a/frontend/e2e/s18-contract-automation.spec.ts +++ b/frontend/e2e/s18-contract-automation.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test } from '@playwright/test' import { authenticate } from './support/auth' @@ -8,7 +9,7 @@ test('S18 契约 Diff、Schema 覆盖率与生成草稿审核主路径', async ( await page.goto('/') await authenticate(page) - await page.getByRole('link', { name: '测试资产' }).click() + await navigateMenu(page, '测试资产') await page.getByRole('tab', { name: '契约自动化' }).click() const schema = openApiSchema(`S18 Contract ${suffix}`) diff --git a/frontend/e2e/s19-quality-scale.spec.ts b/frontend/e2e/s19-quality-scale.spec.ts index c9e45991..33f786b0 100644 --- a/frontend/e2e/s19-quality-scale.spec.ts +++ b/frontend/e2e/s19-quality-scale.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test } from '@playwright/test' import { authenticate } from './support/auth' @@ -30,7 +31,7 @@ test('S19 质量门禁与 Cron 计划配置主路径', async ({ page }) => { page.getByRole('main').getByText(project.title, { exact: true }).first(), ).toBeVisible() - await page.getByRole('link', { name: '质量中心' }).click() + await navigateMenu(page, '质量中心') await expect(page).toHaveURL(/\/quality$/) await expect(page.getByRole('heading', { name: '质量中心' })).toBeVisible() @@ -46,7 +47,7 @@ test('S19 质量门禁与 Cron 计划配置主路径', async ({ page }) => { expect((await created).status()).toBe(201) await expect(page.getByRole('row').filter({ hasText: `S19 Gate ${suffix}` })).toContainText('95%') - await page.getByRole('link', { name: '任务执行' }).click() + await navigateMenu(page, '任务执行') await page.getByRole('button', { name: /新建计划/ }).click() const planDialog = page.getByRole('dialog', { name: '新建测试计划' }) const scheduleMode = planDialog.getByLabel('调度方式') diff --git a/frontend/e2e/s21-ai-review.spec.ts b/frontend/e2e/s21-ai-review.spec.ts index 216e3487..4cedde2d 100644 --- a/frontend/e2e/s21-ai-review.spec.ts +++ b/frontend/e2e/s21-ai-review.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test } from '@playwright/test' import { authenticate } from './support/auth' @@ -8,7 +9,7 @@ test('S21 AI 脱敏任务与人工接受主路径', async ({ page }) => { await page.goto('/') await authenticate(page) - await page.getByRole('link', { name: 'AI 助手' }).click() + await navigateMenu(page, 'AI 助手') await expect(page).toHaveURL(/\/ai$/) await expect(page.getByRole('heading', { name: 'AI 助手' })).toBeVisible() await expect(page.getByText(/AI 不会读取 Secret、自动发布或自动执行/)).toBeVisible() diff --git a/frontend/e2e/s22-capability-sdk.spec.ts b/frontend/e2e/s22-capability-sdk.spec.ts index dd839372..59cc4fe6 100644 --- a/frontend/e2e/s22-capability-sdk.spec.ts +++ b/frontend/e2e/s22-capability-sdk.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test } from '@playwright/test' import { authenticate } from './support/auth' @@ -6,7 +7,7 @@ test('S22 能力版本、安全边界与平台深链接主路径', async ({ page await page.goto('/') await authenticate(page) - await page.getByRole('link', { name: '平台管理' }).click() + await navigateMenu(page, '平台管理') await expect(page).toHaveURL(/\/platform$/) await expect(page.getByRole('heading', { name: '能力与插件中心' })).toBeVisible() const httpCapability = page.getByRole('row').filter({ hasText: 'http.request' }) diff --git a/frontend/e2e/s29-execution-fabric.spec.ts b/frontend/e2e/s29-execution-fabric.spec.ts index b1b5b0ca..36060769 100644 --- a/frontend/e2e/s29-execution-fabric.spec.ts +++ b/frontend/e2e/s29-execution-fabric.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test } from '@playwright/test' import { authenticate } from './support/auth' @@ -7,7 +8,7 @@ test('S29 Worker 故障转移在执行面显示递增 Fence、唯一终态与 Dr await page.context().clearCookies() await page.goto('/') await authenticate(page) - await page.getByRole('link', { name: '分布式执行面' }).click() + await navigateMenu(page, '分布式执行面') await expect(page.getByRole('heading', { name: '分布式执行面' })).toBeVisible() await expect(page.getByText('PostgreSQL 是任务、Lease 与 Fence 的唯一真相源')).toBeVisible() diff --git a/frontend/e2e/s30-failure-intelligence.spec.ts b/frontend/e2e/s30-failure-intelligence.spec.ts index 46da26b6..a105d1e9 100644 --- a/frontend/e2e/s30-failure-intelligence.spec.ts +++ b/frontend/e2e/s30-failure-intelligence.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test, type APIRequestContext } from '@playwright/test' type Identified = { id: string } @@ -29,7 +30,7 @@ test('S30 发布风险证据经 AI 变更集逐项审核后只生成草稿', asy await expect(page.getByText('发布风险分析已完成')).toBeVisible() await expect(page.getByText('证据指纹')).toBeVisible() - await page.getByRole('link', { name: 'AI 变更集' }).click() + await navigateMenu(page, 'AI 变更集') await expect(page).toHaveURL(new RegExp(`/projects/${project.id}/ai-changes$`)) await expect(page.getByRole('heading', { name: 'AI 测试资产变更审核' })).toBeVisible() await expect(page.getByText(/AI 只生成草稿/)).toBeVisible() diff --git a/frontend/e2e/s31-release-gate.spec.ts b/frontend/e2e/s31-release-gate.spec.ts index 446bbc79..5efb6760 100644 --- a/frontend/e2e/s31-release-gate.spec.ts +++ b/frontend/e2e/s31-release-gate.spec.ts @@ -114,6 +114,10 @@ test('S31 质量指挥中心展示真实历史判断并保持证据深链', asyn 'href', `/projects/${project.id}/impact`, ) + await page + .getByRole('navigation', { name: '功能导航' }) + .getByRole('menuitem', { name: '质量分析', exact: true }) + .click() await expect(page.getByRole('link', { name: '发布门禁' }).first()).toHaveAttribute( 'href', `/projects/${project.id}/release`, diff --git a/frontend/e2e/support/navigation.ts b/frontend/e2e/support/navigation.ts new file mode 100644 index 00000000..e871e51c --- /dev/null +++ b/frontend/e2e/support/navigation.ts @@ -0,0 +1,30 @@ +import { expect, type Page } from '@playwright/test' + +const groups: Record = { + 项目与接口: ['接口管理', '服务目录', '请求目标', '多协议工作台', '项目管理'], + 测试设计: ['流程编排', '测试资产', '数据与 Mock', '契约中心', '测试工程'], + 执行与环境: ['任务执行', '环境实验室', '性能实验室'], + 质量分析: ['测试报告', '影响分析', '变更回归', '质量中心', '发布门禁'], + 'AI 与集成': ['AI 助手', '上下文检查器', 'AI 变更集', 'MCP 变更集'], + 系统管理: ['组织治理', '分布式执行面', '平台管理'], +} + +/** Navigate through the same visible parent/leaf controls as a person. */ +export async function navigateMenu(page: Page, label: string): Promise { + const trigger = page.getByRole('button', { name: '打开导航', exact: true }) + if (await trigger.isVisible()) await trigger.click() + const navigation = page.getByRole('navigation', { name: '功能导航' }) + const group = Object.entries(groups).find(([, leaves]) => leaves.includes(label))?.[0] + if (group) { + const parent = navigation.getByRole('menuitem', { name: group, exact: true }) + if ((await parent.getAttribute('aria-expanded')) !== 'true') await parent.click() + } + // Collapsed menus render the popup outside the sidebar. + const leaf = navigation + .getByRole('link', { name: label, exact: true }) + .or(page.locator('.ant-menu-submenu-popup').getByRole('link', { name: label, exact: true })) + .filter({ visible: true }) + await expect(leaf).toHaveCount(1) + await expect(leaf).toBeVisible() + await leaf.click() +} diff --git a/frontend/e2e/v1-acceptance.spec.ts b/frontend/e2e/v1-acceptance.spec.ts index b1e3ca7e..910f8ad4 100644 --- a/frontend/e2e/v1-acceptance.spec.ts +++ b/frontend/e2e/v1-acceptance.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test } from '@playwright/test' import { authenticate } from './support/auth' @@ -28,7 +29,7 @@ test('V1.0 项目治理与脱敏报告主路径', async ({ page }) => { page.getByRole('main').getByText(pilotProject.title, { exact: true }).first(), ).toBeVisible() - await page.getByRole('link', { name: '项目管理' }).click() + await navigateMenu(page, '项目管理') await expect(page.getByRole('heading', { name: '项目治理' })).toBeVisible() await expect(page).toHaveURL((url) => url.pathname === governancePath && url.search === '') const governanceUrl = page.url() @@ -44,12 +45,12 @@ test('V1.0 项目治理与脱敏报告主路径', async ({ page }) => { await expect(page.getByLabel('允许私网 CIDR(每行一个)')).toHaveValue('172.16.0.0/12') await expect(page.getByText('api.created').first()).toBeVisible() - await page.getByText('质量总览', { exact: true }).click() + await navigateMenu(page, '质量总览') await expect(page).toHaveURL(/\/projects\/[^/]+\/dashboard$/) await expect(page.getByRole('heading', { name: '质量指挥中心' })).toBeVisible() await expect(page.getByText(/^当前查看:S11 V1 Pilot /)).toBeVisible() - await page.getByText('测试报告', { exact: true }).click() + await navigateMenu(page, '测试报告') await expect(page.getByRole('heading', { name: '测试报告' })).toBeVisible() const businessExecution = page.getByRole('row', { name: /V1 登录下单流程/ }) await expect(businessExecution).toContainText('8/8 通过') @@ -68,7 +69,7 @@ test('V1.0 项目治理与脱敏报告主路径', async ({ page }) => { ['流程编排', '流程编排'], ['任务执行', '任务执行'], ] as const) { - await page.getByText(menu, { exact: true }).click() + await navigateMenu(page, menu) await expect(page.getByRole('heading', { name: heading })).toBeVisible() } }) diff --git a/frontend/e2e/workflow-editor-interactions.spec.ts b/frontend/e2e/workflow-editor-interactions.spec.ts index 529c34af..c1201c55 100644 --- a/frontend/e2e/workflow-editor-interactions.spec.ts +++ b/frontend/e2e/workflow-editor-interactions.spec.ts @@ -1,3 +1,4 @@ +import { navigateMenu } from './support/navigation' import { expect, test } from '@playwright/test' import { canvasKey, @@ -324,10 +325,10 @@ test('FORM/KEY/LIFE:无效 JSON 保留、顶栏写保护及路由往返恢复 await drawer.getByRole('button', { name: '关闭', exact: true }).click() await page.getByRole('button', { name: '保存草稿', exact: true }).click() await expect(page.getByText(/有尚未应用的节点配置/).last()).toBeVisible() - await page.getByRole('menuitem', { name: '接口管理' }).click() + await navigateMenu(page, '接口管理') await page.getByRole('button', { name: '保留草稿并切换', exact: true }).click() await expect(page.locator('.workflow-workspace-page')).toHaveCount(0) - await page.getByRole('menuitem', { name: '流程编排' }).click() + await navigateMenu(page, '流程编排') await page.getByRole('button', { name: '保留草稿并切换', exact: true }).click() await page.locator('.react-flow__node[data-id=api]').click() await page.getByRole('button', { name: 'setting 配置节点请求', exact: true }).click() diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-01-default-edit-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-01-default-edit-chromium-linux.png index 53aa6be2..c5221201 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-01-default-edit-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-01-default-edit-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-03-node-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-03-node-selected-chromium-linux.png index 1ee15df5..5ebf0783 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-03-node-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-03-node-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-04-edge-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-04-edge-selected-chromium-linux.png index 6171983e..0e921747 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-04-edge-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-04-edge-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-05-inspector-resized-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-05-inspector-resized-chromium-linux.png index f4725839..16bc033f 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-05-inspector-resized-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-05-inspector-resized-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-08-run-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-08-run-mode-chromium-linux.png index 93708008..7984ce20 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-08-run-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-08-run-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-09-history-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-09-history-mode-chromium-linux.png index 97d8b89c..ec08a67c 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-09-history-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-09-history-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-01-default-edit-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-01-default-edit-chromium-linux.png index 715f19ff..c38139a4 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-01-default-edit-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-01-default-edit-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-03-node-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-03-node-selected-chromium-linux.png index 78a4f703..cef8b514 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-03-node-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-03-node-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-04-edge-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-04-edge-selected-chromium-linux.png index bdbbfcc1..244ecdb3 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-04-edge-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-04-edge-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-05-inspector-resized-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-05-inspector-resized-chromium-linux.png index 9f68b6cc..c580e5fa 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-05-inspector-resized-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-05-inspector-resized-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-08-run-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-08-run-mode-chromium-linux.png index e98ce245..1ae93806 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-08-run-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-08-run-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-09-history-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-09-history-mode-chromium-linux.png index 9f3f51d8..7305bc44 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-09-history-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-09-history-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-01-default-edit-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-01-default-edit-chromium-linux.png index 100ccb19..07a7e558 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-01-default-edit-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-01-default-edit-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-03-node-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-03-node-selected-chromium-linux.png index 102f12b3..ce7e4cfc 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-03-node-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-03-node-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-04-edge-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-04-edge-selected-chromium-linux.png index 296ed4ef..1a6d2eec 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-04-edge-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-04-edge-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-05-inspector-resized-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-05-inspector-resized-chromium-linux.png index 81bd4153..18cfa270 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-05-inspector-resized-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-05-inspector-resized-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-08-run-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-08-run-mode-chromium-linux.png index 15885ed2..618c2389 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-08-run-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-08-run-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-09-history-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-09-history-mode-chromium-linux.png index add69c10..86f45263 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-09-history-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-09-history-mode-chromium-linux.png differ diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 0d230507..a5b2354d 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { App as AntdApp } from 'antd' import { http, HttpResponse } from 'msw' @@ -26,6 +26,77 @@ describe('App authentication', () => { afterEach(() => vi.restoreAllMocks()) + it.each([true, false])( + 'preserves every existing navigation URL for admin=%s', + async (isAdmin) => { + authenticateExistingUser() + useAuthStore.setState({ user: { ...user, is_system_admin: isAdmin } }) + renderApp(`/projects/${project.id}/dashboard`) + await screen.findByRole('heading', { name: '质量指挥中心' }) + const navigation = within(document.querySelector('.sidebar') as HTMLElement) + const sections = { + dashboard: '质量总览', + settings: '项目管理', + services: '服务目录', + 'request-targets': '请求目标', + apis: '接口管理', + protocols: '多协议工作台', + assets: '测试资产', + workflows: '流程编排', + data: '数据与 Mock', + tasks: '任务执行', + performance: '性能实验室', + environments: '环境实验室', + contracts: '契约中心', + 'test-engineering': '测试工程', + contexts: '上下文检查器', + impact: '影响分析', + 'change-regression': '变更回归', + quality: '质量中心', + release: '发布门禁', + ai: 'AI 助手', + 'ai-changes': 'AI 变更集', + 'mcp-changes': 'MCP 变更集', + reports: '测试报告', + organization: '组织治理', + fabric: '分布式执行面', + platform: '平台管理', + } + const globalPaths: Record = { + organization: '/organization', + fabric: '/execution-fabric', + platform: '/platform', + } + const seen = new Set() + const browser = userEvent.setup() + const groups: Record = { + 项目与接口: ['apis', 'services', 'request-targets', 'protocols', 'settings'], + 测试设计: ['workflows', 'assets', 'data', 'contracts', 'test-engineering'], + 执行与环境: ['tasks', 'environments', 'performance'], + 质量分析: ['reports', 'impact', 'change-regression', 'quality', 'release'], + 'AI 与集成': ['ai', 'contexts', 'ai-changes', 'mcp-changes'], + 系统管理: ['organization', 'fabric', 'platform'], + } + for (const [section, label] of Object.entries(sections)) { + const group = Object.keys(groups).find((name) => groups[name].includes(section)) + if (group) { + const parent = navigation.getByRole('menuitem', { name: group }) + if (parent.getAttribute('aria-expanded') !== 'true') await browser.click(parent) + } + if (!isAdmin && ['fabric', 'platform'].includes(section)) { + expect(navigation.queryByRole('link', { name: label })).not.toBeInTheDocument() + continue + } + seen.add(section) + expect(navigation.getByRole('link', { name: label })).toHaveAttribute( + 'href', + globalPaths[section] ?? `/projects/${project.id}/${section}`, + ) + } + expect(seen.size).toBe(isAdmin ? 26 : 24) + }, + ) + it('logs in, shows the lazy dashboard, and logs out', async () => { server.use( http.post('/api/v1/auth/refresh', () => HttpResponse.json({}, { status: 401 })), @@ -63,6 +134,7 @@ describe('App authentication', () => { expect(close).toBeInstanceOf(HTMLElement) fireEvent.click(close as HTMLElement) expect(screen.queryByRole('tab', { name: '接口管理' })).not.toBeInTheDocument() + await browser.click(screen.getByRole('menuitem', { name: '项目与接口' })) await browser.click(screen.getByRole('link', { name: '接口管理' })) expect(await screen.findByRole('tab', { name: '接口管理' })).toBeVisible() await browser.click(screen.getByRole('tab', { name: '质量总览' })) @@ -159,6 +231,7 @@ describe('App authentication', () => { expect(await screen.findByRole('heading', { name: '质量指挥中心' })).toBeVisible() expect((await screen.findAllByText(project.name)).length).toBeGreaterThanOrEqual(2) + await userEvent.setup().click(screen.getByRole('menuitem', { name: '项目与接口' })) expect(screen.getByRole('link', { name: '接口管理' })).toHaveAttribute( 'href', `/projects/${project.id}/apis`, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e05f2e4a..63723a92 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,34 +1,10 @@ import { DraftSessionProvider } from './features/drafts/DraftSessionProvider' -import { - ApiOutlined, - AppstoreOutlined, - AuditOutlined, - ApartmentOutlined, - BarChartOutlined, - BranchesOutlined, - CodeOutlined, - CloudServerOutlined, - DashboardOutlined, - DatabaseOutlined, - FolderOpenOutlined, - FundProjectionScreenOutlined, - FileSearchOutlined, - ExperimentOutlined, - LogoutOutlined, - ScheduleOutlined, - SafetyCertificateOutlined, - ToolOutlined, - RobotOutlined, - ShareAltOutlined, - TeamOutlined, - UserOutlined, -} from '@ant-design/icons' +import { LogoutOutlined, UserOutlined } from '@ant-design/icons' import { Avatar, Breadcrumb, Button, Layout, - Menu, Select, Space, Spin, @@ -36,7 +12,7 @@ import { Tag, Typography, } from 'antd' -import { lazy, Suspense, useEffect, useState, type ReactNode } from 'react' +import { lazy, Suspense, useEffect, useState } from 'react' import { Link, Navigate, Route, Routes, useNavigate } from 'react-router-dom' import LoginPage from './features/auth/LoginPage' @@ -44,8 +20,14 @@ import PasswordChangePage from './features/auth/PasswordChangePage' import { useAuthStore } from './features/auth/auth-store' import ProjectProvider from './features/projects/ProjectProvider' import ProjectEmptyState from './features/projects/ProjectEmptyState' -import { projectPath, type ProjectSection } from './features/projects/project-routing' +import { + isGlobalAdministrationSection, + projectPath, + type ProjectSection, +} from './features/projects/project-routing' import { useProjectContext } from './features/projects/use-project-context' +import { groupForSection, sectionLabels } from './features/navigation/navigation-config' +import ShellSidebar from './features/navigation/ShellSidebar' import GlobalSearch from './features/search/GlobalSearch' const DashboardPage = lazy(() => import('./pages/DashboardPage')) @@ -75,36 +57,7 @@ const ProtocolWorkbenchPage = lazy(() => import('./pages/ProtocolWorkbenchPage') const RequestTargetsPage = lazy(() => import('./pages/RequestTargetsPage')) const OrganizationGovernancePage = lazy(() => import('./pages/OrganizationGovernancePage')) -const { Header, Content, Sider } = Layout - -const sectionLabels: Record = { - dashboard: '质量总览', - settings: '项目管理', - services: '服务目录', - 'request-targets': '请求目标', - apis: '接口管理', - protocols: '多协议工作台', - assets: '测试资产', - workflows: '流程编排', - data: '数据与 Mock', - tasks: '任务执行', - performance: '性能实验室', - environments: '环境实验室', - contracts: '契约中心', - 'test-engineering': '测试工程', - contexts: '上下文检查器', - impact: '影响分析', - 'change-regression': '变更回归', - quality: '质量中心', - release: '发布门禁', - ai: 'AI 助手', - 'ai-changes': 'AI 变更集', - 'mcp-changes': 'MCP 变更集', - reports: '测试报告', - platform: '平台管理', - fabric: '分布式执行面', - organization: '组织治理', -} +const { Header, Content } = Layout export default function App() { const initialized = useAuthStore((state) => state.initialized) @@ -137,22 +90,19 @@ function AuthenticatedShell() { const isGlobalAdministration = isGlobalAdministrationSection(section) return ( - -
- - FlowTest -
- - +
- - 接口自动化测试平台 + + + 接口自动化测试平台 + setValue(event.target.value)} + /> + + + + + ) +} + +function renderWorkspace(route = '/workflows?focus=node&proposal=p#draft', admin = true) { + return render( + + + , + ) +} + +function mockViewport(width: number) { + const listeners = new Set<() => void>() + let current = width + vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({ + get matches() { + return current <= Number(query.match(/max-width: (\d+)/)?.[1] ?? 0) + }, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: () => true, + addEventListener: (_event: string, listener: EventListenerOrEventListenerObject) => + listeners.add(listener as () => void), + removeEventListener: (_event: string, listener: EventListenerOrEventListenerObject) => + listeners.delete(listener as () => void), + })) + return (next: number) => + act(() => { + current = next + listeners.forEach((listener) => listener()) + }) +} + +describe('grouped shell navigation', () => { + beforeEach(() => localStorage.clear()) + afterEach(() => vi.restoreAllMocks()) + + it('restores a manually expanded dashboard group on reload while an unconfigured dashboard stays closed', async () => { + const browser = userEvent.setup() + const view = renderWorkspace('/dashboard') + const group = screen.getByRole('menuitem', { name: '质量分析' }) + expect(group).toHaveAttribute('aria-expanded', 'false') + await browser.click(group) + view.unmount() + renderWorkspace('/dashboard') + expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + await browser.click(screen.getByRole('button', { name: '收起侧栏' })) + await browser.click(screen.getByRole('button', { name: '展开侧栏' })) + expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + }) + + it('keeps URL, local draft and manually closed active group stable across ordinary renders', async () => { + const browser = userEvent.setup() + localStorage.setItem( + 'flowtest:navigation:v1:alice', + JSON.stringify({ openKeys: ['nav:system'] }), + ) + renderWorkspace() + const group = screen.getByRole('menuitem', { name: '测试设计' }) + expect(group).toHaveAttribute('aria-expanded', 'true') + await browser.click(group) + await browser.click(screen.getByRole('button', { name: '重新渲染' })) + expect(group).toHaveAttribute('aria-expanded', 'false') + await browser.click(screen.getByRole('menuitem', { name: '项目与接口' })) + expect(screen.getByTestId('location')).toHaveTextContent( + '/workflows?focus=node&proposal=p#draft', + ) + expect(screen.getByLabelText('本地草稿')).toHaveValue('普通 render') + await browser.click(screen.getByRole('button', { name: '收起侧栏' })) + await browser.click(screen.getByRole('button', { name: '展开侧栏' })) + expect(screen.getByRole('menuitem', { name: '测试设计' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + expect(screen.getByLabelText('本地草稿')).toHaveValue('普通 render') + }) + + it('navigates via row and collapsed dashboard icon activation without a text Link', async () => { + const browser = userEvent.setup() + renderWorkspace() + await browser.click(screen.getByRole('button', { name: '收起侧栏' })) + await browser.click(screen.getByRole('menuitem', { name: '质量总览' })) + expect(screen.getByTestId('location')).toHaveTextContent('/dashboard') + }) + + it('opens the matching parent on Tab navigation and browser history, and uses an accordion', async () => { + const browser = userEvent.setup() + renderWorkspace() + await browser.click(screen.getByRole('menuitem', { name: '项目与接口' })) + expect(screen.getByRole('menuitem', { name: '测试设计' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + await browser.click(screen.getByRole('button', { name: '打开报告 Tab' })) + expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + await browser.click(screen.getByRole('button', { name: '后退' })) + expect(screen.getByRole('menuitem', { name: '测试设计' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + }) + + it('defaults the dashboard to closed parents and filters admin leaves after role changes', async () => { + const browser = userEvent.setup() + const view = renderWorkspace('/dashboard') + await browser.click(screen.getByRole('menuitem', { name: '系统管理' })) + await waitFor(() => expect(screen.getByRole('link', { name: '平台管理' })).toBeVisible()) + view.rerender( + + + , + ) + await browser.click(screen.getByRole('menuitem', { name: '系统管理' })) + await waitFor(() => expect(screen.getByRole('link', { name: '组织治理' })).toBeVisible()) + expect(screen.queryByRole('link', { name: '平台管理' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: '分布式执行面' })).not.toBeInTheDocument() + }) + + it('defaults compact screens to collapsed without overwriting the explicit desktop preference', async () => { + const resize = mockViewport(1100) + const browser = userEvent.setup() + renderWorkspace('/dashboard') + expect(screen.getByRole('button', { name: '展开侧栏' })).toBeVisible() + expect(localStorage.getItem('flowtest:navigation:v1:alice')).toBeNull() + resize(1440) + await browser.click(screen.getByRole('button', { name: '收起侧栏' })) + resize(1100) + resize(1920) + expect(screen.getByRole('button', { name: '展开侧栏' })).toBeVisible() + expect(JSON.parse(localStorage.getItem('flowtest:navigation:v1:alice')!).collapsed).toBe(true) + }) + + it('uses a narrow-screen drawer, closes after choosing a leaf and restores trigger focus', async () => { + mockViewport(390) + const browser = userEvent.setup() + renderWorkspace('/dashboard') + const trigger = screen.getByRole('button', { name: '打开导航' }) + await browser.click(trigger) + const drawer = await screen.findByRole('dialog') + await browser.click(within(drawer).getByRole('menuitem', { name: '测试设计' })) + await browser.click(within(drawer).getByRole('link', { name: '流程编排' })) + expect(screen.getByTestId('location')).toHaveTextContent('/workflows') + await waitFor(() => expect(trigger).toHaveFocus()) + await browser.click(trigger) + fireEvent.keyDown(await screen.findByRole('dialog'), { key: 'Escape', keyCode: 27 }) + await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'false')) + expect(localStorage.getItem('flowtest:navigation:v1:alice')).toEqual(expect.any(String)) + }) +}) diff --git a/frontend/src/features/navigation/ShellSidebar.tsx b/frontend/src/features/navigation/ShellSidebar.tsx new file mode 100644 index 00000000..81b40c17 --- /dev/null +++ b/frontend/src/features/navigation/ShellSidebar.tsx @@ -0,0 +1,108 @@ +import { ApiOutlined, MenuFoldOutlined, MenuOutlined, MenuUnfoldOutlined } from '@ant-design/icons' +import { Button, ConfigProvider, Drawer, Layout, Menu, type MenuProps } from 'antd' +import { useRef } from 'react' +import { useNavigate } from 'react-router-dom' + +import type { ProjectSection } from '../projects/project-routing' +import { navigationPath, shellNavigationItems, visibleSections } from './navigation-config' +import { useNavigationState } from './use-navigation-state' + +interface ShellSidebarProps { + userId: string + isSystemAdmin: boolean + section: ProjectSection + pathFor: (section: ProjectSection) => string +} + +export default function ShellSidebar({ + userId, + isSystemAdmin, + section, + pathFor, +}: ShellSidebarProps) { + const state = useNavigationState(userId, section, isSystemAdmin) + const navigate = useNavigate() + const handleLeafClick: NonNullable = ({ key, domEvent }) => { + if (state.mobile) state.setDrawerOpen(false) + const target = visibleSections(isSystemAdmin).find((item) => item === key) + const clickedLink = domEvent.target instanceof Element && domEvent.target.closest('a') + // Links keep native browser behavior; row/icon/keyboard activation uses the same router guard. + if (target && !clickedLink) navigate(navigationPath(target, pathFor)) + } + const drawerTrigger = useRef(null) + // Each Menu mode owns its popup lifecycle; the workspace keeps the same instance. + const navigation = ( +
+
+ + {!state.collapsed && FlowTest} +
+ +
+ +
+
+ ) + if (state.mobile) { + return ( + <> +