diff --git a/Dockerfile b/Dockerfile
index bb0c04d1b..942cacb57 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -33,7 +33,7 @@ RUN pip install uv && uv --version && \
# install sageattention
ADD pkg/sageattention-2.2.0-cp312-cp312-linux_x86_64.whl /workspace/pkg/sageattention-2.2.0-cp312-cp312-linux_x86_64.whl
-RUN uv pip install -U --no-deps --no-build-isolation spandrel timm tensorboard poetry flash-attn "xformers==0.0.31.post1" "file:./pkg/sageattention-2.2.0-cp312-cp312-linux_x86_64.whl"
+RUN uv pip install -U --no-deps --no-build-isolation spandrel timm tensorboard poetry "flash-attn<=2.8.0" "xformers==0.0.31.post1" "file:./pkg/sageattention-2.2.0-cp312-cp312-linux_x86_64.whl"
# this exotic command will determine the correct torchaudio to install for the image
RUN <<-EOF
python -c 'import torch, re, subprocess
@@ -66,7 +66,7 @@ WORKDIR /workspace
# addresses https://github.com/pytorch/pytorch/issues/104801
# and issues reported by importing nodes_canny
# smoke test
-RUN python -c "import torch; import xformers; import sageattention; import cv2" && comfyui --quick-test-for-ci --cpu --cwd /workspace
+RUN python -c "import torch; import xformers; import sageattention; import cv2; import diffusers.hooks" && comfyui --quick-test-for-ci --cpu --cwd /workspace
EXPOSE 8188
CMD ["python", "-m", "comfy.cmd.main", "--listen", "--use-sage-attention", "--reserve-vram=0", "--logging-level=INFO", "--enable-cors"]
diff --git a/comfy/cli_args.py b/comfy/cli_args.py
index fe964ba45..63157dd24 100644
--- a/comfy/cli_args.py
+++ b/comfy/cli_args.py
@@ -156,6 +156,7 @@ def _create_parser() -> EnhancedConfigArgParser:
parser.add_argument("--whitelist-custom-nodes", type=str, action=FlattenAndAppendAction, nargs='+', default=[], help="Specify custom node folders to load even when --disable-all-custom-nodes is enabled.")
parser.add_argument("--blacklist-custom-nodes", type=str, action=FlattenAndAppendAction, nargs='+', default=[], help="Specify custom node folders to never load. Accepts shell-style globs.")
parser.add_argument("--disable-api-nodes", action="store_true", help="Disable loading all api nodes.")
+ parser.add_argument("--enable-eval", action="store_true", help="Enable nodes that can evaluate Python code in workflows.")
parser.add_argument("--multi-user", action="store_true", help="Enables per-user storage.")
parser.add_argument("--create-directories", action="store_true",
diff --git a/comfy/cli_args_types.py b/comfy/cli_args_types.py
index 953747f95..903f46f1a 100644
--- a/comfy/cli_args_types.py
+++ b/comfy/cli_args_types.py
@@ -169,6 +169,7 @@ class Configuration(dict):
whitelist_custom_nodes (list[str]): Specify custom node folders to load even when --disable-all-custom-nodes is enabled.
default_device (Optional[int]): Set the id of the default device, all other devices will stay visible.
block_runtime_package_installation (Optional[bool]): When set, custom nodes like ComfyUI Manager, Easy Use, Nunchaku and others will not be able to use pip or uv to install packages at runtime (experimental).
+ enable_eval (Optional[bool]): Enable nodes that can evaluate Python code in workflows.
"""
def __init__(self, **kwargs):
@@ -288,6 +289,7 @@ class Configuration(dict):
self.database_url: str = db_config()
self.default_device: Optional[int] = None
self.block_runtime_package_installation = None
+ self.enable_eval: Optional[bool] = False
for key, value in kwargs.items():
self[key] = value
@@ -420,6 +422,7 @@ class FlattenAndAppendAction(argparse.Action):
Custom action to handle comma-separated values and multiple invocations
of the same argument, flattening them into a single list.
"""
+
def __call__(self, parser, namespace, values, option_string=None):
items = getattr(namespace, self.dest, None)
if items is None:
diff --git a/comfy_extras/eval_web/__init__.py b/comfy_extras/eval_web/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/comfy_extras/eval_web/ace_utils.js b/comfy_extras/eval_web/ace_utils.js
new file mode 100644
index 000000000..78c00d809
--- /dev/null
+++ b/comfy_extras/eval_web/ace_utils.js
@@ -0,0 +1,769 @@
+/**
+ * Uses code adapted from https://github.com/yorkane/ComfyUI-KYNode
+ *
+ * MIT License
+ *
+ * Copyright (c) 2024 Kevin Yuan
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+// Make modal window
+function makeModal({ title = "Message", text = "No text", type = "info", parent = null, stylePos = "fixed", classes = [] } = {}) {
+ const overlay = document.createElement("div");
+ Object.assign(overlay.style, {
+ display: "none",
+ position: stylePos,
+ background: "rgba(0 0 0 / 0.8)",
+ opacity: 0,
+ top: "0",
+ left: "0",
+ right: "0",
+ bottom: "0",
+ zIndex: "500",
+ transition: "all .8s",
+ cursor: "pointer",
+ });
+
+ const boxModal = document.createElement("div");
+ Object.assign(boxModal.style, {
+ transition: "all 0.5s",
+ opacity: 0,
+ display: "none",
+ position: stylePos,
+ top: "50%",
+ left: "50%",
+ transform: "translate(-50%,-50%)",
+ background: "#525252",
+ minWidth: "300px",
+ fontFamily: "sans-serif",
+ zIndex: "501",
+ border: "1px solid rgb(255 255 255 / 45%)",
+ });
+
+ boxModal.className = "alekpet_modal_window";
+ boxModal.classList.add(...classes);
+
+ const boxModalBody = document.createElement("div");
+ Object.assign(boxModalBody.style, {
+ display: "flex",
+ flexDirection: "column",
+ textAlign: "center",
+ });
+
+ boxModalBody.className = "alekpet_modal_body";
+
+ const boxModalHtml = `
+
+ ${text}
`;
+ boxModalBody.innerHTML = boxModalHtml;
+
+ const alekpet_modal_header = boxModalBody.querySelector(".alekpet_modal_header");
+ Object.assign(alekpet_modal_header.style, {
+ display: "flex",
+ alignItems: "center",
+ });
+
+ const close = boxModalBody.querySelector(".alekpet_modal_close");
+ Object.assign(close.style, {
+ cursor: "pointer",
+ });
+
+ let parentElement = document.body;
+ if (parent && parent.nodeType === 1) {
+ parentElement = parent;
+ }
+
+ boxModal.append(boxModalBody);
+ parentElement.append(overlay, boxModal);
+
+ const removeEvent = new Event("removeElements");
+ const remove = () => {
+ animateTransitionProps(boxModal, { opacity: 0 }).then(() =>
+ animateTransitionProps(overlay, { opacity: 0 }).then(() => {
+ parentElement.removeChild(boxModal);
+ parentElement.removeChild(overlay);
+ }),
+ );
+ };
+
+ boxModal.addEventListener("removeElements", remove);
+ overlay.addEventListener("removeElements", remove);
+
+ animateTransitionProps(overlay)
+ .then(() => {
+ overlay.addEventListener("click", () => {
+ overlay.dispatchEvent(removeEvent);
+ });
+ animateTransitionProps(boxModal);
+ })
+ .then(() => boxModal.querySelector(".alekpet_modal_close").addEventListener("click", () => boxModal.dispatchEvent(removeEvent)));
+}
+
+function findWidget(node, value, attr = "name", func = "find") {
+ return node?.widgets ? node.widgets[func]((w) => (Array.isArray(value) ? value.includes(w[attr]) : w[attr] === value)) : null;
+}
+
+function animateTransitionProps(el, props = { opacity: 1 }, preStyles = { display: "block" }) {
+ Object.assign(el.style, preStyles);
+
+ el.style.transition = !el.style.transition || !window.getComputedStyle(el).getPropertyValue("transition") ? "all .8s" : el.style.transition;
+
+ return new Promise((res) => {
+ setTimeout(() => {
+ Object.assign(el.style, props);
+
+ const transstart = () => (el.isAnimating = true);
+ const transchancel = () => (el.isAnimating = false);
+ el.addEventListener("transitionstart", transstart);
+ el.addEventListener("transitioncancel", transchancel);
+
+ el.addEventListener("transitionend", function transend() {
+ el.isAnimating = false;
+ el.removeEventListener("transitionend", transend);
+ el.removeEventListener("transitionend", transchancel);
+ el.removeEventListener("transitionend", transstart);
+ res(el);
+ });
+ }, 100);
+ });
+}
+
+function animateClick(target, params = {}) {
+ const { opacityVal = 0.9, callback = () => {} } = params;
+ if (target?.isAnimating) return;
+
+ const hide = +target.style.opacity === 0;
+ return animateTransitionProps(target, {
+ opacity: hide ? opacityVal : 0,
+ }).then((el) => {
+ const isHide = hide || el.style.display === "none";
+ showHide({ elements: [target], hide: !hide });
+ callback();
+ return isHide;
+ });
+}
+
+function showHide({ elements = [], hide = null, displayProp = "block" } = {}) {
+ Array.from(elements).forEach((el) => {
+ if (hide !== null) {
+ el.style.display = !hide ? displayProp : "none";
+ } else {
+ el.style.display = !el.style.display || el.style.display === "none" ? displayProp : "none";
+ }
+ });
+}
+
+function isEmptyObject(obj) {
+ if (!obj) return true;
+ return Object.keys(obj).length === 0 && obj.constructor === Object;
+}
+
+function makeElement(tag, attrs = {}) {
+ if (!tag) tag = "div";
+ const element = document.createElement(tag);
+ Object.keys(attrs).forEach((key) => {
+ const currValue = attrs[key];
+ if (key === "class") {
+ if (Array.isArray(currValue)) {
+ element.classList.add(...currValue);
+ } else if (currValue instanceof String || typeof currValue === "string") {
+ element.className = currValue;
+ }
+ } else if (key === "dataset") {
+ try {
+ if (Array.isArray(currValue)) {
+ currValue.forEach((datasetArr) => {
+ const [prop, propval] = Object.entries(datasetArr)[0];
+ element.dataset[prop] = propval;
+ });
+ } else {
+ Object.entries(currValue).forEach((datasetArr) => {
+ const [prop, propval] = datasetArr;
+ element.dataset[prop] = propval;
+ });
+ }
+ } catch (err) {
+ console.log(err);
+ }
+ } else if (key === "style") {
+ if (typeof currValue === "object" && !Array.isArray(currValue) && Object.keys(currValue).length) {
+ Object.assign(element[key], currValue);
+ } else if (typeof currValue === "object" && Array.isArray(currValue) && currValue.length) {
+ element[key] = [...currValue];
+ } else if (currValue instanceof String || typeof currValue === "string") {
+ element[key] = currValue;
+ }
+ } else if (["for"].includes(key)) {
+ element.setAttribute(key, currValue);
+ } else if (key === "children") {
+ element.append(...(currValue instanceof Array ? currValue : [currValue]));
+ } else if (key === "parent") {
+ currValue.append(element);
+ } else {
+ element[key] = currValue;
+ }
+ });
+ return element;
+}
+
+function isValidStyle(opt, strColor) {
+ let op = new Option().style;
+ if (!op.hasOwnProperty(opt)) return { result: false, color: "", color_hex: "" };
+
+ op[opt] = strColor;
+
+ return {
+ result: op[opt] !== "",
+ color_rgb: op[opt],
+ color_hex: rgbToHex(op[opt]),
+ };
+}
+
+function rgbToHex(rgb) {
+ const regEx = new RegExp(/\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
+ if (regEx.test(rgb)) {
+ let [, r, g, b] = regEx.exec(rgb);
+ r = parseInt(r).toString(16);
+ g = parseInt(g).toString(16);
+ b = parseInt(b).toString(16);
+
+ r = r.length === 1 ? r + "0" : r;
+ g = g.length === 1 ? g + "0" : g;
+ b = b.length === 1 ? b + "0" : b;
+
+ return `#${r}${g}${b}`;
+ }
+}
+
+async function getDataJSON(url) {
+ try {
+ const response = await fetch(url);
+ const jsonData = await response.json();
+ return jsonData;
+ } catch (err) {
+ return new Error(err);
+ }
+}
+
+function deepMerge(target, source) {
+ if (source?.nodeType) return;
+ for (let key in source) {
+ if (source[key] instanceof Object && key in target) {
+ Object.assign(source[key], deepMerge(target[key], source[key]));
+ }
+ }
+
+ Object.assign(target || {}, source);
+ return target;
+}
+
+const THEME_MODAL_WINDOW_BASE = {
+ stylesTitle: {
+ background: "auto",
+ padding: "5px",
+ borderRadius: "6px",
+ marginBottom: "5px",
+ alignSelf: "stretch",
+ },
+ stylesWrapper: {
+ display: "none",
+ opacity: 0,
+ minWidth: "220px",
+ position: "absolute",
+ left: "50%",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ transition: "all .8s",
+ fontFamily: "monospace",
+ zIndex: 99999,
+ },
+ stylesBox: {
+ display: "flex",
+ flexDirection: "column",
+ background: "#0e0e0e",
+ padding: "6px",
+ justifyContent: "center",
+ alignItems: "center",
+ gap: "3px",
+ textAlign: "center",
+ borderRadius: "6px",
+ color: "white",
+ border: "2px solid silver",
+ boxShadow: "2px 2px 4px silver",
+ maxWidth: "300px",
+ },
+ stylesClose: {
+ position: "absolute",
+ top: "-10px",
+ right: "-10px",
+ background: "silver",
+ borderRadius: "50%",
+ width: "20px",
+ height: "20px",
+ cursor: "pointer",
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ fontSize: "0.8rem",
+ },
+};
+
+const THEMES_MODAL_WINDOW = {
+ error: {
+ stylesTitle: {
+ ...THEME_MODAL_WINDOW_BASE.stylesTitle,
+ background: "#8f210f",
+ },
+ stylesBox: {
+ ...THEME_MODAL_WINDOW_BASE.stylesBox,
+ background: "#3b2222",
+ boxShadow: "3px 3px 6px #141414",
+ border: "1px solid #f91b1b",
+ },
+ stylesWrapper: { ...THEME_MODAL_WINDOW_BASE.stylesWrapper },
+ stylesClose: {
+ ...THEME_MODAL_WINDOW_BASE.stylesClose,
+ background: "#3b2222",
+ },
+ },
+ warning: {
+ stylesTitle: {
+ ...THEME_MODAL_WINDOW_BASE.stylesTitle,
+ background: "#e99818",
+ },
+ stylesBox: {
+ ...THEME_MODAL_WINDOW_BASE.stylesBox,
+ background: "#594e32",
+ boxShadow: "3px 3px 6px #141414",
+ border: "1px solid #e99818",
+ },
+ stylesWrapper: { ...THEME_MODAL_WINDOW_BASE.stylesWrapper },
+ stylesClose: {
+ ...THEME_MODAL_WINDOW_BASE.stylesClose,
+ background: "#594e32",
+ },
+ },
+ normal: {
+ stylesTitle: {
+ ...THEME_MODAL_WINDOW_BASE.stylesTitle,
+ background: "#108f0f",
+ },
+ stylesBox: {
+ ...THEME_MODAL_WINDOW_BASE.stylesBox,
+ background: "#223b2a",
+ boxShadow: "3px 3px 6px #141414",
+ border: "1px solid #108f0f",
+ },
+ stylesWrapper: { ...THEME_MODAL_WINDOW_BASE.stylesWrapper },
+ stylesClose: {
+ ...THEME_MODAL_WINDOW_BASE.stylesClose,
+ background: "#223b2a",
+ },
+ },
+};
+
+const defaultOptions = {
+ auto: {
+ autohide: false,
+ autoshow: false,
+ autoremove: false,
+ propStyles: { opacity: 0 },
+ propPreStyles: {},
+ timewait: 2000,
+ },
+ overlay: {
+ overlay_enabled: false,
+ overlayClasses: [],
+ overlayStyles: {},
+ },
+ close: { closeRemove: false, showClose: true },
+ parent: null,
+};
+
+function createWindowModal({ textTitle = "Message", textBody = "Hello world!", textFooter = null, classesWrapper = [], stylesWrapper = {}, classesBox = [], stylesBox = {}, classesTitle = [], stylesTitle = {}, classesBody = [], stylesBody = {}, classesClose = [], stylesClose = {}, classesFooter = [], stylesFooter = {}, options = defaultOptions } = {}) {
+ // Check all options exist
+ const _options = deepMerge(JSON.parse(JSON.stringify(defaultOptions)), options);
+
+ const {
+ parent,
+ overlay: { overlay_enabled, overlayClasses, overlayStyles },
+ close: { closeRemove, showClose },
+ auto: { autohide, autoshow, autoremove, timewait, propStyles, propPreStyles },
+ } = _options;
+
+ // Function past text(html)
+ function addText(text, parent) {
+ if (!parent) return;
+
+ switch (typeof text) {
+ case "string":
+ if (/^\<.*\/?\>$/.test(text)) {
+ parent.innerHTML = text;
+ } else {
+ parent.textContent = text;
+ }
+ break;
+ case "object":
+ default:
+ if (Array.isArray(text)) {
+ text.forEach((element) => (element.nodeType === 1 || element.nodeType === 3) && parent.append(element));
+ } else if (text.nodeType === 1 || text.nodeType === 3) parent.append(text);
+ }
+ }
+
+ // Overlay
+ let overlayElement = null;
+ if (overlay_enabled) {
+ overlayElement = makeElement("div", {
+ class: [...overlayClasses],
+ style: {
+ display: "none",
+ position: "fixed",
+ background: "rgba(0 0 0 / 0.8)",
+ opacity: 0,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ zIndex: 99999,
+ transition: "all .8s",
+ cursor: "pointer",
+ ...overlayStyles,
+ },
+ });
+ }
+
+ // Wrapper
+ const wrapper_settings = makeElement("div", {
+ class: ["alekpet__wrapper__window", ...classesWrapper],
+ });
+
+ Object.assign(wrapper_settings.style, {
+ ...THEME_MODAL_WINDOW_BASE.stylesWrapper,
+ ...stylesWrapper,
+ });
+
+ // Box
+ const box__settings = makeElement("div", {
+ class: ["alekpet__window__box", ...classesBox],
+ });
+ Object.assign(box__settings.style, {
+ ...THEME_MODAL_WINDOW_BASE.stylesBox,
+ ...stylesBox,
+ });
+
+ // Title
+ let box_settings_title = "";
+ if (textTitle) {
+ box_settings_title = makeElement("div", {
+ class: ["alekpet__window__title", ...classesTitle],
+ });
+
+ Object.assign(box_settings_title.style, {
+ ...THEME_MODAL_WINDOW_BASE.stylesTitle,
+ ...stylesTitle,
+ });
+
+ // Add text (html) to title
+ addText(textTitle, box_settings_title);
+ }
+ // Body
+ let box_settings_body = "";
+ if (textBody) {
+ box_settings_body = makeElement("div", {
+ class: ["alekpet__window__body", ...classesBody],
+ });
+
+ Object.assign(box_settings_body.style, {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "flex-end",
+ gap: "5px",
+ textWrap: "wrap",
+ ...stylesBody,
+ });
+
+ // Add text (html) to body
+ addText(textBody, box_settings_body);
+ }
+
+ // Close button
+ const close__box__button = makeElement("div", {
+ class: ["close__box__button", ...classesClose],
+ textContent: "✖",
+ });
+
+ Object.assign(close__box__button.style, {
+ ...THEME_MODAL_WINDOW_BASE.stylesClose,
+ ...stylesClose,
+ });
+
+ if (!showClose) close__box__button.style.display = "none";
+
+ const closeEvent = new Event("closeModal");
+ const closeModalWindow = function () {
+ overlay_enabled
+ ? animateTransitionProps(overlayElement, {
+ opacity: 0,
+ })
+ .then(() =>
+ animateTransitionProps(wrapper_settings, {
+ opacity: 0,
+ }),
+ )
+ .then(() => {
+ if (closeRemove) {
+ parent.removeChild(wrapper_settings);
+ parent.removeChild(overlayElement);
+ } else {
+ showHide({ elements: [wrapper_settings, overlayElement] });
+ }
+ })
+ : animateTransitionProps(wrapper_settings, {
+ opacity: 0,
+ }).then(() => {
+ showHide({ elements: [wrapper_settings] });
+ });
+ };
+
+ close__box__button.addEventListener("closeModal", closeModalWindow);
+
+ close__box__button.addEventListener("click", () => close__box__button.dispatchEvent(closeEvent));
+
+ close__box__button.onmouseenter = () => {
+ close__box__button.style.opacity = 0.8;
+ };
+
+ close__box__button.onmouseleave = () => {
+ close__box__button.style.opacity = 1;
+ };
+
+ box__settings.append(box_settings_title, box_settings_body);
+
+ // Footer
+ if (textFooter) {
+ const box_settings_footer = makeElement("div", {
+ class: [...classesFooter],
+ });
+ Object.assign(box_settings_footer.style, {
+ ...stylesFooter,
+ });
+
+ // Add text (html) to body
+ addText(textFooter, box_settings_footer);
+
+ box__settings.append(box_settings_footer);
+ }
+
+ wrapper_settings.append(close__box__button, box__settings);
+
+ if (parent && parent.nodeType === 1) {
+ if (overlay_enabled) parent.append(overlayElement);
+ parent.append(wrapper_settings);
+
+ if (autoshow) {
+ overlay_enabled
+ ? animateClick(overlayElement).then(() =>
+ animateClick(wrapper_settings).then(
+ () =>
+ autohide &&
+ setTimeout(
+ () =>
+ animateTransitionProps(wrapper_settings, { ...propStyles }, { ...propPreStyles })
+ .then(() => animateTransitionProps(overlayElement, { ...propStyles }, { ...propPreStyles }))
+ .then(() => {
+ if (autoremove) {
+ parent.removeChild(wrapper_settings);
+ parent.removeChild(overlayElement);
+ }
+ }),
+ timewait,
+ ),
+ ),
+ )
+ : animateClick(wrapper_settings).then(() => autohide && setTimeout(() => animateTransitionProps(wrapper_settings, { ...propStyles }, { ...propPreStyles }).then(() => autoremove && parent.removeChild(wrapper_settings)), timewait));
+ }
+ }
+
+ return wrapper_settings;
+}
+
+// Prompt
+async function comfyuiDesktopPrompt(title, message, defaultValue) {
+ try {
+ return await app.extensionManager.dialog.prompt({
+ title,
+ message,
+ defaultValue,
+ });
+ } catch (err) {
+ return prompt(title, message);
+ }
+}
+
+// Alert
+function comfyuiDesktopAlert(message) {
+ try {
+ app.extensionManager.toast.addAlert(message);
+ } catch (err) {
+ alert(message);
+ }
+}
+
+// Confirm
+function confirmModal({ title, message }) {
+ return new Promise((res) => {
+ const overlay = makeElement("div", {
+ class: ["alekpet_confOverlay"],
+ style: {
+ background: "rgba(0, 0, 0, 0.7)",
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ zIndex: 9999,
+ userSelect: "none",
+ },
+ });
+
+ const modal = makeElement("div", {
+ class: ["alekpet_confModal"],
+ style: {
+ ...THEME_MODAL_WINDOW_BASE.stylesBox,
+ position: "fixed",
+ top: "50%",
+ left: "50%",
+ fontFamily: "monospace",
+ background: "rgb(92 186 255 / 20%)",
+ transform: "translate(-50%, -50%)",
+ borderColor: "rgba(92, 186, 255, 0.63)",
+ boxShadow: "rgba(92, 186, 255, 0.63) 2px 2px 4px",
+ },
+ });
+
+ const titleEl = makeElement("div", {
+ class: ["alekpet_confTitle"],
+ style: {
+ ...THEME_MODAL_WINDOW_BASE.stylesTitle,
+ background: "rgba(92, 186, 255, 0.63)",
+ },
+ textContent: title,
+ });
+
+ const messageEl = makeElement("div", {
+ class: ["alekpet_confMessage"],
+ style: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "flex-end",
+ gap: "5px",
+ textWrap: "wrap",
+ },
+ textContent: message,
+ });
+
+ const action_box = makeElement("div", {
+ class: ["alekpet_confActions"],
+ style: {
+ display: "flex",
+ gap: "5px",
+ width: "100%",
+ padding: "4px",
+ justifyContent: "flex-end",
+ },
+ });
+
+ const remove = () => {
+ modal.remove();
+ overlay.remove();
+ };
+
+ const ok = makeElement("div", {
+ class: ["alekpet_confButtons", "alekpet_confButtonOk"],
+ style: {
+ background: "linear-gradient(45deg, green, limegreen) rgb(21, 100, 6)",
+ },
+ textContent: "Ok",
+ onclick: (e) => {
+ res(true);
+ remove();
+ },
+ });
+
+ const Cancel = makeElement("div", {
+ class: ["alekpet_confButtons", "alekpet_confButtonCancel"],
+ style: {
+ background: "linear-gradient(45deg, #b64396, #a52a8b) rgb(135 3 161)",
+ },
+ textContent: "Cancel",
+ onclick: (e) => {
+ res(false);
+ remove();
+ },
+ });
+
+ action_box.append(ok, Cancel);
+ modal.append(titleEl, messageEl, action_box);
+ overlay.append(modal);
+ document.body.append(overlay);
+ });
+}
+
+async function comfyuiDesktopConfirm(message) {
+ try {
+ const result = await confirmModal({
+ title: "Confirm",
+ message: message,
+ });
+
+ // Wait update comfyui frontend! Confirm Cancel not return value! Fixed in ComfyUI_frontend ver. v1.10.8
+ // https://github.com/Comfy-Org/ComfyUI_frontend/issues/2649
+ // const result = await app.extensionManager.dialog.confirm({
+ // title: "Confirm",
+ // message: message,
+ // });
+ return result;
+ } catch (err) {
+ return confirm(message);
+ }
+}
+
+export {
+ makeModal,
+ createWindowModal,
+ animateTransitionProps,
+ animateClick,
+ showHide,
+ makeElement,
+ getDataJSON,
+ isEmptyObject,
+ isValidStyle,
+ rgbToHex,
+ findWidget,
+ THEMES_MODAL_WINDOW,
+ //
+ comfyuiDesktopConfirm,
+ comfyuiDesktopPrompt,
+ comfyuiDesktopAlert,
+};
diff --git a/comfy_extras/eval_web/ky_eval_python.js b/comfy_extras/eval_web/ky_eval_python.js
new file mode 100644
index 000000000..3d65aa5c0
--- /dev/null
+++ b/comfy_extras/eval_web/ky_eval_python.js
@@ -0,0 +1,377 @@
+/**
+ * Uses code adapted from https://github.com/yorkane/ComfyUI-KYNode
+ *
+ * MIT License
+ *
+ * Copyright (c) 2024 Kevin Yuan
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+import { app } from "../../scripts/app.js";
+
+import * as ace from "https://cdn.jsdelivr.net/npm/ace-code@1.43.4/+esm";
+import { makeElement, findWidget } from "./ace_utils.js";
+
+// Constants
+const varTypes = ["int", "boolean", "string", "float", "json", "list", "dict"];
+const typeMap = {
+ int: "int",
+ boolean: "bool",
+ string: "str",
+ float: "float",
+ json: "json",
+ list: "list",
+ dict: "dict",
+};
+
+ace.config.setModuleLoader('ace/mode/python', () =>
+ import('https://cdn.jsdelivr.net/npm/ace-builds@1.43.4/src/mode-python.js')
+);
+
+ace.config.setModuleLoader('ace/theme/monokai', () =>
+ import('https://cdn.jsdelivr.net/npm/ace-builds@1.43.4/src/theme-monokai.js')
+);
+
+function getPostition(node, ctx, w_width, y, n_height) {
+ const margin = 5;
+
+ const rect = ctx.canvas.getBoundingClientRect();
+ const transform = new DOMMatrix()
+ .scaleSelf(rect.width / ctx.canvas.width, rect.height / ctx.canvas.height)
+ .multiplySelf(ctx.getTransform())
+ .translateSelf(margin, margin + y);
+ const scale = new DOMMatrix().scaleSelf(transform.a, transform.d);
+
+ return {
+ transformOrigin: "0 0",
+ transform: scale,
+ left: `${transform.a + transform.e + rect.left}px`,
+ top: `${transform.d + transform.f + rect.top}px`,
+ maxWidth: `${w_width - margin * 2}px`,
+ maxHeight: `${n_height - margin * 2 - y - 15}px`,
+ width: `${w_width - margin * 2}px`,
+ height: "90%",
+ position: "absolute",
+ scrollbarColor: "var(--descrip-text) var(--bg-color)",
+ scrollbarWidth: "thin",
+ zIndex: app.graph._nodes.indexOf(node),
+ };
+}
+
+// Create editor code
+function codeEditor(node, inputName, inputData) {
+ const widget = {
+ type: "pycode",
+ name: inputName,
+ options: { hideOnZoom: true },
+ value:
+ inputData[1]?.default ||
+ `def my(a, b=1):
+ return a * b
+
+r0 = str(my(23, 9))`,
+ draw(ctx, node, widget_width, y, widget_height) {
+ const hidden = node.flags?.collapsed || (!!widget.options.hideOnZoom && app.canvas.ds.scale < 0.5) || widget.type === "converted-widget" || widget.type === "hidden";
+
+ widget.codeElement.hidden = hidden;
+
+ if (hidden) {
+ widget.options.onHide?.(widget);
+ return;
+ }
+
+ Object.assign(this.codeElement.style, getPostition(node, ctx, widget_width, y, node.size[1]));
+ },
+ computeSize(...args) {
+ return [500, 250];
+ },
+ };
+
+ widget.codeElement = makeElement("pre", {
+ innerHTML: widget.value,
+ });
+
+ widget.editor = ace.edit(widget.codeElement);
+ widget.editor.setTheme("ace/theme/monokai");
+ widget.editor.session.setMode("ace/mode/python");
+ widget.editor.setOptions({
+ enableAutoIndent: true,
+ enableLiveAutocompletion: true,
+ enableBasicAutocompletion: true,
+ fontFamily: "monospace",
+ });
+ widget.codeElement.hidden = true;
+
+ document.body.appendChild(widget.codeElement);
+
+ const collapse = node.collapse;
+ node.collapse = function () {
+ collapse.apply(this, arguments);
+ if (this.flags?.collapsed) {
+ widget.codeElement.hidden = true;
+ } else {
+ if (this.flags?.collapsed === false) {
+ widget.codeElement.hidden = false;
+ }
+ }
+ };
+
+ return widget;
+}
+
+// Save data to workflow forced!
+function saveValue() {
+ app?.extensionManager?.workflow?.activeWorkflow?.changeTracker?.checkState();
+}
+
+// Register extensions
+app.registerExtension({
+ name: "KYNode.KY_Eval_Python",
+ getCustomWidgets(app) {
+ return {
+ PYCODE: (node, inputName, inputData, app) => {
+ const widget = codeEditor(node, inputName, inputData);
+
+ widget.editor.getSession().on("change", function (e) {
+ widget.value = widget.editor.getValue();
+ saveValue();
+ });
+
+ const varTypeList = node.addWidget(
+ "combo",
+ "select_type",
+ "string",
+ (v) => {
+ // widget.editor.setTheme(`ace/theme/${varTypeList.value}`);
+ },
+ {
+ values: varTypes,
+ serialize: false,
+ },
+ );
+
+ // 6. 使用 addDOMWidget 将容器添加到节点上
+ // - 第一个参数是 widget 的名称,在节点内部需要是唯一的。
+ // - 第二个参数是 widget 的类型,对于自定义 DOM 元素,通常是 "div"。
+ // - 第三个参数是您创建的 DOM 元素。
+ // - 第四个参数是一个选项对象,可以用来配置 widget。
+ // node.addDOMWidget("rowOfButtons", "div", container, {
+ // });
+ node.addWidget("button", "Add Input variable", "add_input_variable", async () => {
+ // Input name variable and check
+ let nameInput = node?.inputs?.length ? `p${node.inputs.length - 1}` : "p0";
+
+ const currentWidth = node.size[0];
+ let tp = varTypeList.value;
+ nameInput = nameInput + "_" + typeMap[tp];
+ node.addInput(nameInput, "*");
+ node.setSize([currentWidth, node.size[1]]);
+ let cv = widget.editor.getValue();
+ if (tp === "json") {
+ cv = cv + "\n" + nameInput + " = json.loads(" + nameInput + ")";
+ } else if (tp === "list") {
+ cv = cv + "\n" + nameInput + " = []";
+ } else if (tp === "dict") {
+ cv = cv + "\n" + nameInput + " = {}";
+ } else {
+ cv = cv + "\n" + nameInput + " = " + typeMap[tp] + "(" + nameInput + ")";
+ }
+ widget.editor.setValue(cv);
+ saveValue();
+ });
+
+ node.addWidget("button", "Add Output variable", "add_output_variable", async () => {
+ const currentWidth = node.size[0];
+ // Output name variable
+ let nameOutput = node?.outputs?.length ? `r${node.outputs.length}` : "r0";
+ let tp = varTypeList.value;
+ nameOutput = nameOutput + "_" + typeMap[tp];
+ node.addOutput(nameOutput, tp);
+ node.setSize([currentWidth, node.size[1]]);
+ let cv = widget.editor.getValue();
+ if (tp === "json") {
+ cv = cv + "\n" + nameOutput + " = json.dumps(" + nameOutput + ")";
+ } else if (tp === "list") {
+ cv = cv + "\n" + nameOutput + " = []";
+ } else if (tp === "dict") {
+ cv = cv + "\n" + nameOutput + " = {}";
+ } else {
+ cv = cv + "\n" + nameOutput + " = " + typeMap[tp] + "(" + nameOutput + ")";
+ }
+ widget.editor.setValue(cv);
+ saveValue();
+ });
+
+ node.onRemoved = function () {
+ for (const w of node?.widgets) {
+ if (w?.codeElement) w.codeElement.remove();
+ }
+ };
+
+ node.addCustomWidget(widget);
+
+ return widget;
+ },
+ };
+ },
+
+ async beforeRegisterNodeDef(nodeType, nodeData, app) {
+ // --- IDENode
+ if (nodeData.name === "KY_Eval_Python") {
+ // Node Created
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = async function () {
+ const ret = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
+
+ const node_title = await this.getTitle();
+ const nodeName = `${nodeData.name}_${this.id}`;
+
+ this.name = nodeName;
+
+ // Create default inputs, when first create node
+ if (this?.inputs?.length < 2) {
+ ["p0_str"].forEach((inputName) => {
+ const currentWidth = this.size[0];
+ this.addInput(inputName, "*");
+ this.setSize([currentWidth, this.size[1]]);
+ });
+ }
+
+ const widgetEditor = findWidget(this, "pycode", "type");
+
+ this.setSize([530, this.size[1]]);
+
+ return ret;
+ };
+
+ const onDrawForeground = nodeType.prototype.onDrawForeground;
+ nodeType.prototype.onDrawForeground = function (ctx) {
+ const r = onDrawForeground?.apply?.(this, arguments);
+
+ // if (this.flags?.collapsed) return r;
+
+ if (this?.outputs?.length) {
+ for (let o = 0; o < this.outputs.length; o++) {
+ const { name, type } = this.outputs[o];
+ const colorType = LGraphCanvas.link_type_colors[type.toUpperCase()];
+ const nameSize = ctx.measureText(name);
+ const typeSize = ctx.measureText(`[${type === "*" ? "any" : type.toLowerCase()}]`);
+
+ ctx.fillStyle = colorType === "" ? "#AAA" : colorType;
+ ctx.font = "12px Arial, sans-serif";
+ ctx.textAlign = "right";
+ ctx.fillText(`[${type === "*" ? "any" : type.toLowerCase()}]`, this.size[0] - nameSize.width - typeSize.width, o * 20 + 19);
+ }
+ }
+
+ if (this?.inputs?.length) {
+ const not_showing = ["select_type", "pycode"];
+ for (let i = 1; i < this.inputs.length; i++) {
+ const { name, type } = this.inputs[i];
+ if (not_showing.includes(name)) continue;
+ const colorType = LGraphCanvas.link_type_colors[type.toUpperCase()];
+ const nameSize = ctx.measureText(name);
+
+ ctx.fillStyle = !colorType || colorType === "" ? "#AAA" : colorType;
+ ctx.font = "12px Arial, sans-serif";
+ ctx.textAlign = "left";
+ ctx.fillText(`[${type === "*" ? "any" : type.toLowerCase()}]`, nameSize.width + 25, i * 20);
+ }
+ }
+ return r;
+ };
+
+ // Node Configure
+ const onConfigure = nodeType.prototype.onConfigure;
+ nodeType.prototype.onConfigure = function (node) {
+ onConfigure?.apply(this, arguments);
+ if (node?.widgets_values?.length) {
+ const widget_code_id = findWidget(this, "pycode", "type", "findIndex");
+ const widget_theme_id = findWidget(this, "varTypeList", "name", "findIndex");
+ const widget_language_id = findWidget(this, "language", "name", "findIndex");
+
+ const editor = this.widgets[widget_code_id]?.editor;
+
+ if (editor) {
+ // editor.setTheme(
+ // `ace/theme/${this.widgets_values[widget_theme_id]}`
+ // );
+ // editor.session.setMode(
+ // `ace/mode/${this.widgets_values[widget_language_id]}`
+ // );
+ editor.setValue(this.widgets_values[widget_code_id]);
+ editor.clearSelection();
+ }
+ }
+ };
+
+ // ExtraMenuOptions
+ const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
+ nodeType.prototype.getExtraMenuOptions = function (_, options) {
+ getExtraMenuOptions?.apply(this, arguments);
+
+ const past_index = options.length - 1;
+ const past = options[past_index];
+
+ if (!!past) {
+ // Inputs remove
+ for (const input_idx in this.inputs) {
+ const input = this.inputs[input_idx];
+
+ if (["language", "select_type"].includes(input.name)) continue;
+
+ options.splice(past_index + 1, 0, {
+ content: `Remove Input ${input.name}`,
+ callback: (e) => {
+ const currentWidth = this.size[0];
+ if (input.link) {
+ app.graph.removeLink(input.link);
+ }
+ this.removeInput(input_idx);
+ this.setSize([80, this.size[1]]);
+ saveValue();
+ },
+ });
+ }
+
+ // Output remove
+ for (const output_idx in this.outputs) {
+ const output = this.outputs[output_idx];
+
+ if (output.name === "r0") continue;
+
+ options.splice(past_index + 1, 0, {
+ content: `Remove Output ${output.name}`,
+ callback: (e) => {
+ const currentWidth = this.size[0];
+ if (output.link) {
+ app.graph.removeLink(output.link);
+ }
+ this.removeOutput(output_idx);
+ this.setSize([currentWidth, this.size[1]]);
+ saveValue();
+ },
+ });
+ }
+ }
+ };
+ // end - ExtraMenuOptions
+ }
+ },
+});
diff --git a/comfy_extras/nodes/nodes_eval.py b/comfy_extras/nodes/nodes_eval.py
new file mode 100644
index 000000000..a09739c21
--- /dev/null
+++ b/comfy_extras/nodes/nodes_eval.py
@@ -0,0 +1,110 @@
+import re
+import traceback
+import types
+
+from comfy.execution_context import current_execution_context
+from comfy.node_helpers import export_package_as_web_directory, export_custom_nodes
+from comfy.nodes.package_typing import CustomNode
+
+remove_type_name = re.compile(r"(\{.*\})", re.I | re.M)
+
+
+# Hack: string type that is always equal in not equal comparisons, thanks pythongosssss
+class AnyType(str):
+ def __ne__(self, __value: object) -> bool:
+ return False
+
+
+PY_CODE = AnyType("*")
+IDEs_DICT = {}
+
+
+# - Thank you very much for the class -> Trung0246 -
+# - https://github.com/Trung0246/ComfyUI-0246/blob/main/utils.py#L51
+class TautologyStr(str):
+ def __ne__(self, other):
+ return False
+
+
+class ByPassTypeTuple(tuple):
+ def __getitem__(self, index):
+ if index > 0:
+ index = 0
+ item = super().__getitem__(index)
+ if isinstance(item, str):
+ return TautologyStr(item)
+ return item
+
+
+# ---------------------------
+
+
+class KY_Eval_Python(CustomNode):
+ @classmethod
+ def INPUT_TYPES(s):
+
+ return {
+ "required": {
+ "pycode": (
+ "PYCODE",
+ {
+ "default": """import re, json, os, traceback
+from time import strftime
+
+def runCode():
+ nowDataTime = strftime("%Y-%m-%d %H:%M:%S")
+ return f"Hello ComfyUI with us today {nowDataTime}!"
+r0_str = runCode() + unique_id
+"""
+ },
+ ),
+ },
+ "hidden": {"unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO"},
+ }
+
+ RETURN_TYPES = ByPassTypeTuple((PY_CODE,))
+ RETURN_NAMES = ("r0_str",)
+ FUNCTION = "exec_py"
+ DESCRIPTION = "IDE Node is an node that allows you to run code written in Python or Javascript directly in the node."
+ CATEGORY = "KYNode/Code"
+
+ def exec_py(self, pycode, unique_id, extra_pnginfo, **kwargs):
+ ctx = current_execution_context()
+ if ctx.configuration.enable_eval is not True:
+ raise ValueError("Python eval is disabled")
+
+ if unique_id not in IDEs_DICT:
+ IDEs_DICT[unique_id] = self
+
+ outputs = {unique_id: unique_id}
+ if extra_pnginfo and 'workflow' in extra_pnginfo and extra_pnginfo['workflow']:
+ for node in extra_pnginfo['workflow']['nodes']:
+ if node['id'] == int(unique_id):
+ outputs_valid = [ouput for ouput in node.get('outputs', []) if ouput.get('name', '') != '' and ouput.get('type', '') != '']
+ outputs = {ouput['name']: None for ouput in outputs_valid}
+ self.RETURN_TYPES = ByPassTypeTuple(out["type"] for out in outputs_valid)
+ self.RETURN_NAMES = tuple(name for name in outputs.keys())
+ my_namespace = types.SimpleNamespace()
+ # 从 prompt 对象中提取 prompt_id
+ # if extra_data and 'extra_data' in extra_data and 'prompt_id' in extra_data['extra_data']:
+ # prompt_id = prompt['extra_data']['prompt_id']
+ # outputs['p0_str'] = p0_str
+
+ my_namespace.__dict__.update(outputs)
+ my_namespace.__dict__.update({prop: kwargs[prop] for prop in kwargs})
+ # my_namespace.__dict__.setdefault("r0_str", "The r0 variable is not assigned")
+
+ try:
+ exec(pycode, my_namespace.__dict__)
+ except Exception as e:
+ err = traceback.format_exc()
+ mc = re.search(r'line (\d+), in ([\w\W]+)$', err, re.MULTILINE)
+ msg = mc[1] + ':' + mc[2]
+ my_namespace.r0 = f"Error Line{msg}"
+
+ new_dict = {key: my_namespace.__dict__[key] for key in my_namespace.__dict__ if key not in ['__builtins__', *kwargs.keys()] and not callable(my_namespace.__dict__[key])}
+ return (*new_dict.values(),)
+
+
+export_custom_nodes()
+export_package_as_web_directory("comfy_extras.eval_web")
diff --git a/tests/conftest.py b/tests/conftest.py
index 931ec8deb..1c5b3df20 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -100,7 +100,7 @@ def frontend_backend_worker_with_rabbitmq(request, tmp_path_factory, num_workers
frontend_command = [
"comfyui",
- "--listen=127.0.0.1",
+ "--listen=0.0.0.0",
"--port=19001",
"--cpu",
"--distributed-queue-frontend",
diff --git a/tests/distributed/test_tracing_integration.py b/tests/distributed/test_tracing_integration.py
index 412b14c02..36a9fabd6 100644
--- a/tests/distributed/test_tracing_integration.py
+++ b/tests/distributed/test_tracing_integration.py
@@ -8,6 +8,7 @@ full distributed trace.
import asyncio
import logging
import os
+import tempfile
import time
import uuid
@@ -21,6 +22,7 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.semconv.attributes import service_attributes
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_utils import wait_for_logs
+from testcontainers.nginx import NginxContainer
from comfy.client.sdxl_with_refiner_workflow import sdxl_workflow_with_refiner
@@ -54,6 +56,102 @@ class JaegerContainer(DockerContainer):
return self
+@pytest.fixture(scope="function")
+def nginx_proxy(frontend_backend_worker_with_rabbitmq):
+ """
+ Provide an nginx proxy in front of the ComfyUI frontend.
+ This tests if nginx is blocking W3C trace context propagation.
+ """
+ import socket
+ import subprocess
+
+ # Extract host and port from frontend address
+ frontend_url = frontend_backend_worker_with_rabbitmq
+ # frontend_url is like "http://127.0.0.1:19001"
+ import re
+ match = re.match(r'http://([^:]+):(\d+)', frontend_url)
+ if not match:
+ raise ValueError(f"Could not parse frontend URL: {frontend_url}")
+
+ frontend_host = match.group(1)
+ frontend_port = match.group(2)
+ nginx_port = 8085
+
+ # Get the Docker bridge gateway IP (this is how containers reach the host on Linux)
+ # Try to get the default Docker bridge gateway
+ try:
+ result = subprocess.run(
+ ["docker", "network", "inspect", "bridge", "-f", "{{range .IPAM.Config}}{{.Gateway}}{{end}}"],
+ capture_output=True,
+ text=True,
+ check=True
+ )
+ docker_gateway = result.stdout.strip()
+ logger.info(f"Using Docker gateway IP: {docker_gateway}")
+ except Exception as e:
+ # Fallback: try common gateway IPs
+ docker_gateway = "172.17.0.1" # Default Docker bridge gateway on Linux
+ logger.warning(f"Could not detect Docker gateway, using default: {docker_gateway}")
+
+ # Create nginx config that proxies to the frontend and passes trace headers
+ nginx_conf = f"""
+events {{
+ worker_connections 1024;
+}}
+
+http {{
+ upstream backend {{
+ server {docker_gateway}:{frontend_port};
+ }}
+
+ server {{
+ listen {nginx_port};
+
+ location / {{
+ proxy_pass http://backend;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }}
+ }}
+}}
+"""
+
+ # Write config to a temporary file
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False) as f:
+ f.write(nginx_conf)
+ nginx_conf_path = f.name
+
+ try:
+ # Start nginx container with the config
+ nginx = NginxContainer(port=nginx_port)
+ nginx.with_volume_mapping(nginx_conf_path, "/etc/nginx/nginx.conf")
+ nginx.start()
+
+ # Get the nginx URL
+ host = nginx.get_container_host_ip()
+ port = nginx.get_exposed_port(nginx_port)
+ nginx_url = f"http://{host}:{port}"
+
+ logger.info(f"Nginx proxy started at {nginx_url} -> {frontend_url}")
+
+ # Wait for nginx to be ready
+ for _ in range(30):
+ try:
+ response = requests.get(nginx_url, timeout=1)
+ if response.status_code:
+ break
+ except Exception:
+ pass
+ time.sleep(0.5)
+
+ yield nginx_url
+ finally:
+ nginx.stop()
+ os.unlink(nginx_conf_path)
+
+
@pytest.fixture(scope="module")
def jaeger_container():
"""
@@ -201,21 +299,21 @@ def verify_trace_continuity(trace: dict, expected_services: list[str]) -> bool:
# order matters, execute jaeger_container first
@pytest.mark.asyncio
-async def test_tracing_integration(jaeger_container, frontend_backend_worker_with_rabbitmq):
+async def test_tracing_integration(jaeger_container, nginx_proxy):
"""
- Integration test for distributed tracing across services.
+ Integration test for distributed tracing across services with nginx proxy.
This test:
1. Starts ComfyUI frontend and worker with RabbitMQ
- 2. Configures OTLP export to Jaeger testcontainer
- 3. Submits a workflow through the frontend
- 4. Queries Jaeger to verify trace propagation
- 5. Validates that the trace spans multiple services with proper relationships
+ 2. Starts nginx proxy in front of the frontend to test trace context propagation through nginx
+ 3. Configures OTLP export to Jaeger testcontainer
+ 4. Submits a workflow through the nginx proxy
+ 5. Queries Jaeger to verify trace propagation
+ 6. Validates that the trace spans multiple services with proper relationships
- Note: The frontend_backend_worker_with_rabbitmq fixture is parameterized,
- so this test will run with both ThreadPoolExecutor and ProcessPoolExecutor.
+ This specifically tests if nginx is blocking W3C trace context (traceparent/tracestate headers).
"""
- server_address = frontend_backend_worker_with_rabbitmq
+ server_address = nginx_proxy
jaeger_url = jaeger_container.get_query_url()
otlp_endpoint = jaeger_container.get_otlp_endpoint()
@@ -410,31 +508,27 @@ async def test_multiple_requests_different_traces(frontend_backend_worker_with_r
# Query Jaeger and verify we have multiple distinct traces
jaeger_url = jaeger_container.get_query_url()
- try:
- traces_response = query_jaeger_traces(jaeger_url, "comfyui", lookback="5m", limit=10)
- traces = traces_response.get("data", [])
+ traces_response = query_jaeger_traces(jaeger_url, "comfyui", lookback="5m", limit=10)
+ traces = traces_response.get("data", [])
- if len(traces) >= 2:
- # Get trace IDs
- trace_ids = [trace.get("traceID") for trace in traces]
- unique_trace_ids = set(trace_ids)
+ assert len(traces) >= 2
+ # Get trace IDs
+ trace_ids = [trace.get("traceID") for trace in traces]
+ unique_trace_ids = set(trace_ids)
- logger.info(f"Found {len(unique_trace_ids)} unique traces")
+ logger.info(f"Found {len(unique_trace_ids)} unique traces")
- # Verify we have multiple distinct traces
- assert len(unique_trace_ids) >= 2, (
- f"Expected at least 2 distinct traces, found {len(unique_trace_ids)}. "
- "Each request should create its own trace."
- )
+ # Verify we have multiple distinct traces
+ assert len(unique_trace_ids) >= 2, (
+ f"Expected at least 2 distinct traces, found {len(unique_trace_ids)}. "
+ "Each request should create its own trace."
+ )
- logger.info("✓ Multiple requests created distinct traces")
- else:
- pytest.skip("Not enough traces to validate")
- except Exception as e:
- pytest.skip(f"Could not query Jaeger: {e}")
+ logger.info("✓ Multiple requests created distinct traces")
@pytest.mark.asyncio
+@pytest.mark.skip(reason="rabbitmq has to be configured for observability?")
async def test_trace_contains_rabbitmq_operations(frontend_backend_worker_with_rabbitmq, jaeger_container):
"""
Test that traces include RabbitMQ publish/consume operations.
@@ -455,43 +549,21 @@ async def test_trace_contains_rabbitmq_operations(frontend_backend_worker_with_r
await asyncio.sleep(5)
- try:
- traces_response = query_jaeger_traces(jaeger_url, "comfyui", lookback="5m")
- traces = traces_response.get("data", [])
+ traces_response = query_jaeger_traces(jaeger_url, "comfyui", lookback="5m")
+ traces = traces_response.get("data", [])
- if traces:
- # Look for RabbitMQ-related operations in any trace
- rabbitmq_operations = [
- "publish", "consume", "amq_queue_publish", "amq_queue_consume",
- "amq.basic.publish", "amq.basic.consume", "send", "receive"
- ]
+ # Look for RabbitMQ-related operations in any trace
+ rabbitmq_operations = [
+ "publish", "consume", "amq_queue_publish", "amq_queue_consume",
+ "amq.basic.publish", "amq.basic.consume", "send", "receive"
+ ]
- found_rabbitmq_ops = []
- for trace in traces:
- for span in trace.get("spans", []):
- op_name = span.get("operationName", "").lower()
- for rmq_op in rabbitmq_operations:
- if rmq_op in op_name:
- found_rabbitmq_ops.append(op_name)
+ found_rabbitmq_ops = []
+ for trace in traces:
+ for span in trace.get("spans", []):
+ op_name = span.get("operationName", "").lower()
+ for rmq_op in rabbitmq_operations:
+ if rmq_op in op_name:
+ found_rabbitmq_ops.append(op_name)
- if found_rabbitmq_ops:
- logger.info(f"✓ Found RabbitMQ operations in traces: {set(found_rabbitmq_ops)}")
- else:
- logger.warning(
- "No RabbitMQ operations found in traces. "
- "This suggests that either:\n"
- "1. AioPikaInstrumentor is not creating spans, or\n"
- "2. The spans are being filtered out by the collector, or\n"
- "3. The spans exist but use different operation names"
- )
-
- # Log all operation names to help debug
- all_ops = set()
- for trace in traces[:3]: # First 3 traces
- for span in trace.get("spans", []):
- all_ops.add(span.get("operationName"))
- logger.info(f"Sample operation names: {all_ops}")
- else:
- pytest.skip("No traces found")
- except Exception as e:
- pytest.skip(f"Could not query Jaeger: {e}")
+ assert found_rabbitmq_ops, "No RabbitMQ-related operations found in traces"
\ No newline at end of file