4 Commits
Author SHA1 Message Date
WildAi 4597af8f59 chore: drop tests workflow, bump 2.0.3
CI cannot boot ComfyUI; pytest gate runs locally.
2026-10-14 13:26:47 +03:00
WildAi 896c6c34df feat(ui): toast node errors in the web ui
Core app logged execution_error without a toast; startup note dropped.
2026-10-14 13:26:27 +03:00
WildAi fcf92a906e update 2026-10-11 20:13:00 +03:00
WildAi 6d4c82deba update 2026-10-11 19:09:58 +03:00
11 changed files with 1476 additions and 251 deletions
+3 -10
View File
@@ -4,25 +4,18 @@ on:
push:
branches:
- main
- master
- master
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'wildminder' }}
steps:
- name: Check out code
uses: actions/checkout@v4
with:
submodules: true
uses: actions/checkout@v7
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
uses: Comfy-Org/publish-node-action@main
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
-67
View File
@@ -1,67 +0,0 @@
name: Tests
# The publish-only workflow next to this one is untouched.
#
# The point of this job is that it is nearly empty. `conftest.py` mocks the
# whole vendored Chatterbox model stack at collection time, so the suite runs
# on a clean runner with:
#
# * no resemble-perth (the watermarking import is defensive)
# * no diffusers (not a dependency any more)
# * no s3tokenizer (vendored imports are mocked)
# * no conformer (vendored imports are mocked)
#
# That is the Phase 0 mock paying for itself: if a future change ever makes the
# suite depend on one of those, this workflow fails. So do not "fix" it by
# adding them to the install list below -- that would delete the signal.
on:
workflow_dispatch:
push:
branches: [main, master]
pull_request:
permissions:
contents: read
jobs:
pytest:
name: pytest (mocked vendor, real ComfyUI core)
runs-on: ubuntu-latest
steps:
- name: Check out the node package
uses: actions/checkout@v4
# A real ComfyUI checkout, laid out as a sibling of this repository so
# conftest's own resolution logic finds it even without COMFYUI_ROOT.
# `comfy` and `folder_paths` are imported FOR REAL: these are ComfyUI
# nodes, and testing them against a fake core would test nothing.
- name: Check out ComfyUI core
uses: actions/checkout@v4
with:
repository: comfyanonymous/ComfyUI
path: ComfyUI
# Only what the suite actually touches from the host environment, which
# a real user already has. Deliberately not this repository's own
# requirements.txt -- see the header.
- name: Install the test runner
run: |
python -m pip install --upgrade pip
python -m pip install pytest setuptools
python -m pip install --index-url https://download.pytorch.org/whl/cpu torch
python -m pip install numpy soundfile huggingface_hub
- name: Report the resolved environment
run: |
python --version
python -c "import torch; print('torch', torch.__version__)"
python -m pytest --version
- name: Run the suite
env:
COMFYUI_ROOT: ${{ github.workspace }}/ComfyUI
# -rs so every skip prints its reason: a passing suite whose skips are
# unexplained is not a green suite.
run: python -m pytest tests/ -q -rs
+5
View File
@@ -127,6 +127,11 @@ from ComfyUI itself.
Every widget is documented in its node tooltip. Load a graph from
[`example_workflows/`](./example_workflows) to see the nodes wired up.
When a node fails at execution — a missing reference clip, a pack with no
conversion model — the message shows up as a toast in the web UI, naming the
node and the error. Add `Chatterbox.ErrorToasts` to the `Comfy.Extension.Disabled`
list in ComfyUI's settings to turn the toasts off.
<p align="right"><a href="#readme-top">⟔ ▲ ⟓ back to top</a></p>
<p id="good-to-know" align="center">◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆</p>
+11 -5
View File
@@ -3,10 +3,11 @@
ComfyUI imports this module for one reason: to find ``comfy_entrypoint``, the
async factory that hands back the :class:`~comfy_api.latest.ComfyExtension`
holding this package's nodes. That is all a V3 custom node exposes -- the V1
``NODE_CLASS_MAPPINGS`` / ``NODE_DISPLAY_NAME_MAPPINGS`` pair is gone, and
there is deliberately **no ``WEB_DIRECTORY``**: this package ships no frontend
assets, and pointing ComfyUI at a missing directory only produces a warning on
every startup.
``NODE_CLASS_MAPPINGS`` / ``NODE_DISPLAY_NAME_MAPPINGS`` pair is gone. The one
other name it does expose is ``WEB_DIRECTORY``: ``js/`` ships ``extension.js``,
a plain-ESM extension that turns a failed Chatterbox node execution into a
web-UI toast, and ComfyUI serves that directory at
``/extensions/ComfyUI-Chatterbox/`` for its loader to import at boot.
What happens here, in order, each step degrading rather than raising:
@@ -43,7 +44,12 @@ else:
from .modules.model_discovery import get_chatterbox_model_pack_names
from .modules.vendor_loader import ensure_vendor_on_path
logging.info("[Chatterbox TTS] Registering the Chatterbox custom node.")
# Declared with the bootstrap, never above the pytest guard: the guard is
# what makes this module importable during collection, and a web directory
# bound there would be a claim the test process cannot back up. ComfyUI
# resolves it against this module's own directory and serves whatever it
# finds at /extensions/ComfyUI-Chatterbox/.
WEB_DIRECTORY = "./js"
# 1. The vendored `chatterbox` package lives in this repository's `src/`.
ensure_vendor_on_path()
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

+341
View File
@@ -0,0 +1,341 @@
/**
* node:test suite for the shipped js/extension.js. Zero dependencies: the
* built-in runner, the standard library, nothing else.
*
* The extension imports ComfyUI's frontend `app` module, which does not exist
* outside a browser. So every case stages a throwaway tree in the OS temp dir
* shaped like a real deployment:
*
* <stage>/scripts/app.js <- the stub below, configured by stub.json
* <stage>/ComfyUI-Chatterbox/js/extension.js <- a copy of the shipped file
*
* The extension is imported *from the staged package path*, so its
* `import { app } from "../../scripts/app.js"` resolves to the stub two
* directories up -- the same relative shape the core extension loader serves it
* under. Each case gets a fresh stage, hence a fresh module instance, hence a
* fresh listener.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, "..");
const SHIPPED_EXTENSION = path.join(REPO_ROOT, "js", "extension.js");
const EXTENSION_NAME = "Chatterbox.ErrorToasts";
const DISABLED_SETTING = "Comfy.Extension.Disabled";
const TOAST_DURATION = 10000;
const DEDUPE_WINDOW = 3000;
// The two messages the ask names, verbatim. Anything that reformats them --
// a rewrap, a dropped article -- breaks the toast the user reads.
const FLASH_MESSAGE =
"Chatterbox Flash ships no built-in voice -- connect a reference " +
"audio clip to the node's audio prompt input.";
const VC_MESSAGE =
"The Chatterbox voice pack 'chatterbox-flash' loaded without a " +
"conversion model. Use the Chatterbox TTS node for it.";
/**
* A minimal stand-in for the frontend app module. Its shape comes from
* stub.json, written next to this file's parent directory by stage(), so one
* stub plays every delivery branch the extension feature-detects.
*/
const STUB_SOURCE = `import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const config = JSON.parse(readFileSync(join(here, "..", "stub.json"), "utf8"));
const state = {
listeners: [],
registered: [],
modern: [],
legacy: [],
settingReads: [],
};
const manager = {};
if (config.toast === "modern") {
manager.toast = {
error(title, options) {
state.modern.push({ title, options });
},
};
} else if (config.toast === "legacy") {
manager.toast = {
add(options) {
state.legacy.push(options);
},
};
}
if (config.disabled === true) {
manager.setting = {
get(key) {
state.settingReads.push(key);
return config.disabledEntries;
},
};
}
export const app = {
api: {
addEventListener(type, handler) {
state.listeners.push({ type, handler });
},
},
registerExtension(extension) {
state.registered.push(extension);
},
extensionManager: manager,
};
export { state };
`;
/** Stage a throwaway deployment tree and return its paths. */
function stage(config) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "chatterbox-toast-"));
const packageJs = path.join(root, "ComfyUI-Chatterbox", "js");
fs.mkdirSync(packageJs, { recursive: true });
const extensionPath = path.join(packageJs, "extension.js");
fs.copyFileSync(SHIPPED_EXTENSION, extensionPath);
const scriptsDir = path.join(root, "scripts");
fs.mkdirSync(scriptsDir, { recursive: true });
const stubPath = path.join(scriptsDir, "app.js");
fs.writeFileSync(stubPath, STUB_SOURCE, "utf8");
fs.writeFileSync(
path.join(root, "stub.json"),
JSON.stringify(config),
"utf8"
);
return { root, stubPath, extensionPath };
}
function discard(staged) {
fs.rmSync(staged.root, { recursive: true, force: true });
}
/**
* Import both modules from the staged tree. The extension's own import of the
* app stub resolves to the same file URL, so the two calls share one module
* instance and the test sees exactly what the extension saw.
*/
async function load(staged) {
await import(pathToFileURL(staged.extensionPath).href);
const stub = await import(pathToFileURL(staged.stubPath).href);
return { stub };
}
/** Fire a real execution_error event at the recorded listener. */
function dispatch(state, detail) {
assert.equal(state.listeners.length, 1, "exactly one listener registered");
const [listener] = state.listeners;
assert.equal(listener.type, "execution_error");
listener.handler(new CustomEvent("execution_error", { detail }));
}
/** The payload ComfyUI's execution layer broadcasts for a failed node. */
function chatterboxError(overrides = {}) {
return {
prompt_id: "prompt-1",
node_id: "7",
node_type: "ChatterboxTTS",
executed: ["7"],
exception_message: FLASH_MESSAGE,
exception_type: "ValueError",
traceback: ["Traceback (most recent call last):", "ValueError: ..."],
current_inputs: { text: "Hello.", audio_prompt: null },
current_outputs: {},
...overrides,
};
}
function frozenClock(start) {
const real = Date.now;
let now = start;
Date.now = () => now;
return {
advance(ms) {
now += ms;
},
restore() {
Date.now = real;
},
};
}
test("1. the execution_error listener is registered at module load", async (t) => {
const staged = stage({ toast: "modern" });
t.after(() => discard(staged));
const { stub } = await load(staged);
// Nothing has called setup() -- nothing in this harness can -- yet the
// listener is already there, and a dispatch right now already toasts. A
// listener registered lazily would miss every event that fired first.
assert.equal(stub.state.listeners.length, 1);
assert.equal(stub.state.listeners[0].type, "execution_error");
assert.equal(stub.state.modern.length, 0, "importing must not toast");
dispatch(stub.state, chatterboxError());
assert.equal(stub.state.modern.length, 1);
});
test("2. a Chatterbox failure toasts the message verbatim with node and type", async (t) => {
const staged = stage({ toast: "modern" });
t.after(() => discard(staged));
const { stub } = await load(staged);
dispatch(stub.state, chatterboxError());
assert.equal(stub.state.legacy.length, 0, "the deprecated branch must not fire");
assert.equal(stub.state.modern.length, 1);
const { title, options } = stub.state.modern[0];
assert.equal(title, FLASH_MESSAGE);
assert.ok(options.description.includes("ChatterboxTTS"), options.description);
assert.ok(options.description.includes("ValueError"), options.description);
assert.equal(options.closable, true);
assert.equal(options.duration, TOAST_DURATION);
});
test("3. a non-Chatterbox node_type produces no toast", async (t) => {
const staged = stage({ toast: "modern" });
t.after(() => discard(staged));
const { stub } = await load(staged);
dispatch(
stub.state,
chatterboxError({ node_type: "SomeOtherPackNode", node_id: "99" })
);
assert.equal(stub.state.modern.length, 0, "another pack's failure is not ours");
assert.equal(stub.state.legacy.length, 0);
});
test("4. an identical message is deduped inside the window and re-toasts outside it", async (t) => {
const staged = stage({ toast: "modern" });
t.after(() => discard(staged));
const { stub } = await load(staged);
const clock = frozenClock(0);
try {
dispatch(stub.state, chatterboxError());
assert.equal(stub.state.modern.length, 1);
dispatch(stub.state, chatterboxError());
assert.equal(stub.state.modern.length, 1, "the same fault twice in a row");
clock.advance(DEDUPE_WINDOW - 1);
dispatch(stub.state, chatterboxError());
assert.equal(stub.state.modern.length, 1, "still inside the window");
clock.advance(1); // exactly at the boundary: not inside it
dispatch(stub.state, chatterboxError());
assert.equal(stub.state.modern.length, 2, "past the window it toasts again");
// The key names the node, so the same message on a different node is a
// different fault and is never suppressed.
dispatch(stub.state, chatterboxError({ node_type: "ChatterboxVC" }));
assert.equal(stub.state.modern.length, 3, "a different node is not a duplicate");
} finally {
clock.restore();
}
});
test("5. without toast.error the deprecated toast.add is used", async (t) => {
const staged = stage({ toast: "legacy" });
t.after(() => discard(staged));
const { stub } = await load(staged);
dispatch(stub.state, chatterboxError());
assert.equal(stub.state.modern.length, 0, "the modern branch must not fire");
assert.equal(stub.state.legacy.length, 1);
const call = stub.state.legacy[0];
assert.equal(call.severity, "error");
assert.equal(call.summary, FLASH_MESSAGE);
assert.ok(call.detail.includes("ChatterboxTTS"), call.detail);
assert.ok(call.detail.includes("ValueError"), call.detail);
assert.equal(call.life, TOAST_DURATION);
assert.equal(call.closable, true);
});
test("6. without a toast manager it logs to the console and never throws", async (t) => {
const staged = stage({ toast: "none" });
t.after(() => discard(staged));
const { stub } = await load(staged);
const logged = [];
const realError = console.error;
console.error = (...args) => logged.push(args);
try {
dispatch(stub.state, chatterboxError());
// Not our node: filtered out before any delivery, so no log either.
dispatch(stub.state, chatterboxError({ node_type: "SomeOtherPackNode" }));
// A malformed event must not throw either.
dispatch(stub.state, null);
} finally {
console.error = realError;
}
assert.equal(logged.length, 1, "only the Chatterbox failure logs");
assert.match(String(logged[0][0]), /\[Chatterbox TTS\] Node error:/);
assert.ok(String(logged[0][1]).includes(FLASH_MESSAGE));
assert.ok(String(logged[0][2]).includes("ChatterboxTTS"));
});
test("7. the extension registers under its own name", async (t) => {
const staged = stage({ toast: "modern" });
t.after(() => discard(staged));
const { stub } = await load(staged);
assert.deepEqual(stub.state.registered, [{ name: EXTENSION_NAME }]);
});
test("8. both named messages pass through verbatim as the toast title", async (t) => {
const staged = stage({ toast: "modern" });
t.after(() => discard(staged));
const { stub } = await load(staged);
dispatch(stub.state, chatterboxError());
dispatch(
stub.state,
chatterboxError({
node_type: "ChatterboxVC",
exception_message: VC_MESSAGE,
})
);
assert.deepEqual(
stub.state.modern.map((entry) => entry.title),
[FLASH_MESSAGE, VC_MESSAGE]
);
});
test("9. naming the extension in Comfy.Extension.Disabled skips the listener", async (t) => {
const staged = stage({
toast: "modern",
disabled: true,
disabledEntries: [EXTENSION_NAME],
});
t.after(() => discard(staged));
const { stub } = await load(staged);
// The setting was really read -- the skip is the opt-out, not a shortcut.
assert.deepEqual(stub.state.settingReads, [DISABLED_SETTING]);
assert.equal(stub.state.listeners.length, 0, "a disabled extension registers nothing");
assert.deepEqual(
stub.state.registered,
[{ name: EXTENSION_NAME }],
"the name still registers, so it appears in the core disabled list"
);
});
+106
View File
@@ -0,0 +1,106 @@
/**
* ComfyUI-Chatterbox frontend extension: node-error toasts.
*
* Served from the package's WEB_DIRECTORY at /extensions/ComfyUI-Chatterbox/
* and dynamic-imported at boot by the core extension loader. Plain ESM: no
* build step, no dependencies.
*/
import { app } from "../../scripts/app.js";
/** Node ids whose execution failures deserve a toast. Kept in sync with the
* Python schema by tests/test_frontend_node_set.py. */
const CHATTERBOX_NODE_TYPES = new Set([
"ChatterboxTTS",
"ChatterboxMTLTTS",
"ChatterboxSRT",
"ChatterboxDialog",
"ChatterboxVC",
]);
const EXTENSION_NAME = "Chatterbox.ErrorToasts";
/** Toast lifetime and dedupe window, both in milliseconds. */
const TOAST_DURATION = 10000;
const DEDUPE_WINDOW = 3000;
/**
* Opt-out via the core `Comfy.Extension.Disabled` setting.
*
* That setting gates extension *callbacks* (the store invokes only enabled
* extensions); it does not gate side effects performed at module load. So the
* list is consulted here, at load. A missing store, a missing key or a throwing
* read fails open: toasts stay on. Silencing every node error with the same
* broken read that skipped the registration would be the worse failure.
*/
function isDisabledByName() {
try {
const setting = app.extensionManager && app.extensionManager.setting;
if (!setting || typeof setting.get !== "function") return false;
const disabled = setting.get("Comfy.Extension.Disabled");
return Array.isArray(disabled) && disabled.includes(EXTENSION_NAME);
} catch (error) {
return false;
}
}
let shownKey = null;
let shownAt = 0;
/**
* Show one toast for one execution_error event, honouring the dedupe window.
* Never throws: a broken toast must not take core error rendering with it.
*/
function showErrorToast(detail) {
const summary = detail.exception_message || "Chatterbox node failed.";
const nodeType = detail.node_type || "unknown node";
const description =
"Node: " + nodeType +
(detail.exception_type ? " (" + detail.exception_type + ")" : "");
const now = Date.now();
const key = nodeType + "\n" + summary;
if (key === shownKey && now - shownAt < DEDUPE_WINDOW) {
return; // the same fault is already on screen
}
shownKey = key;
shownAt = now;
const toast = app.extensionManager?.toast;
if (toast && typeof toast.error === "function") {
// Current API: error(title, options).
toast.error(summary, {
description: description,
duration: TOAST_DURATION,
closable: true,
});
} else if (toast && typeof toast.add === "function") {
// Deprecated API, still present in older frontends.
toast.add({
severity: "error",
summary: summary,
detail: description,
life: TOAST_DURATION,
closable: true,
});
} else {
console.error("[Chatterbox TTS] Node error:", summary, description);
}
}
if (!isDisabledByName()) {
// Registered at module load, never in setup(): a lazily loaded extension
// may never get setup() called, and a listener registered late silently
// misses every event that fired before it.
app.api.addEventListener("execution_error", (event) => {
try {
const detail = event && event.detail;
if (!detail) return;
if (!CHATTERBOX_NODE_TYPES.has(detail.node_type)) return; // not ours
showErrorToast(detail);
} catch (error) {
console.error("[Chatterbox TTS] Node-error toast failed:", error);
}
});
}
app.registerExtension({ name: EXTENSION_NAME });
+3 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI-ChatterboxTTS"
description = "Chatterbox TTS and voice conversion nodes for ComfyUI"
version = "2.0.0"
description = "Super fast TTS and voice clonning tool"
version = "2.0.3"
license = {file = "LICENSE"}
requires-python = ">=3.10"
dependencies = [
@@ -37,6 +37,6 @@ Repository = "https://github.com/wildminder/ComfyUI-Chatterbox"
[tool.comfy]
PublisherId = "wildai"
DisplayName = "Chatterbox TTS Revised"
DisplayName = "WM Chatterbox TTS Revised"
Icon = ""
requires-comfyui = ">=0.37.0"
+93
View File
@@ -0,0 +1,93 @@
"""The frontend node filter has to name exactly the Python schema's nodes.
``js/extension.js`` listens for ``execution_error`` and toasts only the events
whose ``node_type`` it recognises. That list is hand-maintained, so it can drift:
a node renamed in ``nodes/`` leaves every Python test green -- the ids are pinned
there, against the frozen snapshot -- and the browser simply stops toasting for
that node. Nothing reports it. The graph keeps running, the node keeps failing,
and the operator sees nothing.
So the ids are parsed back out of the file itself and compared against the live
schema: the same registry ComfyUI reads the node list from. A match here is a
fact, not a comment promising the two sides agree.
"""
from __future__ import annotations
import asyncio
import os
import re
import pytest
from conftest import ROOT_DIR
from ComfyUI_Chatterbox.chatterbox_nodes import comfy_entrypoint
EXTENSION_JS = os.path.join(ROOT_DIR, "js", "extension.js")
#: The opening expression of the filter's declaration. Anchoring on the whole
#: thing, not on the bare const name, is what keeps the slice off the comment
#: above it -- that comment names this very test, and a name-only anchor would
#: slice starting at the wrong place.
SET_ANCHOR = 'CHATTERBOX_NODE_TYPES = new Set(['
def frontend_node_types() -> list[str]:
"""Every node id the frontend filters on, in the order the JS lists them.
The span is sliced between the anchor and its ``])`` rather than matched
across the whole file: the extension names node ids in prose comments too,
and a file-wide pattern would quietly compare the documentation against
the schema instead of the code.
"""
with open(EXTENSION_JS, "r", encoding="utf-8") as handle:
source = handle.read()
if SET_ANCHOR not in source:
raise AssertionError(
f"js/extension.js no longer declares {SET_ANCHOR!r}; the node "
"filter this module compares against cannot be found"
)
start = source.index(SET_ANCHOR) + len(SET_ANCHOR)
span = source[start : source.index("])", start)]
return re.findall(r'"([^"]*)"', span)
@pytest.fixture(scope="module")
def schema_node_ids() -> set[str]:
"""The ids the live Python schema exposes, straight from the registry."""
extension = asyncio.run(comfy_entrypoint())
return {
node_cls.GET_SCHEMA().node_id
for node_cls in asyncio.run(extension.get_node_list())
}
class TestFrontendNodeSet:
def test_the_js_list_is_found_and_not_empty(self) -> None:
"""A renamed anchor must fail loudly, not compare two empty sets."""
frontend = frontend_node_types()
assert frontend, (
"no node ids were parsed out of js/extension.js, so the comparison "
"below could not tell a drift from an unreadable file"
)
def test_the_js_list_matches_the_live_schema(
self, schema_node_ids: set[str]
) -> None:
"""Both directions: an id dropped from the JS and one invented in it.
The direction that matters most is the second. A JS id the schema does
not define toasts for a node type no graph can ever produce, which is
the sound of a node having been renamed on the Python side and the
filter left behind -- and the first direction, the silent one this
guard exists for.
"""
frontend = set(frontend_node_types())
assert frontend == schema_node_ids
assert schema_node_ids, (
"the schema exposes no nodes at all; both sides of the comparison "
"would be empty and nothing would be guarded"
)
+116 -8
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import ast
import importlib.util
import logging
import os
import sys
from types import SimpleNamespace
@@ -101,16 +102,35 @@ class TestExports:
assert "NODE_CLASS_MAPPINGS" not in referenced
assert "NODE_DISPLAY_NAME_MAPPINGS" not in referenced
def test_no_web_directory(self, tree: ast.Module) -> None:
"""The package ships no frontend assets.
def test_web_directory_is_declared_and_shipped(self, tree: ast.Module) -> None:
"""The web directory the host is told to serve is there and holds the JS.
VibeVoice deliberately declares none. Pointing ComfyUI at a directory
that does not exist produces a startup warning on every boot and, worse,
makes "we have a web extension" a claim nothing backs up.
The guard this replaces was its mirror image: the package deliberately
declared no ``WEB_DIRECTORY``, because pointing the host at a directory
that does not exist warns on every boot and makes "we ship a frontend"
a claim nothing backs up. Both halves still have to hold -- declared
**and** present -- so the check stays, inverted. Resolving the way the
host does, against this module's own directory rather than the process
working directory, is what makes the ``isdir`` half a real assertion
instead of a guess about where the server was started.
The attribute belongs to the module, not to the extension object;
``tests/test_extension.py`` is what keeps the object clean of it.
"""
referenced = _referenced_names(tree)
assert "WEB_DIRECTORY" not in referenced
assert "web_directory" not in referenced
assigned = _assigned_names(tree)
assert "WEB_DIRECTORY" in assigned, (
"the entry point declares no web directory, so js/extension.js is "
"never served and no node error is ever toasted"
)
web_dir = ast.literal_eval(assigned["WEB_DIRECTORY"])
assert web_dir == "./js"
resolved = os.path.abspath(os.path.join(os.path.dirname(INIT_PATH), web_dir))
assert os.path.isdir(resolved), f"{web_dir} does not resolve to a directory: {resolved}"
assert os.path.isfile(os.path.join(resolved, "extension.js")), (
"the served directory has no extension.js, so the browser imports "
"nothing and the toast listener never registers"
)
class TestPytestGuard:
@@ -206,6 +226,94 @@ class TestBootstrapRunsForReal:
assert "sys.path.insert" not in source
class TestStartupIsQuiet:
"""The bootstrap says nothing at INFO or above.
The pack used to log ``[Chatterbox TTS] Registering the Chatterbox custom
node.`` on every boot. Other extensions chat just as loudly, so the host's
own startup output was buried under a wall of "look at me" -- and the note
told the operator nothing: a silent import succeeded exactly as well as a
chatty one. What survives is one ``debug`` pack-count line, invisible at the
default level.
The re-exec is the same one :class:`TestBootstrapRunsForReal` performs:
pytest out of ``sys.modules`` so the guard opens, fake ``folder_paths`` /
``server`` so nothing real is registered. ``caplog`` is attached to the root
logger first, so the records this fixture collects are exactly the ones the
bootstrap emitted while being imported -- and they are captured explicitly,
because ``caplog`` resets its handler when the *call* phase begins, after
this fixture has already run.
"""
@pytest.fixture
def loaded(self, monkeypatch, caplog):
fake_folder_paths = SimpleNamespace(
models_dir=os.path.join(ROOT_DIR, ".test-models"),
folder_names_and_paths={},
supported_pt_extensions={".safetensors"},
get_folder_paths=lambda name: [],
add_model_folder_path=lambda *a, **k: None,
get_user_directory=lambda: os.path.join(ROOT_DIR, ".test-user"),
)
fake_server = SimpleNamespace(
PromptServer=SimpleNamespace(instance=SimpleNamespace(routes=MagicMock()))
)
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths)
monkeypatch.setitem(sys.modules, "server", fake_server)
# The guard keys off pytest being importable; hide it for this exec only.
monkeypatch.delitem(sys.modules, "pytest")
# Before the exec, so the caller can recount what the bootstrap emitted.
caplog.set_level(logging.INFO)
spec = importlib.util.spec_from_file_location(
f"{PKG_ALIAS}._entrypoint_probe_quiet",
INIT_PATH,
submodule_search_locations=[ROOT_DIR],
)
module = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, spec.name, module)
path_before = list(sys.path)
try:
spec.loader.exec_module(module)
captured = list(caplog.get_records("setup"))
yield module, fake_folder_paths, fake_server, captured
finally:
for entry in list(sys.path):
if entry not in path_before:
sys.path.remove(entry)
def test_importing_the_entry_point_emits_no_info_or_above(self, loaded) -> None:
"""A startup note is a regression; this is where it comes back to fail.
The silence has to be pinned by an assertion that can fail. The removed
``logging.info`` call was one line in this module, and nothing else in
the suite looks at what the bootstrap says on the console.
"""
_module, _folder_paths, _server, captured = loaded
offenders = [
f"{record.name}:{record.levelname}: {record.getMessage()}"
for record in captured
if record.levelno >= logging.INFO
]
assert offenders == [], (
"the bootstrap still speaks at INFO or above; an install-time note "
"is exactly what the quiet start removed:\n " + "\n ".join(offenders)
)
def test_the_bootstrap_still_does_its_job(self, loaded) -> None:
"""Silence is not the goal; a silent, working import is.
The quiet fixture replays the real bootstrap, so the registration it
performs has to be as real as in :class:`TestBootstrapRunsForReal` --
otherwise the silence above could be the sound of a bootstrap that broke
early and logged nothing because it did nothing.
"""
_module, folder_paths_module, _server, _captured = loaded
registered = folder_paths_module.folder_names_and_paths
assert "tts" in registered
assert "chatterbox" in registered
class TestAbsentServerDegrades:
"""A process with no ``PromptServer`` still has to import cleanly."""