| Index: go/src/infra/tools/cipd/client_test.go
|
| diff --git a/go/src/infra/tools/cipd/client_test.go b/go/src/infra/tools/cipd/client_test.go
|
| new file mode 100644
|
| index 0000000000000000000000000000000000000000..8eec4c4f9265261526df6c411ddf5da5eb3dd004
|
| --- /dev/null
|
| +++ b/go/src/infra/tools/cipd/client_test.go
|
| @@ -0,0 +1,594 @@
|
| +// Copyright 2015 The Chromium Authors. All rights reserved.
|
| +// Use of this source code is governed by a BSD-style license that can be
|
| +// found in the LICENSE file.
|
| +
|
| +package cipd
|
| +
|
| +import (
|
| + "bytes"
|
| + "fmt"
|
| + "io"
|
| + "io/ioutil"
|
| + "net/http"
|
| + "net/http/httptest"
|
| + "net/url"
|
| + "os"
|
| + "path/filepath"
|
| + "testing"
|
| + "time"
|
| +
|
| + . "github.com/smartystreets/goconvey/convey"
|
| +
|
| + "infra/tools/cipd/common"
|
| + "infra/tools/cipd/local"
|
| +)
|
| +
|
| +func TestUploadToCAS(t *testing.T) {
|
| + Convey("UploadToCAS full flow", t, func(c C) {
|
| + client := mockClient(c, []expectedHTTPCall{
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/cas/v1/upload/SHA1/abc",
|
| + Reply: `{"status":"SUCCESS","upload_session_id":"12345","upload_url":"http://localhost"}`,
|
| + },
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/cas/v1/finalize/12345",
|
| + Reply: `{"status":"VERIFYING"}`,
|
| + },
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/cas/v1/finalize/12345",
|
| + Reply: `{"status":"PUBLISHED"}`,
|
| + },
|
| + })
|
| + client.storage = &mockedStorage{c, nil}
|
| + err := client.UploadToCAS("abc", nil, nil)
|
| + So(err, ShouldBeNil)
|
| + })
|
| +
|
| + Convey("UploadToCAS timeout", t, func(c C) {
|
| + // Append a bunch of "still verifying" responses at the end.
|
| + calls := []expectedHTTPCall{
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/cas/v1/upload/SHA1/abc",
|
| + Reply: `{"status":"SUCCESS","upload_session_id":"12345","upload_url":"http://localhost"}`,
|
| + },
|
| + }
|
| + for i := 0; i < 19; i++ {
|
| + calls = append(calls, expectedHTTPCall{
|
| + Method: "POST",
|
| + Path: "/_ah/api/cas/v1/finalize/12345",
|
| + Reply: `{"status":"VERIFYING"}`,
|
| + })
|
| + }
|
| + client := mockClient(c, calls)
|
| + client.storage = &mockedStorage{c, nil}
|
| + err := client.UploadToCAS("abc", nil, nil)
|
| + So(err, ShouldEqual, ErrFinalizationTimeout)
|
| + })
|
| +}
|
| +
|
| +func TestRegisterInstance(t *testing.T) {
|
| + Convey("Mocking a package instance", t, func() {
|
| + // Build an empty package to be uploaded.
|
| + out := bytes.Buffer{}
|
| + err := local.BuildInstance(local.BuildInstanceOptions{
|
| + Input: []local.File{},
|
| + Output: &out,
|
| + PackageName: "testing",
|
| + })
|
| + So(err, ShouldBeNil)
|
| +
|
| + // Open it for reading.
|
| + inst, err := local.OpenInstance(bytes.NewReader(out.Bytes()), "")
|
| + So(err, ShouldBeNil)
|
| + Reset(func() { inst.Close() })
|
| +
|
| + Convey("RegisterInstance full flow", func(c C) {
|
| + client := mockClient(c, []expectedHTTPCall{
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/repo/v1/instance",
|
| + Query: url.Values{
|
| + "instance_id": []string{inst.Pin().InstanceID},
|
| + "package_name": []string{inst.Pin().PackageName},
|
| + },
|
| + Reply: `{
|
| + "status": "UPLOAD_FIRST",
|
| + "upload_session_id": "12345",
|
| + "upload_url": "http://localhost"
|
| + }`,
|
| + },
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/cas/v1/finalize/12345",
|
| + Reply: `{"status":"PUBLISHED"}`,
|
| + },
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/repo/v1/instance",
|
| + Query: url.Values{
|
| + "instance_id": []string{inst.Pin().InstanceID},
|
| + "package_name": []string{inst.Pin().PackageName},
|
| + },
|
| + Reply: `{
|
| + "status": "REGISTERED",
|
| + "instance": {
|
| + "registered_by": "user:a@example.com",
|
| + "registered_ts": "0"
|
| + }
|
| + }`,
|
| + },
|
| + })
|
| + client.storage = &mockedStorage{c, nil}
|
| + err = client.RegisterInstance(inst)
|
| + So(err, ShouldBeNil)
|
| + })
|
| +
|
| + Convey("RegisterInstance already registered", func(c C) {
|
| + client := mockClient(c, []expectedHTTPCall{
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/repo/v1/instance",
|
| + Query: url.Values{
|
| + "instance_id": []string{inst.Pin().InstanceID},
|
| + "package_name": []string{inst.Pin().PackageName},
|
| + },
|
| + Reply: `{
|
| + "status": "ALREADY_REGISTERED",
|
| + "instance": {
|
| + "registered_by": "user:a@example.com",
|
| + "registered_ts": "0"
|
| + }
|
| + }`,
|
| + },
|
| + })
|
| + client.storage = &mockedStorage{c, nil}
|
| + err = client.RegisterInstance(inst)
|
| + So(err, ShouldBeNil)
|
| + })
|
| + })
|
| +}
|
| +
|
| +func TestAttachTagsWhenReady(t *testing.T) {
|
| + Convey("AttachTagsWhenReady works", t, func(c C) {
|
| + client := mockClient(c, []expectedHTTPCall{
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/repo/v1/tags",
|
| + Query: url.Values{
|
| + "instance_id": []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
| + "package_name": []string{"pkgname"},
|
| + },
|
| + Body: `{"tags":["tag1:value1"]}`,
|
| + Reply: `{"status": "PROCESSING_NOT_FINISHED_YET"}`,
|
| + },
|
| + {
|
| + Method: "POST",
|
| + Path: "/_ah/api/repo/v1/tags",
|
| + Query: url.Values{
|
| + "instance_id": []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
| + "package_name": []string{"pkgname"},
|
| + },
|
| + Body: `{"tags":["tag1:value1"]}`,
|
| + Reply: `{"status": "SUCCESS"}`,
|
| + },
|
| + })
|
| + pin := common.Pin{
|
| + PackageName: "pkgname",
|
| + InstanceID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
| + }
|
| + err := client.AttachTagsWhenReady(pin, []string{"tag1:value1"})
|
| + So(err, ShouldBeNil)
|
| + })
|
| +
|
| + Convey("AttachTagsWhenReady timeout", t, func(c C) {
|
| + calls := []expectedHTTPCall{}
|
| + for i := 0; i < 12; i++ {
|
| + calls = append(calls, expectedHTTPCall{
|
| + Method: "POST",
|
| + Path: "/_ah/api/repo/v1/tags",
|
| + Query: url.Values{
|
| + "instance_id": []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
| + "package_name": []string{"pkgname"},
|
| + },
|
| + Body: `{"tags":["tag1:value1"]}`,
|
| + Reply: `{"status": "PROCESSING_NOT_FINISHED_YET"}`,
|
| + })
|
| + }
|
| + client := mockClient(c, calls)
|
| + pin := common.Pin{
|
| + PackageName: "pkgname",
|
| + InstanceID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
| + }
|
| + err := client.AttachTagsWhenReady(pin, []string{"tag1:value1"})
|
| + So(err, ShouldEqual, ErrAttachTagsTimeout)
|
| + })
|
| +}
|
| +
|
| +func TestFetch(t *testing.T) {
|
| + Convey("Mocking remote services", t, func() {
|
| + tempDir, err := ioutil.TempDir("", "cipd_test")
|
| + So(err, ShouldBeNil)
|
| + Reset(func() { os.RemoveAll(tempDir) })
|
| + tempFile := filepath.Join(tempDir, "pkg")
|
| +
|
| + Convey("FetchInstance works", func(c C) {
|
| + inst := buildInstanceInMemory("pkgname", nil)
|
| + defer inst.Close()
|
| +
|
| + out, err := os.OpenFile(tempFile, os.O_WRONLY|os.O_CREATE, 0666)
|
| + So(err, ShouldBeNil)
|
| + closed := false
|
| + defer func() {
|
| + if !closed {
|
| + out.Close()
|
| + }
|
| + }()
|
| +
|
| + client := mockClientForFetch(c, []local.PackageInstance{inst})
|
| + err = client.FetchInstance(inst.Pin(), out)
|
| + So(err, ShouldBeNil)
|
| + out.Close()
|
| + closed = true
|
| +
|
| + fetched, err := local.OpenInstanceFile(tempFile, "")
|
| + So(err, ShouldBeNil)
|
| + So(fetched.Pin(), ShouldResemble, inst.Pin())
|
| + })
|
| +
|
| + Convey("FetchAndDeployInstance works", func(c C) {
|
| + // Build a package instance with some file.
|
| + inst := buildInstanceInMemory("testing/package", []local.File{
|
| + local.NewTestFile("file", "test data", false),
|
| + })
|
| + defer inst.Close()
|
| +
|
| + // Install the package, fetching it from the fake server.
|
| + client := mockClientForFetch(c, []local.PackageInstance{inst})
|
| + err = client.FetchAndDeployInstance(tempDir, inst.Pin())
|
| + So(err, ShouldBeNil)
|
| +
|
| + // The file from the package should be installed.
|
| + data, err := ioutil.ReadFile(filepath.Join(tempDir, "file"))
|
| + So(err, ShouldBeNil)
|
| + So(data, ShouldResemble, []byte("test data"))
|
| + })
|
| + })
|
| +}
|
| +
|
| +func TestProcessEnsureFile(t *testing.T) {
|
| + call := func(c C, data string) ([]common.Pin, error) {
|
| + client := mockClient(c, nil)
|
| + return client.ProcessEnsureFile(bytes.NewBufferString(data))
|
| + }
|
| +
|
| + Convey("ProcessEnsureFile works", t, func(c C) {
|
| + out, err := call(c, `
|
| + # Comment
|
| +
|
| + pkg/a 0000000000000000000000000000000000000000
|
| + pkg/b 1000000000000000000000000000000000000000
|
| + `)
|
| + So(err, ShouldBeNil)
|
| + So(out, ShouldResemble, []common.Pin{
|
| + {"pkg/a", "0000000000000000000000000000000000000000"},
|
| + {"pkg/b", "1000000000000000000000000000000000000000"},
|
| + })
|
| + })
|
| +
|
| + Convey("ProcessEnsureFile empty", t, func(c C) {
|
| + out, err := call(c, "")
|
| + So(err, ShouldBeNil)
|
| + So(out, ShouldResemble, []common.Pin{})
|
| + })
|
| +
|
| + Convey("ProcessEnsureFile bad package name", t, func(c C) {
|
| + _, err := call(c, "bad.package.name/a 0000000000000000000000000000000000000000")
|
| + So(err, ShouldNotBeNil)
|
| + })
|
| +
|
| + Convey("ProcessEnsureFile bad instance ID", t, func(c C) {
|
| + _, err := call(c, "pkg/a 0000")
|
| + So(err, ShouldNotBeNil)
|
| + })
|
| +
|
| + Convey("ProcessEnsureFile bad line", t, func(c C) {
|
| + _, err := call(c, "pkg/a")
|
| + So(err, ShouldNotBeNil)
|
| + })
|
| +}
|
| +
|
| +func TestEnsurePackages(t *testing.T) {
|
| + Convey("Mocking temp dir", t, func() {
|
| + tempDir, err := ioutil.TempDir("", "cipd_test")
|
| + So(err, ShouldBeNil)
|
| + Reset(func() { os.RemoveAll(tempDir) })
|
| +
|
| + assertFile := func(relPath, data string) {
|
| + body, err := ioutil.ReadFile(filepath.Join(tempDir, relPath))
|
| + So(err, ShouldBeNil)
|
| + So(string(body), ShouldEqual, data)
|
| + }
|
| +
|
| + Convey("EnsurePackages full flow", func(c C) {
|
| + // Prepare a bunch of packages.
|
| + a1 := buildInstanceInMemory("pkg/a", []local.File{local.NewTestFile("file a 1", "test data", false)})
|
| + defer a1.Close()
|
| + a2 := buildInstanceInMemory("pkg/a", []local.File{local.NewTestFile("file a 2", "test data", false)})
|
| + defer a2.Close()
|
| + b := buildInstanceInMemory("pkg/b", []local.File{local.NewTestFile("file b", "test data", false)})
|
| + defer b.Close()
|
| +
|
| + // Calls EnsurePackages, mocking fetch backend first. Backend will be mocked
|
| + // to serve only 'fetched' packages.
|
| + callEnsure := func(instances []local.PackageInstance, fetched []local.PackageInstance) error {
|
| + client := mockClientForFetch(c, fetched)
|
| + pins := []common.Pin{}
|
| + for _, i := range instances {
|
| + pins = append(pins, i.Pin())
|
| + }
|
| + return client.EnsurePackages(tempDir, pins)
|
| + }
|
| +
|
| + // Noop run on top of empty directory.
|
| + err := callEnsure(nil, nil)
|
| + So(err, ShouldBeNil)
|
| +
|
| + // Specify same package twice. Fails.
|
| + err = callEnsure([]local.PackageInstance{a1, a2}, nil)
|
| + So(err, ShouldNotBeNil)
|
| +
|
| + // Install a1 into a site root.
|
| + err = callEnsure([]local.PackageInstance{a1}, []local.PackageInstance{a1})
|
| + So(err, ShouldBeNil)
|
| + assertFile("file a 1", "test data")
|
| + deployed, err := local.FindDeployed(tempDir)
|
| + So(err, ShouldBeNil)
|
| + So(deployed, ShouldResemble, []common.Pin{a1.Pin()})
|
| +
|
| + // Noop run. Nothing is fetched.
|
| + err = callEnsure([]local.PackageInstance{a1}, nil)
|
| + So(err, ShouldBeNil)
|
| + assertFile("file a 1", "test data")
|
| + deployed, err = local.FindDeployed(tempDir)
|
| + So(err, ShouldBeNil)
|
| + So(deployed, ShouldResemble, []common.Pin{a1.Pin()})
|
| +
|
| + // Upgrade a1 to a2.
|
| + err = callEnsure([]local.PackageInstance{a2}, []local.PackageInstance{a2})
|
| + So(err, ShouldBeNil)
|
| + assertFile("file a 2", "test data")
|
| + deployed, err = local.FindDeployed(tempDir)
|
| + So(err, ShouldBeNil)
|
| + So(deployed, ShouldResemble, []common.Pin{a2.Pin()})
|
| +
|
| + // Remove a2 and install b.
|
| + err = callEnsure([]local.PackageInstance{b}, []local.PackageInstance{b})
|
| + So(err, ShouldBeNil)
|
| + assertFile("file b", "test data")
|
| + deployed, err = local.FindDeployed(tempDir)
|
| + So(err, ShouldBeNil)
|
| + So(deployed, ShouldResemble, []common.Pin{b.Pin()})
|
| +
|
| + // Remove b.
|
| + err = callEnsure(nil, nil)
|
| + So(err, ShouldBeNil)
|
| + deployed, err = local.FindDeployed(tempDir)
|
| + So(err, ShouldBeNil)
|
| + So(deployed, ShouldResemble, []common.Pin{})
|
| +
|
| + // Install a1 and b.
|
| + err = callEnsure([]local.PackageInstance{a1, b}, []local.PackageInstance{a1, b})
|
| + So(err, ShouldBeNil)
|
| + assertFile("file a 1", "test data")
|
| + assertFile("file b", "test data")
|
| + deployed, err = local.FindDeployed(tempDir)
|
| + So(err, ShouldBeNil)
|
| + So(deployed, ShouldResemble, []common.Pin{a1.Pin(), b.Pin()})
|
| + })
|
| + })
|
| +}
|
| +
|
| +////////////////////////////////////////////////////////////////////////////////
|
| +
|
| +// buildInstanceInMemory makes fully functional PackageInstance object that uses
|
| +// memory buffer as a backing store.
|
| +func buildInstanceInMemory(pkgName string, files []local.File) local.PackageInstance {
|
| + out := bytes.Buffer{}
|
| + err := local.BuildInstance(local.BuildInstanceOptions{
|
| + Input: files,
|
| + Output: &out,
|
| + PackageName: pkgName,
|
| + })
|
| + So(err, ShouldBeNil)
|
| + inst, err := local.OpenInstance(bytes.NewReader(out.Bytes()), "")
|
| + So(err, ShouldBeNil)
|
| + return inst
|
| +}
|
| +
|
| +////////////////////////////////////////////////////////////////////////////////
|
| +
|
| +// mockClientForFetch returns Client with fetch related calls mocked.
|
| +func mockClientForFetch(c C, instances []local.PackageInstance) *Client {
|
| + // Mock RPC calls.
|
| + calls := []expectedHTTPCall{}
|
| + for _, inst := range instances {
|
| + calls = append(calls, expectedHTTPCall{
|
| + Method: "GET",
|
| + Path: "/_ah/api/repo/v1/instance",
|
| + Query: url.Values{
|
| + "instance_id": []string{inst.Pin().InstanceID},
|
| + "package_name": []string{inst.Pin().PackageName},
|
| + },
|
| + Reply: fmt.Sprintf(`{
|
| + "status": "SUCCESS",
|
| + "instance": {
|
| + "registered_by": "user:a@example.com",
|
| + "registered_ts": "0"
|
| + },
|
| + "fetch_url": "http://localhost/fetch/%s"
|
| + }`, inst.Pin().InstanceID),
|
| + })
|
| + }
|
| + client := mockClient(c, calls)
|
| +
|
| + // Mock storage.
|
| + data := map[string][]byte{}
|
| + for _, inst := range instances {
|
| + r := inst.DataReader()
|
| + _, err := r.Seek(0, os.SEEK_SET)
|
| + c.So(err, ShouldBeNil)
|
| + blob, err := ioutil.ReadAll(r)
|
| + c.So(err, ShouldBeNil)
|
| + data["http://localhost/fetch/"+inst.Pin().InstanceID] = blob
|
| + }
|
| + client.storage = &mockedStorage{c, data}
|
| + return client
|
| +}
|
| +
|
| +////////////////////////////////////////////////////////////////////////////////
|
| +
|
| +// mockedStorage implements storage by returning mocked data in 'download' and
|
| +// doing nothing in 'upload'.
|
| +type mockedStorage struct {
|
| + c C
|
| + data map[string][]byte
|
| +}
|
| +
|
| +func (s *mockedStorage) download(url string, output io.WriteSeeker) error {
|
| + blob, ok := s.data[url]
|
| + if !ok {
|
| + return ErrDownloadError
|
| + }
|
| + _, err := output.Seek(0, os.SEEK_SET)
|
| + s.c.So(err, ShouldBeNil)
|
| + _, err = output.Write(blob)
|
| + s.c.So(err, ShouldBeNil)
|
| + return nil
|
| +}
|
| +
|
| +func (s *mockedStorage) upload(url string, data io.ReadSeeker) error {
|
| + return nil
|
| +}
|
| +
|
| +////////////////////////////////////////////////////////////////////////////////
|
| +
|
| +type mockedClocked struct {
|
| + ts time.Time
|
| +}
|
| +
|
| +func (c *mockedClocked) now() time.Time { return c.ts }
|
| +func (c *mockedClocked) sleep(d time.Duration) { c.ts = c.ts.Add(d) }
|
| +
|
| +////////////////////////////////////////////////////////////////////////////////
|
| +
|
| +type expectedHTTPCall struct {
|
| + Method string
|
| + Path string
|
| + Query url.Values
|
| + Body string
|
| + Headers http.Header
|
| + Reply string
|
| + Status int
|
| + ResponseHeaders http.Header
|
| +}
|
| +
|
| +// mockClient returns Client with clock and HTTP calls mocked.
|
| +func mockClient(c C, expectations []expectedHTTPCall) *Client {
|
| + client := NewClient()
|
| + client.clock = &mockedClocked{}
|
| +
|
| + // Kill factories. They should not be called for mocked client.
|
| + client.AuthenticatedClientFactory = nil
|
| + client.AnonymousClientFactory = nil
|
| +
|
| + // Provide fake client instead.
|
| + handler := &expectedHTTPCallHandler{c, expectations, 0}
|
| + server := httptest.NewServer(handler)
|
| + Reset(func() {
|
| + server.Close()
|
| + // All expected calls should be made.
|
| + if handler.index != len(handler.calls) {
|
| + c.Printf("Unfinished calls: %v\n", handler.calls[handler.index:])
|
| + }
|
| + c.So(handler.index, ShouldEqual, len(handler.calls))
|
| + })
|
| + transport := &http.Transport{
|
| + Proxy: func(req *http.Request) (*url.URL, error) {
|
| + return url.Parse(server.URL)
|
| + },
|
| + }
|
| + client.ServiceURL = server.URL
|
| + client.anonClient = &http.Client{Transport: transport}
|
| + client.authClient = &http.Client{Transport: transport}
|
| +
|
| + return client
|
| +}
|
| +
|
| +// expectedHTTPCallHandler is http.Handler that serves mocked HTTP calls.
|
| +type expectedHTTPCallHandler struct {
|
| + c C
|
| + calls []expectedHTTPCall
|
| + index int
|
| +}
|
| +
|
| +func (s *expectedHTTPCallHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
| + // Unexpected call?
|
| + if s.index == len(s.calls) {
|
| + s.c.Printf("Unexpected call: %v\n", r)
|
| + }
|
| + s.c.So(s.index, ShouldBeLessThan, len(s.calls))
|
| +
|
| + // Fill in defaults.
|
| + exp := s.calls[s.index]
|
| + if exp.Method == "" {
|
| + exp.Method = "GET"
|
| + }
|
| + if exp.Query == nil {
|
| + exp.Query = url.Values{}
|
| + }
|
| + if exp.Headers == nil {
|
| + exp.Headers = http.Header{}
|
| + }
|
| +
|
| + // Read body and essential headers.
|
| + body, err := ioutil.ReadAll(r.Body)
|
| + s.c.So(err, ShouldBeNil)
|
| + blacklist := map[string]bool{
|
| + "Accept-Encoding": true,
|
| + "Content-Length": true,
|
| + "Content-Type": true,
|
| + "User-Agent": true,
|
| + }
|
| + headers := http.Header{}
|
| + for k, v := range r.Header {
|
| + _, isExpected := exp.Headers[k]
|
| + if isExpected || !blacklist[k] {
|
| + headers[k] = v
|
| + }
|
| + }
|
| +
|
| + // Check that request is what it is expected to be.
|
| + s.c.So(r.Method, ShouldEqual, exp.Method)
|
| + s.c.So(r.URL.Path, ShouldEqual, exp.Path)
|
| + s.c.So(r.URL.Query(), ShouldResemble, exp.Query)
|
| + s.c.So(headers, ShouldResemble, exp.Headers)
|
| + s.c.So(string(body), ShouldEqual, exp.Body)
|
| +
|
| + // Mocked reply.
|
| + if exp.Status != 0 {
|
| + for k, v := range exp.ResponseHeaders {
|
| + for _, s := range v {
|
| + w.Header().Add(k, s)
|
| + }
|
| + }
|
| + w.WriteHeader(exp.Status)
|
| + }
|
| + if exp.Reply != "" {
|
| + w.Write([]byte(exp.Reply))
|
| + }
|
| + s.index++
|
| +}
|
|
|