github.com/actions-on-google/gactions@v3.2.0+incompatible/api/sdk_test.go (about) 1 // Copyright 2020 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // https://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package sdk 16 17 import ( 18 "bytes" 19 "encoding/json" 20 "io" 21 "io/ioutil" 22 "net/http" 23 "os" 24 "path" 25 "path/filepath" 26 "runtime" 27 "sort" 28 "strings" 29 "testing" 30 "time" 31 32 "github.com/actions-on-google/gactions/api/request" 33 "github.com/actions-on-google/gactions/api/testutils" 34 "github.com/actions-on-google/gactions/api/yamlutils" 35 "github.com/actions-on-google/gactions/project" 36 "github.com/actions-on-google/gactions/project/studio" 37 "github.com/google/go-cmp/cmp" 38 "github.com/google/go-cmp/cmp/cmpopts" 39 ) 40 41 func buildPathToProjectFiles() string { 42 return filepath.Join("api", "examples", "account_linking_gsi") 43 } 44 45 type MockStudio struct { 46 files map[string][]byte 47 clientSecret []byte 48 root string 49 projectID string 50 } 51 52 func NewMock(files map[string][]byte) MockStudio { 53 m := MockStudio{} 54 m.files = files 55 m.projectID = "placeholder_project" 56 return m 57 } 58 59 func (m MockStudio) ProjectID() string { 60 return m.projectID 61 } 62 63 func (MockStudio) Download(sample project.SampleProject, dest string) error { 64 return nil 65 } 66 67 func (MockStudio) AlreadySetup(pathToWorkDir string) bool { 68 return false 69 } 70 71 func (p MockStudio) Files() (map[string][]byte, error) { 72 return p.files, nil 73 } 74 75 func (MockStudio) ClientSecretJSON() ([]byte, error) { 76 return []byte{}, nil 77 } 78 79 func (p MockStudio) ProjectRoot() string { 80 return p.root 81 } 82 83 type myReader struct { 84 r io.Reader 85 lat time.Duration 86 } 87 88 func (mr myReader) Read(p []byte) (n int, err error) { 89 time.Sleep(mr.lat) 90 return mr.r.Read(p) 91 } 92 93 func TestReadBodyWithTimeout(t *testing.T) { 94 var got, want []byte 95 var err error 96 var r myReader 97 98 r = myReader{r: strings.NewReader("hello"), lat: time.Duration(200) * time.Millisecond} 99 // Timeout for 5 seconds to reduce flakiness. 100 got, err = readBodyWithTimeout(r, time.Duration(5)*time.Second) 101 want = []byte("hello") 102 if err != nil { 103 t.Errorf("readBodyWithTimeout returned %v, want %v", err, nil) 104 } 105 if string(got) != string(want) { 106 t.Errorf("readBodyWithTimeout got %v, want %v", string(got), string(want)) 107 } 108 109 // slow case 110 r = myReader{r: strings.NewReader("hello"), lat: time.Duration(3) * time.Second} 111 got, err = readBodyWithTimeout(r, time.Duration(1)*time.Second) 112 want = []byte("") 113 if err != nil { 114 t.Errorf("readBodyWithTimeout returned %v, want %v", err, nil) 115 } 116 if string(got) != string(want) { 117 t.Errorf("readBodyWithTimeout got %v, want %v", string(got), string(want)) 118 } 119 } 120 121 func TestPostprocessJSONResponse(t *testing.T) { 122 tests := []struct { 123 in *http.Response 124 shouldErr bool 125 }{ 126 { 127 in: &http.Response{ 128 StatusCode: 200, 129 Body: ioutil.NopCloser(bytes.NewReader([]byte( 130 `{ 131 "validationResults": { 132 "results":[ 133 { 134 "validationMesssage": "Your app doesn't have the correct size for the logo." 135 } 136 ] 137 } 138 }`, 139 ))), 140 }, 141 shouldErr: false, 142 }, 143 { 144 in: &http.Response{ 145 StatusCode: 500, 146 Body: ioutil.NopCloser(bytes.NewReader([]byte( 147 `{ 148 "error": { 149 "code": 500, 150 "message": "Internal error encountered", 151 "status": "INTERNAL", 152 "details": [ 153 { 154 "@type": "type.googleapis.com/google.rpc.DebugInfo", 155 "detail": "Should not be shown to user." 156 } 157 ] 158 } 159 }`, 160 ))), 161 }, 162 shouldErr: true, 163 }, 164 { 165 in: &http.Response{ 166 StatusCode: 400, 167 Body: ioutil.NopCloser(bytes.NewReader([]byte( 168 `{}`, 169 ))), 170 }, 171 shouldErr: true, 172 }, 173 } 174 for _, tc := range tests { 175 errCh := make(chan error) 176 go postprocessJSONResponse(tc.in, errCh, func(body []byte) error { 177 // TODO: Ideally would like to check that this function gets called. 178 // Need a way to cleanly implement it. 179 return nil 180 }) 181 got := <-errCh 182 if tc.shouldErr && got == nil { 183 t.Errorf("postprocessJSONResponse returned incorrect result: got %v, want an error", got) 184 } 185 } 186 } 187 188 func unmarshal(t *testing.T, p string) map[string]interface{} { 189 t.Helper() 190 b := testutils.ReadFileOrDie(p) 191 m, err := yamlutils.UnmarshalYAMLToMap(b) 192 if err != nil { 193 t.Fatalf("unmarshal: can not parse settins yaml into proto: %v", err) 194 } 195 return m 196 } 197 198 func TestSendFilesToServerJSON(t *testing.T) { 199 if runtime.GOOS == "windows" { 200 // This test does not work on Windows, as the "actions/actions.yaml" 201 // and other files cannot be found. 202 // The error specifically is: 203 // Cannot open file C:\...\_bazel_kbuilder\jzegmkbf\execroot\__main__\bazel-out\x64_windows-fastbuild\bin\api\sdk_test_\sdk_test.exe.runfiles\__main__92api\examples\account_linking_gsi\actions\actions.yaml 204 // Exit early. 205 return 206 } 207 tests := []struct { 208 projFiles map[string][]byte 209 wantRequests []map[string]interface{} 210 wantErrorMessageToContain string 211 }{ 212 { 213 projFiles: map[string][]byte{ 214 "actions/actions.yaml": testutils.ReadFileOrDie(filepath.Join(buildPathToProjectFiles(), "actions", "actions.yaml")), 215 "manifest.yaml": testutils.ReadFileOrDie(filepath.Join(buildPathToProjectFiles(), "manifest.yaml")), 216 "settings/settings.yaml": testutils.ReadFileOrDie(filepath.Join(buildPathToProjectFiles(), "settings", "settings.yaml")), 217 "resources/audio/confirmation_01.mp3": testutils.ReadFileOrDie(filepath.Join(buildPathToProjectFiles(), "resources", "audio", "confirmation_01.mp3")), 218 "resources/images/smallLogo.jpg": testutils.ReadFileOrDie(filepath.Join(buildPathToProjectFiles(), "resources", "images", "smallLogo.jpg")), 219 "settings/zh-TW/settings.yaml": testutils.ReadFileOrDie(filepath.Join(buildPathToProjectFiles(), "settings", "zh-TW", "settings.yaml")), 220 "resources/images/zh-TW/smallLogo.jpg": testutils.ReadFileOrDie(filepath.Join(buildPathToProjectFiles(), "resources", "images", "zh-TW", "smallLogo.jpg")), 221 "webhooks/webhook.yaml": testutils.ReadFileOrDie(filepath.Join(buildPathToProjectFiles(), "webhooks", "webhook.yaml")), 222 "settings/accountLinkingSecret.yaml": []byte(strings.Join([]string{"encryptedClientSecret: bar", "encryptionKeyVersion: 1"}, "\n")), 223 }, 224 wantRequests: []map[string]interface{}{ 225 map[string]interface{}{ 226 "parent": "projects/placeholder_project", 227 "files": map[string]interface{}{ 228 "configFiles": map[string]interface{}{ 229 "configFiles": []map[string]interface{}{ 230 map[string]interface{}{ 231 "filePath": "actions/actions.yaml", 232 "actions": unmarshal(t, filepath.Join(buildPathToProjectFiles(), "actions", "actions.yaml")), 233 }, 234 map[string]interface{}{ 235 "filePath": "manifest.yaml", 236 "manifest": unmarshal(t, path.Join(buildPathToProjectFiles(), "manifest.yaml")), 237 }, 238 map[string]interface{}{ 239 "filePath": "settings/settings.yaml", 240 "settings": unmarshal(t, path.Join(buildPathToProjectFiles(), "settings", "settings.yaml")), 241 }, 242 map[string]interface{}{ 243 "filePath": "settings/zh-TW/settings.yaml", 244 "settings": unmarshal(t, path.Join(buildPathToProjectFiles(), "settings", "zh-TW", "settings.yaml")), 245 }, 246 map[string]interface{}{ 247 "filePath": "webhooks/webhook.yaml", 248 "webhook": unmarshal(t, path.Join(buildPathToProjectFiles(), "webhooks", "webhook.yaml")), 249 }, 250 map[string]interface{}{ 251 "filePath": "settings/accountLinkingSecret.yaml", 252 "accountLinkingSecret": map[string]interface{}{ 253 "encryptedClientSecret": "bar", 254 "encryptionKeyVersion": 1, 255 }, 256 }, 257 }, 258 }, 259 }, 260 }, 261 map[string]interface{}{ 262 "parent": "projects/placeholder_project", 263 "files": map[string]interface{}{ 264 "dataFiles": map[string]interface{}{ 265 "dataFiles": []map[string]interface{}{ 266 map[string]interface{}{ 267 "filePath": "resources/images/smallLogo.jpg", 268 "contentType": "image/jpeg", 269 "payload": testutils.ReadFileOrDie(path.Join(buildPathToProjectFiles(), "resources", "images", "smallLogo.jpg")), 270 }, 271 map[string]interface{}{ 272 "filePath": "resources/audio/confirmation_01.mp3", 273 "contentType": "audio/mpeg", 274 "payload": testutils.ReadFileOrDie(path.Join(buildPathToProjectFiles(), "resources", "audio", "confirmation_01.mp3")), 275 }, 276 map[string]interface{}{ 277 "filePath": "resources/images/zh-TW/smallLogo.jpg", 278 "contentType": "image/jpeg", 279 "payload": testutils.ReadFileOrDie(path.Join(buildPathToProjectFiles(), "resources", "images", "zh-TW", "smallLogo.jpg")), 280 }, 281 }, 282 }, 283 }, 284 }, 285 }, 286 wantErrorMessageToContain: "", 287 }, 288 { 289 projFiles: map[string][]byte{}, 290 wantRequests: nil, 291 wantErrorMessageToContain: "configuration files for your Action were not found", 292 }, 293 } 294 for _, tc := range tests { 295 p := NewMock(tc.projFiles) 296 r, w := io.Pipe() 297 ch := make(chan []byte) 298 errCh := make(chan error) 299 go func() { 300 b, err := ioutil.ReadAll(r) 301 ch <- b 302 errCh <- err 303 }() 304 err := sendFilesToServerJSON(p, w, func() map[string]interface{} { 305 // TODO: Parametrize this to enable testing of various requests. 306 // This will remove need for request tests in request_test. 307 return request.WriteDraft("placeholder_project") 308 }) 309 gotBytes := <-ch 310 if err := <-errCh; err != nil { 311 t.Errorf("Unable to read from pipe: got %v, input %v", err, tc.projFiles) 312 } 313 if tc.wantRequests != nil { 314 wantBytes, err := json.Marshal(tc.wantRequests) 315 if err != nil { 316 t.Errorf("Could not marshall into JSON: got %v", err) 317 } 318 var got []map[string]interface{} 319 if err := json.Unmarshal(gotBytes, &got); err != nil { 320 t.Errorf("Could not unmarshall to JSON: got %v", err) 321 } 322 // Checks request were sent in alphabetical order of filenames. 323 var fps []string 324 for _, v := range got { 325 if fp, ok := v["filePath"]; ok { 326 fps = append(fps, fp.(string)) 327 } 328 } 329 if ok := sort.StringsAreSorted(fps); !ok { 330 t.Errorf("Expected requests to be in alphabetical order, but got %v\n", fps) 331 } 332 var want []map[string]interface{} 333 if err := json.Unmarshal(wantBytes, &want); err != nil { 334 t.Errorf("Could not unmarshall to JSON: got %v", err) 335 } 336 if diff := cmp.Diff(want, got, cmpopts.SortSlices(func(l, r interface{}) bool { 337 lb, err := json.Marshal(l) 338 if err != nil { 339 t.Errorf("can not marshal %v to JSON: %v", lb, err) 340 } 341 rb, err := json.Marshal(r) 342 if err != nil { 343 t.Errorf("can not marshal %v to JSON: %v", rb, err) 344 } 345 return string(lb) < string(rb) 346 })); diff != "" { 347 t.Errorf("sendFilesToServerJSON didn't send correct files: diff (-want, +got)\n%s", diff) 348 } 349 } else { 350 if !strings.Contains(err.Error(), tc.wantErrorMessageToContain) { 351 t.Errorf("sendFilesToServerJSON got %v, but want the error to have %v\n", err, tc.wantErrorMessageToContain) 352 } 353 } 354 } 355 } 356 357 func TestProcWritePreviewResponse(t *testing.T) { 358 tests := []struct { 359 in []byte 360 wantURL string 361 }{ 362 { 363 in: []byte( 364 ` 365 { 366 "simulatorUrl": "https://google.com" 367 }`, 368 ), 369 wantURL: "https://google.com", 370 }, 371 { 372 in: []byte( 373 ` 374 { 375 "simulatorUrl": "https://google.com", 376 "validationResults": { 377 "results": [ 378 { 379 "validationMessage": "Your app must have a 32x32 logo" 380 } 381 ] 382 } 383 }`, 384 ), 385 wantURL: "https://google.com", 386 }, 387 { 388 in: []byte("{}"), 389 wantURL: "", 390 }, 391 { 392 in: []byte( 393 ` 394 { 395 "simulatorUrl": "https://google.com", 396 "validationResults": { 397 "results": [ 398 {} 399 ] 400 } 401 }`, 402 ), 403 wantURL: "https://google.com", 404 }, 405 } 406 for _, tc := range tests { 407 gotURL, err := procWritePreviewResponse(tc.in) 408 if err != nil { 409 t.Errorf("procWritePreviewResponse returned %v, but want %v, input %v", err, nil, tc.in) 410 } 411 if tc.wantURL != gotURL { 412 t.Errorf("procWritePreviewResponse didn't set the right value of the simulator URL: got %v, want %v, input %v", gotURL, tc.wantURL, tc.in) 413 } 414 } 415 } 416 417 func TestProcWriteDraftResponse(t *testing.T) { 418 tests := []struct { 419 body string 420 }{ 421 { 422 body: ` 423 { 424 "name": "foo/bar", 425 "validationResults": { 426 "results": [ 427 { 428 "validationMessage": "Your app must have a 32x32 logo" 429 } 430 ] 431 } 432 } 433 `, 434 }, 435 { 436 body: ` 437 { 438 "name": "foo/bar", 439 "validationResults": { 440 "results": [ 441 {} 442 ] 443 } 444 } 445 `, 446 }, 447 } 448 for _, tc := range tests { 449 if err := procWriteDraftResponse([]byte(tc.body)); err != nil { 450 t.Errorf("procWriteDraftResponse returned %v, but want %v", err, nil) 451 } 452 } 453 } 454 455 func TestErrorMessage(t *testing.T) { 456 tests := []struct { 457 code int 458 message string 459 details []map[string]interface{} 460 want string 461 }{ 462 { 463 code: 500, 464 message: "Internal error occurred", 465 details: []map[string]interface{}{ 466 map[string]interface{}{ 467 "@type": "type.googleapis.com/google.rpc.DebugInfo", 468 "detail": "[ORIGINAL ERROR]", 469 }, 470 }, 471 want: strings.Join([]string{ 472 "{", 473 " \"error\": {", 474 " \"code\": 500,", 475 " \"message\": \"Internal error occurred\"", 476 " }", 477 "}", 478 }, "\n"), 479 }, 480 { 481 code: 400, 482 message: "Invalid Argument", 483 details: []map[string]interface{}{ 484 map[string]interface{}{ 485 "@type": "type.googleapis.com/google.rpc.InvalidArgument", 486 "detail": "[ORIGINAL ERROR]", 487 }, 488 }, 489 want: strings.Join([]string{ 490 `{`, 491 ` "error": {`, 492 ` "code": 400,`, 493 ` "message": "Invalid Argument",`, 494 ` "details": [`, 495 ` {`, 496 ` "@type": "type.googleapis.com/google.rpc.InvalidArgument",`, 497 ` "detail": "[ORIGINAL ERROR]"`, 498 ` }`, 499 ` ]`, 500 ` }`, 501 `}`, 502 }, "\n"), 503 }, 504 { 505 code: 400, 506 message: "Failed precondition", 507 details: []map[string]interface{}{ 508 map[string]interface{}{ 509 "@type": "type.googleapis.com/google.rpc.FailedPrecondition", 510 "detail": "[ORIGINAL ERROR]", 511 }, 512 }, 513 want: strings.Join([]string{ 514 `{`, 515 ` "error": {`, 516 ` "code": 400,`, 517 ` "message": "Failed precondition",`, 518 ` "details": [`, 519 ` {`, 520 ` "@type": "type.googleapis.com/google.rpc.FailedPrecondition",`, 521 ` "detail": "[ORIGINAL ERROR]"`, 522 ` }`, 523 ` ]`, 524 ` }`, 525 `}`, 526 }, "\n"), 527 }, 528 } 529 for _, tc := range tests { 530 in := &PublicError{} 531 in.Error.Code = tc.code 532 in.Error.Message = tc.message 533 in.Error.Details = tc.details 534 got := errorMessage(in) 535 if got != tc.want { 536 t.Errorf("errorMessages got %v, want %v", got, tc.want) 537 } 538 } 539 } 540 541 func TestReceiveStream(t *testing.T) { 542 tests := []struct { 543 body string 544 wantFiles []string 545 name string 546 }{ 547 { 548 name: "only settings", 549 body: strings.Join([]string{ 550 `[`, 551 ` {`, 552 ` "files": {`, 553 ` "configFiles": {`, 554 ` "configFiles":`, 555 ` [`, 556 ` {`, 557 ` "filePath": "settings/settings.yaml",`, 558 ` "settings": {`, 559 ` "actionsForFamilyUpdated": true,`, 560 ` "category": "GAMES_AND_TRIVIA",`, 561 ` "defaultLocale": "en",`, 562 ` "localizedSettings": {`, 563 ` "developerEmail": "dschrute@gmail.com",`, 564 ` "developerName": "Dwight Schrute",`, 565 ` "displayName": "Mike Simple Question",`, 566 ` "fullDescription": "Test Full Description",`, 567 ` "sampleInvocations": [`, 568 ` "Talk to Mike Simple Question"`, 569 ` ],`, 570 ` "smallLogoImage": "$resources.images.square"`, 571 ` },`, 572 ` "projectId": "placeholder_project"`, 573 ` }`, 574 ` }`, 575 ` ]`, 576 ` }`, 577 ` }`, 578 ` }`, 579 `]`}, "\n"), 580 wantFiles: []string{"settings/settings.yaml"}, 581 }, 582 { 583 name: "configFiles and dataFiles", 584 body: strings.Join([]string{ 585 `[`, 586 ` {`, 587 ` "files": {`, 588 ` "configFiles": {`, 589 ` "configFiles": [`, 590 ` {`, 591 ` "filePath": "settings/settings.yaml",`, 592 ` "settings": {`, 593 ` "category": "GAMES_AND_TRIVIA"`, 594 ` }`, 595 ` },`, 596 ` {`, 597 ` "filePath": "custom/global/actions.intent.MAIN.yaml",`, 598 ` "globalIntentEvent": {`, 599 ` "handler": {`, 600 ` "staticPrompt": {`, 601 ` "candidates": [`, 602 ` {`, 603 ` "promptResponse": {`, 604 ` "firstSimple": {`, 605 ` "variants": [`, 606 ` {`, 607 ` "speech": "$resources.strings.WELCOME",`, 608 ` "text": "$resources.strings.WELCOME"`, 609 ` }`, 610 ` ]`, 611 ` }`, 612 ` }`, 613 ` }`, 614 ` ]`, 615 ` }`, 616 ` },`, 617 ` "transitionToScene": "questionpage"`, 618 ` }`, 619 ` },`, 620 ` {`, 621 ` "filePath": "settings/es/settings.yaml",`, 622 ` "settings": {`, 623 ` "localizedSettings": {`, 624 ` "displayName": "Mike Pregunta simple",`, 625 ` "fullDescription": "Descripción completa de la muestra"`, 626 ` }`, 627 ` }`, 628 ` }`, 629 ` ]`, 630 ` }`, 631 ` }`, 632 ` },`, 633 ` {`, 634 ` "files": {`, 635 ` "dataFiles": {`, 636 ` "dataFiles": [`, 637 ` {`, 638 ` "filePath": "resources/images/foo.png",`, 639 ` "contentType": "images/png",`, 640 ` "payload": ""`, 641 ` }`, 642 ` ]`, 643 ` }`, 644 ` }`, 645 ` }`, 646 `]`}, "\n"), 647 wantFiles: []string{"resources/images/foo.png", "settings/es/settings.yaml", "custom/global/actions.intent.MAIN.yaml"}, 648 }, 649 } 650 for _, tc := range tests { 651 t.Run(tc.name, func(t *testing.T) { 652 // Setup directory where receiveStream will write files to. 653 dirName, err := ioutil.TempDir(testutils.TestTmpDir, "actions-sdk-cli-project-folder") 654 if err != nil { 655 t.Fatalf("Can't create temporary directory under %q: %v", testutils.TestTmpDir, err) 656 } 657 defer func() { 658 if err := os.RemoveAll(dirName); err != nil { 659 t.Fatalf("Can't remove temp directory: %v", err) 660 } 661 }() 662 proj := studio.New([]byte("secret"), dirName) 663 seen := map[string]bool{} 664 if err := receiveStream(proj, strings.NewReader(tc.body), false, seen); err != nil { 665 t.Errorf("receiveStream returned %v, but expected to return %v", err, nil) 666 } 667 for _, v := range tc.wantFiles { 668 osPath := filepath.FromSlash(v) 669 // TODO: Verify the content of the written file 670 _, err := ioutil.ReadFile(filepath.Join(proj.ProjectRoot(), osPath)) 671 if err != nil { 672 t.Errorf("receiveStream expected to write file to disk, but got %v", err) 673 } 674 if !seen[v] { 675 t.Errorf("receiveStream expected to mark file as seen, but did not") 676 } 677 } 678 }) 679 } 680 } 681 682 func TestFindExtra(t *testing.T) { 683 tests := []struct { 684 a map[string][]byte 685 b map[string]bool 686 want []string 687 }{ 688 { 689 a: map[string][]byte{ 690 "settings/settings.yaml": []byte("abc"), 691 "manifest.yaml": []byte("abc"), 692 "resources/strings/en/bundle.yaml": []byte("abc"), 693 }, 694 b: map[string]bool{ 695 "settings/settings.yaml": true, 696 "manifest.yaml": true, 697 "resources/strings/en/bundle.yaml": true, 698 }, 699 want: nil, 700 }, 701 { 702 a: map[string][]byte{ 703 "settings/settings.yaml": []byte("abc"), 704 "manifest.yaml": []byte("abc"), 705 }, 706 b: map[string]bool{ 707 "settings/settings.yaml": true, 708 "manifest.yaml": true, 709 "resources/strings/en/bundle.yaml": true, 710 }, 711 want: nil, 712 }, 713 { 714 a: map[string][]byte{ 715 "settings/settings.yaml": []byte("abc"), 716 "manifest.yaml": []byte("abc"), 717 "resources/strings/en/bundle.yaml": []byte("abc"), 718 }, 719 b: map[string]bool{ 720 "settings/settings.yaml": true, 721 "manifest.yaml": true, 722 }, 723 want: []string{"resources/strings/en/bundle.yaml"}, 724 }, 725 } 726 for _, tc := range tests { 727 got := findExtra(tc.a, tc.b) 728 sort.Strings(got) 729 sort.Strings(tc.want) 730 if diff := cmp.Diff(tc.want, got); diff != "" { 731 t.Errorf("findExtra didn't return correct result: diff (-want, +got)\n%s", diff) 732 } 733 } 734 }