feat: add native Factory Droid hook integration
This commit is contained in:
1 parent
2eff1ae119
commit
577e3609b5
13 files changed
+2292
-1
No files matched your search
@@ -0,0 +1,263 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDroidRegistryUsesOfficialGlobalHookSource(t *testing.T) {
|
||||
entry := SupportedAgents[droidAgentKey]
|
||||
if entry.Name != "Factory Droid" || entry.ConfigDir != "~/.factory" || entry.ConfigFile != "hooks.json" {
|
||||
t.Fatalf("Factory Droid registry entry = %#v", entry)
|
||||
}
|
||||
if _, ok := NewPlugin(droidAgentKey).(*DroidPlugin); !ok {
|
||||
t.Fatal("Factory Droid plugin is not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroidInstallPreservesJSONCAndUsesPerEventPrecedence(t *testing.T) {
|
||||
hooks := `{
|
||||
// root hook source
|
||||
"PreToolUse": [
|
||||
// keep root group comment
|
||||
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "root-user" }] }
|
||||
],
|
||||
"Notification": []
|
||||
}
|
||||
`
|
||||
settings := `{
|
||||
// general setting
|
||||
"theme": "dark",
|
||||
"hooks": {
|
||||
// settings fallback event
|
||||
"PostToolUse": [
|
||||
// keep settings group comment
|
||||
{ "matcher": "Edit", "hooks": [{ "type": "command", "command": "settings-user" }] }
|
||||
],
|
||||
"showHookOutput": true,
|
||||
},
|
||||
}
|
||||
`
|
||||
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
||||
hooks: droidString(hooks), settings: droidString(settings),
|
||||
})
|
||||
installDroidFixture(t, fixture)
|
||||
firstRoot := readDroidTestFile(t, fixture.configPath)
|
||||
firstSettings := readDroidTestFile(t, fixture.settingsPath)
|
||||
installDroidFixture(t, fixture)
|
||||
|
||||
if readDroidTestFile(t, fixture.configPath) != firstRoot ||
|
||||
readDroidTestFile(t, fixture.settingsPath) != firstSettings {
|
||||
t.Fatal("idempotent install changed Factory Droid hook sources")
|
||||
}
|
||||
for _, marker := range []string{"root hook source", "keep root group comment"} {
|
||||
if !strings.Contains(firstRoot, marker) {
|
||||
t.Fatalf("root JSONC comment %q was lost", marker)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"general setting", "settings fallback event", "keep settings group comment"} {
|
||||
if !strings.Contains(firstSettings, marker) {
|
||||
t.Fatalf("settings JSONC comment %q was lost", marker)
|
||||
}
|
||||
}
|
||||
root := readDroidTestObject(t, fixture.configPath)
|
||||
userSettings := readDroidTestObject(t, fixture.settingsPath)
|
||||
preGroups := requireDroidArray(t, root["PreToolUse"])
|
||||
if len(preGroups) != 2 || requireDroidObject(t, requireDroidArray(t,
|
||||
requireDroidObject(t, preGroups[0])["hooks"])[0])["command"] != "root-user" {
|
||||
t.Fatalf("root PreToolUse groups = %#v", preGroups)
|
||||
}
|
||||
if _, exists := root["PostToolUse"]; exists {
|
||||
t.Fatalf("PostToolUse leaked into root source: %#v", root)
|
||||
}
|
||||
if userSettings["theme"] != "dark" {
|
||||
t.Fatalf("settings theme = %#v", userSettings["theme"])
|
||||
}
|
||||
settingsHooks := requireDroidObject(t, userSettings["hooks"])
|
||||
postGroups := requireDroidArray(t, settingsHooks["PostToolUse"])
|
||||
if len(postGroups) != 2 {
|
||||
t.Fatalf("settings PostToolUse groups = %#v", postGroups)
|
||||
}
|
||||
if _, exists := settingsHooks["PreToolUse"]; exists {
|
||||
t.Fatalf("PreToolUse leaked into settings source: %#v", settingsHooks)
|
||||
}
|
||||
for _, item := range []struct {
|
||||
groups any
|
||||
path string
|
||||
}{{preGroups, fixture.guardPath}, {postGroups, fixture.hookPath}} {
|
||||
handler := droidManagedHandler(t, item.groups, item.path)
|
||||
if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != float64(10) {
|
||||
t.Fatalf("managed handler = %#v", handler)
|
||||
}
|
||||
}
|
||||
requireMissingDroidFile(t, filepath.Join(fixture.workspaceDir, ".factory", "hooks.json"))
|
||||
}
|
||||
|
||||
func TestDroidInstallKeepsActiveLegacySource(t *testing.T) {
|
||||
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
||||
legacyHooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
|
||||
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
|
||||
})
|
||||
installDroidFixture(t, fixture)
|
||||
requireMissingDroidFile(t, fixture.configPath)
|
||||
droidManagedHandler(t, readDroidTestObject(t, fixture.legacyPath)["PreToolUse"], fixture.guardPath)
|
||||
settings := readDroidTestObject(t, fixture.settingsPath)
|
||||
droidManagedHandler(t, requireDroidObject(t, settings["hooks"])["PostToolUse"], fixture.hookPath)
|
||||
}
|
||||
|
||||
func TestDroidInstallReusesSettingsContainerAndFormatting(t *testing.T) {
|
||||
source := "{\r\n\t\"owner\": \"user\",\r\n\t\"hooks\": {}\r\n}\r\n"
|
||||
fixture := prepareDroidFixture(t, droidFixtureOptions{settings: droidString(source)})
|
||||
installDroidFixture(t, fixture)
|
||||
requireMissingDroidFile(t, fixture.configPath)
|
||||
raw := readDroidTestFile(t, fixture.settingsPath)
|
||||
if !strings.Contains(raw, "\r\n\t\t\"PreToolUse\"") ||
|
||||
!strings.Contains(raw, "\r\n\t\t\"PostToolUse\"") {
|
||||
t.Fatalf("settings formatting was not preserved:\n%s", raw)
|
||||
}
|
||||
if readDroidTestObject(t, fixture.settingsPath)["owner"] != "user" {
|
||||
t.Fatal("settings owner was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroidInstallReportsEventSourcesAndReviewCommand(t *testing.T) {
|
||||
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
||||
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
|
||||
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
|
||||
})
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("create output pipe: %v", err)
|
||||
}
|
||||
original := os.Stdout
|
||||
os.Stdout = writer
|
||||
installErr := fixture.plugin.Install(fixture.config)
|
||||
os.Stdout = original
|
||||
closeErr := writer.Close()
|
||||
output, readErr := io.ReadAll(reader)
|
||||
_ = reader.Close()
|
||||
if installErr != nil || closeErr != nil || readErr != nil {
|
||||
t.Fatalf("capture install output: install=%v close=%v read=%v", installErr, closeErr, readErr)
|
||||
}
|
||||
text := string(output)
|
||||
for _, marker := range []string{fixture.configPath, fixture.settingsPath, "PreToolUse", "PostToolUse", "/hooks"} {
|
||||
if !strings.Contains(text, marker) {
|
||||
t.Fatalf("install output is missing %q: %s", marker, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroidCommandsBlockAndForwardOfficialInputByteForByte(t *testing.T) {
|
||||
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
||||
installDroidFixture(t, fixture)
|
||||
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
||||
encodedPath, err := json.Marshal(capturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal capture path: %v", err)
|
||||
}
|
||||
captureScript := "const fs = require('node:fs'); const chunks = []; " +
|
||||
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
||||
"process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n"
|
||||
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
|
||||
t.Fatalf("write capture audit runtime: %v", err)
|
||||
}
|
||||
root := readDroidTestObject(t, fixture.configPath)
|
||||
guard := droidManagedHandler(t, root["PreToolUse"], fixture.guardPath)
|
||||
audit := droidManagedHandler(t, root["PostToolUse"], fixture.hookPath)
|
||||
prePayload := `{"session_id":"session-1","cwd":"C:/workspace","permission_mode":"auto-high","hook_event_name":"PreToolUse","tool_name":"Execute","tool_input":{"command":"echo test"}}`
|
||||
guardResult := runDroidCommand(t, guard["command"].(string), fixture.homeDir, prePayload)
|
||||
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") {
|
||||
t.Fatalf("guard result = %#v", guardResult)
|
||||
}
|
||||
postPayload := "{\n \"session_id\": \"session-1\",\n \"hook_event_name\": \"PostToolUse\",\n \"tool_response\": {\"success\": true}\n}"
|
||||
auditResult := runDroidCommand(t, audit["command"].(string), fixture.homeDir, postPayload)
|
||||
if auditResult.exitCode != 0 {
|
||||
t.Fatalf("audit result = %#v", auditResult)
|
||||
}
|
||||
if readDroidTestFile(t, capturePath) != postPayload {
|
||||
t.Fatal("audit hook changed the official Factory Droid payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroidStatusRequiresEnabledPairAndRuntimeFiles(t *testing.T) {
|
||||
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
||||
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
|
||||
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
|
||||
})
|
||||
installDroidFixture(t, fixture)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
||||
t.Fatalf("installed status = %#v, %v", status, err)
|
||||
}
|
||||
if err := os.Remove(fixture.hookPath); err != nil {
|
||||
t.Fatalf("remove audit runtime: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
||||
t.Fatalf("missing-runtime status = %#v, %v", status, err)
|
||||
}
|
||||
root := readDroidTestObject(t, fixture.configPath)
|
||||
root["hooksDisabled"] = true
|
||||
disabled, err := json.MarshalIndent(root, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode disabled hooks: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.configPath, append(disabled, '\n'), 0600); err != nil {
|
||||
t.Fatalf("disable hooks: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("disabled status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroidUninstallPreservesUserSourcesAndExactOwnership(t *testing.T) {
|
||||
hooks := "{\n // keep root comment\n \"PreToolUse\": []\n}\n"
|
||||
settings := "{\n \"theme\": \"dark\",\n \"hooks\": {\n" +
|
||||
" // keep settings comment\n \"PostToolUse\": []\n }\n}\n"
|
||||
fixture := prepareDroidFixture(t, droidFixtureOptions{
|
||||
hooks: droidString(hooks), settings: droidString(settings),
|
||||
})
|
||||
installDroidFixture(t, fixture)
|
||||
if err := fixture.plugin.Uninstall("agent-10"); err != nil {
|
||||
t.Fatalf("uninstall lookalike agent: %v", err)
|
||||
}
|
||||
droidManagedHandler(t, readDroidTestObject(t, fixture.configPath)["PreToolUse"], fixture.guardPath)
|
||||
agentID := droidTestAgentID
|
||||
if runtime.GOOS == "windows" {
|
||||
agentID = "AGENT-1"
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(agentID); err != nil {
|
||||
t.Fatalf("uninstall Factory Droid hooks: %v", err)
|
||||
}
|
||||
rootRaw := readDroidTestFile(t, fixture.configPath)
|
||||
settingsRaw := readDroidTestFile(t, fixture.settingsPath)
|
||||
if !strings.Contains(rootRaw, "keep root comment") || !strings.Contains(settingsRaw, "keep settings comment") {
|
||||
t.Fatal("uninstall removed user JSONC comments")
|
||||
}
|
||||
if len(requireDroidArray(t, readDroidTestObject(t, fixture.configPath)["PreToolUse"])) != 0 {
|
||||
t.Fatal("uninstall removed the user-owned empty event key")
|
||||
}
|
||||
currentSettings := readDroidTestObject(t, fixture.settingsPath)
|
||||
if currentSettings["theme"] != "dark" ||
|
||||
len(requireDroidArray(t, requireDroidObject(t, currentSettings["hooks"])["PostToolUse"])) != 0 {
|
||||
t.Fatalf("settings after uninstall = %#v", currentSettings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDroidUninstallDeletesOnlyOwnedEmptyHookFile(t *testing.T) {
|
||||
fixture := prepareDroidFixture(t, droidFixtureOptions{})
|
||||
installDroidFixture(t, fixture)
|
||||
if !strings.HasPrefix(readDroidTestFile(t, fixture.configPath), droidOwnedFileMarker) {
|
||||
t.Fatal("created hook source is missing its ownership marker")
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Factory Droid hooks: %v", err)
|
||||
}
|
||||
requireMissingDroidFile(t, fixture.configPath)
|
||||
}
|
||||
Reference in new issue
Block a user