From 462f57d004509bd5a0c51d0b14414c63fbf4e17e Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 15 Oct 2026 12:09:40 +0900 Subject: [PATCH 1/2] test: check the records through the record store from outside it Tests of the record internals (manifest and baseline formats, paths, versions, atomic writes, output and generator output hashing, forgetting) move to the record store's own test files and work from output names instead of compile units. The other tests observe the records only through the store: a usable baseline, whether one of the current format exists, whether a compile result is on record, and a snapshot of every file the check keeps for an assembly. No expected reason, byte or count changed. --- .../compilecheck/baseline_record_test.go | 62 +---- .../compilecheck/fast_path_fixture_test.go | 99 ++++--- .../compilecheck/fast_path_order_test.go | 6 +- .../internal/compilecheck/fast_path_test.go | 61 +++-- .../compilecheck/record_format_test.go | 28 +- .../record_store_baseline_test.go | 48 ++++ .../compilecheck/record_store_test.go | 255 ++++++++++++++++++ .../compilecheck/result_cache_test.go | 191 ------------- .../compilecheck/result_cache_write_test.go | 101 +++---- .../compilecheck/scheduler_cache_test.go | 56 ++-- 10 files changed, 479 insertions(+), 428 deletions(-) create mode 100644 cli/dispatcher/internal/compilecheck/record_store_baseline_test.go create mode 100644 cli/dispatcher/internal/compilecheck/record_store_test.go delete mode 100644 cli/dispatcher/internal/compilecheck/result_cache_test.go 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..7a7e8b30a 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) +} + +// 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 !strings.HasPrefix(reason, "no baseline:") } -// hasManifest reports whether one assembly has a recorded result right now. -func (project *cachedSchedulerProject) hasManifest(name string) bool { - _, err := os.Stat(project.manifestPathOf(name)) +// 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 err == nil + 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") } @@ -433,7 +455,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 +476,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 +500,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 +522,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") } From ff4cfac855c4731cc3ff636f631a2fe03c182008 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 15 Oct 2026 12:35:53 +0900 Subject: [PATCH 2/2] test: compile again when either recorded output was changed The store's own tests now record a fixed list of outputs, so nothing checked that a compile result covers the reference assembly the unit names. The test that changes an output now changes the assembly and the reference assembly in turn, and each must send the unit back through the compiler. --- .../compilecheck/scheduler_cache_test.go | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go index 7a7e8b30a..8eb8c39f3 100644 --- a/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go +++ b/cli/dispatcher/internal/compilecheck/scheduler_cache_test.go @@ -432,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"]) + } + }) } }