|  | // Copyright 2025 The Dawn & Tint Authors | 
|  | // | 
|  | // Redistribution and use in source and binary forms, with or without | 
|  | // modification, are permitted provided that the following conditions are met: | 
|  | // | 
|  | // 1. Redistributions of source code must retain the above copyright notice, this | 
|  | //    list of conditions and the following disclaimer. | 
|  | // | 
|  | // 2. Redistributions in binary form must reproduce the above copyright notice, | 
|  | //    this list of conditions and the following disclaimer in the documentation | 
|  | //    and/or other materials provided with the distribution. | 
|  | // | 
|  | // 3. Neither the name of the copyright holder nor the names of its | 
|  | //    contributors may be used to endorse or promote products derived from | 
|  | //    this software without specific prior written permission. | 
|  | // | 
|  | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | 
|  | // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | 
|  | // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | 
|  | // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | 
|  | // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | 
|  | // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | 
|  | // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | 
|  | // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | 
|  | // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | 
|  | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | 
|  |  | 
|  | package expectationcoverage | 
|  |  | 
|  | import ( | 
|  | "context" | 
|  | "flag" | 
|  | "fmt" | 
|  | "io" | 
|  | "os" | 
|  | "runtime" | 
|  | "slices" | 
|  | "sort" | 
|  | "strings" | 
|  | "sync" | 
|  | "time" | 
|  |  | 
|  | "dawn.googlesource.com/dawn/tools/src/cmd/cts/common" | 
|  | "dawn.googlesource.com/dawn/tools/src/cts/expectations" | 
|  | "dawn.googlesource.com/dawn/tools/src/cts/result" | 
|  | "dawn.googlesource.com/dawn/tools/src/oswrapper" | 
|  | ) | 
|  |  | 
|  | const maxResultsPerWorker = 200000 | 
|  |  | 
|  | func init() { | 
|  | common.Register(&cmd{}) | 
|  | } | 
|  |  | 
|  | type cmd struct { | 
|  | flags struct { | 
|  | maxOutput               int | 
|  | checkCompatExpectations bool | 
|  | individualExpectations  bool | 
|  | ignoreSkipExpectations  bool | 
|  | cacheDir                string | 
|  | verbose                 bool | 
|  | } | 
|  | } | 
|  |  | 
|  | type ChunkWithCounter struct { | 
|  | Chunk *expectations.Chunk | 
|  | Count int | 
|  | } | 
|  |  | 
|  | func (cmd) Name() string { | 
|  | return "expectation-coverage" | 
|  | } | 
|  |  | 
|  | func (cmd) Desc() string { | 
|  | return "checks how much test coverage is lost due to expectations" | 
|  | } | 
|  |  | 
|  | func (c *cmd) RegisterFlags(ctx context.Context, cfg common.Config) ([]string, error) { | 
|  | flag.IntVar( | 
|  | &c.flags.maxOutput, | 
|  | "max-output", | 
|  | 25, | 
|  | "limit output to the top X expectation groups, set to 0 for unlimited") | 
|  | flag.BoolVar( | 
|  | &c.flags.checkCompatExpectations, | 
|  | "check-compat-expectations", | 
|  | false, | 
|  | "check compat expectations instead of regular expectations") | 
|  | flag.BoolVar( | 
|  | &c.flags.individualExpectations, | 
|  | "check-individual-expectations", | 
|  | false, | 
|  | "check individual expectations instead of groups") | 
|  | flag.BoolVar( | 
|  | &c.flags.ignoreSkipExpectations, | 
|  | "ignore-skip-expectations", | 
|  | false, | 
|  | "do not check the impact of Skip expectations") | 
|  | flag.StringVar( | 
|  | &c.flags.cacheDir, | 
|  | "cache", | 
|  | common.DefaultCacheDir, | 
|  | "path to the results cache") | 
|  | flag.BoolVar( | 
|  | &c.flags.verbose, | 
|  | "verbose", | 
|  | false, | 
|  | "emit additional logging") | 
|  | return nil, nil | 
|  | } | 
|  |  | 
|  | func (c *cmd) Run(ctx context.Context, cfg common.Config) error { | 
|  | individualExpectations := c.flags.individualExpectations | 
|  |  | 
|  | // Parse expectation file | 
|  | fmt.Println("Getting trimmed expectation file content") | 
|  | startTime := time.Now() | 
|  | var expectationPath string | 
|  | if c.flags.checkCompatExpectations { | 
|  | expectationPath = common.DefaultCompatExpectationsPath(cfg.OsWrapper) | 
|  | } else { | 
|  | expectationPath = common.DefaultExpectationsPath(cfg.OsWrapper) | 
|  | } | 
|  | content, err := getTrimmedContent(expectationPath, | 
|  | individualExpectations, | 
|  | c.flags.ignoreSkipExpectations, | 
|  | c.flags.verbose, | 
|  | cfg.OsWrapper) | 
|  | if err != nil { | 
|  | return err | 
|  | } | 
|  | if c.flags.verbose { | 
|  | fmt.Printf("Took %s\n", time.Now().Sub(startTime).String()) | 
|  | fmt.Printf("Got %d chunks/individual expectations\n", len(content.Chunks)) | 
|  | } | 
|  |  | 
|  | // Get ResultDB data | 
|  | fmt.Println("Getting results") | 
|  | startTime = time.Now() | 
|  | var uniqueResults result.List | 
|  | if c.flags.checkCompatExpectations { | 
|  | uniqueResults, err = common.CacheRecentUniqueSuppressedCompatResults( | 
|  | ctx, cfg, c.flags.cacheDir, cfg.Querier, cfg.OsWrapper) | 
|  | } else { | 
|  | uniqueResults, err = common.CacheRecentUniqueSuppressedCoreResults( | 
|  | ctx, cfg, c.flags.cacheDir, cfg.Querier, cfg.OsWrapper) | 
|  | } | 
|  | if err != nil { | 
|  | return err | 
|  | } | 
|  | if c.flags.verbose { | 
|  | fmt.Printf("Took %s\n", time.Now().Sub(startTime).String()) | 
|  | fmt.Printf("Got %d unique results\n", len(uniqueResults)) | 
|  | } | 
|  |  | 
|  | // Process ResultDB data | 
|  | fmt.Println("Processing results") | 
|  | startTime = time.Now() | 
|  | orderedChunks := getChunksOrderedByCoverageLoss(&content, &uniqueResults) | 
|  | if c.flags.verbose { | 
|  | fmt.Printf("Took %s\n", time.Now().Sub(startTime).String()) | 
|  | } | 
|  |  | 
|  | // Output results. | 
|  | outputResults(orderedChunks, c.flags.maxOutput, individualExpectations, os.Stdout) | 
|  |  | 
|  | return nil | 
|  | } | 
|  |  | 
|  | // getTrimmedContent returns a Content with certain Chunks removed or modified | 
|  | // based on the provided arguments. | 
|  | func getTrimmedContent( | 
|  | expectationPath string, | 
|  | individualExpectations bool, | 
|  | ignoreSkipExpectations bool, | 
|  | verbose bool, | 
|  | fsReader oswrapper.FilesystemReader) (expectations.Content, error) { | 
|  | rawFileContentBytes, err := fsReader.ReadFile(expectationPath) | 
|  | if err != nil { | 
|  | return expectations.Content{}, err | 
|  | } | 
|  | rawFileContent := string(rawFileContentBytes[:]) | 
|  |  | 
|  | content, err := expectations.Parse(expectationPath, rawFileContent) | 
|  | if err != nil { | 
|  | return expectations.Content{}, err | 
|  | } | 
|  |  | 
|  | // Remove any permanent Skip expectations since they are never expected to | 
|  | // be removed from the file. Also remove any pure comment chunks. | 
|  | permanentSkipContent, err := getPermanentSkipContent(expectationPath, rawFileContent) | 
|  | if err != nil { | 
|  | return expectations.Content{}, err | 
|  | } | 
|  |  | 
|  | // Get a copy of all relevant chunks. | 
|  | var trimmedChunks []expectations.Chunk | 
|  | for _, chunk := range content.Chunks { | 
|  | if chunk.IsCommentOnly() { | 
|  | continue | 
|  | } | 
|  | if chunk.ContainedWithinList(&permanentSkipContent.Chunks) { | 
|  | continue | 
|  | } | 
|  |  | 
|  | var maybeSkiplessChunk expectations.Chunk | 
|  | if ignoreSkipExpectations { | 
|  | maybeSkiplessChunk = expectations.Chunk{ | 
|  | Comments:     chunk.Comments, | 
|  | Expectations: expectations.Expectations{}, | 
|  | } | 
|  | for _, e := range chunk.Expectations { | 
|  | if slices.Contains(e.Status, string(result.Skip)) { | 
|  | continue | 
|  | } | 
|  | maybeSkiplessChunk.Expectations = append(maybeSkiplessChunk.Expectations, e) | 
|  | } | 
|  | } else { | 
|  | maybeSkiplessChunk = chunk | 
|  | } | 
|  |  | 
|  | if maybeSkiplessChunk.IsCommentOnly() { | 
|  | continue | 
|  | } | 
|  | trimmedChunks = append(trimmedChunks, maybeSkiplessChunk) | 
|  | } | 
|  |  | 
|  | // Split chunks into individual expectations if requested. | 
|  | var maybeSplitChunks []expectations.Chunk | 
|  | if individualExpectations { | 
|  | for _, chunk := range trimmedChunks { | 
|  | for _, e := range chunk.Expectations { | 
|  | individualChunk := expectations.Chunk{ | 
|  | Comments:     chunk.Comments, | 
|  | Expectations: expectations.Expectations{e}, | 
|  | } | 
|  | maybeSplitChunks = append(maybeSplitChunks, individualChunk) | 
|  | } | 
|  | } | 
|  | } else { | 
|  | maybeSplitChunks = trimmedChunks | 
|  | } | 
|  | content.Chunks = maybeSplitChunks | 
|  |  | 
|  | return content, nil | 
|  | } | 
|  |  | 
|  | // getPermanentSkipContent returns a Content only containing Chunks for | 
|  | // permanent Skip expectations. | 
|  | func getPermanentSkipContent( | 
|  | expectationPath string, | 
|  | rawFileContent string) (expectations.Content, error) { | 
|  | // Since the standard format for expectation files is: | 
|  | //  - Permanent Skip expectations | 
|  | //  - Temporary Skip expectations | 
|  | //  - Triaged flakes/failure expectations | 
|  | //  - Untriaged auto-generated expectations | 
|  | // Assume we care about everything up to the temporary Skip expectation | 
|  | // section. | 
|  | targetLine := "# Temporary Skip Expectations" | 
|  | var keptLines []string | 
|  | brokeEarly := false | 
|  | for _, line := range strings.Split(rawFileContent, "\n") { | 
|  | if strings.HasPrefix(line, targetLine) { | 
|  | brokeEarly = true | 
|  | break | 
|  | } | 
|  | keptLines = append(keptLines, line) | 
|  | } | 
|  |  | 
|  | if !brokeEarly { | 
|  | fmt.Println("Unable to find permanent Skip expectations, assuming none exist") | 
|  | return expectations.Content{}, nil | 
|  | } | 
|  |  | 
|  | permanentSkipRawContent := strings.Join(keptLines, "\n") | 
|  | permanentSkipContent, err := expectations.Parse(expectationPath, permanentSkipRawContent) | 
|  | if err != nil { | 
|  | return expectations.Content{}, err | 
|  | } | 
|  |  | 
|  | // Omit any pure comment chunks. | 
|  | var trimmedChunks []expectations.Chunk | 
|  | for _, chunk := range permanentSkipContent.Chunks { | 
|  | if !chunk.IsCommentOnly() { | 
|  | trimmedChunks = append(trimmedChunks, chunk) | 
|  | } | 
|  | } | 
|  | permanentSkipContent.Chunks = trimmedChunks | 
|  |  | 
|  | return permanentSkipContent, nil | 
|  | } | 
|  |  | 
|  | // math.Min only works on floats, and the built-in min is not available until | 
|  | // go 1.21. | 
|  | func minInt(a, b int) int { | 
|  | if a < b { | 
|  | return a | 
|  | } | 
|  | return b | 
|  | } | 
|  |  | 
|  | // getChunksOrderedByCoverageLost returns the Chunks contained within 'content' | 
|  | // ordered by how many results from 'uniqueResults' are affected by expectations | 
|  | // within the Chunk. | 
|  | // | 
|  | // Under the hood, actual processing is farmed out to goroutines to better | 
|  | // handle large amounts of results. | 
|  | func getChunksOrderedByCoverageLoss( | 
|  | content *expectations.Content, | 
|  | uniqueResults *result.List) []ChunkWithCounter { | 
|  |  | 
|  | affectedChunks := make([]ChunkWithCounter, len(content.Chunks)) | 
|  | for i, _ := range content.Chunks { | 
|  | affectedChunks[i].Chunk = &(content.Chunks[i]) | 
|  | } | 
|  |  | 
|  | // Create a goroutine pool. Each worker pulls a single ChunkWithCounter from | 
|  | // the queue at a time and handles all of the processing for it. | 
|  | numWorkers := minInt(len(affectedChunks), runtime.NumCPU()) | 
|  | workQueue := make(chan *ChunkWithCounter) | 
|  | waitGroup := new(sync.WaitGroup) | 
|  | waitGroup.Add(numWorkers) | 
|  | for i := 0; i < numWorkers; i++ { | 
|  | go processChunk(workQueue, uniqueResults, waitGroup) | 
|  | } | 
|  |  | 
|  | // Each of the ChunkWithCounter will have its Count filled in place by a | 
|  | // worker when picked up. | 
|  | for i, _ := range affectedChunks { | 
|  | workQueue <- &(affectedChunks[i]) | 
|  | } | 
|  | close(workQueue) | 
|  | waitGroup.Wait() | 
|  |  | 
|  | // Sort based on the final tally. | 
|  | sortFunc := func(i, j int) bool { | 
|  | return affectedChunks[i].Count > affectedChunks[j].Count | 
|  | } | 
|  | sort.SliceStable(affectedChunks, sortFunc) | 
|  |  | 
|  | return affectedChunks | 
|  | } | 
|  |  | 
|  | // processChunk counts how many Results in 'uniqueResults' apply to Expectations | 
|  | // in a provided ChunkWithCounter that is provided via 'workQueue'. The function | 
|  | // will continue to pull work from 'workQueue' until it is closed and empty, at | 
|  | // which point the function will exit and signal to 'waitGroup' that it is | 
|  | // finished. | 
|  | // | 
|  | // Under the hood, actual processing is farmed out to additional goroutines to | 
|  | // better handle large amounts of results. | 
|  | func processChunk( | 
|  | workQueue chan *ChunkWithCounter, | 
|  | uniqueResults *result.List, | 
|  | waitGroup *sync.WaitGroup) { | 
|  |  | 
|  | defer waitGroup.Done() | 
|  |  | 
|  | // Create a pool of workers to handle processing of subsets of results. Each | 
|  | // worker handles every ith result and returns the number of those results | 
|  | // that applied to an expectation within the given ChunkWithCounter. | 
|  | numWorkers := int(len(*uniqueResults)/maxResultsPerWorker) + 1 | 
|  | subWorkQueues := []chan *ChunkWithCounter{} | 
|  | subResultQueues := []chan int{} | 
|  | for i := 0; i < numWorkers; i++ { | 
|  | subWorkQueues = append(subWorkQueues, make(chan *ChunkWithCounter)) | 
|  | subResultQueues = append(subResultQueues, make(chan int)) | 
|  | go processChunkForResultSubset( | 
|  | subWorkQueues[i], | 
|  | subResultQueues[i], | 
|  | uniqueResults, | 
|  | i, | 
|  | numWorkers) | 
|  | } | 
|  |  | 
|  | for { | 
|  | chunkWithCounter, queueOpen := <-workQueue | 
|  | if !queueOpen { | 
|  | for _, swq := range subWorkQueues { | 
|  | close(swq) | 
|  | } | 
|  | return | 
|  | } | 
|  |  | 
|  | for i := 0; i < numWorkers; i++ { | 
|  | subWorkQueues[i] <- chunkWithCounter | 
|  | } | 
|  | for i := 0; i < numWorkers; i++ { | 
|  | chunkWithCounter.Count += <-subResultQueues[i] | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | // processChunkForResultSubset counts how many Results in 'uniqueResults' apply | 
|  | // to Expectations in a provided ChunkWithCounter that is provided via | 
|  | // 'workQueue'. Only every 'numWorkers' element of 'uniqueResults' is processed, | 
|  | // starting at the 'workNumber' element. The count for each ChunkWithCounter is | 
|  | // returned via 'resultQueue' in the same order that the work was provided. | 
|  | // | 
|  | // The function will continue to pull work from 'workQueue' until it is closed | 
|  | // and empty. | 
|  | func processChunkForResultSubset( | 
|  | workQueue chan *ChunkWithCounter, | 
|  | resultQueue chan int, | 
|  | uniqueResults *result.List, | 
|  | workerNumber, numWorkers int) { | 
|  |  | 
|  | for { | 
|  | chunkWithCounter, queueOpen := <-workQueue | 
|  | if !queueOpen { | 
|  | return | 
|  | } | 
|  |  | 
|  | numApplicableResults := 0 | 
|  | for i := workerNumber; i < len(*uniqueResults); i += numWorkers { | 
|  | result := (*uniqueResults)[i] | 
|  | for _, expectation := range chunkWithCounter.Chunk.Expectations { | 
|  | if expectation.AppliesToResult(result) { | 
|  | numApplicableResults += 1 | 
|  | break | 
|  | } | 
|  | } | 
|  | } | 
|  |  | 
|  | resultQueue <- numApplicableResults | 
|  | } | 
|  | } | 
|  |  | 
|  | func outputResults( | 
|  | orderedChunks []ChunkWithCounter, | 
|  | maxChunksToOutput int, | 
|  | individualExpectations bool, | 
|  | writer io.Writer) { | 
|  |  | 
|  | var expectationPrefix, chunkType string | 
|  | if individualExpectations { | 
|  | chunkType = "individual expectation" | 
|  | expectationPrefix = "Expectation: " | 
|  | } else { | 
|  | chunkType = "chunk" | 
|  | expectationPrefix = "First expectation: " | 
|  | } | 
|  |  | 
|  | if maxChunksToOutput == 0 { | 
|  | fmt.Fprintln(writer, "\nComplete output:") | 
|  | } else { | 
|  | fmt.Fprintf( | 
|  | writer, | 
|  | "\nTop %d %ss contributing to test coverage loss:\n", | 
|  | maxChunksToOutput, | 
|  | chunkType) | 
|  | } | 
|  |  | 
|  | for i, chunkWithCounter := range orderedChunks { | 
|  | if maxChunksToOutput != 0 && i == maxChunksToOutput { | 
|  | break | 
|  | } | 
|  |  | 
|  | chunk := chunkWithCounter.Chunk | 
|  | firstExpectation := chunk.Expectations[0] | 
|  | fmt.Fprintln(writer, "") | 
|  | fmt.Fprintf(writer, "Comment: %s\n", strings.Join(chunk.Comments, "\n")) | 
|  | fmt.Fprintf(writer, "%s%s\n", expectationPrefix, firstExpectation.AsExpectationFileString()) | 
|  | fmt.Fprintf(writer, "Line number: %d\n", firstExpectation.Line) | 
|  | fmt.Fprintf(writer, "Affected %d test results\n", chunkWithCounter.Count) | 
|  | } | 
|  | } |