"use strict"; // End-to-end UI check of chj.file-manager: real plugin runtime, preload, IPC, SSH session, Monaco, // sudo dialog, transfer plan dialog and Transfer Manager panel against a disposable Docker container // built from test/fixtures/filesIntegration. Never point it at a real server. // Run: CHJ_FILES_IMAGE=chj-files-it-debian npx electron scripts/smokeFileManagerUi.js [screenshot-dir] const { app, BrowserWindow, ipcMain, protocol } = require("electron"); const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync, spawnSync } = require("node:child_process"); const { hardenSession } = require("../src/main/bootstrap/createMainWindow"); const { PluginRegistry } = require("../src/main/plugins/pluginRegistry"); const { PluginRuntime } = require("../src/main/plugins/pluginRuntime"); const { SessionManager } = require("../src/main/sessions/sessionManager"); const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec"); const { RemoteFileService } = require("../src/main/files/remoteFileService"); const { TransferManager } = require("../src/main/files/transferManager"); const image = process.env.CHJ_FILES_IMAGE || "chj-files-it-debian"; const container = `chj-files-ui-${process.pid}`; const root = fs.mkdtempSync(path.join(os.tmpdir(), "chj-files-ui-")); const shots = process.argv.find((arg, index) => index > 1 && !arg.startsWith("-") && !arg.endsWith(".js")) || null; app.setPath("userData", path.join(root, "userData")); // Closing and reopening the plugin window is part of the check: closing it must not quit the app. app.on("window-all-closed", () => {}); protocol.registerSchemesAsPrivileged([{ scheme: "chj-plugin", privileges: { standard: true, secure: true, supportFetchAPI: true } }]); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const errors = []; let window; const js = (source) => window.webContents.executeJavaScript(source); async function until(source, timeout = 30000) { const started = Date.now(); while (Date.now() - started < timeout) { if (await js(`(() => { try { return Boolean(${source}); } catch { return false; } })()`)) return; await delay(60); } throw new Error(`UI condition timed out: ${source}\nstatus: ${await js("document.querySelector('#status').textContent")}\ntransfers: ${await js("document.querySelector('#transferList').innerText")}`); } async function shot(name) { if (!shots) return; fs.mkdirSync(shots, { recursive: true }); fs.writeFileSync(path.join(shots, `${name}.png`), (await window.webContents.capturePage()).toPNG()); } function sh(command) { const result = spawnSync("docker", ["exec", container, "sh", "-c", command], { encoding: "utf8" }); if (result.status !== 0) throw new Error(`container command failed: ${command}\n${result.stderr}`); return result.stdout.trim(); } const facts = (file) => sh(`stat -c '%u:%g:%a' ${file}; getfacl -cp ${file} | sort | tr '\\n' ' '; getfattr -d --absolute-names -m '^user\\.' ${file} | grep -v '^#' | tr '\\n' ' '`); async function sudo(password = "AdminPass-1") { await until("document.querySelector('#sudoDialog').open"); await js(`document.querySelector('#sudoPassword').value = ${JSON.stringify(password)}; document.querySelector('#sudoConfirm').click();`); } async function openPath(target) { await js(`document.querySelector('#pathInput').value = ${JSON.stringify(target)}; document.querySelector('#pathForm').requestSubmit();`); await until(`document.querySelector('#pathInput').value === ${JSON.stringify(target)} && document.querySelector('#busyOverlay').hidden`); } async function openFile(directory, name) { await openPath(directory); await until(`document.querySelector('tr[data-path=${JSON.stringify(path.posix.join(directory, name))}] [data-row-action="open"]')`); await js(`document.querySelector('tr[data-path=${JSON.stringify(path.posix.join(directory, name))}] [data-row-action="open"]').click()`); } // Every visible control must be inside the window, below the Core window bar and not covered by anything. async function assertLayout(scope, label) { for (const [width, height] of [[1360, 900], [1024, 760], [760, 700], [600, 640]]) { window.setContentSize(width, height); await delay(250); const problems = await js(`(() => { const bar = document.querySelector('chj-plugin-chrome').getBoundingClientRect().bottom; const out = []; for (const el of document.querySelectorAll(${JSON.stringify(scope)})) { const r = el.getBoundingClientRect(); if (!r.width || !r.height || getComputedStyle(el).visibility === 'hidden') continue; const name = (el.id || el.textContent.trim().slice(0, 20)); if (r.top < bar - 0.5 || r.left < -0.5 || r.right > innerWidth + 0.5 || r.bottom > innerHeight + 0.5) { out.push(name + ' outside ' + [Math.round(r.top), Math.round(r.left), Math.round(r.right), Math.round(r.bottom), bar, innerWidth, innerHeight].join(',')); continue; } const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); if (!hit || !(hit === el || el.contains(hit) || hit.contains(el) && hit.tagName === 'LABEL')) out.push(name + ' covered by ' + (hit?.id || hit?.className || hit?.tagName)); } return out; })()`); assert.deepEqual(problems, [], `${label} at ${width}x${height}`); await shot(`layout-${label}-${width}`); } window.setContentSize(1360, 900); await delay(200); } async function setEditorText(text) { await js(`monaco.editor.getModels()[0].setValue(${JSON.stringify(text)})`); } async function run() { execFileSync("docker", ["run", "-d", "--rm", "--name", container, "-p", "127.0.0.1::22", image]); const port = Number(execFileSync("docker", ["port", container, "22/tcp"], { encoding: "utf8" }).trim().split(":").pop()); await delay(1500); sh(`set -e; mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled printf 'worker_processes 1;\\n' > /etc/nginx/nginx.conf; setfacl -m u:plain:r-- /etc/nginx/nginx.conf; setfattr -n user.chj -v kept /etc/nginx/nginx.conf printf 'server { listen 80; }\\n' > /etc/nginx/sites-available/example.conf; ln -s ../sites-available/example.conf /etc/nginx/sites-enabled/example.conf printf 'secret=1\\n' > /etc/chj-secret.conf; chmod 0600 /etc/chj-secret.conf`); const before = facts("/etc/nginx/nginx.conf"); await app.whenReady(); hardenSession(); const hostKeys = new Map(); const profileService = { get: () => ({ id: "it", label: "Docker test", host: "127.0.0.1", port, username: "admin", authMethod: "password" }), getHostKey: () => hostKeys.get(port) || null, trustHostKey: (_host, _port, fingerprint) => hostKeys.set(port, fingerprint), getStoredPassword: () => "AdminPass-1", markUsed() {} }; const sessionManager = new SessionManager({ profileService, latencyMonitor: { start: () => () => {} } }); try { await sessionManager.connect({ sessionId: "ui", profileId: "it" }); } catch (error) { sessionManager.trustPending({ sessionId: "ui", profileId: "it", fingerprint: error.fingerprint }); await sessionManager.connect({ sessionId: "ui", profileId: "it" }); } const privilegedExecutor = new PrivilegedExecutor({ sessionManager }); sessionManager.privilegedExecutor = privilegedExecutor; let uploadPaths = []; const remoteFileService = new RemoteFileService({ sessionManager, privilegedExecutor, selectUploadPath: async () => ({ canceled: false, paths: uploadPaths }), selectDownloadPath: async (name) => ({ canceled: false, path: path.join(root, name) }), selectDownloadDirectory: async () => ({ canceled: false, path: root }) }); const transferManager = new TransferManager({ sessionManager, remoteFileService, storageRoot: path.join(root, "userData") }); const registry = new PluginRegistry(path.join(root, "userData"), null, { bundledRoot: path.join(__dirname, "../src/main/firstPartyPlugins") }); const runtime = new PluginRuntime({ BrowserWindow, registry, sessionManager, remoteFileService, transferManager, preload: path.join(__dirname, "../src/preload/pluginPreload.js"), getMainWindow: () => null, isVaultUnlocked: () => true }); runtime.registerIpc(ipcMain); runtime.open("chj.file-manager"); window = runtime.windows.get("chj.file-manager"); window.webContents.on("console-message", (event) => { if (event.level === "error") errors.push(event.message); }); window.setSize(1360, 900); await until("document.querySelector('tr[data-path=\"/etc\"]')"); assert.equal(await js("document.querySelector('#transfers').hidden"), false, "transfer panel present"); await shot("01-root-listing"); await assertLayout(".app-header button, .app-header select, .navigation button, .navigation input, .toolbar button, .toolbar input, .transfers-header button, .transfers-header select", "main"); // Symlinks are shown with their target type. await openPath("/etc/nginx/sites-enabled"); assert.match(await js("document.querySelector('#filesBody').textContent"), /odkaz → soubor/); // Editing /etc/nginx/nginx.conf as a sudo user: save offers sudo, a wrong password is reported, metadata kept. await openFile("/etc/nginx", "nginx.conf"); await until("!document.querySelector('#editor').hidden && window.monaco"); assert.match(await js("document.querySelector('#status').textContent"), /uložení bude vyžadovat sudo/); await assertLayout("#editor .editor-header button, #editor .editor-header input, #editor .editor-header .pill, #editorPath", "editor"); await setEditorText("worker_processes 4;\n"); await until("!document.querySelector('#editorDirty').hidden"); await js("document.querySelector('#saveEditorButton').click()"); await sudo("wrong-password"); await until("document.querySelector('#sudoDialog').open && !document.querySelector('#sudoError').hidden", 30000); await shot("02-sudo-wrong-password"); assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 1;", "nothing written after a rejected password"); await sudo(); await until("document.querySelector('#status').textContent.includes('Atomicky uloženo')", 30000); assert.equal(sh("cat /etc/nginx/nginx.conf"), "worker_processes 4;"); assert.equal(facts("/etc/nginx/nginx.conf"), before, "owner, mode, ACL and xattr unchanged"); assert.equal(await js("document.querySelector('#editorPrivilege').hidden"), false); await shot("03-saved-with-sudo"); await js("document.querySelector('#closeEditorButton').click()"); // Symlink editing (password remembered in Main memory → no second prompt). await openFile("/etc/nginx/sites-enabled", "example.conf"); await until("!document.querySelector('#editor').hidden && !document.querySelector('#editorTarget').hidden"); assert.match(await js("document.querySelector('#editorTarget').textContent"), /\/etc\/nginx\/sites-available\/example\.conf/); await setEditorText("server { listen 8080; }\n"); await js("document.querySelector('#saveEditorButton').click()"); await until("document.querySelector('#status').textContent.includes('do cíle odkazu')", 30000); assert.equal(sh("readlink /etc/nginx/sites-enabled/example.conf"), "../sites-available/example.conf"); assert.equal(sh("cat /etc/nginx/sites-available/example.conf"), "server { listen 8080; }"); await shot("04-symlink-saved"); await js("document.querySelector('#closeEditorButton').click()"); // Reading a root-only file uses the remembered sudo authorization. await openFile("/etc", "chj-secret.conf"); await until("!document.querySelector('#editor').hidden && document.querySelector('#status').textContent.includes('se sudo')", 30000); assert.equal(await js("monaco.editor.getModels()[0].getValue()"), "secret=1\n"); await js("document.querySelector('#closeEditorButton').click()"); // Upload into /etc/nginx: plan dialog with sudo section, real progress, privileged publication. const conf = path.join(root, "upload.conf"); fs.writeFileSync(conf, "# uploaded\n".repeat(20000)); uploadPaths = [conf]; await openPath("/etc/nginx"); await js("document.querySelector('#uploadButton').click()"); await until("document.querySelector('#planDialog').open"); assert.equal(await js("document.querySelector('#planSudo').hidden"), false, "privileged route explained"); assert.equal(await js("getComputedStyle(document.querySelector('#planPasswordRow')).display"), "none", "no password field while sudo is remembered"); await shot("05-plan-dialog"); await js("document.querySelector('#planStart').click()"); await until("document.querySelector('.transfer .t-state.completed')", 60000); assert.equal(sh("sha256sum /etc/nginx/upload.conf | cut -d' ' -f1"), crypto.createHash("sha256").update(fs.readFileSync(conf)).digest("hex")); assert.equal(sh("stat -c '%u:%g:%a' /etc/nginx/upload.conf"), "0:0:644"); assert.match(await js("document.querySelector('.transfer').textContent"), /Dokončení na cíli: dokončeno/); // Pause and resume a large upload from the transfer card. const big = path.join(root, "big.bin"); fs.closeSync(fs.openSync(big, "w")); fs.truncateSync(big, 1024 * 1024 * 1024); uploadPaths = [big]; await openPath("/home/admin"); await js("document.querySelector('#uploadButton').click()"); await until("document.querySelector('#planDialog').open"); await js("document.querySelector('#planStart').click()"); await until("Array.from(document.querySelectorAll('.transfer')).some((node) => node.textContent.includes('big.bin') && node.querySelector('[data-transfer-action=pause]'))", 30000); await until("Array.from(document.querySelectorAll('.transfer')).some((node) => node.textContent.includes('big.bin') && /Rychlost: [0-9]/.test(node.textContent))", 30000); await shot("06-transferring"); await js("Array.from(document.querySelectorAll('.transfer')).find((node) => node.textContent.includes('big.bin')).querySelector('[data-transfer-action=pause]').click()"); await until("Array.from(document.querySelectorAll('.transfer')).some((node) => node.textContent.includes('big.bin') && node.querySelector('.t-state.paused'))", 30000); await shot("07-paused"); await js("Array.from(document.querySelectorAll('.transfer')).find((node) => node.textContent.includes('big.bin')).querySelector('[data-transfer-action=resume]').click()"); await until("Array.from(document.querySelectorAll('.transfer')).some((node) => node.textContent.includes('big.bin') && node.querySelector('.t-state.completed'))", 120000); assert.equal(sh("stat -c %s /home/admin/big.bin"), String(1024 * 1024 * 1024)); assert.match(await js("document.querySelector('#transferSummary').textContent"), /2 souborů · 2 dokončeno/); assert.match(await js("document.querySelector('#transferOverallText').textContent"), /^100 %/); await shot("08-completed"); // The panel survives closing and reopening the plugin window (jobs live in Main). window.close(); await delay(500); runtime.open("chj.file-manager"); window = runtime.windows.get("chj.file-manager"); window.setSize(1360, 900); await until("document.querySelectorAll('.transfer').length === 2"); assert.deepEqual(errors, [], "no renderer errors"); transferManager.shutdown(); await sessionManager.disconnectAll("test"); console.log("chj.file-manager UI smoke passed"); } run().then(() => { cleanup(); app.exit(0); }, (error) => { console.error(error); cleanup(); app.exit(1); }); function cleanup() { const removed = spawnSync("docker", ["rm", "-f", container], { encoding: "utf8" }); if (removed.status !== 0) console.error(`Container ${container} could not be removed: ${removed.stderr}`); fs.rmSync(root, { recursive: true, force: true }); }