mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-13 07:09:20 +00:00
feat: type the plugin host record, move plugin strings out of core and add lint checks
This commit is contained in:
1 parent
bba06f7ff6
commit
b8bbcd2196
626 files changed
+23884
-4590
No files matched your search
@@ -7,7 +7,8 @@
|
||||
* action, slot or permission id that starts with one ("docker.open"). The
|
||||
* shell (src/ui) also may not spell a view a plugin owns (a tab, panel or
|
||||
* dashboard card id from a manifest). What core needs from a plugin comes
|
||||
* through the registries instead.
|
||||
* through the registries instead. Regex literals are read too, so
|
||||
* /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
|
||||
*
|
||||
* Tests and locales are exempt, and so is src/backend/upgrade/: the one-time
|
||||
* 2.8 to 2.9 data moves have to name the plugin each piece of data moves to,
|
||||
@@ -22,17 +23,22 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SRC = path.join(ROOT, "src");
|
||||
const UI = path.join(SRC, "ui");
|
||||
const PLUGINS = path.join(ROOT, "plugins");
|
||||
const EXEMPT_DIRS = new Set(["tests", "locales"]);
|
||||
const EXEMPT_PATHS = [path.join(SRC, "backend", "upgrade")];
|
||||
const SSH_CONNECT = path.join(SRC, "backend", "hosts", "connect");
|
||||
const SSH_TERMS = new Set(["totp"]);
|
||||
|
||||
function manifests() {
|
||||
const dir = path.join(ROOT, "plugins");
|
||||
function paths(root) {
|
||||
const src = path.join(root, "src");
|
||||
return {
|
||||
root,
|
||||
src,
|
||||
ui: path.join(src, "ui"),
|
||||
plugins: path.join(root, "plugins"),
|
||||
exempt: [path.join(src, "backend", "upgrade")],
|
||||
sshConnect: path.join(src, "backend", "hosts", "connect"),
|
||||
};
|
||||
}
|
||||
|
||||
function manifests(dir) {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
@@ -41,10 +47,10 @@ function manifests() {
|
||||
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
|
||||
}
|
||||
|
||||
function names() {
|
||||
function names(pluginsDir) {
|
||||
const ids = new Set();
|
||||
const views = new Set();
|
||||
for (const manifest of manifests()) {
|
||||
for (const manifest of manifests(pluginsDir)) {
|
||||
ids.add(manifest.id);
|
||||
const contributes = manifest.contributes ?? {};
|
||||
for (const list of [
|
||||
@@ -58,13 +64,13 @@ function names() {
|
||||
return { ids, views };
|
||||
}
|
||||
|
||||
function walk(dir, out) {
|
||||
if (EXEMPT_PATHS.includes(dir)) return out;
|
||||
function walk(dir, exempt, out) {
|
||||
if (exempt.includes(dir)) return out;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (EXEMPT_DIRS.has(entry.name)) continue;
|
||||
walk(full, out);
|
||||
walk(full, exempt, out);
|
||||
} else if (/\.(tsx?|mjs|cjs|jsx?)$/.test(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
@@ -83,25 +89,43 @@ function literals(source) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function scan() {
|
||||
const { ids, views } = names();
|
||||
/**
|
||||
* Every regex literal in a file, with escaped slashes undone, so a pattern
|
||||
* reads the way the path it matches does.
|
||||
*/
|
||||
function regexLiterals(source) {
|
||||
const out = [];
|
||||
const pattern =
|
||||
/(^|[=(,:[!&|?{};]|\breturn)\s*\/((?:[^/\\\n[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+)\/[dgimsuyv]*/gm;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const body = match[2];
|
||||
// A comment, not a pattern.
|
||||
if (body.startsWith("/") || body.startsWith("*")) continue;
|
||||
out.push(body.replace(/\\(.)/g, "$1"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function scan(root = path.resolve(__dirname, "..")) {
|
||||
const { src, ui, plugins, exempt, sshConnect } = paths(root);
|
||||
const { ids, views } = names(plugins);
|
||||
const found = {};
|
||||
const add = (file, what) => {
|
||||
const key = path.relative(ROOT, file).replaceAll("\\", "/");
|
||||
const key = path.relative(root, file).replaceAll("\\", "/");
|
||||
(found[key] ??= new Set()).add(what);
|
||||
};
|
||||
|
||||
for (const file of walk(SRC, [])) {
|
||||
for (const file of walk(src, exempt, [])) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const inShell = file.startsWith(UI + path.sep);
|
||||
const inConnect = file.startsWith(SSH_CONNECT + path.sep);
|
||||
const inShell = file.startsWith(ui + path.sep);
|
||||
const inConnect = file.startsWith(sshConnect + path.sep);
|
||||
for (const match of source.matchAll(
|
||||
/(?:from|import)\s*\(?\s*["']([^"']+)["']/g,
|
||||
)) {
|
||||
const target = match[1].startsWith(".")
|
||||
? path.resolve(path.dirname(file), match[1])
|
||||
: "";
|
||||
if (target.startsWith(PLUGINS + path.sep)) {
|
||||
if (target.startsWith(plugins + path.sep)) {
|
||||
add(file, `import ${match[1]}`);
|
||||
}
|
||||
}
|
||||
@@ -118,6 +142,17 @@ function scan() {
|
||||
const prefix = /^([a-z][a-z0-9-]*)\.[a-zA-Z]/.exec(text);
|
||||
if (prefix && ids.has(prefix[1])) add(file, text);
|
||||
}
|
||||
for (const pattern of regexLiterals(source)) {
|
||||
for (const route of pattern.matchAll(
|
||||
/\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g,
|
||||
)) {
|
||||
if (ids.has(route[1])) add(file, `/${pattern}/`);
|
||||
}
|
||||
const sshTerm = inConnect && SSH_TERMS.has(pattern);
|
||||
if (ids.has(pattern.replace(/^\^|\$$/g, "")) && !sshTerm) {
|
||||
add(file, `/${pattern}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(found).map(([file, set]) => [file, [...set].sort()]),
|
||||
@@ -140,4 +175,6 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { scan, regexLiterals };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { scan, regexLiterals } = require("./check-core-plugin-ids.cjs") as {
|
||||
scan: (root: string) => Record<string, string[]>;
|
||||
regexLiterals: (source: string) => string[];
|
||||
};
|
||||
|
||||
let root: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
function fixture(files: Record<string, string>): string {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "core-plugin-ids-"));
|
||||
const all = {
|
||||
"plugins/docker/manifest.json": JSON.stringify({
|
||||
id: "docker",
|
||||
contributes: { tabs: [{ id: "docker-tab" }] },
|
||||
}),
|
||||
...files,
|
||||
};
|
||||
for (const [file, content] of Object.entries(all)) {
|
||||
const full = path.join(root, file);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("regexLiterals", () => {
|
||||
it("reads patterns with their slashes unescaped, not divisions or comments", () => {
|
||||
const source = [
|
||||
"if (/^\\/plugin-api\\/snippets/.test(p)) return x / 2;",
|
||||
"const r = a.match(/[a/b]c/g); // not /here/",
|
||||
"return /docker/;",
|
||||
].join("\n");
|
||||
expect(regexLiterals(source)).toEqual([
|
||||
"^/plugin-api/snippets",
|
||||
"[a/b]c",
|
||||
"docker",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scan", () => {
|
||||
it("passes core code that names no plugin", () => {
|
||||
const dir = fixture({
|
||||
"src/backend/ok.ts": 'const route = "/plugin-api/" + id;\n',
|
||||
});
|
||||
expect(scan(dir)).toEqual({});
|
||||
});
|
||||
|
||||
it("fails a plugin route in a string or a regex literal", () => {
|
||||
const dir = fixture({
|
||||
"src/backend/string.ts": 'const a = "/plugin-api/docker/list";\n',
|
||||
"src/backend/regex.ts":
|
||||
"const b = /^\\/plugin-api\\/docker(\\/|$)/.test(url);\n",
|
||||
});
|
||||
expect(scan(dir)).toEqual({
|
||||
"src/backend/regex.ts": ["/^/plugin-api/docker(/|$)/"],
|
||||
"src/backend/string.ts": ["/plugin-api/docker"],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails a bare id, and a view id in the shell", () => {
|
||||
const dir = fixture({
|
||||
"src/backend/id.ts": 'if (pluginId === "docker") {}\n',
|
||||
"src/ui/view.ts": 'open("docker-tab");\n',
|
||||
"src/ui/tests/skip.ts": 'open("docker");\n',
|
||||
});
|
||||
expect(scan(dir)).toEqual({
|
||||
"src/backend/id.ts": ["docker"],
|
||||
"src/ui/view.ts": ["docker-tab"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* A plugin's strings live in its own locales/en.json.
|
||||
*
|
||||
* A plugin's i18next namespace falls back to core's, so a plugin can use
|
||||
* `common.*` without copying it. Any other key a plugin uses has to be in the
|
||||
* plugin's own en.json: one that only resolves through the fallback breaks
|
||||
* the moment core renames or drops it, and core cannot tell it is in use.
|
||||
*
|
||||
* Reads t("...") calls and i18n key props (titleKey, labelKey and the rest)
|
||||
* in plugins/<id>/src, and the *Key values in plugins/<id>/manifest.json.
|
||||
* A key with a namespace ("docker:hosts.x") is checked against that plugin.
|
||||
*
|
||||
* --list prints what is missing per plugin, with whether core has the key.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const PLUGINS = path.join(ROOT, "plugins");
|
||||
const CORE_LOCALE = path.join(ROOT, "src", "ui", "locales", "en.json");
|
||||
|
||||
const SHARED_PREFIXES = ["common."];
|
||||
const KEY = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
|
||||
const KEY_PROPS = new Set([
|
||||
"titleKey",
|
||||
"labelKey",
|
||||
"descriptionKey",
|
||||
"noHostMessageKey",
|
||||
"paletteKey",
|
||||
"hintKey",
|
||||
"messageKey",
|
||||
"placeholderKey",
|
||||
"tooltipKey",
|
||||
"i18nKey",
|
||||
"enableLabelKey",
|
||||
"enableDescriptionKey",
|
||||
"consequenceKey",
|
||||
"groupKey",
|
||||
"emptyKey",
|
||||
"nameKey",
|
||||
]);
|
||||
const PLURAL_SUFFIXES = [
|
||||
"_zero",
|
||||
"_one",
|
||||
"_two",
|
||||
"_few",
|
||||
"_many",
|
||||
"_other",
|
||||
"_plural",
|
||||
];
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a dotted key resolves in a locale tree, the way i18next finds it:
|
||||
* a segment may itself hold dots ("services.use"), and a last segment may be
|
||||
* a plural form.
|
||||
*/
|
||||
function hasKey(tree, key) {
|
||||
const find = (node, parts) => {
|
||||
if (parts.length === 0) return node !== undefined;
|
||||
if (!node || typeof node !== "object") return false;
|
||||
for (let end = parts.length; end >= 1; end--) {
|
||||
const name = parts.slice(0, end).join(".");
|
||||
if (name in node && find(node[name], parts.slice(end))) return true;
|
||||
if (
|
||||
end === parts.length &&
|
||||
PLURAL_SUFFIXES.some((suffix) => `${name}${suffix}` in node)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return find(tree, key.split("."));
|
||||
}
|
||||
|
||||
function isShared(key) {
|
||||
return SHARED_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
function walk(dir, out = []) {
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drops comments, so an example in one is not read as a call. */
|
||||
function stripComments(source) {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Every literal i18n key a source file uses. A prefix a key is built from at
|
||||
* run time ends in ".*".
|
||||
*/
|
||||
function keysInSource(text) {
|
||||
const source = stripComments(text);
|
||||
const keys = new Set();
|
||||
for (const match of source.matchAll(
|
||||
/\bt\(\s*(["'])((?:(?!\1)[^\\\n$`])+)\1/g,
|
||||
)) {
|
||||
keys.add(match[2]);
|
||||
}
|
||||
for (const match of source.matchAll(
|
||||
/\b([A-Za-z]+Key)\s*[:=]\s*\{?\s*(["'])((?:(?!\2)[^\\\n])+)\2/g,
|
||||
)) {
|
||||
if (KEY_PROPS.has(match[1])) keys.add(match[3]);
|
||||
}
|
||||
// A key built at run time: `hosts.sharing.levels.${level}.label` needs the
|
||||
// whole hosts.sharing.levels subtree. A one-segment prefix says too little
|
||||
// to check.
|
||||
for (const match of source.matchAll(
|
||||
/\bt\(\s*`((?:[A-Za-z0-9_-]+\.){2,})\$\{/g,
|
||||
)) {
|
||||
keys.add(`${match[1]}*`);
|
||||
}
|
||||
return [...keys].filter((key) =>
|
||||
KEY.test(key.replace(/^[a-z0-9-]+:/, "").replace(/\.\*$/, "")),
|
||||
);
|
||||
}
|
||||
|
||||
function keysInManifest(value, out = new Set()) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) keysInManifest(item, out);
|
||||
} else if (value && typeof value === "object") {
|
||||
for (const [name, child] of Object.entries(value)) {
|
||||
if (KEY_PROPS.has(name) && typeof child === "string") out.add(child);
|
||||
else keysInManifest(child, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pluginIds(pluginsDir) {
|
||||
return fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((id) => fs.existsSync(path.join(pluginsDir, id, "manifest.json")))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys each plugin uses but does not define, other than the shared ones.
|
||||
* Returns { [pluginId]: [{ key, file, inCore }] }.
|
||||
*/
|
||||
function scan({ pluginsDir = PLUGINS, coreLocale = CORE_LOCALE } = {}) {
|
||||
const core = fs.existsSync(coreLocale) ? readJson(coreLocale) : {};
|
||||
const ids = pluginIds(pluginsDir);
|
||||
const locales = new Map(
|
||||
ids.map((id) => {
|
||||
const file = path.join(pluginsDir, id, "locales", "en.json");
|
||||
return [id, fs.existsSync(file) ? readJson(file) : {}];
|
||||
}),
|
||||
);
|
||||
|
||||
const missing = {};
|
||||
const check = (owner, rawKey, file) => {
|
||||
const colon = rawKey.indexOf(":");
|
||||
const namespace = colon > 0 ? rawKey.slice(0, colon) : owner;
|
||||
const key = colon > 0 ? rawKey.slice(colon + 1) : rawKey;
|
||||
if (isShared(key)) return;
|
||||
const tree = locales.get(namespace);
|
||||
const lookup = key.endsWith(".*") ? key.slice(0, -2) : key;
|
||||
if (tree && hasKey(tree, lookup)) return;
|
||||
const inCore = hasKey(core, lookup);
|
||||
// A bare word that is nowhere is a local helper named t, not a string.
|
||||
if (!key.includes(".") && !inCore) return;
|
||||
const list = (missing[owner] ??= []);
|
||||
if (!list.some((entry) => entry.key === rawKey)) {
|
||||
list.push({
|
||||
key: rawKey,
|
||||
file: path.relative(ROOT, file).replaceAll("\\", "/"),
|
||||
inCore,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const id of ids) {
|
||||
const manifestFile = path.join(pluginsDir, id, "manifest.json");
|
||||
for (const key of keysInManifest(readJson(manifestFile))) {
|
||||
if (KEY.test(key.replace(/^[a-z0-9-]+:/, ""))) {
|
||||
check(id, key, manifestFile);
|
||||
}
|
||||
}
|
||||
for (const file of walk(path.join(pluginsDir, id, "src"))) {
|
||||
for (const key of keysInSource(fs.readFileSync(file, "utf8"))) {
|
||||
check(id, key, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const list of Object.values(missing)) {
|
||||
list.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const missing = scan();
|
||||
if (process.argv.includes("--list")) {
|
||||
console.log(JSON.stringify(missing, null, 2));
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(missing);
|
||||
if (entries.length === 0) return;
|
||||
console.error(
|
||||
"Plugins use i18n keys their own locales/en.json does not define:",
|
||||
);
|
||||
for (const [id, list] of entries) {
|
||||
for (const { key, file } of list) {
|
||||
console.error(` ${id}: ${key} (${file})`);
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { scan, keysInSource, hasKey };
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { scan, keysInSource, hasKey } =
|
||||
require("./check-plugin-locales.cjs") as {
|
||||
scan: (options: {
|
||||
pluginsDir: string;
|
||||
coreLocale: string;
|
||||
}) => Record<string, Array<{ key: string; file: string; inCore: boolean }>>;
|
||||
keysInSource: (source: string) => string[];
|
||||
hasKey: (tree: unknown, key: string) => boolean;
|
||||
};
|
||||
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
function fixture(files: Record<string, unknown>) {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-locales-"));
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
const full = path.join(dir, file);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
full,
|
||||
typeof content === "string" ? content : JSON.stringify(content),
|
||||
);
|
||||
}
|
||||
return {
|
||||
pluginsDir: path.join(dir, "plugins"),
|
||||
coreLocale: path.join(dir, "core.json"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("keysInSource", () => {
|
||||
it("reads t() calls, key props and run time prefixes, not comments", () => {
|
||||
const keys = keysInSource(
|
||||
[
|
||||
'const a = t("hosts.one");',
|
||||
"const b = i18next.t('docker:hosts.two', { x: 1 });",
|
||||
'app.registerTab("x", X, { titleKey: "nav.three" });',
|
||||
'<EmptyState messageKey="hosts.four" />',
|
||||
"t(`hosts.sharing.levels.${level}.label`);",
|
||||
"t(`hosts.${label}`);",
|
||||
'// t("hosts.commented")',
|
||||
'storageKey: "not.a.string.key"',
|
||||
].join("\n"),
|
||||
);
|
||||
expect(keys.sort()).toEqual(
|
||||
[
|
||||
"docker:hosts.two",
|
||||
"hosts.four",
|
||||
"hosts.one",
|
||||
"hosts.sharing.levels.*",
|
||||
"nav.three",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasKey", () => {
|
||||
it("finds nested keys, dotted names and plural forms", () => {
|
||||
const tree = {
|
||||
a: { b: "x" },
|
||||
permissions: { "services.use": { title: "t" } },
|
||||
items: { count_one: "1", count_other: "n" },
|
||||
};
|
||||
expect(hasKey(tree, "a.b")).toBe(true);
|
||||
expect(hasKey(tree, "a")).toBe(true);
|
||||
expect(hasKey(tree, "permissions.services.use.title")).toBe(true);
|
||||
expect(hasKey(tree, "items.count")).toBe(true);
|
||||
expect(hasKey(tree, "a.c")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scan", () => {
|
||||
it("passes keys a plugin defines and the shared common ones", () => {
|
||||
const options = fixture({
|
||||
"core.json": { common: { save: "Save" }, hosts: { name: "Name" } },
|
||||
"plugins/demo/manifest.json": { id: "demo", titleKey: "title" },
|
||||
"plugins/demo/locales/en.json": { title: "Demo", hosts: { name: "N" } },
|
||||
"plugins/demo/src/frontend/index.tsx":
|
||||
't("common.save"); t("hosts.name"); t("title");',
|
||||
});
|
||||
expect(scan(options)).toEqual({});
|
||||
});
|
||||
|
||||
it("fails a key that only resolves through core's fallback", () => {
|
||||
const options = fixture({
|
||||
"core.json": { hosts: { name: "Name" } },
|
||||
"plugins/demo/manifest.json": { id: "demo" },
|
||||
"plugins/demo/locales/en.json": {},
|
||||
"plugins/demo/src/frontend/index.tsx": 't("hosts.name");',
|
||||
});
|
||||
expect(scan(options)).toEqual({
|
||||
demo: [
|
||||
{
|
||||
key: "hosts.name",
|
||||
file: expect.stringContaining("index.tsx"),
|
||||
inCore: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("checks a namespaced key against that plugin and a prefix as a subtree", () => {
|
||||
const options = fixture({
|
||||
"core.json": { hosts: { levels: { view: "View" } } },
|
||||
"plugins/demo/manifest.json": { id: "demo" },
|
||||
"plugins/demo/locales/en.json": {},
|
||||
"plugins/demo/src/frontend/index.tsx":
|
||||
't("other:known.key"); t("other:gone.key"); t(`hosts.levels.${l}`);',
|
||||
"plugins/other/manifest.json": { id: "other" },
|
||||
"plugins/other/locales/en.json": { known: { key: "k" } },
|
||||
});
|
||||
expect(scan(options).demo.map((entry) => entry.key)).toEqual([
|
||||
"hosts.levels.*",
|
||||
"other:gone.key",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @termix/plugin-sdk/ui is public API, implemented by
|
||||
* src/ui/plugin-host/sdk-ui.ts. Adding to it is a contract change and
|
||||
* removing from it is a breaking one, so neither should happen by accident.
|
||||
*
|
||||
* Resolves every name the module exports (through its `export *` lines too)
|
||||
* and fails when that differs from ALLOWED. A new export goes on the list in
|
||||
* the same change, which is what makes it a deliberate one. Only generic
|
||||
* shell pieces belong here: anything one feature needs lives in its plugin.
|
||||
*
|
||||
* --list prints what the module exports now.
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||
|
||||
const ALLOWED = [
|
||||
"ActionSlot",
|
||||
"Alert",
|
||||
"AlertDescription",
|
||||
"AlertDialog",
|
||||
"AlertDialogAction",
|
||||
"AlertDialogCancel",
|
||||
"AlertDialogContent",
|
||||
"AlertDialogDescription",
|
||||
"AlertDialogFooter",
|
||||
"AlertDialogHeader",
|
||||
"AlertDialogOverlay",
|
||||
"AlertDialogPortal",
|
||||
"AlertDialogTitle",
|
||||
"AlertDialogTrigger",
|
||||
"AlertTitle",
|
||||
"Badge",
|
||||
"BarSeries",
|
||||
"BarSeriesItem",
|
||||
"BarSeriesProps",
|
||||
"BrowserSignInDialog",
|
||||
"Button",
|
||||
"ButtonProps",
|
||||
"Card",
|
||||
"CardAction",
|
||||
"CardContent",
|
||||
"CardDescription",
|
||||
"CardFooter",
|
||||
"CardGridCanvas",
|
||||
"CardHeader",
|
||||
"CardTitle",
|
||||
"Checkbox",
|
||||
"ColumnCountStepper",
|
||||
"ComponentSlot",
|
||||
"ConnectionLogPanel",
|
||||
"ConnectionLogProvider",
|
||||
"ConnectionOrigin",
|
||||
"ConnectionScreen",
|
||||
"ConnectionStage",
|
||||
"ConnectionStatus",
|
||||
"CustomKeybinding",
|
||||
"Dialog",
|
||||
"DialogClose",
|
||||
"DialogContent",
|
||||
"DialogDescription",
|
||||
"DialogFooter",
|
||||
"DialogHeader",
|
||||
"DialogOverlay",
|
||||
"DialogPortal",
|
||||
"DialogTitle",
|
||||
"DialogTrigger",
|
||||
"DropdownMenu",
|
||||
"DropdownMenuCheckboxItem",
|
||||
"DropdownMenuContent",
|
||||
"DropdownMenuGroup",
|
||||
"DropdownMenuItem",
|
||||
"DropdownMenuLabel",
|
||||
"DropdownMenuPortal",
|
||||
"DropdownMenuRadioGroup",
|
||||
"DropdownMenuRadioItem",
|
||||
"DropdownMenuSeparator",
|
||||
"DropdownMenuShortcut",
|
||||
"DropdownMenuSub",
|
||||
"DropdownMenuSubContent",
|
||||
"DropdownMenuSubTrigger",
|
||||
"DropdownMenuTrigger",
|
||||
"EmptyState",
|
||||
"FOLDER_COLORS",
|
||||
"FakeSwitch",
|
||||
"FullScreenAppPhase",
|
||||
"FullScreenAppWrapper",
|
||||
"GridCardCatalogEntry",
|
||||
"GridColSpan",
|
||||
"GridLayout",
|
||||
"GridSlot",
|
||||
"HostFeatureFields",
|
||||
"HostFeatureFieldsProps",
|
||||
"HostKeyVerificationDialog",
|
||||
"Input",
|
||||
"Kbd",
|
||||
"KbdKey",
|
||||
"KbdSeparator",
|
||||
"KeyCombo",
|
||||
"KeybindingAction",
|
||||
"KeybindingActionType",
|
||||
"Label",
|
||||
"LineChart",
|
||||
"LineChartSeries",
|
||||
"LocalCollectedFile",
|
||||
"LocalDirectoryListing",
|
||||
"LocalFileEntry",
|
||||
"LocalFsHomeInfo",
|
||||
"LocalFsResult",
|
||||
"LocalTransferOrigin",
|
||||
"LocalTransferProgress",
|
||||
"LocalTrashResult",
|
||||
"LocalWalkFile",
|
||||
"LocalWalkResult",
|
||||
"LogContext",
|
||||
"LogEntry",
|
||||
"MFAPromptMode",
|
||||
"ManagerCardError",
|
||||
"ManagerCardShell",
|
||||
"ManagerSearch",
|
||||
"MetricCard",
|
||||
"MiniStat",
|
||||
"PassphraseDialog",
|
||||
"PasswordInput",
|
||||
"PluginComponent",
|
||||
"PluginViewPlaceholder",
|
||||
"Popover",
|
||||
"PopoverAnchor",
|
||||
"PopoverContent",
|
||||
"PopoverTrigger",
|
||||
"RadialGauge",
|
||||
"RadialGaugeProps",
|
||||
"RecentActivityItem",
|
||||
"RobustClipboardProvider",
|
||||
"SSHAuthDialog",
|
||||
"ScrollArea",
|
||||
"ScrollBar",
|
||||
"SectionCard",
|
||||
"Select",
|
||||
"Select2",
|
||||
"SelectContent",
|
||||
"SelectGroup",
|
||||
"SelectItem",
|
||||
"SelectLabel",
|
||||
"SelectScrollDownButton",
|
||||
"SelectScrollUpButton",
|
||||
"SelectSeparator",
|
||||
"SelectTrigger",
|
||||
"SelectValue",
|
||||
"Separator",
|
||||
"SettingRow",
|
||||
"Sheet",
|
||||
"SheetClose",
|
||||
"SheetContent",
|
||||
"SheetDescription",
|
||||
"SheetFooter",
|
||||
"SheetHeader",
|
||||
"SheetTitle",
|
||||
"SheetTrigger",
|
||||
"Skeleton",
|
||||
"Slider",
|
||||
"Sparkline",
|
||||
"SparklineProps",
|
||||
"StatRow",
|
||||
"Switch",
|
||||
"TOTPDialog",
|
||||
"Textarea",
|
||||
"TextareaProps",
|
||||
"Tooltip",
|
||||
"TooltipContent",
|
||||
"TooltipProvider",
|
||||
"TooltipTrigger",
|
||||
"UptimeInfo",
|
||||
"VersionInfo",
|
||||
"WebSocketConnectionTarget",
|
||||
"WidgetTitle",
|
||||
"badgeVariants",
|
||||
"buildOriginWsUrl",
|
||||
"buttonVariants",
|
||||
"cn",
|
||||
"copyToClipboard",
|
||||
"createFrontendLogger",
|
||||
"createQuickConnectHost",
|
||||
"findMatchingKeybinding",
|
||||
"getAdaptiveResourceBudget",
|
||||
"getBasePath",
|
||||
"getDatabaseHealth",
|
||||
"getDeviceId",
|
||||
"getPollingEnvironmentMultiplier",
|
||||
"getRecentActivity",
|
||||
"getUptime",
|
||||
"getVersionInfo",
|
||||
"globalShortcutHandler",
|
||||
"guacStateToStage",
|
||||
"guacStateToStatus",
|
||||
"hydrateLocalSharedHostAuth",
|
||||
"isElectron",
|
||||
"isMacPlatform",
|
||||
"isQuickConnectHost",
|
||||
"isTabJumpHotkey",
|
||||
"linkedServerUrl",
|
||||
"markAdaptiveResourceUsed",
|
||||
"pluginWsUrl",
|
||||
"readFromClipboard",
|
||||
"resolveConnectionOrigin",
|
||||
"resolveRemoteHostId",
|
||||
"runAdaptiveBackgroundTask",
|
||||
"runAdaptivePolling",
|
||||
"runVisibleInterval",
|
||||
"useAdaptivePolling",
|
||||
"useAppTheme",
|
||||
"useConfirmation",
|
||||
"useConnectionLog",
|
||||
"useIsMobile",
|
||||
"useOptionalConnectionLog",
|
||||
"useTabs",
|
||||
"useTabsSafe",
|
||||
];
|
||||
|
||||
/** Every name a module exports, resolved the way the compiler sees it. */
|
||||
function exportedNames(file = SDK_UI, tsconfig = TSCONFIG) {
|
||||
const ts = require("typescript");
|
||||
const parsed = ts.getParsedCommandLineOfConfigFile(
|
||||
tsconfig,
|
||||
{},
|
||||
{ ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} },
|
||||
);
|
||||
const program = ts.createProgram([file], {
|
||||
...(parsed?.options ?? {}),
|
||||
noEmit: true,
|
||||
});
|
||||
const checker = program.getTypeChecker();
|
||||
const source = program.getSourceFile(file);
|
||||
const symbol = source && checker.getSymbolAtLocation(source);
|
||||
if (!symbol) throw new Error(`Cannot read the exports of ${file}`);
|
||||
return checker
|
||||
.getExportsOfModule(symbol)
|
||||
.map((entry) => entry.getName())
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** What the module adds to, and drops from, the allowlist. */
|
||||
function compare(names, allowed = ALLOWED) {
|
||||
const allowedSet = new Set(allowed);
|
||||
const nameSet = new Set(names);
|
||||
return {
|
||||
added: names.filter((name) => !allowedSet.has(name)),
|
||||
removed: allowed.filter((name) => !nameSet.has(name)),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const names = exportedNames();
|
||||
if (process.argv.includes("--list")) {
|
||||
console.log(names.join("\n"));
|
||||
return;
|
||||
}
|
||||
const { added, removed } = compare(names);
|
||||
if (added.length === 0 && removed.length === 0) return;
|
||||
if (added.length > 0) {
|
||||
console.error(
|
||||
"@termix/plugin-sdk/ui exports names that are not on the allowlist in scripts/check-sdk-ui-exports.cjs:",
|
||||
);
|
||||
for (const name of added) console.error(` + ${name}`);
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
console.error(
|
||||
"@termix/plugin-sdk/ui no longer exports these allowlisted names (a breaking change for plugins):",
|
||||
);
|
||||
for (const name of removed) console.error(` - ${name}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { ALLOWED, compare, exportedNames };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { ALLOWED, compare, exportedNames } =
|
||||
require("./check-sdk-ui-exports.cjs") as {
|
||||
ALLOWED: string[];
|
||||
compare: (
|
||||
names: string[],
|
||||
allowed?: string[],
|
||||
) => { added: string[]; removed: string[] };
|
||||
exportedNames: (file?: string, tsconfig?: string) => string[];
|
||||
};
|
||||
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
describe("compare", () => {
|
||||
it("names what was added and what was dropped", () => {
|
||||
expect(compare(["a", "b", "c"], ["a", "b", "d"])).toEqual({
|
||||
added: ["c"],
|
||||
removed: ["d"],
|
||||
});
|
||||
expect(compare(["a"], ["a"])).toEqual({ added: [], removed: [] });
|
||||
});
|
||||
|
||||
it("keeps the allowlist free of duplicates", () => {
|
||||
expect(new Set(ALLOWED).size).toBe(ALLOWED.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportedNames", () => {
|
||||
it("follows export * and renamed exports", () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-ui-exports-"));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "tsconfig.json"),
|
||||
JSON.stringify({ compilerOptions: { module: "esnext", strict: true } }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "parts.ts"),
|
||||
"export const Button = 1;\nexport type ButtonProps = { a: 1 };\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "entry.ts"),
|
||||
'export * from "./parts";\nconst theme = 2;\nexport { theme as useAppTheme };\n',
|
||||
);
|
||||
expect(
|
||||
exportedNames(
|
||||
path.join(dir, "entry.ts"),
|
||||
path.join(dir, "tsconfig.json"),
|
||||
),
|
||||
).toEqual(["Button", "ButtonProps", "useAppTheme"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tests mirror the source tree in their own directories and never sit next
|
||||
* to the code they test. A *.test.ts(x) file may only live under
|
||||
* src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SKIP = new Set([
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"release",
|
||||
"coverage",
|
||||
".vite",
|
||||
".turbo",
|
||||
]);
|
||||
const TEST_FILE = /\.test\.tsx?$/;
|
||||
const ALLOWED = [
|
||||
/^src\/backend\/tests\//,
|
||||
/^src\/ui\/tests\//,
|
||||
/^scripts\//,
|
||||
/^plugins\/[^/]+\/tests\//,
|
||||
];
|
||||
|
||||
function walk(dir, root, out) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (SKIP.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, root, out);
|
||||
else if (TEST_FILE.test(entry.name)) {
|
||||
out.push(path.relative(root, full).replaceAll("\\", "/"));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Test files outside the directories tests belong in. */
|
||||
function misplaced(root = ROOT) {
|
||||
return walk(root, root, [])
|
||||
.filter((file) => !ALLOWED.some((pattern) => pattern.test(file)))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function main() {
|
||||
const found = misplaced();
|
||||
if (found.length === 0) return;
|
||||
console.error(
|
||||
"Tests belong in src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/, mirroring the source:",
|
||||
);
|
||||
for (const file of found) console.error(` ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { misplaced };
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { misplaced } = require("./check-test-locations.cjs") as {
|
||||
misplaced: (root: string) => string[];
|
||||
};
|
||||
|
||||
let root: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
function fixture(files: string[]): string {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "test-locations-"));
|
||||
for (const file of files) {
|
||||
const full = path.join(root, file);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, "");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("misplaced", () => {
|
||||
it("accepts the mirrored test trees", () => {
|
||||
const dir = fixture([
|
||||
"src/backend/tests/utils/a.test.ts",
|
||||
"src/ui/tests/lib/b.test.tsx",
|
||||
"scripts/c.test.ts",
|
||||
"plugins/docker/tests/backend/d.test.ts",
|
||||
"plugins/docker/node_modules/pkg/e.test.ts",
|
||||
"plugins/docker/dist/f.test.ts",
|
||||
]);
|
||||
expect(misplaced(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a test next to its source", () => {
|
||||
const dir = fixture([
|
||||
"src/backend/utils/a.test.ts",
|
||||
"src/ui/lib/b.test.tsx",
|
||||
"plugins/docker/src/frontend/c.test.tsx",
|
||||
"packages/plugin-sdk/src/d.test.ts",
|
||||
"src/backend/utils/not-a-test.ts",
|
||||
]);
|
||||
expect(misplaced(dir)).toEqual([
|
||||
"packages/plugin-sdk/src/d.test.ts",
|
||||
"plugins/docker/src/frontend/c.test.tsx",
|
||||
"src/backend/utils/a.test.ts",
|
||||
"src/ui/lib/b.test.tsx",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user