golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/task/tweet_test.go (about) 1 // Copyright 2021 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package task 6 7 import ( 8 "bytes" 9 "context" 10 "encoding/json" 11 "flag" 12 "fmt" 13 "image" 14 "image/png" 15 "io" 16 "net/http" 17 "net/http/httptest" 18 "os" 19 "path/filepath" 20 "reflect" 21 "strings" 22 "testing" 23 24 "github.com/google/go-cmp/cmp" 25 "golang.org/x/build/buildenv" 26 "golang.org/x/build/internal/secret" 27 "golang.org/x/build/internal/workflow" 28 ) 29 30 var postTests = [...]struct { 31 name string 32 kind ReleaseKind 33 published []Published 34 security string 35 announcement string 36 randomSeed int64 37 wantLog string 38 }{ 39 { 40 name: "minor", 41 kind: KindMinor, 42 published: []Published{ 43 {Version: "go1.17.1", Files: []WebsiteFile{{ 44 OS: "linux", Arch: "arm64", 45 Filename: "go1.17.1.linux-arm64.tar.gz", Size: 102606384, Kind: "archive"}}, 46 }, 47 {Version: "go1.16.8"}, 48 }, 49 security: "Includes security fixes for A and B.", 50 announcement: "https://groups.google.com/g/golang-announce/c/dx9d7IOseHw/m/KNH37k37AAAJ", 51 randomSeed: 234, 52 wantLog: `tweet text: 53 🎊 Go 1.17.1 and 1.16.8 are released! 54 55 🔐 Security: Includes security fixes for A and B. 56 57 📢 Announcement: https://groups.google.com/g/golang-announce/c/dx9d7IOseHw/m/KNH37k37AAAJ 58 59 ⬇️ Download: https://go.dev/dl/#go1.17.1 60 61 #golang 62 tweet image: 63 $ go install golang.org/dl/go1.17.1@latest 64 $ go1.17.1 download 65 Downloaded 0.0% ( 0 / 102606384 bytes) ... 66 Downloaded 50.0% ( 51303192 / 102606384 bytes) ... 67 Downloaded 100.0% (102606384 / 102606384 bytes) 68 Unpacking go1.17.1.linux-arm64.tar.gz ... 69 Success. You may now run 'go1.17.1' 70 $ go1.17.1 version 71 go version go1.17.1 linux/arm64` + "\n", 72 }, 73 { 74 name: "minor-solo", 75 kind: KindMinor, 76 published: []Published{{Version: "go1.11.1", Files: []WebsiteFile{{ 77 OS: "darwin", Arch: "amd64", 78 Filename: "go1.11.1.darwin-amd64.tar.gz", Size: 124181190, Kind: "archive"}}, 79 }}, 80 announcement: "https://groups.google.com/g/golang-announce/c/pFXKAfoVJqw", 81 randomSeed: 23, 82 wantLog: `tweet text: 83 🎆 Go 1.11.1 is released! 84 85 📣 Announcement: https://groups.google.com/g/golang-announce/c/pFXKAfoVJqw 86 87 📦 Download: https://go.dev/dl/#go1.11.1 88 89 #golang 90 tweet image: 91 $ go install golang.org/dl/go1.11.1@latest 92 $ go1.11.1 download 93 Downloaded 0.0% ( 0 / 124181190 bytes) ... 94 Downloaded 50.0% ( 62090595 / 124181190 bytes) ... 95 Downloaded 100.0% (124181190 / 124181190 bytes) 96 Unpacking go1.11.1.darwin-amd64.tar.gz ... 97 Success. You may now run 'go1.11.1' 98 $ go1.11.1 version 99 go version go1.11.1 darwin/amd64` + "\n", 100 }, 101 { 102 name: "beta", 103 kind: KindBeta, 104 published: []Published{{Version: "go1.17beta1", Files: []WebsiteFile{{ 105 OS: "darwin", Arch: "amd64", 106 Filename: "go1.17beta1.darwin-amd64.tar.gz", Size: 135610703, Kind: "archive"}}, 107 }}, 108 announcement: "https://groups.google.com/g/golang-announce/c/i4EliPDV9Ok/m/MxA-nj53AAAJ", 109 randomSeed: 678, 110 wantLog: `tweet text: 111 ⚡️ Go 1.17 Beta 1 is released! 112 113 ⚙️ Try it! File bugs! https://go.dev/issue/new 114 115 🗣 Announcement: https://groups.google.com/g/golang-announce/c/i4EliPDV9Ok/m/MxA-nj53AAAJ 116 117 📦 Download: https://go.dev/dl/#go1.17beta1 118 119 #golang 120 tweet image: 121 $ go install golang.org/dl/go1.17beta1@latest 122 $ go1.17beta1 download 123 Downloaded 0.0% ( 0 / 135610703 bytes) ... 124 Downloaded 50.0% ( 67805351 / 135610703 bytes) ... 125 Downloaded 100.0% (135610703 / 135610703 bytes) 126 Unpacking go1.17beta1.darwin-amd64.tar.gz ... 127 Success. You may now run 'go1.17beta1' 128 $ go1.17beta1 version 129 go version go1.17beta1 darwin/amd64` + "\n", 130 }, 131 { 132 name: "rc", 133 kind: KindRC, 134 published: []Published{{Version: "go1.17rc2", Files: []WebsiteFile{{ 135 OS: "windows", Arch: "arm64", 136 Filename: "go1.17rc2.windows-arm64.zip", Size: 116660997, Kind: "archive"}}, 137 }}, 138 announcement: "https://groups.google.com/g/golang-announce/c/yk30ovJGXWY/m/p9uUnKbbBQAJ", 139 randomSeed: 456, 140 wantLog: `tweet text: 141 🎉 Go 1.17 Release Candidate 2 is released! 142 143 🏖 Run it in dev! Run it in prod! File bugs! https://go.dev/issue/new 144 145 🔈 Announcement: https://groups.google.com/g/golang-announce/c/yk30ovJGXWY/m/p9uUnKbbBQAJ 146 147 📦 Download: https://go.dev/dl/#go1.17rc2 148 149 #golang 150 tweet image: 151 $ go install golang.org/dl/go1.17rc2@latest 152 $ go1.17rc2 download 153 Downloaded 0.0% ( 0 / 116660997 bytes) ... 154 Downloaded 50.0% ( 58330498 / 116660997 bytes) ... 155 Downloaded 100.0% (116660997 / 116660997 bytes) 156 Unpacking go1.17rc2.windows-arm64.zip ... 157 Success. You may now run 'go1.17rc2' 158 $ go1.17rc2 version 159 go version go1.17rc2 windows/arm64` + "\n", 160 }, 161 { 162 name: "major", 163 kind: KindMajor, 164 published: []Published{{Version: "go1.21.0", Files: []WebsiteFile{{ 165 OS: "freebsd", Arch: "amd64", 166 Filename: "go1.21.0.freebsd-amd64.tar.gz", Size: 133579378, Kind: "archive"}}, 167 }}, 168 security: "Includes a super duper security fix (CVE-123).", 169 randomSeed: 123, 170 wantLog: `tweet text: 171 🥳 Go 1.21.0 is released! 172 173 🔐 Security: Includes a super duper security fix (CVE-123). 174 175 📝 Release notes: https://go.dev/doc/go1.21 176 177 📦 Download: https://go.dev/dl/#go1.21.0 178 179 #golang 180 tweet image: 181 $ go install golang.org/dl/go1.21.0@latest 182 $ go1.21.0 download 183 Downloaded 0.0% ( 0 / 133579378 bytes) ... 184 Downloaded 50.0% ( 66789689 / 133579378 bytes) ... 185 Downloaded 100.0% (133579378 / 133579378 bytes) 186 Unpacking go1.21.0.freebsd-amd64.tar.gz ... 187 Success. You may now run 'go1.21.0' 188 $ go1.21.0 version 189 go version go1.21.0 freebsd/amd64` + "\n", 190 }, 191 } 192 193 func TestTweetRelease(t *testing.T) { 194 for _, tc := range postTests { 195 t.Run(tc.name, func(t *testing.T) { 196 // Call the tweet task function in dry-run mode so it 197 // doesn't actually try to tweet, but capture its log. 198 var buf bytes.Buffer 199 ctx := &workflow.TaskContext{Context: context.Background(), Logger: fmtWriter{&buf}} 200 tweetURL, err := (SocialMediaTasks{RandomSeed: tc.randomSeed}).TweetRelease(ctx, tc.kind, tc.published, tc.security, tc.announcement) 201 if err != nil { 202 t.Fatal("got a non-nil error:", err) 203 } 204 if got, want := tweetURL, "(dry-run)"; got != want { 205 t.Errorf("unexpected tweetURL: got = %q, want %q", got, want) 206 } 207 if diff := cmp.Diff(tc.wantLog, buf.String()); diff != "" { 208 t.Errorf("log mismatch (-want +got):\n%s", diff) 209 } 210 }) 211 } 212 } 213 214 type fmtWriter struct{ w io.Writer } 215 216 func (f fmtWriter) Printf(format string, v ...interface{}) { 217 fmt.Fprintf(f.w, format, v...) 218 } 219 220 var mastodonAPI secret.MastodonCredentials 221 var secretErr error 222 var mastodonPMTarget = flag.String("mastodon", "", "Name of account to receive private message (e.g., @user@instance.suffix)") 223 224 func init() { 225 secretErr = secret.InitFlagSupport(context.Background()) 226 secret.JSONVarFlag(&mastodonAPI, "mastodon-api-secret", "Mastodon API secret to use for tests that post.") 227 flag.Set("mastodon-api-secret", fmt.Sprintf("secret:%s/%s", buildenv.Production.ProjectName, secret.NameMastodonAPISecret)) 228 } 229 230 var updateFlag = flag.Bool("update", false, "Update golden files.") 231 232 func TestDrawTerminal(t *testing.T) { 233 got, err := drawTerminal(`$ go install golang.org/dl/go1.18beta1@latest 234 $ go1.18beta1 download 235 Downloaded 0.0% ( 0 / 111109966 bytes) ... 236 Downloaded 50.0% ( 55554983 / 111109966 bytes) ... 237 Downloaded 100.0% (111109966 / 111109966 bytes) 238 Unpacking go1.18beta1.linux-s390x.tar.gz ... 239 Success. You may now run 'go1.18beta1' 240 $ go1.18beta1 version 241 go version go1.18beta1 linux/s390x`) 242 if err != nil { 243 t.Fatalf("drawTerminal: got error=%v, want nil", err) 244 } 245 if *updateFlag { 246 encodePNG(t, filepath.Join("testdata", "terminal.png"), got) 247 return 248 } 249 want := decodePNG(t, filepath.Join("testdata", "terminal.png")) 250 if !got.Bounds().Eq(want.Bounds()) { 251 t.Fatalf("drawTerminal: got image bounds=%v, want %v", got.Bounds(), want.Bounds()) 252 } 253 diff := func(a, b uint32) uint64 { 254 if a < b { 255 return uint64(b - a) 256 } 257 return uint64(a - b) 258 } 259 var total uint64 260 for y := 0; y < want.Bounds().Dy(); y++ { 261 for x := 0; x < want.Bounds().Dx(); x++ { 262 r0, g0, b0, a0 := got.At(x, y).RGBA() 263 r1, g1, b1, a1 := want.At(x, y).RGBA() 264 const D = 0xffff * 20 / 100 // Diff threshold of 20% for RGB color components. 265 if diff(r0, r1) > D || diff(g0, g1) > D || diff(b0, b1) > D || a0 != a1 { 266 t.Errorf("at (%d, %d):\n got RGBA %v\nwant RGBA %v", x, y, got.At(x, y), want.At(x, y)) 267 } 268 total += diff(r0, r1) + diff(g0, g1) + diff(b0, b1) 269 } 270 } 271 if testing.Verbose() { 272 t.Logf("average pixel color diff: %v%%", 100*float64(total)/float64(0xffff*want.Bounds().Dx()*want.Bounds().Dy())) 273 } 274 } 275 276 func encodePNG(t *testing.T, name string, m image.Image) { 277 t.Helper() 278 var buf bytes.Buffer 279 err := (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&buf, m) 280 if err != nil { 281 t.Fatal(err) 282 } 283 err = os.WriteFile(name, buf.Bytes(), 0644) 284 if err != nil { 285 t.Fatal(err) 286 } 287 } 288 289 func decodePNG(t *testing.T, name string) image.Image { 290 t.Helper() 291 f, err := os.Open(name) 292 if err != nil { 293 t.Fatal(err) 294 } 295 defer f.Close() 296 m, err := png.Decode(f) 297 if err != nil { 298 t.Fatal(err) 299 } 300 return m 301 } 302 303 // TestPostToMastodonUsingCredentials always passes unless some invariant is badly wrong. 304 // This is intended to allow as-close-to-real testing as possible. 305 // It is capable of actual activity on Mastodon, this will be a DM 306 // to a specified person, so don't get cute with your example recipient. 307 // DO NOT AUTOMATE THIS TEST, IT SHOULD BE RUN BY A HUMAN. 308 func TestPostToMastodonUsingCredentials(t *testing.T) { 309 pmTarget := strings.TrimSpace(*mastodonPMTarget) 310 t.Logf("private message target (-mastodon)=%v", pmTarget) 311 t.Logf("mastodonAPI=%v", mastodonAPI) 312 if pmTarget == "" { 313 t.Skipf("Nothing to do here without a '-mastodon' flag") 314 } 315 if secretErr != nil { 316 t.Skipf("Nothing to do here without access to secrets, err=%v", secretErr) 317 } 318 if mastodonAPI.Application == "" { 319 t.Skipf("Nothing to do here without a valid API key") 320 } 321 322 cl, err := NewTestMastodonClient(mastodonAPI, pmTarget) 323 if err != nil { 324 t.Fatalf("NewTestMastodonClient(%v, %s) error %v", mastodonAPI, pmTarget, err) 325 } 326 327 tc := &postTests[0] 328 329 var buf bytes.Buffer 330 ctx := &workflow.TaskContext{Context: context.Background(), Logger: fmtWriter{&buf}} 331 tweetURL, err := (SocialMediaTasks{RandomSeed: tc.randomSeed, MastodonClient: cl}).TrumpetRelease(ctx, tc.kind, tc.published, tc.security, tc.announcement) 332 t.Logf("Mastodon post URL=%v, err=%v", tweetURL, err) 333 t.Log(buf.String()) 334 } 335 336 func TestPostTweet(t *testing.T) { 337 mux := http.NewServeMux() 338 mux.HandleFunc("upload.twitter.com/1.1/media/upload.json", func(w http.ResponseWriter, req *http.Request) { 339 if got, want := req.Method, http.MethodPost; got != want { 340 t.Errorf("/1.1/media/upload.json: got method %s, want %s", got, want) 341 return 342 } 343 if got, want := req.FormValue("media_category"), "tweet_image"; got != want { 344 t.Errorf("/1.1/media/upload.json: got media_category=%q, want %q", got, want) 345 } 346 f, hdr, err := req.FormFile("media") 347 if err != nil { 348 t.Errorf("/1.1/media/upload.json: error getting image file: %v", err) 349 return 350 } 351 if got, want := hdr.Filename, "image.png"; got != want { 352 t.Errorf("/1.1/media/upload.json: got file name=%q, want %q", got, want) 353 } 354 if got, want := mustRead(f), "image-png-bytes"; got != want { 355 t.Errorf("/1.1/media/upload.json: got file content=%q, want %q", got, want) 356 return 357 } 358 mustWrite(w, `{"media_id_string": "media-123"}`) 359 }) 360 mux.HandleFunc("api.twitter.com/2/tweets", func(w http.ResponseWriter, req *http.Request) { 361 if got, want := req.Method, http.MethodPost; got != want { 362 t.Errorf("/2/tweets: got method %s, want %s", got, want) 363 return 364 } 365 if got, want := req.Header.Get("Content-Type"), "application/json"; got != want { 366 t.Errorf("/2/tweets: got Content-Type=%q, want %q", got, want) 367 return 368 } 369 var v struct { 370 Text string `json:"text"` 371 Media struct { 372 MediaIDs []string `json:"media_ids"` 373 } `json:"media"` 374 } 375 if err := json.NewDecoder(req.Body).Decode(&v); err != nil { 376 t.Errorf("/2/tweets: decode JSON error: %v", err) 377 return 378 } 379 if got, want := v.Text, "tweet-text"; got != want { 380 t.Errorf("/2/tweets: got status=%q, want %q", got, want) 381 } 382 if got, want := v.Media.MediaIDs, []string{"media-123"}; !reflect.DeepEqual(got, want) { 383 t.Errorf("/2/tweets: got media_ids=%q, want %q", got, want) 384 } 385 w.WriteHeader(http.StatusCreated) 386 mustWrite(w, `{"data": {"id": "tweet-123"}}`) 387 }) 388 cl := realTwitterClient{twitterAPI: &http.Client{Transport: localRoundTripper{mux}}} 389 390 tweetURL, err := cl.PostTweet("tweet-text", []byte("image-png-bytes"), "alt text") 391 if err != nil { 392 t.Fatal("PostTweet:", err) 393 } 394 if got, want := tweetURL, "https://twitter.com/username/status/tweet-123"; got != want { 395 t.Errorf("got tweetURL=%q, want %q", got, want) 396 } 397 } 398 399 // localRoundTripper is an http.RoundTripper that executes HTTP transactions 400 // by using handler directly, instead of going over an HTTP connection. 401 type localRoundTripper struct { 402 handler http.Handler 403 } 404 405 func (l localRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 406 w := httptest.NewRecorder() 407 l.handler.ServeHTTP(w, req) 408 return w.Result(), nil 409 } 410 411 func mustRead(r io.Reader) string { 412 b, err := io.ReadAll(r) 413 if err != nil { 414 panic(err) 415 } 416 return string(b) 417 } 418 419 func mustWrite(w io.Writer, s string) { 420 _, err := io.WriteString(w, s) 421 if err != nil { 422 panic(err) 423 } 424 }