From 5cb820387caf67f064ca4e327babe9cf8bcc8c61 Mon Sep 17 00:00:00 2001 From: Zach <3724288+zedkipp@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:33:09 -0600 Subject: [PATCH] fix: use quartz clock in task status test (#22969) Replace time.Since() usage with a quartz.Clock injected via RootCmd to ensure relative time strings ("Xs ago") are deterministic. --- cli/clitest/clitest.go | 13 +++++++++++++ cli/root.go | 15 +++++++++++++++ cli/task_status.go | 10 +++++----- cli/task_status_test.go | 21 ++++++++++++--------- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index f1d7e6a1ce..11b2a0436f 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" "github.com/coder/serpent" ) @@ -40,6 +41,18 @@ func New(t testing.TB, args ...string) (*serpent.Invocation, config.Root) { return NewWithCommand(t, cmd, args...) } +// NewWithClock is like New, but injects the given clock for +// tests that are time-dependent. +func NewWithClock(t testing.TB, clk quartz.Clock, args ...string) (*serpent.Invocation, config.Root) { + var root cli.RootCmd + root.SetClock(clk) + + cmd, err := root.Command(root.AGPL()) + require.NoError(t, err) + + return NewWithCommand(t, cmd, args...) +} + type logWriter struct { prefix string log slog.Logger diff --git a/cli/root.go b/cli/root.go index aed58e4690..e02fdbfc24 100644 --- a/cli/root.go +++ b/cli/root.go @@ -39,6 +39,7 @@ import ( "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/pretty" + "github.com/coder/quartz" "github.com/coder/serpent" ) @@ -230,6 +231,10 @@ func (r *RootCmd) RunWithSubcommands(subcommands []*serpent.Command) { } func (r *RootCmd) Command(subcommands []*serpent.Command) (*serpent.Command, error) { + if r.clock == nil { + r.clock = quartz.NewReal() + } + fmtLong := `Coder %s — A tool for provisioning self-hosted development environments with Terraform. ` hiddenAgentAuth := &AgentAuth{} @@ -548,6 +553,16 @@ type RootCmd struct { useKeyring bool keyringServiceName string useKeyringWithGlobalConfig bool + + // clock is used for time-dependent operations. Initialized to + // quartz.NewReal() in Command() if not set via SetClock. + clock quartz.Clock +} + +// SetClock sets the clock used for time-dependent operations. +// Must be called before Command() to take effect. +func (r *RootCmd) SetClock(clk quartz.Clock) { + r.clock = clk } // ensureClientURL loads the client URL from the config file if it diff --git a/cli/task_status.go b/cli/task_status.go index 7c91cd55e9..6c73c6112b 100644 --- a/cli/task_status.go +++ b/cli/task_status.go @@ -90,7 +90,7 @@ func (r *RootCmd) taskStatus() *serpent.Command { return err } - tsr := toStatusRow(task) + tsr := toStatusRow(task, r.clock.Now()) out, err := formatter.Format(ctx, []taskStatusRow{tsr}) if err != nil { return xerrors.Errorf("format task status: %w", err) @@ -112,7 +112,7 @@ func (r *RootCmd) taskStatus() *serpent.Command { } // Only print if something changed - newStatusRow := toStatusRow(task) + newStatusRow := toStatusRow(task, r.clock.Now()) if !taskStatusRowEqual(lastStatusRow, newStatusRow) { out, err := formatter.Format(ctx, []taskStatusRow{newStatusRow}) if err != nil { @@ -166,10 +166,10 @@ func taskStatusRowEqual(r1, r2 taskStatusRow) bool { taskStateEqual(r1.CurrentState, r2.CurrentState) } -func toStatusRow(task codersdk.Task) taskStatusRow { +func toStatusRow(task codersdk.Task, now time.Time) taskStatusRow { tsr := taskStatusRow{ Task: task, - ChangedAgo: time.Since(task.UpdatedAt).Truncate(time.Second).String() + " ago", + ChangedAgo: now.Sub(task.UpdatedAt).Truncate(time.Second).String() + " ago", } tsr.Healthy = task.WorkspaceAgentHealth != nil && task.WorkspaceAgentHealth.Healthy && @@ -178,7 +178,7 @@ func toStatusRow(task codersdk.Task) taskStatusRow { !task.WorkspaceAgentLifecycle.ShuttingDown() if task.CurrentState != nil { - tsr.ChangedAgo = time.Since(task.CurrentState.Timestamp).Truncate(time.Second).String() + " ago" + tsr.ChangedAgo = now.Sub(task.CurrentState.Timestamp).Truncate(time.Second).String() + " ago" } return tsr } diff --git a/cli/task_status_test.go b/cli/task_status_test.go index 0c0d7facaf..319fe68c29 100644 --- a/cli/task_status_test.go +++ b/cli/task_status_test.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" + "github.com/coder/quartz" ) func Test_TaskStatus(t *testing.T) { @@ -28,12 +29,12 @@ func Test_TaskStatus(t *testing.T) { args []string expectOutput string expectError string - hf func(context.Context, time.Time) func(http.ResponseWriter, *http.Request) + hf func(context.Context, quartz.Clock) func(http.ResponseWriter, *http.Request) }{ { args: []string{"doesnotexist"}, expectError: httpapi.ResourceNotFoundResponse.Message, - hf: func(ctx context.Context, _ time.Time) func(w http.ResponseWriter, r *http.Request) { + hf: func(ctx context.Context, _ quartz.Clock) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/tasks/me/doesnotexist": @@ -49,7 +50,8 @@ func Test_TaskStatus(t *testing.T) { args: []string{"exists"}, expectOutput: `STATE CHANGED STATUS HEALTHY STATE MESSAGE 0s ago active true working Thinking furiously...`, - hf: func(ctx context.Context, now time.Time) func(w http.ResponseWriter, r *http.Request) { + hf: func(ctx context.Context, clk quartz.Clock) func(w http.ResponseWriter, r *http.Request) { + now := clk.Now() return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v2/tasks/me/exists": @@ -84,7 +86,8 @@ func Test_TaskStatus(t *testing.T) { 4s ago active true 3s ago active true working Reticulating splines... 2s ago active true complete Splines reticulated successfully!`, - hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) { + hf: func(ctx context.Context, clk quartz.Clock) func(http.ResponseWriter, *http.Request) { + now := clk.Now() var calls atomic.Int64 return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -215,7 +218,7 @@ func Test_TaskStatus(t *testing.T) { "created_at": "2025-08-26T12:34:56Z", "updated_at": "2025-08-26T12:34:56Z" }`, - hf: func(ctx context.Context, now time.Time) func(http.ResponseWriter, *http.Request) { + hf: func(ctx context.Context, _ quartz.Clock) func(http.ResponseWriter, *http.Request) { ts := time.Date(2025, 8, 26, 12, 34, 56, 0, time.UTC) return func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -252,8 +255,8 @@ func Test_TaskStatus(t *testing.T) { var ( ctx = testutil.Context(t, testutil.WaitShort) - now = time.Now().UTC() // TODO: replace with quartz - srv = httptest.NewServer(http.HandlerFunc(tc.hf(ctx, now))) + mClock = quartz.NewMock(t) + srv = httptest.NewServer(http.HandlerFunc(tc.hf(ctx, mClock))) client = codersdk.New(testutil.MustURL(t, srv.URL)) sb = strings.Builder{} args = []string{"task", "status", "--watch-interval", testutil.IntervalFast.String()} @@ -261,10 +264,10 @@ func Test_TaskStatus(t *testing.T) { t.Cleanup(srv.Close) args = append(args, tc.args...) - inv, root := clitest.New(t, args...) + inv, cfgDir := clitest.NewWithClock(t, mClock, args...) inv.Stdout = &sb inv.Stderr = &sb - clitest.SetupConfig(t, client, root) + clitest.SetupConfig(t, client, cfgDir) err := inv.WithContext(ctx).Run() if tc.expectError == "" { assert.NoError(t, err)