From 91abc815e767e23103df11260f46f4f58c1eaa1b Mon Sep 17 00:00:00 2001 From: tk Date: Sun, 5 Apr 2026 12:21:09 -0700 Subject: [PATCH 1/3] Add unit tests for all sample apps (104 tests) Add comprehensive test coverage across all four sample apps: - dotnet/a2a-raw.tests: 24 tests (ExtractText JSON parsing, arg parsing) - dotnet/rest.tests: 25 tests (BuildChatBody, Trunc, arg parsing) - dotnet/a2a.tests: 21 tests (Extract/Join with A2A SDK types, arg parsing) - rust/a2a: 34 inline tests (auth code extraction, JWT decode, message building, text joining, delta printing) - swift/a2a: 8 tests (ChatMessage model) + commented test suggestions for private members ~60% of tests cover non-happy-path scenarios: malformed JSON, empty/null inputs, arg parsing crashes (IndexOutOfRangeException, FormatException), JSON injection attempts, unicode/emoji, large payloads, and auth error priority handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/a2a-raw.tests/ArgParsingTests.cs | 110 +++++++ dotnet/a2a-raw.tests/ExtractTextTests.cs | 229 ++++++++++++++ .../a2a-raw.tests/workiq-a2a-raw-tests.csproj | 13 + dotnet/a2a.tests/ArgParsingTests.cs | 149 +++++++++ dotnet/a2a.tests/ExtractTests.cs | 204 ++++++++++++ dotnet/a2a.tests/nuget.config | 7 + dotnet/a2a.tests/workiq-a2a-tests.csproj | 14 + dotnet/rest.tests/ArgParsingTests.cs | 152 +++++++++ dotnet/rest.tests/ChatBodyTests.cs | 111 +++++++ dotnet/rest.tests/TruncTests.cs | 48 +++ dotnet/rest.tests/workiq-rest-tests.csproj | 13 + rust/a2a/src/auth.rs | 140 +++++++++ rust/a2a/src/main.rs | 290 ++++++++++++++++++ swift/a2a/A2A ChatTests/A2A_ChatTests.swift | 241 ++++++++++++++- 14 files changed, 1718 insertions(+), 3 deletions(-) create mode 100644 dotnet/a2a-raw.tests/ArgParsingTests.cs create mode 100644 dotnet/a2a-raw.tests/ExtractTextTests.cs create mode 100644 dotnet/a2a-raw.tests/workiq-a2a-raw-tests.csproj create mode 100644 dotnet/a2a.tests/ArgParsingTests.cs create mode 100644 dotnet/a2a.tests/ExtractTests.cs create mode 100644 dotnet/a2a.tests/nuget.config create mode 100644 dotnet/a2a.tests/workiq-a2a-tests.csproj create mode 100644 dotnet/rest.tests/ArgParsingTests.cs create mode 100644 dotnet/rest.tests/ChatBodyTests.cs create mode 100644 dotnet/rest.tests/TruncTests.cs create mode 100644 dotnet/rest.tests/workiq-rest-tests.csproj diff --git a/dotnet/a2a-raw.tests/ArgParsingTests.cs b/dotnet/a2a-raw.tests/ArgParsingTests.cs new file mode 100644 index 0000000..4293753 --- /dev/null +++ b/dotnet/a2a-raw.tests/ArgParsingTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace WorkIQ.A2ARaw.Tests; + +/// +/// Tests for arg-parsing logic duplicated from a2a-raw Program.cs. +/// The original uses args[++i] which will throw IndexOutOfRangeException +/// if a flag requiring a value is the last argument. +/// +public class ArgParsingTests +{ + // ── Duplicated arg parsing logic ───────────────────────────────────── + + private record ParseResult( + string? Endpoint, string? Token, string? AppId, string? Account, + bool Stream, bool AllHeaders, string? Error); + + private static ParseResult ParseArgs(string[] args) + { + string? endpoint = null, token = null, appId = null, account = null; + bool stream = false, allHeaders = false; + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--endpoint" or "-e": endpoint = args[++i]; break; + case "--token" or "-t": token = args[++i]; break; + case "--appid" or "-a": appId = args[++i]; break; + case "--account": account = args[++i]; break; + case "--stream": stream = true; break; + case "--all-headers": allHeaders = true; break; + default: + return new ParseResult(null, null, null, null, false, false, $"Unknown flag: {args[i]}"); + } + } + + return new ParseResult(endpoint, token, appId, account, stream, allHeaders, null); + } + + // ── Tests ──────────────────────────────────────────────────────────── + + [Fact] + public void ValidArgs_AllParsedCorrectly() + { + var result = ParseArgs(["--endpoint", "https://example.com", "--token", "abc", "--appid", "id1", "--stream"]); + Assert.Null(result.Error); + Assert.Equal("https://example.com", result.Endpoint); + Assert.Equal("abc", result.Token); + Assert.Equal("id1", result.AppId); + Assert.True(result.Stream); + } + + [Fact] + public void ShortFlags_Work() + { + var result = ParseArgs(["-e", "https://example.com", "-t", "tok", "-a", "app"]); + Assert.Null(result.Error); + Assert.Equal("https://example.com", result.Endpoint); + Assert.Equal("tok", result.Token); + Assert.Equal("app", result.AppId); + } + + [Fact] + public void UnknownFlag_ReturnsError() + { + var result = ParseArgs(["--unknown"]); + Assert.NotNull(result.Error); + Assert.Contains("Unknown flag", result.Error); + } + + [Fact] + public void AllHeadersFlag_Parsed() + { + var result = ParseArgs(["--all-headers", "--endpoint", "url", "--token", "t"]); + Assert.True(result.AllHeaders); + } + + [Fact] + public void AccountFlag_Parsed() + { + var result = ParseArgs(["--endpoint", "url", "--token", "t", "--account", "user@example.com"]); + Assert.Equal("user@example.com", result.Account); + } + + [Fact] + public void MissingValueAfterToken_ThrowsIndexOutOfRange() + { + // Bug: args[++i] throws when --token is the last arg with no value + Assert.Throws(() => ParseArgs(["--token"])); + } + + [Fact] + public void MissingValueAfterEndpoint_ThrowsIndexOutOfRange() + { + Assert.Throws(() => ParseArgs(["--endpoint"])); + } + + [Fact] + public void EmptyArgs_NoError() + { + var result = ParseArgs([]); + Assert.Null(result.Error); + Assert.Null(result.Endpoint); + Assert.Null(result.Token); + } +} diff --git a/dotnet/a2a-raw.tests/ExtractTextTests.cs b/dotnet/a2a-raw.tests/ExtractTextTests.cs new file mode 100644 index 0000000..bb3d8b8 --- /dev/null +++ b/dotnet/a2a-raw.tests/ExtractTextTests.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; +using Xunit; + +namespace WorkIQ.A2ARaw.Tests; + +/// +/// Tests for ExtractText / TryGetParts logic duplicated from a2a-raw Program.cs. +/// These are local functions in the top-level program so we replicate them here. +/// +public class ExtractTextTests +{ + // ── Duplicated logic under test ────────────────────────────────────── + + private static string ExtractText(JsonElement el) + { + if (TryGetParts(el, out var text)) return text; + if (el.TryGetProperty("status", out var status) && + status.TryGetProperty("message", out var msg) && + TryGetParts(msg, out text)) return text; + if (el.TryGetProperty("message", out var m) && + TryGetParts(m, out text)) return text; + return ""; + } + + private static bool TryGetParts(JsonElement el, out string text) + { + text = ""; + if (!el.TryGetProperty("parts", out var parts) || parts.ValueKind != JsonValueKind.Array) + return false; + + var sb = new StringBuilder(); + foreach (var part in parts.EnumerateArray()) + { + if (part.TryGetProperty("text", out var t)) + sb.Append(t.GetString()); + } + + text = sb.ToString(); + return text.Length > 0; + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static JsonElement Parse(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + // ── ExtractText tests ─────────────────────────────────────────────── + + [Fact] + public void ExtractText_DirectParts_ReturnsText() + { + var el = Parse("""{ "parts": [{ "text": "Hello world" }] }"""); + Assert.Equal("Hello world", ExtractText(el)); + } + + [Fact] + public void ExtractText_StatusMessageParts_ReturnsText() + { + var el = Parse(""" + { + "status": { + "message": { + "parts": [{ "text": "Task completed" }] + } + } + } + """); + Assert.Equal("Task completed", ExtractText(el)); + } + + [Fact] + public void ExtractText_MessageParts_ReturnsText() + { + var el = Parse(""" + { + "message": { + "parts": [{ "text": "From message" }] + } + } + """); + Assert.Equal("From message", ExtractText(el)); + } + + [Fact] + public void ExtractText_EmptyObject_ReturnsEmpty() + { + var el = Parse("{}"); + Assert.Equal("", ExtractText(el)); + } + + [Fact] + public void ExtractText_MissingParts_ReturnsEmpty() + { + var el = Parse("""{ "status": { "message": {} } }"""); + Assert.Equal("", ExtractText(el)); + } + + [Fact] + public void ExtractText_MultipleParts_Concatenated() + { + var el = Parse("""{ "parts": [{ "text": "Hello " }, { "text": "world" }] }"""); + Assert.Equal("Hello world", ExtractText(el)); + } + + [Fact] + public void ExtractText_MixedPartTypes_OnlyTextExtracted() + { + var el = Parse(""" + { + "parts": [ + { "text": "visible" }, + { "kind": "data", "data": "abc" }, + { "text": " text" } + ] + } + """); + Assert.Equal("visible text", ExtractText(el)); + } + + [Fact] + public void ExtractText_PrefersDirectParts_OverStatusMessage() + { + var el = Parse(""" + { + "parts": [{ "text": "direct" }], + "status": { "message": { "parts": [{ "text": "nested" }] } } + } + """); + Assert.Equal("direct", ExtractText(el)); + } + + // ── TryGetParts tests ─────────────────────────────────────────────── + + [Fact] + public void TryGetParts_MissingPartsProperty_ReturnsFalse() + { + var el = Parse("""{ "other": 123 }"""); + var result = TryGetParts(el, out var text); + Assert.False(result); + Assert.Equal("", text); + } + + [Fact] + public void TryGetParts_NonArrayParts_ReturnsFalse() + { + var el = Parse("""{ "parts": "not-an-array" }"""); + var result = TryGetParts(el, out var text); + Assert.False(result); + Assert.Equal("", text); + } + + [Fact] + public void TryGetParts_EmptyArray_ReturnsFalse() + { + var el = Parse("""{ "parts": [] }"""); + var result = TryGetParts(el, out var text); + Assert.False(result); + Assert.Equal("", text); + } + + [Fact] + public void TryGetParts_PartsWithNoTextProperty_ReturnsFalse() + { + var el = Parse("""{ "parts": [{ "kind": "data" }] }"""); + var result = TryGetParts(el, out var text); + Assert.False(result); + Assert.Equal("", text); + } + + [Fact] + public void TryGetParts_ValidParts_ReturnsTrueWithText() + { + var el = Parse("""{ "parts": [{ "text": "ok" }] }"""); + var result = TryGetParts(el, out var text); + Assert.True(result); + Assert.Equal("ok", text); + } + + // ── Edge-case tests ───────────────────────────────────────────────── + + [Fact] + public void ExtractText_NullTextInParts_HandledGracefully() + { + // "text": null — GetString() returns null, StringBuilder.Append(null) is a no-op. + var el = Parse("""{ "parts": [{ "text": null }] }"""); + // TryGetParts returns false because appended text length is 0. + var result = ExtractText(el); + Assert.Equal("", result); + } + + [Fact] + public void ExtractText_DeeplyNestedStatusMessage_Works() + { + // Realistic server response with full status.message.parts path + var json = """ + { + "id": "task-123", + "status": { + "state": "completed", + "message": { + "role": "agent", + "parts": [ + { "text": "Here is the answer: " }, + { "text": "42" } + ] + } + }, + "contextId": "ctx-abc" + } + """; + var el = Parse(json); + Assert.Equal("Here is the answer: 42", ExtractText(el)); + } + + [Fact] + public void ExtractText_UnicodeText_PreservedCorrectly() + { + // Emoji, CJK characters, and RTL text + var el = Parse("""{ "parts": [{ "text": "🚀 你好世界 مرحبا" }] }"""); + Assert.Equal("🚀 你好世界 مرحبا", ExtractText(el)); + } +} diff --git a/dotnet/a2a-raw.tests/workiq-a2a-raw-tests.csproj b/dotnet/a2a-raw.tests/workiq-a2a-raw-tests.csproj new file mode 100644 index 0000000..8b35db3 --- /dev/null +++ b/dotnet/a2a-raw.tests/workiq-a2a-raw-tests.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + false + + + + + + + diff --git a/dotnet/a2a.tests/ArgParsingTests.cs b/dotnet/a2a.tests/ArgParsingTests.cs new file mode 100644 index 0000000..34d4b2c --- /dev/null +++ b/dotnet/a2a.tests/ArgParsingTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace WorkIQ.A2A.Tests; + +/// +/// Tests for arg-parsing logic duplicated from a2a Program.cs. +/// +public class ArgParsingTests +{ + // ── Duplicated types and logic ─────────────────────────────────────── + + private record GatewayConfig(string Name, string Endpoint, string[] Scopes, string Authority, string[] ExtraHeaders); + private record Config(string Token, string AppId, GatewayConfig Gateway, string? Account, bool ShowToken, int Verbosity, bool Stream); + + private static readonly GatewayConfig Graph = new( + Name: "Graph RP", + Endpoint: "https://graph.microsoft.com/rp/workiq/", + Scopes: ["https://graph.microsoft.com/.default"], + Authority: "https://login.microsoftonline.com/common", + ExtraHeaders: []); + + private static readonly GatewayConfig WorkIQ = new( + Name: "WorkIQ Gateway", + Endpoint: "", + Scopes: [], + Authority: "https://login.microsoftonline.com/common", + ExtraHeaders: []); + + private static Config? ParseArgs(string[] args) + { + string? token = null, appId = null, endpoint = null, account = null; + bool graph = false, workiq = false, showToken = false, stream = false; + int verbosity = 1; + var headers = new List(); + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--graph": graph = true; break; + case "--workiq": workiq = true; break; + case "--token" or "-t": token = args[++i]; break; + case "--appid" or "-a": appId = args[++i]; break; + case "--endpoint" or "-e": endpoint = args[++i]; break; + case "--account": account = args[++i]; break; + case "--show-token": showToken = true; break; + case "--stream": stream = true; break; + case "--verbosity" or "-v": verbosity = int.Parse(args[++i]); break; + case "--header" or "-H": headers.Add(args[++i]); break; + } + } + + if (string.IsNullOrEmpty(token) || (!graph && !workiq)) + return null; + + if (graph && workiq) + return null; + + if (workiq) + return null; + + if (token.Equals("WAM", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(appId)) + return null; + + var gw = graph ? Graph : WorkIQ; + if (!string.IsNullOrEmpty(endpoint)) + gw = gw with { Endpoint = endpoint }; + if (headers.Count > 0) + gw = gw with { ExtraHeaders = [.. gw.ExtraHeaders, .. headers] }; + + return new Config(token, appId ?? "", gw, account, showToken, verbosity, stream); + } + + // ── Tests ──────────────────────────────────────────────────────────── + + [Fact] + public void ValidArgs_ProduceCorrectConfig() + { + var result = ParseArgs(["--graph", "--token", "mytoken", "--appid", "app1", "--stream"]); + Assert.NotNull(result); + Assert.Equal("mytoken", result.Token); + Assert.Equal("app1", result.AppId); + Assert.True(result.Stream); + Assert.Equal("Graph RP", result.Gateway.Name); + } + + [Fact] + public void MissingToken_ReturnsNull() + { + Assert.Null(ParseArgs(["--graph"])); + } + + [Fact] + public void MissingGateway_ReturnsNull() + { + Assert.Null(ParseArgs(["--token", "abc"])); + } + + [Fact] + public void GraphAndWorkiqTogether_ReturnsNull() + { + Assert.Null(ParseArgs(["--graph", "--workiq", "--token", "abc"])); + } + + [Fact] + public void WamWithoutAppid_ReturnsNull() + { + Assert.Null(ParseArgs(["--graph", "--token", "WAM"])); + } + + [Fact] + public void EndpointOverride_AppliedToGateway() + { + var result = ParseArgs(["--graph", "--token", "t", "--endpoint", "https://custom.com/"]); + Assert.NotNull(result); + Assert.Equal("https://custom.com/", result.Gateway.Endpoint); + } + + [Fact] + public void HeaderValues_AreCollected() + { + var result = ParseArgs(["--graph", "--token", "t", "--header", "X-Custom: v1", "-H", "X-Other: v2"]); + Assert.NotNull(result); + Assert.Equal(2, result.Gateway.ExtraHeaders.Length); + } + + [Fact] + public void VerbosityWithNonInteger_ThrowsFormatException() + { + Assert.Throws(() => ParseArgs(["--graph", "--token", "t", "--verbosity", "abc"])); + } + + [Fact] + public void MissingValueAfterToken_ThrowsIndexOutOfRange() + { + Assert.Throws(() => ParseArgs(["--graph", "--token"])); + } + + [Fact] + public void DefaultVerbosity_IsOne() + { + var result = ParseArgs(["--graph", "--token", "t"]); + Assert.NotNull(result); + Assert.Equal(1, result.Verbosity); + } +} diff --git a/dotnet/a2a.tests/ExtractTests.cs b/dotnet/a2a.tests/ExtractTests.cs new file mode 100644 index 0000000..731f86a --- /dev/null +++ b/dotnet/a2a.tests/ExtractTests.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using A2A; +using Xunit; + +namespace WorkIQ.A2A.Tests; + +/// +/// Tests for Extract/Join logic duplicated from a2a Program.cs. +/// +public class ExtractTests +{ + // ── Duplicated logic under test ────────────────────────────────────── + + private static (string text, string? contextId, Dictionary? metadata) Extract(object response) => response switch + { + AgentMessage am => (Join(am), am.ContextId, am.Metadata), + AgentTask { Status: { State: TaskState.Completed, Message: AgentMessage cm } } t => (Join(cm), t.ContextId, cm.Metadata), + AgentTask t => ($"[Task {t.Id} — {t.Status.State}]", t.ContextId, null), + _ => ("(no response)", null, null), + }; + + private static string Join(AgentMessage m) => string.Join("\n", m.Parts.OfType().Select(p => p.Text)); + + // ── Extract tests ─────────────────────────────────────────────────── + + [Fact] + public void Extract_AgentMessage_ReturnsTextAndContext() + { + var msg = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "m1", + ContextId = "ctx-1", + Parts = [new TextPart { Text = "Hello from agent" }], + }; + + var (text, contextId, metadata) = Extract(msg); + Assert.Equal("Hello from agent", text); + Assert.Equal("ctx-1", contextId); + } + + [Fact] + public void Extract_AgentMessage_WithMetadata_ReturnsMetadata() + { + var meta = new Dictionary + { + ["key"] = JsonSerializer.SerializeToElement("value"), + }; + var msg = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "m2", + Parts = [new TextPart { Text = "test" }], + Metadata = meta, + }; + + var (_, _, metadata) = Extract(msg); + Assert.NotNull(metadata); + Assert.Equal("value", metadata["key"].GetString()); + } + + [Fact] + public void Extract_CompletedAgentTask_ReturnsMessageText() + { + var agentMsg = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "m3", + Parts = [new TextPart { Text = "Task done" }], + }; + var task = new AgentTask + { + Id = "t1", + ContextId = "ctx-2", + Status = new AgentTaskStatus { State = TaskState.Completed, Message = agentMsg }, + }; + + var (text, contextId, _) = Extract(task); + Assert.Equal("Task done", text); + Assert.Equal("ctx-2", contextId); + } + + [Fact] + public void Extract_NonCompletedAgentTask_ReturnsStatusString() + { + var task = new AgentTask + { + Id = "t2", + ContextId = "ctx-3", + Status = new AgentTaskStatus { State = TaskState.Working }, + }; + + var (text, contextId, metadata) = Extract(task); + Assert.Contains("t2", text); + Assert.Contains("Working", text); + Assert.Equal("ctx-3", contextId); + Assert.Null(metadata); + } + + [Fact] + public void Extract_UnknownType_ReturnsNoResponse() + { + var (text, contextId, metadata) = Extract("some random object"); + Assert.Equal("(no response)", text); + Assert.Null(contextId); + Assert.Null(metadata); + } + + // ── Join tests ────────────────────────────────────────────────────── + + [Fact] + public void Join_MultipleTextParts_JoinedWithNewline() + { + var msg = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "m4", + Parts = [new TextPart { Text = "Line 1" }, new TextPart { Text = "Line 2" }], + }; + + Assert.Equal("Line 1\nLine 2", Join(msg)); + } + + [Fact] + public void Join_EmptyParts_ReturnsEmptyString() + { + var msg = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "m5", + Parts = [], + }; + + Assert.Equal("", Join(msg)); + } + + [Fact] + public void Join_NonTextParts_Filtered() + { + var msg = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "m6", + Parts = [new TextPart { Text = "visible" }, new DataPart { Data = new Dictionary() }], + }; + + Assert.Equal("visible", Join(msg)); + } + + [Fact] + public void Join_MixedParts_OnlyTextPartsIncluded() + { + var msg = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "m7", + Parts = + [ + new TextPart { Text = "A" }, + new DataPart { Data = new Dictionary() }, + new TextPart { Text = "B" }, + new FilePart { File = new FileContent(bytes: new byte[] { 0 }) { Name = "file.txt" } }, + new TextPart { Text = "C" }, + ], + }; + + Assert.Equal("A\nB\nC", Join(msg)); + } + + // ── Edge-case tests ───────────────────────────────────────────────── + + [Fact] + public void Extract_AgentMessage_EmptyParts_ReturnsEmptyString() + { + var msg = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "m-empty", + Parts = [], + }; + + var (text, _, _) = Extract(msg); + Assert.Equal("", text); + } + + [Fact] + public void Extract_AgentMessage_NullContextId_ReturnsNull() + { + var msg = new AgentMessage + { + Role = MessageRole.Agent, + MessageId = "m-no-ctx", + Parts = [new TextPart { Text = "test" }], + // ContextId not set — defaults to null + }; + + var (text, contextId, _) = Extract(msg); + Assert.Equal("test", text); + Assert.Null(contextId); + } +} diff --git a/dotnet/a2a.tests/nuget.config b/dotnet/a2a.tests/nuget.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/dotnet/a2a.tests/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dotnet/a2a.tests/workiq-a2a-tests.csproj b/dotnet/a2a.tests/workiq-a2a-tests.csproj new file mode 100644 index 0000000..149f24a --- /dev/null +++ b/dotnet/a2a.tests/workiq-a2a-tests.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + false + + + + + + + + diff --git a/dotnet/rest.tests/ArgParsingTests.cs b/dotnet/rest.tests/ArgParsingTests.cs new file mode 100644 index 0000000..a432e70 --- /dev/null +++ b/dotnet/rest.tests/ArgParsingTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace WorkIQ.Rest.Tests; + +/// +/// Tests for arg-parsing logic duplicated from rest Program.cs. +/// +public class ArgParsingTests +{ + // ── Duplicated types and logic ─────────────────────────────────────── + + private record Config(string Token, string AppId, string? Account, bool Stream, bool ShowToken, int Verbosity, List Headers); + + private static Config? ParseArgs(string[] args) + { + string? token = null, appId = null, account = null; + bool graph = false, workiq = false, stream = false, showToken = false; + int verbosity = 1; + var headers = new List(); + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--graph": graph = true; break; + case "--workiq": workiq = true; break; + case "--token" or "-t": token = args[++i]; break; + case "--appid" or "-a": appId = args[++i]; break; + case "--account": account = args[++i]; break; + case "--stream": stream = true; break; + case "--show-token": showToken = true; break; + case "--verbosity" or "-v": verbosity = int.Parse(args[++i]); break; + case "--header" or "-H": headers.Add(args[++i]); break; + } + } + + if (string.IsNullOrEmpty(token) || (!graph && !workiq)) + return null; + + if (graph && workiq) + return null; + + if (workiq) + return null; + + if (token.Equals("WAM", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(appId)) + return null; + + return new Config(token, appId ?? "", account, stream, showToken, verbosity, headers); + } + + // ── Tests ──────────────────────────────────────────────────────────── + + [Fact] + public void GraphAndWorkiqTogether_ReturnsNull() + { + var result = ParseArgs(["--graph", "--workiq", "--token", "abc"]); + Assert.Null(result); + } + + [Fact] + public void WamWithoutAppid_ReturnsNull() + { + var result = ParseArgs(["--graph", "--token", "WAM"]); + Assert.Null(result); + } + + [Fact] + public void MissingToken_ReturnsNull() + { + var result = ParseArgs(["--graph"]); + Assert.Null(result); + } + + [Fact] + public void MissingGateway_ReturnsNull() + { + var result = ParseArgs(["--token", "abc"]); + Assert.Null(result); + } + + [Fact] + public void ValidArgs_ProduceCorrectConfig() + { + var result = ParseArgs(["--graph", "--token", "mytoken", "--appid", "app1", "--stream", "--account", "user@test.com"]); + Assert.NotNull(result); + Assert.Equal("mytoken", result.Token); + Assert.Equal("app1", result.AppId); + Assert.Equal("user@test.com", result.Account); + Assert.True(result.Stream); + } + + [Fact] + public void VerbosityWithNonInteger_ThrowsFormatException() + { + // Bug: int.Parse will throw if the value isn't an integer + Assert.Throws(() => ParseArgs(["--graph", "--token", "t", "--verbosity", "abc"])); + } + + [Fact] + public void HeaderValues_AreCollected() + { + var result = ParseArgs(["--graph", "--token", "t", "--header", "X-Custom: value1", "-H", "X-Other: value2"]); + Assert.NotNull(result); + Assert.Equal(2, result.Headers.Count); + Assert.Equal("X-Custom: value1", result.Headers[0]); + Assert.Equal("X-Other: value2", result.Headers[1]); + } + + [Fact] + public void ShowToken_Parsed() + { + var result = ParseArgs(["--graph", "--token", "t", "--show-token"]); + Assert.NotNull(result); + Assert.True(result.ShowToken); + } + + [Fact] + public void DefaultVerbosity_IsOne() + { + var result = ParseArgs(["--graph", "--token", "t"]); + Assert.NotNull(result); + Assert.Equal(1, result.Verbosity); + } + + [Fact] + public void MissingValueAfterToken_ThrowsIndexOutOfRange() + { + Assert.Throws(() => ParseArgs(["--graph", "--token"])); + } + + // ── Edge-case tests ───────────────────────────────────────────────── + + [Fact] + public void WorkiqGateway_ReturnsNull_WithMessage() + { + // --workiq is recognized but not yet implemented — always returns null + var result = ParseArgs(["--workiq", "--token", "X"]); + Assert.Null(result); + } + + [Fact] + public void StreamFlag_SetsStreamTrue() + { + var result = ParseArgs(["--graph", "--token", "t", "--stream"]); + Assert.NotNull(result); + Assert.True(result.Stream); + } +} diff --git a/dotnet/rest.tests/ChatBodyTests.cs b/dotnet/rest.tests/ChatBodyTests.cs new file mode 100644 index 0000000..419462f --- /dev/null +++ b/dotnet/rest.tests/ChatBodyTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using Xunit; + +namespace WorkIQ.Rest.Tests; + +/// +/// Tests for BuildChatBody logic duplicated from rest Program.cs. +/// +public class ChatBodyTests +{ + // ── Duplicated logic under test ────────────────────────────────────── + + private static string BuildChatBody(string message) + { + string tz; + try { tz = TimeZoneInfo.Local.HasIanaId ? TimeZoneInfo.Local.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(TimeZoneInfo.Local.Id, out var iana) ? iana : "UTC"; } + catch { tz = "UTC"; } + + return JsonSerializer.Serialize(new + { + message = new { text = message }, + locationHint = new { timeZone = tz }, + }); + } + + // ── Tests ──────────────────────────────────────────────────────────── + + [Fact] + public void BuildChatBody_ContainsMessageText() + { + var body = BuildChatBody("Hello"); + using var doc = JsonDocument.Parse(body); + var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); + Assert.Equal("Hello", text); + } + + [Fact] + public void BuildChatBody_OutputIsValidJson() + { + var body = BuildChatBody("test"); + var ex = Record.Exception(() => JsonDocument.Parse(body)); + Assert.Null(ex); + } + + [Fact] + public void BuildChatBody_SpecialCharacters_ArePreserved() + { + var msg = "He said \"hello\"\nNew line\tTab \u00e9"; + var body = BuildChatBody(msg); + using var doc = JsonDocument.Parse(body); + var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); + Assert.Equal(msg, text); + } + + [Fact] + public void BuildChatBody_ContainsLocationHintTimeZone() + { + var body = BuildChatBody("test"); + using var doc = JsonDocument.Parse(body); + var tz = doc.RootElement.GetProperty("locationHint").GetProperty("timeZone").GetString(); + Assert.NotNull(tz); + Assert.NotEmpty(tz); + } + + [Fact] + public void BuildChatBody_EmptyMessage_StillValid() + { + var body = BuildChatBody(""); + using var doc = JsonDocument.Parse(body); + var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); + Assert.Equal("", text); + } + + // ── Edge-case tests ───────────────────────────────────────────────── + + [Fact] + public void BuildChatBody_VeryLongMessage_Handled() + { + var longMessage = new string('A', 100_000); + var body = BuildChatBody(longMessage); + using var doc = JsonDocument.Parse(body); + var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); + Assert.Equal(longMessage, text); + } + + [Fact] + public void BuildChatBody_JsonInjectionAttempt_Escaped() + { + // Attempt to break out of the JSON string value + var malicious = "\"}, \"evil\": true, {\""; + var body = BuildChatBody(malicious); + using var doc = JsonDocument.Parse(body); // must still be valid JSON + var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); + Assert.Equal(malicious, text); + // The raw JSON must NOT contain an unescaped "evil" key at the top level + Assert.False(doc.RootElement.TryGetProperty("evil", out _)); + } + + [Fact] + public void BuildChatBody_NullCharactersInMessage_Handled() + { + var msg = "before\0after"; + var body = BuildChatBody(msg); + using var doc = JsonDocument.Parse(body); + var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); + Assert.Equal(msg, text); + } +} diff --git a/dotnet/rest.tests/TruncTests.cs b/dotnet/rest.tests/TruncTests.cs new file mode 100644 index 0000000..408d0ee --- /dev/null +++ b/dotnet/rest.tests/TruncTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace WorkIQ.Rest.Tests; + +/// +/// Tests for the Trunc utility duplicated from rest Program.cs. +/// +public class TruncTests +{ + // ── Duplicated logic under test ────────────────────────────────────── + + private static string Trunc(string s, int max) => s.Length <= max ? s : $"{s[..max]}..."; + + // ── Tests ──────────────────────────────────────────────────────────── + + [Fact] + public void Trunc_ShorterThanMax_ReturnsSameString() + { + Assert.Equal("hi", Trunc("hi", 10)); + } + + [Fact] + public void Trunc_ExactlyAtMax_ReturnsSameString() + { + Assert.Equal("hello", Trunc("hello", 5)); + } + + [Fact] + public void Trunc_LongerThanMax_TruncatesWithEllipsis() + { + Assert.Equal("hel...", Trunc("hello world", 3)); + } + + [Fact] + public void Trunc_EmptyString_ReturnsEmpty() + { + Assert.Equal("", Trunc("", 5)); + } + + [Fact] + public void Trunc_MaxOfOne_TruncatesCorrectly() + { + Assert.Equal("h...", Trunc("hello", 1)); + } +} diff --git a/dotnet/rest.tests/workiq-rest-tests.csproj b/dotnet/rest.tests/workiq-rest-tests.csproj new file mode 100644 index 0000000..8b35db3 --- /dev/null +++ b/dotnet/rest.tests/workiq-rest-tests.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + false + + + + + + + diff --git a/rust/a2a/src/auth.rs b/rust/a2a/src/auth.rs index 1b63971..4abd6db 100644 --- a/rust/a2a/src/auth.rs +++ b/rust/a2a/src/auth.rs @@ -396,3 +396,143 @@ fn decode_jwt_part(part: &str) -> Result { .context("base64 decode")?; serde_json::from_slice(&decoded).context("JSON parse") } + +#[cfg(test)] +mod tests { + use super::*; + + // ── extract_code_from_request ──────────────────────────────────── + + #[test] + fn extract_code_valid() { + let req = "GET /?code=abc123&state=xyz HTTP/1.1\r\nHost: localhost\r\n"; + let code = extract_code_from_request(req).unwrap(); + assert_eq!(code, "abc123"); + } + + #[test] + fn extract_code_no_state() { + let req = "GET /?code=onlycode HTTP/1.1\r\n"; + let code = extract_code_from_request(req).unwrap(); + assert_eq!(code, "onlycode"); + } + + #[test] + fn extract_code_error_response() { + let req = "GET /?error=access_denied&error_description=User+cancelled HTTP/1.1\r\n"; + let err = extract_code_from_request(req).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("access_denied"), "got: {msg}"); + assert!(msg.contains("User cancelled"), "got: {msg}"); + } + + #[test] + fn extract_code_url_encoded_error_description() { + let req = "GET /?error=invalid_grant&error_description=Token+has+expired HTTP/1.1\r\n"; + let err = extract_code_from_request(req).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Token has expired"), "got: {msg}"); + } + + #[test] + fn extract_code_missing() { + let req = "GET /?state=xyz HTTP/1.1\r\n"; + let err = extract_code_from_request(req).unwrap_err(); + assert!( + err.to_string().contains("No authorization code"), + "got: {}", + err + ); + } + + #[test] + fn extract_code_empty_request() { + let err = extract_code_from_request("").unwrap_err(); + assert!(err.to_string().contains("Empty HTTP request"), "got: {err}"); + } + + #[test] + fn extract_code_malformed_request() { + let err = extract_code_from_request("BADREQUEST").unwrap_err(); + assert!( + err.to_string().contains("Malformed HTTP request"), + "got: {err}" + ); + } + + #[test] + fn extract_code_no_query_string() { + let req = "GET / HTTP/1.1\r\n"; + let err = extract_code_from_request(req).unwrap_err(); + assert!( + err.to_string().contains("No authorization code"), + "got: {err}" + ); + } + + // ── decode_jwt_part ───────────────────────────────────────────── + + #[test] + fn decode_jwt_valid() { + // {"sub":"1234567890","name":"Test User"} + let encoded = URL_SAFE_NO_PAD + .encode(r#"{"sub":"1234567890","name":"Test User"}"#); + let val = decode_jwt_part(&encoded).unwrap(); + assert_eq!(val["sub"], "1234567890"); + assert_eq!(val["name"], "Test User"); + } + + #[test] + fn decode_jwt_with_padding() { + let encoded = URL_SAFE_NO_PAD.encode(r#"{"a":"b"}"#); + // Append padding chars — should still work + let padded = format!("{encoded}=="); + let val = decode_jwt_part(&padded).unwrap(); + assert_eq!(val["a"], "b"); + } + + #[test] + fn decode_jwt_invalid_base64() { + let err = decode_jwt_part("!!!not-base64!!!").unwrap_err(); + assert!(err.to_string().contains("base64"), "got: {err}"); + } + + #[test] + fn decode_jwt_invalid_json() { + let encoded = URL_SAFE_NO_PAD.encode("not json at all"); + let err = decode_jwt_part(&encoded).unwrap_err(); + assert!(err.to_string().contains("JSON"), "got: {err}"); + } + + #[test] + fn extract_code_with_extra_params() { + let req = "GET /?code=XXX&session_state=YYY&other=ZZZ HTTP/1.1\r\n"; + let code = extract_code_from_request(req).unwrap(); + assert_eq!(code, "XXX"); + } + + #[test] + fn extract_code_error_takes_priority() { + // When both code and error are present, the function should return + // the error because errors are checked first (after iterating all params). + let req = "GET /?code=XXX&error=access_denied HTTP/1.1\r\n"; + let result = extract_code_from_request(req); + assert!( + result.is_err(), + "Expected error to take priority over code, but got Ok({:?})", + result.unwrap() + ); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("access_denied"), "got: {msg}"); + } + + #[test] + fn decode_jwt_empty_string() { + // Empty string is valid base64 (decodes to empty bytes) but not valid JSON. + let err = decode_jwt_part("").unwrap_err(); + assert!( + err.to_string().contains("JSON"), + "expected JSON parse error for empty input, got: {err}" + ); + } +} diff --git a/rust/a2a/src/main.rs b/rust/a2a/src/main.rs index 36bc98a..4e90d3d 100644 --- a/rust/a2a/src/main.rs +++ b/rust/a2a/src/main.rs @@ -577,3 +577,293 @@ impl Drop for Spinner { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── uuid_v4 ───────────────────────────────────────────────────── + + #[test] + fn uuid_v4_format() { + let id = uuid_v4(); + assert_eq!(id.len(), 32, "expected 32 hex chars, got {}", id.len()); + assert!( + id.chars().all(|c| c.is_ascii_hexdigit()), + "non-hex char in: {id}" + ); + } + + #[test] + fn uuid_v4_uniqueness() { + let a = uuid_v4(); + // Small sleep so the nanos-based ID advances + std::thread::sleep(std::time::Duration::from_millis(1)); + let b = uuid_v4(); + assert_ne!(a, b, "IDs should differ with a time gap"); + } + + // ── build_message ─────────────────────────────────────────────── + + #[test] + fn build_message_structure() { + let msg = build_message(Role::User, "hello", None); + assert_eq!(msg.kind, "message"); + assert_eq!(msg.role, Role::User); + assert_eq!(msg.parts.len(), 1); + match &msg.parts[0] { + Part::Text { text, .. } => assert_eq!(text, "hello"), + other => panic!("expected Text part, got {other:?}"), + } + assert!(msg.context_id.is_none()); + assert_eq!(msg.message_id.len(), 32); + } + + #[test] + fn build_message_with_context() { + let msg = build_message(Role::Agent, "reply", Some("ctx-42".into())); + assert_eq!(msg.context_id.as_deref(), Some("ctx-42")); + assert_eq!(msg.role, Role::Agent); + } + + #[test] + fn build_message_has_location_metadata() { + let msg = build_message(Role::User, "test", None); + let meta = msg.metadata.as_ref().expect("metadata missing"); + let loc = &meta["Location"]; + assert!(loc.get("timeZoneOffset").is_some(), "missing timeZoneOffset"); + assert!(loc.get("timeZone").is_some(), "missing timeZone"); + } + + // ── join_text_parts ───────────────────────────────────────────── + + #[test] + fn join_text_parts_basic() { + let parts = vec![ + Part::Text { + text: "hello".into(), + metadata: None, + }, + Part::Text { + text: "world".into(), + metadata: None, + }, + ]; + assert_eq!(join_text_parts(&parts), "hello\nworld"); + } + + #[test] + fn join_text_parts_skips_non_text() { + let parts = vec![ + Part::Text { + text: "a".into(), + metadata: None, + }, + Part::Data { + data: serde_json::json!({}), + metadata: None, + }, + Part::Text { + text: "b".into(), + metadata: None, + }, + ]; + assert_eq!(join_text_parts(&parts), "a\nb"); + } + + #[test] + fn join_text_parts_empty() { + assert_eq!(join_text_parts(&[]), ""); + } + + #[test] + fn join_text_parts_single() { + let parts = vec![Part::Text { + text: "only".into(), + metadata: None, + }]; + assert_eq!(join_text_parts(&parts), "only"); + } + + // ── extract_result ────────────────────────────────────────────── + + #[test] + fn extract_result_from_message() { + let msg = Message { + kind: "message".into(), + message_id: "m1".into(), + context_id: Some("ctx-1".into()), + task_id: None, + role: Role::Agent, + parts: vec![Part::Text { + text: "response".into(), + metadata: None, + }], + metadata: Some(serde_json::json!({"key": "val"})), + extensions: vec![], + reference_task_ids: None, + }; + let result = SendMessageResult::Message(msg); + let (text, ctx, meta) = extract_result(&result); + assert_eq!(text, "response"); + assert_eq!(ctx.as_deref(), Some("ctx-1")); + assert_eq!(meta.unwrap()["key"], "val"); + } + + #[test] + fn extract_result_from_task() { + let task = a2a_rs_core::Task { + kind: "task".into(), + id: "t1".into(), + context_id: "ctx-2".into(), + status: a2a_rs_core::TaskStatus { + state: a2a_rs_core::TaskState::Completed, + message: Some(Message { + kind: "message".into(), + message_id: "m2".into(), + context_id: None, + task_id: None, + role: Role::Agent, + parts: vec![Part::Text { + text: "done".into(), + metadata: None, + }], + metadata: Some(serde_json::json!({"cite": true})), + extensions: vec![], + reference_task_ids: None, + }), + timestamp: None, + }, + artifacts: None, + history: None, + metadata: None, + }; + let result = SendMessageResult::Task(task); + let (text, ctx, meta) = extract_result(&result); + assert_eq!(text, "done"); + assert_eq!(ctx.as_deref(), Some("ctx-2")); + assert_eq!(meta.unwrap()["cite"], true); + } + + #[test] + fn extract_result_task_without_message() { + let task = a2a_rs_core::Task { + kind: "task".into(), + id: "t2".into(), + context_id: "ctx-3".into(), + status: a2a_rs_core::TaskStatus { + state: a2a_rs_core::TaskState::Working, + message: None, + timestamp: None, + }, + artifacts: None, + history: None, + metadata: None, + }; + let result = SendMessageResult::Task(task); + let (text, ctx, _meta) = extract_result(&result); + assert!(text.contains("t2"), "expected task id in fallback: {text}"); + assert!(text.contains("Working"), "expected state in fallback: {text}"); + assert_eq!(ctx.as_deref(), Some("ctx-3")); + } + + // ── print_delta ───────────────────────────────────────────────── + + #[test] + fn print_delta_incremental() { + let mut prev = String::new(); + + // First call — entire text is new + print_delta("Hello", &mut prev); + assert_eq!(prev, "Hello"); + + // Second call — only " world" is new + print_delta("Hello world", &mut prev); + assert_eq!(prev, "Hello world"); + } + + #[test] + fn print_delta_full_replace() { + let mut prev = "old text".to_string(); + // New text doesn't start with old — prints full new text + print_delta("completely new", &mut prev); + assert_eq!(prev, "completely new"); + } + + #[test] + fn print_delta_empty() { + let mut prev = String::new(); + print_delta("", &mut prev); + assert_eq!(prev, ""); + } + + // ── edge-case tests ───────────────────────────────────────────── + + #[test] + fn build_message_empty_text() { + let msg = build_message(Role::User, "", None); + assert_eq!(msg.parts.len(), 1); + match &msg.parts[0] { + Part::Text { text, .. } => assert_eq!(text, ""), + other => panic!("expected Text part, got {other:?}"), + } + assert_eq!(msg.kind, "message"); + } + + #[test] + fn build_message_special_characters() { + let input = "He said \"hello\"\nnew line\ttab 🚀 café"; + let msg = build_message(Role::User, input, None); + match &msg.parts[0] { + Part::Text { text, .. } => assert_eq!(text, input), + other => panic!("expected Text part, got {other:?}"), + } + } + + #[test] + fn extract_result_from_task_completed_empty_message() { + let task = a2a_rs_core::Task { + kind: "task".into(), + id: "t-empty".into(), + context_id: "ctx-e".into(), + status: a2a_rs_core::TaskStatus { + state: a2a_rs_core::TaskState::Completed, + message: Some(Message { + kind: "message".into(), + message_id: "m-empty".into(), + context_id: None, + task_id: None, + role: Role::Agent, + parts: vec![], // no text parts + metadata: None, + extensions: vec![], + reference_task_ids: None, + }), + timestamp: None, + }, + artifacts: None, + history: None, + metadata: None, + }; + let result = SendMessageResult::Task(task); + let (text, ctx, _meta) = extract_result(&result); + assert_eq!(text, "", "empty parts should produce empty string"); + assert_eq!(ctx.as_deref(), Some("ctx-e")); + } + + #[test] + fn join_text_parts_with_newlines_in_text() { + let parts = vec![ + Part::Text { + text: "line1\nline2".into(), + metadata: None, + }, + Part::Text { + text: "line3\nline4".into(), + metadata: None, + }, + ]; + // Parts are joined with \n, so embedded newlines are preserved alongside the separator. + assert_eq!(join_text_parts(&parts), "line1\nline2\nline3\nline4"); + } +} diff --git a/swift/a2a/A2A ChatTests/A2A_ChatTests.swift b/swift/a2a/A2A ChatTests/A2A_ChatTests.swift index bdcdc6c..404263b 100644 --- a/swift/a2a/A2A ChatTests/A2A_ChatTests.swift +++ b/swift/a2a/A2A ChatTests/A2A_ChatTests.swift @@ -6,12 +6,247 @@ // import Testing +import Foundation @testable import A2A_Chat -struct A2A_ChatTests { +// MARK: - ChatMessage Model Tests - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. +struct ChatMessageTests { + + @Test func initSetsTextAndIsUser() { + let userMsg = ChatMessage(text: "Hello", isUser: true) + #expect(userMsg.text == "Hello") + #expect(userMsg.isUser == true) + + let botMsg = ChatMessage(text: "Hi there", isUser: false) + #expect(botMsg.text == "Hi there") + #expect(botMsg.isUser == false) + } + + @Test func initDefaultsIsCompleteToFalse() { + let msg = ChatMessage(text: "test", isUser: true) + #expect(msg.isComplete == false) + } + + @Test func idIsUniqueAcrossInstances() { + let msg1 = ChatMessage(text: "a", isUser: true) + let msg2 = ChatMessage(text: "a", isUser: true) + #expect(msg1.id != msg2.id) + } + + @Test func timestampIsSetToApproximatelyNow() { + let before = Date() + let msg = ChatMessage(text: "test", isUser: false) + let after = Date() + + #expect(msg.timestamp >= before) + #expect(msg.timestamp <= after) + } + + @Test func textIsMutable() { + let msg = ChatMessage(text: "original", isUser: false) + msg.text = "updated" + #expect(msg.text == "updated") + } + + @Test func isCompleteIsMutable() { + let msg = ChatMessage(text: "streaming…", isUser: false) + #expect(msg.isComplete == false) + msg.isComplete = true + #expect(msg.isComplete == true) + } + + @Test func emptyTextIsAllowed() { + let msg = ChatMessage(text: "", isUser: true) + #expect(msg.text == "") + } + + @Test func textWithSpecialCharacters() { + let special = "Hello 🌍! Line1\nLine2\t**bold** `code`" + let msg = ChatMessage(text: special, isUser: false) + #expect(msg.text == special) + } +} + +// MARK: - BearerTokenAuth Tests +// +// NOTE: BearerTokenAuth is declared `private` in A2AService.swift, so it cannot +// be tested from outside the module. To enable unit testing, consider changing +// the access level to `internal` (the Swift default) or extracting it into its +// own file. +// +// The tests below demonstrate what SHOULD be tested if the struct were visible. +// They are commented out because they will not compile against the current source. + +/* +struct BearerTokenAuthTests { + + @Test func authenticateAddsBearerHeader() async throws { + let auth = BearerTokenAuth(token: "test-token-123") + let original = URLRequest(url: URL(string: "https://example.com/api")!) + + let authenticated = try await auth.authenticate(request: original) + + #expect(authenticated.value(forHTTPHeaderField: "Authorization") == "Bearer test-token-123") + } + + @Test func authenticatePreservesOriginalURL() async throws { + let url = URL(string: "https://example.com/path?q=1")! + let auth = BearerTokenAuth(token: "abc") + let original = URLRequest(url: url) + + let authenticated = try await auth.authenticate(request: original) + + #expect(authenticated.url == url) + } + + @Test func authenticatePreservesExistingHeaders() async throws { + let auth = BearerTokenAuth(token: "xyz") + var original = URLRequest(url: URL(string: "https://example.com")!) + original.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let authenticated = try await auth.authenticate(request: original) + + #expect(authenticated.value(forHTTPHeaderField: "Content-Type") == "application/json") + #expect(authenticated.value(forHTTPHeaderField: "Authorization") == "Bearer xyz") + } + + @Test func authenticateHandlesEmptyToken() async throws { + let auth = BearerTokenAuth(token: "") + let original = URLRequest(url: URL(string: "https://example.com")!) + + let authenticated = try await auth.authenticate(request: original) + + #expect(authenticated.value(forHTTPHeaderField: "Authorization") == "Bearer ") + } +} +*/ + +// MARK: - Configuration Loading Tests +// +// AuthService.loadClientId(), loadRedirectUri(), loadTenantId(), loadScopes() +// and A2AService.loadEndpoint() are all `private static` methods that rely on +// Bundle.main to locate Configuration.plist. This makes them difficult to unit +// test without either: +// 1. Injecting a Bundle parameter (recommended refactoring), or +// 2. Placing a test Configuration.plist in the test bundle. +// +// Below are TODO stubs documenting the cases that should be covered once the +// methods accept a configurable Bundle or dictionary source. + +/* +struct ConfigurationLoadingTests { + + // --- AuthService.loadClientId --- + + // TODO: Test that loadClientId returns nil when Configuration.plist is missing. + // Refactoring suggestion: `static func loadClientId(from bundle: Bundle) -> String?` + // Then pass a test bundle that has no plist. + + // TODO: Test that loadClientId returns nil when ClientId is the placeholder + // value "YOUR_APP_CLIENT_ID". + + // TODO: Test that loadClientId returns nil when ClientId is an empty string. + + // TODO: Test that loadClientId returns the value when ClientId is a valid + // non-placeholder string (e.g., "00000000-0000-0000-0000-000000000000"). + + // --- AuthService.loadRedirectUri --- + + // TODO: Test that loadRedirectUri returns nil for missing plist. + // TODO: Test that loadRedirectUri returns nil for placeholder "YOUR_REDIRECT_URI". + // TODO: Test that loadRedirectUri returns nil for empty string. + // TODO: Test that loadRedirectUri returns the value for a valid URI. + + // --- AuthService.loadTenantId --- + + // TODO: Test that loadTenantId returns nil for missing plist. + // TODO: Test that loadTenantId returns nil for empty string. + // TODO: Test that loadTenantId returns the value for a valid tenant ID. + + // --- AuthService.loadScopes --- + + // TODO: Test that loadScopes returns nil for missing plist. + // TODO: Test that loadScopes returns nil for an empty array. + // TODO: Test that loadScopes returns the array for a non-empty array. + + // --- A2AService.loadEndpoint --- + + // TODO: Test that loadEndpoint returns nil for missing plist. + // TODO: Test that loadEndpoint returns nil for placeholder "YOUR_ENDPOINT_URL". + // TODO: Test that loadEndpoint returns nil for empty string. + // TODO: Test that loadEndpoint returns nil for an invalid URL string. + // TODO: Test that loadEndpoint returns the URL for a valid endpoint. +} +*/ + +// MARK: - Markdown Rendering Tests +// +// MessageBubbleView.markdownAttributedString is a `private` computed property +// on a SwiftUI View. To make it testable, consider extracting it into a free +// function or a static method on ChatMessage / a helper type: +// +// func renderMarkdown(text: String, isComplete: Bool) -> AttributedString +// +// The tests below demonstrate what SHOULD be verified. They are commented out +// because the private property is not accessible from the test target. + +/* +struct MarkdownRenderingTests { + + // Suggested extracted function to test: + // + // func renderMarkdown(text: String, isComplete: Bool) -> AttributedString { + // if isComplete { + // if let result = try? AttributedString( + // markdown: text, + // options: .init(interpretedSyntax: .full) + // ) { + // return result + // } + // } + // if let result = try? AttributedString( + // markdown: text, + // options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + // ) { + // return result + // } + // return AttributedString(text) + // } + + @Test func completedMessageUsesFullMarkdown() { + let result = renderMarkdown(text: "**bold** text", isComplete: true) + // Full markdown should parse block-level elements (paragraphs, lists, etc.) + #expect(result != AttributedString("**bold** text")) } + @Test func streamingMessageUsesInlineMarkdown() { + let result = renderMarkdown(text: "**bold** text", isComplete: false) + // Inline-only should still handle bold/italic but not block elements + #expect(result != AttributedString("**bold** text")) + } + + @Test func plainTextFallsBackGracefully() { + let result = renderMarkdown(text: "no markdown here", isComplete: false) + // Should still produce a valid AttributedString + #expect(String(result.characters) == "no markdown here") + } + + @Test func emptyStringReturnsEmptyAttributedString() { + let result = renderMarkdown(text: "", isComplete: true) + #expect(result.characters.count == 0) + } + + @Test func completedMessageWithListsParsesCorrectly() { + let md = """ + - Item 1 + - Item 2 + - Item 3 + """ + let result = renderMarkdown(text: md, isComplete: true) + let text = String(result.characters) + #expect(text.contains("Item 1")) + #expect(text.contains("Item 2")) + } } +*/ From e9138fc33b3eefd12389a3d2d66989a88869053c Mon Sep 17 00:00:00 2001 From: tk Date: Sun, 5 Apr 2026 20:47:29 -0700 Subject: [PATCH 2/3] Extract shared helpers, fix arg-parsing bugs, add test runner - Move testable pure functions (ParseArgs, ExtractText, BuildChatBody, Trunc, Extract, Join) from Program.cs into Helpers.cs files so tests exercise real production code instead of duplicated copies - Fix args[++i] crash when a flag is the last argument (returns error instead of IndexOutOfRangeException) in all 3 .NET samples - Fix int.Parse on --verbosity (use TryParse, return error instead of FormatException) in rest and a2a samples - Add WorkIQSamples.sln and scripts/test.sh for running all tests - Make Swift BearerTokenAuth internal and extract renderMarkdown as a free function so both can be unit tested (9 new Swift tests enabled) Co-Authored-By: Claude Opus 4.6 (1M context) --- dotnet/WorkIQSamples.sln | 124 ++++++++++++++ dotnet/a2a-raw.tests/ArgParsingTests.cs | 104 +++++------- dotnet/a2a-raw.tests/ExtractTextTests.cs | 70 +++----- .../a2a-raw.tests/workiq-a2a-raw-tests.csproj | 3 + dotnet/a2a-raw/Helpers.cs | 85 ++++++++++ dotnet/a2a-raw/Program.cs | 66 ++------ dotnet/a2a.tests/ArgParsingTests.cs | 147 ++++++---------- dotnet/a2a.tests/ExtractTests.cs | 37 ++-- dotnet/a2a.tests/workiq-a2a-tests.csproj | 3 + dotnet/a2a/Helpers.cs | 72 ++++++++ dotnet/a2a/Program.cs | 38 ++--- dotnet/rest.tests/ArgParsingTests.cs | 160 ++++++++---------- dotnet/rest.tests/ChatBodyTests.cs | 36 ++-- dotnet/rest.tests/TruncTests.cs | 19 +-- dotnet/rest.tests/workiq-rest-tests.csproj | 3 + dotnet/rest/Helpers.cs | 76 +++++++++ dotnet/rest/Program.cs | 42 ++--- scripts/test.sh | 33 ++++ swift/a2a/A2A Chat/Services/A2AService.swift | 2 +- .../A2A Chat/Views/MessageBubbleView.swift | 23 ++- swift/a2a/A2A ChatTests/A2A_ChatTests.swift | 41 ----- 21 files changed, 664 insertions(+), 520 deletions(-) create mode 100644 dotnet/WorkIQSamples.sln create mode 100644 dotnet/a2a-raw/Helpers.cs create mode 100644 dotnet/a2a/Helpers.cs create mode 100644 dotnet/rest/Helpers.cs create mode 100755 scripts/test.sh diff --git a/dotnet/WorkIQSamples.sln b/dotnet/WorkIQSamples.sln new file mode 100644 index 0000000..d199b91 --- /dev/null +++ b/dotnet/WorkIQSamples.sln @@ -0,0 +1,124 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "a2a-raw", "a2a-raw", "{CCFF03C2-3201-45FB-2E8F-00A04B2D057D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "workiq-a2a-raw", "a2a-raw\workiq-a2a-raw.csproj", "{1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "a2a-raw.tests", "a2a-raw.tests", "{ECE5971F-AE9E-BA12-55AA-4929917FAF06}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "workiq-a2a-raw-tests", "a2a-raw.tests\workiq-a2a-raw-tests.csproj", "{64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rest", "rest", "{CAF2E94B-1F3E-339F-03CF-980AEBF14E62}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "workiq-rest-sample", "rest\workiq-rest-sample.csproj", "{521D7579-B37C-47B5-AE2C-6ACFCC1AD790}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rest.tests", "rest.tests", "{99F3A7AA-53A2-3584-6717-8D7F80AF0238}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "workiq-rest-tests", "rest.tests\workiq-rest-tests.csproj", "{44DDF8F5-469B-4650-A170-721FFACB4B08}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "a2a", "a2a", "{DF537D00-43B0-169F-440F-F716863E989D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "workiq-a2a-sample", "a2a\workiq-a2a-sample.csproj", "{709CB2BA-85F0-41E1-8D28-F46CAC8BF783}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "a2a.tests", "a2a.tests", "{80F48381-A032-BB77-AC00-2E31FC5BD3EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "workiq-a2a-tests", "a2a.tests\workiq-a2a-tests.csproj", "{F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Debug|x64.ActiveCfg = Debug|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Debug|x64.Build.0 = Debug|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Debug|x86.ActiveCfg = Debug|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Debug|x86.Build.0 = Debug|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Release|Any CPU.Build.0 = Release|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Release|x64.ActiveCfg = Release|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Release|x64.Build.0 = Release|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Release|x86.ActiveCfg = Release|Any CPU + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791}.Release|x86.Build.0 = Release|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Debug|x64.ActiveCfg = Debug|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Debug|x64.Build.0 = Debug|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Debug|x86.ActiveCfg = Debug|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Debug|x86.Build.0 = Debug|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Release|Any CPU.Build.0 = Release|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Release|x64.ActiveCfg = Release|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Release|x64.Build.0 = Release|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Release|x86.ActiveCfg = Release|Any CPU + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A}.Release|x86.Build.0 = Release|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Debug|Any CPU.Build.0 = Debug|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Debug|x64.ActiveCfg = Debug|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Debug|x64.Build.0 = Debug|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Debug|x86.ActiveCfg = Debug|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Debug|x86.Build.0 = Debug|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Release|Any CPU.ActiveCfg = Release|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Release|Any CPU.Build.0 = Release|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Release|x64.ActiveCfg = Release|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Release|x64.Build.0 = Release|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Release|x86.ActiveCfg = Release|Any CPU + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790}.Release|x86.Build.0 = Release|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Debug|x64.ActiveCfg = Debug|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Debug|x64.Build.0 = Debug|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Debug|x86.ActiveCfg = Debug|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Debug|x86.Build.0 = Debug|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Release|Any CPU.Build.0 = Release|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Release|x64.ActiveCfg = Release|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Release|x64.Build.0 = Release|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Release|x86.ActiveCfg = Release|Any CPU + {44DDF8F5-469B-4650-A170-721FFACB4B08}.Release|x86.Build.0 = Release|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Debug|Any CPU.Build.0 = Debug|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Debug|x64.ActiveCfg = Debug|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Debug|x64.Build.0 = Debug|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Debug|x86.ActiveCfg = Debug|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Debug|x86.Build.0 = Debug|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Release|Any CPU.ActiveCfg = Release|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Release|Any CPU.Build.0 = Release|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Release|x64.ActiveCfg = Release|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Release|x64.Build.0 = Release|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Release|x86.ActiveCfg = Release|Any CPU + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783}.Release|x86.Build.0 = Release|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Debug|x64.ActiveCfg = Debug|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Debug|x64.Build.0 = Debug|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Debug|x86.ActiveCfg = Debug|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Debug|x86.Build.0 = Debug|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Release|Any CPU.Build.0 = Release|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Release|x64.ActiveCfg = Release|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Release|x64.Build.0 = Release|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Release|x86.ActiveCfg = Release|Any CPU + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {1FDE52B4-ADE3-45C1-92B1-7FBDED0AF791} = {CCFF03C2-3201-45FB-2E8F-00A04B2D057D} + {64FD8CEC-4C54-4873-B4E9-CA3F6BDA108A} = {ECE5971F-AE9E-BA12-55AA-4929917FAF06} + {521D7579-B37C-47B5-AE2C-6ACFCC1AD790} = {CAF2E94B-1F3E-339F-03CF-980AEBF14E62} + {44DDF8F5-469B-4650-A170-721FFACB4B08} = {99F3A7AA-53A2-3584-6717-8D7F80AF0238} + {709CB2BA-85F0-41E1-8D28-F46CAC8BF783} = {DF537D00-43B0-169F-440F-F716863E989D} + {F1B663D7-DBB9-4FB0-B8D5-60A8A077CD4F} = {80F48381-A032-BB77-AC00-2E31FC5BD3EE} + EndGlobalSection +EndGlobal diff --git a/dotnet/a2a-raw.tests/ArgParsingTests.cs b/dotnet/a2a-raw.tests/ArgParsingTests.cs index 4293753..8807c1e 100644 --- a/dotnet/a2a-raw.tests/ArgParsingTests.cs +++ b/dotnet/a2a-raw.tests/ArgParsingTests.cs @@ -1,110 +1,92 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using WorkIQ.A2ARaw; using Xunit; namespace WorkIQ.A2ARaw.Tests; /// -/// Tests for arg-parsing logic duplicated from a2a-raw Program.cs. -/// The original uses args[++i] which will throw IndexOutOfRangeException -/// if a flag requiring a value is the last argument. +/// Tests for — the arg-parsing logic +/// used by the a2a-raw sample app. /// public class ArgParsingTests { - // ── Duplicated arg parsing logic ───────────────────────────────────── - - private record ParseResult( - string? Endpoint, string? Token, string? AppId, string? Account, - bool Stream, bool AllHeaders, string? Error); - - private static ParseResult ParseArgs(string[] args) - { - string? endpoint = null, token = null, appId = null, account = null; - bool stream = false, allHeaders = false; - - for (int i = 0; i < args.Length; i++) - { - switch (args[i]) - { - case "--endpoint" or "-e": endpoint = args[++i]; break; - case "--token" or "-t": token = args[++i]; break; - case "--appid" or "-a": appId = args[++i]; break; - case "--account": account = args[++i]; break; - case "--stream": stream = true; break; - case "--all-headers": allHeaders = true; break; - default: - return new ParseResult(null, null, null, null, false, false, $"Unknown flag: {args[i]}"); - } - } - - return new ParseResult(endpoint, token, appId, account, stream, allHeaders, null); - } - - // ── Tests ──────────────────────────────────────────────────────────── - [Fact] public void ValidArgs_AllParsedCorrectly() { - var result = ParseArgs(["--endpoint", "https://example.com", "--token", "abc", "--appid", "id1", "--stream"]); - Assert.Null(result.Error); - Assert.Equal("https://example.com", result.Endpoint); - Assert.Equal("abc", result.Token); - Assert.Equal("id1", result.AppId); - Assert.True(result.Stream); + var r = Helpers.ParseArgs(["--endpoint", "https://example.com", "--token", "abc", "--appid", "id1", "--stream"]); + Assert.Null(r.Error); + Assert.Equal("https://example.com", r.Endpoint); + Assert.Equal("abc", r.Token); + Assert.Equal("id1", r.AppId); + Assert.True(r.Stream); } [Fact] public void ShortFlags_Work() { - var result = ParseArgs(["-e", "https://example.com", "-t", "tok", "-a", "app"]); - Assert.Null(result.Error); - Assert.Equal("https://example.com", result.Endpoint); - Assert.Equal("tok", result.Token); - Assert.Equal("app", result.AppId); + var r = Helpers.ParseArgs(["-e", "https://example.com", "-t", "tok", "-a", "app"]); + Assert.Null(r.Error); + Assert.Equal("https://example.com", r.Endpoint); + Assert.Equal("tok", r.Token); + Assert.Equal("app", r.AppId); } [Fact] public void UnknownFlag_ReturnsError() { - var result = ParseArgs(["--unknown"]); - Assert.NotNull(result.Error); - Assert.Contains("Unknown flag", result.Error); + var r = Helpers.ParseArgs(["--unknown"]); + Assert.NotNull(r.Error); + Assert.Contains("Unknown flag", r.Error); } [Fact] public void AllHeadersFlag_Parsed() { - var result = ParseArgs(["--all-headers", "--endpoint", "url", "--token", "t"]); - Assert.True(result.AllHeaders); + var r = Helpers.ParseArgs(["--all-headers", "--endpoint", "url", "--token", "t"]); + Assert.True(r.AllHeaders); } [Fact] public void AccountFlag_Parsed() { - var result = ParseArgs(["--endpoint", "url", "--token", "t", "--account", "user@example.com"]); - Assert.Equal("user@example.com", result.Account); + var r = Helpers.ParseArgs(["--endpoint", "url", "--token", "t", "--account", "user@example.com"]); + Assert.Equal("user@example.com", r.Account); + } + + [Fact] + public void MissingValueAfterToken_ReturnsError() + { + var r = Helpers.ParseArgs(["--token"]); + Assert.NotNull(r.Error); + Assert.Contains("Missing value", r.Error); + Assert.Contains("--token", r.Error); } [Fact] - public void MissingValueAfterToken_ThrowsIndexOutOfRange() + public void MissingValueAfterEndpoint_ReturnsError() { - // Bug: args[++i] throws when --token is the last arg with no value - Assert.Throws(() => ParseArgs(["--token"])); + var r = Helpers.ParseArgs(["--endpoint"]); + Assert.NotNull(r.Error); + Assert.Contains("Missing value", r.Error); + Assert.Contains("--endpoint", r.Error); } [Fact] - public void MissingValueAfterEndpoint_ThrowsIndexOutOfRange() + public void MissingValueAfterShortFlag_ReturnsError() { - Assert.Throws(() => ParseArgs(["--endpoint"])); + var r = Helpers.ParseArgs(["-t"]); + Assert.NotNull(r.Error); + Assert.Contains("Missing value", r.Error); } [Fact] public void EmptyArgs_NoError() { - var result = ParseArgs([]); - Assert.Null(result.Error); - Assert.Null(result.Endpoint); - Assert.Null(result.Token); + var r = Helpers.ParseArgs([]); + Assert.Null(r.Error); + Assert.Null(r.Endpoint); + Assert.Null(r.Token); } } diff --git a/dotnet/a2a-raw.tests/ExtractTextTests.cs b/dotnet/a2a-raw.tests/ExtractTextTests.cs index bb3d8b8..152d05b 100644 --- a/dotnet/a2a-raw.tests/ExtractTextTests.cs +++ b/dotnet/a2a-raw.tests/ExtractTextTests.cs @@ -1,50 +1,18 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System.Text; using System.Text.Json; +using WorkIQ.A2ARaw; using Xunit; namespace WorkIQ.A2ARaw.Tests; /// -/// Tests for ExtractText / TryGetParts logic duplicated from a2a-raw Program.cs. -/// These are local functions in the top-level program so we replicate them here. +/// Tests for and +/// from the a2a-raw sample app. /// public class ExtractTextTests { - // ── Duplicated logic under test ────────────────────────────────────── - - private static string ExtractText(JsonElement el) - { - if (TryGetParts(el, out var text)) return text; - if (el.TryGetProperty("status", out var status) && - status.TryGetProperty("message", out var msg) && - TryGetParts(msg, out text)) return text; - if (el.TryGetProperty("message", out var m) && - TryGetParts(m, out text)) return text; - return ""; - } - - private static bool TryGetParts(JsonElement el, out string text) - { - text = ""; - if (!el.TryGetProperty("parts", out var parts) || parts.ValueKind != JsonValueKind.Array) - return false; - - var sb = new StringBuilder(); - foreach (var part in parts.EnumerateArray()) - { - if (part.TryGetProperty("text", out var t)) - sb.Append(t.GetString()); - } - - text = sb.ToString(); - return text.Length > 0; - } - - // ── Helpers ────────────────────────────────────────────────────────── - private static JsonElement Parse(string json) { using var doc = JsonDocument.Parse(json); @@ -57,7 +25,7 @@ private static JsonElement Parse(string json) public void ExtractText_DirectParts_ReturnsText() { var el = Parse("""{ "parts": [{ "text": "Hello world" }] }"""); - Assert.Equal("Hello world", ExtractText(el)); + Assert.Equal("Hello world", Helpers.ExtractText(el)); } [Fact] @@ -72,7 +40,7 @@ public void ExtractText_StatusMessageParts_ReturnsText() } } """); - Assert.Equal("Task completed", ExtractText(el)); + Assert.Equal("Task completed", Helpers.ExtractText(el)); } [Fact] @@ -85,28 +53,28 @@ public void ExtractText_MessageParts_ReturnsText() } } """); - Assert.Equal("From message", ExtractText(el)); + Assert.Equal("From message", Helpers.ExtractText(el)); } [Fact] public void ExtractText_EmptyObject_ReturnsEmpty() { var el = Parse("{}"); - Assert.Equal("", ExtractText(el)); + Assert.Equal("", Helpers.ExtractText(el)); } [Fact] public void ExtractText_MissingParts_ReturnsEmpty() { var el = Parse("""{ "status": { "message": {} } }"""); - Assert.Equal("", ExtractText(el)); + Assert.Equal("", Helpers.ExtractText(el)); } [Fact] public void ExtractText_MultipleParts_Concatenated() { var el = Parse("""{ "parts": [{ "text": "Hello " }, { "text": "world" }] }"""); - Assert.Equal("Hello world", ExtractText(el)); + Assert.Equal("Hello world", Helpers.ExtractText(el)); } [Fact] @@ -121,7 +89,7 @@ public void ExtractText_MixedPartTypes_OnlyTextExtracted() ] } """); - Assert.Equal("visible text", ExtractText(el)); + Assert.Equal("visible text", Helpers.ExtractText(el)); } [Fact] @@ -133,7 +101,7 @@ public void ExtractText_PrefersDirectParts_OverStatusMessage() "status": { "message": { "parts": [{ "text": "nested" }] } } } """); - Assert.Equal("direct", ExtractText(el)); + Assert.Equal("direct", Helpers.ExtractText(el)); } // ── TryGetParts tests ─────────────────────────────────────────────── @@ -142,7 +110,7 @@ public void ExtractText_PrefersDirectParts_OverStatusMessage() public void TryGetParts_MissingPartsProperty_ReturnsFalse() { var el = Parse("""{ "other": 123 }"""); - var result = TryGetParts(el, out var text); + var result = Helpers.TryGetParts(el, out var text); Assert.False(result); Assert.Equal("", text); } @@ -151,7 +119,7 @@ public void TryGetParts_MissingPartsProperty_ReturnsFalse() public void TryGetParts_NonArrayParts_ReturnsFalse() { var el = Parse("""{ "parts": "not-an-array" }"""); - var result = TryGetParts(el, out var text); + var result = Helpers.TryGetParts(el, out var text); Assert.False(result); Assert.Equal("", text); } @@ -160,7 +128,7 @@ public void TryGetParts_NonArrayParts_ReturnsFalse() public void TryGetParts_EmptyArray_ReturnsFalse() { var el = Parse("""{ "parts": [] }"""); - var result = TryGetParts(el, out var text); + var result = Helpers.TryGetParts(el, out var text); Assert.False(result); Assert.Equal("", text); } @@ -169,7 +137,7 @@ public void TryGetParts_EmptyArray_ReturnsFalse() public void TryGetParts_PartsWithNoTextProperty_ReturnsFalse() { var el = Parse("""{ "parts": [{ "kind": "data" }] }"""); - var result = TryGetParts(el, out var text); + var result = Helpers.TryGetParts(el, out var text); Assert.False(result); Assert.Equal("", text); } @@ -178,7 +146,7 @@ public void TryGetParts_PartsWithNoTextProperty_ReturnsFalse() public void TryGetParts_ValidParts_ReturnsTrueWithText() { var el = Parse("""{ "parts": [{ "text": "ok" }] }"""); - var result = TryGetParts(el, out var text); + var result = Helpers.TryGetParts(el, out var text); Assert.True(result); Assert.Equal("ok", text); } @@ -191,7 +159,7 @@ public void ExtractText_NullTextInParts_HandledGracefully() // "text": null — GetString() returns null, StringBuilder.Append(null) is a no-op. var el = Parse("""{ "parts": [{ "text": null }] }"""); // TryGetParts returns false because appended text length is 0. - var result = ExtractText(el); + var result = Helpers.ExtractText(el); Assert.Equal("", result); } @@ -216,7 +184,7 @@ public void ExtractText_DeeplyNestedStatusMessage_Works() } """; var el = Parse(json); - Assert.Equal("Here is the answer: 42", ExtractText(el)); + Assert.Equal("Here is the answer: 42", Helpers.ExtractText(el)); } [Fact] @@ -224,6 +192,6 @@ public void ExtractText_UnicodeText_PreservedCorrectly() { // Emoji, CJK characters, and RTL text var el = Parse("""{ "parts": [{ "text": "🚀 你好世界 مرحبا" }] }"""); - Assert.Equal("🚀 你好世界 مرحبا", ExtractText(el)); + Assert.Equal("🚀 你好世界 مرحبا", Helpers.ExtractText(el)); } } diff --git a/dotnet/a2a-raw.tests/workiq-a2a-raw-tests.csproj b/dotnet/a2a-raw.tests/workiq-a2a-raw-tests.csproj index 8b35db3..8c82d84 100644 --- a/dotnet/a2a-raw.tests/workiq-a2a-raw-tests.csproj +++ b/dotnet/a2a-raw.tests/workiq-a2a-raw-tests.csproj @@ -10,4 +10,7 @@ + + + diff --git a/dotnet/a2a-raw/Helpers.cs b/dotnet/a2a-raw/Helpers.cs new file mode 100644 index 0000000..a9ffe32 --- /dev/null +++ b/dotnet/a2a-raw/Helpers.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text; +using System.Text.Json; + +namespace WorkIQ.A2ARaw; + +public record RawArgs( + string? Endpoint, string? Token, string? AppId, string? Account, + bool Stream, bool AllHeaders, string? Error); + +public static class Helpers +{ + // ── Arg parsing ───────────────────────────────────────────────────── + + public static RawArgs ParseArgs(string[] args) + { + string? endpoint = null, token = null, appId = null, account = null; + bool stream = false, allHeaders = false; + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--endpoint" or "-e": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + endpoint = args[++i]; break; + case "--token" or "-t": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + token = args[++i]; break; + case "--appid" or "-a": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + appId = args[++i]; break; + case "--account": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + account = args[++i]; break; + case "--stream": stream = true; break; + case "--all-headers": allHeaders = true; break; + default: + return Err($"Unknown flag: {args[i]}"); + } + } + + return new RawArgs(endpoint, token, appId, account, stream, allHeaders, null); + + static RawArgs Err(string msg) => new(null, null, null, null, false, false, msg); + } + + // ── A2A response text extraction ──────────────────────────────────── + + public static string ExtractText(JsonElement el) + { + // Try direct parts + if (TryGetParts(el, out var text)) return text; + + // Try result.status.message.parts (task response) + if (el.TryGetProperty("status", out var status) && + status.TryGetProperty("message", out var msg) && + TryGetParts(msg, out text)) return text; + + // Try result.message.parts + if (el.TryGetProperty("message", out var m) && + TryGetParts(m, out text)) return text; + + return ""; + } + + public static bool TryGetParts(JsonElement el, out string text) + { + text = ""; + if (!el.TryGetProperty("parts", out var parts) || parts.ValueKind != JsonValueKind.Array) + return false; + + var sb = new StringBuilder(); + foreach (var part in parts.EnumerateArray()) + { + if (part.TryGetProperty("text", out var t)) + sb.Append(t.GetString()); + } + + text = sb.ToString(); + return text.Length > 0; + } +} diff --git a/dotnet/a2a-raw/Program.cs b/dotnet/a2a-raw/Program.cs index cb12acd..19f7972 100644 --- a/dotnet/a2a-raw/Program.cs +++ b/dotnet/a2a-raw/Program.cs @@ -17,29 +17,21 @@ using System.Text.Json; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Broker; +using WorkIQ.A2ARaw; // ── Parse args ────────────────────────────────────────────────────────── -string? endpoint = null, token = null, appId = null, account = null; -bool stream = false, allHeaders = false; - -for (int i = 0; i < args.Length; i++) +var parsed = Helpers.ParseArgs(args); +if (parsed.Error != null) { - switch (args[i]) - { - case "--endpoint" or "-e": endpoint = args[++i]; break; - case "--token" or "-t": token = args[++i]; break; - case "--appid" or "-a": appId = args[++i]; break; - case "--account": account = args[++i]; break; - case "--stream": stream = true; break; - case "--all-headers": allHeaders = true; break; - default: - Console.Error.WriteLine($"Unknown flag: {args[i]}"); - PrintUsage(); - return; - } + Console.Error.WriteLine(parsed.Error); + PrintUsage(); + return; } +string? endpoint = parsed.Endpoint, token = parsed.Token, appId = parsed.AppId, account = parsed.Account; +bool stream = parsed.Stream, allHeaders = parsed.AllHeaders; + if (string.IsNullOrEmpty(endpoint) || string.IsNullOrEmpty(token)) { PrintUsage(); @@ -225,7 +217,7 @@ async Task SyncResponse(HttpClient client, string ep, HttpContent body, Cancella msg.TryGetProperty("contextId", out var sCtx)) contextId = sCtx.GetString(); - var text = ExtractText(result); + var text = Helpers.ExtractText(result); Console.WriteLine(text); } else @@ -294,7 +286,7 @@ async Task StreamResponse(HttpClient client, string ep, HttpContent body, Cancel contextId = sCtx.GetString(); // Extract and print text delta - var fullText = ExtractText(payload); + var fullText = Helpers.ExtractText(payload); if (fullText.StartsWith(previousText, StringComparison.Ordinal)) { Console.Write(fullText[previousText.Length..]); @@ -312,42 +304,6 @@ async Task StreamResponse(HttpClient client, string ep, HttpContent body, Cancel Console.WriteLine(); } -// ── Extract text from A2A response ─────────────────────────────────────── - -string ExtractText(JsonElement el) -{ - // Try direct parts - if (TryGetParts(el, out var text)) return text; - - // Try result.status.message.parts (task response) - if (el.TryGetProperty("status", out var status) && - status.TryGetProperty("message", out var msg) && - TryGetParts(msg, out text)) return text; - - // Try result.message.parts - if (el.TryGetProperty("message", out var m) && - TryGetParts(m, out text)) return text; - - return ""; -} - -bool TryGetParts(JsonElement el, out string text) -{ - text = ""; - if (!el.TryGetProperty("parts", out var parts) || parts.ValueKind != JsonValueKind.Array) - return false; - - var sb = new StringBuilder(); - foreach (var part in parts.EnumerateArray()) - { - if (part.TryGetProperty("text", out var t)) - sb.Append(t.GetString()); - } - - text = sb.ToString(); - return text.Length > 0; -} - // ── WAM auth ───────────────────────────────────────────────────────────── async Task<(string token, IPublicClientApplication app, IAccount? account)> AcquireToken( diff --git a/dotnet/a2a.tests/ArgParsingTests.cs b/dotnet/a2a.tests/ArgParsingTests.cs index 34d4b2c..0b9fbd5 100644 --- a/dotnet/a2a.tests/ArgParsingTests.cs +++ b/dotnet/a2a.tests/ArgParsingTests.cs @@ -1,149 +1,108 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using WorkIQ.A2A; using Xunit; namespace WorkIQ.A2A.Tests; /// -/// Tests for arg-parsing logic duplicated from a2a Program.cs. +/// Tests for — the arg-parsing logic +/// used by the a2a sample app. /// public class ArgParsingTests { - // ── Duplicated types and logic ─────────────────────────────────────── - - private record GatewayConfig(string Name, string Endpoint, string[] Scopes, string Authority, string[] ExtraHeaders); - private record Config(string Token, string AppId, GatewayConfig Gateway, string? Account, bool ShowToken, int Verbosity, bool Stream); - - private static readonly GatewayConfig Graph = new( - Name: "Graph RP", - Endpoint: "https://graph.microsoft.com/rp/workiq/", - Scopes: ["https://graph.microsoft.com/.default"], - Authority: "https://login.microsoftonline.com/common", - ExtraHeaders: []); - - private static readonly GatewayConfig WorkIQ = new( - Name: "WorkIQ Gateway", - Endpoint: "", - Scopes: [], - Authority: "https://login.microsoftonline.com/common", - ExtraHeaders: []); - - private static Config? ParseArgs(string[] args) + [Fact] + public void ValidArgs_ProduceCorrectConfig() { - string? token = null, appId = null, endpoint = null, account = null; - bool graph = false, workiq = false, showToken = false, stream = false; - int verbosity = 1; - var headers = new List(); - - for (int i = 0; i < args.Length; i++) - { - switch (args[i]) - { - case "--graph": graph = true; break; - case "--workiq": workiq = true; break; - case "--token" or "-t": token = args[++i]; break; - case "--appid" or "-a": appId = args[++i]; break; - case "--endpoint" or "-e": endpoint = args[++i]; break; - case "--account": account = args[++i]; break; - case "--show-token": showToken = true; break; - case "--stream": stream = true; break; - case "--verbosity" or "-v": verbosity = int.Parse(args[++i]); break; - case "--header" or "-H": headers.Add(args[++i]); break; - } - } - - if (string.IsNullOrEmpty(token) || (!graph && !workiq)) - return null; - - if (graph && workiq) - return null; - - if (workiq) - return null; - - if (token.Equals("WAM", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(appId)) - return null; - - var gw = graph ? Graph : WorkIQ; - if (!string.IsNullOrEmpty(endpoint)) - gw = gw with { Endpoint = endpoint }; - if (headers.Count > 0) - gw = gw with { ExtraHeaders = [.. gw.ExtraHeaders, .. headers] }; - - return new Config(token, appId ?? "", gw, account, showToken, verbosity, stream); + var r = Helpers.ParseArgs(["--graph", "--token", "mytoken", "--appid", "app1", "--stream"]); + Assert.Null(r.Error); + Assert.Equal("mytoken", r.Token); + Assert.Equal("app1", r.AppId); + Assert.True(r.Stream); + Assert.True(r.Graph); } - // ── Tests ──────────────────────────────────────────────────────────── - [Fact] - public void ValidArgs_ProduceCorrectConfig() + public void MissingToken_ReturnsNullToken() { - var result = ParseArgs(["--graph", "--token", "mytoken", "--appid", "app1", "--stream"]); - Assert.NotNull(result); - Assert.Equal("mytoken", result.Token); - Assert.Equal("app1", result.AppId); - Assert.True(result.Stream); - Assert.Equal("Graph RP", result.Gateway.Name); + var r = Helpers.ParseArgs(["--graph"]); + Assert.Null(r.Error); + Assert.Null(r.Token); } [Fact] - public void MissingToken_ReturnsNull() + public void MissingGateway_NoGatewayFlagSet() { - Assert.Null(ParseArgs(["--graph"])); + var r = Helpers.ParseArgs(["--token", "abc"]); + Assert.Null(r.Error); + Assert.False(r.Graph); + Assert.False(r.Workiq); } [Fact] - public void MissingGateway_ReturnsNull() + public void GraphAndWorkiqTogether_BothFlagsSet() { - Assert.Null(ParseArgs(["--token", "abc"])); + // Mutual-exclusion is enforced at the Program.cs layer, not here. + var r = Helpers.ParseArgs(["--graph", "--workiq", "--token", "abc"]); + Assert.Null(r.Error); + Assert.True(r.Graph); + Assert.True(r.Workiq); } [Fact] - public void GraphAndWorkiqTogether_ReturnsNull() + public void EndpointOverride_Captured() { - Assert.Null(ParseArgs(["--graph", "--workiq", "--token", "abc"])); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--endpoint", "https://custom.com/"]); + Assert.Null(r.Error); + Assert.Equal("https://custom.com/", r.Endpoint); } [Fact] - public void WamWithoutAppid_ReturnsNull() + public void HeaderValues_AreCollected() { - Assert.Null(ParseArgs(["--graph", "--token", "WAM"])); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--header", "X-Custom: v1", "-H", "X-Other: v2"]); + Assert.Null(r.Error); + Assert.Equal(2, r.Headers.Count); } [Fact] - public void EndpointOverride_AppliedToGateway() + public void VerbosityWithNonInteger_ReturnsError() { - var result = ParseArgs(["--graph", "--token", "t", "--endpoint", "https://custom.com/"]); - Assert.NotNull(result); - Assert.Equal("https://custom.com/", result.Gateway.Endpoint); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--verbosity", "abc"]); + Assert.NotNull(r.Error); + Assert.Contains("integer", r.Error); } [Fact] - public void HeaderValues_AreCollected() + public void VerbosityWithInteger_Parsed() { - var result = ParseArgs(["--graph", "--token", "t", "--header", "X-Custom: v1", "-H", "X-Other: v2"]); - Assert.NotNull(result); - Assert.Equal(2, result.Gateway.ExtraHeaders.Length); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--verbosity", "3"]); + Assert.Null(r.Error); + Assert.Equal(3, r.Verbosity); } [Fact] - public void VerbosityWithNonInteger_ThrowsFormatException() + public void MissingValueAfterToken_ReturnsError() { - Assert.Throws(() => ParseArgs(["--graph", "--token", "t", "--verbosity", "abc"])); + var r = Helpers.ParseArgs(["--graph", "--token"]); + Assert.NotNull(r.Error); + Assert.Contains("Missing value", r.Error); } [Fact] - public void MissingValueAfterToken_ThrowsIndexOutOfRange() + public void MissingValueAfterEndpoint_ReturnsError() { - Assert.Throws(() => ParseArgs(["--graph", "--token"])); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--endpoint"]); + Assert.NotNull(r.Error); + Assert.Contains("Missing value", r.Error); } [Fact] public void DefaultVerbosity_IsOne() { - var result = ParseArgs(["--graph", "--token", "t"]); - Assert.NotNull(result); - Assert.Equal(1, result.Verbosity); + var r = Helpers.ParseArgs(["--graph", "--token", "t"]); + Assert.Null(r.Error); + Assert.Equal(1, r.Verbosity); } } diff --git a/dotnet/a2a.tests/ExtractTests.cs b/dotnet/a2a.tests/ExtractTests.cs index 731f86a..69f3758 100644 --- a/dotnet/a2a.tests/ExtractTests.cs +++ b/dotnet/a2a.tests/ExtractTests.cs @@ -3,27 +3,16 @@ using System.Text.Json; using A2A; +using WorkIQ.A2A; using Xunit; namespace WorkIQ.A2A.Tests; /// -/// Tests for Extract/Join logic duplicated from a2a Program.cs. +/// Tests for and from the a2a sample app. /// public class ExtractTests { - // ── Duplicated logic under test ────────────────────────────────────── - - private static (string text, string? contextId, Dictionary? metadata) Extract(object response) => response switch - { - AgentMessage am => (Join(am), am.ContextId, am.Metadata), - AgentTask { Status: { State: TaskState.Completed, Message: AgentMessage cm } } t => (Join(cm), t.ContextId, cm.Metadata), - AgentTask t => ($"[Task {t.Id} — {t.Status.State}]", t.ContextId, null), - _ => ("(no response)", null, null), - }; - - private static string Join(AgentMessage m) => string.Join("\n", m.Parts.OfType().Select(p => p.Text)); - // ── Extract tests ─────────────────────────────────────────────────── [Fact] @@ -37,7 +26,7 @@ public void Extract_AgentMessage_ReturnsTextAndContext() Parts = [new TextPart { Text = "Hello from agent" }], }; - var (text, contextId, metadata) = Extract(msg); + var (text, contextId, _) = Helpers.Extract(msg); Assert.Equal("Hello from agent", text); Assert.Equal("ctx-1", contextId); } @@ -57,7 +46,7 @@ public void Extract_AgentMessage_WithMetadata_ReturnsMetadata() Metadata = meta, }; - var (_, _, metadata) = Extract(msg); + var (_, _, metadata) = Helpers.Extract(msg); Assert.NotNull(metadata); Assert.Equal("value", metadata["key"].GetString()); } @@ -78,7 +67,7 @@ public void Extract_CompletedAgentTask_ReturnsMessageText() Status = new AgentTaskStatus { State = TaskState.Completed, Message = agentMsg }, }; - var (text, contextId, _) = Extract(task); + var (text, contextId, _) = Helpers.Extract(task); Assert.Equal("Task done", text); Assert.Equal("ctx-2", contextId); } @@ -93,7 +82,7 @@ public void Extract_NonCompletedAgentTask_ReturnsStatusString() Status = new AgentTaskStatus { State = TaskState.Working }, }; - var (text, contextId, metadata) = Extract(task); + var (text, contextId, metadata) = Helpers.Extract(task); Assert.Contains("t2", text); Assert.Contains("Working", text); Assert.Equal("ctx-3", contextId); @@ -103,7 +92,7 @@ public void Extract_NonCompletedAgentTask_ReturnsStatusString() [Fact] public void Extract_UnknownType_ReturnsNoResponse() { - var (text, contextId, metadata) = Extract("some random object"); + var (text, contextId, metadata) = Helpers.Extract("some random object"); Assert.Equal("(no response)", text); Assert.Null(contextId); Assert.Null(metadata); @@ -121,7 +110,7 @@ public void Join_MultipleTextParts_JoinedWithNewline() Parts = [new TextPart { Text = "Line 1" }, new TextPart { Text = "Line 2" }], }; - Assert.Equal("Line 1\nLine 2", Join(msg)); + Assert.Equal("Line 1\nLine 2", Helpers.Join(msg)); } [Fact] @@ -134,7 +123,7 @@ public void Join_EmptyParts_ReturnsEmptyString() Parts = [], }; - Assert.Equal("", Join(msg)); + Assert.Equal("", Helpers.Join(msg)); } [Fact] @@ -147,7 +136,7 @@ public void Join_NonTextParts_Filtered() Parts = [new TextPart { Text = "visible" }, new DataPart { Data = new Dictionary() }], }; - Assert.Equal("visible", Join(msg)); + Assert.Equal("visible", Helpers.Join(msg)); } [Fact] @@ -167,7 +156,7 @@ public void Join_MixedParts_OnlyTextPartsIncluded() ], }; - Assert.Equal("A\nB\nC", Join(msg)); + Assert.Equal("A\nB\nC", Helpers.Join(msg)); } // ── Edge-case tests ───────────────────────────────────────────────── @@ -182,7 +171,7 @@ public void Extract_AgentMessage_EmptyParts_ReturnsEmptyString() Parts = [], }; - var (text, _, _) = Extract(msg); + var (text, _, _) = Helpers.Extract(msg); Assert.Equal("", text); } @@ -197,7 +186,7 @@ public void Extract_AgentMessage_NullContextId_ReturnsNull() // ContextId not set — defaults to null }; - var (text, contextId, _) = Extract(msg); + var (text, contextId, _) = Helpers.Extract(msg); Assert.Equal("test", text); Assert.Null(contextId); } diff --git a/dotnet/a2a.tests/workiq-a2a-tests.csproj b/dotnet/a2a.tests/workiq-a2a-tests.csproj index 149f24a..a345c46 100644 --- a/dotnet/a2a.tests/workiq-a2a-tests.csproj +++ b/dotnet/a2a.tests/workiq-a2a-tests.csproj @@ -11,4 +11,7 @@ + + + diff --git a/dotnet/a2a/Helpers.cs b/dotnet/a2a/Helpers.cs new file mode 100644 index 0000000..b46f7aa --- /dev/null +++ b/dotnet/a2a/Helpers.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; +using A2A; + +namespace WorkIQ.A2A; + +public record A2AArgs( + string? Token, string? AppId, string? Endpoint, string? Account, + bool Graph, bool Workiq, bool ShowToken, bool Stream, + int Verbosity, List Headers, string? Error); + +public static class Helpers +{ + // ── Arg parsing ───────────────────────────────────────────────────── + + public static A2AArgs ParseArgs(string[] args) + { + string? token = null, appId = null, endpoint = null, account = null; + bool graph = false, workiq = false, showToken = false, stream = false; + int verbosity = 1; + var headers = new List(); + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--graph": graph = true; break; + case "--workiq": workiq = true; break; + case "--token" or "-t": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + token = args[++i]; break; + case "--appid" or "-a": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + appId = args[++i]; break; + case "--endpoint" or "-e": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + endpoint = args[++i]; break; + case "--account": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + account = args[++i]; break; + case "--show-token": showToken = true; break; + case "--stream": stream = true; break; + case "--verbosity" or "-v": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + if (!int.TryParse(args[++i], out verbosity)) + return Err($"--verbosity requires an integer, got: {args[i]}"); + break; + case "--header" or "-H": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + headers.Add(args[++i]); break; + } + } + + return new A2AArgs(token, appId, endpoint, account, graph, workiq, showToken, stream, verbosity, headers, null); + + static A2AArgs Err(string msg) => new(null, null, null, null, false, false, false, false, 1, new List(), msg); + } + + // ── Response extraction (uses A2A SDK types) ──────────────────────── + + public static (string text, string? contextId, Dictionary? metadata) Extract(object response) => response switch + { + AgentMessage am => (Join(am), am.ContextId, am.Metadata), + AgentTask { Status: { State: TaskState.Completed, Message: AgentMessage cm } } t => (Join(cm), t.ContextId, cm.Metadata), + AgentTask t => ($"[Task {t.Id} — {t.Status.State}]", t.ContextId, null), + _ => ("(no response)", null, null), + }; + + public static string Join(AgentMessage m) => string.Join("\n", m.Parts.OfType().Select(p => p.Text)); +} diff --git a/dotnet/a2a/Program.cs b/dotnet/a2a/Program.cs index 68cb26b..686f16f 100644 --- a/dotnet/a2a/Program.cs +++ b/dotnet/a2a/Program.cs @@ -164,15 +164,8 @@ // ── Core helpers ───────────────────────────────────────────────────────── -static (string text, string? contextId, Dictionary? metadata) Extract(object response) => response switch -{ - AgentMessage am => (Join(am), am.ContextId, am.Metadata), - AgentTask { Status: { State: TaskState.Completed, Message: AgentMessage cm } } t => (Join(cm), t.ContextId, cm.Metadata), - AgentTask t => ($"[Task {t.Id} — {t.Status.State}]", t.ContextId, null), - _ => ("(no response)", null, null), -}; - -static string Join(AgentMessage m) => string.Join("\n", m.Parts.OfType().Select(p => p.Text)); +static (string text, string? contextId, Dictionary? metadata) Extract(object response) + => WorkIQ.A2A.Helpers.Extract(response); static HttpClient CreateHttpClient(string bearerToken, GatewayConfig gw) { @@ -263,28 +256,19 @@ static void DecodeToken(string token) static Config? ParseArgs(string[] args) { - string? token = null, appId = null, endpoint = null, account = null; - bool graph = false, workiq = false, showToken = false, stream = false; - int verbosity = 1; - var headers = new List(); + var a = WorkIQ.A2A.Helpers.ParseArgs(args); - for (int i = 0; i < args.Length; i++) + if (a.Error != null) { - switch (args[i]) - { - case "--graph": graph = true; break; - case "--workiq": workiq = true; break; - case "--token" or "-t": token = args[++i]; break; - case "--appid" or "-a": appId = args[++i]; break; - case "--endpoint" or "-e": endpoint = args[++i]; break; - case "--account": account = args[++i]; break; - case "--show-token": showToken = true; break; - case "--stream": stream = true; break; - case "--verbosity" or "-v": verbosity = int.Parse(args[++i]); break; - case "--header" or "-H": headers.Add(args[++i]); break; - } + Ink($"ERROR: {a.Error}\n", ConsoleColor.Red); + return null; } + string? token = a.Token, appId = a.AppId, endpoint = a.Endpoint, account = a.Account; + bool graph = a.Graph, workiq = a.Workiq, showToken = a.ShowToken, stream = a.Stream; + int verbosity = a.Verbosity; + var headers = a.Headers; + if (string.IsNullOrEmpty(token) || (!graph && !workiq)) { Console.WriteLine(""" diff --git a/dotnet/rest.tests/ArgParsingTests.cs b/dotnet/rest.tests/ArgParsingTests.cs index a432e70..7137ae5 100644 --- a/dotnet/rest.tests/ArgParsingTests.cs +++ b/dotnet/rest.tests/ArgParsingTests.cs @@ -1,152 +1,136 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using WorkIQ.Rest; using Xunit; namespace WorkIQ.Rest.Tests; /// -/// Tests for arg-parsing logic duplicated from rest Program.cs. +/// Tests for — the arg-parsing logic +/// used by the rest sample app. /// public class ArgParsingTests { - // ── Duplicated types and logic ─────────────────────────────────────── - - private record Config(string Token, string AppId, string? Account, bool Stream, bool ShowToken, int Verbosity, List Headers); - - private static Config? ParseArgs(string[] args) - { - string? token = null, appId = null, account = null; - bool graph = false, workiq = false, stream = false, showToken = false; - int verbosity = 1; - var headers = new List(); - - for (int i = 0; i < args.Length; i++) - { - switch (args[i]) - { - case "--graph": graph = true; break; - case "--workiq": workiq = true; break; - case "--token" or "-t": token = args[++i]; break; - case "--appid" or "-a": appId = args[++i]; break; - case "--account": account = args[++i]; break; - case "--stream": stream = true; break; - case "--show-token": showToken = true; break; - case "--verbosity" or "-v": verbosity = int.Parse(args[++i]); break; - case "--header" or "-H": headers.Add(args[++i]); break; - } - } - - if (string.IsNullOrEmpty(token) || (!graph && !workiq)) - return null; - - if (graph && workiq) - return null; - - if (workiq) - return null; - - if (token.Equals("WAM", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(appId)) - return null; - - return new Config(token, appId ?? "", account, stream, showToken, verbosity, headers); - } - - // ── Tests ──────────────────────────────────────────────────────────── - [Fact] - public void GraphAndWorkiqTogether_ReturnsNull() + public void ValidArgs_ProduceCorrectConfig() { - var result = ParseArgs(["--graph", "--workiq", "--token", "abc"]); - Assert.Null(result); + var r = Helpers.ParseArgs(["--graph", "--token", "mytoken", "--appid", "app1", "--stream", "--account", "user@test.com"]); + Assert.Null(r.Error); + Assert.Equal("mytoken", r.Token); + Assert.Equal("app1", r.AppId); + Assert.Equal("user@test.com", r.Account); + Assert.True(r.Stream); + Assert.True(r.Graph); } [Fact] - public void WamWithoutAppid_ReturnsNull() + public void GraphAndWorkiqTogether_BothFlagsSet() { - var result = ParseArgs(["--graph", "--token", "WAM"]); - Assert.Null(result); + // The Helpers.ParseArgs layer just extracts flags — mutual-exclusion is + // enforced at the Program.cs Config-construction layer. + var r = Helpers.ParseArgs(["--graph", "--workiq", "--token", "abc"]); + Assert.Null(r.Error); + Assert.True(r.Graph); + Assert.True(r.Workiq); } [Fact] - public void MissingToken_ReturnsNull() + public void MissingToken_ReturnsNullToken() { - var result = ParseArgs(["--graph"]); - Assert.Null(result); + var r = Helpers.ParseArgs(["--graph"]); + Assert.Null(r.Error); + Assert.Null(r.Token); } [Fact] - public void MissingGateway_ReturnsNull() + public void MissingGateway_NoGatewayFlagSet() { - var result = ParseArgs(["--token", "abc"]); - Assert.Null(result); + var r = Helpers.ParseArgs(["--token", "abc"]); + Assert.Null(r.Error); + Assert.False(r.Graph); + Assert.False(r.Workiq); } [Fact] - public void ValidArgs_ProduceCorrectConfig() + public void VerbosityWithNonInteger_ReturnsError() { - var result = ParseArgs(["--graph", "--token", "mytoken", "--appid", "app1", "--stream", "--account", "user@test.com"]); - Assert.NotNull(result); - Assert.Equal("mytoken", result.Token); - Assert.Equal("app1", result.AppId); - Assert.Equal("user@test.com", result.Account); - Assert.True(result.Stream); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--verbosity", "abc"]); + Assert.NotNull(r.Error); + Assert.Contains("integer", r.Error); } [Fact] - public void VerbosityWithNonInteger_ThrowsFormatException() + public void VerbosityWithInteger_Parsed() { - // Bug: int.Parse will throw if the value isn't an integer - Assert.Throws(() => ParseArgs(["--graph", "--token", "t", "--verbosity", "abc"])); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--verbosity", "2"]); + Assert.Null(r.Error); + Assert.Equal(2, r.Verbosity); } [Fact] public void HeaderValues_AreCollected() { - var result = ParseArgs(["--graph", "--token", "t", "--header", "X-Custom: value1", "-H", "X-Other: value2"]); - Assert.NotNull(result); - Assert.Equal(2, result.Headers.Count); - Assert.Equal("X-Custom: value1", result.Headers[0]); - Assert.Equal("X-Other: value2", result.Headers[1]); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--header", "X-Custom: value1", "-H", "X-Other: value2"]); + Assert.Null(r.Error); + Assert.Equal(2, r.Headers.Count); + Assert.Equal("X-Custom: value1", r.Headers[0]); + Assert.Equal("X-Other: value2", r.Headers[1]); } [Fact] public void ShowToken_Parsed() { - var result = ParseArgs(["--graph", "--token", "t", "--show-token"]); - Assert.NotNull(result); - Assert.True(result.ShowToken); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--show-token"]); + Assert.Null(r.Error); + Assert.True(r.ShowToken); } [Fact] public void DefaultVerbosity_IsOne() { - var result = ParseArgs(["--graph", "--token", "t"]); - Assert.NotNull(result); - Assert.Equal(1, result.Verbosity); + var r = Helpers.ParseArgs(["--graph", "--token", "t"]); + Assert.Null(r.Error); + Assert.Equal(1, r.Verbosity); } [Fact] - public void MissingValueAfterToken_ThrowsIndexOutOfRange() + public void MissingValueAfterToken_ReturnsError() { - Assert.Throws(() => ParseArgs(["--graph", "--token"])); + var r = Helpers.ParseArgs(["--graph", "--token"]); + Assert.NotNull(r.Error); + Assert.Contains("Missing value", r.Error); } - // ── Edge-case tests ───────────────────────────────────────────────── + [Fact] + public void MissingValueAfterHeader_ReturnsError() + { + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--header"]); + Assert.NotNull(r.Error); + Assert.Contains("Missing value", r.Error); + } + + [Fact] + public void MissingValueAfterVerbosity_ReturnsError() + { + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--verbosity"]); + Assert.NotNull(r.Error); + Assert.Contains("Missing value", r.Error); + } [Fact] - public void WorkiqGateway_ReturnsNull_WithMessage() + public void WorkiqGateway_FlagRecognized() { - // --workiq is recognized but not yet implemented — always returns null - var result = ParseArgs(["--workiq", "--token", "X"]); - Assert.Null(result); + var r = Helpers.ParseArgs(["--workiq", "--token", "X"]); + Assert.Null(r.Error); + Assert.True(r.Workiq); } [Fact] public void StreamFlag_SetsStreamTrue() { - var result = ParseArgs(["--graph", "--token", "t", "--stream"]); - Assert.NotNull(result); - Assert.True(result.Stream); + var r = Helpers.ParseArgs(["--graph", "--token", "t", "--stream"]); + Assert.Null(r.Error); + Assert.True(r.Stream); } } diff --git a/dotnet/rest.tests/ChatBodyTests.cs b/dotnet/rest.tests/ChatBodyTests.cs index 419462f..dfb4631 100644 --- a/dotnet/rest.tests/ChatBodyTests.cs +++ b/dotnet/rest.tests/ChatBodyTests.cs @@ -2,36 +2,20 @@ // Licensed under the MIT License. using System.Text.Json; +using WorkIQ.Rest; using Xunit; namespace WorkIQ.Rest.Tests; /// -/// Tests for BuildChatBody logic duplicated from rest Program.cs. +/// Tests for from the rest sample app. /// public class ChatBodyTests { - // ── Duplicated logic under test ────────────────────────────────────── - - private static string BuildChatBody(string message) - { - string tz; - try { tz = TimeZoneInfo.Local.HasIanaId ? TimeZoneInfo.Local.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(TimeZoneInfo.Local.Id, out var iana) ? iana : "UTC"; } - catch { tz = "UTC"; } - - return JsonSerializer.Serialize(new - { - message = new { text = message }, - locationHint = new { timeZone = tz }, - }); - } - - // ── Tests ──────────────────────────────────────────────────────────── - [Fact] public void BuildChatBody_ContainsMessageText() { - var body = BuildChatBody("Hello"); + var body = Helpers.BuildChatBody("Hello"); using var doc = JsonDocument.Parse(body); var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); Assert.Equal("Hello", text); @@ -40,7 +24,7 @@ public void BuildChatBody_ContainsMessageText() [Fact] public void BuildChatBody_OutputIsValidJson() { - var body = BuildChatBody("test"); + var body = Helpers.BuildChatBody("test"); var ex = Record.Exception(() => JsonDocument.Parse(body)); Assert.Null(ex); } @@ -49,7 +33,7 @@ public void BuildChatBody_OutputIsValidJson() public void BuildChatBody_SpecialCharacters_ArePreserved() { var msg = "He said \"hello\"\nNew line\tTab \u00e9"; - var body = BuildChatBody(msg); + var body = Helpers.BuildChatBody(msg); using var doc = JsonDocument.Parse(body); var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); Assert.Equal(msg, text); @@ -58,7 +42,7 @@ public void BuildChatBody_SpecialCharacters_ArePreserved() [Fact] public void BuildChatBody_ContainsLocationHintTimeZone() { - var body = BuildChatBody("test"); + var body = Helpers.BuildChatBody("test"); using var doc = JsonDocument.Parse(body); var tz = doc.RootElement.GetProperty("locationHint").GetProperty("timeZone").GetString(); Assert.NotNull(tz); @@ -68,7 +52,7 @@ public void BuildChatBody_ContainsLocationHintTimeZone() [Fact] public void BuildChatBody_EmptyMessage_StillValid() { - var body = BuildChatBody(""); + var body = Helpers.BuildChatBody(""); using var doc = JsonDocument.Parse(body); var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); Assert.Equal("", text); @@ -80,7 +64,7 @@ public void BuildChatBody_EmptyMessage_StillValid() public void BuildChatBody_VeryLongMessage_Handled() { var longMessage = new string('A', 100_000); - var body = BuildChatBody(longMessage); + var body = Helpers.BuildChatBody(longMessage); using var doc = JsonDocument.Parse(body); var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); Assert.Equal(longMessage, text); @@ -91,7 +75,7 @@ public void BuildChatBody_JsonInjectionAttempt_Escaped() { // Attempt to break out of the JSON string value var malicious = "\"}, \"evil\": true, {\""; - var body = BuildChatBody(malicious); + var body = Helpers.BuildChatBody(malicious); using var doc = JsonDocument.Parse(body); // must still be valid JSON var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); Assert.Equal(malicious, text); @@ -103,7 +87,7 @@ public void BuildChatBody_JsonInjectionAttempt_Escaped() public void BuildChatBody_NullCharactersInMessage_Handled() { var msg = "before\0after"; - var body = BuildChatBody(msg); + var body = Helpers.BuildChatBody(msg); using var doc = JsonDocument.Parse(body); var text = doc.RootElement.GetProperty("message").GetProperty("text").GetString(); Assert.Equal(msg, text); diff --git a/dotnet/rest.tests/TruncTests.cs b/dotnet/rest.tests/TruncTests.cs index 408d0ee..84480b6 100644 --- a/dotnet/rest.tests/TruncTests.cs +++ b/dotnet/rest.tests/TruncTests.cs @@ -1,48 +1,43 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using WorkIQ.Rest; using Xunit; namespace WorkIQ.Rest.Tests; /// -/// Tests for the Trunc utility duplicated from rest Program.cs. +/// Tests for from the rest sample app. /// public class TruncTests { - // ── Duplicated logic under test ────────────────────────────────────── - - private static string Trunc(string s, int max) => s.Length <= max ? s : $"{s[..max]}..."; - - // ── Tests ──────────────────────────────────────────────────────────── - [Fact] public void Trunc_ShorterThanMax_ReturnsSameString() { - Assert.Equal("hi", Trunc("hi", 10)); + Assert.Equal("hi", Helpers.Trunc("hi", 10)); } [Fact] public void Trunc_ExactlyAtMax_ReturnsSameString() { - Assert.Equal("hello", Trunc("hello", 5)); + Assert.Equal("hello", Helpers.Trunc("hello", 5)); } [Fact] public void Trunc_LongerThanMax_TruncatesWithEllipsis() { - Assert.Equal("hel...", Trunc("hello world", 3)); + Assert.Equal("hel...", Helpers.Trunc("hello world", 3)); } [Fact] public void Trunc_EmptyString_ReturnsEmpty() { - Assert.Equal("", Trunc("", 5)); + Assert.Equal("", Helpers.Trunc("", 5)); } [Fact] public void Trunc_MaxOfOne_TruncatesCorrectly() { - Assert.Equal("h...", Trunc("hello", 1)); + Assert.Equal("h...", Helpers.Trunc("hello", 1)); } } diff --git a/dotnet/rest.tests/workiq-rest-tests.csproj b/dotnet/rest.tests/workiq-rest-tests.csproj index 8b35db3..145bcf1 100644 --- a/dotnet/rest.tests/workiq-rest-tests.csproj +++ b/dotnet/rest.tests/workiq-rest-tests.csproj @@ -10,4 +10,7 @@ + + + diff --git a/dotnet/rest/Helpers.cs b/dotnet/rest/Helpers.cs new file mode 100644 index 0000000..a2644c3 --- /dev/null +++ b/dotnet/rest/Helpers.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Text.Json; + +namespace WorkIQ.Rest; + +public record RestArgs( + string? Token, string? AppId, string? Account, + bool Graph, bool Workiq, bool Stream, bool ShowToken, + int Verbosity, List Headers, string? Error); + +public static class Helpers +{ + // ── Arg parsing ───────────────────────────────────────────────────── + + public static RestArgs ParseArgs(string[] args) + { + string? token = null, appId = null, account = null; + bool graph = false, workiq = false, stream = false, showToken = false; + int verbosity = 1; + var headers = new List(); + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--graph": graph = true; break; + case "--workiq": workiq = true; break; + case "--token" or "-t": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + token = args[++i]; break; + case "--appid" or "-a": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + appId = args[++i]; break; + case "--account": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + account = args[++i]; break; + case "--stream": stream = true; break; + case "--show-token": showToken = true; break; + case "--verbosity" or "-v": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + if (!int.TryParse(args[++i], out verbosity)) + return Err($"--verbosity requires an integer, got: {args[i]}"); + break; + case "--header" or "-H": + if (i + 1 >= args.Length) return Err($"Missing value for {args[i]}"); + headers.Add(args[++i]); break; + } + } + + return new RestArgs(token, appId, account, graph, workiq, stream, showToken, verbosity, headers, null); + + static RestArgs Err(string msg) => new(null, null, null, false, false, false, false, 1, new List(), msg); + } + + // ── Chat body builder ─────────────────────────────────────────────── + + public static string BuildChatBody(string message) + { + // Graph API requires IANA timezone (e.g. "America/Los_Angeles"), not Windows (e.g. "Pacific Standard Time") + string tz; + try { tz = TimeZoneInfo.Local.HasIanaId ? TimeZoneInfo.Local.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(TimeZoneInfo.Local.Id, out var iana) ? iana : "UTC"; } + catch { tz = "UTC"; } + + return JsonSerializer.Serialize(new + { + message = new { text = message }, + locationHint = new { timeZone = tz }, + }); + } + + // ── String truncation ─────────────────────────────────────────────── + + public static string Trunc(string s, int max) => s.Length <= max ? s : $"{s[..max]}..."; +} diff --git a/dotnet/rest/Program.cs b/dotnet/rest/Program.cs index 803d149..e7110a7 100644 --- a/dotnet/rest/Program.cs +++ b/dotnet/rest/Program.cs @@ -284,19 +284,7 @@ async Task ChatStream(HttpClient client, string convId, string message) } } -static string BuildChatBody(string message) -{ - // Graph API requires IANA timezone (e.g. "America/Los_Angeles"), not Windows (e.g. "Pacific Standard Time") - string tz; - try { tz = TimeZoneInfo.Local.HasIanaId ? TimeZoneInfo.Local.Id : TimeZoneInfo.TryConvertWindowsIdToIanaId(TimeZoneInfo.Local.Id, out var iana) ? iana : "UTC"; } - catch { tz = "UTC"; } - - return JsonSerializer.Serialize(new - { - message = new { text = message }, - locationHint = new { timeZone = tz }, - }); -} +static string BuildChatBody(string message) => WorkIQ.Rest.Helpers.BuildChatBody(message); // ── WAM auth ───────────────────────────────────────────────────────────── @@ -376,27 +364,19 @@ static void DecodeToken(string token) static Config? ParseArgs(string[] args) { - string? token = null, appId = null, account = null; - bool graph = false, workiq = false, stream = false, showToken = false; - int verbosity = 1; - var headers = new List(); + var a = WorkIQ.Rest.Helpers.ParseArgs(args); - for (int i = 0; i < args.Length; i++) + if (a.Error != null) { - switch (args[i]) - { - case "--graph": graph = true; break; - case "--workiq": workiq = true; break; - case "--token" or "-t": token = args[++i]; break; - case "--appid" or "-a": appId = args[++i]; break; - case "--account": account = args[++i]; break; - case "--stream": stream = true; break; - case "--show-token": showToken = true; break; - case "--verbosity" or "-v": verbosity = int.Parse(args[++i]); break; - case "--header" or "-H": headers.Add(args[++i]); break; - } + Ink($"ERROR: {a.Error}\n", ConsoleColor.Red); + return null; } + string? token = a.Token, appId = a.AppId, account = a.Account; + bool graph = a.Graph, workiq = a.Workiq, stream = a.Stream, showToken = a.ShowToken; + int verbosity = a.Verbosity; + var headers = a.Headers; + if (string.IsNullOrEmpty(token) || (!graph && !workiq)) { Console.WriteLine(""" @@ -473,7 +453,7 @@ static void LogWire(string method, string url, string? body, HttpResponseMessage Console.ResetColor(); } -static string Trunc(string s, int max) => s.Length <= max ? s : $"{s[..max]}..."; +static string Trunc(string s, int max) => WorkIQ.Rest.Helpers.Trunc(s, max); [DllImport("kernel32.dll", EntryPoint = "GetConsoleWindow")] static extern IntPtr Win32GetConsoleWindow(); [DllImport("user32.dll", ExactSpelling = true)] static extern IntPtr GetAncestor(IntPtr hwnd, uint flags); diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..1d0ff5a --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Run all tests across all sample apps. +# Usage: ./scripts/test.sh +set -euo pipefail + +cd "$(dirname "$0")/.." +failed=0 + +echo "=== .NET tests ===" +(cd dotnet && dotnet test WorkIQSamples.sln --nologo) || failed=1 + +echo "" +echo "=== Rust tests ===" +(cd rust/a2a && cargo test) || failed=1 + +echo "" +echo "=== Swift tests ===" +if command -v xcodebuild &>/dev/null; then + (cd swift/a2a && xcodebuild test \ + -scheme "A2A Chat" \ + -destination "platform=iOS Simulator,name=iPhone 16" \ + -quiet 2>&1 | tail -20) || failed=1 +else + echo " Skipped (xcodebuild not found)" +fi + +echo "" +if [ "$failed" -ne 0 ]; then + echo "FAILED: some test suites had failures" + exit 1 +else + echo "All tests passed." +fi diff --git a/swift/a2a/A2A Chat/Services/A2AService.swift b/swift/a2a/A2A Chat/Services/A2AService.swift index 93ad636..71a4699 100644 --- a/swift/a2a/A2A Chat/Services/A2AService.swift +++ b/swift/a2a/A2A Chat/Services/A2AService.swift @@ -157,7 +157,7 @@ class A2AService { } /// Auth provider that adds a bearer token to every request. -private struct BearerTokenAuth: AuthenticationProvider, Sendable { +struct BearerTokenAuth: AuthenticationProvider, Sendable { let token: String func authenticate(request: URLRequest) async throws -> URLRequest { diff --git a/swift/a2a/A2A Chat/Views/MessageBubbleView.swift b/swift/a2a/A2A Chat/Views/MessageBubbleView.swift index ae5a397..f13f36f 100644 --- a/swift/a2a/A2A Chat/Views/MessageBubbleView.swift +++ b/swift/a2a/A2A Chat/Views/MessageBubbleView.swift @@ -32,16 +32,21 @@ struct MessageBubbleView: View { } private var markdownAttributedString: AttributedString { - // Complete messages use full markdown parsing (handles paragraphs, lists, etc.) - // Streaming chunks use inline-only (handles partial bold/italic/links) - if message.isComplete { - if let result = try? AttributedString(markdown: message.text, options: .init(interpretedSyntax: .full)) { - return result - } - } - if let result = try? AttributedString(markdown: message.text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { + renderMarkdown(text: message.text, isComplete: message.isComplete) + } +} + +/// Render text as an AttributedString with markdown formatting. +/// Complete messages use full markdown parsing (handles paragraphs, lists, etc.) +/// Streaming chunks use inline-only (handles partial bold/italic/links) +func renderMarkdown(text: String, isComplete: Bool) -> AttributedString { + if isComplete { + if let result = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .full)) { return result } - return AttributedString(message.text) } + if let result = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { + return result + } + return AttributedString(text) } diff --git a/swift/a2a/A2A ChatTests/A2A_ChatTests.swift b/swift/a2a/A2A ChatTests/A2A_ChatTests.swift index 404263b..3aadcf3 100644 --- a/swift/a2a/A2A ChatTests/A2A_ChatTests.swift +++ b/swift/a2a/A2A ChatTests/A2A_ChatTests.swift @@ -69,16 +69,7 @@ struct ChatMessageTests { } // MARK: - BearerTokenAuth Tests -// -// NOTE: BearerTokenAuth is declared `private` in A2AService.swift, so it cannot -// be tested from outside the module. To enable unit testing, consider changing -// the access level to `internal` (the Swift default) or extracting it into its -// own file. -// -// The tests below demonstrate what SHOULD be tested if the struct were visible. -// They are commented out because they will not compile against the current source. -/* struct BearerTokenAuthTests { @Test func authenticateAddsBearerHeader() async throws { @@ -120,7 +111,6 @@ struct BearerTokenAuthTests { #expect(authenticated.value(forHTTPHeaderField: "Authorization") == "Bearer ") } } -*/ // MARK: - Configuration Loading Tests // @@ -181,39 +171,9 @@ struct ConfigurationLoadingTests { */ // MARK: - Markdown Rendering Tests -// -// MessageBubbleView.markdownAttributedString is a `private` computed property -// on a SwiftUI View. To make it testable, consider extracting it into a free -// function or a static method on ChatMessage / a helper type: -// -// func renderMarkdown(text: String, isComplete: Bool) -> AttributedString -// -// The tests below demonstrate what SHOULD be verified. They are commented out -// because the private property is not accessible from the test target. -/* struct MarkdownRenderingTests { - // Suggested extracted function to test: - // - // func renderMarkdown(text: String, isComplete: Bool) -> AttributedString { - // if isComplete { - // if let result = try? AttributedString( - // markdown: text, - // options: .init(interpretedSyntax: .full) - // ) { - // return result - // } - // } - // if let result = try? AttributedString( - // markdown: text, - // options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - // ) { - // return result - // } - // return AttributedString(text) - // } - @Test func completedMessageUsesFullMarkdown() { let result = renderMarkdown(text: "**bold** text", isComplete: true) // Full markdown should parse block-level elements (paragraphs, lists, etc.) @@ -249,4 +209,3 @@ struct MarkdownRenderingTests { #expect(text.contains("Item 2")) } } -*/ From f419e05d2dec8bab68dbd9fd6a4f795f32e5bd82 Mon Sep 17 00:00:00 2001 From: tk Date: Sun, 5 Apr 2026 20:59:16 -0700 Subject: [PATCH 3/3] Fix cross-platform documentation gaps - Add platform column to root sample table, include a2a-raw and Swift - Fix .NET SDK version: 8.0 -> 10.0, add Xcode to prerequisites - Add pre-obtained JWT examples to all .NET READMEs for macOS/Linux - Add explicit "WAM is Windows-only" notes to all .NET quick starts - Fix rest README: add missing --graph flag, --verbosity, --header params - Add Azure CLI install commands for all platforms (brew/winget/apt link) - Document Windows token cache path alongside Unix path in Rust README - Note NTFS vs Unix file permissions for cached tokens Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 43 +++++++++++++++++++--------------------- dotnet/a2a-raw/README.md | 10 ++++++---- dotnet/a2a/README.md | 9 +++++++-- dotnet/rest/README.md | 16 +++++++++++---- rust/a2a/README.md | 11 +++++++--- swift/a2a/README.md | 5 ++++- 6 files changed, 57 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index cd5b7a7..6937ba3 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,13 @@ Sample clients for the [Work IQ](https://learn.microsoft.com/en-us/microsoft-365/copilot/extensibility/workiq-overview) API — Microsoft's AI-native interface to Microsoft 365 work intelligence. -| Sample | Language | Protocol | Description | -|--------|----------|----------|-------------| -| [**dotnet/a2a/**](dotnet/a2a/) | C# | [A2A (Agent-to-Agent)](https://a2a-protocol.org) | Interactive agent session using the open A2A protocol over JSON-RPC | -| [**dotnet/rest/**](dotnet/rest/) | C# | REST | Interactive chat using the [Copilot Chat API](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/chat/overview) with sync and streaming modes | -| [**rust/a2a/**](rust/a2a/) | Rust | [A2A (Agent-to-Agent)](https://a2a-protocol.org) | Interactive agent session with device code auth, token caching, and SSE streaming | - -## Swift Samples (`swift/`) - -| Sample | Protocol | Description | -|--------|----------|-------------| -| [**swift/a2a/**](swift/a2a/) | [A2A (Agent-to-Agent)](https://a2a-protocol.org) | SwiftUI iOS/iPadOS chat app using A2A v0.3 with streaming responses | - -## Rust Samples (`rust/`) - -| Sample | Protocol | Description | -|--------|----------|-------------| -| [**rust/a2a/**](rust/a2a/) | [A2A (Agent-to-Agent)](https://a2a-protocol.org) | Interactive agent session with device code auth, token caching, and SSE streaming | +| Sample | Language | Platform | Protocol | Description | +|--------|----------|----------|----------|-------------| +| [**dotnet/a2a/**](dotnet/a2a/) | C# | Windows, macOS, Linux | [A2A](https://a2a-protocol.org) | Interactive agent session using the A2A protocol over JSON-RPC | +| [**dotnet/a2a-raw/**](dotnet/a2a-raw/) | C# | Windows, macOS, Linux | [A2A](https://a2a-protocol.org) | Same, but with raw `HttpClient` + JSON (no A2A SDK) | +| [**dotnet/rest/**](dotnet/rest/) | C# | Windows, macOS, Linux | REST | Interactive chat using the [Copilot Chat API](https://learn.microsoft.com/en-us/microsoft-365-copilot/extensibility/api/ai-services/chat/overview) | +| [**rust/a2a/**](rust/a2a/) | Rust | Windows, macOS, Linux | [A2A](https://a2a-protocol.org) | Interactive agent session with device code auth and token caching | +| [**swift/a2a/**](swift/a2a/) | Swift | iOS/iPadOS (macOS to build) | [A2A](https://a2a-protocol.org) | SwiftUI chat app with streaming responses | > **Current state**: Work IQ is accessed through the Microsoft Graph API at `graph.microsoft.com`. All samples use Graph endpoints and Graph authentication today. > @@ -54,12 +44,15 @@ After adding permissions, click **Grant admin consent for [your tenant]**. ### 3. Language-specific SDKs -- **dotnet/** samples: [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) or later +- **dotnet/** samples: [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later - **rust/** samples: [Rust toolchain](https://rustup.rs/) (stable) +- **swift/** samples: [Xcode 26+](https://developer.apple.com/xcode/) (macOS only) ## Authentication -### WAM (Windows Account Manager) — recommended on Windows (.NET only) +All samples support multiple authentication methods. Choose the one that fits your platform: + +### WAM (Windows Account Manager) — Windows only, .NET samples Uses the Windows broker for silent SSO. No browser popup for returning users. @@ -68,7 +61,11 @@ cd dotnet/a2a dotnet run -- --graph --token WAM --appid ``` -### Device code flow — any platform (Rust) +> **Note:** WAM is only available on Windows. On macOS and Linux, use a pre-obtained JWT token instead (see below). + +### Device code flow — all platforms (Rust, Swift) + +The Rust CLI and Swift app use device code flow, which works on any platform with a web browser. ```bash cd rust/a2a @@ -76,12 +73,12 @@ cargo run -- --appid # Follow the on-screen instructions to authenticate in a browser ``` -### Pre-obtained JWT token — any platform +### Pre-obtained JWT token — all platforms, all samples -Acquire a token externally (e.g., via [Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer), `az account get-access-token`, or your own MSAL code) and pass it directly: +Acquire a token externally (e.g., via [Graph Explorer](https://developer.microsoft.com/en-us/graph/graph-explorer), `az account get-access-token`, or your own MSAL code) and pass it directly. This works on Windows, macOS, and Linux. ```bash -# .NET +# .NET samples cd dotnet/a2a dotnet run -- --graph --token eyJ0eXAiOiJKV1Qi... diff --git a/dotnet/a2a-raw/README.md b/dotnet/a2a-raw/README.md index 8be7732..aa079e4 100644 --- a/dotnet/a2a-raw/README.md +++ b/dotnet/a2a-raw/README.md @@ -24,16 +24,18 @@ Use this sample when you want to understand the A2A protocol at the HTTP level, ```bash dotnet build -# Sync mode +# With a pre-obtained JWT token (any platform) +dotnet run -- --endpoint https://graph.microsoft.com/rp/workiq/ --token eyJ0eXAi... + +# With WAM broker auth (Windows only) dotnet run -- --endpoint https://graph.microsoft.com/rp/workiq/ --token WAM --appid # Streaming mode (SSE) dotnet run -- --endpoint https://graph.microsoft.com/rp/workiq/ --token WAM --appid --stream - -# With a pre-obtained token -dotnet run -- --endpoint https://graph.microsoft.com/rp/workiq/ --token eyJ0eXAi... ``` +> **macOS / Linux users:** WAM is only available on Windows. Use `--token ` with a pre-obtained token instead. See the [root README](../../README.md#authentication) for how to acquire a token. + ## Parameters | Flag | Description | diff --git a/dotnet/a2a/README.md b/dotnet/a2a/README.md index f887366..8ebaaed 100644 --- a/dotnet/a2a/README.md +++ b/dotnet/a2a/README.md @@ -17,16 +17,21 @@ The **Agent-to-Agent (A2A) Protocol** is an open standard for communication betw # Build dotnet build -# Run (sync mode) +# With a pre-obtained JWT token (any platform) +dotnet run -- --graph --token eyJ0eXAiOiJKV1Qi... + +# With WAM broker auth (Windows only) dotnet run -- --graph --token WAM --appid -# Run (streaming mode — SSE via message/stream) +# Streaming mode dotnet run -- --graph --token WAM --appid --stream # With account hint dotnet run -- --graph --token WAM --appid --account user@contoso.com ``` +> **macOS / Linux users:** WAM is only available on Windows. Use `--token ` with a pre-obtained token instead. See the [root README](../../README.md#authentication) for how to acquire a token. + ## Parameters | Flag | Description | diff --git a/dotnet/rest/README.md b/dotnet/rest/README.md index 348910b..7d2a9b3 100644 --- a/dotnet/rest/README.md +++ b/dotnet/rest/README.md @@ -20,25 +20,33 @@ Supports both **synchronous** and **streaming** (SSE) modes. # Build dotnet build -# Synchronous mode (default) -dotnet run -- --token WAM --appid +# With a pre-obtained JWT token (any platform) +dotnet run -- --graph --token eyJ0eXAiOiJKV1Qi... + +# With WAM broker auth (Windows only) +dotnet run -- --graph --token WAM --appid # Streaming mode (SSE) -dotnet run -- --token WAM --appid --stream +dotnet run -- --graph --token WAM --appid --stream # With account hint -dotnet run -- --token WAM --appid --account user@contoso.com +dotnet run -- --graph --token WAM --appid --account user@contoso.com ``` +> **macOS / Linux users:** WAM is only available on Windows. Use `--token ` with a pre-obtained token instead. See the [root README](../../README.md#authentication) for how to acquire a token. + ## Parameters | Flag | Description | |------|-------------| +| `--graph` | Use Microsoft Graph API (required) | | `--token`, `-t` | Bearer JWT token, or `WAM` for Windows broker auth | | `--appid`, `-a` | Azure AD app client ID (required with `--token WAM`) | | `--account` | Account hint for WAM (e.g. `user@contoso.com`) | | `--stream` | Use streaming mode (`/chatOverStream` with SSE) | | `--show-token` | Print the raw JWT after decoding | +| `-v`, `--verbosity` | `0` = response only, `1` = default, `2` = full wire diagnostics | +| `--header`, `-H` | Custom HTTP header in `Key: Value` format (repeatable) | ## How it works diff --git a/rust/a2a/README.md b/rust/a2a/README.md index 4409f2e..b7f9b42 100644 --- a/rust/a2a/README.md +++ b/rust/a2a/README.md @@ -29,7 +29,9 @@ cargo run # and enter the code XXXXXXXXX to authenticate. ``` -After authentication, your token is cached at `~/.workiq/token_cache.json` and refreshed automatically. +After authentication, your token is cached locally and refreshed automatically: +- **macOS / Linux:** `~/.workiq/token_cache.json` +- **Windows:** `%USERPROFILE%\.workiq\token_cache.json` ## Usage @@ -93,7 +95,9 @@ A default app ID is included for convenience. To register your own: ### macOS / Linux ```bash -# Requires Azure CLI — install via: brew install azure-cli +# Requires Azure CLI +# macOS: brew install azure-cli +# Linux: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux az login ./setup-app-registration.sh ``` @@ -102,6 +106,7 @@ az login ```powershell # Requires Azure CLI +# Install: winget install Microsoft.AzureCLI az login .\setup-app-registration.ps1 ``` @@ -126,7 +131,7 @@ src/ 2. **Silent refresh** — exchange refresh token for a new access token 3. **Device code flow** — interactive login as a fallback -Tokens are cached at `~/.workiq/token_cache.json` with `0600` permissions. +Tokens are cached at `~/.workiq/token_cache.json` (on Unix, file permissions are set to `0600`; on Windows, the file relies on user-level NTFS permissions). ## License diff --git a/swift/a2a/README.md b/swift/a2a/README.md index da836e9..184301f 100644 --- a/swift/a2a/README.md +++ b/swift/a2a/README.md @@ -25,7 +25,9 @@ The included scripts automate app registration. Pick whichever matches your envi ### macOS / Linux ```bash -# Requires Azure CLI — install via: brew install azure-cli +# Requires Azure CLI +# macOS: brew install azure-cli +# Linux: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli-linux az login ./setup-app-registration.sh ``` @@ -34,6 +36,7 @@ az login ```powershell # Requires Azure CLI +# Install: winget install Microsoft.AzureCLI az login .\setup-app-registration.ps1 ```