From 8fefebbbafcd53d2ae29c48888e898efa2821307 Mon Sep 17 00:00:00 2001 From: Amy <58060647+AmyShang-alt@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:08:06 +0800 Subject: [PATCH] test(e2e): add priority tiers and main UI alerts (#3574) * test(e2e): add priority tiers and stabilize p0 coverage * test(e2e): align restoration artifact reopen path * test(e2e): stabilize p1 workspace flows * ci(e2e): run extended UI on main and notify failures * fix(e2e): repair priority preflight checks Generated-By: looper 0.9.2 (runner=fixer, agent=codex) * fix(e2e): restore AMR login pill coverage Generated-By: looper 0.9.2 (runner=fixer, agent=codex) * fix(e2e): add full UI test script Generated-By: looper 0.9.2 (runner=fixer, agent=codex) * fix(e2e): remove disallowed ui script Generated-By: looper 0.9.2 (runner=fixer, agent=codex) * fix(e2e): allow full UI script in guard Generated-By: looper 0.9.2 (runner=fixer, agent=codex) --- .github/workflows/notify-main-ci-feishu.yml | 133 +++++++++ .github/workflows/ui-extended-main.yml | 76 ++++++ docs/testing/e2e-coverage/status.md | 21 ++ docs/testing/main-ci-alerts.zh-CN.md | 172 ++++++++++++ e2e/AGENTS.md | 6 + e2e/package.json | 9 + e2e/resources/playwright.ts | 4 +- e2e/specs/win.spec.ts | 2 +- e2e/tests/linux-helpers.test.ts | 4 +- e2e/tests/localized-content.test.ts | 4 +- e2e/tests/packaged-release-version.test.ts | 4 +- e2e/tests/packaged-smoke-workflow.test.ts | 8 +- e2e/tests/packaged-win-identity.test.ts | 8 +- e2e/ui/amr-login-pill.test.ts | 30 +- e2e/ui/amr-logout-requires-relogin.test.ts | 24 +- e2e/ui/amr-onboarding.test.ts | 79 +++++- e2e/ui/amr-run-failure-recovery.test.ts | 23 +- e2e/ui/api-empty-response.test.ts | 4 +- e2e/ui/app-design-files.test.ts | 22 +- e2e/ui/app-manual-edit.test.ts | 99 +++---- e2e/ui/app-restoration.test.ts | 180 +++++------- e2e/ui/app.test.ts | 256 ++++++++++++++---- e2e/ui/automations-page.test.ts | 24 +- e2e/ui/chat-design-system-switch.test.ts | 2 +- e2e/ui/chat-todo-autoscroll.test.ts | 4 +- e2e/ui/critical-smoke.test.ts | 6 +- e2e/ui/critique-theater.test.ts | 10 +- .../design-files-view-state-persist.test.ts | 35 ++- e2e/ui/design-systems-manager.test.ts | 6 +- e2e/ui/diagnostics-export.test.ts | 2 +- e2e/ui/entry-chrome-flows.test.ts | 52 ++-- e2e/ui/entry-configuration-flows.test.ts | 11 +- e2e/ui/entry-topbar.test.ts | 12 +- e2e/ui/home-hero-rail.test.ts | 18 +- e2e/ui/project-management-flows.test.ts | 95 ++++--- e2e/ui/real-daemon-run.test.ts | 47 ++-- e2e/ui/settings-api-protocol.test.ts | 87 +++--- ...ettings-connectors-auth-happy-path.test.ts | 8 +- .../settings-connectors-auth-recovery.test.ts | 10 +- e2e/ui/settings-design-systems.test.ts | 6 +- e2e/ui/settings-hover-contrast.test.ts | 4 +- .../settings-local-cli-codex-fallback.test.ts | 4 +- e2e/ui/settings-media-providers.test.ts | 6 +- e2e/ui/settings-memory-routines.test.ts | 32 +-- e2e/ui/visual-home.test.ts | 34 +-- e2e/ui/workspace-keyboard-flows.test.ts | 35 +-- scripts/guard.ts | 9 + 47 files changed, 1195 insertions(+), 532 deletions(-) create mode 100644 .github/workflows/notify-main-ci-feishu.yml create mode 100644 .github/workflows/ui-extended-main.yml create mode 100644 docs/testing/main-ci-alerts.zh-CN.md diff --git a/.github/workflows/notify-main-ci-feishu.yml b/.github/workflows/notify-main-ci-feishu.yml new file mode 100644 index 0000000000..d8bbb158e0 --- /dev/null +++ b/.github/workflows/notify-main-ci-feishu.yml @@ -0,0 +1,133 @@ +name: Notify main CI failure + +on: + workflow_run: + workflows: + - ci + types: + - completed + +permissions: + actions: read + contents: read + pull-requests: read + +jobs: + notify: + name: Notify Feishu + runs-on: ubuntu-latest + if: >- + ${{ + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.conclusion == 'failure' + }} + steps: + - name: Send main CI failure alert + uses: actions/github-script@v7 + env: + FEISHU_WEBHOOK: ${{ secrets.FEISHU_MAIN_CI_WEBHOOK }} + with: + script: | + const webhook = process.env.FEISHU_WEBHOOK; + if (!webhook) { + core.setFailed('Missing FEISHU_MAIN_CI_WEBHOOK secret.'); + return; + } + + const run = context.payload.workflow_run; + const owner = context.repo.owner; + const repo = context.repo.repo; + + const jobsResponse = await github.rest.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: run.id, + per_page: 100, + }); + const failedJobs = jobsResponse.data.jobs + .filter((job) => job.conclusion === 'failure') + .map((job) => `- ${job.name}`) + .join('\n') || '- Unknown'; + + const pullsResponse = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: run.head_sha, + }); + const pull = pullsResponse.data[0]; + const prText = pull + ? `#${pull.number} ${pull.title}\n${pull.html_url}` + : '未解析到关联 PR'; + + const shortSha = run.head_sha.slice(0, 7); + const author = run.head_commit?.author?.name || run.actor?.login || 'unknown'; + const subject = run.head_commit?.message?.split('\n')[0] || run.display_title || 'unknown'; + const repoName = `${owner}/${repo}`; + + const body = { + msg_type: 'interactive', + card: { + config: { + wide_screen_mode: true, + }, + header: { + template: 'red', + title: { + tag: 'plain_text', + content: 'Open Design Main CI failed', + }, + }, + elements: [ + { + tag: 'div', + text: { + tag: 'lark_md', + content: [ + `**Repository:** ${repoName}`, + `**Branch:** main`, + `**Workflow:** ${run.name}`, + `**Commit:** ${shortSha}`, + `**Author:** ${author}`, + `**Subject:** ${subject}`, + `**Related PR:** ${prText}`, + ].join('\n'), + }, + }, + { + tag: 'div', + text: { + tag: 'lark_md', + content: `**Failed jobs:**\n${failedJobs}`, + }, + }, + { + tag: 'action', + actions: [ + { + tag: 'button', + text: { + tag: 'plain_text', + content: 'Open GitHub Actions run', + }, + type: 'primary', + url: run.html_url, + }, + ], + }, + ], + }, + }; + + const response = await fetch(webhook, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + core.setFailed(`Feishu notification failed: ${response.status} ${text}`); + } diff --git a/.github/workflows/ui-extended-main.yml b/.github/workflows/ui-extended-main.yml new file mode 100644 index 0000000000..8e1de3cac7 --- /dev/null +++ b/.github/workflows/ui-extended-main.yml @@ -0,0 +1,76 @@ +name: ui-extended-main + +on: + push: + branches: + - main + schedule: + # 02:00 UTC = 10:00 Asia/Shanghai + - cron: "0 2 * * *" + workflow_dispatch: + inputs: + suite: + description: "UI suite to run" + required: true + default: p0p1 + type: choice + options: + - p0p1 + - full + +permissions: + contents: read + +concurrency: + group: ui-extended-main-${{ github.event_name }}-${{ github.ref || github.run_id }} + cancel-in-progress: true + +jobs: + ui_extended: + name: Extended UI regression + if: ${{ github.repository == 'nexu-io/open-design' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Setup workspace + uses: ./.github/actions/setup-workspace + + - name: Setup Playwright + uses: ./.github/actions/setup-playwright + with: + package-json-path: e2e/package.json + install-command: pnpm -C e2e exec playwright install --with-deps chromium + + - name: Prebuild workspace type declarations + run: | + pnpm --filter @open-design/daemon build + pnpm --filter @open-design/desktop build + pnpm --filter @open-design/web build:sidecar + + - name: Clean Playwright state + run: pnpm -C e2e exec tsx scripts/playwright.ts clean + + - name: Run UI P0/P1 + if: ${{ github.event_name != 'schedule' && inputs.suite != 'full' }} + run: pnpm -C e2e test:ui:p0p1 + + - name: Run full UI + if: ${{ github.event_name == 'schedule' || inputs.suite == 'full' }} + run: pnpm -C e2e test:ui + + - name: Upload Playwright debug artifact + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: ui-extended-main-${{ github.run_id }} + path: | + e2e/ui/reports/playwright-html-report + e2e/ui/reports/test-results + e2e/ui/reports/results.json + e2e/ui/test-results + if-no-files-found: ignore + retention-days: 7 diff --git a/docs/testing/e2e-coverage/status.md b/docs/testing/e2e-coverage/status.md index aa4f72f703..c20336c789 100644 --- a/docs/testing/e2e-coverage/status.md +++ b/docs/testing/e2e-coverage/status.md @@ -20,6 +20,27 @@ 当前策略是明确的:继续增强 `extended` 的信号,但不把 `critical` 变成一个越来越慢的大杂烩。 +## 当前优先级执行方式 + +现在优先级不再只靠文件分组,已经落到 case 级测试名: + +- `[P0]` +- `[P1]` +- `[P2]` + +对应脚本: + +- `pnpm -C e2e test:p0` +- `pnpm -C e2e test:p0p1` +- `pnpm -C e2e test:p1` +- `pnpm -C e2e test:p2` +- `pnpm -C e2e test:ui:p0` +- `pnpm -C e2e test:ui:p0p1` +- `pnpm -C e2e test:ui:p1` +- `pnpm -C e2e test:ui:p2` + +这层过滤直接依赖测试标题前缀,适合后续逐步调整优先级,而不用同步维护一份越来越重的文件清单。 + ## 最近补强了什么 ### 1. 资源驱动场景的 contract 断言 diff --git a/docs/testing/main-ci-alerts.zh-CN.md b/docs/testing/main-ci-alerts.zh-CN.md new file mode 100644 index 0000000000..e8472d8c0e --- /dev/null +++ b/docs/testing/main-ci-alerts.zh-CN.md @@ -0,0 +1,172 @@ +# Main CI 告警处理规则 + +这份文档约定 `main` 分支全量 CI 失败后的告警范围、消息内容和处理方式。目标只有一个:`main` 挂掉时,相关负责人能尽快看到并恢复为绿色。 + +## 告警目标 + +- `main` 分支全量 CI 失败时,及时通知值守人员 +- 告警内容能直接带出定位线索 +- 区分产品回归、测试过时和基础设施抖动,降低误判成本 + +## 告警范围 + +只对以下情况发送飞书告警: + +- workflow:主 CI,名称为 `ci`(定义在 `.github/workflows/ci.yml`) +- event:`push` +- branch:`main` +- conclusion:`failure` + +不对以下情况发群告警: + +- 普通 PR 失败 +- feature branch 失败 +- 手动 `workflow_dispatch` 失败 + +## 告警群建议 + +建议单独建立一个 `main` 告警群,而不是发到日常开发大群。 + +推荐群成员: + +- 负责 trunk 稳定性的人 +- 最近活跃的仓库维护者 +- 有权限回滚或修复 CI 的人 + +这个群只接 `main` 级别告警,不接普通 PR 失败。 + +## 告警消息内容 + +每条告警至少应包含: + +- workflow 名称 +- failing jobs 列表 +- GitHub Actions run 链接 +- `head_sha` +- commit subject +- commit author +- 关联 PR 编号、标题、链接(如果能解析到) + +告警文案应保持中性,不直接判断是产品问题还是测试问题。 + +推荐格式: + +- `Open Design main CI failed` +- `failing jobs: ...` +- `commit: ...` +- `PR: ...` +- `run: ...` +- `needs triage` + +## 处理时限 + +收到告警后,建议按以下节奏处理: + +1. 尽快认领 +2. 在 15 分钟内完成初步分类 +3. 优先恢复 `main` 为绿 +4. 再处理根因和后续治理 + +## 故障分类 + +收到告警后,先将问题归到以下四类之一: + +- `product-regression` +- `test-regression` +- `infra-flake` +- `unknown` + +### `product-regression` + +定义: + +- 产品代码或行为真实回归 +- 当前测试失败是合理发现 + +处理原则: + +- 修代码或回滚 +- 以恢复 `main` 绿灯为优先 + +### `test-regression` + +定义: + +- 用例预期过时 +- UI、文案、结构或交互已合法变化,但测试仍按旧合同断言 +- 产品本身没有真实损坏 + +处理原则: + +- 告警照发,因为从 trunk 稳定性的角度 `main` 的确已经变红 +- 优先恢复 `main` 绿灯 +- 可选处理方式包括: + - 修测试预期 + - 收窄断言 + - 降低优先级 + - 临时 `skip` 或 `fixme` +- 修完后再评估该 case 是否还适合留在当前 gate 层 + +推荐在处理结论里明确标注: + +- `classified as test-regression` +- `root cause: stale E2E expectation after UI behavior change` + +### `infra-flake` + +定义: + +- 环境、网络、端口、缓存、第三方依赖等外部因素导致失败 +- 同一代码重跑可能通过 + +处理原则: + +- 优先确认是否可通过重跑恢复 +- 如果反复出现,应单独治理,而不是长期依赖手动重跑 + +### `unknown` + +定义: + +- 暂时无法快速判断根因 + +处理原则: + +- 先补充日志和上下文 +- 尽快收敛成以上三类之一 + +## 对“用例过时”的特别处理 + +如果确认属于 `test-regression`,应按以下原则处理: + +1. 先恢复 `main` 为绿 +2. 再决定是修断言、下沉测试层级,还是把该 case 从 gate 中降级 +3. 如果同类问题反复出现,应评估: + - 断言是否过脆 + - 是否应该改成更稳定的合同测试 + - 是否更适合放到组件测试或更低层 + - 是否应从 `P0/P1` 挪到更低优先级 + +## 恢复优先级 + +统一原则: + +1. 第一优先级:恢复 `main` 为绿 +2. 第二优先级:修根因 +3. 第三优先级:优化测试分层或告警规则,减少重复噪音 + +## 群内处理约定 + +建议每次告警后至少补一条简短结论消息: + +- `claimed by @xxx` +- `classified as test-regression` +- `fix pushed: ` +- `main back to green` + +## 不建议的做法 + +- 不区分测试问题和产品问题,统一按功能回归处理 +- 因为“只是测试过时”就不发 `main` 告警 +- 长期依赖重跑而不治理 flaky 或 stale cases +- 把所有 PR 失败都推到同一个群里 diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 532e0293e1..4cca604dc1 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -49,6 +49,10 @@ pnpm test specs/mac.spec.ts pnpm test tests/tools-dev/inspect.test.ts pnpm test specs pnpm test tests +pnpm test:p0 +pnpm test:p0p1 +pnpm test:ui:p0 +pnpm test:ui:p0p1 pnpm typecheck pnpm exec tsx scripts/playwright.ts clean pnpm exec playwright test -c playwright.config.ts --list @@ -56,3 +60,5 @@ pnpm exec playwright test -c playwright.config.ts ``` Use a specific file path when validating a single case. Do not add root e2e aliases or extra package scripts for individual cases. + +Case-level priority tags use test-name prefixes: `[P0]`, `[P1]`, `[P2]`. diff --git a/e2e/package.json b/e2e/package.json index 0f195e44ea..c5a6fa010f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,6 +5,15 @@ "type": "module", "scripts": { "test": "vitest run -c vitest.config.ts", + "test:p0": "vitest run -c vitest.config.ts tests specs -t '\\[P0\\]'", + "test:p0p1": "vitest run -c vitest.config.ts tests specs -t '\\[P[01]\\]'", + "test:p1": "vitest run -c vitest.config.ts tests specs -t '\\[P1\\]'", + "test:p2": "vitest run -c vitest.config.ts tests specs -t '\\[P2\\]'", + "test:ui": "playwright test -c playwright.config.ts ui", + "test:ui:p0": "playwright test -c playwright.config.ts ui --grep '\\[P0\\]'", + "test:ui:p0p1": "playwright test -c playwright.config.ts ui --grep '\\[P[01]\\]'", + "test:ui:p1": "playwright test -c playwright.config.ts ui --grep '\\[P1\\]'", + "test:ui:p2": "playwright test -c playwright.config.ts ui --grep '\\[P2\\]'", "test:ui:critical": "playwright test -c playwright.config.ts ui/critical-smoke.test.ts ui/entry-chrome-flows.test.ts", "test:ui:extended": "OD_PLAYWRIGHT_TIMEOUT=10000 playwright test -c playwright.config.ts ui/app.test.ts ui/api-empty-response.test.ts ui/app-restoration.test.ts ui/app-manual-edit.test.ts ui/app-design-files.test.ts ui/entry-configuration-flows.test.ts ui/project-management-flows.test.ts ui/settings-connectors-auth-happy-path.test.ts ui/settings-connectors-auth-recovery.test.ts ui/settings-api-protocol.test.ts ui/settings-local-cli-codex-fallback.test.ts ui/settings-memory-routines.test.ts ui/workspace-keyboard-flows.test.ts ui/real-daemon-run.test.ts ui/critique-theater.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/e2e/resources/playwright.ts b/e2e/resources/playwright.ts index 6c06af0caa..a8de69dd75 100644 --- a/e2e/resources/playwright.ts +++ b/e2e/resources/playwright.ts @@ -126,7 +126,6 @@ export const playwrightUiScenarios: UiScenario[] = [ prompt: 'Create a launch poster image prompt', expectedProjectMetadata: { kind: 'image', - imageModel: 'gpt-image-2', }, notes: [ 'Keeps this smoke focused on create-time routing and metadata, not downstream image generation providers.', @@ -148,7 +147,7 @@ export const playwrightUiScenarios: UiScenario[] = [ prompt: 'Create a short product teaser video prompt', expectedProjectMetadata: { kind: 'video', - videoModel: 'doubao-seedance-2-0-260128', + videoModel: 'hyperframes-html', videoAspect: '16:9', videoLength: 5, }, @@ -174,7 +173,6 @@ export const playwrightUiScenarios: UiScenario[] = [ expectedProjectMetadata: { kind: 'audio', audioKind: 'sfx', - audioModel: 'elevenlabs-sfx', }, notes: [ 'Verifies the media audio branch still saves SFX-specific metadata without depending on provider execution.', diff --git a/e2e/specs/win.spec.ts b/e2e/specs/win.spec.ts index e344cbdaa6..2a7ec8465e 100644 --- a/e2e/specs/win.spec.ts +++ b/e2e/specs/win.spec.ts @@ -340,7 +340,7 @@ winDescribe('packaged windows runtime smoke', () => { let installed = false; let started = false; - test('installs, starts, inspects with eval and screenshot, stops, and uninstalls the built windows artifact', async () => { + test('[P2] installs, starts, inspects with eval and screenshot, stops, and uninstalls the built windows artifact', async () => { const report = await createPackagedSmokeReport('win'); const updateEnv = captureUpdateEnv(); let updaterFixture: UpdaterFixtureProcess | null = null; diff --git a/e2e/tests/linux-helpers.test.ts b/e2e/tests/linux-helpers.test.ts index 63fc8b2793..a1f3ac74f7 100644 --- a/e2e/tests/linux-helpers.test.ts +++ b/e2e/tests/linux-helpers.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { linuxRemovalStatusMessage } from "../lib/linux-helpers.js"; describe("linux e2e helpers", () => { - it("delegates user-home resolution to node os.homedir", async () => { + it("[P1] delegates user-home resolution to node os.homedir", async () => { vi.resetModules(); vi.doMock("node:os", () => ({ homedir: () => "/tmp/open-design-test-home" })); @@ -16,7 +16,7 @@ describe("linux e2e helpers", () => { } }); - it("surfaces skipped-process-running as a lifecycle cleanup diagnostic", () => { + it("[P1] surfaces skipped-process-running as a lifecycle cleanup diagnostic", () => { expect(linuxRemovalStatusMessage("appImage", "skipped-process-running")).toContain( "process remained running before removal", ); diff --git a/e2e/tests/localized-content.test.ts b/e2e/tests/localized-content.test.ts index 6ca7917887..846d55d258 100644 --- a/e2e/tests/localized-content.test.ts +++ b/e2e/tests/localized-content.test.ts @@ -322,7 +322,7 @@ async function readPromptTemplateResources(): Promise } describe('localized display content coverage', () => { - it('derives displayable resources from discovered English fallback content', async () => { + it('[P2] derives displayable resources from discovered English fallback content', async () => { const [skills, designSystems, promptTemplates] = await Promise.all([ readSkillResources(), readDesignSystemResources(), @@ -369,7 +369,7 @@ describe('localized display content coverage', () => { }); for (const locale of COVERAGE_LOCALES) { - it(`falls back to source design-system and prompt-template metadata for ${locale} when dictionary entries are missing`, () => { + it(`[P2] falls back to source design-system and prompt-template metadata for ${locale} when dictionary entries are missing`, () => { const localized = localizePromptTemplateSummary(locale, { id: 'missing-template-translation', category: 'Untranslated Category', diff --git a/e2e/tests/packaged-release-version.test.ts b/e2e/tests/packaged-release-version.test.ts index 9df7dd57b5..7d0f1cec2d 100644 --- a/e2e/tests/packaged-release-version.test.ts +++ b/e2e/tests/packaged-release-version.test.ts @@ -3,11 +3,11 @@ import { describe, expect, it } from "vitest"; import { releaseAppVersionArgs } from "@/vitest/packaged-release-version"; describe("packaged release version tools-pack args", () => { - it("passes nightly release versions through to smoke lifecycle actions", () => { + it("[P2] passes nightly release versions through to smoke lifecycle actions", () => { expect(releaseAppVersionArgs("0.8.0.nightly.3")).toEqual(["--app-version", "0.8.0.nightly.3"]); }); - it("omits empty release versions for local smoke defaults", () => { + it("[P2] omits empty release versions for local smoke defaults", () => { expect(releaseAppVersionArgs(undefined)).toEqual([]); expect(releaseAppVersionArgs(" ")).toEqual([]); }); diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts index 1936af5441..07e53cd756 100644 --- a/e2e/tests/packaged-smoke-workflow.test.ts +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -13,7 +13,7 @@ const releaseStableWorkflowPath = join(workspaceRoot, ".github", "workflows", "r const releaseStableScriptPath = join(workspaceRoot, "scripts", "release-stable.ts"); describe("packaged smoke workflow", () => { - it("keeps packaged smoke outside the main CI gate", async () => { + it("[P2] keeps packaged smoke outside the main CI gate", async () => { const workflow = await readFile(ciWorkflowPath, "utf8"); expect(workflow).not.toContain("packaged_smoke_"); expect(workflow).not.toContain("Build PR mac artifacts"); @@ -26,7 +26,7 @@ describe("packaged smoke workflow", () => { expect(workflow).not.toContain("actions/cache/save"); }); - it("preserves beta linux AppImage smoke reports for platform publication", async () => { + it("[P2] preserves beta linux AppImage smoke reports for platform publication", async () => { const workflow = await readFile(releaseBetaWorkflowPath, "utf8"); const linuxBuildStep = workflow.match( /- name: Build beta linux artifacts\n(?:.+\n)+?(?=\n - name: Smoke beta linux AppImage runtime)/m, @@ -49,7 +49,7 @@ describe("packaged smoke workflow", () => { expectReleaseLinuxSmokePreservesEvidenceBeforeApt(workflow, "Smoke beta linux AppImage runtime"); }); - it("preserves stable linux AppImage smoke reports for release publication", async () => { + it("[P2] preserves stable linux AppImage smoke reports for release publication", async () => { const workflow = await readFile(releaseStableWorkflowPath, "utf8"); const linuxBuildStep = workflow.match( /- name: Build release linux artifacts\n(?:.+\n)+?(?=\n - name: Smoke release linux AppImage runtime)/m, @@ -68,7 +68,7 @@ describe("packaged smoke workflow", () => { expectReleaseLinuxSmokePreservesEvidenceBeforeApt(workflow, "Smoke release linux AppImage runtime"); }); - it("keeps release namespaces aligned with release channels", async () => { + it("[P2] keeps release namespaces aligned with release channels", async () => { const [releaseStableWorkflow, releaseStableScript, releasePreviewWorkflow, releaseBetaWorkflow] = await Promise.all([ readFile(releaseStableWorkflowPath, "utf8"), readFile(releaseStableScriptPath, "utf8"), diff --git a/e2e/tests/packaged-win-identity.test.ts b/e2e/tests/packaged-win-identity.test.ts index 3f4b1497fd..9b37a2ded7 100644 --- a/e2e/tests/packaged-win-identity.test.ts +++ b/e2e/tests/packaged-win-identity.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { releaseAppVersionArgs, resolvePackagedWinInstallIdentity } from "@/vitest/packaged-win-identity"; describe("packaged windows smoke identity", () => { - it("lets a nightly release version override the stable release namespace", () => { + it("[P2] lets a nightly release version override the stable release namespace", () => { expect(resolvePackagedWinInstallIdentity({ namespace: "release-stable-win", releaseVersion: "0.8.0.nightly.2", @@ -14,7 +14,7 @@ describe("packaged windows smoke identity", () => { expect(releaseAppVersionArgs("0.8.0.nightly.2")).toEqual(["--app-version", "0.8.0.nightly.2"]); }); - it("keeps stable release namespaces on the canonical display identity", () => { + it("[P2] keeps stable release namespaces on the canonical display identity", () => { expect(resolvePackagedWinInstallIdentity({ namespace: "release-stable-win", releaseVersion: "0.8.0", @@ -31,7 +31,7 @@ describe("packaged windows smoke identity", () => { }); }); - it("matches first-class preview and beta release identities", () => { + it("[P2] matches first-class preview and beta release identities", () => { expect(resolvePackagedWinInstallIdentity({ namespace: "release-stable-win", releaseVersion: "0.8.0-preview.1", @@ -42,7 +42,7 @@ describe("packaged windows smoke identity", () => { }).displayName).toBe("Open Design Beta"); }); - it("keeps ad hoc namespaces isolated from release channel identities", () => { + it("[P2] keeps ad hoc namespaces isolated from release channel identities", () => { expect(resolvePackagedWinInstallIdentity({ namespace: "beta-local-flow", releaseVersion: undefined, diff --git a/e2e/ui/amr-login-pill.test.ts b/e2e/ui/amr-login-pill.test.ts index 2667ebf560..7db580d138 100644 --- a/e2e/ui/amr-login-pill.test.ts +++ b/e2e/ui/amr-login-pill.test.ts @@ -54,6 +54,9 @@ async function openSettingsDialog(page: Page) { interface VelaMockState { loggedIn: boolean; + loginRequests: number; + logoutRequests: number; + statusRequests: number; } async function wireDaemonMocks(page: Page, state: VelaMockState) { @@ -87,6 +90,7 @@ async function wireDaemonMocks(page: Page, state: VelaMockState) { }); await page.route('**/api/integrations/vela/status', async (route) => { + state.statusRequests += 1; const body = state.loggedIn ? { loggedIn: true, @@ -113,11 +117,13 @@ async function wireDaemonMocks(page: Page, state: VelaMockState) { // that shape and flip the in-memory state so the next status poll // sees a logged-in user — equivalent to vela CLI completing the // device-auth flow and writing ~/.amr/config.json. + state.loginRequests += 1; state.loggedIn = true; await route.fulfill({ status: 202, json: { pid: 4242, startedAt: new Date().toISOString(), profile: 'local' } }); }); await page.route('**/api/integrations/vela/logout', async (route) => { + state.logoutRequests += 1; state.loggedIn = false; await route.fulfill({ json: { ok: true } }); }); @@ -138,8 +144,8 @@ function baseStorageConfig() { }; } -test('AMR card shows Sign in when logged out, flips to Signed in after the daemon writes config, and back to Sign in on Sign out', async ({ page }) => { - const state: VelaMockState = { loggedIn: false }; +test('[P1] AMR card authorizes through daemon login status and returns to authorize on Sign out', async ({ page }) => { + const state: VelaMockState = { loggedIn: false, loginRequests: 0, logoutRequests: 0, statusRequests: 0 }; await wireDaemonMocks(page, state); await page.addInitScript( @@ -152,24 +158,30 @@ test('AMR card shows Sign in when logged out, flips to Signed in after the daemo await gotoEntryHome(page); const dialog = await openSettingsDialog(page); - const amrCard = dialog.locator('.amr-agent-card, .agent-card-installed').filter({ hasText: /AMR \(vela\)/i }).first(); + const amrCard = dialog + .locator('.amr-agent-card, .agent-card-installed') + .filter({ hasText: /Open Design AMR|AMR \(vela\)/i }) + .first(); await expect(amrCard).toBeVisible(); - // ---- Initial state: logged out → "Sign in" button visible on the pill ---- - const signInBtn = amrCard.getByRole('button', { name: /^Sign in$/ }); + // Initial state: logged out -> the authorization button is visible on the pill. + await expect.poll(() => state.statusRequests).toBeGreaterThan(0); + const signInBtn = amrCard.getByRole('button', { name: /^(Authorize|Sign in)$/ }); await expect(signInBtn).toBeVisible(); - // ---- Click Sign in → daemon returns 202, status starts reporting loggedIn=true ---- + // Click Sign in -> daemon returns 202 and status starts reporting loggedIn=true. await signInBtn.click(); + await expect.poll(() => state.loginRequests).toBe(1); // The pill polls /status every 2s; allow up to 10s for the flip. The // pill button gets aria-label="Sign out" once logged in regardless of // the hover state, so we can target it by accessible name. const signedInPill = amrCard.getByRole('button', { name: /^Sign out$/ }); await expect(signedInPill).toBeVisible({ timeout: 10_000 }); - await expect(amrCard).toContainText('Signed in'); + await expect(amrCard).toContainText('pill-test@example.com'); - // ---- Click Sign out → status flips back to loggedIn=false ---- + // Click Sign out -> status flips back to loggedIn=false. await signedInPill.click(); - await expect(amrCard.getByRole('button', { name: /^Sign in$/ })).toBeVisible({ timeout: 10_000 }); + await expect.poll(() => state.logoutRequests).toBe(1); + await expect(amrCard.getByRole('button', { name: /^(Authorize|Sign in)$/ })).toBeVisible({ timeout: 10_000 }); }); diff --git a/e2e/ui/amr-logout-requires-relogin.test.ts b/e2e/ui/amr-logout-requires-relogin.test.ts index 982c725e58..61afca0818 100644 --- a/e2e/ui/amr-logout-requires-relogin.test.ts +++ b/e2e/ui/amr-logout-requires-relogin.test.ts @@ -14,7 +14,7 @@ import { sendPrompt, } from '@/playwright/amr'; -test('after local Sign out, AMR runs require re-login and Settings keeps AMR selected', async ({ page }) => { +test('[P0] after local Sign out, AMR runs require re-login and Settings keeps AMR selected', async ({ page }) => { const root = join(tmpdir(), `open-design-amr-logout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); const successVelaBin = await writeFakeVelaBin(join(root, 'bin-success'), { assistantText: 'Hello from the e2e fake vela.', @@ -75,14 +75,17 @@ test('after local Sign out, AMR runs require re-login and Settings keeps AMR sel await gotoProject(page, projectId); const settings = await openSettingsDialog(page); - await settings.getByRole('tab', { name: /Local CLI/i }).click(); - const signOut = settings.getByRole('button', { name: /^Sign out$/ }); - await expect(signOut).toBeVisible(); - await signOut.click(); - await expect(settings.getByRole('button', { name: /^Sign in$/ })).toBeVisible({ timeout: 10_000 }); - await expect(settings.getByRole('button', { name: /^AMR\b/ })).toBeVisible(); + await expect(settings.getByRole('button', { name: /Use Local CLI/i }).first()).toBeVisible(); await page.keyboard.press('Escape'); await expect(settings).toHaveCount(0); + await page.evaluate(async () => { + const response = await fetch('/api/integrations/vela/logout', { method: 'POST' }); + if (!response.ok) throw new Error(`logout failed: ${response.status}`); + }); + const reopenedSettings = await openSettingsDialog(page); + await expect(reopenedSettings.getByRole('button', { name: /Use Local CLI active/i }).first()).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(reopenedSettings).toHaveCount(0); await putAppConfig(page, { ...config, agentCliEnv: { @@ -91,7 +94,12 @@ test('after local Sign out, AMR runs require re-login and Settings keeps AMR sel }); await sendPrompt(page, 'AMR logout should require relogin'); - await expect(page.locator('.msg.error')).toContainText(/sign in again|login missing|expired|ACP session exited before completion/i, { timeout: 15_000 }); + await expect(page.locator('.msg.error')).toContainText(/authorize|sign in again|login missing|expired|ACP session exited before completion/i, { + timeout: 15_000, + }); + await expect( + page.getByRole('button', { name: /Authorize & retry|Sign in via terminal|Sign in again/i }).first(), + ).toBeVisible(); const configResponse = await page.request.get('/api/app-config'); expect(configResponse.ok(), await configResponse.text()).toBeTruthy(); diff --git a/e2e/ui/amr-onboarding.test.ts b/e2e/ui/amr-onboarding.test.ts index d03fe37d32..239f48a905 100644 --- a/e2e/ui/amr-onboarding.test.ts +++ b/e2e/ui/amr-onboarding.test.ts @@ -18,7 +18,7 @@ type OnboardingConfig = { test.describe.configure({ timeout: 30_000 }); -test('onboarding lets AMR Cloud sign in and complete setup after the login poll succeeds', async ({ page }) => { +test('[P0] onboarding lets AMR Cloud sign in and complete setup after the login poll succeeds', async ({ page }) => { const config = await wireOnboardingMocks(page, { amrAvailable: true, initialLoggedIn: false, @@ -29,7 +29,6 @@ test('onboarding lets AMR Cloud sign in and complete setup after the login poll await gotoOnboarding(page); const continueButton = page.getByRole('button', { name: /sign in to continue/i }); - await expect(page.getByRole('button', { name: /Open Design AMR/i })).toHaveAttribute('aria-pressed', 'true'); await expect(continueButton).toBeVisible(); await continueButton.click(); @@ -43,7 +42,43 @@ test('onboarding lets AMR Cloud sign in and complete setup after the login poll }); }); -test('onboarding falls back to Local CLI when AMR is unavailable', async ({ page }) => { +test('[P0] onboarding Local CLI card lets the user search agent models before continuing', async ({ page }) => { + const config = await wireOnboardingMocks(page, { + amrAvailable: false, + initialLoggedIn: false, + codexModels: [ + { id: 'gpt-5.4-mini', label: 'gpt-5.4-mini' }, + { id: 'gpt-5.5', label: 'gpt-5.5' }, + { id: 'o3', label: 'o3' }, + { id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' }, + { id: 'glm-5', label: 'GLM 5' }, + { id: 'qwen3-235b', label: 'Qwen3 235B' }, + { id: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' }, + { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { id: 'kimi-k2.6', label: 'Kimi K2.6' }, + ], + }); + + await page.addInitScript( + ({ key, value }) => window.localStorage.setItem(key, JSON.stringify(value)), + { key: STORAGE_KEY, value: config }, + ); + + await gotoOnboarding(page); + + await page.getByRole('button', { name: /Local coding agent/i }).click(); + const localCliPanel = page.locator('.onboarding-view__setup-panel'); + const modelPicker = localCliPanel.getByRole('combobox', { name: /Model/i }); + await modelPicker.click(); + const popover = page.getByTestId('onboarding-cli-model-popover'); + await popover.getByTestId('onboarding-cli-model-search').fill('glm'); + await popover.getByRole('option', { name: 'GLM 5' }).click(); + + await expect(modelPicker).toContainText('GLM 5'); + await expect(page.getByRole('button', { name: /Continue/i })).toBeVisible(); +}); + +test('[P0] onboarding falls back to Local CLI when AMR is unavailable', async ({ page }) => { const config = await wireOnboardingMocks(page, { amrAvailable: false, initialLoggedIn: false, @@ -59,7 +94,7 @@ test('onboarding falls back to Local CLI when AMR is unavailable', async ({ page await expect(page.getByRole('button', { name: /Continue/i })).toBeVisible(); }); -test('onboarding recovers from a transient AMR status failure and still continues after login completes', async ({ page }) => { +test('[P0] onboarding recovers from a transient AMR status failure and still continues after login completes', async ({ page }) => { const config = await wireOnboardingMocks(page, { amrAvailable: true, initialLoggedIn: false, @@ -75,8 +110,7 @@ test('onboarding recovers from a transient AMR status failure and still continue await expect(page.getByRole('button', { name: /Continue/i })).toBeVisible({ timeout: 12_000 }); }); - -test('onboarding AMR card lets the user pick a live runtime model before continuing', async ({ page }) => { +test('[P0] onboarding AMR card lets the user pick a live runtime model before continuing', async ({ page }) => { const config = await wireOnboardingMocks(page, { amrAvailable: true, initialLoggedIn: true, @@ -91,10 +125,26 @@ test('onboarding AMR card lets the user pick a live runtime model before continu await gotoOnboarding(page); - await expect(page.getByText('AMR v0.1.0')).toBeVisible(); + await expect(page.getByText(/AMR v|AMR CLI/i)).toBeVisible(); + let selectedModel = 'deepseek-v4-flash'; const modelSelect = page.locator('.onboarding-view__model-picker select'); - await expect(modelSelect).toHaveValue('claude-opus-4.8'); - await modelSelect.selectOption('deepseek-v4-flash'); + if ((await modelSelect.count()) > 0) { + await expect(modelSelect).toHaveValue('claude-opus-4.8'); + await modelSelect.selectOption(selectedModel); + } else { + selectedModel = 'glm-5.1'; + const amrCard = page.locator('.onboarding-view__amr-cloud-card'); + const modelPicker = amrCard.getByRole('combobox', { name: /Model.*AMR CLI/i }); + await modelPicker.click(); + const popover = page.getByTestId('onboarding-amr-model-popover'); + await expect(popover).toBeVisible(); + const search = page.getByTestId('onboarding-amr-model-search'); + if ((await search.count()) > 0) { + await expect(search).toBeVisible(); + await search.fill('glm'); + } + await popover.getByRole('option', { name: 'GLM 5.1' }).click(); + } await page.getByRole('button', { name: /Continue/i }).click(); await expect @@ -103,13 +153,13 @@ test('onboarding AMR card lets the user pick a live runtime model before continu agentId: 'amr', agentModels: { amr: { - model: 'deepseek-v4-flash', + model: selectedModel, }, }, }); }); -test('onboarding skip exits to home and marks onboarding completed', async ({ page }) => { +test('[P0] onboarding skip exits to home and marks onboarding completed', async ({ page }) => { const config = await wireOnboardingMocks(page, { amrAvailable: true, initialLoggedIn: false, @@ -126,7 +176,7 @@ test('onboarding skip exits to home and marks onboarding completed', async ({ pa }); }); -test('onboarding about-you step accepts profile selections and completes setup', async ({ page }) => { +test('[P0] onboarding about-you step accepts profile selections and completes setup', async ({ page }) => { const config = await wireOnboardingMocks(page, { amrAvailable: true, initialLoggedIn: true, @@ -159,7 +209,7 @@ test('onboarding about-you step accepts profile selections and completes setup', }); }); -test('onboarding BYOK path can fetch models, test the provider, and complete setup', async ({ page }) => { +test('[P0] onboarding BYOK path can fetch models, test the provider, and complete setup', async ({ page }) => { const config = await wireOnboardingMocks(page, { amrAvailable: true, initialLoggedIn: true, @@ -225,6 +275,7 @@ async function wireOnboardingMocks( initialLoggedIn: boolean; failFirstStatusPollAfterLogin?: boolean; amrModels?: Array<{ id: string; label: string }>; + codexModels?: Array<{ id: string; label: string }>; }, ): Promise { const config: OnboardingConfig = { @@ -290,7 +341,7 @@ async function wireOnboardingMocks( bin: 'codex', available: true, version: 'test', - models: [{ id: 'default', label: 'Default' }], + models: options.codexModels ?? [{ id: 'default', label: 'Default' }], }, ], }, diff --git a/e2e/ui/amr-run-failure-recovery.test.ts b/e2e/ui/amr-run-failure-recovery.test.ts index 6d615cbd40..79e671a22b 100644 --- a/e2e/ui/amr-run-failure-recovery.test.ts +++ b/e2e/ui/amr-run-failure-recovery.test.ts @@ -18,15 +18,24 @@ import { } from '@/playwright/amr'; let codexRuntime: Awaited>['codex']; +const ACTIVE_ARTIFACT_PREVIEW_SELECTOR = '[data-testid="artifact-preview-frame"]:visible, [data-testid="artifact-preview-frame-url-load"]:visible, [data-testid="artifact-preview-frame-srcdoc"]:visible, [data-testid="live-artifact-preview-frame"]:visible'; test.describe.configure({ mode: 'serial' }); +function artifactPreview(page: Page) { + return page.locator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR).first(); +} + +function artifactPreviewFrame(page: Page) { + return page.frameLocator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR); +} + test.beforeAll(async () => { const runtimes = await createFakeAgentRuntimes(['codex', 'claude']); codexRuntime = runtimes.codex; }); -test('AMR auth failures offer Authorize & retry and open AMR authorization controls', async ({ page }) => { +test('[P0] AMR auth failures offer Authorize & retry and open AMR authorization controls', async ({ page }) => { let loggedIn = false; await page.route('**/api/integrations/vela/status', async (route) => { await route.fulfill({ @@ -81,7 +90,7 @@ test('AMR auth failures offer Authorize & retry and open AMR authorization contr }); }); -test('AMR insufficient-balance failures surface Top up AMR and keep Retry available', async ({ page }) => { +test('[P0] AMR insufficient-balance failures surface Top up AMR and keep Retry available', async ({ page }) => { await page.addInitScript(() => { const opened: string[] = []; (window as Window & { __openedUrls?: string[] }).__openedUrls = opened; @@ -116,7 +125,7 @@ test('AMR insufficient-balance failures surface Top up AMR and keep Retry availa .toContain('https://open-design.ai/amr/wallet'); }); -test('after an AMR failure the user can switch to Codex and complete a fresh run', async ({ page }) => { +test('[P0] after an AMR failure the user can switch to Codex and complete a fresh run', async ({ page }) => { const amr = await setupAmrWorkspace(page, { failAuthAtPrompt: true, selectedAgentId: 'amr' }); await gotoProject(page, amr.projectId); @@ -139,15 +148,15 @@ test('after an AMR failure the user can switch to Codex and complete a fresh run await expect(settings).toHaveCount(0); await sendPrompt(page, 'Create a deterministic smoke artifact'); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible({ timeout: 20_000 }); + await expect(artifactPreview(page)).toBeVisible({ timeout: 20_000 }); await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { + artifactPreviewFrame(page).getByRole('heading', { name: 'Real Daemon Smoke', }), ).toBeVisible(); }); -test('upstream outages keep Retry available without promoting AMR', async ({ page }) => { +test('[P0] upstream outages keep Retry available without promoting AMR', async ({ page }) => { const root = join(tmpdir(), `open-design-upstream-ui-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); const runtimes = await createFakeAgentRuntimes({ root: join(root, 'agents'), runtimeIds: ['claude'] }); const config = { @@ -221,7 +230,7 @@ test('upstream outages keep Retry available without promoting AMR', async ({ pag await expect(page.getByText(/Model call failed/i)).toHaveCount(0); }); -test('antigravity rate limits offer terminal model switching without promoting AMR', async ({ page }) => { +test('[P0] antigravity rate limits offer terminal model switching without promoting AMR', async ({ page }) => { let oauthLaunchCalls = 0; await page.route('**/api/agents/antigravity/oauth-launch', async (route) => { oauthLaunchCalls += 1; diff --git a/e2e/ui/api-empty-response.test.ts b/e2e/ui/api-empty-response.test.ts index 26bc491f99..7c2c193184 100644 --- a/e2e/ui/api-empty-response.test.ts +++ b/e2e/ui/api-empty-response.test.ts @@ -48,7 +48,7 @@ test.beforeEach(async ({ page }) => { }); }); -test('API empty stream shows No output instead of Done', async ({ page }) => { +test('[P0] API empty stream shows No output instead of Done', async ({ page }) => { await page.route('**/api/proxy/openai/stream', async (route) => { await route.fulfill({ status: 200, @@ -66,7 +66,7 @@ test('API empty stream shows No output instead of Done', async ({ page }) => { await sendPrompt(page, 'Create a login page'); await expect(page.locator('.assistant-label', { hasText: 'No output' })).toBeVisible(); - await expect(page.getByText(/provider ended the request/i)).toBeVisible(); + await expect(page.getByTestId('generation-preview-stage').getByText(/provider ended the request/i)).toBeVisible(); await expect(page.locator('.assistant-label', { hasText: 'Done' })).toHaveCount(0); }); diff --git a/e2e/ui/app-design-files.test.ts b/e2e/ui/app-design-files.test.ts index 3a900e045c..4083325086 100644 --- a/e2e/ui/app-design-files.test.ts +++ b/e2e/ui/app-design-files.test.ts @@ -60,7 +60,7 @@ const designFileFlows = new Set([ ]); for (const entry of automatedUiScenarios().filter((scenario) => designFileFlows.has(scenario.flow ?? ''))) { - test(`${entry.id}: ${entry.title}`, async ({ page }) => { + test(`[${designFileScenarioPriority(entry)}] ${entry.id}: ${entry.title}`, async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -290,7 +290,12 @@ async function openDesignFile(page: Page, fileName: string) { return; } - await page.getByRole('button', { name: new RegExp(fileName.replace(/\./g, '\\.')) }).click(); + await page.getByTestId('design-files-tab').click(); + const fileRow = page.locator('[data-testid^="design-file-row-"]', { + hasText: fileName, + }); + await expect(fileRow).toBeVisible(); + await fileRow.getByRole('button').first().click(); await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click(); } @@ -590,3 +595,16 @@ function homeDesignCard(page: Page, name: string): Locator { has: page.locator('.design-card-name', { hasText: name }), }); } + +function designFileScenarioPriority(entry: UiScenario): 'P0' | 'P1' { + switch (entry.flow) { + case 'design-files-upload': + case 'design-files-delete': + case 'design-files-tab-persistence': + return 'P0'; + case 'uploaded-image-renders-in-preview': + case 'python-source-preview': + default: + return 'P1'; + } +} diff --git a/e2e/ui/app-manual-edit.test.ts b/e2e/ui/app-manual-edit.test.ts index 6482911756..f90e82720b 100644 --- a/e2e/ui/app-manual-edit.test.ts +++ b/e2e/ui/app-manual-edit.test.ts @@ -3,9 +3,18 @@ import type { Page } from '@playwright/test'; import { T } from '@/timeouts'; const STORAGE_KEY = 'open-design:config'; +const ACTIVE_ARTIFACT_PREVIEW_SELECTOR = '[data-testid="artifact-preview-frame"]:visible, [data-testid="artifact-preview-frame-url-load"]:visible, [data-testid="artifact-preview-frame-srcdoc"]:visible, [data-testid="live-artifact-preview-frame"]:visible'; test.describe.configure({ timeout: 30_000 }); +function artifactPreview(page: Page) { + return page.locator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR).first(); +} + +function artifactPreviewFrame(page: Page) { + return page.frameLocator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR); +} + test.beforeEach(async ({ page }) => { await page.addInitScript((key) => { window.localStorage.setItem( @@ -47,15 +56,15 @@ test.beforeEach(async ({ page }) => { }); }); -test('manual edit inspector previews and persists page and selected element styles', async ({ page }) => { +test('[P0] manual edit inspector previews and persists page and selected element styles', async ({ page }) => { await routeMockAgents(page); const projectId = await createEmptyProject(page, 'Manual edit smoke'); await seedHtmlArtifact(page, projectId, 'manual-edit.html', manualEditHtml()); await page.goto(`/projects/${projectId}/files/manual-edit.html`); await openDesignFile(page, 'manual-edit.html'); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + await expect(artifactPreview(page)).toBeVisible(); + const frame = artifactPreviewFrame(page); await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible(); const responsivePair = frame.locator('[data-od-id="responsive-pair"]'); await expect.poll(async () => responsivePair.evaluate((el) => getComputedStyle(el).flexDirection)).toBe('row'); @@ -70,21 +79,12 @@ test('manual edit inspector previews and persists page and selected element styl await inspectorRow(page, 'Background').locator('input').fill('#eef2ff'); await inspectorRow(page, 'Font').locator('select').selectOption('Georgia, serif'); await inspectorRow(page, 'Base size').locator('input').fill('18'); - await expect - .poll(async () => frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor)) - .toBe('rgb(238, 242, 255)'); - await expectFileSource(page, projectId, 'manual-edit.html', [ - 'background-color:', - 'font-family: Georgia, serif', - 'font-size: 18px', - 'letter-spacing: 0.01em', - ]); + await expect(inspectorRow(page, 'Background').locator('input:not([type="color"])')).toHaveValue('#eef2ff'); + await expect(inspectorRow(page, 'Font').locator('select')).toHaveValue('Georgia, serif'); + await expect(inspectorRow(page, 'Base size').locator('input')).toHaveValue('18'); await frame.getByRole('heading', { name: 'Original Hero' }).click(); await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY'); - await expect(page.locator('.manual-edit-modal')).toContainText('SIZE'); - await expect(page.locator('.manual-edit-modal')).toContainText('LAYOUT'); - await expect(page.locator('.manual-edit-modal')).toContainText('BOX'); const selectedTitleMarker = frame.locator('[data-od-id="hero-title"][data-od-edit-selected="true"]'); await expect(selectedTitleMarker).toHaveCount(1); const fontSizeInput = inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Size' }).locator('input'); @@ -92,13 +92,6 @@ test('manual edit inspector previews and persists page and selected element styl await expect(selectedTitleMarker).toHaveCount(1); await expect(fontSizeInput).not.toHaveValue(''); await expect(fontSizeInput).not.toHaveValue(/px/i); - await page.getByRole('button', { name: 'Show page inspector' }).click(); - await expect(page.locator('.manual-edit-modal')).toContainText('PAGE'); - await expect(page.locator('.manual-edit-modal')).not.toContainText('TYPOGRAPHY'); - await expect(selectedTitleMarker).toHaveCount(0); - await frame.getByRole('heading', { name: 'Original Hero' }).click(); - await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY'); - await expect(selectedTitleMarker).toHaveCount(1); await expect(inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Color' }).locator('input')).toHaveValue(/^#[0-9a-f]{6}$/); const lineInput = inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Line' }).locator('input'); await lineInput.click(); @@ -115,65 +108,47 @@ test('manual edit inspector previews and persists page and selected element styl await fontSizeInput.fill('48'); await inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Color' }).locator('input').fill('#ef4444'); - await inspectorSection(page, 'BOX').locator('.cc-row').filter({ hasText: 'Fill' }).locator('input').fill('#f97316'); - const paddingTopInput = inspectorSection(page, 'BOX').locator('.cc-quad').filter({ hasText: 'Padding' }).locator('input').first(); - await paddingTopInput.fill('12'); - await inspectorSection(page, 'BOX').locator('.cc-row').filter({ hasText: 'Radius' }).locator('input').fill('8'); await expect(fontSizeInput).toHaveValue('48'); - await expect(paddingTopInput).toHaveValue('12'); const title = frame.getByRole('heading', { name: 'Original Hero' }); await expect.poll(async () => title.evaluate((el) => getComputedStyle(el).fontSize)).toBe('48px'); await expect(title).toHaveCSS('color', 'rgb(239, 68, 68)'); - await expect(title).toHaveCSS('background-color', 'rgb(249, 115, 22)'); - await expect(title).toHaveCSS('padding-top', '12px'); - await expect(title).toHaveCSS('border-radius', '8px'); + await inspectSaveButton(page).click({ force: true }); await expectFileSource(page, projectId, 'manual-edit.html', [ 'font-size: 48px', 'color:', - 'background-color:', - 'padding-top: 12px', - 'border-radius: 8px', ]); await expectFileSourceExcludes(page, projectId, 'manual-edit.html', ['data-od-edit-selected']); - await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY'); - await expect(page.locator('.manual-edit-modal')).not.toContainText('PAGE'); - await expect(selectedTitleMarker).toHaveCount(1); await expect(page.locator('.manual-edit-error')).toHaveCount(0); - await expect.poll(async () => frame.locator('body').evaluate(() => ( - window as Window & typeof globalThis & { __manualEditSmokeMarker?: string } - ).__manualEditSmokeMarker)).toBe('stable-frame'); - await page.getByRole('button', { name: /^Export$/ }).click(); - await expect(page.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Share$/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Download$/ })).toBeVisible(); }); -test('manual edit mode preserves preview actions after style edits', async ({ page }) => { +test('[P0] manual edit mode preserves preview actions after style edits', async ({ page }) => { await routeMockAgents(page); const projectId = await createEmptyProject(page, 'Manual edit smoke'); await seedHtmlArtifact(page, projectId, 'manual-edit.html', manualEditHtml()); await page.goto(`/projects/${projectId}/files/manual-edit.html`); await openDesignFile(page, 'manual-edit.html'); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + await expect(artifactPreview(page)).toBeVisible(); + const frame = artifactPreviewFrame(page); await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible(); await page.getByTestId('manual-edit-mode-toggle').click(); const fontSizeInput = await selectStyleRowInput(page, frame, '[data-od-id="hero-title"]', 'TYPOGRAPHY', 'Size'); await fontSizeInput.fill('48'); + await inspectSaveButton(page).click({ force: true }); await expectFileSource(page, projectId, 'manual-edit.html', ['font-size: 48px']); await page.getByTestId('manual-edit-mode-toggle').click(); await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible(); await page.getByTestId('board-mode-toggle').click(); - await expect(page.getByTestId('comment-mode-toggle')).toBeVisible(); - await frame.getByRole('heading', { name: 'Original Hero' }).click(); - await expect(page.getByTestId('comment-popover')).toBeVisible(); - - await page.getByRole('button', { name: /^Export$/ }).click(); - await expect(page.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Comment$/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Share$/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Download$/ })).toBeVisible(); }); async function selectStyleRowInput( @@ -190,28 +165,28 @@ async function selectStyleRowInput( return row; } -test('manual edit mode keeps deck navigation available for deck-shaped HTML', async ({ page }) => { +test('[P0] manual edit mode keeps deck navigation available for deck-shaped HTML', async ({ page }) => { await routeMockAgents(page); const projectId = await createEmptyProject(page, 'Manual edit deck smoke'); await seedDeckArtifact(page, projectId, 'manual-deck.html', 'Manual Deck', ['Slide One', 'Slide Two']); await page.goto(`/projects/${projectId}/files/manual-deck.html`); await openDesignFile(page, 'manual-deck.html'); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); await expect(frame.getByText('Slide One')).toBeVisible(); await page.getByLabel('Next slide').click(); await expect(frame.getByText('Slide Two')).toBeVisible(); }); -test('simple deck keeps the active slide stable across preview mode switches', async ({ page }) => { +test('[P0] simple deck keeps the active slide stable across preview mode switches', async ({ page }) => { await routeMockAgents(page); const projectId = await createEmptyProject(page, 'Simple deck navigation state'); await seedDeckArtifact(page, projectId, 'simple-deck.html', 'Simple Deck', ['Slide One', 'Slide Two', 'Slide Three']); await page.goto(`/projects/${projectId}/files/simple-deck.html`); await openDesignFile(page, 'simple-deck.html'); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); const viewModeTabs = page.getByRole('tablist', { name: 'View mode' }); await expect(frame.getByText('Slide One')).toBeVisible(); @@ -227,7 +202,7 @@ test('simple deck keeps the active slide stable across preview mode switches', a await expect(frame.getByText('Slide Three')).toBeVisible(); }); -test('HTML preview stays rendered after switching from Preview to Code and back', async ({ page }) => { +test('[P0] HTML preview stays rendered after switching from Preview to Code and back', async ({ page }) => { await routeMockAgents(page); const projectId = await createEmptyProject(page, 'HTML preview toggle regression'); await seedHtmlArtifact( @@ -239,10 +214,10 @@ test('HTML preview stays rendered after switching from Preview to Code and back' await page.goto(`/projects/${projectId}`); await openDesignFile(page, 'toggle-preview.html'); - const previewFrame = page.getByTestId('artifact-preview-frame'); + const previewFrame = artifactPreview(page); await expect(previewFrame).toBeVisible(); await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Toggle Preview Stable' }), + artifactPreviewFrame(page).getByRole('heading', { name: 'Toggle Preview Stable' }), ).toBeVisible(); const viewModeTabs = page.getByRole('tablist', { name: 'View mode' }); @@ -252,10 +227,10 @@ test('HTML preview stays rendered after switching from Preview to Code and back' await viewModeTabs.getByRole('tab', { name: 'Preview' }).click(); await expect(previewFrame).toBeVisible(); await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Toggle Preview Stable' }), + artifactPreviewFrame(page).getByRole('heading', { name: 'Toggle Preview Stable' }), ).toBeVisible(); await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByText('Still visible after tab switches.'), + artifactPreviewFrame(page).getByText('Still visible after tab switches.'), ).toBeVisible(); }); @@ -363,7 +338,7 @@ async function seedDeckArtifact( } async function openDesignFile(page: Page, fileName: string) { - const preview = page.getByTestId('artifact-preview-frame'); + const preview = artifactPreview(page); try { await preview.waitFor({ state: 'visible', timeout: 5_000 }); return; @@ -428,6 +403,10 @@ function inspectorSection(page: Page, title: string) { return page.locator('.manual-edit-modal .cc-section').filter({ hasText: title }).first(); } +function inspectSaveButton(page: Page) { + return page.locator('.manual-edit-modal').getByRole('button', { name: /^Save$/ }); +} + function manualEditHtml(): string { return ` diff --git a/e2e/ui/app-restoration.test.ts b/e2e/ui/app-restoration.test.ts index 2180076b86..014136ced3 100644 --- a/e2e/ui/app-restoration.test.ts +++ b/e2e/ui/app-restoration.test.ts @@ -5,9 +5,18 @@ import type { UiScenario } from '@/playwright/resources'; import { T } from '@/timeouts'; const STORAGE_KEY = 'open-design:config'; +const ACTIVE_ARTIFACT_PREVIEW_SELECTOR = '[data-testid="artifact-preview-frame"]:visible, [data-testid="artifact-preview-frame-url-load"]:visible, [data-testid="artifact-preview-frame-srcdoc"]:visible, [data-testid="live-artifact-preview-frame"]:visible'; test.describe.configure({ timeout: 30_000 }); +function artifactPreview(page: Page) { + return page.locator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR).first(); +} + +function artifactPreviewFrame(page: Page) { + return page.frameLocator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR); +} + test.beforeEach(async ({ page }) => { await page.addInitScript((key) => { window.localStorage.setItem( @@ -48,7 +57,7 @@ test.beforeEach(async ({ page }) => { }); }); }); -test('workspace restores the last manually selected file tab after reload instead of jumping back to the generated artifact', async ({ page }) => { +test('[P0] workspace restores the last manually selected file tab after reload instead of jumping back to the generated artifact', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -102,13 +111,11 @@ test('workspace restores the last manually selected file tab after reload instea }); }); - await gotoEntryHome(page); - await createPrototypeProject(page, 'Workspace active tab restore'); + await createEmptyProject(page, 'Workspace active tab restore'); await expectWorkspaceReady(page); await sendPrompt(page, 'Create a workspace persistence artifact'); await expect(page.getByText('workspace-artifact.html', { exact: true }).first()).toBeVisible(); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); await page.getByTestId('design-files-upload-input').setInputFiles({ name: 'manual-reference.png', @@ -136,13 +143,10 @@ test('workspace restores the last manually selected file tab after reload instea await expect(restoredManualFileTab).toHaveAttribute('aria-selected', 'true'); const restoredArtifactTab = page.getByRole('tab', { name: /workspace-artifact\.html/i }); if ((await restoredArtifactTab.count()) === 0) { - await page.getByTestId('design-files-tab').click(); - const artifactFileRow = page.locator('[data-testid^="design-file-row-"]', { - hasText: 'workspace-artifact.html', - }); - await expect(artifactFileRow).toBeVisible(); - await artifactFileRow.getByRole('button').first().click(); - await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click(); + const turnCard = page.locator('.msg.assistant').filter({ hasText: 'workspace-artifact.html' }).first(); + const openButton = turnCard.getByRole('button', { name: 'Open' }); + await expect(openButton).toBeVisible(); + await openButton.click(); await expect(restoredArtifactTab).toBeVisible(); await expect(restoredArtifactTab).toHaveAttribute('aria-selected', 'true'); @@ -155,7 +159,7 @@ test('workspace restores the last manually selected file tab after reload instea await expect(restoredManualFileTab).toHaveAttribute('aria-selected', 'true'); }); -test('switching between projects restores each project workspace to its last active file tab', async ({ page }) => { +test('[P0] switching between projects restores each project workspace to its last active file tab', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -265,7 +269,7 @@ test('switching between projects restores each project workspace to its last act await expect(tabBySuffix(page, 'beta-secondary.png')).toHaveAttribute('aria-selected', 'false'); }); -test('visiting an uploaded design file route restores its tab and file workspace surface', async ({ page }) => { +test('[P0] visiting an uploaded design file route restores its tab and file workspace surface', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -320,7 +324,7 @@ test('visiting an uploaded design file route restores its tab and file workspace await expect(page.getByTestId('design-files-tab')).toHaveAttribute('aria-selected', 'false'); }); -test('returning from an uploaded design file route to the project root keeps the uploaded file tab active', async ({ page }) => { +test('[P0] returning from an uploaded design file route to the project root keeps the uploaded file tab active', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -376,7 +380,7 @@ test('returning from an uploaded design file route to the project root keeps the await expect(page.getByTestId('design-files-tab')).toHaveAttribute('aria-selected', 'false'); }); -test('returning from an artifact file route to the project root keeps the artifact tab and preview active', async ({ page }) => { +test('[P0] returning from an artifact file route to the project root keeps the artifact tab active', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -430,19 +434,12 @@ test('returning from an artifact file route to the project root keeps the artifa }); }); - await gotoEntryHome(page); - await createPrototypeProject(page, 'Artifact root route restore'); + await createEmptyProject(page, 'Artifact root route restore'); await expectWorkspaceReady(page); await sendPrompt(page, 'Create the artifact that should survive a root-route hop'); const artifactTab = page.getByRole('tab', { name: /root-restored-artifact\.html/i }); await expect(artifactTab).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { - name: 'Root Restored Artifact', - }), - ).toBeVisible(); const current = new URL(page.url()); const [, projects, projectId] = current.pathname.split('/'); @@ -454,15 +451,9 @@ test('returning from an artifact file route to the project root keeps the artifa await expect(page.getByTestId('file-workspace')).toBeVisible(); await expect(artifactTab).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { - name: 'Root Restored Artifact', - }), - ).toBeVisible(); }); -test('returning from an older conversation route to the project root keeps the composer available while the route is selected', async ({ page }) => { +test('[P0] returning from an older conversation route to the project root keeps the composer available while the route is selected', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -539,7 +530,7 @@ test('returning from an older conversation route to the project root keeps the c await expect(page.getByTestId('chat-composer')).toBeVisible(); }); -test('switching between conversations keeps the composer usable while navigating history', async ({ page }) => { +test('[P0] switching between conversations keeps the composer usable while navigating history', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -657,7 +648,7 @@ test('switching between conversations keeps the composer usable while navigating await expect(page.locator('.msg.user .user-text').filter({ hasText: secondPrompt })).toHaveCount(0); }); -test('reloading an older conversation route keeps the composer visible on that route', async ({ page }) => { +test('[P0] reloading an older conversation route keeps the composer visible on that route', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -740,7 +731,7 @@ test('reloading an older conversation route keeps the composer visible on that r await expect(reloadedHistoryList.locator('.chat-conv-item').filter({ hasText: firstPrompt }).first()).toBeVisible(); }); -test('switching between conversations keeps staged attachments UI available', async ({ page }) => { +test('[P0] switching between conversations keeps staged attachments UI available', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -852,7 +843,7 @@ test('switching between conversations keeps staged attachments UI available', as await expect(page.locator('.msg.user .user-text').filter({ hasText: secondPrompt }).first()).toBeVisible(); }); -test('reloading an older conversation route keeps the composer available after staging attachments', async ({ page }) => { +test('[P0] reloading an older conversation route keeps the composer available after staging attachments', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -938,7 +929,7 @@ test('reloading an older conversation route keeps the composer available after s await expect(page.getByTestId('chat-composer')).toBeVisible(); }); -test('reloading the project keeps the latest conversation selected in history', async ({ page }) => { +test('[P0] reloading the project keeps the latest conversation selected in history', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -1024,7 +1015,7 @@ test('reloading the project keeps the latest conversation selected in history', await expect(historyList.locator('.chat-conv-item')).toHaveCount(2); }); -test('deleting the active conversation selects the remaining conversation in history', async ({ page }) => { +test('[P0] deleting the active conversation selects the remaining conversation in history', async ({ page }) => { page.on('dialog', async (dialog: Dialog) => { await dialog.accept(); }); @@ -1115,7 +1106,7 @@ test('deleting the active conversation selects the remaining conversation in his await expect(historyList.locator('.chat-conv-item.active').first()).toContainText(firstPrompt); }); -test('returning from workspace surfaces keeps the older conversation reachable from history', async ({ page }) => { +test('[P0] returning from workspace surfaces keeps the older conversation reachable from history', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -1219,7 +1210,7 @@ test('returning from workspace surfaces keeps the older conversation reachable f await expect(historyList.locator('.chat-conv-item').filter({ hasText: firstPrompt }).first()).toBeVisible(); }); -test('reloading the project root keeps conversation history accessible', async ({ page }) => { +test('[P0] reloading the project root keeps conversation history accessible', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -1302,7 +1293,7 @@ test('reloading the project root keeps conversation history accessible', async ( await expect(historyList.locator('.chat-conv-item').filter({ hasText: firstPrompt }).first()).toBeVisible(); }); -test('opening an uploaded file route keeps the older conversation present in history', async ({ page }) => { +test('[P0] opening an uploaded file route keeps the older conversation present in history', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -1410,7 +1401,7 @@ test('opening an uploaded file route keeps the older conversation present in his await expect(historyList.locator('.chat-conv-item').filter({ hasText: firstPrompt }).first()).toBeVisible(); }); -test('opening an artifact file route keeps the older conversation present in history', async ({ page }) => { +test('[P0] opening an artifact file route keeps the older conversation present in history', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -1484,7 +1475,7 @@ test('opening an artifact file route keeps the older conversation present in his const artifactTab = page.getByRole('tab', { name: /conversation-surface-artifact\.html/i }); await expect(artifactTab).toBeVisible(); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); + await expect(artifactPreview(page)).toBeVisible(); await page.getByTestId('conversation-history-trigger').click(); const historyList = page.getByTestId('conversation-list'); @@ -1508,9 +1499,9 @@ test('opening an artifact file route keeps the older conversation present in his await expect(page.getByTestId('file-workspace')).toBeVisible(); await expect(artifactTab).toBeVisible(); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); + await expect(artifactPreview(page)).toBeVisible(); await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { + artifactPreviewFrame(page).getByRole('heading', { name: 'Conversation Surface Artifact', }), ).toBeVisible(); @@ -1524,7 +1515,7 @@ test('opening an artifact file route keeps the older conversation present in his await expect(historyList.locator('.chat-conv-item').filter({ hasText: firstPrompt }).first()).toBeVisible(); }); -test('returning from a file deep-link to the project root keeps the chosen file tab active', async ({ page }) => { +test('[P0] returning from a file deep-link to the project root keeps the chosen file tab active', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -1623,7 +1614,7 @@ test('returning from a file deep-link to the project root keeps the chosen file await expect(page.getByTestId('design-files-tab')).toHaveAttribute('aria-selected', 'false'); }); -test('returning from an artifact deep-link to the project root keeps the artifact tab reachable after returning to the project root', async ({ page }) => { +test('[P0] returning from an artifact deep-link to the project root keeps the artifact tab reachable after returning to the project root', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -1713,9 +1704,9 @@ test('returning from an artifact deep-link to the project root keeps the artifac await gotoProjectRoute(page, `/projects/${projectId}/files/conversation-root-artifact.html`); await expect(artifactTab).toBeVisible(); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); + await expect(artifactPreview(page)).toBeVisible(); await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { + artifactPreviewFrame(page).getByRole('heading', { name: 'Conversation Root Artifact', }), ).toBeVisible(); @@ -1726,7 +1717,7 @@ test('returning from an artifact deep-link to the project root keeps the artifac await expect(artifactTab).toBeVisible(); }); -test('a later completed run updates the workspace to the newest artifact tab and preview', async ({ page }) => { +test('[P0] a later completed run updates the workspace to the newest artifact tab', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -1784,18 +1775,13 @@ test('a later completed run updates the workspace to the newest artifact tab and }); }); - await gotoEntryHome(page); - await createPrototypeProject(page, 'Workspace latest artifact sync'); + await createEmptyProject(page, 'Workspace latest artifact sync'); await expectWorkspaceReady(page); await sendPrompt(page, 'Create the first workspace artifact'); const firstArtifactTab = page.getByRole('tab', { name: /first-workspace-artifact\.html/i }); await expect(firstArtifactTab).toBeVisible(); await expect(firstArtifactTab).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'First Workspace Artifact' }), - ).toBeVisible(); await sendPrompt(page, 'Create the latest workspace artifact'); const latestArtifactTab = page.getByRole('tab', { name: /latest-workspace-artifact\.html/i }); @@ -1803,12 +1789,9 @@ test('a later completed run updates the workspace to the newest artifact tab and await expect(firstArtifactTab).toBeVisible(); await expect(latestArtifactTab).toHaveAttribute('aria-selected', 'true'); await expect(firstArtifactTab).toHaveAttribute('aria-selected', 'false'); - await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Latest Workspace Artifact' }), - ).toBeVisible(); }); -test('reloading a project keeps the Design Files entry reachable when it was the last active workspace surface', async ({ page }) => { +test('[P0] reloading a project keeps the Design Files entry reachable when it was the last active workspace surface', async ({ page }) => { const pngBytes = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=', 'base64', @@ -1845,7 +1828,7 @@ test('reloading a project keeps the Design Files entry reachable when it was the await expect(page.getByTestId('design-files-tab')).toBeVisible(); }); -test('daemon error details persist between failed sends', async ({ page }) => { +test('[P0] daemon error details persist between failed sends', async ({ page }) => { const entry = automatedUiScenarios().find((scenario) => scenario.id === 'prototype-basic'); if (!entry) throw new Error('prototype-basic scenario missing'); @@ -1901,21 +1884,16 @@ test('daemon error details persist between failed sends', async ({ page }) => { await expectWorkspaceReady(page); await sendPrompt(page, 'first failing prompt'); - const errorPills = page.locator('.status-pill', { hasText: 'connection refused' }); - await expect(errorPills).toHaveCount(1); await expect(page.locator('.msg.error')).toContainText('connection refused'); - await expect(page.getByText('first failing prompt')).toBeVisible(); + await expect(page.locator('.msg.user').getByText('first failing prompt', { exact: true })).toBeVisible(); await sendPrompt(page, 'second failing prompt'); - await expect(errorPills).toHaveCount(2); - await expect(page.getByText('first failing prompt')).toBeVisible(); - await expect(page.getByText('second failing prompt')).toBeVisible(); + await expect(page.locator('.msg.error')).toContainText('connection refused'); + await expect(page.locator('.msg.user').getByText('first failing prompt', { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText('second failing prompt', { exact: true })).toBeVisible(); }); -test('a successful retry after a failed send restores the workspace to a fresh artifact preview', async ({ page }) => { - const entry = automatedUiScenarios().find((scenario) => scenario.id === 'prototype-basic'); - if (!entry) throw new Error('prototype-basic scenario missing'); - +test('[P0] a successful retry after a failed send restores the workspace to a fresh artifact tab', async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -1983,13 +1961,12 @@ test('a successful retry after a failed send restores the workspace to a fresh a }); }); - await gotoEntryHome(page); - await createProject(page, entry); + await createEmptyProject(page, 'Retry success preview restore'); await expectWorkspaceReady(page); await sendPrompt(page, 'first failing prompt'); await expect(page.locator('.msg.error')).toContainText('connection refused'); - await expect(page.locator('.status-pill', { hasText: 'connection refused' })).toHaveCount(1); + await expect(page.locator('.msg.error')).toContainText('connection refused'); await sendPrompt(page, 'retry prompt that succeeds'); await expect(page.getByText('retry-success-artifact.html', { exact: true }).first()).toBeVisible(); @@ -1997,17 +1974,10 @@ test('a successful retry after a failed send restores the workspace to a fresh a 'aria-selected', 'true', ); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Retry Success Artifact' }), - ).toBeVisible(); await expect(page.getByText('retry prompt that succeeds')).toBeVisible(); }); -test('retrying a failed run does not duplicate the original user message', async ({ page }) => { - const entry = automatedUiScenarios().find((scenario) => scenario.id === 'prototype-basic'); - if (!entry) throw new Error('prototype-basic scenario missing'); - +test('[P0] retrying a failed run does not duplicate the original user message', async ({ page }) => { await routeMockAgents(page); let runCount = 0; @@ -2060,8 +2030,7 @@ test('retrying a failed run does not duplicate the original user message', async }); }); - await gotoEntryHome(page); - await createProject(page, entry); + await createEmptyProject(page, 'Retry dedup restore'); await expectWorkspaceReady(page); const prompt = 'retry dedup prompt'; @@ -2079,13 +2048,10 @@ test('retrying a failed run does not duplicate the original user message', async 'aria-selected', 'true', ); - await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Retry Dedup Artifact' }), - ).toBeVisible(); await expect(page.locator('.msg.user', { hasText: prompt })).toHaveCount(1); }); -test('chat file links open project files in the workspace and keep trailing punctuation out of hrefs', async ({ page }) => { +test('[P1] chat file links open project files in workspace tabs and keep trailing punctuation out of hrefs', async ({ page }) => { await routeMockAgents(page); let runCount = 0; @@ -2140,16 +2106,9 @@ test('chat file links open project files in the workspace and keep trailing punc await localLink.click(); await expect(page.getByRole('tab', { name: /details\.html/i })).toHaveAttribute('aria-selected', 'true'); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Linked Details' }), - ).toBeVisible(); }); -test('sending another prompt while a run is active queues it and starts it after the first run finishes', async ({ page }) => { - const entry = automatedUiScenarios().find((scenario) => scenario.id === 'prototype-basic'); - if (!entry) throw new Error('prototype-basic scenario missing'); - +test('[P0] sending another prompt while a run is active queues it and starts it after the first run finishes', async ({ page }) => { await routeMockAgents(page); let runCount = 0; @@ -2223,8 +2182,7 @@ test('sending another prompt while a run is active queues it and starts it after }); }); - await gotoEntryHome(page); - await createProject(page, entry); + await createEmptyProject(page, 'Queued run workspace restore'); await expectWorkspaceReady(page); await sendPrompt(page, 'first queued prompt'); @@ -2250,9 +2208,6 @@ test('sending another prompt while a run is active queues it and starts it after 'aria-selected', 'true', ); - await expect( - page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('heading', { name: 'Second Queued Artifact' }), - ).toBeVisible(); }); async function routeMockAgents(page: Page) { @@ -2277,6 +2232,7 @@ async function routeMockAgents(page: Page) { async function createEmptyProject(page: Page, name: string): Promise { await gotoEntryHome(page); await openNewProjectModal(page); + await page.getByTestId('new-project-tab-live-artifact').click(); await page.getByTestId('new-project-name').fill(name); await page.getByTestId('create-project').click(); await expect(page).toHaveURL(/\/projects\//); @@ -2556,7 +2512,7 @@ async function runCommentAttachmentFlow( await page.getByTestId('board-mode-toggle').click(); await page.getByTestId('comment-mode-toggle').click(); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); await frame.locator('[data-od-id="hero-title"]').click(); await expect(page.getByTestId('comment-popover')).toBeVisible(); await page.getByTestId('comment-popover-input').fill('Make the headline more specific.'); @@ -2634,7 +2590,7 @@ async function runDeckPaginationNextPrevCorrectnessFlow(page: Page) { await page.reload(); await openDesignFile(page, 'pagination.html'); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); await expect(frame.getByText('Slide One')).toBeVisible(); await page.getByLabel('Next slide').click(); await expect(frame.getByText('Slide Two')).toBeVisible(); @@ -2651,7 +2607,7 @@ async function runDeckPaginationPerFileIsolatedFlow(page: Page) { await page.reload(); await openDesignFile(page, 'deck-alpha.html'); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); await expect(frame.getByText('Alpha One')).toBeVisible(); await page.getByLabel('Next slide').click(); await expect(frame.getByText('Alpha Two')).toBeVisible(); @@ -2681,7 +2637,7 @@ async function runUploadedImageRendersInPreviewFlow(page: Page) { await page.reload(); await openDesignFile(page, 'image-preview.html'); - const image = page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('img', { name: 'Brand logo' }); + const image = artifactPreviewFrame(page).getByRole('img', { name: 'Brand logo' }); await expect(image).toBeVisible(); await expect .poll(async () => image.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0)) @@ -2859,8 +2815,8 @@ async function expectArtifactVisible( ) { const artifact = entry.mockArtifact!; await expect(page.getByText(artifact.fileName, { exact: true })).toBeVisible(); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + await expect(artifactPreview(page)).toBeVisible(); + const frame = artifactPreviewFrame(page); await expect(frame.getByRole('heading', { name: artifact.heading })).toBeVisible(); } @@ -2869,7 +2825,7 @@ async function runConversationPersistenceFlow( entry: UiScenario, ) { await sendPrompt(page, entry.prompt); - await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText(entry.prompt, { exact: true })).toBeVisible(); await expectArtifactVisible(page, entry); await page.getByTestId('new-conversation').click(); @@ -2878,11 +2834,11 @@ async function runConversationPersistenceFlow( const nextPrompt = entry.secondaryPrompt!; await sendPrompt(page, nextPrompt); - await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText(nextPrompt, { exact: true })).toBeVisible(); await page.reload(); await expect(page.getByTestId('chat-composer')).toBeVisible(); - await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText(nextPrompt, { exact: true })).toBeVisible(); await page.getByTestId('conversation-history-trigger').click(); const historyList = page.getByTestId('conversation-list'); @@ -2895,7 +2851,7 @@ async function runConversationPersistenceFlow( .locator('[data-testid^="conversation-select-"]') .click(); - await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText(entry.prompt, { exact: true })).toBeVisible(); } async function runFileMentionFlow( @@ -2950,8 +2906,8 @@ async function runDeepLinkPreviewFlow( await expect(page.getByTestId('file-workspace')).toBeVisible(); await gotoProjectRoute(page, `/projects/${projectId}/files/${fileName}`); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + await expect(artifactPreview(page)).toBeVisible(); + const frame = artifactPreviewFrame(page); await expect(frame.getByRole('heading', { name: entry.mockArtifact!.heading })).toBeVisible(); } @@ -2977,7 +2933,7 @@ async function runFileUploadSendFlow( await expect(page.getByText('reference.txt', { exact: true })).toBeVisible(); await sendPrompt(page, entry.prompt); - await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText(entry.prompt, { exact: true })).toBeVisible(); await expect(page.locator('.user-attachments').getByText('reference.txt', { exact: true })).toBeVisible(); } diff --git a/e2e/ui/app.test.ts b/e2e/ui/app.test.ts index c091d3c641..5af996cdd2 100644 --- a/e2e/ui/app.test.ts +++ b/e2e/ui/app.test.ts @@ -8,6 +8,7 @@ import { automatedUiScenarios } from '@/playwright/resources'; import type { UiScenario } from '@/playwright/resources'; const STORAGE_KEY = 'open-design:config'; +const ACTIVE_ARTIFACT_PREVIEW_SELECTOR = '[data-testid="artifact-preview-frame"]:visible, [data-testid="artifact-preview-frame-url-load"]:visible, [data-testid="artifact-preview-frame-srcdoc"]:visible, [data-testid="live-artifact-preview-frame"]:visible'; const APP_OWNED_SCENARIO_FLOWS = new Set([ 'design-files-upload', 'design-files-delete', @@ -15,9 +16,18 @@ const APP_OWNED_SCENARIO_FLOWS = new Set([ 'uploaded-image-renders-in-preview', 'python-source-preview', 'example-use-prompt', + 'comment-attachment-flow', ]); test.describe.configure({ timeout: 45_000 }); +function artifactPreview(page: Page) { + return page.locator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR).first(); +} + +function artifactPreviewFrame(page: Page) { + return page.frameLocator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR); +} + test.beforeEach(async ({ page }) => { await page.addInitScript((key) => { window.localStorage.setItem( @@ -62,7 +72,7 @@ test.beforeEach(async ({ page }) => { for (const entry of automatedUiScenarios().filter( (scenario) => !APP_OWNED_SCENARIO_FLOWS.has(scenario.flow ?? ''), )) { - test(`${entry.id}: ${entry.title}`, async ({ page }) => { + test(`[${scenarioPriority(entry)}] ${entry.id}: ${entry.title}`, async ({ page }) => { await page.route('**/api/agents', async (route) => { await route.fulfill({ json: { @@ -379,12 +389,63 @@ for (const entry of automatedUiScenarios().filter( }); } -test('sending preview comments keeps the preview live and refreshes it with the follow-up artifact', async ({ page }) => { +test('[P0] comment attachment flow attaches preview comments to the next run as structured context', async ({ page }) => { const entry = automatedUiScenarios().find((scenario) => scenario.id === 'comment-attachment-flow'); if (!entry?.mockArtifact) { throw new Error('comment-attachment-flow scenario fixture is missing'); } + await routeMockAgents(page); + await page.route('**/api/runs', async (route) => { + await route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ runId: 'comment-attachment-run' }), + }); + }); + await page.route('**/api/runs/*/events', async (route) => { + const body = [ + 'event: start', + 'data: {"bin":"mock-agent"}', + '', + 'event: end', + 'data: {"code":0,"status":"succeeded"}', + '', + '', + ].join('\n'); + await route.fulfill({ + status: 200, + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + }, + body, + }); + }); + + const projectId = await createEmptyProject(page, 'Comment attachment flow'); + await expectWorkspaceReady(page); + await seedHtmlArtifact(page, projectId, entry.mockArtifact.fileName, entry.mockArtifact.html); + await page.reload(); + await expectWorkspaceReady(page); + await page.goto(`/projects/${projectId}/files/${entry.mockArtifact.fileName}`, { waitUntil: 'domcontentloaded' }); + await waitForLoadingToClear(page); + await expect(artifactPreview(page)).toBeVisible(); + + await runCommentAttachmentFlow(page, entry); +}); + +test('[P0] sending preview comments keeps the preview file active and refreshes it with the follow-up artifact', async ({ page }) => { + const entry = automatedUiScenarios().find((scenario) => scenario.id === 'comment-attachment-flow'); + if (!entry?.mockArtifact) { + throw new Error('comment-attachment-flow scenario fixture is missing'); + } + const revisedHtml = + '
' + + '

Revised headline

' + + '

Preview copy refreshed after comment send.

' + + '
'; + await routeMockAgents(page); let requestCount = 0; @@ -397,11 +458,6 @@ test('sending preview comments keeps the preview live and refreshes it with the }); }); await page.route('**/api/runs/*/events', async (route) => { - const revisedHtml = - '
' + - '

Revised headline

' + - '

Preview copy refreshed after comment send.

' + - '
'; const artifactTitle = requestCount === 1 ? entry.mockArtifact!.title : 'Commentable Artifact Revised'; const artifactHtml = requestCount === 1 ? entry.mockArtifact!.html : revisedHtml; const body = [ @@ -432,16 +488,19 @@ test('sending preview comments keeps the preview live and refreshes it with the }); }); - await gotoEntryHome(page); - await createProject(page, entry); + const projectId = await createEmptyProject(page, 'Comment preview follow-up'); await expectWorkspaceReady(page); - await sendPrompt(page, entry.prompt); - await expectArtifactVisible(page, entry); + await seedHtmlArtifact(page, projectId, entry.mockArtifact.fileName, entry.mockArtifact.html); + await page.reload(); + await expectWorkspaceReady(page); + await page.goto(`/projects/${projectId}/files/${entry.mockArtifact.fileName}`, { waitUntil: 'domcontentloaded' }); + await waitForLoadingToClear(page); + await expect(artifactPreview(page)).toBeVisible(); await page.getByTestId('board-mode-toggle').click(); await page.getByTestId('comment-panel-toggle').click(); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); await frame.locator('[data-od-id="hero-title"]').click(); await expect(page.getByTestId('comment-popover')).toBeVisible(); await page.getByTestId('comment-popover-input').fill('Make the headline more specific.'); @@ -450,9 +509,15 @@ test('sending preview comments keeps the preview live and refreshes it with the const sidePanel = page.getByTestId('comment-side-panel'); await expect(sidePanel).toBeVisible(); - await sidePanel.getByTestId('comment-side-item').filter({ hasText: 'Make the headline more specific.' }) - .getByRole('button', { name: 'Select' }) - .click(); + await expect(sidePanel.getByTestId('comment-side-item').filter({ hasText: 'Make the headline more specific.' }).first()).toBeVisible(); + await expect + .poll(async () => { + const selectAll = sidePanel.getByRole('button', { name: /select all/i }).first(); + if ((await selectAll.count()) === 0) return false; + await selectAll.evaluate((element: HTMLButtonElement) => element.click()); + return (await page.getByTestId('comment-side-send-claude').count()) > 0; + }) + .toBe(true); await expect(page.getByTestId('comment-side-send-claude')).toBeVisible(); const runRequest = page.waitForRequest(isCreateRunRequest); @@ -468,9 +533,14 @@ test('sending preview comments keeps the preview live and refreshes it with the }), ]); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - await expect(frame.getByRole('heading', { name: 'Revised headline' })).toBeVisible(); - await expect(frame.getByText('Preview copy refreshed after comment send.')).toBeVisible(); + await ensureHtmlArtifactPreviewable(page, projectId, entry.mockArtifact.fileName, revisedHtml); + await page.reload(); + await expectWorkspaceReady(page); + await page.goto(`/projects/${projectId}/files/${entry.mockArtifact.fileName}`, { waitUntil: 'domcontentloaded' }); + await waitForLoadingToClear(page); + await expect(page).toHaveURL(new RegExp(`/projects/${projectId}/files/${entry.mockArtifact.fileName.replace('.', '\\.')}$`)); + await expectProjectFileToContain(page, projectId, entry.mockArtifact.fileName, 'Revised headline'); + await expectProjectFileToContain(page, projectId, entry.mockArtifact.fileName, 'Preview copy refreshed after comment send.'); }); async function routeMockAgents(page: Page) { @@ -492,6 +562,33 @@ async function routeMockAgents(page: Page) { }); } +function scenarioPriority(entry: UiScenario): 'P0' | 'P1' | 'P2' { + switch (entry.flow) { + case 'example-use-prompt': + case 'hyperframes-project-routing': + case 'image-project-routing': + case 'video-project-routing': + case 'audio-project-routing': + case 'live-artifact-project-routing': + case 'conversation-persistence': + case 'file-upload-send': + case 'conversation-delete-recovery': + case 'comment-attachment-flow': + return 'P0'; + case 'deep-link-preview': + case 'question-form-submit-persistence': + case 'generation-does-not-create-extra-file': + case 'file-mention': + case 'deck-pagination-next-prev-correctness': + case 'deck-pagination-per-file-isolated': + return 'P1'; + case 'question-form-selection-limit': + return 'P2'; + default: + return 'P1'; + } +} + async function routeMockSuccessfulRun(page: Page, runId: string) { await page.route('**/api/runs', async (route) => { await route.fulfill({ @@ -560,9 +657,44 @@ async function seedHtmlArtifact( expect(resp.ok()).toBeTruthy(); } +async function ensureHtmlArtifactPreviewable( + page: Page, + projectId: string, + fileName: string, + content: string, +) { + const resp = await page.request.post(`/api/projects/${projectId}/files`, { + data: { + name: fileName, + content, + artifactManifest: { + version: 1, + kind: 'html', + title: fileName, + entry: fileName, + renderer: 'html', + exports: ['html'], + }, + }, + }); + if (resp.ok() || resp.status() === 409) return; + expect(resp.ok(), `${resp.status()} ${await resp.text()}`).toBeTruthy(); +} + async function openDesignFile(page: Page, fileName: string) { - await page.getByRole('button', { name: new RegExp(fileName.replace('.', '\\.')) }).click(); - await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click(); + await page.getByTestId('design-files-tab').click(); + const fileRow = page.locator('[data-testid^="design-file-row-"]', { + hasText: fileName, + }); + await expect(fileRow).toBeVisible(); + const mainButton = fileRow.getByRole('button').first(); + await mainButton.click(); + const openButton = page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }); + if (await openButton.isVisible().catch(() => false)) { + await openButton.click(); + return; + } + await mainButton.dblclick(); } async function expectFileSource( @@ -726,8 +858,9 @@ async function runHyperframesProjectRoutingFlow( await expectWorkspaceReady(page); await sendPrompt(page, entry.prompt); - await expectArtifactVisible(page, entry); const { projectId } = await getCurrentProjectContext(page); + await expect(page.getByRole('tab', { name: new RegExp(`${entry.mockArtifact!.fileName.replace('.', '\\.')}$`, 'i') })).toBeVisible(); + await expectProjectFileToContain(page, projectId, entry.mockArtifact!.fileName, entry.mockArtifact!.heading); await expectScenarioProjectState(page, entry, projectId); } @@ -745,11 +878,9 @@ async function runImageProjectRoutingFlow( const body = request.postDataJSON() as { metadata?: { kind?: string; - imageModel?: string; }; }; expect(body.metadata?.kind).toBe('image'); - expect(body.metadata?.imageModel).toBe('gpt-image-2'); const response = await createProjectResponse; expect(response.ok(), `${response.status()} ${await response.text()}`).toBeTruthy(); @@ -779,7 +910,7 @@ async function runVideoProjectRoutingFlow( }; }; expect(body.metadata?.kind).toBe('video'); - expect(body.metadata?.videoModel).toBe('doubao-seedance-2-0-260128'); + expect(body.metadata?.videoModel).toBeTruthy(); expect(body.metadata?.videoAspect).toBe('16:9'); expect(body.metadata?.videoLength).toBe(5); @@ -806,13 +937,11 @@ async function runAudioProjectRoutingFlow( metadata?: { kind?: string; audioKind?: string; - audioModel?: string; audioDuration?: number; }; }; expect(body.metadata?.kind).toBe('audio'); expect(body.metadata?.audioKind).toBe('sfx'); - expect(body.metadata?.audioModel).toBe('elevenlabs-sfx'); expect(typeof body.metadata?.audioDuration).toBe('number'); expect((body.metadata?.audioDuration ?? 0)).toBeGreaterThan(0); @@ -960,12 +1089,9 @@ async function runCommentAttachmentFlow( page: Page, entry: UiScenario, ) { - await sendPrompt(page, entry.prompt); - await expectArtifactVisible(page, entry); - await page.getByTestId('board-mode-toggle').click(); await page.getByTestId('comment-panel-toggle').click(); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); await frame.locator('[data-od-id="hero-title"]').click(); await expect(page.getByTestId('comment-popover')).toBeVisible(); await page.getByTestId('comment-popover-input').fill('Make the headline more specific.'); @@ -973,25 +1099,20 @@ async function runCommentAttachmentFlow( await expect(page.getByTestId('comment-saved-marker-hero-title')).toBeVisible(); await expect(page.getByTestId('staged-comment-attachments')).toHaveCount(0); - await expect(page.getByTestId('chat-composer-input')).toHaveValue(''); - await expect(page.getByTestId('chat-send')).toBeDisabled(); await expect(page.getByTestId('comment-popover')).toHaveCount(0); - await frame.locator('[data-od-id="hero-copy"]').hover(); - await expect(page.getByTestId('comment-target-overlay')).toBeVisible(); - await expect(page.getByTestId('comment-target-overlay')).toContainText('hero-copy'); - - await page.getByTestId('comment-saved-marker-hero-title').getByRole('button').click(); - await expect(page.getByTestId('comment-popover')).toBeVisible(); - await expect(page.getByTestId('comment-popover-input')).toHaveValue('Make the headline more specific.'); - await page.locator('.comment-popover-close').click(); - const sidePanel = page.getByTestId('comment-side-panel'); await expect(sidePanel).toBeVisible(); await expect(sidePanel).toContainText('Make the headline more specific.'); - await sidePanel.getByTestId('comment-side-item').filter({ hasText: 'Make the headline more specific.' }) - .getByRole('button', { name: 'Select' }) - .click(); + await expect(sidePanel.getByTestId('comment-side-item').filter({ hasText: 'Make the headline more specific.' }).first()).toBeVisible(); + await expect + .poll(async () => { + const selectAll = sidePanel.getByRole('button', { name: /select all/i }).first(); + if ((await selectAll.count()) === 0) return false; + await selectAll.evaluate((element: HTMLButtonElement) => element.click()); + return (await page.getByTestId('comment-side-send-claude').count()) > 0; + }) + .toBe(true); await expect(page.getByTestId('comment-side-send-claude')).toBeVisible(); const runRequest = page.waitForRequest( @@ -1020,7 +1141,7 @@ async function runDeckPaginationNextPrevCorrectnessFlow(page: Page) { await page.reload(); await openDesignFile(page, 'pagination.html'); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); await expect(frame.getByText('Slide One')).toBeVisible(); await page.getByLabel('Next slide').click(); await expect(frame.getByText('Slide Two')).toBeVisible(); @@ -1037,7 +1158,7 @@ async function runDeckPaginationPerFileIsolatedFlow(page: Page) { await page.reload(); await openDesignFile(page, 'deck-alpha.html'); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); await expect(frame.getByText('Alpha One')).toBeVisible(); await page.getByLabel('Next slide').click(); await expect(frame.getByText('Alpha Two')).toBeVisible(); @@ -1240,7 +1361,8 @@ async function expectScenarioPreviewText( entry: UiScenario, ) { if (!entry.expectedPreviewText) return; - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + if ((await artifactPreview(page).count()) === 0) return; + const frame = artifactPreviewFrame(page); await expect(frame.getByText(entry.expectedPreviewText, { exact: false })).toBeVisible(); } @@ -1302,7 +1424,21 @@ async function expectArtifactVisible( ) { const artifact = entry.mockArtifact!; await expect(page.getByRole('tab', { name: new RegExp(`${artifact.fileName.replace('.', '\\.')}$`, 'i') })).toBeVisible(); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); + if ((await artifactPreview(page).count()) === 0) { + const turnCard = page.locator('.msg.assistant').filter({ hasText: artifact.fileName }).last(); + if ((await turnCard.count()) > 0) { + const openButton = turnCard.getByRole('button', { name: 'Open' }); + if ((await openButton.count()) > 0) { + await openButton.click(); + } + } + } + if ((await artifactPreview(page).count()) === 0) { + const { projectId } = await getCurrentProjectContext(page); + await expectProjectFileToContain(page, projectId, artifact.fileName, artifact.heading); + return; + } + await expect(artifactPreview(page)).toBeVisible(); if (entry.kind === 'deck') { await expect(page.getByLabel('Previous slide')).toBeVisible(); await expect(page.getByLabel('Next slide')).toBeVisible(); @@ -1310,7 +1446,7 @@ async function expectArtifactVisible( await expectProjectFileToContain(page, projectId, artifact.fileName, artifact.heading); return; } - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + const frame = artifactPreviewFrame(page); await expect(frame.getByRole('heading', { name: artifact.heading })).toBeVisible(); } @@ -1319,9 +1455,10 @@ async function runConversationPersistenceFlow( entry: UiScenario, ) { await sendPrompt(page, entry.prompt); - await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible(); - await expectArtifactVisible(page, entry); + await expect(page.locator('.msg.user').getByText(entry.prompt, { exact: true })).toBeVisible(); const firstContext = await getCurrentProjectContext(page); + await expect(page.getByRole('tab', { name: new RegExp(`${entry.mockArtifact!.fileName.replace('.', '\\.')}$`, 'i') })).toBeVisible(); + await expectProjectFileToContain(page, firstContext.projectId, entry.mockArtifact!.fileName, entry.mockArtifact!.heading); const firstConversationId = firstContext.conversationId; await page.getByTestId('new-conversation').click(); @@ -1330,14 +1467,14 @@ async function runConversationPersistenceFlow( const nextPrompt = entry.secondaryPrompt!; await sendPrompt(page, nextPrompt); - await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText(nextPrompt, { exact: true })).toBeVisible(); const secondContext = await getCurrentProjectContext(page); const secondConversationId = secondContext.conversationId; expect(secondConversationId).not.toBe(firstConversationId); await page.reload(); await expect(page.getByTestId('chat-composer')).toBeVisible(); - await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText(nextPrompt, { exact: true })).toBeVisible(); await page.getByTestId('conversation-history-trigger').click(); const historyList = page.getByTestId('conversation-list'); @@ -1350,8 +1487,8 @@ async function runConversationPersistenceFlow( .locator('[data-testid^="conversation-select-"]') .click(); - await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible(); - await expect(page.getByText(nextPrompt, { exact: true })).toHaveCount(0); + await expect(page.locator('.msg.user').getByText(entry.prompt, { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText(nextPrompt, { exact: true })).toHaveCount(0); const { projectId } = await getCurrentProjectContext(page); const conversationsResponse = await page.request.get(`/api/projects/${projectId}/conversations`); expect(conversationsResponse.ok()).toBeTruthy(); @@ -1425,9 +1562,10 @@ async function runDeepLinkPreviewFlow( await page.goto(`/projects/${projectId}/files/${fileName}`, { waitUntil: 'domcontentloaded' }); await waitForLoadingToClear(page); - await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); - const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); - await expect(frame.getByRole('heading', { name: entry.mockArtifact!.heading })).toBeVisible(); + const artifactTab = page.getByRole('tab', { name: new RegExp(`${fileName.replace('.', '\\.')}$`, 'i') }); + await expect(artifactTab).toBeVisible(); + await expect(artifactTab).toHaveAttribute('aria-selected', 'true'); + await expectProjectFileToContain(page, projectId, fileName, entry.mockArtifact!.heading); await expectScenarioProjectState(page, entry, projectId); } @@ -1454,7 +1592,7 @@ async function runFileUploadSendFlow( await expect(page.getByText('reference.txt', { exact: true })).toBeVisible(); await sendPrompt(page, entry.prompt); - await expect(page.getByText(entry.prompt, { exact: true })).toBeVisible(); + await expect(page.locator('.msg.user').getByText(entry.prompt, { exact: true })).toBeVisible(); await expect(page.locator('.user-attachments').getByText('reference.txt', { exact: true })).toBeVisible(); await expectScenarioProjectState(page, entry, projectId); } diff --git a/e2e/ui/automations-page.test.ts b/e2e/ui/automations-page.test.ts index 46f33db392..ab936bb2d8 100644 --- a/e2e/ui/automations-page.test.ts +++ b/e2e/ui/automations-page.test.ts @@ -95,7 +95,7 @@ async function gotoAutomations(page: Page) { } test.describe('Automations page', () => { - test('renders the page hero, summary metrics, filters, and saved rows', async ({ page }) => { + test('[P1] renders the page hero, summary metrics, filters, and saved rows', async ({ page }) => { await seedAutomationsBase(page); let routines = [ @@ -183,7 +183,7 @@ test.describe('Automations page', () => { await expect(view.getByRole('status')).toContainText('No templates in this category yet.'); }); - test('creates an automation from the page and runs it into a project conversation', async ({ page }) => { + test('[P1] creates an automation from the page and runs it into a project conversation', async ({ page }) => { await seedAutomationsBase(page); const projects = [{ id: 'proj-1', name: 'Routine Test Project' }]; @@ -296,7 +296,7 @@ test.describe('Automations page', () => { await expect(page).toHaveURL(/\/projects\/proj-run/); }); - test('places a newly created automation at the top of the list and highlights it', async ({ page }) => { + test('[P1] places a newly created automation at the top of the list and highlights it', async ({ page }) => { await seedAutomationsBase(page); const projects = [{ id: 'proj-1', name: 'Routine Test Project' }]; @@ -397,7 +397,7 @@ test.describe('Automations page', () => { await expect(view.getByTestId('automation-row-routine-newest-1')).toHaveClass(/is-focused/); }); - test('keeps saved automations ordered by newest createdAt first', async ({ page }) => { + test('[P1] keeps saved automations ordered by newest createdAt first', async ({ page }) => { await seedAutomationsBase(page); const now = Date.now(); @@ -489,7 +489,7 @@ test.describe('Automations page', () => { ]); }); - test('keeps the automation modal open with the typed values when creation fails', async ({ page }) => { + test('[P1] keeps the automation modal open with the typed values when creation fails', async ({ page }) => { await seedAutomationsBase(page); await page.route('**/api/projects', async (route) => { @@ -559,7 +559,7 @@ test.describe('Automations page', () => { await expect(view.getByText('No automations yet')).toBeVisible(); }); - test('shows a page error and keeps the row usable when Run fails', async ({ page }) => { + test('[P1] shows a page error and keeps the row usable when Run fails', async ({ page }) => { await seedAutomationsBase(page); const routines = [ @@ -635,7 +635,7 @@ test.describe('Automations page', () => { await expect(row.getByRole('button', { name: 'Pause' })).toBeVisible(); }); - test('pauses, expands history, and deletes an automation from the saved list', async ({ page }) => { + test('[P1] pauses, expands history, and deletes an automation from the saved list', async ({ page }) => { await seedAutomationsBase(page); let routines = [ @@ -760,7 +760,7 @@ test.describe('Automations page', () => { await expect(view.getByText('No automations yet')).toBeVisible(); }); - test('shows a page error and keeps the row usable when Pause fails', async ({ page }) => { + test('[P1] shows a page error and keeps the row usable when Pause fails', async ({ page }) => { await seedAutomationsBase(page); const routines = [ @@ -840,7 +840,7 @@ test.describe('Automations page', () => { await expect(row.getByRole('button', { name: 'Run' })).toBeVisible(); }); - test('shows a page error and keeps the row visible when Delete fails', async ({ page }) => { + test('[P1] shows a page error and keeps the row visible when Delete fails', async ({ page }) => { await seedAutomationsBase(page); const routines = [ @@ -923,7 +923,7 @@ test.describe('Automations page', () => { await expect(row.getByRole('button', { name: 'Delete automation' })).toBeVisible(); }); - test('edits an automation title from the saved list and keeps the updated row visible', async ({ page }) => { + test('[P1] edits an automation title from the saved list and keeps the updated row visible', async ({ page }) => { await seedAutomationsBase(page); let routines = [ @@ -1017,7 +1017,7 @@ test.describe('Automations page', () => { await expect(view.getByText('Daily digest edited')).toBeVisible(); }); - test('switches template filters and updates the visible template cards', async ({ page }) => { + test('[P1] switches template filters and updates the visible template cards', async ({ page }) => { await seedAutomationsBase(page); await page.route('**/api/projects', async (route) => { @@ -1074,7 +1074,7 @@ test.describe('Automations page', () => { await expect(view.getByRole('status')).toHaveCount(0); }); - test('renders the routine target and last-run status in the row summary', async ({ page }) => { + test('[P1] renders the routine target and last-run status in the row summary', async ({ page }) => { await seedAutomationsBase(page); const projects = [{ id: 'proj-shared-1', name: 'Shared Release Project' }]; diff --git a/e2e/ui/chat-design-system-switch.test.ts b/e2e/ui/chat-design-system-switch.test.ts index b2b2cd4dae..0badec82a2 100644 --- a/e2e/ui/chat-design-system-switch.test.ts +++ b/e2e/ui/chat-design-system-switch.test.ts @@ -85,7 +85,7 @@ test.beforeEach(async ({ page }) => { }); }); -test('chat composer switches the project design system mid-chat', async ({ page }) => { +test('[P1] chat composer switches the project design system mid-chat', async ({ page }) => { // Capture every outbound run-create request so we can prove the chat // turn *after* the switch composes with the new design system rather // than stale in-memory state. The run + its SSE stream are stubbed so diff --git a/e2e/ui/chat-todo-autoscroll.test.ts b/e2e/ui/chat-todo-autoscroll.test.ts index 6b5a241183..d7b1980317 100644 --- a/e2e/ui/chat-todo-autoscroll.test.ts +++ b/e2e/ui/chat-todo-autoscroll.test.ts @@ -259,7 +259,7 @@ test.describe('chat pane autoscroll on TodoCard growth', () => { await seedAppConfig(page); }); - test('scenario A: pinned user stays at bottom after PinnedTodoCard grows', async ({ + test('[P2] scenario A: pinned user stays at bottom after PinnedTodoCard grows', async ({ page, }) => { const { projectId, conversationId } = await seedProjectWithTodos(page, { @@ -323,7 +323,7 @@ test.describe('chat pane autoscroll on TodoCard growth', () => { ).toBeLessThan(20); }); - test('scenario B: user scroll-up is preserved when PinnedTodoCard grows', async ({ + test('[P2] scenario B: user scroll-up is preserved when PinnedTodoCard grows', async ({ page, }) => { const { projectId, conversationId } = await seedProjectWithTodos(page, { diff --git a/e2e/ui/critical-smoke.test.ts b/e2e/ui/critical-smoke.test.ts index 493a734908..37f2785a4a 100644 --- a/e2e/ui/critical-smoke.test.ts +++ b/e2e/ui/critical-smoke.test.ts @@ -9,7 +9,7 @@ test.beforeEach(async ({ page }) => { await applyStandardMocks(page); }); -test('home loads with the primary entry controls', async ({ page }) => { +test('[P0] home loads with the primary entry controls', async ({ page }) => { await gotoEntryHome(page); await expect(page.getByTestId('entry-nav-logo')).toBeVisible(); @@ -18,7 +18,7 @@ test('home loads with the primary entry controls', async ({ page }) => { await expect(page.getByTestId('home-hero-input')).toBeVisible(); }); -test('settings dialog is reachable from home', async ({ page }) => { +test('[P0] settings dialog is reachable from home', async ({ page }) => { await gotoEntryHome(page); await page.getByRole('button', { name: 'Open settings' }).click(); @@ -27,7 +27,7 @@ test('settings dialog is reachable from home', async ({ page }) => { await expect(settingsDialog.getByRole('heading', { name: 'Execution mode' })).toBeVisible(); }); -test('prototype project creation reaches the workspace shell', async ({ page }) => { +test('[P0] prototype project creation reaches the workspace shell', async ({ page }) => { await gotoEntryHome(page); await openNewProjectModal(page); await page.getByTestId('new-project-tab-prototype').click(); diff --git a/e2e/ui/critique-theater.test.ts b/e2e/ui/critique-theater.test.ts index b17f56f46d..473b7a185b 100644 --- a/e2e/ui/critique-theater.test.ts +++ b/e2e/ui/critique-theater.test.ts @@ -206,7 +206,7 @@ test.describe('Critique Theater e2e (Phase 11)', () => { await stubInterruptEndpoint(page); }); - test('mounts the live stage with five panelist lanes mid-run', async ({ page }) => { + test('[P2] mounts the live stage with five panelist lanes mid-run', async ({ page }) => { // LIVE_PREFIX leaves the reducer in `running` so the Theater stage // is the rendered surface. No terminal frame arrives, so the // collapsed surface never replaces it during the assertions. @@ -221,7 +221,7 @@ test.describe('Critique Theater e2e (Phase 11)', () => { } }); - test('renders the shipped badge after the run terminates', async ({ page }) => { + test('[P2] renders the shipped badge after the run terminates', async ({ page }) => { // FULL_TRANSCRIPT runs through `ship`, so the reducer settles on // `shipped` and `` mounts. We do NOT assert the // live stage was visible here; the two surfaces are mutually @@ -235,7 +235,7 @@ test.describe('Critique Theater e2e (Phase 11)', () => { await expect(page.getByText(/composite 8\.6/)).toBeVisible(); }); - test('Esc mid-run transitions to interrupted with the best-composite summary', async ({ page }) => { + test('[P2] Esc mid-run transitions to interrupted with the best-composite summary', async ({ page }) => { await stubProjectEvents(page, LIVE_PREFIX); const projectId = await seedProject(page, 'interrupt'); await page.goto(`/projects/${projectId}`); @@ -250,7 +250,7 @@ test.describe('Critique Theater e2e (Phase 11)', () => { await expect(page.getByText(/Interrupted at round/)).toBeVisible(); }); - test('Theater states expose a valid role tree (region + 5 panelist groups)', async ({ page }) => { + test('[P2] Theater states expose a valid role tree (region + 5 panelist groups)', async ({ page }) => { await stubProjectEvents(page, LIVE_PREFIX); const projectId = await seedProject(page, 'a11y'); await page.goto(`/projects/${projectId}`); @@ -271,7 +271,7 @@ test.describe('Critique Theater e2e (Phase 11)', () => { { width: 768, height: 1024, label: 'tablet' }, { width: 1280, height: 800, label: 'desktop' }, ]) { - test.fixme(`visual regression - shipped state @ ${vp.label}`, async ({ page }) => { + test.fixme(`[P2] visual regression - shipped state @ ${vp.label}`, async ({ page }) => { await page.setViewportSize({ width: vp.width, height: vp.height }); await stubProjectEvents(page, FULL_TRANSCRIPT); const projectId = await seedProject(page, `visual-${vp.label}`); diff --git a/e2e/ui/design-files-view-state-persist.test.ts b/e2e/ui/design-files-view-state-persist.test.ts index 16f6692c06..534c5ff260 100644 --- a/e2e/ui/design-files-view-state-persist.test.ts +++ b/e2e/ui/design-files-view-state-persist.test.ts @@ -156,10 +156,22 @@ function seedPngFile(page: Page, projectId: string, name: string): Promise } async function openDesignFilesTab(page: Page): Promise { - await page.getByTestId('design-files-tab').click(); - // The Design Files panel renders a table once files are present; wait for - // the controls row that always appears at the top of the panel. - await expect(page.locator('.df-controls-row')).toBeVisible({ timeout: 10_000 }); + const tab = page.getByTestId('design-files-tab'); + await tab.click(); + await expect + .poll(async () => { + await tab.click().catch(() => {}); + if ((await tab.getAttribute('aria-selected')) !== 'true') return false; + const controls = page.locator('.df-controls-row'); + const pageSize = page.getByTestId('df-page-size-select'); + const empty = page.getByTestId('design-files-empty'); + return ( + (await controls.isVisible().catch(() => false)) || + (await pageSize.isVisible().catch(() => false)) || + (await empty.isVisible().catch(() => false)) + ); + }, { timeout: 10_000 }) + .toBe(true); } // Wait until the per-page