Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions internal/adapters/parsers/base/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ const (
MaxAttemptTraceRunes = 32768
)

// Inline attachment limits for attachments that travel as base64 inside the
// report body rather than being uploaded separately. The budget is per parse,
// matching the JVM reporters' own 8 MiB so one family-wide number governs how
// much rides in a report body.
const (
MaxInlineAttachmentBytes = 1 << 20 // 1 MiB for any single attachment
MaxInlineTotalBytes = 8 << 20 // 8 MiB across the whole run
)

// ClampOutput bounds captured output by lines first, then by runes, matching how
// the server truncates on write.
func ClampOutput(lines []string) []string {
Expand Down
12 changes: 4 additions & 8 deletions internal/adapters/parsers/e2e/xctest/xcresult_extras.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"time"

"qualflare-cli/internal/adapters/parsers/base"
"qualflare-cli/internal/adapters/parsers/shared/toolrun"
"qualflare-cli/internal/core/domain"
)
Expand All @@ -30,13 +31,8 @@ import (
// nodeIdentifier, result) are safe; anything else would work in CI and break
// on a developer's laptop.

// Attachments are inlined up to these limits and take the artifact route
// beyond them. The budget is per parse, matching the JVM reporters' own 8 MiB
// so one family-wide number governs how much rides in a report body.
const (
maxInlineAttachmentBytes = 1 << 20 // 1 MiB for any single attachment
maxInlineTotalBytes = 8 << 20 // 8 MiB across the whole run
)
// Attachments are inlined up to base.MaxInlineAttachmentBytes and
// base.MaxInlineTotalBytes and take the artifact route beyond them.

// testDetailsResponse mirrors `xcresulttool get test-results test-details`.
type testDetailsResponse struct {
Expand Down Expand Up @@ -241,7 +237,7 @@ func attachmentsFromManifest(manifest []byte, dir string) (map[string][]domain.A
case strings.HasPrefix(att.MimeType, "image/"):
att.LocalPath = path
att.ArtifactKind = domain.ArtifactKindImage
case info.Size() <= maxInlineAttachmentBytes && inlined+info.Size() <= maxInlineTotalBytes:
case info.Size() <= base.MaxInlineAttachmentBytes && inlined+info.Size() <= base.MaxInlineTotalBytes:
content, err := os.ReadFile(path)
if err != nil {
continue
Expand Down
29 changes: 24 additions & 5 deletions internal/adapters/parsers/unit/flutter/flutter.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ func (p *Parser) Parse(reader io.Reader) (*domain.Suite, error) {
if st == nil || ev.MessageType != "print" {
continue
}
// Markers belong to the running test; one printed after its
// testDone is ignored rather than surfacing as output.
if st.done && isMarkerLine(ev.Message) {
continue
}
if ev.Message == retryPrefix+st.name {
st.retries = append(st.retries, retryMark{len(st.errors), len(st.prints)})
} else {
Expand All @@ -209,6 +214,7 @@ func (p *Parser) Parse(reader io.Reader) (*domain.Suite, error) {
}

root := projectRoot(allPaths)
budget := &inlineBudget{}
suite := &domain.Suite{
Name: "Flutter Tests",
Category: domain.FrameworkFlutter.GetCategory(),
Expand All @@ -223,7 +229,7 @@ func (p *Parser) Parse(reader io.Reader) (*domain.Suite, error) {
if st.hidden {
continue
}
suite.Cases = append(suite.Cases, buildCase(st, root))
suite.Cases = append(suite.Cases, buildCase(st, root, budget))
}
suite.RecomputeCounts()
return suite, nil
Expand All @@ -246,8 +252,9 @@ func newTestState(t *testInfo, suitePath string, start int64) *testState {
return st
}

// buildCase turns a finished (or unfinished) test into a domain case.
func buildCase(st *testState, root string) domain.Case {
// buildCase turns a finished (or unfinished) test into a domain case. budget
// is the run's inline attachment budget, shared by every case.
func buildCase(st *testState, root string, budget *inlineBudget) domain.Case {
file := relativePath(st.file, root)
name := st.name
// A failed load is named after the file; its raw name carries an absolute path.
Expand All @@ -268,10 +275,18 @@ func buildCase(st *testState, root string) domain.Case {
}

slices := splitAttempts(st)
// Marker lines become case metadata and leave each attempt's output.
sets := make([]markerSet, len(slices))
for i := range slices {
sets[i], slices[i].prints = extractMarkers(slices[i].prints)
}
warnings := applyMarkers(&c, sets, budget)

final := slices[len(slices)-1]
finalOut := final.resolve(st.result)

// system-out is every attempt's output, exception blocks removed.
// system-out is every attempt's output, exception blocks removed, clamped;
// then the warnings, after the clamp so a long output cannot hide them.
var outLines []string
for i, s := range slices {
if i == len(slices)-1 {
Expand All @@ -280,7 +295,11 @@ func buildCase(st *testState, root string) domain.Case {
outLines = append(outLines, s.resolve("").out...)
}
}
if out := strings.Join(base.ClampOutput(outLines), "\n"); out != "" {
outLines = base.ClampOutput(outLines)
for _, w := range warnings {
outLines = append(outLines, "qualflare: "+w)
}
if out := strings.Join(outLines, "\n"); out != "" {
c.Properties[propSystemOut] = out
}

Expand Down
109 changes: 109 additions & 0 deletions internal/adapters/parsers/unit/flutter/inline_budget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package flutter

import (
"encoding/base64"
"fmt"
"strings"
"testing"

"qualflare-cli/internal/core/domain"
)

const mib = 1 << 20

// inlineRun builds a synthetic run of one test per entry in sizes, each
// attaching sizes[i] bytes as name att<i>.<ext> of the given MIME type.
func inlineRun(t *testing.T, mime, ext string, sizes ...int) *domain.Suite {
t.Helper()
evs := make([]map[string]any, 0, 2+3*len(sizes))
evs = append(evs,
map[string]any{"type": "start", "time": 0},
map[string]any{"type": "suite", "suite": map[string]any{"id": 0, "platform": "vm", "path": "test/a_test.dart"}},
)
for i, size := range sizes {
id := i + 1
data := base64.StdEncoding.EncodeToString(make([]byte, size))
evs = append(evs,
map[string]any{"type": "testStart", "time": 1, "test": map[string]any{"id": id, "name": fmt.Sprintf("t%d", id), "suiteID": 0, "metadata": map[string]any{}}},
map[string]any{"type": "print", "testID": id, "messageType": "print", "message": marker(t, map[string]any{
"k": "att", "id": "a", "name": fmt.Sprintf("att%d.%s", id, ext), "type": mime, "n": 1, "i": 0, "data": data,
})},
map[string]any{"type": "testDone", "time": 2, "testID": id, "result": "success"},
)
}
return parseString(t, jsonl(t, evs...))
}

func droppedPrefix(name string, size int, mime string) string {
return fmt.Sprintf("qualflare: attachment %q (%d bytes, %s) dropped: ", name, size, mime)
}

// Synthetic: two tests each attach 4 MiB of JSON, over the 1 MiB inline limit.
func TestInlineBudget_LargeNonImageDropped(t *testing.T) {
suite := inlineRun(t, "application/json", "json", 4*mib, 4*mib)
for i, c := range suite.Cases {
if len(c.Attachments) != 0 {
t.Errorf("%s: attachments = %q, want none", c.Name, attNames(c))
}
want := droppedPrefix(fmt.Sprintf("att%d.json", i+1), 4*mib, "application/json")
if !strings.Contains(c.Properties[propSystemOut], want) {
t.Errorf("%s: system-out %q lacks %q", c.Name, c.Properties[propSystemOut], want)
}
}
}

// Synthetic: nine tests each attach 0.9 MiB of JSON; the ninth would take the
// run's inline total past 8 MiB.
func TestInlineBudget_RunTotalSpansCases(t *testing.T) {
size := 9 * mib / 10
sizes := make([]int, 9)
for i := range sizes {
sizes[i] = size
}
suite := inlineRun(t, "application/json", "json", sizes...)
for i, c := range suite.Cases[:8] {
if len(c.Attachments) != 1 {
t.Errorf("case %d: attachments = %q, want one", i+1, attNames(c))
}
if strings.Contains(c.Properties[propSystemOut], "dropped") {
t.Errorf("case %d: unexpected warning %q", i+1, c.Properties[propSystemOut])
}
}
ninth := suite.Cases[8]
if len(ninth.Attachments) != 0 {
t.Errorf("ninth: attachments = %q, want none", attNames(ninth))
}
if want := droppedPrefix("att9.json", size, "application/json"); !strings.Contains(ninth.Properties[propSystemOut], want) {
t.Errorf("ninth: system-out %q lacks %q", ninth.Properties[propSystemOut], want)
}
}

// Synthetic: a 4 MiB PNG is offloaded by the upload, so the inline budget
// does not apply to it.
func TestInlineBudget_ImagesUnaffected(t *testing.T) {
c := inlineRun(t, "image/png", "png", 4*mib).Cases[0]
if got := attNames(c); len(got) != 1 || got[0] != "att1.png" {
t.Errorf("attachments = %q, want att1.png", got)
}
if out := c.Properties[propSystemOut]; out != "" {
t.Errorf("system-out = %q, want empty", out)
}
}

// Synthetic: 250 plain print lines, more than the output clamp keeps, and an
// attachment missing a chunk; its warning must survive the clamp.
func TestWarnings_SurviveOutputClamp(t *testing.T) {
evs := make([]map[string]any, 0, 252)
for i := range 250 {
evs = append(evs, printEv(fmt.Sprintf("plain line %d", i)))
}
evs = append(evs,
printEv(marker(t, map[string]any{"k": "att", "id": "a", "name": "log.txt", "type": "text/plain", "n": 2, "i": 0, "data": "aGk="})),
map[string]any{"type": "testDone", "time": 5, "testID": 1, "result": "success"},
)
c := syntheticRun(t, []string{"test/a_test.dart"}, "chatty", nil, evs...).Cases[0]
want := `qualflare: attachment "log.txt" is missing chunks (1 of 2 arrived); dropped`
if out := c.Properties[propSystemOut]; !strings.HasSuffix(out, want) {
t.Errorf("system-out ends with %q, want %q", out[max(0, len(out)-120):], want)
}
}
Loading
Loading