Skip to content
Open
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
125 changes: 125 additions & 0 deletions apps/studio/src/app/scenarios/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithConfig } from '@/test-utils';
import Scenarios from './page';

vi.mock('next/navigation', () => ({
useSearchParams: () => ({ get: () => null }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));

// Lightweight stub so the tests drive the page's refresh state machine
// (loader, defer/flush, error notice) without rendering the full bento tree.
vi.mock('@/components/svm/scenarios-bento', () => ({
default: ({ scenarios, onRefresh, onDetailPaneChange }: any) => (
<div>
<ul>
{scenarios.map((s: any) => (
<li key={s.id}>{s.name}</li>
))}
</ul>
<button onClick={() => onRefresh?.()}>refresh</button>
<button onClick={() => onDetailPaneChange?.(true)}>open-pane</button>
<button onClick={() => onDetailPaneChange?.(false)}>close-pane</button>
</div>
),
}));

const okResponse = (items: Array<{ id: string; name: string }>) => ({
ok: true,
status: 200,
text: async () => JSON.stringify(items),
});

const failResponse = () => ({ ok: false, status: 500, text: async () => '' });

describe('scenarios page refresh state machine', () => {
beforeEach(() => {
global.fetch = vi.fn();
vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('shows the loader on first load, then the list', async () => {
(global.fetch as any).mockResolvedValue(okResponse([{ id: 'a', name: 'Alpha' }]));

renderWithConfig(<Scenarios />);

expect(screen.getByText(/Loading scenarios/i)).toBeInTheDocument();
expect(await screen.findByText('Alpha')).toBeInTheDocument();
});

it('refreshes in the background without blanking the list', async () => {
(global.fetch as any)
.mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }]))
.mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }, { id: 'b', name: 'Beta' }]));

renderWithConfig(<Scenarios />);
await screen.findByText('Alpha');

fireEvent.click(screen.getByText('refresh'));

// The full-screen loader must not reappear during a background refetch.
expect(screen.queryByText(/Loading scenarios/i)).not.toBeInTheDocument();
expect(await screen.findByText('Beta')).toBeInTheDocument();
expect(screen.getByText('Alpha')).toBeInTheDocument();
});

it('defers a refresh while the pane is open and flushes exactly once on close', async () => {
(global.fetch as any).mockResolvedValue(okResponse([{ id: 'a', name: 'Alpha' }]));

renderWithConfig(<Scenarios />);
await screen.findByText('Alpha');
const callsAfterLoad = (global.fetch as any).mock.calls.length;

fireEvent.click(screen.getByText('open-pane'));
act(() => {
window.dispatchEvent(new Event('scenarioUpdated'));
window.dispatchEvent(new Event('scenarioUpdated'));
});

// Deferred: no refetch fires while the detail pane is open.
expect((global.fetch as any).mock.calls.length).toBe(callsAfterLoad);

fireEvent.click(screen.getByText('close-pane'));

await waitFor(() => {
expect((global.fetch as any).mock.calls.length).toBe(callsAfterLoad + 1);
});
});

it('keeps the list and shows a notice when a background refresh fails', async () => {
(global.fetch as any)
.mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }]))
.mockResolvedValueOnce(failResponse());

renderWithConfig(<Scenarios />);
await screen.findByText('Alpha');

fireEvent.click(screen.getByText('refresh'));

expect(await screen.findByText(/Couldn't refresh/i)).toBeInTheDocument();
expect(screen.getByText('Alpha')).toBeInTheDocument();
});

it('clears the notice and reloads when the failed refresh is retried', async () => {
(global.fetch as any)
.mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }]))
.mockResolvedValueOnce(failResponse())
.mockResolvedValueOnce(okResponse([{ id: 'a', name: 'Alpha' }, { id: 'b', name: 'Beta' }]));

renderWithConfig(<Scenarios />);
await screen.findByText('Alpha');

fireEvent.click(screen.getByText('refresh'));
await screen.findByText(/Couldn't refresh/i);

fireEvent.click(screen.getByText('Retry'));

expect(await screen.findByText('Beta')).toBeInTheDocument();
expect(screen.queryByText(/Couldn't refresh/i)).not.toBeInTheDocument();
});
});
82 changes: 67 additions & 15 deletions apps/studio/src/app/scenarios/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { parseScenariosJson } from '@/lib/scenarios-api';
import { Scenario } from '@/lib/scenarios-data';
import { logger } from '@surfpool/shared';
import { useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { Suspense, useEffect, useRef, useState } from 'react';

function ScenariosContent() {
const searchParams = useSearchParams();
Expand All @@ -15,6 +15,9 @@ function ScenariosContent() {
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const [isDetailPaneOpen, setIsDetailPaneOpen] = useState(false);
const [pendingRefresh, setPendingRefresh] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);

// Read search params reactively - these will update when URL changes
const selectedId = searchParams?.get('id') || undefined;
Expand All @@ -28,7 +31,10 @@ function ScenariosContent() {
useEffect(() => {
async function loadScenarios() {
try {
setLoading(true);
// Full-screen spinner only on the first load. Later refetches (create,
// close-with-pending-update) swap the list in the background, so the page
// does not blank out and feel like a hard reload.
if (!hasLoadedRef.current) setLoading(true);

const response = await fetch(`${studioUrl}/v1/scenarios`);

Expand Down Expand Up @@ -162,37 +168,58 @@ function ScenariosContent() {
}

setScenarios(loadedScenarios);
setRefreshError(null);
} catch (error) {
console.error('Error loading scenarios:', error);
setScenarios([]);
// Only blank the list if we never had one. A failed background refetch
// keeps the current list rather than wiping it, but says the refresh failed.
if (!hasLoadedRef.current) {
setScenarios([]);
} else {
setRefreshError("Couldn't refresh the scenarios list.");
}
} finally {
setLoading(false);
hasLoadedRef.current = true;
}
}

loadScenarios();
}, [refreshKey, studioUrl]);

// Listen for scenario updates (but not when detail pane is open to avoid refresh loops)
// The editor dispatches this while its pane is open; defer the refresh to close.
useEffect(() => {
const handleScenarioUpdate = () => {
// Only refresh if detail pane is closed
if (!isDetailPaneOpen) {
if (isDetailPaneOpen) {
logger.log('Scenario updated while detail pane open - deferring refresh until close');
setPendingRefresh(true);
} else {
logger.log('Scenario updated event received, refreshing scenarios');
setRefreshKey((prev) => prev + 1);
} else {
logger.log('Scenario updated event received, but detail pane is open - skipping refresh');
}
};

window.addEventListener('scenarioUpdated', handleScenarioUpdate);
return () => window.removeEventListener('scenarioUpdated', handleScenarioUpdate);
}, [isDetailPaneOpen]);

// Flush the deferred refresh once the detail pane closes.
useEffect(() => {
if (!isDetailPaneOpen && pendingRefresh) {
logger.log('Detail pane closed with a pending update - refreshing scenarios');
setRefreshKey((prev) => prev + 1);
setPendingRefresh(false);
}
}, [isDetailPaneOpen, pendingRefresh]);

const handleRefresh = () => {
setRefreshKey((prev) => prev + 1);
};

const handleDismissRefreshError = () => {
setRefreshError(null);
};

if (loading) {
return (
<div className="flex h-screen items-center justify-center">
Expand All @@ -202,13 +229,38 @@ function ScenariosContent() {
}

return (
<ScenariosBento
scenarios={scenarios}
onRefresh={handleRefresh}
onDetailPaneChange={setIsDetailPaneOpen}
initialSelectedId={selectedId}
initialTab={selectedTab}
/>
<>
<ScenariosBento
scenarios={scenarios}
onRefresh={handleRefresh}
onDetailPaneChange={setIsDetailPaneOpen}
initialSelectedId={selectedId}
initialTab={selectedTab}
/>
{!!refreshError && (
<div
role="status"
className="fixed bottom-6 left-6 z-50 flex items-center gap-3 rounded-lg bg-zinc-900/90 px-3 py-2 text-sm text-red-400 shadow-lg"
>
<span>{refreshError}</span>
<button
type="button"
onClick={handleRefresh}
className="text-zinc-300 underline-offset-2 hover:underline"
>
Retry
</button>
<button
type="button"
onClick={handleDismissRefreshError}
aria-label="Dismiss"
className="text-zinc-500 hover:text-zinc-300"
>
</button>
</div>
)}
</>
);
}

Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/components/svm/scenario-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ export default function ScenarioEditor({
console.error('Failed to sync scenario with backend:', response.status, errorText);
} else {
logger.log('Scenario synced with backend successfully');
// Fire after the PATCH resolves so a refetch reads the saved state.
window.dispatchEvent(new Event('scenarioUpdated'));
}
} catch (error) {
console.error('Error syncing scenario with backend:', error);
Expand All @@ -365,7 +367,6 @@ export default function ScenarioEditor({
};

syncWithBackend();
window.dispatchEvent(new Event('scenarioUpdated'));
} else {
isFirstSlotsChangeRef.current = false;
}
Expand Down
Loading