github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/ci/run_tests_test.go (about) 1 package ci_test 2 3 import ( 4 "context" 5 "os" 6 "strings" 7 "sync" 8 "testing" 9 "time" 10 11 "github.com/google/go-cmp/cmp" 12 "github.com/quickfeed/quickfeed/ci" 13 "github.com/quickfeed/quickfeed/internal/qlog" 14 "github.com/quickfeed/quickfeed/internal/qtest" 15 "github.com/quickfeed/quickfeed/internal/rand" 16 "github.com/quickfeed/quickfeed/kit/score" 17 "github.com/quickfeed/quickfeed/qf" 18 "github.com/quickfeed/quickfeed/scm" 19 "github.com/quickfeed/quickfeed/web/stream" 20 "google.golang.org/protobuf/testing/protocmp" 21 ) 22 23 // To run this test, please see instructions in the developer guide (dev.md). 24 25 // This test uses a test course for experimenting with run.sh behavior. 26 // The tests below will run locally on the test machine, not on the QuickFeed machine. 27 28 func loadDockerfile(t *testing.T) string { 29 t.Helper() 30 b, err := os.ReadFile("testdata/Dockerfile") 31 if err != nil { 32 t.Fatal(err) 33 } 34 return string(b) 35 } 36 37 func testRunData(t *testing.T, runner ci.Runner) *ci.RunData { 38 dockerfile := loadDockerfile(t) 39 qfTestOrg := scm.GetTestOrganization(t) 40 // Only used to fetch the user's GitHub login (user name) 41 _, userName := scm.GetTestSCM(t) 42 43 repo := qf.RepoURL{ProviderURL: "github.com", Organization: qfTestOrg} 44 course := &qf.Course{ 45 ID: 1, 46 Code: "QF101", 47 ScmOrganizationName: qfTestOrg, 48 } 49 course.UpdateDockerfile(dockerfile) 50 51 // Emulate running UpdateFromTestsRepo to ensure the docker image is built before running tests. 52 t.Logf("Building %s's Dockerfile:\n%v", course.GetCode(), course.GetDockerfile()) 53 out, err := runner.Run(context.Background(), &ci.Job{ 54 Name: course.JobName(), 55 Image: course.DockerImage(), 56 Dockerfile: course.GetDockerfile(), 57 Commands: []string{`echo -n "Hello from Dockerfile"`}, 58 }) 59 if err != nil { 60 t.Fatal(err) 61 } 62 t.Log(out) 63 64 return &ci.RunData{ 65 Course: course, 66 Assignment: &qf.Assignment{ 67 Name: "lab1", 68 ContainerTimeout: 1, // minutes 69 }, 70 Repo: &qf.Repository{ 71 HTMLURL: repo.StudentRepoURL(userName), 72 RepoType: qf.Repository_USER, 73 }, 74 JobOwner: "muggles", 75 CommitID: rand.String()[:7], 76 } 77 } 78 79 func TestRunTests(t *testing.T) { 80 runner, closeFn := dockerClient(t) 81 defer closeFn() 82 83 runData := testRunData(t, runner) 84 ctx, cancel := runData.Assignment.WithTimeout(2 * time.Minute) 85 defer cancel() 86 87 scmClient, _ := scm.GetTestSCM(t) 88 results, err := runData.RunTests(ctx, qtest.Logger(t), scmClient, runner) 89 if err != nil { 90 t.Fatal(err) 91 } 92 // We don't actually test anything here since we don't know how many assignments are in QF_TEST_ORG 93 t.Logf("%+v", results.BuildInfo.BuildLog) 94 results.BuildInfo.BuildLog = "removed" 95 t.Logf("%+v\n", qlog.IndentJson(results)) 96 } 97 98 func TestRunTestsTimeout(t *testing.T) { 99 runner, closeFn := dockerClient(t) 100 defer closeFn() 101 102 runData := testRunData(t, runner) 103 // Note that this timeout value is susceptible to variation 104 ctx, cancel := context.WithTimeout(context.Background(), 2000*time.Millisecond) 105 defer cancel() 106 107 scmClient, _ := scm.GetTestSCM(t) 108 results, err := runData.RunTests(ctx, qtest.Logger(t), scmClient, runner) 109 if err != nil { 110 t.Fatal(err) 111 } 112 const wantOut = `Container timeout. Please check for infinite loops or other slowness.` 113 if results.BuildInfo != nil && !strings.HasPrefix(results.BuildInfo.BuildLog, wantOut) { 114 t.Errorf("RunTests(1s timeout) = '%s', got '%s'", wantOut, results.BuildInfo.BuildLog) 115 } 116 } 117 118 func TestRecordResults(t *testing.T) { 119 db, cleanup := qtest.TestDB(t) 120 defer cleanup() 121 122 course := &qf.Course{ 123 Name: "Test", 124 Code: "DAT320", 125 ScmOrganizationID: 1, 126 SlipDays: 5, 127 } 128 admin := qtest.CreateFakeUser(t, db) 129 qtest.CreateCourse(t, db, admin, course) 130 131 assignment := &qf.Assignment{ 132 CourseID: course.ID, 133 Name: "lab1", 134 Deadline: qtest.Timestamp(t, "2022-11-11T13:00:00"), 135 AutoApprove: true, 136 ScoreLimit: 70, 137 Order: 1, 138 IsGroupLab: false, 139 ContainerTimeout: 1, 140 } 141 if err := db.CreateAssignment(assignment); err != nil { 142 t.Fatal(err) 143 } 144 145 buildInfo := &score.BuildInfo{ 146 SubmissionDate: qtest.Timestamp(t, "2022-11-10T13:00:00"), 147 BuildDate: qtest.Timestamp(t, "2022-11-10T13:00:00"), 148 BuildLog: "Testing", 149 ExecTime: 33333, 150 } 151 testScores := []*score.Score{ 152 { 153 Secret: "secret", 154 TestName: "Test", 155 Score: 10, 156 MaxScore: 15, 157 Weight: 1, 158 }, 159 } 160 // Must create a new submission with correct scores and build info, not approved 161 results := &score.Results{ 162 BuildInfo: buildInfo, 163 Scores: testScores, 164 } 165 runData := &ci.RunData{ 166 Course: course, 167 Assignment: assignment, 168 Repo: &qf.Repository{ 169 RepoType: qf.Repository_USER, 170 UserID: 1, 171 }, 172 JobOwner: "test", 173 CommitID: "deadbeef", 174 } 175 176 // Check that submission is recorded correctly 177 submission, err := runData.RecordResults(qtest.Logger(t), db, results) 178 if err != nil { 179 t.Fatal(err) 180 } 181 if submission.IsApproved() { 182 t.Error("Submission must not be auto approved") 183 } 184 if diff := cmp.Diff(testScores, submission.Scores, protocmp.Transform(), protocmp.IgnoreFields(&score.Score{}, "Secret")); diff != "" { 185 t.Errorf("submission score mismatch: (-want +got):\n%s", diff) 186 } 187 if diff := cmp.Diff(buildInfo.BuildDate, submission.BuildInfo.BuildDate, protocmp.Transform()); diff != "" { 188 t.Errorf("build date mismatch: (-want +got):\n%s", diff) 189 } 190 if diff := cmp.Diff(buildInfo.SubmissionDate, submission.BuildInfo.SubmissionDate, protocmp.Transform()); diff != "" { 191 t.Errorf("submission date mismatch: (-want +got):\n%s", diff) 192 } 193 194 // When updating submission after deadline: build info (submission and build dates) and slip days must be updated 195 newSubmissionDate := qtest.Timestamp(t, "2022-11-12T13:00:00") 196 results.BuildInfo.BuildDate = newSubmissionDate 197 results.BuildInfo.SubmissionDate = newSubmissionDate 198 updatedSubmission, err := runData.RecordResults(qtest.Logger(t), db, results) 199 if err != nil { 200 t.Fatal(err) 201 } 202 enrollment, err := db.GetEnrollmentByCourseAndUser(course.ID, admin.ID) 203 if err != nil { 204 t.Fatal(err) 205 } 206 if enrollment.RemainingSlipDays(course) == int32(course.SlipDays) || len(enrollment.UsedSlipDays) < 1 { 207 t.Error("Student must have reduced slip days") 208 } 209 if diff := cmp.Diff(newSubmissionDate, updatedSubmission.BuildInfo.BuildDate, protocmp.Transform()); diff != "" { 210 t.Errorf("build date mismatch: (-want +got):\n%s", diff) 211 } 212 if diff := cmp.Diff(newSubmissionDate, updatedSubmission.BuildInfo.SubmissionDate, protocmp.Transform()); diff != "" { 213 t.Errorf("submission date mismatch: (-want +got):\n%s", diff) 214 } 215 216 // When rebuilding after deadline: delivery date and slip days must stay unchanged, build date must be updated 217 runData.Rebuild = true 218 wantSubmissionDate := newSubmissionDate 219 newDate := qtest.Timestamp(t, "2022-11-13T15:00:00") 220 results.BuildInfo.BuildDate = newDate 221 results.BuildInfo.SubmissionDate = newDate 222 slipDaysBeforeUpdate := enrollment.RemainingSlipDays(course) 223 rebuiltSubmission, err := runData.RecordResults(qtest.Logger(t), db, results) 224 if err != nil { 225 t.Fatal(err) 226 } 227 if diff := cmp.Diff(newDate, rebuiltSubmission.BuildInfo.BuildDate, protocmp.Transform()); diff != "" { 228 t.Errorf("build date mismatch: (-want +got):\n%s", diff) 229 } 230 if diff := cmp.Diff(wantSubmissionDate, rebuiltSubmission.BuildInfo.SubmissionDate, protocmp.Transform()); diff != "" { 231 t.Errorf("submission date mismatch: (-want +got):\n%s", diff) 232 } 233 updatedEnrollment, err := db.GetEnrollmentByCourseAndUser(course.ID, admin.ID) 234 if err != nil { 235 t.Fatal(err) 236 } 237 if diff := cmp.Diff(slipDaysBeforeUpdate, updatedEnrollment.RemainingSlipDays(course)); diff != "" { 238 t.Errorf("slip days mismatch: (-want +got):\n%s", diff) 239 } 240 } 241 242 func TestRecordResultsForManualReview(t *testing.T) { 243 db, cleanup := qtest.TestDB(t) 244 defer cleanup() 245 246 course := &qf.Course{ 247 Name: "Test", 248 ScmOrganizationID: 1, 249 SlipDays: 5, 250 } 251 admin := qtest.CreateFakeUser(t, db) 252 qtest.CreateCourse(t, db, admin, course) 253 254 assignment := &qf.Assignment{ 255 Order: 1, 256 CourseID: course.ID, 257 Name: "assignment-1", 258 Deadline: qtest.Timestamp(t, "2022-11-11T13:00:00"), 259 IsGroupLab: false, 260 Reviewers: 1, 261 } 262 if err := db.CreateAssignment(assignment); err != nil { 263 t.Fatal(err) 264 } 265 266 initialSubmission := &qf.Submission{ 267 AssignmentID: assignment.ID, 268 UserID: admin.ID, 269 Score: 80, 270 Status: qf.Submission_APPROVED, 271 Released: true, 272 } 273 if err := db.CreateSubmission(initialSubmission); err != nil { 274 t.Fatal(err) 275 } 276 277 runData := &ci.RunData{ 278 Course: course, 279 Assignment: assignment, 280 Repo: &qf.Repository{ 281 RepoType: qf.Repository_USER, 282 UserID: admin.ID, 283 }, 284 JobOwner: "test", 285 } 286 287 submission, err := runData.RecordResults(qtest.Logger(t), db, nil) 288 if err != nil { 289 t.Fatal(err) 290 } 291 292 // make sure all fields were saved correctly in the database 293 query := &qf.Submission{ 294 AssignmentID: assignment.ID, 295 UserID: admin.ID, 296 } 297 updatedSubmission, err := db.GetSubmission(query) 298 if err != nil { 299 t.Fatal(err) 300 } 301 302 if diff := cmp.Diff(updatedSubmission, submission, protocmp.Transform()); diff != "" { 303 t.Errorf("Incorrect submission fields in the database. Want: %+v, got %+v", initialSubmission, updatedSubmission) 304 } 305 306 // submission must stay approved, released, with score = 80 307 if diff := cmp.Diff(initialSubmission, updatedSubmission, protocmp.Transform(), protocmp.IgnoreFields(&qf.Submission{}, "BuildInfo", "Scores")); diff != "" { 308 t.Errorf("Incorrect submission after update. Want: %+v, got %+v", initialSubmission, updatedSubmission) 309 } 310 } 311 312 func TestStreamRecordResults(t *testing.T) { 313 db, cleanup := qtest.TestDB(t) 314 defer cleanup() 315 streamService := stream.NewStreamServices() 316 317 course := &qf.Course{ 318 Name: "Test", 319 Code: "DAT320", 320 ScmOrganizationID: 1, 321 SlipDays: 5, 322 } 323 admin := qtest.CreateFakeUser(t, db) 324 qtest.CreateCourse(t, db, admin, course) 325 326 groupMember1 := qtest.CreateFakeUser(t, db) 327 groupMember2 := qtest.CreateFakeUser(t, db) 328 groupMember3 := qtest.CreateFakeUser(t, db) 329 for _, user := range []*qf.User{groupMember1, groupMember2, groupMember3} { 330 qtest.EnrollStudent(t, db, user, course) 331 } 332 group := &qf.Group{ 333 CourseID: course.ID, 334 Name: "group-1", 335 Users: []*qf.User{ 336 groupMember1, 337 groupMember2, 338 groupMember3, 339 }, 340 } 341 if err := db.CreateGroup(group); err != nil { 342 t.Fatal(err) 343 } 344 345 assignment := &qf.Assignment{ 346 CourseID: course.ID, 347 Name: "lab1", 348 Deadline: qtest.Timestamp(t, "2022-11-11T13:00:00"), 349 AutoApprove: true, 350 ScoreLimit: 70, 351 Order: 1, 352 IsGroupLab: true, 353 ContainerTimeout: 1, 354 } 355 if err := db.CreateAssignment(assignment); err != nil { 356 t.Fatal(err) 357 } 358 359 buildInfo := &score.BuildInfo{ 360 BuildDate: qtest.Timestamp(t, "2022-11-10T13:00:00"), 361 SubmissionDate: qtest.Timestamp(t, "2022-11-10T13:00:00"), 362 BuildLog: "Testing", 363 ExecTime: 33333, 364 } 365 testScores := []*score.Score{ 366 { 367 Secret: "secret", 368 TestName: "Test", 369 Score: 10, 370 MaxScore: 15, 371 Weight: 1, 372 }, 373 } 374 375 results := &score.Results{ 376 BuildInfo: buildInfo, 377 Scores: testScores, 378 } 379 runData := &ci.RunData{ 380 Course: course, 381 Assignment: assignment, 382 Repo: &qf.Repository{ 383 RepoType: qf.Repository_GROUP, 384 GroupID: group.ID, 385 }, 386 JobOwner: "test", 387 CommitID: "deadbeef", 388 } 389 390 var streams []*qtest.MockStream[qf.Submission] 391 for _, user := range group.Users { 392 stream := qtest.NewMockStream[qf.Submission](t) 393 streamService.Submission.Add(stream, user.ID) 394 streams = append(streams, stream) 395 } 396 397 // Add a stream for the admin user 398 adminStream := qtest.NewMockStream[qf.Submission](t) 399 streamService.Submission.Add(adminStream, admin.ID) 400 401 var wg sync.WaitGroup 402 for i := range streams { 403 runStream(streams[i], &wg) 404 } 405 runStream(adminStream, &wg) 406 407 // Check that submission is recorded correctly 408 submission, err := runData.RecordResults(qtest.Logger(t), db, results) 409 if err != nil { 410 t.Fatal(err) 411 } 412 413 owners, err := runData.GetOwners(db) 414 if err != nil { 415 t.Fatal(err) 416 } 417 streamService.Submission.SendTo(submission, owners...) 418 if submission.IsApproved() { 419 t.Error("Submission must not be auto approved") 420 } 421 422 newBuildDate := qtest.Timestamp(t, "2022-11-12T13:00:00") 423 results.BuildInfo.BuildDate = newBuildDate 424 updatedSubmission, err := runData.RecordResults(qtest.Logger(t), db, results) 425 if err != nil { 426 t.Fatal(err) 427 } 428 streamService.Submission.SendTo(updatedSubmission, owners...) 429 430 runData.Rebuild = true 431 results.BuildInfo.BuildDate = qtest.Timestamp(t, "2022-11-13T13:00:00") 432 rebuiltSubmission, err := runData.RecordResults(qtest.Logger(t), db, results) 433 if err != nil { 434 t.Fatal(err) 435 } 436 streamService.Submission.SendTo(rebuiltSubmission, owners...) 437 438 for _, stream := range streams { 439 stream.Close() 440 } 441 adminStream.Close() 442 // Wait for all streams to be closed 443 wg.Wait() 444 445 // Admin user should have received 0 submissions 446 if len(adminStream.Messages) != 0 { 447 t.Errorf("Admin user should not have received any submissions, got %d", len(adminStream.Messages)) 448 } 449 450 // We should have received three submissions for each stream 451 numSubmissions := 0 452 for _, stream := range streams { 453 numSubmissions += len(stream.Messages) 454 } 455 if numSubmissions != 9 { 456 t.Errorf("Expected 9 messages, got %d", numSubmissions) 457 } 458 459 // Check that the messages are correct 460 submissions := []*qf.Submission{submission, updatedSubmission, rebuiltSubmission} 461 for _, stream := range streams { 462 for i, submission := range submissions { 463 if diff := cmp.Diff(stream.Messages[i], submission, protocmp.Transform()); diff != "" { 464 t.Errorf("Incorrect submission. Want: %+v, got %+v", submission, stream.Messages[i]) 465 } 466 } 467 } 468 } 469 470 func runStream(stream *qtest.MockStream[qf.Submission], wg *sync.WaitGroup) { 471 wg.Add(1) 472 go func() { 473 defer wg.Done() 474 _ = stream.Run() 475 }() 476 }