mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(client): offer the new version when a release lands during a session
A tab kept open across a deploy ran the previous build until its next start. The server now names its version in the realtime welcome frame, which every open client reads again when it reconnects after the restart a deploy brings. When that version is not the build the page runs, a notice offers to reload. The reload goes through the same service worker handover a launch uses: it waits for the new worker to take over, or for a short timeout, and records the version only when the takeover happened. The notice reads the error boundary's texts for the same situation, so every language has it already. It is one component for both shells, sits above the offline pill, stays away offline and after a reload for that version that did not bring it, and can be closed for the session.
This commit is contained in:
@@ -29,6 +29,7 @@ import { useInAppNotificationListener } from './hooks/useInAppNotificationListen
|
||||
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
// Notice action registrations (side-effect imports):
|
||||
@@ -400,6 +401,7 @@ export default function App() {
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
so a mounted boundary keeps the current page on screen instead of flashing
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Singleton WebSocket manager for real-time collaboration
|
||||
|
||||
import { useServerVersionStore } from '../store/serverVersionStore'
|
||||
|
||||
type WebSocketListener = (event: Record<string, unknown>) => void
|
||||
type RefetchCallback = (tripId: string) => void
|
||||
|
||||
@@ -81,6 +83,7 @@ function handleMessage(event: MessageEvent): void {
|
||||
const parsed = JSON.parse(event.data)
|
||||
if (parsed.type === 'welcome') {
|
||||
mySocketId = parsed.socketId
|
||||
useServerVersionStore.getState().note(parsed.version)
|
||||
return
|
||||
}
|
||||
listeners.forEach(fn => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import NewVersionNotice from './NewVersionNotice'
|
||||
import { useServerVersionStore } from '../../store/serverVersionStore'
|
||||
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode'
|
||||
|
||||
const switchToServerVersion = vi.fn(async (_version: string) => {})
|
||||
vi.mock('../../utils/versionHandover', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/versionHandover')>()),
|
||||
switchToServerVersion: (version: string) => switchToServerVersion(version),
|
||||
}))
|
||||
|
||||
// The version the bundle under test is built as (vite define, from package.json).
|
||||
const BUILT = __TREK_UI_VERSION__
|
||||
const NEXT = `${BUILT}-next`
|
||||
|
||||
beforeEach(() => {
|
||||
useServerVersionStore.setState({ reported: null })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
switchToServerVersion.mockClear()
|
||||
sessionStorage.removeItem('trek_app_version_reload')
|
||||
_resetNetworkMode()
|
||||
})
|
||||
|
||||
describe('NewVersionNotice', () => {
|
||||
it('FE-LAYOUT-NEWVER-001: says nothing before the server reported, or while it runs this very build', () => {
|
||||
const { container } = render(<NewVersionNotice />)
|
||||
expect(container.querySelector('[role="status"]')).toBeNull()
|
||||
|
||||
act(() => useServerVersionStore.getState().note(BUILT))
|
||||
expect(container.querySelector('[role="status"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-LAYOUT-NEWVER-002: offers the reload once the server reports a newer build', async () => {
|
||||
render(<NewVersionNotice />)
|
||||
act(() => useServerVersionStore.getState().note(NEXT))
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('A new version is available')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Reload page' }))
|
||||
|
||||
expect(switchToServerVersion).toHaveBeenCalledWith(NEXT)
|
||||
expect(screen.getByRole('button', { name: 'Reload page' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-LAYOUT-NEWVER-003: closing it hides that version, and the next release asks again', async () => {
|
||||
render(<NewVersionNotice />)
|
||||
act(() => useServerVersionStore.getState().note(NEXT))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
|
||||
act(() => useServerVersionStore.getState().note(`${BUILT}-later`))
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(switchToServerVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-LAYOUT-NEWVER-004: stays away offline, where a reload could not fetch the new build', () => {
|
||||
setForcedOffline(true)
|
||||
render(<NewVersionNotice />)
|
||||
act(() => useServerVersionStore.getState().note(NEXT))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-LAYOUT-NEWVER-005: stays away once this session reloaded for that version without getting it', () => {
|
||||
sessionStorage.setItem('trek_app_version_reload', NEXT)
|
||||
render(<NewVersionNotice />)
|
||||
act(() => useServerVersionStore.getState().note(NEXT))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* NewVersionNotice: a release was deployed while this page stayed open.
|
||||
*
|
||||
* The server names its version on every socket (re)connect, and a deploy
|
||||
* restarts it, so every open client hears of a new release within seconds. A
|
||||
* launch brings an outdated page onto the current build by itself
|
||||
* (versionHandover); a page in use is never reloaded under the user's hands, so
|
||||
* this offers the reload instead, and the reload goes through the same handover.
|
||||
*
|
||||
* One notice for both shells, rendered once in App. Hidden offline, where a
|
||||
* reload could not fetch the new build, and once dismissed for that version.
|
||||
* It reads the error boundary's texts for the same situation, a tab that
|
||||
* outlived a deploy.
|
||||
*/
|
||||
import React, { useState } from 'react'
|
||||
import { RefreshCw, X } from 'lucide-react'
|
||||
import { useServerVersionStore } from '../../store/serverVersionStore'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { offersNewBuild, switchToServerVersion } from '../../utils/versionHandover'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export default function NewVersionNotice(): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const reported = useServerVersionStore(s => s.reported)
|
||||
const { offline } = useNetworkMode()
|
||||
const [dismissed, setDismissed] = useState<string | null>(null)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
|
||||
if (offline || !reported || reported === dismissed || !offersNewBuild(reported)) return null
|
||||
|
||||
const reload = (): void => {
|
||||
setSwitching(true)
|
||||
void switchToServerVersion(reported)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
// Above the OfflineBanner pill, which sits 16px over the bottom nav.
|
||||
style={{ bottom: 'calc(var(--bottom-nav-h) + 56px)' }}
|
||||
className="fixed inset-x-4 z-[9999] flex items-center gap-3 rounded-xl border border-edge bg-surface-elevated px-4 py-3 text-content shadow-lg sm:inset-x-auto sm:start-4 sm:max-w-sm"
|
||||
>
|
||||
<RefreshCw size={16} className={`shrink-0 text-accent-on ${switching ? 'animate-spin' : ''}`} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 text-sm">{t('common.errorUpdateTitle')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reload}
|
||||
disabled={switching}
|
||||
className="shrink-0 rounded-lg bg-accent px-3 py-1.5 text-sm font-semibold text-accent-text hover:bg-accent-hover disabled:opacity-60"
|
||||
>
|
||||
{t('common.errorReload')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(reported)}
|
||||
disabled={switching}
|
||||
aria-label={t('common.close')}
|
||||
className="shrink-0 rounded-md p-1 text-content-muted hover:bg-surface-hover disabled:opacity-60"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand'
|
||||
import { asReleaseTag } from '../utils/versionHandover'
|
||||
|
||||
/**
|
||||
* The version the server says it runs, as its realtime socket reports it on
|
||||
* every (re)connect. A deploy restarts the server, so a client that stayed open
|
||||
* across one reconnects and learns the new version here (NewVersionNotice).
|
||||
*/
|
||||
interface ServerVersionState {
|
||||
/** The last version the server reported, or null before its first report. */
|
||||
reported: string | null
|
||||
/** Takes what the welcome frame carries; anything but a release tag is ignored. */
|
||||
note: (value: unknown) => void
|
||||
}
|
||||
|
||||
export const useServerVersionStore = create<ServerVersionState>()((set, get) => ({
|
||||
reported: null,
|
||||
note(value) {
|
||||
const version = asReleaseTag(value)
|
||||
if (version && version !== get().reported) set({ reported: version })
|
||||
},
|
||||
}))
|
||||
@@ -484,3 +484,124 @@ describe('reconcileAppVersion: the reload guard', () => {
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('offersNewBuild: whether the notice offers a reload', () => {
|
||||
it('FE-UTIL-VERSION-040: offers a version other than the one this page was built as', async () => {
|
||||
const { offersNewBuild } = await loadModule()
|
||||
expect(offersNewBuild(`${BUILT}-next`)).toBe(true)
|
||||
expect(offersNewBuild(BUILT)).toBe(false)
|
||||
expect(offersNewBuild(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-041: not once this session reloaded for that version, since another reload shows the same build', async () => {
|
||||
const { offersNewBuild } = await loadModule()
|
||||
sessionStorage.setItem('trek_app_version_reload', `${BUILT}-next`)
|
||||
expect(offersNewBuild(`${BUILT}-next`)).toBe(false)
|
||||
expect(offersNewBuild(`${BUILT}-later`)).toBe(true)
|
||||
sessionStorage.removeItem('trek_app_version_reload')
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-042: not without session storage, where the reload guard and with it the reload is missing', async () => {
|
||||
const { offersNewBuild } = await loadModule()
|
||||
breakStorage('getItem', () => sessionStorage)
|
||||
expect(offersNewBuild(`${BUILT}-next`)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-043: asReleaseTag takes a release tag and nothing else', async () => {
|
||||
const { asReleaseTag } = await loadModule()
|
||||
expect(asReleaseTag('4.3.4-rc.1')).toBe('4.3.4-rc.1')
|
||||
expect(asReleaseTag(' 4.3.4')).toBeNull()
|
||||
expect(asReleaseTag(434)).toBeNull()
|
||||
expect(asReleaseTag('4'.repeat(65))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('switchToServerVersion: the reload the user asked for', () => {
|
||||
afterEach(() => {
|
||||
sessionStorage.removeItem('trek_app_version_reload')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-050: without a worker it reloads at once, under a fresh URL', async () => {
|
||||
const { switchToServerVersion } = await loadModule()
|
||||
await switchToServerVersion('4.3.4')
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
expect(plainReload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-051: waits for the new worker to take over, records the version, then reloads', async () => {
|
||||
const { container, registration } = installWorker()
|
||||
updateInstalls(registration)
|
||||
const { switchToServerVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.3')
|
||||
|
||||
const switching = switchToServerVersion('4.3.4')
|
||||
await vi.waitFor(() => expect(registration.update).toHaveBeenCalled())
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
|
||||
container.takeOver()
|
||||
await switching
|
||||
expect(marker()).toBe('4.3.4')
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-052: an unchanged sw.js has nothing to wait for, so it reloads and leaves the marker', async () => {
|
||||
const { registration } = installWorker()
|
||||
const { switchToServerVersion } = await loadModule()
|
||||
localStorage.setItem('trek_app_version', '4.3.3')
|
||||
|
||||
await switchToServerVersion('4.3.4')
|
||||
expect(registration.update).toHaveBeenCalled()
|
||||
expect(marker()).toBe('4.3.3')
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-053: an install that goes redundant still ends in the reload', async () => {
|
||||
const { registration } = installWorker()
|
||||
const incoming = updateInstalls(registration)
|
||||
const { switchToServerVersion } = await loadModule()
|
||||
|
||||
const switching = switchToServerVersion('4.3.4')
|
||||
await vi.waitFor(() => expect(registration.update).toHaveBeenCalled())
|
||||
incoming.become('redundant')
|
||||
await switching
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-054: a failed update or a worker that never takes over does not leave the user waiting', async () => {
|
||||
const failing = installWorker()
|
||||
failing.registration.update.mockRejectedValue(new Error('offline'))
|
||||
let { switchToServerVersion } = await loadModule()
|
||||
await switchToServerVersion('4.3.4')
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
|
||||
sessionStorage.removeItem('trek_app_version_reload')
|
||||
const stuck = installWorker()
|
||||
updateInstalls(stuck.registration)
|
||||
;({ switchToServerVersion } = await loadModule())
|
||||
vi.useFakeTimers()
|
||||
const switching = switchToServerVersion('4.3.5')
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await switching
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-055: a worker the browser swapped in already answers the reload, so nothing is asked of it', async () => {
|
||||
const { container, registration } = installWorker()
|
||||
const { switchToServerVersion } = await loadModule()
|
||||
container.controller = new FakeWorker('activated')
|
||||
|
||||
await switchToServerVersion('4.3.4')
|
||||
expect(registration.update).not.toHaveBeenCalled()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-UTIL-VERSION-056: a registration lookup that throws still reloads', async () => {
|
||||
const { container } = installWorker()
|
||||
container.getRegistration.mockRejectedValue(new Error('SecurityError'))
|
||||
const { switchToServerVersion } = await loadModule()
|
||||
|
||||
await switchToServerVersion('4.3.4')
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,6 +94,82 @@ async function handOver(reg: ServiceWorkerRegistration, version: string, runsIt:
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A version is a short release tag and nothing else. It arrives over the wire
|
||||
* and is written to this device's storage, so only a value made of the
|
||||
* characters a tag may contain is taken, as the match itself. Anything else is
|
||||
* null, which also keeps a malformed value from being compared against the
|
||||
* stored marker and starting an update on every launch.
|
||||
*/
|
||||
export function asReleaseTag(value: unknown): string | null {
|
||||
return /^[\w.+-]{1,64}$/.exec(typeof value === 'string' ? value : '')?.[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the server runs `version`, this page runs another build, and a
|
||||
* reload can still bring the page onto it: once this session has reloaded for
|
||||
* that version, another reload would only show the same build again (a proxy
|
||||
* or the server reporting a version its bundle was not built as).
|
||||
*/
|
||||
export function offersNewBuild(version: string | null): boolean {
|
||||
if (!version || !BUNDLE_VERSION || version === BUNDLE_VERSION) return false
|
||||
try {
|
||||
return sessionStorage.getItem(RELOAD_KEY) !== version
|
||||
} catch {
|
||||
// No session storage, no reload guard, and reloadOnce does not reload.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a reload the user asked for waits for the new worker to take over. */
|
||||
const SWITCH_WAIT_MS = 20_000
|
||||
|
||||
/**
|
||||
* The user asked for the build the server runs now, from the notice a release
|
||||
* deployed during the session raises. The same handover as a launch, but the
|
||||
* reload is theirs to ask for, so it always comes: once the new worker has
|
||||
* taken over, or when there is none to wait for, or after SWITCH_WAIT_MS. The
|
||||
* marker only moves on a takeover; anything else is left to the next launch,
|
||||
* as there.
|
||||
*/
|
||||
export async function switchToServerVersion(version: string): Promise<void> {
|
||||
let reg: ServiceWorkerRegistration | undefined
|
||||
try {
|
||||
reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
|
||||
} catch {
|
||||
reg = undefined
|
||||
}
|
||||
// Without a worker the reload goes to the network. A worker other than the
|
||||
// one that served this page has taken over already, and answers it with the
|
||||
// new build.
|
||||
if (reg && currentController() === servedBy) await waitForTakeover(reg, version)
|
||||
reloadOnce(version)
|
||||
}
|
||||
|
||||
function waitForTakeover(reg: ServiceWorkerRegistration, version: string): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const done = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(done, SWITCH_WAIT_MS)
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
markApplied(version)
|
||||
done()
|
||||
}, { once: true })
|
||||
reg.update().then(() => {
|
||||
const incoming = reg.installing ?? reg.waiting
|
||||
if (!incoming) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
incoming.addEventListener('statechange', () => {
|
||||
if (incoming.state === 'redundant') done()
|
||||
})
|
||||
}, done)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the version the server reports with the one this device last ran
|
||||
* and, when they differ, moves the app onto the new build. It never deletes a
|
||||
@@ -103,14 +179,8 @@ async function handOver(reg: ServiceWorkerRegistration, version: string, runsIt:
|
||||
* away the map tiles and files the user had downloaded on purpose.
|
||||
*/
|
||||
export async function reconcileAppVersion(reported: unknown): Promise<void> {
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
// wire and is written to this device's storage, so only a value made of
|
||||
// the characters a tag may contain is taken, as the match itself. Anything
|
||||
// else is ignored, which also keeps a malformed value from being compared
|
||||
// against the stored marker and starting an update on every launch.
|
||||
const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof reported === 'string' ? reported : '')
|
||||
if (!releaseTag) return
|
||||
const version = releaseTag[0]
|
||||
const version = asReleaseTag(reported)
|
||||
if (!version) return
|
||||
|
||||
let storedVersion: string | null
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getSocketId,
|
||||
setRefetchCallback,
|
||||
} from '../../../src/api/websocket';
|
||||
import { useServerVersionStore } from '../../../src/store/serverVersionStore';
|
||||
|
||||
// ── Fake WebSocket ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -290,6 +291,20 @@ describe('handleMessage / listeners', () => {
|
||||
removeListener(listener);
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-012b: the welcome frame tells the store which version the server runs', async () => {
|
||||
useServerVersionStore.setState({ reported: null });
|
||||
const sock = await setupConnectedSocket();
|
||||
|
||||
sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-2', version: '4.3.4' }) });
|
||||
expect(useServerVersionStore.getState().reported).toBe('4.3.4');
|
||||
|
||||
// A server from before the version was sent, or a malformed value, changes nothing.
|
||||
sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-3' }) });
|
||||
sock.onmessage!({ data: JSON.stringify({ type: 'welcome', socketId: 'server-sid-4', version: '<b>5</b>' }) });
|
||||
expect(useServerVersionStore.getState().reported).toBe('4.3.4');
|
||||
expect(getSocketId()).toBe('server-sid-4');
|
||||
});
|
||||
|
||||
it('FE-COMP-WS-013: non-welcome messages are dispatched to all registered listeners', async () => {
|
||||
const sock = await setupConnectedSocket();
|
||||
const l1 = vi.fn();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useServerVersionStore } from '../../../src/store/serverVersionStore'
|
||||
|
||||
beforeEach(() => {
|
||||
useServerVersionStore.setState({ reported: null })
|
||||
})
|
||||
|
||||
describe('serverVersionStore', () => {
|
||||
it('FE-STORE-SRVVER-001: keeps the last release tag the server reported', () => {
|
||||
useServerVersionStore.getState().note('4.3.4')
|
||||
expect(useServerVersionStore.getState().reported).toBe('4.3.4')
|
||||
useServerVersionStore.getState().note('4.4.0-rc.1')
|
||||
expect(useServerVersionStore.getState().reported).toBe('4.4.0-rc.1')
|
||||
})
|
||||
|
||||
it('FE-STORE-SRVVER-002: ignores what is not a release tag, and a repeat of the same version', () => {
|
||||
useServerVersionStore.getState().note('4.3.4')
|
||||
const before = useServerVersionStore.getState()
|
||||
for (const value of [undefined, null, 434, '', '<b>5</b>', '4'.repeat(65)]) {
|
||||
useServerVersionStore.getState().note(value)
|
||||
}
|
||||
useServerVersionStore.getState().note('4.3.4')
|
||||
expect(useServerVersionStore.getState()).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type TrekWebSocket,
|
||||
} from './ws-state';
|
||||
import { JourneyDomainService } from '../journey/journey-domain.service';
|
||||
import { hostVersion } from '../plugins/install/host-compat';
|
||||
|
||||
const HEARTBEAT_INTERVAL = 30_000;
|
||||
|
||||
@@ -153,7 +154,10 @@ export class RealtimeGateway
|
||||
|
||||
socket.isAlive = true;
|
||||
const sid = registerSocket(socket, user as User);
|
||||
socket.send(JSON.stringify({ type: 'welcome', socketId: sid }));
|
||||
// The version lets a client that stayed open across a deploy notice it
|
||||
// runs the previous build: a deploy restarts the server, so every open
|
||||
// client reconnects and reads this again.
|
||||
socket.send(JSON.stringify({ type: 'welcome', socketId: sid, version: hostVersion() }));
|
||||
socket.on('pong', () => { socket.isAlive = true; });
|
||||
} catch (err) {
|
||||
logError(`ws handshake failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
|
||||
}));
|
||||
|
||||
import { RealtimeGateway } from '../../../src/nest/realtime/realtime.gateway';
|
||||
import { hostVersion } from '../../../src/nest/plugins/install/host-compat';
|
||||
import { logError } from '../../../src/nest/audit/audit-log.logger';
|
||||
import {
|
||||
bookPeers,
|
||||
@@ -147,6 +148,15 @@ describe('RealtimeGateway handshake', () => {
|
||||
expect(Number.isInteger(welcome.socketId)).toBe(true);
|
||||
});
|
||||
|
||||
it('WSGW-007d: the welcome frame names the version the server runs', async () => {
|
||||
// A client open across a deploy reconnects and compares this with the
|
||||
// build it runs, so it can offer the new one (NewVersionNotice).
|
||||
const { ws } = await connect('/ws?token=x');
|
||||
const welcome = JSON.parse(ws.sent[0]) as { type: string; version: unknown };
|
||||
expect(welcome.version).toBe(hostVersion());
|
||||
expect(welcome.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
it('WSGW-007b: survives an upgrade request with no url, rather than throwing at it', async () => {
|
||||
const gw = new RealtimeGateway(db, tokens, journeys, users, appSettings);
|
||||
const ws = socket();
|
||||
|
||||
Reference in New Issue
Block a user