diff --git a/README.md b/README.md
index 1f5c7b08..2ffcb69d 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,9 @@ This section documents the external interface of the software, including where i
- Required Outlook permission level for header retrieval: ReadWriteMailbox.
- The app consumes Outlook mailbox APIs and does not expose a separate public REST API.
+- The standalone About dialog and Outlook add-in diagnostics identify the build and source commit.
+- Build metadata is available as JSON at `/Pages/build-info.json`.
+- Local builds display `Local` and identify the Git commit on which the working tree is based.
## Installation Procedure
@@ -112,7 +115,7 @@ For both IOS and Android click open an email, then press the three dots under th
### Add-in testing (VSCode)
-- Follow the steps given [here](https://learn.microsoft.com/en-us/office/dev/add-ins/testing/debug-desktop-using-edge-chromium#use-the-visual-studio-code-debugger).
+- Follow the steps for [using the Visual Studio Code debugger](https://learn.microsoft.com/en-us/office/dev/add-ins/testing/debug-desktop-using-edge-chromium#use-the-visual-studio-code-debugger).
### Add-in testing (Outlook Web App)
diff --git a/eslint.config.js b/eslint.config.js
index 9c15b413..06c2f8c7 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -47,8 +47,7 @@ export default [{
Office: "readonly",
// Webpack defined globals (replaced at build time)
__AIKEY__: "readonly",
- __BUILDTIME__: "readonly",
- __VERSION__: "readonly",
+ mhaBuildInfo: "readonly",
// Node.js/TypeScript globals used in specific contexts
process: "readonly",
global: "readonly",
diff --git a/index.d.ts b/index.d.ts
index 5e0778d6..c7251c0b 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1,3 +1,9 @@
declare module "*.png";
declare module "*.jpg";
declare module "*.css";
+
+declare const mhaBuildInfo: Readonly<{
+ buildNumber: string;
+ commit: string;
+ builtAt: string;
+}>;
diff --git a/jest.config.ts b/jest.config.ts
index e5cfafe9..0b123c21 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -7,7 +7,13 @@ const config: Config = {
"^.+.tsx?$": ["ts-jest",{ diagnostics: { ignoreCodes: ["TS151001"] } }],
},
globals: {
- "__AIKEY__": ""
+ // Stand-ins for webpack DefinePlugin constants so code runs under Jest.
+ "__AIKEY__": "",
+ "mhaBuildInfo": {
+ buildNumber: "local",
+ commit: "0".repeat(40),
+ builtAt: "1970-01-01T00:00:00.000Z"
+ }
},
collectCoverage: true,
collectCoverageFrom: ["./src/**"],
diff --git a/src/Content/classicDesktopFrame.css b/src/Content/classicDesktopFrame.css
index 0bc04ae4..235709f3 100644
--- a/src/Content/classicDesktopFrame.css
+++ b/src/Content/classicDesktopFrame.css
@@ -322,10 +322,77 @@ button[aria-expanded="false"] .collapsibleSwitch::after {
box-sizing: border-box;
}
-#feedbackLink {
+#aboutLink {
float: right;
}
+#aboutButton {
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--primary-blue);
+ font: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+#aboutDialog {
+ --dialog-width: min(360px, calc(100vw - 32px));
+ overflow-x: hidden;
+}
+
+#aboutDialog::part(control),
+#aboutDialog::part(positioning-region),
+#aboutDialog::part(overlay),
+#aboutDialog::part(dialog) {
+ overflow-x: hidden;
+}
+
+#aboutDialog[hidden] {
+ display: none;
+}
+
+#aboutDialog .dialog-header,
+#aboutDialog .dialog-content,
+#aboutDialog .dialog-actions {
+ overflow-x: hidden;
+}
+
+#aboutDialog .dialog-content {
+ padding: 16px 20px;
+}
+
+#aboutDialog dl {
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ gap: 8px 16px;
+ margin: 0 0 16px;
+ min-width: 0;
+}
+
+#aboutDialog dt {
+ font-weight: 600;
+}
+
+#aboutDialog dd {
+ min-width: 0;
+ margin: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+#aboutDialog p {
+ margin: 8px 0 0;
+ overflow-wrap: anywhere;
+}
+
+#aboutDialog .dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ padding: 12px 20px 16px;
+ border-top: 1px solid var(--border-gray);
+}
+
.commandBar {
height: 44px;
background-color: #f4f4f4;
diff --git a/src/Pages/mha.html b/src/Pages/mha.html
index f6693b5d..5b343c60 100644
--- a/src/Pages/mha.html
+++ b/src/Pages/mha.html
@@ -35,8 +35,8 @@
-
-
Submit feedback on github
+
+
@@ -56,6 +56,27 @@
Analysis Results
+
+
+
+
+ - Build
+
+ - Commit
+
+ - Built
+
+
+ Project on GitHub
+ Submit feedback on GitHub
+
+
+
+
diff --git a/src/Scripts/BuildInfo.test.ts b/src/Scripts/BuildInfo.test.ts
new file mode 100644
index 00000000..802ade80
--- /dev/null
+++ b/src/Scripts/BuildInfo.test.ts
@@ -0,0 +1,21 @@
+import { getBuildInfo, getCommitUrl, isLocalBuild } from "./BuildInfo";
+
+describe("BuildInfo", () => {
+ const testCommit = "0".repeat(40);
+
+ test("returns the injected build metadata", () => {
+ expect(getBuildInfo()).toEqual({
+ buildNumber: "local",
+ commit: testCommit,
+ builtAt: "1970-01-01T00:00:00.000Z"
+ });
+ });
+
+ test("builds the GitHub commit URL from the full SHA", () => {
+ expect(getCommitUrl()).toBe(`https://github.com/microsoft/MHA/commit/${testCommit}`);
+ });
+
+ test("identifies local builds", () => {
+ expect(isLocalBuild()).toBe(true);
+ });
+});
\ No newline at end of file
diff --git a/src/Scripts/BuildInfo.ts b/src/Scripts/BuildInfo.ts
new file mode 100644
index 00000000..28b44b2f
--- /dev/null
+++ b/src/Scripts/BuildInfo.ts
@@ -0,0 +1,17 @@
+export interface BuildInfo {
+ readonly buildNumber: string;
+ readonly commit: string;
+ readonly builtAt: string;
+}
+
+export function getBuildInfo(): BuildInfo {
+ return mhaBuildInfo;
+}
+
+export function getCommitUrl(): string {
+ return `https://github.com/microsoft/MHA/commit/${getBuildInfo().commit}`;
+}
+
+export function isLocalBuild(): boolean {
+ return getBuildInfo().buildNumber === "local";
+}
\ No newline at end of file
diff --git a/src/Scripts/Diag.ts b/src/Scripts/Diag.ts
index 1d886db9..c4feec1a 100644
--- a/src/Scripts/Diag.ts
+++ b/src/Scripts/Diag.ts
@@ -2,8 +2,7 @@ import { ApplicationInsights, ICustomProperties, IEventTelemetry, ITelemetryItem
import stackTrace from "stacktrace-js";
import { aikey } from "./aikey";
-import { buildTime } from "./buildTime";
-import { mhaVersion } from "./mhaVersion";
+import { getBuildInfo, isLocalBuild } from "./BuildInfo";
import { ParentFrame } from "./ParentFrame";
import { GetHeaders } from "./ui/getHeaders/GetHeaders";
import { GetHeadersAPI } from "./ui/getHeaders/GetHeadersAPI";
@@ -178,8 +177,10 @@ class Diag {
this.appDiagnostics["ui"] = "standalone";
}
- this.appDiagnostics["Last Update"] = buildTime();
- this.appDiagnostics["mhaVersion"] = mhaVersion();
+ const buildInfo = getBuildInfo();
+ this.appDiagnostics["Build"] = isLocalBuild() ? "Local" : buildInfo.buildNumber;
+ this.appDiagnostics[isLocalBuild() ? "Base commit" : "Commit"] = buildInfo.commit;
+ this.appDiagnostics["Built"] = buildInfo.builtAt;
if (window.Office) {
delete this.appDiagnostics["Office"];
diff --git a/src/Scripts/buildTime.ts b/src/Scripts/buildTime.ts
deleted file mode 100644
index 26d7cc18..00000000
--- a/src/Scripts/buildTime.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// @ts-expect-error Variable replacement from DefinePlugin
-export const buildTime = function ():string { return __BUILDTIME__; };
diff --git a/src/Scripts/mhaVersion.ts b/src/Scripts/mhaVersion.ts
deleted file mode 100644
index e6dac625..00000000
--- a/src/Scripts/mhaVersion.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// @ts-expect-error Variable replacement from DefinePlugin
-export const mhaVersion = function () { return __VERSION__; };
diff --git a/src/Scripts/ui/StandaloneAbout.test.ts b/src/Scripts/ui/StandaloneAbout.test.ts
new file mode 100644
index 00000000..0249a065
--- /dev/null
+++ b/src/Scripts/ui/StandaloneAbout.test.ts
@@ -0,0 +1,33 @@
+import { initializeStandaloneAbout } from "./StandaloneAbout";
+
+describe("StandaloneAbout", () => {
+ test("renders local build metadata and opens and closes the dialog", () => {
+ document.body.innerHTML = `
+
+ `;
+
+ const dialog = document.getElementById("aboutDialog") as HTMLDivElement & { show: jest.Mock; hide: jest.Mock };
+ dialog.show = jest.fn();
+ dialog.hide = jest.fn();
+
+ initializeStandaloneAbout();
+
+ expect(document.getElementById("aboutBuild")?.textContent).toBe("Local");
+ expect(document.getElementById("aboutCommit")?.textContent).toBe("0".repeat(40));
+ expect((document.getElementById("aboutCommit") as HTMLAnchorElement).href)
+ .toBe(`https://github.com/microsoft/MHA/commit/${"0".repeat(40)}`);
+ expect(document.getElementById("aboutBuiltAt")?.textContent).toBe("1970-01-01T00:00:00.000Z");
+ expect(dialog.hidden).toBe(false);
+
+ document.getElementById("aboutButton")?.click();
+ expect(dialog.show).toHaveBeenCalledTimes(1);
+
+ document.getElementById("aboutCloseButton")?.click();
+ expect(dialog.hide).toHaveBeenCalledTimes(1);
+ });
+});
\ No newline at end of file
diff --git a/src/Scripts/ui/StandaloneAbout.ts b/src/Scripts/ui/StandaloneAbout.ts
new file mode 100644
index 00000000..bd338dbc
--- /dev/null
+++ b/src/Scripts/ui/StandaloneAbout.ts
@@ -0,0 +1,31 @@
+import { getBuildInfo, getCommitUrl, isLocalBuild } from "../BuildInfo";
+
+interface FluentDialog extends HTMLElement {
+ show(): void;
+ hide(): void;
+}
+
+export function initializeStandaloneAbout(): void {
+ const dialog = document.getElementById("aboutDialog") as FluentDialog;
+ const aboutButton = document.getElementById("aboutButton") as HTMLButtonElement;
+ const closeButton = document.getElementById("aboutCloseButton") as HTMLButtonElement;
+ const buildElement = document.getElementById("aboutBuild") as HTMLElement;
+ const commitLabel = document.getElementById("aboutCommitLabel") as HTMLElement | null;
+ const commitLink = document.getElementById("aboutCommit") as HTMLAnchorElement;
+ const builtAtElement = document.getElementById("aboutBuiltAt") as HTMLTimeElement;
+ const buildInfo = getBuildInfo();
+
+ buildElement.textContent = isLocalBuild() ? "Local" : buildInfo.buildNumber;
+ if (commitLabel) commitLabel.textContent = isLocalBuild() ? "Base commit" : "Commit";
+ commitLink.textContent = buildInfo.commit;
+ commitLink.href = getCommitUrl();
+ builtAtElement.textContent = buildInfo.builtAt;
+ builtAtElement.dateTime = buildInfo.builtAt;
+ dialog.hidden = false;
+
+ aboutButton.onclick = (): void => dialog.show();
+ closeButton.onclick = (): void => dialog.hide();
+ dialog.addEventListener("click", (event: Event): void => {
+ if (event.target === dialog) dialog.hide();
+ });
+}
\ No newline at end of file
diff --git a/src/Scripts/ui/mha.ts b/src/Scripts/ui/mha.ts
index 2c41db29..f7e16015 100644
--- a/src/Scripts/ui/mha.ts
+++ b/src/Scripts/ui/mha.ts
@@ -1,4 +1,5 @@
import "@fluentui/web-components/button.js";
+import "@fluentui/web-components/dialog.js";
import "../fluentTheme";
import "../../Content/fluentCommon.css";
import "../../Content/Office.css";
@@ -9,6 +10,7 @@ import { HeaderModel } from "../HeaderModel";
import { mhaStrings } from "../mhaStrings";
import { Strings } from "../Strings";
import { DomUtils } from "./domUtils";
+import { initializeStandaloneAbout } from "./StandaloneAbout";
import { Table } from "./Table";
let viewModel: HeaderModel;
@@ -135,6 +137,7 @@ function copy() {
document.addEventListener("DOMContentLoaded", function() {
diagnostics.set("API used", "standalone");
+ initializeStandaloneAbout();
table = new Table();
table.initializeTableUI();
table.makeResizablePane("inputHeaders", "sectionHeader", mhaStrings.mhaPrompt, () => true);
diff --git a/webpack.config.js b/webpack.config.js
index 9d7e21a8..ce7ba316 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -13,12 +13,12 @@
*
* Functions:
* - getHttpsOptions: Asynchronously retrieves HTTPS options for the development server.
- * - getHash: Generates a short hash from a given string.
* - generateEntry: Generates an entry object for webpack configuration.
* - generateHtmlWebpackPlugins: Generates an array of HtmlWebpackPlugin instances for each page.
*
* Environment Variables:
- * - SCM_COMMIT_ID: The commit ID used to generate a version hash.
+ * - MHA_BUILD_NUMBER: The ADO build number.
+ * - SCM_COMMIT_ID: The commit ID used to build the application.
* - APPINSIGHTS_INSTRUMENTATIONKEY: Application Insights instrumentation key.
* - npm_package_config_dev_server_port: Port for the development server.
*
@@ -27,6 +27,7 @@
* @returns {Promise