Candidate on base a3f6e9e6, not committed here. Gate green 21:27:35Z-21:30:19Z;
mutants 30/35 killed, 5 equivalent guards explained in build.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
80 lines
7.1 KiB
Python
80 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
||
# Filbert's row 95 mutants (builder's own). Each edit must match exactly once. The candidate is
|
||
# uncommitted in the worktree, so each file is restored from the bytes read before the edit, and
|
||
# every touched file's sha256 is checked against its start at the end.
|
||
# Usage: mutants.py <worktree> <out.txt>
|
||
import hashlib, os, signal, subprocess, sys
|
||
|
||
WT, OUT = sys.argv[1], sys.argv[2]
|
||
A = 'packages/webui/src/public/app.js'
|
||
B = 'packages/webui/src/public/bus.js'
|
||
TESTS = ['tests/board-held.test.mjs', 'tests/send-browser.test.mjs', 'tests/browser-edge.test.mjs', 'tests/browser.test.mjs']
|
||
RD = "if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }"
|
||
|
||
M = [
|
||
('app getJSON without manual', A, "const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });", "const res = await fetch(path, { cache: 'no-store' });"),
|
||
('app api GET without manual', A, "value === undefined ? { cache: 'no-store', redirect: 'manual' }", "value === undefined ? { cache: 'no-store' }"),
|
||
('app api POST without manual', A, "body: JSON.stringify(value), redirect: 'manual' }", "body: JSON.stringify(value) }"),
|
||
('app api ignores a redirect', A, "{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });\n " + RD, "{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });\n"),
|
||
('bus fetchRead without manual', B, "=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }", "=${enc(subject)}`}`, { cache: 'no-store' }); }"),
|
||
('bus fetchRead ignores a redirect', B, "catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }\n " + RD, "catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }\n"),
|
||
('bus fetchApi without manual', B, "res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' });", "res = await fetch(`/api/${path}`, { cache: 'no-store' });"),
|
||
('bus send without manual', B, "body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });", "body: JSON.stringify({ to, body: text }), cache: 'no-store' });"),
|
||
('bus config without manual', B, "fetch('/api/config', { cache: 'no-store', redirect: 'manual' })", "fetch('/api/config', { cache: 'no-store' })"),
|
||
('accept: no signed-out guard', A, " if (out()) return; // a read that was in flight when the page signed out\n", ''),
|
||
('error: no signed-out guard', A, ' if (err.signedOut || out()) return;\n', ''),
|
||
('refresh reads with the toggle off', A, 'if (busy || !machine || out()) return;', 'if (busy || out()) return;'),
|
||
('schedule polls with the toggle off', A, 'if (!paused && machine && !out()) timer', 'if (!paused && !out()) timer'),
|
||
('refresh: no signed-out guard', A, 'if (busy || !machine || out()) return;', 'if (busy || !machine) return;'),
|
||
('refresh re-enables after sign-out', A, "busy = false; $('refresh').disabled = out(); schedule();", "busy = false; $('refresh').disabled = false; schedule();"),
|
||
('load: no hideMachine when off', A, ' if (!machine) hideMachine();\n', ''),
|
||
('setting not saved', A, " try { localStorage.setItem('mosaic-console-board', JSON.stringify({ machine })); } catch {}\n", ''),
|
||
('setting defaults on', A, "?.machine === true; } catch {}", "?.machine !== false; } catch {}"),
|
||
('toggle on reads nothing', A, "fresh(); refresh(); } else hideMachine();", "fresh(); } else hideMachine();"),
|
||
('hideMachine keeps the rows', A, " for (const id of ['waiting', 'seen', 'sessions']) $(id).replaceChildren();\n for (const id of ['waiting-count'", " for (const id of ['waiting-count'"),
|
||
('held: no address shows blank', B, '''${c.address ? txt(c.address) : '<span class="muted">no address</span>'}''', '''${c.address ? txt(c.address) : ''}'''),
|
||
('held: claims with no address dropped', B, 'const claims = r.rows;', 'const claims = r.rows.filter(c => c.address);'),
|
||
('Message without send', B, "</td>${canSend ? `<td>${typeof c.role", "</td>${true ? `<td>${typeof c.role"),
|
||
('Message keeps the old role', B, 'if (message) { draft.to = message.dataset.messageRole; focusSend = true;', 'if (message) { focusSend = true;'),
|
||
('Message: no focus in the form', B, 'if (message) { draft.to = message.dataset.messageRole; focusSend = true;', 'if (message) { draft.to = message.dataset.messageRole;'),
|
||
('bus render: no signed-out guard', B, " clearTimeout(timer);\n if (out()) return;\n", " clearTimeout(timer);\n"),
|
||
('bus schedule polls signed out', B, "if (!paused() && !out()) timer", "if (!paused()) timer"),
|
||
('board render draws after a signed-out read', B, " if (failed.some(err => err.code === 'signed-out')) return;\n", ''),
|
||
('bus view render draws a signed-out read', B, " if (err.code === 'signed-out') return;\n", ''),
|
||
('send after sign-out fetches', B, " if (out()) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in'", " if (false) { receipt = { kind: 'refused', to: to || 'no role', code: 'sign-in'"),
|
||
('send redirect does not sign out', B, " if (res?.type === 'opaqueredirect') signOut();\n", ''),
|
||
('bus listener keeps the held rows', B, " $('held').replaceChildren(); $('held-count').textContent = '–'; heldHtml = null;\n", " $('held-count').textContent = '–'; heldHtml = null;\n"),
|
||
('bus listener keeps the bus page', B, " for (const el of [...$('bus-view').children]) if (el.id !== 'send-section') el.remove();\n", ''),
|
||
('app listener keeps the sessions', A, " for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();\n", ''),
|
||
('app listener: refresh stays enabled', A, " $('refresh').disabled = $('pause').disabled = true;\n", ''),
|
||
]
|
||
|
||
env = dict(os.environ, TMPDIR=os.path.expanduser('~/filbert-scratch/tmp'), DOCKER_HOST='unix:///nonexistent.sock')
|
||
start = {p: hashlib.sha256(open(os.path.join(WT, p), 'rb').read()).hexdigest() for p in (A, B)}
|
||
out = open(OUT, 'w')
|
||
killed = total = 0
|
||
for name, path, old, new in M:
|
||
full = os.path.join(WT, path)
|
||
orig = open(full, 'rb').read()
|
||
src = orig.decode()
|
||
if src.count(old) != 1:
|
||
out.write(f'BADEDIT {name}: {src.count(old)} matches\n'); out.flush(); continue
|
||
open(full, 'w').write(src.replace(old, new))
|
||
try:
|
||
total += 1
|
||
# Its own process group: a timeout kills node --test's file runners and their browsers too.
|
||
p = subprocess.Popen(['node', '--test', *TESTS], cwd=os.path.join(WT, 'packages/webui'), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True)
|
||
try:
|
||
stdout = p.communicate(timeout=900)[0]
|
||
dead = p.returncode != 0
|
||
fails = [l.strip() for l in stdout.splitlines() if l.startswith('not ok')][:3]
|
||
except subprocess.TimeoutExpired:
|
||
os.killpg(p.pid, signal.SIGKILL); p.communicate()
|
||
dead, fails = True, ['timeout']
|
||
killed += dead
|
||
out.write(f"{'KILLED ' if dead else 'SURVIVED'} {name}{' <- ' + ' | '.join(fails) if fails else ''}\n"); out.flush()
|
||
finally:
|
||
open(full, 'wb').write(orig)
|
||
end = {p: hashlib.sha256(open(os.path.join(WT, p), 'rb').read()).hexdigest() for p in (A, B)}
|
||
out.write(f'{killed}/{total} killed; files restored: {start == end}\n')
|