mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
|
||||
@@ -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