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

+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([]);
});
});