fix(web): 补齐 iframe 与分栏交互状态
This commit is contained in:
@@ -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 端点负责换票;页面不拼接 ticket、cookie 或不安全目标地址。</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)));
|
||||
});
|
||||
Reference in New Issue
Block a user