Files
openidea/src/features/deployment/model.test.ts
T
Florent Vandflorentvdry 2d4d6ee6bf fix(deployment): accept absolute local paths in mappings and name menu entries after the server (#885)
A mapping's local path may now be an absolute path inside the project (the form IntelliJ writes), and a deployment path of . means the server root; absolute paths outside the project are kept as they are. The project-tree Deployment submenu names the default server (Upload to <server>, Download from <server>, Sync with Deployed to <server>…) and gains Sync with Deployed to Default Server. Remote Host downloads use the backend-resolved mapping, which fixes downloads under symlinked or case-different paths.

Fixes #877

Co-authored-by: florentvdry <[email protected]>
2026-11-03 23:35:43 +08:00

176 lines
7.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { SyncEntry } from "@/platform/ipc";
import { DEFAULT_DEPLOYMENT, DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings";
import {
defaultSyncAction,
initialSyncChoices,
joinRemote,
localPathForRemote,
pathsFromArg,
remoteName,
remoteParent,
resolveServer,
sshRemedy,
summarizeReport,
transferMenuEntries,
syncChoices,
syncSteps,
targetPaths,
} from "./model";
const ROOT = "/work/app";
describe("targetPaths", () => {
const ctx = { part: null, selection: [], editorPath: null, projectRoot: ROOT } as const;
it("reads paths passed by menus", () => {
expect(pathsFromArg(`${ROOT}/a.php`)).toEqual([`${ROOT}/a.php`]);
expect(pathsFromArg([`${ROOT}/a`, 3, ""])).toEqual([`${ROOT}/a`]);
expect(pathsFromArg({ paths: [`${ROOT}/b`] })).toEqual([`${ROOT}/b`]);
expect(pathsFromArg(undefined)).toBeNull();
expect(pathsFromArg({ paths: [] })).toBeNull();
});
it("prefers the passed paths over the selection and the editor", () => {
expect(
targetPaths(`${ROOT}/x`, { ...ctx, part: "project", selection: [`${ROOT}/y`], editorPath: `${ROOT}/z` }),
).toEqual([`${ROOT}/x`]);
});
it("uses the project view selection when it had focus last, else the editor file", () => {
const both = { ...ctx, selection: [`${ROOT}/src`], editorPath: `${ROOT}/index.php` };
expect(targetPaths(undefined, { ...both, part: "project" })).toEqual([`${ROOT}/src`]);
expect(targetPaths(undefined, { ...both, part: "editor" })).toEqual([`${ROOT}/index.php`]);
expect(targetPaths(undefined, { ...ctx, part: "editor", selection: [`${ROOT}/src`] })).toEqual([]);
});
it("drops paths outside the project and paths inside selected folders", () => {
expect(targetPaths([`${ROOT}/src`, `${ROOT}/src/a.php`, "/elsewhere/b", `${ROOT}/srcx`], ctx)).toEqual([
`${ROOT}/src`,
`${ROOT}/srcx`,
]);
expect(targetPaths(`${ROOT}/a`, { ...ctx, projectRoot: null })).toEqual([]);
});
});
describe("sync defaults", () => {
it("picks the action from the status", () => {
expect(defaultSyncAction("localOnly")).toBe("upload");
expect(defaultSyncAction("localNewer")).toBe("upload");
expect(defaultSyncAction("different")).toBe("upload");
expect(defaultSyncAction("remoteOnly")).toBe("download");
expect(defaultSyncAction("remoteNewer")).toBe("download");
expect(defaultSyncAction("same")).toBe("skip");
});
const entry = (path: string, status: SyncEntry["status"], local: boolean, remote: boolean): SyncEntry => ({
path,
remotePath: `/srv/${path}`,
local: local ? { size: 1, mtime: 1 } : null,
remote: remote ? { size: 2, mtime: 2 } : null,
status,
});
it("offers only actions possible for the row", () => {
expect(syncChoices(entry("a", "localOnly", true, false))).toEqual(["skip", "upload", "deleteLocal"]);
expect(syncChoices(entry("b", "remoteOnly", false, true))).toEqual(["skip", "download", "deleteRemote"]);
});
it("turns choices into steps without the skipped rows", () => {
const entries = [
entry("a", "localOnly", true, false),
entry("b", "same", true, true),
entry("c", "remoteNewer", true, true),
];
const choices = initialSyncChoices(entries);
expect(choices).toEqual({ a: "upload", b: "skip", c: "download" });
expect(syncSteps({ ...choices, b: "deleteRemote" })).toEqual([
{ path: "a", action: "upload" },
{ path: "b", action: "deleteRemote" },
{ path: "c", action: "download" },
]);
});
});
describe("servers and reports", () => {
const a = { ...DEFAULT_DEPLOYMENT_SERVER, id: "a", name: "Staging" };
const b = { ...DEFAULT_DEPLOYMENT_SERVER, id: "b", name: "" };
it("resolves the default server", () => {
expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a, b], defaultServer: "b" }, null)?.id).toBe("b");
expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a, b], defaultServer: "" }, null)).toBeNull();
expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a], defaultServer: "" }, null)?.id).toBe("a");
expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a, b], defaultServer: "b" }, "a")?.id).toBe("a");
});
it("names the menu's transfers after the default server", () => {
const actions = (servers: (typeof a)[], defaultServer: string) =>
transferMenuEntries({ ...DEFAULT_DEPLOYMENT, servers, defaultServer }).map((e) => e.label ?? e.action);
const named = ["Upload to Staging", "Download from Staging", "Sync with Deployed to Staging…"];
const choosers = ["deployment.uploadTo", "deployment.downloadFrom", "deployment.syncWith"];
expect(actions([a], "")).toEqual(named);
expect(actions([a, b], "a")).toEqual([...named, ...choosers]);
expect(actions([a, b], "")).toEqual(choosers);
});
it("summarizes transfer reports", () => {
expect(summarizeReport({ files: 3, skipped: 1, errors: [] }, "upload", "Staging")).toEqual({
level: "info",
title: "Uploaded {n} files to {server}",
params: { n: 3, server: "Staging", skipped: 1 },
});
expect(
summarizeReport({ files: 2, skipped: 0, errors: [{ path: "x", message: "denied" }] }, "download", "S").level,
).toBe("warning");
expect(
summarizeReport({ files: 0, skipped: 0, errors: [{ path: "x", message: "denied" }] }, "sync", "S").level,
).toBe("error");
});
it("classifies SSH failures", () => {
expect(sshRemedy("protocol", "Unknown SSH host key for example.com", "openSsh")).toBe("trustHostKey");
expect(sshRemedy("permissionDenied", "SSH password required for [email protected]", "password")).toBe("secret");
expect(sshRemedy("permissionDenied", "SSH key passphrase required for ~/.ssh/id", "keyPair")).toBe("secret");
expect(sshRemedy("permissionDenied", "SSH host key of box:22 has changed", "password")).toBeNull();
expect(sshRemedy("permissionDenied", "SSH authentication failed", "openSsh")).toBeNull();
expect(sshRemedy("permissionDenied", "SSH authentication failed", "password")).toBe("secret");
expect(sshRemedy("io", "Connection refused", "password")).toBeNull();
});
});
describe("remote paths", () => {
it("joins and splits remote paths", () => {
expect(joinRemote("/var/www", "app/")).toBe("/var/www/app");
expect(joinRemote("/", "etc")).toBe("/etc");
expect(joinRemote("/var/www", "")).toBe("/var/www");
expect(remoteParent("/var/www/app")).toBe("/var/www");
expect(remoteParent("/var")).toBe("/");
expect(remoteParent("/")).toBe("/");
expect(remoteName("/var/www/app/")).toBe("app");
});
it("maps remote paths back to project paths through the most specific mapping", () => {
const mappings = [
{ local: ROOT, deployment: "/var/www" },
{ local: `${ROOT}/public`, deployment: "/var/www/htdocs" },
{ local: `${ROOT}/shared`, deployment: "/var/www/opt/shared" },
];
const map = (p: string) => localPathForRemote(p, mappings);
expect(map("/var/www/index.php")).toBe(`${ROOT}/index.php`);
expect(map("/var/www/htdocs/css/a.css")).toBe(`${ROOT}/public/css/a.css`);
expect(map("/var/www/htdocs")).toBe(`${ROOT}/public`);
expect(map("/var/www/opt/shared/lib")).toBe(`${ROOT}/shared/lib`);
expect(map("/opt/shared/lib")).toBeNull();
expect(map("/var/wwwx/a")).toBeNull();
expect(map("/etc/passwd")).toBeNull();
});
it("uses the backend folder for aliases and does not invent mappings", () => {
const mappings = [{ local: `${ROOT}/src`, deployment: "/mnt/deploy" }];
expect(localPathForRemote("/mnt/deploy/a.txt", mappings)).toBe(`${ROOT}/src/a.txt`);
expect(localPathForRemote("/mnt/deploy/a.txt", [])).toBeNull();
expect(localPathForRemote("/mnt/deploy/../a.txt", mappings)).toBeNull();
expect(localPathForRemote("/a.txt", [{ local: ROOT, deployment: "/" }])).toBe(`${ROOT}/a.txt`);
});
});