Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add schema validation for localization files #12537

Merged
merged 7 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/fx-core/resource/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@
"_error.appstudio.BotProvisionReturnsForbiddenResult.comment": "This is to describe API call, no need to translate 'Botframework'.",
"error.appstudio.BotProvisionReturnsConflictResult": "Botframework provisioning returns conflict result when attempting to create bot registration.",
"_error.appstudio.BotProvisionReturnsConflictResult.comment": "This is to describe API call, no need to translate 'Botframework'.",
"error.appstudio.localizationFile.pathNotDefined": "Localization file not found. Path: %s.",
"error.appstudio.localizationFile.validationException": "Unable to validate localization file due to errors. File: %s. Error: %s",
"error.generator.ScaffoldLocalTemplateError": "Unable to scaffold template based on local zip package.",
"error.generator.TemplateNotFoundError": "Unable to find template: %s.",
"error.generator.SampleNotFoundError": "Unable to find sample: %s.",
Expand Down
132 changes: 131 additions & 1 deletion packages/fx-core/src/component/driver/teamsApp/validate.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { Result, FxError, ok, err, Platform, ManifestUtil, Colors } from "@microsoft/teamsfx-api";
import {
Result,
FxError,
ok,
err,
Platform,
ManifestUtil,
Colors,
TeamsAppManifest,
} from "@microsoft/teamsfx-api";
import { hooks } from "@feathersjs/hooks/lib";
import { Service } from "typedi";
import { EOL } from "os";
Expand Down Expand Up @@ -96,6 +105,16 @@ export class ValidateManifestDriver implements StepDriver {
);
}

// validate localization files
const localizationFilesValidationRes = await this.validateLocalizatoinFiles(
args,
context,
manifest
);
if (localizationFilesValidationRes.isErr()) {
return err(localizationFilesValidationRes.error);
}

let declarativeCopilotValidationResult;
let pluginValidationResult;
let pluginPath = "";
Expand Down Expand Up @@ -171,6 +190,7 @@ export class ValidateManifestDriver implements StepDriver {

const allErrorCount =
manifestValidationResult.length +
localizationFilesValidationRes.value.error.length +
(declarativeCopilotValidationResult?.validationResult.length ?? 0) +
(pluginValidationResult?.validationResult.length ?? 0) +
actionErrorCount;
Expand Down Expand Up @@ -211,6 +231,23 @@ export class ValidateManifestDriver implements StepDriver {
});
});
}
if (localizationFilesValidationRes.value.error.length > 0) {
outputMessage.push({
content:
getDefaultString(
"driver.teamsApp.summary.validateTeamsManifest.checkPath",
localizationFilesValidationRes.value.filePath
) + "\n",
color: Colors.BRIGHT_WHITE,
});
localizationFilesValidationRes.value.error.map((error: string) => {
outputMessage.push({ content: `${SummaryConstant.Failed} `, color: Colors.BRIGHT_RED });
outputMessage.push({
content: `${error}\n`,
color: Colors.BRIGHT_WHITE,
});
});
}
if (declarativeCopilotValidationResult) {
const validationMessage = copilotGptManifestUtils.logValidationErrors(
declarativeCopilotValidationResult,
Expand Down Expand Up @@ -254,6 +291,21 @@ export class ValidateManifestDriver implements StepDriver {
teamsManifestErrors;
}

if (localizationFilesValidationRes.value.error.length > 0) {
const localizationErrors = localizationFilesValidationRes.value.error
.map((error: string) => {
return `${SummaryConstant.Failed} ${error}`;
})
.join(EOL);
outputMessage +=
EOL +
getLocalizedString(
"driver.teamsApp.summary.validateTeamsManifest.checkPath",
localizationFilesValidationRes.value.filePath
) +
EOL +
localizationErrors;
}
if (declarativeCopilotValidationResult) {
const validationMessage = copilotGptManifestUtils.logValidationErrors(
declarativeCopilotValidationResult,
Expand Down Expand Up @@ -337,4 +389,82 @@ export class ValidateManifestDriver implements StepDriver {
}
return ok(undefined);
}

public async validateLocalizatoinFiles(
args: ValidateManifestArgs,
context: WrapDriverContext,
manifest: TeamsAppManifest
): Promise<Result<{ error: string[]; filePath?: string }, FxError>> {
const additionalLanguages = manifest.localizationInfo?.additionalLanguages;
if (!additionalLanguages || additionalLanguages.length == 0) {
return ok({ error: [] });
}
for (const language of additionalLanguages) {
const filePath = language?.file;
if (!filePath) {
return err(
AppStudioResultFactory.UserError(
AppStudioError.ValidationFailedError.name,
AppStudioError.ValidationFailedError.message([
getLocalizedString("error.appstudio.localizationFile.pathNotDefined", filePath),
])
)
);
}
const localizationFileDir = path.dirname(
getAbsolutePath(args.manifestPath, context.projectPath)
);
const localizationFilePath = getAbsolutePath(filePath, localizationFileDir);

const manifestRes = await manifestUtils._readAppManifest(localizationFilePath);
if (manifestRes.isErr()) {
return err(manifestRes.error);
}
const localizationFile = manifestRes.value;
try {
const schema = await ManifestUtil.fetchSchema(localizationFile);
// the current localization schema has invalid regex sytax, we need to manually fix the properties temporarily
const activityDespString =
"^activities.activityTypes\\[\\b([0-9]|[1-8][0-9]|9[0-9]|1[01][0-9]|12[0-7])\\b]\\.description$";
const fixedActivityDespString =
"^activities.activityTypes\\[\\b([0-9]|[1-8][0-9]|9[0-9]|1[01][0-9]|12[0-7])\\b\\]\\.description$";
if (schema.patternProperties?.[activityDespString]) {
schema.patternProperties[fixedActivityDespString] =
schema.patternProperties[activityDespString];
delete schema.patternProperties[activityDespString];
}
const activityTemplateString =
"^activities.activityTypes\\[\\b([0-9]|[1-8][0-9]|9[0-9]|1[01][0-9]|12[0-7])\\b]\\.templateText$";
const fixedActivityTemplateString =
"^activities.activityTypes\\[\\b([0-9]|[1-8][0-9]|9[0-9]|1[01][0-9]|12[0-7])\\b\\]\\.templateText$";
if (schema.patternProperties?.[activityTemplateString]) {
schema.patternProperties[fixedActivityTemplateString] =
schema.patternProperties[activityTemplateString];
delete schema.patternProperties[activityTemplateString];
}

const validationRes = await ManifestUtil.validateManifestAgainstSchema(
localizationFile,
schema
);
if (validationRes.length > 0) {
return ok({ error: validationRes, filePath: localizationFilePath });
}
} catch (e: any) {
return err(
AppStudioResultFactory.UserError(
AppStudioError.ValidationFailedError.name,
AppStudioError.ValidationFailedError.message([
getLocalizedString(
"error.appstudio.localizationFile.validationException",
filePath,
e.message
),
])
)
);
}
}
return ok({ error: [] });
}
}
189 changes: 189 additions & 0 deletions packages/fx-core/tests/component/driver/teamsApp/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,23 @@ describe("teamsApp/validateManifest", async () => {
});

it("validation error - download failed", async () => {
sinon.stub(ManifestUtil, "validateManifest").resolves([]);
sinon.stub(ManifestUtil, "validateManifestAgainstSchema").throws("error");
const args: ValidateManifestArgs = {
manifestPath:
"./tests/plugins/resource/appstudio/resources-multi-env/templates/appPackage/v3.manifest.template.json",
};

process.env.CONFIG_TEAMS_APP_NAME = "fakeName";

const result = (await teamsAppDriver.execute(args, mockedDriverContext)).result;
chai.assert(result.isErr());
if (result.isErr()) {
chai.assert(result.error.name, AppStudioError.ValidationFailedError.name);
}
});

it("validation error - localization file validation failed", async () => {
sinon
.stub(ManifestUtil, "validateManifest")
.throws(new Error(`Failed to get manifest at url due to: unknown error`));
Expand All @@ -220,6 +237,178 @@ describe("teamsApp/validateManifest", async () => {
}
});

describe("validateLocalizatoinFiles", async () => {
const teamsAppDriver = new ValidateManifestDriver();
const mockedDriverContext: any = {
projectPath: "./",
};

afterEach(() => {
sinon.restore();
});

it("should return ok when no additionalLanguages in manifest", async () => {
const args: ValidateManifestArgs = { manifestPath: "fakepath" };
const manifest = { localizationInfo: { additionalLanguages: [] } } as any;

const result = await teamsAppDriver.validateLocalizatoinFiles(
args,
mockedDriverContext,
manifest
);
chai.assert(result.isOk());
});

it("should return error when language file path is not defined", async () => {
const args: ValidateManifestArgs = { manifestPath: "fakepath" };
const manifest = { localizationInfo: { additionalLanguages: [{ file: undefined }] } } as any;

const result = await teamsAppDriver.validateLocalizatoinFiles(
args,
mockedDriverContext,
manifest
);
chai.assert(result.isErr());
if (result.isErr()) {
chai.assert.equal(result.error.name, AppStudioError.ValidationFailedError.name);
}
});

it("should return error when manifest file cannot be found", async () => {
const args: ValidateManifestArgs = { manifestPath: "fakepath" };
const manifest = { localizationInfo: { additionalLanguages: [{ file: "filePath" }] } } as any;

sinon
.stub(manifestUtils, "_readAppManifest")
.resolves(err(new SystemError("error", "error", "", "")));

const result = await teamsAppDriver.validateLocalizatoinFiles(
args,
mockedDriverContext,
manifest
);
chai.assert(result.isErr());
if (result.isErr()) {
chai.assert.equal(result.error.name, "error");
}
});

it("should return error when validation fails", async () => {
const args: ValidateManifestArgs = { manifestPath: "fakepath" };
const manifest = { localizationInfo: { additionalLanguages: [{ file: "filePath" }] } } as any;
const fakeLocalizationFile = {
$schema:
"https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.Localization.schema.json",
};

sinon.stub(manifestUtils, "_readAppManifest").resolves(ok(fakeLocalizationFile as any));
sinon.stub(ManifestUtil, "validateManifestAgainstSchema").resolves(["Validation error"]);

const result = await teamsAppDriver.validateLocalizatoinFiles(
args,
mockedDriverContext,
manifest
);
chai.assert(result.isOk());
if (result.isOk()) {
chai.assert.isTrue(result.value.error[0].includes("Validation error"));
}
});

it("should output errors when validation fails", async () => {
const args: ValidateManifestArgs = {
manifestPath:
"./tests/plugins/resource/appstudio/resources-multi-env/templates/appPackage/v3.invalid.localization.manifest.json",
};
process.env.CONFIG_TEAMS_APP_NAME = "fakeName";

const result = (await teamsAppDriver.execute(args, mockedDriverContext)).result;
chai.assert(result.isErr());
if (result.isErr()) {
chai.assert.isTrue(result.error.message.includes("2 failed"));
}
});

it("should output errors when validation fails - CLI", async () => {
const args: ValidateManifestArgs = {
manifestPath:
"./tests/plugins/resource/appstudio/resources-multi-env/templates/appPackage/v3.invalid.localization.manifest.json",
};
const mockedCLIDriverContext: any = {
m365TokenProvider: new MockedM365Provider(),
logProvider: new MockedLogProvider(),
ui: new MockedUserInteraction(),
projectPath: "./",
platform: Platform.CLI,
};
process.env.CONFIG_TEAMS_APP_NAME = "fakeName";

const result = (await teamsAppDriver.execute(args, mockedCLIDriverContext)).result;
chai.assert(result.isErr());
if (result.isErr()) {
chai.assert.isTrue(result.error.message.includes("2 failed"));
}
});

it("should return error when validation throws exception", async () => {
const args: ValidateManifestArgs = { manifestPath: "fakepath" };
const manifest = { localizationInfo: { additionalLanguages: [{ file: "filePath" }] } } as any;
const fakeLocalizationFile = {};

sinon.stub(manifestUtils, "_readAppManifest").resolves(ok(fakeLocalizationFile as any));
sinon
.stub(ManifestUtil, "validateManifestAgainstSchema")
.throws(new Error("validation exception"));

const result = await teamsAppDriver.validateLocalizatoinFiles(
args,
mockedDriverContext,
manifest
);
chai.assert(result.isErr());
if (result.isErr()) {
chai.assert.equal(result.error.name, AppStudioError.ValidationFailedError.name);
}
});

it("should not throw error if schema does not have patternProperties", async () => {
const args: ValidateManifestArgs = { manifestPath: "fakepath" };
const manifest = { localizationInfo: { additionalLanguages: [{ file: "filePath" }] } } as any;
sinon.stub(ManifestUtil, "fetchSchema").resolves({} as any);
sinon.stub(manifestUtils, "_readAppManifest").resolves(ok({} as any));
sinon.stub(ManifestUtil, "validateManifestAgainstSchema").resolves([] as any);
const result = await teamsAppDriver.validateLocalizatoinFiles(
args,
mockedDriverContext,
manifest
);
chai.assert(result.isOk());
});

it("should return ok when localization file is valid", async () => {
const args: ValidateManifestArgs = { manifestPath: "fakepath" };
const manifest = { localizationInfo: { additionalLanguages: [{ file: "filePath" }] } } as any;
const fakeLocalizationFile = {
$schema:
"https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.Localization.schema.json",
"name.short": "name short",
"name.full": "name full",
"description.short": "desp short",
"description.full": "desp full",
"staticTabs[0].name": "static tab name",
"activities.activityTypes[0].description": "aa",
};

sinon.stub(manifestUtils, "_readAppManifest").resolves(ok(fakeLocalizationFile as any));
const result = await teamsAppDriver.validateLocalizatoinFiles(
args,
mockedDriverContext,
manifest
);
chai.assert(result.isOk());
});
});

describe("validate Copilot extensions", async () => {
it("validate with errors returned", async () => {
const teamsManifest: TeamsAppManifest = new TeamsAppManifest();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.12/MicrosoftTeams.Localization.schema.json",
"name.short": "Name App",
"name.full": "Name App",
"description.short.fake": "Description Short Deutsch",
"description.full.fake": "Description Long Deutsch",
"staticTabs[0].name": "Home"
}
Loading
Loading