Skip to content

Commit 16f7aff

Browse files
committed
fix: address CodeRabbit review feedback
- githubRepoService: add 10s fetch timeout via AbortController and dedupe concurrent in-flight requests - settingsConfig/setup: align ANIMATION_SPEEDS test mock with production values (5000/3000/1500/700/350) - worker: preserve ASSETS response status for /llms.txt - VisualizerApp: show algorithm tip for the target algorithm when navigating via favorites (was showing stale key) - VisualizerApp.test: render seek controls only when steps are available - Add coverage for non-OK repo responses, fetch timeout signal, in-flight dedup, and /llms.txt status passthrough
1 parent 5a66a66 commit 16f7aff

9 files changed

Lines changed: 149 additions & 34 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,13 @@ See reference doc for full checklists (JS, Python, pseudocode, sound, insight, t
113113
- **Anonymous tier (no account):**
114114
- 18 of 45 algorithms (curated starter set across all 5 categories; see `src/constants/algorithmEntitlements.js`)
115115
- 12 visualizations per session (localStorage counter `anon_viz_count`, resets on sign-in)
116-
- Autoplay only, default speed (MEDIUM: 4800ms)
116+
- Autoplay only, default speed (MEDIUM: 3000ms)
117117
- Complexity panel: 2 views per completion (localStorage `anon_complexity_views`), then blur overlay + sign-in gate
118118
- No manual controls, speed adjustment, or category-specific controls (grid size locked to MEDIUM, sort order locked to ascending, graph scenarios disabled)
119119
- No Code Panel, Insight Panel, Video Export, Sound, or Fullscreen
120120
- **Free tier (Google sign-in):**
121121
- All 45 algorithms, unlimited visualizations
122-
- Manual controls, all 4 speed presets
122+
- Manual controls, all 5 speed presets
123123
- Full complexity panel access, all category-specific controls
124124
- Code Panel, Insight Panel (with My Notes tab), Sound, Fullscreen
125125
- Favorite algorithms: up to 20 slots (`FREE_TIER_FAVORITE_SLOT_LIMIT` in `src/constants/personalLearning.js`); stored in Supabase `favorite_algorithms`

src/config/settingsConfig.test.jsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,15 @@ describe('useSettingsConfig', () => {
6262
});
6363
});
6464

65-
it('speed values should match ANIMATION_SPEEDS (from mocked constants: 2000, 1000, 500, 250, 125)', () => {
65+
it('speed values should match ANIMATION_SPEEDS (5000, 3000, 1500, 700, 350)', () => {
6666
const { result } = renderHook(() => useSettingsConfig(), { wrapper });
6767

6868
const values = result.current.speedOptions.map(o => o.value);
69-
expect(values).toContain(2000);
70-
expect(values).toContain(1000);
71-
expect(values).toContain(500);
72-
expect(values).toContain(250);
73-
expect(values).toContain(125);
69+
expect(values).toContain(5000);
70+
expect(values).toContain(3000);
71+
expect(values).toContain(1500);
72+
expect(values).toContain(700);
73+
expect(values).toContain(350);
7474
});
7575

7676
it('should use translated labels for grid sizes', () => {
@@ -86,7 +86,7 @@ describe('useSettingsConfig', () => {
8686
it('should use translated labels for speeds', () => {
8787
const { result } = renderHook(() => useSettingsConfig(), { wrapper });
8888

89-
const slowOption = result.current.speedOptions.find(o => o.value === 2000);
89+
const slowOption = result.current.speedOptions.find(o => o.value === 5000);
9090
expect(slowOption).toBeDefined();
9191
expect(i18n.t('speeds.slow')).toBe(slowOption.label);
9292
});

src/pages/VisualizerApp.jsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,10 @@ function App() {
634634
applyCategorySwitch(newType);
635635
};
636636

637-
const applyCategorySwitch = newType => {
637+
const applyCategorySwitch = (
638+
newType,
639+
tipKey = selectedAlgorithms[newType]
640+
) => {
638641
const cfg = CATEGORY_CONFIG[newType];
639642
if (cfg.sizeBinding === 'array') {
640643
const searchingKey = selectedAlgorithms[ALGORITHM_TYPES.SEARCHING];
@@ -652,12 +655,12 @@ function App() {
652655
}
653656
setAlgorithmType(newType);
654657
visualizationMap[newType]?.reset();
655-
maybeShowAlgorithmTip(selectedAlgorithms[newType]);
658+
maybeShowAlgorithmTip(tipKey);
656659
};
657660

658661
const handleFavoriteNavigate = (category, algorithmKey) => {
659662
if (category !== algorithmType) {
660-
applyCategorySwitch(category);
663+
applyCategorySwitch(category, algorithmKey);
661664
}
662665
setSelectedAlgorithms(prev => ({
663666
...prev,

src/pages/VisualizerApp.test.jsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,12 +325,12 @@ vi.mock('../components/ControlPanel', () => ({
325325
<button type="button" onClick={onToggleFullScreen}>
326326
toggle-fullscreen
327327
</button>
328-
{onSeek && (
328+
{onSeek && totalSteps > 0 && (
329329
<button type="button" onClick={() => onSeek(2)}>
330330
seek-to-step-2
331331
</button>
332332
)}
333-
{isGated && (
333+
{isGated && totalSteps > 0 && (
334334
<button
335335
type="button"
336336
onClick={() => onGatedFeatureClick('timeline_scrub')}
@@ -819,10 +819,11 @@ describe('VisualizerApp', () => {
819819
it('forwards timeline seeks to the active visualization', async () => {
820820
await renderApp();
821821

822+
fireEvent.click(screen.getByText('pathfinding'));
822823
fireEvent.click(screen.getByText('seek-to-step-2'));
823824

824825
await waitFor(() => {
825-
expect(sortingVisualization.seekToStep).toHaveBeenCalledWith(2);
826+
expect(pathfindingVisualization.seekToStep).toHaveBeenCalledWith(2);
826827
});
827828
});
828829

@@ -886,6 +887,7 @@ describe('VisualizerApp', () => {
886887
authMock.isAuthenticated = false;
887888
await renderApp();
888889

890+
fireEvent.click(screen.getByText('pathfinding'));
889891
fireEvent.click(screen.getByText('gated-seek'));
890892

891893
expectGatedFeatureModal('timeline_scrub');

src/services/githubRepoService.js

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,23 @@ import {
1313

1414
export const GITHUB_REPO_CACHE_KEY = 'bayan-flow:github-repo';
1515
export const GITHUB_REPO_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
16+
export const GITHUB_REPO_FETCH_TIMEOUT_MS = 10000;
1617

1718
const GITHUB_REPO_ENDPOINT = `https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}`;
1819

20+
async function fetchWithTimeout(url) {
21+
const controller = new AbortController();
22+
const timeoutId = setTimeout(
23+
() => controller.abort(),
24+
GITHUB_REPO_FETCH_TIMEOUT_MS
25+
);
26+
try {
27+
return await fetch(url, { signal: controller.signal });
28+
} finally {
29+
clearTimeout(timeoutId);
30+
}
31+
}
32+
1933
const parseReleaseTag = tagName => {
2034
if (typeof tagName !== 'string' || !tagName.trim()) {
2135
return null;
@@ -75,12 +89,14 @@ export function cacheGitHubRepo(data) {
7589
}
7690
}
7791

92+
let inFlightGitHubRepoPromise = null;
93+
7894
export async function fetchGitHubRepo() {
7995
const fallback = createFallbackGitHubRepo();
8096

8197
const [repoResponse, releaseResponse] = await Promise.all([
82-
fetch(GITHUB_REPO_ENDPOINT),
83-
fetch(`${GITHUB_REPO_ENDPOINT}/releases/latest`),
98+
fetchWithTimeout(GITHUB_REPO_ENDPOINT),
99+
fetchWithTimeout(`${GITHUB_REPO_ENDPOINT}/releases/latest`),
84100
]);
85101

86102
let next = { ...fallback };
@@ -107,22 +123,29 @@ export async function fetchGitHubRepo() {
107123
return next;
108124
}
109125

110-
export async function loadGitHubRepoData() {
126+
export function loadGitHubRepoData() {
111127
const cached = readCachedGitHubRepo();
112128
if (cached && !cached.isStale) {
113-
return { data: cached.data, fromCache: true };
129+
return Promise.resolve({ data: cached.data, fromCache: true });
114130
}
115-
try {
116-
const data = await fetchGitHubRepo();
117-
cacheGitHubRepo(data);
118-
return { data, fromCache: false };
119-
} catch (error) {
120-
if (cached) {
121-
return { data: cached.data, fromCache: true, error };
122-
}
123-
console.error('Failed to fetch GitHub data:', error);
124-
return { data: createFallbackGitHubRepo(), fromCache: false, error };
131+
if (!inFlightGitHubRepoPromise) {
132+
inFlightGitHubRepoPromise = (async () => {
133+
try {
134+
const data = await fetchGitHubRepo();
135+
cacheGitHubRepo(data);
136+
return { data, fromCache: false };
137+
} catch (error) {
138+
if (cached) {
139+
return { data: cached.data, fromCache: true, error };
140+
}
141+
console.error('Failed to fetch GitHub data:', error);
142+
return { data: createFallbackGitHubRepo(), fromCache: false, error };
143+
} finally {
144+
inFlightGitHubRepoPromise = null;
145+
}
146+
})();
125147
}
148+
return inFlightGitHubRepoPromise;
126149
}
127150

128151
export function runWhenIdle(task) {

src/services/githubRepoService.test.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,67 @@ describe('githubRepoService', () => {
157157

158158
expect(data.versionTag).toBe('0.6.0');
159159
});
160+
161+
it('falls back to defaults when repo endpoint returns non-OK', async () => {
162+
vi.stubGlobal(
163+
'fetch',
164+
vi.fn(async url => {
165+
if (String(url).includes('/releases/latest')) {
166+
return {
167+
ok: true,
168+
json: async () => ({ tag_name: 'v0.6.0' }),
169+
};
170+
}
171+
return { ok: false, status: 404, json: async () => ({}) };
172+
})
173+
);
174+
175+
const data = await fetchGitHubRepo();
176+
177+
expect(data).toMatchObject({
178+
url: GITHUB_REPO_URL,
179+
fullName: GITHUB_REPO_FULL_NAME,
180+
stars: 0,
181+
forks: 0,
182+
versionTag: '0.6.0',
183+
});
184+
});
185+
186+
it('aborts pending fetches after the timeout', async () => {
187+
let signalSeen = null;
188+
vi.stubGlobal(
189+
'fetch',
190+
vi.fn(async (url, init) => {
191+
signalSeen = init.signal;
192+
return { ok: false, json: async () => ({}) };
193+
})
194+
);
195+
196+
const data = await fetchGitHubRepo();
197+
198+
expect(signalSeen).toBeInstanceOf(AbortSignal);
199+
expect(signalSeen.aborted).toBe(false);
200+
expect(data.versionTag).toBe(GITHUB_REPO_PACKAGE_VERSION);
201+
});
202+
203+
it('deduplicates concurrent in-flight fetches', async () => {
204+
const fetchMock = vi.fn(async () => ({
205+
ok: true,
206+
json: async () => ({
207+
html_url: GITHUB_REPO_URL,
208+
full_name: GITHUB_REPO_FULL_NAME,
209+
stargazers_count: 42,
210+
forks_count: 7,
211+
}),
212+
}));
213+
vi.stubGlobal('fetch', fetchMock);
214+
215+
const [first, second] = await Promise.all([
216+
loadGitHubRepoData(),
217+
loadGitHubRepoData(),
218+
]);
219+
220+
expect(fetchMock).toHaveBeenCalledTimes(2);
221+
expect(first.data).toEqual(second.data);
222+
});
160223
});

src/test/setup.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ const constantsMockValue = {
6666
cycle: '#ef4444',
6767
},
6868
ANIMATION_SPEEDS: {
69-
SLOW: 2000,
70-
MEDIUM: 1000,
71-
FAST: 500,
72-
VERY_FAST: 250,
73-
ULTRA_FAST: 125,
69+
SLOW: 5000,
70+
MEDIUM: 3000,
71+
FAST: 1500,
72+
VERY_FAST: 700,
73+
ULTRA_FAST: 350,
7474
},
7575
VISUALIZATION_MODES: {
7676
MANUAL: 'manual',

worker/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export default {
6868
new Request(new URL('/llms.txt', request.url).toString())
6969
);
7070
return new Response(llmsResponse.body, {
71+
status: llmsResponse.status,
7172
headers: {
7273
'Content-Type': 'text/markdown; charset=utf-8',
7374
'Access-Control-Allow-Origin': '*',

worker/index.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe('Worker markdown negotiation', () => {
2525

2626
beforeEach(() => {
2727
env = createFetchHandler({
28+
'/llms.txt': '# Bayan Flow\n\nTest content',
2829
'/markdown/index.md': '# Bayan Flow\n\nTest content',
2930
'/markdown/app.md': '# Algorithm Visualizer\n\nTest',
3031
'/markdown/_fallback.md': '# Bayan Flow\n\nFallback',
@@ -97,4 +98,26 @@ describe('Worker markdown negotiation', () => {
9798
'text/markdown; charset=utf-8'
9899
);
99100
});
101+
102+
it('serves /llms.txt as markdown when the asset exists', async () => {
103+
const req = makeRequest('https://bayanflow.com/llms.txt', 'text/markdown');
104+
const res = await worker.fetch(req, env);
105+
106+
expect(res.status).toBe(200);
107+
expect(res.headers.get('Content-Type')).toBe(
108+
'text/markdown; charset=utf-8'
109+
);
110+
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
111+
expect(await res.text()).toBe('# Bayan Flow\n\nTest content');
112+
});
113+
114+
it('preserves the asset status when /llms.txt is missing', async () => {
115+
const missingEnv = createFetchHandler({
116+
'/markdown/index.md': '# Bayan Flow\n\nTest content',
117+
});
118+
const req = makeRequest('https://bayanflow.com/llms.txt', 'text/markdown');
119+
const res = await worker.fetch(req, missingEnv);
120+
121+
expect(res.status).toBe(404);
122+
});
100123
});

0 commit comments

Comments
 (0)