Files
dj/integration-work/test_video_cleanup_delivery.py
2026-11-13 11:46:25 +08:00

104 lines
7.9 KiB
Python

"""Isolated verified-download acknowledgement test. No live ComfyUI traffic."""
import base64,hashlib,json,os,pathlib,re,sqlite3,subprocess,threading,time,urllib.request
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
root=pathlib.Path(__file__).resolve().parent.parent
data=root/'integration-work'/('video-cleanup-test-'+time.strftime('%Y%m%d-%H%M%S'));data.mkdir()
stage=root/'integration-work/stage-4.0.11/app';dbfile=data/'huobao.sqlite3'
source=(root/'huobao-source/desktop/build/backend.mjs').read_text('utf-8')
source=source.replace('const require = __hbCreateRequire(import.meta.url)','const require = __hbCreateRequire('+json.dumps(str(stage/'resources/app.asar/build/backend.mjs'))+')',1)
source=re.sub(r'^import (\w+) from "(better-sqlite3|sharp)";',r'const \1 = require("\2");',source,flags=re.M)
source=re.sub(r'(\w+)\.hostname === "192\.168\.110\.111" && \["18792", "18794"\]\.includes\(\1.port\)',r'\1.hostname === "127.0.0.1" && \1.port === "18981"',source)
source=source.replace('intervalMs: 1e4','intervalMs: 100')
(data/'backend.mjs').write_text(source,'utf-8')
subprocess.run([str(stage/'resources/bin/ffmpeg.exe'),'-hide_banner','-loglevel','error','-f','lavfi','-i','color=c=blue:s=320x192:d=1','-c:v','libx264',str(data/'valid.mp4')],check=True,creationflags=subprocess.CREATE_NO_WINDOW)
valid=(data/'valid.mp4').read_bytes();digest=hashlib.sha256(valid).hexdigest()
events=[];job_number=0;mode='cleaned';jobs={}
def sql(query,args=()):
with sqlite3.connect(dbfile) as db:db.row_factory=sqlite3.Row;return [dict(r) for r in db.execute(query,args)]
class Mock(BaseHTTPRequestHandler):
def log_message(self,*args):pass
def reply(self,value,status=200):
self.send_response(status);self.send_header('Content-Type','application/json');self.end_headers();self.wfile.write(json.dumps(value).encode())
def do_POST(self):
global job_number
body=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))) or '{}')
if self.path.endswith('/cleanup'):
jid=self.path.split('/')[-2];events.append(('cleanup',jid,body))
record=sql('select * from sys_task where task_id=?',(jid,))[0]
assert record['status']=='completed' and (data/record['local_path']).exists()
assert body=={'version':1,'index':0,'sha256':digest,'bytes':len(valid)}
self.reply({'status':mode,'sha256':digest,'bytes':len(valid),'index':0},503 if mode=='error' else 200);return
job_number+=1;jid='vid-'+str(job_number);jobs[jid]=mode;events.append(('generate',jid))
self.reply({'task_id':jid})
def do_GET(self):
if self.path.startswith('/v2/query/'):
jid=self.path.split('/')[-1]
self.reply({'task':{'id':jid,'status':'succeeded','duration':1,'content':{'url':f'http://127.0.0.1:18981/media/{jid}/0/video.mp4'}}});return
jid=self.path.split('/')[2];events.append(('download',jid))
raw=valid[:32] if jobs[jid]=='invalid' else valid
self.send_response(200);self.send_header('Content-Type','video/mp4');self.send_header('Content-Length',str(len(raw)));self.end_headers();self.wfile.write(raw)
server=ThreadingHTTPServer(('127.0.0.1',18981),Mock);threading.Thread(target=server.serve_forever,daemon=True).start()
env={**os.environ,'ELECTRON_RUN_AS_NODE':'1','PORT':'18980','HUOBAO_DATA_DIR':str(data),'SQLITE_PATH':str(dbfile),'FRONTEND_DIST':str(root/'huobao-source/frontend/.output/public'),'WORKSPACE_PATH':str(stage/'resources/workspace-template'),'FFMPEG_BIN':str(stage/'resources/bin/ffmpeg.exe'),'FFPROBE_BIN':str(stage/'resources/bin/ffprobe.exe')}
process=None;log=(data/'backend.log').open('w',encoding='utf-8')
def api(method,path,body=None):
request=urllib.request.Request('http://127.0.0.1:18980/api/v1'+path,method=method,data=None if body is None else json.dumps(body).encode(),headers={'Content-Type':'application/json'})
with urllib.request.urlopen(request,timeout=15) as response:return json.load(response).get('data',{})
def wait(fn):
deadline=time.monotonic()+20
while time.monotonic()<deadline:
result=fn()
if result:return result
time.sleep(.1)
raise AssertionError('Timed out')
def start():
global process
process=subprocess.Popen([str(stage/'HuobaoDrama.exe'),str(data/'backend.mjs')],cwd=data,env=env,stdout=log,stderr=log,creationflags=subprocess.CREATE_NO_WINDOW)
for _ in range(120):
assert process.poll() is None
try:api('GET','/health');return
except OSError:time.sleep(.1)
raise AssertionError('Failed to start')
def stop():
global process
process.terminate();process.wait(10);process=None
def record(id):return sql('select * from sys_task where id=?',(id,))[0]
def cleanup(id):return json.loads(record(id)['params']).get('remoteCleanup',{})
def send():return api('POST','/tasks',{'type':'video','config_id':1,'model':'MiniMax-H3','prompt':'A blue square.','duration':4})['id']
def due(id):
params=json.loads(record(id)['params']);params['remoteCleanup']['nextAttemptAt']='2000-01-01T00:00:00Z'
sql('update sys_task set params=? where id=?',(json.dumps(params),id))
try:
start();ts='2026-11-12T00:00:00Z'
sql("insert into ai_service_configs(id,service_type,provider,name,base_url,api_key,model,is_active,created_at,updated_at) values(1,'video','minimax','fixture','http://127.0.0.1:18981','fixture-only','[\"MiniMax-H3\"]',1,?,?)",(ts,ts))
first=send();wait(lambda:cleanup(first).get('status')=='cleaned')
assert record(first)['status']=='completed' and cleanup(first)['localReceipt']['sha256']==digest
assert [e[0] for e in events]==['generate','download','cleanup']
# A failed acknowledgement cannot turn a successful download into a failed video.
mode='error';retry=send();wait(lambda:cleanup(retry).get('lastError'))
assert record(retry)['status']=='completed';before=sum(e[0]=='generate' for e in events)
mode='cleaned';due(retry);stop();start();wait(lambda:cleanup(retry).get('status')=='cleaned')
assert sum(e[0]=='generate' for e in events)==before and sum(e[0]=='download' for e in events)==2
# Truncated MP4 headers must never authorize deletion.
mode='invalid';bad=send();wait(lambda:record(bad)['status']=='failed')
assert not cleanup(bad) and not any(e[0]=='cleanup' and e[1]==record(bad)['task_id'] for e in events)
# A modified local file blocks later acknowledgement retries.
mode='error';changed=send();wait(lambda:cleanup(changed).get('lastError'));old_calls=len(events)
(data/record(changed)['local_path']).write_bytes(b'changed')
due(changed);stop();start();wait(lambda:'已改变' in cleanup(changed).get('lastError',''))
assert len(events)==old_calls and record(changed)['status']=='completed'
# Already completed historic tasks without a receipt are never swept.
sql("insert into sys_task(type,provider,config_id,config_base_url,task_id,status,local_path,params,created_at,updated_at) values('video','minimax',1,'http://127.0.0.1:18981','historical','completed',?,'{}',?,?)",(record(first)['local_path'],ts,ts))
stop();start();assert len(events)==old_calls
if os.environ.get('CLEANUP_UI_TEST'):
sql("insert into dramas(id,title,created_at,updated_at) values(1,'Cleanup fixture',?,?)",(ts,ts))
sql("insert into episodes(id,drama_id,episode_number,title,content,script_content,created_at,updated_at) values(1,1,1,'Fixture','Test script','Test script',?,?)",(ts,ts))
for i in [1,2]:sql("insert into storyboards(id,episode_id,storyboard_number,description,duration,created_at,updated_at) values(?,1,?,'Cleanup fixture shot',4,?,?)",(i,i,ts,ts))
sql('update sys_task set storyboard_id=1,drama_id=1 where id=?',(first,))
sql('update sys_task set storyboard_id=2,drama_id=1 where id=?',(changed,))
subprocess.run(['node',str(root/'integration-work/browser-test/verify_video_cleanup_ui.cjs')],env={**os.environ,'CLEANUP_FIXTURE':str(data)},check=True)
report={'passed':True,'fixture':str(data),'checks':['decode and durable local save before ack','checksum receipt','cleanup failure keeps completed video','restart retry no redownload','corrupt video no cleanup','modified local file no cleanup','no historic sweep']}
(data/'result.json').write_text(json.dumps(report,indent=2),'utf-8');print(json.dumps(report))
finally:
if process:stop()
server.shutdown();log.close()