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:
@@ -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