DebugWorkspace
surfaceE2E 테스트 디버깅 워크스페이스. 2-pane 레이아웃 — 사이드바 테스트 목록 + 탭 콘텐츠 뷰어 (Video/Screenshots/Network/Console/Trace).
컴포넌트 의존 관계
깊이
100%
기본 사용
Debug Workspace
No test selected
Select a test from the sidebar to view details.
테스트 커버리지
2026년 2월 4일생성된 테스트 결과를 찾지 못했습니다.
DebugWorkspace 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.
테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.
DebugWorkspace Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
tests* | DebugTestItem[] | — | 사이드바 테스트 목록 데이터 |
selectedTestId | string | — | 선택된 테스트 ID |
onTestSelect | (testId: string) => void | — | 테스트 선택 핸들러 |
activeTab | DebugTabId | — | 활성 탭 (video/screenshots/network/console/trace) |
renderVideo | (test: DebugTestItem) => ReactNode | — | 비디오 탭 렌더러 |
renderScreenshots | (test: DebugTestItem) => ReactNode | — | 스크린샷 탭 렌더러 |
renderNetwork | (test: DebugTestItem) => ReactNode | — | 네트워크 탭 렌더러 |
renderConsole | (test: DebugTestItem) => ReactNode | — | 콘솔 탭 렌더러 |
renderTrace | (test: DebugTestItem) => ReactNode | — | 트레이스 탭 렌더러 |
renderErrorContext | (test: DebugTestItem) => ReactNode | — | 에러 컨텍스트 (조건부 하단 영역) |
loading | boolean | — | 로딩 상태 |
header | ReactNode | — | 커스텀 헤더 슬롯 |
actions | ReactNode | — | 액션 버튼 슬롯 |
labels | DebugWorkspaceLabels | — | 커스텀 레이블 |
className | string | — | 최외곽 CSS 클래스 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add debug-workspaceConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { DebugWorkspace } from "@/components/surfaces/debug-workspace";Registry metadata
- 설명
- E2E 테스트 디버깅 워크스페이스. 2-pane 레이아웃 — 사이드바 테스트 목록 + 탭 콘텐츠 뷰어 (Video/Screenshots/Network/Console/Trace).
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- filtertable
- Install notes
- 없음
포함 파일
debug-workspace.tsxdebug-workspace.tsx
Surface 소스 보기
debug-workspace.tsx
"use client";
import * as React from "react";
import {
SurfaceLayout,
FilterBar,
cn,
type FilterGroupDef,
} from "@reopt-ai/opt-ui";
// ── Types ──
/** Item shape for `DebugTest`. */
export interface DebugTestItem {
id: string;
name: string;
status: "passed" | "failed" | "skipped" | "flaky";
duration?: number;
group?: string;
[key: string]: unknown;
}
/** Type definition for `DebugTabId`. */
export type DebugTabId =
| "video"
| "screenshots"
| "network"
| "console"
| "trace";
/** Labels for `DebugWorkspace`. */
export interface DebugWorkspaceLabels {
title?: string;
searchPlaceholder?: string;
videoTab?: string;
screenshotsTab?: string;
networkTab?: string;
consoleTab?: string;
traceTab?: string;
testCountLabel?: (count: number) => string;
emptyTitle?: string;
emptyDescription?: string;
noSelectionTitle?: string;
noSelectionDescription?: string;
errorContextLabel?: string;
refreshLabel?: string;
}
const defaultLabels: Required<DebugWorkspaceLabels> = {
title: "Debug Workspace",
searchPlaceholder: "Search tests…",
videoTab: "Video",
screenshotsTab: "Screenshots",
networkTab: "Network",
consoleTab: "Console",
traceTab: "Trace",
testCountLabel: (count: number) => `${count} tests`,
emptyTitle: "No tests found",
emptyDescription: "Run tests to see debug results here.",
noSelectionTitle: "No test selected",
noSelectionDescription: "Select a test from the sidebar to view details.",
errorContextLabel: "Error Context",
refreshLabel: "Refresh",
};
// ── Props ──
/** Props for `DebugWorkspace`. */
export interface DebugWorkspaceProps {
/** List of test items for the sidebar */
tests: DebugTestItem[];
/** Currently selected test id */
selectedTestId?: string;
/** Called when user selects a test */
onTestSelect?: (testId: string) => void;
/** Active tab in the content viewer */
activeTab?: DebugTabId;
/** Called when active tab changes */
onTabChange?: (tabId: DebugTabId) => void;
/** Sidebar filters */
filters?: FilterGroupDef[];
/** Called when filter changes */
onFilterChange?: (
filterId: string,
value: string | string[] | boolean,
) => void;
/** Search query for test list */
searchQuery?: string;
/** Called when search changes */
onSearchChange?: (query: string) => void;
/** Tab content renderers */
renderVideo?: (test: DebugTestItem) => React.ReactNode;
renderScreenshots?: (test: DebugTestItem) => React.ReactNode;
renderNetwork?: (test: DebugTestItem) => React.ReactNode;
renderConsole?: (test: DebugTestItem) => React.ReactNode;
renderTrace?: (test: DebugTestItem) => React.ReactNode;
/** Error context content (conditional bottom section) */
renderErrorContext?: (test: DebugTestItem) => React.ReactNode;
/** Info bar above tabs for selected test */
renderTestInfo?: (test: DebugTestItem) => React.ReactNode;
/** Sidebar width */
sidebarWidth?: "sm" | "md" | "lg";
/** Refresh handler */
onRefresh?: () => void;
loading?: boolean;
header?: React.ReactNode;
actions?: React.ReactNode;
labels?: DebugWorkspaceLabels;
className?: string;
}
// ── Defaults ──
const defaultFilters: FilterGroupDef[] = [
{
id: "status",
label: "Status",
type: "multi-select",
options: [
{ id: "passed", value: "passed", label: "Passed" },
{ id: "failed", value: "failed", label: "Failed" },
{ id: "skipped", value: "skipped", label: "Skipped" },
{ id: "flaky", value: "flaky", label: "Flaky" },
],
},
];
const sidebarWidthClass = {
sm: "w-64",
md: "w-80",
lg: "w-96",
};
const statusIndicator: Record<string, string> = {
passed: "bg-success",
failed: "bg-danger",
skipped: "bg-text-tertiary",
flaky: "bg-warning",
};
// ── Component ──
/** Renders the `DebugWorkspace` component. */
export function DebugWorkspace({
tests,
selectedTestId,
onTestSelect,
activeTab: controlledActiveTab,
onTabChange,
filters = defaultFilters,
onFilterChange,
searchQuery: controlledSearchQuery,
onSearchChange,
renderVideo,
renderScreenshots,
renderNetwork,
renderConsole,
renderTrace,
renderErrorContext,
renderTestInfo,
sidebarWidth = "md",
onRefresh,
loading = false,
header,
actions,
labels: customLabels,
className,
}: DebugWorkspaceProps) {
const labels = { ...defaultLabels, ...customLabels };
const sectionId = React.useId();
// Internal state for uncontrolled usage
const [internalSelectedId, setInternalSelectedId] = React.useState<
string | undefined
>(undefined);
const [internalActiveTab, setInternalActiveTab] =
React.useState<DebugTabId>("video");
const [internalSearch, setInternalSearch] = React.useState("");
const selectedId = selectedTestId ?? internalSelectedId;
const activeTab = controlledActiveTab ?? internalActiveTab;
const searchQuery = controlledSearchQuery ?? internalSearch;
const selectedTest = tests.find((t) => t.id === selectedId);
const isEmpty = tests.length === 0;
const handleTestSelect = React.useCallback(
(testId: string) => {
setInternalSelectedId(testId);
onTestSelect?.(testId);
},
[onTestSelect],
);
const handleTabChange = React.useCallback(
(tabId: string) => {
setInternalActiveTab(tabId as DebugTabId);
onTabChange?.(tabId as DebugTabId);
},
[onTabChange],
);
const handleSearchChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setInternalSearch(e.target.value);
onSearchChange?.(e.target.value);
},
[onSearchChange],
);
// Filter tests by search query
const filteredTests = React.useMemo(() => {
if (!searchQuery) return tests;
const q = searchQuery.toLowerCase();
return tests.filter((t) => t.name.toLowerCase().includes(q));
}, [tests, searchQuery]);
// Group tests
const groupedTests = React.useMemo(() => {
const groups = new Map<string, DebugTestItem[]>();
for (const test of filteredTests) {
const group = test.group ?? "Ungrouped";
const arr = groups.get(group) ?? [];
arr.push(test);
groups.set(group, arr);
}
return groups;
}, [filteredTests]);
const tabs = React.useMemo(
() => [
{ id: "video", label: labels.videoTab },
{ id: "screenshots", label: labels.screenshotsTab },
{ id: "network", label: labels.networkTab },
{ id: "console", label: labels.consoleTab },
{ id: "trace", label: labels.traceTab },
],
[labels],
);
const errorContext =
selectedTest && renderErrorContext
? renderErrorContext(selectedTest)
: null;
return (
<SurfaceLayout
loading={loading}
className={cn("flex-row gap-0", className)}
>
{/* Header bar */}
<div className="border-border-subtle col-span-full flex items-center justify-between border-b px-4 py-3">
{header ? (
<div>{header}</div>
) : (
<h2 className="text-text-primary text-lg font-semibold">
{labels.title}
</h2>
)}
<div className="gap-element flex items-center">
{onRefresh && (
<button
type="button"
onClick={onRefresh}
className="text-text-tertiary hover:text-text-primary rounded p-1.5 text-sm transition-colors"
aria-label={labels.refreshLabel}
>
↻
</button>
)}
{actions}
</div>
</div>
{isEmpty ? (
<div
role="status"
className="col-span-full flex flex-col items-center justify-center py-16 text-center"
>
<h3 className="text-text-primary text-lg font-medium">
{labels.emptyTitle}
</h3>
<p className="text-text-tertiary mt-1 text-sm">
{labels.emptyDescription}
</p>
</div>
) : (
<>
{/* Sidebar — test list */}
<aside
className={cn(
"border-border-subtle shrink-0 overflow-y-auto border-r",
sidebarWidthClass[sidebarWidth],
)}
>
{/* Search */}
<div className="border-border-subtle border-b p-3">
<input
type="search"
value={searchQuery}
onChange={handleSearchChange}
placeholder={labels.searchPlaceholder}
className="bg-bg-subtle text-text-primary placeholder:text-text-tertiary focus-visible:ring-accent w-full rounded-md px-3 py-1.5 text-sm outline-none focus-visible:ring-1"
aria-label={labels.searchPlaceholder}
/>
</div>
{/* Filters */}
<div className="border-border-subtle border-b p-3">
<FilterBar filters={filters} onFilterChange={onFilterChange} />
</div>
{/* Test list */}
<nav aria-label={labels.title} className="flex flex-col">
{[...groupedTests.entries()].map(([group, groupTests]) => (
<div key={group}>
{groupedTests.size > 1 && (
<div className="bg-bg-subtle text-text-tertiary px-3 py-1.5 text-xs font-medium">
{group}
</div>
)}
{groupTests.map((test) => (
<button
key={test.id}
type="button"
onClick={() => handleTestSelect(test.id)}
className={cn(
"gap-element flex w-full items-center px-3 py-2 text-left text-sm transition-colors",
selectedId === test.id
? "bg-accent-subtle text-text-primary font-medium"
: "text-text-secondary hover:bg-bg-subtle",
)}
aria-current={selectedId === test.id ? "true" : undefined}
>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
statusIndicator[test.status],
)}
role="img"
aria-label={test.status}
/>
<span className="min-w-0 flex-1 truncate">
{test.name}
</span>
{test.duration != null && (
<span className="text-text-tertiary shrink-0 text-xs">
{(test.duration / 1000).toFixed(1)}s
</span>
)}
</button>
))}
</div>
))}
</nav>
{/* Test count */}
<div className="border-border-subtle text-text-tertiary border-t px-3 py-2 text-xs">
{labels.testCountLabel(filteredTests.length)}
</div>
</aside>
{/* Content — tabbed viewer */}
<div className="min-w-0 flex-1">
{selectedTest ? (
<div className="flex h-full flex-col">
{/* Test info bar */}
{renderTestInfo && (
<div className="border-border-subtle border-b px-4 py-3">
{renderTestInfo(selectedTest)}
</div>
)}
{/* Tabs */}
<section
aria-labelledby={`${sectionId}-tabs`}
className="flex flex-1 flex-col"
>
<span id={`${sectionId}-tabs`} className="sr-only">
{labels.title}
</span>
<div
role="tablist"
aria-label={labels.title}
className="border-border-subtle flex border-b"
>
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
onClick={() => handleTabChange(tab.id)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors",
activeTab === tab.id
? "border-accent text-text-primary border-b-2"
: "text-text-tertiary hover:text-text-secondary",
)}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel" className="flex-1 p-4">
{activeTab === "video" && renderVideo?.(selectedTest)}
{activeTab === "screenshots" &&
renderScreenshots?.(selectedTest)}
{activeTab === "network" && renderNetwork?.(selectedTest)}
{activeTab === "console" && renderConsole?.(selectedTest)}
{activeTab === "trace" && renderTrace?.(selectedTest)}
</div>
</section>
{/* Error context (conditional) */}
{errorContext && (
<div className="border-border-subtle border-t px-4 py-3">
<h4 className="text-text-tertiary mb-2 text-xs font-medium">
{labels.errorContextLabel}
</h4>
{errorContext}
</div>
)}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center py-16 text-center">
<h3 className="text-text-primary text-lg font-medium">
{labels.noSelectionTitle}
</h3>
<p className="text-text-tertiary mt-1 text-sm">
{labels.noSelectionDescription}
</p>
</div>
)}
</div>
</>
)}
</SurfaceLayout>
);
}
DebugWorkspace.displayName = "DebugWorkspace";