From 0d4d85e2429a5a0df4f5f3e59183fe3ba1c2277d Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:38:23 -0700 Subject: [PATCH 1/6] HTML Widgets --- .../Handlers/HtmlWidgetCallToolHandler.cs | 54 ++ .../Handlers/InvokeHandler.Activity.cs | 46 +- .../HtmlWidget/CallToolRequest.cs | 28 + .../HtmlWidget/CallToolResult.cs | 109 ++++ .../HtmlWidget/HtmlWidgetHelpers.cs | 488 ++++++++++++++++++ .../HtmlWidget/HtmlWidgetPayload.cs | 146 ++++++ 6 files changed, 870 insertions(+), 1 deletion(-) create mode 100644 core/src/Microsoft.Teams.Apps/Handlers/HtmlWidgetCallToolHandler.cs create mode 100644 core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolRequest.cs create mode 100644 core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs create mode 100644 core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetHelpers.cs create mode 100644 core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetPayload.cs diff --git a/core/src/Microsoft.Teams.Apps/Handlers/HtmlWidgetCallToolHandler.cs b/core/src/Microsoft.Teams.Apps/Handlers/HtmlWidgetCallToolHandler.cs new file mode 100644 index 00000000..ed0c6dd7 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/Handlers/HtmlWidgetCallToolHandler.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.HtmlWidget; +using Microsoft.Teams.Apps.Routing; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.Handlers; + +/// +/// Delegate for handling htmlwidget/calltool invoke activities. +/// Sent when a widget calls a tool on the bot via the MCP Apps protocol. +/// +/// The context for the invoke activity with a strongly-typed value. +/// A cancellation token that can be used to cancel the operation. +/// A task that represents the asynchronous operation. The task result contains the invoke response. +[Experimental("ExperimentalTeamsHtmlWidget")] +public delegate Task HtmlWidgetCallToolHandler(Context> context, CancellationToken cancellationToken = default); + +/// +/// Extension methods for registering HTML widget call tool invoke handlers. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public static class HtmlWidgetCallToolExtensions +{ + /// + /// Registers a handler for htmlwidget/calltool invoke activities. + /// Triggered when a widget calls a tool on the bot. + /// Cannot be combined with . + /// + /// The Teams bot application. + /// The handler to register. + /// The updated Teams bot application. + public static TeamsBotApplication OnWidgetCallTool(this TeamsBotApplication app, HtmlWidgetCallToolHandler handler) + { + ArgumentNullException.ThrowIfNull(app, nameof(app)); + app.Router.Register(new Route + { + Name = string.Join("/", TeamsActivityTypes.Invoke, InvokeNames.HtmlWidgetCallTool), + Selector = activity => activity.Name == InvokeNames.HtmlWidgetCallTool, + HandlerWithReturn = async (ctx, cancellationToken) => + { + InvokeActivity typedActivity = new(ctx.Activity); + var typedContext = ctx.CreateDerivedContext(typedActivity); + return await handler(typedContext, cancellationToken).ConfigureAwait(false); + } + }); + + return app; + } +} diff --git a/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs index 92f71f46..b4509d51 100644 --- a/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs +++ b/core/src/Microsoft.Teams.Apps/Handlers/InvokeHandler.Activity.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -184,10 +185,53 @@ public static class InvokeNames /// public const string MessageSubmitAction = "message/submitAction"; + /// + /// HTML widget call tool invoke name. + /// + [Experimental("ExperimentalTeamsHtmlWidget")] + public const string HtmlWidgetCallTool = "htmlwidget/calltool"; + /// /// Suggested action submit invoke name. /// Sent when the user clicks a suggested action of type Action.Submit. /// - [System.Diagnostics.CodeAnalysis.Experimental("ExperimentalTeamsSuggestedAction")] + [Experimental("ExperimentalTeamsSuggestedAction")] public const string SuggestedActionSubmit = "suggestedActions/submit"; + + //TODO : review + /* + /// + /// Execute action invoke name. + /// + public const string ExecuteAction = "actionableMessage/executeAction"; + + /// + /// Handoff invoke name. + /// + public const string Handoff = "handoff/action"; + + /// + /// Search invoke name. + /// + public const string Search = "search"; + /// + /// Config fetch invoke name. + /// + public const string ConfigFetch = "config/fetch"; + + /// + /// Config submit invoke name. + /// + public const string ConfigSubmit = "config/submit"; + + /// + /// Message extension card button clicked invoke name. + /// + public const string MessageExtensionCardButtonClicked = "composeExtension/onCardButtonClicked"; + + /// + /// Message extension setting invoke name. + /// + public const string MessageExtensionSetting = "composeExtension/setting"; + */ } diff --git a/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolRequest.cs b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolRequest.cs new file mode 100644 index 00000000..5a7a2393 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolRequest.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.HtmlWidget; + +/// +/// A request from a widget to call a tool on the bot. +/// Sent as the value of an htmlwidget/calltool invoke activity. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class CallToolRequest +{ + /// + /// The name of the tool to call. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// The arguments to pass to the tool. + /// + [JsonPropertyName("arguments")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? Arguments { get; set; } +} diff --git a/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs new file mode 100644 index 00000000..27a7bf9a --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Apps.HtmlWidget; + +/// +/// A content item in an MCP UI call tool result. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class McpUiCallToolResultContent +{ + /// + /// The type of content (e.g. "text"). + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "text"; + + /// + /// The text content. + /// + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; +} + +/// +/// The result of a widget's tools/call request, returned by the bot +/// in response to an htmlwidget/calltool invoke activity. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class McpUiCallToolResult +{ + /// + /// An array of content items to return to the widget. + /// + [JsonPropertyName("content")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Content { get; set; } + + /// + /// Structured data that the widget can render from. + /// + [JsonPropertyName("structuredContent")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public object? StructuredContent { get; set; } + + /// + /// Whether the tool call resulted in an error. + /// + [JsonPropertyName("isError")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool IsError { get; set; } +} + +/// +/// The wire-format response body for an htmlwidget/calltool invoke. +/// Teams expects this shape (with responseType discriminator) rather than +/// a bare . +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class HtmlWidgetCallToolResponse +{ + /// + /// Discriminator that tells Teams how to interpret the response. + /// + [JsonPropertyName("responseType")] + public string ResponseType { get; set; } = "htmlwidget/calltoolresult"; + + /// + /// The tool call result payload. + /// + [JsonPropertyName("callToolResult")] + public McpUiCallToolResult CallToolResult { get; set; } = new(); + + /// + /// Creates a successful response with text content. + /// + /// The text to return to the widget. + /// A new . + public static HtmlWidgetCallToolResponse FromText(string text) + { + return new HtmlWidgetCallToolResponse + { + CallToolResult = new McpUiCallToolResult + { + Content = [new McpUiCallToolResultContent { Type = "text", Text = text }] + } + }; + } + + /// + /// Creates an error response with a message. + /// + /// The error message. + /// A new with IsError set. + public static HtmlWidgetCallToolResponse FromError(string message) + { + return new HtmlWidgetCallToolResponse + { + CallToolResult = new McpUiCallToolResult + { + Content = [new McpUiCallToolResultContent { Type = "text", Text = message }], + IsError = true + } + }; + } +} diff --git a/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetHelpers.cs b/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetHelpers.cs new file mode 100644 index 00000000..c69e75d0 --- /dev/null +++ b/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetHelpers.cs @@ -0,0 +1,488 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.HtmlWidget; + +/// +/// Options for injecting the MCP Apps protocol into widget HTML. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class InjectWidgetProtocolOptions +{ + /// + /// The widget app name sent during ui/initialize. + /// + public string Name { get; set; } = "widget"; + + /// + /// The widget app version sent during ui/initialize. + /// + public string Version { get; set; } = "1.0.0"; + + /// + /// Display modes this widget supports. + /// + public IList? AvailableDisplayModes { get; set; } + + /// + /// Host notifications to listen for. + /// Known values: "tool-result", "tool-input", "tool-input-partial", + /// "tool-cancelled", "host-context-changed", "resource-teardown". + /// + public IList? Notifications { get; set; } + + /// + /// When true, injects a CSP violation listener for debugging. + /// + public bool DebugCspViolations { get; set; } +} + +/// +/// Options for building an HTML widget markdown string. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class HtmlWidgetMarkdownOptions +{ + /// + /// Text to include before the widget code block. + /// + public string? Before { get; set; } + + /// + /// Text to include after the widget code block. + /// + public string? After { get; set; } + + /// + /// Options forwarded to when the protocol + /// is auto-injected. The Name field is always set from the payload's Name. + /// + public InjectWidgetProtocolOptions? ProtocolOptions { get; set; } +} + +/// +/// A warning produced by when the widget HTML +/// references an external origin not present in the declared security policy. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public class SecurityPolicyWarning +{ + /// + /// The URL or origin found in the HTML. + /// + [SuppressMessage("Design", "CA1056:URI-like properties should not be strings", Justification = "May contain relative URLs, fragments, or unparseable values")] + public string Url { get; set; } = string.Empty; + + /// + /// The HTML element or API where the reference was found. + /// + public string Source { get; set; } = string.Empty; + + /// + /// The securityPolicy field that should include this origin. + /// + public string PolicyField { get; set; } = string.Empty; + + /// + /// A human-readable description of the issue. + /// + public string Message { get; set; } = string.Empty; +} + +/// +/// Helper methods for building and validating HTML widget messages. +/// +[Experimental("ExperimentalTeamsHtmlWidget")] +public static class HtmlWidgetHelpers +{ + private const string McpProtocolVersion = "2026-01-26"; + + private static readonly Dictionary NotificationCallbacks = new() + { + ["tool-result"] = "onToolResult", + ["tool-input"] = "onToolInput", + ["tool-input-partial"] = "onToolInputPartial", + ["tool-cancelled"] = "onToolCancelled", + ["host-context-changed"] = "onHostContextChanged", + ["resource-teardown"] = "onResourceTeardown", + }; + + private static readonly HtmlWidgetSecurityPolicy DefaultSecurityPolicy = new() + { + ConnectDomains = [], + ResourceDomains = ["'self'", "data:"], + FrameDomains = [], + BaseUriDomains = [], + }; + + /// + /// Injects the MCP Apps protocol script into widget HTML. + /// If the HTML already contains the protocol (detected by "ui/initialize"), it is returned unchanged. + /// + /// The raw HTML content for the widget. + /// Optional configuration for the protocol setup. + /// The HTML with the protocol script injected. + public static string InjectWidgetProtocol(string html, InjectWidgetProtocolOptions? options = null) + { + ArgumentNullException.ThrowIfNull(html); + + if (html.Contains("ui/initialize", StringComparison.Ordinal)) + { + return html; + } + + var name = EscapeForInlineScript(options?.Name ?? "widget"); + var version = EscapeForInlineScript(options?.Version ?? "1.0.0"); + + var capsJson = options?.AvailableDisplayModes is { Count: > 0 } modes + ? $"{{availableDisplayModes:{JsonSerializer.Serialize(modes)}}}" + : "{}"; + + var hookLines = new StringBuilder(); + if (options?.Notifications is { Count: > 0 } notifications) + { + foreach (var n in notifications) + { + if (NotificationCallbacks.TryGetValue(n, out var cb)) + { + hookLines.Append(CultureInfo.InvariantCulture, $"if(d.method==='ui/notifications/{n}'&&window.{cb}){{window.{cb}(d.params);}}"); + } + } + } + + var cspDebug = options?.DebugCspViolations == true + ? "document.addEventListener('securitypolicyviolation',function(e){" + + "console.warn('[widget CSP violation]',{" + + "blockedURI:e.blockedURI," + + "violatedDirective:e.violatedDirective," + + "originalPolicy:e.originalPolicy" + + "});});" + : ""; + + var script = ""; + + if (html.Contains("", StringComparison.Ordinal)) + { + return html.Replace("", script + "", StringComparison.Ordinal); + } + + return html + script; + } + + /// + /// Wraps an HTML widget payload in the ```html-widget markdown code fence + /// format required by Teams to render the widget in a message. + /// + /// The widget payload to serialize. + /// Optional text to include before/after the widget block. + /// The markdown string containing the widget code block. + public static string BuildHtmlWidgetMarkdown(HtmlWidgetPayload payload, HtmlWidgetMarkdownOptions? options = null) + { + ArgumentNullException.ThrowIfNull(payload); + ValidatePayload(payload); + + var protocolOpts = new InjectWidgetProtocolOptions + { + Name = payload.Name, + Version = options?.ProtocolOptions?.Version ?? "1.0.0", + AvailableDisplayModes = options?.ProtocolOptions?.AvailableDisplayModes, + Notifications = options?.ProtocolOptions?.Notifications, + DebugCspViolations = options?.ProtocolOptions?.DebugCspViolations ?? false, + }; + + var injectedPayload = new HtmlWidgetPayload + { + Type = payload.Type, + Name = payload.Name, + Description = payload.Description, + Html = InjectWidgetProtocol(payload.Html, protocolOpts), + Domain = payload.Domain, + SecurityPolicy = payload.SecurityPolicy ?? DefaultSecurityPolicy, + ToolInput = payload.ToolInput, + ToolOutput = payload.ToolOutput, + Permissions = payload.Permissions, + }; + + var json = JsonSerializer.Serialize(injectedPayload); + var parts = new List(); + + if (!string.IsNullOrEmpty(options?.Before)) + { + parts.Add(options.Before); + parts.Add(""); + } + + parts.Add("```html-widget"); + parts.Add(json); + parts.Add("```"); + + if (!string.IsNullOrEmpty(options?.After)) + { + parts.Add(""); + parts.Add(options.After); + } + + return string.Join("\n", parts); + } + + /// + /// Builds a message activity containing an HTML widget, ready to be sent. + /// + /// The widget payload to include in the message. + /// Optional text to include before/after the widget block. + /// A MessageActivity with TextFormat set to "extendedmarkdown". + public static MessageActivity BuildHtmlWidgetMessage(HtmlWidgetPayload payload, HtmlWidgetMarkdownOptions? options = null) + { + return new MessageActivity + { + Text = BuildHtmlWidgetMarkdown(payload, options), + TextFormat = TextFormats.ExtendedMarkdown, + }; + } + + /// + /// Validates that external references in widget HTML are covered by the + /// declared security policy. Returns a list of warnings for any + /// references to origins not present in the appropriate policy field. + /// + /// The raw HTML content of the widget. + /// The security policy to validate against. + /// A list of warnings. Empty list means no issues found. + public static IList ValidateSecurityPolicy(string html, HtmlWidgetSecurityPolicy policy) + { + ArgumentNullException.ThrowIfNull(html); + ArgumentNullException.ThrowIfNull(policy); + + var warnings = new List(); + + // resourceDomains: + + """; + + public const string CallToolHtml = """ +
+

CallTool Widget

+
Ready
+ +

+          
+        
+ """; + + public const string MessageBackHtml = """ +
+

MessageBack Widget

+

Click a button to send a messageBack to the bot:

+ + + +
+ """; + + public const string FullscreenHtml = """ +
+

Fullscreen Widget

+

Current mode: inline

+ + + +
+ """; + + public const string MultiToolHtml = """ +
+

Multi-Tool Widget

+
+ + + +
+

+          
+        
+ """; + + public const string OpenLinkHtml = """ +
+

Open Link Widget

+ + + +
+ """; + + public const string UpdateContextHtml = """ +
+

Update Context Widget

+ + +
+ +
+ """; + + public const string HostContextHtml = """ +
+

Host Context Inspector

+
Waiting for ui/initialize...
+ +
+ """; +} diff --git a/core/samples/HtmlWidgetBot/appsettings.json b/core/samples/HtmlWidgetBot/appsettings.json new file mode 100644 index 00000000..5febf4fe --- /dev/null +++ b/core/samples/HtmlWidgetBot/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Teams": "Information" + } + }, + "AllowedHosts": "*" +} From 7e7aa107705c385c4d1ee453c5b693beebef79aa Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:39:48 -0700 Subject: [PATCH 3/6] Unit tests --- .../HtmlWidgetHelpersTests.cs | 738 ++++++++++++++++++ .../Microsoft.Teams.Apps.UnitTests.csproj | 2 +- 2 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.cs diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.cs new file mode 100644 index 00000000..5e174bb3 --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.cs @@ -0,0 +1,738 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; + +using Microsoft.Teams.Apps.Handlers; +using Microsoft.Teams.Apps.HtmlWidget; +using Microsoft.Teams.Apps.Schema; + +namespace Microsoft.Teams.Apps.UnitTests; + +#pragma warning disable ExperimentalTeamsHtmlWidget + +public class HtmlWidgetHelpersTests +{ + // --- InjectWidgetProtocol --- + + [Fact] + public void InjectWidgetProtocol_SkipsIfAlreadyPresent() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html); + Assert.Equal(html, result); + } + + [Fact] + public void InjectWidgetProtocol_InjectsBeforeBodyClose() + { + var html = "

Hello

"; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html); + Assert.Contains("ui/initialize", result); + Assert.Contains("ui/notifications/size-changed", result); + Assert.EndsWith("", result); + } + + [Fact] + public void InjectWidgetProtocol_AppendsIfNoBody() + { + var html = "
Hello
"; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html); + Assert.Contains("ui/initialize", result); + Assert.StartsWith("
Hello
", result); + } + + [Fact] + public void InjectWidgetProtocol_UsesCustomNameAndVersion() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Name = "myWidget", + Version = "2.0.0" + }); + Assert.Contains("name:'myWidget'", result); + Assert.Contains("version:'2.0.0'", result); + } + + [Fact] + public void InjectWidgetProtocol_InjectsNotificationHooks() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Notifications = ["tool-result", "tool-input"] + }); + Assert.Contains("ui/notifications/tool-result", result); + Assert.Contains("window.onToolResult", result); + Assert.Contains("ui/notifications/tool-input", result); + Assert.Contains("window.onToolInput", result); + } + + [Fact] + public void InjectWidgetProtocol_IgnoresUnknownNotifications() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Notifications = ["unknown-notification"] + }); + Assert.DoesNotContain("unknown-notification", result); + } + + [Fact] + public void InjectWidgetProtocol_InjectsCspDebugListener() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + DebugCspViolations = true + }); + Assert.Contains("securitypolicyviolation", result); + } + + [Fact] + public void InjectWidgetProtocol_IncludesDisplayModes() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + AvailableDisplayModes = ["inline", "fullscreen"] + }); + Assert.Contains("availableDisplayModes", result); + Assert.Contains("inline", result); + Assert.Contains("fullscreen", result); + } + + [Fact] + public void InjectWidgetProtocol_EscapesSpecialChars() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Name = "it's a \"test\\" + }); + Assert.Contains("it\\'s a \"test\\\\", result); + } + + [Fact] + public void InjectWidgetProtocol_EscapesScriptCloseTagInName() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Name = "" + }); + // Only one should exist (the injected protocol's closing tag) + var scriptTagCount = System.Text.RegularExpressions.Regex.Matches(result, "", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Count; + Assert.Equal(1, scriptTagCount); + Assert.Contains("<\\/script>", result); + } + + [Fact] + public void InjectWidgetProtocol_EscapesScriptCloseTagInVersion() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Version = "" + }); + var scriptTagCount = System.Text.RegularExpressions.Regex.Matches(result, "", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Count; + Assert.Equal(1, scriptTagCount); + } + + [Fact] + public void InjectWidgetProtocol_EscapesNewlinesInName() + { + var html = ""; + var result = HtmlWidgetHelpers.InjectWidgetProtocol(html, new InjectWidgetProtocolOptions + { + Name = "line1\nline2\rline3" + }); + var scriptMatch = System.Text.RegularExpressions.Regex.Match(result, @"", System.Text.RegularExpressions.RegexOptions.Singleline); + Assert.True(scriptMatch.Success); + var scriptContent = scriptMatch.Groups[1].Value; + Assert.DoesNotContain("\n", scriptContent); + Assert.Contains("\\n", scriptContent); + Assert.Contains("\\r", scriptContent); + } + + // --- BuildHtmlWidgetMarkdown --- + + [Fact] + public void BuildHtmlWidgetMarkdown_WrapsInCodeFence() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var result = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload); + Assert.StartsWith("```html-widget\n", result); + Assert.EndsWith("\n```", result); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_InjectsProtocol() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var result = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload); + Assert.Contains("ui/initialize", result); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_AppliesDefaultSecurityPolicy() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var result = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload); + Assert.Contains("securityPolicy", result); + Assert.Contains("resourceDomains", result); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_IncludesBeforeAndAfter() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var result = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload, new HtmlWidgetMarkdownOptions + { + Before = "Hello before", + After = "Hello after" + }); + Assert.StartsWith("Hello before\n", result); + Assert.EndsWith("\nHello after", result); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_ThrowsOnEmptyName() + { + var payload = new HtmlWidgetPayload + { + Name = "", + Html = "Hi", + Domain = "https://example.com" + }; + var ex = Assert.Throws(() => HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload)); + Assert.Contains("name", ex.Message); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_ThrowsOnEmptyHtml() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "", + Domain = "https://example.com" + }; + var ex = Assert.Throws(() => HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload)); + Assert.Contains("html", ex.Message); + } + + [Fact] + public void BuildHtmlWidgetMarkdown_ThrowsOnInvalidDomain() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "Hi", + Domain = "http://example.com" + }; + var ex = Assert.Throws(() => HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload)); + Assert.Contains("domain", ex.Message); + } + + // --- BuildHtmlWidgetMessage --- + + [Fact] + public void BuildHtmlWidgetMessage_SetsExtendedMarkdownFormat() + { + var payload = new HtmlWidgetPayload + { + Name = "test", + Html = "

Hi

", + Domain = "https://example.com" + }; + var message = HtmlWidgetHelpers.BuildHtmlWidgetMessage(payload); + Assert.Equal(TextFormats.ExtendedMarkdown, message.TextFormat); + Assert.Contains("```html-widget", message.Text); + } + + // --- ValidateSecurityPolicy --- + + [Fact] + public void ValidateSecurityPolicy_DetectsExternalScriptSrc() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy { ResourceDomains = [] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + Assert.Equal(""; + var policy = new HtmlWidgetSecurityPolicy { ResourceDomains = ["https://cdn.example.com"] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Empty(warnings); + } + + [Fact] + public void ValidateSecurityPolicy_DetectsFetch() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy { ConnectDomains = [] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + Assert.Equal("fetch()", warnings[0].Source); + Assert.Equal("connectDomains", warnings[0].PolicyField); + } + + [Fact] + public void ValidateSecurityPolicy_DetectsIframeSrc() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy { FrameDomains = [] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + Assert.Equal(""; + var policy = new HtmlWidgetSecurityPolicy { FrameDomains = ["https://embed.example.com"] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Empty(warnings); + } + + [Fact] + public void ValidateSecurityPolicy_DetectsCssImport() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy { ResourceDomains = [] }; + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + } + + [Fact] + public void ValidateSecurityPolicy_HandlesUndefinedPolicyFields() + { + var html = ""; + var policy = new HtmlWidgetSecurityPolicy(); + var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); + Assert.Single(warnings); + } +} + +#pragma warning restore ExperimentalTeamsHtmlWidget diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj b/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj index bd61ffd3..8e3a491b 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj +++ b/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj @@ -5,7 +5,7 @@ enable enable false - $(NoWarn);ExperimentalTeamsTargeted + $(NoWarn);ExperimentalTeamsTargeted;ExperimentalTeamsHtmlWidget From b802ffecb252760a5d45b4d7cbc51f833b9f92fc Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:40:23 -0700 Subject: [PATCH 4/6] Integration tests --- core/test/IntegrationTests/HtmlWidgetTests.cs | 165 ++++++++++++++++++ .../IntegrationTests/IntegrationTests.csproj | 2 +- 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 core/test/IntegrationTests/HtmlWidgetTests.cs diff --git a/core/test/IntegrationTests/HtmlWidgetTests.cs b/core/test/IntegrationTests/HtmlWidgetTests.cs new file mode 100644 index 00000000..71ef144a --- /dev/null +++ b/core/test/IntegrationTests/HtmlWidgetTests.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Teams.Apps.HtmlWidget; +using Microsoft.Teams.Apps.Schema; +using Microsoft.Teams.Core; +using Microsoft.Teams.Core.Schema; +using Xunit.Abstractions; + +namespace IntegrationTests; + +/// +/// Integration tests for HTML widget messages — verifies Teams accepts widget payloads via the Bot API. +/// These tests require a canary service URL to pass (widgets are canary-only). +/// +[SuppressMessage("Usage", "ExperimentalTeamsHtmlWidget:Type is for evaluation purposes only", Justification = "Integration tests for experimental feature")] +public class HtmlWidgetTests : IClassFixture +{ + private readonly IntegrationTestFixture _f; + private readonly ITestOutputHelper _output; + + public HtmlWidgetTests(IntegrationTestFixture fixture, ITestOutputHelper output) + { + _f = fixture; + _f.OutputHelper = output; + _output = output; + } + + private bool IsCanary => _f.ServiceUrl.ToString().Contains("canary", StringComparison.OrdinalIgnoreCase); + + private CoreActivity CreateWidgetActivity(string markdown) => + CoreActivity.CreateBuilder() + .WithType(ActivityType.Message) + .WithFrom(IntegrationTestFixture.GetChannelAccountWithAgenticProperties()) + .WithProperty("text", markdown) + .WithProperty("textFormat", TextFormats.ExtendedMarkdown) + .Build(); + + [SkippableFact(Timeout = 5000)] + public async Task SendWidgetMessage() + { + Skip.IfNot(IsCanary, "Widgets require canary service"); + + var markdown = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown( + new HtmlWidgetPayload + { + Name = "Integration Test Widget", + Description = "Verifies Teams accepts widget payload.", + Html = "

Integration test widget

", + Domain = "https://teams.microsoft.com", + SecurityPolicy = new HtmlWidgetSecurityPolicy + { + ConnectDomains = [], + ResourceDomains = ["'self'", "data:"], + FrameDomains = [], + BaseUriDomains = [], + }, + Permissions = new HtmlWidgetPermissions(), + }, + new HtmlWidgetMarkdownOptions { Before = "[.NET Integration] HTML widget send test" }); + + CoreActivity activity = CreateWidgetActivity(markdown); + SendActivityResponse? res = await _f.ScopedApiClient.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + + Assert.NotNull(res); + Assert.NotNull(res.Id); + _output.WriteLine($"Sent widget activity: {res.Id}"); + } + + [SkippableFact(Timeout = 5000)] + public async Task SendWidgetWithToolData() + { + Skip.IfNot(IsCanary, "Widgets require canary service"); + + var markdown = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown( + new HtmlWidgetPayload + { + Name = "ToolOutput Widget", + Description = "Widget with initial tool data.", + Html = "

Widget with tool data

", + Domain = "https://teams.microsoft.com", + SecurityPolicy = new HtmlWidgetSecurityPolicy + { + ConnectDomains = [], + ResourceDomains = ["'self'"], + FrameDomains = [], + BaseUriDomains = [], + }, + ToolInput = System.Text.Json.JsonSerializer.SerializeToElement(new { query = "test" }), + ToolOutput = System.Text.Json.JsonSerializer.SerializeToElement(new + { + content = new[] { new { type = "text", text = "Result data" } }, + structuredContent = new { key = "value" }, + isError = false, + }), + Permissions = new HtmlWidgetPermissions { ClipboardWrite = new() }, + }); + + CoreActivity activity = CreateWidgetActivity(markdown); + SendActivityResponse? res = await _f.ScopedApiClient.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + + Assert.NotNull(res); + Assert.NotNull(res.Id); + _output.WriteLine($"Sent widget with tool data: {res.Id}"); + } + + [SkippableFact(Timeout = 5000)] + public async Task UpdateWidgetMessage() + { + Skip.IfNot(IsCanary, "Widgets require canary service"); + + var markdown = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown( + new HtmlWidgetPayload + { + Name = "Update Test Widget", + Html = "

Original content

", + Domain = "https://teams.microsoft.com", + }, + new HtmlWidgetMarkdownOptions { Before = "[.NET Integration] Widget update test - original" }); + + CoreActivity activity = CreateWidgetActivity(markdown); + SendActivityResponse? sent = await _f.ScopedApiClient.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + Assert.NotNull(sent?.Id); + + var updatedMarkdown = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown( + new HtmlWidgetPayload + { + Name = "Update Test Widget", + Html = "

Updated content

", + Domain = "https://teams.microsoft.com", + }, + new HtmlWidgetMarkdownOptions { Before = "[.NET Integration] Widget update test - updated" }); + + CoreActivity updatedActivity = CreateWidgetActivity(updatedMarkdown); + UpdateActivityResponse? res = await _f.ScopedApiClient.Conversations.Activities.UpdateAsync( + _f.ConversationId, sent.Id, updatedActivity); + + Assert.NotNull(res?.Id); + _output.WriteLine($"Updated widget activity: {res.Id}"); + } + + [SkippableFact(Timeout = 10000)] + public async Task DeleteWidgetMessage() + { + Skip.IfNot(IsCanary, "Widgets require canary service"); + + var markdown = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown( + new HtmlWidgetPayload + { + Name = "Delete Test Widget", + Html = "

Will be deleted

", + Domain = "https://teams.microsoft.com", + }); + + CoreActivity activity = CreateWidgetActivity(markdown); + SendActivityResponse? sent = await _f.ScopedApiClient.Conversations.Activities.CreateAsync(_f.ConversationId, activity); + Assert.NotNull(sent?.Id); + + await Task.Delay(2000); + + await _f.ScopedApiClient.Conversations.Activities.DeleteAsync(_f.ConversationId, sent.Id, _f.AgenticIdentity); + _output.WriteLine($"Deleted widget activity: {sent.Id}"); + } +} diff --git a/core/test/IntegrationTests/IntegrationTests.csproj b/core/test/IntegrationTests/IntegrationTests.csproj index aeed14f9..2479fec0 100644 --- a/core/test/IntegrationTests/IntegrationTests.csproj +++ b/core/test/IntegrationTests/IntegrationTests.csproj @@ -5,7 +5,7 @@ enable enable false - $(NoWarn);ExperimentalTeamsTargeted;CS0618 + $(NoWarn);ExperimentalTeamsTargeted;ExperimentalTeamsHtmlWidget;CS0618 From c77ee78e54995cc7579d45dc68e7cde6e0d4855e Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:40:28 -0700 Subject: [PATCH 5/6] CI Fixes --- Samples/Deprecated.Controllers/Deprecated.Controllers.csproj | 3 ++- Samples/Samples.Dialogs/Samples.Dialogs.csproj | 3 ++- Samples/Samples.Echo/Samples.Echo.csproj | 2 +- Samples/Samples.Quoting/Samples.Quoting.csproj | 2 +- Samples/Samples.SuggestedAction/Samples.SuggestedAction.csproj | 2 +- .../Samples.TargetedMessages/Samples.TargetedMessages.csproj | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Samples/Deprecated.Controllers/Deprecated.Controllers.csproj b/Samples/Deprecated.Controllers/Deprecated.Controllers.csproj index 9a269eea..d2c63d5a 100644 --- a/Samples/Deprecated.Controllers/Deprecated.Controllers.csproj +++ b/Samples/Deprecated.Controllers/Deprecated.Controllers.csproj @@ -9,7 +9,8 @@ - + + diff --git a/Samples/Samples.Dialogs/Samples.Dialogs.csproj b/Samples/Samples.Dialogs/Samples.Dialogs.csproj index 90bea372..36e90424 100644 --- a/Samples/Samples.Dialogs/Samples.Dialogs.csproj +++ b/Samples/Samples.Dialogs/Samples.Dialogs.csproj @@ -21,7 +21,8 @@ - + + diff --git a/Samples/Samples.Echo/Samples.Echo.csproj b/Samples/Samples.Echo/Samples.Echo.csproj index 8c396d7b..3747a677 100644 --- a/Samples/Samples.Echo/Samples.Echo.csproj +++ b/Samples/Samples.Echo/Samples.Echo.csproj @@ -7,7 +7,7 @@ - + diff --git a/Samples/Samples.Quoting/Samples.Quoting.csproj b/Samples/Samples.Quoting/Samples.Quoting.csproj index 8c396d7b..3747a677 100644 --- a/Samples/Samples.Quoting/Samples.Quoting.csproj +++ b/Samples/Samples.Quoting/Samples.Quoting.csproj @@ -7,7 +7,7 @@ - + diff --git a/Samples/Samples.SuggestedAction/Samples.SuggestedAction.csproj b/Samples/Samples.SuggestedAction/Samples.SuggestedAction.csproj index 8c396d7b..3747a677 100644 --- a/Samples/Samples.SuggestedAction/Samples.SuggestedAction.csproj +++ b/Samples/Samples.SuggestedAction/Samples.SuggestedAction.csproj @@ -7,7 +7,7 @@ - + diff --git a/Samples/Samples.TargetedMessages/Samples.TargetedMessages.csproj b/Samples/Samples.TargetedMessages/Samples.TargetedMessages.csproj index d6390cbb..16a70e6e 100644 --- a/Samples/Samples.TargetedMessages/Samples.TargetedMessages.csproj +++ b/Samples/Samples.TargetedMessages/Samples.TargetedMessages.csproj @@ -8,7 +8,7 @@ - + From 10c6faf38c444e7234e5a39e44bb583e11e405fa Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:24:40 -0700 Subject: [PATCH 6/6] Address feedback --- .../HtmlWidget/CallToolResult.cs | 3 ++- .../HtmlWidget/HtmlWidgetPayload.cs | 19 ++++++++++-------- ...etProtocol_FullScriptSnapshot.verified.txt | 1 + .../HtmlWidgetHelpersTests.cs | 20 ++++++++++++++++++- .../Microsoft.Teams.Apps.UnitTests.csproj | 1 + 5 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.InjectWidgetProtocol_FullScriptSnapshot.verified.txt diff --git a/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs index 27a7bf9a..4ae49311 100644 --- a/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs +++ b/core/src/Microsoft.Teams.Apps/HtmlWidget/CallToolResult.cs @@ -13,7 +13,8 @@ namespace Microsoft.Teams.Apps.HtmlWidget; public class McpUiCallToolResultContent { /// - /// The type of content (e.g. "text"). + /// The type of content. MCP defines: "text", "image", "audio", "resource". + /// Teams currently only renders "text" content. /// [JsonPropertyName("type")] public string Type { get; set; } = "text"; diff --git a/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetPayload.cs b/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetPayload.cs index a2bec804..974fd0dd 100644 --- a/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetPayload.cs +++ b/core/src/Microsoft.Teams.Apps/HtmlWidget/HtmlWidgetPayload.cs @@ -44,37 +44,38 @@ public class HtmlWidgetSecurityPolicy /// /// Permissions that the widget may request from the host. +/// Presence of a field means the permission is requested (value should be an empty object). /// [Experimental("ExperimentalTeamsHtmlWidget")] public class HtmlWidgetPermissions { /// - /// Request camera access. + /// Request camera access. Set to an empty dictionary to request. /// [JsonPropertyName("camera")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Camera { get; set; } + public IDictionary? Camera { get; set; } /// - /// Request microphone access. + /// Request microphone access. Set to an empty dictionary to request. /// [JsonPropertyName("microphone")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Microphone { get; set; } + public IDictionary? Microphone { get; set; } /// - /// Request geolocation access. + /// Request geolocation access. Set to an empty dictionary to request. /// [JsonPropertyName("geolocation")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Geolocation { get; set; } + public IDictionary? Geolocation { get; set; } /// - /// Request clipboard write access. + /// Request clipboard write access. Set to an empty dictionary to request. /// [JsonPropertyName("clipboardWrite")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? ClipboardWrite { get; set; } + public IDictionary? ClipboardWrite { get; set; } } /// @@ -112,6 +113,8 @@ public class HtmlWidgetPayload /// /// The domain associated with the widget, applied to sandbox metadata. /// Must be a valid domain URL (e.g. 'https://example.com'). + /// This is informational metadata, not a verified identity claim. + /// The platform does not authenticate this value. /// [JsonPropertyName("domain")] public string Domain { get; set; } = string.Empty; diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.InjectWidgetProtocol_FullScriptSnapshot.verified.txt b/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.InjectWidgetProtocol_FullScriptSnapshot.verified.txt new file mode 100644 index 00000000..47ea8ebc --- /dev/null +++ b/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.InjectWidgetProtocol_FullScriptSnapshot.verified.txt @@ -0,0 +1 @@ +(function(){document.addEventListener('securitypolicyviolation',function(e){console.warn('[widget CSP violation]',{blockedURI:e.blockedURI,violatedDirective:e.violatedDirective,originalPolicy:e.originalPolicy});});var id='init-'+Math.random().toString(36).slice(2);function notifySize(){window.parent.postMessage({jsonrpc:'2.0',method:'ui/notifications/size-changed',params:{height:document.body.scrollHeight}},'*');}window.addEventListener('message',function(e){var d=e.data;if(!d||d.jsonrpc!=='2.0')return;if(d.id===id&&d.result){window.parent.postMessage({jsonrpc:'2.0',method:'ui/notifications/initialized'},'*');setTimeout(notifySize,100);}if(d.method==='ui/notifications/tool-result'&&window.onToolResult){window.onToolResult(d.params);}if(d.method==='ui/notifications/tool-input'&&window.onToolInput){window.onToolInput(d.params);}});window.parent.postMessage({jsonrpc:'2.0',id:id,method:'ui/initialize',params:{protocolVersion:'2026-01-26',appInfo:{name:'My Widget',version:'2.0.0'},appCapabilities:{availableDisplayModes:["inline","fullscreen"]}}},'*');document.addEventListener('DOMContentLoaded',notifySize);})() \ No newline at end of file diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.cs b/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.cs index 5e174bb3..ebcf00e4 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.cs +++ b/core/test/Microsoft.Teams.Apps.UnitTests/HtmlWidgetHelpersTests.cs @@ -558,7 +558,7 @@ public void BuildHtmlWidgetMarkdown_SerializesFullPayload() BaseUriDomains = [], }, ToolInput = JsonSerializer.SerializeToElement(new { location = "Seattle" }), - Permissions = new HtmlWidgetPermissions { ClipboardWrite = new() }, + Permissions = new HtmlWidgetPermissions { ClipboardWrite = new Dictionary() }, }; var result = HtmlWidgetHelpers.BuildHtmlWidgetMarkdown(payload); Assert.StartsWith("```html-widget\n", result); @@ -733,6 +733,24 @@ public void ValidateSecurityPolicy_HandlesUndefinedPolicyFields() var warnings = HtmlWidgetHelpers.ValidateSecurityPolicy(html, policy); Assert.Single(warnings); } + + [Fact] + public async Task InjectWidgetProtocol_FullScriptSnapshot() + { + var opts = new InjectWidgetProtocolOptions + { + Name = "My Widget", + Version = "2.0.0", + AvailableDisplayModes = ["inline", "fullscreen"], + Notifications = ["tool-result", "tool-input"], + DebugCspViolations = true, + }; + var result = HtmlWidgetHelpers.InjectWidgetProtocol("

Hello

", opts); + + var match = System.Text.RegularExpressions.Regex.Match(result, @""); + Assert.True(match.Success); + await VerifyXunit.Verifier.Verify(match.Groups[1].Value); + } } #pragma warning restore ExperimentalTeamsHtmlWidget diff --git a/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj b/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj index 8e3a491b..eabfcdbb 100644 --- a/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj +++ b/core/test/Microsoft.Teams.Apps.UnitTests/Microsoft.Teams.Apps.UnitTests.csproj @@ -15,6 +15,7 @@ + all