feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

+152 -3
View File
@@ -1,16 +1,20 @@
/**
* Test helpers for plugin authors.
*
* A2 adds the shared vitest preset each plugin's config extends. For now this
* exposes the one thing a plugin test needs before that lands: a context
* double that satisfies the PluginContext contract without a running server.
* createMockCtx is the one to reach for: it enforces capabilities the way the
* real runtime does. createFakeContext stays for tests that only need a
* context-shaped object and do not care about the gates.
*
* The vitest config these run under comes from @termix/plugin-sdk/vitest-preset.
*/
import { PluginCapabilityError } from "./backend.js";
import type {
PluginContext,
PluginDisposables,
PluginLogger,
} from "./backend.js";
import type { TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js";
export interface FakeContextOptions {
@@ -137,3 +141,148 @@ export function createFakeContext(
return { ctx, disposals, emitted, kv };
}
export interface MockContextOptions {
pluginId?: string;
manifest?: Partial<PluginManifest>;
/** Capabilities the plugin is granted. Anything else throws. */
capabilities?: string[];
/** Seed for ctx.kv. */
kv?: Record<string, unknown>;
/** Plugin settings, readable through settings.get. A5 wires the real one. */
settings?: Record<string, unknown>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
}
export interface MockPluginContext extends FakePluginContext {
/** Settings backing store, so a test can assert what a plugin wrote. */
settings: Map<string, unknown>;
/** Capability ids checked during the test, in order, denied ones included. */
checked: string[];
}
/**
* A context double that gates on capabilities the way src/backend/plugins/ctx.ts
* does, so a test can prove a plugin fails closed without a running server.
*
* Two gates exist on today's ctx surface: kv:own on every ctx.kv call, and
* events:core on emitting a topic outside the plugin's own namespace. As the
* SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
options: MockContextOptions = {},
): MockPluginContext {
const granted = new Set(options.capabilities ?? []);
const checked: string[] = [];
const settings = new Map<string, unknown>(
Object.entries(options.settings ?? {}),
);
const base = createFakeContext({
pluginId: options.pluginId,
actor: options.actor,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
},
});
const { ctx, kv } = base;
const pluginId = ctx.pluginId;
for (const [key, value] of Object.entries(options.kv ?? {})) {
kv.set(key, value);
}
const require = (capability: string) => {
checked.push(capability);
if (!granted.has(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
};
const guardedKv = ctx.kv;
const gatedCtx: PluginContext = {
...ctx,
kv: {
get: async (key) => {
require("kv:own");
return guardedKv.get(key);
},
set: async (key, value) => {
require("kv:own");
return guardedKv.set(key, value);
},
delete: async (key) => {
require("kv:own");
return guardedKv.delete(key);
},
list: async () => {
require("kv:own");
return guardedKv.list();
},
},
events: {
emit: (topic, payload) => {
if (
!topic.startsWith(`plugin.${pluginId}.`) &&
!granted.has("events:core")
) {
throw new Error(
`Plugin ${pluginId} may only emit topics under "plugin.${pluginId}.". ` +
`Declare the events:core capability to emit core topics.`,
);
}
ctx.events.emit(topic, payload);
},
on: (topic, listener) => ctx.events.on(topic, listener),
},
};
return { ...base, ctx: gatedCtx, settings, checked };
}
/**
* A TermixApp double for frontend tests.
*
* A7 builds the real registration surface; until then this records what a
* plugin's frontend entry registered, which is all a test can assert about a
* loader that does not exist yet. Deliberately free of any render library so
* a plugin only needs testing-library if its own tests render something.
*/
export interface FakeTermixApp {
app: TermixApp;
/** Cleanups the plugin handed to app.onDispose. */
disposals: Array<() => void>;
}
export function renderWithApp(
options: { pluginId?: string; manifest?: Partial<PluginManifest> } = {},
): FakeTermixApp {
const pluginId = options.pluginId ?? "test-plugin";
const disposals: Array<() => void> = [];
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
...options.manifest,
} as PluginManifest;
const app: TermixApp = {
pluginId,
manifest,
onDispose: (dispose) => disposals.push(dispose),
};
return { app, disposals };
}
+68
View File
@@ -0,0 +1,68 @@
/**
* Shared vitest setup for core and for every plugin suite.
*
* The root vitest.setup.ts re-exports this so there is one copy of the
* polyfills rather than two that drift apart.
*/
import { afterEach, vi } from "vitest";
if (typeof window !== "undefined" && !window.matchMedia) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
/**
* jsdom in this project ships without a `localStorage` global, and Node only
* provides one when started with --localstorage-file (which persists to disk
* and is shared across test files -- both wrong for tests). Suites that touch
* storage therefore threw `Cannot read properties of undefined`, taking out
* every test in the file rather than just the ones that used it.
*
* A per-process in-memory implementation is enough: it satisfies the Storage
* interface the app uses and keeps nothing on disk.
*/
if (typeof globalThis.localStorage === "undefined") {
const createMemoryStorage = (): Storage => {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
key: (index: number) => Array.from(store.keys())[index] ?? null,
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
} as Storage;
};
Object.defineProperty(globalThis, "localStorage", {
value: createMemoryStorage(),
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, "sessionStorage", {
value: createMemoryStorage(),
configurable: true,
writable: true,
});
}
afterEach(() => {
vi.restoreAllMocks();
});
+92
View File
@@ -0,0 +1,92 @@
/**
* The vitest config every plugin uses.
*
* A plugin's vitest.config.ts is one line:
*
* import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
* export default pluginVitestConfig(import.meta.url);
*
* Two projects, matching the layout in ARCHITECTURE.md: tests/backend runs in
* node, tests/frontend in jsdom.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Walks up from a plugin directory to the repo that holds src/ui.
*
* Plugin frontends and their tests still reach core through the "@/" alias
* (the legacy debt A7 and D1 remove), so the preset has to resolve it. A
* plugin developed outside this repo has no src/ui to point at, which is
* exactly right: the alias stops resolving and the import fails loudly.
*/
function findRepoRoot(startDir: string): string | null {
let dir = startDir;
for (;;) {
if (fs.existsSync(path.join(dir, "src", "ui"))) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
export interface PluginVitestOptions {
/** Extra aliases merged over the defaults. */
alias?: Record<string, string>;
}
export function pluginVitestConfig(
configUrl: string,
options: PluginVitestOptions = {},
) {
const pluginDir = path.dirname(fileURLToPath(configUrl));
const pluginId = path.basename(pluginDir);
const setupFile = fileURLToPath(
new URL("./testing/setup.js", import.meta.url),
);
const repoRoot = findRepoRoot(pluginDir);
const alias: Record<string, string> = {
...(repoRoot
? {
"@/types": path.join(repoRoot, "src", "types"),
"@": path.join(repoRoot, "src", "ui"),
}
: {}),
...options.alias,
};
return {
resolve: { alias },
test: {
globals: true,
setupFiles: [setupFile],
projects: [
{
resolve: { alias },
test: {
name: `${pluginId}:backend`,
root: pluginDir,
environment: "node",
globals: true,
setupFiles: [setupFile],
include: ["tests/backend/**/*.test.ts"],
},
},
{
resolve: { alias },
test: {
name: `${pluginId}:frontend`,
root: pluginDir,
environment: "jsdom",
globals: true,
setupFiles: [setupFile],
include: ["tests/frontend/**/*.test.{ts,tsx}"],
},
},
],
},
};
}