blob: c8d451becfe6bd319ee56a949ccb1f0049f02021 [file] [log] [blame]
// Copyright 2024 The ChromiumOS Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package driver implements drivers to execute tests.
package driver
import (
"compress/gzip"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"go.chromium.org/chromiumos/config/go/test/api"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
tfStageLogsPath = "/tmp/stage-android-build-api/stub"
tfStageResultPattern = "subprocess-test_result.xml_*.xml.gz"
tfStageLogPattern = "subprocess-host_log_*"
tfAospResultPattern = "android-%s/results/latest/test_result.xml"
tfLuciResultPattern = "LUCIResult_*.json"
incompleteError = "Module is missing from results or skipped by exclude filter"
RAW_STATUS_KEY_NAME = "raw_status"
)
//--------------------------------------------------------------
// Data structures for parsing compatibility reporter XML data.
//--------------------------------------------------------------
type TradeFedTestFailure struct {
Message string `xml:"message,attr"`
StackTrace string `xml:"StackTrace"`
}
type TradeFedTest struct {
Result string `xml:"result,attr"`
Abi string
Name string `xml:"name,attr"`
Failure TradeFedTestFailure `xml:"Failure"`
}
type TradefedTestCase struct {
Name string `xml:"name,attr"`
Tests []TradeFedTest `xml:"Test"`
}
type Reason struct {
Message string `xml:"message,attr"`
ErrorName string `xml:"error_name,attr"`
ErrorCode string `xml:"error_code,attr"`
}
type Module struct {
Name string `xml:"name,attr"`
Abi string `xml:"abi,attr"`
Runtime string `xml:"runtime,attr"`
Done string `xml:"done,attr"`
Pass string `xml:"pass,attr"`
Total_Tests string `xml:"total_tests,attr"`
TestCases []TradefedTestCase `xml:"TestCase"`
Reason Reason `xml:"Reason"`
}
type Result struct {
// Start time and end time in milliseconds.
Start string `xml:"start,attr"`
End string `xml:"end,attr"`
BuildNum string `xml:"suite_build_number,attr"`
Modules []Module `xml:"Module"`
}
//--------------------------------------------------------------
// Data structures for parsing LUCI reporter JSON data.
//--------------------------------------------------------------
type LuciJsonResult struct {
Tr []struct {
TestId string `json:"testId"`
Name string `json:"result_name"`
Status string `json:"status"`
Expected bool `json:"expected"`
FailureReason string `json:"failureReason"`
Duration float32 `json:"duration"`
Tags []struct {
Key string `json:"key"`
Value string `json:"value"`
} `json:"tags"`
} `json:"tr"`
}
func selectMostRecentFile(files []string) string {
var recentFile string
var recentTime int64 = 0
for _, file := range files {
fi, err := os.Stat(file)
if err == nil {
fileTime := fi.ModTime().Unix()
if fileTime > recentTime {
recentTime = fileTime
recentFile = file
}
}
}
return recentFile
}
func selectFileByPattern(pattern string) (string, error) {
files, err := filepath.Glob(pattern)
if err != nil {
return "", err
}
if len(files) == 0 {
return "", fmt.Errorf("no files found matching pattern: %s", pattern)
}
if len(files) == 1 {
return files[0], nil
}
recentFile := selectMostRecentFile(files)
if len(recentFile) > 0 {
return recentFile, nil
} else {
return "", fmt.Errorf("no files found matching pattern: %s", pattern)
}
}
// Unmarshal XML or JSON result file into a given struct.
func parseResultFile(logger *log.Logger, resultPath string, v any) error {
resultData, err := os.ReadFile(resultPath)
if err != nil {
logger.Println("Failed to read Tradefed result file: ", err)
return err
}
if strings.HasSuffix(resultPath, ".json") {
err = json.Unmarshal(resultData, v)
} else {
// Use XML parser for all other result files.
err = xml.Unmarshal(resultData, v)
}
if err != nil {
logger.Println("Failed to parse Tradefed result file: ", err)
return err
}
return nil
}
func uncompressGzip(gzFile string, outFileName string) error {
gz, err := os.Open(gzFile)
if err != nil {
return fmt.Errorf("error opening GZIP file: %s, error: %v", gzFile, err)
}
uncompressedStream, err := gzip.NewReader(gz)
if err != nil {
return fmt.Errorf("error extracting GZIP file: %s, error: %v", gzFile, err)
}
// Make sure target path exists.
targetDir := filepath.Dir(outFileName)
cmd := exec.Command("mkdir", "-p", targetDir)
if err = cmd.Run(); err != nil {
return fmt.Errorf("error creating target directory: %s, error: %s", targetDir, err)
}
outFile, err := os.Create(outFileName)
if err != nil {
return fmt.Errorf("error creating output file: %s, error: %v", gzFile, err)
}
defer outFile.Close()
if _, err = io.Copy(outFile, uncompressedStream); err != nil {
return fmt.Errorf("error extracting file: %s, error: %v", gzFile, err)
}
return nil
}
func parseCompatibilityXmlResults(logger *log.Logger, testType string, req *api.CrosTestRequest) ([]*api.TestCaseResult, []*api.CrosTestResponse_GivenTestResult, []string, error) {
var logsDir []string
allTestCases := []*api.TestCaseResult{}
aospResultXmlFile := filepath.Join(tradefedDir, fmt.Sprintf(tfAospResultPattern, testType))
xmlResultFile, err := selectFileByPattern(aospResultXmlFile)
if err != nil {
// Fallback to stub location for XML result file.
xmlResultFile, err = selectFileByPattern(filepath.Join(tfStageLogsPath, "*", "*", tfStageResultPattern))
if err != nil {
return allTestCases, nil, logsDir, fmt.Errorf("failed to locate result file: %w", err)
} else {
standardResultFileName := filepath.Join(filepath.Dir(xmlResultFile), filepath.Base(aospResultXmlFile))
// Unzip stub location XML result file to the standard file name.
// This is required by CTS Archiver.
logger.Printf("Extracting stub XML result file: %s to: %s",
xmlResultFile, standardResultFileName)
if err = uncompressGzip(xmlResultFile, standardResultFileName); err != nil {
return allTestCases, nil, logsDir, fmt.Errorf("failed to prepare result file: %w", err)
} else {
xmlResultFile = standardResultFileName
}
}
}
logsDir = append(logsDir, filepath.Join(filepath.Dir(xmlResultFile), "*"))
logger.Println("Parsing results from: ", xmlResultFile)
R := Result{}
if err = parseResultFile(logger, xmlResultFile, &R); err != nil {
return allTestCases, nil, logsDir, err
}
allTestCases, givenTestCases := generateTestCaseResult(logger, testType, R, req)
return allTestCases, givenTestCases, logsDir, nil
}
func parseLuciResultName(resultName string) (abi string, moduleName string, endtime int64, err error) {
// Parse ABI, Module name and test time from LUCI result name string.
// Format of LUCI result name: "<ABI> <module>##<datetime>"
// First, extract the ABI
parts1 := strings.SplitN(resultName, " ", 2)
if len(parts1) != 2 {
return "", "", 0, fmt.Errorf("could not split ABI from result name: %s", resultName)
}
abi = parts1[0]
// Then, split the remaining part
parts2 := strings.SplitN(parts1[1], "##", 2)
if len(parts2) != 2 {
return "", "", 0, fmt.Errorf("could not parse result name: %s", resultName)
}
moduleName = parts2[0]
// Parse the datetime and convert to timestamp (i.e. "2025-01-06T23:57:37.155198962Z" -> 1736168257)
t, err := time.Parse(time.RFC3339Nano, parts2[1])
if err != nil {
fmt.Println(err)
}
endtime = t.UnixMilli()
return
}
func parseTestName(testName string) (string, string, error) {
testNameParts := strings.SplitN(testName, "#", 2)
if len(testNameParts) != 2 {
return "", "", fmt.Errorf("could not parse test name: %s", testName)
}
return testNameParts[0], testNameParts[1], nil
}
func isPassedTest(status string) bool {
statusUp := strings.ToUpper(status)
return strings.HasPrefix(statusUp, "PASS") || strings.HasPrefix(statusUp, "SKIP")
}
func convertToResult(logger *log.Logger, luciResult LuciJsonResult) (Result, error) {
moduleMap := make(map[string]*Module)
var endTime int64
var maxDuration int64
for _, trResult := range luciResult.Tr {
abi, moduleName, moduleEndTime, err := parseLuciResultName(trResult.Name)
if err != nil {
return Result{}, err
}
testCaseName, testName, err := parseTestName(trResult.TestId)
if err != nil {
return Result{}, err
}
status := trResult.Status
for _, tag := range trResult.Tags {
if tag.Key == RAW_STATUS_KEY_NAME {
status = tag.Value
}
}
if _, ok := moduleMap[moduleName]; !ok {
moduleMap[moduleName] = &Module{
Name: moduleName,
Abi: abi,
Runtime: strconv.FormatInt(int64(trResult.Duration*1000), 10),
Done: "true",
}
}
module := moduleMap[moduleName]
var testCase *TradefedTestCase
for i := range module.TestCases {
if module.TestCases[i].Name == testCaseName {
testCase = &module.TestCases[i]
break
}
}
if testCase == nil {
module.TestCases = append(module.TestCases, TradefedTestCase{Name: testCaseName})
testCase = &module.TestCases[len(module.TestCases)-1]
}
tfTest := TradeFedTest{
Name: testName,
Abi: abi,
Result: status,
}
if len(trResult.FailureReason) > 0 && !trResult.Expected {
tfTest.Failure = TradeFedTestFailure{Message: trResult.FailureReason}
}
testCase.Tests = append(testCase.Tests, tfTest)
// Keep track of latest endTime and max test runtime duration.
currModuleRuntime, err := strconv.Atoi(module.Runtime)
if err == nil {
currModuleRuntime = 1
}
moduleDuration := int64(trResult.Duration * 1000)
if moduleDuration > maxDuration {
maxDuration = moduleDuration
}
if moduleDuration > int64(currModuleRuntime) {
module.Runtime = strconv.FormatInt(moduleDuration, 10)
}
if moduleEndTime > endTime {
endTime = moduleEndTime
}
}
var modules []Module
for _, module := range moduleMap {
passCount := 0
totalCount := 0
for _, testCase := range module.TestCases {
for _, test := range testCase.Tests {
totalCount++
if isPassedTest(test.Result) {
passCount++
}
}
}
module.Pass = strconv.Itoa(passCount)
module.Total_Tests = strconv.Itoa(totalCount)
modules = append(modules, *module)
}
return Result{
Start: strconv.FormatInt(endTime-maxDuration, 10),
End: strconv.FormatInt(endTime, 10),
Modules: modules,
}, nil
}
func parseLuciJsonResults(logger *log.Logger, testType string, req *api.CrosTestRequest) ([]*api.TestCaseResult, []*api.CrosTestResponse_GivenTestResult, []string, error) {
var logsDir []string
allTestCases := []*api.TestCaseResult{}
resultFilePath := filepath.Join(getGlobalLogPath(), "*", tfLuciResultPattern)
jsonResultFile, err := selectFileByPattern(resultFilePath)
if err != nil {
return allTestCases, nil, logsDir, fmt.Errorf("failed to locate result file: %w", err)
}
logsDir = append(logsDir, filepath.Join(filepath.Dir(jsonResultFile), "*"))
logger.Println("Parsing JSON results from: ", jsonResultFile)
TR := LuciJsonResult{}
if err = parseResultFile(logger, jsonResultFile, &TR); err != nil {
return allTestCases, nil, logsDir, err
}
R, err := convertToResult(logger, TR)
if err != nil {
return allTestCases, nil, logsDir, err
}
allTestCases, givenTestCases := generateTestCaseResult(logger, testType, R, req)
// Save location of additional (staged) logs.
stagedLogFile, err := selectFileByPattern(filepath.Join(tfStageLogsPath, "*", "*", tfStageLogPattern))
if err == nil {
logsDir = append(logsDir, filepath.Join(filepath.Dir(stagedLogFile), "*"))
}
return allTestCases, givenTestCases, logsDir, nil
}
func testResponseNoXMLFound(req *api.CrosTestRequest) *api.CrosTestResponse {
r := &api.CrosTestResponse{}
if req == nil || req.GetTestSuites() == nil {
return r
}
for _, suites := range req.GetTestSuites() {
for _, testCaseIds := range suites.GetTestCaseIds().GetTestCaseIds() {
testCaseResult := buildTcResult(testCaseIds.GetValue(), "", "INCOMPLETE", time.Now().Add(-1*time.Second), 1, "")
r.TestCaseResults = append(r.TestCaseResults, testCaseResult)
r.GivenTestResults = append(r.GivenTestResults, &api.CrosTestResponse_GivenTestResult{
ParentTest: testCaseIds.GetValue(),
ChildTestCaseResults: []*api.TestCaseResult{testCaseResult},
})
}
}
return r
}
func generateTestCaseResult(logger *log.Logger, testType string, R Result, req *api.CrosTestRequest) ([]*api.TestCaseResult, []*api.CrosTestResponse_GivenTestResult) {
allTestCases := []*api.TestCaseResult{}
givenTestCases := []*api.CrosTestResponse_GivenTestResult{}
startTimeMs, _ := strconv.ParseInt(R.Start, 10, 64)
startTime := time.UnixMilli(startTimeMs)
logger.Println("Found n module results:", len(R.Modules))
modulesFromReq := getAllModulesFromReq(req)
for _, module := range R.Modules {
moduleName := fmt.Sprintf("tradefed.%s.%s", testType, module.Name)
logger.Println("Parsing Module: ", module.Name)
delete(modulesFromReq, moduleName)
nTests, _ := strconv.Atoi(module.Total_Tests)
runTime, _ := strconv.Atoi(module.Runtime)
// Make a duration of atleast 1 second so the proto isnt empty.
testDur := int64(runTime / 1000)
if testDur == 0 {
testDur = 1
}
if nTests != 0 {
testDur = testDur / int64(nTests)
} else {
// Check and report module-level error.
if module.Reason.Message != "" {
allTestCases = append(allTestCases,
buildTcResult(moduleName, module.Abi, "FAILED", startTime, testDur, module.Reason.Message))
givenTestCases = append(givenTestCases, &api.CrosTestResponse_GivenTestResult{
ParentTest: moduleName,
ChildTestCaseResults: []*api.TestCaseResult{
buildTcResult(moduleName, module.Abi, "FAILED", startTime, testDur, module.Reason.Message),
},
})
} else {
// No tests in the module and no errors, so reporting this module as 'pass'.
allTestCases = append(allTestCases,
buildTcResult(moduleName, module.Abi, "PASSED", startTime, testDur, ""))
givenTestCases = append(givenTestCases, &api.CrosTestResponse_GivenTestResult{
ParentTest: moduleName,
ChildTestCaseResults: []*api.TestCaseResult{
buildTcResult(moduleName, module.Abi, "PASSED", startTime, testDur, ""),
},
})
}
}
givenTestCase := &api.CrosTestResponse_GivenTestResult{}
givenTestCase.ParentTest = moduleName
childTestCases := []*api.TestCaseResult{}
for _, testcase := range module.TestCases {
for _, test := range testcase.Tests {
fullTestName := fmt.Sprintf("tradefed.%s.%s#%s#%s", testType, module.Name, testcase.Name, test.Name)
errorMessage := test.Failure.Message
if len(test.Failure.StackTrace) > 0 {
errorMessage += "\n" + test.Failure.StackTrace
}
abi := module.Abi
if len(test.Abi) > 0 {
// If test ABI is available, use it instead of the module ABI.
abi = test.Abi
}
allTestCases = append(allTestCases,
buildTcResult(fullTestName, abi, test.Result, startTime, testDur, errorMessage))
testName := fmt.Sprintf("%s#%s", testcase.Name, test.Name)
childTestCases = append(childTestCases,
buildTcResult(testName, abi, test.Result, startTime, testDur, errorMessage))
}
}
givenTestCase.ChildTestCaseResults = childTestCases
givenTestCases = append(givenTestCases, givenTestCase)
}
for moduleName := range modulesFromReq {
allTestCases = append(allTestCases,
buildTcResult(moduleName, "", "SKIPPED", startTime, 1, incompleteError))
givenTestCases = append(givenTestCases,
&api.CrosTestResponse_GivenTestResult{
ParentTest: moduleName,
ChildTestCaseResults: []*api.TestCaseResult{
buildTcResult(moduleName, "", "SKIPPED", startTime, 1, incompleteError),
},
})
}
return allTestCases, givenTestCases
}
func getAllModulesFromReq(req *api.CrosTestRequest) map[string]struct{} {
modules := make(map[string]struct{})
if req == nil || req.GetTestSuites() == nil {
return modules
}
for _, suites := range req.GetTestSuites() {
for _, testCaseIds := range suites.GetTestCaseIds().GetTestCaseIds() {
// Split on a space, incase the request is module + class.
// We need to get back to just the module to make a result for the "parent"
modules[moduleNameFromID(testCaseIds.GetValue())] = struct{}{}
}
}
return modules
}
func buildTradefedResult(logger *log.Logger, testType string, req *api.CrosTestRequest) (*api.CrosTestResponse, []string) {
// List of glob patterns of artifacts that should be saved in test results.
var logsToSave = []string{}
logger.Println("Building results...")
f := &api.CrosTestResponse{}
var err error
if isNonXtsTest(testType) {
f.TestCaseResults, f.GivenTestResults, logsToSave, err = parseLuciJsonResults(logger, testType, req)
} else {
f.TestCaseResults, f.GivenTestResults, logsToSave, err = parseCompatibilityXmlResults(logger, testType, req)
}
if err != nil {
logger.Println("Failed to locate or parse Tradefed compatibility result file: ", err)
return testResponseNoXMLFound(req), logsToSave
}
logger.Println("Found n case results:", len(f.TestCaseResults))
return f, logsToSave
}
func buildTcResult(testName string, abi string, testStatus string, startTime time.Time, duration int64,
errorMessage string) *api.TestCaseResult {
tcResult := new(api.TestCaseResult)
tcResult.TestHarness = &api.TestHarness{TestHarnessType: &api.TestHarness_Tradefed_{Tradefed: &api.TestHarness_Tradefed{}}}
tcResult.TestCaseId = &api.TestCase_Id{Value: testName}
if len(abi) > 0 {
tcResult.Tags = []*api.TestCase_Tag{&api.TestCase_Tag{Value: fmt.Sprintf("abi:%s", abi)}}
}
tcResult.StartTime = timestamppb.New(startTime)
// Make a duration of atleast 1 second so the proto isnt empty.
testDuration := duration
if testDuration == 0 {
testDuration = 1
}
tcResult.Duration = &durationpb.Duration{Seconds: duration}
testError := &api.TestCaseResult_Error{Message: errorMessage}
switch testStatus {
case "PASSED", "pass":
tcResult.Verdict = &api.TestCaseResult_Pass_{Pass: &api.TestCaseResult_Pass{}}
case "ASSUMPTION_FAILURE":
tcResult.Verdict = &api.TestCaseResult_Pass_{Pass: &api.TestCaseResult_Pass{}}
tcResult.Errors = []*api.TestCaseResult_Error{testError}
case "FAILED", "FAILURE", "fail":
tcResult.Verdict = &api.TestCaseResult_Fail_{Fail: &api.TestCaseResult_Fail{}}
tcResult.Errors = []*api.TestCaseResult_Error{testError}
case "INCOMPLETE":
tcResult.Verdict = &api.TestCaseResult_Crash_{Crash: &api.TestCaseResult_Crash{}}
tcResult.Errors = []*api.TestCaseResult_Error{testError}
case "SKIPPED":
tcResult.Verdict = &api.TestCaseResult_Skip_{Skip: &api.TestCaseResult_Skip{}}
tcResult.Errors = []*api.TestCaseResult_Error{testError}
case "IGNORED":
tcResult.Verdict = &api.TestCaseResult_Skip_{Skip: &api.TestCaseResult_Skip{}}
tcResult.Errors = []*api.TestCaseResult_Error{testError}
default:
tcResult.Verdict = &api.TestCaseResult_Fail_{Fail: &api.TestCaseResult_Fail{}}
tcResult.Errors = []*api.TestCaseResult_Error{testError}
}
tcResult.TestCaseMetadata = &api.TestCaseMetadata{
TestCase: &api.TestCase{
Id: tcResult.TestCaseId,
Name: extractTestCaseName(tcResult.TestCaseId.Value),
},
TestCaseExec: &api.TestCaseExec{TestHarness: tcResult.TestHarness},
}
return tcResult
}