Files
jubnl 1e6ab069cd Fix/sdk default (#2199)
* feat(plugin-sdk): settings-field default + unknown-attribute warning

* feat(shared): default on the plugin settings-field contract

* feat(server): persist and serve plugin settings-field defaults

* feat(client): pre-fill plugin settings defaults in the admin and user forms

* feat(server): enforce required plugin settings on both save paths

* feat(client): block plugin-settings save on an empty required field

* feat(server): resolve plugin settings defaults at runtime

A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.

Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.

* feat(client): share the plugin settings form rules across all three forms

The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.

* feat(plugin-sdk): seed dev fixtures from manifest defaults

`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.

* docs(plugins): document settings defaults as runtime fallbacks

A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.

* add missing exports
2026-09-02 18:42:16 +02:00

289 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Per-check unit tests.
*
* Every check is a pure function of a CheckContext, so these need no temp dir and no disk — the
* context is just data, and `exists` is a closure the test supplies. That is the property the
* whole checks/ module was shaped around, and it is what makes the boundary cases (399 vs 400
* chars of prose) cheap enough to actually write down.
*/
import { describe, it, expect } from 'vitest';
import { runOffline } from '../src/cli/checks/index.js';
import { blocking, type CheckContext, type CheckReport } from '../src/cli/checks/types.js';
import { proseLength, missingSections, placeholders, undocumentedPermissions } from '../src/cli/checks/readme.js';
/** A manifest that passes everything, so each test can break exactly one thing. */
const GOOD_MANIFEST = {
id: 'flight-tracker',
name: 'Flight Tracker',
version: '1.0.0',
apiVersion: 1,
author: 'Jane Doe',
description: 'Shows your next flight on the trip dashboard.',
type: 'widget',
trek: '>=3.4.0 <4.0.0',
icon: 'Plane',
nativeModules: false,
permissions: ['db:own'],
requiredAddons: [],
pluginDependencies: [],
};
/** A README that passes every registry gate. Long enough to clear the 400-char prose floor. */
const GOOD_README = `# Flight Tracker
![screenshot](./docs/screenshot.png)
## What it does
This plugin shows your next flight on the trip dashboard, so you never have to dig through your
inbox for a boarding pass again. It reads the trip you are looking at, finds the next reservation
of type flight, and renders it as a widget with the gate, the terminal and a countdown. There is
nothing to configure and nothing to sign up for, and it works offline once the trip has synced.
## Screenshots
The image above shows the widget on a trip dashboard.
## Permissions
| Permission | Why |
|---|---|
| \`db:own\` | Stores the cached flight lookup so the widget renders instantly. |
## Setup
Install it and enable it. There is nothing to configure.
`;
function ctx(over: Partial<CheckContext> = {}): CheckContext {
const files = new Set<string>(['server/index.js', 'docs/screenshot.png']);
return {
dir: '/plugins/flight-tracker',
manifest: { ...GOOD_MANIFEST },
readme: GOOD_README,
exists: (rel) => files.has(rel.replace(/^\.?\//, '')),
...over,
};
}
const failed = (r: CheckReport, id: string) => r.errors.some((e) => e.id === id);
const warned = (r: CheckReport, id: string) => r.warnings.some((w) => w.id === id);
describe('the baseline is actually clean', () => {
it('a well-formed plugin passes every offline check', () => {
const r = runOffline(ctx());
expect(r.errors).toEqual([]);
expect(r.ok).toBe(true);
});
});
describe('manifest checks', () => {
it('rejects a description longer than the registry allows', () => {
const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, description: 'x'.repeat(201) } }));
expect(failed(r, 'manifest.entry-fields')).toBe(true);
});
it('rejects a missing description — the entry would carry an empty one and fail the schema', () => {
const m = { ...GOOD_MANIFEST };
delete (m as Record<string, unknown>).description;
expect(failed(runOffline(ctx({ manifest: m })), 'manifest.entry-fields')).toBe(true);
});
it('accepts a description at exactly the 200-char cap', () => {
const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, description: 'x'.repeat(200) } }));
expect(failed(r, 'manifest.entry-fields')).toBe(false);
});
it('rejects an icon lucide does not have — the registry does', () => {
const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, icon: 'Aeroplane' } }));
expect(failed(r, 'manifest.icon')).toBe(true);
});
it('allows no icon at all (TREK falls back to Blocks) but says so', () => {
const m = { ...GOOD_MANIFEST };
delete (m as Record<string, unknown>).icon;
const r = runOffline(ctx({ manifest: m }));
expect(failed(r, 'manifest.icon')).toBe(false);
expect(r.outcomes.find((o) => o.id === 'manifest.icon')?.detail).toMatch(/Blocks/);
});
it('rejects a homoglyph name that mixes Latin with Cyrillic', () => {
// "Flight Trаcker" — the 'а' is Cyrillic U+0430.
const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, name: 'Flight Trаcker' } }));
expect(failed(r, 'manifest.entry-fields')).toBe(true);
});
it('warns — but does not fail — on an unbounded trek range', () => {
const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, trek: '*' } }));
expect(warned(r, 'manifest.trek-range')).toBe(true);
expect(failed(r, 'manifest.trek-range')).toBe(false);
});
});
/**
* The egress trap.
*
* TREK builds the child's network allow-list and the iframe CSP from the `http:outbound:<host>`
* PERMISSIONS. It never reads the manifest's `egress[]` array — that is a declaration for the
* consent screen. So a host in egress[] with no matching permission is silently unreachable at
* runtime: the plugin installs, activates, consents, and every request to it is blocked. Nothing
* anywhere caught this before.
*/
describe('the egress trap', () => {
const outbound = (permissions: string[], egress: string[]) =>
runOffline(ctx({ manifest: { ...GOOD_MANIFEST, permissions, egress } }));
it('fails a host declared in egress[] with only the bare http:outbound permission', () => {
const r = outbound(['db:own', 'http:outbound'], ['api.example.com']);
expect(failed(r, 'code.egress-reachable')).toBe(true);
const e = r.errors.find((x) => x.id === 'code.egress-reachable');
// The fix has to name the exact permission string, or the author is left guessing.
expect(e?.fix).toContain('"http:outbound:api.example.com"');
});
it('passes when every egress host has its matching per-host permission', () => {
const r = outbound(['db:own', 'http:outbound:api.example.com'], ['api.example.com']);
expect(failed(r, 'code.egress-reachable')).toBe(false);
});
it('warns when a host is reachable but not declared in egress[] (the consent screen understates it)', () => {
const r = outbound(['db:own', 'http:outbound:api.example.com'], []);
expect(warned(r, 'code.egress-declared')).toBe(true);
});
});
describe('README checks', () => {
it('fails a README that is one char short of the prose floor, and passes one that is exactly at it', () => {
// Build prose of an exact length, with all four sections present so only the floor is in play.
const withProse = (n: number) =>
GOOD_README.replace(
/This plugin shows[\s\S]*?has synced\./,
'x'.repeat(n),
);
const proseOf = (md: string) => proseLength(md);
const short = withProse(1);
const long = withProse(2000);
expect(proseOf(short)).toBeLessThan(400);
expect(proseOf(long)).toBeGreaterThanOrEqual(400);
expect(failed(runOffline(ctx({ readme: short })), 'docs.readme-prose')).toBe(true);
expect(failed(runOffline(ctx({ readme: long })), 'docs.readme-prose')).toBe(false);
});
it('fails a README missing a required section', () => {
const r = runOffline(ctx({ readme: GOOD_README.replace('## Setup', '## Configuration') }));
expect(failed(r, 'docs.readme-sections')).toBe(true);
});
it('fails a README with a permission it never explains', () => {
const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, permissions: ['db:own', 'weather:read'] } }));
expect(failed(r, 'docs.permissions-explained')).toBe(true);
expect(r.errors.find((e) => e.id === 'docs.permissions-explained')?.detail).toContain('weather:read');
});
it('fails template placeholders left in the README', () => {
const r = runOffline(ctx({ readme: GOOD_README.replace('This plugin shows', 'Describe what it does.') }));
expect(failed(r, 'docs.readme-placeholders')).toBe(true);
});
/**
* The registry's check-readme.mjs fetches EXACTLY docs/screenshot.png at the pinned commit —
* README image links are irrelevant to this gate. (A previous version of this check scanned
* them instead, which passed a README whose only images had other names and failed it in CI,
* after the release was immutable.)
*/
it('fails when docs/screenshot.png does not exist, whatever the README links', () => {
const r = runOffline(ctx({ exists: (rel) => rel === 'server/index.js' }));
expect(failed(r, 'docs.screenshot')).toBe(true);
expect(r.errors.find((e) => e.id === 'docs.screenshot')?.next).toBe('trek-plugin shot');
});
it('passes on docs/screenshot.png alone — README image links are not what CI checks', () => {
const r = runOffline(ctx({ readme: GOOD_README.replace('![screenshot](./docs/screenshot.png)', '') }));
expect(failed(r, 'docs.screenshot')).toBe(false);
});
});
/**
* `pack` must not be blocked by a gate that only matters at publish time — packing is how you
* install a plugin into a local TREK to try it, and an unwritten README does not stop it running.
*/
describe('what blocks what', () => {
it('an undocumented plugin blocks publish but not the artifact', () => {
const undocumented = ctx({ readme: '# Nothing here\n', exists: (rel) => rel === 'server/index.js' });
const full = runOffline(undocumented);
expect(full.ok).toBe(false);
expect(blocking(full, 'artifact').ok).toBe(true);
});
it('a broken manifest blocks the artifact too — that plugin could never load', () => {
const broken = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, trek: 'not-a-range' } }));
expect(blocking(broken, 'artifact').ok).toBe(false);
});
it('a missing server entry blocks the artifact', () => {
const r = runOffline(ctx({ exists: (rel) => rel === 'docs/screenshot.png' }));
expect(blocking(r, 'artifact').ok).toBe(false);
expect(failed(r, 'code.server-entry')).toBe(true);
});
});
describe('manifest.settings-known-keys', () => {
it('warns on an attribute the SDK does not know', () => {
const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, settings: [{ key: 'api_url', defalt: 'oops' }] } }));
expect(warned(r, 'manifest.settings-known-keys')).toBe(true);
const outcome = r.outcomes.find((o) => o.id === 'manifest.settings-known-keys');
expect(outcome?.detail).toMatch(/api_url\.defalt/);
});
it('passes when every attribute is known', () => {
const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, settings: [{ key: 'api_url', default: 'https://x' }] } }));
expect(warned(r, 'manifest.settings-known-keys')).toBe(false);
});
});
describe('README helpers mirror the registry exactly', () => {
it('strips headings, tables, links, images and code fences before measuring prose', () => {
const md = '# Title\n\n![x](y.png)\n\n| a | b |\n|---|---|\n\n```js\nconst x = 1;\n```\n\n[link](u)\n';
expect(proseLength(md)).toBeLessThan(20); // nothing above is prose
});
it('finds required sections case-insensitively and as substrings', () => {
expect(missingSections('## What it does\n## Screenshots\n## Permissions\n## Setup')).toEqual([]);
expect(missingSections('### WHAT IT DOES and more\n## Screenshots\n## Permissions\n## Setup')).toEqual([]);
expect(missingSections('## What it does')).toEqual(['Screenshots', 'Permissions', 'Setup']);
});
it('still ignores the trailing anchor comment authors hide on a heading', () => {
const md = '## What it does <!-- #what -->\n## Screenshots<!--x-->\n##\tPermissions \n## Setup <!-- unterminated';
expect(missingSections(md)).toEqual([]);
// A heading that is nothing but a comment names no section.
expect(missingSections('# <!-- only -->')).toEqual(['What it does', 'Screenshots', 'Permissions', 'Setup']);
});
it('does not stall on a heading padded with spaces', () => {
// The old `(.+?)\s*(?:<!--.*)?$` was quadratic: ~12s at 80k spaces, ~75s at 200k.
// READMEs reach this gate from the registry's submission CI, so the padding is
// attacker-supplied. The bound is 1000x the real cost, so it fails on the shape
// of the regression, not on a slow machine.
const md = `## What it does${' '.repeat(200_000)}x\n## Screenshots\n## Permissions\n## Setup`;
const started = performance.now();
expect(missingSections(md)).toEqual([]);
expect(performance.now() - started).toBeLessThan(1000);
});
it('matches a permission anywhere in the README, case-insensitively', () => {
expect(undocumentedPermissions('we use `DB:OWN` for caching', ['db:own'])).toEqual([]);
expect(undocumentedPermissions('nothing here', ['db:own'])).toEqual(['db:own']);
});
it('spots each template placeholder the registry spots', () => {
expect(placeholders('Describe what your plugin does.')).toHaveLength(1);
expect(placeholders('see your-name/trek-plugin-x')).toHaveLength(1);
expect(placeholders('{{ TODO }}')).toHaveLength(1);
expect(placeholders('a real README')).toEqual([]);
});
});