#!/usr/bin/env node import fs from "node:fs"; import vm from "node:vm"; class ClassList { constructor() { this.values = new Set(); } toggle(name, active) { active ? this.values.add(name) : this.values.delete(name); } } class Element { constructor(id = "") { this.id = id; this.value = ""; this.checked = false; this.disabled = false; this.hidden = false; this.open = false; this.dataset = {}; this.children = []; this.listeners = new Map(); this.classList = new ClassList(); this.attributes = new Map(); this.tabIndex = 0; this.textContent = ""; this.type = ""; this.readOnly = false; this.elements = []; } addEventListener(name, callback) { this.listeners.set(name, callback); } dispatch(name) { return this.listeners.get(name)?.({ target: this, preventDefault() {} }); } setAttribute(name, value) { this.attributes.set(name, String(value)); } append(...items) { this.children.push(...items); } replaceChildren(...items) { this.children = [...items]; } showModal() { this.open = true; } close() { this.open = false; } reset() {} checkValidity() { return true; } } const ids = [ "htaccess-help-close","htaccess-help-dialog","php-help-close","php-help-dialog","web-service-cache-control","web-service-cache-rules", "web-service-close","web-service-default-mime","web-service-dialog","web-service-download-extensions", "web-service-editing","web-service-form","web-service-hardening-attacks","web-service-hardening-headers", "web-service-htaccess-enabled","web-service-htaccess-filename","web-service-htaccess-help","web-service-htaccess-recursive", "web-service-htaccess-strict","web-service-id","web-service-index","web-service-list","web-service-listing", "web-service-mime-types","web-service-name","web-service-new","web-service-php-concurrency", "web-service-php-connect-timeout","web-service-php-enabled","web-service-php-endpoint","web-service-php-extensions", "web-service-php-request-timeout","web-service-php-pass-authorization","web-service-php-path-info","web-service-php-body-limit","web-service-php-header-limit", "web-service-php-stderr-limit","web-service-php-custom-params","web-service-php-values","web-service-php-admin-values","web-service-php-help", "web-service-compression","web-service-compression-options","web-service-compression-zstd","web-service-compression-br","web-service-compression-gzip","web-service-compression-deflate","web-service-precompressed","web-service-precompressed-br","web-service-precompressed-gzip", "web-service-preset","web-service-preset-apply","web-service-preset-summary","web-service-reset", "web-service-response-headers","web-service-rewrites","web-service-root","web-service-search", "web-service-symlink-policy","web-service-tabs","web-service-try-files", "web-service-php-profile", "web-service-php-profiles", "web-service-php-profiles-open", "php-fpm-profiles-dialog", "php-fpm-profiles-list", "php-fpm-profiles-close", "php-fpm-profile-add", "php-fpm-profiles-help" ]; const elements = new Map(ids.map(id => [id, new Element(id)])); const pages = ["general","routing","htaccess","mime","cache","delivery","php","security"].map(name => { const page = new Element(); page.dataset.webServicePage = name; return page; }); elements.get("web-service-php-endpoint").value = "unix:/run/php/php-fpm.sock"; elements.get("web-service-php-connect-timeout").value = "1000"; elements.get("web-service-php-request-timeout").value = "30000"; elements.get("web-service-php-concurrency").value = "64"; elements.get("web-service-php-pass-authorization").checked = true; elements.get("web-service-php-path-info").checked = false; elements.get("web-service-php-body-limit").value = "0"; elements.get("web-service-php-header-limit").value = "65536"; elements.get("web-service-php-stderr-limit").value = "4096"; elements.get("web-service-compression").value = ""; for (const name of ["zstd", "br", "gzip", "deflate"]) elements.get(`web-service-compression-${name}`).value = ""; const document = { body: new Element("body"), createElement: tag => { const node = new Element(); node.type = tag; return node; }, querySelectorAll(selector) { if (selector === "#web-service-tabs button") return elements.get("web-service-tabs").children; if (selector === "[data-web-service-page]") return pages; return []; } }; let savedSection = null; const context = { console, structuredClone, document, window: {}, envelope: null, busy: false, $: id => { const value = elements.get(id); if (!value) throw new Error(`missing fake element ${id}`); return value; }, t: key => key, el: (_tag, text = "", className = "") => { const node = new Element(); node.textContent = text ?? ""; if (className) node.classList.values.add(className); return node; }, toast: () => {}, saveSection: async (name, value) => { savedSection = { name, value: structuredClone(value) }; return true; }, appConfirm: async () => true, }; vm.createContext(context); vm.runInContext(fs.readFileSync("src/admin/web/drafts.js", "utf8"), context, { filename: "drafts.js" }); vm.runInContext(fs.readFileSync("src/admin/web/web-server-profiles.js", "utf8"), context, { filename: "web-server-profiles.js" }); vm.runInContext(fs.readFileSync("src/admin/web/web-server.js", "utf8"), context, { filename: "web-server.js" }); // Explicitly disabling scoped dynamic compression must hide and disable codec // inheritance controls without erasing their saved selections. const compressionMode = elements.get("web-service-compression"); const compressionOptions = elements.get("web-service-compression-options"); const compressionZstd = elements.get("web-service-compression-zstd"); compressionZstd.value = "off"; compressionMode.value = "off"; compressionMode.dispatch("change"); if (!compressionOptions.hidden) throw new Error("Disabled Web Service compression did not hide codec overrides"); for (const name of ["zstd", "br", "gzip", "deflate"]) { if (!elements.get(`web-service-compression-${name}`).disabled) throw new Error(`Disabled Web Service compression left ${name} override active`); } compressionMode.value = "on"; compressionMode.dispatch("change"); if (compressionOptions.hidden) throw new Error("Re-enabled Web Service compression did not restore codec overrides"); if (compressionZstd.value !== "off") throw new Error("Compression visibility toggle erased the saved codec selection"); const preset = elements.get("web-service-preset"); preset.value = "wordpress"; preset.dispatch("change"); const expectedIndex = "index.php\nindex.html\nindex.htm"; const expectedTry = "$uri\n$uri/\n/index.php?$query_string"; if (elements.get("web-service-index").value !== expectedIndex) throw new Error("WordPress preset did not populate index files"); if (elements.get("web-service-try-files").value !== expectedTry) throw new Error("WordPress preset did not populate try_files"); if (!elements.get("web-service-php-enabled").checked) throw new Error("WordPress preset did not enable PHP-FPM"); if (!elements.get("web-service-precompressed").checked) throw new Error("WordPress preset did not enable precompressed sidecars"); if (!elements.get("web-service-cache-rules").value.includes("ext:css = public, max-age=604800")) throw new Error("WordPress preset did not populate cache rules"); preset.value = "downloads"; preset.dispatch("change"); if (elements.get("web-service-index").value !== "") throw new Error("Downloads preset should clear index files"); if (!elements.get("web-service-listing").checked) throw new Error("Downloads preset should enable directory listing"); if (elements.get("web-service-php-enabled").checked) throw new Error("Downloads preset should disable PHP-FPM"); if (elements.get("web-service-cache-control").value !== "public, max-age=3600") throw new Error("Downloads preset cache policy missing"); // The save path must serialize the values materialized by preset selection, not // the previous/custom values that were present when the dialog opened. context.envelope = { config: { web_services: [] } }; elements.get("web-service-id").value = "web1"; elements.get("web-service-name").value = "web1"; elements.get("web-service-root").value = "/srv/web1"; elements.get("web-service-php-pass-authorization").checked = false; elements.get("web-service-php-path-info").checked = true; elements.get("web-service-php-body-limit").value = "1048576"; elements.get("web-service-php-header-limit").value = "131072"; elements.get("web-service-php-stderr-limit").value = "8192"; elements.get("web-service-php-custom-params").value = "APP_ENV = production"; elements.get("web-service-php-values").value = "memory_limit = 256M"; preset.value = "wordpress"; preset.dispatch("change"); elements.get("web-service-compression").value = "on"; elements.get("web-service-compression-gzip").value = "off"; await elements.get("web-service-form").dispatch("submit"); if (savedSection?.name !== "web-services" || savedSection.value.length !== 1) throw new Error("WordPress preset save did not persist a Web Service"); const saved = savedSection.value[0]; if (saved.index.join("\n") !== expectedIndex) throw new Error("Saved WordPress preset lost index files"); if (saved.try_files.join("\n") !== expectedTry) throw new Error("Saved WordPress preset lost try_files"); if (!saved.php.enabled) throw new Error("Saved WordPress preset lost PHP-FPM enablement"); if (!saved.precompressed.enabled) throw new Error("Saved WordPress preset lost precompressed enablement"); if (saved.compression?.enabled !== true || saved.compression?.gzip !== false) throw new Error("Saved WordPress preset lost compression/codec override"); if (saved.php.pass_authorization !== false) throw new Error("Saved WordPress preset lost Authorization forwarding choice"); if (saved.php.path_info !== true) throw new Error("Saved WordPress preset lost PATH_INFO choice"); if (saved.php.max_request_body_bytes !== 1048576 || saved.php.max_response_header_bytes !== 131072 || saved.php.stderr_log_bytes !== 8192) throw new Error("Saved WordPress preset lost advanced FastCGI limits"); if (saved.php.custom_params.APP_ENV !== "production") throw new Error("Saved WordPress preset lost custom FastCGI parameters"); if (saved.php.php_values.memory_limit !== "256M") throw new Error("Saved WordPress preset lost PHP_VALUE settings"); if (!saved.cache_rules.some(rule => rule.matcher === "ext:css" && rule.cache_control.includes("604800"))) throw new Error("Saved WordPress preset lost cache rules"); console.log("PASS: Web Service preset selection materializes and persists CMS/download settings."); // The shared editor lists profiles, saves them under the new revisioned // section, and Web Services can attach without losing local PHP settings. context.envelope.config.php_fpm_profiles = [{ id: "wp-pool", name: "WordPress pool", php: { enabled: true, path_info: true, pass_authorization: true, extensions: ["php"], endpoint: "tcp:127.0.0.1:9000", connect_timeout_ms: 1000, request_timeout_ms: 30000, max_concurrent_requests: 64, max_request_body_bytes: 0, max_response_header_bytes: 65536, stderr_log_bytes: 4096, custom_params: {}, php_values: {}, php_admin_values: {} } }]; // Both entry points must share the existing modal; the toolbar shortcut // belongs next to New Web Service rather than inside the service editor. const html = fs.readFileSync("src/admin/web/index.html", "utf8"); const profileDialog = html.split('id="php-fpm-profiles-dialog"')[1]?.split('')[0] ?? ''; const listAt = profileDialog.indexOf('id="php-fpm-profiles-list"'); const addAt = profileDialog.indexOf('id="php-fpm-profile-add"'); const dividerAt = profileDialog.indexOf('class="php-profile-help-divider"'); const helpAt = profileDialog.indexOf('id="php-fpm-profiles-help"'); if (!(listAt >= 0 && listAt < addAt && addAt < dividerAt && dividerAt < helpAt)) { throw new Error("PHP-FPM syntax/safety help must be at the bottom of the profile modal, separated by a rule"); } const webPanel = html.split('id="panel-web-server"')[1]?.split("")[0] ?? ""; const toolbar = webPanel.match(/
([\s\S]*?)<\/div>/)?.[1] ?? ""; const shortcutAt = toolbar.indexOf('id="web-service-php-profiles-open"'); const newAt = toolbar.indexOf('id="web-service-new"'); if (shortcutAt < 0 || newAt < 0 || shortcutAt > newAt) { throw new Error("PHP-FPM profiles shortcut must be adjacent to New Web Service in the toolbar"); } elements.get("web-service-php-profiles-open").dispatch("click"); if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("Toolbar PHP-FPM profile shortcut did not open the modal"); elements.get("php-fpm-profiles-close").dispatch("click"); if (elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal failed to close"); elements.get("web-service-php-profiles").dispatch("click"); if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal was not opened"); const profileDetail = elements.get("php-fpm-profiles-list").children[0]; if (!profileDetail || profileDetail.children[0].textContent !== "WordPress pool") throw new Error("Shared profile list missing"); const profileForm = profileDetail.children[1]; const phpRows = profileForm.children.filter(node => node.classList.values.has("php-switch-row vh-wide")); if (phpRows.length !== 3) throw new Error("PHP-FPM / PATH_INFO / Authorization switches do not have separate aligned rows"); for (const row of phpRows) { if (row.children.length !== 2 || row.children[0].children[0].type !== "checkbox" || row.children[1].children[0].textContent !== "i" || row.children[1].children[1].children.length < 1) { throw new Error("Profile PHP toggle lacks its checkbox label or explanation popover"); } } if (!phpRows[0].children[0].children[0].checked || !phpRows[1].children[0].children[0].checked) { throw new Error("PHP execution and PATH_INFO saved values were not restored"); } phpRows[1].children[0].children[0].checked = false; if (!phpRows[0].children[0].children[0].checked) { throw new Error("Changing PATH_INFO also disabled PHP execution"); } phpRows[1].children[0].children[0].checked = true; elements.get("php-fpm-profiles-help").dispatch("click"); if (!elements.get("php-help-dialog").open) throw new Error("PHP profile help button did not open syntax documentation"); elements.get("php-help-close").dispatch("click"); if (elements.get("php-help-dialog").open) throw new Error("PHP syntax help dialog did not close"); profileForm.children[1].children[1].value = "Updated WordPress pool"; await profileForm.dispatch("submit"); if (savedSection?.name !== "php-fpm-profiles" || savedSection.value[0].name !== "Updated WordPress pool") { throw new Error("Shared profile edit did not save to the profile configuration section"); } const profileSelector = elements.get("web-service-php-profile"); const localEndpoint = elements.get("web-service-php-endpoint").value; profileSelector.value = "wp-pool"; profileSelector.dispatch("change"); if (!elements.get("web-service-php-endpoint").disabled) throw new Error("Shared profile left local PHP controls editable"); elements.get("web-service-id").value = "wp-shared"; elements.get("web-service-name").value = "WordPress shared"; await elements.get("web-service-form").dispatch("submit"); if (savedSection?.name !== "web-services" || savedSection.value[0].php_profile_id !== "wp-pool") { throw new Error("Web Service did not persist selected shared PHP-FPM profile"); } if (savedSection.value[0].php.endpoint !== localEndpoint) { throw new Error("Attaching a shared FPM profile changed local settings"); } console.log("PASS: Toolbar and editor PHP-FPM shortcuts open the shared modal; profile edits and Web Service attachment work.");