mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
351 lines
16 KiB
Clojure
351 lines
16 KiB
Clojure
(ns swarmforge.script-test
|
|
(:require [babashka.fs :as fs]
|
|
[clojure.java.shell :as sh]
|
|
[clojure.string :as str]
|
|
[clojure.test :refer [deftest is testing]]))
|
|
|
|
(def repo-root (fs/cwd))
|
|
(def scripts-dir (fs/path repo-root "swarmforge" "scripts"))
|
|
|
|
(defn write-file [path text]
|
|
(fs/create-dirs (fs/parent path))
|
|
(spit (str path) text))
|
|
|
|
(defn run
|
|
[{:keys [dir env ok?]} & args]
|
|
(let [result (apply sh/sh (concat args [:dir (str dir)
|
|
:env (merge {"PATH" (System/getenv "PATH")
|
|
"GIT_CONFIG_NOSYSTEM" "1"}
|
|
env)]))]
|
|
(when (and (not (false? ok?)) (not= 0 (:exit result)))
|
|
(throw (ex-info (str "Command failed: " (str/join " " args))
|
|
(assoc result :args args))))
|
|
result))
|
|
|
|
(defn init-repo! [root]
|
|
(run {:dir root} "git" "init" "-q")
|
|
(run {:dir root} "git" "config" "user.email" "test@example.com")
|
|
(run {:dir root} "git" "config" "user.name" "Test User")
|
|
(write-file (fs/path root "README.md") "initial\n")
|
|
(run {:dir root} "git" "add" "README.md")
|
|
(run {:dir root} "git" "commit" "-q" "-m" "Initial commit"))
|
|
|
|
(defn tmp-dir []
|
|
(fs/create-temp-dir {:prefix "swarmforge-script-test."}))
|
|
|
|
(defn script [name]
|
|
(str (fs/path scripts-dir name)))
|
|
|
|
(deftest handoff-lib-parses-and-prints-handoff-files
|
|
(let [root (tmp-dir)
|
|
handoff-file (fs/path root "task.handoff")]
|
|
(try
|
|
(write-file handoff-file
|
|
(str "id: 1\n"
|
|
"from: coder\n"
|
|
"to: cleaner\n"
|
|
"priority: 10\n"
|
|
"type: git_handoff\n"
|
|
"task: task-alpha\n"
|
|
"\n"
|
|
"merge_and_process coder abcdef1234\n"))
|
|
(let [header (run {:dir root} (script "handoff_lib.bb") "header-field" "task.handoff" "task")
|
|
body (run {:dir root} (script "handoff_lib.bb") "body" "task.handoff")
|
|
task (run {:dir root} (script "handoff_lib.bb") "print-task" "task.handoff")]
|
|
(is (str/includes? (:out header) "task-alpha"))
|
|
(is (str/includes? (:out body) "merge_and_process coder abcdef1234"))
|
|
(is (str/includes? (:out task) "TASK: task.handoff"))
|
|
(is (str/includes? (:out task) "FROM: coder"))
|
|
(is (str/includes? (:out task) "TASK_NAME: task-alpha")))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest handoff-lib-updates-headers-and-reads-role-state
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(init-repo! root)
|
|
(write-file (fs/path root ".swarmforge/roles.tsv")
|
|
(str "coder\tmaster\t" root "\tsession\tCoder\tcodex\ttask\n"
|
|
"cleaner\tcleaner\t" root "/.worktrees/cleaner\tsession\tCleaner\tcodex\tbatch\n"))
|
|
(write-file (fs/path root ".swarmforge/handoffs/inbox/new/item.handoff")
|
|
(str "id: 1\n"
|
|
"from: coder\n"
|
|
"to: cleaner\n"
|
|
"priority: 20\n"
|
|
"type: note\n"
|
|
"\n"
|
|
"payload\n"))
|
|
(run {:dir root} (script "handoff_lib.bb") "role-known" "cleaner")
|
|
(run {:dir root} (script "handoff_lib.bb") "set-header" ".swarmforge/handoffs/inbox/new/item.handoff" "dequeued_at" "2026-06-16T00:00:00Z")
|
|
(let [mode (run {:dir root} (script "handoff_lib.bb") "role-receive-mode" "cleaner")
|
|
worktree (run {:dir root} (script "handoff_lib.bb") "role-worktree-name" "cleaner")
|
|
dequeued (run {:dir root} (script "handoff_lib.bb") "header-field" ".swarmforge/handoffs/inbox/new/item.handoff" "dequeued_at")
|
|
seq-1 (run {:dir root} (script "handoff_lib.bb") "next-sequence")
|
|
seq-2 (run {:dir root} (script "handoff_lib.bb") "next-sequence")]
|
|
(is (str/includes? (:out mode) "batch"))
|
|
(is (str/includes? (:out worktree) "cleaner"))
|
|
(is (str/includes? (:out dequeued) "2026-06-16T00:00:00Z"))
|
|
(is (str/includes? (:out seq-1) "000001"))
|
|
(is (str/includes? (:out seq-2) "000002")))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest swarmforge-launcher-parses-config-and-writes-state-files
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(write-file (fs/path root "swarmforge/constitution.prompt")
|
|
"Read articles.\n")
|
|
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
|
(str "# comment\n"
|
|
"window coder codex master\n"
|
|
"window cleaner codex cleaner batch\n"))
|
|
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
|
(write-file (fs/path root "swarmforge/roles/cleaner.prompt") "cleaner\n")
|
|
(let [result (run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))]
|
|
(is (str/includes? (:out result) "coder Coder"))
|
|
(is (str/includes? (:out result) "cleaner Cleaner"))
|
|
(is (str/includes? (:out result) "cleaner batch"))
|
|
(is (str/includes? (:out result) "swarmforge-coder"))
|
|
(is (str/includes? (:out result) "swarmforge-cleaner"))
|
|
(is (fs/exists? (fs/path root ".swarmforge/tmux-socket"))))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest swarmforge-uses-portable-tmux-socket-dir
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(write-file (fs/path root "swarmforge/constitution.prompt")
|
|
"Read articles.\n")
|
|
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
|
"window coder codex master\n")
|
|
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
|
(run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))
|
|
(let [socket-path (str/trim (slurp (str (fs/path root ".swarmforge/tmux-socket"))))]
|
|
(is (str/starts-with? socket-path "/tmp/swarmforge-"))
|
|
(is (not (str/starts-with? socket-path "/private/tmp/"))))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest swarmforge-launcher-rejects-invalid-config
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(write-file (fs/path root "swarmforge/constitution.prompt")
|
|
"Read articles.\n")
|
|
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
|
(str "window coder codex master\n"
|
|
"window coder codex other\n"))
|
|
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
|
(let [result (run {:dir root :ok? false} (script "swarmforge.bb") "--test-parse" (str root))]
|
|
(is (= 1 (:exit result)))
|
|
(is (str/includes? (:err result) "Duplicate role 'coder'")))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest swarmforge-terminal-bridge-preserves-adapter-globals
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(write-file (fs/path root "swarmforge/scripts/swarm-terminal-adapter.sh")
|
|
(str "load_terminal_backend() {\n"
|
|
" source \"$SCRIPT_DIR/terminal-adapters/$1.sh\"\n"
|
|
"}\n"))
|
|
(write-file (fs/path root "swarmforge/scripts/terminal-adapters/probe.sh")
|
|
(str "terminal_open_session() {\n"
|
|
" printf '%s\\n' \"$WORKING_DIR|$TMUX_SOCKET|$1|$2|$3\"\n"
|
|
"}\n"))
|
|
(let [result (run {:dir root}
|
|
(script "swarmforge.bb")
|
|
"--test-terminal-bridge"
|
|
(str root)
|
|
"probe")]
|
|
(is (str/includes? (:out result) (str root "|")))
|
|
(is (str/includes? (:out result) "|swarmforge-specifier|SwarmForge Specifier|"))
|
|
(is (not (str/includes? (:out result) "cd ''")))
|
|
(is (not (str/includes? (:out result) "-S ''"))))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest swarmforge-agent-start-delay-is-configurable
|
|
(let [default-result (run {:dir repo-root}
|
|
(script "swarmforge.bb")
|
|
"--test-agent-start-delay")
|
|
configured-result (run {:dir repo-root
|
|
:env {"SWARMFORGE_AGENT_START_DELAY_MS" "2750"}}
|
|
(script "swarmforge.bb")
|
|
"--test-agent-start-delay")
|
|
invalid-result (run {:dir repo-root
|
|
:env {"SWARMFORGE_AGENT_START_DELAY_MS" "fast"}}
|
|
(script "swarmforge.bb")
|
|
"--test-agent-start-delay")]
|
|
(is (= "1500" (str/trim (:out default-result))))
|
|
(is (= "2750" (str/trim (:out configured-result))))
|
|
(is (= "1500" (str/trim (:out invalid-result))))))
|
|
|
|
(deftest swarmforge-sleep-prevention-can-be-disabled
|
|
(let [result (run {:dir repo-root
|
|
:env {"SWARMFORGE_PREVENT_SLEEP" "0"}}
|
|
(script "swarmforge.bb")
|
|
"--test-sleep-inhibitor-prefix")]
|
|
(is (= "" (str/trim (:out result))))))
|
|
|
|
(deftest swarmforge-launcher-parses-extra-cli-args
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(write-file (fs/path root "swarmforge/constitution.prompt")
|
|
"Read articles.\n")
|
|
(write-file (fs/path root "swarmforge/swarmforge.conf")
|
|
(str "window coder copilot master --yolo\n"
|
|
"window cleaner copilot cleaner batch --allow-all-tools\n"))
|
|
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
|
|
(write-file (fs/path root "swarmforge/roles/cleaner.prompt") "cleaner\n")
|
|
(let [result (run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))]
|
|
(is (str/includes? (:out result) "coder Coder"))
|
|
(is (str/includes? (:out result) "task --yolo"))
|
|
(is (str/includes? (:out result) "batch --allow-all-tools")))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest copilot-launch-command-passes-extra-cli-args
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(let [result (run {:dir root}
|
|
(script "swarmforge.bb")
|
|
"--test-launch-command"
|
|
(str root)
|
|
"copilot"
|
|
"--yolo")
|
|
command (:out result)]
|
|
(is (str/includes? command "copilot -C "))
|
|
(is (re-find #"--name 'SwarmForge Coder' --yolo -i" command)))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest grok-launch-command-passes-initial-prompt
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(let [result (run {:dir root}
|
|
(script "swarmforge.bb")
|
|
"--test-launch-command"
|
|
(str root)
|
|
"grok")
|
|
command (:out result)]
|
|
(is (str/includes? command "grok --cwd "))
|
|
(is (str/includes? command "--permission-mode acceptEdits"))
|
|
(is (str/includes? command "--rules \"$(cat "))
|
|
(is (str/includes? command "--verbatim \"$(cat "))
|
|
(is (str/includes? command ".swarmforge/prompts/coder.md"))
|
|
(is (fs/exists? (fs/path root ".swarmforge/prompts/coder.md"))))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest grok-launch-command-uses-bypass-permissions-with-always-approve
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(let [result (run {:dir root}
|
|
(script "swarmforge.bb")
|
|
"--test-launch-command"
|
|
(str root)
|
|
"grok"
|
|
"--always-approve")
|
|
command (:out result)]
|
|
(is (str/includes? command "--permission-mode bypassPermissions"))
|
|
(is (str/includes? command "--always-approve"))
|
|
(is (not (str/includes? command "--permission-mode acceptEdits"))))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest window-watchdog-rewrites-window-state-and-id-list
|
|
(let [root (tmp-dir)
|
|
state-file (fs/path root "windows.tsv")
|
|
ids-file (fs/path root "window-ids")]
|
|
(try
|
|
(write-file state-file
|
|
(str "1\told-a\tswarmforge-coder\tSwarmForge Coder\n"
|
|
"2\told-b\tswarmforge-cleaner\tSwarmForge Cleaner\n"))
|
|
(write-file ids-file "old-a\nold-b\n")
|
|
(run {:dir root} (script "swarm-window-watchdog.bb") "--rewrite-window-id" "windows.tsv" "window-ids" "2" "new-b")
|
|
(let [state (slurp (str state-file))
|
|
ids (slurp (str ids-file))]
|
|
(is (str/includes? state "1\told-a\tswarmforge-coder\tSwarmForge Coder"))
|
|
(is (str/includes? state "2\tnew-b\tswarmforge-cleaner\tSwarmForge Cleaner"))
|
|
(is (= "old-a\nnew-b\n" ids)))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest swarmforge-detects-nonzero-pane-base-index
|
|
(let [root (tmp-dir)
|
|
sock (str root "/test.sock")
|
|
conf (fs/path root "tmux.conf")]
|
|
(try
|
|
(write-file conf "set -g base-index 1\nset -g pane-base-index 1\n")
|
|
(run {:dir root} "tmux" "-S" sock "-f" (str conf) "new-session" "-d" "-s" "probe" "sleep" "120")
|
|
(let [result (run {:dir root}
|
|
(script "swarmforge.bb")
|
|
"--test-tmux-base-indexes"
|
|
sock)]
|
|
(is (= "1 1" (str/trim (:out result)))))
|
|
(finally
|
|
(run {:dir root :ok? false} "tmux" "-S" sock "kill-server")
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest swarm-cleanup-tolerates-missing-runtime-state
|
|
(let [root (tmp-dir)
|
|
ids-file (fs/path root ".swarmforge/window-ids")]
|
|
(try
|
|
(write-file ids-file "window-a\nwindow-b\n")
|
|
(let [result (run {:dir root
|
|
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
|
|
(str (fs/path scripts-dir "swarm-cleanup.sh"))
|
|
"/tmp/nonexistent.sock"
|
|
(str ids-file))]
|
|
(is (= 0 (:exit result)))
|
|
(is (= "" (:err result))))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(defn close-swarm []
|
|
(str (fs/path repo-root "close-swarm")))
|
|
|
|
(deftest close-swarm-reports-when-no-swarm-state
|
|
(let [root (tmp-dir)]
|
|
(try
|
|
(let [result (run {:dir root :ok? false
|
|
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
|
|
(close-swarm)
|
|
(str root))]
|
|
(is (not= 0 (:exit result)))
|
|
(is (str/includes? (str (:err result) (:out result)) "No SwarmForge swarm")))
|
|
(finally
|
|
(fs/delete-tree root)))))
|
|
|
|
(deftest close-swarm-kills-tmux-sessions-and-stops-daemon
|
|
(let [root (tmp-dir)
|
|
sock (str (fs/path root "swarm.sock"))
|
|
pid-file (fs/path root ".swarmforge/daemon/handoffd.pid")
|
|
daemon (.start (java.lang.ProcessBuilder. ["sleep" "120"]))
|
|
pid (str (.pid daemon))]
|
|
(try
|
|
(write-file (fs/path root ".swarmforge/tmux-socket") (str sock "\n"))
|
|
(write-file (fs/path root ".swarmforge/sessions.tsv")
|
|
(str "1\tcoder\tswarmforge-coder\tCoder\tcodex\n"
|
|
"2\tcleaner\tswarmforge-cleaner\tCleaner\tcodex\n"))
|
|
(write-file (fs/path root ".swarmforge/window-ids") "win-a\nwin-b\n")
|
|
(write-file pid-file (str pid "\n"))
|
|
(run {:dir root} "tmux" "-S" sock "new-session" "-d" "-s" "swarmforge-coder" "sleep" "120")
|
|
(run {:dir root} "tmux" "-S" sock "new-session" "-d" "-s" "swarmforge-cleaner" "sleep" "120")
|
|
(let [result (run {:dir root
|
|
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
|
|
(close-swarm)
|
|
(str root))]
|
|
(is (= 0 (:exit result)))
|
|
(is (not= 0 (:exit (run {:dir root :ok? false}
|
|
"tmux" "-S" sock "has-session" "-t" "swarmforge-coder"))))
|
|
(is (not= 0 (:exit (run {:dir root :ok? false}
|
|
"tmux" "-S" sock "has-session" "-t" "swarmforge-cleaner"))))
|
|
(is (not (fs/exists? pid-file)))
|
|
(is (false? (.isAlive daemon))))
|
|
(finally
|
|
(when (.isAlive daemon)
|
|
(.destroyForcibly daemon))
|
|
(run {:dir root :ok? false} "tmux" "-S" sock "kill-server")
|
|
(fs/delete-tree root)))))
|