diff --git a/backend/src/__tests__/fleet-update-hardening.test.ts b/backend/src/__tests__/fleet-update-hardening.test.ts index 0db35d1b..edfb2fec 100644 --- a/backend/src/__tests__/fleet-update-hardening.test.ts +++ b/backend/src/__tests__/fleet-update-hardening.test.ts @@ -276,3 +276,35 @@ describe('forced-recheck throttle', () => { expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)).toBeUndefined(); }); }); + +describe('GET /api/fleet/update-status/release-notes', () => { + // Each case uses ?recheck=true so getLatestRelease force-invalidates the cache + // and fetches fresh, keeping the assertion independent of prior cache state. + it('binds the returned notes to the release version (normalized tag_name)', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ + tag_name: 'v0.93.0', + body: '## Notes for 0.93.0', + html_url: 'https://github.com/studio-saelix/sencho/releases/tag/v0.93.0', + }), { status: 200, headers: { 'content-type': 'application/json' } }), + ); + const res = await request(app) + .get('/api/fleet/update-status/release-notes?recheck=true') + .set('Authorization', adminAuth); + expect(res.status).toBe(200); + expect(res.body.version).toBe('0.93.0'); + expect(res.body.releaseNotes).toBe('## Notes for 0.93.0'); + expect(res.body.htmlUrl).toContain('v0.93.0'); + }); + + it('returns null fields when the upstream release lookup fails', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('', { status: 500 })); + const res = await request(app) + .get('/api/fleet/update-status/release-notes?recheck=true') + .set('Authorization', adminAuth); + expect(res.status).toBe(200); + expect(res.body.version).toBeNull(); + expect(res.body.releaseNotes).toBeNull(); + expect(res.body.htmlUrl).toBeNull(); + }); +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index d276ce0d..4fecaffb 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -1070,6 +1070,9 @@ fleetRouter.get('/update-status/release-notes', authMiddleware, async (req: Requ const forceRefresh = req.query.recheck === 'true'; const release = await getLatestRelease(forceRefresh); res.json({ + // Bind the notes to the release they belong to so the frontend can tell + // when the advertised latest version has moved past the loaded notes. + version: release ? release.tag_name.replace(/^v/, '') : null, releaseNotes: release?.body ?? null, htmlUrl: release?.html_url ?? null, }); diff --git a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx index e447a5dd..3edbb068 100644 --- a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx +++ b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx @@ -44,43 +44,58 @@ export function NodeUpdatesSheet({ const [skipLoading, setSkipLoading] = useState(null); const [releaseNotes, setReleaseNotes] = useState(null); const [releaseHtmlUrl, setReleaseHtmlUrl] = useState(null); + const [releaseVersion, setReleaseVersion] = useState(null); const [loadingRelease, setLoadingRelease] = useState(false); - // Tracks whether a release-notes fetch has settled (success or failure), so a - // null result lands on the empty state instead of re-triggering the effect. - const [releaseLoaded, setReleaseLoaded] = useState(false); + // The advertised latest version the loaded notes were fetched against, or + // `undefined` before the first settle (so a null/unknown advertised version + // still triggers the initial fetch). When it no longer matches the advertised + // latest, the notes are refetched so the changelog never shows a previous + // version's notes; settling on any result (including null) records the version + // so a failed fetch lands on the empty state instead of looping. + const [loadedForVersion, setLoadedForVersion] = useState(undefined); const [hasSeenChangelog, setHasSeenChangelog] = useState(false); + // The version the Fleet currently advertises as latest (gateway-derived, same + // as the footer's "Latest version" label). Notes are keyed to this so they + // stay in sync. + const advertisedLatest = (updateStatuses.find(s => s.type === 'local') ?? updateStatuses[0])?.latestVersion ?? null; + useEffect(() => { if (open) setActiveTab(initialTab); }, [open, initialTab]); - // Always fetch release notes when the sheet opens (changelog shows current - // release regardless of update availability). Pass recheck when the user + // Fetch release notes when the sheet opens (changelog shows the current + // release regardless of update availability), and refetch whenever the + // advertised latest version changes so reopening after a newer release + // surfaces its notes, never a previous version's. Pass recheck when the user // forced a version recheck so the changelog stays in sync. useEffect(() => { - if (open && !releaseLoaded && !loadingRelease) { - setLoadingRelease(true); - const recheck = recheckingUpdates ? '?recheck=true' : ''; - apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true }) - .then(res => res.ok ? res.json() as Promise<{ releaseNotes: string | null; htmlUrl: string | null }> : null) - .then(data => { - if (data) { - setReleaseNotes(data.releaseNotes); - setReleaseHtmlUrl(data.htmlUrl); - } - }) - .catch((err) => { - // Informational panel: a failure falls through to the empty - // state (with an online changelog link) rather than a toast, - // but leave a breadcrumb so the failure is diagnosable. - console.warn('[Fleet] Release-notes fetch failed:', err); - }) - .finally(() => { - setLoadingRelease(false); - setReleaseLoaded(true); - }); - } - }, [open, releaseLoaded, loadingRelease, recheckingUpdates]); + if (!open || loadingRelease) return; + if (loadedForVersion === advertisedLatest) return; + setLoadingRelease(true); + const recheck = recheckingUpdates ? '?recheck=true' : ''; + apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true }) + .then(res => res.ok ? res.json() as Promise<{ version: string | null; releaseNotes: string | null; htmlUrl: string | null }> : null) + .then(data => { + if (data) { + setReleaseNotes(data.releaseNotes); + setReleaseHtmlUrl(data.htmlUrl); + setReleaseVersion(data.version); + } + }) + .catch((err) => { + // Informational panel: a failure falls through to the empty + // state (with an online changelog link) rather than a toast, + // but leave a breadcrumb so the failure is diagnosable. + console.warn('[Fleet] Release-notes fetch failed:', err); + }) + .finally(() => { + setLoadingRelease(false); + // Record the advertised version this fetch settled for, so a null + // result does not loop and a later version change forces a refetch. + setLoadedForVersion(advertisedLatest); + }); + }, [open, advertisedLatest, loadedForVersion, loadingRelease, recheckingUpdates]); // Clear the changelog dot when user opens that tab. useEffect(() => { @@ -100,10 +115,12 @@ export function NodeUpdatesSheet({ const handleRecheck = async () => { setRecheckingUpdates(true); - // Force a fresh release-notes fetch with the rechecked version. + // Force a fresh release-notes fetch with the rechecked version: clearing + // loadedForVersion makes the effect's version guard miss and refetch. setReleaseNotes(null); setReleaseHtmlUrl(null); - setReleaseLoaded(false); + setReleaseVersion(null); + setLoadedForVersion(undefined); try { const res = await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true }); if (res.ok) { @@ -270,6 +287,11 @@ export function NodeUpdatesSheet({ ) : releaseNotes ? (
+ {releaseVersion && ( +
+ Release {formatVersion(releaseVersion)} +
+ )} {releaseNotes}
{releaseHtmlUrl && ( diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx index eb0d4943..2b30d2e3 100644 --- a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx @@ -92,6 +92,61 @@ describe('NodeUpdatesSheet', () => { await waitFor(() => expect(releaseCalls()).toBe(1)); }); + it('binds the changelog to the release version and shows it', async () => { + apiFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.1.0', releaseNotes: '## What changed', htmlUrl: null }), + }); + render(); + await screen.findByRole('heading', { name: 'What changed' }); + // The notes are labelled with the version they belong to. + expect(screen.getByText('Release v1.1.0')).toBeInTheDocument(); + }); + + it('refetches release notes when the advertised latest version changes', async () => { + apiFetchMock.mockImplementation((url: string) => + String(url).includes('release-notes') + ? Promise.resolve({ ok: true, json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: null }) }) + : Promise.resolve({ ok: true, json: async () => ({}) }), + ); + const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length; + const { rerender } = render(); + await screen.findByRole('heading', { name: 'v1.1.0 notes' }); + expect(releaseCalls()).toBe(1); + + // A newer release surfaces while the sheet is open: the advertised latest + // moves to 1.2.0 and the endpoint now returns its notes. The changelog must + // refetch and show the new version, not the stale 1.1.0 notes. + apiFetchMock.mockImplementation((url: string) => + String(url).includes('release-notes') + ? Promise.resolve({ ok: true, json: async () => ({ version: '1.2.0', releaseNotes: '## v1.2.0 notes', htmlUrl: null }) }) + : Promise.resolve({ ok: true, json: async () => ({}) }), + ); + const bumped = STATUSES.map(s => ({ ...s, latestVersion: '1.2.0' })); + rerender(); + + await screen.findByRole('heading', { name: 'v1.2.0 notes' }); + expect(screen.getByText('Release v1.2.0')).toBeInTheDocument(); + // The stale notes are replaced, not appended alongside the new ones. + expect(screen.queryByRole('heading', { name: 'v1.1.0 notes' })).not.toBeInTheDocument(); + await waitFor(() => expect(releaseCalls()).toBe(2)); + }); + + it('does not refetch loaded notes on re-render when the version is unchanged', async () => { + apiFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ version: '1.1.0', releaseNotes: '## v1.1.0 notes', htmlUrl: null }), + }); + const releaseCalls = () => apiFetchMock.mock.calls.filter(c => String(c[0]).includes('release-notes')).length; + const { rerender } = render(); + await screen.findByRole('heading', { name: 'v1.1.0 notes' }); + expect(releaseCalls()).toBe(1); + // Re-rendering with the same advertised latest version must not refetch: the + // version-keyed guard short-circuits for an already-loaded changelog. + rerender(); + await waitFor(() => expect(releaseCalls()).toBe(1)); + }); + it('Recheck resets and refetches release notes with the recheck flag', async () => { apiFetchMock.mockImplementation((url: string) => String(url).includes('release-notes')