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:
Maurice
2026-08-23 10:08:38 +02:00
committed by Maurice
parent b3d8bf08f1
commit c75e958fc0
27 changed files with 476 additions and 53 deletions
@@ -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>