From fd797a6c8dfcf5fe19fa1cb5dcc26945854bf0bb Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Mon, 28 Sep 2026 18:12:33 +0200
Subject: [PATCH] Add image runtime installation and profile deletion controls
---
README.md | 25 ++++++++++
app.js | 2 +-
deploy/Dockerfile | 3 +-
deploy/install.py | 4 +-
image-test-ui.js | 9 +++-
image_runtime.py | 102 +++++++++++++++++++++++++++++++++++++++
image_test.py | 13 +++--
profiles-ui.js | 3 +-
profiles.py | 9 ++++
server.py | 24 +++++++++
studio.js | 4 +-
test_image_runtime.py | 42 ++++++++++++++++
test_model_management.py | 7 +++
test_server.py | 12 ++++-
14 files changed, 244 insertions(+), 15 deletions(-)
create mode 100644 image_runtime.py
create mode 100644 test_image_runtime.py
diff --git a/README.md b/README.md
index a16d80f..32f014b 100644
--- a/README.md
+++ b/README.md
@@ -105,3 +105,28 @@ CPU-/GPU-Temperaturen, VRAM und Auslastung vorhanden. Hardware-Ausfall im Test
simuliert, ohne Athena oder SSH zu unterbrechen.
Produktive Router-/Modell-/Bild-/Audio-/Netzwerkdienste nicht verändert.
Vorhandene ungesicherte Änderungen im übergeordneten Repository unangetastet.
+
+### Bildlaufzeit installieren und Profile entfernen
+
+Unter **Einstellungen → Laufzeiten → Bildgenerierung** zeigt Deck die Herkunft
+und die festgelegten ComfyUI-/GGUF-Versionen. Fehlt die Umgebung, installiert
+**Bildlaufzeit installieren** sie ohne Rootrechte in `state/image-runtime`.
+Voraussetzungen: Linux, Python mit venv, Git, Internet und 25 GiB freier Speicher;
+ein kompatibler NVIDIA-Treiber muss vom Systeminstaller bereitgestellt sein.
+Die Installation ist abbrechbar, startet kein Modell und aktiviert die neue
+Umgebung erst nach Paketprüfung. Bereits mitgelieferte Umgebungen werden erkannt;
+ein Versionsupdate der Bildlaufzeit ist hier noch nicht vorgesehen.
+
+Profile lassen sich in allen Kategorien löschen. Modell- und Komponentendateien
+bleiben erhalten. Die API verhindert das Löschen des gerade ausgeführten
+Bildprofils und prüft die Profilrevision gegen zwischenzeitliche Änderungen.
+
+Neue sitzungsgeschützte API-Routen: `GET /api/v1/image-runtime`,
+`POST /api/v1/image-runtime/install` und `/cancel` mit `{}` sowie
+`POST /api/v1/profiles/delete` mit `{ "id": "…", "revision": 1 }`.
+POST benötigt wie die übrigen Verwaltungsaktionen `X-Athena-Deck: 1`.
+
+Die Hardware-Seite zeigt GPU-Rechenlast und VRAM-Belegung mit separaten Balken.
+Es sind Host-Gesamtwerte einschließlich anderer Dienste. Der Bild-Textencoder
+arbeitet auf CPU/System-RAM; GPU-Last kann daher während eines Auftrags null
+sein. Nach Abschluss beendet Deck den eigenen Worker und gibt dessen VRAM frei.
diff --git a/app.js b/app.js
index 72786b8..e9f9d10 100644
--- a/app.js
+++ b/app.js
@@ -12,7 +12,7 @@ if(['chat','image','audio','video','runtime','runtimes','image-runtime'].include
if(page==='access'){await accessPage();return;}
if(page==='network'){await networkPage();return;}
if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`
Anwendung
Bereit
Athena Deck läuft auf ${esc(s.location)}.
Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1
Betriebsmodus
Isoliert
Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.
Keine Modell- oder Router-Steuerung angebunden
Demo-Dienst
LOKAL / CPU
Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.
Lädt keine Modelle · endet beim Beenden von Athena Deck
`;
-}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden.');if(!h.available)html+=`Hardware nicht verfügbar
`;}
+}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden. GPU-Rechenlast und belegter VRAM werden getrennt dargestellt.');if(!h.available)html+=`Hardware nicht verfügbar
GPU-Rechenlast${bar(g.percent)}VRAM-Belegung${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}`;if(h.gpus.length<2)html+='Nicht alle zwei GPUs verfügbar.';html+=`
Die Werte gelten für den gesamten Server einschließlich anderer Dienste. Beim Bildtest läuft der Textencoder auf der CPU; 0 % GPU-Rechenlast bedeutet nicht, dass kein Modell geladen ist. Nach dem Test wird der eigene Worker entladen. ${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')} ${esc(h.errors.join(' '))}
Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.
${readonly?'':``}`;}
+ function html(readonly=false){return `
${readonly?'Bildlaufzeit':'Bildmodell testen'}
Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.
${readonly?'':``}
Textencoder: CPU / System-RAM · Diffusionsmodell und VAE: GPU / VRAM. GPU-Last entsteht nicht während aller Verarbeitungsschritte. Aktuelle Hardware-Messwerte →
${e(s.job?.phase||'Installation richtet eine eigene Python-Umgebung mit ComfyUI, GGUF-Unterstützung und CUDA-PyTorch ein. Mindestens 25 GiB freier Speicher und Internetzugang erforderlich.')}
Systemvoraussetzungen: Python mit venv, Git und kompatibler NVIDIA-Treiber. Die Installation ändert keine Host-Pakete oder anderen Dienste.
`;root.querySelector('button').onclick=async()=>{pending=true;root.querySelector('button').disabled=true;try{await api('image-runtime/'+(running?'cancel':'install'),{});}catch(error){alert(error.message);}finally{pending=false;}};}catch(error){if(root.isConnected)root.textContent=error.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}}
+ refresh();
+ }
+ return {html,bind,bindInstaller};
})();
diff --git a/image_runtime.py b/image_runtime.py
new file mode 100644
index 0000000..cdd21a9
--- /dev/null
+++ b/image_runtime.py
@@ -0,0 +1,102 @@
+"""Unprivileged, pinned image-runtime installation in Deck's own state directory."""
+import json
+import os
+from pathlib import Path
+import shutil
+import signal
+import subprocess
+import sys
+import threading
+import time
+import uuid
+
+COMFY_REV='8d534945ebd53cff61e8def81757c6a6c1b9cf2d'
+GGUF_REV='373048b8403a7820620065210a691263d4da0a61'
+BUNDLED=(Path('/opt/deck-image-python/bin/python'),Path('/opt/deck-comfy'))
+
+class ImageRuntime:
+ def __init__(self,root):
+ self.root=Path(root);self.lock=threading.RLock();self.cancel=threading.Event();self.process=None;self.worker=None;self.job=None
+ if (self.root/'installation.json').is_file():
+ self.job=json.loads((self.root/'installation.json').read_text())
+ if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Installation unterbrochen; erneut installieren.')
+ def paths(self):
+ marker=self.root/'active.json'
+ if marker.is_file():
+ ident=json.loads(marker.read_text()).get('id','')
+ if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):
+ base=self.root/ident
+ if (base/'ready').is_file():return base/'python/bin/python',base/'comfy'
+ return BUNDLED
+ def status(self):
+ with self.lock:
+ python,comfy=self.paths()
+ installed=python.is_file() and (comfy/'main.py').is_file()
+ return dict(installed=installed,origin='Deck-Installer' if (python,comfy)==BUNDLED else 'GUI-Installation',job=dict(self.job) if self.job else None,comfy_revision=COMFY_REV,gguf_revision=GGUF_REV,required_disk_gib=25)
+ def _save(self):
+ p=self.root/'installation.tmp';p.write_text(json.dumps(self.job));p.replace(self.root/'installation.json')
+ def _phase(self,text):
+ with self.lock:self.job['phase']=text;self._save()
+ def start(self):
+ with self.lock:
+ if self.job and self.job['state']=='running':raise ValueError('Eine Installation läuft bereits.')
+ if self.status()['installed']:raise ValueError('Die Bildlaufzeit ist bereits installiert und einsatzbereit.')
+ if sys.platform!='linux' or not shutil.which('git'):raise ValueError('Debian/Linux mit Python venv und Git erforderlich. Systemvoraussetzungen über den Deck-Installer bereitstellen.')
+ self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
+ if shutil.disk_usage(self.root).free<25*1024**3:raise ValueError('Mindestens 25 GiB freier Speicher für die Bildlaufzeit erforderlich.')
+ self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Installation wird vorbereitet',started_at=time.time())
+ self._save();self.worker=threading.Thread(target=self._run,daemon=True);self.worker.start();return self.status()
+ def stop(self):
+ with self.lock:
+ self.cancel.set();process=self.process
+ if process and process.poll() is None:
+ try:os.killpg(process.pid,signal.SIGTERM)
+ except ProcessLookupError:pass
+ if self.worker:self.worker.join(timeout=7)
+ return {'cancellation_requested':True}
+ def _command(self,args):
+ with self.lock:
+ if self.cancel.is_set():raise InterruptedError()
+ temporary=self.root/self.job['id']/'tmp';temporary.mkdir(exist_ok=True)
+ env=dict(os.environ,TMPDIR=str(temporary),PIP_NO_INPUT='1',PIP_DISABLE_PIP_VERSION_CHECK='1',GIT_TERMINAL_PROMPT='0',OMP_NUM_THREADS='2')
+ self.process=subprocess.Popen([str(a) for a in args],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True,env=env)
+ process=self.process
+ deadline=time.monotonic()+3600
+ while process.poll() is None:
+ if self.cancel.is_set() or time.monotonic()>deadline:
+ try:
+ os.killpg(process.pid,signal.SIGTERM)
+ try:process.wait(timeout=5)
+ except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
+ except ProcessLookupError:pass
+ if self.cancel.is_set():raise InterruptedError()
+ raise RuntimeError('Zeitlimit beim Installationsschritt erreicht.')
+ time.sleep(.2)
+ if self.cancel.is_set():raise InterruptedError()
+ if process.returncode:raise RuntimeError('Installationsschritt fehlgeschlagen. Internetzugang, freien Speicher und Python-venv-Voraussetzungen prüfen.')
+ def _run(self):
+ base=self.root/self.job['id'];comfy=base/'comfy';python=base/'python/bin/python'
+ phase='';state='failed'
+ try:
+ base.mkdir(mode=0o700)
+ self._phase('Python-Umgebung erstellen');self._command([sys.executable,'-m','venv',base/'python'])
+ self._phase('ComfyUI herunterladen')
+ for url,path,rev in [('https://github.com/Comfy-Org/ComfyUI.git',comfy,COMFY_REV),('https://github.com/leejet/ComfyUI-GGUF.git',comfy/'custom_nodes/ComfyUI-GGUF',GGUF_REV)]:
+ self._command(['git','init',path]);self._command(['git','-C',path,'fetch','--depth','1',url,rev]);self._command(['git','-C',path,'checkout','--detach','FETCH_HEAD'])
+ self._phase('PyTorch mit CUDA herunterladen · mehrere GiB, kann einige Minuten dauern')
+ self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchvision==0.26.0','--index-url','https://download.pytorch.org/whl/cu128'])
+ self._phase('Bildlaufzeit-Abhängigkeiten installieren')
+ self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/image-requirements.lock','-r',comfy/'requirements.txt','-r',comfy/'custom_nodes/ComfyUI-GGUF/requirements.txt'])
+ self._phase('Installation prüfen')
+ self._command([python,'-m','pip','check'])
+ self._command([python,'-c','import torch, gguf, safetensors, aiohttp; assert torch.version.cuda'])
+ with self.lock:
+ if self.cancel.is_set():raise InterruptedError()
+ (base/'ready').write_text(COMFY_REV)
+ marker=self.root/'active.tmp';marker.write_text(json.dumps({'id':base.name}));marker.replace(self.root/'active.json')
+ state='complete';phase='Bildlaufzeit installiert. Es wurde kein Modell gestartet.'
+ except InterruptedError:state='cancelled';phase='Installation abgebrochen. Erneuter Versuch möglich.'
+ except Exception as exc:phase=str(exc) if isinstance(exc,RuntimeError) else 'Installation fehlgeschlagen. Systemvoraussetzungen und Speicher prüfen.'
+ finally:
+ if state!='complete':shutil.rmtree(base,ignore_errors=True)
+ with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save()
diff --git a/image_test.py b/image_test.py
index 18506c9..e7f67f9 100644
--- a/image_test.py
+++ b/image_test.py
@@ -39,7 +39,7 @@ def workflow(prompt,params,seed):
class ImageTests:
def __init__(self,root,profiles):
- self.root=Path(root);self.profiles=profiles;self.lock=threading.RLock();self.process=None;self.cancel=threading.Event();self.job=None
+ self.root=Path(root);self.profiles=profiles;self.runtime=None;self.lock=threading.RLock();self.process=None;self.cancel=threading.Event();self.job=None
if (self.root/'status.json').exists():
self.job=json.loads((self.root/'status.json').read_text())
if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Deck wurde neu gestartet; Test erneut starten.')
@@ -50,7 +50,9 @@ class ImageTests:
with self.lock:
self.job['phase']=text;self._save()
def status(self):
- with self.lock:return dict(job=dict(self.job) if self.job else None,runtime_installed=PYTHON.is_file() and (COMFY/'main.py').is_file())
+ with self.lock:
+ python,comfy=self.runtime.paths() if self.runtime else (PYTHON,COMFY)
+ return dict(job=dict(self.job) if self.job else None,runtime_installed=python.is_file() and (comfy/'main.py').is_file())
def stop(self):
self.cancel.set()
with self.lock:process=self.process
@@ -67,6 +69,7 @@ class ImageTests:
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.')
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Ein Bildtest läuft bereits.')
+ if self.runtime and (self.runtime.status()['job'] or {}).get('state')=='running':raise ValueError('Die Bildlaufzeit wird gerade installiert.')
if not self.status()['runtime_installed']:raise ValueError('Die eigene ComfyUI-Bildlaufzeit ist noch nicht installiert.')
profile=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id),None)
if not profile or profile['kind']!='image' or not profile['model']:raise ValueError('Bildprofil nicht verfügbar.')
@@ -104,10 +107,10 @@ class ImageTests:
(directory/'paths.json').write_text(json.dumps(config)) # JSON is valid YAML.
with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
env=dict(os.environ,CUDA_VISIBLE_DEVICES=gpu['uuid'],OMP_NUM_THREADS='2',MKL_NUM_THREADS='2',HOME=str(directory),HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',PYTHONDONTWRITEBYTECODE='1')
- args=[str(PYTHON),str(COMFY/'main.py'),'--listen','127.0.0.1','--port',str(port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(directory/'paths.json'),'--output-directory',str(directory/'output'),'--temp-directory',str(directory/'temp'),'--user-directory',str(directory/'user'),'--input-directory',str(directory/'input')]
+ args=[str(self.runtime.paths()[0] if self.runtime else PYTHON),str((self.runtime.paths()[1] if self.runtime else COMFY)/'main.py'),'--listen','127.0.0.1','--port',str(port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(directory/'paths.json'),'--output-directory',str(directory/'output'),'--temp-directory',str(directory/'temp'),'--user-directory',str(directory/'user'),'--input-directory',str(directory/'input')]
with self.lock:
if self.cancel.is_set():raise InterruptedError()
- process=subprocess.Popen(args,cwd=COMFY,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True);self.process=process
+ process=subprocess.Popen(args,cwd=self.runtime.paths()[1] if self.runtime else COMFY,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True);self.process=process
def request(path,data=None):
req=urllib.request.Request(f'http://127.0.0.1:{port}'+path,data=json.dumps(data).encode() if data is not None else None,headers={'Content-Type':'application/json'})
with urllib.request.urlopen(req,timeout=5) as r:
@@ -129,7 +132,7 @@ class ImageTests:
nodes=request('/object_info')
required={'UnetLoaderGGUF','CLIPLoader','VAELoader','TextEncodeQwenImage21','EmptyLatentImage','KSampler','VAEDecode','SaveImage'}
if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Qwen/GGUF-Nodes.')
- self._phase('Modell und Textencoder laden · Bild wird erzeugt')
+ self._phase('Auftrag wird verarbeitet · Textencoder auf CPU, Bildberechnung auf GPU')
response=request('/prompt',{'prompt':workflow(prompt,params,seed),'client_id':'deck-'+job_id});prompt_id=response['prompt_id']
deadline=time.monotonic()+1800
while time.monotonic(){
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},panelSequence=0;
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c]=await Promise.all([api('profiles'),api('catalog')]);if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
- el('profile-list').innerHTML=rows.map(p=>`GESPEICHERT AUF ATHENA
${e(p.name)}
${e(p.model?.file||'Modelldatei nicht verfügbar')}
Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.
Textencoder läuft auf CPU, Diffusionsmodell und VAE auf einer freien GPU. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.
Installationsstatus wird geprüft …
Bildgenerierung → Testen öffnen${ImageTestUI.html(true)}`;}
function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile};
})();
diff --git a/test_image_runtime.py b/test_image_runtime.py
new file mode 100644
index 0000000..fd79eab
--- /dev/null
+++ b/test_image_runtime.py
@@ -0,0 +1,42 @@
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+from image_runtime import ImageRuntime
+
+class ImageRuntimeTests(unittest.TestCase):
+ def test_installed_bundle_blocks_reinstall(self):
+ with tempfile.TemporaryDirectory() as d:
+ root=Path(d);python=root/'python';python.touch();comfy=root/'comfy';comfy.mkdir();(comfy/'main.py').touch()
+ with patch('image_runtime.BUNDLED',(python,comfy)):
+ manager=ImageRuntime(root/'state');self.assertTrue(manager.status()['installed'])
+ with self.assertRaises(ValueError):manager.start()
+ def test_failed_install_preserves_active_and_removes_partial(self):
+ with tempfile.TemporaryDirectory() as d:
+ root=Path(d);manager=ImageRuntime(root);manager.job=dict(id='a'*32,state='running')
+ with patch.object(manager,'_command',side_effect=RuntimeError('test failure')):manager._run()
+ self.assertEqual(manager.status()['job']['state'],'failed');self.assertFalse((root/'active.json').exists());self.assertFalse((root/('a'*32)).exists())
+ def test_success_activates_only_after_validation(self):
+ with tempfile.TemporaryDirectory() as d:
+ root=Path(d);manager=ImageRuntime(root);manager.job=dict(id='b'*32,state='running');commands=[]
+ def command(args):
+ self.assertFalse((root/'active.json').exists());commands.append([str(x) for x in args])
+ with patch.object(manager,'_command',side_effect=command):manager._run()
+ self.assertEqual(manager.status()['job']['state'],'complete');self.assertTrue((root/('b'*32)/'ready').exists())
+ self.assertTrue(any(x[-2:]==['pip','check'] for x in commands))
+ self.assertEqual(manager.paths()[1],root/('b'*32)/'comfy')
+ def test_cancel_never_activates(self):
+ with tempfile.TemporaryDirectory() as d:
+ manager=ImageRuntime(d);manager.job=dict(id='c'*32,state='running');manager.cancel.set();manager._run()
+ self.assertEqual(manager.status()['job']['state'],'cancelled');self.assertFalse((Path(d)/'active.json').exists())
+ def test_install_tempfiles_use_owned_disk_not_container_tmpfs(self):
+ import sys
+ with tempfile.TemporaryDirectory() as d:
+ manager=ImageRuntime(d);manager.job=dict(id='d'*32,state='running');base=Path(d)/manager.job['id'];base.mkdir()
+ manager._command([sys.executable,'-c',"import os; from pathlib import Path; p=Path(os.environ['TMPDIR']); assert p.name=='tmp'; (p/'test').write_text('ok')"])
+ self.assertEqual((base/'tmp/test').read_text(),'ok')
+ def test_restart_interrupted_install(self):
+ with tempfile.TemporaryDirectory() as d:
+ (Path(d)/'installation.json').write_text(json.dumps(dict(state='running')))
+ self.assertEqual(ImageRuntime(d).status()['job']['state'],'interrupted')
diff --git a/test_model_management.py b/test_model_management.py
index 5ad74fc..15dfb7c 100644
--- a/test_model_management.py
+++ b/test_model_management.py
@@ -29,6 +29,13 @@ class ManagementTests(unittest.TestCase):
with self.subTest(key=key),self.assertRaises(ValueError):self.profiles.save(req)
(self.root/'models'/self.model_id/'model.gguf').unlink()
with self.assertRaises(ValueError):self.profiles.save(self.request())
+ def test_delete_revision_and_model_preservation(self):
+ row=self.profiles.save(self.request())
+ with self.assertRaises(ValueError):self.profiles.delete(dict(id=row['id'],revision=0))
+ self.assertEqual(len(self.profiles.rows),1)
+ self.profiles.delete(dict(id=row['id'],revision=row['revision']))
+ self.assertEqual(Profiles(self.root/'profiles.json',self.catalog).rows,[])
+ self.assertTrue((self.root/'models'/self.model_id/'model.gguf').exists())
def test_clear_history_keeps_models_and_survives_restart(self):
old=self.catalog.status()['downloads'][0];self.catalog.dismiss(old['id'])
restart=Catalog(self.root/'models');self.assertEqual(restart.status()['downloads'],[]);self.assertEqual(len(restart.status()['entries']),1)
diff --git a/test_server.py b/test_server.py
index 6ebde84..e96d546 100644
--- a/test_server.py
+++ b/test_server.py
@@ -47,12 +47,22 @@ class Tests(unittest.TestCase):
payload=dict(id=None,revision=0,name='image-test',kind='image',model_id='a'*64,parameters=dict(width=512,height=512,steps=10,seed=-1,guidance=1))
req=urllib.request.Request(self.url+'/api/v1/profiles/save',data=json.dumps(payload).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
with urllib.request.urlopen(req) as r:self.assertEqual(r.status,200)
- self.assertEqual(self.request('profiles')['profiles'][0]['name'],'image-test')
+ row=self.request('profiles')['profiles'][0]
+ self.assertEqual(row['name'],'image-test')
+ delete=urllib.request.Request(self.url+'/api/v1/profiles/delete',data=json.dumps(dict(id=row['id'],revision=row['revision'])).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
+ self.server.image_tests.job=dict(state='running',profile_id=row['id'])
+ with self.assertRaises(urllib.error.HTTPError) as busy:urllib.request.urlopen(delete)
+ self.assertEqual(busy.exception.code,400);busy.exception.close()
+ self.server.image_tests.job=None
+ with urllib.request.urlopen(delete) as r:self.assertEqual(r.status,200)
+ self.assertEqual(self.request('profiles')['profiles'],[])
+ self.assertTrue((target/'model.gguf').exists())
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/profiles',headers={'Authorization':'Bearer '+self.api_token}))
self.assertEqual(error.exception.code,401);error.exception.close()
def test_image_test_routes_are_admin_only(self):
+ self.assertIn('installed',self.request('image-runtime'))
self.assertIn('runtime_installed',self.request('image-tests'))
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/image-tests',headers={'Authorization':'Bearer '+self.api_token}))