Compare commits

...

7 Commits

Author SHA1 Message Date
6432255203 quick fix 2026-04-15 07:14:36 +01:00
0b55bc873e fix: use npx tsc in build script to ensure tsc is resolved from node_modules 2026-04-15 05:07:45 +00:00
95972b329e fix(api): handle null return type for localStorage getItem 2026-04-15 05:04:11 +00:00
1a20a1050b fix: remove redundant VITE_API_BASE fallback, use wizard config as sole source of truth for HF_BACKEND_BASE_URL 2026-04-15 05:00:56 +00:00
h z
f61c506fdb Merge pull request 'HarborForge.Frontend: proposal/essential tests on current branch head' (#11) from pr/dev-2026-03-29-frontend-tests-20260405 into main
Reviewed-on: #11
2026-04-05 22:07:46 +00:00
zhi
38ebd2bbd1 TEST-FE-PR-001: adapt proposal/essential tests for current UI 2026-04-05 20:48:47 +00:00
zhi
83c9cd8fb7 TEST-FE-PR-001: Add Proposal/Essential frontend unit tests
- Essential list rendering and display tests
- Essential create/edit/delete form tests
- Accept modal with milestone selection tests
- Story creation restriction UI tests
- Error handling tests
2026-04-05 20:48:47 +00:00
7 changed files with 605 additions and 93 deletions

1
.env
View File

@@ -1,2 +1 @@
VITE_API_BASE=http://backend:8000
VITE_WIZARD_PORT=8080

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "npx tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",

View File

@@ -22,11 +22,13 @@ import SupportDetailPage from '@/pages/SupportDetailPage'
import MeetingDetailPage from '@/pages/MeetingDetailPage'
import axios from 'axios'
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
const WIZARD_BASE = `http://127.0.0.1:${WIZARD_PORT}`
const getStoredWizardPort = (): number | null => {
const stored = Number(localStorage.getItem('HF_WIZARD_PORT'))
return stored && stored > 0 ? stored : null
}
const getApiBase = () => {
return localStorage.getItem('HF_BACKEND_BASE_URL') || import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000'
return localStorage.getItem('HF_BACKEND_BASE_URL') ?? undefined
}
type AppState = 'checking' | 'setup' | 'ready'
@@ -55,24 +57,26 @@ export default function App() {
// Backend unreachable — fall through to wizard check
}
// Fallback: try the wizard directly (needed during initial setup before backend starts)
try {
const res = await axios.get(`${WIZARD_BASE}/api/v1/config/harborforge.json`, {
timeout: 5000,
})
const cfg = res.data || {}
if (cfg.backend_url) {
localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url)
// Fallback: if a wizard port was previously saved during setup, try it directly
const storedPort = getStoredWizardPort()
if (storedPort) {
try {
const res = await axios.get(`http://127.0.0.1:${storedPort}/api/v1/config/harborforge.json`, {
timeout: 5000,
})
const cfg = res.data || {}
if (cfg.backend_url) {
localStorage.setItem('HF_BACKEND_BASE_URL', cfg.backend_url)
}
if (cfg.initialized === true) {
setAppState('ready')
return
}
} catch {
// ignore — fall through to setup
}
if (cfg.initialized === true) {
setAppState('ready')
} else {
setAppState('setup')
}
} catch {
// Neither backend nor wizard reachable → setup needed
setAppState('setup')
}
setAppState('setup')
}
if (appState === 'checking') {
@@ -80,7 +84,7 @@ export default function App() {
}
if (appState === 'setup') {
return <SetupWizardPage wizardBase={WIZARD_BASE} onComplete={checkInitialized} />
return <SetupWizardPage initialWizardPort={getStoredWizardPort()} onComplete={checkInitialized} />
}
if (loading) return <div className="loading">Loading...</div>

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import axios from 'axios'
interface Props {
wizardBase: string
initialWizardPort: number | null
onComplete: () => void
}
@@ -11,55 +11,53 @@ interface SetupForm {
admin_password: string
admin_email: string
admin_full_name: string
db_host: string
db_port: number
db_user: string
db_password: string
db_database: string
backend_base_url: string
project_name: string
project_description: string
}
const STEPS = ['Welcome', 'Database', 'Admin', 'Backend', 'Finish']
const STEPS = ['Wizard', 'Admin', 'Backend', 'Finish']
export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
export default function SetupWizardPage({ initialWizardPort, onComplete }: Props) {
const [step, setStep] = useState(0)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const [wizardOk, setWizardOk] = useState<boolean | null>(null)
const [connecting, setConnecting] = useState(false)
const [wizardPortInput, setWizardPortInput] = useState<string>(
initialWizardPort ? String(initialWizardPort) : ''
)
const [wizardBase, setWizardBase] = useState<string>('')
const [form, setForm] = useState<SetupForm>({
admin_username: 'admin',
admin_password: '',
admin_email: '',
admin_full_name: 'Admin',
db_host: 'mysql',
db_port: 3306,
db_user: 'harborforge',
db_password: 'harborforge_pass',
db_database: 'harborforge',
backend_base_url: 'http://backend:8000',
backend_base_url: '',
project_name: '',
project_description: '',
})
const wizardApi = axios.create({
baseURL: wizardBase,
timeout: 5000,
})
const set = (key: keyof SetupForm, value: string | number) =>
setForm((f) => ({ ...f, [key]: value }))
const checkWizard = async () => {
setError('')
const port = Number(wizardPortInput)
if (!port || port <= 0 || port > 65535) {
setError('Please enter a valid wizard port (1-65535).')
return
}
const base = `http://127.0.0.1:${port}`
setConnecting(true)
try {
await wizardApi.get('/health')
setWizardOk(true)
await axios.get(`${base}/health`, { timeout: 5000 })
setWizardBase(base)
localStorage.setItem('HF_WIZARD_PORT', String(port))
setStep(1)
} catch {
setWizardOk(false)
setError(`Unable to connect to AbstractWizard (${wizardBase}).\nPlease ensure the SSH tunnel is configured:\nssh -L <wizard_port>:127.0.0.1:<wizard_port> user@server`)
setError(`Unable to connect to AbstractWizard at ${base}.\nMake sure the SSH tunnel is up:\nssh -L ${port}:127.0.0.1:${port} user@server`)
} finally {
setConnecting(false)
}
}
@@ -75,25 +73,19 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
email: form.admin_email,
full_name: form.admin_full_name,
},
database: {
host: form.db_host,
port: form.db_port,
user: form.db_user,
password: form.db_password,
database: form.db_database,
},
backend_url: form.backend_base_url || undefined,
}
await wizardApi.put('/api/v1/config/harborforge.json', config, {
await axios.put(`${wizardBase}/api/v1/config/harborforge.json`, config, {
headers: { 'Content-Type': 'application/json' },
timeout: 5000,
})
if (form.backend_base_url) {
localStorage.setItem('HF_BACKEND_BASE_URL', form.backend_base_url)
}
setStep(4)
setStep(3)
} catch (err: any) {
setError(`Failed to save configuration: ${err.message}`)
} finally {
@@ -117,45 +109,38 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
{error && <div className="setup-error">{error}</div>}
{/* Step 0: Welcome */}
{/* Step 0: Wizard connection */}
{step === 0 && (
<div className="setup-step-content">
<h2>Welcome to HarborForge</h2>
<p>Agent/Human collaborative task management platform</p>
<h2>Connect to AbstractWizard</h2>
<p className="text-dim">Enter the local port that forwards to AbstractWizard, then test the connection.</p>
<div className="setup-info">
<p> The setup wizard connects to AbstractWizard via SSH tunnel. Ensure the port is forwarded:</p>
<p> AbstractWizard is reached over an SSH tunnel. Forward the port first:</p>
<code>ssh -L &lt;wizard_port&gt;:127.0.0.1:&lt;wizard_port&gt; user@your-server</code>
</div>
<button className="btn-primary" onClick={checkWizard}>
Connect to Wizard
</button>
{wizardOk === false && (
<p className="setup-hint">Connection failed. Check the SSH tunnel.</p>
)}
</div>
)}
{/* Step 1: Database */}
{step === 1 && (
<div className="setup-step-content">
<h2>Database configuration</h2>
<p className="text-dim">Configure MySQL connection (docker-compose defaults are fine if using the bundled MySQL).</p>
<div className="setup-form">
<label>Host <input value={form.db_host} onChange={(e) => set('db_host', e.target.value)} /></label>
<label>Port <input type="number" value={form.db_port} onChange={(e) => set('db_port', Number(e.target.value))} /></label>
<label>Username <input value={form.db_user} onChange={(e) => set('db_user', e.target.value)} /></label>
<label>Password <input type="password" value={form.db_password} onChange={(e) => set('db_password', e.target.value)} /></label>
<label>Database <input value={form.db_database} onChange={(e) => set('db_database', e.target.value)} /></label>
<label>
Wizard port
<input
type="number"
value={wizardPortInput}
min={1}
max={65535}
onChange={(e) => setWizardPortInput(e.target.value)}
placeholder="e.g. 8080"
/>
</label>
</div>
<div className="setup-nav">
<button className="btn-back" onClick={() => setStep(0)}>Back</button>
<button className="btn-primary" onClick={() => setStep(2)}>Next</button>
<button className="btn-primary" onClick={checkWizard} disabled={connecting}>
{connecting ? 'Connecting...' : 'Test connection & continue'}
</button>
</div>
</div>
)}
{/* Step 2: Admin */}
{step === 2 && (
{/* Step 1: Admin */}
{step === 1 && (
<div className="setup-step-content">
<h2>Admin account</h2>
<p className="text-dim">Create the first admin user</p>
@@ -166,26 +151,26 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
<label>Full name <input value={form.admin_full_name} onChange={(e) => set('admin_full_name', e.target.value)} /></label>
</div>
<div className="setup-nav">
<button className="btn-back" onClick={() => setStep(1)}>Back</button>
<button className="btn-back" onClick={() => setStep(0)}>Back</button>
<button className="btn-primary" onClick={() => {
if (!form.admin_password) { setError('Please set an admin password'); return }
setError('')
setStep(3)
setStep(2)
}}>Next</button>
</div>
</div>
)}
{/* Step 3: Backend */}
{step === 3 && (
{/* Step 2: Backend */}
{step === 2 && (
<div className="setup-step-content">
<h2>Backend URL</h2>
<p className="text-dim">Configure the HarborForge backend API URL</p>
<p className="text-dim">Configure the HarborForge backend API URL (leave blank to use the frontend default).</p>
<div className="setup-form">
<label>Backend Base URL <input value={form.backend_base_url} onChange={(e) => set('backend_base_url', e.target.value)} placeholder="http://backend:8000" /></label>
</div>
<div className="setup-nav">
<button className="btn-back" onClick={() => setStep(2)}>Back</button>
<button className="btn-back" onClick={() => setStep(1)}>Back</button>
<button className="btn-primary" onClick={saveConfig} disabled={saving}>
{saving ? 'Saving...' : 'Finish setup'}
</button>
@@ -193,8 +178,8 @@ export default function SetupWizardPage({ wizardBase, onComplete }: Props) {
</div>
)}
{/* Step 4: Done */}
{step === 4 && (
{/* Step 3: Done */}
{step === 3 && (
<div className="setup-step-content">
<div className="setup-done">
<h2> Setup complete!</h2>

View File

@@ -1,7 +1,7 @@
import axios from 'axios'
const getApiBase = () => {
return localStorage.getItem('HF_BACKEND_BASE_URL') || import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8000'
const getApiBase = (): string | undefined => {
return localStorage.getItem('HF_BACKEND_BASE_URL') ?? undefined
}
const api = axios.create({

View File

@@ -0,0 +1,525 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProposalDetailPage from '@/pages/ProposalDetailPage'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDelete = vi.fn()
vi.mock('@/services/api', () => ({
default: {
get: (...args: any[]) => mockGet(...args),
post: (...args: any[]) => mockPost(...args),
patch: (...args: any[]) => mockPatch(...args),
delete: (...args: any[]) => mockDelete(...args),
},
}))
const mockNavigate = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...actual,
useNavigate: () => mockNavigate,
useParams: () => ({ id: '1' }),
useSearchParams: () => [new URLSearchParams('?project_id=1'), vi.fn()],
}
})
type Essential = {
id: number
essential_code: string
proposal_id: number
type: 'feature' | 'improvement' | 'refactor'
title: string
description: string | null
created_by_id: number | null
created_at: string
updated_at: string | null
}
type Proposal = {
id: number
proposal_code: string | null
title: string
description: string | null
status: 'open' | 'accepted' | 'rejected'
project_id: number
created_by_id: number | null
created_by_username: string | null
feat_task_id: string | null
essentials: Essential[] | null
generated_tasks: any[] | null
created_at: string
updated_at: string | null
}
type Milestone = {
id: number
title: string
status: 'open' | 'freeze' | 'undergoing' | 'completed' | 'closed'
}
const mockProposal: Proposal = {
id: 1,
proposal_code: 'PROP-001',
title: 'Test Proposal',
description: 'Test description',
status: 'open',
project_id: 1,
created_by_id: 1,
created_by_username: 'admin',
feat_task_id: null,
essentials: [],
generated_tasks: null,
created_at: '2026-03-01T00:00:00Z',
updated_at: null,
}
const mockEssentials: Essential[] = [
{
id: 1,
essential_code: 'ESS-001',
proposal_id: 1,
type: 'feature',
title: 'Feature Essential',
description: 'A feature essential',
created_by_id: 1,
created_at: '2026-03-01T00:00:00Z',
updated_at: null,
},
{
id: 2,
essential_code: 'ESS-002',
proposal_id: 1,
type: 'improvement',
title: 'Improvement Essential',
description: null,
created_by_id: 1,
created_at: '2026-03-01T00:00:00Z',
updated_at: null,
},
]
const mockMilestones: Milestone[] = [
{ id: 1, title: 'Milestone 1', status: 'open' },
{ id: 2, title: 'Milestone 2', status: 'open' },
]
function setupApi(options?: {
proposal?: Proposal
essentials?: Essential[]
milestones?: Milestone[]
error?: string
}) {
const proposal = options?.proposal ?? mockProposal
const essentials = options?.essentials ?? mockEssentials
const milestones = options?.milestones ?? mockMilestones
mockGet.mockImplementation((url: string) => {
if (url.includes('/proposals/1') && !url.includes('/essentials')) {
if (options?.error) {
return Promise.reject({ response: { data: { detail: options.error } } })
}
return Promise.resolve({ data: proposal })
}
if (url.includes('/essentials')) {
return Promise.resolve({ data: essentials })
}
if (url.includes('/milestones')) {
return Promise.resolve({ data: milestones })
}
return Promise.reject(new Error(`Unhandled GET ${url}`))
})
mockPost.mockResolvedValue({ data: {} })
mockPatch.mockResolvedValue({ data: {} })
mockDelete.mockResolvedValue({ data: {} })
}
describe('ProposalDetailPage', () => {
beforeEach(() => {
vi.clearAllMocks()
setupApi()
vi.spyOn(window, 'confirm').mockReturnValue(true)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('Essential List Display', () => {
it('renders essentials section with count', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Essentials (2)')).toBeInTheDocument()
})
})
it('displays essential cards with type badges', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
expect(screen.getByText('Improvement Essential')).toBeInTheDocument()
expect(screen.getByText('feature')).toBeInTheDocument()
expect(screen.getByText('improvement')).toBeInTheDocument()
})
it('displays essential codes', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('ESS-001')).toBeInTheDocument()
})
expect(screen.getByText('ESS-002')).toBeInTheDocument()
})
it('displays essential descriptions when available', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('A feature essential')).toBeInTheDocument()
})
})
it('shows empty state when no essentials', async () => {
setupApi({ essentials: [] })
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Essentials (0)')).toBeInTheDocument()
})
expect(screen.getByText(/No essentials yet/)).toBeInTheDocument()
expect(screen.getByText(/Add one to define deliverables/)).toBeInTheDocument()
})
it('shows loading state for essentials', async () => {
mockGet.mockImplementation((url: string) => {
if (url.includes('/proposals/1') && !url.includes('/essentials')) {
return Promise.resolve({ data: mockProposal })
}
if (url.includes('/essentials')) {
return new Promise(() => {}) // Never resolve
}
return Promise.reject(new Error(`Unhandled GET ${url}`))
})
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText(/Loading/i)).toBeInTheDocument()
})
})
})
describe('Essential Create/Edit Forms', () => {
it('opens create essential modal', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('+ New Essential'))
expect(screen.getByRole('heading', { name: 'New Essential' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('Essential title')).toBeInTheDocument()
expect(screen.getByText('Feature')).toBeInTheDocument()
expect(screen.getByText('Improvement')).toBeInTheDocument()
expect(screen.getByText('Refactor')).toBeInTheDocument()
})
it('creates new essential', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('+ New Essential'))
await userEvent.type(screen.getByPlaceholderText('Essential title'), 'New Test Essential')
fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'refactor' } })
await userEvent.type(screen.getByPlaceholderText('Description (optional)'), 'Test description')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/projects/1/proposals/1/essentials',
expect.objectContaining({
title: 'New Test Essential',
type: 'refactor',
description: 'Test description',
})
)
})
})
it('opens edit essential modal with pre-filled data', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
const editButtons = screen.getAllByRole('button', { name: 'Edit' })
await userEvent.click(editButtons[0])
expect(screen.getByRole('heading', { name: 'Edit Essential' })).toBeInTheDocument()
const titleInput = screen.getByDisplayValue('Feature Essential')
expect(titleInput).toBeInTheDocument()
const descriptionInput = screen.getByDisplayValue('A feature essential')
expect(descriptionInput).toBeInTheDocument()
})
it('updates essential', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
const editButtons = screen.getAllByRole('button', { name: 'Edit' })
await userEvent.click(editButtons[0])
const titleInput = screen.getByDisplayValue('Feature Essential')
await userEvent.clear(titleInput)
await userEvent.type(titleInput, 'Updated Essential Title')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalledWith(
'/projects/1/proposals/1/essentials/1',
expect.objectContaining({
title: 'Updated Essential Title',
type: 'feature',
description: 'A feature essential',
})
)
})
})
it('deletes essential after confirmation', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
await userEvent.click(deleteButtons[0])
await waitFor(() => {
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to delete this Essential?')
})
await waitFor(() => {
expect(mockDelete).toHaveBeenCalledWith('/projects/1/proposals/1/essentials/1')
})
})
it('disables save button when title is empty', async () => {
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('+ New Essential'))
const saveButton = screen.getByRole('button', { name: 'Save' })
expect(saveButton).toBeDisabled()
await userEvent.type(screen.getByPlaceholderText('Essential title'), 'Some Title')
expect(saveButton).toBeEnabled()
await userEvent.clear(screen.getByPlaceholderText('Essential title'))
expect(saveButton).toBeDisabled()
})
})
describe('Accept with Milestone Selection', () => {
it('opens accept modal with milestone selector', async () => {
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
expect(screen.getByRole('heading', { name: 'Accept Proposal' })).toBeInTheDocument()
expect(screen.getByText(/milestone to generate story tasks/i)).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('disables confirm button without milestone selection', async () => {
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
const confirmButton = screen.getByRole('button', { name: 'Confirm Accept' })
expect(confirmButton).toBeDisabled()
})
it('enables confirm button after milestone selection', async () => {
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
const select = screen.getByRole('combobox')
fireEvent.change(select, { target: { value: '1' } })
const confirmButton = screen.getByRole('button', { name: 'Confirm Accept' })
expect(confirmButton).toBeEnabled()
})
it('calls accept API with selected milestone', async () => {
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
const select = screen.getByRole('combobox')
fireEvent.change(select, { target: { value: '1' } })
await userEvent.click(screen.getByRole('button', { name: 'Confirm Accept' }))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/projects/1/proposals/1/accept',
{ milestone_id: 1 }
)
})
})
it('shows warning when no open milestones available', async () => {
setupApi({ milestones: [] })
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
expect(screen.getByText('No open milestones available.')).toBeInTheDocument()
})
it('displays generated tasks after accept', async () => {
const acceptedProposal: Proposal = {
...mockProposal,
status: 'accepted',
generated_tasks: [
{ task_id: 1, task_code: 'TASK-001', title: 'Story Task 1', task_type: 'story', task_subtype: 'feature' },
{ task_id: 2, task_code: 'TASK-002', title: 'Story Task 2', task_type: 'story', task_subtype: 'improvement' },
],
}
setupApi({ proposal: acceptedProposal })
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Generated Tasks')).toBeInTheDocument()
})
expect(screen.getByText('Story Task 1')).toBeInTheDocument()
expect(screen.getByText('Story Task 2')).toBeInTheDocument()
expect(screen.getByText('story/feature')).toBeInTheDocument()
expect(screen.getByText('story/improvement')).toBeInTheDocument()
})
})
describe('Story Creation Restriction UI', () => {
it('hides essential controls for accepted proposal', async () => {
const acceptedProposal: Proposal = {
...mockProposal,
status: 'accepted',
essentials: mockEssentials,
}
setupApi({ proposal: acceptedProposal })
render(<ProposalDetailPage />)
await screen.findByText('Feature Essential')
expect(screen.queryByText('+ New Essential')).not.toBeInTheDocument()
})
it('hides edit/delete buttons for essentials when proposal is accepted', async () => {
const acceptedProposal: Proposal = {
...mockProposal,
status: 'accepted',
essentials: mockEssentials,
}
setupApi({ proposal: acceptedProposal })
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('Feature Essential')).toBeInTheDocument()
})
const deleteButtons = screen.queryAllByRole('button', { name: 'Delete' })
expect(deleteButtons.length).toBe(0)
})
it('hides edit/delete buttons for rejected proposal', async () => {
const rejectedProposal: Proposal = {
...mockProposal,
status: 'rejected',
essentials: mockEssentials,
}
setupApi({ proposal: rejectedProposal })
render(<ProposalDetailPage />)
await screen.findByRole('button', { name: /reopen/i })
expect(screen.queryByText('+ New Essential')).not.toBeInTheDocument()
})
})
describe('Error Handling', () => {
it('displays error message on essential create failure', async () => {
mockPost.mockRejectedValue({ response: { data: { detail: 'Failed to create essential' } } })
render(<ProposalDetailPage />)
await waitFor(() => {
expect(screen.getByText('+ New Essential')).toBeInTheDocument()
})
await userEvent.click(screen.getByText('+ New Essential'))
await userEvent.type(screen.getByPlaceholderText('Essential title'), 'Test')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(screen.getByText('Failed to create essential')).toBeInTheDocument()
})
})
it('displays error message on accept failure', async () => {
mockPost.mockRejectedValue({ response: { data: { detail: 'Accept failed: milestone required' } } })
render(<ProposalDetailPage />)
const acceptButton = await screen.findByRole('button', { name: /accept/i })
await userEvent.click(acceptButton)
fireEvent.change(screen.getByRole('combobox'), { target: { value: '1' } })
await userEvent.click(screen.getByRole('button', { name: 'Confirm Accept' }))
await waitFor(() => {
expect(screen.getByText('Accept failed: milestone required')).toBeInTheDocument()
})
})
})
})

1
src/vite-env.d.ts vendored
View File

@@ -2,7 +2,6 @@
interface ImportMetaEnv {
readonly VITE_API_BASE: string
readonly VITE_WIZARD_PORT: string
}
interface ImportMeta {