#!/usr/bin/env bash # Darkwing row 61 mutants: single-site edits to the candidate in WORKTREE. # Each mutant backs up one file, edits one site with mut.mjs (exactly one # match, else BAD-SITE), runs the named test files and restores the backup. # Usage: mutants.sh WORKTREE [browser|light|NAME,NAME...] (TMPDIR must be # set; outputs go there). `light` skips the browser test mutants, `browser` # runs only them, and a list of names runs only those. set -u WT="$1" ONLY="${2:-}" MUT="$(cd "$(dirname "$0")/../queue-59-review/r1" && pwd)/mut.mjs" cd "$WT" || exit 1 F=packages/tasks/src/fields.mjs J=packages/tasks/fields.json V=packages/tasks/src/verbs.mjs S=packages/webui/src/serve.mjs R=packages/webui/src/reads.mjs B=packages/webui/src/public/bus.js TF=tests/fields.test.mjs m() { # name file pkg tests from to local name="$1" file="$2" pkg="$3" tests="$4" from="$5" to="$6" case "$ONLY" in light) [[ $tests == *browser* ]] && return ;; browser) [[ $tests == *browser* ]] || return ;; '') ;; *) [[ ,$ONLY, == *,$name,* ]] || return ;; esac cp -- "$file" "$TMPDIR/mut.bak" if ! node "$MUT" "$file" "$from" "$to"; then echo "$name BAD-SITE"; cp -- "$TMPDIR/mut.bak" "$file"; return; fi if (cd "packages/$pkg" && node --test $tests > "$TMPDIR/$name.out" 2>&1); then echo "$name SURVIVED" else echo "$name killed: $(grep -E '^✖ ' "$TMPDIR/$name.out" | grep -v 'failing tests' | sort -u | sed 's/ ([0-9.]*ms)$//' | tr '\n' ';')" fi cp -- "$TMPDIR/mut.bak" "$file" } # The schema (fields.mjs). m unknown-key-allowed $F tasks $TF "if (!allowed.includes(k)) throw" "if (false) throw" m required-key-optional $F tasks $TF "if (!Object.hasOwn(x, k)) throw" "if (false) throw" m writer-any-string $F tasks $TF "if (!WRITERS.includes(w)) throw" "if (typeof w !== 'string') throw" m field-twice $F tasks $TF "if (seen.has(f.field)) throw" "if (false) throw" m field-name-any $F tasks $TF "!FIELD.test(f.field)" "!f.field" m label-unbounded $F tasks $TF "!str(f.label, 80)" "!str(f.label, 1e9)" m note-any $F tasks $TF "f.note !== undefined && !str(f.note, 256)" "false" m verb-twice $F tasks $TF "if (mine.has(w.verb)) throw" "if (false) throw" m call-any $F tasks $TF "!CALL.test(c)" "!c" m call-twice $F tasks $TF "new Set(calls).size !== calls.length" "false" m unused-verb $F tasks $TF "if (!used.has(v)) throw" "if (false) throw" m verbs-subset $F tasks $TF "exact(table.verbs, 'verbs', verbs);" "exact(table.verbs, 'verbs', verbs, []);" m source-any $F tasks $TF "!str(table.source, 256)" "!table.source" m no-freeze $F tasks $TF " Object.freeze(x);" " void x;" m not-json-code $F tasks $TF "'fields.json: not JSON'" "'fields.json: unreadable'" # The table (fields.json): the walk must notice data that no longer matches the code. m scope-pm $J tasks $TF '"task.scope.change": "any"' '"task.scope.change": "pm"' m priority-any $J tasks $TF '"task.priority.change": "pm"' '"task.priority.change": "any"' m update-any $J tasks $TF '"task.update.assigned": "assigned"' '"task.update.assigned": "any"' m close-assigned $J tasks $TF '"task.close": "pm"' '"task.close": "assigned"' m reassign-no-delete $J tasks $TF '"calls": ["POST /tasks/{t}/assignees", "DELETE /tasks/{t}/assignees/{u}"]' '"calls": ["POST /tasks/{t}/assignees"]' m create-no-due $J tasks $TF $' "label": "Due date",\n "writes": [\n { "verb": "task.create", "calls": ["POST /projects/{p}/tasks"] },\n' $' "label": "Due date",\n "writes": [\n' m comments-by-close $J tasks $TF '"writes": [{ "verb": "task.update.assigned", "calls": ["POST /tasks/{t}/comments"] }]' '"writes": [{ "verb": "task.close", "calls": ["POST /tasks/{t}/comments"] }]' m done-to-bucket $J tasks $TF '"writes": [{ "verb": "task.close", "calls": ["PUT /projects/{p}/views/{k}/buckets/{b}/tasks"] }]' '"writes": [{ "verb": "task.update.assigned", "calls": ["PUT /projects/{p}/views/{k}/buckets/{b}/tasks"] }]' # The code (verbs.mjs): the walk must notice code that no longer matches the table. m code-priority-open $V tasks $TF "'task.schedule', 'task.priority.change', 'task.close'" "'task.schedule', 'task.close'" m code-scope-pm $V tasks $TF "'task.priority.change', 'task.close']);" "'task.priority.change', 'task.close', 'task.scope.change']);" m code-assigned-open $V tasks $TF "if (!fields.assignees.includes(mine.botId)) fail('not-assigned');" "void 0;" # The webui route and section. m route-any-method $S webui tests/reads.test.mjs "if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' }); return consoleRead(res, () => fieldTable(fieldsFile));" "return consoleRead(res, () => fieldTable(fieldsFile));" m route-ignores-file $S webui tests/reads.test.mjs "() => fieldTable(fieldsFile)" "() => fieldTable()" m read-partial $R webui tests/reads.test.mjs " try { return loadFields(file); } catch (err) {" " try { return loadFields(file); } catch (err) { return {};" m read-any-message $R webui tests/reads.test.mjs "err instanceof FieldTableError ? err.message : 'unreadable'" "'unreadable'" m section-throws $B webui tests/bus-browser.test.mjs "catch (err) { return \`

\${why(err)}

\`; }" "catch (err) { throw err; }" m section-writer-swap $B webui tests/bus-browser.test.mjs "const WRITER = { pm: 'pm', assigned: 'the assigned role', any: 'any role' };" "const WRITER = { pm: 'pm', assigned: 'any role', any: 'the assigned role' };" m section-no-note $B webui tests/bus-browser.test.mjs "\${f.note ? \`\${txt(f.note)}\` : ''}" "''"