diff --git a/cli/dispatcher/internal/compilecheck/baseline_record_test.go b/cli/dispatcher/internal/compilecheck/baseline_record_test.go index 838117068..6667ad622 100644 --- a/cli/dispatcher/internal/compilecheck/baseline_record_test.go +++ b/cli/dispatcher/internal/compilecheck/baseline_record_test.go @@ -20,12 +20,14 @@ func TestBaselineRecordedAfterSuccessfulCompile(t *testing.T) { project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) - record := project.readBaseline(t) + record := project.baseline(t) keyResult, err := computeInputKey(project.root, project.paths, project.plan, project.plan.Units[0]) if err != nil { t.Fatalf("expected a key, got error: %v", err) } - if record.InputKey != project.readManifest(t).InputKey || record.InputKey != keyResult.Key { + // Why a usable baseline shows it carries the manifest's input key: the store hands one back only + // when the two keys agree. + if record.InputKey != keyResult.Key { t.Errorf("expected the baseline to carry the manifest's input key, got %s", record.InputKey) } for source, hash := range keyResult.SourceHashes { @@ -129,27 +131,6 @@ func TestCompileUnitPassesGeneratedFilesOut(t *testing.T) { } } -// Verifies that removing a unit's outputs also removes its baseline and its saved generator output. -func TestRemoveUnitOutputsRemovesBaselineAndGeneratedDirectory(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - writeOutputFile(t, outputDirectoryPath, "Alpha"+baselineRecordExtension, "{}") - generatedPath := filepath.Join(outputDirectoryPath, "Alpha"+generatedDirectoryExtension, "Nested") - if err := os.MkdirAll(generatedPath, outputDirPermissions); err != nil { - t.Fatalf("failed to create the generated directory: %v", err) - } - writeOutputFile(t, generatedPath, "G.cs", "class G {}") - - if err := (Compiler{ProjectRoot: outputDirectoryPath}).removeUnitOutputs(BuildPlan{}, unit); err != nil { - t.Fatalf("expected the outputs to be removed, got error: %v", err) - } - - for _, name := range []string{"Alpha" + baselineRecordExtension, "Alpha" + generatedDirectoryExtension} { - if _, err := os.Stat(filepath.Join(outputDirectoryPath, name)); !os.IsNotExist(err) { - t.Errorf("expected %s to be removed, got %v", name, err) - } - } -} - // Verifies that the worker build is shared by later callers, and that a cancelled build is tried again. func TestOnceWithContextSharesTheAnswerButNotACancellation(t *testing.T) { calls := 0 @@ -269,38 +250,3 @@ func TestRecordBaselineRefusesAMissingNonSourceKey(t *testing.T) { t.Errorf("expected no baseline without a non-source key, got %v", err) } } - -// Verifies that only generated C# files are recorded, keyed relative to the generator output directory. -func TestHashGeneratedFilesRecordsOnlyCSharpFiles(t *testing.T) { - directory := t.TempDir() - if err := os.MkdirAll(filepath.Join(directory, "Gen"), outputDirPermissions); err != nil { - t.Fatalf("failed to create the generator folder: %v", err) - } - writeOutputFile(t, filepath.Join(directory, "Gen"), "A.g.cs", "class A { }") - writeOutputFile(t, filepath.Join(directory, "Gen"), "notes.txt", "not code") - - hashes, err := hashGeneratedFiles(directory) - - if err != nil || len(hashes) != 1 || hashes["Gen/A.g.cs"] == "" { - t.Errorf("expected only Gen/A.g.cs, got %v (%v)", hashes, err) - } - if missing, err := hashGeneratedFiles(filepath.Join(directory, "missing")); err != nil || len(missing) != 0 { - t.Errorf("expected a missing directory to hold no files, got %v (%v)", missing, err) - } -} - -// Verifies that a baseline another version of compile-check wrote is not used, and says so rather -// than reporting that no full compile ran. -func TestBaselineOfAnotherFormatFallsBack(t *testing.T) { - project := newFastPathProject(t, fastPathMinimumSources) - project.recordBaseline(t) - record := project.readBaseline(t) - record.FormatVersion = baselineRecordFormatVersion - 1 - project.writeJSON(t, project.baselinePath(), record) - project.editBody(t, 0, "edited") - recorder := newCachedSchedulerRecorder("Alpha", "Beta") - - outcome := project.run(t, recorder, false) - - assertFallback(t, outcome, recorder, "no baseline: it was recorded by another version of compile-check") -} diff --git a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go index a00148c27..cf9ff5458 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_fixture_test.go @@ -4,8 +4,8 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -221,74 +221,71 @@ func (project *fastPathProject) recordBaseline(t *testing.T) { } } -// baselinePath names Alpha's baseline record. -func (project *fastPathProject) baselinePath() string { - return filepath.Join(project.outputDirectory(), "Alpha"+baselineRecordExtension) -} - -// hasBaseline reports whether Alpha has a baseline record. +// hasBaseline reports whether Alpha has a baseline of the current format. func (project *fastPathProject) hasBaseline() bool { - _, err := os.Stat(project.baselinePath()) - - return err == nil -} - -// readBaseline reads Alpha's baseline record. -func (project *fastPathProject) readBaseline(t *testing.T) baselineRecord { - t.Helper() - record := baselineRecord{} - project.readJSON(t, project.baselinePath(), &record) - - return record + return project.hasBaselineOf("Alpha") } -// readManifest reads Alpha's result manifest. -func (project *fastPathProject) readManifest(t *testing.T) unitResultManifest { +// baseline reads Alpha's baseline through the store and fails the test when there is none to use. +func (project *fastPathProject) baseline(t *testing.T) storedBaseline { t.Helper() - manifest := unitResultManifest{} - project.readJSON(t, project.manifestPathOf("Alpha"), &manifest) + baseline, reason := project.records().loadBaseline("Alpha") + if reason != "" { + t.Fatalf("expected a usable baseline for Alpha, got %q", reason) + } - return manifest + return baseline } -func (project *fastPathProject) readJSON(t *testing.T, path string, value any) { +// generatedFileCount counts the files under the generator output a baseline points at. +func (project *fastPathProject) generatedFileCount(t *testing.T, baseline storedBaseline) int { t.Helper() - content, err := os.ReadFile(path) + count := 0 + err := filepath.WalkDir(projectPath(project.root, baseline.GeneratedDirectory), + func(_ string, entry fs.DirEntry, err error) error { + if err == nil && !entry.IsDir() { + count++ + } + + return err + }) if err != nil { - t.Fatalf("failed to read %s: %v", path, err) + t.Fatalf("failed to read the generator output: %v", err) } - if err := json.Unmarshal(content, value); err != nil { - t.Fatalf("failed to parse %s: %v", path, err) - } -} -func (project *fastPathProject) writeJSON(t *testing.T, path string, value any) { - t.Helper() - content, err := json.Marshal(value) - if err != nil { - t.Fatalf("failed to encode %s: %v", path, err) - } - if err := os.WriteFile(path, content, 0o600); err != nil { - t.Fatalf("failed to write %s: %v", path, err) - } + return count } -// fileHashes hashes Alpha's outputs, manifest, and baseline, to show a run left them alone. -func (project *fastPathProject) fileHashes(t *testing.T) map[string]string { +// outputSnapshot hashes every file the check keeps for Alpha, keyed by its path relative to the +// output directory, leaving out the response file the changed-files check writes for itself. +func (project *fastPathProject) outputSnapshot(t *testing.T) map[string]string { t.Helper() - hashes := map[string]string{} - for _, name := range []string{ - "Alpha" + assemblyExtension, "Alpha" + referenceAssemblyExtension, - "Alpha" + resultManifestExtension, "Alpha" + baselineRecordExtension, - } { - hash, err := hashFile(filepath.Join(project.outputDirectory(), name)) + snapshot := map[string]string{} + err := filepath.WalkDir(project.outputDirectory(), func(path string, entry fs.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + relative, err := filepath.Rel(project.outputDirectory(), path) if err != nil { - t.Fatalf("failed to hash %s: %v", name, err) + return err } - hashes[name] = hash + first, _, _ := strings.Cut(filepath.ToSlash(relative), "/") + if !strings.HasPrefix(first, "Alpha.") || first == "Alpha"+fastCheckResponseFileSuffix { + return nil + } + hash, err := hashFile(path) + if err != nil { + return err + } + snapshot[filepath.ToSlash(relative)] = hash + + return nil + }) + if err != nil { + t.Fatalf("failed to read the output directory: %v", err) } - return hashes + return snapshot } // assertFallback checks that Alpha was compiled in full for a reason starting with prefix. diff --git a/cli/dispatcher/internal/compilecheck/fast_path_order_test.go b/cli/dispatcher/internal/compilecheck/fast_path_order_test.go index 89f6e3253..858fc7362 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_order_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_order_test.go @@ -13,7 +13,7 @@ import ( // matches the baseline, and the check that it is gone could run anywhere in the order unnoticed. func (project *fastPathProject) recordBaselineWithGeneratorOutput(t *testing.T) string { t.Helper() - generatedDirectoryPath := openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha") + generatedDirectoryPath := project.records().generatedDirectory("Alpha") recorder := newCachedSchedulerRecorder("Alpha", "Beta") recorder.during = func(unit string) { if unit == "Alpha" { @@ -21,8 +21,8 @@ func (project *fastPathProject) recordBaselineWithGeneratorOutput(t *testing.T) } } project.run(t, recorder, false) - if record := project.readBaseline(t); len(record.GeneratedFiles) != 1 { - t.Fatalf("expected the generated file in the baseline, got %v", record.GeneratedFiles) + if count := project.generatedFileCount(t, project.baseline(t)); count != 1 { + t.Fatalf("expected the generated file in the baseline, got %d files", count) } return filepath.Join(project.root, generatedDirectoryPath) diff --git a/cli/dispatcher/internal/compilecheck/fast_path_test.go b/cli/dispatcher/internal/compilecheck/fast_path_test.go index 09afe4ffc..4f9389081 100644 --- a/cli/dispatcher/internal/compilecheck/fast_path_test.go +++ b/cli/dispatcher/internal/compilecheck/fast_path_test.go @@ -51,9 +51,9 @@ func TestFullFlagSkipsFastPathButRecords(t *testing.T) { outcome := project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), true) assertNotTried(t, outcome, project.worker) - if record, manifest := project.readBaseline(t), project.readManifest(t); record.InputKey != manifest.InputKey { - t.Errorf("expected the baseline to describe the --full compile, got %s and %s", record.InputKey, manifest.InputKey) - } + // Why a usable baseline is enough: the store hands one back only when its input key is the one + // the manifest of the last compile carries, and the --full compile removed the earlier pair. + project.baseline(t) } // Verifies that a unit picked only as a dependent is neither checked by the changed-files check nor @@ -95,7 +95,7 @@ func TestMissingBaselineFallsBackAndRecords(t *testing.T) { outcome := project.run(t, recorder, false) assertFallback(t, outcome, recorder, "no baseline: no full compile of this assembly is on record") - record := project.readBaseline(t) + record := project.baseline(t) if record.WorkerKey != "worker-a" || len(record.Files) != fastPathMinimumSources { t.Errorf("expected a baseline of every source from worker-a, got key %s with %d files", record.WorkerKey, len(record.Files)) } @@ -122,9 +122,11 @@ func TestHandCorruptedFailedManifestFallsBack(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) project.recordBaseline(t) project.editBody(t, 0, "edited") - manifest := project.readManifest(t) - manifest.Succeeded = false - project.writeJSON(t, project.manifestPathOf("Alpha"), manifest) + baseline := project.baseline(t) + if err := project.records().recordCompile("Alpha", baseline.InputKey, + recordedCompile{Succeeded: false, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { + t.Fatalf("failed to record the failed compile: %v", err) + } recorder := newCachedSchedulerRecorder("Alpha", "Beta") outcome := project.run(t, recorder, false) @@ -220,16 +222,16 @@ func TestMissingOrChangedGeneratorOutputFallsBack(t *testing.T) { } { t.Run(name, func(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) - generatedPath := filepath.Join(project.root, openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha")) + generatedPath := filepath.Join(project.root, project.records().generatedDirectory("Alpha")) recorder := newCachedSchedulerRecorder("Alpha", "Beta") recorder.during = func(unit string) { if unit == "Alpha" { - project.writeFile(t, filepath.Join(openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha"), "Gen", "G.g.cs"), "class G { }") + project.writeFile(t, filepath.Join(project.records().generatedDirectory("Alpha"), "Gen", "G.g.cs"), "class G { }") } } project.run(t, recorder, false) - if record := project.readBaseline(t); len(record.GeneratedFiles) != 1 { - t.Fatalf("expected the generated file in the baseline, got %v", record.GeneratedFiles) + if count := project.generatedFileCount(t, project.baseline(t)); count != 1 { + t.Fatalf("expected the generated file in the baseline, got %d files", count) } damage(t, generatedPath) project.editBody(t, 0, "edited") @@ -266,7 +268,7 @@ func TestDeclarationChangeFallsBackAndRerecords(t *testing.T) { outcome := project.run(t, recorder, false) assertFallback(t, outcome, recorder, fastcheck.DeclarationChangedOutcome+": "+project.sourceName(0)) - if file := project.readBaseline(t).Files[project.sourceName(0)]; file.Fingerprint != "class File000 : Base " { + if file := project.baseline(t).Files[project.sourceName(0)]; file.Fingerprint != "class File000 : Base " { t.Errorf("expected the new declaration in the baseline, got %+v", file) } } @@ -322,7 +324,7 @@ func TestWorkerKeyMismatchFallsBack(t *testing.T) { outcome := project.run(t, recorder, false) assertFallback(t, outcome, recorder, "baseline was fingerprinted by another worker build") - if record := project.readBaseline(t); record.WorkerKey != "worker-b" { + if record := project.baseline(t); record.WorkerKey != "worker-b" { t.Errorf("expected the baseline to be recorded again by worker-b, got %s", record.WorkerKey) } } @@ -337,7 +339,7 @@ func TestFastPathCleanResultKeepsBaselineFiles(t *testing.T) { first.surfaces["Alpha"] = "unity Alpha" first.output["Alpha"] = fakeCompilerOutput{stdout: "Assets/Alpha/File001.cs(1,1): warning CS0168: unused"} project.run(t, first, false) - before := project.fileHashes(t) + before := project.outputSnapshot(t) project.editBody(t, 0, "edited") project.worker.uncheckedCodes = []string{"CS0169"} @@ -353,13 +355,13 @@ func TestFastPathCleanResultKeepsBaselineFiles(t *testing.T) { if len(result.Diagnostics) != 1 || result.Diagnostics[0].File != "Assets/Alpha/File001.cs" { t.Errorf("expected the warning of the unchanged file to be carried over, got %+v", result.Diagnostics) } - if after := project.fileHashes(t); !reflect.DeepEqual(after, before) { + if after := project.outputSnapshot(t); !reflect.DeepEqual(after, before) { t.Errorf("expected the outputs, manifest, and baseline to stay, got %v then %v", before, after) } if recorder.runs["Beta"] != 0 || outcome.Skipped != 1 { t.Errorf("expected Beta to be skipped, got %d runs and %d skipped", recorder.runs["Beta"], outcome.Skipped) } - if project.worker.lastCheck.GeneratedDirectory != filepath.Join(project.root, openRecordStore(project.root, project.plan.OutputDir).generatedDirectory("Alpha")) { + if project.worker.lastCheck.GeneratedDirectory != filepath.Join(project.root, project.records().generatedDirectory("Alpha")) { t.Errorf("expected the saved generator output to be passed, got %q", project.worker.lastCheck.GeneratedDirectory) } } @@ -430,18 +432,23 @@ func TestRevertedContentIsReused(t *testing.T) { func TestZeroChangedFilesDropsBaselineAndFallsBack(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) project.recordBaseline(t) - record := project.readBaseline(t) - manifest := project.readManifest(t) - record.InputKey = "tampered" - manifest.InputKey = "tampered" - project.writeJSON(t, project.baselinePath(), record) - project.writeJSON(t, project.manifestPathOf("Alpha"), manifest) + baseline := project.baseline(t) + records := project.records() + if err := records.recordCompile("Alpha", "tampered", + recordedCompile{Succeeded: true, Diagnostics: baseline.Diagnostics}, unitOutputNames(project.plan.Units[0])); err != nil { + t.Fatalf("failed to record the tampered compile: %v", err) + } + if err := records.recordBaseline("Alpha", baselineInputs{ + InputKey: "tampered", NonSourceKey: baseline.NonSourceKey, WorkerKey: baseline.WorkerKey, Files: baseline.Files, + }); err != nil { + t.Fatalf("failed to record the tampered baseline: %v", err) + } recorder := newCachedSchedulerRecorder("Alpha", "Beta") outcome := project.run(t, recorder, false) assertFallback(t, outcome, recorder, "baseline record does not match its compile result") - if project.readBaseline(t).InputKey == "tampered" { + if project.baseline(t).InputKey == "tampered" { t.Error("expected the disagreeing baseline to be replaced") } } @@ -451,7 +458,7 @@ func TestZeroChangedFilesDropsBaselineAndFallsBack(t *testing.T) { func TestCancelledFastPathLeavesBaselineUntouched(t *testing.T) { project := newFastPathProject(t, fastPathMinimumSources) project.recordBaseline(t) - before := project.fileHashes(t) + before := project.outputSnapshot(t) project.editBody(t, 0, "edited") ctx, cancel := context.WithCancel(context.Background()) project.worker.onCheck = cancel @@ -462,7 +469,7 @@ func TestCancelledFastPathLeavesBaselineUntouched(t *testing.T) { if !errors.Is(err, context.Canceled) { t.Fatalf("expected the run to stop with a cancellation, got %v", err) } - if after := project.fileHashes(t); !reflect.DeepEqual(after, before) { + if after := project.outputSnapshot(t); !reflect.DeepEqual(after, before) { t.Errorf("expected the outputs, manifest, and baseline to stay, got %v then %v", before, after) } } @@ -502,8 +509,8 @@ func TestBaselineRemovedByBlockedRunIsNotCalledNeverCompiled(t *testing.T) { } project.plan.Units[1].Sources = sources project.run(t, newCachedSchedulerRecorder("Alpha", "Beta"), false) - if _, err := os.Stat(filepath.Join(project.outputDirectory(), "Beta"+baselineRecordExtension)); err != nil { - t.Fatalf("expected the first run to record Beta's baseline: %v", err) + if !project.hasBaselineOf("Beta") { + t.Fatal("expected the first run to record Beta's baseline") } project.editSource(t, "Alpha", "class Alpha { broken }") diff --git a/cli/dispatcher/internal/compilecheck/record_format_test.go b/cli/dispatcher/internal/compilecheck/record_format_test.go index 0b48a24db..cb09bda6d 100644 --- a/cli/dispatcher/internal/compilecheck/record_format_test.go +++ b/cli/dispatcher/internal/compilecheck/record_format_test.go @@ -8,16 +8,15 @@ import ( // Verifies a baseline of another format is reported as such even when its manifest is gone too, so // the format is checked before the manifest. func TestLoadBaselineReportsAnotherFormatBeforeAMissingManifest(t *testing.T) { - project := newFastPathProject(t, fastPathMinimumSources) - project.recordBaseline(t) - record := project.readBaseline(t) - record.FormatVersion = baselineRecordFormatVersion - 1 - project.writeJSON(t, project.baselinePath(), record) - if err := os.Remove(project.manifestPathOf("Alpha")); err != nil { - t.Fatalf("failed to remove the manifest: %v", err) + directoryPath := newRecordedOutputs(t) + if err := writeBaselineRecord(directoryPath, "Alpha", baselineRecord{ + FormatVersion: baselineRecordFormatVersion - 1, + InputKey: "input", + }); err != nil { + t.Fatalf("failed to write the baseline: %v", err) } - _, reason := openRecordStore(project.root, project.plan.OutputDir).loadBaseline("Alpha") + _, reason := directoryStore(directoryPath).loadBaseline("Alpha") if reason != "no baseline: it was recorded by another version of compile-check" { t.Errorf("reason = %q", reason) @@ -26,7 +25,7 @@ func TestLoadBaselineReportsAnotherFormatBeforeAMissingManifest(t *testing.T) { // Verifies the bytes a compile result is recorded as, so moving the record keeps its format. func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) + directoryPath := newRecordedOutputs(t) result := recordedCompile{ Succeeded: false, Diagnostics: []Diagnostic{{ @@ -39,12 +38,11 @@ func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { }}, } - if err := manifestStore(outputDirectoryPath).recordCompile( - unit.Assembly.AssemblyName, "key", result, unitOutputNames(unit)); err != nil { + if err := directoryStore(directoryPath).recordCompile("Alpha", "key", result, alphaOutputs()); err != nil { t.Fatalf("failed to write the manifest: %v", err) } - assertFileContent(t, manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName), `{"FormatVersion":1,"InputKey":"key","Succeeded":false,`+ + assertFileContent(t, manifestPath(directoryPath, "Alpha"), `{"FormatVersion":1,"InputKey":"key","Succeeded":false,`+ `"Diagnostics":[{"Severity":"error","Code":"CS0103","Message":"The name 'Missing' does not exist in the current context",`+ `"File":"Assets/Alpha/Alpha.cs","Line":3,"Column":9}],`+ `"Outputs":{"Alpha.dll":"e020bdf568793dee747a220086c5e53c8b3af5abd959383a31297f303577efe6",`+ @@ -53,7 +51,7 @@ func TestWriteUnitResultManifestWritesTheRecordedFormat(t *testing.T) { // Verifies the bytes a baseline is recorded as, so moving the record keeps its format. func TestWriteBaselineRecordWritesTheRecordedFormat(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) + directoryPath := t.TempDir() record := baselineRecord{ FormatVersion: baselineRecordFormatVersion, InputKey: "input", @@ -67,11 +65,11 @@ func TestWriteBaselineRecordWritesTheRecordedFormat(t *testing.T) { Files: map[string]baselineFile{"Assets/A.cs": {ContentHash: "a", Fingerprint: "f"}}, } - if err := writeBaselineRecord(outputDirectoryPath, unit.Assembly.AssemblyName, record); err != nil { + if err := writeBaselineRecord(directoryPath, "Alpha", record); err != nil { t.Fatalf("failed to write the baseline: %v", err) } - assertFileContent(t, baselineRecordPath(outputDirectoryPath, unit.Assembly.AssemblyName), `{"FormatVersion":3,"InputKey":"input",`+ + assertFileContent(t, baselineRecordPath(directoryPath, "Alpha"), `{"FormatVersion":3,"InputKey":"input",`+ `"NonSourceKey":{"Key":"non-source","Parts":{"compiler":"c","source list":"s"}},"WorkerKey":"worker",`+ `"GeneratedDirectory":"Library/out/Alpha.generated","GeneratedFiles":{"Gen/G.g.cs":"g"},`+ `"Files":{"Assets/A.cs":{"ContentHash":"a","Fingerprint":"f"}}}`) diff --git a/cli/dispatcher/internal/compilecheck/record_store_baseline_test.go b/cli/dispatcher/internal/compilecheck/record_store_baseline_test.go new file mode 100644 index 000000000..53309e8c7 --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/record_store_baseline_test.go @@ -0,0 +1,48 @@ +package compilecheck + +import ( + "os" + "path/filepath" + "testing" +) + +// Verifies that only generated C# files are recorded, keyed relative to the generator output directory. +func TestHashGeneratedFilesRecordsOnlyCSharpFiles(t *testing.T) { + directory := t.TempDir() + if err := os.MkdirAll(filepath.Join(directory, "Gen"), outputDirPermissions); err != nil { + t.Fatalf("failed to create the generator folder: %v", err) + } + writeOutputFile(t, filepath.Join(directory, "Gen"), "A.g.cs", "class A { }") + writeOutputFile(t, filepath.Join(directory, "Gen"), "notes.txt", "not code") + + hashes, err := hashGeneratedFiles(directory) + + if err != nil || len(hashes) != 1 || hashes["Gen/A.g.cs"] == "" { + t.Errorf("expected only Gen/A.g.cs, got %v (%v)", hashes, err) + } + if missing, err := hashGeneratedFiles(filepath.Join(directory, "missing")); err != nil || len(missing) != 0 { + t.Errorf("expected a missing directory to hold no files, got %v (%v)", missing, err) + } +} + +// Verifies that a baseline another version of compile-check wrote is not used, and says so rather +// than reporting that no full compile ran, even when the compile it describes is still on disk. +func TestLoadBaselineReportsABaselineOfAnotherFormat(t *testing.T) { + directoryPath := newRecordedOutputs(t) + store := directoryStore(directoryPath) + if err := store.recordCompile("Alpha", "input", recordedCompile{Succeeded: true}, alphaOutputs()); err != nil { + t.Fatalf("failed to write the manifest: %v", err) + } + if err := writeBaselineRecord(directoryPath, "Alpha", baselineRecord{ + FormatVersion: baselineRecordFormatVersion - 1, + InputKey: "input", + }); err != nil { + t.Fatalf("failed to write the baseline: %v", err) + } + + _, reason := store.loadBaseline("Alpha") + + if reason != "no baseline: it was recorded by another version of compile-check" { + t.Errorf("reason = %q", reason) + } +} diff --git a/cli/dispatcher/internal/compilecheck/record_store_test.go b/cli/dispatcher/internal/compilecheck/record_store_test.go new file mode 100644 index 000000000..5601ad07b --- /dev/null +++ b/cli/dispatcher/internal/compilecheck/record_store_test.go @@ -0,0 +1,255 @@ +package compilecheck + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// alphaOutputs names the two files a compile of Alpha writes in the output directory. +func alphaOutputs() []string { + return []string{"Alpha" + assemblyExtension, "Alpha" + referenceAssemblyExtension} +} + +// newRecordedOutputs prepares an output directory holding the outputs a compile of Alpha left behind. +func newRecordedOutputs(t *testing.T) string { + t.Helper() + directoryPath := t.TempDir() + writeOutputFile(t, directoryPath, "Alpha"+assemblyExtension, "assembly") + writeOutputFile(t, directoryPath, "Alpha"+referenceAssemblyExtension, "surface") + + return directoryPath +} + +// directoryStore opens the records kept directly in directoryPath. +func directoryStore(directoryPath string) recordStore { + return openRecordStore(directoryPath, "") +} + +// storedResult is the result the tests record and expect to read back unchanged. +func storedResult() recordedCompile { + return recordedCompile{ + Succeeded: false, + Diagnostics: []Diagnostic{{ + Severity: severityError, + Code: "CS0103", + Message: "The name 'Missing' does not exist in the current context", + File: filepath.Join("Assets", "Alpha", "Alpha.cs"), + Line: 3, + Column: 9, + }}, + } +} + +// recordResult stores one compile of Alpha's outcome under a key. +func recordResult(t *testing.T, directoryPath string, key string) { + t.Helper() + if err := directoryStore(directoryPath).recordCompile("Alpha", key, storedResult(), alphaOutputs()); err != nil { + t.Fatalf("failed to write the manifest: %v", err) + } +} + +// blockManifestPath puts a non-empty directory where a unit's manifest belongs, so renaming the +// finished manifest into place fails on every platform. +func blockManifestPath(t *testing.T, manifestFilePath string) { + t.Helper() + writeFileAt(t, filepath.Join(manifestFilePath, "occupied"), "") +} + +// Verifies a recorded compile is handed back with the diagnostics and the outcome it had, rather +// than as a success: an assembly that failed must keep failing until something about it changes. +func TestReusableResultReplaysTheRecordedDiagnostics(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + + result, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key") + + if !reusable { + t.Fatal("expected the recorded result to be reusable") + } + if result.Succeeded { + t.Error("a failed compile must not come back as a successful one") + } + if len(result.Diagnostics) != 1 || result.Diagnostics[0].Code != "CS0103" { + t.Errorf("diagnostics = %+v, want the recorded CS0103", result.Diagnostics) + } +} + +// Verifies a result recorded for other inputs is not reused. +func TestReusableResultRejectsAnotherKey(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "other key"); reusable { + t.Error("a result recorded for other inputs should not be reused") + } +} + +// Verifies an output that no longer holds what the compile wrote stops the reuse: the assemblies +// below this one read those bytes, so replaying diagnostics over them would check nothing. +func TestReusableResultRejectsAChangedOutput(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + + writeOutputFile(t, directoryPath, "Alpha"+referenceAssemblyExtension, "another surface") + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + t.Error("a changed output should stop the reuse") + } +} + +// Verifies a missing output stops the reuse. +func TestReusableResultRejectsAMissingOutput(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + + if err := os.Remove(filepath.Join(directoryPath, "Alpha"+assemblyExtension)); err != nil { + t.Fatalf("failed to remove the output: %v", err) + } + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + t.Error("a missing output should stop the reuse") + } +} + +// Verifies a manifest that cannot be read is treated as no manifest at all rather than as a +// failure: the assembly simply compiles again. +func TestReusableResultRejectsAnUnreadableManifest(t *testing.T) { + directoryPath := newRecordedOutputs(t) + writeOutputFile(t, directoryPath, "Alpha.result.json", "{not json") + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + t.Error("an unreadable manifest should not be reused") + } +} + +// Verifies a manifest written by another version of this check is not reused. +func TestReusableResultRejectsAnotherFormatVersion(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + path := manifestPath(directoryPath, "Alpha") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read the manifest: %v", err) + } + var manifest unitResultManifest + if err := json.Unmarshal(content, &manifest); err != nil { + t.Fatalf("failed to parse the manifest: %v", err) + } + manifest.FormatVersion = resultManifestFormatVersion + 1 + rewritten, err := json.Marshal(manifest) + if err != nil { + t.Fatalf("failed to write the manifest back: %v", err) + } + if err := os.WriteFile(path, rewritten, 0o600); err != nil { + t.Fatalf("failed to write the manifest back: %v", err) + } + + if _, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key"); reusable { + t.Error("a manifest of another format version should not be reused") + } +} + +// Verifies a compile that wrote no outputs still records what it reported, so the next run can tell +// a failure it already knows from one it has not seen. +func TestRecordCompileRecordsACompileThatWroteNothing(t *testing.T) { + directoryPath := newRecordedOutputs(t) + for _, name := range alphaOutputs() { + if err := os.Remove(filepath.Join(directoryPath, name)); err != nil { + t.Fatalf("failed to remove %s: %v", name, err) + } + } + recordResult(t, directoryPath, "key") + + result, reusable := directoryStore(directoryPath).reusableResult("Alpha", "key") + + if !reusable { + t.Fatal("expected a compile that wrote nothing to be reusable") + } + if len(result.Diagnostics) != 1 { + t.Errorf("diagnostics = %+v, want the recorded one", result.Diagnostics) + } +} + +// Verifies a manifest that cannot be renamed into place is reported as a write failure and leaves +// no temporary file behind. +func TestRecordCompileReportsAFailedRename(t *testing.T) { + directoryPath := newRecordedOutputs(t) + blockManifestPath(t, manifestPath(directoryPath, "Alpha")) + + err := directoryStore(directoryPath).recordCompile("Alpha", "key", storedResult(), alphaOutputs()) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(directoryPath, "Alpha")+":") { + t.Fatalf("err = %v", err) + } + matches, globErr := filepath.Glob(filepath.Join(directoryPath, "*"+resultManifestTempSuffix)) + if globErr != nil || len(matches) != 0 { + t.Fatalf("temporary manifests left behind: %v (glob error %v)", matches, globErr) + } +} + +// Verifies a manifest cannot be written into an output directory that does not exist, which is +// reported with the manifest path. +func TestRecordCompileReportsAMissingOutputDirectory(t *testing.T) { + directoryPath := filepath.Join(t.TempDir(), "missing") + + err := directoryStore(directoryPath).recordCompile("Alpha", "key", storedResult(), alphaOutputs()) + + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(directoryPath, "Alpha")+":") { + t.Fatalf("err = %v", err) + } +} + +// Verifies an output that exists but cannot be read is reported instead of being treated as absent. +func TestHashOutputsReportsAnUnreadableOutput(t *testing.T) { + directoryPath := newRecordedOutputs(t) + unreadablePath := filepath.Join(directoryPath, "Alpha"+assemblyExtension) + if err := os.Remove(unreadablePath); err != nil { + t.Fatalf("failed to remove the assembly: %v", err) + } + makeDirAt(t, unreadablePath) + + _, complete, err := hashOutputs(directoryPath, alphaOutputs(), true) + + if err == nil || complete || !strings.Contains(err.Error(), unreadablePath) { + t.Fatalf("complete=%v err=%v, want an error naming %s", complete, err, unreadablePath) + } +} + +// Verifies only the named outputs are hashed, even when another output is on disk. +func TestHashOutputsHashesOnlyTheNamedOutputs(t *testing.T) { + directoryPath := newRecordedOutputs(t) + + outputs, complete, err := hashOutputs(directoryPath, []string{"Alpha" + assemblyExtension}, true) + + if err != nil || !complete || len(outputs) != 1 || outputs["Alpha"+assemblyExtension] == "" { + t.Fatalf("outputs=%v complete=%v err=%v", outputs, complete, err) + } +} + +// Verifies forgetting an assembly removes its manifest, its baseline, and its saved generator output, +// so nothing describes a compile whose outputs are gone. +func TestForgetRemovesTheManifestTheBaselineAndTheGeneratorOutput(t *testing.T) { + directoryPath := newRecordedOutputs(t) + recordResult(t, directoryPath, "key") + writeOutputFile(t, directoryPath, "Alpha"+baselineRecordExtension, "{}") + generatedPath := filepath.Join(directoryPath, "Alpha"+generatedDirectoryExtension, "Nested") + if err := os.MkdirAll(generatedPath, outputDirPermissions); err != nil { + t.Fatalf("failed to create the generated directory: %v", err) + } + writeOutputFile(t, generatedPath, "G.cs", "class G {}") + + if err := directoryStore(directoryPath).forget("Alpha"); err != nil { + t.Fatalf("expected the records to be removed, got error: %v", err) + } + + for _, name := range []string{ + "Alpha" + resultManifestExtension, "Alpha" + baselineRecordExtension, "Alpha" + generatedDirectoryExtension, + } { + if _, err := os.Stat(filepath.Join(directoryPath, name)); !os.IsNotExist(err) { + t.Errorf("expected %s to be removed, got %v", name, err) + } + } +} diff --git a/cli/dispatcher/internal/compilecheck/result_cache_test.go b/cli/dispatcher/internal/compilecheck/result_cache_test.go deleted file mode 100644 index 51bebdf6d..000000000 --- a/cli/dispatcher/internal/compilecheck/result_cache_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package compilecheck - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -// newManifestUnit prepares one compiled unit together with the outputs its compile left behind. -func newManifestUnit(t *testing.T) (string, CompileUnit) { - t.Helper() - outputDirectoryPath := t.TempDir() - unit := CompileUnit{Assembly: ResponseFile{ - AssemblyName: "Alpha", - OutputPath: filepath.Join(planDagDirectory, "Alpha"+assemblyExtension), - RefOutputPath: filepath.Join(planDagDirectory, "Alpha"+referenceAssemblyExtension), - }} - writeOutputFile(t, outputDirectoryPath, "Alpha"+assemblyExtension, "assembly") - writeOutputFile(t, outputDirectoryPath, "Alpha"+referenceAssemblyExtension, "surface") - - return outputDirectoryPath, unit -} - -// writeOutputFile writes one file into the check's own output directory. -func writeOutputFile(t *testing.T, outputDirectoryPath string, name string, content string) { - t.Helper() - if err := os.WriteFile( - filepath.Join(outputDirectoryPath, name), []byte(content), 0o600); err != nil { - t.Fatalf("failed to write %s: %v", name, err) - } -} - -// manifestStore opens the records kept directly in outputDirectoryPath. -func manifestStore(outputDirectoryPath string) recordStore { - return openRecordStore(outputDirectoryPath, "") -} - -// storedResult is the result the tests record and expect to read back unchanged. -func storedResult() recordedCompile { - return recordedCompile{ - Succeeded: false, - Diagnostics: []Diagnostic{{ - Severity: severityError, - Code: "CS0103", - Message: "The name 'Missing' does not exist in the current context", - File: filepath.Join("Assets", "Alpha", "Alpha.cs"), - Line: 3, - Column: 9, - }}, - } -} - -// recordResult stores one compile's outcome under a key. -func recordResult(t *testing.T, outputDirectoryPath string, unit CompileUnit, key string) { - t.Helper() - if err := manifestStore(outputDirectoryPath).recordCompile( - unit.Assembly.AssemblyName, key, storedResult(), unitOutputNames(unit)); err != nil { - t.Fatalf("failed to write the manifest: %v", err) - } -} - -// Verifies a recorded compile is handed back with the diagnostics and the outcome it had, rather -// than as a success: an assembly that failed must keep failing until something about it changes. -func TestLoadReusableResultReplaysTheRecordedDiagnostics(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - result, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key") - - if !reusable { - t.Fatal("expected the recorded result to be reusable") - } - if result.Succeeded { - t.Error("a failed compile must not come back as a successful one") - } - if len(result.Diagnostics) != 1 || result.Diagnostics[0].Code != "CS0103" { - t.Errorf("diagnostics = %+v, want the recorded CS0103", result.Diagnostics) - } -} - -// Verifies a result recorded for other inputs is not reused. -func TestLoadReusableResultRejectsAnotherKey(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "other key"); reusable { - t.Error("a result recorded for other inputs should not be reused") - } -} - -// Verifies an output that no longer holds what the compile wrote stops the reuse: the assemblies -// below this one read those bytes, so replaying diagnostics over them would check nothing. -func TestLoadReusableResultRejectsAChangedOutput(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - writeOutputFile(t, outputDirectoryPath, "Alpha"+referenceAssemblyExtension, "another surface") - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { - t.Error("a changed output should stop the reuse") - } -} - -// Verifies a missing output stops the reuse. -func TestLoadReusableResultRejectsAMissingOutput(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - if err := os.Remove(filepath.Join(outputDirectoryPath, "Alpha"+assemblyExtension)); err != nil { - t.Fatalf("failed to remove the output: %v", err) - } - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { - t.Error("a missing output should stop the reuse") - } -} - -// Verifies a manifest that cannot be read is treated as no manifest at all rather than as a -// failure: the assembly simply compiles again. -func TestLoadReusableResultRejectsAnUnreadableManifest(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - writeOutputFile(t, outputDirectoryPath, "Alpha.result.json", "{not json") - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { - t.Error("an unreadable manifest should not be reused") - } -} - -// Verifies a manifest written by another version of this check is not reused. -func TestLoadReusableResultRejectsAnotherFormatVersion(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - path := manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName) - content, err := os.ReadFile(path) - if err != nil { - t.Fatalf("failed to read the manifest: %v", err) - } - var manifest unitResultManifest - if err := json.Unmarshal(content, &manifest); err != nil { - t.Fatalf("failed to parse the manifest: %v", err) - } - manifest.FormatVersion = resultManifestFormatVersion + 1 - rewritten, err := json.Marshal(manifest) - if err != nil { - t.Fatalf("failed to write the manifest back: %v", err) - } - if err := os.WriteFile(path, rewritten, 0o600); err != nil { - t.Fatalf("failed to write the manifest back: %v", err) - } - - if _, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key"); reusable { - t.Error("a manifest of another format version should not be reused") - } -} - -// Verifies removing a unit's outputs removes the manifest with them, so nothing describes a compile -// whose outputs are gone. -func TestRemoveUnitOutputsRemovesTheManifest(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - recordResult(t, outputDirectoryPath, unit, "key") - - if err := (Compiler{ProjectRoot: outputDirectoryPath}).removeUnitOutputs(BuildPlan{}, unit); err != nil { - t.Fatalf("failed to remove the outputs: %v", err) - } - - if _, err := os.Stat(manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)); !os.IsNotExist(err) { - t.Error("the manifest should have been removed with the outputs") - } -} - -// Verifies a compile that wrote no outputs still records what it reported, so the next run can tell -// a failure it already knows from one it has not seen. -func TestWriteUnitResultManifestRecordsACompileThatWroteNothing(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - for _, name := range []string{"Alpha" + assemblyExtension, "Alpha" + referenceAssemblyExtension} { - if err := os.Remove(filepath.Join(outputDirectoryPath, name)); err != nil { - t.Fatalf("failed to remove %s: %v", name, err) - } - } - recordResult(t, outputDirectoryPath, unit, "key") - - result, reusable := manifestStore(outputDirectoryPath).reusableResult(unit.Assembly.AssemblyName, "key") - - if !reusable { - t.Fatal("expected a compile that wrote nothing to be reusable") - } - if len(result.Diagnostics) != 1 { - t.Errorf("diagnostics = %+v, want the recorded one", result.Diagnostics) - } -} diff --git a/cli/dispatcher/internal/compilecheck/result_cache_write_test.go b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go index 5f026b331..a9c2869cd 100644 --- a/cli/dispatcher/internal/compilecheck/result_cache_write_test.go +++ b/cli/dispatcher/internal/compilecheck/result_cache_write_test.go @@ -10,63 +10,12 @@ import ( "time" ) -// blockManifestPath puts a non-empty directory where a unit's manifest belongs, so renaming the -// finished manifest into place fails on every platform. -func blockManifestPath(t *testing.T, manifestFilePath string) { - t.Helper() - writeFileAt(t, filepath.Join(manifestFilePath, "occupied"), "") -} - -// Verifies a manifest that cannot be renamed into place is reported as a write failure and leaves -// no temporary file behind. -func TestWriteUnitResultManifestReportsAFailedRename(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - blockManifestPath(t, manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)) - - err := manifestStore(outputDirectoryPath).recordCompile(unit.Assembly.AssemblyName, "key", storedResult(), unitOutputNames(unit)) - - if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)+":") { - t.Fatalf("err = %v", err) - } - matches, globErr := filepath.Glob(filepath.Join(outputDirectoryPath, "*"+resultManifestTempSuffix)) - if globErr != nil || len(matches) != 0 { - t.Fatalf("temporary manifests left behind: %v (glob error %v)", matches, globErr) - } -} - -// Verifies a manifest cannot be written into an output directory that does not exist, which is -// reported with the manifest path. -func TestWriteUnitResultManifestReportsAMissingOutputDirectory(t *testing.T) { - _, unit := newManifestUnit(t) - outputDirectoryPath := filepath.Join(t.TempDir(), "missing") - - err := manifestStore(outputDirectoryPath).recordCompile(unit.Assembly.AssemblyName, "key", storedResult(), unitOutputNames(unit)) - - if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+manifestPath(outputDirectoryPath, unit.Assembly.AssemblyName)+":") { - t.Fatalf("err = %v", err) - } -} - -// Verifies an output that exists but cannot be read is reported instead of being treated as absent. -func TestHashUnitOutputsReportsAnUnreadableOutput(t *testing.T) { - outputDirectoryPath, unit := newManifestUnit(t) - unreadablePath := filepath.Join(outputDirectoryPath, "Alpha"+assemblyExtension) - if err := os.Remove(unreadablePath); err != nil { - t.Fatalf("failed to remove the assembly: %v", err) - } - makeDirAt(t, unreadablePath) - - _, complete, err := hashOutputs(outputDirectoryPath, unitOutputNames(unit), true) - - if err == nil || complete || !strings.Contains(err.Error(), unreadablePath) { - t.Fatalf("complete=%v err=%v, want an error naming %s", complete, err, unreadablePath) - } -} - // Verifies a unit without a reference assembly names its assembly alone as an output. func TestUnitOutputNamesSkipsAnAbsentReferenceAssemblyPath(t *testing.T) { - _, unit := newManifestUnit(t) - unit.Assembly.RefOutputPath = "" + unit := CompileUnit{Assembly: ResponseFile{ + AssemblyName: "Alpha", + OutputPath: filepath.Join(planDagDirectory, "Alpha"+assemblyExtension), + }} names := unitOutputNames(unit) @@ -75,14 +24,31 @@ func TestUnitOutputNamesSkipsAnAbsentReferenceAssemblyPath(t *testing.T) { } } -// Verifies only the named outputs are hashed, even when another output is on disk. -func TestHashOutputsHashesOnlyTheNamedOutputs(t *testing.T) { - outputDirectoryPath, _ := newManifestUnit(t) +// Verifies removing a unit's outputs removes both outputs and leaves no record of the compile that +// wrote them: no compile result, no baseline, and no saved generator output. +func TestRemoveUnitOutputsLeavesNoRecords(t *testing.T) { + project := newFastPathProject(t, fastPathMinimumSources) + project.recordBaseline(t) + generatedPath := filepath.Join(project.root, project.records().generatedDirectory("Alpha")) + if _, err := os.Stat(generatedPath); err != nil { + t.Fatalf("expected the compile to leave its generator output directory: %v", err) + } + compiler := Compiler{Paths: project.paths, ProjectRoot: project.root} - outputs, complete, err := hashOutputs(outputDirectoryPath, []string{"Alpha" + assemblyExtension}, true) + if err := compiler.removeUnitOutputs(project.plan, project.plan.Units[0]); err != nil { + t.Fatalf("expected the outputs to be removed, got error: %v", err) + } - if err != nil || !complete || len(outputs) != 1 || outputs["Alpha"+assemblyExtension] == "" { - t.Fatalf("outputs=%v complete=%v err=%v", outputs, complete, err) + for _, name := range unitOutputNames(project.plan.Units[0]) { + if _, err := os.Stat(filepath.Join(project.outputDirectory(), name)); !os.IsNotExist(err) { + t.Errorf("expected %s to be removed, got %v", name, err) + } + } + if project.hasRecordedCompile("Alpha") || project.hasBaseline() { + t.Error("expected no compile result and no baseline after the outputs were removed") + } + if _, err := os.Stat(generatedPath); !os.IsNotExist(err) { + t.Errorf("expected the generator output to be removed, got %v", err) } } @@ -91,23 +57,26 @@ func TestHashOutputsHashesOnlyTheNamedOutputs(t *testing.T) { func TestCompileUnitsFailsWhenAResultCannotBeRecorded(t *testing.T) { project := newCachedSchedulerProject(t, nil, "Alpha") recorder := newCachedSchedulerRecorder("Alpha") + recorder.output["Alpha"] = failingCompilerOutput("Alpha") compileRunner := recorder.runner() compiler := Compiler{ Paths: project.paths, ProjectRoot: project.root, Timeout: time.Minute, - // Why the block is placed during the compile: the compile removes any previous manifest before - // it starts, so only a path occupied after that point reaches the manifest write. + // Why the output directory goes during the compile: the compile failed, so its missing outputs + // are allowed, and only writing the record finds nowhere to go. Run: func(ctx context.Context, cmd *exec.Cmd) (string, string, int, error) { stdout, stderr, exitCode, err := compileRunner(ctx, cmd) - blockManifestPath(t, project.manifestPathOf("Alpha")) + if removeErr := os.RemoveAll(project.outputDirectory()); removeErr != nil { + t.Errorf("failed to remove the output directory: %v", removeErr) + } return stdout, stderr, exitCode, err }, } _, err := compileUnits(context.Background(), compiler, project.plan, 1) - if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+project.manifestPathOf("Alpha")+":") { - t.Fatalf("err = %v, want the manifest write failure", err) + if err == nil || !strings.HasPrefix(err.Error(), "failed to write "+project.outputDirectory()+string(filepath.Separator)) { + t.Fatalf("err = %v, want the record write failure", err) } } diff --git a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go index 1e0af8bd5..8eb8c39f3 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go @@ -108,16 +108,38 @@ func (project *cachedSchedulerProject) outputDirectory() string { return filepath.Join(project.root, project.plan.OutputDir) } -// manifestPathOf names one assembly's result manifest. -func (project *cachedSchedulerProject) manifestPathOf(name string) string { - return filepath.Join(project.outputDirectory(), name+resultManifestExtension) +// writeOutputFile writes one file into the check's own output directory. +func writeOutputFile(t *testing.T, outputDirectoryPath string, name string, content string) { + t.Helper() + if err := os.WriteFile( + filepath.Join(outputDirectoryPath, name), []byte(content), 0o600); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } +} + +// records opens the store the runs of this test project write to. +func (project *cachedSchedulerProject) records() recordStore { + return openRecordStore(project.root, project.plan.OutputDir) } -// hasManifest reports whether one assembly has a recorded result right now. -func (project *cachedSchedulerProject) hasManifest(name string) bool { - _, err := os.Stat(project.manifestPathOf(name)) +// hasBaselineOf reports whether a baseline of the current format is on disk for the assembly. +// Why the "no baseline:" prefix tells: the store gives it only when the baseline file is missing or +// was written by another version; a baseline file that disagrees with its compile result is +// reported as "baseline compile is no longer on disk" instead. +func (project *cachedSchedulerProject) hasBaselineOf(name string) bool { + _, reason := project.records().loadBaseline(name) - return err == nil + return !strings.HasPrefix(reason, "no baseline:") +} + +// hasRecordedCompile reports whether a compile result of the assembly is on record. +// Why the baseline reason tells: these assemblies have fewer sources than the changed-files check +// needs, so they never get a baseline, and the store explains the missing baseline from the +// compile result alone - this reason is the one it gives when there is no compile result. +func (project *cachedSchedulerProject) hasRecordedCompile(name string) bool { + _, reason := project.records().loadBaseline(name) + + return reason != "no baseline: no full compile of this assembly is on record" } // compile runs the whole plan through the scheduler with a fake compiler. @@ -306,20 +328,20 @@ func TestCompileUnitsBlocksTheDependentsOfAReplayedFailure(t *testing.T) { func TestCompileUnitsHasNoManifestWhileAUnitIsCompiling(t *testing.T) { project := newCachedSchedulerProject(t, nil, "Alpha") project.compileOrFail(t, newCachedSchedulerRecorder("Alpha")) - if !project.hasManifest("Alpha") { + if !project.hasRecordedCompile("Alpha") { t.Fatal("the first run should have recorded a result") } project.editSource(t, "Alpha", "class Alpha { void Body() { } }") recorder := newCachedSchedulerRecorder("Alpha") present := map[string]bool{} - recorder.during = func(name string) { present[name] = project.hasManifest(name) } + recorder.during = func(name string) { present[name] = project.hasRecordedCompile(name) } project.compileOrFail(t, recorder) if present["Alpha"] { t.Error("the manifest of the previous compile should be gone while the unit compiles") } - if !project.hasManifest("Alpha") { + if !project.hasRecordedCompile("Alpha") { t.Error("the finished compile should have been recorded") } } @@ -333,7 +355,7 @@ func TestCompileUnitsRecordsNothingForAUnitThatCouldNotBeStarted(t *testing.T) { if _, err := project.compile(context.Background(), failing); err == nil { t.Fatal("expected the failure to start the compiler to be reported") } - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Error("a unit whose compiler never ran should have no recorded result") } @@ -356,7 +378,7 @@ func TestCompileUnitsRecordsNothingForACancelledUnit(t *testing.T) { t.Fatal("expected the cancelled run to be reported as a failure") } cancel() - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Error("a cancelled compile should have no recorded result") } @@ -398,7 +420,7 @@ func TestCompileUnitsRecordsNothingWhenTheCompilerFailedWithoutADiagnostic(t *te broken.output["Alpha"] = fakeCompilerOutput{stdout: "csc : fatal error", exitCode: 1} project.compileOrFail(t, broken) - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Error("a compile that failed without a diagnostic should have no recorded result") } @@ -410,21 +432,31 @@ func TestCompileUnitsRecordsNothingWhenTheCompilerFailedWithoutADiagnostic(t *te } } -// Verifies an output that no longer holds what the compile wrote sends the unit back through the -// compiler: the assemblies below it read those bytes. +// Verifies that either output - the assembly or the reference assembly - no longer holding what the +// compile wrote sends the unit back through the compiler: the assemblies below it read those bytes. func TestCompileUnitsCompilesAgainWhenAnOutputWasChanged(t *testing.T) { - project := newCachedSchedulerProject(t, nil, "Alpha") - project.compileOrFail(t, newCachedSchedulerRecorder("Alpha")) - - assemblyPath := filepath.Join(project.outputDirectory(), "Alpha"+assemblyExtension) - if err := os.WriteFile(assemblyPath, []byte("something else"), 0o600); err != nil { - t.Fatalf("failed to change the output: %v", err) - } - recorder := newCachedSchedulerRecorder("Alpha") - project.compileOrFail(t, recorder) - - if recorder.runs["Alpha"] != 1 { - t.Errorf("a unit whose output changed should have compiled, got %d runs", recorder.runs["Alpha"]) + for name, output := range map[string]string{ + "assembly": "Alpha" + assemblyExtension, + "reference assembly": "Alpha" + referenceAssemblyExtension, + } { + t.Run(name, func(t *testing.T) { + project := newCachedSchedulerProject(t, nil, "Alpha") + project.compileOrFail(t, newCachedSchedulerRecorder("Alpha")) + + outputPath := filepath.Join(project.outputDirectory(), output) + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("expected the compile to write %s: %v", output, err) + } + if err := os.WriteFile(outputPath, []byte("something else"), 0o600); err != nil { + t.Fatalf("failed to change the output: %v", err) + } + recorder := newCachedSchedulerRecorder("Alpha") + project.compileOrFail(t, recorder) + + if recorder.runs["Alpha"] != 1 { + t.Errorf("a unit whose %s changed should have compiled, got %d runs", name, recorder.runs["Alpha"]) + } + }) } } @@ -433,7 +465,7 @@ func TestCompileUnitsCompilesAgainWhenAnOutputWasChanged(t *testing.T) { func TestCompileUnitsLeavesNoManifestForASkippedDependent(t *testing.T) { project := newCachedSchedulerProject(t, map[string][]string{"Beta": {"Alpha"}}, "Alpha", "Beta") project.compileOrFail(t, newCachedSchedulerRecorder("Alpha", "Beta")) - if !project.hasManifest("Beta") { + if !project.hasRecordedCompile("Beta") { t.Fatal("the first run should have recorded a result for the dependent") } @@ -454,7 +486,7 @@ func TestCompileUnitsLeavesNoManifestForASkippedDependent(t *testing.T) { if recorder.runs["Beta"] != 0 { t.Errorf("the skipped dependent should not have compiled, got %d runs", recorder.runs["Beta"]) } - if project.hasManifest("Beta") { + if project.hasRecordedCompile("Beta") { t.Error("the skipped dependent's recorded result should have been removed with its outputs") } } @@ -478,7 +510,7 @@ func TestCompileUnitsRecordsNothingForAUnitKilledWhileItWasReportingDiagnostics( } cancel() - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Fatal("a killed compile must leave no recorded result to replay") } @@ -500,7 +532,7 @@ func TestCompileUnitsRecordsNothingWhenASuccessfulCompileWroteNoOutput(t *testin project.compileOrFail(t, silent) - if project.hasManifest("Alpha") { + if project.hasRecordedCompile("Alpha") { t.Fatal("a compile that produced no output must leave no recorded result") }