chore: flakey test finder
This commit is contained in:
parent
1cbd1cfb6b
commit
ba7d9f51a1
|
|
@ -27,4 +27,5 @@ webui.dev
|
||||||
sessions.yaml
|
sessions.yaml
|
||||||
docs/build/
|
docs/build/
|
||||||
build/
|
build/
|
||||||
|
Binary/
|
||||||
.cursor
|
.cursor
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
codestyle:
|
||||||
|
go fmt ./...
|
||||||
|
go vet ./...
|
||||||
|
gocyclo -over 4 .
|
||||||
|
gocritic check ./...
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -50,6 +51,12 @@ type jsonlRecord struct {
|
||||||
FailureDetails []testFailure `json:"failureDetails"`
|
FailureDetails []testFailure `json:"failureDetails"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testRunState struct {
|
||||||
|
summary runSummary
|
||||||
|
failures []testFailure
|
||||||
|
failureOutput map[string]*strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
func initLog() {
|
func initLog() {
|
||||||
logFormat := os.Getenv("OLIVETIN_LOG_FORMAT")
|
logFormat := os.Getenv("OLIVETIN_LOG_FORMAT")
|
||||||
|
|
||||||
|
|
@ -65,27 +72,46 @@ func initLog() {
|
||||||
log.SetLevel(log.InfoLevel)
|
log.SetLevel(log.InfoLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func serviceRoot() string {
|
func hasGoMod(dir string) bool {
|
||||||
|
stat, err := os.Stat(filepath.Join(dir, "go.mod"))
|
||||||
|
return err == nil && !stat.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootFromExecutable() (string, bool) {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err == nil {
|
if err != nil {
|
||||||
candidate := filepath.Join(filepath.Dir(exe), "..", "..")
|
return "", false
|
||||||
if stat, statErr := os.Stat(filepath.Join(candidate, "go.mod")); statErr == nil && !stat.IsDir() {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
candidate := filepath.Join(filepath.Dir(exe), "..", "..")
|
||||||
|
if hasGoMod(candidate) {
|
||||||
|
return candidate, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootFromWorkingDir() string {
|
||||||
wd, err := os.Getwd()
|
wd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "."
|
return "."
|
||||||
}
|
}
|
||||||
|
|
||||||
if stat, statErr := os.Stat(filepath.Join(wd, "go.mod")); statErr == nil && !stat.IsDir() {
|
if hasGoMod(wd) {
|
||||||
return wd
|
return wd
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Join(wd, "..")
|
return filepath.Join(wd, "..")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serviceRoot() string {
|
||||||
|
if root, ok := rootFromExecutable(); ok {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootFromWorkingDir()
|
||||||
|
}
|
||||||
|
|
||||||
func envOrDefault(name, fallback string) string {
|
func envOrDefault(name, fallback string) string {
|
||||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|
@ -112,20 +138,20 @@ func formatRunCounts(summary runSummary) string {
|
||||||
return fmt.Sprintf("%d pass %d fail %d skip", summary.Passes, summary.Failures, summary.Skipped)
|
return fmt.Sprintf("%d pass %d fail %d skip", summary.Passes, summary.Failures, summary.Skipped)
|
||||||
}
|
}
|
||||||
|
|
||||||
func appendRunLog(logFile, jsonlFile string, run, exitCode int, summary runSummary, failures []testFailure, durationMs int64) error {
|
func runResultLabel(exitCode int) string {
|
||||||
timestamp := time.Now().UTC().Format(time.RFC3339)
|
if exitCode == 0 {
|
||||||
passed := exitCode == 0
|
return "PASS"
|
||||||
result := "PASS"
|
|
||||||
if !passed {
|
|
||||||
result = "FAIL"
|
|
||||||
}
|
}
|
||||||
|
return "FAIL"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRunLogBlock(run int, timestamp string, exitCode int, summary runSummary, failures []testFailure, durationMs int64) string {
|
||||||
block := []string{
|
block := []string{
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"=== RUN %d | %s | %s | %d pass %d fail %d skip | %.1fs ===",
|
"=== RUN %d | %s | %s | %d pass %d fail %d skip | %.1fs ===",
|
||||||
run,
|
run,
|
||||||
timestamp,
|
timestamp,
|
||||||
result,
|
runResultLabel(exitCode),
|
||||||
summary.Passes,
|
summary.Passes,
|
||||||
summary.Failures,
|
summary.Failures,
|
||||||
summary.Skipped,
|
summary.Skipped,
|
||||||
|
|
@ -138,10 +164,10 @@ func appendRunLog(logFile, jsonlFile string, run, exitCode int, summary runSumma
|
||||||
}
|
}
|
||||||
|
|
||||||
block = append(block, "")
|
block = append(block, "")
|
||||||
if err := appendFile(logFile, strings.Join(block, "\n")+"\n"); err != nil {
|
return strings.Join(block, "\n") + "\n"
|
||||||
return err
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
func appendJSONLRecord(jsonlFile string, run int, timestamp string, exitCode int, durationMs int64, summary runSummary, failures []testFailure) error {
|
||||||
record := jsonlRecord{
|
record := jsonlRecord{
|
||||||
Run: run,
|
Run: run,
|
||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
|
|
@ -161,6 +187,16 @@ func appendRunLog(logFile, jsonlFile string, run, exitCode int, summary runSumma
|
||||||
return appendFile(jsonlFile, string(encoded)+"\n")
|
return appendFile(jsonlFile, string(encoded)+"\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendRunLog(logFile, jsonlFile string, run, exitCode int, summary runSummary, failures []testFailure, durationMs int64) error {
|
||||||
|
timestamp := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
if err := appendFile(logFile, buildRunLogBlock(run, timestamp, exitCode, summary, failures, durationMs)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return appendJSONLRecord(jsonlFile, run, timestamp, exitCode, durationMs, summary, failures)
|
||||||
|
}
|
||||||
|
|
||||||
func appendFile(path, content string) error {
|
func appendFile(path, content string) error {
|
||||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -172,6 +208,116 @@ func appendFile(path, content string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTestRunState() *testRunState {
|
||||||
|
return &testRunState{
|
||||||
|
failures: make([]testFailure, 0),
|
||||||
|
failureOutput: make(map[string]*strings.Builder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failureKey(pkg, test string) string {
|
||||||
|
return pkg + "\x00" + test
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *testRunState) handlePass(event testEvent) {
|
||||||
|
if event.Test == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.summary.Passes++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *testRunState) handleSkip(event testEvent) {
|
||||||
|
if event.Test == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.summary.Skipped++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *testRunState) handleFail(event testEvent) {
|
||||||
|
if event.Test == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.summary.Failures++
|
||||||
|
key := failureKey(event.Package, event.Test)
|
||||||
|
output := ""
|
||||||
|
if builder, ok := state.failureOutput[key]; ok {
|
||||||
|
output = strings.TrimSpace(builder.String())
|
||||||
|
} else {
|
||||||
|
state.failureOutput[key] = &strings.Builder{}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.failures = append(state.failures, testFailure{
|
||||||
|
Package: event.Package,
|
||||||
|
Test: event.Test,
|
||||||
|
Output: output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *testRunState) handleOutput(event testEvent) {
|
||||||
|
if event.Test == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := failureKey(event.Package, event.Test)
|
||||||
|
builder, ok := state.failureOutput[key]
|
||||||
|
if !ok {
|
||||||
|
builder = &strings.Builder{}
|
||||||
|
state.failureOutput[key] = builder
|
||||||
|
}
|
||||||
|
builder.WriteString(event.Output)
|
||||||
|
}
|
||||||
|
|
||||||
|
type testEventHandler func(*testRunState, testEvent)
|
||||||
|
|
||||||
|
var testEventHandlers = map[string]testEventHandler{
|
||||||
|
"pass": (*testRunState).handlePass,
|
||||||
|
"fail": (*testRunState).handleFail,
|
||||||
|
"skip": (*testRunState).handleSkip,
|
||||||
|
"output": (*testRunState).handleOutput,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *testRunState) processEvent(event testEvent) {
|
||||||
|
handler, ok := testEventHandlers[event.Action]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler(state, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *testRunState) finalizeFailureOutputs() {
|
||||||
|
for index := range state.failures {
|
||||||
|
key := failureKey(state.failures[index].Package, state.failures[index].Test)
|
||||||
|
if builder, ok := state.failureOutput[key]; ok {
|
||||||
|
state.failures[index].Output = strings.TrimSpace(builder.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTestEvents(stdout io.Reader, state *testRunState) error {
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
var event testEvent
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state.processEvent(event)
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishTestCommand(cmd *exec.Cmd, state *testRunState) (int, runSummary, []testFailure, error) {
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
state.finalizeFailureOutputs()
|
||||||
|
return exitErr.ExitCode(), state.summary, state.failures, nil
|
||||||
|
}
|
||||||
|
return 1, state.summary, state.failures, err
|
||||||
|
}
|
||||||
|
return 0, state.summary, state.failures, nil
|
||||||
|
}
|
||||||
|
|
||||||
func runTestsOnce(rootDir string) (int, runSummary, []testFailure, error) {
|
func runTestsOnce(rootDir string) (int, runSummary, []testFailure, error) {
|
||||||
cmd := exec.Command("go", "test", "./...", "-count=1", "-json")
|
cmd := exec.Command("go", "test", "./...", "-count=1", "-json")
|
||||||
cmd.Dir = rootDir
|
cmd.Dir = rootDir
|
||||||
|
|
@ -185,67 +331,57 @@ func runTestsOnce(rootDir string) (int, runSummary, []testFailure, error) {
|
||||||
return 1, runSummary{}, nil, err
|
return 1, runSummary{}, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
summary := runSummary{}
|
state := newTestRunState()
|
||||||
failures := make([]testFailure, 0)
|
if err := scanTestEvents(stdout, state); err != nil {
|
||||||
failureOutput := make(map[string]*strings.Builder)
|
return 1, state.summary, state.failures, err
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
||||||
for scanner.Scan() {
|
|
||||||
var event testEvent
|
|
||||||
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch event.Action {
|
|
||||||
case "pass":
|
|
||||||
if event.Test != "" {
|
|
||||||
summary.Passes++
|
|
||||||
}
|
|
||||||
case "fail":
|
|
||||||
if event.Test != "" {
|
|
||||||
summary.Failures++
|
|
||||||
key := event.Package + "\x00" + event.Test
|
|
||||||
failures = append(failures, testFailure{
|
|
||||||
Package: event.Package,
|
|
||||||
Test: event.Test,
|
|
||||||
Output: "",
|
|
||||||
})
|
|
||||||
failureOutput[key] = &strings.Builder{}
|
|
||||||
}
|
|
||||||
case "skip":
|
|
||||||
if event.Test != "" {
|
|
||||||
summary.Skipped++
|
|
||||||
}
|
|
||||||
case "output":
|
|
||||||
if event.Test == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := event.Package + "\x00" + event.Test
|
|
||||||
if builder, ok := failureOutput[key]; ok {
|
|
||||||
builder.WriteString(event.Output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
return finishTestCommand(cmd, state)
|
||||||
return 1, summary, failures, err
|
}
|
||||||
|
|
||||||
|
func buildLogHeader(logFile, jsonlFile string) string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
fmt.Sprintf("# Flaky test run log started %s", time.Now().UTC().Format(time.RFC3339)),
|
||||||
|
fmt.Sprintf("# Log file: %s", logFile),
|
||||||
|
fmt.Sprintf("# JSONL file: %s", jsonlFile),
|
||||||
|
"",
|
||||||
|
}, "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func initRunFiles(logFile, jsonlFile string) error {
|
||||||
|
if err := os.WriteFile(logFile, []byte(buildLogHeader(logFile, jsonlFile)), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(jsonlFile, nil, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logRunResult(run, exitCode int, summary runSummary, durationMs int64) {
|
||||||
|
log.Infof(
|
||||||
|
"Run %d: %s | %s (%.1fs) — logged",
|
||||||
|
run,
|
||||||
|
runResultLabel(exitCode),
|
||||||
|
formatRunCounts(summary),
|
||||||
|
float64(durationMs)/1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeRun(run int, rootDir, logFile, jsonlFile string) (exitCode int, stop bool) {
|
||||||
|
start := time.Now()
|
||||||
|
exitCode, summary, failures, err := runTestsOnce(rootDir)
|
||||||
|
durationMs := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Errorf("Run %d failed to execute: %s", run, formatRunCounts(summary))
|
||||||
|
_ = appendRunLog(logFile, jsonlFile, run, 1, summary, failures, durationMs)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cmd.Wait(); err != nil {
|
if logErr := appendRunLog(logFile, jsonlFile, run, exitCode, summary, failures, durationMs); logErr != nil {
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
log.WithError(logErr).Fatal("failed to append run log")
|
||||||
for index := range failures {
|
|
||||||
key := failures[index].Package + "\x00" + failures[index].Test
|
|
||||||
if builder, ok := failureOutput[key]; ok {
|
|
||||||
failures[index].Output = strings.TrimSpace(builder.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return exitErr.ExitCode(), summary, failures, nil
|
|
||||||
}
|
|
||||||
return 1, summary, failures, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0, summary, failures, nil
|
logRunResult(run, exitCode, summary, durationMs)
|
||||||
|
return exitCode, exitCode != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
@ -255,18 +391,8 @@ func main() {
|
||||||
logFile := envOrDefault("FLAKEY_LOG_FILE", filepath.Join(rootDir, defaultLogFile))
|
logFile := envOrDefault("FLAKEY_LOG_FILE", filepath.Join(rootDir, defaultLogFile))
|
||||||
jsonlFile := envOrDefault("FLAKEY_JSONL_FILE", filepath.Join(rootDir, defaultJSONLFile))
|
jsonlFile := envOrDefault("FLAKEY_JSONL_FILE", filepath.Join(rootDir, defaultJSONLFile))
|
||||||
|
|
||||||
header := strings.Join([]string{
|
if err := initRunFiles(logFile, jsonlFile); err != nil {
|
||||||
fmt.Sprintf("# Flaky test run log started %s", time.Now().UTC().Format(time.RFC3339)),
|
log.WithError(err).Fatal("failed to initialize output files")
|
||||||
fmt.Sprintf("# Log file: %s", logFile),
|
|
||||||
fmt.Sprintf("# JSONL file: %s", jsonlFile),
|
|
||||||
"",
|
|
||||||
}, "\n") + "\n"
|
|
||||||
|
|
||||||
if err := os.WriteFile(logFile, []byte(header), 0o644); err != nil {
|
|
||||||
log.WithError(err).Fatal("failed to initialize log file")
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(jsonlFile, nil, 0o644); err != nil {
|
|
||||||
log.WithError(err).Fatal("failed to initialize jsonl file")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Logging flaky test runs to %s", logFile)
|
log.Infof("Logging flaky test runs to %s", logFile)
|
||||||
|
|
@ -275,28 +401,8 @@ func main() {
|
||||||
run := 0
|
run := 0
|
||||||
for {
|
for {
|
||||||
run++
|
run++
|
||||||
|
exitCode, stop := executeRun(run, rootDir, logFile, jsonlFile)
|
||||||
start := time.Now()
|
if stop {
|
||||||
exitCode, summary, failures, err := runTestsOnce(rootDir)
|
|
||||||
durationMs := time.Since(start).Milliseconds()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.WithError(err).Errorf("Run %d failed to execute: %s", run, formatRunCounts(summary))
|
|
||||||
_ = appendRunLog(logFile, jsonlFile, run, 1, summary, failures, durationMs)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if logErr := appendRunLog(logFile, jsonlFile, run, exitCode, summary, failures, durationMs); logErr != nil {
|
|
||||||
log.WithError(logErr).Fatal("failed to append run log")
|
|
||||||
}
|
|
||||||
|
|
||||||
result := "PASS"
|
|
||||||
if exitCode != 0 {
|
|
||||||
result = "FAIL"
|
|
||||||
}
|
|
||||||
log.Infof("Run %d: %s | %s (%.1fs) — logged", run, result, formatRunCounts(summary), float64(durationMs)/1000)
|
|
||||||
|
|
||||||
if exitCode != 0 {
|
|
||||||
log.Errorf("Failure on run %d, stopping. See %s", run, logFile)
|
log.Errorf("Failure on run %d, stopping. See %s", run, logFile)
|
||||||
os.Exit(exitCode)
|
os.Exit(exitCode)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
<Icon Id="OliveTinIcon" SourceFile="$(var.SourceDir)/OliveTin.exe" />
|
<Icon Id="OliveTinIcon" SourceFile="$(var.SourceDir)/OliveTin.exe" />
|
||||||
|
|
||||||
<UIRef Id="WixUI_Minimal" />
|
<UIRef Id="WixUI_Minimal" />
|
||||||
|
<Property Id="WixUILicenseRtf" Value="License.rtf" />
|
||||||
|
|
||||||
<Feature Id="ProductFeature" Title="OliveTin" Level="1">
|
<Feature Id="ProductFeature" Title="OliveTin" Level="1">
|
||||||
<ComponentGroupRef Id="CG.AppFiles" />
|
<ComponentGroupRef Id="CG.AppFiles" />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue