mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move snippets, keybinding actions and macros out of core into plugins
This commit is contained in:
1 parent
d9bba10612
commit
e0c30a4e6f
262 files changed
+9091
-10744
No files matched your search
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateAdminSettings } from "../../src/backend/settings-validation.js";
|
||||
import {
|
||||
validateAdminSettings,
|
||||
validateUserSettings,
|
||||
} from "../../src/backend/settings-validation.js";
|
||||
|
||||
describe("validateAdminSettings", () => {
|
||||
it("rejects relative image paths", () => {
|
||||
@@ -19,3 +22,28 @@ describe("validateAdminSettings", () => {
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateUserSettings", () => {
|
||||
const macro = { id: "m", name: "M", steps: [] };
|
||||
|
||||
it("accepts bounded macros and a save without them", () => {
|
||||
expect(validateUserSettings({ macros: [macro] })).toEqual({});
|
||||
expect(validateUserSettings({ macros: JSON.stringify([macro]) })).toEqual(
|
||||
{},
|
||||
);
|
||||
expect(validateUserSettings({ localEcho: "on" })).toEqual({});
|
||||
});
|
||||
|
||||
it("refuses malformed or too many macros", () => {
|
||||
expect(validateUserSettings({ macros: [{ id: 1 }] }).macros).toBeTruthy();
|
||||
expect(validateUserSettings({ macros: "{" }).macros).toBeTruthy();
|
||||
expect(
|
||||
validateUserSettings({
|
||||
macros: Array.from({ length: 101 }, (_, i) => ({
|
||||
...macro,
|
||||
id: `${i}`,
|
||||
})),
|
||||
}).macros,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { dispatchKeybindingAction } from "../../../src/frontend/lib/keybinding-dispatch";
|
||||
import {
|
||||
dispatchKeybindingAction,
|
||||
isTerminalKeybindingAction,
|
||||
validateSendControlCode,
|
||||
validateSendText,
|
||||
} from "../../../src/frontend/lib/keybinding-dispatch";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { KeybindingDispatchContext } from "../../../src/frontend/lib/keybinding-dispatch";
|
||||
import * as sdkFrontend from "@termix/plugin-sdk/frontend";
|
||||
|
||||
vi.mock("@termix/plugin-sdk/frontend", () => ({ invokeAction: vi.fn() }));
|
||||
|
||||
function makeContext(
|
||||
overrides: Partial<KeybindingDispatchContext> = {},
|
||||
@@ -90,65 +92,36 @@ describe("dispatchKeybindingAction", () => {
|
||||
expect(ctx.sentData).toEqual(["ls -la\r"]);
|
||||
});
|
||||
|
||||
it("runSnippet resolves the snippet through the snippets.resolveForTerminal action and sends its content with a trailing return", async () => {
|
||||
vi.mocked(sdkFrontend.invokeAction).mockResolvedValue({
|
||||
needsInputs: false,
|
||||
content: "uptime",
|
||||
});
|
||||
it("leaves any other action to whoever registered it", () => {
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(sdkFrontend.invokeAction).toHaveBeenCalledWith(
|
||||
"snippets.resolveForTerminal",
|
||||
1,
|
||||
null,
|
||||
);
|
||||
expect(ctx.sentData).toEqual(["uptime\r"]);
|
||||
});
|
||||
|
||||
it("runSnippet does nothing when the snippet no longer exists", async () => {
|
||||
vi.mocked(sdkFrontend.invokeAction).mockResolvedValue(undefined);
|
||||
const ctx = makeContext();
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx),
|
||||
).toBe(false);
|
||||
expect(dispatchKeybindingAction({ type: "nextTab" }, ctx)).toBe(false);
|
||||
expect(dispatchKeybindingAction({ type: "copy" }, ctx)).toBe(true);
|
||||
expect(ctx.sentData).toEqual([]);
|
||||
});
|
||||
|
||||
it("runSnippet passes hostContext through to the resolve action", async () => {
|
||||
vi.mocked(sdkFrontend.invokeAction).mockResolvedValue({
|
||||
needsInputs: false,
|
||||
content: "ping 10.0.0.5 -p 22",
|
||||
});
|
||||
const ctx = makeContext({
|
||||
hostContext: { ip: "10.0.0.5", username: "root", port: 22 },
|
||||
});
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(sdkFrontend.invokeAction).toHaveBeenCalledWith(
|
||||
"snippets.resolveForTerminal",
|
||||
1,
|
||||
{ ip: "10.0.0.5", username: "root", port: 22 },
|
||||
it("knows which actions are its own", () => {
|
||||
expect(isTerminalKeybindingAction("sendText")).toBe(true);
|
||||
expect(isTerminalKeybindingAction("runSnippet")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keybinding validators", () => {
|
||||
it("needs text to send", () => {
|
||||
expect(validateSendText({ type: "sendText", text: " " })).toBe(
|
||||
"keybindings.textRequired",
|
||||
);
|
||||
expect(ctx.sentData).toEqual(["ping 10.0.0.5 -p 22\r"]);
|
||||
expect(validateSendText({ type: "sendText", text: "ls" })).toBeNull();
|
||||
});
|
||||
|
||||
it("runSnippet defers to onSnippetNeedsInputs instead of sending when the action reports needsInputs", async () => {
|
||||
vi.mocked(sdkFrontend.invokeAction).mockResolvedValue({
|
||||
needsInputs: true,
|
||||
content: "echo $INPUT_1",
|
||||
});
|
||||
const onSnippetNeedsInputs = vi.fn();
|
||||
const ctx = makeContext({ onSnippetNeedsInputs });
|
||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(ctx.sentData).toEqual([]);
|
||||
expect(onSnippetNeedsInputs).toHaveBeenCalledWith({
|
||||
id: "1",
|
||||
content: "echo $INPUT_1",
|
||||
});
|
||||
it("needs a single letter for a control code", () => {
|
||||
expect(
|
||||
validateSendControlCode({ type: "sendControlCode", controlCode: "ab" }),
|
||||
).toBe("keybindings.controlCodeRequired");
|
||||
expect(
|
||||
validateSendControlCode({ type: "sendControlCode", controlCode: "w" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
mergeLocalMacros,
|
||||
parseTerminalMacros,
|
||||
sanitizeTerminalMacros,
|
||||
runTerminalMacro,
|
||||
type MacroStep,
|
||||
type TerminalMacro,
|
||||
} from "../../../src/frontend/macros/terminal-macros";
|
||||
|
||||
function macro(steps: MacroStep[]): TerminalMacro {
|
||||
return {
|
||||
id: "macro-1",
|
||||
name: "Test",
|
||||
steps,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("terminal macro runner", () => {
|
||||
it("runs send, delay, regex wait, condition, and repeat steps", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sent: string[] = [];
|
||||
let listener = (_data: string) => {};
|
||||
const run = runTerminalMacro(
|
||||
macro([
|
||||
{ id: "send", type: "send", text: "status", pressEnter: true },
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
pattern: "READY\\s+42",
|
||||
timeoutMs: 1000,
|
||||
onTimeout: "stop",
|
||||
},
|
||||
{
|
||||
id: "if",
|
||||
type: "if",
|
||||
pattern: "READY",
|
||||
then: [
|
||||
{
|
||||
id: "repeat",
|
||||
type: "repeat",
|
||||
count: 2,
|
||||
steps: [
|
||||
{ id: "ok", type: "send", text: "ok", pressEnter: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
else: [],
|
||||
},
|
||||
]),
|
||||
{
|
||||
send: (data) => sent.push(data),
|
||||
subscribe: (next) => {
|
||||
listener = next;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
listener("READY 42");
|
||||
await run;
|
||||
expect(sent).toEqual(["status\r", "ok", "ok"]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops when a required regex times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const run = runTerminalMacro(
|
||||
macro([
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
pattern: "never",
|
||||
timeoutMs: 100,
|
||||
onTimeout: "stop",
|
||||
},
|
||||
]),
|
||||
{ send: () => {}, subscribe: () => () => {} },
|
||||
);
|
||||
const rejection = expect(run).rejects.toThrow("Timed out");
|
||||
await vi.advanceTimersByTimeAsync(101);
|
||||
await rejection;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sanitizes persisted macro bounds and drops unknown steps", () => {
|
||||
const parsed = parseTerminalMacros(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "m",
|
||||
name: "Macro",
|
||||
steps: [
|
||||
{ id: "d", type: "delay", milliseconds: 999_999 },
|
||||
{ id: "r", type: "repeat", count: 1000, steps: [] },
|
||||
{ id: "bad", type: "execute-javascript", code: "nope" },
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(parsed[0].steps).toEqual([
|
||||
{ id: "d", type: "delay", milliseconds: 300_000 },
|
||||
{ id: "r", type: "repeat", count: 100, steps: [] },
|
||||
]);
|
||||
});
|
||||
it("matches literal text without regex escaping when isRegex is false", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sent: string[] = [];
|
||||
let listener = (_data: string) => {};
|
||||
const run = runTerminalMacro(
|
||||
macro([
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
pattern: "progress (1/2)",
|
||||
isRegex: false,
|
||||
timeoutMs: 1000,
|
||||
onTimeout: "stop",
|
||||
},
|
||||
{ id: "send", type: "send", text: "done", pressEnter: false },
|
||||
]),
|
||||
{
|
||||
send: (data) => sent.push(data),
|
||||
subscribe: (next) => {
|
||||
listener = next;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
listener("progress (1/2)");
|
||||
await run;
|
||||
expect(sent).toEqual(["done"]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("falls back to literal matching when a regex is invalid", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sent: string[] = [];
|
||||
let listener = (_data: string) => {};
|
||||
const run = runTerminalMacro(
|
||||
macro([
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
pattern: "oops(",
|
||||
isRegex: true,
|
||||
timeoutMs: 1000,
|
||||
onTimeout: "stop",
|
||||
},
|
||||
{ id: "send", type: "send", text: "ok", pressEnter: false },
|
||||
]),
|
||||
{
|
||||
send: (data) => sent.push(data),
|
||||
subscribe: (next) => {
|
||||
listener = next;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
listener("oops(");
|
||||
await run;
|
||||
expect(sent).toEqual(["ok"]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("branches on literal if-step text", async () => {
|
||||
const sent: string[] = [];
|
||||
await runTerminalMacro(
|
||||
macro([
|
||||
{
|
||||
id: "if",
|
||||
type: "if",
|
||||
pattern: "a.b",
|
||||
isRegex: false,
|
||||
then: [{ id: "y", type: "send", text: "yes", pressEnter: false }],
|
||||
else: [{ id: "n", type: "send", text: "no", pressEnter: false }],
|
||||
},
|
||||
]),
|
||||
{ send: (data) => sent.push(data), subscribe: () => () => {} },
|
||||
);
|
||||
// "a.b" as a literal must not match "axb"
|
||||
expect(sent).toEqual(["no"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeLocalMacros", () => {
|
||||
function storage(value: string | null) {
|
||||
const store = new Map<string, string>();
|
||||
if (value !== null) store.set("terminalMacros", value);
|
||||
return {
|
||||
store,
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
};
|
||||
}
|
||||
const saved = (id: string): TerminalMacro => ({ ...macro([]), id });
|
||||
|
||||
it("returns null when the browser kept nothing", () => {
|
||||
expect(mergeLocalMacros([saved("a")], storage(null))).toBeNull();
|
||||
});
|
||||
|
||||
it("adds the browser's macros the saved list does not have", () => {
|
||||
const local = storage(
|
||||
JSON.stringify([
|
||||
{ id: "a", name: "Dupe", steps: [] },
|
||||
{ id: "b", name: "Local", steps: [] },
|
||||
]),
|
||||
);
|
||||
const merged = mergeLocalMacros([saved("a")], local);
|
||||
expect(merged?.macros.map((m) => m.id)).toEqual(["a", "b"]);
|
||||
expect(local.store.has("terminalMacros")).toBe(true);
|
||||
merged?.clear();
|
||||
expect(local.store.has("terminalMacros")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the key when everything is already saved", () => {
|
||||
const local = storage(JSON.stringify([{ id: "a", name: "A", steps: [] }]));
|
||||
expect(mergeLocalMacros([saved("a")], local)).toBeNull();
|
||||
expect(local.store.has("terminalMacros")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a stored array as well as its JSON", () => {
|
||||
expect(
|
||||
sanitizeTerminalMacros([{ id: "x", name: "X", steps: [] }]).map(
|
||||
(m) => m.id,
|
||||
),
|
||||
).toEqual(["x"]);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ function renderSettings(form: Form = { pluginSettings: {} }) {
|
||||
setField={setField}
|
||||
updateForm={updateForm}
|
||||
host={{ id: "1", name: "box", ip: "10.0.0.1", port: 22 }}
|
||||
snippets={[{ id: 7, name: "deploy" }]}
|
||||
/>,
|
||||
);
|
||||
return { setField, updateForm, current: () => current };
|
||||
@@ -56,13 +55,9 @@ describe("HostTerminalSettings", () => {
|
||||
expect(screen.getByText("hosts.sudoPasswordAutoFillLabel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets the startup snippet, a core host field, from the offered snippets", () => {
|
||||
const { setField } = renderSettings();
|
||||
const select = screen
|
||||
.getByRole("option", { name: "deploy" })
|
||||
.closest("select")!;
|
||||
fireEvent.change(select, { target: { value: "7" } });
|
||||
expect(setField).toHaveBeenCalledWith("startupSnippetId", 7);
|
||||
it("no longer draws the startup snippet, which is the snippets plugin's", () => {
|
||||
renderSettings();
|
||||
expect(screen.queryByText("hosts.startupSnippetLabel")).toBeNull();
|
||||
});
|
||||
|
||||
it("writes its values into the host's ssh-terminal settings", () => {
|
||||
|
||||
Reference in new issue
Block a user