fix(web): 补齐 iframe 与分栏交互状态

This commit is contained in:
root
2026-07-13 05:14:28 +02:00
parent dafa6ed1d2
commit f5ab012925
5 changed files with 275 additions and 9 deletions
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
// SPEC: PJ2026-010405 云端控制台.
// Implementation reference: draft-2026-07-13-p0-cloud-console.
// Responsibility: 验证 bounded split workspace separator 的 ARIA 合同与键盘宽度调整。
import { mount } from "@vue/test-utils";
import { describe, expect, test, vi } from "vitest";
import SplitWorkspaceLayout from "./SplitWorkspaceLayout.vue";
describe("SplitWorkspaceLayout", () => {
test("exposes bounded vertical separator values", () => {
const wrapper = mount(SplitWorkspaceLayout, {
props: {
rightOpen: true,
leftWidth: 28,
rightWidth: 52,
minLeftWidth: 18,
maxLeftWidth: 36,
minRightWidth: 30,
maxRightWidth: 70
}
});
const separators = wrapper.findAll("[role='separator']");
expect(separators).toHaveLength(2);
expect(separators[0].attributes()).toMatchObject({
tabindex: "0",
"aria-orientation": "vertical",
"aria-valuemin": "18",
"aria-valuemax": "36",
"aria-valuenow": "28"
});
expect(separators[1].attributes()).toMatchObject({
tabindex: "0",
"aria-orientation": "vertical",
"aria-valuemin": "30",
"aria-valuemax": "70",
"aria-valuenow": "52"
});
});
test("moves each separator by two percentage points with arrow keys", async () => {
const onLeftWidth = vi.fn();
const onRightWidth = vi.fn();
const wrapper = mount(SplitWorkspaceLayout, { props: { rightOpen: true, leftWidth: 30, rightWidth: 50, "onUpdate:leftWidth": onLeftWidth, "onUpdate:rightWidth": onRightWidth } });
const separators = wrapper.findAll("[role='separator']");
await separators[0].trigger("keydown", { key: "ArrowRight" });
await separators[1].trigger("keydown", { key: "ArrowLeft" });
expect(onLeftWidth).toHaveBeenCalledWith(32);
expect(onRightWidth).toHaveBeenCalledWith(52);
});
test("clamps keyboard changes and removes collapsed separators from tab order", async () => {
const onLeftWidth = vi.fn();
const onRightWidth = vi.fn();
const wrapper = mount(SplitWorkspaceLayout, {
props: { rightOpen: true, leftWidth: 18, rightWidth: 70, leftCollapsed: true, rightCollapsed: true, "onUpdate:leftWidth": onLeftWidth, "onUpdate:rightWidth": onRightWidth }
});
const separators = wrapper.findAll("[role='separator']");
expect(separators[0].attributes("tabindex")).toBe("-1");
expect(separators[1].attributes("tabindex")).toBe("-1");
await wrapper.setProps({ leftCollapsed: false, rightCollapsed: false });
await separators[0].trigger("keydown", { key: "ArrowLeft" });
await separators[1].trigger("keydown", { key: "ArrowLeft" });
expect(onLeftWidth).toHaveBeenCalledWith(18);
expect(onRightWidth).toHaveBeenCalledWith(70);
});
});
@@ -1,3 +1,5 @@
<!-- SPEC: PJ2026-010405 云端控制台. -->
<!-- Implementation reference: draft-2026-07-13-p0-cloud-console. -->
<!-- Responsibility: Generic bounded split workspace layout for tool pages. -->
<script setup lang="ts">
@@ -44,6 +46,7 @@ const emit = defineEmits<{
const layoutRef = ref<HTMLElement | null>(null);
const resizing = ref<"left" | "right" | null>(null);
const KEYBOARD_RESIZE_STEP = 2;
const clampedLeftWidth = computed(() => clamp(props.leftWidth, props.minLeftWidth, props.maxLeftWidth));
const clampedRightWidth = computed(() => clamp(props.rightWidth, props.minRightWidth, props.maxRightWidth));
@@ -91,6 +94,19 @@ function stopResize(): void {
window.removeEventListener("pointerup", stopResize);
}
function handleResizeKey(kind: "left" | "right", event: KeyboardEvent): void {
if (kind === "left" && props.leftCollapsed) return;
if (kind === "right" && (!props.rightOpen || props.rightCollapsed)) return;
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const direction = event.key === "ArrowRight" ? 1 : -1;
if (kind === "left") {
emit("update:leftWidth", Math.round(clamp(clampedLeftWidth.value + direction * KEYBOARD_RESIZE_STEP, props.minLeftWidth, props.maxLeftWidth)));
return;
}
emit("update:rightWidth", Math.round(clamp(clampedRightWidth.value - direction * KEYBOARD_RESIZE_STEP, props.minRightWidth, props.maxRightWidth)));
}
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
@@ -115,9 +131,14 @@ function clamp(value: number, min: number, max: number): number {
class="split-workspace-resizer split-workspace-resizer-left"
:data-testid="leftResizerTestId"
role="separator"
:tabindex="leftCollapsed ? -1 : 0"
aria-orientation="vertical"
aria-label="调整左侧面板宽度"
:aria-valuemin="Math.round(minLeftWidth)"
:aria-valuemax="Math.round(maxLeftWidth)"
:aria-valuenow="Math.round(clampedLeftWidth)"
@pointerdown="startResize('left', $event)"
@keydown="handleResizeKey('left', $event)"
/>
<main class="split-workspace-main">
<slot />
@@ -127,9 +148,14 @@ function clamp(value: number, min: number, max: number): number {
class="split-workspace-resizer split-workspace-resizer-right"
:data-testid="rightResizerTestId"
role="separator"
:tabindex="rightCollapsed ? -1 : 0"
aria-orientation="vertical"
aria-label="调整右侧面板宽度"
:aria-valuemin="Math.round(minRightWidth)"
:aria-valuemax="Math.round(maxRightWidth)"
:aria-valuenow="Math.round(clampedRightWidth)"
@pointerdown="startResize('right', $event)"
@keydown="handleResizeKey('right', $event)"
/>
<aside v-if="rightOpen" class="split-workspace-pane split-workspace-right" :data-collapsed="rightCollapsed">
<slot name="right" />
@@ -176,6 +202,13 @@ function clamp(value: number, min: number, max: number): number {
cursor: col-resize;
}
.split-workspace-resizer:focus-visible {
border-color: #0f766e;
outline: 2px solid #5eead4;
outline-offset: -2px;
background-color: #ecfdf5;
}
.split-workspace-resizer-right {
opacity: 1;
}
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
// SPEC: PJ2026-010405 云端控制台.
// Implementation reference: draft-2026-07-13-p0-cloud-console.
// Responsibility: 验证 OpenCode frame URL 换票完成后仍等待 iframe load,并提供失败、超时与重试状态。
import { flushPromises, mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import OpenCodeFrameView from "./OpenCodeFrameView.vue";
vi.mock("@/config/runtime", () => ({
opencodeFrameUrl: () => "https://opencode.example.test"
}));
function successfulFrameUrlResponse(): Response {
return new Response(JSON.stringify({ url: "/opencode/proxy/?ticket=redacted" }), {
status: 200,
headers: { "content-type": "application/json" }
});
}
describe("OpenCodeFrameView", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn(async () => successfulFrameUrlResponse()));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test("waits for iframe load before reporting ready", async () => {
const wrapper = mount(OpenCodeFrameView);
await flushPromises();
expect(wrapper.get('[data-testid="opencode-load-state"]').text()).toBe("loading");
expect(wrapper.get("iframe").attributes("src")).toBe("/opencode/proxy/?ticket=redacted");
expect(wrapper.get("[role='status']").text()).toContain("OpenCode 加载中");
await wrapper.get("iframe").trigger("load");
expect(wrapper.get('[data-testid="opencode-load-state"]').text()).toBe("ready");
expect(wrapper.find("[role='status']").exists()).toBe(false);
wrapper.unmount();
});
test("shows a retry after iframe error and starts a fresh bounded load", async () => {
const wrapper = mount(OpenCodeFrameView);
await flushPromises();
await wrapper.get("iframe").trigger("error");
expect(wrapper.get('[data-testid="opencode-load-state"]').text()).toBe("error");
expect(wrapper.get("[role='status']").text()).toContain("iframe 加载失败");
expect(wrapper.find("iframe").exists()).toBe(false);
await wrapper.get("button").trigger("click");
await flushPromises();
expect(fetch).toHaveBeenCalledTimes(2);
expect(wrapper.get('[data-testid="opencode-load-state"]').text()).toBe("loading");
expect(wrapper.find("iframe").exists()).toBe(true);
wrapper.unmount();
});
test("fails a frame that does not load within the bounded timeout", async () => {
vi.useFakeTimers();
const wrapper = mount(OpenCodeFrameView);
await flushPromises();
await vi.advanceTimersByTimeAsync(15_000);
expect(wrapper.get('[data-testid="opencode-load-state"]').text()).toBe("error");
expect(wrapper.get("[role='status']").text()).toContain("iframe 加载超时");
expect(wrapper.find("iframe").exists()).toBe(false);
wrapper.unmount();
});
});
@@ -18,6 +18,7 @@ interface OpenCodeFrameUrlResponse {
const OPENCODE_FRAME_URL_ENDPOINT = "/opencode/frame-url";
const MAX_FRAME_URL_ATTEMPTS = 3;
const FRAME_LOAD_TIMEOUT_MS = 15_000;
const configuredFrameUrl = computed(() => opencodeFrameUrl());
const frameUrl = ref<string | null>(null);
@@ -25,6 +26,7 @@ const loadState = ref<LoadState>("idle");
const diagnosticText = ref("");
let activeController: AbortController | null = null;
let retryTimer: number | null = null;
let frameLoadTimer: number | null = null;
let disposed = false;
const statusText = computed(() => {
@@ -53,14 +55,14 @@ onBeforeUnmount(() => {
disposed = true;
activeController?.abort();
activeController = null;
if (retryTimer !== null) window.clearTimeout(retryTimer);
retryTimer = null;
clearRetryTimer();
clearFrameLoadTimer();
});
async function loadFrameUrl(): Promise<void> {
activeController?.abort();
if (retryTimer !== null) window.clearTimeout(retryTimer);
retryTimer = null;
clearRetryTimer();
clearFrameLoadTimer();
diagnosticText.value = "";
frameUrl.value = null;
if (!configuredFrameUrl.value) {
@@ -84,7 +86,7 @@ async function loadFrameUrl(): Promise<void> {
const body = await parseFrameUrlResponse(response);
if (response.ok && typeof body?.url === "string" && isSafeFrameUrl(body.url)) {
frameUrl.value = body.url;
loadState.value = "ready";
startFrameLoadTimer();
return;
}
const authPending = response.status === 401 || response.status === 403;
@@ -110,6 +112,42 @@ async function loadFrameUrl(): Promise<void> {
}
}
function handleFrameLoad(): void {
if (loadState.value !== "loading" || !frameUrl.value) return;
clearFrameLoadTimer();
loadState.value = "ready";
}
function handleFrameError(): void {
failFrameLoad("OpenCode iframe 加载失败,请重试。");
}
function startFrameLoadTimer(): void {
clearFrameLoadTimer();
frameLoadTimer = window.setTimeout(() => {
frameLoadTimer = null;
failFrameLoad("OpenCode iframe 加载超时,请重试。");
}, FRAME_LOAD_TIMEOUT_MS);
}
function failFrameLoad(message: string): void {
if (loadState.value !== "loading") return;
clearFrameLoadTimer();
frameUrl.value = null;
diagnosticText.value = message;
loadState.value = "error";
}
function clearRetryTimer(): void {
if (retryTimer !== null) window.clearTimeout(retryTimer);
retryTimer = null;
}
function clearFrameLoadTimer(): void {
if (frameLoadTimer !== null) window.clearTimeout(frameLoadTimer);
frameLoadTimer = null;
}
async function parseFrameUrlResponse(response: Response): Promise<OpenCodeFrameUrlResponse | null> {
try {
return await response.json() as OpenCodeFrameUrlResponse;
@@ -153,10 +191,10 @@ function isSafeFrameUrl(value: string): boolean {
<template>
<section class="opencode-frame-view" aria-label="OpenCode">
<header class="opencode-frame-header"><div><span>OPENCODE SHELL</span><strong>{{ frameOriginLabel }}</strong></div><div><span>AUTH EXCHANGE</span><strong>{{ loadState }}</strong></div><button v-if="canRetry" type="button" @click="loadFrameUrl">重试</button></header>
<header class="opencode-frame-header"><div><span>OPENCODE SHELL</span><strong>{{ frameOriginLabel }}</strong></div><div><span>LOAD STATE</span><strong data-testid="opencode-load-state">{{ loadState }}</strong></div><button v-if="canRetry" type="button" @click="loadFrameUrl">重试</button></header>
<section class="opencode-frame-surface">
<iframe v-if="frameUrl" class="opencode-frame" :src="frameUrl" title="OpenCode" allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy="same-origin" />
<div v-else class="opencode-frame-empty" role="status">
<iframe v-if="frameUrl" class="opencode-frame" :src="frameUrl" title="OpenCode" allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy="same-origin" :data-state="loadState" @load="handleFrameLoad" @error="handleFrameError" />
<div v-if="!frameUrl || loadState !== 'ready'" class="opencode-frame-empty" role="status" aria-live="polite">
<span class="opencode-frame-code" aria-hidden="true">OC</span>
<p>{{ statusText }}</p>
<small>同源 frame-url 端点负责换票页面不拼接 ticketcookie 或不安全目标地址</small>
@@ -194,7 +232,7 @@ function isSafeFrameUrl(value: string): boolean {
.opencode-frame-header span { color: #71c9c3; font: 800 8px/1 ui-monospace, monospace; letter-spacing: .12em; }
.opencode-frame-header strong { overflow: hidden; font: 750 10px/1.25 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.opencode-frame-header button { min-height: 30px; border: 1px solid #6e9da0; border-radius: 5px; background: #efffff; padding: 0 10px; color: #174d51; font-size: 10px; font-weight: 800; cursor: pointer; }
.opencode-frame-surface { display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.opencode-frame-surface { position: relative; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.opencode-frame {
flex: 1 1 auto;
@@ -205,6 +243,8 @@ function isSafeFrameUrl(value: string): boolean {
}
.opencode-frame-empty {
position: absolute;
inset: 0;
display: grid;
flex: 1 1 auto;
gap: 12px;
@@ -0,0 +1,48 @@
// SPEC: PJ2026-010405 云端控制台.
// Implementation reference: draft-2026-07-13-p0-cloud-console.
// Responsibility: 浏览器验证 OpenCode iframe load/retry 状态与 MDTODO 分栏键盘调整。
import { expect, test } from "../fixtures/test";
test("OpenCode waits for the iframe and recovers through the retry action", async ({ page }) => {
await page.addInitScript(() => {
window.HWLAB_CLOUD_WEB_CONFIG = {
...(window.HWLAB_CLOUD_WEB_CONFIG ?? {}),
opencode: { url: "/opencode" }
};
});
let frameUrlRequests = 0;
await page.route("**/opencode/frame-url", async (route) => {
frameUrlRequests += 1;
if (frameUrlRequests === 1) {
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "temporary_unavailable" }) });
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ url: "/__e2e/opencode-frame?ticket=redacted" }) });
});
await page.route("**/__e2e/opencode-frame**", async (route) => {
await route.fulfill({ status: 200, contentType: "text/html", body: "<!doctype html><title>OpenCode fixture</title><main>OpenCode ready</main>" });
});
await page.goto("/opencode");
await expect(page.getByTestId("opencode-load-state")).toHaveText("error");
await page.getByRole("button", { name: "重试" }).click();
await expect(page.locator('iframe[title="OpenCode"]')).toBeVisible();
await expect(page.getByTestId("opencode-load-state")).toHaveText("ready");
expect(frameUrlRequests).toBe(2);
});
test("MDTODO split separator is focusable and adjusts its bounded value", async ({ page }) => {
await page.goto("/projects/mdtodo");
const separator = page.getByRole("separator", { name: "调整左侧面板宽度" });
await expect(separator).toBeVisible();
await expect(separator).toHaveAttribute("aria-valuemin", "22");
await expect(separator).toHaveAttribute("aria-valuemax", "30");
const initialValue = Number(await separator.getAttribute("aria-valuenow"));
await separator.focus();
await page.keyboard.press("ArrowRight");
await expect(separator).toBeFocused();
await expect(separator).toHaveAttribute("aria-valuenow", String(Math.min(30, initialValue + 2)));
});