diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 29124210c..76fcddfaf 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -7,7 +7,8 @@ import { server } from '../tests/helpers/msw/server' import { useAuthStore } from './store/authStore' import { useSettingsStore } from './store/settingsStore' import { resetAllStores } from '../tests/helpers/store' -import { buildUser, buildSettings } from '../tests/helpers/factories' +import { buildUser, buildSettings, buildTrip } from '../tests/helpers/factories' +import { offlineDb } from './db/offlineDb' import { SETTINGS_WAIT_MS } from './utils/startDestination' import App from './App' @@ -142,6 +143,26 @@ describe('RootRedirect — startup destination', () => { await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) }) + it('FE-COMP-APP-028b: opens the cached active trip when the launch is offline', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'active_trip' }), + }) + const today = new Date() + const iso = (d: Date) => d.toISOString().slice(0, 10) + await offlineDb.trips.put(buildTrip({ id: 42, title: 'Japan', start_date: iso(today), end_date: iso(today) })) + const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, 'onLine') + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }) + + renderApp('/') + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) + + if (onLine) Object.defineProperty(Navigator.prototype, 'onLine', onLine) + delete (navigator as unknown as { onLine?: boolean }).onLine + await offlineDb.trips.clear() + }) + // The whole point of the localStorage mirror: the default launch must not pay // for a lookup it doesn't need. it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => { diff --git a/client/src/App.tsx b/client/src/App.tsx index c9fc9edad..523e6150b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,7 +19,8 @@ import ErrorBoundary from './components/shared/ErrorBoundary' import { lazyWithRetry } from './utils/lazyWithRetry' import { useIsPhone } from './mobile/useIsPhone' import { TranslationProvider, useTranslation } from './i18n' -import { authApi, tripsApi } from './api/client' +import { authApi } from './api/client' +import { tripRepo } from './repo/tripRepo' import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' @@ -244,7 +245,9 @@ function RootRedirect() { : (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB }) if (page !== 'active_trip') { setTarget('/dashboard'); return } let cancelled = false - tripsApi.active() + // Through the repo, not the api: offline this answers from Dexie instead of + // bouncing a cached trip to the dashboard. + tripRepo.active() .then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') }) .catch(() => { if (!cancelled) setTarget('/dashboard') }) return () => { cancelled = true } diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 7fff27480..371df1556 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -1862,3 +1862,43 @@ describe('AdminPluginsPanel signature fingerprints and dismissals', () => { await waitFor(() => expect(uploaded).toBe(false)) }) }) + +// FE-W5PLG-030 — Update all drives the same single-slot busy flag as a per-row update, +// so the requests have to go out one after another. +describe('AdminPluginsPanel — update all', () => { + const updatableRow = (over: Record = {}) => plugin({ + id: 'a-widget', name: 'Alpha Widget', type: 'widget', version: '1.0.0', operatorEgress: false, ...over, + }) + + it('FE-W5PLG-030: Update all sends the second update only after the first came back', async () => { + const started: string[] = [] + let release = () => {} + const gate = new Promise(resolve => { release = resolve }) + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ + enabled: true, devLink: false, + plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })], + })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json([ + { id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' }, + { id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0' }, + ])), + http.post('*/api/admin/plugins/:id/update', async ({ params }) => { + started.push(String(params.id)) + if (started.length === 1) await gate + return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }) + }), + ) + render() + + fireEvent.click(await screen.findByRole('button', { name: /update all/i })) + + await waitFor(() => expect(started).toHaveLength(1)) + // Give a parallel dispatch every chance to show up before releasing the first. + await new Promise(resolve => setTimeout(resolve, 20)) + expect(started).toEqual(['a-widget']) + + release() + await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget'])) + }) +}) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 7342da053..4ca8ae1f1 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -641,7 +641,7 @@ export default function AdminPluginsPanel() { const runUpdate = (p: PluginRow) => { setBusy(p.id); setMenu(null) - adminApi.pluginUpdate(p.id) + return adminApi.pluginUpdate(p.id) .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) @@ -676,6 +676,12 @@ export default function AdminPluginsPanel() { // eslint-disable-next-line react-hooks/exhaustive-deps const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]) + // One after another: busy is a single slot, so parallel updates would leave the + // other rows clickable mid-install and refresh once per plugin. + const updateAll = async () => { + for (const p of updatable) await runUpdate(p) + } + // Installed list after search / type / status filters + sort. const shownInstalled = useMemo(() => { const term = q.trim().toLowerCase() @@ -865,7 +871,7 @@ export default function AdminPluginsPanel() {
{t('admin.plugins.updatesAvailable', { count: updatable.length })} - diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx index b9ffcf8eb..626a97794 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx @@ -1,4 +1,4 @@ -// FE-W4BGT-001 to FE-W4BGT-020 +// FE-W4BGT-001 to FE-W4BGT-022 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { screen, act, waitFor } from '@testing-library/react' import { render, fireEvent } from '../../../tests/helpers/render' @@ -185,6 +185,34 @@ describe('BackgroundTasksWidget — rehydrate', () => { }) }) +describe('BackgroundTasksWidget — poll backstop', () => { + it('FE-W4BGT-021: a job the server drops mid-poll ends as an error instead of spinning on', async () => { + vi.useFakeTimers() + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus) + .mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never) + .mockRejectedValue({ response: { status: 404 } }) + + render() + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + + expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('error') + }) + + it('FE-W4BGT-022: a transient poll failure leaves the job running', async () => { + vi.useFakeTimers() + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus) + .mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never) + .mockRejectedValue({ response: { status: 500 } }) + + render() + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + + expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('running') + }) +}) + describe('BackgroundTasksWidget — AI retry', () => { const withFiles = () => task({ items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })], diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx index 9d3a5b79f..6101c4e81 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx @@ -111,11 +111,15 @@ export default function BackgroundTasksWidget() { else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error') else setProgress(task.id, task.tripId, s.done, s.total) }) - .catch(() => {}) + .catch((err: { response?: { status?: number } }) => { + // The server 404s a job once it has expired (or after a restart). Nothing is + // ever coming, so end the card instead of spinning and re-polling forever. + if (err?.response?.status === 404) setError(task.id, task.tripId, t('reservations.import.error')) + }) } }, 5000) return () => clearInterval(iv) - }, [tasks, setProgress, setDone, setError]) + }, [tasks, setProgress, setDone, setError, t]) if (tasks.length === 0) return null diff --git a/client/src/components/Files/FileManagerImageLightbox.test.tsx b/client/src/components/Files/FileManagerImageLightbox.test.tsx index f0c470860..6721cfc61 100644 --- a/client/src/components/Files/FileManagerImageLightbox.test.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.test.tsx @@ -1,7 +1,7 @@ -// FE-W4LBX-001 to FE-W4LBX-019 +// FE-W4LBX-001 to FE-W4LBX-020 import { describe, it, expect, vi, beforeEach } from 'vitest' import type { TripFile } from '../../types' -import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render' +import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render' const getAuthUrl = vi.fn(async (url: string, _kind: string) => `${url}?token=abc`) const openFile = vi.fn(async (_url: string, _name: string) => {}) @@ -222,6 +222,23 @@ describe('ImageLightbox', () => { expect(first).not.toHaveBeenCalled() }) + it('FE-W4LBX-020: a mint that lands after the gallery moved on does not paint', async () => { + const pending: Record void> = {} + getAuthUrl.mockImplementation((url: string) => new Promise(resolve => { pending[url] = resolve })) + const { container } = render( {}} />) + await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download')) + + fireEvent.keyDown(window, { key: 'ArrowRight' }) + await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/b.jpg', 'download')) + // The token for the image we already left resolves last. + await act(async () => { + pending['/f/b.jpg']('/f/b.jpg?token=b') + pending['/f/a.jpg']('/f/a.jpg?token=a') + }) + + expect(container.querySelector('img[alt="b.jpg"]')).toHaveAttribute('src', '/f/b.jpg?token=b') + }) + it('FE-W4LBX-019: the strip mints thumbnail tokens only for thumbs in view', async () => { // Every thumbnail costs its own one-shot token, so an off-screen thumb must // stay quiet. The default observer stub never intersects. diff --git a/client/src/components/Files/FileManagerImageLightbox.tsx b/client/src/components/Files/FileManagerImageLightbox.tsx index 0b2b53911..aa497850b 100644 --- a/client/src/components/Files/FileManagerImageLightbox.tsx +++ b/client/src/components/Files/FileManagerImageLightbox.tsx @@ -27,7 +27,12 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro setImgSrc('') // Images use a one-shot signed URL; a video must use the plain same-origin // URL (cookie auth) so its many Range requests all authenticate (#823). - if (file && !isVideo(file.mime_type)) getAuthUrl(file.url, 'download').then(setImgSrc) + if (!file || isVideo(file.mime_type)) return + // Arrowing through the gallery leaves several mints in flight; only the one for + // the file still on screen may paint. + let current = true + getAuthUrl(file.url, 'download').then(u => { if (current) setImgSrc(u) }).catch(() => {}) + return () => { current = false } }, [file?.url, file?.mime_type]) const goPrev = () => setIndex(i => Math.max(0, i - 1)) diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts index 6d9b990ad..58e5133a1 100644 --- a/client/src/components/PDF/TripPDF.test.ts +++ b/client/src/components/PDF/TripPDF.test.ts @@ -300,6 +300,18 @@ describe('downloadTripPDF', () => { expect(iframe!.srcdoc).toContain('cover.jpg') }) + it('FE-COMP-TRIPPDF-023: a cover url cannot close url() and add a second declaration', async () => { + // cover_image is a free string on the write path, and the style attribute is decoded + // before the CSS is parsed, so the quote and the paren have to be encoded. + const hostile = "http://host/a.jpg');background-image:url('http://elsewhere/leak.jpg" + await downloadTripPDF({ ...richArgs, trip: { ...richArgs.trip, cover_image: hostile } as any }) + const iframe = getIframe() + const style = /
{ server.use( http.get('/api/trips/:id/accommodations', () => diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 179c96461..7e531f4d8 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -81,6 +81,13 @@ function escHtml(str) { return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } +// The style attribute is decoded back into raw quotes before the CSS is parsed, so +// escHtml alone cannot stop a cover_image from closing the url() and appending its own +// declaration. Percent-encoding is transparent to the fetch. +function cssUrl(url) { + return String(url).replace(/["'()\\\s]/g, c => '%' + c.charCodeAt(0).toString(16).padStart(2, '0')) +} + function absUrl(url) { if (!url) return null if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url @@ -703,7 +710,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
- ${coverImg ? `
` : ''} + ${coverImg ? `
` : ''}
diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx index ebc2edbb2..dfe17fc3b 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx @@ -229,6 +229,33 @@ describe('KategorieGruppe — bulk actions', () => { await waitFor(() => expect(puts).toEqual([1])) }) + it('FE-W5CAT-011a: Check All sends the items together, not one round trip after another', async () => { + const started: number[] = [] + let release = () => {} + const gate = new Promise(resolve => { release = resolve }) + server.use( + http.put('/api/trips/1/packing/:itemId', async ({ params }) => { + started.push(Number(params.itemId)) + // The first item only answers once the second one has been sent, so a + // serialised loop would never get its second request out. + if (started.length === 1) await gate + return HttpResponse.json({ item: buildPackingItem({ id: Number(params.itemId) }) }) + }), + ) + const { container } = setup({ + items: [ + buildPackingItem({ id: 1, name: 'Tent', checked: 0 }), + buildPackingItem({ id: 2, name: 'Rope', checked: 0 }), + ], + }) + openCategoryMenu(container) + + fireEvent.click(screen.getByText('Check All')) + + await waitFor(() => expect(started).toHaveLength(2)) + release() + }) + it('FE-W5CAT-012: a failing PUT during Check All is reported by the store, once', async () => { server.use(http.put('/api/trips/1/packing/:itemId', () => new HttpResponse(null, { status: 500 }))) seedStore(useTripStore, { packingItems: [buildPackingItem({ id: 1, name: 'Tent', checked: 0 })] }) diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx index 5579f0955..8c2bad168 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx @@ -99,16 +99,13 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen } // togglePackingItem rolls its own optimistic update back and reports the - // failure itself, so the bulk actions just drive it item by item. + // failure itself, so the bulk actions just drive it item by item. They go out + // together: serialised, a long list costs one round trip per item. const handleCheckAll = async () => { - for (const item of Array.from(items)) { - if (!item.checked) await togglePackingItem(tripId, item.id, true) - } + await Promise.all(items.filter(i => !i.checked).map(i => togglePackingItem(tripId, i.id, true))) } const handleUncheckAll = async () => { - for (const item of Array.from(items)) { - if (item.checked) await togglePackingItem(tripId, item.id, false) - } + await Promise.all(items.filter(i => i.checked).map(i => togglePackingItem(tripId, i.id, false))) } const handleDeleteAll = async () => { await onDeleteAll(items) diff --git a/client/src/components/Plugins/PluginFrame.test.tsx b/client/src/components/Plugins/PluginFrame.test.tsx index 34b28c50a..151484793 100644 --- a/client/src/components/Plugins/PluginFrame.test.tsx +++ b/client/src/components/Plugins/PluginFrame.test.tsx @@ -1,4 +1,4 @@ -// FE-PLUGINS-FRAME-001 to 046 +// FE-PLUGINS-FRAME-001 to 061 import { render, cleanup, waitFor, fireEvent, screen, act } from '@testing-library/react'; import PluginFrame from './PluginFrame'; import { usePluginStore } from '../../store/pluginStore'; @@ -340,12 +340,12 @@ describe('PluginFrame', () => { function mount(granted: boolean) { grant(granted); - const { container, unmount } = render(); - const iframe = container.querySelector('iframe')!; + const view = render(); + const iframe = view.container.querySelector('iframe')!; const posted: Array> = []; (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m as Record); - return { iframe, posted, unmount }; + return { ...view, iframe, posted }; } it('FE-PLUGINS-FRAME-015: refuses an ungranted plugin without touching the browser API', () => { @@ -381,6 +381,33 @@ describe('PluginFrame', () => { expect(geo.clearWatch).toHaveBeenCalledWith(7); }); + it('FE-PLUGINS-FRAME-060: a host settings change re-bridges without dropping the watch', () => { + let tick: ((p: unknown) => void) | null = null; + geo.watchPosition.mockImplementation((ok: (p: unknown) => void) => { tick = ok; return 7; }); + const { iframe, posted, rerender } = mount(true); + act(() => { fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g20', action: 'watch' }); }); + + // Any settings write re-runs the bridge effect; the watch must survive it. + host.settings = { ...DEFAULT_SETTINGS, distance_unit: 'imperial' }; + act(() => { rerender(); }); + + expect(geo.clearWatch).not.toHaveBeenCalled(); + const before = posted.length; + act(() => tick!({ coords: { latitude: 5, longitude: 6, accuracy: 5, heading: null, speed: null }, timestamp: 3 })); + expect(posted.length).toBe(before + 1); + }); + + it('FE-PLUGINS-FRAME-061: a frame that navigates itself has its watch released', () => { + const { iframe } = mount(true); + act(() => { fireEvent.load(iframe); }); + act(() => { fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g21', action: 'watch' }); }); + + // Second load = the frame navigated away, so nothing receives the stream any more. + act(() => { fireEvent.load(iframe); }); + + expect(geo.clearWatch).toHaveBeenCalledWith(7); + }); + it('FE-PLUGINS-FRAME-018: clear stops the watch on request', () => { const { iframe, posted } = mount(true); fromFrame(iframe, { type: 'trek:geolocation', requestId: 'g4', action: 'watch' }); diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index f773d3731..e65af4390 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -650,8 +650,6 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d window.removeEventListener('message', onMessage) themeObserver.disconnect() if (wsForward) removeListener(wsForward) - // The frame is going away (or re-bridging) — never leave a live GPS watch behind. - if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null } } }, [pluginId, tripId, fill, navigate, postFrame, buildContext, userId]) @@ -664,6 +662,11 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d setHeight(null) confirmReqRef.current = null setConfirmReq(null) + // Only a real teardown may kill the watch: the bridge above re-runs on any + // settings, locale or breakpoint change, and nothing would re-arm it. + return () => { + if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null } + } }, [pluginId]) const answerConfirm = (confirmed: boolean) => { @@ -689,7 +692,14 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d // Deliver the context as soon as the document is parsed (the plugin sets up its // message listener during parse), closing the trek:ready race so the theme is // right on first paint. A 2nd load is a self-navigation — don't bridge to it. - onLoad={() => { setLoadFailed(false); loadsRef.current += 1; if (loadsRef.current === 1) postFrame(buildContext()) }} + onLoad={() => { + setLoadFailed(false) + loadsRef.current += 1 + if (loadsRef.current === 1) postFrame(buildContext()) + // A self-navigated frame is no longer bridged, so its watch is only + // burning battery for positions nobody receives. + else if (geoWatchRef.current != null) { navigator.geolocation.clearWatch(geoWatchRef.current); geoWatchRef.current = null } + }} onError={() => setLoadFailed(true)} sandbox="allow-scripts allow-forms" referrerPolicy="no-referrer" diff --git a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx index 5e9f1d766..63822b13d 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.test.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.test.tsx @@ -298,6 +298,18 @@ describe('ModalRenderer', () => { expect(screen.getByText('Hello Alice, welcome to TREK')).toBeTruthy(); }); + it('FE-SN-MODAL-057: a body param is inserted literally, not as a replacement pattern', async () => { + const notice = makeNotice({ + bodyKey: 'Version {version} is here', + bodyParams: { version: '4.0.0 ($&)' }, + }); + render(); + + await flushGraceDelay(); + + expect(screen.getByText('Version 4.0.0 ($&) is here')).toBeTruthy(); + }); + it('FE-SN-MODAL-008: empty notices renders nothing', () => { const { container } = render(); expect(container.firstChild).toBeNull(); diff --git a/client/src/components/SystemNotices/SystemNoticeModal.tsx b/client/src/components/SystemNotices/SystemNoticeModal.tsx index e2fce3284..74bd3d98b 100644 --- a/client/src/components/SystemNotices/SystemNoticeModal.tsx +++ b/client/src/components/SystemNotices/SystemNoticeModal.tsx @@ -617,13 +617,17 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) { type NoticeState = ReturnType; +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Build the NoticeContent props for a given notice + pager slot index. function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps { const { t, isDark, canPage, notices, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S; const rawBody = t(n.bodyKey); + // The key is escaped into the pattern and the value goes in through a function, so a + // value carrying $&, $1 or $` is inserted as typed instead of being expanded. const body = n.bodyParams ? Object.entries(n.bodyParams).reduce( - (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v), + (s, [k, v]) => s.replace(new RegExp(`\\{${escapeRe(k)}\\}`, 'g'), () => v), rawBody ) : rawBody; diff --git a/client/src/components/Todo/TodoListPanel.test.tsx b/client/src/components/Todo/TodoListPanel.test.tsx index 3901be5a9..592d56d6e 100644 --- a/client/src/components/Todo/TodoListPanel.test.tsx +++ b/client/src/components/Todo/TodoListPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-TODO-001 to FE-COMP-TODO-052 +// FE-COMP-TODO-001 to FE-COMP-TODO-079 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -24,6 +24,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); Object.defineProperty(window, 'innerWidth', { value: 0, writable: true, configurable: true }); }); @@ -202,6 +203,25 @@ describe('TodoListPanel', () => { expect(screen.queryByText('Future Task')).not.toBeInTheDocument(); }); + it('FE-COMP-TODO-079: Overdue uses the local calendar day, not the UTC one', () => { + // Freeze on an instant whose UTC date differs from the local one, so a UTC-derived + // "today" would move the cut-off a day in whichever direction this runner sits. + const offset = new Date(2026, 4, 15, 12).getTimezoneOffset(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 4, 15, offset > 0 ? 23 : 0, 30)); + const items = [ + buildTodoItem({ id: 1, name: 'Due Today', checked: 0, due_date: '2026-05-15' }), + buildTodoItem({ id: 2, name: 'Due Yesterday', checked: 0, due_date: '2026-05-14' }), + ]; + render(); + const overdueBtn = screen.getAllByRole('button').find( + b => b.textContent?.includes('Overdue') || b.getAttribute('title') === 'Overdue' + ); + fireEvent.click(overdueBtn!); + expect(screen.getByText('Due Yesterday')).toBeInTheDocument(); + expect(screen.queryByText('Due Today')).not.toBeInTheDocument(); + }); + it('FE-COMP-TODO-017: My Tasks filter shows only items assigned to current user', async () => { // Use default current_user_id: 1 from beforeEach; assign one item to user 1 const items = [ diff --git a/client/src/components/Todo/useTodoList.ts b/client/src/components/Todo/useTodoList.ts index afda45b4b..79bd22739 100644 --- a/client/src/components/Todo/useTodoList.ts +++ b/client/src/components/Todo/useTodoList.ts @@ -6,6 +6,7 @@ import { useTranslation } from '../../i18n' import apiClient from '../../api/client' import { formatDate as fmtDate } from '../../utils/formatters' import type { TodoItem } from '../../types' +import { localToday } from '../Planner/today' import type { FilterType, Member } from './todoListModel' /** @@ -65,7 +66,9 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu return Array.from(cats).sort() }, [items]) - const today = new Date().toISOString().split('T')[0] + // due_date is a bare calendar date, so "today" has to be the user's calendar + // day: toISOString() hands over the UTC one and shifts the overdue cut-off. + const today = localToday() const filtered = useMemo(() => { let result: TodoItem[] diff --git a/client/src/components/Trips/TripFormModal.test.tsx b/client/src/components/Trips/TripFormModal.test.tsx index ce298903a..75982b6cd 100644 --- a/client/src/components/Trips/TripFormModal.test.tsx +++ b/client/src/components/Trips/TripFormModal.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-082 +// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-084 import type { Mock } from 'vitest'; import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -1214,4 +1214,37 @@ describe('TripFormModal', () => { await waitFor(() => expect(staleResolved).toBe(true)); expect(screen.queryByText('Stale failure')).not.toBeInTheDocument(); }); + + it('FE-COMP-TRIPFORM-083: a closed modal fetches nothing until it is opened', async () => { + // The trip planner keeps the modal mounted behind the page. + const seen: string[] = []; + server.use( + http.get('/api/auth/users', () => { seen.push('users'); return HttpResponse.json({ users: [] }); }), + http.get('/api/trips/:id/members', () => { seen.push('members'); return HttpResponse.json({ members: [] }); }), + ); + const trip = buildTrip({ id: 5 }); + const { rerender } = render(); + + await new Promise(resolve => setTimeout(resolve, 0)); + expect(seen).toEqual([]); + + rerender(); + await waitFor(() => expect(seen).toContain('users')); + expect(seen).toContain('members'); + }); + + it('FE-COMP-TRIPFORM-084: staging another cover revokes the preview it replaces', async () => { + const revokeObjectURL = vi.fn(); + const original = URL.revokeObjectURL; + Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revokeObjectURL }); + createObjectURL.mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:second'); + render(); + + fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); + await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1)); + fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); + + await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:first')); + Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: original }); + }); }); diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index d3068bdd9..e253fda38 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -40,6 +40,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp const isEditing = !!trip const fileRef = useRef(null) const coverSearchSeq = useRef(0) + // The staged cover lives on as an object URL until it is replaced or the modal goes. + const previewUrlRef = useRef(null) const toast = useToast() const { t } = useTranslation() const currentUser = useAuthStore(s => s.user) @@ -104,6 +106,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp setCoverPreview(null) setCoverSearchQuery('') } + if (previewUrlRef.current) { + URL.revokeObjectURL(previewUrlRef.current) + previewUrlRef.current = null + } setPendingCoverFile(null) setPendingUnsplashUrl(null) setCoverSearchResults([]) @@ -112,16 +118,17 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp setPendingDateShift(null) setDateShiftMode('keep_bookings') setError('') + setExistingMembers([]) + // The planner keeps this modal mounted while it is closed, so nothing may be + // fetched until it is actually open. if (isOpen) { authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => { if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) }).catch(() => {}) - } - authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) - if (trip) { - tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {}) - } else { - setExistingMembers([]) + authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) + if (trip) { + tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {}) + } } }, [trip, isOpen]) @@ -131,6 +138,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } }, [tripRemindersEnabled]) + // A staged cover that never got uploaded would otherwise pin the full image for + // as long as the tab lives. + useEffect(() => () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current) + }, []) + const handleSubmit = async (e) => { e.preventDefault() setError('') @@ -219,7 +232,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp } else { // New trip: stage for upload after creation setPendingCoverFile(normalized) - setCoverPreview(URL.createObjectURL(normalized)) + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current) + previewUrlRef.current = URL.createObjectURL(normalized) + setCoverPreview(previewUrlRef.current) } } diff --git a/client/src/components/shared/CustomDateTimePicker.test.tsx b/client/src/components/shared/CustomDateTimePicker.test.tsx index d771b0f3f..fa72bc7ed 100644 --- a/client/src/components/shared/CustomDateTimePicker.test.tsx +++ b/client/src/components/shared/CustomDateTimePicker.test.tsx @@ -126,6 +126,26 @@ describe('CustomDatePicker', () => { expect(onChange).toHaveBeenCalledWith('2026-07-17'); }); + it('FE-COMP-DATEPICKER-031: text input rejects a day the month does not have', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /enter date manually/i })); + const input = screen.getByPlaceholderText('DD.MM.YYYY'); + fireEvent.change(input, { target: { value: '31.02.2026' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('FE-COMP-DATEPICKER-032: text input still accepts 29 February in a leap year', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /enter date manually/i })); + const input = screen.getByPlaceholderText('DD.MM.YYYY'); + fireEvent.change(input, { target: { value: '29.02.2024' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledWith('2024-02-29'); + }); + it('FE-COMP-DATEPICKER-014: Escape in text input cancels text mode', async () => { const user = userEvent.setup(); render(); diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index a04e38d52..d30227acf 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -240,6 +240,10 @@ export function CustomDatePicker({ } const year = y < 100 ? 2000 + y : y; if (m < 1 || m > 12 || d < 1 || d > 31) return; + // 31.02. parses fine as numbers but is no day at all; without the round-trip it would + // travel on as '2026-02-31' and come back out of new Date() as 3 March. + const probe = new Date(Date.UTC(year, m - 1, d)); + if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== m - 1 || probe.getUTCDate() !== d) return; const iso = `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; if (isOutOfRange(iso)) return; onChange(iso); diff --git a/plugin-sdk/src/cli/dev.ts b/plugin-sdk/src/cli/dev.ts index 5189696b6..51716929b 100644 --- a/plugin-sdk/src/cli/dev.ts +++ b/plugin-sdk/src/cli/dev.ts @@ -456,7 +456,7 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise // renders into docs/screenshot.png. ?theme=dark picks the dark palette. const chrome = url.searchParams.get('chrome') !== '0'; const theme = url.searchParams.get('theme') === 'dark' ? 'dark' : 'light'; - return send(200, preview(id, String(manifest.type), previewTripId, { chrome, theme }), 'text/html; charset=utf-8'); + return send(200, preview(id, String(manifest.type), previewTripId, { chrome, theme, geoAllowed: grants.has('geolocation:read') }), 'text/html; charset=utf-8'); } // Static plugin UI at /ui (page/widget client bundle). The iframe loads @@ -598,7 +598,7 @@ ${LIVE_RELOAD}`; * toggle), proxies trek:invoke to the dev server's /api routes with the dev user, * and surfaces resize/notify/navigate. This is where the design kit renders themed. */ -function preview(id: string, type: string, tripId: number, opts: { chrome?: boolean; theme?: 'light' | 'dark' } = {}): string { +export function preview(id: string, type: string, tripId: number, opts: { chrome?: boolean; theme?: 'light' | 'dark'; geoAllowed?: boolean } = {}): string { const maxW = type === 'widget' ? '440px' : '1000px'; // `trek-plugin shot` renders this page into docs/screenshot.png — the image the plugin store // shows. The theme picker and the "runs sandboxed at an opaque origin" note are DEV furniture: @@ -666,6 +666,7 @@ function postCtx(){ if(f.contentWindow) f.contentWindow.postMessage(ctx(),"*"); var tt; function toast(msg){var el=document.getElementById("toast");el.textContent=msg;el.classList.add("on");clearTimeout(tt);tt=setTimeout(function(){el.classList.remove("on");},2200);} var SESSION_PREFIX="trek:plugin-session:1:"+encodeURIComponent(${JSON.stringify(id)})+":"; var SESSION_MAX_KEYS=32,SESSION_MAX_KEY_LENGTH=64,SESSION_MAX_VALUE_BYTES=1024; +var GEO_ALLOWED=${opts.geoAllowed ? 'true' : 'false'}; function sessionFail(m,code,message){f.contentWindow.postMessage({type:"trek:error",requestId:m.requestId,code:code,message:message},"*");} function sessionMessage(m){ try { @@ -715,7 +716,11 @@ window.addEventListener("message", function(ev){ } else if(m.type==="trek:geolocation"){ // Mirror the host's geolocation bridge with a fixed dev position, so - // trek.geolocation.* works in the preview without a permission prompt. + // trek.geolocation.* works in the preview without a browser prompt. The grant + // still gates it: without geolocation:read the host answers "forbidden" + // (PluginFrame.tsx), and a preview that answered anyway would green-light a + // plugin that production then refuses. + if(!GEO_ALLOWED){ f.contentWindow.postMessage({type:"trek:geolocation:result",requestId:m.requestId,error:"forbidden"},"*"); return; } var pos={lat:52.5163,lng:13.3777,accuracy:12,heading:null,speed:null,timestamp:Date.now()}; var act=m.action||"get"; if(act==="clear"){ clearInterval(geoTick); geoTick=null; f.contentWindow.postMessage({type:"trek:geolocation:result",requestId:m.requestId,cleared:true},"*"); } diff --git a/plugin-sdk/src/ui/kit.ts b/plugin-sdk/src/ui/kit.ts index ac09c6758..25b74a422 100644 --- a/plugin-sdk/src/ui/kit.ts +++ b/plugin-sdk/src/ui/kit.ts @@ -483,7 +483,12 @@ export const TREK_THEME_JS = `(function () { var seq = 0; var lastH = -1; - function send(msg) { try { window.parent.postMessage(msg, '*'); } catch (e) {} } + function send(msg) { try { window.parent.postMessage(msg, '*'); return true; } catch (e) { return false; } } + // postMessage throws synchronously on a value structured clone cannot carry (a + // function, a DOM node, a Proxy). Swallowing that left the request registered and + // the promise pending forever, so the same call rejects under the mock host and + // hangs in the real one. Drop the entry and reject with the host's own code. + function unsendable(bucket, id, code, message) { delete bucket[id]; var e = new Error(message); e.code = code; return e; } function setFlag(name, on) { if (on) { docEl.setAttribute(name, ''); } else { docEl.removeAttribute(name); } } function applyContext(m) { @@ -593,28 +598,28 @@ export const TREK_THEME_JS = `(function () { var id = 's' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:session:get', requestId: id, key: key, scope: opts && opts.scope }); + if (!send({ type: 'trek:session:get', requestId: id, key: key, scope: opts && opts.scope })) { reject(unsendable(pending, id, 'SESSION_INVALID_VALUE', 'session value must be JSON-serialisable')); } }); }, set: function (key, value, opts) { var id = 's' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:session:set', requestId: id, key: key, value: value, scope: opts && opts.scope }); + if (!send({ type: 'trek:session:set', requestId: id, key: key, value: value, scope: opts && opts.scope })) { reject(unsendable(pending, id, 'SESSION_INVALID_VALUE', 'session value must be JSON-serialisable')); } }); }, remove: function (key, opts) { var id = 's' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:session:remove', requestId: id, key: key, scope: opts && opts.scope }); + if (!send({ type: 'trek:session:remove', requestId: id, key: key, scope: opts && opts.scope })) { reject(unsendable(pending, id, 'SESSION_INVALID_VALUE', 'session value must be JSON-serialisable')); } }); }, clear: function (opts) { var id = 's' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:session:clear', requestId: id, scope: opts && opts.scope }); + if (!send({ type: 'trek:session:clear', requestId: id, scope: opts && opts.scope })) { reject(unsendable(pending, id, 'SESSION_INVALID_VALUE', 'session value must be JSON-serialisable')); } }); } }; @@ -641,7 +646,7 @@ export const TREK_THEME_JS = `(function () { var id = 'c' + (++seq); return new Promise(function (resolve) { pendingConfirms[id] = resolve; - send({ type: 'trek:confirm', requestId: id, title: opts.title, message: opts.message, confirmLabel: opts.confirmLabel, cancelLabel: opts.cancelLabel, danger: opts.danger }); + if (!send({ type: 'trek:confirm', requestId: id, title: opts.title, message: opts.message, confirmLabel: opts.confirmLabel, cancelLabel: opts.cancelLabel, danger: opts.danger })) { delete pendingConfirms[id]; resolve(false); } }); }, // Core-event names for the trip in view ({ event, tripId } only, no payloads) — @@ -655,7 +660,7 @@ export const TREK_THEME_JS = `(function () { var id = 'r' + (++seq); return new Promise(function (resolve, reject) { pending[id] = { resolve: resolve, reject: reject }; - send({ type: 'trek:invoke', requestId: id, sub: sub, method: opts.method, body: opts.body }); + if (!send({ type: 'trek:invoke', requestId: id, sub: sub, method: opts.method, body: opts.body })) { reject(unsendable(pending, id, 'error', 'invoke body must be JSON-serialisable')); } }); }, // Host-brokered browser position (needs the geolocation:read grant; the diff --git a/plugin-sdk/test/permissions-parity.test.ts b/plugin-sdk/test/permissions-parity.test.ts index fc7713d32..c01d5754d 100644 --- a/plugin-sdk/test/permissions-parity.test.ts +++ b/plugin-sdk/test/permissions-parity.test.ts @@ -15,26 +15,32 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { HOOK_PERMISSION } from '../src/permissions.js'; +import { PLUGIN_SESSION_MAX_KEYS, PLUGIN_SESSION_MAX_KEY_LENGTH, PLUGIN_SESSION_MAX_VALUE_BYTES } from '../src/index.js'; const here = path.dirname(fileURLToPath(import.meta.url)); const serverPlugins = path.resolve(here, '../../server/src/nest/plugins'); const envelopeFile = path.join(serverPlugins, 'protocol/envelope.ts'); const serverEgress = path.join(serverPlugins, 'runtime/egress-policy.ts'); const inMonorepo = fs.existsSync(envelopeFile) && fs.existsSync(serverEgress); +const hostFrame = path.resolve(here, '../../client/src/components/Plugins/PluginFrame.tsx'); describe.skipIf(!inMonorepo)('parity with the host', () => { - it('the generated host facts match the server's envelope.ts', () => { + it("the generated host facts match the server's envelope.ts", () => { // Not a regex scrape any more: server/scripts/gen-plugin-facts.ts imports envelope.ts // and writes src/generated/host-facts.ts, and its --check mode is a CI gate. This // asserts the checked-in artefact is the one the host would produce, so a standalone // publish (which has no server present) still ships the right list. - const generated = fs.readFileSync(path.resolve(here, '../src/generated/host-facts.ts'), 'utf8'); + // + // Compared as whole maps, both ways round: asserting the artefact merely CONTAINS + // each entry passed when the host grew a hook the artefact never learned about, + // which is the drift that actually ships a wrong permission list. const envelope = fs.readFileSync(envelopeFile, 'utf8'); + const block = envelope.match(/HOOK_PERMISSION[^{]*\{([\s\S]*?)\n\}/); + expect(block, 'HOOK_PERMISSION not found in envelope.ts').toBeTruthy(); + const theirs: Record = {}; + for (const m of block![1].matchAll(/^\s*(\w+):\s*'([^']+)'/gm)) theirs[m[1]] = m[2]; - for (const [hook, perm] of Object.entries(HOOK_PERMISSION)) { - expect(generated, `${hook} missing from the generated facts`).toContain(`${hook}: '${perm}'`); - expect(envelope, `${hook} missing from envelope.ts`).toContain(`${hook}: '${perm}'`); - } + expect(theirs).toEqual({ ...HOOK_PERMISSION }); expect(Object.keys(HOOK_PERMISSION).length).toBeGreaterThan(0); }); @@ -55,3 +61,28 @@ describe.skipIf(!inMonorepo)('parity with the host', () => { for (const fn of fns) expect(body(ours, fn), `${fn} has drifted from the server`).toBe(body(theirs, fn)); }); }); + +describe.skipIf(!fs.existsSync(hostFrame))('parity with the host frame', () => { + it('the session storage contract is one contract in all three copies', () => { + // Nothing generates these: the limits are written out by hand in the SDK export + // authors import, in the dev preview's inlined host, and in the real frame, because + // the SDK ships standalone and cannot import across the package boundary. Bump one + // and the preview happily stores what production then rejects. + const frame = fs.readFileSync(hostFrame, 'utf8'); + const devServer = fs.readFileSync(path.resolve(here, '../src/cli/dev.ts'), 'utf8'); + + expect(frame).toContain(`const PLUGIN_SESSION_MAX_KEYS = ${PLUGIN_SESSION_MAX_KEYS}`); + expect(frame).toContain(`const PLUGIN_SESSION_MAX_KEY_LENGTH = ${PLUGIN_SESSION_MAX_KEY_LENGTH}`); + expect(frame).toContain(`const PLUGIN_SESSION_MAX_VALUE_BYTES = ${PLUGIN_SESSION_MAX_VALUE_BYTES}`); + expect(devServer).toContain( + `SESSION_MAX_KEYS=${PLUGIN_SESSION_MAX_KEYS},SESSION_MAX_KEY_LENGTH=${PLUGIN_SESSION_MAX_KEY_LENGTH},SESSION_MAX_VALUE_BYTES=${PLUGIN_SESSION_MAX_VALUE_BYTES}`, + ); + + // Same for the codes a plugin branches on — a rename in one copy is a silent + // behaviour change for anyone who developed against the other. + for (const code of ['SESSION_INVALID_KEY', 'SESSION_INVALID_VALUE', 'SESSION_VALUE_TOO_LARGE', 'SESSION_KEY_LIMIT', 'NO_TRIP_CONTEXT', 'SESSION_STORAGE_ERROR']) { + expect(frame, `${code} missing from the host frame`).toContain(`'${code}'`); + expect(devServer, `${code} missing from the dev preview`).toContain(`"${code}"`); + } + }); +}); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 4103b745c..15b4d2832 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -669,6 +669,21 @@ describe('dev-server SDK injection', () => { }); }); +describe('dev-server preview', () => { + // The preview plays the host, so it has to refuse what the host refuses. A preview + // that answered trek:geolocation without the grant green-lights a plugin production + // then breaks — the one drift direction this package cannot afford. + it('gates the geolocation bridge on geolocation:read, like PluginFrame', async () => { + const { preview } = await import('../src/cli/dev.js'); + const without = preview('demo', 'widget', 1, {}); + expect(without).toContain('var GEO_ALLOWED=false;'); + expect(without).toContain('if(!GEO_ALLOWED){'); + expect(without).toContain('error:"forbidden"'); + + expect(preview('demo', 'widget', 1, { geoAllowed: true })).toContain('var GEO_ALLOWED=true;'); + }); +}); + describe('dev db bind shapes', () => { let tmp: string; beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'devdb-')); }); diff --git a/plugin-sdk/test/ui-kit.test.ts b/plugin-sdk/test/ui-kit.test.ts index e329a4183..26a80fc88 100644 --- a/plugin-sdk/test/ui-kit.test.ts +++ b/plugin-sdk/test/ui-kit.test.ts @@ -70,3 +70,55 @@ describe('design kit', () => { expect(out.match(/