diff --git a/apps/web/package.json b/apps/web/package.json
index 139c6529..54f0c31f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,6 +7,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
+ "test": "vitest",
+ "test:run": "vitest run",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
@@ -71,6 +73,9 @@
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
+ "@testing-library/jest-dom": "^6.4.0",
+ "@testing-library/react": "^15.0.7",
+ "@testing-library/user-event": "^14.5.2",
"@types/bun": "latest",
"@types/node": "^24.1.0",
"@types/pg": "^8.15.4",
@@ -78,9 +83,11 @@
"@types/react-dom": "^18.2.18",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.4",
+ "jsdom": "^26.0.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tsx": "^4.7.1",
- "typescript": "^5.8.3"
+ "typescript": "^5.8.3",
+ "vitest": "^2.1.8"
}
}
diff --git a/apps/web/src/components/editor/timeline/__tests__/TimelineToolbar.test.tsx b/apps/web/src/components/editor/timeline/__tests__/TimelineToolbar.test.tsx
new file mode 100644
index 00000000..9a8ef666
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/__tests__/TimelineToolbar.test.tsx
@@ -0,0 +1,217 @@
+import React from 'react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { Timeline } from '../index'
+
+// Mock the stores and hooks
+vi.mock('@/stores/timeline-store', () => ({
+ useTimelineStore: () => ({
+ tracks: [],
+ addTrack: vi.fn(),
+ addElementToTrack: vi.fn(),
+ removeElementFromTrack: vi.fn(),
+ removeElementFromTrackWithRipple: vi.fn(),
+ selectedElements: [],
+ clearSelectedElements: vi.fn(),
+ splitElement: vi.fn(),
+ splitAndKeepLeft: vi.fn(),
+ splitAndKeepRight: vi.fn(),
+ separateAudio: vi.fn(),
+ snappingEnabled: false,
+ toggleSnapping: vi.fn(),
+ deleteElements: vi.fn(),
+ duplicateElement: vi.fn(),
+ selectAll: vi.fn(),
+ zoomLevel: 1,
+ setZoomLevel: vi.fn(),
+ dragState: { isDragging: false, draggedElement: null, dragOffset: { x: 0, y: 0 } },
+ getTotalDuration: () => 100,
+ setSelectedElements: vi.fn(),
+ toggleTrackMute: vi.fn(),
+ })
+}))
+
+vi.mock('@/stores/media-store', () => ({
+ useMediaStore: () => ({
+ mediaItems: [],
+ addMediaItem: vi.fn(),
+ })
+}))
+
+vi.mock('@/stores/project-store', () => ({
+ useProjectStore: () => ({
+ activeProject: null,
+ })
+}))
+
+const mockSeek = vi.fn()
+
+vi.mock('@/stores/playback-store', () => ({
+ usePlaybackStore: () => ({
+ currentTime: 0,
+ duration: 100,
+ seek: mockSeek,
+ setDuration: vi.fn(),
+ isPlaying: false,
+ toggle: vi.fn(),
+ }),
+}))
+
+// Mock the timeline playhead hook
+vi.mock('@/hooks/use-timeline-playhead', () => ({
+ useTimelinePlayhead: () => ({
+ playheadRef: { current: null },
+ updatePlayheadPosition: vi.fn(),
+ onPlayheadDrag: vi.fn(),
+ })
+}))
+
+vi.mock('@/hooks/use-editor-actions', () => ({
+ useEditorActions: () => ({})
+}))
+
+vi.mock('@/hooks/use-timeline-context-menu', () => ({
+ useTimelineContextMenu: () => ({
+ contextMenu: null,
+ handleContextMenu: vi.fn(),
+ closeContextMenu: vi.fn(),
+ })
+}))
+
+vi.mock('@/hooks/use-timeline-selection', () => ({
+ useTimelineSelection: () => ({
+ isSelecting: false,
+ selectionBox: null,
+ handleMouseDown: vi.fn(),
+ })
+}))
+
+vi.mock('@/hooks/use-timeline-drag-drop', () => ({
+ useTimelineDragDrop: () => ({
+ dragProps: {},
+ })
+}))
+
+// Mock UI components to avoid dependency issues
+vi.mock('../../ui/tooltip', () => ({
+ Tooltip: ({ children }: any) => children,
+ TooltipTrigger: ({ children }: any) => children,
+ TooltipContent: ({ children }: any) =>
{children}
,
+ TooltipProvider: ({ children }: any) => children,
+}))
+
+vi.mock('../../ui/button', () => ({
+ Button: ({ children, onClick, ...props }: any) => (
+
+ ),
+}))
+
+vi.mock('../../ui/scroll-area', () => ({
+ ScrollArea: ({ children }: any) => {children}
,
+}))
+
+vi.mock('lucide-react', () => ({
+ SkipBack: () => ,
+ Play: () => ,
+ Pause: () => ,
+ Scissors: () => ,
+ ArrowLeftToLine: () => ,
+ ArrowRightToLine: () => ,
+ Trash2: () => ,
+ Snowflake: () => ,
+ Copy: () => ,
+ SplitSquareHorizontal: () => ,
+ Video: () => ,
+ Music: () => ,
+ TypeIcon: () => ,
+ Magnet: () => ,
+ Link: () => ,
+ ZoomIn: () => ,
+ ZoomOut: () => ,
+}))
+
+vi.mock('@/constants/timeline', () => ({
+ TIMELINE_CONSTANTS: {
+ DEFAULT_TEXT_DURATION: 5,
+ }
+}))
+
+vi.mock('@/lib/utils', () => ({
+ cn: (...args: any[]) => args.join(' '),
+ snapTimeToFrame: (time: number) => time,
+}))
+
+// Mock timeline utilities
+vi.mock('@/lib/timeline-utils', () => ({
+ getTotalDuration: () => 100,
+ getCurrentSnapPoint: () => null,
+}))
+
+// Mock context menu components
+vi.mock('../../ui/context-menu', () => ({
+ ContextMenu: ({ children }: any) => children,
+ ContextMenuTrigger: ({ children }: any) => children,
+ ContextMenuContent: ({ children }: any) => {children}
,
+ ContextMenuItem: ({ children }: any) => {children}
,
+ ContextMenuSeparator: () => ,
+}))
+
+// Mock dialog components
+vi.mock('../../ui/dialog', () => ({
+ Dialog: ({ children }: any) => children,
+ DialogContent: ({ children }: any) => {children}
,
+ DialogHeader: ({ children }: any) => {children}
,
+ DialogTitle: ({ children }: any) => {children}
,
+}))
+
+describe('Timeline - Return to Start Button', () => {
+ beforeEach(() => {
+ mockSeek.mockClear()
+ })
+
+ it('should render Return to Start button', () => {
+ render()
+
+ // Look for the SkipBack icon which indicates our button
+ const skipBackIcon = screen.getByTestId('skip-back-icon')
+ expect(skipBackIcon).toBeInTheDocument()
+ })
+
+ it('should call seek(0) when Return to Start button is clicked', () => {
+ render()
+
+ // Find the button that contains the SkipBack icon
+ const skipBackIcon = screen.getByTestId('skip-back-icon')
+ const button = skipBackIcon.closest('button')
+
+ expect(button).toBeInTheDocument()
+ fireEvent.click(button!)
+
+ expect(mockSeek).toHaveBeenCalledWith(0)
+ })
+
+ it('should have tooltip structure for Return to Start button', () => {
+ render()
+
+ // Find the skip back button and verify it's wrapped in tooltip components
+ const skipBackIcon = screen.getByTestId('skip-back-icon')
+ const button = skipBackIcon.closest('button')
+
+ expect(button).toBeInTheDocument()
+ // The button should exist and be clickable, which means the tooltip structure is in place
+ expect(button).toHaveAttribute('data-state', 'closed') // Tooltip trigger state
+ })
+
+ it('should have SkipBack icon in Return to Start button', () => {
+ render()
+
+ const skipBackIcon = screen.getByTestId('skip-back-icon')
+ expect(skipBackIcon).toBeInTheDocument()
+
+ // Verify it's inside a button
+ const button = skipBackIcon.closest('button')
+ expect(button).toBeInTheDocument()
+ })
+})
\ No newline at end of file
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index cf5fd524..0d9660bb 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -1,5 +1,6 @@
"use client";
+import React from "react";
import { ScrollArea } from "../../ui/scroll-area";
import { Button } from "../../ui/button";
import {
@@ -984,12 +985,13 @@ function TimelineToolbar({
{isPlaying ? "Pause (Space)" : "Play (Space)"}
+ {/* Return to Start Button - Seeks timeline to 00:00:00 */}