feat: add native Qwen Code hook integration
This commit is contained in:
1 parent
577e3609b5
commit
eae9015a61
18 files changed
+2138
-341
No files matched your search
@@ -0,0 +1,300 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQwenRegistryUsesOfficialUserSettings(t *testing.T) {
|
||||
entry := SupportedAgents[qwenAgentKey]
|
||||
if entry.Name != "Qwen Code" || entry.ConfigDir != "~/.qwen" || entry.ConfigFile != "settings.json" {
|
||||
t.Fatalf("Qwen Code registry entry = %#v", entry)
|
||||
}
|
||||
if _, ok := NewPlugin(qwenAgentKey).(*QwenPlugin); !ok {
|
||||
t.Fatal("Qwen Code plugin is not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenInstallPreservesJSONCommentsAndIsIdempotent(t *testing.T) {
|
||||
settings := "{\r\n" +
|
||||
" // Keep this user preference.\r\n" +
|
||||
" \"theme\": \"GitHub\",\r\n" +
|
||||
" \"hooks\": {\r\n" +
|
||||
" \"SessionStart\": [{ \"hooks\": [{ \"type\": \"command\", \"command\": \"session-hook\" }] }],\r\n" +
|
||||
" \"PreToolUse\": [{ \"matcher\": \"read_file\", \"hooks\": [{ \"type\": \"command\", \"command\": \"user-hook\" }] }]\r\n" +
|
||||
" }\r\n" +
|
||||
"}\r\n"
|
||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{settings: qwenString(settings)})
|
||||
workspaceSettings := filepath.Join(fixture.workspaceDir, ".qwen", "settings.json")
|
||||
writeOptionalQwenFile(t, workspaceSettings, qwenString("{ \"owner\": \"workspace\" }\n"))
|
||||
installQwenFixture(t, fixture)
|
||||
first := readQwenTestFile(t, fixture.configPath)
|
||||
installQwenFixture(t, fixture)
|
||||
if readQwenTestFile(t, fixture.configPath) != first {
|
||||
t.Fatal("idempotent install changed Qwen Code settings")
|
||||
}
|
||||
if !strings.Contains(first, "Keep this user preference") || !strings.Contains(first, "\r\n") {
|
||||
t.Fatalf("settings comments or line endings were lost:\n%s", first)
|
||||
}
|
||||
root := readQwenTestObject(t, fixture.configPath)
|
||||
if root["theme"] != "GitHub" {
|
||||
t.Fatalf("theme = %#v", root["theme"])
|
||||
}
|
||||
hooks := requireQwenObject(t, root["hooks"])
|
||||
if len(requireQwenArray(t, hooks["SessionStart"])) != 1 ||
|
||||
len(requireQwenArray(t, hooks["PreToolUse"])) != 2 ||
|
||||
len(requireQwenArray(t, hooks["PostToolUse"])) != 1 {
|
||||
t.Fatalf("Qwen hooks = %#v", hooks)
|
||||
}
|
||||
for _, item := range []struct{ event, path string }{
|
||||
{"PreToolUse", fixture.guardPath}, {"PostToolUse", fixture.hookPath},
|
||||
} {
|
||||
handler := qwenManagedHandler(t, root, item.event, item.path)
|
||||
if len(handler) != 4 || handler["type"] != "command" ||
|
||||
handler["shell"] != expectedQwenShell() || handler["timeout"] != float64(10_000) {
|
||||
t.Fatalf("managed handler = %#v", handler)
|
||||
}
|
||||
}
|
||||
if readQwenTestObject(t, workspaceSettings)["owner"] != "workspace" {
|
||||
t.Fatal("workspace settings changed")
|
||||
}
|
||||
runtimeConfig := readQwenTestObject(t, fixture.runtimeConfig)
|
||||
if runtimeConfig["agent_name"] != qwenAgentKey {
|
||||
t.Fatalf("runtime config = %#v", runtimeConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenInstallReportsReviewCommand(t *testing.T) {
|
||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||
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, "/hooks"} {
|
||||
if !strings.Contains(text, marker) {
|
||||
t.Fatalf("install output is missing %q: %s", marker, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenHomeUsesOfficialUserEnvironmentPrecedence(t *testing.T) {
|
||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||
firstHome := filepath.Join(filepath.Dir(fixture.homeDir), "first # qwen home")
|
||||
secondHome := filepath.Join(filepath.Dir(fixture.homeDir), "second qwen home")
|
||||
writeOptionalQwenFile(
|
||||
t,
|
||||
filepath.Join(fixture.qwenDir, ".env"),
|
||||
qwenString("export QWEN_HOME = \""+firstHome+"\" # selected by Qwen\n"),
|
||||
)
|
||||
writeOptionalQwenFile(t, filepath.Join(fixture.homeDir, ".env"), qwenString("QWEN_HOME="+secondHome+"\n"))
|
||||
installQwenFixture(t, fixture)
|
||||
selected := filepath.Join(firstHome, "settings.json")
|
||||
qwenManagedHandler(t, readQwenTestObject(t, selected), "PreToolUse", fixture.guardPath)
|
||||
requireMissingQwenFile(t, filepath.Join(secondHome, "settings.json"))
|
||||
requireMissingQwenFile(t, fixture.configPath)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || status.ConfigPath != selected || !status.Installed {
|
||||
t.Fatalf("Qwen status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenExplicitHomeSupportsRelativeTildeAndEmptyValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, value string
|
||||
expected func(*qwenFixture) string
|
||||
}{
|
||||
{"relative", "relative-qwen", func(f *qwenFixture) string {
|
||||
return filepath.Join(f.workspaceDir, "relative-qwen", "settings.json")
|
||||
}},
|
||||
{"tilde", "~/custom-qwen", func(f *qwenFixture) string {
|
||||
return filepath.Join(f.homeDir, "custom-qwen", "settings.json")
|
||||
}},
|
||||
{"empty", "", func(f *qwenFixture) string { return f.configPath }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||
ignored := filepath.Join(filepath.Dir(fixture.homeDir), "ignored-qwen-home")
|
||||
writeOptionalQwenFile(
|
||||
t, filepath.Join(fixture.qwenDir, ".env"), qwenString("QWEN_HOME="+ignored+"\n"),
|
||||
)
|
||||
t.Setenv("QWEN_HOME", test.value)
|
||||
installQwenFixture(t, fixture)
|
||||
qwenManagedHandler(t, readQwenTestObject(t, test.expected(fixture)), "PreToolUse", fixture.guardPath)
|
||||
requireMissingQwenFile(t, filepath.Join(ignored, "settings.json"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenCommandsBlockAndForwardOfficialInputByteForByte(t *testing.T) {
|
||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||
installQwenFixture(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)
|
||||
}
|
||||
settings := readQwenTestObject(t, fixture.configPath)
|
||||
guard := qwenManagedHandler(t, settings, "PreToolUse", fixture.guardPath)
|
||||
audit := qwenManagedHandler(t, settings, "PostToolUse", fixture.hookPath)
|
||||
prePayload := `{"session_id":"session-1","cwd":"C:/workspace","hook_event_name":"PreToolUse","timestamp":"2026-07-19T00:00:00.000Z","tool_name":"run_shell_command","tool_input":{"command":"echo test"}}`
|
||||
guardResult := runQwenHandler(t, guard, fixture.homeDir, prePayload)
|
||||
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") {
|
||||
t.Fatalf("guard result = %#v", guardResult)
|
||||
}
|
||||
postPayload := strings.Replace(prePayload, "PreToolUse", "PostToolUse", 1)
|
||||
auditResult := runQwenHandler(t, audit, fixture.homeDir, postPayload)
|
||||
if auditResult.exitCode != 0 {
|
||||
t.Fatalf("audit result = %#v", auditResult)
|
||||
}
|
||||
if readQwenTestFile(t, capturePath) != postPayload {
|
||||
t.Fatal("audit hook changed the official Qwen Code payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenStatusRequiresEnabledPairAndRuntimeFiles(t *testing.T) {
|
||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||
installQwenFixture(t, fixture)
|
||||
status, err := fixture.plugin.Status()
|
||||
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
||||
t.Fatalf("installed status = %#v, %v", status, err)
|
||||
}
|
||||
settings := readQwenTestObject(t, fixture.configPath)
|
||||
settings["disableAllHooks"] = true
|
||||
writeQwenTestObject(t, fixture.configPath, settings)
|
||||
status, err = fixture.plugin.Status()
|
||||
if err != nil || status.Installed || status.HookConfigured {
|
||||
t.Fatalf("disabled status = %#v, %v", status, err)
|
||||
}
|
||||
settings["disableAllHooks"] = false
|
||||
writeQwenTestObject(t, fixture.configPath, settings)
|
||||
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("missing-runtime status = %#v, %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenUninstallPreservesExternalMutationsAndExactOwnership(t *testing.T) {
|
||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{
|
||||
settings: qwenString(`{"$version":4,"owner":"user"}`),
|
||||
})
|
||||
installQwenFixture(t, fixture)
|
||||
settings := readQwenTestObject(t, fixture.configPath)
|
||||
group := qwenManagedGroup(t, settings, "PreToolUse", fixture.guardPath)
|
||||
group["hooks"] = append(requireQwenArray(t, group["hooks"]), map[string]any{
|
||||
"type": "command", "command": "user-command",
|
||||
})
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve Node.js: %v", err)
|
||||
}
|
||||
hooks := requireQwenObject(t, settings["hooks"])
|
||||
hooks["PreToolUse"] = append(requireQwenArray(t, hooks["PreToolUse"]), map[string]any{
|
||||
"matcher": "*",
|
||||
"hooks": []any{map[string]any{
|
||||
"type": "command", "command": buildQwenCommand(nodePath, filepath.Join(filepath.Dir(fixture.agentDir), "agent-10", qwenGuardScript)),
|
||||
"shell": expectedQwenShell(), "timeout": float64(10_000),
|
||||
}},
|
||||
})
|
||||
writeQwenTestObject(t, fixture.configPath, settings)
|
||||
before := readQwenTestFile(t, fixture.configPath)
|
||||
if err := fixture.plugin.Uninstall("other-agent"); err != nil {
|
||||
t.Fatalf("uninstall other agent: %v", err)
|
||||
}
|
||||
if readQwenTestFile(t, fixture.configPath) != before {
|
||||
t.Fatal("uninstall changed an unrelated installation")
|
||||
}
|
||||
agentID := qwenTestAgentID
|
||||
if runtime.GOOS == "windows" {
|
||||
agentID = "AGENT-1"
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(agentID); err != nil {
|
||||
t.Fatalf("uninstall Qwen Code hooks: %v", err)
|
||||
}
|
||||
remaining := readQwenTestObject(t, fixture.configPath)
|
||||
if remaining["$version"] != float64(4) || remaining["owner"] != "user" {
|
||||
t.Fatalf("root metadata = %#v", remaining)
|
||||
}
|
||||
remainingRaw := readQwenTestFile(t, fixture.configPath)
|
||||
for _, marker := range []string{"user-command", "agent-10"} {
|
||||
if !strings.Contains(remainingRaw, marker) {
|
||||
t.Fatalf("uninstall removed %q", marker)
|
||||
}
|
||||
}
|
||||
remainingHooks := requireQwenObject(t, remaining["hooks"])
|
||||
if _, exists := remainingHooks["PostToolUse"]; exists {
|
||||
t.Fatalf("PostToolUse remains: %#v", remainingHooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenUninstallDeletesOwnedEmptySettings(t *testing.T) {
|
||||
fixture := prepareQwenFixture(t, qwenFixtureOptions{})
|
||||
installQwenFixture(t, fixture)
|
||||
if !strings.HasPrefix(readQwenTestFile(t, fixture.configPath), qwenOwnedFileMarker) {
|
||||
t.Fatal("created settings are missing the ownership marker")
|
||||
}
|
||||
if err := fixture.plugin.Uninstall(qwenTestAgentID); err != nil {
|
||||
t.Fatalf("uninstall Qwen Code hooks: %v", err)
|
||||
}
|
||||
requireMissingQwenFile(t, fixture.configPath)
|
||||
}
|
||||
|
||||
func TestQwenDotenvParserMatchesOfficialGrammar(t *testing.T) {
|
||||
tests := []struct {
|
||||
source string
|
||||
want map[string]string
|
||||
}{
|
||||
{"QWEN_HOME=plain\n", map[string]string{"QWEN_HOME": "plain"}},
|
||||
{"export QWEN_HOME = \"path # one\" # comment\n", map[string]string{"QWEN_HOME": "path # one"}},
|
||||
{"QWEN_HOME: path with spaces\n", map[string]string{"QWEN_HOME": "path with spaces"}},
|
||||
{"QWEN_HOME=\n", map[string]string{"QWEN_HOME": ""}},
|
||||
{"QWEN_HOME='single quoted'\r\n", map[string]string{"QWEN_HOME": "single quoted"}},
|
||||
{"QWEN_HOME=`backtick value`\n", map[string]string{"QWEN_HOME": "backtick value"}},
|
||||
{"QWEN_HOME=first\nQWEN_HOME=second\n", map[string]string{"QWEN_HOME": "second"}},
|
||||
{"OTHER=x\nQWEN_HOME=\"line\\nnext\"\n", map[string]string{"OTHER": "x", "QWEN_HOME": "line\nnext"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got := parseDotenv([]byte(test.source))
|
||||
encodedGot, _ := json.Marshal(got)
|
||||
encodedWant, _ := json.Marshal(test.want)
|
||||
if string(encodedGot) != string(encodedWant) {
|
||||
t.Fatalf("parseDotenv(%q) = %s, want %s", test.source, encodedGot, encodedWant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeQwenTestObject(t *testing.T, path string, value map[string]any) {
|
||||
t.Helper()
|
||||
encoded, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("encode %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(encoded, '\n'), 0600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user