feat: shell controller, TabStage, layout and theme plugins with repair view

This commit is contained in:
LukeGus committed 2026-10-18 16:37:36 -05:00
1 parent aff29a9581
commit 86161d66e2
120 files changed
+23693 -3381

No files matched your search

@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
userId: "user-1",
admins: new Set(["admin-1"]),
prefs: null as { layoutId: string | null; themeId: string | null } | null,
settings: new Map<string, string>(),
audits: [] as unknown[],
skipped: [] as unknown[],
defaultLookIds: [] as string[],
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: unknown) => {
state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = state.userId;
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserPreferenceRepository: () => ({
findByUserId: async () => state.prefs,
}),
createCurrentUserRepository: () => ({
findById: async (id: string) => ({
id,
username: id,
isAdmin: state.admins.has(id),
}),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
set: async (key: string, value: string) => {
state.settings.set(key, value);
},
delete: async (key: string) => {
state.settings.delete(key);
},
}),
}));
vi.mock("../../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: {
get: (id: string) =>
id === "shipped-look"
? {
manifest: {
contributes: {
layouts: [
{ id: "shipped", label: "Shipped", defaultTheme: "ink" },
],
},
},
}
: undefined,
skipped: () => state.skipped,
},
}),
}));
vi.mock("../../../plugins/bundled-index.js", () => ({
defaultLookPluginIds: () => state.defaultLookIds,
}));
const { default: lookRoutes } =
await import("../../../database/routes/look.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.userId = "user-1";
state.prefs = null;
state.settings.clear();
state.audits = [];
state.skipped = [];
state.defaultLookIds = [];
delete process.env.TERMIX_SAFE_LAYOUT;
const app = express();
app.use(express.json());
app.use("/user-preferences/look", lookRoutes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
delete process.env.TERMIX_SAFE_LAYOUT;
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function getLook() {
const res = await fetch(`${baseUrl}/user-preferences/look`);
return { status: res.status, body: await res.json() };
}
async function putDefaults(body: unknown) {
const res = await fetch(`${baseUrl}/user-preferences/look/defaults`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
describe("GET /user-preferences/look", () => {
it("is all empty on a fresh install", async () => {
expect((await getLook()).body).toEqual({
layoutId: null,
themeId: null,
defaults: { layout: null, theme: null },
defaultLook: { layout: null, theme: null },
safeLayout: false,
unavailable: [],
});
});
it("returns the user's pick, the admin defaults and the default look", async () => {
state.prefs = { layoutId: "mine", themeId: "nord" };
state.settings.set("ui.layout", "admin-layout");
state.settings.set("ui.theme", "gruvbox");
state.defaultLookIds = ["shipped-look"];
state.skipped = [
{
id: "broken",
version: "1.0.0",
layouts: ["b"],
themes: [],
error: "x",
},
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
];
process.env.TERMIX_SAFE_LAYOUT = "1";
const { body } = await getLook();
expect(body).toMatchObject({
layoutId: "mine",
themeId: "nord",
defaults: { layout: "admin-layout", theme: "gruvbox" },
defaultLook: { layout: "shipped", theme: "ink" },
safeLayout: true,
});
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
["broken"],
);
});
});
describe("PUT /user-preferences/look/defaults", () => {
it("is for admins only", async () => {
const { status } = await putDefaults({ layout: "x" });
expect(status).toBe(403);
expect(state.settings.size).toBe(0);
});
it("sets and clears the defaults, and audits it", async () => {
state.userId = "admin-1";
let res = await putDefaults({ layout: "fancy", theme: "nord" });
expect(res).toEqual({
status: 200,
body: { layout: "fancy", theme: "nord" },
});
res = await putDefaults({ layout: null });
expect(res.body).toEqual({ layout: null, theme: "nord" });
expect(state.audits).toHaveLength(2);
expect(state.audits[0]).toMatchObject({ action: "update_default_look" });
});
it("refuses something that is not an id", async () => {
state.userId = "admin-1";
expect((await putDefaults({ theme: "../evil" })).status).toBe(400);
expect((await putDefaults({ layout: 5 })).status).toBe(400);
expect(state.settings.size).toBe(0);
});
});
@@ -433,6 +433,7 @@ describe("plugins route", () => {
"name",
"optionalDependencies",
"state",
"themeVersion",
"version",
].sort(),
);
@@ -624,10 +625,12 @@ describe("plugins route", () => {
contributes: null,
dependencies: {},
optionalDependencies: {},
defaultLook: false,
frontend: false,
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
});
});
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
const state = vi.hoisted(() => ({
row: null as Record<string, unknown> | null,
upserts: [] as Record<string, unknown>[],
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: express.Request, _res: unknown, next: () => void) => {
(req as unknown as { userId: string }).userId = "user-1";
next();
},
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserPreferenceRepository: () => ({
findByUserId: async () => state.row,
upsert: async (_userId: string, updates: Record<string, unknown>) => {
state.upserts.push(updates);
state.row = { ...(state.row ?? {}), ...updates };
},
}),
}));
vi.mock("../../../database/routes/keybinding-validation.js", () => ({
isValidKeybinding: () => true,
}));
const { default: routes } =
await import("../../../database/routes/user-preferences.js");
let server: http.Server;
let baseUrl: string;
beforeEach(async () => {
state.row = null;
state.upserts = [];
const app = express();
app.use(express.json());
app.use("/user-preferences", routes);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
async function put(body: unknown) {
const res = await fetch(`${baseUrl}/user-preferences`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
describe("user preferences: layout and theme", () => {
it("reads null for both until chosen", async () => {
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
expect(body).toMatchObject({ layoutId: null, themeId: null });
});
it("saves a layout and theme id, and null to follow the default", async () => {
expect((await put({ layoutId: "fancy", themeId: "nord" })).status).toBe(
200,
);
expect(state.upserts[0]).toMatchObject({
layoutId: "fancy",
themeId: "nord",
});
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
expect(body).toMatchObject({ layoutId: "fancy", themeId: "nord" });
await put({ layoutId: null });
expect(state.upserts[1]).toMatchObject({ layoutId: null });
expect(state.upserts[1]).not.toHaveProperty("themeId");
});
it("refuses anything that is not an id", async () => {
for (const bad of ["Fancy", "../x", "a", 7]) {
expect((await put({ themeId: bad })).status).toBe(400);
}
expect(state.upserts).toHaveLength(0);
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ const main = fs.readFileSync(
"utf8",
);
const shell = fs.readFileSync(
new URL("../../../ui/AppShell.tsx", import.meta.url),
new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url),
"utf8",
);
+46
View File
@@ -108,6 +108,7 @@ describe("describePluginFrontend", () => {
css: false,
assetVersion: null,
locales: [],
themeVersion: null,
});
});
});
@@ -175,3 +176,48 @@ describe("GET /plugin-assets", () => {
}
});
});
describe("theme stylesheets", () => {
let themed: { id: string; dir: string; manifest: never };
beforeAll(() => {
const dir = path.join(root, "inks");
fs.mkdirSync(path.join(dir, "themes"), { recursive: true });
fs.writeFileSync(path.join(dir, "themes", "paper.css"), ".paper{}");
fs.writeFileSync(path.join(dir, "themes", "secret.css"), ".secret{}");
themed = {
id: "inks",
dir,
manifest: {
id: "inks",
version: "1.0.0",
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
},
} as never,
};
});
it("serves a declared theme's CSS under _themes/<id>.css only", () => {
expect(resolvePluginAsset(themed, "_themes/paper.css")).toBe(
path.join(themed.dir, "themes", "paper.css"),
);
expect(resolvePluginAsset(themed, "_themes/secret.css")).toBeNull();
expect(resolvePluginAsset(themed, "themes/paper.css")).toBeNull();
expect(resolvePluginAsset(themed, "_themes/paper.js")).toBeNull();
});
it("versions theme files even without a frontend bundle", () => {
const info = describePluginFrontend(themed);
expect(info.frontend).toBe(false);
expect(info.themeVersion).toMatch(/^[0-9a-f]{12}$/);
expect(describePluginFrontend(plugin).themeVersion).toBeNull();
});
});
@@ -3,7 +3,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
defaultLookPluginIds,
initialPluginState,
isDefaultLookPlugin,
onboardingDefaults,
resetBundledIndexCache,
} from "../../plugins/bundled-index.js";
@@ -103,3 +105,28 @@ describe("initialPluginState", () => {
expect(initialPluginState("kept", "user", false)).toBe("disabled");
});
});
describe("default look", () => {
it("lists the plugins flagged as the default look", () => {
writeIndex({
"some-layout": { defaultLook: true },
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
other: { onboarding: { recommended: true } },
});
expect(defaultLookPluginIds()).toEqual(["some-layout", "some-themes"]);
expect(isDefaultLookPlugin("some-themes")).toBe(true);
expect(isDefaultLookPlugin("other")).toBe(false);
});
it("has none without an index", () => {
expect(defaultLookPluginIds()).toEqual([]);
});
it("starts the default look enabled even on a fresh install", () => {
writeIndex({
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
});
expect(initialPluginState("some-themes", "bundled", true)).toBe("enabled");
expect(initialPluginState("some-themes", "user", true)).toBe("disabled");
});
});
+79
View File
@@ -132,6 +132,85 @@ describe("PluginLoader discovery", () => {
});
});
describe("PluginLoader themes and skipped looks", () => {
const themed = {
capabilities: ["ui:theme", "ui:layout"],
manifestOverrides: {
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
layouts: [{ id: "fancy", label: "Fancy" }],
},
},
};
it("loads a plugin whose theme CSS is allowed", async () => {
const fixture = createFixturePlugin(themed);
cleanups.push(fixture.cleanup);
fs.mkdirSync(path.join(fixture.dir, "themes"));
fs.writeFileSync(
path.join(fixture.dir, "themes", "paper.css"),
".paper { --background: #fff; }",
);
const plugin = await new PluginLoader().load(fixture.dir, "bundled");
expect(plugin.manifest.contributes?.themes?.[0].id).toBe("paper");
});
it("refuses a plugin whose theme CSS reaches outside", async () => {
const fixture = createFixturePlugin(themed);
cleanups.push(fixture.cleanup);
fs.mkdirSync(path.join(fixture.dir, "themes"));
fs.writeFileSync(
path.join(fixture.dir, "themes", "paper.css"),
'@import url("https://example.com/x.css");',
);
await expect(
new PluginLoader().load(fixture.dir, "bundled"),
).rejects.toThrow(/theme paper is not allowed: @import/);
});
it("refuses a plugin whose theme file is missing", async () => {
const fixture = createFixturePlugin(themed);
cleanups.push(fixture.cleanup);
await expect(
new PluginLoader().load(fixture.dir, "bundled"),
).rejects.toThrow(/theme paper file themes\/paper.css is missing/);
});
it("remembers why a layout plugin was skipped", async () => {
const bundled = tempRoot("termix-bundled-");
const user = tempRoot("termix-data-");
createFixturePlugin({
...themed,
id: "fancy-layout",
root: bundled,
manifestOverrides: {
...themed.manifestOverrides,
version: "2.0.0",
engine: { termix: ">=99.0.0", api: "1" },
},
});
setRoots(bundled, user);
const loader = new PluginLoader();
await loader.loadAll();
const [skipped] = loader.skipped();
expect(skipped).toMatchObject({
id: "fancy-layout",
version: "2.0.0",
layouts: ["fancy"],
themes: ["paper"],
});
expect(skipped.error).toMatch(/needs Termix >=99.0.0/);
});
});
describe("PluginLoader dependency resolution", () => {
async function loadAll(
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
+105
View File
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const index = vi.hoisted(() => ({ ids: [] as string[] }));
vi.mock("../../plugins/bundled-index.js", () => ({
defaultLookPluginIds: () => index.ids,
}));
import {
resolveDefaultLook,
safeLayoutForced,
unavailableLooks,
} from "../../plugins/looks.js";
import type { SkippedPlugin } from "../../plugins/loader.js";
type Contributes = Record<string, unknown>;
function loaderWith(
plugins: Record<string, Contributes>,
skipped: SkippedPlugin[] = [],
) {
return {
get: (id: string) =>
plugins[id]
? ({ manifest: { contributes: plugins[id] } } as never)
: undefined,
skipped: () => skipped,
};
}
beforeEach(() => {
index.ids = [];
});
afterEach(() => {
delete process.env.TERMIX_SAFE_LAYOUT;
});
describe("resolveDefaultLook", () => {
it("is empty without a default-look plugin", () => {
expect(resolveDefaultLook(loaderWith({}))).toEqual({
layout: null,
theme: null,
});
});
it("takes the layout marked default and its theme", () => {
index.ids = ["look-a"];
const loader = loaderWith({
"look-a": {
layouts: [
{ id: "first", label: "First", defaultTheme: "one" },
{ id: "main", label: "Main", defaultTheme: "two", default: true },
],
},
});
expect(resolveDefaultLook(loader)).toEqual({
layout: "main",
theme: "two",
});
});
it("takes the first layout, and a theme from a themes-only plugin", () => {
index.ids = ["look-a", "look-b"];
const loader = loaderWith({
"look-a": { themes: [{ id: "paper", label: "Paper" }] },
"look-b": { layouts: [{ id: "solo", label: "Solo" }] },
});
expect(resolveDefaultLook(loader)).toEqual({
layout: "solo",
theme: "paper",
});
});
it("skips a default-look plugin that did not load", () => {
index.ids = ["gone"];
expect(resolveDefaultLook(loaderWith({}))).toEqual({
layout: null,
theme: null,
});
});
});
describe("unavailableLooks", () => {
it("lists only skipped plugins that brought a layout or theme", () => {
const skipped: SkippedPlugin[] = [
{ id: "a", version: "1.0.0", layouts: ["x"], themes: [], error: "e" },
{ id: "b", version: null, layouts: [], themes: [], error: "e" },
{ id: "c", version: "2.0.0", layouts: [], themes: ["t"], error: "e" },
];
expect(
unavailableLooks(loaderWith({}, skipped)).map((plugin) => plugin.id),
).toEqual(["a", "c"]);
});
});
describe("safeLayoutForced", () => {
it("reads TERMIX_SAFE_LAYOUT", () => {
expect(safeLayoutForced()).toBe(false);
process.env.TERMIX_SAFE_LAYOUT = "1";
expect(safeLayoutForced()).toBe(true);
process.env.TERMIX_SAFE_LAYOUT = "true";
expect(safeLayoutForced()).toBe(true);
process.env.TERMIX_SAFE_LAYOUT = "0";
expect(safeLayoutForced()).toBe(false);
});
});
+26
View File
@@ -289,6 +289,32 @@ describe("uninstall and reinstall of a bundled plugin", () => {
expect(loader.get("docker")?.source).toBe("user");
});
it("refuses to uninstall the default look but lets it be turned off", async () => {
createFixturePlugin({ id: "some-look", root: bundled });
fs.writeFileSync(
path.join(bundled, "bundled-index.json"),
JSON.stringify({
version: 1,
plugins: { "some-look": { defaultLook: true } },
}),
);
const { resetBundledIndexCache } =
await import("../../plugins/bundled-index.js");
resetBundledIndexCache();
try {
const loader = await boot();
await expect(manage.uninstallPlugin("some-look")).rejects.toMatchObject({
code: "DEFAULT_LOOK",
status: 409,
});
expect(loader.get("some-look")?.state).toBe("active");
expect(fs.existsSync(path.join(bundled, "some-look"))).toBe(true);
} finally {
fs.rmSync(path.join(bundled, "bundled-index.json"), { force: true });
resetBundledIndexCache();
}
});
it("deletes a plugin dropped in as a plain folder", async () => {
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
const loader = await boot();
@@ -1002,3 +1002,79 @@ describe("docs and env fields", () => {
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
});
});
describe("layouts and themes", () => {
const layouts = [
{
id: "fancy",
label: "layout.fancy",
defaultTheme: "paper",
default: true,
},
];
const themes = [
{
id: "paper",
label: "theme.paper",
mode: "light",
css: "themes/paper.css",
swatch: { background: "#ffffff", panel: "#eeeeee", text: "#111111" },
},
];
it("accepts layouts and themes with their capabilities", () => {
const { manifest, errors } = parseManifest(
base({
capabilities: ["ui:layout", "ui:theme"],
contributes: { layouts, themes },
}),
);
expect(errors).toEqual([]);
expect(manifest?.contributes?.layouts?.[0].id).toBe("fancy");
expect(manifest?.contributes?.themes?.[0].mode).toBe("light");
});
it("needs ui:layout for layouts and ui:theme for themes", () => {
const { errors } = parseManifest(
base({ capabilities: ["kv:own"], contributes: { layouts, themes } }),
);
expect(errors.some((e) => e.includes('"ui:layout"'))).toBe(true);
expect(errors.some((e) => e.includes('"ui:theme"'))).toBe(true);
});
it("refuses bad ids, modes, paths and swatches", () => {
const errors = validateManifest(
base({
capabilities: ["ui:layout", "ui:theme"],
contributes: {
layouts: [
{ id: "Fancy", label: "x" },
{ id: "two", label: "x", default: true },
{ id: "three", label: "x", default: true, extra: 1 },
],
themes: [
{ id: "paper", label: "x", mode: "sepia", css: "../paper.css" },
{ id: "ink", label: "x", mode: "dark", css: "ink.scss" },
{
id: "dusk",
label: "x",
mode: "dark",
css: "dusk.css",
swatch: { background: "red", panel: "#000", text: "#fff" },
},
{ id: "dusk", label: "x", mode: "dark", css: "dusk2.css" },
],
},
}),
);
const has = (text: string) => errors.some((e) => e.includes(text));
expect(has("contributes.layouts[0].id must match")).toBe(true);
expect(has("Only one entry")).toBe(true);
expect(has('Unknown field "extra"')).toBe(true);
expect(has('mode must be "light" or "dark"')).toBe(true);
expect(has("relative path inside the plugin")).toBe(true);
expect(has("must be a .css file")).toBe(true);
expect(has("swatch.background must be a hex color")).toBe(true);
expect(has('duplicates "dusk"')).toBe(true);
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { validateThemeCss } from "../../plugins/manifest.js";
describe("validateThemeCss", () => {
it("accepts token rules scoped under the theme class", () => {
const css = `
/* Paper */
.paper { --background: #fff; --foreground: #111; }
:root.paper, html.paper .card { --card: #f8f8f8; }
@media (prefers-reduced-motion: reduce) {
.paper * { transition: none; }
}
@supports (color: oklch(0 0 0)) { .paper { --border: oklch(0.9 0 0); } }
.paper .logo { background: url("logo.svg") no-repeat; }
.paper .dot { background: url(data:image/png;base64,AAAA); }
`;
expect(validateThemeCss(css, "paper")).toEqual([]);
});
it("refuses selectors outside the theme", () => {
const errors = validateThemeCss(
".paper { --a: 1 } body { color: red } .paperish { --b: 2 } .dark { --c: 3 }",
"paper",
);
expect(errors).toHaveLength(3);
expect(errors[0]).toContain('"body"');
});
it("refuses anything that loads or runs", () => {
const errors = validateThemeCss(
`@import url("https://evil.example/x.css");
.paper { background: url(https://evil.example/a.png); }
.paper { background: url(//evil.example/a.png); }
.paper { width: expression(alert(1)); }
.paper { background: url(javascript:alert(1)); }
.paper { behavior: url(x.htc); }
@font-face { font-family: x; src: url(x.woff2); }
@keyframes spin { from { opacity: 0 } }`,
"paper",
);
const has = (text: string) => errors.some((e) => e.includes(text));
expect(has("@import is not allowed")).toBe(true);
expect(has("url(https://evil.example/a.png) points outside")).toBe(true);
expect(has("url(//evil.example/a.png) points outside")).toBe(true);
expect(has("expression() is not allowed")).toBe(true);
expect(has("javascript: is not allowed")).toBe(true);
expect(has("Script bindings")).toBe(true);
expect(has("@font-face is not allowed")).toBe(true);
expect(has("@keyframes is not allowed")).toBe(true);
});
it("refuses nested rules and broken braces", () => {
expect(validateThemeCss(".paper { .x { color: red } }", "paper")).toEqual([
'Nested rules are not allowed in ".paper"',
]);
expect(validateThemeCss(".paper { color: red", "paper")).toEqual([
"A rule is missing its closing brace",
]);
});
it("does not trip on braces inside strings", () => {
expect(
validateThemeCss('.paper .x::after { content: "{ }"; }', "paper"),
).toEqual([]);
});
});