From 82d35d36d86a86790147d08f3e2d233e2e36a750 Mon Sep 17 00:00:00 2001 From: Bryan Bennett Date: Thu, 16 Jul 2026 12:30:30 -0400 Subject: [PATCH 1/3] initial checkin --- Localize/lang/strings.json | 52 --- .../openDesignerForLocalProject.ts | 36 +++ .../__test__/getAuthorizationToken.test.ts | 144 +++++++++ .../codeless/__test__/saveWorkflow.test.ts | 232 ++++++++++++++ .../__test__/designerCommandBarSave.spec.tsx | 303 ++++++++++++++++++ apps/vs-code-react/src/app/designer/appV2.tsx | 6 +- apps/vs-code-react/src/state/DesignerSlice.ts | 6 + .../src/webviewCommunication.tsx | 2 + eslint.config.mjs | 1 + .../core/actions/bjsworkflow/serializer.ts | 14 +- .../core/actions/bjsworkflow/serializer.ts | 14 +- .../lib/services/__test__/httpClient.spec.ts | 130 ++++++++ .../src/lib/services/httpClient.ts | 8 + 13 files changed, 874 insertions(+), 74 deletions(-) create mode 100644 apps/vs-code-designer/src/app/utils/codeless/__test__/getAuthorizationToken.test.ts create mode 100644 apps/vs-code-designer/src/app/utils/codeless/__test__/saveWorkflow.test.ts create mode 100644 apps/vs-code-react/src/app/designer/__test__/designerCommandBarSave.spec.tsx diff --git a/Localize/lang/strings.json b/Localize/lang/strings.json index 3a382abf132..b8356c3a7a1 100644 --- a/Localize/lang/strings.json +++ b/Localize/lang/strings.json @@ -39,7 +39,6 @@ "+l5XmZ": "Enter a positive integer between {min} and {max}", "+mAJR3": "(UTC+08:00) Kuala Lumpur, Singapore", "+mNJQl": "Create a logic app", - "+nCfrr": "Delete assertion", "+nKZHB": "Can't upload file. Please try again. Error: {errorMessage}", "+oX/2M": "Add actions to define the steps in your workflow", "+oelX4": "Required. The string to examine.", @@ -386,7 +385,6 @@ "5R1r3q": "Create a new connection", "5SAQOb": "Authority", "5Tqzsm": "Categorization", - "5U6Dee": "Action Result", "5Vbd0e": "Select a knowledge hub", "5VhY2X": "Loading models...", "5WTRY8": "Errors", @@ -427,7 +425,6 @@ "6776lH": "Processing...", "67FI5P": "Integration service environment", "68UJHa": "This list shows the new resources to create for your logic app and existing resources if any.", - "69+CIW": "View workflow", "6ASWgB": "Confirm that you want to delete this MCP server group? You can't undo this action.", "6D5fAm": "Trigger", "6DZp5H": "Search", @@ -500,7 +497,6 @@ "7MP7FJ": "How do values resolve at runtime?", "7MbFEx": "Select a connection", "7N+Zcl": "App Service Plan", - "7PTnxD": "New assertion", "7PtWvu": "(UTC-05:00) Eastern Time (US & Canada)", "7QymrD": "Required. The string from which the substring is taken.", "7ScdN6": "Name", @@ -534,7 +530,6 @@ "8+0teU": "Parameter name already exists.", "8+TVCG": "Edit: {selectedOperationSummary}", "8/Vjz3": "Knowledge base", - "805nCJ": "Error Message", "80T6Ut": "The deployment is taking longer than expected. Please check the Azure portal for more details.", "80z7j2": "Key-based authentication", "810OUB": "When triggered by another workflow", @@ -719,7 +714,6 @@ "BYrP8F": "Number", "BYsNzz": "Your template has been unpublished.", "Bewmet": "Array", - "BfGFkk": "Test icon", "Bft/H3": "All the benefits of Stateful, plus the option to build AI agents in your workflow to automate complex tasks.", "BjrVzW": "Resource group", "Bkc/+3": "Retry policy minimum interval is invalid, must match ISO 8601 duration format", @@ -914,7 +908,6 @@ "FTrMxN": "Close", "FUXlzD": "Run ID: {id}", "FUuFlC": "Default", - "FUvA4o": "Must provide a condition expression.", "FXLR5M": "{hours, plural, one {# hour} other {# hours}}", "FYtDP0": "Enter an email address.", "Fcvgvg": "Search", @@ -950,7 +943,6 @@ "GQnN3U": "Explain flow", "GR5+k2": "AI and machine learning capabilities", "GSzT3T": "Refresh", - "GX3fkR": "New assertion", "GXFvm+": "To enable chat client for production, setup authentication.", "GYu5XE": "Failed to load run details", "GYui13": "Continue editing", @@ -993,7 +985,6 @@ "H9pzpO": "Deleted the following hub artifacts:", "HDqP2g": "Required. The key name of the form data value to return.", "HET2nV": "Add a Variable", - "HF2SNx": "Is successful", "HF4xfS": "Enter agent name...", "HFt+tF": "Favorites", "HH970i": "Month", @@ -1171,7 +1162,6 @@ "LBlM+D": "Not specified", "LCRHQ9": "(UTC+12:00) Fiji", "LElaX3": "Next flow suggestion", - "LG7hSo": "Assertions", "LGUiVk": "Public access", "LLJrOT": "Description", "LMB8am": "Creating...", @@ -1190,7 +1180,6 @@ "LZm3ze": "Add a parallel branch", "LaFlFh": "Removed this action", "Ld62T8": "Delete", - "LdBN0m": "Assertion name", "LdITnG": "(UTC-03:00) Cayenne, Fortaleza", "LeR+TX": "Zoom in", "Lft/is": "Add new", @@ -1408,7 +1397,6 @@ "Peg6ZT": "Setting errors", "Pehyf2": "Enter agent instructions...", "PfCJlN": "Workflow functions", - "PhBS5+": "Enter name", "PjBcG3": "(UTC+02:00) Amman", "Pk4cb+": "Tracked properties", "Pnt0Xj": "Explain how to receive files from SFTP server.", @@ -1636,7 +1624,6 @@ "U3iWVd": "Generates an array of integers starting from a certain number", "U4zovj": "Runs {onTime} {onDays}", "U6V60S": "Validation failed :", - "U7UAV0": "Mocked Results Tab", "U82s8v": "Select a subscription, resource group and Logic App instance to find the workflows you want to convert to templates. Your changes apply only to this template and won't affect the original workflows.", "U9SHxw": "Code", "UCNM4L": "To reference a parameter, use the dynamic content list.", @@ -1728,7 +1715,6 @@ "VbMYd8": "Triggers tell your app when to start running. Each workflow needs at least one trigger.", "VchR9d": "Headers", "Vecdzb": "Logic app details", - "VfUtlo": "Save unit test definition", "Vi5TIV": "No warnings found.", "ViOMjt": "Use the chat client to talk to your agent.", "VjvWve": "Microsoft Authored", @@ -2003,7 +1989,6 @@ "_+l5XmZ.comment": "description of maximum waiting runs setting", "_+mAJR3.comment": "Time zone value ", "_+mNJQl.comment": "Create new logic app link", - "_+nCfrr.comment": "Create Assertion Text", "_+nKZHB.comment": "Error message when file upload fails in add files panel", "_+oX/2M.comment": "Description of what Actions are, on a tooltip about Actions", "_+oelX4.comment": "Required string parameter to check if is integer using isInt function", @@ -2350,7 +2335,6 @@ "_5R1r3q.comment": "Button text to create a new connection", "_5SAQOb.comment": "Authority Label Display Name", "_5Tqzsm.comment": "Categorization section title", - "_5U6Dee.comment": "The label for the action result dropdown in the unit test panel.", "_5Vbd0e.comment": "Placeholder text for knowledge hub dropdown", "_5VhY2X.comment": "Placeholder while models load for create form", "_5WTRY8.comment": "The tab label for the errors tab on the errors panel", @@ -2391,7 +2375,6 @@ "_6776lH.comment": "Processing message in the chatbot", "_67FI5P.comment": "ISE divider label", "_68UJHa.comment": "The aria label for the resources table", - "_69+CIW.comment": "View workflow button text", "_6ASWgB.comment": "Content for the delete MCP server group modal", "_6D5fAm.comment": "Tag for trigger operations", "_6DZp5H.comment": "Placeholder text for search connectors", @@ -2464,7 +2447,6 @@ "_7MP7FJ.comment": "Link text for learning more about tool parameters", "_7MbFEx.comment": "Title for step 1 - connection selection", "_7N+Zcl.comment": "The type for app service plan resource", - "_7PTnxD.comment": "Heading title for an assertion without name", "_7PtWvu.comment": "Time zone value ", "_7QymrD.comment": "Required string parameter required to obtain substring", "_7ScdN6.comment": "Deployment model resource name label", @@ -2498,7 +2480,6 @@ "_8+0teU.comment": "Error message when the workflow parameter name already exists.", "_8+TVCG.comment": "Title for edit operation panel", "_8/Vjz3.comment": "Title for knowledge hub editor", - "_805nCJ.comment": "Label for the error message input field", "_80T6Ut.comment": "Error message shown when deployment times out", "_80z7j2.comment": "Description for key-based authentication", "_810OUB.comment": "Workflow execution trigger category", @@ -2683,7 +2664,6 @@ "_BYrP8F.comment": "Placeholder title for a newly inserted Number parameter", "_BYsNzz.comment": "Title for the toaster after unpublishing template.", "_Bewmet.comment": "Title for array dropdown input setting", - "_BfGFkk.comment": "Test icon aria label", "_Bft/H3.comment": "Autonomous agents workflow description", "_BjrVzW.comment": "Label for choosing resource group", "_Bkc/+3.comment": "error message for invalid minimum retry interval", @@ -2878,7 +2858,6 @@ "_FTrMxN.comment": "Button text for closing the panel", "_FUXlzD.comment": "Label for the run ID", "_FUuFlC.comment": "Default, the backup option if none other apply", - "_FUvA4o.comment": "Error message when the assertion condition expression is empty.", "_FXLR5M.comment": "A duration of time shown in hours", "_FYtDP0.comment": "The placeholder description for a newly inserted 'Email' parameter.", "_Fcvgvg.comment": "Default placeholder for search box that searches dropdown options", @@ -2914,7 +2893,6 @@ "_GQnN3U.comment": "Chatbot prompt to explain the flow", "_GR5+k2.comment": "AI Agent category description", "_GSzT3T.comment": "Refresh button text to reload agent details", - "_GX3fkR.comment": "New Assertion Text", "_GXFvm+.comment": "Option 2 description when auth is not enabled", "_GYu5XE.comment": "Error message title when a single run fails to load", "_GYui13.comment": "Button text for closing the delete hub artifacts modal", @@ -2957,7 +2935,6 @@ "_H9pzpO.comment": "Content for the toaster after successfully deleting hub artifacts, with the names of the deleted artifacts", "_HDqP2g.comment": "Required string parameter to be used as key for triggerFormDataValue function", "_HET2nV.comment": "label to add a variable", - "_HF2SNx.comment": "Successful action result", "_HF4xfS.comment": "Placeholder for agent name input", "_HFt+tF.comment": "Favorites category", "_HH970i.comment": "Frequency value ", @@ -3135,7 +3112,6 @@ "_LBlM+D.comment": "The status message to show not specified in monitoring view.", "_LCRHQ9.comment": "Time zone value ", "_LElaX3.comment": "Text for button that shows the next flow suggestion", - "_LG7hSo.comment": "Unit test assertions button", "_LGUiVk.comment": "Label for the public access field", "_LLJrOT.comment": "Label for the operation description field", "_LMB8am.comment": "Button text to show a connection is being created", @@ -3154,7 +3130,6 @@ "_LZm3ze.comment": "Text for button to add a parallel branch", "_LaFlFh.comment": "Chatbot removed operation sentence format", "_Ld62T8.comment": "Button text for deleting selected workflows", - "_LdBN0m.comment": "Assertion field name title", "_LdITnG.comment": "Time zone value ", "_LeR+TX.comment": "Aria label for a button that zooms in on the workflow", "_Lft/is.comment": "Button to add a new connection", @@ -3372,7 +3347,6 @@ "_Peg6ZT.comment": "Header for the setting errors subsection", "_Pehyf2.comment": "Placeholder for instructions textarea", "_PfCJlN.comment": "Label for workflow functions", - "_PhBS5+.comment": "Assertion field name placeholder", "_PjBcG3.comment": "Time zone value ", "_Pk4cb+.comment": "title for tracked properties setting", "_Pnt0Xj.comment": "Chatbot suggestion message to recieve specific files from SFTP server", @@ -3600,7 +3574,6 @@ "_U3iWVd.comment": "Label for description of custom range Function", "_U4zovj.comment": "Recurrence schedule description on days of week at times", "_U6V60S.comment": "Error message title for workflow validation errors", - "_U7UAV0.comment": "An accessibility label that describes the mocked results tab", "_U82s8v.comment": "Label for the logic app resource selection description", "_U9SHxw.comment": "Code view title", "_UCNM4L.comment": "Description for Workflow Parameters Part 2", @@ -3692,7 +3665,6 @@ "_VbMYd8.comment": "Description of what Triggers are, on a tooltip about Triggers", "_VchR9d.comment": "Headers", "_Vecdzb.comment": "Logic app details step title", - "_VfUtlo.comment": "Save unit test button", "_Vi5TIV.comment": "Text to show when no warnings exist", "_ViOMjt.comment": "Option 2 description when auth is enabled", "_VjvWve.comment": "Label text for Microsoft authored templates tab", @@ -3950,7 +3922,6 @@ "_aSnCCB.comment": "Header for resource lcoation", "_aV/nLS.comment": "Frequency value ", "_aWcxdZ.comment": "Label text custom templates tab", - "_aWkG01.comment": "Unsupported message for mock results tab", "_aYTy7X.comment": "The status message to show in monitoring view.", "_aZtqSZ.comment": "Default error message for deployment model resource creation", "_afCjXx.comment": "Custom time range start label", @@ -4079,7 +4050,6 @@ "_cwHxwb.comment": "Text for create connection button", "_cx1uqu.comment": "Label for wrapping selected actions in a scope", "_cyEBus.comment": "Time zone value ", - "_cySYfO.comment": "Failed action result", "_d020eg.comment": "Label for description of custom first Function", "_d0BKdI.comment": "Message displayed when search returns no results", "_d0wgcV.comment": "Indicates that the run is currently waiting", @@ -4130,7 +4100,6 @@ "_dsz+Ae.comment": "Label for description of custom split Function", "_duhwio.comment": "Label for the API key", "_dwrqEc.comment": "Warnings section title", - "_dzPAxq.comment": "Edit Assertion Text", "_e+GuGo.comment": "Placeholder title for a newly inserted Text parameter", "_e00zot.comment": "Recurrence parameter group title", "_e1+Gqi.comment": "Description for resource location section.", @@ -4256,7 +4225,6 @@ "_gWNQQQ.comment": "Title for the resource selection section", "_gWyYg0.comment": "Time zone value ", "_gYaVvl.comment": "Error validation message for floats", - "_gZRdSs.comment": "Label for the error code input field", "_gaHI0k.comment": "Message to show when 0 or more than 2 items are present in the overflow menu", "_gc6v4X.comment": "Text for system assigned managed identity", "_gcn3Jg.comment": "Placeholder for the group description input field", @@ -4316,7 +4284,6 @@ "_hRVVdR.comment": "Chatbot undo button for undoing assistant change to flow", "_hTjAB+.comment": "Error message when tool name exceeds maximum characters", "_hUnLzB.comment": "Chatbot progress message shown while generating the final workflow from connector results", - "_hW7oe7.comment": "Assertion field description placeholder", "_hZqQdt.comment": "Time zone value ", "_haeWoU.comment": "Error message when splitOn cannot be evaluated", "_hbOvB4.comment": "Dislike button text for suggested flow", @@ -4469,7 +4436,6 @@ "_kM+Mr0.comment": "Loading text when items are being fetched", "_kN6kce.comment": "Label for description of custom decodeBase64 Function", "_kSK9Pq.comment": "Time zone value ", - "_kSXjTx.comment": "Assertion field no description text", "_kU4VfD.comment": "Choice group first choice: Stateful Type", "_kVwJXt.comment": "Time zone value ", "_kXn5e0.comment": "Chabot input placeholder text", @@ -4498,7 +4464,6 @@ "_kxv92S.comment": "Confirmation message for deleting a server", "_l/3yJr.comment": "Text to show when there is an error with the connection", "_l/9YHQ.comment": "Time zone value ", - "_l0hO5f.comment": "Error message when the workflow assertion name already exists.", "_l2YXln.comment": "Chatbot prompt to replace an action", "_l36V56.comment": "This is a time duration in full non abbreviated format", "_l3nn+9.comment": "Required field label", @@ -4511,7 +4476,6 @@ "_l9TY/4.comment": "ARIA label and tooltip text for the popup button", "_lA/sHA.comment": "Accessibility label for a button to copy all text in a value box", "_lB56l2.comment": "Error validation message for Numbers", - "_lC+EbT.comment": "The tab label for the mocked results tab on the operation panel", "_lFWXhc.comment": "The tab label for the monitoring parameters tab on the operation panel", "_lIVS+K.comment": "Name of the organization or developer that published this template", "_lK+Vzo.comment": "This is an option in a dropdown where users can select type Secure String for their parameter.", @@ -4679,7 +4643,6 @@ "_oBK3A4.comment": "Accessible label for editable expression token", "_oChTO9.comment": "Accessibility label for the select workflow row checkbox", "_oDHXKh.comment": "Display name for item output", - "_oFq3ng.comment": "Assertions Panel Title", "_oGINHJ.comment": "Workflow version filter label", "_oIRKrF.comment": "Text to show no connections present in the template.", "_oJebOR.comment": "Button text displayed while saving authentication settings", @@ -4863,7 +4826,6 @@ "_s0Qaot.comment": "Workflow Parameters Panel", "_s2f0XK.comment": "Content for the toaster after creating a group in add files panel", "_s2ydQX.comment": "Error validation message for invalid JSON object. Do not remove the double single quotes around the display name, as it is needed to wrap the placeholder text.", - "_s4omwa.comment": "Assertion field description title", "_s5AOpV.comment": "Label for the title for panel header card", "_s5RV9B.comment": "Label for description of custom dayOfMonth Function", "_s5jHO7.comment": "Label for agent name input", @@ -4948,7 +4910,6 @@ "_tEqgJQ.comment": "Delete button disabled tooltip text", "_tGSsgZ.comment": "Placehodler text for dropdown", "_tH2pT1.comment": "Text for the Details page navigation heading", - "_tHDcfJ.comment": "Error message when the assertion name is empty.", "_tHFp5l.comment": "Title for the main section with the count of tools", "_tIl0ss.comment": "Title for YouTube video iframe", "_tImHz/.comment": "This is a time duration in abbreviated format", @@ -5033,7 +4994,6 @@ "_uh/+ZN.comment": "Time zone value ", "_ui3KuP.comment": "Header for the input errors category", "_ui7GCl.comment": "Hour of the day", - "_ujp53j.comment": "Assertion field condition title", "_ukB9Bs.comment": "Setup section description in info dialog", "_ukGRNP.comment": "Message shown when parallel branch addition is disabled on root in A2A workflows", "_umLmPm.comment": "Operation Panel", @@ -5330,7 +5290,6 @@ "aSnCCB": "Location", "aV/nLS": "Second", "aWcxdZ": "My Templates", - "aWkG01": "This operation does not support mocking. Mocking is only supported for operations that are connected to a service provider, function, API connection, or API Management.", "aYTy7X": "Cancelled", "aZtqSZ": "An error occurred while creating the deployment model resource.", "afCjXx": "Start", @@ -5459,7 +5418,6 @@ "cwHxwb": "Add connection", "cx1uqu": "Scope", "cyEBus": "(UTC-04:00) Cuiaba", - "cySYfO": "Has failed", "d020eg": "Returns the first element from the passed-in array or string.", "d0BKdI": "Search can't find any results.", "d0wgcV": "Waiting", @@ -5510,7 +5468,6 @@ "dsz+Ae": "Splits the string using a separator", "duhwio": "API key", "dwrqEc": "Warnings", - "dzPAxq": "Edit assertion", "e+GuGo": "Input", "e00zot": "How often do you want to check for items?", "e1+Gqi": "Select the resource location for your workflow", @@ -5636,7 +5593,6 @@ "gWNQQQ": "Project details", "gWyYg0": "(UTC+05:00) Ashgabat, Tashkent", "gYaVvl": "Enter a valid float.", - "gZRdSs": "Error Code", "gaHI0k": "{overflowItemsLength} more items", "gc6v4X": "System-assigned managed identity", "gcn3Jg": "Enter a description", @@ -5696,7 +5652,6 @@ "hRVVdR": "Undo", "hTjAB+": "Tool name cannot exceed maximum characters", "hUnLzB": "Building workflow...", - "hW7oe7": "Enter description", "hZqQdt": "(UTC+02:00) Gaza, Hebron", "haeWoU": "Failed to evaluate outputs because splitOn {splitOn} cannot be evaluated. As a result, this operation's outputs might not be correctly visible in subsequent actions", "hbOvB4": "This isn't what I'm looking for", @@ -5849,7 +5804,6 @@ "kM+Mr0": "Loading...", "kN6kce": "Returns a string representation of an input based64 string", "kSK9Pq": "(UTC-05:00) Bogota, Lima, Quito, Rio Branco", - "kSXjTx": "No description", "kU4VfD": "Stateful", "kVwJXt": "(UTC+04:00) Abu Dhabi, Muscat", "kXn5e0": "Ask a question about this workflow or about Azure Logic Apps as a whole ...", @@ -5878,7 +5832,6 @@ "kxv92S": "Successfully deleted MCP server: ''{serverName}''.", "l/3yJr": "Invalid connection", "l/9YHQ": "(UTC+01:00) Windhoek", - "l0hO5f": "Assertion name already exists.", "l2YXln": "Replace action with", "l36V56": "{hours} hours {minutes} minutes", "l3nn+9": "Required field", @@ -5891,7 +5844,6 @@ "l9TY/4": "Open in popup", "lA/sHA": "Copy the value of ''{label}'' to the clipboard", "lB56l2": "Enter a valid number.", - "lC+EbT": "Mocked Results", "lFWXhc": "Workflow", "lIVS+K": "By", "lK+Vzo": "Secure string", @@ -6059,7 +6011,6 @@ "oBK3A4": "Edit {tokenTitle} expression", "oChTO9": "Select workflow row checkbox label", "oDHXKh": "Item", - "oFq3ng": "Assertions", "oGINHJ": "Version", "oIRKrF": "No connections in this template", "oJebOR": "Saving...", @@ -6243,7 +6194,6 @@ "s0Qaot": "Workflow Parameters Panel", "s2f0XK": "Group {name} has been created and selected.", "s2ydQX": "''Value'' must be a valid JSON object", - "s4omwa": "Description", "s5AOpV": "Card title", "s5RV9B": "Returns the day of year component of a string timestamp", "s5jHO7": "Agent name", @@ -6328,7 +6278,6 @@ "tEqgJQ": "Cannot delete the last parameter", "tGSsgZ": "select an identity", "tH2pT1": "Return to search", - "tHDcfJ": "Must provide the assertion name.", "tHFp5l": "Tools", "tIl0ss": "YouTube video player", "tImHz/": "{days}d {hours}h", @@ -6413,7 +6362,6 @@ "uh/+ZN": "(UTC-03:00) Greenland", "ui3KuP": "Operation errors", "ui7GCl": "16", - "ujp53j": "Condition expression", "ukB9Bs": "To enable authentication for production use, follow the authentication setup guide.", "ukGRNP": "Cannot add parallel branches on the root level in agent to agent workflows", "umLmPm": "Operation Panel", diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts index 5e97a1cebcf..c04b4e17502 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts @@ -27,6 +27,7 @@ import { saveConnectionReferences, saveCustomCodeStandard, } from '../../../utils/codeless/connection'; +import { getAuthorizationToken } from '../../../utils/codeless/getAuthorizationToken'; import { saveWorkflowParameter } from '../../../utils/codeless/parameter'; import { startDesignTimeApi } from '../../../utils/codeless/startDesignTimeApi'; import { sendRequest } from '../../../utils/requestUtils'; @@ -58,6 +59,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { private projectPath: string | undefined; private panelMetadata: IDesignerPanelMetadata; private workflowRuntimeBaseUrlInterval: NodeJS.Timeout; + private accessTokenInterval: NodeJS.Timeout; private getWorkflowRuntimeBaseUrl: () => string | undefined; constructor(context: IActionContext, node: Uri, runId?: string) { @@ -181,6 +183,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { this.panel.onDidDispose( () => { clearInterval(this.workflowRuntimeBaseUrlInterval); + clearInterval(this.accessTokenInterval); removeWebviewPanelFromCache(this.panelGroupKey, this.panelName); }, null, @@ -210,6 +213,25 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { } }, 3000); + // Refresh access token periodically to prevent stale-token failures on save + this.accessTokenInterval = setInterval(async () => { + try { + const tenantId = this.panelMetadata.azureDetails?.tenantId; + const updatedAccessToken = await getAuthorizationToken(tenantId); + if (updatedAccessToken !== this.panelMetadata.accessToken) { + this.panelMetadata.accessToken = updatedAccessToken; + this.panel.webview.postMessage({ + command: ExtensionCommand.update_access_token, + data: { + accessToken: updatedAccessToken, + }, + }); + } + } catch { + // Silently ignore token refresh failures — the existing token may still be valid + } + }, 5000); + this.sendMsgToWebview({ command: ExtensionCommand.initialize_frame, data: { @@ -586,6 +608,10 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { /** * Merges parameters from JSON. + * For parameters that exist only in the file (not in the designer output or panel), + * they are preserved as-is. For parameters that exist in both the file and designer, + * file-only properties (e.g., metadata, description) are preserved while designer + * properties take precedence. * @param filePath The file path of the parameters JSON file. * @param definitionParameters The parameters from the designer. * @param panelParameterRecord The parameters from the panel @@ -600,7 +626,17 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { Object.entries(jsonParameters).forEach(([key, parameter]) => { if (!definitionParameters[key] && !panelParameterRecord[key]) { + // Parameter exists only in the file — preserve it entirely definitionParameters[key] = parameter; + } else if (definitionParameters[key]) { + // Parameter exists in both — preserve file-only properties that the designer doesn't emit + const fileParam = parameter as Record; + const defParam = definitionParameters[key] as Record; + for (const prop of Object.keys(fileParam)) { + if (!(prop in defParam)) { + defParam[prop] = fileParam[prop]; + } + } } }); } diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/getAuthorizationToken.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/getAuthorizationToken.test.ts new file mode 100644 index 00000000000..e1dac31eb01 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/getAuthorizationToken.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getAuthorizationToken, getAuthorizationTokenFromNode, getCloudHost } from '../getAuthorizationToken'; + +// The module-level mock for '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode' +// is aliased via vitest.config.ts to '__mocks__/vscode-azext-azureauth.ts'. +// We import and spy on it to control return values per test. +import * as azureAuth from '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode'; +import * as vscode from 'vscode'; + +vi.mock('@microsoft/vscode-azext-azureauth', () => ({ + getConfiguredAzureEnv: vi.fn(() => ({ + managementEndpointUrl: 'https://management.azure.com', + })), +})); + +describe('getAuthorizationToken', () => { + beforeEach(() => { + vi.restoreAllMocks(); + // Mock vscode.workspace.getConfiguration to return a config with get() + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn(() => false), + } as any); + }); + + it('should return a Bearer token when session has an accessToken', async () => { + vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({ + accessToken: 'test-token-123', + id: 'session-1', + account: { id: 'account-1', label: 'Test' }, + scopes: [], + } as any); + + const token = await getAuthorizationToken('test-tenant'); + expect(token).toBe('Bearer test-token-123'); + }); + + it('should return "Bearer undefined" when session returns no accessToken', async () => { + vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({ + id: 'session-1', + account: { id: 'account-1', label: 'Test' }, + scopes: [], + } as any); + + const token = await getAuthorizationToken(); + expect(token).toBe('Bearer undefined'); + }); + + it('should propagate errors when session acquisition fails', async () => { + vi.spyOn(azureAuth, 'getSessionFromVSCode').mockRejectedValue(new Error('Auth session expired')); + + await expect(getAuthorizationToken()).rejects.toThrow('Auth session expired'); + }); + + it('should pass tenantId to getSessionFromVSCode', async () => { + const spy = vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({ + accessToken: 'tenant-token', + id: 'session-1', + account: { id: 'account-1', label: 'Test' }, + scopes: [], + } as any); + + await getAuthorizationToken('specific-tenant-id'); + expect(spy).toHaveBeenCalledWith(undefined, 'specific-tenant-id', expect.any(Object)); + }); +}); + +describe('getAuthorizationTokenFromNode', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn(() => false), + } as any); + }); + + it('should throw when node is null/undefined', async () => { + await expect(getAuthorizationTokenFromNode(null as any)).rejects.toThrow(); + }); + + it('should throw when node has no subscription', async () => { + const node = {} as any; + await expect(getAuthorizationTokenFromNode(node)).rejects.toThrow(); + }); + + it('should return Bearer token from node subscription credentials', async () => { + const node = { + subscription: { + tenantId: 'tenant-1', + credentials: { + getToken: vi.fn().mockResolvedValue({ token: 'node-token-abc' }), + }, + }, + } as any; + + const token = await getAuthorizationTokenFromNode(node); + expect(token).toBe('Bearer node-token-abc'); + }); + + it('should fall back to getAuthorizationToken when credentials.getToken returns null', async () => { + vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({ + accessToken: 'fallback-token', + id: 'session-1', + account: { id: 'account-1', label: 'Test' }, + scopes: [], + } as any); + + const node = { + subscription: { + tenantId: 'tenant-1', + credentials: { + getToken: vi.fn().mockResolvedValue(null), + }, + }, + } as any; + + const token = await getAuthorizationTokenFromNode(node); + expect(token).toBe('Bearer fallback-token'); + }); + + it('should fall back to getAuthorizationToken when no credentials exist', async () => { + vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({ + accessToken: 'fallback-token-2', + id: 'session-1', + account: { id: 'account-1', label: 'Test' }, + scopes: [], + } as any); + + const node = { + subscription: { + tenantId: 'tenant-2', + credentials: undefined, + }, + } as any; + + const token = await getAuthorizationTokenFromNode(node); + expect(token).toBe('Bearer fallback-token-2'); + }); +}); + +describe('getCloudHost', () => { + it('should return the managementEndpointUrl from configured environment', async () => { + const host = await getCloudHost(); + expect(host).toBe('https://management.azure.com'); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/saveWorkflow.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/saveWorkflow.test.ts new file mode 100644 index 00000000000..6891dfbd463 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/saveWorkflow.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { writeFileSync } from 'fs'; + +/** + * Tests for workflow save logic as used by OpenDesignerForLocalProject.saveWorkflow(). + * These focus on the serialization, error handling, and concurrency aspects + * without requiring the full extension host environment. + */ + +describe('workflow save - definition serialization', () => { + it('should preserve workflow structure when saving definition', () => { + const workflow = { + definition: { + $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', + actions: { action1: { type: 'Http' } }, + triggers: { manual: { type: 'Request' } }, + }, + }; + + const newDefinition = { + ...workflow.definition, + actions: { + ...workflow.definition.actions, + action2: { type: 'Response' }, + }, + }; + + workflow.definition = newDefinition; + const serialized = JSON.stringify(workflow, null, 4); + const parsed = JSON.parse(serialized); + + expect(parsed.definition.actions.action1).toEqual({ type: 'Http' }); + expect(parsed.definition.actions.action2).toEqual({ type: 'Response' }); + expect(parsed.definition.triggers.manual).toEqual({ type: 'Request' }); + }); + + it('should handle undefined definition in workflow JSON', () => { + // Simulates what happens in the saveWorkflow method when definition is undefined + const workflow = { definition: undefined }; + + // The actual code does: writeFileSync(filePath, JSON.stringify(workflow, null, 4)) + // Verify that JSON.stringify handles undefined definition gracefully + const serialized = JSON.stringify(workflow, null, 4); + expect(serialized).toBeDefined(); + expect(JSON.parse(serialized)).toEqual({}); + }); + + it('should handle null values in definition', () => { + const workflow = { + definition: { + actions: { step1: { type: 'Http', inputs: null } }, + triggers: {}, + }, + }; + const serialized = JSON.stringify(workflow, null, 4); + const parsed = JSON.parse(serialized); + expect(parsed.definition.actions.step1.inputs).toBeNull(); + }); + + it('should produce valid 4-space indented JSON', () => { + const workflow = { definition: { actions: {} } }; + const serialized = JSON.stringify(workflow, null, 4); + const lines = serialized.split('\n'); + // Second line should be indented by 4 spaces + expect(lines[1]).toMatch(/^ {4}/); + }); +}); + +describe('workflow save - writeFileSync behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call writeFileSync with correct path and serialized content', () => { + const filePath = '/project/workflow/workflow.json'; + const workflow = { definition: { actions: {} } }; + const expected = JSON.stringify(workflow, null, 4); + + writeFileSync(filePath, expected); + + expect(writeFileSync).toHaveBeenCalledWith(filePath, expected); + }); + + it('should throw when writeFileSync fails (simulating disk full)', () => { + vi.mocked(writeFileSync).mockImplementation(() => { + throw new Error('ENOSPC: no space left on device'); + }); + + const filePath = '/project/workflow/workflow.json'; + const workflow = { definition: { actions: {} } }; + + expect(() => writeFileSync(filePath, JSON.stringify(workflow, null, 4))).toThrow('ENOSPC'); + }); + + it('should not leave partial data when write fails', () => { + // writeFileSync is atomic at the OS level - it either succeeds or fails. + // This test verifies the error message propagation pattern used in saveWorkflow: + // catch (error) { const errorMessage = error instanceof Error ? error.message : ... } + const error = new Error('Permission denied'); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + expect(errorMessage).toBe('Permission denied'); + }); +}); + +describe('workflow save - concurrent saves', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should handle concurrent save attempts without data corruption', () => { + const writes: string[] = []; + vi.mocked(writeFileSync).mockImplementation((_path: any, content: any) => { + writes.push(content); + }); + + const workflow1 = { definition: { actions: { step1: { type: 'Http' } } } }; + const workflow2 = { definition: { actions: { step2: { type: 'Response' } } } }; + + // Simulate two concurrent saves + writeFileSync('/test/workflow.json', JSON.stringify(workflow1, null, 4)); + writeFileSync('/test/workflow.json', JSON.stringify(workflow2, null, 4)); + + // Last write wins — verify no data mixing + const lastWrite = JSON.parse(writes[writes.length - 1]); + expect(lastWrite.definition.actions).toHaveProperty('step2'); + expect(lastWrite.definition.actions).not.toHaveProperty('step1'); + }); +}); + +describe('workflow save - error message extraction', () => { + it('should extract message from Error instances', () => { + const error = new Error('Connection timeout'); + const errorMessage = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'; + expect(errorMessage).toBe('Connection timeout'); + }); + + it('should use string errors directly', () => { + const error = 'Auth token expired'; + const errorMessage = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'; + expect(errorMessage).toBe('Auth token expired'); + }); + + it('should fall back to Unknown error for non-string/Error types', () => { + const error = { code: 500 }; + const errorMessage = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'; + expect(errorMessage).toBe('Unknown error'); + }); + + it('should produce a localized error string with the error message', () => { + const error = new Error('Network failure'); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorLocalized = `Workflow not saved. ${errorMessage}`; + expect(errorLocalized).toBe('Workflow not saved. Network failure'); + }); +}); + +describe('workflow save - code view JSON parsing', () => { + it('should handle malformed JSON from code editor', () => { + const invalidJson = '{ "definition": { invalid }'; + expect(() => JSON.parse(invalidJson)).toThrow(); + }); + + it('should parse valid workflow JSON from code editor', () => { + const validJson = JSON.stringify({ + definition: { actions: {}, triggers: {} }, + parameters: {}, + connectionReferences: {}, + }); + const parsed = JSON.parse(validJson); + expect(parsed.definition).toBeDefined(); + expect(parsed.parameters).toBeDefined(); + expect(parsed.connectionReferences).toBeDefined(); + }); + + it('should handle empty string from code editor', () => { + expect(() => JSON.parse('')).toThrow(); + }); + + it('should handle code editor returning undefined', () => { + // The code does: JSON.parse(codeEditorRef.current?.getValue() ?? '') + const value = undefined; + expect(() => JSON.parse(value ?? '')).toThrow(); + }); +}); + +describe('workflow save - parameter cleanup before save', () => { + it('should remove $connections from parameters before save', () => { + const parameters: Record = { + $connections: { defaultValue: {} }, + myParam: { type: 'String', value: 'hello', defaultValue: 'world' }, + }; + + // This mirrors the save logic in openDesignerForLocalProject.ts + delete parameters.$connections; + for (const parameterKey of Object.keys(parameters)) { + const parameter = parameters[parameterKey]; + parameter.value = parameter.value ?? parameter.defaultValue; + delete parameter.defaultValue; + } + + expect(parameters).not.toHaveProperty('$connections'); + expect(parameters.myParam.value).toBe('hello'); + expect(parameters.myParam).not.toHaveProperty('defaultValue'); + }); + + it('should use defaultValue when value is null/undefined', () => { + const parameters: Record = { + myParam: { type: 'String', value: undefined, defaultValue: 'fallback' }, + }; + + for (const parameterKey of Object.keys(parameters)) { + const parameter = parameters[parameterKey]; + parameter.value = parameter.value ?? parameter.defaultValue; + delete parameter.defaultValue; + } + + expect(parameters.myParam.value).toBe('fallback'); + }); + + it('should handle empty parameters object', () => { + const parameters: Record = {}; + + delete parameters.$connections; + for (const parameterKey of Object.keys(parameters)) { + const parameter = parameters[parameterKey]; + parameter.value = parameter.value ?? parameter.defaultValue; + delete parameter.defaultValue; + } + + expect(Object.keys(parameters)).toHaveLength(0); + }); +}); diff --git a/apps/vs-code-react/src/app/designer/__test__/designerCommandBarSave.spec.tsx b/apps/vs-code-react/src/app/designer/__test__/designerCommandBarSave.spec.tsx new file mode 100644 index 00000000000..c780468b42c --- /dev/null +++ b/apps/vs-code-react/src/app/designer/__test__/designerCommandBarSave.spec.tsx @@ -0,0 +1,303 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * Tests for the save workflow validation logic used by the DesignerCommandBar. + * + * The DesignerCommandBar.saveWorkflowMutate flow: + * 1. Serializes the workflow via serializeBJSWorkflow + * 2. Validates all operation input parameters + * 3. Only calls saveWorkflow if no validation errors + * + * These tests exercise the validation and save-gating logic + * without requiring the full React component tree (which has + * deep designer-v2 dependencies that need a built library). + */ + +// Mirrors the validation logic from DesignerCommandBar/indexV2.tsx saveWorkflowMutate +function validateAndSave( + designerState: { + operations: { + inputParameters: Record< + string, + { + parameterGroups: Record< + string, + { + id: string; + parameters: Array<{ id: string; value: any }>; + } + >; + } + >; + }; + customCode: Record; + }, + validateParameter: (param: any, value: any) => string[], + saveWorkflow: (workflow: any, customCode: any) => void, + serializedWorkflow: any, + customCodeData: any, + onValidationError: (nodeId: string, groupId: string, parameterId: string, errors: string[]) => void +): { saved: boolean; validationErrors: Record } { + const validationErrorsList: Record = {}; + const arr = Object.entries(designerState.operations.inputParameters); + + for (const [id, nodeInputs] of arr) { + const hasValidationErrors = Object.values(nodeInputs.parameterGroups).some((parameterGroup) => { + return parameterGroup.parameters.some((parameter) => { + const validationErrors = validateParameter(parameter, parameter.value); + if (validationErrors.length > 0) { + onValidationError(id, parameterGroup.id, parameter.id, validationErrors); + } + return validationErrors.length; + }); + }); + if (hasValidationErrors) { + validationErrorsList[id] = hasValidationErrors; + } + } + + const hasParametersErrors = Object.keys(validationErrorsList).length > 0; + + if (!hasParametersErrors) { + saveWorkflow(serializedWorkflow, customCodeData); + return { saved: true, validationErrors: validationErrorsList }; + } + + return { saved: false, validationErrors: validationErrorsList }; +} + +describe('DesignerCommandBar save validation logic', () => { + let mockValidateParameter: ReturnType; + let mockSaveWorkflow: ReturnType; + let mockOnValidationError: ReturnType; + + beforeEach(() => { + mockValidateParameter = vi.fn(); + mockSaveWorkflow = vi.fn(); + mockOnValidationError = vi.fn(); + }); + + it('should call saveWorkflow when no validation errors exist', () => { + const state = { + operations: { inputParameters: {} }, + customCode: {}, + }; + mockValidateParameter.mockReturnValue([]); + + const result = validateAndSave(state, mockValidateParameter, mockSaveWorkflow, { definition: {} }, {}, mockOnValidationError); + + expect(result.saved).toBe(true); + expect(mockSaveWorkflow).toHaveBeenCalledWith({ definition: {} }, {}); + }); + + it('should call saveWorkflow when all parameters pass validation', () => { + const state = { + operations: { + inputParameters: { + 'node-1': { + parameterGroups: { + default: { + id: 'default', + parameters: [ + { id: 'param-1', value: 'valid-value' }, + { id: 'param-2', value: 42 }, + ], + }, + }, + }, + }, + }, + customCode: {}, + }; + mockValidateParameter.mockReturnValue([]); + + const result = validateAndSave(state, mockValidateParameter, mockSaveWorkflow, { definition: {} }, {}, mockOnValidationError); + + expect(result.saved).toBe(true); + expect(mockSaveWorkflow).toHaveBeenCalled(); + expect(mockValidateParameter).toHaveBeenCalledTimes(2); + }); + + it('should block save when parameter validation returns errors', () => { + const state = { + operations: { + inputParameters: { + 'node-1': { + parameterGroups: { + default: { + id: 'default', + parameters: [{ id: 'param-1', value: '' }], + }, + }, + }, + }, + }, + customCode: {}, + }; + mockValidateParameter.mockReturnValue(['Parameter is required']); + + const result = validateAndSave(state, mockValidateParameter, mockSaveWorkflow, { definition: {} }, {}, mockOnValidationError); + + expect(result.saved).toBe(false); + expect(mockSaveWorkflow).not.toHaveBeenCalled(); + expect(result.validationErrors).toEqual({ 'node-1': true }); + }); + + it('should report validation errors for each invalid parameter', () => { + const state = { + operations: { + inputParameters: { + 'node-1': { + parameterGroups: { + default: { + id: 'default', + parameters: [ + { id: 'param-1', value: '' }, + { id: 'param-2', value: 'valid' }, + ], + }, + }, + }, + }, + }, + customCode: {}, + }; + mockValidateParameter.mockReturnValueOnce(['Required field']).mockReturnValueOnce([]); + + validateAndSave(state, mockValidateParameter, mockSaveWorkflow, { definition: {} }, {}, mockOnValidationError); + + expect(mockOnValidationError).toHaveBeenCalledTimes(1); + expect(mockOnValidationError).toHaveBeenCalledWith('node-1', 'default', 'param-1', ['Required field']); + }); + + it('should block save when any node has errors across multiple nodes', () => { + const state = { + operations: { + inputParameters: { + 'node-1': { + parameterGroups: { + default: { + id: 'default', + parameters: [{ id: 'param-1', value: 'valid' }], + }, + }, + }, + 'node-2': { + parameterGroups: { + default: { + id: 'default', + parameters: [{ id: 'param-2', value: '' }], + }, + }, + }, + }, + }, + customCode: {}, + }; + mockValidateParameter.mockReturnValueOnce([]).mockReturnValueOnce(['Missing value']); + + const result = validateAndSave(state, mockValidateParameter, mockSaveWorkflow, { definition: {} }, {}, mockOnValidationError); + + expect(result.saved).toBe(false); + expect(result.validationErrors).toEqual({ 'node-2': true }); + expect(mockSaveWorkflow).not.toHaveBeenCalled(); + }); + + it('should validate all parameter groups within a node', () => { + const state = { + operations: { + inputParameters: { + 'node-1': { + parameterGroups: { + general: { + id: 'general', + parameters: [{ id: 'p1', value: 'ok' }], + }, + advanced: { + id: 'advanced', + parameters: [{ id: 'p2', value: '' }], + }, + }, + }, + }, + }, + customCode: {}, + }; + mockValidateParameter.mockReturnValueOnce([]).mockReturnValueOnce(['Advanced param required']); + + const result = validateAndSave(state, mockValidateParameter, mockSaveWorkflow, { definition: {} }, {}, mockOnValidationError); + + expect(result.saved).toBe(false); + expect(mockOnValidationError).toHaveBeenCalledWith('node-1', 'advanced', 'p2', ['Advanced param required']); + }); + + it('should pass custom code data to saveWorkflow', () => { + const state = { + operations: { inputParameters: {} }, + customCode: {}, + }; + const customCode = { 'file.csx': 'return "hello";' }; + + validateAndSave(state, mockValidateParameter, mockSaveWorkflow, { definition: {} }, customCode, mockOnValidationError); + + expect(mockSaveWorkflow).toHaveBeenCalledWith({ definition: {} }, customCode); + }); + + it('should handle multiple validation errors per parameter', () => { + const state = { + operations: { + inputParameters: { + 'node-1': { + parameterGroups: { + default: { + id: 'default', + parameters: [{ id: 'param-1', value: 'x' }], + }, + }, + }, + }, + }, + customCode: {}, + }; + mockValidateParameter.mockReturnValue(['Too short', 'Invalid format']); + + const result = validateAndSave(state, mockValidateParameter, mockSaveWorkflow, { definition: {} }, {}, mockOnValidationError); + + expect(result.saved).toBe(false); + expect(mockOnValidationError).toHaveBeenCalledWith('node-1', 'default', 'param-1', ['Too short', 'Invalid format']); + }); +}); + +describe('DesignerCommandBar save disabled conditions', () => { + it('should compute save disabled when in monitoring view', () => { + const isMonitoringView = true; + const isSaving = false; + const isSavingFromCode = false; + const haveErrors = false; + const designerIsDirty = true; + + const isSaveDisabled = isMonitoringView || isSaving || isSavingFromCode || haveErrors || !designerIsDirty; + expect(isSaveDisabled).toBe(true); + }); + + it('should compute save disabled when currently saving', () => { + const isSaveDisabled = false || true || false || false || false; + expect(isSaveDisabled).toBe(true); + }); + + it('should compute save disabled when there are errors', () => { + const isSaveDisabled = false || false || false || true || false; + expect(isSaveDisabled).toBe(true); + }); + + it('should compute save disabled when designer is not dirty', () => { + const designerIsDirty = false; + const isSaveDisabled = false || false || false || false || !designerIsDirty; + expect(isSaveDisabled).toBe(true); + }); + + it('should compute save enabled when dirty, not saving, no errors, not monitoring', () => { + const isSaveDisabled = false || false || false || false || !true; + expect(isSaveDisabled).toBe(false); + }); +}); diff --git a/apps/vs-code-react/src/app/designer/appV2.tsx b/apps/vs-code-react/src/app/designer/appV2.tsx index 02457e5db51..9a397c0532a 100644 --- a/apps/vs-code-react/src/app/designer/appV2.tsx +++ b/apps/vs-code-react/src/app/designer/appV2.tsx @@ -168,7 +168,7 @@ export const DesignerApp = () => { // Saving const saveWorkflowFromDesigner = useCallback( - async (workflowToSave: Workflow, customCodeData: Record | undefined, clearDirtyState?: () => void) => { + async (workflowToSave: Workflow, customCodeData: Record | undefined, _clearDirtyState?: () => void) => { const { definition, parameters, connectionReferences } = workflowToSave; vscode.postMessage({ command: ExtensionCommand.save, @@ -183,7 +183,9 @@ export const DesignerApp = () => { } as StandardApp; setWorkflow(newWorkflow); setInitialWorkflow(newWorkflow); - clearDirtyState?.(); + // clearDirtyState is intentionally NOT called here — the extension host + // sends resetDesignerDirtyState after a successful file write, which + // properly resets dirty state only on confirmed persistence. return { definition, parameters, diff --git a/apps/vs-code-react/src/state/DesignerSlice.ts b/apps/vs-code-react/src/state/DesignerSlice.ts index 4796c7c9eee..6d146d34d80 100644 --- a/apps/vs-code-react/src/state/DesignerSlice.ts +++ b/apps/vs-code-react/src/state/DesignerSlice.ts @@ -107,6 +107,11 @@ export const designerSlice: Slice = createSlice({ state.connectionData = connectionData; state.apiHubServiceDetails = apiHubServiceDetails; }, + updateDesignerAccessToken: (state, action: PayloadAction) => { + if (state.panelMetaData) { + state.panelMetaData = { ...state.panelMetaData, accessToken: action.payload }; + } + }, createFileSystemConnection: (state, action: PayloadAction) => { const { connectionName, resolve, reject } = action.payload; state.fileSystemConnections[connectionName] = { resolveConnection: resolve, rejectConnection: reject }; @@ -131,4 +136,5 @@ export const { createFileSystemConnection, updateFileSystemConnection, updatePanelMetadata, + updateDesignerAccessToken, } = designerSlice.actions; diff --git a/apps/vs-code-react/src/webviewCommunication.tsx b/apps/vs-code-react/src/webviewCommunication.tsx index 2ce3752a704..62f34523164 100644 --- a/apps/vs-code-react/src/webviewCommunication.tsx +++ b/apps/vs-code-react/src/webviewCommunication.tsx @@ -57,6 +57,7 @@ import { import { initializeDesigner, updateCallbackUrl, + updateDesignerAccessToken, updateFileSystemConnection, updatePanelMetadata, updateRuntimeBaseUrl, @@ -374,6 +375,7 @@ export const WebViewCommunication: React.FC<{ children: ReactNode }> = ({ childr } case ExtensionCommand.update_access_token: { dispatch(updateAccessToken(message.data.accessToken)); + dispatch(updateDesignerAccessToken(message.data.accessToken)); break; } case ExtensionCommand.update_export_path: { diff --git a/eslint.config.mjs b/eslint.config.mjs index 3ec32c8b1f6..030ff2c9dbd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -40,6 +40,7 @@ export default [ react: { version: "detect", }, + "import/ignore": ["node_modules"], }, extends: ["eslint:recommended"], overrides: [ diff --git a/libs/designer-v2/src/lib/core/actions/bjsworkflow/serializer.ts b/libs/designer-v2/src/lib/core/actions/bjsworkflow/serializer.ts index 3fa6878f3b9..13726411030 100644 --- a/libs/designer-v2/src/lib/core/actions/bjsworkflow/serializer.ts +++ b/libs/designer-v2/src/lib/core/actions/bjsworkflow/serializer.ts @@ -60,13 +60,7 @@ import { } from '@microsoft/logic-apps-shared'; import type { ParameterInfo } from '@microsoft/designer-ui'; import { UIConstants } from '@microsoft/designer-ui'; -import type { - Segment, - LocationSwapMap, - LogicAppsV2, - OperationManifest, - SubGraphDetail, -} from '@microsoft/logic-apps-shared'; +import type { Segment, LocationSwapMap, LogicAppsV2, OperationManifest, SubGraphDetail } from '@microsoft/logic-apps-shared'; import merge from 'lodash.merge'; import { ConnectorManifest } from './agent'; import { isA2AWorkflow, isBuiltInMcpOperation, isManagedMcpOperation } from '../../../core/state/workflow/helper'; @@ -277,9 +271,9 @@ export const parseWorkflowParameterValue = (parameterType: any, parameterValue: : typeof parameterValue !== 'string' ? parameterValue : JSON.parse(parameterValue); - } catch (error) { - console.log(error); - return undefined; + } catch { + // Return the raw value rather than undefined to avoid silently dropping parameter data + return parameterValue; } }; diff --git a/libs/designer/src/lib/core/actions/bjsworkflow/serializer.ts b/libs/designer/src/lib/core/actions/bjsworkflow/serializer.ts index 44824ffa740..b7508f80004 100644 --- a/libs/designer/src/lib/core/actions/bjsworkflow/serializer.ts +++ b/libs/designer/src/lib/core/actions/bjsworkflow/serializer.ts @@ -60,13 +60,7 @@ import { } from '@microsoft/logic-apps-shared'; import type { ParameterInfo } from '@microsoft/designer-ui'; import { UIConstants } from '@microsoft/designer-ui'; -import type { - Segment, - LocationSwapMap, - LogicAppsV2, - OperationManifest, - SubGraphDetail, -} from '@microsoft/logic-apps-shared'; +import type { Segment, LocationSwapMap, LogicAppsV2, OperationManifest, SubGraphDetail } from '@microsoft/logic-apps-shared'; import merge from 'lodash.merge'; import { ConnectorManifest } from './agent'; import { isA2AWorkflow, isBuiltInMcpOperation, isManagedMcpOperation } from '../../../core/state/workflow/helper'; @@ -273,9 +267,9 @@ export const parseWorkflowParameterValue = (parameterType: any, parameterValue: : typeof parameterValue !== 'string' ? parameterValue : JSON.parse(parameterValue); - } catch (error) { - console.log(error); - return undefined; + } catch { + // Return the raw value rather than undefined to avoid silently dropping parameter data + return parameterValue; } }; diff --git a/libs/vscode-extension/src/lib/services/__test__/httpClient.spec.ts b/libs/vscode-extension/src/lib/services/__test__/httpClient.spec.ts index d95e6d1535d..a2049c4c6a4 100644 --- a/libs/vscode-extension/src/lib/services/__test__/httpClient.spec.ts +++ b/libs/vscode-extension/src/lib/services/__test__/httpClient.spec.ts @@ -274,4 +274,134 @@ describe('HttpClient', () => { await expect(httpClient.delete(options)).rejects.toThrow(errorMessage); }); + + describe('auth token handling', () => { + it('should send empty Authorization header for non-ARM GET requests', async () => { + (axios as any).mockResolvedValue({ data: {}, status: 200 }); + + await httpClient.get({ uri: '/local-endpoint', headers: {} }); + + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: '', + }), + }) + ); + }); + + it('should send access token for ARM GET requests', async () => { + (axios as any).mockResolvedValue({ data: {}, status: 200 }); + + await httpClient.get({ uri: '/subscriptions/sub-1/resource', headers: {} }); + + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: accessToken, + }), + }) + ); + }); + + it('should use stale token when no refresh mechanism exists', async () => { + const staleToken = 'expired-token'; + const staleClient = new HttpClient({ ...httpClientOptions, accessToken: staleToken }); + (axios as any).mockRejectedValueOnce({ + response: { status: 401, data: { error: { message: 'Token expired' } } }, + }); + + const options: HttpRequestOptions = { + uri: '/subscriptions/sub-1/resource', + headers: {}, + content: { key: 'value' }, + }; + + await expect(staleClient.post(options)).rejects.toEqual({ + error: { message: 'Token expired' }, + }); + }); + + it('should reject PUT with 401 status when token is invalid', async () => { + (axios as any).mockRejectedValueOnce({ + response: { status: 401, data: { error: { message: 'Unauthorized' } } }, + }); + + const options: HttpRequestOptions = { + uri: '/subscriptions/sub-1/resource', + headers: {}, + content: {}, + }; + + await expect(httpClient.put(options)).rejects.toEqual(expect.objectContaining({ status: 401 })); + }); + + it('should construct HttpClient with undefined token without throwing', () => { + const clientNoToken = new HttpClient({ + baseUrl, + accessToken: undefined, + apiHubBaseUrl, + hostVersion, + }); + expect(clientNoToken).toBeDefined(); + }); + + it('should send undefined Authorization for ARM requests when token is undefined', async () => { + const clientNoToken = new HttpClient({ + baseUrl, + accessToken: undefined, + apiHubBaseUrl, + hostVersion, + }); + (axios as any).mockResolvedValue({ data: {}, status: 200 }); + + await clientNoToken.get({ uri: '/subscriptions/sub-1/resource', headers: {} }); + + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'undefined', + }), + }) + ); + }); + }); + + describe('request URL construction', () => { + it('should use apiHubBaseUrl for ARM resource IDs', async () => { + (axios as any).mockResolvedValue({ data: {}, status: 200 }); + + await httpClient.get({ uri: '/subscriptions/sub-1/providers/test', headers: {} }); + + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${apiHubBaseUrl}/subscriptions/sub-1/providers/test`, + }) + ); + }); + + it('should use baseUrl for non-ARM URIs', async () => { + (axios as any).mockResolvedValue({ data: {}, status: 200 }); + + await httpClient.get({ uri: '/api/workflows', headers: {} }); + + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + url: `${baseUrl}/api/workflows`, + }) + ); + }); + + it('should use the URI as-is when it is a full URL', async () => { + (axios as any).mockResolvedValue({ data: {}, status: 200 }); + + await httpClient.get({ uri: 'https://other.example.com/api/data', headers: {} }); + + expect(axios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://other.example.com/api/data', + }) + ); + }); + }); }); diff --git a/libs/vscode-extension/src/lib/services/httpClient.ts b/libs/vscode-extension/src/lib/services/httpClient.ts index ed59f91d026..a49a4144de0 100644 --- a/libs/vscode-extension/src/lib/services/httpClient.ts +++ b/libs/vscode-extension/src/lib/services/httpClient.ts @@ -22,6 +22,14 @@ export class HttpClient implements IHttpClient { this._extraHeaders = getExtraHeaders(options.hostVersion ?? ''); } + /** + * Updates the access token used for authenticated requests. + * Call this when the token is refreshed to avoid stale-token failures on save. + */ + updateAccessToken(token: string | undefined): void { + this._accessToken = token; + } + dispose(): void {} async get(options: HttpRequestOptions): Promise { From c8ba0ed90b25b30c17d8b3da190bad3c4c192b0f Mon Sep 17 00:00:00 2001 From: Bryan Bennett Date: Fri, 17 Jul 2026 17:14:53 -0400 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/app/utils/codeless/__test__/saveWorkflow.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/saveWorkflow.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/saveWorkflow.test.ts index 6891dfbd463..878be1409ef 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/saveWorkflow.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/saveWorkflow.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { writeFileSync } from 'fs'; +vi.mock('fs', () => ({ + writeFileSync: vi.fn(), +})); /** * Tests for workflow save logic as used by OpenDesignerForLocalProject.saveWorkflow(). * These focus on the serialization, error handling, and concurrency aspects From ac4eacd1915ac634e725304aa41abbe8af7d10d4 Mon Sep 17 00:00:00 2001 From: Bryan Bennett Date: Fri, 17 Jul 2026 18:08:52 -0400 Subject: [PATCH 3/3] addressing pr comments --- .../openDesignerForLocalProject.ts | 37 ++++++++++++++----- .../__test__/getAuthorizationToken.test.ts | 3 ++ apps/vs-code-react/src/state/DesignerSlice.ts | 2 +- .../lib/services/__test__/httpClient.spec.ts | 4 +- .../src/lib/services/httpClient.ts | 10 ++--- 5 files changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts index c04b4e17502..f086778abed 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/openDesigner/openDesignerForLocalProject.ts @@ -218,7 +218,8 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { try { const tenantId = this.panelMetadata.azureDetails?.tenantId; const updatedAccessToken = await getAuthorizationToken(tenantId); - if (updatedAccessToken !== this.panelMetadata.accessToken) { + // Guard against "Bearer undefined" — only update if we got a real token + if (updatedAccessToken && !updatedAccessToken.endsWith('undefined') && updatedAccessToken !== this.panelMetadata.accessToken) { this.panelMetadata.accessToken = updatedAccessToken; this.panel.webview.postMessage({ command: ExtensionCommand.update_access_token, @@ -230,7 +231,7 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { } catch { // Silently ignore token refresh failures — the existing token may still be valid } - }, 5000); + }, 30000); this.sendMsgToWebview({ command: ExtensionCommand.initialize_frame, @@ -629,18 +630,34 @@ export default class OpenDesignerForLocalProject extends OpenDesignerBase { // Parameter exists only in the file — preserve it entirely definitionParameters[key] = parameter; } else if (definitionParameters[key]) { - // Parameter exists in both — preserve file-only properties that the designer doesn't emit - const fileParam = parameter as Record; - const defParam = definitionParameters[key] as Record; - for (const prop of Object.keys(fileParam)) { - if (!(prop in defParam)) { - defParam[prop] = fileParam[prop]; - } - } + // Parameter exists in both — deep-merge file properties that the designer doesn't emit + this.deepMergePreserveExisting(definitionParameters[key], parameter as Record); } }); } + /** + * Recursively merges properties from source into target, only adding + * properties that don't already exist in target. For nested objects, + * merges recursively so nested file-only fields are preserved. + */ + private deepMergePreserveExisting(target: Record, source: Record): void { + for (const prop of Object.keys(source)) { + if (!(prop in target)) { + target[prop] = source[prop]; + } else if ( + typeof target[prop] === 'object' && + target[prop] !== null && + !Array.isArray(target[prop]) && + typeof source[prop] === 'object' && + source[prop] !== null && + !Array.isArray(source[prop]) + ) { + this.deepMergePreserveExisting(target[prop], source[prop]); + } + } + } + /** * Reloads the webview panel and updates the view state. * @param webviewPanel The web view panel to update. diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/getAuthorizationToken.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/getAuthorizationToken.test.ts index e1dac31eb01..7457bab37f9 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/getAuthorizationToken.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/getAuthorizationToken.test.ts @@ -35,6 +35,9 @@ describe('getAuthorizationToken', () => { }); it('should return "Bearer undefined" when session returns no accessToken', async () => { + // Note: getAuthorizationToken does not guard against undefined accessToken. + // The token refresh interval in openDesignerForLocalProject guards against this + // by checking for "undefined" in the returned string before propagating it. vi.spyOn(azureAuth, 'getSessionFromVSCode').mockResolvedValue({ id: 'session-1', account: { id: 'account-1', label: 'Test' }, diff --git a/apps/vs-code-react/src/state/DesignerSlice.ts b/apps/vs-code-react/src/state/DesignerSlice.ts index 6d146d34d80..eb8031aae46 100644 --- a/apps/vs-code-react/src/state/DesignerSlice.ts +++ b/apps/vs-code-react/src/state/DesignerSlice.ts @@ -107,7 +107,7 @@ export const designerSlice: Slice = createSlice({ state.connectionData = connectionData; state.apiHubServiceDetails = apiHubServiceDetails; }, - updateDesignerAccessToken: (state, action: PayloadAction) => { + updateDesignerAccessToken: (state, action: PayloadAction) => { if (state.panelMetaData) { state.panelMetaData = { ...state.panelMetaData, accessToken: action.payload }; } diff --git a/libs/vscode-extension/src/lib/services/__test__/httpClient.spec.ts b/libs/vscode-extension/src/lib/services/__test__/httpClient.spec.ts index a2049c4c6a4..60738de3d7c 100644 --- a/libs/vscode-extension/src/lib/services/__test__/httpClient.spec.ts +++ b/libs/vscode-extension/src/lib/services/__test__/httpClient.spec.ts @@ -346,7 +346,7 @@ describe('HttpClient', () => { expect(clientNoToken).toBeDefined(); }); - it('should send undefined Authorization for ARM requests when token is undefined', async () => { + it('should send empty Authorization for ARM requests when token is undefined', async () => { const clientNoToken = new HttpClient({ baseUrl, accessToken: undefined, @@ -360,7 +360,7 @@ describe('HttpClient', () => { expect(axios).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ - Authorization: 'undefined', + Authorization: '', }), }) ); diff --git a/libs/vscode-extension/src/lib/services/httpClient.ts b/libs/vscode-extension/src/lib/services/httpClient.ts index a49a4144de0..2843293aaf6 100644 --- a/libs/vscode-extension/src/lib/services/httpClient.ts +++ b/libs/vscode-extension/src/lib/services/httpClient.ts @@ -40,7 +40,7 @@ export class HttpClient implements IHttpClient { headers: { ...this._extraHeaders, ...options.headers, - Authorization: `${isArmId ? this._accessToken : ''}`, + Authorization: isArmId ? (this._accessToken ?? '') : '', }, }; const response = await axios({ @@ -66,7 +66,7 @@ export class HttpClient implements IHttpClient { headers: { ...this._extraHeaders, ...options.headers, - Authorization: `${isArmId ? this._accessToken : ''}`, + Authorization: isArmId ? (this._accessToken ?? '') : '', 'Content-Type': 'application/json', }, data: options.content, @@ -85,7 +85,7 @@ export class HttpClient implements IHttpClient { } async post(options: HttpRequestOptions): Promise { - const authHeader: Record = options.includeAuth || !options.noAuth ? { Authorization: `${this._accessToken}` } : {}; + const authHeader: Record = options.includeAuth || !options.noAuth ? { Authorization: this._accessToken ?? '' } : {}; const request = { ...options, url: this.getRequestUrl(options), @@ -123,7 +123,7 @@ export class HttpClient implements IHttpClient { headers: { ...this._extraHeaders, ...options.headers, - Authorization: `${isArmId ? this._accessToken : ''}`, + Authorization: isArmId ? (this._accessToken ?? '') : '', 'Content-Type': 'application/json', }, data: options.content, @@ -152,7 +152,7 @@ export class HttpClient implements IHttpClient { headers: { ...this._extraHeaders, ...options.headers, - Authorization: `${this._accessToken}`, + Authorization: this._accessToken ?? '', }, }; const response = await axios({