Files

79 lines
5.8 KiB
Python

#!/usr/bin/env python3
"""Isolated room mutants. Own worktree dependencies, assertion kills only, killpg after each run."""
import argparse, hashlib, json, os, signal, subprocess, tarfile, tempfile, time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[4]
UI = 'packages/webui/src/public/chat.js'
HTTP = 'packages/webui/src/chat-http.mjs'
BROKER = 'packages/bus/src/broker.mjs'
PLUGIN = 'packages/plugins/channel/webui/index.mjs'
SEAM = 'packages/webui/src/chat.mjs'
BROWSER_TEST = 'packages/webui/tests/room-browser.test.mjs'
HTTP_TEST = 'packages/webui/tests/room-http.test.mjs'
BUS_TEST = 'packages/bus/tests/room-check.test.mjs'
PLUGIN_TEST = 'packages/plugins/tests/room-channel.test.mjs'
MUTANTS = [
('M01-plugin', PLUGIN, ' "room.open",\n', '', PLUGIN_TEST),
('M02-seam-body', SEAM, "channel('room.send', { project, body })", "channel('room.send', { project, body: body.trim() })", HTTP_TEST),
('M03-page-cursor', HTTP, 'value.at(-1)?.ids.at(-1)', 'value.at(-1)?.id', HTTP_TEST),
('M04-room-status', HTTP, 'Object.keys(ROOM_LINES).map(code => [code, 409])', 'Object.keys(ROOM_LINES).map(code => [code, 400])', HTTP_TEST),
('M05-strict-body', HTTP, "!exact(v, ['body']) || ", '', HTTP_TEST),
('M06-receipt-type', HTTP, "typeof t.instance === 'string' && ", '', HTTP_TEST),
('M07-room-check', BROKER, 'return entries.map((m) => this.#withCheck(m));', 'return entries;', BUS_TEST),
('M08-room-last', BROKER, "['thread.messages', 'room.read'].includes(request.verb)", "['thread.messages'].includes(request.verb)", BUS_TEST),
('M09-browser-body', UI, 'request(`rooms/${c.id}/messages`, { body })', 'request(`rooms/${c.id}/messages`, { body: body.trim() })', BROWSER_TEST),
('M10-members', UI, 'catalog.rows.filter(m => threads.rows.some(t => t.project === id && t.instance === m.instance && t.name === m.instance))', 'catalog.rows', BROWSER_TEST),
('M11-checked-render', UI, 'messageBody({ ...m, from_role:', 'messageBody({ ...m, check: null, from_role:', BROWSER_TEST),
('M12-leading-tags', UI, 'let body = input.trimStart();', 'let body = input;', BROWSER_TEST),
('M13-readonly', UI, 'if (!roomOwner() || !c.canSend || !c.roomReady || out())', 'if (!roomOwner() || !c.roomReady || out())', BROWSER_TEST),
('M14-uncertainty', HTTP, 'write ? sendOutcome(err) :', "write ? { code: 'invalid-request', sent: false } :", HTTP_TEST),
('M15-pending', UI, ' if (s.pending) return;', '', BROWSER_TEST),
('M16-body-dedup', UI, 'new Map((after ? [...s.rows, ...page.rows] : page.rows).map(m => [m.id, m]))', 'new Map((after ? [...s.rows, ...page.rows] : page.rows).map(m => [m.body, m]))', BROWSER_TEST),
]
def sha(p): return hashlib.sha256(p.read_bytes()).hexdigest()
def main():
p=argparse.ArgumentParser(); p.add_argument('--output',type=Path,required=True);p.add_argument('--only');a=p.parse_args()
names=set(a.only.split(',')) if a.only else {m[0] for m in MUTANTS}
assert names <= {m[0] for m in MUTANTS}
assert not subprocess.check_output(['git','status','--porcelain','--','packages'],cwd=ROOT,text=True).strip(),'Commit tested packages first'
assert (ROOT/'node_modules').is_dir() and not (ROOT/'node_modules').is_symlink(),'Local install required'
before={f:sha(ROOT/f) for f in {m[1] for m in MUTANTS}}
head=subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()
a.output.mkdir(parents=True,exist_ok=True);results=[]
with tempfile.TemporaryDirectory(prefix='fethry266-mutants-') as temp:
temp=Path(temp);archive=temp/'source.tar'
with archive.open('wb') as stream:
subprocess.run(['git','archive','HEAD','packages','roles','contracts','tasks','missions','package.json','RELEASE'],cwd=ROOT,stdout=stream,check=True)
for name,target,old,new,test in MUTANTS:
if name not in names:continue
tree=temp/name;tree.mkdir()
with tarfile.open(archive) as bundle:bundle.extractall(tree,filter='data')
(tree/'node_modules').symlink_to(ROOT/'node_modules',target_is_directory=True)
path=tree/target;source=path.read_text();hits=source.count(old)
assert hits==1,(name,'anchor hits',hits)
path.write_text(source.replace(old,new,1));limit=240 if test==BROWSER_TEST else 120
cmd=['timeout','-k','10',str(limit),'node','--test','--test-reporter=tap','--test-concurrency=1','--test-timeout=30000',test]
signals=[];started=time.monotonic()
with (a.output/(name+'.tap')).open('w') as log:
child=subprocess.Popen(cmd,cwd=tree,env={**os.environ,'MOSAIC_REQUIRE_EDITOR_BUILD':'1'},stdout=log,stderr=subprocess.STDOUT,start_new_session=True)
try:code=child.wait(timeout=limit+15)
except subprocess.TimeoutExpired:os.killpg(child.pid,signal.SIGKILL);signals.append('KILL');code=child.wait()
finally:
try:
os.killpg(child.pid,signal.SIGTERM);signals.append('TERM');time.sleep(.2)
try:os.killpg(child.pid,signal.SIGKILL);signals.append('KILL')
except ProcessLookupError:pass
except ProcessLookupError:pass
path.write_text(source)
restored=sha(path)==hashlib.sha256(source.encode()).hexdigest();unchanged=before=={f:sha(ROOT/f) for f in before}
assert restored and unchanged,(name,'restore changed')
output=(a.output/(name+'.tap')).read_text()
killed=code==1 and 'ERR_ASSERTION' in output and 'not ok ' in output and "failureType: 'hookFailed'" not in output and '# cancelled 0\n' in output and '# skipped 0\n' in output
result={'mutant':name,'test':test,'exit':code,'killed':killed,'hits':hits,'restored':restored,'candidateUnchanged':unchanged,'seconds':round(time.monotonic()-started,3),'cleanupSignals':signals}
results.append(result);print(json.dumps(result),flush=True)
if not killed:break
report={'source':head,'selected':sorted(names),'candidateUnchanged':before=={f:sha(ROOT/f) for f in before},'results':results}
(a.output/'summary.json').write_text(json.dumps(report,indent=2)+'\n')
if len(results)!=len(names) or not all(r['killed'] for r in results):raise SystemExit(1)
if __name__=='__main__':main()