diff --git a/docs/fixes/2026-08-19-emulator-endpoint-job-container-network-join.md b/docs/fixes/2026-08-19-emulator-endpoint-job-container-network-join.md index 87ff02e2efd..10462fba6f9 100644 --- a/docs/fixes/2026-08-19-emulator-endpoint-job-container-network-join.md +++ b/docs/fixes/2026-08-19-emulator-endpoint-job-container-network-join.md @@ -86,6 +86,19 @@ real CI job container that doesn't explicitly join a custom Docker network. already in use"), bounded the `host.docker.internal` DNS lookup with a timeout, and tightened the corresponding test's timing assertion against the actual configured constant instead of a loose bound. +- Re-verified 2026-09-07: `git merge-base --is-ancestor` confirms both this PR (#2960) and its + prerequisite (#2942) are ancestors of `HEAD` and of the published `v1.228.0` tag, and no commit + since has touched the network-join/endpoint-selection code. Reran the exact `bugs.md` item 8 + reproduction against the published `ghcr.io/cloudposse/atmos:1.228.0` image (a socket-mounted + container, no `--network` flag, against this repo's own `examples/terraform-tests` `fixtures` + stack): `atmos emulator up aws -s fixtures --ephemeral` reported + `emulator aws is up at http://fixtures-aws:4566`, and `curl http://fixtures-aws:4566/` from + inside that same container returned `HTTP 200`. No loopback/gateway fallback, no regression. + (The `--ephemeral` flag was needed only to skip an unrelated host-bind-mount requirement for + persistence when running Atmos itself inside a container with a mounted Docker socket -- not a + factor in the endpoint-selection logic this doc covers.) The original caveat still stands: this + confirms Docker Desktop's local VM networking, not an actual GitHub-hosted Actions job + container's networking. ## Follow-ups diff --git a/docs/fixes/2026-09-08-ci-test-json-backfill-unbounded-synthetic-runs.md b/docs/fixes/2026-09-08-ci-test-json-backfill-unbounded-synthetic-runs.md new file mode 100644 index 00000000000..be8d85e555b --- /dev/null +++ b/docs/fixes/2026-09-08-ci-test-json-backfill-unbounded-synthetic-runs.md @@ -0,0 +1,70 @@ +# Fix: `backfillMissingTestJSONRuns` no longer appends unbounded synthetic runs + +**Date:** 2026-09-08 + +## Summary + +`pkg/ci/plugins/terraform/parser.go`'s `backfillMissingTestJSONRuns` synthesizes placeholder +`data.Runs` entries when the authoritative `test_summary` counts from a `terraform|tofu test +-json` stream exceed the runs the parser actually captured. The per-status loop bound came +straight from the untrusted `passed`/`failed`/`errored`/`skipped` ints in that stream with no +upper limit, so a single oversized count (e.g. `passed: 1000000000`) drove an unbounded `append` +loop that could exhaust memory or hang the `atmos terraform test --ci` command before it reported +anything. The loop is now capped per status at `maxBackfillRunsPerStatus` (10,000), and the +parser marks the result as incomplete when a count is truncated instead of silently +under-representing it. + +## Context + +Flagged by CodeRabbit's review of PR #3082 (thread `PRRT_kwDOEW4XoM6gUagt`, 🟠 Major) against +`backfillMissingTestJSONRuns`, which had been added and retained across two earlier commits on +this branch (`3ead9012f7`, `e57b5b8eb0`) as a last-resort guard for a schema gap in the parser. +The guard itself was never bounded, so it traded one failure mode (a schema gap silently dropping +runs) for another (an oversized summary count silently exhausting memory). This fix hardens the +guard added by `docs/fixes/2026-09-08-ci-test-json-opentofu-runs-dropped.md` without changing its +purpose. + +## Changes + +- `pkg/ci/plugins/terraform/parser.go`: + - New `maxBackfillRunsPerStatus` constant (10,000) bounding synthetic rows per status. + - `backfillMissingTestJSONRuns` clamps the per-status append count to the cap; when a count is + truncated it escalates from `log.Warn` to `log.Error` and sets the new + `data.BackfillTruncated` flag (never touches real captured rows). + - `testJSONHasErrors` includes `BackfillTruncated` so a truncated backfill always marks + `result.HasErrors`. + - `renderTestSummaryLine` (the plain-text fallback renderer) treats `BackfillTruncated` as a + failure condition and appends an explicit "parser output incomplete" line. +- `pkg/ci/internal/plugin/types.go`: new `TerraformTestOutputData.BackfillTruncated bool` field. +- `pkg/ci/plugins/terraform/handlers.go`: `buildTerraformTestStatusDescription` appends a + `"parser output incomplete"` part when `BackfillTruncated` is set, alongside the existing + `CleanupFailures` part, so the step-summary/status description surfaces the truncation. +- `pkg/ci/plugins/terraform/test_json_test.go`: + - `TestBackfillMissingTestJSONRuns` table gained a case asserting untouched, non-truncated + backfills leave `BackfillTruncated` false. + - New `TestBackfillMissingTestJSONRuns_CapsOversizedCount`: a `Pass: 1_000_000_000` summary + count is capped at `maxBackfillRunsPerStatus` synthesized runs, with `BackfillTruncated` and + `Total` asserted. + - New `TestParseTestJSON_OversizedSummaryCountIsBounded`: the same scenario through the public + `ParseTestJSON` entry point, asserting `result.HasErrors` and a bounded `data.Runs`. + +## Validation + +- `go build ./...` -- clean. +- `go test ./pkg/ci/plugins/terraform/... -run 'TestBackfillMissingTestJSONRuns|TestParseTestJSON' -v` + -- all pass, including the two new oversized-count tests, in under 2s (proving the cap actually + bounds the work rather than just bounding the assertion). +- `go test ./pkg/ci/...` -- full package, all pass, no regressions in JUnit/handlers rendering. +- Confirmed the new tests are load-bearing: with the fix removed (`git stash` of the three + non-test files), the package fails to *compile* because the tests reference + `maxBackfillRunsPerStatus` and `BackfillTruncated`, which only exist after the fix. Actually + reverting just the loop's bound (to reproduce a literal billion-iteration append) was not run, + since doing so would intentionally trigger the exact memory-exhaustion/hang this fix prevents. +- `atmos fix lint` (patch-scoped, `--new-from-rev=origin/main`) -- the only findings are 3 + pre-existing issues in unrelated files (`pkg/store/providers/azure_keyvault_store.go`, + `cmd/terraform/utils.go`, `pkg/component/helm/client.go`); none in the files touched by this + fix. + +## Follow-ups + +None. diff --git a/docs/fixes/2026-09-08-ci-test-json-opentofu-runs-dropped.md b/docs/fixes/2026-09-08-ci-test-json-opentofu-runs-dropped.md new file mode 100644 index 00000000000..0718ca4620f --- /dev/null +++ b/docs/fixes/2026-09-08-ci-test-json-opentofu-runs-dropped.md @@ -0,0 +1,103 @@ +# Fix: `terraform test --ci` dropped every OpenTofu run and late assertion diagnostics from the summary/JUnit + +**Date:** 2026-09-08 + +## Summary + +In CI mode, `atmos terraform test` always runs `terraform|tofu test -json` and builds the step +summary, the JUnit report, and inline annotations from that event stream. The parser only accepted +a `test_run`/`test_file` event as final when it carried `progress: "complete"`. OpenTofu never +emits a `progress` field at all -- it emits exactly one event per run/file carrying only the final +`status` -- so under OpenTofu every run and file event was discarded. The `test_summary` event has +the same shape in both tools, so the badge counts still came out right while the results table was +empty and `.junit.xml` reported `tests="0"` on a passing run. Separately, both tools +emit an assertion-failure `diagnostic` *after* the run's final event, but the parser only attached +diagnostics that arrived *before* it, so failing runs lost their message and `file:line` (and +therefore the `::error` annotation and the Details column) under Terraform as well. + +## Context + +Reported from an application repository whose toolchain pins `tofu`: a passing run produced +`TESTS-1`/`PASSED-1` badges, no results table, `app.junit.xml` with `tests="0"`, and a run log +ending `Success! 1 passed, 0 failed, 0 skipped.` That three-field summary line is Atmos's own +`RenderTestText` format (Terraform's native message is `Success! 1 passed, 0 failed.`), which +placed the failure squarely on the JSON path. + +The bug could not be reproduced with this repository's own `examples/terraform-tests` fixture: +eleven consecutive `atmos terraform test app -s fixtures --ci` runs against the Floci emulator all +produced `tests="4"` with real run names. That fixture is Terraform-only (its `.tftest.hcl` files +use `variable` blocks, which OpenTofu rejects in favour of `variables`), so it never exercised the +OpenTofu event shape. Capturing raw `-json` streams from both tools on a minimal provider-free +module made the difference obvious: + +- Terraform: `{"path":…,"run":"plan_case","progress":"complete","status":"pass"}` (preceded by a + `progress: "starting"` event for the same run). +- OpenTofu: `{"path":…,"run":"plan_case","status":"pass"}` -- no `progress`, one event per run. + +`completedTestRun`/`completedTestFile` gated on `Progress != "complete"`, so the OpenTofu stream +yielded an empty `data.Runs`/`data.Files`; `applyTestJSONSummary` then backfilled `Total`/`Pass` +from the summary event, which is exactly the reported badge/table/JUnit disagreement. The same +captures showed the assertion diagnostic arriving after the run event in both tools (Terraform +1.15.8, OpenTofu 1.12.5); the existing `sampleTestJSON` fixture had been hand-written with the +diagnostic first, which is why the diagnostic-attachment tests passed. + +This is a sibling of the two earlier fixes on the plain-text fallback path +(`docs/fixes/2026-08-14-ci-summary-test-table-fallback-dropped.md`, +`docs/fixes/2026-08-19-ci-test-summary-fallback-recovers-error-detail.md`); neither touched the +JSON path. + +## Changes + +- `pkg/ci/plugins/terraform/parser.go`: + - New `testEventComplete(progress, status)`: an event is final when `progress == "complete"` + (Terraform) or when `progress` is absent and a `status` is present (OpenTofu). Used by both + `completedTestRun` and `completedTestFile`. Terraform's intermediate `starting`/`running`/ + `teardown` events still carry a `progress` value and remain excluded. + - Diagnostic attachment factored into `attachPendingDiag`; new `attachLateDiagnostics` runs + first in `finalizeTestJSON` (which now receives `diagByRun`) and attaches any diagnostic keyed + by the run's file+name to a recorded run that has no error yet -- covering the + diagnostic-after-run ordering without changing the diagnostic-before-run path. + - `backfillMissingTestJSONRuns` (added earlier on this branch as a stop-gap) is retained purely + as a last-resort guard against a future unrecognised event shape, and now emits a + `log.Warn` whenever it fires so a schema gap can never again be silently absorbed into a + placeholder row. +- `pkg/ci/plugins/terraform/test_json_test.go`: + - `sampleOpenTofuPassJSON` / `sampleOpenTofuFailJSON`: verbatim `tofu test -json` streams + (OpenTofu 1.12.5). + - `TestParseTestJSON_OpenTofu_AllPass`, `TestParseTestJSON_OpenTofu_Failure`, + `TestToJUnit_OpenTofu`, `TestRenderTestText_OpenTofu`: real run names, files, counts, + message, and `file:line` all captured from the OpenTofu shape. + - `TestParseTestJSON_DiagnosticAfterCompleteEvent`: Terraform-shaped stream with the diagnostic + after the `complete` event. + - The earlier `TestParseTestJSON_SummaryExceedsRuns`, `TestBackfillMissingTestJSONRuns`, and + `TestToJUnit_BackfillsMissingRuns` remain, covering the guard. + +No changes were needed in `junit.go`, `templates/test.md`, or the annotation emitter -- all of +them already key off `data.Runs`/`data.Files`. + +## Validation + +- Before the parser change, the new tests failed with the exact reported symptom: every run came + back as `run detail unavailable (pass)` with empty `File`, `Line`, and `Error`, and + `data.Files` was empty. +- `go test ./pkg/ci/plugins/terraform/...` and `go test ./pkg/ci/...` -- all pass, including the + pre-existing diagnostic-before-run fixture. +- `go build ./...`, `gofumpt -l`, `atmos lint --changed` -- clean for the touched files (three + pre-existing findings elsewhere on the branch, untouched). +- End-to-end under OpenTofu: a throwaway Atmos project (`components.terraform.command: tofu`) + around a provider-free module with one passing and one failing run, executed with the rebuilt + binary as `GITHUB_ACTIONS=true … atmos terraform test min -s fx --ci`. Result: + `min.junit.xml` reports `tests="2" failures="1"` with `` and + ``; the step summary lists both runs by name with `tests/min.tftest.hcl:12` in the Details + column plus the per-file breakdown; and the log carries + `::error file=tests/min.tftest.hcl,line=12,title=terraform test: failing_case::…`. +- Terraform path unchanged: the repository's own `examples/terraform-tests` fixture still yields + `tests="4"` with all four real run names. + +## Follow-ups + +- `examples/terraform-tests` cannot run under OpenTofu (`variable` vs `variables` in + `.tftest.hcl`), so there is no OpenTofu end-to-end fixture in this repository; the verbatim + OpenTofu streams in `test_json_test.go` are the regression coverage for that shape. Adding a + tool-agnostic fixture would let `atmos test --full` exercise both tools. diff --git a/pkg/ci/internal/plugin/types.go b/pkg/ci/internal/plugin/types.go index c226d430608..da44ad2d6a6 100644 --- a/pkg/ci/internal/plugin/types.go +++ b/pkg/ci/internal/plugin/types.go @@ -185,6 +185,11 @@ type TerraformTestOutputData struct { // CleanupFailures contains resources Terraform could not destroy after tests. CleanupFailures []TerraformTestCleanupFailure + + // BackfillTruncated indicates a test_summary count for some status exceeded the parser's + // synthetic-placeholder-row cap, so the backfilled Runs (and therefore Total/JUnit/the + // results table) are known to be incomplete for that status. + BackfillTruncated bool } // TerraformTestFile represents the result of a single `.tftest.hcl` file. diff --git a/pkg/ci/plugins/terraform/handlers.go b/pkg/ci/plugins/terraform/handlers.go index ea5313eedde..de3bb2fb9e2 100644 --- a/pkg/ci/plugins/terraform/handlers.go +++ b/pkg/ci/plugins/terraform/handlers.go @@ -1028,6 +1028,9 @@ func buildTerraformTestStatusDescription(testData *plugin.TerraformTestOutputDat if len(testData.CleanupFailures) > 0 { parts = append(parts, fmt.Sprintf("%d cleanup failed", len(testData.CleanupFailures))) } + if testData.BackfillTruncated { + parts = append(parts, "parser output incomplete") + } return strings.Join(parts, ", ") } diff --git a/pkg/ci/plugins/terraform/parser.go b/pkg/ci/plugins/terraform/parser.go index 267def06760..b733099d686 100644 --- a/pkg/ci/plugins/terraform/parser.go +++ b/pkg/ci/plugins/terraform/parser.go @@ -14,6 +14,7 @@ import ( errUtils "github.com/cloudposse/atmos/errors" "github.com/cloudposse/atmos/pkg/ci/internal/plugin" + log "github.com/cloudposse/atmos/pkg/logger" "github.com/cloudposse/atmos/pkg/perf" ) @@ -21,6 +22,12 @@ import ( // diagnostic with a long detail can be large). const testJSONMaxLine = 4 * 1024 * 1024 +// maxBackfillRunsPerStatus caps synthetic placeholder rows backfillMissingTestJSONRuns can +// create for a single status. A test_summary count is untrusted input (from the tool's -json +// stream); without a cap, a single oversized count (e.g. passed: 1000000000) would make the +// backfill loop append unbounded rows and exhaust memory / hang the CI command. +const maxBackfillRunsPerStatus = 10_000 + // millisecondsPerSecond converts the `elapsed` field (milliseconds in the // `test -json` stream) to the seconds used by JUnit `time` attributes. const millisecondsPerSecond = 1000.0 @@ -839,8 +846,11 @@ type testRunKey struct { run string } -// pendingDiag holds the first error diagnostic seen for a run, attached when the -// run's `complete` event arrives (diagnostics precede the complete event). +// pendingDiag holds the first error diagnostic seen for a run. Ordering is not +// fixed: mid-run provider errors precede the run's final event, but assertion +// failures follow it (both Terraform and OpenTofu), so a diagnostic is attached +// at build time when it arrived first and reconciled in finalizeTestJSON when +// it arrived after. type pendingDiag struct { message string file string @@ -876,10 +886,19 @@ func ParseTestJSON(stream []byte) *plugin.OutputResult { } } - finalizeTestJSON(data, result, summary) + finalizeTestJSON(data, result, summary, diagByRun) return result } +// testEventComplete reports whether a test_run/test_file event is the final one +// for its subject. Terraform emits intermediate progress events (`starting`, +// `running`, `teardown`) and marks the last one `progress: "complete"`. +// OpenTofu emits a single event per run/file with no `progress` field at all, +// carrying only the final `status` -- so a bare status is also terminal. +func testEventComplete(progress, status string) bool { + return progress == "complete" || (progress == "" && status != "") +} + func handleTestJSONEvent( ev *testJSONEvent, data *plugin.TerraformTestOutputData, @@ -911,7 +930,7 @@ func handleTestJSONEvent( func completedTestFile(ev *testJSONEvent) (plugin.TerraformTestFile, bool) { var tf testJSONFile _ = json.Unmarshal(ev.TestFileP, &tf) - if tf.Progress != "complete" { + if !testEventComplete(tf.Progress, tf.Status) { return plugin.TerraformTestFile{}, false } path := firstNonEmpty(tf.Path, ev.TestFile) @@ -952,7 +971,7 @@ func recordDiagnostic(ev *testJSONEvent, diagByRun map[testRunKey]pendingDiag) { func completedTestRun(ev *testJSONEvent, diagByRun map[testRunKey]pendingDiag) (plugin.TerraformTestRun, bool) { var tr testJSONRun _ = json.Unmarshal(ev.TestRunP, &tr) - if tr.Progress != "complete" { + if !testEventComplete(tr.Progress, tr.Status) { return plugin.TerraformTestRun{}, false } return buildTestRun(ev, tr, diagByRun), true @@ -999,27 +1018,105 @@ func buildTestRun(ev *testJSONEvent, tr testJSONRun, diagByRun map[testRunKey]pe Duration: float64(tr.Elapsed) / millisecondsPerSecond, } if dg, ok := diagByRun[testRunKey{file: ev.TestFile, run: name}]; ok { - run.Error = dg.message - if dg.line > 0 { - run.Line = dg.line + attachPendingDiag(&run, dg) + } + return run +} + +// attachPendingDiag copies a diagnostic's message and source location onto a run. +func attachPendingDiag(run *plugin.TerraformTestRun, dg pendingDiag) { + run.Error = dg.message + if dg.line > 0 { + run.Line = dg.line + } + if dg.file != "" { + run.File = dg.file + } +} + +// attachLateDiagnostics attaches diagnostics that arrived after their run's +// final event (assertion failures in both tools), which buildTestRun could not +// see at the time the run was recorded. +func attachLateDiagnostics(data *plugin.TerraformTestOutputData, diagByRun map[testRunKey]pendingDiag) { + for i := range data.Runs { + run := &data.Runs[i] + if run.Error != "" { + continue } - if dg.file != "" { - run.File = dg.file + if dg, ok := diagByRun[testRunKey{file: run.File, run: run.Name}]; ok { + attachPendingDiag(run, dg) } } - return run } // finalizeTestJSON sets totals/counts and HasErrors from the parsed runs and the // authoritative test_summary (when present). -func finalizeTestJSON(data *plugin.TerraformTestOutputData, result *plugin.OutputResult, summary *testJSONSummary) { +func finalizeTestJSON( + data *plugin.TerraformTestOutputData, + result *plugin.OutputResult, + summary *testJSONSummary, + diagByRun map[testRunKey]pendingDiag, +) { + attachLateDiagnostics(data, diagByRun) data.Total = len(data.Runs) collectTestJSONRunResults(data, result) applyTestJSONSummary(data, summary) + backfillMissingTestJSONRuns(data) populateTestFileCounts(data) result.HasErrors = testJSONHasErrors(data, result) } +// backfillMissingTestJSONRuns is a last-resort guard: if the authoritative +// test_summary counts ever exceed the runs actually captured into data.Runs +// (i.e. a tool emitted run events in a shape this parser did not recognise), it +// synthesizes placeholder rows so data.Total/Pass/Fail/Error/Skip, the JUnit +// report, and the step-summary table can never disagree. It never removes or +// mutates real captured rows, and it warns loudly when it fires, because that +// means the parser has a schema gap to fix rather than a condition to tolerate. +// +// The summary counts are untrusted input from the tool's -json stream, so the +// number of rows synthesized per status is capped at maxBackfillRunsPerStatus: +// without a cap, a single oversized count could make this loop append unbounded +// rows and exhaust memory or hang the CI command. When a count is truncated, +// data.BackfillTruncated is set so callers can report the parser output as +// incomplete rather than silently under-representing the truncated status. +func backfillMissingTestJSONRuns(data *plugin.TerraformTestOutputData) { + remaining := map[string]int{ + testStatusPass: data.Pass, + testStatusFail: data.Fail, + testStatusError: data.Error, + testStatusSkip: data.Skip, + } + for _, r := range data.Runs { + if _, ok := remaining[r.Status]; ok { + remaining[r.Status]-- + } + } + for _, status := range []string{testStatusPass, testStatusFail, testStatusError, testStatusSkip} { + missing := remaining[status] + if missing <= 0 { + continue + } + toCreate := missing + if toCreate > maxBackfillRunsPerStatus { + log.Error("terraform test JSON stream under-reported runs; capping synthesized placeholder rows", + "status", status, "missing", missing, "cap", maxBackfillRunsPerStatus, "captured_runs", len(data.Runs)) + toCreate = maxBackfillRunsPerStatus + data.BackfillTruncated = true + } else { + log.Warn("terraform test JSON stream under-reported runs; synthesizing placeholder rows", + "status", status, "missing", missing, "captured_runs", len(data.Runs)) + } + for i := 0; i < toCreate; i++ { + data.Runs = append(data.Runs, plugin.TerraformTestRun{ + Name: fmt.Sprintf("run detail unavailable (%s)", status), + Status: status, + }) + } + } + data.Total = len(data.Runs) +} + func collectTestJSONRunResults(data *plugin.TerraformTestOutputData, result *plugin.OutputResult) { for _, r := range data.Runs { switch r.Status { @@ -1051,7 +1148,8 @@ func applyTestJSONSummary(data *plugin.TerraformTestOutputData, summary *testJSO } func testJSONHasErrors(data *plugin.TerraformTestOutputData, result *plugin.OutputResult) bool { - return data.Fail > 0 || data.Error > 0 || len(result.Errors) > 0 || len(data.CleanupFailures) > 0 + return data.Fail > 0 || data.Error > 0 || len(result.Errors) > 0 || len(data.CleanupFailures) > 0 || + data.BackfillTruncated } // populateTestFileCounts derives file-level counts from completed run events. @@ -1158,14 +1256,17 @@ func renderTestRunLine(b *strings.Builder, run *plugin.TerraformTestRun) { func renderTestSummaryLine(b *strings.Builder, data *plugin.TerraformTestOutputData) { headline := "Success!" - if data.Fail > 0 || data.Error > 0 || len(data.CleanupFailures) > 0 { + if data.Fail > 0 || data.Error > 0 || len(data.CleanupFailures) > 0 || data.BackfillTruncated { headline = "Failure!" } if data.Error > 0 { fmt.Fprintf(b, "%s %d passed, %d failed, %d errored, %d skipped.\n", headline, data.Pass, data.Fail, data.Error, data.Skip) - return + } else { + fmt.Fprintf(b, "%s %d passed, %d failed, %d skipped.\n", headline, data.Pass, data.Fail, data.Skip) + } + if data.BackfillTruncated { + fmt.Fprintf(b, " parser output incomplete: summary counts exceeded the synthesized-run cap\n") } - fmt.Fprintf(b, "%s %d passed, %d failed, %d skipped.\n", headline, data.Pass, data.Fail, data.Skip) } // ParseOutput parses terraform output for a given command (fallback when JSON not available). diff --git a/pkg/ci/plugins/terraform/test_json_test.go b/pkg/ci/plugins/terraform/test_json_test.go index 91fc89a4947..f26d7be5048 100644 --- a/pkg/ci/plugins/terraform/test_json_test.go +++ b/pkg/ci/plugins/terraform/test_json_test.go @@ -89,6 +89,223 @@ func TestParseTestJSON_AllPass(t *testing.T) { assert.Equal(t, 0, data.Error) } +func TestParseTestJSON_SummaryExceedsRuns(t *testing.T) { + // Only the authoritative test_summary event arrives; no test_run "complete" + // events were captured into data.Runs (e.g. one was dropped upstream). Runs + // must be backfilled so Total/JUnit/the results table never under-report a + // passing run as tests="0". + stream := `{"@level":"info","type":"test_summary","test_summary":{"status":"pass","passed":1,"failed":0,"errored":0,"skipped":0}} +` + result := ParseTestJSON([]byte(stream)) + data := testJSONData(t, result) + + assert.False(t, result.HasErrors) + assert.Equal(t, 1, data.Total) + assert.Equal(t, 1, data.Pass) + require.Len(t, data.Runs, 1) + assert.Equal(t, testStatusPass, data.Runs[0].Status) +} + +func TestParseTestJSON_OversizedSummaryCountIsBounded(t *testing.T) { + // An oversized test_summary count (e.g. a corrupted or hostile stream) must not make + // ParseTestJSON hang or exhaust memory synthesizing placeholder runs; it must complete + // quickly with a capped Runs slice and report the result as incomplete. + stream := `{"@level":"info","type":"test_summary","test_summary":{"status":"pass","passed":1000000000,"failed":0,"errored":0,"skipped":0}} +` + result := ParseTestJSON([]byte(stream)) + data := testJSONData(t, result) + + assert.True(t, result.HasErrors) + require.Len(t, data.Runs, maxBackfillRunsPerStatus) + assert.Equal(t, maxBackfillRunsPerStatus, data.Total) +} + +func TestBackfillMissingTestJSONRuns(t *testing.T) { + tests := []struct { + name string + data plugin.TerraformTestOutputData + want []string // expected Status of each run in data.Runs after backfill + }{ + { + name: "no mismatch leaves runs untouched", + data: plugin.TerraformTestOutputData{ + Pass: 1, + Runs: []plugin.TerraformTestRun{{Name: "a", Status: testStatusPass}}, + }, + want: []string{testStatusPass}, + }, + { + name: "summary exceeds runs — fully missing", + data: plugin.TerraformTestOutputData{Pass: 1}, + want: []string{testStatusPass}, + }, + { + name: "summary exceeds runs — partial, mixed statuses", + data: plugin.TerraformTestOutputData{ + Pass: 2, + Fail: 1, + Runs: []plugin.TerraformTestRun{{Name: "a", Status: testStatusPass}}, + }, + want: []string{testStatusPass, testStatusPass, testStatusFail}, + }, + { + name: "runs exceed summary — never deletes real data", + data: plugin.TerraformTestOutputData{ + Pass: 1, + Runs: []plugin.TerraformTestRun{ + {Name: "a", Status: testStatusPass}, + {Name: "b", Status: testStatusPass}, + }, + }, + want: []string{testStatusPass, testStatusPass}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := tt.data + backfillMissingTestJSONRuns(&data) + require.Len(t, data.Runs, len(tt.want)) + for i, status := range tt.want { + assert.Equal(t, status, data.Runs[i].Status) + } + assert.False(t, data.BackfillTruncated) + }) + } +} + +// TestBackfillMissingTestJSONRuns_CapsOversizedCount is the regression test for a summary count +// that is untrusted input from the tool's -json stream: an oversized passed/failed/errored/skipped +// count must not make the backfill loop append unbounded placeholder rows (memory exhaustion / CI +// hang). The loop must cap synthesized rows per status and flag the result as incomplete. +func TestBackfillMissingTestJSONRuns_CapsOversizedCount(t *testing.T) { + data := plugin.TerraformTestOutputData{Pass: 1_000_000_000} + backfillMissingTestJSONRuns(&data) + + require.Len(t, data.Runs, maxBackfillRunsPerStatus) + assert.Equal(t, testStatusPass, data.Runs[0].Status) + assert.Equal(t, testStatusPass, data.Runs[len(data.Runs)-1].Status) + assert.True(t, data.BackfillTruncated) + assert.Equal(t, maxBackfillRunsPerStatus, data.Total) +} + +func TestToJUnit_BackfillsMissingRuns(t *testing.T) { + stream := `{"@level":"info","type":"test_summary","test_summary":{"status":"pass","passed":1,"failed":0,"errored":0,"skipped":0}} +` + data := testJSONData(t, ParseTestJSON([]byte(stream))) + report := toJUnit(data, "app") + + assert.Equal(t, 1, report.Tests) + assert.True(t, report.Passed()) +} + +// sampleOpenTofuPassJSON is a verbatim `tofu test -json` stream (OpenTofu +// 1.12.5): OpenTofu emits exactly one test_run/test_file event per run/file, +// carrying only `status` -- there is no `progress` field at all. +const sampleOpenTofuPassJSON = `{"@level":"info","@message":"OpenTofu 1.12.5","@module":"tofu.ui","@timestamp":"2026-09-08T09:25:28.566974-05:00","tofu":"1.12.5","type":"version","ui":"1.2"} +{"@level":"info","@message":"Found 1 file and 2 run blocks","@module":"tofu.ui","@timestamp":"2026-09-08T09:25:28.567828-05:00","test_abstract":{"tests/min.tftest.hcl":["plan_case","apply_case"]},"type":"test_abstract"} +{"@level":"info","@message":"tests/min.tftest.hcl... pass","@module":"tofu.ui","@testfile":"tests/min.tftest.hcl","@timestamp":"2026-09-08T09:25:28.581280-05:00","test_file":{"path":"tests/min.tftest.hcl","status":"pass"},"type":"test_file"} +{"@level":"info","@message":" \"plan_case\"... pass","@module":"tofu.ui","@testfile":"tests/min.tftest.hcl","@testrun":"plan_case","@timestamp":"2026-09-08T09:25:28.581312-05:00","test_run":{"path":"tests/min.tftest.hcl","run":"plan_case","status":"pass"},"type":"test_run"} +{"@level":"info","@message":" \"apply_case\"... pass","@module":"tofu.ui","@testfile":"tests/min.tftest.hcl","@testrun":"apply_case","@timestamp":"2026-09-08T09:25:28.581323-05:00","test_run":{"path":"tests/min.tftest.hcl","run":"apply_case","status":"pass"},"type":"test_run"} +{"@level":"info","@message":"Success! 2 passed, 0 failed.","@module":"tofu.ui","@timestamp":"2026-09-08T09:25:28.582628-05:00","test_summary":{"status":"pass","passed":2,"failed":0,"errored":0,"skipped":0},"type":"test_summary"} +` + +// sampleOpenTofuFailJSON is a verbatim failing `tofu test -json` stream. Note +// the assertion diagnostic arrives AFTER the run's test_run event. +const sampleOpenTofuFailJSON = `{"@level":"info","@message":"OpenTofu 1.12.5","@module":"tofu.ui","@timestamp":"2026-09-08T09:26:17.462022-05:00","tofu":"1.12.5","type":"version","ui":"1.2"} +{"@level":"info","@message":"Found 1 file and 2 run blocks","@module":"tofu.ui","@timestamp":"2026-09-08T09:26:17.464956-05:00","test_abstract":{"tests/min.tftest.hcl":["passing_case","failing_case"]},"type":"test_abstract"} +{"@level":"info","@message":"tests/min.tftest.hcl... fail","@module":"tofu.ui","@testfile":"tests/min.tftest.hcl","@timestamp":"2026-09-08T09:26:17.478520-05:00","test_file":{"path":"tests/min.tftest.hcl","status":"fail"},"type":"test_file"} +{"@level":"info","@message":" \"passing_case\"... pass","@module":"tofu.ui","@testfile":"tests/min.tftest.hcl","@testrun":"passing_case","@timestamp":"2026-09-08T09:26:17.478559-05:00","test_run":{"path":"tests/min.tftest.hcl","run":"passing_case","status":"pass"},"type":"test_run"} +{"@level":"info","@message":" \"failing_case\"... fail","@module":"tofu.ui","@testfile":"tests/min.tftest.hcl","@testrun":"failing_case","@timestamp":"2026-09-08T09:26:17.478570-05:00","test_run":{"path":"tests/min.tftest.hcl","run":"failing_case","status":"fail"},"type":"test_run"} +{"@level":"error","@message":"Error: Test assertion failed","@module":"tofu.ui","@testfile":"tests/min.tftest.hcl","@testrun":"failing_case","@timestamp":"2026-09-08T09:26:17.478795-05:00","diagnostic":{"severity":"error","summary":"Test assertion failed","detail":"name should be b","range":{"filename":"tests/min.tftest.hcl","start":{"line":12,"column":21,"byte":203},"end":{"line":12,"column":39,"byte":221}},"snippet":{"context":"run \"failing_case\"","code":" condition = output.name == \"b\"","start_line":12,"highlight_start_offset":20,"highlight_end_offset":38,"values":[{"traversal":"output.name","statement":"is \"a\""}]},"difference":{"before":"a","after":"b","after_unknown":false,"before_sensitive":false,"after_sensitive":false}},"type":"diagnostic"} +{"@level":"info","@message":"Failure! 1 passed, 1 failed.","@module":"tofu.ui","@timestamp":"2026-09-08T09:26:17.481079-05:00","test_summary":{"status":"fail","passed":1,"failed":1,"errored":0,"skipped":0},"type":"test_summary"} +` + +func TestParseTestJSON_OpenTofu_AllPass(t *testing.T) { + // OpenTofu's test_run/test_file events carry no `progress` field, so a + // "progress == complete" gate silently discards every run: badges show the + // summary counts while the results table and JUnit report come out empty. + result := ParseTestJSON([]byte(sampleOpenTofuPassJSON)) + data := testJSONData(t, result) + + assert.False(t, result.HasErrors) + assert.Equal(t, 2, data.Total) + assert.Equal(t, 2, data.Pass) + require.Len(t, data.Runs, 2) + assert.Equal(t, plugin.TerraformTestRun{Name: "plan_case", File: "tests/min.tftest.hcl", Status: testStatusPass}, data.Runs[0]) + assert.Equal(t, plugin.TerraformTestRun{Name: "apply_case", File: "tests/min.tftest.hcl", Status: testStatusPass}, data.Runs[1]) + require.Len(t, data.Files, 1) + assert.Equal(t, plugin.TerraformTestFile{Path: "tests/min.tftest.hcl", Status: testStatusPass, Pass: 2}, data.Files[0]) +} + +func TestParseTestJSON_OpenTofu_Failure(t *testing.T) { + result := ParseTestJSON([]byte(sampleOpenTofuFailJSON)) + data := testJSONData(t, result) + + assert.True(t, result.HasErrors) + assert.Equal(t, 2, data.Total) + assert.Equal(t, 1, data.Pass) + assert.Equal(t, 1, data.Fail) + require.Len(t, data.Runs, 2) + assert.Equal(t, "passing_case", data.Runs[0].Name) + assert.Equal(t, testStatusPass, data.Runs[0].Status) + + // The assertion diagnostic arrived after the run event; it must still be + // attached so the results table, annotations, and JUnit carry file:line. + failing := data.Runs[1] + assert.Equal(t, "failing_case", failing.Name) + assert.Equal(t, testStatusFail, failing.Status) + assert.Equal(t, "tests/min.tftest.hcl", failing.File) + assert.Equal(t, 12, failing.Line) + assert.Equal(t, "Test assertion failed: name should be b", failing.Error) + assert.Contains(t, result.Errors, "Test assertion failed: name should be b") + + require.Len(t, data.Files, 1) + assert.Equal(t, plugin.TerraformTestFile{Path: "tests/min.tftest.hcl", Status: testStatusFail, Pass: 1, Fail: 1}, data.Files[0]) +} + +func TestParseTestJSON_DiagnosticAfterCompleteEvent(t *testing.T) { + // Terraform emits assertion-failure diagnostics after the run's `complete` + // event too (only mid-apply provider errors precede it), so attachment must + // work in either order. + stream := `{"@level":"info","type":"test_run","@testfile":"tests/app.tftest.hcl","@testrun":"broken","test_run":{"path":"tests/app.tftest.hcl","run":"broken","progress":"complete","status":"fail","elapsed":50}} +{"@level":"error","type":"diagnostic","@testfile":"tests/app.tftest.hcl","@testrun":"broken","diagnostic":{"severity":"error","summary":"Test assertion failed","detail":"bucket not created","range":{"filename":"tests/app.tftest.hcl","start":{"line":30,"column":5}}}} +{"@level":"info","type":"test_summary","test_summary":{"status":"fail","passed":0,"failed":1,"errored":0,"skipped":0}} +` + result := ParseTestJSON([]byte(stream)) + data := testJSONData(t, result) + + require.Len(t, data.Runs, 1) + assert.Equal(t, "broken", data.Runs[0].Name) + assert.Equal(t, 30, data.Runs[0].Line) + assert.Equal(t, "Test assertion failed: bucket not created", data.Runs[0].Error) + assert.Contains(t, result.Errors, "Test assertion failed: bucket not created") +} + +func TestToJUnit_OpenTofu(t *testing.T) { + data := testJSONData(t, ParseTestJSON([]byte(sampleOpenTofuFailJSON))) + report := toJUnit(data, "app") + + assert.Equal(t, 2, report.Tests) + assert.Equal(t, 1, report.Failures) + require.Len(t, report.Suites, 1) + require.Len(t, report.Suites[0].Cases, 2) + assert.Equal(t, "passing_case", report.Suites[0].Cases[0].Name) + failing := report.Suites[0].Cases[1] + assert.Equal(t, "failing_case", failing.Name) + assert.Equal(t, 12, failing.Line) + require.NotNil(t, failing.Failure) + assert.Equal(t, "Test assertion failed: name should be b", failing.Failure.Message) +} + +func TestRenderTestText_OpenTofu(t *testing.T) { + text := RenderTestText([]byte(sampleOpenTofuFailJSON)) + assert.Contains(t, text, `✓ run "passing_case"... pass`) + assert.Contains(t, text, `✗ run "failing_case"... fail`) + assert.Contains(t, text, "Test assertion failed: name should be b") + assert.Contains(t, text, "Failure! 1 passed, 1 failed, 0 skipped.") +} + func TestParseOutput_RoutesTestJSON(t *testing.T) { // Leading `{` → JSON path; the text path would not populate File/Line. data := testJSONData(t, ParseOutput(sampleTestJSON, "test")) diff --git a/website/package.json b/website/package.json index 78138777c48..02a71442ee5 100644 --- a/website/package.json +++ b/website/package.json @@ -107,13 +107,14 @@ "brace-expansion@^1": "1.1.18", "brace-expansion@^2": "2.1.4", "browserslist@^4": "^4.28.7", + "colord@^2": "^2.9.4", "dompurify@^3": "^3.4.13", "fast-uri@^3": "^3.1.6", "follow-redirects@^1": "^1.16.0", "http-proxy-middleware@^2": "^2.0.10", - "joi@^17": "^17.13.4", - "js-yaml@^3": "^3.15.1", - "js-yaml@^4": "^4.3.1", + "joi@^17": "^17.13.6", + "js-yaml@^3": "^3.15.2", + "js-yaml@^4": "^4.3.2", "launch-editor@^2": "^2.14.1", "lodash@^4": "^4.18.0", "lodash-es@^4": "^4.18.0", @@ -131,7 +132,7 @@ "qs@^6": "^6.16.0", "serialize-javascript@^6": "^7.0.5", "shell-quote@^1": "^1.8.4", - "svgo@^3": "^3.3.4", + "svgo@^3": "^3.3.5", "uuid@^8": "^11.1.1", "webpack@^5": "^5.104.1", "webpack-dev-server@^5": "^5.2.5", diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index 7f815ab6885..9905067ef6d 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -11,13 +11,14 @@ overrides: brace-expansion@^1: 1.1.18 brace-expansion@^2: 2.1.4 browserslist@^4: ^4.28.7 + colord@^2: ^2.9.4 dompurify@^3: ^3.4.13 fast-uri@^3: ^3.1.6 follow-redirects@^1: ^1.16.0 http-proxy-middleware@^2: ^2.0.10 - joi@^17: ^17.13.4 - js-yaml@^3: ^3.15.1 - js-yaml@^4: ^4.3.1 + joi@^17: ^17.13.6 + js-yaml@^3: ^3.15.2 + js-yaml@^4: ^4.3.2 launch-editor@^2: ^2.14.1 lodash@^4: ^4.18.0 lodash-es@^4: ^4.18.0 @@ -35,7 +36,7 @@ overrides: qs@^6: ^6.16.0 serialize-javascript@^6: ^7.0.5 shell-quote@^1: ^1.8.4 - svgo@^3: ^3.3.4 + svgo@^3: ^3.3.5 uuid@^8: ^11.1.1 webpack@^5: ^5.104.1 webpack-dev-server@^5: ^5.2.5 @@ -2862,8 +2863,8 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colord@2.9.3: - resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + colord@2.10.0: + resolution: {integrity: sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==} colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -4251,8 +4252,8 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true - joi@17.13.4: - resolution: {integrity: sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==} + joi@17.13.6: + resolution: {integrity: sha512-ImNZaq/LSysofih+xIGYfR0WUXMA9GLUNB//YTCSrZptoRmVgaNAdJyi6K1kXi9pkLEoSkoI8I4UwtNiu/D7nw==} jotai-scope@0.7.2: resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==} @@ -4278,12 +4279,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.1: - resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -6173,8 +6174,8 @@ packages: svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} - svgo@3.3.4: - resolution: {integrity: sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==} + svgo@3.3.5: + resolution: {integrity: sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==} engines: {node: '>=14.0.0'} hasBin: true @@ -6635,7 +6636,7 @@ snapshots: '@11ty/gray-matter@1.0.0': dependencies: - js-yaml: 4.3.1 + js-yaml: 4.3.2 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -8269,7 +8270,7 @@ snapshots: '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.3.2 - js-yaml: 4.3.1 + js-yaml: 4.3.2 lodash: 4.18.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -8806,7 +8807,7 @@ snapshots: '@types/mdast': 4.0.4 '@types/react': 19.2.2 commander: 5.1.0 - joi: 17.13.4 + joi: 17.13.6 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)' @@ -8857,8 +8858,8 @@ snapshots: '@docusaurus/utils': 3.10.2(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.23))(html-minifier-terser@7.2.0)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@docusaurus/utils-common': 3.10.2(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.23))(html-minifier-terser@7.2.0)(postcss@8.5.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) fs-extra: 11.3.2 - joi: 17.13.4 - js-yaml: 4.3.1 + joi: 17.13.6 + js-yaml: 4.3.2 lodash: 4.18.1 tslib: 2.8.1 transitivePeerDependencies: @@ -8892,7 +8893,7 @@ snapshots: github-slugger: 1.5.0 globby: 11.1.0 jiti: 1.21.7 - js-yaml: 4.3.1 + js-yaml: 4.3.2 lodash: 4.18.1 micromatch: 4.0.8 p-queue: 6.6.2 @@ -9718,7 +9719,7 @@ snapshots: '@svgr/core': 8.1.0 cosmiconfig: 8.3.6 deepmerge: 4.3.1 - svgo: 3.3.4 + svgo: 3.3.5 transitivePeerDependencies: - typescript @@ -10540,7 +10541,7 @@ snapshots: color-name@1.1.4: {} - colord@2.9.3: {} + colord@2.10.0: {} colorette@2.0.20: {} @@ -10650,7 +10651,7 @@ snapshots: cosmiconfig@8.3.6: dependencies: import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 parse-json: 5.2.0 path-type: 4.0.0 @@ -11554,7 +11555,7 @@ snapshots: gray-matter@4.0.3: dependencies: - js-yaml: 3.15.1 + js-yaml: 3.15.2 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -12006,7 +12007,7 @@ snapshots: jiti@1.21.7: {} - joi@17.13.4: + joi@17.13.6: dependencies: '@hapi/hoek': 9.3.0 '@hapi/topo': 5.1.0 @@ -12028,12 +12029,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.1: + js-yaml@3.15.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -13095,7 +13096,7 @@ snapshots: dependencies: browserslist: 4.28.8 caniuse-api: 3.0.0 - colord: 2.9.3 + colord: 2.10.0 postcss: 8.5.23 postcss-value-parser: 4.2.0 @@ -13238,7 +13239,7 @@ snapshots: postcss-minify-gradients@6.0.3(postcss@8.5.23): dependencies: - colord: 2.9.3 + colord: 2.10.0 cssnano-utils: 4.0.2(postcss@8.5.23) postcss: 8.5.23 postcss-value-parser: 4.2.0 @@ -13472,7 +13473,7 @@ snapshots: dependencies: postcss: 8.5.23 postcss-value-parser: 4.2.0 - svgo: 3.3.4 + svgo: 3.3.5 postcss-unique-selectors@6.0.4(postcss@8.5.23): dependencies: @@ -14359,7 +14360,7 @@ snapshots: svg-parser@2.0.4: {} - svgo@3.3.4: + svgo@3.3.5: dependencies: commander: 7.2.0 css-select: 5.2.2