mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
1e6ab069cd
* 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
289 lines
13 KiB
TypeScript
289 lines
13 KiB
TypeScript
/**
|
||
* 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
|
||
|
||

|
||
|
||
## 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('', '') }));
|
||
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\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([]);
|
||
});
|
||
});
|