Ben Clayton | a2a00e2 | 2019-02-16 01:05:23 +0000 | [diff] [blame^] | 1 | // Copyright 2019 The SwiftShader Authors. All Rights Reserved. |
| 2 | // |
| 3 | // Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | // you may not use this file except in compliance with the License. |
| 5 | // You may obtain a copy of the License at |
| 6 | // |
| 7 | // http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | // |
| 9 | // Unless required by applicable law or agreed to in writing, software |
| 10 | // distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | // See the License for the specific language governing permissions and |
| 13 | // limitations under the License. |
| 14 | |
| 15 | // Regres is a tool that detects test regressions with SwiftShader changes. |
| 16 | // |
| 17 | // Regres monitors changes that have been put up for review with Gerrit. |
| 18 | // Once a new patchset has been found, regres will checkout, build and test the |
| 19 | // change against the parent changelist. Any differences in results are reported |
| 20 | // as a review comment on the change. |
| 21 | // |
| 22 | // Once a day regres will also test another, larger set of tests, and post the |
| 23 | // full test results as a Gerrit changelist. The CI test lists can be based from |
| 24 | // this daily test list, so testing can be limited to tests that were known to |
| 25 | // pass. |
| 26 | package main |
| 27 | |
| 28 | import ( |
| 29 | "bytes" |
| 30 | "encoding/json" |
| 31 | "errors" |
| 32 | "flag" |
| 33 | "fmt" |
| 34 | "io/ioutil" |
| 35 | "log" |
| 36 | "os" |
| 37 | "os/exec" |
| 38 | "path/filepath" |
| 39 | "regexp" |
| 40 | "runtime" |
| 41 | "sort" |
| 42 | "strings" |
| 43 | "sync" |
| 44 | "time" |
| 45 | |
| 46 | "./cause" |
| 47 | "./git" |
| 48 | "./shell" |
| 49 | |
| 50 | gerrit "github.com/andygrunwald/go-gerrit" |
| 51 | ) |
| 52 | |
| 53 | const ( |
| 54 | gitURL = "https://swiftshader.googlesource.com/SwiftShader" |
| 55 | gerritURL = "https://swiftshader-review.googlesource.com/" |
| 56 | reportHeader = "Regres report:" |
| 57 | dataVersion = 1 |
| 58 | changeUpdateFrequency = time.Minute * 5 |
| 59 | changeQueryFrequency = time.Minute * 5 |
| 60 | testTimeout = time.Minute * 5 // timeout for a single test |
| 61 | buildTimeout = time.Minute * 10 // timeout for a build |
| 62 | dailyUpdateTestListHour = 5 // 5am |
| 63 | fullTestList = "tests/regres/full-tests.json" |
| 64 | ciTestList = "tests/regres/ci-tests.json" |
| 65 | ) |
| 66 | |
| 67 | var ( |
| 68 | numParallelTests = runtime.NumCPU() |
| 69 | |
| 70 | deqpPath = flag.String("deqp", "", "path to the deqp build directory") |
| 71 | cacheDir = flag.String("cache", "cache", "path to the output cache directory") |
| 72 | gerritEmail = flag.String("email", "$SS_REGRES_EMAIL", "gerrit email address for posting regres results") |
| 73 | gerritUser = flag.String("user", "$SS_REGRES_USER", "gerrit username for posting regres results") |
| 74 | gerritPass = flag.String("pass", "$SS_REGRES_PASS", "gerrit password for posting regres results") |
| 75 | keepCheckouts = flag.Bool("keep", false, "don't delete checkout directories after use") |
| 76 | dryRun = flag.Bool("dry", false, "don't post regres reports to gerrit") |
| 77 | maxProcMemory = flag.Uint64("max-proc-mem", shell.MaxProcMemory, "maximum virtual memory per child process") |
| 78 | ) |
| 79 | |
| 80 | func main() { |
| 81 | if runtime.GOOS != "linux" { |
| 82 | log.Fatal("regres only currently runs on linux") |
| 83 | } |
| 84 | |
| 85 | flag.ErrHelp = errors.New("regres is a tool to detect regressions between versions of SwiftShader") |
| 86 | flag.Parse() |
| 87 | |
| 88 | shell.MaxProcMemory = *maxProcMemory |
| 89 | |
| 90 | r := regres{ |
| 91 | deqpBuild: *deqpPath, |
| 92 | cacheRoot: *cacheDir, |
| 93 | gerritEmail: os.ExpandEnv(*gerritEmail), |
| 94 | gerritUser: os.ExpandEnv(*gerritUser), |
| 95 | gerritPass: os.ExpandEnv(*gerritPass), |
| 96 | keepCheckouts: *keepCheckouts, |
| 97 | dryRun: *dryRun, |
| 98 | } |
| 99 | |
| 100 | if err := r.run(); err != nil { |
| 101 | fmt.Fprintln(os.Stderr, err) |
| 102 | os.Exit(-1) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | type regres struct { |
| 107 | deqpBuild string // path to the build directory of deqp |
| 108 | cmake string // path to cmake |
| 109 | make string // path to make |
| 110 | cacheRoot string // path to the regres cache directory |
| 111 | gerritEmail string // gerrit email address used for posting results |
| 112 | gerritUser string // gerrit username used for posting results |
| 113 | gerritPass string // gerrit password used for posting results |
| 114 | keepCheckouts bool // don't delete source & build checkouts after testing |
| 115 | dryRun bool // don't post any reviews |
| 116 | maxProcMemory uint64 // max virtual memory for child processes |
| 117 | } |
| 118 | |
| 119 | // resolveDirs ensures that the necessary directories used can be found, and |
| 120 | // expands them to absolute paths. |
| 121 | func (r *regres) resolveDirs() error { |
| 122 | for _, path := range []*string{ |
| 123 | &r.deqpBuild, |
| 124 | &r.cacheRoot, |
| 125 | } { |
| 126 | abs, err := filepath.Abs(*path) |
| 127 | if err != nil { |
| 128 | return cause.Wrap(err, "Couldn't find path '%v'", *path) |
| 129 | } |
| 130 | if _, err := os.Stat(abs); err != nil { |
| 131 | return cause.Wrap(err, "Couldn't find path '%v'", abs) |
| 132 | } |
| 133 | *path = abs |
| 134 | } |
| 135 | return nil |
| 136 | } |
| 137 | |
| 138 | // resolveExes resolves all external executables used by regres. |
| 139 | func (r *regres) resolveExes() error { |
| 140 | type exe struct { |
| 141 | name string |
| 142 | path *string |
| 143 | } |
| 144 | for _, e := range []exe{ |
| 145 | {"cmake", &r.cmake}, |
| 146 | {"make", &r.make}, |
| 147 | } { |
| 148 | path, err := exec.LookPath(e.name) |
| 149 | if err != nil { |
| 150 | return cause.Wrap(err, "Couldn't find path to %s", e.name) |
| 151 | } |
| 152 | *e.path = path |
| 153 | } |
| 154 | return nil |
| 155 | } |
| 156 | |
| 157 | // run performs the main processing loop for the regress tool. It: |
| 158 | // * Scans for open and recently updated changes in gerrit using queryChanges() |
| 159 | // and changeInfo.update(). |
| 160 | // * Builds the most recent patchset and the commit's parent CL using |
| 161 | // r.newTest(<hash>).lazyRun(). |
| 162 | // * Compares the results of the tests using compare(). |
| 163 | // * Posts the results of the compare to gerrit as a review. |
| 164 | // * Repeats the above steps until the process is interrupted. |
| 165 | func (r *regres) run() error { |
| 166 | if err := r.resolveExes(); err != nil { |
| 167 | return cause.Wrap(err, "Couldn't resolve all exes") |
| 168 | } |
| 169 | |
| 170 | if err := r.resolveDirs(); err != nil { |
| 171 | return cause.Wrap(err, "Couldn't resolve all directories") |
| 172 | } |
| 173 | |
| 174 | client, err := gerrit.NewClient(gerritURL, nil) |
| 175 | if err != nil { |
| 176 | return cause.Wrap(err, "Couldn't create gerrit client") |
| 177 | } |
| 178 | if r.gerritUser != "" { |
| 179 | client.Authentication.SetBasicAuth(r.gerritUser, r.gerritPass) |
| 180 | } |
| 181 | |
| 182 | changes := map[string]*changeInfo{} // Change ID -> changeInfo |
| 183 | lastUpdatedTestLists := toDate(time.Now()) |
| 184 | lastQueriedChanges := time.Time{} |
| 185 | |
| 186 | for { |
| 187 | if now := time.Now(); toDate(now) != lastUpdatedTestLists && now.Hour() >= dailyUpdateTestListHour { |
| 188 | lastUpdatedTestLists = toDate(now) |
| 189 | if err := r.updateTestLists(client); err != nil { |
| 190 | log.Println(err.Error()) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // Update list of tracked changes. |
| 195 | if time.Since(lastQueriedChanges) > changeQueryFrequency { |
| 196 | lastQueriedChanges = time.Now() |
| 197 | if err := queryChanges(client, changes); err != nil { |
| 198 | log.Println(err.Error()) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // Update change info. |
| 203 | for _, change := range changes { |
| 204 | if time.Since(change.lastUpdated) > changeUpdateFrequency { |
| 205 | change.lastUpdated = time.Now() |
| 206 | err := change.update(client) |
| 207 | if err != nil { |
| 208 | log.Println(cause.Wrap(err, "Couldn't update info for change '%s'", change.id)) |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // Find the change with the highest priority. |
| 214 | var change *changeInfo |
| 215 | numPending := 0 |
| 216 | for _, c := range changes { |
| 217 | if c.pending { |
| 218 | numPending++ |
| 219 | if change == nil || c.priority > change.priority { |
| 220 | change = c |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | if change == nil { |
| 226 | // Everything up to date. Take a break. |
| 227 | log.Println("Nothing to do. Sleeping") |
| 228 | time.Sleep(time.Minute) |
| 229 | continue |
| 230 | } |
| 231 | |
| 232 | log.Printf("%d changes queued for testing\n", numPending) |
| 233 | |
| 234 | log.Printf("Testing change '%s'\n", change.id) |
| 235 | |
| 236 | // Get the test results for the latest patchset in the change. |
| 237 | latest, err := r.newTest(ciTestList, change.latest).lazyRun() |
| 238 | if err != nil { |
| 239 | log.Println(cause.Wrap(err, "Failed to test changelist '%s'", change.latest)) |
| 240 | time.Sleep(time.Minute) |
| 241 | continue |
| 242 | } |
| 243 | |
| 244 | // Get the test results for the changes's parent changelist. |
| 245 | parent, err := r.newTest(ciTestList, change.parent).lazyRun() |
| 246 | if err != nil { |
| 247 | log.Println(cause.Wrap(err, "Failed to test changelist '%s'", change.parent)) |
| 248 | time.Sleep(time.Minute) |
| 249 | continue |
| 250 | } |
| 251 | |
| 252 | // Compare the latest patchset to the change's parent commit. |
| 253 | msg := compare(parent, latest) |
| 254 | |
| 255 | // Always include the reportHeader in the message. |
| 256 | // changeInfo.update() uses this header to detect whether a patchset has |
| 257 | // already got a test result. |
| 258 | msg = reportHeader + "\n\n" + msg |
| 259 | |
| 260 | if r.dryRun { |
| 261 | log.Printf("DRY RUN: add review to change '%v':\n%v\n", change.id, msg) |
| 262 | } else { |
| 263 | log.Printf("Posting review to '%s'\n", change.id) |
| 264 | _, _, err = client.Changes.SetReview(change.id, change.latest.String(), &gerrit.ReviewInput{ |
| 265 | Message: msg, |
| 266 | Tag: "autogenerated:regress", |
| 267 | }) |
| 268 | if err != nil { |
| 269 | return cause.Wrap(err, "Failed to post comments on change '%s'", change.id) |
| 270 | } |
| 271 | } |
| 272 | change.pending = false |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | func (r *regres) updateTestLists(client *gerrit.Client) error { |
| 277 | log.Println("Updating test lists") |
| 278 | |
| 279 | headHash, err := git.FetchRefHash("HEAD", gitURL) |
| 280 | if err != nil { |
| 281 | return cause.Wrap(err, "Could not get hash of master HEAD") |
| 282 | } |
| 283 | |
| 284 | // Get the full test results for latest master. |
| 285 | t := r.newTest(fullTestList, headHash) |
| 286 | |
| 287 | // Keep the checked out directory after the test is run. We want this so |
| 288 | // we can build a new patchset containing the updated test lists. |
| 289 | t.keepCheckouts = true |
| 290 | if !r.keepCheckouts { |
| 291 | defer os.RemoveAll(t.srcDir) |
| 292 | } |
| 293 | |
| 294 | if _, err := t.run(); err != nil { |
| 295 | return cause.Wrap(err, "Failed to test changelist '%s'", headHash) |
| 296 | } |
| 297 | |
| 298 | // Stage all the updated test files. |
| 299 | for _, path := range t.writtenTestLists { |
| 300 | log.Println("Staging", path) |
| 301 | git.Add(t.srcDir, path) |
| 302 | } |
| 303 | |
| 304 | log.Println("Checking for existing test list") |
| 305 | results, _, err := client.Changes.QueryChanges(&gerrit.QueryChangeOptions{ |
| 306 | QueryOptions: gerrit.QueryOptions{ |
| 307 | Query: []string{fmt.Sprintf(`status:open+owner:"%v"`, r.gerritEmail)}, |
| 308 | Limit: 1, |
| 309 | }, |
| 310 | }) |
| 311 | if err != nil { |
| 312 | return cause.Wrap(err, "Failed to checking for existing test list") |
| 313 | } |
| 314 | |
| 315 | commitMsg := strings.Builder{} |
| 316 | commitMsg.WriteString("Regres: Update test lists @ " + headHash.String()[:8]) |
| 317 | if results != nil && len(*results) > 0 { |
| 318 | // Reuse gerrit change ID if there's already a change up for review. |
| 319 | id := (*results)[0].ChangeID |
| 320 | commitMsg.WriteString("\n\n") |
| 321 | commitMsg.WriteString("Change-Id: " + id) |
| 322 | } |
| 323 | |
| 324 | if err := git.Commit(t.srcDir, commitMsg.String(), git.CommitFlags{ |
| 325 | Name: "SwiftShader Regression Bot", |
| 326 | Email: r.gerritEmail, |
| 327 | }); err != nil { |
| 328 | return cause.Wrap(err, "Failed to commit test results") |
| 329 | } |
| 330 | |
| 331 | if r.dryRun { |
| 332 | log.Printf("DRY RUN: post results for review") |
| 333 | } else { |
| 334 | log.Println("Pushing test results for review") |
| 335 | if err := git.Push(t.srcDir, gitURL, "HEAD", "refs/for/master", git.PushFlags{ |
| 336 | Username: r.gerritUser, |
| 337 | Password: r.gerritPass, |
| 338 | }); err != nil { |
| 339 | return cause.Wrap(err, "Failed to push test results for review") |
| 340 | } |
| 341 | log.Println("Test results posted for review") |
| 342 | } |
| 343 | |
| 344 | return nil |
| 345 | } |
| 346 | |
| 347 | // changeInfo holds the important information about a single, open change in |
| 348 | // gerrit. |
| 349 | type changeInfo struct { |
| 350 | id string // Gerrit change ID. |
| 351 | pending bool // Is this change waiting a test for the latest patchset? |
| 352 | priority int // Calculated priority based on Gerrit labels. |
| 353 | latest git.Hash // Git hash of the latest patchset in the change. |
| 354 | parent git.Hash // Git hash of the changelist this change is based on. |
| 355 | lastUpdated time.Time // Time the change was last fetched. |
| 356 | } |
| 357 | |
| 358 | // queryChanges updates the changes map by querying gerrit for the latest open |
| 359 | // changes. |
| 360 | func queryChanges(client *gerrit.Client, changes map[string]*changeInfo) error { |
| 361 | log.Println("Checking for latest changes") |
| 362 | results, _, err := client.Changes.QueryChanges(&gerrit.QueryChangeOptions{ |
| 363 | QueryOptions: gerrit.QueryOptions{ |
| 364 | Query: []string{"status:open+-age:3d"}, |
| 365 | Limit: 100, |
| 366 | }, |
| 367 | }) |
| 368 | if err != nil { |
| 369 | return cause.Wrap(err, "Failed to get list of changes") |
| 370 | } |
| 371 | |
| 372 | ids := map[string]bool{} |
| 373 | for _, r := range *results { |
| 374 | ids[r.ChangeID] = true |
| 375 | } |
| 376 | |
| 377 | // Add new changes |
| 378 | for id := range ids { |
| 379 | if _, found := changes[id]; !found { |
| 380 | log.Printf("Tracking new change '%v'\n", id) |
| 381 | changes[id] = &changeInfo{id: id} |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | // Remove old changes |
| 386 | for id := range changes { |
| 387 | if found := ids[id]; !found { |
| 388 | log.Printf("Untracking change '%v'\n", id) |
| 389 | delete(changes, id) |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | return nil |
| 394 | } |
| 395 | |
| 396 | // update queries gerrit for information about the given change. |
| 397 | func (c *changeInfo) update(client *gerrit.Client) error { |
| 398 | change, _, err := client.Changes.GetChange(c.id, &gerrit.ChangeOptions{ |
| 399 | AdditionalFields: []string{"CURRENT_REVISION", "CURRENT_COMMIT", "MESSAGES", "LABELS"}, |
| 400 | }) |
| 401 | if err != nil { |
| 402 | return cause.Wrap(err, "Getting info for change '%s'", c.id) |
| 403 | } |
| 404 | |
| 405 | current, ok := change.Revisions[change.CurrentRevision] |
| 406 | if !ok { |
| 407 | return fmt.Errorf("Couldn't find current revision for change '%s'", c.id) |
| 408 | } |
| 409 | |
| 410 | if len(current.Commit.Parents) == 0 { |
| 411 | return fmt.Errorf("Couldn't find current commit for change '%s' has no parents(?)", c.id) |
| 412 | } |
| 413 | |
| 414 | kokoroPresubmit := change.Labels["Kokoro-Presubmit"].Approved.AccountID != 0 |
| 415 | codeReviewScore := change.Labels["Code-Review"].Value |
| 416 | presubmitReady := change.Labels["Presubmit-Ready"].Approved.AccountID != 0 |
| 417 | |
| 418 | c.priority = 0 |
| 419 | if presubmitReady { |
| 420 | c.priority += 10 |
| 421 | } |
| 422 | c.priority += codeReviewScore |
| 423 | if kokoroPresubmit { |
| 424 | c.priority++ |
| 425 | } |
| 426 | |
| 427 | // Is the change from a Googler? |
| 428 | canTest := strings.HasSuffix(current.Commit.Committer.Email, "@google.com") |
| 429 | |
| 430 | // Has the latest patchset already been tested? |
| 431 | if canTest { |
| 432 | for _, msg := range change.Messages { |
| 433 | if msg.RevisionNumber == current.Number && |
| 434 | strings.Contains(msg.Message, reportHeader) { |
| 435 | canTest = false |
| 436 | break |
| 437 | } |
| 438 | } |
| 439 | } |
| 440 | |
| 441 | c.pending = canTest |
| 442 | c.latest = git.ParseHash(change.CurrentRevision) |
| 443 | c.parent = git.ParseHash(current.Commit.Parents[0].Commit) |
| 444 | |
| 445 | return nil |
| 446 | } |
| 447 | |
| 448 | func (r *regres) newTest(testListPath string, commit git.Hash) *test { |
| 449 | srcDir := filepath.Join(r.cacheRoot, "src", commit.String()) |
| 450 | resDir := filepath.Join(r.cacheRoot, "res", commit.String()) |
| 451 | return &test{ |
| 452 | r: r, |
| 453 | commit: commit, |
| 454 | srcDir: srcDir, |
| 455 | resDir: resDir, |
| 456 | outDir: filepath.Join(srcDir, "out"), |
| 457 | buildDir: filepath.Join(srcDir, "build"), |
| 458 | testListPath: testListPath, |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | type test struct { |
| 463 | r *regres |
| 464 | commit git.Hash // hash of the commit to test |
| 465 | srcDir string // directory for the SwiftShader checkout |
| 466 | resDir string // directory for the test results |
| 467 | outDir string // directory for SwiftShader output |
| 468 | buildDir string // directory for SwiftShader build |
| 469 | keepCheckouts bool // don't delete source & build checkouts after testing |
| 470 | testListPath string // relative path to the test list .json file |
| 471 | |
| 472 | writtenTestLists []string // paths to test updated lists that have been written |
| 473 | } |
| 474 | |
| 475 | // lazyRun lazily runs the test t. |
| 476 | // If the test results are not already cached, then test will setup the test |
| 477 | // environment, and call t.run(). |
| 478 | // The results of the test will be cached into r.cacheRoot. |
| 479 | func (t *test) lazyRun() (*CommitTestResults, error) { |
| 480 | load := func(data []byte) (interface{}, error) { |
| 481 | var res CommitTestResults |
| 482 | if err := json.NewDecoder(bytes.NewReader(data)).Decode(&res); err != nil { |
| 483 | return nil, err |
| 484 | } |
| 485 | if res.Version != dataVersion { |
| 486 | return nil, errors.New("Data is from an old version") |
| 487 | } |
| 488 | return &res, nil |
| 489 | } |
| 490 | |
| 491 | build := func() ([]byte, interface{}, error) { |
| 492 | res, err := t.run() |
| 493 | |
| 494 | if err != nil { |
| 495 | return nil, nil, err |
| 496 | } |
| 497 | |
| 498 | b := bytes.Buffer{} |
| 499 | enc := json.NewEncoder(&b) |
| 500 | enc.SetIndent("", " ") |
| 501 | if err := enc.Encode(res); err != nil { |
| 502 | return nil, nil, err |
| 503 | } |
| 504 | |
| 505 | return b.Bytes(), res, nil |
| 506 | } |
| 507 | |
| 508 | res, err := loadOrBuild(filepath.Join(t.resDir, "results.json"), load, build) |
| 509 | if err != nil { |
| 510 | return nil, err |
| 511 | } |
| 512 | |
| 513 | return res.(*CommitTestResults), nil |
| 514 | } |
| 515 | |
| 516 | // run executes the tests for the test environment t. |
| 517 | // If the source is not cached, run will fetch the commit to be tested, |
| 518 | // before building it, and then run the required tests. |
| 519 | func (t *test) run() (*CommitTestResults, error) { |
| 520 | if isDir(t.srcDir) && t.keepCheckouts { |
| 521 | log.Printf("Reusing source cache for commit '%s'\n", t.commit) |
| 522 | } else { |
| 523 | log.Printf("Checking out '%s'\n", t.commit) |
| 524 | os.RemoveAll(t.srcDir) |
| 525 | if err := git.Checkout(t.srcDir, gitURL, t.commit); err != nil { |
| 526 | return nil, cause.Wrap(err, "Checking out commit '%s'", t.commit) |
| 527 | } |
| 528 | log.Printf("Checked out commit '%s'\n", t.commit) |
| 529 | if !t.keepCheckouts { |
| 530 | defer os.RemoveAll(t.srcDir) |
| 531 | } |
| 532 | } |
| 533 | |
| 534 | if err := t.build(); err != nil { |
| 535 | log.Printf("Warning: Commit '%s' failed to build. %v", t.commit, err) |
| 536 | return &CommitTestResults{Version: dataVersion, Built: false}, nil |
| 537 | } |
| 538 | log.Printf("Built '%s'\n", t.commit) |
| 539 | |
| 540 | // Load the list of tests that need executing. |
| 541 | // Note: this list may vary by each commit. |
| 542 | testLists, err := t.loadTestList() |
| 543 | if err != nil { |
| 544 | return nil, cause.Wrap(err, "Loading test lists") |
| 545 | } |
| 546 | |
| 547 | results, err := t.runTests(testLists) |
| 548 | if err != nil { |
| 549 | return nil, cause.Wrap(err, "Running tests") |
| 550 | } |
| 551 | log.Printf("Ran tests for '%s'\n", t.commit) |
| 552 | |
| 553 | if t.keepCheckouts { |
| 554 | if err := t.writeTestListsByStatus(testLists, results); err != nil { |
| 555 | return nil, cause.Wrap(err, "Writing test lists by status") |
| 556 | } |
| 557 | } |
| 558 | |
| 559 | return results, nil |
| 560 | } |
| 561 | |
| 562 | // loadOrBuild is a helper for building a lazy resolved cache. |
| 563 | // loadOrBuild attempts to load the file at path. If the file exists and loaded |
| 564 | // successfully, then load() is called with the file data, and the the result |
| 565 | // object from load() is returned. |
| 566 | // If the file does not exist, the file cannot be loaded, or load() returns an |
| 567 | // error, then build() is called and the byte slice is saved to path, and the |
| 568 | // object is returned. |
| 569 | func loadOrBuild(path string, |
| 570 | load func([]byte) (interface{}, error), |
| 571 | build func() ([]byte, interface{}, error)) (interface{}, error) { |
| 572 | |
| 573 | if data, err := ioutil.ReadFile(path); err == nil { |
| 574 | out, err := load(data) |
| 575 | if err == nil { |
| 576 | return out, nil |
| 577 | } |
| 578 | log.Printf("Warning: Failed to load '%s': %v", path, err) |
| 579 | os.Remove(path) // Delete and rebuild. |
| 580 | } |
| 581 | |
| 582 | data, obj, err := build() |
| 583 | if err != nil { |
| 584 | return nil, err |
| 585 | } |
| 586 | |
| 587 | os.MkdirAll(filepath.Dir(path), 0777) |
| 588 | |
| 589 | if err := ioutil.WriteFile(path, data, 0777); err != nil { |
| 590 | log.Printf("Warning: Failed to write to '%s': %v", path, err) |
| 591 | } |
| 592 | return obj, nil |
| 593 | } |
| 594 | |
| 595 | // build builds the SwiftShader source into t.buildDir. |
| 596 | func (t *test) build() error { |
| 597 | log.Printf("Building '%s'\n", t.commit) |
| 598 | |
| 599 | if err := os.MkdirAll(t.buildDir, 0777); err != nil { |
| 600 | return cause.Wrap(err, "Failed to create build directory") |
| 601 | } |
| 602 | |
| 603 | if err := shell.Shell(buildTimeout, t.r.cmake, t.buildDir, |
| 604 | "-DCMAKE_BUILD_TYPE=Release", |
| 605 | "-DDCHECK_ALWAYS_ON=1", |
| 606 | ".."); err != nil { |
| 607 | return err |
| 608 | } |
| 609 | |
| 610 | if err := shell.Shell(buildTimeout, t.r.make, t.buildDir, fmt.Sprintf("-j%d", runtime.NumCPU())); err != nil { |
| 611 | return err |
| 612 | } |
| 613 | |
| 614 | return nil |
| 615 | } |
| 616 | |
| 617 | // runTests runs all the tests. |
| 618 | func (t *test) runTests(testLists []TestList) (*CommitTestResults, error) { |
| 619 | log.Printf("Running tests for '%s'\n", t.commit) |
| 620 | start := time.Now() |
| 621 | |
| 622 | // Wait group that completes once all the tests have finished. |
| 623 | wg := sync.WaitGroup{} |
| 624 | results := make(chan TestResult, 256) |
| 625 | |
| 626 | numTests := 0 |
| 627 | |
| 628 | // For each API that we are testing |
| 629 | for _, list := range testLists { |
| 630 | // Resolve the test runner |
| 631 | var exe string |
| 632 | switch list.API { |
| 633 | case egl: |
| 634 | exe = filepath.Join(t.r.deqpBuild, "modules", "egl", "deqp-egl") |
| 635 | case gles2: |
| 636 | exe = filepath.Join(t.r.deqpBuild, "modules", "gles2", "deqp-gles2") |
| 637 | case gles3: |
| 638 | exe = filepath.Join(t.r.deqpBuild, "modules", "gles3", "deqp-gles3") |
| 639 | case vulkan: |
| 640 | exe = filepath.Join(t.r.deqpBuild, "external", "vulkancts", "modules", "vulkan", "deqp-vk") |
| 641 | default: |
| 642 | return nil, fmt.Errorf("Unknown API '%v'", list.API) |
| 643 | } |
| 644 | if !isFile(exe) { |
| 645 | return nil, fmt.Errorf("Couldn't find dEQP executable at '%s'", exe) |
| 646 | } |
| 647 | |
| 648 | // Build a chan for the test names to be run. |
| 649 | tests := make(chan string, len(list.Tests)) |
| 650 | |
| 651 | // Start a number of go routines to run the tests. |
| 652 | wg.Add(numParallelTests) |
| 653 | for i := 0; i < numParallelTests; i++ { |
| 654 | go func() { |
| 655 | t.deqpTestRoutine(exe, tests, results) |
| 656 | wg.Done() |
| 657 | }() |
| 658 | } |
| 659 | |
| 660 | // Hand the tests to the deqpTestRoutines. |
| 661 | for _, t := range list.Tests { |
| 662 | tests <- t |
| 663 | } |
| 664 | |
| 665 | // Close the tests chan to indicate that there are no more tests to run. |
| 666 | // The deqpTestRoutine functions will return once all tests have been |
| 667 | // run. |
| 668 | close(tests) |
| 669 | |
| 670 | numTests += len(list.Tests) |
| 671 | } |
| 672 | |
| 673 | out := CommitTestResults{ |
| 674 | Version: dataVersion, |
| 675 | Built: true, |
| 676 | Tests: map[string]TestResult{}, |
| 677 | } |
| 678 | |
| 679 | // Collect the results. |
| 680 | finished := make(chan struct{}) |
| 681 | lastUpdate := time.Now() |
| 682 | go func() { |
| 683 | start, i := time.Now(), 0 |
| 684 | for r := range results { |
| 685 | i++ |
| 686 | out.Tests[r.Test] = r |
| 687 | if time.Since(lastUpdate) > time.Minute { |
| 688 | lastUpdate = time.Now() |
| 689 | remaining := numTests - i |
| 690 | log.Printf("Ran %d/%d tests (%v%%). Estimated completion in %v.\n", |
| 691 | i, numTests, percent(i, numTests), |
| 692 | (time.Since(start)/time.Duration(i))*time.Duration(remaining)) |
| 693 | } |
| 694 | } |
| 695 | close(finished) |
| 696 | }() |
| 697 | |
| 698 | wg.Wait() // Block until all the deqpTestRoutines have finished. |
| 699 | close(results) // Signal no more results. |
| 700 | <-finished // And wait for the result collecting go-routine to finish. |
| 701 | |
| 702 | out.Duration = time.Since(start) |
| 703 | |
| 704 | return &out, nil |
| 705 | } |
| 706 | |
| 707 | func (t *test) writeTestListsByStatus(testLists []TestList, results *CommitTestResults) error { |
| 708 | for _, list := range testLists { |
| 709 | files := map[Status]*os.File{} |
| 710 | ext := filepath.Ext(list.File) |
| 711 | name := list.File[:len(list.File)-len(ext)] |
| 712 | for _, status := range Statuses { |
| 713 | path := filepath.Join(t.srcDir, name+"-"+string(status)+ext) |
| 714 | dir := filepath.Dir(path) |
| 715 | os.MkdirAll(dir, 0777) |
| 716 | f, err := os.Create(path) |
| 717 | if err != nil { |
| 718 | return cause.Wrap(err, "Couldn't create file '%v'", path) |
| 719 | } |
| 720 | defer f.Close() |
| 721 | files[status] = f |
| 722 | |
| 723 | t.writtenTestLists = append(t.writtenTestLists, path) |
| 724 | } |
| 725 | |
| 726 | for _, testName := range list.Tests { |
| 727 | if r, found := results.Tests[testName]; found { |
| 728 | fmt.Fprintln(files[r.Status], testName) |
| 729 | } |
| 730 | } |
| 731 | } |
| 732 | |
| 733 | return nil |
| 734 | } |
| 735 | |
| 736 | // Status is an enumerator of test results. |
| 737 | type Status string |
| 738 | |
| 739 | const ( |
| 740 | // Pass is the status of a successful test. |
| 741 | Pass = Status("PASS") |
| 742 | // Fail is the status of a failed test. |
| 743 | Fail = Status("FAIL") |
| 744 | // Timeout is the status of a test that failed to complete in the alloted |
| 745 | // time. |
| 746 | Timeout = Status("TIMEOUT") |
| 747 | // Crash is the status of a test that crashed. |
| 748 | Crash = Status("CRASH") |
| 749 | // NotSupported is the status of a test feature not supported by the driver. |
| 750 | NotSupported = Status("NOT_SUPPORTED") |
| 751 | // CompatibilityWarning is the status passing test with a warning. |
| 752 | CompatibilityWarning = Status("COMPATIBILITY_WARNING") |
| 753 | // QualityWarning is the status passing test with a warning. |
| 754 | QualityWarning = Status("QUALITY_WARNING") |
| 755 | ) |
| 756 | |
| 757 | // Statuses is the full list of status types |
| 758 | var Statuses = []Status{Pass, Fail, Timeout, Crash, NotSupported, CompatibilityWarning, QualityWarning} |
| 759 | |
| 760 | // Failing returns true if the task status requires fixing. |
| 761 | func (s Status) Failing() bool { |
| 762 | switch s { |
| 763 | case Fail, Timeout, Crash: |
| 764 | return true |
| 765 | default: |
| 766 | return false |
| 767 | } |
| 768 | } |
| 769 | |
| 770 | // CommitTestResults holds the results the tests across all APIs for a given |
| 771 | // commit. The CommitTestResults structure may be serialized to cache the |
| 772 | // results. |
| 773 | type CommitTestResults struct { |
| 774 | Version int |
| 775 | Built bool |
| 776 | Tests map[string]TestResult |
| 777 | Duration time.Duration |
| 778 | } |
| 779 | |
| 780 | // compare returns a string describing all differences between two |
| 781 | // CommitTestResults. This string is used as the report message posted to the |
| 782 | // gerrit code review. |
| 783 | func compare(old, new *CommitTestResults) string { |
| 784 | switch { |
| 785 | case !old.Built && !new.Built: |
| 786 | return "Build continues to be broken." |
| 787 | case old.Built && !new.Built: |
| 788 | return "Build broken." |
| 789 | case !old.Built && !new.Built: |
| 790 | return "Build now fixed. Cannot compare against broken parent." |
| 791 | } |
| 792 | |
| 793 | oldStatusCounts, newStatusCounts := map[Status]int{}, map[Status]int{} |
| 794 | totalTests := 0 |
| 795 | |
| 796 | broken, fixed, failing, removed, changed := []string{}, []string{}, []string{}, []string{}, []string{} |
| 797 | |
| 798 | for test, new := range new.Tests { |
| 799 | old, found := old.Tests[test] |
| 800 | switch { |
| 801 | case (!found || old.Status == Pass) && new.Status.Failing(): |
| 802 | broken = append(broken, test) |
| 803 | case (found && old.Status.Failing()) && new.Status == Pass: |
| 804 | fixed = append(fixed, test) |
| 805 | case found && old.Status != new.Status: |
| 806 | changed = append(changed, test) |
| 807 | case found && old.Status.Failing() && new.Status.Failing(): |
| 808 | failing = append(failing, test) // Still broken |
| 809 | } |
| 810 | totalTests++ |
| 811 | if found { |
| 812 | oldStatusCounts[old.Status] = oldStatusCounts[old.Status] + 1 |
| 813 | } |
| 814 | newStatusCounts[new.Status] = newStatusCounts[new.Status] + 1 |
| 815 | } |
| 816 | |
| 817 | for test := range old.Tests { |
| 818 | if _, found := new.Tests[test]; !found { |
| 819 | removed = append(removed, test) |
| 820 | } |
| 821 | } |
| 822 | |
| 823 | sb := strings.Builder{} |
| 824 | |
| 825 | // list prints the list l to sb, truncating after a limit. |
| 826 | list := func(l []string) { |
| 827 | const max = 10 |
| 828 | for i, s := range l { |
| 829 | sb.WriteString(" ") |
| 830 | if i == max { |
| 831 | sb.WriteString(fmt.Sprintf("> %d more\n", len(l)-i)) |
| 832 | break |
| 833 | } |
| 834 | sb.WriteString(fmt.Sprintf("> %s", s)) |
| 835 | if n, ok := new.Tests[s]; ok { |
| 836 | if o, ok := old.Tests[s]; ok && n != o { |
| 837 | sb.WriteString(fmt.Sprintf(" - [%s -> %s]", o.Status, n.Status)) |
| 838 | } else { |
| 839 | sb.WriteString(fmt.Sprintf(" - [%s]", n.Status)) |
| 840 | } |
| 841 | } |
| 842 | sb.WriteString("\n") |
| 843 | } |
| 844 | } |
| 845 | |
| 846 | sb.WriteString(fmt.Sprintf(" Total tests: %d\n", totalTests)) |
| 847 | for _, s := range []struct { |
| 848 | label string |
| 849 | status Status |
| 850 | }{ |
| 851 | {" Pass", Pass}, |
| 852 | {" Fail", Fail}, |
| 853 | {" Timeout", Timeout}, |
| 854 | {" Crash", Crash}, |
| 855 | {" Not Supported", NotSupported}, |
| 856 | {"Compatibility Warning", CompatibilityWarning}, |
| 857 | {" Quality Warning", QualityWarning}, |
| 858 | } { |
| 859 | old, new := oldStatusCounts[s.status], newStatusCounts[s.status] |
| 860 | if old == 0 && new == 0 { |
| 861 | continue |
| 862 | } |
| 863 | change := percent64(int64(new-old), int64(old)) |
| 864 | switch { |
| 865 | case old == new: |
| 866 | sb.WriteString(fmt.Sprintf("%s: %v\n", s.label, new)) |
| 867 | case change == 0: |
| 868 | sb.WriteString(fmt.Sprintf("%s: %v -> %v\n", s.label, old, new)) |
| 869 | default: |
| 870 | sb.WriteString(fmt.Sprintf("%s: %v -> %v (%+d%%)\n", s.label, old, new, change)) |
| 871 | } |
| 872 | } |
| 873 | |
| 874 | if old, new := old.Duration, new.Duration; old != 0 && new != 0 { |
| 875 | label := " Time taken" |
| 876 | change := percent64(int64(new-old), int64(old)) |
| 877 | switch { |
| 878 | case old == new: |
| 879 | sb.WriteString(fmt.Sprintf("%s: %v\n", label, new)) |
| 880 | case change == 0: |
| 881 | sb.WriteString(fmt.Sprintf("%s: %v -> %v\n", label, old, new)) |
| 882 | default: |
| 883 | sb.WriteString(fmt.Sprintf("%s: %v -> %v (%+d%%)\n", label, old, new, change)) |
| 884 | } |
| 885 | } |
| 886 | |
| 887 | if n := len(broken); n > 0 { |
| 888 | sort.Strings(broken) |
| 889 | sb.WriteString(fmt.Sprintf("\n--- This change breaks %d tests: ---\n", n)) |
| 890 | list(broken) |
| 891 | } |
| 892 | if n := len(fixed); n > 0 { |
| 893 | sort.Strings(fixed) |
| 894 | sb.WriteString(fmt.Sprintf("\n--- This change fixes %d tests: ---\n", n)) |
| 895 | list(fixed) |
| 896 | } |
| 897 | if n := len(removed); n > 0 { |
| 898 | sort.Strings(removed) |
| 899 | sb.WriteString(fmt.Sprintf("\n--- This change removes %d tests: ---\n", n)) |
| 900 | list(removed) |
| 901 | } |
| 902 | if n := len(changed); n > 0 { |
| 903 | sort.Strings(changed) |
| 904 | sb.WriteString(fmt.Sprintf("\n--- This change alters %d tests: ---\n", n)) |
| 905 | list(changed) |
| 906 | } |
| 907 | |
| 908 | if len(broken) == 0 && len(fixed) == 0 && len(removed) == 0 && len(changed) == 0 { |
| 909 | sb.WriteString(fmt.Sprintf("\n--- No change in test results ---\n")) |
| 910 | } |
| 911 | |
| 912 | return sb.String() |
| 913 | } |
| 914 | |
| 915 | // TestResult holds the results of a single API test. |
| 916 | type TestResult struct { |
| 917 | Test string |
| 918 | Status Status |
| 919 | Err string `json:",omitempty"` |
| 920 | } |
| 921 | |
| 922 | func (r TestResult) String() string { |
| 923 | if r.Err != "" { |
| 924 | return fmt.Sprintf("%s: %s (%s)", r.Test, r.Status, r.Err) |
| 925 | } |
| 926 | return fmt.Sprintf("%s: %s", r.Test, r.Status) |
| 927 | } |
| 928 | |
| 929 | // Regular expression to parse the output of a dEQP test. |
| 930 | var parseRE = regexp.MustCompile(`(Fail|Pass|NotSupported|CompatibilityWarning|QualityWarning) \(([\s\S]*)\)`) |
| 931 | |
| 932 | // deqpTestRoutine repeatedly runs the dEQP test executable exe with the tests |
| 933 | // taken from tests. The output of the dEQP test is parsed, and the test result |
| 934 | // is written to results. |
| 935 | // deqpTestRoutine only returns once the tests chan has been closed. |
| 936 | // deqpTestRoutine does not close the results chan. |
| 937 | func (t *test) deqpTestRoutine(exe string, tests <-chan string, results chan<- TestResult) { |
| 938 | for name := range tests { |
| 939 | // log.Printf("Running test '%s'\n", name) |
| 940 | env := []string{ |
| 941 | "LD_LIBRARY_PATH=" + t.buildDir + ":" + os.Getenv("LD_LIBRARY_PATH"), |
| 942 | "VK_ICD_FILENAMES=" + filepath.Join(t.outDir, "Linux", "vk_swiftshader_icd.json"), |
| 943 | "DISPLAY=" + os.Getenv("DISPLAY"), |
| 944 | "LIBC_FATAL_STDERR_=1", // Put libc explosions into logs. |
| 945 | } |
| 946 | |
| 947 | out, err := shell.Exec(testTimeout, exe, filepath.Dir(exe), env, "--deqp-surface-type=pbuffer", "-n="+name) |
| 948 | switch err.(type) { |
| 949 | default: |
| 950 | results <- TestResult{ |
| 951 | Test: name, |
| 952 | Status: Crash, |
| 953 | Err: cause.Wrap(err, string(out)).Error(), |
| 954 | } |
| 955 | case shell.ErrTimeout: |
| 956 | results <- TestResult{ |
| 957 | Test: name, |
| 958 | Status: Timeout, |
| 959 | Err: cause.Wrap(err, string(out)).Error(), |
| 960 | } |
| 961 | case nil: |
| 962 | toks := parseRE.FindStringSubmatch(string(out)) |
| 963 | if len(toks) < 3 { |
| 964 | err := fmt.Sprintf("Couldn't parse test '%v' output:\n%s", name, string(out)) |
| 965 | log.Println("Warning: ", err) |
| 966 | results <- TestResult{Test: name, Status: Fail, Err: err} |
| 967 | continue |
| 968 | } |
| 969 | switch toks[1] { |
| 970 | case "Pass": |
| 971 | results <- TestResult{Test: name, Status: Pass} |
| 972 | case "NotSupported": |
| 973 | results <- TestResult{Test: name, Status: NotSupported} |
| 974 | case "CompatibilityWarning": |
| 975 | results <- TestResult{Test: name, Status: CompatibilityWarning} |
| 976 | case "QualityWarning": |
| 977 | results <- TestResult{Test: name, Status: CompatibilityWarning} |
| 978 | case "Fail": |
| 979 | var err string |
| 980 | if toks[2] != "Fail" { |
| 981 | err = toks[2] |
| 982 | } |
| 983 | results <- TestResult{Test: name, Status: Fail, Err: err} |
| 984 | default: |
| 985 | err := fmt.Sprintf("Couldn't parse test output:\n%s", string(out)) |
| 986 | log.Println("Warning: ", err) |
| 987 | results <- TestResult{Test: name, Status: Fail, Err: err} |
| 988 | } |
| 989 | } |
| 990 | } |
| 991 | } |
| 992 | |
| 993 | // API is an enumerator of graphics APIs. |
| 994 | type API string |
| 995 | |
| 996 | const ( |
| 997 | egl = API("egl") |
| 998 | gles2 = API("gles2") |
| 999 | gles3 = API("gles3") |
| 1000 | vulkan = API("vulkan") |
| 1001 | ) |
| 1002 | |
| 1003 | // TestList is a list of tests to be run for a given API. |
| 1004 | type TestList struct { |
| 1005 | Name string |
| 1006 | File string |
| 1007 | API API |
| 1008 | Tests []string |
| 1009 | } |
| 1010 | |
| 1011 | // loadTestList loads the test list json file. |
| 1012 | // The file is first searched at {Commit}/{t.testListPath} |
| 1013 | // If this cannot be found, then the file is searched at the fallback path |
| 1014 | // {CWD}/{t.testListPath} |
| 1015 | // This allows CLs to alter the list of tests to be run, as well as providing |
| 1016 | // a default set. |
| 1017 | func (t *test) loadTestList() ([]TestList, error) { |
| 1018 | // find the test.json file in {SwiftShader}/tests/regres |
| 1019 | root := t.srcDir |
| 1020 | if isFile(filepath.Join(root, t.testListPath)) { |
| 1021 | log.Println("Using test list from commit") |
| 1022 | } else { |
| 1023 | // Not found there. Search locally. |
| 1024 | root, _ = os.Getwd() |
| 1025 | if isFile(filepath.Join(root, t.testListPath)) { |
| 1026 | log.Println("Using test list from regres") |
| 1027 | } else { |
| 1028 | return nil, fmt.Errorf("Could not find test list file '%v'", t.testListPath) |
| 1029 | } |
| 1030 | } |
| 1031 | |
| 1032 | jsonPath := filepath.Join(root, t.testListPath) |
| 1033 | i, err := ioutil.ReadFile(jsonPath) |
| 1034 | if err != nil { |
| 1035 | return nil, cause.Wrap(err, "Couldn't read test list from '%s'", jsonPath) |
| 1036 | } |
| 1037 | |
| 1038 | var groups []struct { |
| 1039 | Name string |
| 1040 | API string |
| 1041 | TestFile string `json:"tests"` |
| 1042 | } |
| 1043 | if err := json.NewDecoder(bytes.NewReader(i)).Decode(&groups); err != nil { |
| 1044 | return nil, cause.Wrap(err, "Couldn't parse '%s'", jsonPath) |
| 1045 | } |
| 1046 | |
| 1047 | dir := filepath.Dir(jsonPath) |
| 1048 | |
| 1049 | out := make([]TestList, len(groups)) |
| 1050 | for i, group := range groups { |
| 1051 | path := filepath.Join(dir, group.TestFile) |
| 1052 | tests, err := ioutil.ReadFile(path) |
| 1053 | if err != nil { |
| 1054 | return nil, cause.Wrap(err, "Couldn't read '%s'", tests) |
| 1055 | } |
| 1056 | relPath, err := filepath.Rel(root, path) |
| 1057 | if err != nil { |
| 1058 | return nil, cause.Wrap(err, "Couldn't get relative path for '%s'", path) |
| 1059 | } |
| 1060 | list := TestList{ |
| 1061 | Name: group.Name, |
| 1062 | File: relPath, |
| 1063 | API: API(group.API), |
| 1064 | } |
| 1065 | for _, line := range strings.Split(string(tests), "\n") { |
| 1066 | line = strings.TrimSpace(line) |
| 1067 | if line != "" && !strings.HasPrefix(line, "#") { |
| 1068 | list.Tests = append(list.Tests, line) |
| 1069 | } |
| 1070 | } |
| 1071 | sort.Strings(list.Tests) |
| 1072 | out[i] = list |
| 1073 | } |
| 1074 | |
| 1075 | return out, nil |
| 1076 | } |
| 1077 | |
| 1078 | // isDir returns true if path is a file. |
| 1079 | func isFile(path string) bool { |
| 1080 | s, err := os.Stat(path) |
| 1081 | if err != nil { |
| 1082 | return false |
| 1083 | } |
| 1084 | return !s.IsDir() |
| 1085 | } |
| 1086 | |
| 1087 | // isDir returns true if path is a directory. |
| 1088 | func isDir(path string) bool { |
| 1089 | s, err := os.Stat(path) |
| 1090 | if err != nil { |
| 1091 | return false |
| 1092 | } |
| 1093 | return s.IsDir() |
| 1094 | } |
| 1095 | |
| 1096 | // percent returns the percentage completion of i items out of n. |
| 1097 | func percent(i, n int) int { |
| 1098 | return int(percent64(int64(i), int64(n))) |
| 1099 | } |
| 1100 | |
| 1101 | // percent64 returns the percentage completion of i items out of n. |
| 1102 | func percent64(i, n int64) int64 { |
| 1103 | if n == 0 { |
| 1104 | return 0 |
| 1105 | } |
| 1106 | return (100 * i) / n |
| 1107 | } |
| 1108 | |
| 1109 | type date struct { |
| 1110 | year int |
| 1111 | month time.Month |
| 1112 | day int |
| 1113 | } |
| 1114 | |
| 1115 | func toDate(t time.Time) date { |
| 1116 | d := date{} |
| 1117 | d.year, d.month, d.day = t.Date() |
| 1118 | return d |
| 1119 | } |