@@ -3,6 +3,7 @@ import sinonGlobal from "sinon";
33import { fileURLToPath } from "node:url" ;
44import { setTimeout } from "node:timers/promises" ;
55import fs from "node:fs/promises" ;
6+ import { appendFileSync } from "node:fs" ;
67import { graphFromPackageDependencies } from "../../../lib/graph/graph.js" ;
78import { setLogLevel } from "@ui5/logger" ;
89import Cache from "../../../lib/build/cache/Cache.js" ;
@@ -762,6 +763,81 @@ test.serial(
762763 }
763764) ;
764765
766+ // Regression: a build that hits the NO_CACHE state in prepareProjectBuildAndValidateCache
767+ // (because the source signature does not match anything in the persistent cache) and then
768+ // throws SourceChangedDuringBuildError from allTasksCompleted used to fail on retry with
769+ // "Unexpected result cache state after restoring dependency indices for project XYZ: no_cache".
770+ // The fix resets #resultCacheState to PENDING_VALIDATION in the source-changed branch.
771+ //
772+ // Repro recipe — must hit *all* of these conditions on the same ProjectBuildCache instance:
773+ // 1. A first build runs to completion, populating the persistent index + result cache.
774+ // 2. The project is invalidated (a real source change observed by the watcher) so the next
775+ // reader request drives a second build.
776+ // 3. The second build's #initSourceIndex finds an existing index cache and transitions to
777+ // RESTORING_DEPENDENCY_INDICES (rather than INITIAL, which short-circuits prepare).
778+ // 4. prepareProjectBuildAndValidateCache sees a source-signature mismatch against the
779+ // persisted result cache and sets #resultCacheState = NO_CACHE.
780+ // 5. A *further* on-disk source change lands during the second build, but the watcher path
781+ // is stubbed so the abort signal is never set. allTasksCompleted's revalidateSourceIndex
782+ // then throws SourceChangedDuringBuildError instead of taking the abort path.
783+ test . serial ( "Source change during second build retries cleanly without no_cache error" , async ( t ) => {
784+ const fixtureTester = t . context . fixtureTester = await FixtureTester . create ( t , "library.d" ) ;
785+ await fixtureTester . serveProject ( {
786+ config : { excludedTasks : [ "minify" ] }
787+ } ) ;
788+
789+ const changedFilePath = `${ fixtureTester . fixturePath } /main/src/library/d/some.js` ;
790+ const originalContent = await fs . readFile ( changedFilePath , { encoding : "utf8" } ) ;
791+
792+ // Build 1 — populates the on-disk index + result cache.
793+ await fixtureTester . requestResource ( { resource : "/resources/library/d/some.js" } ) ;
794+
795+ // Invalidate the project for build 2 by touching the source file. Use the live watcher
796+ // path here: a real modification needs to flow through _projectResourceChanged so the
797+ // project transitions to INVALIDATED and the next request enqueues a rebuild.
798+ await fs . writeFile ( changedFilePath , originalContent + "\n// pre-build-2 change\n" ) ;
799+ await setTimeout ( 500 ) ; // let the watcher fire and settle
800+
801+ // Now suppress further watcher-driven aborts. The mid-build modification below is meant
802+ // to flow through #revalidateSourceIndex inside allTasksCompleted, *not* through the
803+ // watcher — otherwise the abort path runs first and the no_cache assertion never fires.
804+ t . context . sinon . stub ( fixtureTester . buildServer , "_projectResourceChanged" ) ;
805+
806+ // During build 2's task pipeline, append a second on-disk change. Hook the first task
807+ // that is *not* short-circuited from cache (replaceCopyright) so the synchronous write
808+ // lands well before allTasksCompleted's #revalidateSourceIndex reads from disk.
809+ let triggered = false ;
810+ const handler = ( event ) => {
811+ if (
812+ ! triggered &&
813+ event . projectName === "library.d" &&
814+ event . status === "task-start" &&
815+ event . taskName === "replaceCopyright"
816+ ) {
817+ triggered = true ;
818+ appendFileSync ( changedFilePath , "\n// mid-build-2 change\n" ) ;
819+ }
820+ } ;
821+ process . on ( "ui5.project-build-status" , handler ) ;
822+
823+ let resource ;
824+ try {
825+ // Without the fix this rejects with
826+ // "Unexpected result cache state after restoring dependency indices for project XYZ: no_cache".
827+ resource = await fixtureTester . _reader . byPath ( "/resources/library/d/some.js" ) ;
828+ } finally {
829+ process . off ( "ui5.project-build-status" , handler ) ;
830+ }
831+
832+ t . true ( triggered , "Test setup precondition: source change handler fired during build 2" ) ;
833+
834+ const servedContent = await resource . getString ( ) ;
835+ t . true ( servedContent . includes ( "pre-build-2 change" ) ,
836+ "Retry served content reflecting the pre-build-2 change" ) ;
837+ t . true ( servedContent . includes ( "mid-build-2 change" ) ,
838+ "Retry served content reflecting the mid-build-2 change" ) ;
839+ } ) ;
840+
765841function getFixturePath ( fixtureName ) {
766842 return fileURLToPath ( new URL ( `../../fixtures/${ fixtureName } ` , import . meta. url ) ) ;
767843}
0 commit comments