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

+120 -20
View File
@@ -126,12 +126,30 @@ is disposed automatically on deactivate. See [Lifecycle rules](#lifecycle-rules)
### 11. Tests
Each plugin has `plugins/<id>/tests/backend` and `plugins/<id>/tests/frontend`,
run by its own vitest config built on the SDK preset (**A2**). Core tests
(`src/backend/tests`, `src/ui/tests`) never test plugin code; they may test the
plugin runtime with fixture plugins.
Each plugin has `plugins/<id>/tests/backend` (node) and
`plugins/<id>/tests/frontend` (jsdom), run by its own vitest config:
Until A2, plugin tests still live under `src/*/tests/plugins/`.
```ts
// plugins/<id>/vitest.config.ts
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
```
The preset supplies both projects, the shared setup file
(`@termix/plugin-sdk/testing/setup`, which core's `vitest.setup.ts` re-exports
so there is one copy) and the `@/` alias the frontends still use.
`npm run test` runs core only, `npm run test:plugins` runs every plugin and
`npm run test:all` runs both. Core tests (`src/backend/tests`, `src/ui/tests`)
never test plugin code; they may test the plugin runtime with fixture plugins,
which is why `src/backend/tests/plugins/` still holds the loader, manifest,
capability, registry, service, secret and shutdown suites.
`@termix/plugin-sdk/testing` provides `createMockCtx()`, which enforces
capabilities the way the runtime does, `createFakeContext()` for tests that do
not care about the gates, and `renderWithApp()` for frontend tests (**A7**
finishes it).
### 12. Auth
@@ -146,6 +164,24 @@ Official plugins live in `plugins/<id>/` as npm workspace packages in this
monorepo for all of 2.9.0, and get split into their own repos later. Every
plugin is enabled by default in 2.9.0; there is no install UI until 3.0.0.
The layout is fixed, and the directory name must equal the manifest `id`:
```
plugins/<id>/
manifest.json id, capabilities, contributions
package.json @termix-plugin/<id>, private, scripts call the CLI
tsconfig.json extends @termix/plugin-sdk/tsconfig.plugin.json
vitest.config.ts one line, the SDK preset
src/backend/index.ts exports activate(ctx) and deactivate()
src/frontend/index.tsx exports activate(app) and deactivate() (A7)
locales/en.json English strings; Crowdin translates the rest
migrations/{sqlite,pg,mysql}/ owned tables (A3)
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
README.md CHANGELOG.md
dist/ build output, gitignored
```
### 14. Upgrades must be lossless
Every move of data, settings or host columns into a plugin comes with a
@@ -431,35 +467,99 @@ until one is, this document does not pretend otherwise.
---
## Building a plugin
`termix-plugin`, the CLI the SDK ships, is the only build a plugin needs. It
runs from the plugin's own directory, so a plugin that moves to its own repo
keeps working unchanged.
| Command | What it does |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `termix-plugin build` | esbuild bundles `src/backend/index.ts` to `dist/backend.js` (ESM, node22) and `src/frontend/index.tsx` to `dist/frontend.js` (ESM, browser), then copies `locales/` and `migrations/` into `dist/`. Imported CSS lands in `dist/frontend.css`. |
| `termix-plugin validate` | Runs the SDK's `parseManifest` and checks the files the manifest names exist. |
| `termix-plugin test` | Runs the plugin's vitest suite. |
| `termix-plugin pack` | Writes a `.tgz` of the manifest, `dist/`, locales, migrations and README. **D3** adds signing. |
`npm run build:plugins` builds every plugin and stages the result in
`dist/plugins/<id>/`, which is what `getBundledPluginsDir()` resolves to and
what Docker and electron-builder package. `npm run build`, `build:backend` and
`dev:backend` all call it.
TypeScript only type-checks (`tsconfig.plugins.json`, `noEmit`); esbuild does
every emit. Nothing is written next to a source file.
### Host-provided packages
These ship with the Termix server and are never bundled into a plugin, because
a second copy of express or React is a bug, and a second `ssh2` means a second
set of native bindings. A plugin may depend on anything outside these lists,
and that is bundled into its output.
**Backend** (plus every node builtin): `@termix/plugin-sdk`, `express`, `ssh2`,
`ws`, `multer`, `cookie-parser`, `axios`, `jszip`, `guacamole-lite`,
`@anthropic-ai/sdk`, `drizzle-orm`.
**Frontend**: `react`, `react-dom`, `react/jsx-runtime`, `@termix/plugin-sdk`,
`i18next`, `react-i18next`, and the UI libraries core provides today
(`lucide-react`, `sonner`, `axios`, `cytoscape`, `react-cytoscapejs`,
`guacamole-common-js`, `react-xtermjs`, `@xterm/*`). **A7** serves these
through the SDK `ui` entry.
The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`.
---
## Legacy core imports: the debt D1 removes
The twelve bundled plugins predate the SDK. They still reach core by relative
path (`../../../src/backend/...`), which a build-time string rewrite in
`scripts/copy-bundled-plugins.cjs` patches to the compiled output path.
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
and rewrites to the compiled output path (`../../../backend/backend/...`, or
`../../../backend/types/...` for shared types). The rewrite is output-relative,
so a source file at any nesting collapses to the same prefix. D1 deletes that
file.
This is expected and temporary. The SDK does not yet expose what they need:
the SSH connection pool, host resolution, the repository layer, the shared
component library. D1 removes the debt once A3 through A8 have given them
supported APIs to move onto.
What the lint fence enforces today:
What the lint fence enforces today, in `eslint.config.mjs`:
- Core must not import a plugin backend. **Error**, no offenders.
- A plugin backend must not import frontend code or use the `@/` alias.
**Error**, no offenders.
- The shell must not import plugin components. **Warning** with 21 offenders,
which is exactly the work A7 does. It becomes an error then.
- Plugin backends importing core by relative path is not yet flagged, because
there is nowhere for them to go.
| Direction | Severity | Offenders | Emptied by |
| ------------------------------------------------ | --------- | --------- | ---------- |
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin components | Warning | 16 files | A7 |
| A plugin importing core by relative path | Warning | 71 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 |
A warning does not fail a build, so the counts are held by
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
listed in `scripts/plugin-boundary-allowlist.json`: a new one fails, and so
does an entry that stopped being an offender, so the list shrinks as each step
lands rather than rotting. `--write` regenerates it. D1 empties it and the two
plugin-side warnings become errors.
The two plugin-backend directions use different rule names
(`no-restricted-imports` and `@typescript-eslint/no-restricted-imports`)
because flat config replaces a rule's options rather than merging them, so one
rule cannot carry both severities on the same files.
Known specifics:
- **TODO(B18):** `plugins/ai/backend/tools/executor.ts` imports
`../../../automations/backend/routes.js` directly. It is declared as a hard
`dependencies` entry so the loader starts automations first and marks `ai`
blocked if automations is disabled. In B18 `ai` calls automations through an
- **TODO(B18):** `plugins/ai/src/backend/tools/executor.ts` imports
`automations`' `routes.js` directly. It is declared as a hard `dependencies`
entry so the loader starts automations first and marks `ai` blocked if
automations is disabled. In B18 `ai` calls automations through an
`automations` service as an `optionalDependency` and the direct import goes
away. This is the only cross-plugin import in the repo.
away.
- **TODO(B18):** `plugins/host-metrics/src/backend/` reaches `automations`
twice, for a `MetricsSnapshot` type and a fire-and-forget
`import("...headless-viewer.js")` that registers a viewer bridge. Both
degrade silently when automations is absent, so host-metrics declares it as
an `optionalDependency` (added in A2; it previously declared nothing, and
the loader had no reason to order the two).
- Eight plugins keep their original core URLs through the dispatchers in
`src/backend/database/routes/*-dispatch.ts` rather than `/plugin-api`. A4
moves them.
@@ -0,0 +1,69 @@
import fs from "node:fs";
import path from "node:path";
import * as esbuild from "esbuild";
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
import { legacyCoreImports } from "../lib/legacy-core-imports.mjs";
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
const BACKEND_ENTRIES = [
"src/backend/index.ts",
"src/backend/index.mjs",
"src/backend/index.js",
];
const FRONTEND_ENTRIES = [
"src/frontend/index.tsx",
"src/frontend/index.ts",
"src/frontend/index.mjs",
"src/frontend/index.js",
];
export async function build({ cwd }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const outDir = path.join(cwd, "dist");
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
if (!backendEntry) {
throw new Error(
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
);
}
await esbuild.build({
entryPoints: [backendEntry],
outfile: path.join(outDir, "backend.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
external: BACKEND_EXTERNALS,
plugins: [legacyCoreImports({ pluginId, platform: "node" })],
});
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) {
await esbuild.build({
entryPoints: [frontendEntry],
outfile: path.join(outDir, "frontend.js"),
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
plugins: [legacyCoreImports({ pluginId, platform: "browser" })],
});
}
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
console.log(`built ${pluginId}`);
}
+33
View File
@@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";
import { create } from "tar";
import { readManifest } from "../lib/plugin-dir.mjs";
/** Everything a server needs to run the plugin, and nothing else. D3 signs it. */
const CONTENTS = [
"manifest.json",
"dist",
"locales",
"migrations",
"README.md",
"CHANGELOG.md",
];
export async function pack({ cwd }) {
const manifest = readManifest(cwd);
const pluginId = manifest.id ?? path.basename(cwd);
const version = manifest.version ?? "0.0.0";
if (!fs.existsSync(path.join(cwd, "dist", "backend.js"))) {
throw new Error(`${pluginId}: nothing built. Run termix-plugin build.`);
}
const files = CONTENTS.filter((entry) =>
fs.existsSync(path.join(cwd, entry)),
);
const outfile = path.join(cwd, `${pluginId}-${version}.tgz`);
await create({ gzip: true, cwd, file: outfile }, files);
console.log(`packed ${path.basename(outfile)}`);
}
+33
View File
@@ -0,0 +1,33 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { createRequire } from "node:module";
/**
* Runs vitest through its own entry rather than the npx shim, so no shell is
* involved and paths with spaces work the same on every platform.
*/
export async function test({ cwd, args }) {
const require = createRequire(import.meta.url);
let vitestBin;
try {
// vitest.mjs is the package bin, not an exported subpath, so resolve the
// manifest and walk to it.
const manifest = require.resolve("vitest/package.json", { paths: [cwd] });
vitestBin = path.join(path.dirname(manifest), "vitest.mjs");
} catch {
throw new Error(
"vitest is not installed. Run npm install at the repo root.",
);
}
const result = spawnSync(
process.execPath,
[vitestBin, "run", "--config", "vitest.config.ts", ...args],
{ cwd, stdio: "inherit" },
);
if (result.status !== 0) {
throw new Error(`vitest exited with ${result.status}`);
}
}
@@ -0,0 +1,44 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
* there is one implementation rather than a copy here that drifts.
*/
async function loadParseManifest() {
const entry = new URL("../../dist/manifest.js", import.meta.url);
if (!fs.existsSync(fileURLToPath(entry))) {
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
}
const mod = await import(entry.href);
return mod.parseManifest;
}
export async function validate({ cwd }) {
const raw = readManifest(cwd);
const parseManifest = await loadParseManifest();
const { errors } = parseManifest(raw);
const problems = [...errors];
// The manifest names files. They have to be there.
const referenced = [
raw.backend ?? "dist/backend.js",
raw.frontend ?? "dist/frontend.js",
raw.locales ?? "locales",
];
for (const rel of referenced) {
if (!fs.existsSync(path.join(cwd, rel))) {
problems.push(`manifest references ${rel}, which does not exist`);
}
}
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
}
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
/**
* termix-plugin: build, validate, test and pack a Termix plugin.
*
* Run from a plugin directory (npm run build inside plugins/<id>/ does).
*/
import process from "node:process";
import { build } from "./commands/build.mjs";
import { validate } from "./commands/validate.mjs";
import { test } from "./commands/test.mjs";
import { pack } from "./commands/pack.mjs";
const COMMANDS = { build, validate, test, pack };
const [command, ...args] = process.argv.slice(2);
if (!command || command === "--help" || command === "-h") {
console.log(
[
"Usage: termix-plugin <command>",
"",
" build Bundle the plugin into dist/",
" validate Check manifest.json and the files it names",
" test Run the plugin's vitest suite",
" pack Write a .tgz of the built plugin",
].join("\n"),
);
process.exit(command ? 0 : 1);
}
const run = COMMANDS[command];
if (!run) {
console.error(`Unknown command: ${command}`);
process.exit(1);
}
try {
await run({ cwd: process.cwd(), args });
} catch (error) {
console.error(error?.message ?? error);
process.exit(1);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Host-provided packages.
*
* These ship with the Termix server and are never bundled into a plugin:
* bundling them would give each plugin its own copy of express or React, and
* a second ssh2 would mean a second set of native bindings. A plugin may
* depend on anything outside these lists, and that does get bundled.
*
* Node builtins are external automatically through platform: "node".
*/
export const BACKEND_EXTERNALS = [
"@termix/plugin-sdk",
"@termix/plugin-sdk/*",
"express",
"ssh2",
"ws",
"multer",
"cookie-parser",
"axios",
"jszip",
"guacamole-lite",
"@anthropic-ai/sdk",
"drizzle-orm",
"drizzle-orm/*",
];
export const FRONTEND_EXTERNALS = [
"react",
"react-dom",
"react/jsx-runtime",
"react-dom/client",
"@termix/plugin-sdk",
"@termix/plugin-sdk/*",
"i18next",
"react-i18next",
// Core-provided UI libraries. A7 serves these through the SDK ui entry.
"lucide-react",
"sonner",
"axios",
"cytoscape",
"react-cytoscapejs",
"guacamole-common-js",
"react-xtermjs",
"@xterm/*",
// Legacy: the shell's own modules, still reached by alias. A7 removes.
"@/*",
];
@@ -0,0 +1,61 @@
/**
* Legacy core imports: the debt D1 removes.
*
* Plugin code predates the SDK and still reaches core, and in one case a
* sibling plugin, by relative path. Those specifiers must stay out of the
* bundle and be rewritten to where the built server puts the target.
*
* The rewrite is output-relative, not source-relative. Every plugin bundles
* to plugins/<id>/dist/backend.js, which sits at the same depth the old
* per-file output did, so one prefix covers a source file at any nesting.
*
* Delete this file in D1, once every plugin reaches core through the SDK.
*/
// "../../../src/backend/" or "../../../../src/backend/", any depth.
const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
// Shared types. Mostly erased at compile, but automations imports a real
// value (AUTOMATION_DEFINITION_VERSION), so this has to resolve at runtime.
const CORE_TYPES = /^(?:\.\.\/)+src\/types\//;
const CORE_UI = /^(?:\.\.\/)+src\/ui\//;
// "../../../<other-plugin>/backend/" -- only plugins/ai/.../executor.ts today.
// esbuild filters are Go RE2, which has no lookahead, so "src" is excluded in
// the callback rather than the pattern.
const CROSS_PLUGIN = /^(?:\.\.\/)+([a-z0-9-]+)\/(?:src\/)?backend\//;
/** dist/plugins/<id>/dist/backend.js -> dist/backend/... */
const COMPILED_CORE = "../../../backend/backend/";
const COMPILED_TYPES = "../../../backend/types/";
export function legacyCoreImports({ pluginId, platform }) {
return {
name: "legacy-core-imports",
setup(build) {
build.onResolve({ filter: CORE_BACKEND }, (args) => ({
path: args.path.replace(CORE_BACKEND, COMPILED_CORE),
external: true,
}));
build.onResolve({ filter: CORE_TYPES }, (args) => ({
path: args.path.replace(CORE_TYPES, COMPILED_TYPES),
external: true,
}));
// The shell's own modules. Only plugins/docker's frontend entry reaches
// core this way; every other frontend uses the "@/" alias, which is
// externalized by name. A7 removes both.
if (platform === "browser") {
build.onResolve({ filter: CORE_UI }, (args) => ({
path: args.path,
external: true,
}));
}
build.onResolve({ filter: CROSS_PLUGIN }, (args) => {
const [, other] = CROSS_PLUGIN.exec(args.path);
if (other === "src" || other === pluginId) return null;
return { path: `../../${other}/dist/backend.js`, external: true };
});
},
};
}
@@ -0,0 +1,29 @@
import fs from "node:fs";
import path from "node:path";
export function readManifest(cwd) {
const manifestPath = path.join(cwd, "manifest.json");
if (!fs.existsSync(manifestPath)) {
throw new Error(`No manifest.json in ${cwd}`);
}
try {
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
} catch (error) {
throw new Error(`manifest.json is not valid JSON: ${error.message}`);
}
}
/** The first of these that exists, or null. */
export function resolveEntry(cwd, candidates) {
for (const candidate of candidates) {
const full = path.join(cwd, candidate);
if (fs.existsSync(full)) return full;
}
return null;
}
export function copyDir(from, to) {
if (!fs.existsSync(from)) return false;
fs.cpSync(from, to, { recursive: true });
return true;
}
+17 -1
View File
@@ -4,6 +4,9 @@
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "MIT",
"type": "module",
"bin": {
"termix-plugin": "./cli/index.mjs"
},
"exports": {
"./backend": {
"types": "./dist/backend.d.ts",
@@ -25,17 +28,30 @@
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
},
"./testing/setup": {
"types": "./dist/testing/setup.d.ts",
"default": "./dist/testing/setup.js"
},
"./vitest-preset": {
"types": "./dist/vitest-preset.d.ts",
"default": "./dist/vitest-preset.js"
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
},
"files": [
"dist",
"cli",
"schema",
"tsconfig.plugin.json",
"ARCHITECTURE.md"
],
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"semver": "^7.6.3"
"esbuild": "^0.28.1",
"semver": "^7.6.3",
"tar": "^7.5.22"
}
}
+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}"],
},
},
],
},
};
}
+2 -1
View File
@@ -2,7 +2,8 @@
"compilerOptions": {
"tsBuildInfoFile": "../../node_modules/.tmp/tsconfig.plugin-sdk.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true,
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "nodenext",
"moduleResolution": "nodenext",
"jsx": "react-jsx",
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"esModuleInterop": true,
"resolveJsonModule": true,
"noEmit": true,
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedSideEffectImports": false,
"noImplicitAny": false,
"noImplicitThis": false,
"noUncheckedIndexedAccess": false,
"exactOptionalPropertyTypes": false,
"noPropertyAccessFromIndexSignature": false,
"allowUnusedLabels": true,
"allowUnreachableCode": true
}
}