From 42a9409a34ac463a28c5e94237494a54fd9d24ee Mon Sep 17 00:00:00 2001 From: Bhukya Date: Tue, 10 Nov 2026 03:47:05 +0000 Subject: [PATCH] Print test progress and results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each finished case prints " of ", padded with dots to 80 columns, then "[ in s]", so the statuses line up while each case prints as it ends. After the run, the details of every failed or errored case follow, then the reason for an incomplete run, and the run ends with "Done. PASSED=… FAILED=… SKIPPED=… ERROR=… TOTAL=…". The command does not use the printer yet. Co-authored-by: Isaac --- cmd/pipelines/test_render.go | 118 ++++++++++++++++++++++++++++++ cmd/pipelines/test_render_test.go | 80 ++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 cmd/pipelines/test_render.go create mode 100644 cmd/pipelines/test_render_test.go diff --git a/cmd/pipelines/test_render.go b/cmd/pipelines/test_render.go new file mode 100644 index 00000000000..7d5b5c8df67 --- /dev/null +++ b/cmd/pipelines/test_render.go @@ -0,0 +1,118 @@ +package pipelines + +import ( + "fmt" + "io" + "strings" + "unicode/utf8" + + "github.com/databricks/databricks-sdk-go/service/pipelines" +) + +// testLineWidth is the width to which a case line is padded with dots before its status. The +// fixed width lines up the statuses while each case prints as it finishes. +const testLineWidth = 80 + +// testProgressPrinter prints the phase of a test update and each case as it finishes. +type testProgressPrinter struct { + out io.Writer + shortID func(id string) string + finished int + waiting bool + running bool +} + +func (p *testProgressPrinter) updateState(state pipelines.UpdateInfoState) { + switch state { + case pipelines.UpdateInfoStateQueued, + pipelines.UpdateInfoStateCreated, + pipelines.UpdateInfoStateWaitingForResources, + pipelines.UpdateInfoStateInitializing, + pipelines.UpdateInfoStateResetting, + pipelines.UpdateInfoStateSettingUpTables: + if !p.waiting && !p.running { + fmt.Fprintln(p.out, "Waiting for compute...") + p.waiting = true + } + case pipelines.UpdateInfoStateRunning: + p.startRunning() + default: + } +} + +func (p *testProgressPrinter) startRunning() { + if !p.running { + fmt.Fprintln(p.out, "Running tests...") + p.running = true + } +} + +// caseFinished prints a line such as +// "2 of 3 FAILED tests/test_orders.py::test_backfill ...... [FAILED in 2.14s]". total is the +// number of known cases; the service reports every case as PENDING before the first one runs. +func (p *testProgressPrinter) caseFinished(c *testCaseResult, total int) { + p.startRunning() + p.finished++ + prefix := fmt.Sprintf("%d of %d %s %s ", p.finished, total, c.status, p.shortID(c.id)) + status := string(c.status) + if d, ok := c.duration(); ok { + status += fmt.Sprintf(" in %.2fs", d.Seconds()) + } + dots := strings.Repeat(".", max(testLineWidth-utf8.RuneCountInString(prefix), 0)) + fmt.Fprintf(p.out, "%s%s [%s]\n", prefix, dots, status) +} + +// renderTestResult prints the end of a test run, before its summary line: the details of every +// failed or errored case, then why the result is incomplete or that no tests were collected. +func renderTestResult(out io.Writer, run *testRun, incompleteReason string, shortID func(id string) string) { + fmt.Fprintln(out) + counts := run.counts() + if counts.Failed > 0 || counts.Errored > 0 { + fmt.Fprintf(out, "Completed with %d failed and %d errored tests:\n", counts.Failed, counts.Errored) + for _, c := range run.cases { + var label string + switch c.status { + case testCaseFailed: + label = "Failure in" + case testCaseError: + label = "Error in" + default: + continue + } + fmt.Fprintf(out, "\n%s %s\n", label, shortID(c.id)) + details := "" + if c.errorDetail != nil { + details = errorDetailText(c.errorDetail) + } + if details == "" { + details = "No details were reported." + } + for line := range strings.SplitSeq(details, "\n") { + fmt.Fprintf(out, " %s\n", line) + } + if c.detailsTruncated { + fmt.Fprintln(out, " (details truncated)") + } + } + fmt.Fprintln(out) + } + + switch { + case incompleteReason != "": + fmt.Fprintf(out, "RUN INCOMPLETE: %s.\n", incompleteReason) + case run.summaries[0].Total == 0: + fmt.Fprintln(out, "No tests were collected.") + } +} + +// renderTestSummaryLine prints the last line of a test run, "Done. PASSED=… TOTAL=…". +// A complete run shows the service's summary. An incomplete run shows the results received, and +// its total counts every case the events named. +func renderTestSummaryLine(out io.Writer, run *testRun, incompleteReason string) { + s := run.counts() + s.Total = len(run.cases) + if incompleteReason == "" { + s = run.summaries[0] + } + fmt.Fprintf(out, "Done. PASSED=%d FAILED=%d SKIPPED=%d ERROR=%d TOTAL=%d\n", s.Passed, s.Failed, s.Skipped, s.Errored, s.Total) +} diff --git a/cmd/pipelines/test_render_test.go b/cmd/pipelines/test_render_test.go new file mode 100644 index 00000000000..874a841a719 --- /dev/null +++ b/cmd/pipelines/test_render_test.go @@ -0,0 +1,80 @@ +package pipelines + +import ( + "bytes" + "strings" + "testing" + + "github.com/databricks/databricks-sdk-go/service/pipelines" + "github.com/stretchr/testify/assert" +) + +func shortTestID(id string) string { + return strings.TrimPrefix(id, "/Workspace/p/") +} + +func TestTestProgressPrinter(t *testing.T) { + var out bytes.Buffer + p := &testProgressPrinter{out: &out, shortID: shortTestID} + p.updateState(pipelines.UpdateInfoStateWaitingForResources) + p.updateState(pipelines.UpdateInfoStateInitializing) + p.updateState(pipelines.UpdateInfoStateRunning) + + ev := newTestEvents(t) + run := newTestRun() + run.add(ev.testCase("/Workspace/p/tests/test_orders.py::test_backfill", "RUNNING", "2026-01-01T00:00:00.000Z")) + p.caseFinished(run.add(ev.testCase("/Workspace/p/tests/test_orders.py::test_backfill", "FAILED", "2026-01-01T00:00:02.140Z")), 2) + long := "/Workspace/p/tests/" + strings.Repeat("x", 70) + ".py::test_y" + p.caseFinished(run.add(ev.testCase(long, "SKIPPED", "")), 2) + + assert.Equal(t, `Waiting for compute... +Running tests... +1 of 2 FAILED tests/test_orders.py::test_backfill .............................. [FAILED in 2.14s] +2 of 2 SKIPPED `+shortTestID(long)+` [SKIPPED] +`, out.String()) +} + +func TestRenderTestResult(t *testing.T) { + ev := newTestEvents(t) + render := func(run *testRun, incompleteReason string) string { + var out bytes.Buffer + renderTestResult(&out, run, incompleteReason, shortTestID) + renderTestSummaryLine(&out, run, incompleteReason) + return out.String() + } + + failed := newTestRun() + failed.add(ev.testCase("/Workspace/p/tests/test_a.py::test_ok", "PASSED", "")) + failed.add(ev.testCase("/Workspace/p/tests/test_a.py::test_bad", "FAILED", "", + `"error": {"exceptions": [{"class_name": "AssertionError", "message": "assert 1 == 2"}]}`, + `"truncation": {"truncated_fields": [{"field_name": "error"}]}`)) + failed.add(ev.testCase("/Workspace/p/tests/test_a.py::test_fixture", "ERROR", "")) + failed.add(ev.summary(3, 1, 1, 0, 1)) + assert.Equal(t, ` +Completed with 1 failed and 1 errored tests: + +Failure in tests/test_a.py::test_bad + AssertionError: assert 1 == 2 + (details truncated) + +Error in tests/test_a.py::test_fixture + No details were reported. + +Done. PASSED=1 FAILED=1 SKIPPED=0 ERROR=1 TOTAL=3 +`, render(failed, "")) + + incomplete := newTestRun() + incomplete.add(ev.testCase("/Workspace/p/tests/test_b.py::test_one", "PASSED", "")) + incomplete.add(ev.testCase("/Workspace/p/tests/test_b.py::test_two", "PENDING", "")) + assert.Equal(t, ` +RUN INCOMPLETE: the update failed before all test results were received. +Done. PASSED=1 FAILED=0 SKIPPED=0 ERROR=0 TOTAL=2 +`, render(incomplete, "the update failed before all test results were received")) + + empty := newTestRun() + empty.add(ev.summary(0, 0, 0, 0, 0)) + assert.Equal(t, ` +No tests were collected. +Done. PASSED=0 FAILED=0 SKIPPED=0 ERROR=0 TOTAL=0 +`, render(empty, "")) +}