mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user