Some checks are pending
Docs Deploy / build_and_deploy (push) Waiting to run
Generate Docs / cli (push) Waiting to run
Generate Config Doc / cli (push) Waiting to run
Go formatting / go-formatting (push) Waiting to run
Check links / markdown-link-check (push) Waiting to run
Integration / pre-test (push) Waiting to run
Integration / test on (push) Blocked by required conditions
Integration / status (push) Blocked by required conditions
Lint / Lint Go code (push) Waiting to run
Test / test (ubuntu-latest) (push) Waiting to run
611 lines
17 KiB
Go
611 lines
17 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
|
hplugin "github.com/hashicorp/go-plugin"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
pluginsconfig "github.com/ignite/cli/v29/ignite/config/plugins"
|
|
"github.com/ignite/cli/v29/ignite/pkg/cliui"
|
|
"github.com/ignite/cli/v29/ignite/pkg/errors"
|
|
"github.com/ignite/cli/v29/ignite/pkg/gocmd"
|
|
"github.com/ignite/cli/v29/ignite/pkg/gomodule"
|
|
)
|
|
|
|
func TestNewPlugin(t *testing.T) {
|
|
wd, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
pluginCfg pluginsconfig.Plugin
|
|
expectedPlugin Plugin
|
|
}{
|
|
{
|
|
name: "fail: empty path",
|
|
expectedPlugin: Plugin{
|
|
Error: errors.Errorf(`missing app property "path"`),
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "fail: local plugin doesnt exists",
|
|
pluginCfg: pluginsconfig.Plugin{Path: "/xxx/yyy/app"},
|
|
expectedPlugin: Plugin{
|
|
Error: errors.Errorf(`local app path "/xxx/yyy/app" not found`),
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "fail: local plugin is not a directory",
|
|
pluginCfg: pluginsconfig.Plugin{Path: path.Join(wd, "testdata/fakebin")},
|
|
expectedPlugin: Plugin{
|
|
Error: errors.Errorf(fmt.Sprintf("local app path %q is not a directory", path.Join(wd, "testdata/fakebin"))),
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "ok: local plugin",
|
|
pluginCfg: pluginsconfig.Plugin{Path: path.Join(wd, "testdata")},
|
|
expectedPlugin: Plugin{
|
|
srcPath: path.Join(wd, "testdata"),
|
|
name: "testdata",
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "fail: remote plugin with only domain",
|
|
pluginCfg: pluginsconfig.Plugin{Path: "github.com"},
|
|
expectedPlugin: Plugin{
|
|
Error: errors.Errorf(`app path "github.com" is not a valid repository URL`),
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "fail: remote plugin with incomplete URL",
|
|
pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite"},
|
|
expectedPlugin: Plugin{
|
|
Error: errors.Errorf(`app path "github.com/ignite" is not a valid repository URL`),
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "ok: remote app",
|
|
pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app"},
|
|
expectedPlugin: Plugin{
|
|
repoPath: "github.com/ignite/app",
|
|
cloneURL: "https://github.com/ignite/app",
|
|
cloneDir: ".ignite/apps/github.com/ignite/app",
|
|
reference: "",
|
|
srcPath: ".ignite/apps/github.com/ignite/app",
|
|
name: "app",
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "ok: remote plugin with @ref",
|
|
pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app@develop"},
|
|
expectedPlugin: Plugin{
|
|
repoPath: "github.com/ignite/app@develop",
|
|
cloneURL: "https://github.com/ignite/app",
|
|
cloneDir: ".ignite/apps/github.com/ignite/app-develop",
|
|
reference: "develop",
|
|
srcPath: ".ignite/apps/github.com/ignite/app-develop",
|
|
name: "app",
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "ok: remote plugin with @ref containing slash",
|
|
pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app@package/v1.0.0"},
|
|
expectedPlugin: Plugin{
|
|
repoPath: "github.com/ignite/app@package/v1.0.0",
|
|
cloneURL: "https://github.com/ignite/app",
|
|
cloneDir: ".ignite/apps/github.com/ignite/app-package-v1.0.0",
|
|
reference: "package/v1.0.0",
|
|
srcPath: ".ignite/apps/github.com/ignite/app-package-v1.0.0",
|
|
name: "app",
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "ok: remote plugin with subpath",
|
|
pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app/plugin1"},
|
|
expectedPlugin: Plugin{
|
|
repoPath: "github.com/ignite/app",
|
|
cloneURL: "https://github.com/ignite/app",
|
|
cloneDir: ".ignite/apps/github.com/ignite/app",
|
|
reference: "",
|
|
srcPath: ".ignite/apps/github.com/ignite/app/plugin1",
|
|
name: "plugin1",
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "ok: remote plugin with subpath and @ref",
|
|
pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app/plugin1@develop"},
|
|
expectedPlugin: Plugin{
|
|
repoPath: "github.com/ignite/app@develop",
|
|
cloneURL: "https://github.com/ignite/app",
|
|
cloneDir: ".ignite/apps/github.com/ignite/app-develop",
|
|
reference: "develop",
|
|
srcPath: ".ignite/apps/github.com/ignite/app-develop/plugin1",
|
|
name: "plugin1",
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
{
|
|
name: "ok: remote plugin with subpath and @ref containing slash",
|
|
pluginCfg: pluginsconfig.Plugin{Path: "github.com/ignite/app/plugin1@package/v1.0.0"},
|
|
expectedPlugin: Plugin{
|
|
repoPath: "github.com/ignite/app@package/v1.0.0",
|
|
cloneURL: "https://github.com/ignite/app",
|
|
cloneDir: ".ignite/apps/github.com/ignite/app-package-v1.0.0",
|
|
reference: "package/v1.0.0",
|
|
srcPath: ".ignite/apps/github.com/ignite/app-package-v1.0.0/plugin1",
|
|
name: "plugin1",
|
|
stdout: os.Stdout,
|
|
stderr: os.Stderr,
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tt.expectedPlugin.Plugin = tt.pluginCfg
|
|
|
|
p := newPlugin(".ignite/apps", tt.pluginCfg)
|
|
|
|
assertPlugin(t, tt.expectedPlugin, *p)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper to make a local git repository with gofile committed.
|
|
// Returns the repo directory and the git.Repository.
|
|
func makeGitRepo(t *testing.T, name string) (string, *git.Repository) {
|
|
t.Helper()
|
|
|
|
require := require.New(t)
|
|
repoDir := t.TempDir()
|
|
scaffoldPlugin(t, repoDir, "github.com/ignite/"+name, false)
|
|
|
|
repo, err := git.PlainInit(repoDir, false)
|
|
require.NoError(err)
|
|
|
|
w, err := repo.Worktree()
|
|
require.NoError(err)
|
|
|
|
_, err = w.Add(".")
|
|
require.NoError(err)
|
|
|
|
_, err = w.Commit("msg", &git.CommitOptions{
|
|
Author: &object.Signature{
|
|
Name: "bob",
|
|
Email: "bob@example.com",
|
|
When: time.Now(),
|
|
},
|
|
})
|
|
require.NoError(err)
|
|
return repoDir, repo
|
|
}
|
|
|
|
type TestClientAPI struct{ ClientAPI }
|
|
|
|
func (TestClientAPI) GetChainInfo(context.Context) (*ChainInfo, error) {
|
|
return &ChainInfo{}, nil
|
|
}
|
|
|
|
func (TestClientAPI) GetIgniteInfo(context.Context) (*IgniteInfo, error) {
|
|
return &IgniteInfo{}, nil
|
|
}
|
|
|
|
func TestPluginLoad(t *testing.T) {
|
|
wd, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
|
|
clientAPI := &TestClientAPI{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
buildPlugin func(t *testing.T) Plugin
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "fail: plugin is already in error",
|
|
buildPlugin: func(t *testing.T) Plugin {
|
|
t.Helper()
|
|
return Plugin{
|
|
Error: errors.New("oups"),
|
|
}
|
|
},
|
|
expectedError: `oups`,
|
|
},
|
|
{
|
|
name: "fail: no go files in srcPath",
|
|
buildPlugin: func(t *testing.T) Plugin {
|
|
t.Helper()
|
|
return Plugin{
|
|
srcPath: path.Join(wd, "testdata"),
|
|
name: "testdata",
|
|
}
|
|
},
|
|
expectedError: `no Go files in`,
|
|
},
|
|
{
|
|
name: "ok: from local",
|
|
buildPlugin: func(t *testing.T) Plugin {
|
|
t.Helper()
|
|
path := scaffoldPlugin(t, t.TempDir(), "github.com/foo/bar", false)
|
|
return Plugin{
|
|
srcPath: path,
|
|
name: "bar",
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "ok: from git repo",
|
|
buildPlugin: func(t *testing.T) Plugin {
|
|
t.Helper()
|
|
repoDir, _ := makeGitRepo(t, "remote")
|
|
cloneDir := t.TempDir()
|
|
|
|
return Plugin{
|
|
cloneURL: repoDir,
|
|
cloneDir: cloneDir,
|
|
srcPath: path.Join(cloneDir, "remote"),
|
|
name: "remote",
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "fail: git repo doesnt exists",
|
|
buildPlugin: func(t *testing.T) Plugin {
|
|
t.Helper()
|
|
cloneDir := t.TempDir()
|
|
|
|
return Plugin{
|
|
repoPath: "/xxxx/yyyy",
|
|
cloneURL: "/xxxx/yyyy",
|
|
cloneDir: cloneDir,
|
|
srcPath: path.Join(cloneDir, "app"),
|
|
}
|
|
},
|
|
expectedError: `cloning "/xxxx/yyyy": repository not found`,
|
|
},
|
|
{
|
|
name: "ok: from git repo with tag",
|
|
buildPlugin: func(t *testing.T) Plugin {
|
|
t.Helper()
|
|
repoDir, repo := makeGitRepo(t, "remote-tag")
|
|
h, err := repo.Head()
|
|
require.NoError(t, err)
|
|
_, err = repo.CreateTag("v1", h.Hash(), &git.CreateTagOptions{
|
|
Tagger: &object.Signature{Name: "me"},
|
|
Message: "v1",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cloneDir := t.TempDir()
|
|
|
|
return Plugin{
|
|
cloneURL: repoDir,
|
|
reference: "v1",
|
|
cloneDir: cloneDir,
|
|
srcPath: path.Join(cloneDir, "remote-tag"),
|
|
name: "remote-tag",
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "ok: from git repo with branch",
|
|
buildPlugin: func(t *testing.T) Plugin {
|
|
t.Helper()
|
|
repoDir, repo := makeGitRepo(t, "remote-branch")
|
|
w, err := repo.Worktree()
|
|
require.NoError(t, err)
|
|
err = w.Checkout(&git.CheckoutOptions{
|
|
Branch: plumbing.NewBranchReferenceName("branch1"),
|
|
Create: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cloneDir := t.TempDir()
|
|
|
|
return Plugin{
|
|
cloneURL: repoDir,
|
|
reference: "branch1",
|
|
cloneDir: cloneDir,
|
|
srcPath: path.Join(cloneDir, "remote-branch"),
|
|
name: "remote-branch",
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "ok: from git repo with hash",
|
|
buildPlugin: func(t *testing.T) Plugin {
|
|
t.Helper()
|
|
repoDir, repo := makeGitRepo(t, "remote-hash")
|
|
h, err := repo.Head()
|
|
require.NoError(t, err)
|
|
|
|
cloneDir := t.TempDir()
|
|
|
|
return Plugin{
|
|
cloneURL: repoDir,
|
|
reference: h.Hash().String(),
|
|
cloneDir: cloneDir,
|
|
srcPath: path.Join(cloneDir, "remote-hash"),
|
|
name: "remote-hash",
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "fail: git ref not found",
|
|
buildPlugin: func(t *testing.T) Plugin {
|
|
t.Helper()
|
|
repoDir, _ := makeGitRepo(t, "remote-no-ref")
|
|
|
|
cloneDir := t.TempDir()
|
|
|
|
return Plugin{
|
|
cloneURL: repoDir,
|
|
reference: "doesnt_exists",
|
|
cloneDir: cloneDir,
|
|
srcPath: path.Join(cloneDir, "remote-no-ref"),
|
|
name: "remote-no-ref",
|
|
}
|
|
},
|
|
expectedError: `cloning ".*": reference not found`,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
require := require.New(t)
|
|
assert := assert.New(t)
|
|
p := tt.buildPlugin(t)
|
|
defer p.KillClient()
|
|
|
|
p.load(context.Background())
|
|
|
|
if tt.expectedError != "" {
|
|
require.Error(p.Error, "expected error %q", tt.expectedError)
|
|
require.Regexp(tt.expectedError, p.Error.Error())
|
|
return
|
|
}
|
|
|
|
require.NoError(p.Error)
|
|
require.NotNil(p.Interface)
|
|
manifest, err := p.Interface.Manifest(ctx)
|
|
require.NoError(err)
|
|
assert.Equal(p.name, manifest.Name)
|
|
assert.NoError(p.Interface.Execute(ctx, &ExecutedCommand{OsArgs: []string{"ignite", p.name, "hello"}}, clientAPI))
|
|
assert.NoError(p.Interface.ExecuteHookPre(ctx, &ExecutedHook{}, clientAPI))
|
|
assert.NoError(p.Interface.ExecuteHookPost(ctx, &ExecutedHook{}, clientAPI))
|
|
assert.NoError(p.Interface.ExecuteHookCleanUp(ctx, &ExecutedHook{}, clientAPI))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPluginLoadSharedHost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
instances int
|
|
sharesHost bool
|
|
}{
|
|
{
|
|
name: "ok: from local sharedhost is on 1 instance",
|
|
instances: 1,
|
|
sharesHost: true,
|
|
},
|
|
{
|
|
name: "ok: from local sharedhost is on 2 instances",
|
|
instances: 2,
|
|
sharesHost: true,
|
|
},
|
|
{
|
|
name: "ok: from local sharedhost is on 4 instances",
|
|
instances: 4,
|
|
sharesHost: true,
|
|
},
|
|
{
|
|
name: "ok: from local sharedhost is off 4 instances",
|
|
instances: 4,
|
|
sharesHost: false,
|
|
},
|
|
}
|
|
|
|
for i, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var (
|
|
require = require.New(t)
|
|
assert = assert.New(t)
|
|
// scaffold an unique plugin for all instances
|
|
path = scaffoldPlugin(t, t.TempDir(),
|
|
fmt.Sprintf("github.com/foo/bar-%d", i), tt.sharesHost)
|
|
plugins []*Plugin
|
|
)
|
|
// Load one plugin per instance
|
|
for i := 0; i < tt.instances; i++ {
|
|
p := Plugin{
|
|
Plugin: pluginsconfig.Plugin{Path: path},
|
|
srcPath: path,
|
|
name: filepath.Base(path),
|
|
}
|
|
p.load(context.Background())
|
|
require.NoError(p.Error)
|
|
plugins = append(plugins, &p)
|
|
}
|
|
// Ensure all plugins are killed at the end of test case
|
|
defer func() {
|
|
for i := len(plugins) - 1; i >= 0; i-- {
|
|
plugins[i].KillClient()
|
|
if tt.sharesHost && i > 0 {
|
|
assert.False(plugins[i].client.Exited(), "non host app can't kill host app")
|
|
assert.True(checkConfCache(plugins[i].Path), "non host app doesn't remove config cache when killed")
|
|
} else {
|
|
assert.True(plugins[i].client.Exited(), "app should be killed")
|
|
}
|
|
assert.False(plugins[i].isHost, "killed plugins are no longer host")
|
|
}
|
|
assert.False(checkConfCache(plugins[0].Path), "once host is killed the cache should be cleared")
|
|
}()
|
|
|
|
var hostConf *hplugin.ReattachConfig
|
|
for i := 0; i < len(plugins); i++ {
|
|
if tt.sharesHost {
|
|
assert.True(checkConfCache(plugins[i].Path), "sharedHost must have a cache entry")
|
|
if i == 0 {
|
|
// first plugin is the host
|
|
assert.True(plugins[i].isHost, "first app is the host")
|
|
// Assert reattach config has been saved
|
|
hostConf = plugins[i].client.ReattachConfig()
|
|
ref, err := readConfigCache(plugins[i].Path)
|
|
if assert.NoError(err) {
|
|
assert.Equal(hostConf, &ref, "wrong cache entry for app host")
|
|
}
|
|
} else {
|
|
// plugins after first aren't host
|
|
assert.False(plugins[i].isHost, "app %d can't be host", i)
|
|
assert.Equal(hostConf, plugins[i].client.ReattachConfig(), "ReattachConfig different from host app")
|
|
}
|
|
} else {
|
|
assert.False(plugins[i].isHost, "app %d can't be host if sharedHost is disabled", i)
|
|
assert.False(checkConfCache(plugins[i].Path), "app %d can't have a cache entry if sharedHost is disabled", i)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPluginClean(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
plugin *Plugin
|
|
expectRemove bool
|
|
}{
|
|
{
|
|
name: "dont clean local app",
|
|
plugin: &Plugin{
|
|
Plugin: pluginsconfig.Plugin{Path: "/local"},
|
|
},
|
|
},
|
|
{
|
|
name: "dont clean plugin with errors",
|
|
plugin: &Plugin{Error: errors.New("oups")},
|
|
},
|
|
{
|
|
name: "ok",
|
|
plugin: &Plugin{
|
|
cloneURL: "https://github.com/ignite/app",
|
|
},
|
|
expectRemove: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmp, err := os.MkdirTemp("", "cloneDir")
|
|
require.NoError(t, err)
|
|
tt.plugin.cloneDir = tmp
|
|
|
|
err = tt.plugin.clean()
|
|
|
|
require.NoError(t, err)
|
|
if tt.expectRemove {
|
|
_, err := os.Stat(tmp)
|
|
assert.True(t, os.IsNotExist(err), "cloneDir not removed")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPluginOutdatedBinary(t *testing.T) {
|
|
t.Run("returns true when source and binary mtimes are equal", func(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
srcFile := filepath.Join(tmp, "main.go")
|
|
binFile := filepath.Join(tmp, "app.ign")
|
|
|
|
require.NoError(t, os.WriteFile(srcFile, []byte("package main\n"), 0o644))
|
|
require.NoError(t, os.WriteFile(binFile, []byte("binary"), 0o755))
|
|
|
|
equalTime := time.Now().Add(-time.Minute).Truncate(time.Second)
|
|
require.NoError(t, os.Chtimes(srcFile, equalTime, equalTime))
|
|
require.NoError(t, os.Chtimes(binFile, equalTime, equalTime))
|
|
|
|
p := Plugin{
|
|
srcPath: tmp,
|
|
name: "app",
|
|
}
|
|
|
|
require.True(t, p.outdatedBinary())
|
|
})
|
|
}
|
|
|
|
// scaffoldPlugin runs Scaffold and updates the go.mod so it uses the
|
|
// current ignite/cli sources.
|
|
func scaffoldPlugin(t *testing.T, dir, name string, sharedHost bool) string {
|
|
t.Helper()
|
|
|
|
require := require.New(t)
|
|
|
|
session := cliui.New(cliui.WithoutUserInteraction(true))
|
|
path, err := Scaffold(context.Background(), session, dir, name, sharedHost)
|
|
require.NoError(err)
|
|
|
|
// We want the scaffolded plugin to use the current version of ignite/cli,
|
|
// for that we need to update the plugin go.mod and add a replace to target
|
|
// current ignite/cli
|
|
gomod, err := gomodule.ParseAt(path)
|
|
require.NoError(err)
|
|
|
|
// use GOMOD env to get current directory module path
|
|
modpath, err := gocmd.Env(gocmd.EnvGOMOD)
|
|
require.NoError(err)
|
|
modpath = filepath.Dir(modpath)
|
|
err = gomod.AddReplace("github.com/ignite/cli/v29", "", modpath, "")
|
|
require.NoError(err)
|
|
// Save go.mod
|
|
data, err := gomod.Format()
|
|
require.NoError(err)
|
|
err = os.WriteFile(filepath.Join(path, "go.mod"), data, 0o644)
|
|
require.NoError(err)
|
|
return path
|
|
}
|
|
|
|
func assertPlugin(t *testing.T, want, have Plugin) {
|
|
t.Helper()
|
|
|
|
if want.Error != nil {
|
|
require.Errorf(t, have.Error, "expected error %q", want.Error)
|
|
assert.Regexp(t, want.Error.Error(), have.Error.Error())
|
|
} else {
|
|
require.NoErrorf(t, have.Error, "expected no error, got %v", have.Error)
|
|
}
|
|
|
|
// Errors aren't comparable with assert.Equal, because of the different stacks
|
|
want.Error = nil
|
|
have.Error = nil
|
|
assert.Equal(t, want, have)
|
|
}
|