From 7ac5a565339ace2814b805d43093a9be83ede444 Mon Sep 17 00:00:00 2001 From: Neil Owen Date: Tue, 30 Jun 2026 15:59:54 -0400 Subject: [PATCH 1/2] fix(fx-core): recover from corrupted template metadata cache Handle corrupted ~/.fx metadata and UI cache JSON by cleaning invalid cache entries and falling back to bundled metadata; add regression tests for recovery behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generator/templates/metadata/index.ts | 34 +++++++++++++- .../src/question/scaffold/vsc/rootNode.ts | 38 +++++++++++++-- .../generator/templates/metadata.test.ts | 46 +++++++++++++++++++ .../fx-core/tests/question/scaffold.test.ts | 20 ++++++++ 4 files changed, 133 insertions(+), 5 deletions(-) diff --git a/packages/fx-core/src/component/generator/templates/metadata/index.ts b/packages/fx-core/src/component/generator/templates/metadata/index.ts index 33cc4f12133..58aec274df2 100644 --- a/packages/fx-core/src/component/generator/templates/metadata/index.ts +++ b/packages/fx-core/src/component/generator/templates/metadata/index.ts @@ -9,6 +9,32 @@ import * as folder from "../../../../folder"; import * as templateHelper from "../../templateHelper"; import { Template } from "./interface"; +function getMetadataVersionFiles(platform?: Platform): string[] { + const configRoot = path.join(os.homedir(), `.${String(ConfigFolderName)}`); + if (platform === Platform.VS) { + return [path.join(configRoot, "vs-metadata", "template-vs-version.txt")]; + } + return [ + path.join(configRoot, "template-version.txt"), + path.join(configRoot, "template-version-v4.txt"), + ]; +} + +function invalidateCorruptedMetadataCache(cacheFilePath: string, platform?: Platform): void { + try { + fs.removeSync(cacheFilePath); + } catch { + // best-effort cleanup + } + for (const versionFile of getMetadataVersionFiles(platform)) { + try { + fs.removeSync(versionFile); + } catch { + // best-effort cleanup + } + } +} + function getTemplateMetadataConfig(configName: string, platform?: Platform): Template[] { let jsonPath: string; @@ -31,7 +57,13 @@ function getTemplateMetadataConfig(configName: string, platform?: Platform): Tem cachedJsonPath && fs.pathExistsSync(cachedJsonPath) ) { - jsonPath = cachedJsonPath; + try { + const content = fs.readFileSync(cachedJsonPath, "utf-8"); + return JSON.parse(content) as Template[]; + } catch { + invalidateCorruptedMetadataCache(cachedJsonPath, platform); + } + jsonPath = path.join(folder.getTemplatesFolder(), "metadata", configName); } else { jsonPath = path.join(folder.getTemplatesFolder(), "metadata", configName); } diff --git a/packages/fx-core/src/question/scaffold/vsc/rootNode.ts b/packages/fx-core/src/question/scaffold/vsc/rootNode.ts index add063e5552..4aa8676cf26 100644 --- a/packages/fx-core/src/question/scaffold/vsc/rootNode.ts +++ b/packages/fx-core/src/question/scaffold/vsc/rootNode.ts @@ -11,6 +11,26 @@ import * as templateHelper from "../../../component/generator/templateHelper"; import * as folder from "../../../folder"; import { constructNode } from "../constructNode"; +function invalidateCorruptedUiCache(cacheFilePath: string): void { + try { + fs.removeSync(cacheFilePath); + } catch { + // best-effort cleanup + } + const configRoot = path.join(os.homedir(), `.${String(ConfigFolderName)}`); + const versionFiles = [ + path.join(configRoot, "template-version.txt"), + path.join(configRoot, "template-version-v4.txt"), + ]; + for (const versionFile of versionFiles) { + try { + fs.removeSync(versionFile); + } catch { + // best-effort cleanup + } + } +} + /** * Load the wizard question tree from wizardNode.json. * Combined JSON with all sub-trees inlined. @@ -43,16 +63,26 @@ function loadUiNode(fileName: string, platform: Platform): IQTreeNode { // escape hatch in metadata/index.ts — they carry no local code bindings.) const v4Enabled = featureFlagManager.getBooleanValue(FeatureFlags.V4Enabled); - let jsonPath: string; let source: string; if (!v4Enabled && !templateHelper.useLocalTemplate() && fs.pathExistsSync(cachedJsonPath)) { - jsonPath = cachedJsonPath; - source = "cache"; + try { + const content = fs.readFileSync(cachedJsonPath, "utf-8"); + TOOLS?.logProvider?.info( + `[Dynamic Template] Loaded ${fileName} from cache: ${cachedJsonPath}` + ); + return constructNode(content, platform); + } catch { + invalidateCorruptedUiCache(cachedJsonPath); + source = "bundled"; + TOOLS?.logProvider?.info( + `[Dynamic Template] Cached ${fileName} is corrupted, fallback to bundled metadata.` + ); + } } else { - jsonPath = path.join(folder.getTemplatesFolder(), "ui", fileName); source = "bundled"; } + const jsonPath = path.join(folder.getTemplatesFolder(), "ui", fileName); const content = fs.readFileSync(jsonPath, "utf-8"); TOOLS?.logProvider?.info(`[Dynamic Template] Loaded ${fileName} from ${source}: ${jsonPath}`); return constructNode(content, platform); diff --git a/packages/fx-core/tests/component/generator/templates/metadata.test.ts b/packages/fx-core/tests/component/generator/templates/metadata.test.ts index 2e781cceec7..d5f85004d7d 100644 --- a/packages/fx-core/tests/component/generator/templates/metadata.test.ts +++ b/packages/fx-core/tests/component/generator/templates/metadata.test.ts @@ -81,6 +81,52 @@ describe("metadata platform routing", () => { assert.include(readPath, path.join("metadata", "allTemplates.json")); }); + it("falls back to bundled metadata and clears cache markers when cached metadata is corrupted", () => { + sandbox.stub(templateHelper, "useLocalTemplate").returns(false); + sandbox.stub(folder, "getTemplatesFolder").returns(path.resolve("/bundled")); + sandbox.stub(fs, "pathExistsSync").returns(true); + const readFileSyncStub = sandbox.stub(fs, "readFileSync"); + readFileSyncStub.onFirstCall().throws(new Error("corrupted cache")); + readFileSyncStub.onSecondCall().returns(JSON.stringify(mockTemplates)); + const removeSyncStub = sandbox.stub(fs, "removeSync"); + + const result = getAllTemplatesOnPlatform(Platform.VSCode); + + assert.deepEqual(result, [mockTemplates[0], mockTemplates[2]]); + assert.isTrue(removeSyncStub.called); + assert.isTrue( + removeSyncStub.calledWithMatch( + sinon.match((p) => String(p).includes("template-version.txt")) + ) + ); + assert.isTrue( + removeSyncStub.calledWithMatch( + sinon.match((p) => String(p).includes("template-version-v4.txt")) + ) + ); + }); + + it("clears VS marker when VS cached metadata is corrupted", () => { + sandbox.stub(templateHelper, "useLocalTemplate").returns(false); + sandbox.stub(folder, "getTemplatesFolder").returns(path.resolve("/bundled")); + sandbox.stub(fs, "pathExistsSync").returns(true); + const readFileSyncStub = sandbox.stub(fs, "readFileSync"); + readFileSyncStub.onFirstCall().throws(new Error("corrupted cache")); + readFileSyncStub.onSecondCall().returns(JSON.stringify(mockTemplates)); + const removeSyncStub = sandbox.stub(fs, "removeSync"); + + const result = getAllTemplatesOnPlatform(Platform.VS); + + assert.deepEqual(result, [mockTemplates[1]]); + assert.isTrue( + removeSyncStub.calledWithMatch( + sinon.match((p) => + String(p).includes(path.join("vs-metadata", "template-vs-version.txt")) + ) + ) + ); + }); + it("falls back to bundled path when useLocalTemplate is true", () => { vi.spyOn(templateHelper, "useLocalTemplate").mockReturnValue(true); vi.spyOn(folder, "getTemplatesFolder").mockReturnValue(path.resolve("/bundled")); diff --git a/packages/fx-core/tests/question/scaffold.test.ts b/packages/fx-core/tests/question/scaffold.test.ts index 680d0dd4a82..8d3bcf43382 100644 --- a/packages/fx-core/tests/question/scaffold.test.ts +++ b/packages/fx-core/tests/question/scaffold.test.ts @@ -1471,6 +1471,26 @@ describe("rootNode", () => { const readPath = String(readFileSyncStub.mock.calls[0][0]); assert.include(readPath, path.join(`.${String(ConfigFolderName)}`, "ui", "wizardNode.json")); }); + + it("clears cache markers and falls back to bundled UI when v3 cache JSON is corrupted", () => { + vi.spyOn(templateHelper, "useLocalTemplate").mockReturnValue(false); + vi.spyOn(featureFlagManager, "getBooleanValue").mockReturnValue(false); + vi.spyOn(fs, "pathExistsSync").mockReturnValue(true); + vi.spyOn(fs, "readFileSync") + .mockImplementationOnce(() => { + throw new Error("bad json"); + }) + .mockReturnValueOnce(JSON.stringify({ data: { type: "group", name: "root" }, children: [] })); + const removeSyncStub = vi.spyOn(fs, "removeSync").mockImplementation(() => undefined as any); + + const node = getRootProjectTypeNode(Platform.VSCode); + + assert.isDefined(node); + assert.equal(node.data?.name, "root"); + const removedPaths = removeSyncStub.mock.calls.map((call) => String(call[0])); + assert.isTrue(removedPaths.some((p) => p.includes("template-version.txt"))); + assert.isTrue(removedPaths.some((p) => p.includes("template-version-v4.txt"))); + }); }); describe("constructNode", () => { From b6ad46c3d9da0f40153cf0a1e74ead55d16915b5 Mon Sep 17 00:00:00 2001 From: Neil Owen Date: Wed, 8 Jul 2026 10:32:06 -0400 Subject: [PATCH 2/2] fix(fx-core): use vitest spies in metadata tests; fix UI fallback log message --- .../src/question/scaffold/vsc/rootNode.ts | 2 +- .../generator/templates/metadata.test.ts | 55 +++++++++---------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/packages/fx-core/src/question/scaffold/vsc/rootNode.ts b/packages/fx-core/src/question/scaffold/vsc/rootNode.ts index 4aa8676cf26..23980160b14 100644 --- a/packages/fx-core/src/question/scaffold/vsc/rootNode.ts +++ b/packages/fx-core/src/question/scaffold/vsc/rootNode.ts @@ -75,7 +75,7 @@ function loadUiNode(fileName: string, platform: Platform): IQTreeNode { invalidateCorruptedUiCache(cachedJsonPath); source = "bundled"; TOOLS?.logProvider?.info( - `[Dynamic Template] Cached ${fileName} is corrupted, fallback to bundled metadata.` + `[Dynamic Template] Cached ${fileName} is corrupted, fallback to bundled UI.` ); } } else { diff --git a/packages/fx-core/tests/component/generator/templates/metadata.test.ts b/packages/fx-core/tests/component/generator/templates/metadata.test.ts index d5f85004d7d..de34d392a18 100644 --- a/packages/fx-core/tests/component/generator/templates/metadata.test.ts +++ b/packages/fx-core/tests/component/generator/templates/metadata.test.ts @@ -82,48 +82,43 @@ describe("metadata platform routing", () => { }); it("falls back to bundled metadata and clears cache markers when cached metadata is corrupted", () => { - sandbox.stub(templateHelper, "useLocalTemplate").returns(false); - sandbox.stub(folder, "getTemplatesFolder").returns(path.resolve("/bundled")); - sandbox.stub(fs, "pathExistsSync").returns(true); - const readFileSyncStub = sandbox.stub(fs, "readFileSync"); - readFileSyncStub.onFirstCall().throws(new Error("corrupted cache")); - readFileSyncStub.onSecondCall().returns(JSON.stringify(mockTemplates)); - const removeSyncStub = sandbox.stub(fs, "removeSync"); + vi.spyOn(templateHelper, "useLocalTemplate").mockReturnValue(false); + vi.spyOn(folder, "getTemplatesFolder").mockReturnValue(path.resolve("/bundled")); + vi.spyOn(fs, "pathExistsSync").mockReturnValue(true); + const readFileSyncStub = vi + .spyOn(fs, "readFileSync") + .mockImplementationOnce(() => { + throw new Error("corrupted cache"); + }) + .mockReturnValue(JSON.stringify(mockTemplates) as any); + const removeSyncStub = vi.spyOn(fs, "removeSync").mockImplementation(() => {}); const result = getAllTemplatesOnPlatform(Platform.VSCode); assert.deepEqual(result, [mockTemplates[0], mockTemplates[2]]); - assert.isTrue(removeSyncStub.called); - assert.isTrue( - removeSyncStub.calledWithMatch( - sinon.match((p) => String(p).includes("template-version.txt")) - ) - ); - assert.isTrue( - removeSyncStub.calledWithMatch( - sinon.match((p) => String(p).includes("template-version-v4.txt")) - ) - ); + assert.isTrue(removeSyncStub.mock.calls.length > 0); + const removedPaths = removeSyncStub.mock.calls.map((c) => String(c[0])); + assert.isTrue(removedPaths.some((p) => p.includes("template-version.txt"))); + assert.isTrue(removedPaths.some((p) => p.includes("template-version-v4.txt"))); }); it("clears VS marker when VS cached metadata is corrupted", () => { - sandbox.stub(templateHelper, "useLocalTemplate").returns(false); - sandbox.stub(folder, "getTemplatesFolder").returns(path.resolve("/bundled")); - sandbox.stub(fs, "pathExistsSync").returns(true); - const readFileSyncStub = sandbox.stub(fs, "readFileSync"); - readFileSyncStub.onFirstCall().throws(new Error("corrupted cache")); - readFileSyncStub.onSecondCall().returns(JSON.stringify(mockTemplates)); - const removeSyncStub = sandbox.stub(fs, "removeSync"); + vi.spyOn(templateHelper, "useLocalTemplate").mockReturnValue(false); + vi.spyOn(folder, "getTemplatesFolder").mockReturnValue(path.resolve("/bundled")); + vi.spyOn(fs, "pathExistsSync").mockReturnValue(true); + vi.spyOn(fs, "readFileSync") + .mockImplementationOnce(() => { + throw new Error("corrupted cache"); + }) + .mockReturnValue(JSON.stringify(mockTemplates) as any); + const removeSyncStub = vi.spyOn(fs, "removeSync").mockImplementation(() => {}); const result = getAllTemplatesOnPlatform(Platform.VS); assert.deepEqual(result, [mockTemplates[1]]); + const removedPaths = removeSyncStub.mock.calls.map((c) => String(c[0])); assert.isTrue( - removeSyncStub.calledWithMatch( - sinon.match((p) => - String(p).includes(path.join("vs-metadata", "template-vs-version.txt")) - ) - ) + removedPaths.some((p) => p.includes(path.join("vs-metadata", "template-vs-version.txt"))) ); });