mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(client,sdk): the smaller silent failures, and a dead SDK test file
The SDK's parity suite had an unescaped apostrophe in an it() title, so the file did not parse and vitest reported it as "no tests". Both guards in it were dead - including the byte-identical egress-policy check the package's own notes call the worst possible bug in that module to lose. No CI job runs the SDK suite, so it landed quietly; the next npm publish would have failed on it, since prepublishOnly runs the tests. Also in the SDK: the dev preview answered trek:geolocation without checking the geolocation:read grant, so a plugin whose manifest is missing the permission works perfectly in dev and is dead once installed. That is the lenient direction, which is the one that ships broken plugins. Client side: the background-task poll never dismissed a 404'd import job and spun forever, "Update All" collapsed its busy flag after the first plugin settled, the lightbox had no cancellation so a slow signed URL rendered the wrong image, the plugin geolocation watch was torn down by any unrelated settings write, manual date entry accepted 31 February, cover previews were never revoked, and check-all issued one request per packing item.
This commit is contained in:
+22
-1
@@ -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 () => {
|
||||
|
||||
+5
-2
@@ -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 }
|
||||
|
||||
@@ -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<string, unknown> = {}) => 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<void>(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(<AdminPluginsPanel />)
|
||||
|
||||
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']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
<div className="mx-1.5 sm:mx-3 mb-2 mt-1 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl bg-warning-soft border border-warning/30">
|
||||
<ArrowUpCircle size={16} className="text-warning shrink-0" />
|
||||
<span className="text-xs text-content-secondary">{t('admin.plugins.updatesAvailable', { count: updatable.length })}</span>
|
||||
<button onClick={() => updatable.forEach(runUpdate)}
|
||||
<button onClick={() => void updateAll()}
|
||||
className="ml-auto text-xs font-semibold px-3 py-1.5 rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
|
||||
{t('admin.plugins.updateAll')}
|
||||
</button>
|
||||
|
||||
@@ -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(<BackgroundTasksWidget />)
|
||||
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(<BackgroundTasksWidget />)
|
||||
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' })],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, (url: string) => void> = {}
|
||||
getAuthUrl.mockImplementation((url: string) => new Promise<string>(resolve => { pending[url] = resolve }))
|
||||
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
|
||||
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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 = /<div class="cover-bg" style="([^"]*)"/.exec(iframe!.srcdoc)![1]
|
||||
|
||||
expect(style.split('url(')).toHaveLength(2)
|
||||
expect(style.match(/'/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-015: renders accommodation section when accommodations exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:id/accommodations', () =>
|
||||
|
||||
@@ -81,6 +81,13 @@ function escHtml(str) {
|
||||
return String(str).replace(/&/g, '&').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
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="cover">
|
||||
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
||||
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(cssUrl(coverImg))}')"></div>` : ''}
|
||||
<div class="cover-dim"></div>
|
||||
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
|
||||
<div class="cover-body">
|
||||
|
||||
@@ -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<void>(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 })] })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const view = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = view.container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) =>
|
||||
posted.push(m as Record<string, unknown>);
|
||||
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(<PluginFrame pluginId="demo" />); });
|
||||
|
||||
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' });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(<ModalRenderer notices={[notice]} />);
|
||||
|
||||
await flushGraceDelay();
|
||||
|
||||
expect(screen.getByText('Version 4.0.0 ($&) is here')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-SN-MODAL-008: empty notices renders nothing', () => {
|
||||
const { container } = render(<ModalRenderer notices={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
@@ -617,13 +617,17 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
||||
|
||||
type NoticeState = ReturnType<typeof useSystemNoticeModal>;
|
||||
|
||||
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;
|
||||
|
||||
@@ -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(<TodoListPanel tripId={1} items={items} />);
|
||||
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 = [
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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(<TripFormModal {...defaultProps} isOpen={false} trip={trip} />);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(seen).toEqual([]);
|
||||
|
||||
rerender(<TripFormModal {...defaultProps} isOpen trip={trip} />);
|
||||
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(<TripFormModal {...defaultProps} trip={null} />);
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,8 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const isEditing = !!trip
|
||||
const fileRef = useRef<HTMLInputElement>(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<string | null>(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([])
|
||||
}
|
||||
}
|
||||
}, [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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
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(<CustomDatePicker value="" onChange={onChange} />);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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},"*"); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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-')); });
|
||||
|
||||
@@ -70,3 +70,55 @@ describe('design kit', () => {
|
||||
expect(out.match(/<style data-trek-ui>/g)?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Boot the kit bootstrap against a stub window, so the bridge's promise handling can be
|
||||
* exercised without a DOM. `document.readyState` stays 'loading', which parks boot() on
|
||||
* DOMContentLoaded and leaves only the message plumbing live. The stub's postMessage runs
|
||||
* structuredClone first, exactly like the browser's — a value it cannot carry throws.
|
||||
*/
|
||||
function bootBridge() {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
const parent = { postMessage: (msg: Record<string, unknown>) => { structuredClone(msg); sent.push(msg); } };
|
||||
let onMessage: ((ev: { source: unknown; data: unknown }) => void) | null = null;
|
||||
const win: Record<string, unknown> = {
|
||||
parent,
|
||||
addEventListener: (type: string, cb: (ev: { source: unknown; data: unknown }) => void) => { if (type === 'message') onMessage = cb; },
|
||||
};
|
||||
const docEl = { setAttribute: () => {}, removeAttribute: () => {}, style: { setProperty: () => {} } };
|
||||
const doc = { documentElement: docEl, readyState: 'loading', addEventListener: () => {}, body: null };
|
||||
new Function('window', 'document', TREK_THEME_JS)(win, doc);
|
||||
return {
|
||||
trek: win.trek as {
|
||||
session: { set: (k: string, v: unknown) => Promise<unknown>; get: (k: string) => Promise<unknown> };
|
||||
invoke: (sub: string, opts?: { body?: unknown }) => Promise<unknown>;
|
||||
confirm: (opts: unknown) => Promise<boolean>;
|
||||
},
|
||||
sent,
|
||||
deliver: (data: unknown) => onMessage?.({ source: parent, data }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('bridge requests never hang', () => {
|
||||
it('rejects session.set with a value the frame cannot post, instead of pending forever', async () => {
|
||||
const { trek, sent } = bootBridge();
|
||||
// A function can't be structured-cloned: postMessage throws synchronously and the
|
||||
// request never reaches the host, so the promise has to settle here.
|
||||
await expect(trek.session.set('k', () => 1)).rejects.toMatchObject({ code: 'SESSION_INVALID_VALUE' });
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects invoke with an unpostable body and resolves confirm as declined', async () => {
|
||||
const { trek } = bootBridge();
|
||||
await expect(trek.invoke('/x', { body: { cb: () => 1 } })).rejects.toMatchObject({ code: 'error' });
|
||||
await expect(trek.confirm({ message: 'ok?', title: () => 'x' })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('still settles a normal request from the host response', async () => {
|
||||
const { trek, sent, deliver } = bootBridge();
|
||||
const p = trek.session.get('k');
|
||||
expect(sent).toHaveLength(1);
|
||||
deliver({ type: 'trek:response', requestId: sent[0].requestId, data: 'v' });
|
||||
await expect(p).resolves.toBe('v');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user