reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

DebugWorkspace

surface

E2E 테스트 디버깅 워크스페이스. 2-pane 레이아웃 — 사이드바 테스트 목록 + 탭 콘텐츠 뷰어 (Video/Screenshots/Network/Console/Trace).

컴포넌트 의존 관계

깊이
▼ USES (4)DebugWorkspacecontent-tabsfilter-bardata-tableloading-overlay
100%

기본 사용

Debug Workspace

Auth
E2E
Settings
5 tests

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[]—사이드바 테스트 목록 데이터
selectedTestIdstring—선택된 테스트 ID
onTestSelect(testId: string) => void—테스트 선택 핸들러
activeTabDebugTabId—활성 탭 (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—에러 컨텍스트 (조건부 하단 영역)
loadingboolean—로딩 상태
headerReactNode—커스텀 헤더 슬롯
actionsReactNode—액션 버튼 슬롯
labelsDebugWorkspaceLabels—커스텀 레이블
classNamestring—최외곽 CSS 클래스

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add debug-workspace

Consumer 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.tsx→debug-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";