mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: shell controller, TabStage, layout and theme plugins with repair view
This commit is contained in:
1 parent
aff29a9581
commit
86161d66e2
120 files changed
+23693
-3381
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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> }>,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user