d7f640f8ab
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
498 lines
17 KiB
Go
498 lines
17 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
type stubEnvRepo struct {
|
|
env *models.TaskEnvironment
|
|
deleted bool
|
|
getErr error
|
|
delErr error
|
|
}
|
|
|
|
func (s *stubEnvRepo) CreateTaskEnvironment(context.Context, *models.TaskEnvironment) error {
|
|
return nil
|
|
}
|
|
func (s *stubEnvRepo) CreateTaskEnvironmentRepo(context.Context, *models.TaskEnvironmentRepo) error {
|
|
return nil
|
|
}
|
|
func (s *stubEnvRepo) ListTaskEnvironmentRepos(context.Context, string) ([]*models.TaskEnvironmentRepo, error) {
|
|
return nil, nil
|
|
}
|
|
func (s *stubEnvRepo) UpdateTaskEnvironmentRepo(context.Context, *models.TaskEnvironmentRepo) error {
|
|
return nil
|
|
}
|
|
func (s *stubEnvRepo) DeleteTaskEnvironmentRepo(context.Context, string) error {
|
|
return nil
|
|
}
|
|
func (s *stubEnvRepo) DeleteTaskEnvironmentReposByEnv(context.Context, string) error {
|
|
return nil
|
|
}
|
|
func (s *stubEnvRepo) GetTaskEnvironment(context.Context, string) (*models.TaskEnvironment, error) {
|
|
return s.env, s.getErr
|
|
}
|
|
func (s *stubEnvRepo) GetTaskEnvironmentByTaskID(context.Context, string) (*models.TaskEnvironment, error) {
|
|
return s.env, s.getErr
|
|
}
|
|
func (s *stubEnvRepo) UpdateTaskEnvironment(context.Context, *models.TaskEnvironment) error {
|
|
return nil
|
|
}
|
|
func (s *stubEnvRepo) DeleteTaskEnvironment(context.Context, string) error {
|
|
if s.delErr != nil {
|
|
return s.delErr
|
|
}
|
|
s.deleted = true
|
|
return nil
|
|
}
|
|
func (s *stubEnvRepo) DeleteTaskEnvironmentsByTask(context.Context, string) error { return nil }
|
|
|
|
type stubDestroyer struct {
|
|
containerCalls []string
|
|
sandboxCalls []string
|
|
worktreeCalls []string
|
|
cancelAfterContainer context.CancelFunc
|
|
cancelAfterFirstWorktree context.CancelFunc
|
|
pushCalls int
|
|
containerErr error
|
|
sandboxErr error
|
|
worktreeErr error
|
|
pushErr error
|
|
}
|
|
|
|
func (s *stubDestroyer) DestroyContainer(_ context.Context, id string) error {
|
|
s.containerCalls = append(s.containerCalls, id)
|
|
if s.cancelAfterContainer != nil {
|
|
s.cancelAfterContainer()
|
|
}
|
|
return s.containerErr
|
|
}
|
|
func (s *stubDestroyer) DestroySandbox(_ context.Context, id, _ string) error {
|
|
s.sandboxCalls = append(s.sandboxCalls, id)
|
|
return s.sandboxErr
|
|
}
|
|
func (s *stubDestroyer) DestroyWorktree(_ context.Context, id string) error {
|
|
s.worktreeCalls = append(s.worktreeCalls, id)
|
|
if s.cancelAfterFirstWorktree != nil && len(s.worktreeCalls) == 1 {
|
|
s.cancelAfterFirstWorktree()
|
|
}
|
|
return s.worktreeErr
|
|
}
|
|
func (s *stubDestroyer) PushEnvironmentBranch(context.Context, *models.TaskEnvironment) error {
|
|
s.pushCalls++
|
|
return s.pushErr
|
|
}
|
|
func (s *stubDestroyer) GetContainerLiveStatus(context.Context, string) (*ContainerLiveStatus, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
type stubRunningChecker struct {
|
|
running bool
|
|
err error
|
|
}
|
|
|
|
func (s *stubRunningChecker) IsAnySessionRunningForTask(context.Context, string) (bool, error) {
|
|
return s.running, s.err
|
|
}
|
|
|
|
func newResetTestService(t *testing.T, repo *stubEnvRepo) *Service {
|
|
t.Helper()
|
|
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
|
|
if err != nil {
|
|
t.Fatalf("failed to create logger: %v", err)
|
|
}
|
|
return &Service{
|
|
logger: log,
|
|
taskEnvironments: repo,
|
|
}
|
|
}
|
|
|
|
func TestResetTaskEnvironment_NoEnvironment(t *testing.T) {
|
|
svc := newResetTestService(t, &stubEnvRepo{env: nil})
|
|
err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{})
|
|
if !errors.Is(err, ErrNoEnvironment) {
|
|
t.Fatalf("expected ErrNoEnvironment, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResetTaskEnvironment_SessionRunningBlocks(t *testing.T) {
|
|
repo := &stubEnvRepo{env: &models.TaskEnvironment{ID: "env-1", TaskID: "task-1", ContainerID: "c"}}
|
|
svc := newResetTestService(t, repo)
|
|
svc.SetSessionRunningChecker(&stubRunningChecker{running: true})
|
|
svc.SetEnvironmentDestroyer(&stubDestroyer{})
|
|
|
|
err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{})
|
|
if !errors.Is(err, ErrSessionRunning) {
|
|
t.Fatalf("expected ErrSessionRunning, got %v", err)
|
|
}
|
|
if repo.deleted {
|
|
t.Error("expected environment row to be preserved when session is running")
|
|
}
|
|
}
|
|
|
|
func TestSessionBlocksEnvironmentReset(t *testing.T) {
|
|
tests := []struct {
|
|
state models.TaskSessionState
|
|
want bool
|
|
}{
|
|
{models.TaskSessionStateCreated, false},
|
|
{models.TaskSessionStateStarting, true},
|
|
{models.TaskSessionStateRunning, true},
|
|
{models.TaskSessionStateWaitingForInput, false},
|
|
{models.TaskSessionStateCompleted, false},
|
|
{models.TaskSessionStateFailed, false},
|
|
{models.TaskSessionStateCancelled, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
if got := sessionBlocksEnvironmentReset(tt.state); got != tt.want {
|
|
t.Fatalf("sessionBlocksEnvironmentReset(%q) = %v, want %v", tt.state, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResetTaskEnvironment_DestroysEachResourceTypeAndDeletesRow(t *testing.T) {
|
|
repo := &stubEnvRepo{env: &models.TaskEnvironment{
|
|
ID: "env-1",
|
|
TaskID: "task-1",
|
|
ContainerID: "container-abc",
|
|
SandboxID: "sandbox-xyz",
|
|
Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "wt-1"}},
|
|
}}
|
|
destroyer := &stubDestroyer{}
|
|
svc := newResetTestService(t, repo)
|
|
svc.SetSessionRunningChecker(&stubRunningChecker{running: false})
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
if err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{}); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !repo.deleted {
|
|
t.Error("expected environment row to be deleted")
|
|
}
|
|
if len(destroyer.containerCalls) != 1 || destroyer.containerCalls[0] != "container-abc" {
|
|
t.Errorf("expected 1 container destroy call, got %v", destroyer.containerCalls)
|
|
}
|
|
if len(destroyer.sandboxCalls) != 1 || destroyer.sandboxCalls[0] != "sandbox-xyz" {
|
|
t.Errorf("expected 1 sandbox destroy call, got %v", destroyer.sandboxCalls)
|
|
}
|
|
if len(destroyer.worktreeCalls) != 1 || destroyer.worktreeCalls[0] != "wt-1" {
|
|
t.Errorf("expected 1 worktree destroy call, got %v", destroyer.worktreeCalls)
|
|
}
|
|
}
|
|
|
|
func TestTeardownEnvironmentResources_CancellationStopsBeforeNextResource(t *testing.T) {
|
|
svc := newResetTestService(t, &stubEnvRepo{})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
destroyer := &stubDestroyer{cancelAfterContainer: cancel}
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
err := svc.teardownEnvironmentResources(ctx, &models.TaskEnvironment{
|
|
ContainerID: "container-1", SandboxID: "sandbox-1",
|
|
Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "worktree-1"}},
|
|
})
|
|
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("teardown error = %v, want context cancellation", err)
|
|
}
|
|
if len(destroyer.sandboxCalls) != 0 || len(destroyer.worktreeCalls) != 0 {
|
|
t.Fatalf("resources destroyed after cancellation: sandboxes=%v worktrees=%v",
|
|
destroyer.sandboxCalls, destroyer.worktreeCalls)
|
|
}
|
|
}
|
|
|
|
func TestTeardownEnvironmentResources_MultiRepoCancellationStopsBeforeNextWorktree(t *testing.T) {
|
|
svc := newResetTestService(t, &stubEnvRepo{})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
destroyer := &stubDestroyer{cancelAfterFirstWorktree: cancel}
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
err := svc.teardownEnvironmentResources(ctx, &models.TaskEnvironment{
|
|
Repos: []*models.TaskEnvironmentRepo{
|
|
{RepositoryID: "repo-a", WorktreeID: "wt-first"},
|
|
{RepositoryID: "repo-b", WorktreeID: "wt-second"},
|
|
},
|
|
})
|
|
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("teardown error = %v, want context cancellation", err)
|
|
}
|
|
if len(destroyer.worktreeCalls) != 1 || destroyer.worktreeCalls[0] != "wt-first" {
|
|
t.Fatalf("expected only the first worktree destroyed before cancellation, got %v", destroyer.worktreeCalls)
|
|
}
|
|
}
|
|
|
|
func TestTeardownEnvironmentResources_MultiRepoDestroysEveryWorktree(t *testing.T) {
|
|
svc := newResetTestService(t, &stubEnvRepo{})
|
|
destroyer := &stubDestroyer{}
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
err := svc.teardownEnvironmentResources(context.Background(), &models.TaskEnvironment{
|
|
Repos: []*models.TaskEnvironmentRepo{
|
|
{RepositoryID: "repo-a", WorktreeID: "wt-primary"},
|
|
{RepositoryID: "repo-b", WorktreeID: "wt-secondary"},
|
|
{RepositoryID: "repo-c", WorktreeID: "wt-tertiary"},
|
|
},
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
want := []string{"wt-primary", "wt-secondary", "wt-tertiary"}
|
|
if len(destroyer.worktreeCalls) != len(want) {
|
|
t.Fatalf("worktree destroy calls = %v, want %v", destroyer.worktreeCalls, want)
|
|
}
|
|
for i, id := range want {
|
|
if destroyer.worktreeCalls[i] != id {
|
|
t.Errorf("worktree destroy call[%d] = %q, want %q", i, destroyer.worktreeCalls[i], id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTeardownEnvironmentResources_ReposOnlyEnvironmentIsNotEmpty(t *testing.T) {
|
|
svc := newResetTestService(t, &stubEnvRepo{})
|
|
destroyer := &stubDestroyer{}
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
err := svc.teardownEnvironmentResources(context.Background(), &models.TaskEnvironment{
|
|
Repos: []*models.TaskEnvironmentRepo{
|
|
{RepositoryID: "repo-a", WorktreeID: "wt-a"},
|
|
{RepositoryID: "repo-b", WorktreeID: "wt-b"},
|
|
},
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(destroyer.worktreeCalls) != 2 {
|
|
t.Fatalf("worktree destroy calls = %v, want 2", destroyer.worktreeCalls)
|
|
}
|
|
}
|
|
|
|
func TestTeardownEnvironmentResources_GenericWorktreeFailureRemainsError(t *testing.T) {
|
|
worktreeErr := errors.New("worktree backend unavailable")
|
|
svc := newResetTestService(t, &stubEnvRepo{})
|
|
svc.SetEnvironmentDestroyer(&stubDestroyer{worktreeErr: worktreeErr})
|
|
|
|
err := svc.teardownEnvironmentResources(context.Background(), &models.TaskEnvironment{
|
|
Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "worktree-1"}},
|
|
})
|
|
|
|
if !errors.Is(err, worktreeErr) {
|
|
t.Fatalf("teardown error = %v, want %v", err, worktreeErr)
|
|
}
|
|
}
|
|
|
|
func TestCleanupTaskEnvironment_CancellationPreservesEnvironmentRow(t *testing.T) {
|
|
repo := &stubEnvRepo{}
|
|
svc := newResetTestService(t, repo)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
errs := svc.cleanupTaskEnvironment(ctx, "task-1", taskEnvironmentCleanup{
|
|
env: &models.TaskEnvironment{ID: "environment-1", TaskID: "task-1"}, deleteRow: true,
|
|
})
|
|
|
|
if !errors.Is(errors.Join(errs...), context.Canceled) {
|
|
t.Fatalf("cleanup errors = %v, want context cancellation", errs)
|
|
}
|
|
if repo.deleted {
|
|
t.Fatal("environment row deleted after cancellation")
|
|
}
|
|
}
|
|
|
|
func TestResetTaskEnvironment_ContainerDestroyFailurePreservesRow(t *testing.T) {
|
|
repo := &stubEnvRepo{env: &models.TaskEnvironment{
|
|
ID: "env-1",
|
|
TaskID: "task-1",
|
|
ContainerID: "container-abc",
|
|
}}
|
|
destroyer := &stubDestroyer{containerErr: errors.New("docker unreachable")}
|
|
svc := newResetTestService(t, repo)
|
|
svc.SetSessionRunningChecker(&stubRunningChecker{running: false})
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error when container destroy fails")
|
|
}
|
|
if repo.deleted {
|
|
t.Error("expected environment row to be preserved when destroy fails")
|
|
}
|
|
}
|
|
|
|
func TestResetTaskEnvironment_RunningCheckErrorFailsClosed(t *testing.T) {
|
|
repo := &stubEnvRepo{env: &models.TaskEnvironment{
|
|
ID: "env-1",
|
|
TaskID: "task-1",
|
|
ContainerID: "container-abc",
|
|
}}
|
|
destroyer := &stubDestroyer{}
|
|
svc := newResetTestService(t, repo)
|
|
svc.SetSessionRunningChecker(&stubRunningChecker{err: errors.New("db locked")})
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error when running-session check fails")
|
|
}
|
|
if len(destroyer.containerCalls) != 0 {
|
|
t.Errorf("expected teardown to be skipped when guard errors, got %v", destroyer.containerCalls)
|
|
}
|
|
if repo.deleted {
|
|
t.Error("expected environment row to be preserved when guard errors")
|
|
}
|
|
}
|
|
|
|
func TestResetTaskEnvironment_TeardownIsBestEffortAcrossResources(t *testing.T) {
|
|
repo := &stubEnvRepo{env: &models.TaskEnvironment{
|
|
ID: "env-1",
|
|
TaskID: "task-1",
|
|
ContainerID: "container-abc",
|
|
Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "wt-1"}},
|
|
}}
|
|
destroyer := &stubDestroyer{containerErr: errors.New("docker unreachable")}
|
|
svc := newResetTestService(t, repo)
|
|
svc.SetSessionRunningChecker(&stubRunningChecker{running: false})
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected joined error when container destroy fails")
|
|
}
|
|
if len(destroyer.containerCalls) != 1 {
|
|
t.Errorf("expected container destroy attempted, got %v", destroyer.containerCalls)
|
|
}
|
|
if len(destroyer.worktreeCalls) != 1 {
|
|
t.Errorf("expected worktree destroy attempted even when container failed, got %v", destroyer.worktreeCalls)
|
|
}
|
|
if repo.deleted {
|
|
t.Error("expected environment row to be preserved when any destroy fails")
|
|
}
|
|
}
|
|
|
|
func TestResetTaskEnvironment_PushBranchFailureAbortsResetBeforeTeardown(t *testing.T) {
|
|
repo := &stubEnvRepo{env: &models.TaskEnvironment{
|
|
ID: "env-1",
|
|
TaskID: "task-1",
|
|
Repos: []*models.TaskEnvironmentRepo{{WorktreeID: "wt-1", WorktreePath: "/tmp/worktree"}},
|
|
}}
|
|
destroyer := &stubDestroyer{pushErr: errors.New("remote rejected")}
|
|
svc := newResetTestService(t, repo)
|
|
svc.SetSessionRunningChecker(&stubRunningChecker{running: false})
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
err := svc.ResetTaskEnvironment(context.Background(), "task-1", ResetOptions{PushBranch: true})
|
|
if err == nil {
|
|
t.Fatal("expected error when push fails")
|
|
}
|
|
if destroyer.pushCalls != 1 {
|
|
t.Errorf("expected push to be attempted once, got %d", destroyer.pushCalls)
|
|
}
|
|
if len(destroyer.worktreeCalls) != 0 {
|
|
t.Error("expected teardown to be skipped when push fails")
|
|
}
|
|
if repo.deleted {
|
|
t.Error("expected environment row to be preserved when push fails")
|
|
}
|
|
}
|
|
|
|
func TestPerformTaskCleanup_TearsDownTaskEnvironmentAndDeletesRow(t *testing.T) {
|
|
env := &models.TaskEnvironment{
|
|
ID: "env-1",
|
|
TaskID: "task-1",
|
|
ContainerID: "container-abc",
|
|
}
|
|
repo := &stubEnvRepo{env: env}
|
|
destroyer := &stubDestroyer{}
|
|
svc := newResetTestService(t, repo)
|
|
svc.SetEnvironmentDestroyer(destroyer)
|
|
|
|
errs := svc.performTaskCleanup(context.Background(), "task-1", nil, nil, nil, taskEnvironmentCleanup{
|
|
env: env,
|
|
deleteRow: true,
|
|
}, nil)
|
|
|
|
if len(errs) != 0 {
|
|
t.Fatalf("unexpected cleanup errors: %v", errs)
|
|
}
|
|
if len(destroyer.containerCalls) != 1 || destroyer.containerCalls[0] != "container-abc" {
|
|
t.Fatalf("expected container teardown, got %v", destroyer.containerCalls)
|
|
}
|
|
if !repo.deleted {
|
|
t.Fatal("expected task environment row to be deleted")
|
|
}
|
|
}
|
|
|
|
// TestBuildSSHLiveStatus_StringsAndStringEncodedInts pins the projection
|
|
// from ExecutorRunning.Metadata into the popover-shaped SSHLiveStatus. The
|
|
// SSH executor writes its numeric metadata as strings (strconv.Itoa) so
|
|
// the projection must accept both "41001" and 41001 — every JSON
|
|
// round-trip through SQLite blob storage gives us strings, but direct
|
|
// in-memory writes give us ints.
|
|
func TestBuildSSHLiveStatus_StringsAndStringEncodedInts(t *testing.T) {
|
|
got := buildSSHLiveStatus(map[string]interface{}{
|
|
"ssh_host": "koi.zeval.local",
|
|
"ssh_port": "2222",
|
|
"ssh_user": "zeval",
|
|
"ssh_remote_task_dir": "/home/zeval/.kandev/tasks/task-1",
|
|
"ssh_remote_agentctl_pid": "4732",
|
|
"ssh_remote_agentctl_port": "41001",
|
|
"ssh_local_forward_port": "59123",
|
|
"ssh_host_fingerprint": "SHA256:abc",
|
|
})
|
|
if got.Host != "koi.zeval.local" || got.Port != 2222 || got.User != "zeval" {
|
|
t.Errorf("connection fields = %+v, want host/port/user", got)
|
|
}
|
|
if got.RemoteTaskDir != "/home/zeval/.kandev/tasks/task-1" {
|
|
t.Errorf("RemoteTaskDir = %q", got.RemoteTaskDir)
|
|
}
|
|
if got.RemoteAgentctlPID != 4732 || got.RemoteAgentctlPort != 41001 || got.LocalForwardPort != 59123 {
|
|
t.Errorf("agentctl fields = %+v", got)
|
|
}
|
|
if got.Fingerprint != "SHA256:abc" {
|
|
t.Errorf("Fingerprint = %q", got.Fingerprint)
|
|
}
|
|
}
|
|
|
|
func TestBuildSSHLiveStatus_NativeIntsAlsoAccepted(t *testing.T) {
|
|
got := buildSSHLiveStatus(map[string]interface{}{
|
|
"ssh_host": "h",
|
|
"ssh_port": 22,
|
|
"ssh_remote_agentctl_pid": int64(99),
|
|
"ssh_remote_agentctl_port": float64(41001),
|
|
})
|
|
if got.Port != 22 || got.RemoteAgentctlPID != 99 || got.RemoteAgentctlPort != 41001 {
|
|
t.Errorf("native int projection failed: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildSSHLiveStatus_EmptyMetadataReturnsZeroValueStruct(t *testing.T) {
|
|
got := buildSSHLiveStatus(map[string]interface{}{})
|
|
if got == nil {
|
|
t.Fatal("expected non-nil status (zero value), got nil")
|
|
}
|
|
if got.Host != "" || got.Port != 0 || got.RemoteAgentctlPID != 0 {
|
|
t.Errorf("expected zero-value fields, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildSSHLiveStatus_InvalidPortString_NoCrash(t *testing.T) {
|
|
// SSH executor only emits Itoa'd ports, but be defensive in case
|
|
// something else writes the metadata (e.g. a future migration or
|
|
// import path). Don't want a stray non-numeric value to panic the
|
|
// popover endpoint for every other field too.
|
|
got := buildSSHLiveStatus(map[string]interface{}{
|
|
"ssh_host": "h",
|
|
"ssh_port": "not-a-port",
|
|
})
|
|
if got.Host != "h" || got.Port != 0 {
|
|
t.Errorf("expected host preserved, port=0 on bad input, got %+v", got)
|
|
}
|
|
}
|