diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d23f5e44..fdbe8b5d 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -226,6 +226,7 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { }{ {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + {filepath.Join("..", "..", "scripts", "install-event.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, } for _, check := range checks { data, err := os.ReadFile(check.path) @@ -242,6 +243,210 @@ func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { } } +func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { + t.Parallel() + + checks := []struct { + path string + required []string + forbidden []string + }{ + { + filepath.Join("..", "..", "scripts", "install.ps1"), + []string{ + "function Remove-LinkStageRoot", + // Windows PowerShell 5.1 follows reparse points during + // Remove-Item -Recurse; published junctions must be removed + // lexically so rollback never deletes canonical content. + "Remove-SkillPathLexically -Path $Published[$i]", + "Remove-LinkStageRoot -StageRoot $stageRoot", + }, + []string{"Remove-Item -LiteralPath $Published[$i] -Recurse -Force"}, + }, + { + filepath.Join("..", "..", "scripts", "install-devapp.ps1"), + []string{ + "function Remove-DevLinkStageRoot", + "Remove-DevLinkStageRoot $stageRoot", + }, + []string{}, + }, + } + for _, check := range checks { + data, err := os.ReadFile(check.path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", check.path, err) + } + text := string(data) + for _, want := range check.required { + if !strings.Contains(text, want) { + t.Errorf("%s missing junction-safe removal contract %q", check.path, want) + } + } + for _, avoid := range check.forbidden { + if strings.Contains(text, avoid) { + t.Errorf("%s still removes junction-capable rollback paths recursively: %q", check.path, avoid) + } + } + } +} + +func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) + } + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", + "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + }) + writeInstallerFixtureChecksums(t, releaseDir) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + // A regular file where an agent skill base belongs makes that one agent + // target uninstallable, while later agents (.zcode) stay reachable. + mustWriteFile(t, filepath.Join(fakeHome, ".aider-desk", "skills"), []byte("not a directory\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".zcode", "keep.txt"), []byte("zcode home\n"), 0o644) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install-event.sh should fail loudly when an agent target fails:\n%s", string(output)) + } + got := string(output) + for _, want := range []string{ + "Agent 目标安装失败,已跳过", + "dingtalk-event 分发到 Agent 目录失败", + } { + if !strings.Contains(got, want) { + t.Fatalf("install-event output missing degrade report %q:\n%s", want, got) + } + } + // The failed agent must not abort the loop: later agents still get links. + if _, err := os.Stat(filepath.Join(fakeHome, ".zcode", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("later agent link missing after per-agent degrade: %v\noutput:\n%s", err, got) + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } +} + +func TestInstallPowerShellJunctionsResolvePhysicalRoot(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + probe := prefix + ` +$canonical = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$linkParent = Join-Path $env:DWS_TEST_HOME '.zcode\skills' +New-Item -ItemType Directory -Path $linkParent -Force | Out-Null +$link = Join-Path $linkParent 'dingtalk-chat' +New-Item -ItemType Junction -Path $link -Target $canonical | Out-Null +if (Test-SamePhysicalSkillRoot -Left $link -Right $canonical) { exit 0 } +exit 3 +` + probePath := filepath.Join(t.TempDir(), "junction-probe.ps1") + mustWriteFile(t, probePath, []byte(probe), 0o644) + home := t.TempDir() + probeCmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", probePath) + probeCmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := probeCmd.CombinedOutput(); err != nil { + t.Fatalf("junction must resolve to its physical target (lexical Resolve-Path regression):\n%s", string(output)) + } + + // A rerun over an already-linked agent base must be idempotent: the + // junction is recognized as published and never re-backed-up. + install := prefix + ` +if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 } +exit 0 +` + installPath := filepath.Join(t.TempDir(), "junction-idempotent.ps1") + mustWriteFile(t, installPath, []byte(install), 0o644) + multi := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644) + + runInstall := func() string { + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", installPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell junction idempotency harness failed: %v\n%s", err, string(output)) + } + return string(output) + } + countBackups := func() int { + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + return 0 + } + return len(entries) + } + runInstall() + first := countBackups() + runInstall() + if second := countBackups(); second != first { + t.Fatalf("rerun re-backed-up already-published junctions (backup churn): first=%d second=%d", first, second) + } + linkData, err := os.ReadFile(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")) + if err != nil || string(linkData) != "new chat\n" { + t.Fatalf("linked ZCode Skill content mismatch: %v %q", err, string(linkData)) + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() @@ -1544,15 +1749,18 @@ if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi; then exit 2 fi identity_dest="$DWS_TEST_BASE/identity-link" +identity_anchor="$HOME/identity-anchor" identity_manifest="$HOME/identity.manifest" -command ln -s ../same-target "$identity_dest" -identity_inode="$(skill_link_inode "$identity_dest")" +command ln -s ../same-target "$identity_anchor" +command ln -P "$identity_anchor" "$identity_dest" +identity_inode="$(skill_link_inode "$identity_anchor")" printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" rm -f "$identity_dest" command ln -s ../same-target "$identity_dest" if restore_linked_skill_set "$identity_manifest" /dev/null; then exit 3 fi +rm -f "$identity_anchor" ` cmd := exec.Command("sh", "-c", harness) cmd.Env = append(os.Environ(),