Skip to content

Commit 23a1773

Browse files
committed
refactor(fx-core): delegate manifest template resolution to app-manifest
Now that the resolver lives in @microsoft/app-manifest (reached transitively via @microsoft/teamsfx-api), remove the duplicated logic from fx-core and delegate: - component/utils/common.ts re-exports expandEnvironmentVariable and getEnvironmentVariables from @microsoft/teamsfx-api, so existing call sites are unchanged. - envFunctionUtils.expandVariableWithFunction delegates its loop to expandFileFunctionMacros, keeping only the telemetry event and mapping the resolver's plain errors to localized UserError / fx-core FileNotFoundError via toFxError. getResolvedManifest is unchanged and keeps composing these primitives. Update the affected tests to drive resolution through real temp files instead of fs mocks, since file reads now happen inside app-manifest.
1 parent 68999ef commit 23a1773

4 files changed

Lines changed: 106 additions & 218 deletions

File tree

packages/fx-core/src/component/utils/common.ts

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ import { getLocalizedString } from "../../common/localizeUtils";
1818
import { SummaryConstant } from "../configManager/constant";
1919
import { EOL } from "os";
2020

21-
const placeholderRegex = /\${{ *[a-zA-Z_][a-zA-Z0-9_]* *}}/g;
22-
2321
/**
2422
* check parameter, throw error if value is null or undefined
2523
* @param name parameter name
@@ -128,44 +126,10 @@ export async function wrapSummary(
128126
}
129127
}
130128

131-
// Expand environment variables in content. The format of referencing environment variable is: ${{ENV_NAME}}
132-
export function expandEnvironmentVariable(
133-
content: string,
134-
envs?: { [key in string]: string }
135-
): string {
136-
const placeholders = content.match(placeholderRegex);
137-
if (placeholders) {
138-
for (const placeholder of placeholders) {
139-
const envName = placeholder.slice(3, -2).trim(); // removes `${{` and `}}`
140-
const envValue = envs ? envs[envName] : process.env[envName];
141-
if (envName === "APP_NAME_SUFFIX") {
142-
if (envValue !== undefined && envValue !== null) {
143-
content = content.replace(placeholder, envValue);
144-
}
145-
} else {
146-
if (envValue) {
147-
content = content.replace(placeholder, envValue);
148-
}
149-
}
150-
}
151-
}
152-
153-
return content;
154-
}
155-
156-
/**
157-
* Expand environment variables in content. The format of referencing environment variable is: ${{ENV_NAME}}
158-
* @return An array of environment variables
159-
*/
160-
export function getEnvironmentVariables(content: string): string[] {
161-
const placeholders = content.match(placeholderRegex);
162-
if (placeholders) {
163-
const variables = placeholders.map((placeholder) => placeholder.slice(3, -2).trim()); // removes `${{` and `}}`)
164-
// remove duplicates
165-
return [...new Set(variables)];
166-
}
167-
return [];
168-
}
129+
// Manifest env-variable (`${{ENV_NAME}}`) expansion lives in @microsoft/app-manifest
130+
// (re-exported via @microsoft/teamsfx-api) so hosts can resolve manifests without
131+
// fx-core. Re-exported here to keep the existing import path for fx-core call sites.
132+
export { expandEnvironmentVariable, getEnvironmentVariables } from "@microsoft/teamsfx-api";
169133

170134
export function getAbsolutePath(relativeOrAbsolutePath: string, projectPath: string): string {
171135
relativeOrAbsolutePath = relativeOrAbsolutePath || "";

packages/fx-core/src/component/utils/envFunctionUtils.ts

Lines changed: 49 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@ import {
88
Result,
99
UserError,
1010
UserErrorOptions,
11+
// Host-agnostic manifest-template resolution lives in @microsoft/app-manifest and
12+
// is re-exported by @microsoft/teamsfx-api. fx-core stays the home for telemetry,
13+
// localized messages, and FxError mapping; the resolution logic lives one layer down.
14+
ManifestType,
15+
expandFileFunctionMacros,
16+
UnsupportedFileFormatError as ManifestUnsupportedFileFormatError,
17+
InvalidFunctionError as ManifestInvalidFunctionError,
18+
InvalidFunctionParameterError as ManifestInvalidFunctionParameterError,
19+
ReadFileError as ManifestReadFileError,
20+
FileNotFoundError as ManifestFileNotFoundError,
1121
} from "@microsoft/teamsfx-api";
12-
import path from "path";
13-
import fs from "fs-extra";
14-
import stripBom from "strip-bom";
1522
import { FileNotFoundError } from "../../error";
16-
import { expandEnvironmentVariable } from "./common";
1723
import { getLocalizedString } from "../../common/localizeUtils";
1824
import { DriverContext } from "../driver/interface/commonArgs";
1925

@@ -26,145 +32,64 @@ enum TelemetryPropertyKey {
2632
functionCount = "function-count",
2733
}
2834

29-
export enum ManifestType {
30-
TeamsManifest = "teams-manifest",
31-
PluginManifest = "plugin-manifest",
32-
DeclarativeCopilotManifest = "declarative-copilot-manifest",
33-
ApiSpec = "api-spec",
34-
EmbeddedKnowledgeFile = "embedded-knowledge-file",
35-
}
35+
// Re-exported for existing importers (ManifestUtils, PluginManifestUtils, utils, createAppPackage).
36+
export { ManifestType };
3637

37-
export async function expandVariableWithFunction(
38-
content: string,
39-
ctx: DriverContext,
40-
envs: { [key in string]: string } | undefined,
41-
isJson: boolean,
42-
manifestType: ManifestType,
43-
fromPath: string
44-
): Promise<Result<string, FxError>> {
45-
const regex = /\$\[ *[a-zA-Z][a-zA-Z]*\([^\]]*\) *\]/g;
46-
const matches = content.match(regex);
47-
48-
if (!matches) {
49-
return ok(content); // no function
50-
}
51-
let count = 0;
52-
for (const placeholder of matches) {
53-
const processedRes = await processFunction(
54-
placeholder.slice(2, -1).trim(),
55-
ctx,
56-
envs,
57-
fromPath
38+
// Map a plain error thrown by @microsoft/app-manifest's resolver to the localized
39+
// FxError surface fx-core drivers expect, emitting the same diagnostic logs as before.
40+
function toFxError(e: unknown, ctx: DriverContext): FxError {
41+
if (e instanceof ManifestUnsupportedFileFormatError) {
42+
ctx.logProvider.error(
43+
getLocalizedString("core.envFunc.unsupportedFile.errorLog", e.filePath, "txt")
5844
);
59-
if (processedRes.isErr()) {
60-
return err(processedRes.error);
61-
}
62-
let value = processedRes.value;
63-
if (isJson && value) {
64-
value = JSON.stringify(value).slice(1, -1);
65-
}
66-
if (value) {
67-
count += 1;
68-
content = content.replace(placeholder, value);
69-
}
70-
}
71-
72-
if (count > 0) {
73-
ctx.telemetryReporter.sendTelemetryEvent(telemetryEvent, {
74-
[TelemetryPropertyKey.manifestType]: manifestType.toString(),
75-
[TelemetryPropertyKey.functionCount]: count.toString(),
76-
});
45+
return new UnsupportedFileFormatError(ctx.platform);
7746
}
78-
return ok(content);
79-
}
80-
81-
async function processFunction(
82-
content: string,
83-
ctx: DriverContext,
84-
envs: { [key in string]: string } | undefined,
85-
path: string
86-
): Promise<Result<string, FxError>> {
87-
const firstTrimmedContent = content.trim();
88-
if (!firstTrimmedContent.startsWith("file(") || !firstTrimmedContent.endsWith(")")) {
47+
if (e instanceof ManifestInvalidFunctionError) {
8948
ctx.logProvider.error(
90-
getLocalizedString("core.envFunc.unsupportedFunction.errorLog", firstTrimmedContent, "file")
49+
getLocalizedString("core.envFunc.unsupportedFunction.errorLog", e.token, "file")
9150
);
92-
return err(new InvalidFunctionError(ctx.platform));
51+
return new InvalidFunctionError(ctx.platform);
9352
}
94-
95-
// file()
96-
const trimmedParameter = content.slice(5, -1).trim();
97-
if (trimmedParameter[0] === "'" && trimmedParameter[trimmedParameter.length - 1] === "'") {
98-
// static string as function parameter
99-
const res = await readFileContent(
100-
trimmedParameter.substring(1, trimmedParameter.length - 1),
101-
ctx,
102-
envs,
103-
path
53+
if (e instanceof ManifestInvalidFunctionParameterError) {
54+
ctx.logProvider.error(
55+
getLocalizedString("core.envFunc.invalidFunctionParameter.errorLog", e.token, "file")
10456
);
105-
return res;
106-
} else if (trimmedParameter.startsWith("${{") && trimmedParameter.endsWith("}}")) {
107-
// env variable inside
108-
const resolvedParameter = expandEnvironmentVariable(trimmedParameter, envs);
109-
110-
const res = readFileContent(resolvedParameter, ctx, envs, path);
111-
return res;
112-
} else if (trimmedParameter.startsWith("file(") && trimmedParameter.endsWith(")")) {
113-
// nested function inside
114-
const processsedRes = await processFunction(trimmedParameter, ctx, envs, path);
115-
116-
if (processsedRes.isErr()) {
117-
return err(processsedRes.error);
118-
}
119-
120-
const readFileRes = await readFileContent(processsedRes.value, ctx, envs, path);
121-
return readFileRes;
122-
} else {
123-
// invalid content inside function
57+
return new InvalidFunctionParameter(ctx.platform);
58+
}
59+
if (e instanceof ManifestReadFileError) {
12460
ctx.logProvider.error(
125-
getLocalizedString("core.envFunc.invalidFunctionParameter.errorLog", trimmedParameter, "file")
61+
getLocalizedString("core.envFunc.readFile.errorLog", e.filePath, e.cause?.toString())
12662
);
127-
return err(new InvalidFunctionParameter(ctx.platform));
63+
return new ReadFileError(ctx.platform, e.filePath);
12864
}
65+
if (e instanceof ManifestFileNotFoundError) {
66+
return new FileNotFoundError(source, e.filePath);
67+
}
68+
throw e;
12969
}
13070

131-
async function readFileContent(
132-
filePath: string,
71+
export async function expandVariableWithFunction(
72+
content: string,
13373
ctx: DriverContext,
13474
envs: { [key in string]: string } | undefined,
75+
isJson: boolean,
76+
manifestType: ManifestType,
13577
fromPath: string
13678
): Promise<Result<string, FxError>> {
137-
const ext = path.extname(filePath);
138-
if (ext.toLowerCase() !== ".txt" && ext.toLowerCase() !== ".md") {
139-
ctx.logProvider.error(
140-
getLocalizedString("core.envFunc.unsupportedFile.errorLog", filePath, "txt")
141-
);
142-
return err(new UnsupportedFileFormatError(ctx.platform));
79+
let resolved: { content: string; functionCount: number };
80+
try {
81+
resolved = await expandFileFunctionMacros(content, isJson, { envs, fromPath });
82+
} catch (e) {
83+
return err(toFxError(e, ctx));
14384
}
14485

145-
const absolutePath = getAbsolutePath(filePath, fromPath);
146-
if (await fs.pathExists(absolutePath)) {
147-
try {
148-
let fileContent = await fs.readFile(absolutePath, "utf8");
149-
fileContent = stripBom(fileContent);
150-
let processedFileContent = expandEnvironmentVariable(fileContent, envs);
151-
processedFileContent = processedFileContent.replace(/\r\n/g, "\n");
152-
return ok(processedFileContent);
153-
} catch (e) {
154-
ctx.logProvider.error(
155-
getLocalizedString("core.envFunc.readFile.errorLog", absolutePath, e?.toString())
156-
);
157-
return err(new ReadFileError(ctx.platform, absolutePath));
158-
}
159-
} else {
160-
return err(new FileNotFoundError(source, filePath));
86+
if (resolved.functionCount > 0) {
87+
ctx.telemetryReporter.sendTelemetryEvent(telemetryEvent, {
88+
[TelemetryPropertyKey.manifestType]: manifestType.toString(),
89+
[TelemetryPropertyKey.functionCount]: resolved.functionCount.toString(),
90+
});
16191
}
162-
}
163-
164-
function getAbsolutePath(relativeOrAbsolutePath: string, fromPath: string): string {
165-
return path.isAbsolute(relativeOrAbsolutePath)
166-
? relativeOrAbsolutePath
167-
: path.join(path.dirname(fromPath), relativeOrAbsolutePath);
92+
return ok(resolved.content);
16893
}
16994

17095
class UnsupportedFileFormatError extends UserError {

packages/fx-core/tests/component/driver/teamsApp/manifestUtils.test.ts

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@microsoft/teamsfx-api";
1111
import fs from "fs-extra";
1212
import mockedEnv, { RestoreFn } from "mocked-env";
13+
import os from "os";
1314
import path from "path";
1415
import { assert, vi } from "vitest";
1516
import {
@@ -527,13 +528,18 @@ describe("trimManifestShortName", () => {
527528
describe("resolveLocFile", () => {
528529
const sandbox = vi;
529530
let mockedEnvRestore: RestoreFn;
531+
let tmpDir: string | undefined;
530532

531-
afterEach(() => {
533+
afterEach(async () => {
532534
if (mockedEnvRestore) {
533535
mockedEnvRestore();
534536
}
535537
vi.restoreAllMocks();
536538
vi.restoreAllMocks();
539+
if (tmpDir) {
540+
await fs.remove(tmpDir);
541+
tmpDir = undefined;
542+
}
537543
});
538544

539545
it("returns error when loc file doesn't exist", async () => {
@@ -582,21 +588,17 @@ describe("resolveLocFile", () => {
582588
});
583589

584590
it("resolves $[file(...)] when context is provided", async () => {
585-
vi.spyOn(fs, "pathExists").mockImplementation(async (filePath) => {
586-
return filePath === "loc_file_path" || filePath === "instruction.txt";
587-
});
588-
vi.spyOn(fs, "readFile").mockImplementation(((filePath: number | fs.PathLike) => {
589-
if (filePath === "loc_file_path") {
590-
return Promise.resolve(
591-
JSON.stringify({
592-
name: {
593-
short: "$[file('instruction.txt')]",
594-
},
595-
})
596-
);
597-
}
598-
return Promise.resolve("localized short name");
599-
}) as any);
591+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "resolveloc-"));
592+
const locFilePath = path.join(tmpDir, "loc_file.json");
593+
await fs.writeFile(
594+
locFilePath,
595+
JSON.stringify({
596+
name: {
597+
short: "$[file('instruction.txt')]",
598+
},
599+
})
600+
);
601+
await fs.writeFile(path.join(tmpDir, "instruction.txt"), "localized short name");
600602

601603
const context: any = {
602604
platform: Platform.VSCode,
@@ -608,7 +610,7 @@ describe("resolveLocFile", () => {
608610
},
609611
};
610612

611-
const locFile = await manifestUtils.resolveLocFile("loc_file_path", context);
613+
const locFile = await manifestUtils.resolveLocFile(locFilePath, context);
612614

613615
assert.isTrue(locFile.isOk());
614616
if (locFile.isOk()) {

0 commit comments

Comments
 (0)