Editing a cron job with the model picker at "Default" sent provider: null unconditionally, wiping provider-only pins (provider set, no model — the pattern for self-hosted LLM endpoints). The combined picker cannot express that state, so such jobs always rendered as "Default" and were silently re-resolved to the bare global default on any save, breaking dispatch. #4030 deliberately made clearing the picker mean "no overrides"; that contract is preserved for explicit clears and model+provider jobs, while provider-only pins survive saves where the picker was never touched. - _cronProviderForClear(prevDetail, pickerTouched) decides the provider submitted on clear: touched picker -> null; untouched + no model on the job -> prev.provider. - _cronModelPickerTouched is set by a change listener added when the picker populates, and reset at openCronEdit/openCronCreate/duplicateCurrentCron. - Tests: delegation + flag lifecycle locked in, and the helper driven through node for provider-only / model-pinned / no-override / no-snapshot inputs (6 passed). Refs: #4030
261 lines
9.1 KiB
Python
261 lines
9.1 KiB
Python
"""Tests for cron model override features."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
|
|
|
|
|
class _JSONHandler:
|
|
def __init__(self):
|
|
self.status = None
|
|
self.headers = {}
|
|
self.response_headers = []
|
|
self.wfile = io.BytesIO()
|
|
|
|
def send_response(self, status):
|
|
self.status = status
|
|
|
|
def send_header(self, key, value):
|
|
self.response_headers.append((key, value))
|
|
|
|
def end_headers(self):
|
|
pass
|
|
|
|
|
|
def _payload(handler):
|
|
return json.loads(handler.wfile.getvalue().decode("utf-8"))
|
|
|
|
|
|
def _function_body(name: str) -> str:
|
|
marker = f"function {name}("
|
|
start = PANELS_JS.find(marker)
|
|
assert start != -1, f"{name} not found"
|
|
paren = PANELS_JS.find("(", start)
|
|
assert paren != -1, f"{name} params not found"
|
|
depth = 0
|
|
for idx in range(paren, len(PANELS_JS)):
|
|
ch = PANELS_JS[idx]
|
|
if ch == "(":
|
|
depth += 1
|
|
elif ch == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
brace = PANELS_JS.find("{", idx)
|
|
break
|
|
else:
|
|
raise AssertionError(f"{name} params did not terminate")
|
|
assert brace != -1, f"{name} body not found"
|
|
depth = 0
|
|
for idx in range(brace, len(PANELS_JS)):
|
|
ch = PANELS_JS[idx]
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return PANELS_JS[brace + 1 : idx]
|
|
raise AssertionError(f"{name} body did not terminate")
|
|
|
|
|
|
def test_cron_create_forwards_model_and_provider(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
created = {"id": "job-model-override", "prompt": "override test", "schedule": "every 1h"}
|
|
calls = []
|
|
cron_pkg = types.ModuleType("cron")
|
|
cron_pkg.__path__ = []
|
|
cron_jobs = types.ModuleType("cron.jobs")
|
|
cron_jobs.create_job = lambda **kwargs: calls.append(("create", kwargs)) or {**created, **kwargs}
|
|
cron_jobs.update_job = lambda job_id, updates: calls.append(("update", job_id, updates)) or {**created, **updates}
|
|
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
|
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
|
|
|
handler = _JSONHandler()
|
|
routes._handle_cron_create(
|
|
handler,
|
|
{
|
|
"prompt": "override test",
|
|
"schedule": "every 1h",
|
|
"model": "my-custom-model",
|
|
"provider": "my-provider",
|
|
},
|
|
)
|
|
|
|
assert handler.status == 200
|
|
assert calls[0][0] == "create"
|
|
assert calls[0][1]["model"] == "my-custom-model"
|
|
assert calls[0][1]["provider"] == "my-provider"
|
|
|
|
|
|
def test_cron_update_allows_overwriting_and_clearing_model_provider(monkeypatch):
|
|
import api.routes as routes
|
|
|
|
calls = []
|
|
cron_pkg = types.ModuleType("cron")
|
|
cron_pkg.__path__ = []
|
|
cron_jobs = types.ModuleType("cron.jobs")
|
|
cron_jobs.update_job = lambda job_id, updates: calls.append(("update", job_id, updates)) or {"id": job_id, **updates}
|
|
monkeypatch.setitem(sys.modules, "cron", cron_pkg)
|
|
monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs)
|
|
|
|
# 1. Update model & provider
|
|
handler = _JSONHandler()
|
|
routes._handle_cron_update(
|
|
handler,
|
|
{
|
|
"job_id": "test-job",
|
|
"model": "new-model",
|
|
"provider": "new-provider",
|
|
},
|
|
)
|
|
assert handler.status == 200
|
|
assert calls[0] == ("update", "test-job", {"model": "new-model", "provider": "new-provider"})
|
|
|
|
# 2. Clear model & provider overrides to default
|
|
handler = _JSONHandler()
|
|
routes._handle_cron_update(
|
|
handler,
|
|
{
|
|
"job_id": "test-job",
|
|
"model": None,
|
|
"provider": None,
|
|
},
|
|
)
|
|
assert handler.status == 200
|
|
assert calls[1] == ("update", "test-job", {"model": None, "provider": None})
|
|
|
|
|
|
def test_cron_panels_form_structure_and_population():
|
|
render_body = _function_body("_renderCronForm")
|
|
save_body = _function_body("saveCronForm")
|
|
edit_body = _function_body("openCronEdit")
|
|
duplicate_body = _function_body("duplicateCurrentCron")
|
|
|
|
# Check that model element is added to the HTML template in panels.js
|
|
assert "cronFormModel" in render_body
|
|
assert "cron_model_label" in render_body
|
|
|
|
# Check that _populateCronFormModelSelect is called in _renderCronForm
|
|
assert "_populateCronFormModelSelect" in render_body
|
|
|
|
# Check that openCronEdit and duplicateCurrentCron pass model and provider overrides to _renderCronForm
|
|
assert "model" in edit_body
|
|
assert "provider" in edit_body
|
|
assert "model" in duplicate_body
|
|
assert "provider" in duplicate_body
|
|
|
|
# Check saveCronForm parses and submits model/provider
|
|
assert "cronFormModel" in save_body
|
|
assert "updates.model" in save_body or "body.model" in save_body
|
|
assert "const modelLoaded = !!(modelEl && modelEl.dataset.loaded === '1')" in save_body
|
|
assert "selectedModel && modelLoaded" in save_body
|
|
assert "else if (modelLoaded)" in save_body
|
|
assert "_cronPreFormDetail.provider || null" in save_body
|
|
|
|
|
|
def test_cron_model_picker_marks_loaded_only_after_successful_population():
|
|
body = _function_body("_populateCronFormModelSelect")
|
|
|
|
assert "delete sel.dataset.loaded" in body
|
|
assert "sel.dataset.loaded = '1'" in body
|
|
assert "} finally {" not in body
|
|
try_block = body.split("} catch (e)", 1)[0]
|
|
catch_block = body.split("} catch (e)", 1)[1]
|
|
assert "sel.dataset.loaded = '1'" in try_block
|
|
assert "sel.dataset.loaded = '1'" not in catch_block
|
|
|
|
|
|
def test_cron_update_preserves_provider_only_pin_when_model_cleared():
|
|
"""Clearing the picker to "Default" must not wipe a provider-only pin.
|
|
|
|
#4030 intentionally made "clearing the picker = no overrides", which is
|
|
correct for model+provider pins but silently destroyed provider-only
|
|
pins (jobs that pin a provider with no model, e.g. self-hosted LLM
|
|
endpoints). The combined picker cannot represent that state, so
|
|
saveCronForm must preserve prev.provider when the job carried no model
|
|
AND the user never changed the picker; a deliberate return to Default
|
|
must clear it.
|
|
"""
|
|
save_body = _function_body("saveCronForm")
|
|
helper_body = _function_body("_cronProviderForClear")
|
|
|
|
# The clear branch delegates to the helper and records picker changes.
|
|
assert "_cronProviderForClear(_cronPreFormDetail, _cronModelPickerTouched)" in save_body
|
|
assert "preserveProvider" not in save_body
|
|
|
|
# The helper distinguishes an explicit clear (pickerTouched) from an
|
|
# untouched provider-only pin.
|
|
assert "if (pickerTouched) return null" in helper_body
|
|
assert "prev.model == null" in helper_body
|
|
|
|
# The form entry points reset the touched flag.
|
|
for fn in ("openCronCreate", "openCronEdit", "duplicateCurrentCron"):
|
|
assert "_cronModelPickerTouched = false" in _function_body(fn)
|
|
# The picker records deliberate changes.
|
|
pop_body = _function_body("_populateCronFormModelSelect")
|
|
assert "_cronModelPickerTouched = true" in pop_body
|
|
|
|
|
|
def test_cron_provider_for_clear_behavior():
|
|
"""Exercise _cronProviderForClear through node for both pin shapes."""
|
|
import shutil
|
|
import subprocess
|
|
|
|
node = shutil.which("node")
|
|
if node is None:
|
|
pytest.skip("node not on PATH")
|
|
|
|
# Locate the full declaration (signature + body) of the helper.
|
|
src = PANELS_JS
|
|
start = src.find("function _cronProviderForClear(")
|
|
assert start != -1, "helper declaration not found"
|
|
brace = src.find("{", start)
|
|
depth = 0
|
|
end = None
|
|
for i in range(brace, len(src)):
|
|
if src[i] == "{":
|
|
depth += 1
|
|
elif src[i] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
end = i + 1
|
|
break
|
|
decl = src[start:end]
|
|
|
|
driver = f"""
|
|
{decl}
|
|
const cases = [
|
|
['provider_only_untouched', {{model: null, provider: 'custom:llama-swap'}}, false],
|
|
['provider_only_touched', {{model: null, provider: 'custom:llama-swap'}}, true],
|
|
['model_pin_touched', {{model: 'gpt-5', provider: 'openai'}}, true],
|
|
['no_override_untouched', {{model: null, provider: null}}, false],
|
|
['no_prev', null, false],
|
|
];
|
|
const out = {{}};
|
|
for (const [name, detail, touched] of cases) out[name] = _cronProviderForClear(detail, touched) ?? null;
|
|
process.stdout.write(JSON.stringify(out));
|
|
"""
|
|
proc = subprocess.run([node, "-e", driver], capture_output=True, text=True, timeout=30)
|
|
assert proc.returncode == 0, f"node driver failed: {proc.stderr}"
|
|
r = json.loads(proc.stdout)
|
|
|
|
# Untouched picker on a provider-only job: the pin survives.
|
|
assert r["provider_only_untouched"] == "custom:llama-swap"
|
|
# Deliberate return to Default: "Default = no overrides" wins.
|
|
assert r["provider_only_touched"] is None
|
|
# Model-pinned job, explicit clear: provider removed too.
|
|
assert r["model_pin_touched"] is None
|
|
# No overrides stored, untouched picker: nothing to preserve.
|
|
assert r["no_override_untouched"] is None
|
|
# No pre-form snapshot (create form): no provider.
|
|
assert r["no_prev"] is None
|