mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
The instance-settings save button paired bg-accent with a hard text-white and vanished in dark mode; it uses the accent pair token now. A failed activate() after saving no longer reports success over a dead plugin: the config stays, the response says what happened. The allowed-hosts dialog scrolls like the ones e70d7397 fixed, and the discover cards get the press-scale hardening from #2158, on desktop and on the phone panel.
This commit is contained in:
@@ -858,6 +858,21 @@ describe('AdminPluginsPanel — row actions', () => {
|
||||
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
|
||||
expect(screen.queryByText('Allowed hosts')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-050: a long host list scrolls inside the dialog instead of growing past the viewport (#2159)', async () => {
|
||||
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
||||
HttpResponse.json({ supported: true, hosts: ['gotify.lan', 'ntfy.lan'] })))
|
||||
await openRowMenu()
|
||||
|
||||
fireEvent.click(screen.getByText('Allowed hosts'))
|
||||
|
||||
// The card is capped and its body scrolls, so the input, the Add button and the
|
||||
// restart note below the list stay reachable however many hosts the admin entered.
|
||||
const body = (await screen.findByText('gotify.lan')).closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).toContainElement(screen.getByPlaceholderText('gotify.example.com'))
|
||||
expect(body!.parentElement!.className).toContain('max-h-[88vh]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — enabling a plugin', () => {
|
||||
@@ -1463,6 +1478,18 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
|
||||
expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-041a: the card opts out of the global press-scale (#2158)', async () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
||||
// the card shifted the Install button out from under the pointer, so the click
|
||||
// retargeted onto the card and opened the detail modal instead of installing.
|
||||
// The data-no-press attribute is the pin.
|
||||
discoverWith({})
|
||||
render(<AdminPluginsPanel />)
|
||||
await clickDiscover()
|
||||
|
||||
expect((await screen.findByText('Gotify')).closest('[role="button"]')).toHaveAttribute('data-no-press')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-042: the detail modal lists setup fields, metadata and a separate homepage', async () => {
|
||||
discoverWith(
|
||||
{ downloadCount: 999_500, reviewedAt: '2026-01-05T00:00:00Z', homepage: 'https://gotify.example', trek: '>=3.2.0' },
|
||||
@@ -2343,4 +2370,14 @@ describe('AdminPluginsPanel — instance settings', () => {
|
||||
expect(await screen.findByText('"Client ID" is required', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-012: Save paints its label with the accent-text token, so a light accent stays readable', async () => {
|
||||
await openSettings()
|
||||
|
||||
// `--accent-text` flips per scheme and per light/dark; a hard white label vanishes on
|
||||
// the near-white accent the dark theme uses by default.
|
||||
const save = screen.getByRole('button', { name: /^save$/i })
|
||||
expect(save.className).toContain('text-accent-text')
|
||||
expect(save.className).not.toContain('text-white')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1013,12 +1013,12 @@ export default function AdminPluginsPanel() {
|
||||
{/* Operator-supplied egress hosts */}
|
||||
{egressFor && (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg max-h-[88vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shrink-0 px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
|
||||
<button type="button" onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="p-5 space-y-3 min-h-0 flex-1 overflow-y-auto">
|
||||
{!egressFor.supported ? (
|
||||
<p className="text-sm text-content-faint">{t('admin.plugins.allowedHosts.unsupported')}</p>
|
||||
) : (
|
||||
@@ -1109,7 +1109,7 @@ export default function AdminPluginsPanel() {
|
||||
<button type="button"
|
||||
disabled={settings.saving}
|
||||
onClick={() => void settings.save()}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-text disabled:opacity-60"
|
||||
>{t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1506,6 +1506,10 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
const offer = installOffer(item, t)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
// No press-scale on the card: shrinking it mid-click slides the Install
|
||||
// button out from under the pointer, so the click retargets onto the card
|
||||
// and opens the detail modal instead of installing (#2158).
|
||||
data-no-press
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
|
||||
className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150">
|
||||
<div className="relative">
|
||||
|
||||
@@ -1544,6 +1544,8 @@ function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
const offer = installOffer(item, t)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
// Same press-scale opt-out as the desktop Discover card (#2158).
|
||||
data-no-press
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
|
||||
className="overflow-hidden rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)]">
|
||||
<div className="relative">
|
||||
|
||||
@@ -452,6 +452,16 @@ describe('MAdminPluginsPanel — Discover', () => {
|
||||
fireEvent.keyDown(card, { key: 'Enter' });
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PLUGP-093: the card opts out of the global press-scale (#2158)', async () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
||||
// the card shifted the Install button out from under the pointer, so the tap
|
||||
// retargeted onto the card and opened the detail sheet instead of installing.
|
||||
// The data-no-press attribute is the pin.
|
||||
await openDiscover([registryEntry()]);
|
||||
|
||||
expect((await screen.findByText('Acme')).closest('[role="button"]')).toHaveAttribute('data-no-press');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAdminPluginsPanel — the registry detail sheet', () => {
|
||||
|
||||
@@ -147,17 +147,38 @@ export class PluginsController {
|
||||
/**
|
||||
* Save the admin-owned `scope:'instance'` settings. A RUNNING plugin gets its config
|
||||
* once, in the child's init envelope — so a save re-spawns it (like the egress-hosts
|
||||
* PUT), and `restarted` tells the UI whether that happened.
|
||||
* PUT), and `restarted` tells the UI whether that happened. A respawn that fails
|
||||
* answers 409 `{ error, code: 'RESTART_FAILED', config }`: the save itself landed,
|
||||
* so the envelope still carries the stored config.
|
||||
*/
|
||||
@Put(':id/config')
|
||||
async updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto): Promise<PluginInstanceConfigUpdated> {
|
||||
// Same gate as the egress-hosts twin: the respawn below is a spawn, and the kill
|
||||
// switch is read live, so a save must not start a child while plugins are off.
|
||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
const config = this.plugins.updateInstanceConfig(id, body || {});
|
||||
return { config, restarted: await this.runtime.respawnIfActive(id) };
|
||||
config = this.plugins.updateInstanceConfig(id, body || {});
|
||||
} catch (e) {
|
||||
if (e instanceof MissingRequiredSettingError) throw new HttpException({ error: e.message }, 400);
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
return { config, restarted: await this.runtime.respawnIfActive(id) };
|
||||
} catch (e) {
|
||||
// The config IS written by this point, only bringing the child back up failed
|
||||
// (a widened permission set awaiting re-consent, a dependency that went away).
|
||||
// Reporting that as a failed save would be a lie, so the envelope carries the
|
||||
// saved config and names the restart as the part that broke. `disable()` leaves
|
||||
// the row enabled while no child runs, so record the plugin as off: the enable
|
||||
// toggle is where the real reason is offered as a decision the admin can take.
|
||||
await this.runtime.deactivate(id).catch(() => {});
|
||||
const reason = e instanceof Error ? e.message : 'restart failed';
|
||||
throw new HttpException(
|
||||
{ error: `Settings saved, but ${id} could not be restarted: ${reason}`, code: 'RESTART_FAILED', config },
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* the child once, in its init envelope), and an inactive plugin is left alone.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -25,7 +26,7 @@ import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
|
||||
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { PluginConsentRequired, type PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import type { PluginRegistryService } from '../../../src/nest/plugins/registry/registry.service';
|
||||
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
@@ -260,6 +261,48 @@ describe('admin config endpoints (controller)', () => {
|
||||
expect(out.config).toEqual({});
|
||||
expect(out.restarted).toBe(false);
|
||||
});
|
||||
|
||||
it('INS-010: with the kill switch off the save is refused before anything is written', async () => {
|
||||
install('p');
|
||||
declareField('p', 'apiUrl', 'instance');
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const respawnIfActive = vi.fn(async () => false);
|
||||
|
||||
const failed = await controllerWith({ respawnIfActive })
|
||||
.updateConfig('p', { apiUrl: 'https://y.example' })
|
||||
.then(() => null, (e: unknown) => e as HttpException);
|
||||
|
||||
expect(failed?.getStatus()).toBe(503);
|
||||
// The respawn is a spawn: it must not run while the whole plugin system is off.
|
||||
expect(respawnIfActive).not.toHaveBeenCalled();
|
||||
expect(svc().getInstanceConfig('p')).toEqual({});
|
||||
});
|
||||
|
||||
it('INS-011: a respawn that fails reports the save that DID happen, and stops claiming the plugin runs', async () => {
|
||||
install('p');
|
||||
declareField('p', 'apiUrl', 'instance');
|
||||
const deactivate = vi.fn(async () => {});
|
||||
const respawnIfActive = vi.fn(async () => {
|
||||
throw new PluginConsentRequired('plugin p requires consent for db:read:trips', ['db:read:trips']);
|
||||
});
|
||||
|
||||
const failed = await controllerWith({ respawnIfActive, deactivate })
|
||||
.updateConfig('p', { apiUrl: 'https://y.example' })
|
||||
.then(() => null, (e: unknown) => e as HttpException);
|
||||
|
||||
expect(failed?.getStatus()).toBe(409);
|
||||
// The envelope carries the saved config and names the restart as the part that broke:
|
||||
// the message must not read as "your settings were lost", because they were not.
|
||||
expect(failed?.getResponse()).toMatchObject({
|
||||
code: 'RESTART_FAILED',
|
||||
config: { apiUrl: 'https://y.example' },
|
||||
error: expect.stringMatching(/Settings saved.*db:read:trips/),
|
||||
});
|
||||
expect(svc().getInstanceConfig('p')).toEqual({ apiUrl: 'https://y.example' });
|
||||
// disable() leaves the row enabled, so the admin list would keep showing a plugin
|
||||
// that no longer has a child. The enable toggle is where the reason is offered.
|
||||
expect(deactivate).toHaveBeenCalledWith('p');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaults reach the child at spawn', () => {
|
||||
|
||||
Reference in New Issue
Block a user