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/dataMapper/DataMapperPanel.ts b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts index 5eb4462e4c1..f9bea6acd06 100644 --- a/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts +++ b/apps/vs-code-designer/src/app/commands/dataMapper/DataMapperPanel.ts @@ -22,7 +22,7 @@ import { LogEntryLevel } from '@microsoft/logic-apps-shared'; import type { SchemaType, MapMetadata, IFileSysTreeItem } from '@microsoft/logic-apps-shared'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import { callWithTelemetryAndErrorHandlingSync } from '@microsoft/vscode-azext-utils'; -import type { MapDefinitionData, MessageToVsix, MessageToWebview } from '@microsoft/vscode-extension-logic-apps'; +import type { MapDefinitionData, MessageToVsix } from '@microsoft/vscode-extension-logic-apps'; import { ExtensionCommand, Platform, ProjectName } from '@microsoft/vscode-extension-logic-apps'; import { copyFileSync, diff --git a/apps/vs-code-designer/src/app/commands/workflows/connectionView/openConnectionView.ts b/apps/vs-code-designer/src/app/commands/workflows/connectionView/openConnectionView.ts index 6c563ab853e..bda9c931246 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/connectionView/openConnectionView.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/connectionView/openConnectionView.ts @@ -15,14 +15,6 @@ export async function openConnectionView( range: CodeSelection, currentConnectionId: string ): Promise { - const connectionPanel = new ConnectionPanel( - context, - filePath, - methodName, - connectorName, - connectorType, - range, - currentConnectionId - ); + const connectionPanel = new ConnectionPanel(context, filePath, methodName, connectorName, connectorType, range, currentConnectionId); await connectionPanel.create(); } diff --git a/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/connectionPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/connectionPanel.ts index 0000a8fe463..e608ffbf08e 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/connectionPanel.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/connectionView/panels/connectionPanel.ts @@ -324,13 +324,7 @@ export default class ConnectionPanel extends DesignerPanel { throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); } - const [ - connectionsData, - parametersData, - artifacts, - bundleVersionNumber, - azureDetails - ] = await Promise.all([ + const [connectionsData, parametersData, artifacts, bundleVersionNumber, azureDetails] = await Promise.all([ getConnectionsFromFile(this.context, this.workflowFilePath), getParametersFromFile(this.context, this.workflowFilePath), getArtifactsInLocalProject(projectPath), diff --git a/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/localDesignerPanel.test.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/localDesignerPanel.test.ts index 57f24e3f4fa..65612aad0b7 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/localDesignerPanel.test.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/__test__/localDesignerPanel.test.ts @@ -202,9 +202,7 @@ describe('LocalDesignerPanel', () => { const instance = new LocalDesignerPanel(mockContext, mockUri); - await expect(instance.create()).rejects.toThrow( - 'Design time failed to start for project /test/project. func host failed to start' - ); + await expect(instance.create()).rejects.toThrow('Design time failed to start for project /test/project. func host failed to start'); }); it('should fail when no design-time instance is available for the project', async () => { diff --git a/apps/vs-code-designer/src/app/commands/workflows/designer/panels/designerPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/designerPanel.ts index addc339d0ac..88c091c60d7 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/designer/panels/designerPanel.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/designerPanel.ts @@ -7,7 +7,13 @@ import { getWebViewHTML } from '../../../../utils/codeless/getWebViewHTML'; import { getRecordEntry, isEmptyString, resolveConnectionsReferences } from '@microsoft/logic-apps-shared'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import type { Artifacts, AzureConnectorDetails, ConnectionsData, FileDetails, Parameter } from '@microsoft/vscode-extension-logic-apps'; -import { azurePublicBaseUrl, workflowManagementBaseURIKey, designerVersionSetting, defaultDesignerVersion, suppressDesignerVersionNotification } from '../../../../../constants'; +import { + azurePublicBaseUrl, + workflowManagementBaseURIKey, + designerVersionSetting, + defaultDesignerVersion, + suppressDesignerVersionNotification, +} from '../../../../../constants'; import { ext } from '../../../../../extensionVariables'; import { localize } from '../../../../../localize'; import type { WebviewPanel, WebviewOptions, WebviewPanelOptions } from 'vscode'; @@ -213,10 +219,7 @@ export abstract class DesignerPanel { } else if (selection === enablePreview) { await config.update(designerVersionSetting, 2, ConfigurationTarget.Global); const closeButton = localize('close', 'Close'); - const reopenMessage = localize( - 'closeToApply', - 'Setting updated. Please close and reopen the workflow to apply the new experience.' - ); + const reopenMessage = localize('closeToApply', 'Setting updated. Please close and reopen the workflow to apply the new experience.'); const reopenSelection = await window.showInformationMessage(reopenMessage, closeButton); if (reopenSelection === closeButton) { this.panel?.dispose(); diff --git a/apps/vs-code-designer/src/app/commands/workflows/designer/panels/localDesignerPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/localDesignerPanel.ts index a5d792b80e6..4239d987c48 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/designer/panels/localDesignerPanel.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/localDesignerPanel.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 LocalDesignerPanel extends DesignerPanel { private projectPath?: string; private panelMetadata?: DesignerPanelMetadata; private workflowRuntimeBaseUrlInterval?: NodeJS.Timeout; + private accessTokenInterval?: NodeJS.Timeout; constructor(context: IActionContext, node: Uri, runId?: string) { const workflowName = path.basename(path.dirname(node.fsPath)); @@ -178,6 +180,7 @@ export default class LocalDesignerPanel extends DesignerPanel { this.panel.onDidDispose( () => { clearInterval(this.workflowRuntimeBaseUrlInterval); + clearInterval(this.accessTokenInterval); removeWebviewPanelFromCache(this.panelGroupKey, this.panelName); }, null, @@ -207,6 +210,28 @@ export default class LocalDesignerPanel extends DesignerPanel { } }, 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); + // Guard against "Bearer undefined" — only update if we got a real token + if (updatedAccessToken && !updatedAccessToken.endsWith('undefined') && updatedAccessToken !== this.panelMetadata?.accessToken) { + if (this.panelMetadata) { + 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 + } + }, 30000); + this.panel?.webview.postMessage({ command: ExtensionCommand.initialize_frame, data: { @@ -603,6 +628,10 @@ export default class LocalDesignerPanel extends DesignerPanel { /** * 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 @@ -617,11 +646,37 @@ export default class LocalDesignerPanel extends DesignerPanel { 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 — 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/commands/workflows/designer/panels/remoteDesignerPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/remoteDesignerPanel.ts index 66cd222cf74..c7299f30d87 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/designer/panels/remoteDesignerPanel.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/designer/panels/remoteDesignerPanel.ts @@ -59,7 +59,7 @@ export class RemoteDesignerPanel extends DesignerPanel { azureDetails: this.panelMetadata.azureDetails, workflowDetails: this.panelMetadata.workflowDetails, }); - + this.panelMetadata.mapArtifacts = this.mapArtifacts as Record; this.panelMetadata.schemaArtifacts = this.schemaArtifacts as FileDetails[]; diff --git a/apps/vs-code-designer/src/app/commands/workflows/monitoringView/openMonitoringView.ts b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/openMonitoringView.ts index 002214b3fa8..6c3171fbdb5 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/monitoringView/openMonitoringView.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/openMonitoringView.ts @@ -22,9 +22,10 @@ export async function openMonitoringView( return; } - const monitoringPanel = node instanceof Uri - ? new LocalMonitoringPanel(context, runId, workflowFilePath) - : new RemoteMonitoringPanel(context, runId, workflowFilePath, node); + const monitoringPanel = + node instanceof Uri + ? new LocalMonitoringPanel(context, runId, workflowFilePath) + : new RemoteMonitoringPanel(context, runId, workflowFilePath, node); await callWithTelemetryAndErrorHandling('azureLogicAppsStandard.openMonitoringView', async (actionContext: IActionContext) => { actionContext.telemetry.properties.isLocal = node instanceof Uri ? 'true' : 'false'; diff --git a/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/localMonitoringPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/localMonitoringPanel.ts index 2aa462df15f..eba43771d65 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/localMonitoringPanel.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/localMonitoringPanel.ts @@ -192,23 +192,16 @@ export default class LocalMonitoringPanel extends MonitoringPanel { throw new Error(localize('FunctionRootFolderError', 'Unable to determine function project root folder.')); } - const [ - connectionsData, - parametersData, - customCodeData, - bundleVersionNumber, - azureDetails, - artifacts, - workflowContent, - ] = await Promise.all([ - getConnectionsFromFile(this.context, this.workflowFilePath), - getParametersFromFile(this.context, this.workflowFilePath), - getCustomCodeFromFiles(this.workflowFilePath), - getBundleVersionNumber(projectPath), - getAzureConnectorDetailsForLocalProject(this.context, projectPath), - getArtifactsInLocalProject(projectPath), - this.getWorkflowContent(), - ]); + const [connectionsData, parametersData, customCodeData, bundleVersionNumber, azureDetails, artifacts, workflowContent] = + await Promise.all([ + getConnectionsFromFile(this.context, this.workflowFilePath), + getParametersFromFile(this.context, this.workflowFilePath), + getCustomCodeFromFiles(this.workflowFilePath), + getBundleVersionNumber(projectPath), + getAzureConnectorDetailsForLocalProject(this.context, projectPath), + getArtifactsInLocalProject(projectPath), + this.getWorkflowContent(), + ]); const localSettings = (await getLocalSettingsJson(this.context, path.join(projectPath, localSettingsFileName))).Values!; diff --git a/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/monitoringPanel.ts b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/monitoringPanel.ts index bb184941c0c..a36cc435c53 100644 --- a/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/monitoringPanel.ts +++ b/apps/vs-code-designer/src/app/commands/workflows/monitoringView/panels/monitoringPanel.ts @@ -13,13 +13,7 @@ import * as vscode from 'vscode'; export abstract class MonitoringPanel extends DesignerPanel { protected workflowFilePath: string; - protected constructor( - context: IActionContext, - runId: string, - workflowFilePath: string, - isLocal: boolean, - apiVersion: string - ) { + protected constructor(context: IActionContext, runId: string, workflowFilePath: string, isLocal: boolean, apiVersion: string) { const runName = runId ? runId.split('/').slice(-1)[0] : ''; const workflowName = runId.split('/').slice(-3)[0]; const panelNamePrefix = isLocal ? `${vscode.workspace.name}-` : ''; 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..7457bab37f9 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/getAuthorizationToken.test.ts @@ -0,0 +1,147 @@ +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 () => { + // 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' }, + 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..878be1409ef --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/saveWorkflow.test.ts @@ -0,0 +1,235 @@ +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 + * 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 f730e16013c..b34d78da22b 100644 --- a/apps/vs-code-react/src/state/DesignerSlice.ts +++ b/apps/vs-code-react/src/state/DesignerSlice.ts @@ -1,9 +1,5 @@ import type { ApiHubServiceDetails, ConnectionsData, ListDynamicValue } from '@microsoft/logic-apps-shared'; -import type { - CompleteFileSystemConnectionData, - ICallbackUrlResponse, - DesignerPanelMetadata, -} from '@microsoft/vscode-extension-logic-apps'; +import type { CompleteFileSystemConnectionData, ICallbackUrlResponse, DesignerPanelMetadata } from '@microsoft/vscode-extension-logic-apps'; import type { PayloadAction, Slice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; @@ -107,6 +103,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 +132,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..60738de3d7c 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 empty 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: '', + }), + }) + ); + }); + }); + + 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..2843293aaf6 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 { @@ -32,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({ @@ -58,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, @@ -77,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), @@ -115,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, @@ -144,7 +152,7 @@ export class HttpClient implements IHttpClient { headers: { ...this._extraHeaders, ...options.headers, - Authorization: `${this._accessToken}`, + Authorization: this._accessToken ?? '', }, }; const response = await axios({