mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-09-29 09:28:35 -05:00
* chore(assets): drop the unused asset_meta table from migration 0007 * fix(assets): drop asset_meta when downgrading a database that already created it * docs(assets): clarify asset schema docstring * test(assets): assert alembic and ORM index parity for the surviving asset tables * test(assets): cover asset system state index parity * refactor(tests): hoist migration-0007 test imports to module scope * fix(assets): guard the hashing dependency and chain the real import error * fix(assets): always resume background scanning when prompt handling fails * fix(api): derive the assets feature flag from the selected manager * fix(assets): paginate enrichment by id cursor so failures cannot starve or overflow the query * refactor(assets): extract prompt_worker so its resume contract is testable in-process * refactor(assets): test the blake3 import guard in-process instead of via subprocess * fix(assets): advance the enrichment cursor only past rows the batch attempted * fix(assets): track the scan pause across prompt worker iterations * chore(assets): address review follow-ups in the hashing guard, feature flags, and pagination pin * docs(assets): describe enrichment rows as attempted rather than selected The cursor holds at the last row a batch actually attempted, so a pause ends a batch early and the rows behind it are selected again when the scan resumes. Only the attempt is capped at once per pass. * test(api): derive the expected assets flag from the manager under test The assertion asked for the flag with no argument, so it read the parameter default rather than anything the manager reported - in a test whose subject is the two agreeing. Passing the manager's own state keeps it honest if the setup ever yields an enabled manager. * test(assets): let a broken prompt worker import fail instead of skipping The fixture wrapped importlib.import_module in a bare except that called pytest.skip, so a circular import, a missing dependency or a syntax error in app/prompt_worker.py would retire all four resume-contract tests while CI stayed green. The whole premise of extracting the module is that main.py can import it, so an import failure has to be a collection error. The CPU guard is genuinely load-bearing — comfy.model_management selects its device at import time and a CUDA build with no driver raises there — so it is kept, but as a precondition rather than an exception handler, matching the args.cpu-before-import convention already used by the comfy_test and comfy_api_nodes_test modules. Nothing is caught now. * docs(assets): name the unattempted rows instead of the ones behind the cursor The cursor moves forward through ascending ids, so 'the rows behind it' reads as the rows already passed - the opposite of what is selected again. * fix(assets): only absorb duplicate-path races when seeding scanned assets * fix(db): copy the legacy database inside the process lock * fix(assets): drop watch-list entries on stat errors instead of aborting the scan * fix(assets): clean up temp uploads on validation failures Multipart parsing writes the uploaded bytes to a temporary file before it validates the remaining form fields, so a request rejected after its file part had already been read left the temp file and its uuid directory on disk. Routing those removals through delete_temp_file_if_exists also changes the success path. The previous helper returned early when the temp file was already gone, so it never reached the parent rmdir; the shared helper attempts the rmdir unconditionally. Moving the upload to its destination leaves the temp path absent, so a successful upload now also discards its empty uuid directory, closing a pre-existing leak. * fix(assets): keep updated_at stable on no-op renames * docs(assets): make module docstrings and the rebuild warning truthful * fix(assets): correct event-log status snapshots and failure telemetry * test(assets): make the keyset tie-breaker and temp-exclusion tests falsifiable * chore: comment cleanup Comment-Gate: 3 quarantined * fix(assets): keep unreadable filesystem metadata from failing a whole scan batch * fix(assets): remove temporary uploads on non-UploadError failures * fix(assets): preserve successful specs when a scan batch fault propagates * test(db): drop the inert legacy-copy patch from the path preparation tests prepare_file_db_path no longer copies the legacy database - that moved inside the process lock in _init_file_db - so patching copy_legacy_default_db here did nothing. Leaving it implied a side effect the function does not have, and would have masked one if it were reintroduced. * fix(assets): report specs committed before a batch fault and preserve the fault itself * fix(assets): distinguish partial batch insert failures * refactor(assets): collapse the duplicated batch fault deferral into seed_asset_specs * fix(assets): reject duplicate file parts instead of stranding the first upload * refactor(assets): reap empty upload directories without importing the API layer * fix(assets): emit the invalid-mtime event once per scan Every other per-file emit on this scan path is gated -- mark_emitted( "stat_failed:enrich"), "hash_discarded_modified", "hash_failed", "enrich_failed" -- but scanner.invalid_mtime fired per file, so a restored archive or a FAT volume of pre-epoch mtimes put one structured event per file into the stream the closed vocabulary exists to keep parseable. Counted and emitted once, carrying the count. seed_asset_specs receives no _ScanProgress object and neither does insert_asset_specs above it, so routing this through mark_emitted would mean changing both signatures plus the seeder call site; the count form needs neither and the event is now strictly more informative than N identical fieldless lines. The per-file logging.warning is unchanged, and the emit stays inside seed_asset_specs so the static call-site manifest still matches. test_seed_skips_negative_fresh_mtime_with_warning_and_telemetry now pins the full list of invalid_mtime lines to exactly ["... count=1"] instead of asserting one such line exists -- a strictly stronger assertion, and the only change the new field required. * fix(assets): keep spec construction failures from wedging the watch list get_name_and_tags_from_asset_path raises ValueError by contract when a path stops resolving to a configured root, and it sat outside the guard, as did compute_loader_path and mimetypes.guess_type. An escape skipped the _WATCH_LIST[:] = remaining write at the end, so drained entries stayed on the list and were re-attempted every tick while entries past the fault never reached the increment _WATCH_SCAN_RETRIES needs to retire them. The list wedged permanently. Spec construction is now inside a guard that drops just the offending entry, and the list write moved into a finally so no future escape can skip it. The loop walks an iterator rather than the list, so the finally can put back the entries it never reached instead of discarding them. New event name rather than reusing one: scanner.watch_seed_failed is emitted only when seed_asset_specs returns an error, and widening it to also mean "never got as far as seeding" would make it lie -- a consumer treating it as a database-health signal would get false positives from what is really a path layout problem. scanner.watch_spec_failed is registered in ALLOWED_EVENTS and in the static call-site manifest. * refactor(assets): export the live-path conflict check as public API scanner.py reached past the package's own re-export surface to import _is_live_path_conflict directly out of records.py. The underscore said module-private while the import said otherwise, and records.py deliberately publishes its public names through app.assets.database.queries -- which the same import block three lines above was already using. The use is correct and unchanged; only the name and the route change. Renamed to is_live_path_conflict, listed in the package __init__ import and __all__ alongside its siblings, and scanner.py now takes it from the package like everything else it imports from there. * docs(db): restore the rationale for locking before migration Commit1dbcdcd7and the comment-cleanup pass8205022freduced this to "All database reads and writes, including the legacy import, run under the lock", dropping the part that did the work: upstream master locks after migrating and justifies it with "Alembic uses its own connection, so we must wait until it's done before locking -- otherwise our own lock blocks the migration". That is false, the lock is on a separate <db>.lock file, and the surviving sentence said nothing to stop a contributor "fixing" the ordering back. Restored and adapted rather than pasted: the legacy copy and the db_exists probe now happen inside the lock, which the original text predates, so both are named in the list of things the ordering makes mutually exclusive. * fix(assets): stop a scan on memory exhaustion instead of deferring it MemoryError is an Exception, so the per-spec and per-batch handlers stored it alongside ordinary faults and carried on - allocating for every remaining spec and then every remaining batch while the process was already out of memory. Both handlers now let it through, and the scan records a failure and stops. * docs(assets): document the prune failure response and its None result The route gained a 500 PRUNE_FAILED branch and the seeder method gained a None return, both so a prune that did not run cannot be reported as a clean one. Neither contract was written down. * test(assets): assert the surviving spec count after a propagated fault * docs(db): shorten the lock-ordering comment while keeping its rationale * docs(tests): drop the cross-module justification from the import-order comment * test(assets): restore the cpu flag after the guarded prompt worker import * test(assets): restore the cpu flag even when the prompt worker import fails * fix(assets): drop asset_meta in a new migration instead of editing 0007 0007 shipped in v0.36.0, v0.37.0 and v0.37.1, so editing it would leave two installs at that revision with different schemas depending on when they upgraded. Restore 0007 to its released form and drop the unused asset_meta table in 0008 instead. Nothing reads or writes asset_meta; asset metadata lives in the JSON columns on assets. 0008 downgrades by recreating the table and its four indexes exactly as 0007 creates them. * refactor(assets): keep prompt_worker in main.py Custom nodes may reference main.prompt_worker, and tests can already import main (test_db_init_locking does), so the function stays where it was. Its body keeps the resume-on-failure handling and the pause flag that persists across loop iterations; the tests now call main.prompt_worker. * fix(assets): report the selected manager through the existing feature flags * fix(assets): record a failed prune in the scan status A failed prune left the scan's error list empty, so the run looked clean. Record it instead and let discovery continue as before. * test(assets): test the feature flag API directly instead of copying the startup write Both parity tests wrote SERVER_FEATURE_FLAGS["assets"] themselves, so they passed whatever startup did. Pin the one real claim - a no-argument get_server_features() reports the flag - in the feature flags tests, and drop the disabled case, which default_asset_manager already covers. * Bring #16486's watch-list batching and seed logging into this branch The merge before this commit is `git merge -X ours origin/master`: in conflicting hunks it keeps this branch's side. This commit ports what #16486 changed in those hunks. - tick_watch_list takes no session. It collects settled entries and seeds them in one batch through insert_asset_specs, keeping this branch's per-entry stat and spec handling and the finally that always rewrites the watch list. A failed seed is reported once per batch, since the batch only returns its first error. - seed_asset_specs no longer warns again for a skipped spec; observe_asset_specs already logged why. - Tests call tick_watch_list() without a session, bind the write session where they seed for real, and fake insert_asset_specs with its (created, error) return. The mid-drain fault now comes from stat, because seeding runs after the loop. * Note where the assets core flag is set and why prompt_worker catches BaseException --------- Co-authored-by: guill <jacob.e.segal@gmail.com>
258 lines
11 KiB
Python
258 lines
11 KiB
Python
"""Tests for feature flags functionality."""
|
|
|
|
import pytest
|
|
|
|
from comfy_api.feature_flags import (
|
|
get_connection_feature,
|
|
supports_feature,
|
|
get_server_features,
|
|
CLI_FEATURE_FLAG_REGISTRY,
|
|
SERVER_FEATURE_FLAGS,
|
|
_coerce_flag_value,
|
|
_parse_cli_feature_flags,
|
|
)
|
|
from comfy.comfy_api_env import (
|
|
environment_overrides_for_base,
|
|
get_environment_overrides,
|
|
normalize_comfy_api_base,
|
|
)
|
|
|
|
|
|
class TestFeatureFlags:
|
|
"""Test suite for feature flags functions."""
|
|
|
|
def test_get_server_features_returns_copy(self):
|
|
"""Test that get_server_features returns a copy of the server flags."""
|
|
features = get_server_features()
|
|
# Verify it's a copy by modifying it
|
|
features["test_flag"] = True
|
|
# Original should be unchanged
|
|
assert "test_flag" not in SERVER_FEATURE_FLAGS
|
|
|
|
def test_get_server_features_contains_expected_flags(self):
|
|
"""Test that server features contain expected flags."""
|
|
features = get_server_features()
|
|
assert "supports_preview_metadata" in features
|
|
assert features["supports_preview_metadata"] is True
|
|
assert "supports_model_type_tags" in features
|
|
assert features["supports_model_type_tags"] is True
|
|
assert "max_upload_size" in features
|
|
assert isinstance(features["max_upload_size"], (int, float))
|
|
|
|
def test_get_server_features_reports_the_assets_flag_without_an_argument(self, monkeypatch):
|
|
"""A no-argument call must report the current assets flag, not a default."""
|
|
monkeypatch.setitem(SERVER_FEATURE_FLAGS, "assets", True)
|
|
assert get_server_features()["assets"] is True
|
|
|
|
def test_get_connection_feature_with_missing_sid(self):
|
|
"""Test getting feature for non-existent session ID."""
|
|
sockets_metadata = {}
|
|
result = get_connection_feature(sockets_metadata, "missing_sid", "some_feature")
|
|
assert result is False # Default value
|
|
|
|
def test_get_connection_feature_with_custom_default(self):
|
|
"""Test getting feature with custom default value."""
|
|
sockets_metadata = {}
|
|
result = get_connection_feature(
|
|
sockets_metadata, "missing_sid", "some_feature", default="custom_default"
|
|
)
|
|
assert result == "custom_default"
|
|
|
|
def test_get_connection_feature_with_feature_flags(self):
|
|
"""Test getting feature from connection with feature flags."""
|
|
sockets_metadata = {
|
|
"sid1": {
|
|
"feature_flags": {
|
|
"supports_preview_metadata": True,
|
|
"custom_feature": "value",
|
|
},
|
|
}
|
|
}
|
|
result = get_connection_feature(sockets_metadata, "sid1", "supports_preview_metadata")
|
|
assert result is True
|
|
|
|
result = get_connection_feature(sockets_metadata, "sid1", "custom_feature")
|
|
assert result == "value"
|
|
|
|
def test_get_connection_feature_missing_feature(self):
|
|
"""Test getting non-existent feature from connection."""
|
|
sockets_metadata = {
|
|
"sid1": {"feature_flags": {"existing_feature": True}}
|
|
}
|
|
result = get_connection_feature(sockets_metadata, "sid1", "missing_feature")
|
|
assert result is False
|
|
|
|
def test_supports_feature_returns_boolean(self):
|
|
"""Test that supports_feature always returns boolean."""
|
|
sockets_metadata = {
|
|
"sid1": {
|
|
"feature_flags": {
|
|
"bool_feature": True,
|
|
"string_feature": "value",
|
|
"none_feature": None,
|
|
},
|
|
}
|
|
}
|
|
|
|
# True boolean feature
|
|
assert supports_feature(sockets_metadata, "sid1", "bool_feature") is True
|
|
|
|
# Non-boolean values should return False
|
|
assert supports_feature(sockets_metadata, "sid1", "string_feature") is False
|
|
assert supports_feature(sockets_metadata, "sid1", "none_feature") is False
|
|
assert supports_feature(sockets_metadata, "sid1", "missing_feature") is False
|
|
|
|
def test_supports_feature_with_missing_connection(self):
|
|
"""Test supports_feature with missing connection."""
|
|
sockets_metadata = {}
|
|
assert supports_feature(sockets_metadata, "missing_sid", "any_feature") is False
|
|
|
|
def test_empty_feature_flags_dict(self):
|
|
"""Test connection with empty feature flags dictionary."""
|
|
sockets_metadata = {"sid1": {"feature_flags": {}}}
|
|
result = get_connection_feature(sockets_metadata, "sid1", "any_feature")
|
|
assert result is False
|
|
assert supports_feature(sockets_metadata, "sid1", "any_feature") is False
|
|
|
|
|
|
class TestCoerceFlagValue:
|
|
"""Test suite for _coerce_flag_value."""
|
|
|
|
def test_registered_bool_true(self):
|
|
assert _coerce_flag_value("show_signin_button", "true") is True
|
|
assert _coerce_flag_value("show_signin_button", "True") is True
|
|
|
|
def test_registered_bool_false(self):
|
|
assert _coerce_flag_value("show_signin_button", "false") is False
|
|
assert _coerce_flag_value("show_signin_button", "FALSE") is False
|
|
|
|
def test_unregistered_key_stays_string(self):
|
|
assert _coerce_flag_value("unknown_flag", "true") == "true"
|
|
assert _coerce_flag_value("unknown_flag", "42") == "42"
|
|
|
|
def test_bool_typo_raises(self):
|
|
"""Strict bool: typos like 'ture' or 'yes' must raise so the flag can be dropped."""
|
|
with pytest.raises(ValueError):
|
|
_coerce_flag_value("show_signin_button", "ture")
|
|
with pytest.raises(ValueError):
|
|
_coerce_flag_value("show_signin_button", "yes")
|
|
with pytest.raises(ValueError):
|
|
_coerce_flag_value("show_signin_button", "1")
|
|
with pytest.raises(ValueError):
|
|
_coerce_flag_value("show_signin_button", "")
|
|
|
|
def test_failed_int_coercion_raises(self, monkeypatch):
|
|
"""Malformed values for typed flags must raise; caller decides what to do."""
|
|
monkeypatch.setitem(
|
|
CLI_FEATURE_FLAG_REGISTRY,
|
|
"test_int_flag",
|
|
{"type": "int", "default": 0, "description": "test"},
|
|
)
|
|
with pytest.raises(ValueError):
|
|
_coerce_flag_value("test_int_flag", "not_a_number")
|
|
|
|
|
|
class TestParseCliFeatureFlags:
|
|
"""Test suite for _parse_cli_feature_flags."""
|
|
|
|
def test_single_flag(self, monkeypatch):
|
|
monkeypatch.setattr("comfy_api.feature_flags.args", type("Args", (), {"feature_flag": ["show_signin_button=true"]})())
|
|
result = _parse_cli_feature_flags()
|
|
assert result == {"show_signin_button": True}
|
|
|
|
def test_missing_equals_defaults_to_true(self, monkeypatch):
|
|
"""Bare flag without '=' is treated as the string 'true' (and coerced if registered)."""
|
|
monkeypatch.setattr("comfy_api.feature_flags.args", type("Args", (), {"feature_flag": ["show_signin_button", "valid=1"]})())
|
|
result = _parse_cli_feature_flags()
|
|
assert result == {"show_signin_button": True, "valid": "1"}
|
|
|
|
def test_empty_key_skipped(self, monkeypatch):
|
|
monkeypatch.setattr("comfy_api.feature_flags.args", type("Args", (), {"feature_flag": ["=value", "valid=1"]})())
|
|
result = _parse_cli_feature_flags()
|
|
assert result == {"valid": "1"}
|
|
|
|
def test_invalid_bool_value_dropped(self, monkeypatch, caplog):
|
|
"""A typo'd bool value must be dropped entirely, not silently set to False
|
|
and not stored as a raw string. A warning must be logged."""
|
|
monkeypatch.setattr(
|
|
"comfy_api.feature_flags.args",
|
|
type("Args", (), {"feature_flag": ["show_signin_button=ture", "valid=1"]})(),
|
|
)
|
|
with caplog.at_level("WARNING"):
|
|
result = _parse_cli_feature_flags()
|
|
assert result == {"valid": "1"}
|
|
assert "show_signin_button" not in result
|
|
assert any("show_signin_button" in r.message and "drop" in r.message.lower() for r in caplog.records)
|
|
|
|
|
|
class TestCliFeatureFlagRegistry:
|
|
"""Test suite for the CLI feature flag registry."""
|
|
|
|
def test_registry_entries_have_required_fields(self):
|
|
for key, info in CLI_FEATURE_FLAG_REGISTRY.items():
|
|
assert "type" in info, f"{key} missing 'type'"
|
|
assert "default" in info, f"{key} missing 'default'"
|
|
assert "description" in info, f"{key} missing 'description'"
|
|
|
|
|
|
class TestComfyApiEnv:
|
|
"""--comfy-api-base staging-tier detection + testenv main-host -> -registry rewrite."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"url, expected",
|
|
[
|
|
# testenv friendly main host -> comfy-api -registry sibling (slash trimmed)
|
|
("https://pr-4398.testenvs.comfy.org", "https://pr-4398-registry.testenvs.comfy.org"),
|
|
("https://pr-4398.testenvs.comfy.org/", "https://pr-4398-registry.testenvs.comfy.org"),
|
|
("https://pr-4398-registry.testenvs.comfy.org", "https://pr-4398-registry.testenvs.comfy.org"),
|
|
# staging + everything else -> unchanged (no -registry split)
|
|
("https://stagingapi.comfy.org", "https://stagingapi.comfy.org"),
|
|
("https://api.comfy.org", "https://api.comfy.org"),
|
|
("https://pr-1.testenvs.comfy.org.evil.com", "https://pr-1.testenvs.comfy.org.evil.com"),
|
|
("", ""),
|
|
],
|
|
)
|
|
def test_normalize_comfy_api_base(self, url, expected):
|
|
assert normalize_comfy_api_base(url) == expected
|
|
|
|
def test_config_for_staging_tier_else_none(self):
|
|
# ephemeral testenv: friendly main host -> -registry, staging platform, dev Firebase env
|
|
eph = environment_overrides_for_base("https://pr-1234.testenvs.comfy.org/")
|
|
assert eph["comfy_api_base_url"] == "https://pr-1234-registry.testenvs.comfy.org"
|
|
assert eph["comfy_platform_base_url"] == "https://stagingplatform.comfy.org"
|
|
assert eph["firebase_env"] == "dev"
|
|
# staging api host: emitted as-is
|
|
stg = environment_overrides_for_base("https://stagingapi.comfy.org")
|
|
assert stg["comfy_api_base_url"] == "https://stagingapi.comfy.org"
|
|
assert stg["comfy_platform_base_url"] == "https://stagingplatform.comfy.org"
|
|
assert stg["firebase_env"] == "dev"
|
|
# prod / unknown: nothing
|
|
assert environment_overrides_for_base("https://api.comfy.org") is None
|
|
|
|
def test_environment_overrides_only_for_staging_tier(self, monkeypatch):
|
|
def set_base(url):
|
|
monkeypatch.setattr(
|
|
"comfy.comfy_api_env.args",
|
|
type("Args", (), {"comfy_api_base": url})(),
|
|
)
|
|
|
|
# The overrides merged into the HTTP /features response are present for staging-tier bases...
|
|
set_base("https://stagingapi.comfy.org")
|
|
assert "comfy_api_base_url" in get_environment_overrides()
|
|
set_base("https://pr-7.testenvs.comfy.org")
|
|
assert "comfy_api_base_url" in get_environment_overrides()
|
|
# ...but never for prod.
|
|
set_base("https://api.comfy.org")
|
|
assert get_environment_overrides() is None
|
|
|
|
def test_server_features_never_carry_env_overrides(self, monkeypatch):
|
|
"""The WebSocket capability handshake must stay free of routing keys."""
|
|
monkeypatch.setattr(
|
|
"comfy.comfy_api_env.args",
|
|
type("Args", (), {"comfy_api_base": "https://pr-7.testenvs.comfy.org"})(),
|
|
)
|
|
features = get_server_features()
|
|
assert "comfy_api_base_url" not in features
|
|
assert "comfy_platform_base_url" not in features
|
|
assert "firebase_env" not in features
|