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