Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ build/Release
# Dependency directories
node_modules/
jspm_packages/
.opencode/
.vite/

# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "opencode-theme-editor",
"private": true,
"version": "0.9.2",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
98 changes: 98 additions & 0 deletions src/app/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { render, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDefaultThemeDraft } from '../domain/theme/createDefaultThemeDraft'
import { clearThemeShareLocation } from '../domain/share/themeShareLink'
import { saveCurrentDraft } from '../persistence/drafts-db'
import { getHydratedDraft } from '../state/hydrateDraft'
import { App } from './App'

vi.mock('../state/hydrateDraft', () => ({
getHydratedDraft: vi.fn(),
}))

vi.mock('../persistence/drafts-db', () => ({
saveCurrentDraft: vi.fn(),
}))

vi.mock('../domain/share/themeShareLink', () => ({
clearThemeShareLocation: vi.fn(),
}))

vi.mock('../state/persistence', () => ({
DraftPersistenceBoundary: ({ children }: { children: ReactNode }) => children,
}))

vi.mock('./ThemeEditorPage', () => ({
ThemeEditorPage: ({ startupSource }: { startupSource?: string | null }) => (
<div data-testid="theme-editor-page">{startupSource ?? 'pending'}</div>
),
}))

function createDeferredPromise() {
let resolvePromise: () => void = () => {}
let rejectPromise: (reason?: unknown) => void = () => {}

const promise = new Promise<void>((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})

return {
promise,
resolve: resolvePromise,
reject: rejectPromise,
}
}

afterEach(() => {
vi.resetAllMocks()
})

describe('App', () => {
it('persists shared drafts before clearing the share url', async () => {
const sharedDraft = createDefaultThemeDraft()
const saveDeferred = createDeferredPromise()

vi.mocked(getHydratedDraft).mockResolvedValue({
draft: sharedDraft,
source: 'shared',
})
vi.mocked(saveCurrentDraft).mockReturnValue(saveDeferred.promise)

render(<App />)

await waitFor(() => {
expect(screen.getByTestId('theme-editor-page')).toHaveTextContent('shared')
})

expect(saveCurrentDraft).toHaveBeenCalledWith(sharedDraft)
expect(clearThemeShareLocation).not.toHaveBeenCalled()

saveDeferred.resolve()

await waitFor(() => {
expect(clearThemeShareLocation).toHaveBeenCalledOnce()
})
})

it('keeps the share url intact when saving the shared draft fails', async () => {
vi.mocked(getHydratedDraft).mockResolvedValue({
draft: createDefaultThemeDraft(),
source: 'shared',
})
vi.mocked(saveCurrentDraft).mockRejectedValue(new Error('save failed'))

render(<App />)

await waitFor(() => {
expect(screen.getByTestId('theme-editor-page')).toHaveTextContent('shared')
})

await waitFor(() => {
expect(saveCurrentDraft).toHaveBeenCalledOnce()
})

expect(clearThemeShareLocation).not.toHaveBeenCalled()
})
})
20 changes: 17 additions & 3 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { clearThemeShareLocation } from '../domain/share/themeShareLink'
import { saveCurrentDraft } from '../persistence/drafts-db'
import { getHydratedDraft } from '../state/hydrateDraft'
import { DraftPersistenceBoundary } from '../state/persistence'
import { ThemeStoreProvider } from '../state/theme-store'
Expand All @@ -7,18 +9,30 @@ import { ThemeEditorPage } from './ThemeEditorPage'

function AppContent() {
const { hydrateDraft } = useThemeStoreActions()
const [startupSource, setStartupSource] = useState<Awaited<ReturnType<typeof getHydratedDraft>>['source'] | null>(null)

useEffect(() => {
void getHydratedDraft().then((draft) => {
void getHydratedDraft().then(async ({ draft, source }) => {
setStartupSource(source)

if (draft) {
hydrateDraft(draft)
}

if (source === 'shared' && draft) {
try {
await saveCurrentDraft(draft)
clearThemeShareLocation()
} catch {
return
}
}
})
}, [hydrateDraft])

return (
<DraftPersistenceBoundary>
<ThemeEditorPage />
<ThemeEditorPage startupSource={startupSource} />
</DraftPersistenceBoundary>
)
}
Expand Down
57 changes: 45 additions & 12 deletions src/app/ThemeEditorPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ const mockState = vi.hoisted(() => ({

vi.mock('../domain/presets/themePresets', () => ({
REMIX_STRENGTHS: ['subtle', 'balanced', 'wild'],
THEME_PRESETS: [],
applyThemePresetToDraft: (_preset: unknown, draft: ThemeDraft) => draft,
THEME_PRESETS: [{ id: 'random-default', name: 'Random Default' }],
applyThemePresetToDraft: (preset: { name?: string }, draft: ThemeDraft) => ({
...draft,
name: preset.name ?? draft.name,
}),
createRandomSemanticModeSelection: (mode: 'dark' | 'light') => ({
name: 'Randomized',
palette: ['#112233', '#445566', '#778899'],
Expand Down Expand Up @@ -185,11 +188,11 @@ function DraftInitializer({ draft }: { draft?: ThemeDraft }) {
return null
}

function renderPage(initialDraft?: ThemeDraft) {
function renderPage(initialDraft?: ThemeDraft, startupSource?: 'default' | 'persisted' | 'shared' | null) {
return render(
<ThemeStoreProvider>
<DraftInitializer draft={initialDraft} />
<ThemeEditorPage />
<ThemeEditorPage startupSource={startupSource} />
</ThemeStoreProvider>,
)
}
Expand All @@ -202,7 +205,7 @@ afterEach(() => {

describe('ThemeEditorPage', () => {
it('switches between the editor tabs', () => {
renderPage()
renderPage(undefined, 'persisted')

expect(screen.getByTestId('preset-picker')).toBeInTheDocument()

Expand All @@ -212,7 +215,7 @@ describe('ThemeEditorPage', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Tuner' }))
expect(screen.getByTestId('advanced-editor')).toBeInTheDocument()

fireEvent.click(screen.getByRole('tab', { name: 'Advanced' }))
fireEvent.click(screen.getByRole('tab', { name: '{...}' }))
expect(screen.getByTestId('json-editor')).toBeInTheDocument()

fireEvent.click(screen.getByRole('tab', { name: 'Export' }))
Expand All @@ -228,8 +231,38 @@ describe('ThemeEditorPage', () => {
expect(screen.queryByTestId('preset-picker')).not.toBeInTheDocument()
})

it('applies a random preset on a default load and keeps Presets open', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)

renderPage(undefined, 'default')

await waitFor(() => {
expect(screen.getByLabelText('Theme name')).toHaveValue('Random Default')
})

expect(screen.getByTestId('preset-picker')).toBeInTheDocument()
expect(screen.queryByTestId('advanced-editor')).not.toBeInTheDocument()

randomSpy.mockRestore()
})

it('applies a random preset on a persisted reload and keeps Presets open', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)

renderPage(undefined, 'persisted')

await waitFor(() => {
expect(screen.getByLabelText('Theme name')).toHaveValue('Random Default')
})

expect(screen.getByTestId('preset-picker')).toBeInTheDocument()
expect(screen.queryByTestId('advanced-editor')).not.toBeInTheDocument()

randomSpy.mockRestore()
})

it('keeps the inspire tab open after applying a preset', () => {
renderPage()
renderPage(undefined, 'persisted')

fireEvent.click(screen.getByRole('button', { name: 'apply preset' }))

Expand All @@ -238,7 +271,7 @@ describe('ThemeEditorPage', () => {
})

it('keeps save actions in the editor tabs without the legacy metadata row', () => {
renderPage()
renderPage(undefined, 'persisted')

expect(screen.getByLabelText('Theme name')).toBeInTheDocument()
expect(screen.getByRole('switch', { name: 'Switch to light mode' })).toBeInTheDocument()
Expand Down Expand Up @@ -276,7 +309,7 @@ describe('ThemeEditorPage', () => {
})

it('updates the generated theme when a generated palette color changes', () => {
renderPage()
renderPage(undefined, 'persisted')

fireEvent.click(screen.getByRole('tab', { name: 'Mixer' }))
fireEvent.click(screen.getByRole('button', { name: 'generate palette' }))
Expand All @@ -286,7 +319,7 @@ describe('ThemeEditorPage', () => {
})

it('keeps the generated mixer palette stable across dark and light mode switches', async () => {
renderPage()
renderPage(undefined, 'persisted')

fireEvent.click(screen.getByRole('tab', { name: 'Mixer' }))
fireEvent.click(screen.getByRole('button', { name: 'generate palette' }))
Expand All @@ -303,7 +336,7 @@ describe('ThemeEditorPage', () => {
})

it('shows a stable extracted palette in Mixer for loaded themes without a generated palette', async () => {
renderPage()
renderPage(undefined, 'persisted')

fireEvent.click(screen.getByRole('tab', { name: 'Mixer' }))

Expand Down Expand Up @@ -334,7 +367,7 @@ describe('ThemeEditorPage', () => {

renderPage(draft)

fireEvent.click(screen.getByRole('tab', { name: 'Advanced' }))
fireEvent.click(screen.getByRole('tab', { name: '{...}' }))
fireEvent.click(screen.getByRole('button', { name: 'apply json' }))
fireEvent.click(screen.getByRole('tab', { name: 'Tuner' }))

Expand Down
Loading
Loading