Files
pad/internal/cli/workspace_link_write_test.go
T
xarmian ba35908f2b fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612) (#2103)
* fix(cli,web): link re-points a directory to another server and keeps .pad.toml's other keys; the move-to-Cloud guide is browser-only (TASK-3612)

Verifying the guide's step 5 found it wrong for the people it is written
for. A project linked to a self-hosted server pins that server's url in
.pad.toml, and the pin wins over the global cloud mode, so
`pad workspace link <slug>` talked to the OLD server, found the slug and
printed "Already linked". With --url it still did, whenever Cloud had the
same slug, which is the usual case after an import.

- link is "already linked" only when the existing link's url equals the
  target. An explicit --url naming another server re-points it, and the
  output names the old and the new server.
- WriteWorkspaceLink UPDATES .pad.toml instead of rewriting it: only
  workspace and url change, so agent_name, the [push] consent table and
  unknown keys survive every relink (link, switch and the three init
  paths). A file it cannot parse is refused and left as it was.
- Step 5 now reads `pad workspace link <slug> --url https://app.getpad.dev`.
- Lead ruling: the guide as a whole steers to Pad Cloud, so inside a mobile
  app's web view the route shows one line ("Open Pad in a browser to move a
  workspace") and both entry points are hidden. The entry points are one
  component carrying that gate, with a wiring test for each mount.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(cli): link compares server URLs normalised, so case, a default port or a trailing slash is not a different server (TASK-3612, codex r1)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(cli): server URL comparison keeps escaped path distinctions and strips one trailing slash only (TASK-3612, codex r2)

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-12 09:24:50 -04:00

104 lines
3.0 KiB
Go

package cli
import (
"os"
"path/filepath"
"testing"
"github.com/BurntSushi/toml"
)
// TASK-3612: WriteWorkspaceLink updates an existing .pad.toml instead of
// replacing it, so a relink keeps agent_name, the [push] consent table and
// any key a newer pad wrote.
func readPadTomlMap(t *testing.T, dir string) map[string]any {
t.Helper()
doc := map[string]any{}
if _, err := toml.DecodeFile(filepath.Join(dir, ".pad.toml"), &doc); err != nil {
t.Fatalf("decode: %v", err)
}
return doc
}
func TestWriteWorkspaceLinkKeepsTheFilesOtherKeys(t *testing.T) {
t.Parallel()
dir := t.TempDir()
body := "workspace = \"old\"\nurl = \"https://pad.example.com\"\nagent_name = \"codex\"\nfuture_key = 7\n\n[push]\n auto_arm = true\n"
if err := os.WriteFile(filepath.Join(dir, ".pad.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := WriteWorkspaceLink(dir, "new", "https://app.getpad.dev"); err != nil {
t.Fatal(err)
}
doc := readPadTomlMap(t, dir)
if doc["workspace"] != "new" || doc["url"] != "https://app.getpad.dev" {
t.Errorf("link not updated: %v", doc)
}
if doc["agent_name"] != "codex" {
t.Errorf("agent_name = %v, want codex", doc["agent_name"])
}
if doc["future_key"] != int64(7) {
t.Errorf("future_key = %#v, want 7", doc["future_key"])
}
push, _ := doc["push"].(map[string]any)
if push == nil || push["auto_arm"] != true {
t.Errorf("[push] = %v, want auto_arm kept", doc["push"])
}
// And the typed reader still sees what it always did.
var typed PadToml
if _, err := toml.DecodeFile(filepath.Join(dir, ".pad.toml"), &typed); err != nil {
t.Fatal(err)
}
if typed.Workspace != "new" || typed.AgentName != "codex" || !typed.PadTomlAutoArm() {
t.Errorf("typed read = %+v", typed)
}
}
func TestWriteWorkspaceLinkDropsTheURLForALocalLink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".pad.toml"), []byte("workspace = \"a\"\nurl = \"https://pad.example.com\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := WriteWorkspaceLink(dir, "a", ""); err != nil {
t.Fatal(err)
}
if doc := readPadTomlMap(t, dir); doc["url"] != nil {
t.Errorf("url = %v, want it removed for a local link", doc["url"])
}
}
func TestWriteWorkspaceLinkCreatesAMissingFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
if err := WriteWorkspaceLink(dir, "fresh", ""); err != nil {
t.Fatal(err)
}
doc := readPadTomlMap(t, dir)
if doc["workspace"] != "fresh" || len(doc) != 1 {
t.Errorf("new file = %v, want only workspace", doc)
}
}
func TestWriteWorkspaceLinkRefusesAFileItCannotRead(t *testing.T) {
t.Parallel()
dir := t.TempDir()
broken := []byte("workspace = \"a\"\nthis is not toml\n")
path := filepath.Join(dir, ".pad.toml")
if err := os.WriteFile(path, broken, 0o644); err != nil {
t.Fatal(err)
}
if err := WriteWorkspaceLink(dir, "b", ""); err == nil {
t.Fatal("wrote over a .pad.toml it could not parse")
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != string(broken) {
t.Errorf("the unreadable file was changed:\n%s", got)
}
}