104 lines
7.9 KiB
Python
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()
|