feat: add native Cline hook integration
This commit is contained in:
1 parent
9d03a68477
commit
2eff1ae119
10 files changed
+1316
-5
No files matched your search
@@ -0,0 +1,206 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClineStatusRequiresIntactHooksAndRuntimes(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(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 status.AgentName != "cline" || status.DisplayName != "Cline" || status.ConfigPath != fixture.hooksDir {
|
||||
t.Fatalf("status metadata = %#v", status)
|
||||
}
|
||||
if err := os.Remove(fixture.guardPath); err != nil {
|
||||
t.Fatalf("remove guard runtime: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
||||
t.Fatalf("degraded status = %#v, %v", status, err)
|
||||
}
|
||||
if err := os.WriteFile(fixture.guardPath, []byte("process.exit(0);\n"), 0700); err != nil {
|
||||
t.Fatalf("restore guard runtime: %v", err)
|
||||
}
|
||||
if err := os.Remove(fixture.auditWrapper); err != nil {
|
||||
t.Fatalf("remove audit wrapper: %v", err)
|
||||
}
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("partial status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineStatusSurfacesCorruptHooksAndRuntimeMetadata(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
guard := readClineTestFile(t, fixture.guardWrapper)
|
||||
if err := os.WriteFile(fixture.guardWrapper, []byte(guard+"\n// tampered\n"), 0700); err != nil {
|
||||
t.Fatalf("tamper guard wrapper: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "managed template") {
|
||||
t.Fatalf("tampered status error = %v", err)
|
||||
}
|
||||
installClineFixture(t, fixture)
|
||||
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
|
||||
t.Fatalf("corrupt runtime config: %v", err)
|
||||
}
|
||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
||||
t.Fatalf("runtime status error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineInstallRejectsUserFilenameCollisionsBeforeWrites(t *testing.T) {
|
||||
for _, collision := range []string{"guard", "audit"} {
|
||||
t.Run(collision, func(t *testing.T) {
|
||||
options := clineFixtureOptions{}
|
||||
if collision == "guard" {
|
||||
options.ExistingGuard = clineString("// user PreToolUse hook\n")
|
||||
} else {
|
||||
options.ExistingAudit = clineString("// user PostToolUse hook\n")
|
||||
}
|
||||
fixture := prepareClineFixture(t, options)
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "owned by another integration") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
}
|
||||
if options.ExistingGuard != nil && readClineTestFile(t, fixture.guardWrapper) != *options.ExistingGuard {
|
||||
t.Fatalf("user guard wrapper changed")
|
||||
}
|
||||
if options.ExistingAudit != nil && readClineTestFile(t, fixture.auditWrapper) != *options.ExistingAudit {
|
||||
t.Fatalf("user audit wrapper changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineInstallPreservesCorruptOwnedMetadataForRecovery(t *testing.T) {
|
||||
corrupt := "#!/usr/bin/env node\n// @elydora-cline-hook invalid\n"
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{ExistingGuard: &corrupt})
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "parse Elydora Cline hook metadata") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
if readClineTestFile(t, fixture.guardWrapper) != corrupt {
|
||||
t.Fatalf("corrupt hook changed")
|
||||
}
|
||||
requireMissingClineTestFile(t, fixture.auditWrapper)
|
||||
requireMissingClineTestFile(t, fixture.hookPath)
|
||||
}
|
||||
|
||||
func TestClineInstallRejectsMissingGuardBeforeWrites(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{SkipGuard: true})
|
||||
err := fixture.plugin.Install(fixture.config)
|
||||
if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") {
|
||||
t.Fatalf("install error = %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
fixture.guardWrapper,
|
||||
fixture.auditWrapper,
|
||||
fixture.hookPath,
|
||||
fixture.runtimeConfig,
|
||||
fixture.privateKey,
|
||||
} {
|
||||
requireMissingClineTestFile(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineUninstallRemovesExactOwnershipAndPreservesOtherHooks(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
userHook := filepath.Join(fixture.hooksDir, "PreToolUse.py")
|
||||
if err := os.WriteFile(userHook, []byte("# user hook\n"), 0600); err != nil {
|
||||
t.Fatalf("write user hook: %v", err)
|
||||
}
|
||||
if err := fixture.plugin.Uninstall("agent-10"); err != nil {
|
||||
t.Fatalf("uninstall other agent: %v", err)
|
||||
}
|
||||
readClineTestFile(t, fixture.guardWrapper)
|
||||
readClineTestFile(t, fixture.auditWrapper)
|
||||
if err := fixture.plugin.Uninstall(clineTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Cline hooks: %v", err)
|
||||
}
|
||||
requireMissingClineTestFile(t, fixture.guardWrapper)
|
||||
requireMissingClineTestFile(t, fixture.auditWrapper)
|
||||
if readClineTestFile(t, userHook) != "# user hook\n" {
|
||||
t.Fatalf("user hook changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineHookPairRollsBackFirstCommitWhenSecondCommitFails(t *testing.T) {
|
||||
fixture := prepareClineFixture(t, clineFixtureOptions{})
|
||||
installClineFixture(t, fixture)
|
||||
originalGuard := readClineTestFile(t, fixture.guardWrapper)
|
||||
originalAudit := readClineTestFile(t, fixture.auditWrapper)
|
||||
guardState, err := readClineHookFile(fixture.guardWrapper)
|
||||
if err != nil {
|
||||
t.Fatalf("read guard state: %v", err)
|
||||
}
|
||||
auditState, err := readClineHookFile(fixture.auditWrapper)
|
||||
if err != nil {
|
||||
t.Fatalf("read audit state: %v", err)
|
||||
}
|
||||
guardMetadata, err := buildClineMetadata("guard", "agent-2", fixture.guardPath)
|
||||
if err != nil {
|
||||
t.Fatalf("build replacement guard metadata: %v", err)
|
||||
}
|
||||
auditMetadata, err := buildClineMetadata("audit", "agent-2", fixture.hookPath)
|
||||
if err != nil {
|
||||
t.Fatalf("build replacement audit metadata: %v", err)
|
||||
}
|
||||
replacementGuard, err := buildClineWrapper(guardMetadata)
|
||||
if err != nil {
|
||||
t.Fatalf("build replacement guard: %v", err)
|
||||
}
|
||||
replacementAudit, err := buildClineWrapper(auditMetadata)
|
||||
if err != nil {
|
||||
t.Fatalf("build replacement audit: %v", err)
|
||||
}
|
||||
failureInjected := false
|
||||
rename := func(source, destination string) error {
|
||||
if !failureInjected && sameClineTestPath(destination, fixture.auditWrapper) {
|
||||
failureInjected = true
|
||||
return errors.New("simulated audit commit failure")
|
||||
}
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
err = writeClineHookPairWithRename(
|
||||
clinePendingWrite{state: guardState, source: replacementGuard},
|
||||
clinePendingWrite{state: auditState, source: replacementAudit},
|
||||
rename,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "write Cline hook pair") {
|
||||
t.Fatalf("write pair error = %v", err)
|
||||
}
|
||||
if !failureInjected {
|
||||
t.Fatalf("audit commit failure was not injected")
|
||||
}
|
||||
if readClineTestFile(t, fixture.guardWrapper) != originalGuard ||
|
||||
readClineTestFile(t, fixture.auditWrapper) != originalAudit {
|
||||
t.Fatalf("hook pair was not restored")
|
||||
}
|
||||
entries, err := os.ReadDir(fixture.hooksDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read hook directory: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".tmp") {
|
||||
t.Fatalf("temporary hook remains: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameClineTestPath(left, right string) bool {
|
||||
leftPath, leftErr := filepath.Abs(left)
|
||||
rightPath, rightErr := filepath.Abs(right)
|
||||
return leftErr == nil && rightErr == nil && strings.EqualFold(leftPath, rightPath)
|
||||
}
|
||||
Reference in new issue
Block a user