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..23980160b14 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 UI.` + ); + } } 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..de34d392a18 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,47 @@ 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", () => { + 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.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", () => { + 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( + removedPaths.some((p) => 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", () => {