#!/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 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) : 'no address'}''', '''${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, "${canSend ? `${typeof c.role", "${true ? `${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')