/** Deployment diff over an in-memory server: no SSH or filesystem writes. */
import { test, expect } from "./fixtures";
import type { Page } from "@playwright/test";
test("native deployment entry fills its own window and exposes minimize and maximize controls", async ({
page,
}, info) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/deployment-window-test", (route) =>
route.fulfill({
contentType: "text/html",
body: '
',
}),
);
await page.goto("/deployment-window-test");
await page.evaluate(async () => {
const refreshModule = "/@react-refresh";
const { default: refresh } = await import(/* @vite-ignore */ refreshModule);
refresh.injectIntoGlobalHook(window);
Object.assign(window, {
$RefreshReg$: () => {},
$RefreshSig$: () => (type: unknown) => type,
__vite_plugin_react_preamble_installed__: true,
});
const mocksModule = "/node_modules/@tauri-apps/api/mocks.js";
const { mockIPC, mockWindows } = await import(/* @vite-ignore */ mocksModule);
const calls: string[] = [];
(window as unknown as { deploymentCalls: string[] }).deploymentCalls = calls;
const settings = {
appearance: { theme: "dark", syncWithOs: false },
deployment: { servers: [{ id: "staging", name: "Staging", kind: "local", rootPath: "/srv/app" }] },
};
let launch = true;
const local = ") => {
calls.push(command);
if (/deploy_(upload|download|sync|delete)|doc_save|fs_write/.test(command)) throw new Error("Unexpected write");
if (command === "app_info")
return { platform: "windows", version: "test", configDir: "/config", wayland: false };
if (command === "settings_get") return settings;
if (command === "project_current") return { root: "/second", name: "Second", settings };
if (command === "window_take_launch") {
if (!launch) return null;
launch = false;
return { deployment: { server: "staging", paths: ["/second/perfil.php"] } };
}
if (command === "deploy_coverage") return [{ server: "staging", paths: ["/second/perfil.php"], skipped: [] }];
if (command === "deploy_compare")
return [
{
path: "perfil.php",
remotePath: "/srv/app/perfil.php",
local: { size: local.length, mtime: 1700000100 },
remote: { size: remote.length, mtime: 1700000000 },
status: "localNewer",
},
];
if (command === "remote_open") return "/snapshot/perfil.php";
if (command === "fs_probe") return { size: local.length, binary: false, tooLarge: false };
if (command === "fs_read_text") return String(args.path).startsWith("/snapshot/") ? remote : local;
if (command === "doc_open") throw new Error("Read-only test fixture");
if (command === "languages") return [];
if (command === "plugin:window|is_maximized" || command === "plugin:window|is_fullscreen") return false;
return null;
},
{ shouldMockEvents: true },
);
const entry = "/src/main.tsx";
await import(/* @vite-ignore */ entry);
});
const comparison = page.getByRole("region", { name: "Diff Between 'local files' and 'files on Staging'" });
await expect(comparison.getByRole("grid", { name: "Files to synchronize" })).toBeVisible();
await expect(comparison.getByRole("region", { name: "perfil.php" }).getByRole("textbox")).toHaveCount(2);
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByRole("tree", { name: "Project" })).toHaveCount(0);
const frame = await comparison.boundingBox();
expect(frame!.y).toBe(0);
expect(frame!.height).toBe(page.viewportSize()!.height);
await expect(comparison.getByRole("button", { name: "Synchronize All", exact: true })).toBeInViewport();
await expect(comparison.getByRole("button", { name: "Synchronize Selected", exact: true })).toBeVisible();
await comparison.getByRole("button", { name: "Minimize", exact: true }).click();
await comparison.getByRole("button", { name: "Maximize", exact: true }).click();
// The caption buttons reach the window API asynchronously.
const calls = () => page.evaluate(() => (window as unknown as { deploymentCalls: string[] }).deploymentCalls);
await expect.poll(calls).toContain("plugin:window|minimize");
await expect.poll(calls).toContain("plugin:window|toggle_maximize");
expect(errors).toEqual([]);
await page.screenshot({ path: info.outputPath("deployment-native-window.png") });
});
async function showComparison(page: Page) {
await page.evaluate(async () => {
const projectModule = "/src/platform/project.ts";
const settingsModule = "/src/platform/remoteSettings.ts";
const stateModule = "/src/features/deployment/store.ts";
const routeModule = "/src/platform/tauriCore.ts";
const backendModule = "/src/editor/core/backend.ts";
const [{ useProject }, { DEFAULT_DEPLOYMENT_SERVER }, { useDeployment }, { setInvokeRoute }, { docBackend }] =
await Promise.all([
import(/* @vite-ignore */ projectModule),
import(/* @vite-ignore */ settingsModule),
import(/* @vite-ignore */ stateModule),
import(/* @vite-ignore */ routeModule),
import(/* @vite-ignore */ backendModule),
]);
const local: Record = {
"app.js": "export const version = 'local';\n",
"perfil.php":
"find('list', [\n 'fields' => [$this->alias . '.id', $this->alias . '.descripcion'],\n 'order' => $this->alias . '.descripcion ASC',\n ]);\n }\n}\n",
"hidden.php": "alpha",
"same.js": "same",
};
const remote: Record = {
"app.js": "export const version = 'deployed';\n",
"perfil.php":
" (path.startsWith("/snapshot/") ? remote : local)[path.split("/").at(-1)!] ?? "";
setInvokeRoute((command: string, args: Record) => {
const path = String(args?.path ?? "");
if (command === "remote_open") return Promise.resolve(`/snapshot/${path.split("/").at(-1)}`);
if (command === "fs_probe") return Promise.resolve({ size: text(path).length, binary: false, tooLarge: false });
if (command === "fs_read_text") return Promise.resolve(text(path));
if (command === "fs_read_range") {
const content = text(path);
return Promise.resolve({
total: content.length,
offset: args.offset,
data: btoa(content.slice(Number(args.offset), Number(args.offset) + Number(args.len))),
});
}
if (command === "doc_open") return Promise.reject(new Error("No live document in this fixture"));
if (command === "deploy_sync" && syncState.enabled) {
const steps = args.steps as { path: string; action: string }[];
syncState.transfers.push(steps.map((step) => ({ ...step })));
writes.push(command);
for (const step of steps) {
if (step.action === "upload") remote[step.path] = local[step.path]!;
else if (step.action === "download") local[step.path] = remote[step.path]!;
else if (step.action === "deleteLocal") delete local[step.path];
else if (step.action === "deleteRemote") delete remote[step.path];
}
return Promise.resolve({ files: steps.length, skipped: 0, errors: [] });
}
if (command === "deploy_compare") {
const name = path.slice(root.length + 1);
if (local[name] === undefined && remote[name] === undefined) return Promise.resolve([]);
const identical = local[name] === remote[name];
return Promise.resolve([
{
path: name,
remotePath: `/srv/app/${name}`,
local: local[name] === undefined ? null : { size: local[name]!.length, mtime: 1_700_000_100 },
remote:
remote[name] === undefined
? null
: { size: remote[name]!.length, mtime: identical ? 1_700_000_100 : 1_700_000_000 },
status:
local[name] === undefined
? "remoteOnly"
: remote[name] === undefined
? "localOnly"
: identical
? "same"
: "localNewer",
},
]);
}
if (/deploy_(upload|download|sync|delete)|doc_save|fs_write/.test(command)) {
writes.push(command);
return Promise.reject(new Error("Unexpected write"));
}
return null;
});
const entries = Object.keys(local).map((path) => ({
path,
remotePath: `/srv/app/${path}`,
local: { size: local[path]!.length, mtime: 1_700_000_100 },
remote: { size: remote[path]!.length, mtime: 1_700_000_000 },
status: path === "hidden.php" || path === "same.js" ? "same" : "localNewer",
}));
useProject.setState({
settings: {
...useProject.getState().settings,
appearance: { ...useProject.getState().settings?.appearance, theme: "dark", syncWithOs: false },
deployment: {
servers: [
{ ...DEFAULT_DEPLOYMENT_SERVER, id: "staging", kind: "local", name: "Staging", rootPath: "/srv/app" },
],
},
},
});
useDeployment.setState({
sync: {
server: "staging",
serverName: "Staging",
paths: [useProject.getState().project.root],
entries,
skipped: [],
request: null,
error: null,
choices: Object.fromEntries(entries.map((entry) => [entry.path, entry.status === "same" ? "skip" : "upload"])),
hideSame: true,
running: false,
},
});
});
}
test("deployment shows mirrored lists and a selectable side-by-side content diff", async ({
workbench: page,
}, info) => {
await showComparison(page);
const dialog = page.getByRole("dialog", { name: "Diff Between 'local files' and 'files on Staging'" });
await expect(dialog).toBeVisible();
const grid = dialog.getByRole("grid", { name: "Files to synchronize" });
await grid.getByRole("combobox", { name: "Action perfil.php" }).locator("..", {}).locator("..").click();
const preview = dialog.locator('[aria-label="Deployment content comparison"]');
await expect(preview.locator(".cm-editor")).toHaveCount(2);
await expect(preview.locator(".cm-editor").first()).toContainText("getPerfilesValidos");
await expect(preview.locator(".cm-editor").last()).not.toContainText("getPerfilesValidos");
await expect(preview.locator(".cm-editor").last().locator(".cm-content")).toHaveAttribute("contenteditable", "false");
await expect(dialog.getByRole("toolbar", { name: "Diff" })).toBeVisible();
await expect(dialog.getByRole("separator", { name: "Resize file list and diff preview" })).toBeVisible();
await dialog.screenshot({ path: info.outputPath("deployment-comparison.png"), animations: "disabled" });
await page.getByLabel("Action perfil.php", { exact: true }).selectOption("skip");
await grid.focus();
await page.keyboard.press("Enter");
await expect(dialog).toBeVisible();
expect(await page.evaluate(() => (window as unknown as { deploymentWrites: string[] }).deploymentWrites)).toEqual([]);
});
test("Synchronize Selected and Enter transfer one file at a time while the comparison stays open", async ({
workbench: page,
}) => {
await showComparison(page);
await page.evaluate(() => {
(window as unknown as { deploymentSyncState: { enabled: boolean } }).deploymentSyncState.enabled = true;
});
const dialog = page.getByRole("dialog", { name: "Diff Between 'local files' and 'files on Staging'" });
const grid = dialog.getByRole("grid", { name: "Files to synchronize" });
await grid.getByRole("combobox", { name: "Action perfil.php" }).locator("..").locator("..").click();
await dialog.getByRole("button", { name: "Synchronize Selected", exact: true }).click();
await expect(dialog).toBeVisible();
await expect(grid.getByRole("combobox", { name: "Action perfil.php" })).toHaveCount(0);
await expect(grid.getByRole("combobox", { name: "Action app.js" })).toHaveValue("upload");
await expect(dialog.locator('[aria-label="Deployment content comparison"] .cm-editor').last()).toContainText(
"deployed",
);
await grid.focus();
await page.keyboard.press("Enter");
await expect(dialog.getByRole("button", { name: "Synchronize All", exact: true })).toBeDisabled();
await expect(dialog.getByRole("button", { name: "Synchronize Selected", exact: true })).toBeDisabled();
await expect(dialog).toBeVisible();
expect(
await page.evaluate(
() => (window as unknown as { deploymentSyncState: { transfers: unknown[] } }).deploymentSyncState.transfers,
),
).toEqual([[{ path: "perfil.php", action: "upload" }], [{ path: "app.js", action: "upload" }]]);
});
test("binary content finds changes hidden by metadata and file filters keep the preview in sync", async ({
workbench: page,
}) => {
await showComparison(page);
await page.getByLabel("Compare by", { exact: true }).selectOption("content");
await expect(page.getByLabel("Action hidden.php", { exact: true })).toHaveValue("upload");
await expect(page.getByLabel("Action same.js", { exact: true })).toHaveCount(0);
await page.getByLabel("Filter by name", { exact: true }).fill("hidden.php");
const preview = page.locator('[aria-label="Deployment content comparison"]');
await expect(preview.locator(".cm-editor").first()).toContainText("alpha");
await expect(preview.locator(".cm-editor").last()).toContainText("bravo");
await page.getByLabel("Action hidden.php", { exact: true }).selectOption("skip");
expect(await page.evaluate(() => (window as unknown as { deploymentWrites: string[] }).deploymentWrites)).toEqual([]);
});