Compare commits

..
Author SHA1 Message Date
Gerald LonlasandClaude Opus 4.8 d9d5c77e0f test(contracts): add image-encoding and node status behavioral contracts
Close gaps that full line+branch coverage does not catch:

- comfy_image_to_base64_png_url: assert RGBA (4-channel) tensor round-trips
  correctly through PNG encoding (pixel values preserved).
- comfy_image_to_base64_png_url: assert non-square image dimensions (H x W)
  are preserved in the encoded PNG — catches H/W transposition mutations.
- comfy_image_to_base64_png_url: assert batch[0] (red) is encoded, not
  batch[1] (blue) — catches wrong-frame-index mutations.
- image_to_text_node._responses_kwargs: assert every image content entry uses
  the key 'image_url' (not 'url') and contains a valid data:image/png;base64,
  URL — for both single and batch paths.
- image_to_text_node._responses_kwargs: assert batch frames produce distinct
  data URLs — catches mutations that pass the same index for all frames.
- image_to_text_node.execute: assert the status message contains the actual
  model name for both the responses and chat.completions fallback paths.
- text_gen_node.execute: assert the status message contains the actual model
  name for both paths — a hardcoded name mutation goes undetected otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:26:41 +08:00
Gérald LONLASandClaude Opus 4.8 1ccce73904 test(contracts): add behavioral-contract tests that catch value-mutant regressions (#15)
25 new tests across client.py, routes.py, connect_node.py, and text_gen_node.py.
Each test targets a specific behavioral contract that 100% line+branch coverage
did not verify and that a realistic mutation would silently break:

- normalize_server_url: whitespace-only input, trailing-slash stripping,
  https acceptance
- coerce_seed: whitespace-only string returns None (not ValueError)
- resolve_request_seed: seed=0 passes through; large values not clipped
- build_responses_input_text: 'type' must be 'input_text' (not 'text')
- build_chat_messages: whitespace system prompt omitted; empty user included
- list_models: result is sorted AND deduplicated (both invariants checked)
- strip_think_content: surrounding text order preserved; block-only → ""
- _query_int: negative integers rejected; empty string uses default
- _models_handler: empty models → default_model="" not IndexError;
  error prefix is 'Failed to list models' not 'Connection test failed'
- _test_handler: model_count matches actual list length; distinct error prefix
- connect_node execute: empty model with test_connectivity=False still probes;
  no-probe status omits 'Found N model(s)'; probe status includes exact count
- _responses_kwargs: 'input' key present with user-role and correct prompt text;
  temperature/max_tokens forwarded from payload
- text_gen_node execute: fallback path status includes resolved seed;
  user_prompt=None raises clean ValueError

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:22:32 +08:00
Gérald LONLASandClaude Opus 4.8 4fc78624c4 fix(nodes): raise error when response is entirely think-block content (#16)
When a reasoning model returns a response consisting solely of
<think>...</think> content, the emptiness guard fired before
strip_think_content ran, so the node silently returned an empty
string rather than signalling an error. Add a post-strip check
in both LMStudioTextGen and LMStudioImageToText that raises
ValueError if strip_think_content reduces the text to nothing.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:21:55 +08:00
5 changed files with 454 additions and 0 deletions
+4
View File
@@ -187,6 +187,10 @@ class LMStudioImageToText(io.ComfyNode):
)
text = strip_think_content(text)
if not text:
raise ValueError(
"All content was inside <think> blocks; no visible text was produced."
)
status = f"Model '{connection.model}' via {via_endpoint} (seed={resolved_seed})."
if fallback_reason:
+376
View File
@@ -0,0 +1,376 @@
"""
Behavioral-contract tests for image encoding and node status messages.
Each test targets a specific mutation that existing coverage does not catch:
1. comfy_image_to_base64_png_url — image_url field key in _responses_kwargs content.
2. comfy_image_to_base64_png_url — RGBA (4-channel) tensors round-trip correctly.
3. comfy_image_to_base64_png_url — non-1x1 image preserves correct dimensions.
4. image_to_text_node.execute — status message includes the model name.
5. image_to_text_node._responses_kwargs — every image content entry has valid data URL.
6. text_gen_node.execute — status message includes the model name.
"""
from __future__ import annotations
import base64
from io import BytesIO
from types import SimpleNamespace
import numpy as np
import pytest
from PIL import Image
import client
from helpers.fakes import FakeTensor
from helpers.imports import import_repo_module
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _decode_data_url_png(data_url: str) -> Image.Image:
assert data_url.startswith("data:image/png;base64,"), (
f"Expected data:image/png;base64, prefix, got: {data_url[:40]!r}"
)
encoded = data_url.split(",", maxsplit=1)[1]
raw = base64.b64decode(encoded)
return Image.open(BytesIO(raw))
def _image_module():
return import_repo_module("image_to_text_node", force_reload=True)
def _text_module():
return import_repo_module("text_gen_node", force_reload=True)
def _connection_payload(model: str = "vision-model"):
models = import_repo_module("models", force_reload=True)
return models.LMStudioConnectionPayload(
server_url="http://127.0.0.1:1234",
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model=model,
reasoning_enabled=False,
max_tokens=128,
temperature=0.3,
timeout_seconds=30,
use_tooling_mcp=False,
)
def _single_image() -> FakeTensor:
return FakeTensor(np.array([[[1.0, 0.0, 0.0]]], dtype=np.float32))
def _batch_image() -> FakeTensor:
return FakeTensor(
np.array(
[
[[[1.0, 0.0, 0.0]]], # red
[[[0.0, 0.0, 1.0]]], # blue
],
dtype=np.float32,
)
)
# ---------------------------------------------------------------------------
# client.comfy_image_to_base64_png_url — RGBA (4-channel) round-trip
# ---------------------------------------------------------------------------
def test_comfy_image_to_base64_png_url_rgba_round_trip() -> None:
"""
A 4-channel (RGBA) float32 tensor must survive the encode-decode round-trip.
The encoded PNG must be decodable and preserve the RGB channels.
A mutation that accidentally dropped channels (e.g. slicing to [:,:,:3]) would
change the pixel values and break this assertion.
"""
# 1x1 RGBA image: R=1.0, G=0.5, B=0.0, A=0.8
rgba = FakeTensor(np.array([[[1.0, 0.5, 0.0, 0.8]]], dtype=np.float32))
url = client.comfy_image_to_base64_png_url(rgba)
img = _decode_data_url_png(url)
# PIL will create an RGBA image for 4-channel input.
r, g, b, a = img.getpixel((0, 0))
assert r == 255, f"Red channel: expected 255, got {r}"
assert g == 127 or g == 128, f"Green channel: expected ~127, got {g}" # float rounding
assert b == 0, f"Blue channel: expected 0, got {b}"
assert a == 204 or a == 203, f"Alpha channel: expected ~204, got {a}" # 0.8*255
def test_comfy_image_to_base64_png_url_correct_dimensions() -> None:
"""
The encoded PNG must preserve the exact width and height of the input tensor.
A mutation that transposed H and W (e.g. using .T) would swap width/height
and break this assertion.
"""
# 3 rows, 5 columns, 3 channels
h, w = 3, 5
arr = np.zeros((h, w, 3), dtype=np.float32)
tensor = FakeTensor(arr)
url = client.comfy_image_to_base64_png_url(tensor)
img = _decode_data_url_png(url)
assert img.size == (w, h), f"Expected ({w}, {h}), got {img.size}"
def test_comfy_image_to_base64_png_url_batch_uses_first_frame_exactly() -> None:
"""
For a batch tensor (ndim==4), only frame 0 must be encoded.
Frame 1 (blue) must NOT appear in the output.
A mutation that encoded frame 1 instead of frame 0 would change the pixel
from red to blue.
"""
batch = FakeTensor(
np.array(
[
[[[1.0, 0.0, 0.0]]], # frame 0: red
[[[0.0, 0.0, 1.0]]], # frame 1: blue
],
dtype=np.float32,
)
)
url = client.comfy_image_to_base64_png_url(batch)
img = _decode_data_url_png(url)
r, g, b = img.getpixel((0, 0))[:3]
assert r == 255 and g == 0 and b == 0, (
f"Expected red (255,0,0) from frame 0, got ({r},{g},{b})"
)
# ---------------------------------------------------------------------------
# image_to_text_node._responses_kwargs — image_url field key correctness
# ---------------------------------------------------------------------------
def test_responses_kwargs_image_content_field_key_is_image_url() -> None:
"""
Each image entry in the content list must use the key 'image_url', not 'url'
or any other variant. A mutation renaming the field would break LMStudio's
Responses API contract silently.
This test covers both the single (ndim==3) and batch (ndim==4) paths.
"""
image_node = _image_module()
# Single image (3D tensor from __getitem__ on batch)
single = FakeTensor(np.array([[[0.0, 1.0, 0.0]]], dtype=np.float32))
kwargs_single = image_node.LMStudioImageToText._responses_kwargs(
connection=_connection_payload(),
image=single,
system_prompt="sys",
user_prompt="describe",
seed=1,
)
single_content = kwargs_single["input"][0]["content"]
image_entry = single_content[1]
assert image_entry["type"] == "input_image"
assert "image_url" in image_entry, (
f"Expected 'image_url' key, found keys: {list(image_entry.keys())}"
)
assert image_entry["image_url"].startswith("data:image/png;base64,")
# Batch (4D tensor) — every image entry must have valid image_url
batch = _batch_image()
kwargs_batch = image_node.LMStudioImageToText._responses_kwargs(
connection=_connection_payload(),
image=batch,
system_prompt="sys",
user_prompt="describe",
seed=2,
)
batch_content = kwargs_batch["input"][0]["content"]
image_entries = [e for e in batch_content if e.get("type") == "input_image"]
assert len(image_entries) == 2, f"Expected 2 image entries, got {len(image_entries)}"
for i, entry in enumerate(image_entries):
assert "image_url" in entry, (
f"Image entry {i} missing 'image_url' key; keys: {list(entry.keys())}"
)
assert entry["image_url"].startswith("data:image/png;base64,"), (
f"Image entry {i} 'image_url' lacks data URL prefix: {entry['image_url'][:40]!r}"
)
def test_responses_kwargs_batch_images_are_distinct_data_urls() -> None:
"""
In a multi-frame batch, the data URLs for different frames must differ.
A mutation that passed the same frame index for all images would produce
identical URLs and break this assertion.
"""
image_node = _image_module()
batch = FakeTensor(
np.array(
[
[[[1.0, 0.0, 0.0]]], # red
[[[0.0, 0.0, 1.0]]], # blue
],
dtype=np.float32,
)
)
kwargs = image_node.LMStudioImageToText._responses_kwargs(
connection=_connection_payload(),
image=batch,
system_prompt="",
user_prompt="describe",
seed=3,
)
content = kwargs["input"][0]["content"]
image_urls = [e["image_url"] for e in content if e.get("type") == "input_image"]
assert len(image_urls) == 2
assert image_urls[0] != image_urls[1], (
"Both batch frames produced the same data URL — frame indexing is broken"
)
# ---------------------------------------------------------------------------
# image_to_text_node.execute — status message includes the model name
# ---------------------------------------------------------------------------
def test_execute_status_includes_model_name_responses_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The status message must embed the actual model name from the connection payload.
A mutation hardcoding a model name (e.g. always "vision-model") would pass
tests that don't check the name, but fail here when a different model is used.
"""
image_node = _image_module()
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: SimpleNamespace(output_text="some result")
),
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(AssertionError("no fallback"))
)
),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 11)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
output = image_node.LMStudioImageToText.execute(
connection=_connection_payload(model="my-custom-vision-model"),
image=_single_image(),
system_prompt="",
user_prompt="describe this",
seed=5,
)
assert "my-custom-vision-model" in output.ui.text, (
f"Model name not found in status: {output.ui.text!r}"
)
def test_execute_status_includes_model_name_fallback_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The model name must also appear in the status when chat.completions fallback is used.
"""
image_node = _image_module()
completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="fallback result"))]
)
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(RuntimeError("responses off"))
),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: completion)),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 22)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
output = image_node.LMStudioImageToText.execute(
connection=_connection_payload(model="fallback-model-xyz"),
image=_single_image(),
system_prompt="",
user_prompt="what is this",
seed=0,
)
assert "fallback-model-xyz" in output.ui.text, (
f"Model name not found in fallback status: {output.ui.text!r}"
)
# ---------------------------------------------------------------------------
# text_gen_node.execute — status message includes the model name
# ---------------------------------------------------------------------------
def _text_connection_payload(model: str = "text-model"):
models = import_repo_module("models", force_reload=True)
return models.LMStudioConnectionPayload(
server_url="http://127.0.0.1:1234",
base_url="http://127.0.0.1:1234/v1",
api_key="token",
model=model,
reasoning_enabled=False,
max_tokens=256,
temperature=0.7,
timeout_seconds=30,
use_tooling_mcp=False,
)
def test_text_gen_execute_status_includes_model_name_responses_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The status message in LMStudioTextGen.execute must include the actual model name.
A mutation hardcoding the model name in the status template would break this.
"""
text_gen_node = _text_module()
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: SimpleNamespace(output_text="text output")
),
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(AssertionError("no fallback"))
)
),
)
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 33)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
output = text_gen_node.LMStudioTextGen.execute(
connection=_text_connection_payload(model="unique-text-model-abc"),
system_prompt="",
user_prompt="generate something",
seed=5,
)
assert "unique-text-model-abc" in output.ui.text, (
f"Model name not found in text_gen status: {output.ui.text!r}"
)
def test_text_gen_execute_status_includes_model_name_fallback_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The model name must appear in the status even when chat.completions fallback is used.
"""
text_gen_node = _text_module()
completion = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="text fallback answer"))]
)
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(RuntimeError("no responses"))
),
chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: completion)),
)
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 44)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
output = text_gen_node.LMStudioTextGen.execute(
connection=_text_connection_payload(model="special-fallback-model"),
system_prompt="",
user_prompt="ask something",
seed=0,
)
assert "special-fallback-model" in output.ui.text, (
f"Model name not found in fallback status: {output.ui.text!r}"
)
+35
View File
@@ -158,3 +158,38 @@ def test_execute_chat_fallback_reports_dropped_frames(
assert "via chat.completions" in output.ui.text
assert "responses unavailable" in output.ui.text
assert "additional frame(s) were dropped" in output.ui.text
def test_execute_raises_when_all_text_is_think_blocks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Regression: when the model returns only <think> content (no visible text),
strip_think_content reduces the response to "". The node must raise
ValueError rather than silently returning an empty string.
"""
image_node = _image_module()
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: SimpleNamespace(output_text="<think>internal only</think>")
),
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(
AssertionError("chat fallback should not run")
)
)
),
)
monkeypatch.setattr(image_node, "resolve_request_seed", lambda _: 1)
monkeypatch.setattr(image_node, "create_openai_client", lambda **_: fake_client)
with pytest.raises(ValueError, match="All content was inside <think> blocks"):
image_node.LMStudioImageToText.execute(
connection=_connection_payload(),
image=_single_image(),
system_prompt="sys",
user_prompt="describe",
seed=1,
)
+35
View File
@@ -168,3 +168,38 @@ def test_execute_raises_if_both_endpoints_return_no_text(
user_prompt="hello",
seed=1,
)
def test_execute_raises_when_all_text_is_think_blocks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Regression: when the model returns only <think> content (no visible text),
strip_think_content reduces the response to "". The node must raise
ValueError rather than silently returning an empty string.
"""
text_gen_node = _text_module()
fake_client = SimpleNamespace(
responses=SimpleNamespace(
create=lambda **_: SimpleNamespace(output_text="<think>internal only</think>")
),
chat=SimpleNamespace(
completions=SimpleNamespace(
create=lambda **_: (_ for _ in ()).throw(
AssertionError("chat fallback should not run")
)
)
),
)
monkeypatch.setattr(text_gen_node, "resolve_request_seed", lambda _: 1)
monkeypatch.setattr(text_gen_node, "create_openai_client", lambda **_: fake_client)
with pytest.raises(ValueError, match="All content was inside <think> blocks"):
text_gen_node.LMStudioTextGen.execute(
connection=_connection_payload(),
system_prompt="sys",
user_prompt="hello",
seed=1,
)
+4
View File
@@ -149,6 +149,10 @@ class LMStudioTextGen(io.ComfyNode):
raise ValueError("chat.completions fallback returned no text output")
text = strip_think_content(text)
if not text:
raise ValueError(
"All content was inside <think> blocks; no visible text was produced."
)
status = f"Model '{connection.model}' via {via_endpoint} (seed={resolved_seed})."
if fallback_reason: