From 1d5f15525eb3994b902f160debb9a1141799a4bc Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 8 Jul 2026 12:07:51 -0700 Subject: [PATCH 01/19] test(vscode): characterize local.settings.json + creation-path content for logic app types Add golden/characterization coverage that pins the exact content of the project-root and workflow-designtime local.settings.json schemas (getLocalSettingsSchema) across the codeless/codeful axis, plus the codeful creation path in CreateLogicAppWorkspace. Expected values are reconstructed from the shared key/value constants so structural regressions are caught while staying in sync with the codebase. These tests exercise pre-existing behavior (source unchanged from main) and lock it in before the regeneration feature. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/CreateLogicAppWorkspace.test.ts | 33 ++++++++ .../__test__/localSettings.test.ts | 79 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts index 6aa65879027..d2400cdab10 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts @@ -1308,6 +1308,13 @@ describe('createLocalConfigurationFiles', () => { workflowName: 'TestWorkflow', } as any; + const mockContextCodeful: IWebviewProjectContext = { + workspaceName: 'TestWorkspace', + logicAppName: 'TestLogicApp', + logicAppType: ProjectType.codeful, + workflowName: 'TestWorkflow', + } as any; + const logicAppFolderPath = actualPath.join('test', 'workspace', 'TestLogicApp'); beforeEach(() => { @@ -1506,6 +1513,32 @@ describe('createLocalConfigurationFiles', () => { expect(Object.keys(localSettingsData.Values)).toHaveLength(6); }); + it('should create local.settings.json with exact required values for codeful projects', async () => { + await CreateLogicAppWorkspaceModule.createLocalConfigurationFiles(mockContextCodeful, logicAppFolderPath); + + const localSettingsCall = vi + .mocked(fsUtils.writeFormattedJson) + .mock.calls.find((call) => call[0].toString().includes('local.settings.json')); + expect(localSettingsCall).toBeDefined(); + const localSettingsData = localSettingsCall![1] as any; + + // Codeful is not a plain logic app (so it gets the multi-language worker flag) and additionally + // gets the codeful-enabled flag plus the job-host extension bundle id. + expect(localSettingsData.Values).toEqual({ + AzureWebJobsStorage: 'UseDevelopmentStorage=true', + FUNCTIONS_INPROC_NET8_ENABLED: '1', + FUNCTIONS_WORKER_RUNTIME: 'dotnet', + APP_KIND: 'workflowapp', + ProjectDirectoryPath: path.join('test', 'workspace', 'TestLogicApp'), + AzureWebJobsFeatureFlags: 'EnableMultiLanguageWorker', + WORKFLOW_CODEFUL_ENABLED: 'true', + AzureFunctionsJobHost__extensionBundle__id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows', + }); + + // Verify exactly 8 properties exist (5 standard + feature flag + 2 codeful). + expect(Object.keys(localSettingsData.Values)).toHaveLength(8); + }); + it('should include extension bundle configuration in host.json', async () => { await CreateLogicAppWorkspaceModule.createLocalConfigurationFiles(mockContext, logicAppFolderPath); diff --git a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts index deac1c6e368..9d930d57120 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; import { getLocalSettingsSchema } from '../localSettings'; import { ProjectDirectoryPathKey, @@ -6,8 +7,12 @@ import { azureStorageTypeSetting, azureWebJobsSecretStorageTypeKey, azureWebJobsStorageKey, + functionsInprocNet8Enabled, + functionsInprocNet8EnabledTrue, localEmulatorConnectionString, + logicAppKind, workerRuntimeKey, + workflowCodefulEnabled, } from '../../../../constants'; describe('utils/appSettings', () => { @@ -38,5 +43,79 @@ describe('utils/appSettings', () => { expect(settings['Values']).toHaveProperty(azureWebJobsStorageKey, localEmulatorConnectionString); expect(settings['Values']).toHaveProperty(ProjectDirectoryPathKey, projectPath); }); + + // Golden / characterization tests: lock the exact content of every local.settings.json this + // extension generates, for every logic-app content axis. The content only varies by + // (isDesignTime x isCodeful). + // + // Expected values are expressed via the shared constants (the same named keys/values the rest of + // the codebase uses) rather than magic strings. The assertions still reconstruct the full + // expected object explicitly and deep-equal it, so a structural regression (a setting added, + // dropped, or moved to the wrong branch) is caught. + describe('golden content by logic app type', () => { + it('root local.settings.json (codeless / Standard Node)', () => { + expect(getLocalSettingsSchema(false, projectPath, false)).toEqual({ + IsEncrypted: false, + Values: { + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Dotnet, + [azureWebJobsStorageKey]: localEmulatorConnectionString, + [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + }, + }); + }); + + it('root local.settings.json (codeful / .NET8) adds WORKFLOW_CODEFUL_ENABLED', () => { + expect(getLocalSettingsSchema(false, projectPath, true)).toEqual({ + IsEncrypted: false, + Values: { + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Dotnet, + [azureWebJobsStorageKey]: localEmulatorConnectionString, + [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + [workflowCodefulEnabled]: 'true', + }, + }); + }); + + it('design-time local.settings.json (codeless / Standard Node)', () => { + expect(getLocalSettingsSchema(true, projectPath, false)).toEqual({ + IsEncrypted: false, + Values: { + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Node, + [azureWebJobsSecretStorageTypeKey]: azureStorageTypeSetting, + }, + }); + }); + + it('design-time local.settings.json (codeful / .NET8) adds WORKFLOW_CODEFUL_ENABLED', () => { + expect(getLocalSettingsSchema(true, projectPath, true)).toEqual({ + IsEncrypted: false, + Values: { + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Node, + [azureWebJobsSecretStorageTypeKey]: azureStorageTypeSetting, + [workflowCodefulEnabled]: 'true', + }, + }); + }); + + it('omits ProjectDirectoryPath when no project path is supplied (root, codeless)', () => { + expect(getLocalSettingsSchema(false)).toEqual({ + IsEncrypted: false, + Values: { + [appKindSetting]: logicAppKind, + [workerRuntimeKey]: WorkerRuntime.Dotnet, + [azureWebJobsStorageKey]: localEmulatorConnectionString, + [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + }, + }); + }); + }); }); }); From 9b03c7512fdac3c917fa787e4b0f7b1cd0eb36db Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 8 Jul 2026 12:10:54 -0700 Subject: [PATCH 02/19] feat(vscode): regenerate local.settings.json and workflow-designtime for source-controlled projects When source control is enabled, the project-root local.settings.json and the workflow-designtime folder are gitignored, so a fresh clone lacks them and the design-time host cannot start. Add validateProjectArtifacts to detect and regenerate these artifacts: - Scan connections.json, parameters.json and workflows for @appsetting('X') references and ensure the root local.settings.json has the baseline schema plus placeholder entries for every referenced key (never overwriting values). - Validate the workflow-designtime folder (host.json + local.settings.json) and regenerate it when missing or invalid. Wired into startDesignTimeApi so artifacts are validated/regenerated before the host launches. Covered by validateProjectArtifacts.test.ts (incl. golden content by logic app type, reconstructed from shared constants) and updated startDesignTimeApi tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/startDesignTimeApi.test.ts | 13 +- .../__test__/validateProjectArtifacts.test.ts | 387 ++++++++++++++++++ .../app/utils/codeless/startDesignTimeApi.ts | 57 +-- .../codeless/validateProjectArtifacts.ts | 310 ++++++++++++++ 4 files changed, 722 insertions(+), 45 deletions(-) create mode 100644 apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts create mode 100644 apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts index 0e9d2d92c55..b8c92cf41f4 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { window, workspace } from 'vscode'; +import { Uri, window, workspace } from 'vscode'; import axios from 'axios'; import * as cp from 'child_process'; import { EventEmitter } from 'events'; @@ -15,6 +15,17 @@ vi.mock('../../appSettings/localSettings', () => ({ getLocalSettingsSchema: vi.fn(() => ({ Values: {} })), })); +vi.mock('../validateProjectArtifacts', () => ({ + regenerateLocalSettings: vi.fn(), + // Preserve the existing failure-injection semantics: the design-time startup tests drive + // success/failure through workspace.fs.createDirectory, so route the orchestrator through it. + validateAndRegenerateProjectArtifacts: vi.fn(async (_context: unknown, projectPath: string) => { + const designTimeDirectory = Uri.file(`${projectPath}/workflow-designtime`); + await workspace.fs.createDirectory(designTimeDirectory); + return designTimeDirectory; + }), +})); + vi.mock('../../fs', () => ({ writeFormattedJson: vi.fn(), })); diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts new file mode 100644 index 00000000000..3213040251d --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -0,0 +1,387 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; +import { workspace } from 'vscode'; +import * as fse from 'fs-extra'; +import { + ProjectDirectoryPathKey, + appKindSetting, + azureStorageTypeSetting, + azureWebJobsSecretStorageTypeKey, + azureWebJobsStorageKey, + defaultVersionRange, + extensionBundleId, + functionsInprocNet8Enabled, + functionsInprocNet8EnabledTrue, + localEmulatorConnectionString, + logicAppKind, + workerRuntimeKey, + workflowCodefulEnabled, +} from '../../../../constants'; +import * as localSettings from '../../appSettings/localSettings'; +import { writeFormattedJson } from '../../fs'; +import { isCodefulProject } from '../../codeful'; +import { + extractAppSettingReferences, + getReferencedAppSettings, + regenerateLocalSettings, + validateDesignTimeDirectory, + regenerateDesignTimeDirectory, +} from '../validateProjectArtifacts'; + +vi.mock('fs-extra', () => ({ + pathExists: vi.fn(), + readFile: vi.fn(), + readdir: vi.fn(), +})); + +vi.mock('../../appSettings/localSettings', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + addOrUpdateLocalAppSettings: vi.fn(), + getLocalSettingsJson: vi.fn(), + }; +}); + +vi.mock('../../fs', () => ({ + writeFormattedJson: vi.fn(), +})); + +vi.mock('../../codeful', () => ({ + isCodefulProject: vi.fn(() => Promise.resolve(false)), +})); + +const projectPath = '/workspace/LogicApp'; + +/** Normalize path separators so tests behave the same on Windows and POSIX. */ +const norm = (p: string): string => p.replace(/\\/g, '/'); + +const mockedFse = fse as unknown as { + pathExists: ReturnType; + readFile: ReturnType; + readdir: ReturnType; +}; +const mockedAddOrUpdate = localSettings.addOrUpdateLocalAppSettings as unknown as ReturnType; +const mockedGetLocalSettingsJson = localSettings.getLocalSettingsJson as unknown as ReturnType; +const mockedWriteFormattedJson = writeFormattedJson as unknown as ReturnType; +const mockedIsCodeful = isCodefulProject as unknown as ReturnType; + +/** Returns the object written via writeFormattedJson for the file whose path ends with fileName. */ +function writtenContentFor(fileName: string): unknown { + const call = mockedWriteFormattedJson.mock.calls.find((c) => norm(c[0] as string).endsWith(fileName)); + return call?.[1]; +} + +const context = { telemetry: { properties: {}, measurements: {} } } as any; + +/** Helper to back the fs-extra mocks with an in-memory, separator-agnostic file map. */ +function mockFiles(files: Record): void { + const normFiles: Record = {}; + for (const key of Object.keys(files)) { + normFiles[norm(key)] = files[key]; + } + const has = (p: string) => Object.prototype.hasOwnProperty.call(normFiles, norm(p)); + mockedFse.pathExists.mockImplementation((p: string) => Promise.resolve(has(p))); + mockedFse.readFile.mockImplementation((p: string) => Promise.resolve(Buffer.from(normFiles[norm(p)] ?? ''))); +} + +describe('validateProjectArtifacts', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedIsCodeful.mockResolvedValue(false); + mockedFse.readdir.mockResolvedValue([]); + (workspace as any).fs = { createDirectory: vi.fn(() => Promise.resolve()) }; + }); + + describe('extractAppSettingReferences', () => { + it('returns an empty array for empty content', () => { + expect(extractAppSettingReferences('')).toEqual([]); + expect(extractAppSettingReferences(undefined as unknown as string)).toEqual([]); + }); + + it('extracts plain and interpolated app setting references', () => { + const content = `{ + "a": "@appsetting('SETTING_ONE')", + "b": "/subscriptions/@{appsetting('SETTING_TWO')}/resourceGroups" + }`; + expect(extractAppSettingReferences(content).sort()).toEqual(['SETTING_ONE', 'SETTING_TWO']); + }); + + it('deduplicates repeated references and supports double quotes', () => { + const content = `@appsetting('DUP') @appsetting('DUP') @appsetting("DOUBLE")`; + expect(extractAppSettingReferences(content).sort()).toEqual(['DOUBLE', 'DUP']); + }); + }); + + describe('getReferencedAppSettings', () => { + it('aggregates references from connections.json, parameters.json and workflows', async () => { + mockFiles({ + [`${projectPath}/connections.json`]: `{ "k": "@appsetting('CONN_KEY')" }`, + [`${projectPath}/parameters.json`]: `{ "p": { "value": "@appsetting('PARAM_KEY')" } }`, + [`${projectPath}/wf1/workflow.json`]: `{ "d": "@{appsetting('WF_KEY')}" }`, + }); + mockedFse.readdir.mockResolvedValue(['wf1', 'connections.json']); + + const result = await getReferencedAppSettings(projectPath); + expect(result.sort()).toEqual(['CONN_KEY', 'PARAM_KEY', 'WF_KEY']); + }); + + it('returns an empty array when no artifacts reference app settings', async () => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + expect(await getReferencedAppSettings(projectPath)).toEqual([]); + }); + }); + + describe('regenerateLocalSettings', () => { + it('creates local.settings.json with baseline settings and referenced placeholders when missing', async () => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + mockedGetLocalSettingsJson.mockResolvedValue({ IsEncrypted: false, Values: {} }); + + const changed = await regenerateLocalSettings(context, projectPath); + + expect(changed).toBe(true); + expect(mockedAddOrUpdate).toHaveBeenCalledTimes(1); + const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; + expect(settingsAdded.APP_KIND).toBe('workflowapp'); + expect(settingsAdded.FUNCTIONS_WORKER_RUNTIME).toBeDefined(); + }); + + it('adds the full codeful baseline (incl. WORKFLOW_CODEFUL_ENABLED) when missing for a codeful project', async () => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + mockedIsCodeful.mockResolvedValue(true); + mockedGetLocalSettingsJson.mockResolvedValue({ IsEncrypted: false, Values: {} }); + + const changed = await regenerateLocalSettings(context, projectPath); + + expect(changed).toBe(true); + const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; + expect(settingsAdded).toEqual({ + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Dotnet, + [azureWebJobsStorageKey]: localEmulatorConnectionString, + [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + [workflowCodefulEnabled]: 'true', + }); + }); + + it('adds only missing referenced placeholders without overwriting existing values', async () => { + mockFiles({ + [`${projectPath}/local.settings.json`]: '{}', + [`${projectPath}/connections.json`]: `{ "k": "@appsetting('NEEDED_KEY')" }`, + }); + mockedFse.readdir.mockResolvedValue(['connections.json']); + mockedGetLocalSettingsJson.mockResolvedValue({ + IsEncrypted: false, + Values: { + APP_KIND: 'workflowapp', + FUNCTIONS_WORKER_RUNTIME: 'dotnet', + ProjectDirectoryPath: projectPath, + AzureWebJobsStorage: 'UseDevelopmentStorage=true', + FUNCTIONS_INPROC_NET8_ENABLED: '1', + EXISTING_SECRET: 'super-secret', + }, + }); + + const changed = await regenerateLocalSettings(context, projectPath); + + expect(changed).toBe(true); + const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; + expect(settingsAdded).toEqual({ NEEDED_KEY: '' }); + expect(settingsAdded.EXISTING_SECRET).toBeUndefined(); + }); + + it('does not update when the file exists and already contains everything required', async () => { + mockFiles({ [`${projectPath}/local.settings.json`]: '{}' }); + mockedFse.readdir.mockResolvedValue([]); + mockedGetLocalSettingsJson.mockResolvedValue({ + IsEncrypted: false, + Values: { + APP_KIND: 'workflowapp', + FUNCTIONS_WORKER_RUNTIME: 'dotnet', + ProjectDirectoryPath: projectPath, + AzureWebJobsStorage: 'UseDevelopmentStorage=true', + FUNCTIONS_INPROC_NET8_ENABLED: '1', + }, + }); + + const changed = await regenerateLocalSettings(context, projectPath); + + expect(changed).toBe(false); + expect(mockedAddOrUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('validateDesignTimeDirectory', () => { + const designTimeDir = `${projectPath}/workflow-designtime`; + const hostPath = `${designTimeDir}/host.json`; + const settingsPath = `${designTimeDir}/local.settings.json`; + + const validHost = JSON.stringify({ + version: '2.0', + extensionBundle: { id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows', version: '[1.*, 2.0.0)' }, + }); + const validSettings = JSON.stringify({ + Values: { APP_KIND: 'workflowapp', FUNCTIONS_WORKER_RUNTIME: 'node', ProjectDirectoryPath: projectPath }, + }); + + it('reports invalid when the directory does not exist', async () => { + mockFiles({}); + const result = await validateDesignTimeDirectory(projectPath); + expect(result).toEqual({ directoryExists: false, hostFileValid: false, settingsFileValid: false, isValid: false }); + }); + + it('reports valid when host.json and local.settings.json are well-formed', async () => { + mockFiles({ [designTimeDir]: '', [hostPath]: validHost, [settingsPath]: validSettings }); + + const result = await validateDesignTimeDirectory(projectPath); + expect(result.isValid).toBe(true); + expect(result.hostFileValid).toBe(true); + expect(result.settingsFileValid).toBe(true); + }); + + it('reports invalid host when the bundle id is wrong', async () => { + mockFiles({ + [designTimeDir]: '', + [hostPath]: '{"version":"2.0","extensionBundle":{"id":"Wrong"}}', + [settingsPath]: validSettings, + }); + + const result = await validateDesignTimeDirectory(projectPath); + expect(result.hostFileValid).toBe(false); + expect(result.isValid).toBe(false); + }); + }); + + describe('regenerateDesignTimeDirectory', () => { + const designTimeDir = `${projectPath}/workflow-designtime`; + + it('regenerates host.json and local.settings.json when the directory is missing', async () => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + + const dir = await regenerateDesignTimeDirectory(context, projectPath); + + expect(norm(dir.fsPath)).toContain('workflow-designtime'); + const writtenPaths = mockedWriteFormattedJson.mock.calls.map((c) => norm(c[0] as string)); + expect(writtenPaths.some((p) => p.includes('host.json'))).toBe(true); + expect(writtenPaths.some((p) => p.includes('local.settings.json'))).toBe(true); + expect(mockedAddOrUpdate).toHaveBeenCalled(); + }); + + it('preserves valid existing files and does not rewrite them', async () => { + const hostPath = `${designTimeDir}/host.json`; + const settingsPath = `${designTimeDir}/local.settings.json`; + const validHost = JSON.stringify({ + version: '2.0', + extensionBundle: { id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows', version: '[1.*, 2.0.0)' }, + }); + const validSettings = JSON.stringify({ + Values: { APP_KIND: 'workflowapp', FUNCTIONS_WORKER_RUNTIME: 'node', ProjectDirectoryPath: projectPath }, + }); + + mockFiles({ [designTimeDir]: '', [hostPath]: validHost, [settingsPath]: validSettings }); + + await regenerateDesignTimeDirectory(context, projectPath); + + expect(mockedWriteFormattedJson).not.toHaveBeenCalled(); + expect(mockedAddOrUpdate).not.toHaveBeenCalled(); + }); + + // Golden / characterization tests: lock the EXACT content written for each design-time file, + // Golden / characterization tests: lock the exact content written for each design-time file, per + // logic app type. The expected objects are reconstructed explicitly from the shared constants + // (rather than referencing hostFileContent / getLocalSettingsSchema directly), so a structural + // change to the generated files is caught while the key/value strings stay in sync with the + // named constants the codebase uses. + describe('golden content by logic app type', () => { + // Mirrors the host.json that startDesignTimeApi generated inline before the regeneration + // refactor. It is the regression baseline for the design-time host. + const goldenHostJson = { + version: '2.0', + extensionBundle: { + id: extensionBundleId, + version: defaultVersionRange, + }, + extensions: { + workflow: { + settings: { + 'Runtime.WorkflowOperationDiscoveryHostMode': 'true', + }, + }, + }, + }; + + it('writes host.json equal to the design-time host baseline (codeless)', async () => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + mockedIsCodeful.mockResolvedValue(false); + + await regenerateDesignTimeDirectory(context, projectPath); + + expect(writtenContentFor('host.json')).toEqual(goldenHostJson); + }); + + it('writes host.json equal to the design-time host baseline (codeful)', async () => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + mockedIsCodeful.mockResolvedValue(true); + + await regenerateDesignTimeDirectory(context, projectPath); + + // host.json is type-independent: the codeful project produces the same host.json. + expect(writtenContentFor('host.json')).toEqual(goldenHostJson); + }); + + it('writes design-time local.settings.json with the exact baseline and upserts Node runtime (codeless)', async () => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + mockedIsCodeful.mockResolvedValue(false); + + await regenerateDesignTimeDirectory(context, projectPath); + + expect(writtenContentFor('local.settings.json')).toEqual({ + IsEncrypted: false, + Values: { + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Node, + [azureWebJobsSecretStorageTypeKey]: azureStorageTypeSetting, + }, + }); + expect(mockedAddOrUpdate).toHaveBeenCalledWith( + context, + expect.stringContaining('workflow-designtime'), + { [appKindSetting]: logicAppKind, [ProjectDirectoryPathKey]: projectPath, [workerRuntimeKey]: WorkerRuntime.Node }, + true + ); + }); + + it('writes design-time local.settings.json with WORKFLOW_CODEFUL_ENABLED for a codeful project', async () => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + mockedIsCodeful.mockResolvedValue(true); + + await regenerateDesignTimeDirectory(context, projectPath); + + expect(writtenContentFor('local.settings.json')).toEqual({ + IsEncrypted: false, + Values: { + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Node, + [azureWebJobsSecretStorageTypeKey]: azureStorageTypeSetting, + [workflowCodefulEnabled]: 'true', + }, + }); + }); + }); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts index c1e4994343d..8bd0bd38e26 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -3,20 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { - ProjectDirectoryPathKey, autoStartDesignTimeSetting, defaultVersionRange, designTimeDirectoryName, designerStartApi, extensionBundleId, hostFileName, - localSettingsFileName, - logicAppKind, showStartDesignTimeMessageSetting, designerApiLoadTimeout, type hostFileContent, - workerRuntimeKey, - appKindSetting, } from '../../../constants'; import { ext } from '../../../extensionVariables'; @@ -26,12 +21,12 @@ import { ext } from '../../../extensionVariables'; const processValidationCache = new Map(); const VALIDATION_CACHE_TTL = 60000; // Cache for 60 seconds - revalidate every minute to catch process changes import { localize } from '../../../localize'; -import { addOrUpdateLocalAppSettings, getLocalSettingsSchema } from '../appSettings/localSettings'; import { updateFuncIgnore } from '../codeless/common'; import { writeFormattedJson } from '../fs'; import { getFunctionsCommand } from '../funcCoreTools/funcVersion'; import { getWorkspaceSetting, updateGlobalSetting } from '../vsCodeConfig/settings'; import { getWorkspaceLogicAppFolders } from '../workspace'; +import { regenerateLocalSettings, validateAndRegenerateProjectArtifacts } from './validateProjectArtifacts'; import { delay } from '../delay'; import { DialogResponses, @@ -41,7 +36,7 @@ import { callWithTelemetryAndErrorHandling, } from '@microsoft/vscode-azext-utils'; import type { ILocalSettingsJson } from '@microsoft/vscode-extension-logic-apps'; -import { Platform, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; +import { Platform } from '@microsoft/vscode-extension-logic-apps'; import axios from 'axios'; import * as cp from 'child_process'; import * as fs from 'fs'; @@ -53,7 +48,6 @@ import { Uri, window, workspace, type MessageItem } from 'vscode'; import { findChildProcess } from '../../commands/pickFuncProcess'; import find_process from 'find-process'; import { getChildProcessesWithScript } from '../findChildProcess/findChildProcess'; -import { hasCodefulSdkReference } from '../codeful'; import { ensureExtensionBundleHealthy, isExtensionBundleDownloadInFlight, @@ -254,41 +248,17 @@ export async function startDesignTimeApi(projectPath: string): Promise { try { ext.outputChannel.appendLog(localize('startingDesignTimeApi', 'Starting Design Time Api for project: {0}', projectPath)); - const designTimeDirectory: Uri | undefined = await getOrCreateDesignTimeDirectory(designTimeDirectoryName, projectPath); - const isCodeful = (await hasCodefulSdkReference(projectPath)) ?? false; - const settingsFileContent = getLocalSettingsSchema(true, projectPath, isCodeful); - - const hostFileContent: any = { - version: '2.0', - extensionBundle: { - id: extensionBundleId, - version: defaultVersionRange, - }, - extensions: { - workflow: { - settings: { - 'Runtime.WorkflowOperationDiscoveryHostMode': 'true', - }, - }, - }, - }; + // Validate and, when needed, regenerate the project artifacts required for a valid project: + // the project-level local.settings.json (derived from the logic app, connections.json and + // parameters.json) and the workflow-designtime directory baseline. This covers the case where + // source control is enabled and these git-ignored files are missing from a fresh clone. + const designTimeDirectory: Uri | undefined = await validateAndRegenerateProjectArtifacts(actionContext, projectPath); + if (!designTimeDirectory) { throw new Error(localize('DesignTimeDirectoryError', 'Failed to create design-time directory.')); } - await createJsonFile(designTimeDirectory, hostFileName, hostFileContent); - await createJsonFile(designTimeDirectory, localSettingsFileName, settingsFileContent); - await addOrUpdateLocalAppSettings( - actionContext, - designTimeDirectory.fsPath, - { - [appKindSetting]: logicAppKind, - [ProjectDirectoryPathKey]: projectPath, - [workerRuntimeKey]: WorkerRuntime.Node, - }, - true - ); const cwd: string = designTimeDirectory.fsPath; const portArgs = `--port ${designTimeInst.port}`; ext.outputChannel.appendLog( @@ -766,12 +736,11 @@ export async function promptStartDesignTimeOption(context: IActionContext) { } for (const projectPath of logicAppFolders) { - if (!fs.existsSync(path.join(projectPath, localSettingsFileName))) { - const isCodeful = (await hasCodefulSdkReference(projectPath)) ?? false; - const settingsFileContent = getLocalSettingsSchema(false, projectPath, isCodeful); - const projectUri: Uri = Uri.file(projectPath); - await createJsonFile(projectUri, localSettingsFileName, settingsFileContent); - } + // Ensure the project-level local.settings.json exists and includes every app setting the + // logic app references (from connections.json, parameters.json and the workflows). This keeps + // source-controlled projects valid when the git-ignored local.settings.json is missing. + await regenerateLocalSettings(context, projectPath); + if (autoStartDesignTime) { scheduleStartDesignTimeApi(projectPath); diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts new file mode 100644 index 00000000000..0768ebefcc4 --- /dev/null +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -0,0 +1,310 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { + ProjectDirectoryPathKey, + appKindSetting, + connectionsFileName, + designTimeDirectoryName, + extensionBundleId, + hostFileContent, + hostFileName, + localSettingsFileName, + logicAppKind, + parametersFileName, + workerRuntimeKey, + workflowFileName, +} from '../../../constants'; +import { localize } from '../../../localize'; +import { ext } from '../../../extensionVariables'; +import { addOrUpdateLocalAppSettings, getLocalSettingsJson, getLocalSettingsSchema } from '../appSettings/localSettings'; +import { writeFormattedJson } from '../fs'; +import { parseJson } from '../parseJson'; +import { isCodefulProject } from '../codeful'; +import { WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; +import type { ILocalSettingsJson } from '@microsoft/vscode-extension-logic-apps'; +import type { IActionContext } from '@microsoft/vscode-azext-utils'; +import * as fse from 'fs-extra'; +import * as path from 'path'; +import { Uri, workspace } from 'vscode'; + +/** + * Matches app setting references such as `@appsetting('MY_SETTING')` and the interpolated + * variant `@{appsetting('MY_SETTING')}`. Both single and double quotes are supported. + */ +const appSettingReferenceRegex = /appsetting\(\s*['"]([^'"]+)['"]\s*\)/g; + +/** + * App setting keys that are required for the design-time local.settings.json to be considered valid. + */ +const requiredDesignTimeSettingKeys = [appKindSetting, workerRuntimeKey, ProjectDirectoryPathKey]; + +/** + * Reads the text content of a file, returning an empty string when the file does not exist or cannot be read. + * @param {string} filePath - Absolute path to the file. + * @returns {Promise} The file content, or an empty string. + */ +async function readFileTextSafe(filePath: string): Promise { + try { + if (await fse.pathExists(filePath)) { + return (await fse.readFile(filePath)).toString(); + } + } catch { + // Ignore read errors and treat the file as empty. + } + return ''; +} + +/** + * Extracts the unique set of app setting names referenced through `@appsetting('name')` / + * `@{appsetting('name')}` expressions in the provided content. + * @param {string} content - Raw file content to scan. + * @returns {string[]} Unique app setting names referenced in the content. + */ +export function extractAppSettingReferences(content: string): string[] { + if (!content) { + return []; + } + + const keys = new Set(); + appSettingReferenceRegex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = appSettingReferenceRegex.exec(content)) !== null) { + if (match[1]) { + keys.add(match[1]); + } + } + + return Array.from(keys); +} + +/** + * Collects all app settings referenced by the logic app project. This scans connections.json, + * parameters.json, and every workflow.json in the project for `@appsetting('name')` references. + * @param {string} projectPath - The logic app project root. + * @returns {Promise} Unique app setting names referenced anywhere in the project. + */ +export async function getReferencedAppSettings(projectPath: string): Promise { + const keys = new Set(); + + const addReferences = (content: string): void => { + for (const key of extractAppSettingReferences(content)) { + keys.add(key); + } + }; + + addReferences(await readFileTextSafe(path.join(projectPath, connectionsFileName))); + addReferences(await readFileTextSafe(path.join(projectPath, parametersFileName))); + + try { + const subPaths: string[] = await fse.readdir(projectPath); + for (const subPath of subPaths) { + const workflowFilePath = path.join(projectPath, subPath, workflowFileName); + if (await fse.pathExists(workflowFilePath)) { + addReferences(await readFileTextSafe(workflowFilePath)); + } + } + } catch { + // If the project cannot be enumerated, fall back to connections/parameters references only. + } + + return Array.from(keys); +} + +/** + * Ensures the project-level local.settings.json exists and contains every app setting the project + * requires. This is needed when source control is enabled: local.settings.json is git-ignored, so a + * fresh clone is missing it and any `@appsetting('name')` references in connections.json / + * parameters.json / workflows resolve to undefined, making the project invalid. + * + * Baseline runtime settings are added when missing and every referenced app setting is added with an + * empty placeholder value when missing. Existing values are never overwritten. + * @param {IActionContext} context - The action context. + * @param {string} projectPath - The logic app project root. + * @returns {Promise} True when the file was created or updated, otherwise false. + */ +export async function regenerateLocalSettings(context: IActionContext, projectPath: string): Promise { + const localSettingsPath = path.join(projectPath, localSettingsFileName); + const fileExisted = await fse.pathExists(localSettingsPath); + + const isCodeful = (await isCodefulProject(projectPath)) ?? false; + const baselineValues = getLocalSettingsSchema(false, projectPath, isCodeful).Values ?? {}; + const referencedSettings = await getReferencedAppSettings(projectPath); + + const currentSettings: ILocalSettingsJson = await getLocalSettingsJson(context, localSettingsPath); + const currentValues = currentSettings.Values ?? {}; + + const settingsToAdd: Record = {}; + + for (const [key, value] of Object.entries(baselineValues)) { + if (currentValues[key] === undefined) { + settingsToAdd[key] = value; + } + } + + for (const key of referencedSettings) { + if (currentValues[key] === undefined && settingsToAdd[key] === undefined) { + settingsToAdd[key] = ''; + } + } + + if (!fileExisted || Object.keys(settingsToAdd).length > 0) { + await addOrUpdateLocalAppSettings(context, projectPath, settingsToAdd); + ext.outputChannel.appendLog( + localize( + 'regeneratedLocalSettings', + 'Ensured local settings for project "{0}". Added {1} setting(s).', + projectPath, + Object.keys(settingsToAdd).length + ) + ); + return true; + } + + return false; +} + +/** + * Validates the host.json file content in the design-time directory. + * @param {string} hostFilePath - Absolute path to the host.json file. + * @returns {Promise} True when host.json is present and structurally valid. + */ +async function isDesignTimeHostFileValid(hostFilePath: string): Promise { + const content = await readFileTextSafe(hostFilePath); + if (!content) { + return false; + } + + try { + const parsed = parseJson(content) as { version?: string; extensionBundle?: { id?: string } }; + return !!parsed?.version && parsed?.extensionBundle?.id === extensionBundleId; + } catch { + return false; + } +} + +/** + * Validates the local.settings.json file content in the design-time directory. + * @param {string} settingsFilePath - Absolute path to the design-time local.settings.json file. + * @returns {Promise} True when the file is present and contains the required keys. + */ +async function isDesignTimeSettingsFileValid(settingsFilePath: string): Promise { + const content = await readFileTextSafe(settingsFilePath); + if (!content) { + return false; + } + + try { + const parsed = parseJson(content) as ILocalSettingsJson; + const values = parsed?.Values ?? {}; + return requiredDesignTimeSettingKeys.every((key) => values[key] !== undefined && values[key] !== ''); + } catch { + return false; + } +} + +/** + * Describes the validation state of a design-time directory. + */ +export interface DesignTimeDirectoryValidation { + directoryExists: boolean; + hostFileValid: boolean; + settingsFileValid: boolean; + isValid: boolean; +} + +/** + * Validates that the workflow-designtime directory has the expected contents (host.json and + * local.settings.json with the required settings). + * @param {string} projectPath - The logic app project root. + * @returns {Promise} The validation result. + */ +export async function validateDesignTimeDirectory(projectPath: string): Promise { + const designTimeDirectoryPath = path.join(projectPath, designTimeDirectoryName); + const directoryExists = await fse.pathExists(designTimeDirectoryPath); + + if (!directoryExists) { + return { directoryExists: false, hostFileValid: false, settingsFileValid: false, isValid: false }; + } + + const hostFileValid = await isDesignTimeHostFileValid(path.join(designTimeDirectoryPath, hostFileName)); + const settingsFileValid = await isDesignTimeSettingsFileValid(path.join(designTimeDirectoryPath, localSettingsFileName)); + + return { + directoryExists: true, + hostFileValid, + settingsFileValid, + isValid: hostFileValid && settingsFileValid, + }; +} + +/** + * Ensures the workflow-designtime directory exists, creating it if necessary. + * @param {string} projectPath - The logic app project root. + * @returns {Promise} The design-time directory Uri. + */ +async function ensureDesignTimeDirectory(projectPath: string): Promise { + // When the project path already points inside the design-time directory, use it directly. + if (projectPath.includes(designTimeDirectoryName)) { + return Uri.file(projectPath); + } + + const designTimeDirectoryUri = Uri.file(path.join(projectPath, designTimeDirectoryName + path.sep)); + if (!(await fse.pathExists(designTimeDirectoryUri.fsPath))) { + await workspace.fs.createDirectory(designTimeDirectoryUri); + } + return designTimeDirectoryUri; +} + +/** + * Validates and, when needed, regenerates the workflow-designtime directory baseline contents + * (host.json and local.settings.json). Valid existing files are preserved so that customizations + * such as a pinned extension bundle version are not lost. + * @param {IActionContext} context - The action context. + * @param {string} projectPath - The logic app project root. + * @returns {Promise} The design-time directory Uri. + */ +export async function regenerateDesignTimeDirectory(context: IActionContext, projectPath: string): Promise { + const designTimeDirectory = await ensureDesignTimeDirectory(projectPath); + const validation = await validateDesignTimeDirectory(projectPath); + + if (!validation.hostFileValid) { + await writeFormattedJson(path.join(designTimeDirectory.fsPath, hostFileName), hostFileContent); + ext.outputChannel.appendLog(localize('regeneratedDesignTimeHost', 'Regenerated design-time host.json for project "{0}".', projectPath)); + } + + if (!validation.settingsFileValid) { + const isCodeful = (await isCodefulProject(projectPath)) ?? false; + const settingsFileContent = getLocalSettingsSchema(true, projectPath, isCodeful); + await writeFormattedJson(path.join(designTimeDirectory.fsPath, localSettingsFileName), settingsFileContent); + await addOrUpdateLocalAppSettings( + context, + designTimeDirectory.fsPath, + { + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Node, + }, + true + ); + ext.outputChannel.appendLog( + localize('regeneratedDesignTimeSettings', 'Regenerated design-time local.settings.json for project "{0}".', projectPath) + ); + } + + return designTimeDirectory; +} + +/** + * Validates and regenerates the artifacts required for a logic app project to be valid when source + * control strips git-ignored files: the project-level local.settings.json (built from the logic app, + * connections.json, and parameters.json) and the workflow-designtime directory baseline. + * @param {IActionContext} context - The action context. + * @param {string} projectPath - The logic app project root. + * @returns {Promise} The design-time directory Uri, ready to be used as the host working directory. + */ +export async function validateAndRegenerateProjectArtifacts(context: IActionContext, projectPath: string): Promise { + await regenerateLocalSettings(context, projectPath); + return regenerateDesignTimeDirectory(context, projectPath); +} From 4c1536095a9e5476c95bf06c329f5a94659105ec Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 8 Jul 2026 12:50:42 -0700 Subject: [PATCH 03/19] fix(vscode): align regenerated root local.settings.json key order with creation path getLocalSettingsSchema built the root (non-design-time) Values in a different key order than CreateLogicAppWorkspace.createLocalConfigurationFiles, so a regenerated local.settings.json was key-for-key reordered versus a freshly created project (e.g. APP_KIND/FUNCTIONS_WORKER_RUNTIME first instead of AzureWebJobsStorage first). Reorder the root branch to mirror the creation path (AzureWebJobsStorage, FUNCTIONS_INPROC_NET8_ENABLED, FUNCTIONS_WORKER_RUNTIME, APP_KIND, ProjectDirectoryPath, [WORKFLOW_CODEFUL_ENABLED]); design-time order is unchanged. Add key-order regression tests (toEqual ignores property order). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/localSettings.test.ts | 29 +++++++++++++ .../app/utils/appSettings/localSettings.ts | 41 ++++++++++--------- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts index 9d930d57120..4ee03e5d3ff 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts @@ -117,5 +117,34 @@ describe('utils/appSettings', () => { }); }); }); + + // Key ORDER regression: toEqual ignores property order, but writeFormattedJson serializes in + // insertion order, so the on-disk key order matters. The regenerated root local.settings.json + // must be key-for-key identical to a freshly created project (CreateLogicAppWorkspace), so these + // pin the exact order rather than just the set of keys. + describe('root key order matches the creation path', () => { + it('orders keys like CreateLogicAppWorkspace (codeless)', () => { + const keys = Object.keys(getLocalSettingsSchema(false, projectPath, false).Values); + expect(keys).toEqual([ + azureWebJobsStorageKey, + functionsInprocNet8Enabled, + workerRuntimeKey, + appKindSetting, + ProjectDirectoryPathKey, + ]); + }); + + it('appends WORKFLOW_CODEFUL_ENABLED last (codeful)', () => { + const keys = Object.keys(getLocalSettingsSchema(false, projectPath, true).Values); + expect(keys).toEqual([ + azureWebJobsStorageKey, + functionsInprocNet8Enabled, + workerRuntimeKey, + appKindSetting, + ProjectDirectoryPathKey, + workflowCodefulEnabled, + ]); + }); + }); }); }); diff --git a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts index c2a4e7b917b..3ae4f68a327 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts @@ -179,33 +179,36 @@ export async function getAzureWebJobsStorage(context: IActionContext, projectPat * @returns The local settings schema. */ export const getLocalSettingsSchema = (isDesignTime: boolean, projectPath?: string, isCodeful?: boolean): ILocalSettingsJson => { - const baseSettings: ILocalSettingsJson = { - IsEncrypted: false, - Values: { - [appKindSetting]: logicAppKind, - }, - }; - - // Add project path if provided - if (projectPath) { - baseSettings.Values![ProjectDirectoryPathKey] = projectPath; - } + const values: Record = {}; - // Add runtime-specific settings if (isDesignTime) { - baseSettings.Values![workerRuntimeKey] = WorkerRuntime.Node; - baseSettings.Values![azureWebJobsSecretStorageTypeKey] = azureStorageTypeSetting; + // Design-time order: APP_KIND, ProjectDirectoryPath, FUNCTIONS_WORKER_RUNTIME, AzureWebJobsSecretStorageType. + values[appKindSetting] = logicAppKind; + if (projectPath) { + values[ProjectDirectoryPathKey] = projectPath; + } + values[workerRuntimeKey] = WorkerRuntime.Node; + values[azureWebJobsSecretStorageTypeKey] = azureStorageTypeSetting; } else { - baseSettings.Values![workerRuntimeKey] = WorkerRuntime.Dotnet; - baseSettings.Values![azureWebJobsStorageKey] = localEmulatorConnectionString; - baseSettings.Values![functionsInprocNet8Enabled] = functionsInprocNet8EnabledTrue; + // Root order mirrors the creation path (CreateLogicAppWorkspace.createLocalConfigurationFiles) so a + // regenerated local.settings.json is key-for-key identical to a freshly created project. + values[azureWebJobsStorageKey] = localEmulatorConnectionString; + values[functionsInprocNet8Enabled] = functionsInprocNet8EnabledTrue; + values[workerRuntimeKey] = WorkerRuntime.Dotnet; + values[appKindSetting] = logicAppKind; + if (projectPath) { + values[ProjectDirectoryPathKey] = projectPath; + } } if (isCodeful) { - baseSettings.Values![workflowCodefulEnabledKey] = 'true'; + values[workflowCodefulEnabledKey] = 'true'; } - return baseSettings; + return { + IsEncrypted: false, + Values: values, + }; }; export async function removeAppKindFromLocalSettings(logicAppPath: string, context: IActionContext): Promise { From 38428452951ccc31fcaa262dc0412b4b6084e3b5 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 8 Jul 2026 13:15:06 -0700 Subject: [PATCH 04/19] test(vscode): cover codeful regeneration of an existing-but-invalid design-time dir Add a regression test for the codeful branch where workflow-designtime already exists but its host.json and local.settings.json are invalid (wrong bundle id / missing required keys). Verifies both artifacts are rewritten to the codeful baseline (host.json + local.settings.json incl. WORKFLOW_CODEFUL_ENABLED), closing the gap where only the missing-directory codeful path was covered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/validateProjectArtifacts.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index 3213040251d..ee85abfe846 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -382,6 +382,38 @@ describe('validateProjectArtifacts', () => { }, }); }); + + it('regenerates an existing-but-invalid design-time directory for a codeful project', async () => { + const designTimeDir = `${projectPath}/workflow-designtime`; + const hostPath = `${designTimeDir}/host.json`; + const settingsPath = `${designTimeDir}/local.settings.json`; + + // Directory exists but both files are invalid: host.json has the wrong bundle id and + // local.settings.json is missing the required keys, so regeneration is triggered by + // invalidation (not a missing directory). + mockFiles({ + [designTimeDir]: '', + [hostPath]: '{"version":"2.0","extensionBundle":{"id":"Wrong"}}', + [settingsPath]: '{"Values":{}}', + }); + mockedFse.readdir.mockResolvedValue([]); + mockedIsCodeful.mockResolvedValue(true); + + await regenerateDesignTimeDirectory(context, projectPath); + + // Both artifacts are rewritten to the codeful baseline. + expect(writtenContentFor('host.json')).toEqual(goldenHostJson); + expect(writtenContentFor('local.settings.json')).toEqual({ + IsEncrypted: false, + Values: { + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Node, + [azureWebJobsSecretStorageTypeKey]: azureStorageTypeSetting, + [workflowCodefulEnabled]: 'true', + }, + }); + }); }); }); }); From 1204623700448ef40af9c8c663de87152f11a581 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 8 Jul 2026 13:52:44 -0700 Subject: [PATCH 05/19] feat(vscode): regenerate project-level host.json for source-controlled projects The regeneration feature restored the root local.settings.json and the workflow-designtime folder, but never regenerated the project-level host.json. When source control strips this git-ignored file from a fresh clone the function host cannot start. Add regenerateRootHostFile (mirroring the workspace creation path host.json content) and wire it into validateAndRegenerateProjectArtifacts and promptStartDesignTimeOption. Rename isDesignTimeHostFileValid to isHostFileValid since it now validates both the root and design-time host.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/startDesignTimeApi.test.ts | 1 + .../__test__/validateProjectArtifacts.test.ts | 54 ++++++++++++++++++ .../app/utils/codeless/startDesignTimeApi.ts | 17 +++--- .../codeless/validateProjectArtifacts.ts | 57 +++++++++++++++++-- 4 files changed, 116 insertions(+), 13 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts index b8c92cf41f4..f7f39039a8e 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts @@ -17,6 +17,7 @@ vi.mock('../../appSettings/localSettings', () => ({ vi.mock('../validateProjectArtifacts', () => ({ regenerateLocalSettings: vi.fn(), + regenerateRootHostFile: vi.fn(), // Preserve the existing failure-injection semantics: the design-time startup tests drive // success/failure through workspace.fs.createDirectory, so route the orchestrator through it. validateAndRegenerateProjectArtifacts: vi.fn(async (_context: unknown, projectPath: string) => { diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index ee85abfe846..4186238dbe2 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -28,6 +28,7 @@ import { extractAppSettingReferences, getReferencedAppSettings, regenerateLocalSettings, + regenerateRootHostFile, validateDesignTimeDirectory, regenerateDesignTimeDirectory, } from '../validateProjectArtifacts'; @@ -416,4 +417,57 @@ describe('validateProjectArtifacts', () => { }); }); }); + + describe('regenerateRootHostFile', () => { + const rootHostPath = `${projectPath}/host.json`; + + // Mirrors the project-level host.json produced by the creation path + // (CreateLogicAppWorkspace.getHostContent). Regression baseline for the root host.json. + const goldenRootHostJson = { + version: '2.0', + logging: { + applicationInsights: { + samplingSettings: { + isEnabled: true, + excludedTypes: 'Request', + }, + }, + }, + extensionBundle: { + id: extensionBundleId, + version: defaultVersionRange, + }, + }; + + it('regenerates host.json when it is missing', async () => { + mockFiles({}); + + const created = await regenerateRootHostFile(projectPath); + + expect(created).toBe(true); + expect(writtenContentFor('host.json')).toEqual(goldenRootHostJson); + }); + + it('regenerates host.json when it exists but is invalid', async () => { + mockFiles({ [rootHostPath]: JSON.stringify({ version: '2.0', extensionBundle: { id: 'wrong.bundle.id' } }) }); + + const created = await regenerateRootHostFile(projectPath); + + expect(created).toBe(true); + expect(writtenContentFor('host.json')).toEqual(goldenRootHostJson); + }); + + it('preserves a valid existing host.json and does not rewrite it', async () => { + const validHost = JSON.stringify({ + version: '2.0', + extensionBundle: { id: extensionBundleId, version: '[1.50.0]' }, + }); + mockFiles({ [rootHostPath]: validHost }); + + const created = await regenerateRootHostFile(projectPath); + + expect(created).toBe(false); + expect(mockedWriteFormattedJson).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts index 8bd0bd38e26..f5a6ae20558 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -26,7 +26,7 @@ import { writeFormattedJson } from '../fs'; import { getFunctionsCommand } from '../funcCoreTools/funcVersion'; import { getWorkspaceSetting, updateGlobalSetting } from '../vsCodeConfig/settings'; import { getWorkspaceLogicAppFolders } from '../workspace'; -import { regenerateLocalSettings, validateAndRegenerateProjectArtifacts } from './validateProjectArtifacts'; +import { regenerateLocalSettings, regenerateRootHostFile, validateAndRegenerateProjectArtifacts } from './validateProjectArtifacts'; import { delay } from '../delay'; import { DialogResponses, @@ -249,9 +249,10 @@ export async function startDesignTimeApi(projectPath: string): Promise { ext.outputChannel.appendLog(localize('startingDesignTimeApi', 'Starting Design Time Api for project: {0}', projectPath)); // Validate and, when needed, regenerate the project artifacts required for a valid project: - // the project-level local.settings.json (derived from the logic app, connections.json and - // parameters.json) and the workflow-designtime directory baseline. This covers the case where - // source control is enabled and these git-ignored files are missing from a fresh clone. + // the project-level host.json and local.settings.json (derived from the logic app, + // connections.json and parameters.json) and the workflow-designtime directory baseline. This + // covers the case where source control is enabled and these git-ignored files are missing from + // a fresh clone. const designTimeDirectory: Uri | undefined = await validateAndRegenerateProjectArtifacts(actionContext, projectPath); @@ -736,9 +737,11 @@ export async function promptStartDesignTimeOption(context: IActionContext) { } for (const projectPath of logicAppFolders) { - // Ensure the project-level local.settings.json exists and includes every app setting the - // logic app references (from connections.json, parameters.json and the workflows). This keeps - // source-controlled projects valid when the git-ignored local.settings.json is missing. + // Ensure the project-level host.json and local.settings.json exist and that local.settings.json + // includes every app setting the logic app references (from connections.json, parameters.json + // and the workflows). This keeps source-controlled projects valid when these git-ignored files + // are missing. + await regenerateRootHostFile(projectPath); await regenerateLocalSettings(context, projectPath); diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts index 0768ebefcc4..c6fbb55145c 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -6,6 +6,7 @@ import { ProjectDirectoryPathKey, appKindSetting, connectionsFileName, + defaultVersionRange, designTimeDirectoryName, extensionBundleId, hostFileContent, @@ -23,7 +24,7 @@ import { writeFormattedJson } from '../fs'; import { parseJson } from '../parseJson'; import { isCodefulProject } from '../codeful'; import { WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; -import type { ILocalSettingsJson } from '@microsoft/vscode-extension-logic-apps'; +import type { IHostJsonV2, ILocalSettingsJson } from '@microsoft/vscode-extension-logic-apps'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import * as fse from 'fs-extra'; import * as path from 'path'; @@ -166,11 +167,54 @@ export async function regenerateLocalSettings(context: IActionContext, projectPa } /** - * Validates the host.json file content in the design-time directory. + * Returns the baseline project-level host.json content. This mirrors the workspace creation path + * (CreateLogicAppWorkspace.getHostContent) so a regenerated host.json matches a freshly created one. + * @returns {IHostJsonV2} The baseline host.json content. + */ +function getRootHostFileContent(): IHostJsonV2 { + return { + version: '2.0', + logging: { + applicationInsights: { + samplingSettings: { + isEnabled: true, + excludedTypes: 'Request', + }, + }, + }, + extensionBundle: { + id: extensionBundleId, + version: defaultVersionRange, + }, + }; +} + +/** + * Ensures the project-level host.json exists and is structurally valid. When source control is + * enabled this file can be missing from a fresh clone; without it the function host cannot start. + * A valid existing host.json (correct version + workflows extension bundle) is preserved so that + * customizations such as a pinned extension bundle version are not lost. + * @param {string} projectPath - The logic app project root. + * @returns {Promise} True when the file was created, otherwise false. + */ +export async function regenerateRootHostFile(projectPath: string): Promise { + const hostFilePath = path.join(projectPath, hostFileName); + if (await isHostFileValid(hostFilePath)) { + return false; + } + + await writeFormattedJson(hostFilePath, getRootHostFileContent()); + ext.outputChannel.appendLog(localize('regeneratedRootHost', 'Regenerated host.json for project "{0}".', projectPath)); + return true; +} + +/** + * Validates the host.json file content. Used for both the project-level host.json and the + * design-time host.json, since both require a version and the workflows extension bundle. * @param {string} hostFilePath - Absolute path to the host.json file. * @returns {Promise} True when host.json is present and structurally valid. */ -async function isDesignTimeHostFileValid(hostFilePath: string): Promise { +async function isHostFileValid(hostFilePath: string): Promise { const content = await readFileTextSafe(hostFilePath); if (!content) { return false; @@ -228,7 +272,7 @@ export async function validateDesignTimeDirectory(projectPath: string): Promise< return { directoryExists: false, hostFileValid: false, settingsFileValid: false, isValid: false }; } - const hostFileValid = await isDesignTimeHostFileValid(path.join(designTimeDirectoryPath, hostFileName)); + const hostFileValid = await isHostFileValid(path.join(designTimeDirectoryPath, hostFileName)); const settingsFileValid = await isDesignTimeSettingsFileValid(path.join(designTimeDirectoryPath, localSettingsFileName)); return { @@ -298,13 +342,14 @@ export async function regenerateDesignTimeDirectory(context: IActionContext, pro /** * Validates and regenerates the artifacts required for a logic app project to be valid when source - * control strips git-ignored files: the project-level local.settings.json (built from the logic app, - * connections.json, and parameters.json) and the workflow-designtime directory baseline. + * control strips git-ignored files: the project-level host.json and local.settings.json (built from + * the logic app, connections.json, and parameters.json) and the workflow-designtime directory baseline. * @param {IActionContext} context - The action context. * @param {string} projectPath - The logic app project root. * @returns {Promise} The design-time directory Uri, ready to be used as the host working directory. */ export async function validateAndRegenerateProjectArtifacts(context: IActionContext, projectPath: string): Promise { + await regenerateRootHostFile(projectPath); await regenerateLocalSettings(context, projectPath); return regenerateDesignTimeDirectory(context, projectPath); } From e033fd0bea631e3394aefd9ec628062fdc932387 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 8 Jul 2026 14:12:19 -0700 Subject: [PATCH 06/19] chore(vscode): add diagnostic logging for project artifact regeneration Adds output-channel logging at every decision point in the host.json / local.settings.json regeneration flow: which files are checked and whether they exist, whether each file was regenerated or preserved, and how many logic app folders were detected. Crucially logs when zero logic app folders are detected, which happens when host.json is missing (a folder is only recognized as a logic app when host.json exists), explaining why regeneration appears to do nothing in that case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../app/utils/codeless/startDesignTimeApi.ts | 26 +++++++++++ .../codeless/validateProjectArtifacts.ts | 43 +++++++++++++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts index f5a6ae20558..0396fec8280 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -715,6 +715,15 @@ export async function promptStartDesignTimeOption(context: IActionContext) { const showStartDesignTimeMessage = !!getWorkspaceSetting(showStartDesignTimeMessageSetting); let autoStartDesignTime = !!getWorkspaceSetting(autoStartDesignTimeSetting); + ext.outputChannel.appendLog( + localize( + 'detectedLogicAppFolders', + 'Detected {0} logic app project folder(s) for artifact regeneration: {1}.', + logicAppFolders.length, + logicAppFolders.join(', ') || '(none)' + ) + ); + if (logicAppFolders && logicAppFolders.length > 0) { if (!autoStartDesignTime && showStartDesignTimeMessage) { const message = localize( @@ -741,6 +750,9 @@ export async function promptStartDesignTimeOption(context: IActionContext) { // includes every app setting the logic app references (from connections.json, parameters.json // and the workflows). This keeps source-controlled projects valid when these git-ignored files // are missing. + ext.outputChannel.appendLog( + localize('ensuringProjectArtifacts', 'Ensuring host.json and local.settings.json for logic app "{0}".', projectPath) + ); await regenerateRootHostFile(projectPath); await regenerateLocalSettings(context, projectPath); @@ -749,7 +761,21 @@ export async function promptStartDesignTimeOption(context: IActionContext) { scheduleStartDesignTimeApi(projectPath); } } + } else { + // A folder is only recognized as a logic app project when its host.json is present. If host.json + // itself is missing the folder is not detected here, so host.json and local.settings.json cannot + // be regenerated on this path. Log this so the situation is diagnosable from the output channel. + ext.outputChannel.appendLog( + localize( + 'noLogicAppFoldersForRegen', + 'No logic app project folders were detected in the open workspace, so host.json and local.settings.json were not regenerated. A folder is only recognized as a logic app when its host.json exists; if host.json is missing, restore it (it is normally committed to source control) and reload the window.' + ) + ); } + } else { + ext.outputChannel.appendLog( + localize('noWorkspaceFoldersForRegen', 'No workspace folders are open. Skipping host.json and local.settings.json regeneration.') + ); } } diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts index c6fbb55145c..2b9ee1c91dd 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -128,6 +128,15 @@ export async function getReferencedAppSettings(projectPath: string): Promise { const localSettingsPath = path.join(projectPath, localSettingsFileName); const fileExisted = await fse.pathExists(localSettingsPath); + ext.outputChannel.appendLog( + localize( + 'checkingLocalSettings', + 'Checking local.settings.json for project "{0}" at "{1}" (exists: {2}).', + projectPath, + localSettingsPath, + String(fileExisted) + ) + ); const isCodeful = (await isCodefulProject(projectPath)) ?? false; const baselineValues = getLocalSettingsSchema(false, projectPath, isCodeful).Values ?? {}; @@ -155,14 +164,23 @@ export async function regenerateLocalSettings(context: IActionContext, projectPa ext.outputChannel.appendLog( localize( 'regeneratedLocalSettings', - 'Ensured local settings for project "{0}". Added {1} setting(s).', + 'Ensured local.settings.json for project "{0}" (file existed: {1}). Added {2} setting(s): {3}.', projectPath, - Object.keys(settingsToAdd).length + String(fileExisted), + Object.keys(settingsToAdd).length, + Object.keys(settingsToAdd).join(', ') || '(none)' ) ); return true; } + ext.outputChannel.appendLog( + localize( + 'localSettingsUpToDate', + 'local.settings.json for project "{0}" already exists and contains all required settings. Skipping regeneration.', + projectPath + ) + ); return false; } @@ -199,12 +217,28 @@ function getRootHostFileContent(): IHostJsonV2 { */ export async function regenerateRootHostFile(projectPath: string): Promise { const hostFilePath = path.join(projectPath, hostFileName); + const hostFileExisted = await fse.pathExists(hostFilePath); + ext.outputChannel.appendLog( + localize( + 'checkingRootHost', + 'Checking host.json for project "{0}" at "{1}" (exists: {2}).', + projectPath, + hostFilePath, + String(hostFileExisted) + ) + ); + if (await isHostFileValid(hostFilePath)) { + ext.outputChannel.appendLog( + localize('rootHostValid', 'host.json for project "{0}" is present and valid. Skipping regeneration.', projectPath) + ); return false; } await writeFormattedJson(hostFilePath, getRootHostFileContent()); - ext.outputChannel.appendLog(localize('regeneratedRootHost', 'Regenerated host.json for project "{0}".', projectPath)); + ext.outputChannel.appendLog( + localize('regeneratedRootHost', 'Regenerated missing or invalid host.json for project "{0}" at "{1}".', projectPath, hostFilePath) + ); return true; } @@ -349,6 +383,9 @@ export async function regenerateDesignTimeDirectory(context: IActionContext, pro * @returns {Promise} The design-time directory Uri, ready to be used as the host working directory. */ export async function validateAndRegenerateProjectArtifacts(context: IActionContext, projectPath: string): Promise { + ext.outputChannel.appendLog( + localize('validatingProjectArtifacts', 'Validating and regenerating project artifacts for logic app "{0}".', projectPath) + ); await regenerateRootHostFile(projectPath); await regenerateLocalSettings(context, projectPath); return regenerateDesignTimeDirectory(context, projectPath); From 4e5569e13a695e30f45317d9b3fbe9bb744e8724 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Wed, 8 Jul 2026 15:16:41 -0700 Subject: [PATCH 07/19] test(vscode): cover validateAndRegenerateProjectArtifacts orchestrator, design-time settings validation, and promptStartDesignTimeOption regeneration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/startDesignTimeApi.test.ts | 61 ++++++++- .../__test__/validateProjectArtifacts.test.ts | 117 ++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts index f7f39039a8e..863393257a6 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/startDesignTimeApi.test.ts @@ -8,7 +8,15 @@ import * as os from 'os'; import * as portfinder from 'portfinder'; import { ext } from '../../../../extensionVariables'; import * as workspaceUtils from '../../workspace'; -import { startAllDesignTimeApis, startDesignTimeApi, startDesignTimeProcess, stopDesignTimeApi } from '../startDesignTimeApi'; +import { + startAllDesignTimeApis, + startDesignTimeApi, + startDesignTimeProcess, + stopDesignTimeApi, + promptStartDesignTimeOption, +} from '../startDesignTimeApi'; +import { regenerateLocalSettings, regenerateRootHostFile } from '../validateProjectArtifacts'; +import { getWorkspaceSetting } from '../../vsCodeConfig/settings'; vi.mock('../../appSettings/localSettings', () => ({ addOrUpdateLocalAppSettings: vi.fn(), @@ -360,3 +368,54 @@ describe('startDesignTimeProcess', () => { expect(ext.outputChannel.appendLog).toHaveBeenCalledWith('Conflicting port found when launching func. Restarting design-time process.'); }); }); + +describe('promptStartDesignTimeOption', () => { + const context = { ui: { showWarningMessage: vi.fn() }, telemetry: { properties: {}, measurements: {} } } as any; + + beforeEach(() => { + vi.clearAllMocks(); + ext.designTimeInstances.clear(); + (workspace as any).workspaceFolders = []; + // Default: auto-start disabled and the prompt suppressed (getWorkspaceSetting -> undefined), so + // only the artifact-regeneration loop runs — no scheduled design-time startup, no warning dialog. + vi.mocked(getWorkspaceSetting).mockReturnValue(undefined as any); + }); + + it('regenerates host.json and local.settings.json for each detected logic app folder', async () => { + (workspace as any).workspaceFolders = [{ uri: { fsPath: 'D:/workspace' } }]; + vi.mocked(workspaceUtils.getWorkspaceLogicAppFolders).mockResolvedValue(['D:/workspace/app-one', 'D:/workspace/app-two']); + + await promptStartDesignTimeOption(context); + + expect(regenerateRootHostFile).toHaveBeenCalledWith('D:/workspace/app-one'); + expect(regenerateRootHostFile).toHaveBeenCalledWith('D:/workspace/app-two'); + expect(regenerateLocalSettings).toHaveBeenCalledWith(context, 'D:/workspace/app-one'); + expect(regenerateLocalSettings).toHaveBeenCalledWith(context, 'D:/workspace/app-two'); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( + 'Detected 2 logic app project folder(s) for artifact regeneration: D:/workspace/app-one, D:/workspace/app-two.' + ); + }); + + it('logs and skips regeneration when no logic app folders are detected', async () => { + (workspace as any).workspaceFolders = [{ uri: { fsPath: 'D:/workspace' } }]; + vi.mocked(workspaceUtils.getWorkspaceLogicAppFolders).mockResolvedValue([]); + + await promptStartDesignTimeOption(context); + + expect(regenerateRootHostFile).not.toHaveBeenCalled(); + expect(regenerateLocalSettings).not.toHaveBeenCalled(); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith(expect.stringContaining('No logic app project folders were detected')); + }); + + it('logs and skips regeneration when no workspace folders are open', async () => { + (workspace as any).workspaceFolders = undefined; + + await promptStartDesignTimeOption(context); + + expect(workspaceUtils.getWorkspaceLogicAppFolders).not.toHaveBeenCalled(); + expect(regenerateRootHostFile).not.toHaveBeenCalled(); + expect(ext.outputChannel.appendLog).toHaveBeenCalledWith( + 'No workspace folders are open. Skipping host.json and local.settings.json regeneration.' + ); + }); +}); diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index 4186238dbe2..48be20137b9 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -29,6 +29,7 @@ import { getReferencedAppSettings, regenerateLocalSettings, regenerateRootHostFile, + validateAndRegenerateProjectArtifacts, validateDesignTimeDirectory, regenerateDesignTimeDirectory, } from '../validateProjectArtifacts'; @@ -259,6 +260,34 @@ describe('validateProjectArtifacts', () => { expect(result.hostFileValid).toBe(false); expect(result.isValid).toBe(false); }); + + it('reports invalid settings when a required key has an empty value', async () => { + mockFiles({ + [designTimeDir]: '', + [hostPath]: validHost, + [settingsPath]: JSON.stringify({ + Values: { APP_KIND: '', FUNCTIONS_WORKER_RUNTIME: 'node', ProjectDirectoryPath: projectPath }, + }), + }); + + const result = await validateDesignTimeDirectory(projectPath); + expect(result.hostFileValid).toBe(true); + expect(result.settingsFileValid).toBe(false); + expect(result.isValid).toBe(false); + }); + + it('reports invalid settings when a required key is missing', async () => { + mockFiles({ + [designTimeDir]: '', + [hostPath]: validHost, + [settingsPath]: JSON.stringify({ Values: { FUNCTIONS_WORKER_RUNTIME: 'node', ProjectDirectoryPath: projectPath } }), + }); + + const result = await validateDesignTimeDirectory(projectPath); + expect(result.hostFileValid).toBe(true); + expect(result.settingsFileValid).toBe(false); + expect(result.isValid).toBe(false); + }); }); describe('regenerateDesignTimeDirectory', () => { @@ -470,4 +499,92 @@ describe('validateProjectArtifacts', () => { expect(mockedWriteFormattedJson).not.toHaveBeenCalled(); }); }); + + // The top-level orchestrator used by the design-time startup flow. These tests prove that a single + // call accounts for EVERY required artifact together: the project-root host.json, the project-root + // local.settings.json, and the workflow-designtime baseline (host.json + local.settings.json). + describe('validateAndRegenerateProjectArtifacts', () => { + const designTimeDir = `${projectPath}/workflow-designtime`; + const rootHostPath = `${projectPath}/host.json`; + const rootSettingsPath = `${projectPath}/local.settings.json`; + const validHostJson = JSON.stringify({ + version: '2.0', + extensionBundle: { id: extensionBundleId, version: '[1.*, 2.0.0)' }, + }); + const validDesignSettings = JSON.stringify({ + Values: { APP_KIND: 'workflowapp', FUNCTIONS_WORKER_RUNTIME: 'node', ProjectDirectoryPath: projectPath }, + }); + + it('regenerates the root host.json, root local.settings.json and design-time directory when all are missing', async () => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + mockedGetLocalSettingsJson.mockResolvedValue({ IsEncrypted: false, Values: {} }); + + const dir = await validateAndRegenerateProjectArtifacts(context, projectPath); + + const writtenPaths = mockedWriteFormattedJson.mock.calls.map((c) => norm(c[0] as string)); + // Project-root host.json (distinct from the design-time copy). + expect(writtenPaths).toContain(rootHostPath); + // Design-time baseline files. + expect(writtenPaths).toContain(`${designTimeDir}/host.json`); + expect(writtenPaths).toContain(`${designTimeDir}/local.settings.json`); + // Root local.settings.json baseline + design-time runtime settings are upserted. + expect(mockedAddOrUpdate).toHaveBeenCalled(); + // Returns the design-time directory to be used as the host working directory. + expect(norm(dir.fsPath)).toContain('workflow-designtime'); + }); + + it('preserves everything and writes nothing when all artifacts are already valid', async () => { + mockFiles({ + [rootHostPath]: validHostJson, + [rootSettingsPath]: '{}', + [designTimeDir]: '', + [`${designTimeDir}/host.json`]: validHostJson, + [`${designTimeDir}/local.settings.json`]: validDesignSettings, + }); + mockedFse.readdir.mockResolvedValue([]); + mockedGetLocalSettingsJson.mockResolvedValue({ + IsEncrypted: false, + Values: { + APP_KIND: 'workflowapp', + FUNCTIONS_WORKER_RUNTIME: 'dotnet', + ProjectDirectoryPath: projectPath, + AzureWebJobsStorage: 'UseDevelopmentStorage=true', + FUNCTIONS_INPROC_NET8_ENABLED: '1', + }, + }); + + const dir = await validateAndRegenerateProjectArtifacts(context, projectPath); + + expect(mockedWriteFormattedJson).not.toHaveBeenCalled(); + expect(mockedAddOrUpdate).not.toHaveBeenCalled(); + expect(norm(dir.fsPath)).toContain('workflow-designtime'); + }); + + it('regenerates only the root host.json when it alone is missing', async () => { + mockFiles({ + [rootSettingsPath]: '{}', + [designTimeDir]: '', + [`${designTimeDir}/host.json`]: validHostJson, + [`${designTimeDir}/local.settings.json`]: validDesignSettings, + }); + mockedFse.readdir.mockResolvedValue([]); + mockedGetLocalSettingsJson.mockResolvedValue({ + IsEncrypted: false, + Values: { + APP_KIND: 'workflowapp', + FUNCTIONS_WORKER_RUNTIME: 'dotnet', + ProjectDirectoryPath: projectPath, + AzureWebJobsStorage: 'UseDevelopmentStorage=true', + FUNCTIONS_INPROC_NET8_ENABLED: '1', + }, + }); + + await validateAndRegenerateProjectArtifacts(context, projectPath); + + const writtenPaths = mockedWriteFormattedJson.mock.calls.map((c) => norm(c[0] as string)); + expect(writtenPaths).toEqual([rootHostPath]); + expect(mockedAddOrUpdate).not.toHaveBeenCalled(); + }); + }); }); From 876fe04a60cb2391cf690cd08eeafd1ced88133d Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Thu, 9 Jul 2026 10:59:08 -0700 Subject: [PATCH 08/19] test(vscode): characterize pre-change regen local.settings baseline per logic app type Locks current regenerateLocalSettings output for logicApp/customCode/rulesEngine/codeful before refactoring. Documents the known gaps (customCode/rulesEngine undetected at regen time; codeful missing AzureWebJobsFeatureFlags) so upcoming fixes surface as intentional test diffs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/validateProjectArtifacts.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index 48be20137b9..ed923458f6b 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -221,6 +221,80 @@ describe('validateProjectArtifacts', () => { }); }); + // Characterization (pre-change baseline): locks the CURRENT regenerateLocalSettings output for each + // logic app type so upcoming changes are visible as intentional test diffs rather than silent + // regressions. Today regeneration only inspects isCodefulProject; it has NO detection for customCode + // or rulesEngine, so those types currently regenerate the plain codeless baseline WITHOUT the + // multi-language worker flag. Codeful adds WORKFLOW_CODEFUL_ENABLED but ALSO currently omits the + // AzureWebJobsFeatureFlags flag that the creation path adds. These gaps are what later stages fix. + describe('regenerateLocalSettings — characterization by logic app type (pre-change baseline)', () => { + const codelessBaseline = { + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + [workerRuntimeKey]: WorkerRuntime.Dotnet, + [azureWebJobsStorageKey]: localEmulatorConnectionString, + [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + }; + + beforeEach(() => { + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + mockedGetLocalSettingsJson.mockResolvedValue({ IsEncrypted: false, Values: {} }); + }); + + it('logicApp (codeless): regenerates the 5-key codeless baseline, no AzureWebJobsFeatureFlags', async () => { + mockedIsCodeful.mockResolvedValue(false); + + const changed = await regenerateLocalSettings(context, projectPath); + + expect(changed).toBe(true); + const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; + expect(settingsAdded).toEqual(codelessBaseline); + expect(settingsAdded).not.toHaveProperty('AzureWebJobsFeatureFlags'); + expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabled); + }); + + it('customCode: CURRENTLY regenerates the codeless baseline WITHOUT AzureWebJobsFeatureFlags (undetected type; fixed in a later stage)', async () => { + // customCode is not codeful, and regeneration has no way to detect it today, so it is treated + // identically to a plain codeless logic app (no EnableMultiLanguageWorker flag). + mockedIsCodeful.mockResolvedValue(false); + + const changed = await regenerateLocalSettings(context, projectPath); + + expect(changed).toBe(true); + const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; + expect(settingsAdded).toEqual(codelessBaseline); + expect(settingsAdded).not.toHaveProperty('AzureWebJobsFeatureFlags'); + }); + + it('rulesEngine: CURRENTLY regenerates the codeless baseline WITHOUT AzureWebJobsFeatureFlags (undetected type; fixed in a later stage)', async () => { + // rulesEngine, like customCode, is undetectable at regeneration time today and falls back to the + // codeless baseline. + mockedIsCodeful.mockResolvedValue(false); + + const changed = await regenerateLocalSettings(context, projectPath); + + expect(changed).toBe(true); + const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; + expect(settingsAdded).toEqual(codelessBaseline); + expect(settingsAdded).not.toHaveProperty('AzureWebJobsFeatureFlags'); + }); + + it('codeful: regenerates WORKFLOW_CODEFUL_ENABLED but CURRENTLY omits AzureWebJobsFeatureFlags (fixed in a later stage)', async () => { + mockedIsCodeful.mockResolvedValue(true); + + const changed = await regenerateLocalSettings(context, projectPath); + + expect(changed).toBe(true); + const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; + expect(settingsAdded).toEqual({ + ...codelessBaseline, + [workflowCodefulEnabled]: 'true', + }); + expect(settingsAdded).not.toHaveProperty('AzureWebJobsFeatureFlags'); + }); + }); + describe('validateDesignTimeDirectory', () => { const designTimeDir = `${projectPath}/workflow-designtime`; const hostPath = `${designTimeDir}/host.json`; From 7e37fb873ee206dde6421993e0cad92ccad41d2d Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Thu, 9 Jul 2026 11:22:53 -0700 Subject: [PATCH 09/19] refactor(vscode): share one root local.settings builder between creation and regeneration Extract getRootLocalSettings(logicAppFolderPath, logicAppType) as the single source of truth for the project-root local.settings.json. Both createLocalConfigurationFiles (fresh project creation) and regenerateLocalSettings (source-control regeneration) now build the file from it, so a regenerated file is key-for-key identical to a freshly created project of the same type. Regeneration already detects codeful via isCodefulProject, so codeful projects now regenerate with AzureWebJobsFeatureFlags in addition to WORKFLOW_CODEFUL_ENABLED, matching fresh creation. Also drops AzureFunctionsJobHost__extensionBundle__id from the codeful creation path per review feedback (codeful bundle id is not yet published). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CreateLogicAppWorkspace.ts | 38 +--------- .../__test__/CreateLogicAppWorkspace.test.ts | 7 +- .../__test__/localSettings.test.ts | 75 ++++++++++++++++++- .../app/utils/appSettings/localSettings.ts | 38 +++++++++- .../__test__/validateProjectArtifacts.test.ts | 27 ++++--- .../codeless/validateProjectArtifacts.ts | 14 +++- 6 files changed, 143 insertions(+), 56 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts index 07dca6b0144..4d973a5c728 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts @@ -1,32 +1,21 @@ import { - appKindSetting, artifactsDirectory, assetsFolderName, autoRuntimeDependenciesPathSettingKey, - azureWebJobsFeatureFlagsKey, - azureWebJobsStorageKey, defaultVersionRange, devContainerFolderName, extensionBundleId, extensionCommand, funcIgnoreFileName, - functionsInprocNet8Enabled, - functionsInprocNet8EnabledTrue, gitignoreFileName, hostFileName, libDirectory, - localEmulatorConnectionString, localSettingsFileName, - logicAppKind, lspDirectory, - multiLanguageWorkerSetting, - ProjectDirectoryPathKey, rulesDirectory, schemasDirectory, testsDirectoryName, vscodeFolderName, - workerRuntimeKey, - workflowCodefulEnabledKey, workflowFileName, } from '../../../../constants'; import { localize } from '../../../../localize'; @@ -43,14 +32,9 @@ import { gitInit, isGitInstalled, isInsideRepo } from '../../../utils/git'; import { writeFormattedJson } from '../../../utils/fs'; import { getCodelessWorkflowTemplate } from '../../../utils/codeless/templates'; import { CreateFunctionAppFiles } from './CreateFunctionAppFiles'; -import type { - IFunctionWizardContext, - IHostJsonV2, - ILocalSettingsJson, - IWebviewProjectContext, - StandardApp, -} from '@microsoft/vscode-extension-logic-apps'; -import { WorkerRuntime, ProjectType, WorkflowType } from '@microsoft/vscode-extension-logic-apps'; +import type { IFunctionWizardContext, IHostJsonV2, IWebviewProjectContext, StandardApp } from '@microsoft/vscode-extension-logic-apps'; +import { ProjectType, WorkflowType } from '@microsoft/vscode-extension-logic-apps'; +import { getRootLocalSettings } from '../../../utils/appSettings/localSettings'; import { createDevContainerContents, createLogicAppVsCodeContents } from './CreateLogicAppVSCodeContents'; import { logicAppPackageProcessing, unzipLogicAppPackageIntoWorkspace } from '../../../utils/cloudToLocalUtils'; import { isLogicAppProject } from '../../../utils/verifyIsProject'; @@ -213,24 +197,10 @@ export async function createLocalConfigurationFiles( '.debug', 'workflow-designtime/', ]; - const localSettingsJson: ILocalSettingsJson = { - IsEncrypted: false, - Values: { - [azureWebJobsStorageKey]: localEmulatorConnectionString, - [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, - [workerRuntimeKey]: WorkerRuntime.Dotnet, - [appKindSetting]: logicAppKind, - [ProjectDirectoryPathKey]: logicAppFolderPath, - }, - }; + const localSettingsJson = getRootLocalSettings(logicAppFolderPath, logicAppType); if (logicAppType !== ProjectType.logicApp) { funcignore.push('global.json'); - localSettingsJson.Values[azureWebJobsFeatureFlagsKey] = multiLanguageWorkerSetting; - } - - if (logicAppType === ProjectType.codeful) { - localSettingsJson.Values[workflowCodefulEnabledKey] = 'true'; } const hostJsonPath: string = path.join(logicAppFolderPath, hostFileName); diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts index d2400cdab10..c67175d3cb7 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/__test__/CreateLogicAppWorkspace.test.ts @@ -1523,7 +1523,7 @@ describe('createLocalConfigurationFiles', () => { const localSettingsData = localSettingsCall![1] as any; // Codeful is not a plain logic app (so it gets the multi-language worker flag) and additionally - // gets the codeful-enabled flag plus the job-host extension bundle id. + // gets the codeful-enabled flag. expect(localSettingsData.Values).toEqual({ AzureWebJobsStorage: 'UseDevelopmentStorage=true', FUNCTIONS_INPROC_NET8_ENABLED: '1', @@ -1532,11 +1532,10 @@ describe('createLocalConfigurationFiles', () => { ProjectDirectoryPath: path.join('test', 'workspace', 'TestLogicApp'), AzureWebJobsFeatureFlags: 'EnableMultiLanguageWorker', WORKFLOW_CODEFUL_ENABLED: 'true', - AzureFunctionsJobHost__extensionBundle__id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows', }); - // Verify exactly 8 properties exist (5 standard + feature flag + 2 codeful). - expect(Object.keys(localSettingsData.Values)).toHaveLength(8); + // Verify exactly 7 properties exist (5 standard + feature flag + codeful-enabled). + expect(Object.keys(localSettingsData.Values)).toHaveLength(7); }); it('should include extension bundle configuration in host.json', async () => { diff --git a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts index 4ee03e5d3ff..3d00ca45fed 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts @@ -1,16 +1,18 @@ import { describe, it, expect, vi } from 'vitest'; -import { WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; -import { getLocalSettingsSchema } from '../localSettings'; +import { ProjectType, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; +import { getLocalSettingsSchema, getRootLocalSettings } from '../localSettings'; import { ProjectDirectoryPathKey, appKindSetting, azureStorageTypeSetting, + azureWebJobsFeatureFlagsKey, azureWebJobsSecretStorageTypeKey, azureWebJobsStorageKey, functionsInprocNet8Enabled, functionsInprocNet8EnabledTrue, localEmulatorConnectionString, logicAppKind, + multiLanguageWorkerSetting, workerRuntimeKey, workflowCodefulEnabled, } from '../../../../constants'; @@ -147,4 +149,73 @@ describe('utils/appSettings', () => { }); }); }); + + // getRootLocalSettings is the single source of truth for the project-root local.settings.json shared + // by fresh project creation (CreateLogicAppWorkspace.createLocalConfigurationFiles) and regeneration + // (validateProjectArtifacts.regenerateLocalSettings). These golden tests lock the exact content and + // key order per ProjectType so the two paths cannot drift apart. + describe('getRootLocalSettings', () => { + const projectPath = 'path/to/project'; + + const baseValues = { + [azureWebJobsStorageKey]: localEmulatorConnectionString, + [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + [workerRuntimeKey]: WorkerRuntime.Dotnet, + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + }; + + it('logicApp: 5 base keys, no feature or codeful flags', () => { + expect(getRootLocalSettings(projectPath, ProjectType.logicApp)).toEqual({ + IsEncrypted: false, + Values: { ...baseValues }, + }); + }); + + it('customCode: adds the multi-language worker feature flag', () => { + expect(getRootLocalSettings(projectPath, ProjectType.customCode)).toEqual({ + IsEncrypted: false, + Values: { + ...baseValues, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, + }, + }); + }); + + it('rulesEngine: adds the multi-language worker feature flag', () => { + expect(getRootLocalSettings(projectPath, ProjectType.rulesEngine)).toEqual({ + IsEncrypted: false, + Values: { + ...baseValues, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, + }, + }); + }); + + it('codeful: adds the feature flag and WORKFLOW_CODEFUL_ENABLED (and no extension bundle id)', () => { + const settings = getRootLocalSettings(projectPath, ProjectType.codeful); + expect(settings).toEqual({ + IsEncrypted: false, + Values: { + ...baseValues, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, + [workflowCodefulEnabled]: 'true', + }, + }); + expect(settings.Values).not.toHaveProperty('AzureFunctionsJobHost__extensionBundle__id'); + }); + + it('orders keys deterministically for codeful (base keys, then feature flag, then codeful flag)', () => { + const keys = Object.keys(getRootLocalSettings(projectPath, ProjectType.codeful).Values); + expect(keys).toEqual([ + azureWebJobsStorageKey, + functionsInprocNet8Enabled, + workerRuntimeKey, + appKindSetting, + ProjectDirectoryPathKey, + azureWebJobsFeatureFlagsKey, + workflowCodefulEnabled, + ]); + }); + }); }); diff --git a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts index 3ae4f68a327..e3e884c02ad 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts @@ -8,8 +8,10 @@ import { ProjectDirectoryPathKey, appKindSetting, azureWebJobsSecretStorageTypeKey, + azureWebJobsFeatureFlagsKey, localEmulatorConnectionString, logicAppKind, + multiLanguageWorkerSetting, workerRuntimeKey, azureStorageTypeSetting, functionsInprocNet8Enabled, @@ -24,7 +26,7 @@ import { writeFormattedJson } from '../fs'; import { parseJson } from '../parseJson'; import { DialogResponses, parseError } from '@microsoft/vscode-azext-utils'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; -import { MismatchBehavior, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; +import { MismatchBehavior, ProjectType, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; import type { ILocalSettingsJson } from '@microsoft/vscode-extension-logic-apps'; import * as fse from 'fs-extra'; import * as path from 'path'; @@ -211,6 +213,40 @@ export const getLocalSettingsSchema = (isDesignTime: boolean, projectPath?: stri }; }; +/** + * Builds the project-root (runtime) local.settings.json content for a logic app of the given type. + * + * This is the single source of truth shared by the workspace-creation path + * (CreateLogicAppWorkspace.createLocalConfigurationFiles) and the regeneration path + * (validateProjectArtifacts.regenerateLocalSettings) so a regenerated file is key-for-key identical to + * what a freshly created project of the same type would produce and the two paths cannot drift apart. + * @param {string} logicAppFolderPath - Absolute path to the logic app project folder (ProjectDirectoryPath value). + * @param {ProjectType} logicAppType - The logic app project type. + * @returns {ILocalSettingsJson} The root local.settings.json content. + */ +export const getRootLocalSettings = (logicAppFolderPath: string, logicAppType: ProjectType): ILocalSettingsJson => { + const values: Record = { + [azureWebJobsStorageKey]: localEmulatorConnectionString, + [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + [workerRuntimeKey]: WorkerRuntime.Dotnet, + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: logicAppFolderPath, + }; + + if (logicAppType !== ProjectType.logicApp) { + values[azureWebJobsFeatureFlagsKey] = multiLanguageWorkerSetting; + } + + if (logicAppType === ProjectType.codeful) { + values[workflowCodefulEnabled] = 'true'; + } + + return { + IsEncrypted: false, + Values: values, + }; +}; + export async function removeAppKindFromLocalSettings(logicAppPath: string, context: IActionContext): Promise { const localSettingsPath: string = path.join(logicAppPath, localSettingsFileName); const settings: ILocalSettingsJson = await getLocalSettingsJson(context, localSettingsPath); diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index ed923458f6b..56cc4a5dc8a 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -10,6 +10,7 @@ import { ProjectDirectoryPathKey, appKindSetting, azureStorageTypeSetting, + azureWebJobsFeatureFlagsKey, azureWebJobsSecretStorageTypeKey, azureWebJobsStorageKey, defaultVersionRange, @@ -18,6 +19,7 @@ import { functionsInprocNet8EnabledTrue, localEmulatorConnectionString, logicAppKind, + multiLanguageWorkerSetting, workerRuntimeKey, workflowCodefulEnabled, } from '../../../../constants'; @@ -154,7 +156,7 @@ describe('validateProjectArtifacts', () => { expect(settingsAdded.FUNCTIONS_WORKER_RUNTIME).toBeDefined(); }); - it('adds the full codeful baseline (incl. WORKFLOW_CODEFUL_ENABLED) when missing for a codeful project', async () => { + it('adds the full codeful baseline (incl. WORKFLOW_CODEFUL_ENABLED and AzureWebJobsFeatureFlags) when missing for a codeful project', async () => { mockFiles({}); mockedFse.readdir.mockResolvedValue([]); mockedIsCodeful.mockResolvedValue(true); @@ -170,6 +172,7 @@ describe('validateProjectArtifacts', () => { [workerRuntimeKey]: WorkerRuntime.Dotnet, [azureWebJobsStorageKey]: localEmulatorConnectionString, [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, [workflowCodefulEnabled]: 'true', }); }); @@ -221,13 +224,13 @@ describe('validateProjectArtifacts', () => { }); }); - // Characterization (pre-change baseline): locks the CURRENT regenerateLocalSettings output for each - // logic app type so upcoming changes are visible as intentional test diffs rather than silent - // regressions. Today regeneration only inspects isCodefulProject; it has NO detection for customCode + // Behavior by logic app type: regeneration builds the root local.settings.json from the same shared + // source of truth as fresh project creation (getRootLocalSettings). Regeneration detects codeful via + // isCodefulProject, so codeful projects get BOTH the codeful-enabled flag and the multi-language + // worker flag — matching a freshly created codeful project. It still has NO detection for customCode // or rulesEngine, so those types currently regenerate the plain codeless baseline WITHOUT the - // multi-language worker flag. Codeful adds WORKFLOW_CODEFUL_ENABLED but ALSO currently omits the - // AzureWebJobsFeatureFlags flag that the creation path adds. These gaps are what later stages fix. - describe('regenerateLocalSettings — characterization by logic app type (pre-change baseline)', () => { + // multi-language worker flag; that gap is closed in a later stage. + describe('regenerateLocalSettings — behavior by logic app type', () => { const codelessBaseline = { [appKindSetting]: logicAppKind, [ProjectDirectoryPathKey]: projectPath, @@ -250,7 +253,7 @@ describe('validateProjectArtifacts', () => { expect(changed).toBe(true); const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; expect(settingsAdded).toEqual(codelessBaseline); - expect(settingsAdded).not.toHaveProperty('AzureWebJobsFeatureFlags'); + expect(settingsAdded).not.toHaveProperty(azureWebJobsFeatureFlagsKey); expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabled); }); @@ -264,7 +267,7 @@ describe('validateProjectArtifacts', () => { expect(changed).toBe(true); const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; expect(settingsAdded).toEqual(codelessBaseline); - expect(settingsAdded).not.toHaveProperty('AzureWebJobsFeatureFlags'); + expect(settingsAdded).not.toHaveProperty(azureWebJobsFeatureFlagsKey); }); it('rulesEngine: CURRENTLY regenerates the codeless baseline WITHOUT AzureWebJobsFeatureFlags (undetected type; fixed in a later stage)', async () => { @@ -277,10 +280,10 @@ describe('validateProjectArtifacts', () => { expect(changed).toBe(true); const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; expect(settingsAdded).toEqual(codelessBaseline); - expect(settingsAdded).not.toHaveProperty('AzureWebJobsFeatureFlags'); + expect(settingsAdded).not.toHaveProperty(azureWebJobsFeatureFlagsKey); }); - it('codeful: regenerates WORKFLOW_CODEFUL_ENABLED but CURRENTLY omits AzureWebJobsFeatureFlags (fixed in a later stage)', async () => { + it('codeful: regenerates the codeless baseline plus WORKFLOW_CODEFUL_ENABLED and AzureWebJobsFeatureFlags (matches fresh creation)', async () => { mockedIsCodeful.mockResolvedValue(true); const changed = await regenerateLocalSettings(context, projectPath); @@ -289,9 +292,9 @@ describe('validateProjectArtifacts', () => { const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; expect(settingsAdded).toEqual({ ...codelessBaseline, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, [workflowCodefulEnabled]: 'true', }); - expect(settingsAdded).not.toHaveProperty('AzureWebJobsFeatureFlags'); }); }); diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts index 2b9ee1c91dd..34b8bcd4426 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -19,11 +19,16 @@ import { } from '../../../constants'; import { localize } from '../../../localize'; import { ext } from '../../../extensionVariables'; -import { addOrUpdateLocalAppSettings, getLocalSettingsJson, getLocalSettingsSchema } from '../appSettings/localSettings'; +import { + addOrUpdateLocalAppSettings, + getLocalSettingsJson, + getLocalSettingsSchema, + getRootLocalSettings, +} from '../appSettings/localSettings'; import { writeFormattedJson } from '../fs'; import { parseJson } from '../parseJson'; import { isCodefulProject } from '../codeful'; -import { WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; +import { ProjectType, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; import type { IHostJsonV2, ILocalSettingsJson } from '@microsoft/vscode-extension-logic-apps'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; import * as fse from 'fs-extra'; @@ -139,7 +144,10 @@ export async function regenerateLocalSettings(context: IActionContext, projectPa ); const isCodeful = (await isCodefulProject(projectPath)) ?? false; - const baselineValues = getLocalSettingsSchema(false, projectPath, isCodeful).Values ?? {}; + // Build the baseline from the same source of truth as fresh project creation so a regenerated + // local.settings.json matches what a newly created project of this type would produce. + const logicAppType = isCodeful ? ProjectType.codeful : ProjectType.logicApp; + const baselineValues = getRootLocalSettings(projectPath, logicAppType).Values ?? {}; const referencedSettings = await getReferencedAppSettings(projectPath); const currentSettings: ILocalSettingsJson = await getLocalSettingsJson(context, localSettingsPath); From ad86c92539f9d603539aab04b1fe116af8f60b4c Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Thu, 9 Jul 2026 11:36:29 -0700 Subject: [PATCH 10/19] feat(vscode): detect logic app project type when regenerating local.settings Add detectLogicAppProjectType, which infers a source-controlled project's ProjectType from its files: codeful via isCodefulProject, and customCode/rulesEngine via a sibling custom-code functions (.csproj) project in the workspace root. regenerateLocalSettings now uses it so customCode and rulesEngine projects regenerate with AzureWebJobsFeatureFlags=EnableMultiLanguageWorker, matching what fresh creation would produce (previously they were treated as plain codeless). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/validateProjectArtifacts.test.ts | 89 +++++++++++++++---- .../codeless/validateProjectArtifacts.ts | 34 ++++++- 2 files changed, 104 insertions(+), 19 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index 56cc4a5dc8a..6529bdabc02 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; +import { ProjectType, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; import { workspace } from 'vscode'; +import * as path from 'path'; import * as fse from 'fs-extra'; import { ProjectDirectoryPathKey, @@ -26,7 +27,9 @@ import { import * as localSettings from '../../appSettings/localSettings'; import { writeFormattedJson } from '../../fs'; import { isCodefulProject } from '../../codeful'; +import { isCustomCodeFunctionsProjectInRoot } from '../../customCodeUtils'; import { + detectLogicAppProjectType, extractAppSettingReferences, getReferencedAppSettings, regenerateLocalSettings, @@ -59,6 +62,10 @@ vi.mock('../../codeful', () => ({ isCodefulProject: vi.fn(() => Promise.resolve(false)), })); +vi.mock('../../customCodeUtils', () => ({ + isCustomCodeFunctionsProjectInRoot: vi.fn(() => Promise.resolve(false)), +})); + const projectPath = '/workspace/LogicApp'; /** Normalize path separators so tests behave the same on Windows and POSIX. */ @@ -73,6 +80,7 @@ const mockedAddOrUpdate = localSettings.addOrUpdateLocalAppSettings as unknown a const mockedGetLocalSettingsJson = localSettings.getLocalSettingsJson as unknown as ReturnType; const mockedWriteFormattedJson = writeFormattedJson as unknown as ReturnType; const mockedIsCodeful = isCodefulProject as unknown as ReturnType; +const mockedIsCustomCodeInRoot = isCustomCodeFunctionsProjectInRoot as unknown as ReturnType; /** Returns the object written via writeFormattedJson for the file whose path ends with fileName. */ function writtenContentFor(fileName: string): unknown { @@ -97,6 +105,7 @@ describe('validateProjectArtifacts', () => { beforeEach(() => { vi.clearAllMocks(); mockedIsCodeful.mockResolvedValue(false); + mockedIsCustomCodeInRoot.mockResolvedValue(false); mockedFse.readdir.mockResolvedValue([]); (workspace as any).fs = { createDirectory: vi.fn(() => Promise.resolve()) }; }); @@ -225,11 +234,10 @@ describe('validateProjectArtifacts', () => { }); // Behavior by logic app type: regeneration builds the root local.settings.json from the same shared - // source of truth as fresh project creation (getRootLocalSettings). Regeneration detects codeful via - // isCodefulProject, so codeful projects get BOTH the codeful-enabled flag and the multi-language - // worker flag — matching a freshly created codeful project. It still has NO detection for customCode - // or rulesEngine, so those types currently regenerate the plain codeless baseline WITHOUT the - // multi-language worker flag; that gap is closed in a later stage. + // source of truth as fresh project creation (getRootLocalSettings). The project type is inferred from + // the project files (detectLogicAppProjectType): codeful via isCodefulProject, and customCode / + // rulesEngine via a sibling custom-code functions (.csproj) project in the workspace root. As a + // result every type regenerates the same content a freshly created project of that type would. describe('regenerateLocalSettings — behavior by logic app type', () => { const codelessBaseline = { [appKindSetting]: logicAppKind, @@ -247,6 +255,7 @@ describe('validateProjectArtifacts', () => { it('logicApp (codeless): regenerates the 5-key codeless baseline, no AzureWebJobsFeatureFlags', async () => { mockedIsCodeful.mockResolvedValue(false); + mockedIsCustomCodeInRoot.mockResolvedValue(false); const changed = await regenerateLocalSettings(context, projectPath); @@ -257,30 +266,38 @@ describe('validateProjectArtifacts', () => { expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabled); }); - it('customCode: CURRENTLY regenerates the codeless baseline WITHOUT AzureWebJobsFeatureFlags (undetected type; fixed in a later stage)', async () => { - // customCode is not codeful, and regeneration has no way to detect it today, so it is treated - // identically to a plain codeless logic app (no EnableMultiLanguageWorker flag). + it('customCode: regenerates the codeless baseline plus AzureWebJobsFeatureFlags (sibling custom-code project detected)', async () => { + // A sibling custom-code functions project in the workspace root identifies this as a customCode + // logic app, which gets the EnableMultiLanguageWorker flag just like fresh creation. mockedIsCodeful.mockResolvedValue(false); + mockedIsCustomCodeInRoot.mockResolvedValue(true); const changed = await regenerateLocalSettings(context, projectPath); expect(changed).toBe(true); const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; - expect(settingsAdded).toEqual(codelessBaseline); - expect(settingsAdded).not.toHaveProperty(azureWebJobsFeatureFlagsKey); + expect(settingsAdded).toEqual({ + ...codelessBaseline, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, + }); + expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabled); }); - it('rulesEngine: CURRENTLY regenerates the codeless baseline WITHOUT AzureWebJobsFeatureFlags (undetected type; fixed in a later stage)', async () => { - // rulesEngine, like customCode, is undetectable at regeneration time today and falls back to the - // codeless baseline. + it('rulesEngine: regenerates the codeless baseline plus AzureWebJobsFeatureFlags (indistinguishable from customCode, same content)', async () => { + // rulesEngine cannot be told apart from customCode at regeneration time, but both produce the same + // root local.settings.json, so detecting the sibling custom-code project is sufficient. mockedIsCodeful.mockResolvedValue(false); + mockedIsCustomCodeInRoot.mockResolvedValue(true); const changed = await regenerateLocalSettings(context, projectPath); expect(changed).toBe(true); const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; - expect(settingsAdded).toEqual(codelessBaseline); - expect(settingsAdded).not.toHaveProperty(azureWebJobsFeatureFlagsKey); + expect(settingsAdded).toEqual({ + ...codelessBaseline, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, + }); + expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabled); }); it('codeful: regenerates the codeless baseline plus WORKFLOW_CODEFUL_ENABLED and AzureWebJobsFeatureFlags (matches fresh creation)', async () => { @@ -298,6 +315,46 @@ describe('validateProjectArtifacts', () => { }); }); + describe('detectLogicAppProjectType', () => { + it('returns codeful when the project itself is a codeful project', async () => { + mockedIsCodeful.mockResolvedValue(true); + mockedIsCustomCodeInRoot.mockResolvedValue(true); + + // Codeful takes precedence even when a sibling custom-code project is also present. + expect(await detectLogicAppProjectType(projectPath)).toBe(ProjectType.codeful); + }); + + it('returns customCode when a sibling custom-code functions project exists in the workspace root', async () => { + mockedIsCodeful.mockResolvedValue(false); + mockedIsCustomCodeInRoot.mockResolvedValue(true); + + expect(await detectLogicAppProjectType(projectPath)).toBe(ProjectType.customCode); + }); + + it('returns logicApp when the project is neither codeful nor has a sibling custom-code project', async () => { + mockedIsCodeful.mockResolvedValue(false); + mockedIsCustomCodeInRoot.mockResolvedValue(false); + + expect(await detectLogicAppProjectType(projectPath)).toBe(ProjectType.logicApp); + }); + + it('treats undefined detection results as not-detected (logicApp)', async () => { + mockedIsCodeful.mockResolvedValue(undefined); + mockedIsCustomCodeInRoot.mockResolvedValue(undefined); + + expect(await detectLogicAppProjectType(projectPath)).toBe(ProjectType.logicApp); + }); + + it('inspects the workspace root (the parent of the logic app folder) for custom-code siblings', async () => { + mockedIsCodeful.mockResolvedValue(false); + mockedIsCustomCodeInRoot.mockResolvedValue(false); + + await detectLogicAppProjectType(projectPath); + + expect(mockedIsCustomCodeInRoot).toHaveBeenCalledWith(path.dirname(projectPath)); + }); + }); + describe('validateDesignTimeDirectory', () => { const designTimeDir = `${projectPath}/workflow-designtime`; const hostPath = `${designTimeDir}/host.json`; diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts index 34b8bcd4426..1e30e7d3a36 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -28,6 +28,7 @@ import { import { writeFormattedJson } from '../fs'; import { parseJson } from '../parseJson'; import { isCodefulProject } from '../codeful'; +import { isCustomCodeFunctionsProjectInRoot } from '../customCodeUtils'; import { ProjectType, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; import type { IHostJsonV2, ILocalSettingsJson } from '@microsoft/vscode-extension-logic-apps'; import type { IActionContext } from '@microsoft/vscode-azext-utils'; @@ -118,6 +119,33 @@ export async function getReferencedAppSettings(projectPath: string): Promise} The inferred project type. + */ +export async function detectLogicAppProjectType(projectPath: string): Promise { + if ((await isCodefulProject(projectPath)) ?? false) { + return ProjectType.codeful; + } + + const hasCustomCodeSibling = (await isCustomCodeFunctionsProjectInRoot(path.dirname(projectPath))) ?? false; + if (hasCustomCodeSibling) { + return ProjectType.customCode; + } + + return ProjectType.logicApp; +} + /** * Ensures the project-level local.settings.json exists and contains every app setting the project * requires. This is needed when source control is enabled: local.settings.json is git-ignored, so a @@ -143,10 +171,10 @@ export async function regenerateLocalSettings(context: IActionContext, projectPa ) ); - const isCodeful = (await isCodefulProject(projectPath)) ?? false; // Build the baseline from the same source of truth as fresh project creation so a regenerated - // local.settings.json matches what a newly created project of this type would produce. - const logicAppType = isCodeful ? ProjectType.codeful : ProjectType.logicApp; + // local.settings.json matches what a newly created project of this type would produce. The project + // type is inferred from the project files because a source-controlled clone has no explicit marker. + const logicAppType = await detectLogicAppProjectType(projectPath); const baselineValues = getRootLocalSettings(projectPath, logicAppType).Values ?? {}; const referencedSettings = await getReferencedAppSettings(projectPath); From eea1b32fe25ebecabf122dda9950093d4788046c Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Thu, 9 Jul 2026 11:41:43 -0700 Subject: [PATCH 11/19] fix(vscode): tighten host.json validation for bundle version and design-time discovery mode isHostFileValid now requires a non-empty extensionBundle.version, and for the design-time host.json additionally requires the workflow operation discovery host mode setting. Extract the setting key into a shared constant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/validateProjectArtifacts.test.ts | 43 ++++++++++++++++++- .../codeless/validateProjectArtifacts.ts | 32 +++++++++++--- apps/vs-code-designer/src/constants.ts | 6 ++- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index 6529bdabc02..eabe92b00a4 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -23,6 +23,7 @@ import { multiLanguageWorkerSetting, workerRuntimeKey, workflowCodefulEnabled, + workflowOperationDiscoveryHostModeKey, } from '../../../../constants'; import * as localSettings from '../../appSettings/localSettings'; import { writeFormattedJson } from '../../fs'; @@ -363,6 +364,7 @@ describe('validateProjectArtifacts', () => { const validHost = JSON.stringify({ version: '2.0', extensionBundle: { id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows', version: '[1.*, 2.0.0)' }, + extensions: { workflow: { settings: { [workflowOperationDiscoveryHostModeKey]: 'true' } } }, }); const validSettings = JSON.stringify({ Values: { APP_KIND: 'workflowapp', FUNCTIONS_WORKER_RUNTIME: 'node', ProjectDirectoryPath: projectPath }, @@ -395,6 +397,37 @@ describe('validateProjectArtifacts', () => { expect(result.isValid).toBe(false); }); + it('reports invalid host when the extension bundle version is missing', async () => { + mockFiles({ + [designTimeDir]: '', + [hostPath]: JSON.stringify({ + version: '2.0', + extensionBundle: { id: extensionBundleId }, + extensions: { workflow: { settings: { [workflowOperationDiscoveryHostModeKey]: 'true' } } }, + }), + [settingsPath]: validSettings, + }); + + const result = await validateDesignTimeDirectory(projectPath); + expect(result.hostFileValid).toBe(false); + expect(result.isValid).toBe(false); + }); + + it('reports invalid host when the design-time discovery host mode setting is missing', async () => { + mockFiles({ + [designTimeDir]: '', + [hostPath]: JSON.stringify({ + version: '2.0', + extensionBundle: { id: extensionBundleId, version: defaultVersionRange }, + }), + [settingsPath]: validSettings, + }); + + const result = await validateDesignTimeDirectory(projectPath); + expect(result.hostFileValid).toBe(false); + expect(result.isValid).toBe(false); + }); + it('reports invalid settings when a required key has an empty value', async () => { mockFiles({ [designTimeDir]: '', @@ -446,6 +479,7 @@ describe('validateProjectArtifacts', () => { const validHost = JSON.stringify({ version: '2.0', extensionBundle: { id: 'Microsoft.Azure.Functions.ExtensionBundle.Workflows', version: '[1.*, 2.0.0)' }, + extensions: { workflow: { settings: { [workflowOperationDiscoveryHostModeKey]: 'true' } } }, }); const validSettings = JSON.stringify({ Values: { APP_KIND: 'workflowapp', FUNCTIONS_WORKER_RUNTIME: 'node', ProjectDirectoryPath: projectPath }, @@ -645,6 +679,11 @@ describe('validateProjectArtifacts', () => { version: '2.0', extensionBundle: { id: extensionBundleId, version: '[1.*, 2.0.0)' }, }); + const validDesignHostJson = JSON.stringify({ + version: '2.0', + extensionBundle: { id: extensionBundleId, version: '[1.*, 2.0.0)' }, + extensions: { workflow: { settings: { [workflowOperationDiscoveryHostModeKey]: 'true' } } }, + }); const validDesignSettings = JSON.stringify({ Values: { APP_KIND: 'workflowapp', FUNCTIONS_WORKER_RUNTIME: 'node', ProjectDirectoryPath: projectPath }, }); @@ -673,7 +712,7 @@ describe('validateProjectArtifacts', () => { [rootHostPath]: validHostJson, [rootSettingsPath]: '{}', [designTimeDir]: '', - [`${designTimeDir}/host.json`]: validHostJson, + [`${designTimeDir}/host.json`]: validDesignHostJson, [`${designTimeDir}/local.settings.json`]: validDesignSettings, }); mockedFse.readdir.mockResolvedValue([]); @@ -699,7 +738,7 @@ describe('validateProjectArtifacts', () => { mockFiles({ [rootSettingsPath]: '{}', [designTimeDir]: '', - [`${designTimeDir}/host.json`]: validHostJson, + [`${designTimeDir}/host.json`]: validDesignHostJson, [`${designTimeDir}/local.settings.json`]: validDesignSettings, }); mockedFse.readdir.mockResolvedValue([]); diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts index 1e30e7d3a36..923e51b7b94 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -16,6 +16,7 @@ import { parametersFileName, workerRuntimeKey, workflowFileName, + workflowOperationDiscoveryHostModeKey, } from '../../../constants'; import { localize } from '../../../localize'; import { ext } from '../../../extensionVariables'; @@ -264,7 +265,7 @@ export async function regenerateRootHostFile(projectPath: string): Promise} True when host.json is present and structurally valid. */ -async function isHostFileValid(hostFilePath: string): Promise { +async function isHostFileValid(hostFilePath: string, isDesignTime: boolean): Promise { const content = await readFileTextSafe(hostFilePath); if (!content) { return false; } try { - const parsed = parseJson(content) as { version?: string; extensionBundle?: { id?: string } }; - return !!parsed?.version && parsed?.extensionBundle?.id === extensionBundleId; + const parsed = parseJson(content) as { + version?: string; + extensionBundle?: { id?: string; version?: string }; + extensions?: { workflow?: { settings?: Record } }; + }; + + const hasValidBundle = !!parsed?.version && parsed?.extensionBundle?.id === extensionBundleId && !!parsed?.extensionBundle?.version; + if (!hasValidBundle) { + return false; + } + + if (isDesignTime) { + return !!parsed?.extensions?.workflow?.settings?.[workflowOperationDiscoveryHostModeKey]; + } + + return true; } catch { return false; } @@ -342,7 +360,7 @@ export async function validateDesignTimeDirectory(projectPath: string): Promise< return { directoryExists: false, hostFileValid: false, settingsFileValid: false, isValid: false }; } - const hostFileValid = await isHostFileValid(path.join(designTimeDirectoryPath, hostFileName)); + const hostFileValid = await isHostFileValid(path.join(designTimeDirectoryPath, hostFileName), true); const settingsFileValid = await isDesignTimeSettingsFileValid(path.join(designTimeDirectoryPath, localSettingsFileName)); return { diff --git a/apps/vs-code-designer/src/constants.ts b/apps/vs-code-designer/src/constants.ts index 521c530047c..434c16b2891 100644 --- a/apps/vs-code-designer/src/constants.ts +++ b/apps/vs-code-designer/src/constants.ts @@ -332,6 +332,10 @@ export const defaultExtensionBundlePathValue = path.join( export const defaultDataMapperVersion = 2; export const defaultDesignerVersion = 1; +// The design-time host.json enables workflow operation discovery host mode so the design-time API can +// enumerate operations. Shared between the generated host.json content and its validation. +export const workflowOperationDiscoveryHostModeKey = 'Runtime.WorkflowOperationDiscoveryHostMode'; + // Fallback Dependency Versions export const DependencyVersion = { dotnet8: '8.0.318', @@ -349,7 +353,7 @@ export const hostFileContent = { extensions: { workflow: { settings: { - 'Runtime.WorkflowOperationDiscoveryHostMode': 'true', + [workflowOperationDiscoveryHostModeKey]: 'true', }, }, }, From dc7f4b452a349677a5958294855b1bd0499b7274 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Thu, 9 Jul 2026 11:43:34 -0700 Subject: [PATCH 12/19] fix(vscode): use path-segment boundary when detecting the design-time directory ensureDesignTimeDirectory previously used projectPath.includes(designTimeDirectoryName), which false-positives on siblings such as workflow-designtime-backup. Match on a full path segment instead so a backup folder gets a nested workflow-designtime directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/validateProjectArtifacts.test.ts | 15 +++++++++++++++ .../utils/codeless/validateProjectArtifacts.ts | 6 ++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index eabe92b00a4..860edb31b64 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -493,6 +493,21 @@ describe('validateProjectArtifacts', () => { expect(mockedAddOrUpdate).not.toHaveBeenCalled(); }); + it('creates a nested workflow-designtime directory for a "workflow-designtime-backup" sibling (path-segment boundary)', async () => { + // A project path whose last segment merely CONTAINS the design-time name as a substring + // (e.g. workflow-designtime-backup) must not be treated as the design-time directory itself. + const backupPath = `${projectPath}/workflow-designtime-backup`; + mockFiles({}); + mockedFse.readdir.mockResolvedValue([]); + + const dir = await regenerateDesignTimeDirectory(context, backupPath); + + // The design-time directory is nested UNDER the backup folder, not the backup folder itself. + expect(norm(dir.fsPath)).toContain('workflow-designtime-backup/workflow-designtime'); + const writtenPaths = mockedWriteFormattedJson.mock.calls.map((c) => norm(c[0] as string)); + expect(writtenPaths.some((p) => p.includes('workflow-designtime-backup/workflow-designtime/host.json'))).toBe(true); + }); + // Golden / characterization tests: lock the EXACT content written for each design-time file, // Golden / characterization tests: lock the exact content written for each design-time file, per // logic app type. The expected objects are reconstructed explicitly from the shared constants diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts index 923e51b7b94..320c7a45b58 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -377,8 +377,10 @@ export async function validateDesignTimeDirectory(projectPath: string): Promise< * @returns {Promise} The design-time directory Uri. */ async function ensureDesignTimeDirectory(projectPath: string): Promise { - // When the project path already points inside the design-time directory, use it directly. - if (projectPath.includes(designTimeDirectoryName)) { + // When the project path already points at (or inside) the design-time directory, use it directly. + // Match on a full path segment so siblings like "workflow-designtime-backup" don't false-positive. + const pathSegments = projectPath.split(/[\\/]/); + if (pathSegments.includes(designTimeDirectoryName)) { return Uri.file(projectPath); } From 84547051eeef3d60cc0b399757c025f49c89af20 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Thu, 9 Jul 2026 11:46:09 -0700 Subject: [PATCH 13/19] refactor(vscode): prune redundant inline comments around artifact regeneration Trim inline comments that restated the validateAndRegenerateProjectArtifacts, regenerateRootHostFile and regenerateLocalSettings docstrings, and remove a duplicated golden-test comment line. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/validateProjectArtifacts.test.ts | 1 - .../src/app/utils/codeless/startDesignTimeApi.ts | 13 ++++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index 860edb31b64..8ab7d62c023 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -508,7 +508,6 @@ describe('validateProjectArtifacts', () => { expect(writtenPaths.some((p) => p.includes('workflow-designtime-backup/workflow-designtime/host.json'))).toBe(true); }); - // Golden / characterization tests: lock the EXACT content written for each design-time file, // Golden / characterization tests: lock the exact content written for each design-time file, per // logic app type. The expected objects are reconstructed explicitly from the shared constants // (rather than referencing hostFileContent / getLocalSettingsSchema directly), so a structural diff --git a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts index 0396fec8280..ad827b08e38 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -248,11 +248,8 @@ export async function startDesignTimeApi(projectPath: string): Promise { try { ext.outputChannel.appendLog(localize('startingDesignTimeApi', 'Starting Design Time Api for project: {0}', projectPath)); - // Validate and, when needed, regenerate the project artifacts required for a valid project: - // the project-level host.json and local.settings.json (derived from the logic app, - // connections.json and parameters.json) and the workflow-designtime directory baseline. This - // covers the case where source control is enabled and these git-ignored files are missing from - // a fresh clone. + // Regenerate any git-ignored project artifacts (host.json, local.settings.json, + // workflow-designtime) that a source-controlled clone may be missing before starting the host. const designTimeDirectory: Uri | undefined = await validateAndRegenerateProjectArtifacts(actionContext, projectPath); @@ -746,10 +743,8 @@ export async function promptStartDesignTimeOption(context: IActionContext) { } for (const projectPath of logicAppFolders) { - // Ensure the project-level host.json and local.settings.json exist and that local.settings.json - // includes every app setting the logic app references (from connections.json, parameters.json - // and the workflows). This keeps source-controlled projects valid when these git-ignored files - // are missing. + // Keep source-controlled projects valid by regenerating the git-ignored host.json / + // local.settings.json (incl. every app setting the logic app references). ext.outputChannel.appendLog( localize('ensuringProjectArtifacts', 'Ensuring host.json and local.settings.json for logic app "{0}".', projectPath) ); From 1f8721846f90348034b71c5f9623a14ab86b6599 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Thu, 9 Jul 2026 12:31:55 -0700 Subject: [PATCH 14/19] chore(vscode): align regeneration code with upstream codeful API renames Rebase onto main picked up upstream's renames of the codeful constant (workflowCodefulEnabled -> workflowCodefulEnabledKey) and SDK-detection helper (isCodefulProject -> hasCodefulSdkReference). Update the artifact regeneration module and its tests to use the current symbol names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__test__/localSettings.test.ts | 12 +++++----- .../app/utils/appSettings/localSettings.ts | 2 +- .../__test__/validateProjectArtifacts.test.ts | 24 +++++++++---------- .../app/utils/codeless/startDesignTimeApi.ts | 2 -- .../codeless/validateProjectArtifacts.ts | 8 +++---- 5 files changed, 23 insertions(+), 25 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts index 3d00ca45fed..88dd475de3a 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts @@ -14,7 +14,7 @@ import { logicAppKind, multiLanguageWorkerSetting, workerRuntimeKey, - workflowCodefulEnabled, + workflowCodefulEnabledKey, } from '../../../../constants'; describe('utils/appSettings', () => { @@ -77,7 +77,7 @@ describe('utils/appSettings', () => { [workerRuntimeKey]: WorkerRuntime.Dotnet, [azureWebJobsStorageKey]: localEmulatorConnectionString, [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, - [workflowCodefulEnabled]: 'true', + [workflowCodefulEnabledKey]: 'true', }, }); }); @@ -102,7 +102,7 @@ describe('utils/appSettings', () => { [ProjectDirectoryPathKey]: projectPath, [workerRuntimeKey]: WorkerRuntime.Node, [azureWebJobsSecretStorageTypeKey]: azureStorageTypeSetting, - [workflowCodefulEnabled]: 'true', + [workflowCodefulEnabledKey]: 'true', }, }); }); @@ -144,7 +144,7 @@ describe('utils/appSettings', () => { workerRuntimeKey, appKindSetting, ProjectDirectoryPathKey, - workflowCodefulEnabled, + workflowCodefulEnabledKey, ]); }); }); @@ -199,7 +199,7 @@ describe('utils/appSettings', () => { Values: { ...baseValues, [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, - [workflowCodefulEnabled]: 'true', + [workflowCodefulEnabledKey]: 'true', }, }); expect(settings.Values).not.toHaveProperty('AzureFunctionsJobHost__extensionBundle__id'); @@ -214,7 +214,7 @@ describe('utils/appSettings', () => { appKindSetting, ProjectDirectoryPathKey, azureWebJobsFeatureFlagsKey, - workflowCodefulEnabled, + workflowCodefulEnabledKey, ]); }); }); diff --git a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts index e3e884c02ad..ccc088e3876 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts @@ -238,7 +238,7 @@ export const getRootLocalSettings = (logicAppFolderPath: string, logicAppType: P } if (logicAppType === ProjectType.codeful) { - values[workflowCodefulEnabled] = 'true'; + values[workflowCodefulEnabledKey] = 'true'; } return { diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index 8ab7d62c023..76be4b0d932 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -22,12 +22,12 @@ import { logicAppKind, multiLanguageWorkerSetting, workerRuntimeKey, - workflowCodefulEnabled, + workflowCodefulEnabledKey, workflowOperationDiscoveryHostModeKey, } from '../../../../constants'; import * as localSettings from '../../appSettings/localSettings'; import { writeFormattedJson } from '../../fs'; -import { isCodefulProject } from '../../codeful'; +import { hasCodefulSdkReference } from '../../codeful'; import { isCustomCodeFunctionsProjectInRoot } from '../../customCodeUtils'; import { detectLogicAppProjectType, @@ -60,7 +60,7 @@ vi.mock('../../fs', () => ({ })); vi.mock('../../codeful', () => ({ - isCodefulProject: vi.fn(() => Promise.resolve(false)), + hasCodefulSdkReference: vi.fn(() => Promise.resolve(false)), })); vi.mock('../../customCodeUtils', () => ({ @@ -80,7 +80,7 @@ const mockedFse = fse as unknown as { const mockedAddOrUpdate = localSettings.addOrUpdateLocalAppSettings as unknown as ReturnType; const mockedGetLocalSettingsJson = localSettings.getLocalSettingsJson as unknown as ReturnType; const mockedWriteFormattedJson = writeFormattedJson as unknown as ReturnType; -const mockedIsCodeful = isCodefulProject as unknown as ReturnType; +const mockedIsCodeful = hasCodefulSdkReference as unknown as ReturnType; const mockedIsCustomCodeInRoot = isCustomCodeFunctionsProjectInRoot as unknown as ReturnType; /** Returns the object written via writeFormattedJson for the file whose path ends with fileName. */ @@ -183,7 +183,7 @@ describe('validateProjectArtifacts', () => { [azureWebJobsStorageKey]: localEmulatorConnectionString, [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, - [workflowCodefulEnabled]: 'true', + [workflowCodefulEnabledKey]: 'true', }); }); @@ -236,7 +236,7 @@ describe('validateProjectArtifacts', () => { // Behavior by logic app type: regeneration builds the root local.settings.json from the same shared // source of truth as fresh project creation (getRootLocalSettings). The project type is inferred from - // the project files (detectLogicAppProjectType): codeful via isCodefulProject, and customCode / + // the project files (detectLogicAppProjectType): codeful via hasCodefulSdkReference, and customCode / // rulesEngine via a sibling custom-code functions (.csproj) project in the workspace root. As a // result every type regenerates the same content a freshly created project of that type would. describe('regenerateLocalSettings — behavior by logic app type', () => { @@ -264,7 +264,7 @@ describe('validateProjectArtifacts', () => { const settingsAdded = mockedAddOrUpdate.mock.calls[0][2]; expect(settingsAdded).toEqual(codelessBaseline); expect(settingsAdded).not.toHaveProperty(azureWebJobsFeatureFlagsKey); - expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabled); + expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabledKey); }); it('customCode: regenerates the codeless baseline plus AzureWebJobsFeatureFlags (sibling custom-code project detected)', async () => { @@ -281,7 +281,7 @@ describe('validateProjectArtifacts', () => { ...codelessBaseline, [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, }); - expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabled); + expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabledKey); }); it('rulesEngine: regenerates the codeless baseline plus AzureWebJobsFeatureFlags (indistinguishable from customCode, same content)', async () => { @@ -298,7 +298,7 @@ describe('validateProjectArtifacts', () => { ...codelessBaseline, [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, }); - expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabled); + expect(settingsAdded).not.toHaveProperty(workflowCodefulEnabledKey); }); it('codeful: regenerates the codeless baseline plus WORKFLOW_CODEFUL_ENABLED and AzureWebJobsFeatureFlags (matches fresh creation)', async () => { @@ -311,7 +311,7 @@ describe('validateProjectArtifacts', () => { expect(settingsAdded).toEqual({ ...codelessBaseline, [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, - [workflowCodefulEnabled]: 'true', + [workflowCodefulEnabledKey]: 'true', }); }); }); @@ -590,7 +590,7 @@ describe('validateProjectArtifacts', () => { [ProjectDirectoryPathKey]: projectPath, [workerRuntimeKey]: WorkerRuntime.Node, [azureWebJobsSecretStorageTypeKey]: azureStorageTypeSetting, - [workflowCodefulEnabled]: 'true', + [workflowCodefulEnabledKey]: 'true', }, }); }); @@ -622,7 +622,7 @@ describe('validateProjectArtifacts', () => { [ProjectDirectoryPathKey]: projectPath, [workerRuntimeKey]: WorkerRuntime.Node, [azureWebJobsSecretStorageTypeKey]: azureStorageTypeSetting, - [workflowCodefulEnabled]: 'true', + [workflowCodefulEnabledKey]: 'true', }, }); }); diff --git a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts index ad827b08e38..459c0d9f89c 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/startDesignTimeApi.ts @@ -252,7 +252,6 @@ export async function startDesignTimeApi(projectPath: string): Promise { // workflow-designtime) that a source-controlled clone may be missing before starting the host. const designTimeDirectory: Uri | undefined = await validateAndRegenerateProjectArtifacts(actionContext, projectPath); - if (!designTimeDirectory) { throw new Error(localize('DesignTimeDirectoryError', 'Failed to create design-time directory.')); } @@ -751,7 +750,6 @@ export async function promptStartDesignTimeOption(context: IActionContext) { await regenerateRootHostFile(projectPath); await regenerateLocalSettings(context, projectPath); - if (autoStartDesignTime) { scheduleStartDesignTimeApi(projectPath); } diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts index 320c7a45b58..2e07f481dde 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -28,7 +28,7 @@ import { } from '../appSettings/localSettings'; import { writeFormattedJson } from '../fs'; import { parseJson } from '../parseJson'; -import { isCodefulProject } from '../codeful'; +import { hasCodefulSdkReference } from '../codeful'; import { isCustomCodeFunctionsProjectInRoot } from '../customCodeUtils'; import { ProjectType, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; import type { IHostJsonV2, ILocalSettingsJson } from '@microsoft/vscode-extension-logic-apps'; @@ -125,7 +125,7 @@ export async function getReferencedAppSettings(projectPath: string): Promise} The inferred project type. */ export async function detectLogicAppProjectType(projectPath: string): Promise { - if ((await isCodefulProject(projectPath)) ?? false) { + if ((await hasCodefulSdkReference(projectPath)) ?? false) { return ProjectType.codeful; } @@ -409,7 +409,7 @@ export async function regenerateDesignTimeDirectory(context: IActionContext, pro } if (!validation.settingsFileValid) { - const isCodeful = (await isCodefulProject(projectPath)) ?? false; + const isCodeful = (await hasCodefulSdkReference(projectPath)) ?? false; const settingsFileContent = getLocalSettingsSchema(true, projectPath, isCodeful); await writeFormattedJson(path.join(designTimeDirectory.fsPath, localSettingsFileName), settingsFileContent); await addOrUpdateLocalAppSettings( From 44544799ab23768335d559cbe984c5c955d4d330 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Fri, 10 Jul 2026 09:22:47 -0700 Subject: [PATCH 15/19] refactor(vscode): merge getRootLocalSettings into getLocalSettingsSchema Consolidate the two local.settings.json builders into a single ProjectType-aware function. getLocalSettingsSchema's third parameter changes from an isCodeful boolean to a ProjectType, so the root branch now emits the multi-language worker feature flag for non-plain types (matching the creation path) instead of only WORKFLOW_CODEFUL_ENABLED. Creation, design-time API startup, and regeneration all route through the one function so the files cannot drift apart. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CreateLogicAppWorkspace.ts | 4 +- .../__test__/localSettings.test.ts | 167 +++++++----------- .../app/utils/appSettings/localSettings.ts | 59 +++---- .../__test__/validateProjectArtifacts.test.ts | 2 +- .../codeless/validateProjectArtifacts.ts | 13 +- 5 files changed, 91 insertions(+), 154 deletions(-) diff --git a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts index 4d973a5c728..dacd055fb94 100644 --- a/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts +++ b/apps/vs-code-designer/src/app/commands/createNewCodeProject/CodeProjectBase/CreateLogicAppWorkspace.ts @@ -34,7 +34,7 @@ import { getCodelessWorkflowTemplate } from '../../../utils/codeless/templates'; import { CreateFunctionAppFiles } from './CreateFunctionAppFiles'; import type { IFunctionWizardContext, IHostJsonV2, IWebviewProjectContext, StandardApp } from '@microsoft/vscode-extension-logic-apps'; import { ProjectType, WorkflowType } from '@microsoft/vscode-extension-logic-apps'; -import { getRootLocalSettings } from '../../../utils/appSettings/localSettings'; +import { getLocalSettingsSchema } from '../../../utils/appSettings/localSettings'; import { createDevContainerContents, createLogicAppVsCodeContents } from './CreateLogicAppVSCodeContents'; import { logicAppPackageProcessing, unzipLogicAppPackageIntoWorkspace } from '../../../utils/cloudToLocalUtils'; import { isLogicAppProject } from '../../../utils/verifyIsProject'; @@ -197,7 +197,7 @@ export async function createLocalConfigurationFiles( '.debug', 'workflow-designtime/', ]; - const localSettingsJson = getRootLocalSettings(logicAppFolderPath, logicAppType); + const localSettingsJson = getLocalSettingsSchema(false, logicAppFolderPath, logicAppType); if (logicAppType !== ProjectType.logicApp) { funcignore.push('global.json'); diff --git a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts index 88dd475de3a..05f8a22de2b 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { ProjectType, WorkerRuntime } from '@microsoft/vscode-extension-logic-apps'; -import { getLocalSettingsSchema, getRootLocalSettings } from '../localSettings'; +import { getLocalSettingsSchema } from '../localSettings'; import { ProjectDirectoryPathKey, appKindSetting, @@ -18,6 +18,12 @@ import { } from '../../../../constants'; describe('utils/appSettings', () => { + // getLocalSettingsSchema is the single source of truth for both local.settings.json files this + // extension generates: the project-root (runtime) file (isDesignTime=false) and the + // workflow-designtime/ folder file (isDesignTime=true). It is shared by fresh project creation + // (CreateLogicAppWorkspace.createLocalConfigurationFiles), the design-time API startup, and + // regeneration of source-controlled clones (validateProjectArtifacts), so these golden tests lock + // the exact content and key order per (isDesignTime x ProjectType) so those paths cannot drift apart. describe('getLocalSettingsSchema', () => { const projectPath = 'path/to/project'; @@ -47,43 +53,78 @@ describe('utils/appSettings', () => { }); // Golden / characterization tests: lock the exact content of every local.settings.json this - // extension generates, for every logic-app content axis. The content only varies by - // (isDesignTime x isCodeful). + // extension generates, for every logic-app content axis. The content varies by + // (isDesignTime x ProjectType). // // Expected values are expressed via the shared constants (the same named keys/values the rest of // the codebase uses) rather than magic strings. The assertions still reconstruct the full // expected object explicitly and deep-equal it, so a structural regression (a setting added, // dropped, or moved to the wrong branch) is caught. - describe('golden content by logic app type', () => { - it('root local.settings.json (codeless / Standard Node)', () => { - expect(getLocalSettingsSchema(false, projectPath, false)).toEqual({ + describe('golden root content by logic app type', () => { + const baseRootValues = { + [azureWebJobsStorageKey]: localEmulatorConnectionString, + [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + [workerRuntimeKey]: WorkerRuntime.Dotnet, + [appKindSetting]: logicAppKind, + [ProjectDirectoryPathKey]: projectPath, + }; + + it('logicApp: 5 base keys, no feature or codeful flags', () => { + expect(getLocalSettingsSchema(false, projectPath, ProjectType.logicApp)).toEqual({ + IsEncrypted: false, + Values: { ...baseRootValues }, + }); + }); + + it('customCode: adds the multi-language worker feature flag', () => { + expect(getLocalSettingsSchema(false, projectPath, ProjectType.customCode)).toEqual({ IsEncrypted: false, Values: { - [appKindSetting]: logicAppKind, - [ProjectDirectoryPathKey]: projectPath, - [workerRuntimeKey]: WorkerRuntime.Dotnet, - [azureWebJobsStorageKey]: localEmulatorConnectionString, - [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, + ...baseRootValues, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, + }, + }); + }); + + it('rulesEngine: adds the multi-language worker feature flag', () => { + expect(getLocalSettingsSchema(false, projectPath, ProjectType.rulesEngine)).toEqual({ + IsEncrypted: false, + Values: { + ...baseRootValues, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, }, }); }); - it('root local.settings.json (codeful / .NET8) adds WORKFLOW_CODEFUL_ENABLED', () => { - expect(getLocalSettingsSchema(false, projectPath, true)).toEqual({ + it('codeful: adds the feature flag and WORKFLOW_CODEFUL_ENABLED (and no extension bundle id)', () => { + const settings = getLocalSettingsSchema(false, projectPath, ProjectType.codeful); + expect(settings).toEqual({ + IsEncrypted: false, + Values: { + ...baseRootValues, + [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, + [workflowCodefulEnabledKey]: 'true', + }, + }); + expect(settings.Values).not.toHaveProperty('AzureFunctionsJobHost__extensionBundle__id'); + }); + + it('omits ProjectDirectoryPath when no project path is supplied (root, codeless)', () => { + expect(getLocalSettingsSchema(false)).toEqual({ IsEncrypted: false, Values: { [appKindSetting]: logicAppKind, - [ProjectDirectoryPathKey]: projectPath, [workerRuntimeKey]: WorkerRuntime.Dotnet, [azureWebJobsStorageKey]: localEmulatorConnectionString, [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, - [workflowCodefulEnabledKey]: 'true', }, }); }); + }); + describe('golden design-time content by logic app type', () => { it('design-time local.settings.json (codeless / Standard Node)', () => { - expect(getLocalSettingsSchema(true, projectPath, false)).toEqual({ + expect(getLocalSettingsSchema(true, projectPath, ProjectType.logicApp)).toEqual({ IsEncrypted: false, Values: { [appKindSetting]: logicAppKind, @@ -94,8 +135,8 @@ describe('utils/appSettings', () => { }); }); - it('design-time local.settings.json (codeful / .NET8) adds WORKFLOW_CODEFUL_ENABLED', () => { - expect(getLocalSettingsSchema(true, projectPath, true)).toEqual({ + it('design-time local.settings.json (codeful / .NET8) adds WORKFLOW_CODEFUL_ENABLED but no feature flag', () => { + expect(getLocalSettingsSchema(true, projectPath, ProjectType.codeful)).toEqual({ IsEncrypted: false, Values: { [appKindSetting]: logicAppKind, @@ -106,18 +147,6 @@ describe('utils/appSettings', () => { }, }); }); - - it('omits ProjectDirectoryPath when no project path is supplied (root, codeless)', () => { - expect(getLocalSettingsSchema(false)).toEqual({ - IsEncrypted: false, - Values: { - [appKindSetting]: logicAppKind, - [workerRuntimeKey]: WorkerRuntime.Dotnet, - [azureWebJobsStorageKey]: localEmulatorConnectionString, - [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, - }, - }); - }); }); // Key ORDER regression: toEqual ignores property order, but writeFormattedJson serializes in @@ -126,7 +155,7 @@ describe('utils/appSettings', () => { // pin the exact order rather than just the set of keys. describe('root key order matches the creation path', () => { it('orders keys like CreateLogicAppWorkspace (codeless)', () => { - const keys = Object.keys(getLocalSettingsSchema(false, projectPath, false).Values); + const keys = Object.keys(getLocalSettingsSchema(false, projectPath, ProjectType.logicApp).Values); expect(keys).toEqual([ azureWebJobsStorageKey, functionsInprocNet8Enabled, @@ -136,86 +165,18 @@ describe('utils/appSettings', () => { ]); }); - it('appends WORKFLOW_CODEFUL_ENABLED last (codeful)', () => { - const keys = Object.keys(getLocalSettingsSchema(false, projectPath, true).Values); + it('orders keys deterministically for codeful (base keys, then feature flag, then codeful flag)', () => { + const keys = Object.keys(getLocalSettingsSchema(false, projectPath, ProjectType.codeful).Values); expect(keys).toEqual([ azureWebJobsStorageKey, functionsInprocNet8Enabled, workerRuntimeKey, appKindSetting, ProjectDirectoryPathKey, + azureWebJobsFeatureFlagsKey, workflowCodefulEnabledKey, ]); }); }); }); - - // getRootLocalSettings is the single source of truth for the project-root local.settings.json shared - // by fresh project creation (CreateLogicAppWorkspace.createLocalConfigurationFiles) and regeneration - // (validateProjectArtifacts.regenerateLocalSettings). These golden tests lock the exact content and - // key order per ProjectType so the two paths cannot drift apart. - describe('getRootLocalSettings', () => { - const projectPath = 'path/to/project'; - - const baseValues = { - [azureWebJobsStorageKey]: localEmulatorConnectionString, - [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, - [workerRuntimeKey]: WorkerRuntime.Dotnet, - [appKindSetting]: logicAppKind, - [ProjectDirectoryPathKey]: projectPath, - }; - - it('logicApp: 5 base keys, no feature or codeful flags', () => { - expect(getRootLocalSettings(projectPath, ProjectType.logicApp)).toEqual({ - IsEncrypted: false, - Values: { ...baseValues }, - }); - }); - - it('customCode: adds the multi-language worker feature flag', () => { - expect(getRootLocalSettings(projectPath, ProjectType.customCode)).toEqual({ - IsEncrypted: false, - Values: { - ...baseValues, - [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, - }, - }); - }); - - it('rulesEngine: adds the multi-language worker feature flag', () => { - expect(getRootLocalSettings(projectPath, ProjectType.rulesEngine)).toEqual({ - IsEncrypted: false, - Values: { - ...baseValues, - [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, - }, - }); - }); - - it('codeful: adds the feature flag and WORKFLOW_CODEFUL_ENABLED (and no extension bundle id)', () => { - const settings = getRootLocalSettings(projectPath, ProjectType.codeful); - expect(settings).toEqual({ - IsEncrypted: false, - Values: { - ...baseValues, - [azureWebJobsFeatureFlagsKey]: multiLanguageWorkerSetting, - [workflowCodefulEnabledKey]: 'true', - }, - }); - expect(settings.Values).not.toHaveProperty('AzureFunctionsJobHost__extensionBundle__id'); - }); - - it('orders keys deterministically for codeful (base keys, then feature flag, then codeful flag)', () => { - const keys = Object.keys(getRootLocalSettings(projectPath, ProjectType.codeful).Values); - expect(keys).toEqual([ - azureWebJobsStorageKey, - functionsInprocNet8Enabled, - workerRuntimeKey, - appKindSetting, - ProjectDirectoryPathKey, - azureWebJobsFeatureFlagsKey, - workflowCodefulEnabledKey, - ]); - }); - }); }); diff --git a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts index ccc088e3876..38ff08fc2ce 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/localSettings.ts @@ -175,12 +175,24 @@ export async function getAzureWebJobsStorage(context: IActionContext, projectPat } /** - * Retrieves the local settings schema based on the project path and design time flag. - * @param {boolean} isDesignTime - A flag indicating whether it is design time or not. - * @param {string} projectPath - The path of the project. - * @returns The local settings schema. + * Builds the content for one of the two local.settings.json files this extension generates, from a + * single source of truth so every path (fresh project creation, design-time API startup, and + * regeneration of a source-controlled clone) produces byte-for-byte identical files that cannot drift + * apart. + * + * The `isDesignTime` flag selects which file is produced: + * - `true` -> the `workflow-designtime/` folder file (Node worker runtime + secret storage type). + * - `false` -> the project-root (runtime) file. Its key order mirrors the creation path + * (CreateLogicAppWorkspace.createLocalConfigurationFiles), and it is `ProjectType`-aware: + * non-plain types add the multi-language worker feature flag. + * + * `WORKFLOW_CODEFUL_ENABLED` is appended (last) for codeful projects in both files. + * @param {boolean} isDesignTime - Whether to build the design-time folder file (true) or the project-root file (false). + * @param {string} [projectPath] - Absolute path to the logic app project folder (ProjectDirectoryPath value). Omitted -> the key is not written. + * @param {ProjectType} [logicAppType] - The logic app project type; drives the multi-language worker and codeful flags. + * @returns {ILocalSettingsJson} The local.settings.json content. */ -export const getLocalSettingsSchema = (isDesignTime: boolean, projectPath?: string, isCodeful?: boolean): ILocalSettingsJson => { +export const getLocalSettingsSchema = (isDesignTime: boolean, projectPath?: string, logicAppType?: ProjectType): ILocalSettingsJson => { const values: Record = {}; if (isDesignTime) { @@ -201,40 +213,9 @@ export const getLocalSettingsSchema = (isDesignTime: boolean, projectPath?: stri if (projectPath) { values[ProjectDirectoryPathKey] = projectPath; } - } - - if (isCodeful) { - values[workflowCodefulEnabledKey] = 'true'; - } - - return { - IsEncrypted: false, - Values: values, - }; -}; - -/** - * Builds the project-root (runtime) local.settings.json content for a logic app of the given type. - * - * This is the single source of truth shared by the workspace-creation path - * (CreateLogicAppWorkspace.createLocalConfigurationFiles) and the regeneration path - * (validateProjectArtifacts.regenerateLocalSettings) so a regenerated file is key-for-key identical to - * what a freshly created project of the same type would produce and the two paths cannot drift apart. - * @param {string} logicAppFolderPath - Absolute path to the logic app project folder (ProjectDirectoryPath value). - * @param {ProjectType} logicAppType - The logic app project type. - * @returns {ILocalSettingsJson} The root local.settings.json content. - */ -export const getRootLocalSettings = (logicAppFolderPath: string, logicAppType: ProjectType): ILocalSettingsJson => { - const values: Record = { - [azureWebJobsStorageKey]: localEmulatorConnectionString, - [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, - [workerRuntimeKey]: WorkerRuntime.Dotnet, - [appKindSetting]: logicAppKind, - [ProjectDirectoryPathKey]: logicAppFolderPath, - }; - - if (logicAppType !== ProjectType.logicApp) { - values[azureWebJobsFeatureFlagsKey] = multiLanguageWorkerSetting; + if (logicAppType !== undefined && logicAppType !== ProjectType.logicApp) { + values[azureWebJobsFeatureFlagsKey] = multiLanguageWorkerSetting; + } } if (logicAppType === ProjectType.codeful) { diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index 76be4b0d932..f2e32f182ea 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -235,7 +235,7 @@ describe('validateProjectArtifacts', () => { }); // Behavior by logic app type: regeneration builds the root local.settings.json from the same shared - // source of truth as fresh project creation (getRootLocalSettings). The project type is inferred from + // source of truth as fresh project creation (getLocalSettingsSchema). The project type is inferred from // the project files (detectLogicAppProjectType): codeful via hasCodefulSdkReference, and customCode / // rulesEngine via a sibling custom-code functions (.csproj) project in the workspace root. As a // result every type regenerates the same content a freshly created project of that type would. diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts index 2e07f481dde..68cb88907b5 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -20,12 +20,7 @@ import { } from '../../../constants'; import { localize } from '../../../localize'; import { ext } from '../../../extensionVariables'; -import { - addOrUpdateLocalAppSettings, - getLocalSettingsJson, - getLocalSettingsSchema, - getRootLocalSettings, -} from '../appSettings/localSettings'; +import { addOrUpdateLocalAppSettings, getLocalSettingsJson, getLocalSettingsSchema } from '../appSettings/localSettings'; import { writeFormattedJson } from '../fs'; import { parseJson } from '../parseJson'; import { hasCodefulSdkReference } from '../codeful'; @@ -176,7 +171,7 @@ export async function regenerateLocalSettings(context: IActionContext, projectPa // local.settings.json matches what a newly created project of this type would produce. The project // type is inferred from the project files because a source-controlled clone has no explicit marker. const logicAppType = await detectLogicAppProjectType(projectPath); - const baselineValues = getRootLocalSettings(projectPath, logicAppType).Values ?? {}; + const baselineValues = getLocalSettingsSchema(false, projectPath, logicAppType).Values ?? {}; const referencedSettings = await getReferencedAppSettings(projectPath); const currentSettings: ILocalSettingsJson = await getLocalSettingsJson(context, localSettingsPath); @@ -409,8 +404,8 @@ export async function regenerateDesignTimeDirectory(context: IActionContext, pro } if (!validation.settingsFileValid) { - const isCodeful = (await hasCodefulSdkReference(projectPath)) ?? false; - const settingsFileContent = getLocalSettingsSchema(true, projectPath, isCodeful); + const logicAppType = await detectLogicAppProjectType(projectPath); + const settingsFileContent = getLocalSettingsSchema(true, projectPath, logicAppType); await writeFormattedJson(path.join(designTimeDirectory.fsPath, localSettingsFileName), settingsFileContent); await addOrUpdateLocalAppSettings( context, From 79eb7735ed54e214fe996467283b2857601436fb Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Fri, 10 Jul 2026 13:18:28 -0700 Subject: [PATCH 16/19] Recognize logic app projects by workflow signal, not host.json isLogicAppProject now keys off the workflow-folder signal (codeless workflow.json with a Microsoft.Logic schema, codeful workflow.cs at the project root, or the isCodeful workspace setting) instead of requiring host.json to exist and parse. host.json is a generic Azure Functions artifact and is commonly gitignored under source control, so a fresh clone can be missing or have a corrupted host.json yet still be a valid logic app project. Keying off the workflow signal lets the extension detect such projects and heal/regenerate host.json, local.settings.json, and workflow-designtime before starting. Also fixes codeful detection to look for workflow.cs at the project root rather than in subfolders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../utils/__test__/verifyIsProject.test.ts | 115 ++++++++++++++++- .../codeless/validateProjectArtifacts.ts | 12 +- .../src/app/utils/verifyIsProject.ts | 119 ++++++------------ 3 files changed, 157 insertions(+), 89 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts b/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts index 886de52b7f9..dbb2285ecb6 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts @@ -3,7 +3,7 @@ import type { WorkspaceFolder } from 'vscode'; import * as fse from 'fs-extra'; import * as path from 'path'; import * as verifyIsProject from '../verifyIsProject'; -import { hostFileName, workflowFileName } from '../../../constants'; +import { codefulWorkflowFileName, hostFileName, localSettingsFileName, workflowFileName } from '../../../constants'; describe('tryGetAllLogicAppProjectRoots', () => { const testWorkspaceFolderPath = path.join('test', 'workspace', 'LogicApp1'); @@ -29,7 +29,7 @@ describe('tryGetAllLogicAppProjectRoots', () => { }); it('should return the folderPath if it is a logic app project', async () => { - vi.spyOn(fse, 'pathExists').mockResolvedValue(true); + vi.spyOn(fse, 'pathExists').mockImplementation(async (filePath: fse.PathLike) => !String(filePath).endsWith(codefulWorkflowFileName)); vi.spyOn(fse, 'readdir').mockImplementation(async (filePath: fse.PathLike) => { if (filePath === testWorkspaceFolderPath) return [hostFileName, 'workflow1']; if (filePath === path.join(testWorkspaceFolderPath, 'workflow1')) return [workflowFileName]; @@ -57,7 +57,7 @@ describe('tryGetAllLogicAppProjectRoots', () => { const testLogicAppProjectPath1 = path.join(testWorkspaceFolderPath, 'LogicApp1'); const testLogicAppProjectPath2 = path.join(testWorkspaceFolderPath, 'LogicApp2'); - vi.spyOn(fse, 'pathExists').mockResolvedValue(true); + vi.spyOn(fse, 'pathExists').mockImplementation(async (filePath: fse.PathLike) => !String(filePath).endsWith(codefulWorkflowFileName)); vi.spyOn(fse, 'readdir').mockImplementation(async (filePath: fse.PathLike) => { if (filePath === testWorkspaceFolderPath) return ['LogicApp1', 'LogicApp2']; if (filePath === testLogicAppProjectPath1) return [hostFileName, 'workflow1']; @@ -95,14 +95,47 @@ describe('tryGetAllLogicAppProjectRoots', () => { }); it('should return an empty array if no logic app project is found in root or subfolders', async () => { - vi.spyOn(fse, 'pathExists').mockResolvedValue(true); + vi.spyOn(fse, 'pathExists').mockImplementation(async (filePath: fse.PathLike) => !String(filePath).endsWith(codefulWorkflowFileName)); vi.spyOn(fse, 'readdir').mockImplementation(async (filePath: fse.PathLike) => { if (filePath === testWorkspaceFolderPath) return ['sub1', 'sub2']; if (filePath === path.join(testWorkspaceFolderPath, 'sub1')) return [workflowFileName]; return []; }); vi.spyOn(fse, 'readFile').mockImplementation(async (filePath: fse.PathLike) => { + // A workflow.json that is not a Microsoft.Logic workflow definition is not a Logic Apps signal. if (filePath === path.join(testWorkspaceFolderPath, 'sub1', workflowFileName)) { + return JSON.stringify({ definition: { $schema: 'https://example.com/not-a-logic-workflow.json#' } }); + } + return ''; + }); + + const result = await verifyIsProject.tryGetAllLogicAppProjectRoots(testWorkspaceFolder); + expect(result).toEqual([]); + }); +}); + +describe('isLogicAppProject', () => { + const projectPath = path.join('test', 'LogicApp'); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns false when the folder does not exist', async () => { + vi.spyOn(fse, 'pathExists').mockResolvedValue(false); + expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(false); + }); + + it('detects a codeless project via workflow.json even when host.json is missing', async () => { + const workflowJsonPath = path.join(projectPath, 'stateful1', workflowFileName); + // No host.json, no local.settings.json, no workflow.cs on disk — only the workflow folder exists. + vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { + const s = String(p); + return s === projectPath || s === workflowJsonPath; + }); + vi.spyOn(fse, 'readdir').mockImplementation(async (p: fse.PathLike) => (String(p) === projectPath ? ['stateful1'] : [])); + vi.spyOn(fse, 'readFile').mockImplementation(async (p: fse.PathLike) => { + if (String(p) === workflowJsonPath) { return JSON.stringify({ definition: { $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', @@ -112,7 +145,77 @@ describe('tryGetAllLogicAppProjectRoots', () => { return ''; }); - const result = await verifyIsProject.tryGetAllLogicAppProjectRoots(testWorkspaceFolder); - expect(result).toEqual([]); + expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(true); + }); + + it('detects a codeless project even when a present host.json is corrupted', async () => { + const workflowJsonPath = path.join(projectPath, 'stateful1', workflowFileName); + vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { + const s = String(p); + return s === projectPath || s === path.join(projectPath, hostFileName) || s === workflowJsonPath; + }); + vi.spyOn(fse, 'readdir').mockImplementation(async (p: fse.PathLike) => (String(p) === projectPath ? [hostFileName, 'stateful1'] : [])); + vi.spyOn(fse, 'readFile').mockImplementation(async (p: fse.PathLike) => { + if (String(p) === path.join(projectPath, hostFileName)) { + return 'this is not valid json {'; + } + if (String(p) === workflowJsonPath) { + return JSON.stringify({ + definition: { + $schema: 'https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#', + }, + }); + } + return ''; + }); + + expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(true); + }); + + it('detects a codeful project via workflow.cs at the project root', async () => { + const workflowCsPath = path.join(projectPath, codefulWorkflowFileName); + vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { + const s = String(p); + return s === projectPath || s === workflowCsPath; + }); + vi.spyOn(fse, 'readdir').mockImplementation(async (p: fse.PathLike) => + String(p) === projectPath ? [codefulWorkflowFileName, 'Program.cs'] : [] + ); + vi.spyOn(fse, 'readFile').mockResolvedValue(''); + + expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(true); + }); + + it('does not tag a plain Functions project (host.json present, no workflow signal)', async () => { + const localSettingsPath = path.join(projectPath, localSettingsFileName); + vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { + const s = String(p); + return s === projectPath || s === path.join(projectPath, hostFileName) || s === localSettingsPath; + }); + vi.spyOn(fse, 'readdir').mockImplementation(async (p: fse.PathLike) => + String(p) === projectPath ? [hostFileName, localSettingsFileName, 'MyHttpFunction'] : [] + ); + vi.spyOn(fse, 'readFile').mockImplementation(async (p: fse.PathLike) => { + if (String(p) === localSettingsPath) { + return JSON.stringify({ Values: { FUNCTIONS_WORKER_RUNTIME: 'dotnet' } }); + } + return ''; + }); + + expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(false); + }); + + it('does not tag a folder whose workflow.json is not a Microsoft.Logic workflow', async () => { + const workflowJsonPath = path.join(projectPath, 'wf', workflowFileName); + vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { + const s = String(p); + return s === projectPath || s === workflowJsonPath; + }); + vi.spyOn(fse, 'readdir').mockImplementation(async (p: fse.PathLike) => (String(p) === projectPath ? ['wf'] : [])); + vi.spyOn(fse, 'readFile').mockImplementation(async (p: fse.PathLike) => + String(p) === workflowJsonPath ? JSON.stringify({ definition: { $schema: 'https://example.com/not-logic.json#' } }) : '' + ); + + expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(false); }); }); diff --git a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts index 68cb88907b5..1c76d29c7cb 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/validateProjectArtifacts.ts @@ -240,12 +240,14 @@ function getRootHostFileContent(): IHostJsonV2 { } /** - * Ensures the project-level host.json exists and is structurally valid. When source control is - * enabled this file can be missing from a fresh clone; without it the function host cannot start. - * A valid existing host.json (correct version + workflows extension bundle) is preserved so that - * customizations such as a pinned extension bundle version are not lost. + * Ensures the project-level host.json exists and is structurally valid, healing it when needed. + * Because {@link isLogicAppProject} identifies a project by its workflow-folder signal (not host.json), + * a source-controlled clone can reach this point with host.json missing or corrupted; without a valid + * host.json the function host cannot start. This regenerates the file in either case. A valid existing + * host.json (correct version + workflows extension bundle) is preserved so that customizations such as a + * pinned extension bundle version are not lost. * @param {string} projectPath - The logic app project root. - * @returns {Promise} True when the file was created, otherwise false. + * @returns {Promise} True when the file was written (created or repaired), otherwise false. */ export async function regenerateRootHostFile(projectPath: string): Promise { const hostFilePath = path.join(projectPath, hostFileName); diff --git a/apps/vs-code-designer/src/app/utils/verifyIsProject.ts b/apps/vs-code-designer/src/app/utils/verifyIsProject.ts index 28a33cfcaba..83e91aab3ca 100644 --- a/apps/vs-code-designer/src/app/utils/verifyIsProject.ts +++ b/apps/vs-code-designer/src/app/utils/verifyIsProject.ts @@ -2,14 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - extensionBundleId, - hostFileName, - extensionCommand, - workflowFileName, - codefulWorkflowFileName, - customExtensionContext, -} from '../../constants'; +import { extensionCommand, workflowFileName, codefulWorkflowFileName, customExtensionContext } from '../../constants'; import { localize } from '../../localize'; import { getWorkspaceSetting, updateWorkspaceSetting } from './vsCodeConfig/settings'; import { isNullOrUndefined, isString } from '@microsoft/logic-apps-shared'; @@ -23,94 +16,64 @@ import { hasCodefulWorkflowSetting } from './codeful'; const projectSubpathKey = 'projectSubpath'; -// Use 'host.json' and 'local.settings.json' as an indicator that this is a functions project +/** + * Determines whether the given folder is a Logic Apps project. + * + * A Logic Apps project is identified by a workflow-folder signal — any one of: + * - a codeless `workflow.json` one level down whose `definition.$schema` is a + * `Microsoft.Logic` workflow-definition schema, + * - a codeful `workflow.cs` at the project root, or + * - the codeful workspace setting (`hasCodefulWorkflowSetting`). + * + * host.json is intentionally NOT required. Source-controlled projects commonly + * gitignore `host.json` and `local.settings.json`, so a freshly cloned project can + * be missing (or have a corrupted) host.json yet still be a valid Logic Apps project. + * Keying off the workflow signal lets the extension recognize such a project and + * heal/regenerate the missing artifacts (host.json, local.settings.json, + * workflow-designtime) before starting. host.json is also a generic Azure Functions + * artifact, so its mere presence is not a reliable Logic Apps indicator. + */ export async function isLogicAppProject(folderPath: string): Promise { - const hostFilePath = path.join(folderPath, hostFileName); - if (!(await fse.pathExists(hostFilePath))) { - return false; - } - - const subpaths: string[] = await fse.readdir(folderPath); - - // Helper function to validate a workflow JSON file - async function isValidCodefulWorkflowFolder(workflowCsPath: string): Promise { - if (!(await fse.pathExists(workflowCsPath))) { - return false; - } - const filesInSubpath = await fse.readdir(path.dirname(workflowCsPath)); - if (filesInSubpath.includes(codefulWorkflowFileName)) { - return true; - } + if (!(await fse.pathExists(folderPath))) { return false; } - // Helper function to validate a workflow JSON file - async function isValidCodelessWorkflowFolder(workflowJsonPath: string): Promise { - if (!(await fse.pathExists(workflowJsonPath))) { - return false; - } - try { - const filesInSubpath = await fse.readdir(path.dirname(workflowJsonPath)); - let isJsonWorkflow = false; - if (filesInSubpath.includes(workflowFileName)) { - const workflowJsonData = await fse.readFile(workflowJsonPath, 'utf-8'); - const workflowJson = JSON.parse(workflowJsonData); - const schema = workflowJson?.definition?.$schema; - isJsonWorkflow = schema && schema.includes('Microsoft.Logic') && schema.includes('workflowdefinition.json'); - } - const workflowCsPaths = subpaths.map((subpath) => path.join(folderPath, subpath, codefulWorkflowFileName)); - const validWorkflowCsPaths = await Promise.all( - workflowCsPaths.map(async (workflowCsPath) => { - if (await fse.pathExists(workflowCsPath)) { - return true; - } - return false; - }) - ); - - if (validWorkflowCsPaths.some(Boolean) || isJsonWorkflow) { - return true; - } - } catch { - return false; - } - + let subpaths: string[]; + try { + subpaths = await fse.readdir(folderPath); + } catch { return false; } - const validWorkflowChecks = await Promise.all( - subpaths.map(async (subpath) => { - const workflowJsonPath = path.join(folderPath, subpath, workflowFileName); + // Codeful projects place workflow.cs at the root of the logic app project. + const hasValidCodefulWorkflow = await fse.pathExists(path.join(folderPath, codefulWorkflowFileName)); - return isValidCodelessWorkflowFolder(workflowJsonPath); - }) + // Codeless projects place workflow.json one level down, inside a per-workflow subfolder. + const validCodelessWorkflowChecks = await Promise.all( + subpaths.map((subpath) => isValidCodelessWorkflowFolder(path.join(folderPath, subpath, workflowFileName))) ); + const hasValidCodelessWorkflow = validCodelessWorkflowChecks.some(Boolean); - const validCodefulWorkflowChecks = await Promise.all( - subpaths.map(async (subpath) => { - const workflowCsPath = path.join(folderPath, subpath, codefulWorkflowFileName); - - return isValidCodefulWorkflowFolder(workflowCsPath); - }) - ); - - const hasValidCodefulWorkflow = validCodefulWorkflowChecks.some((valid) => valid); - const hasValidCodelessWorkflow = validWorkflowChecks.some((valid) => valid); const isCodeful = await hasCodefulWorkflowSetting(folderPath); - if (isCodeful) { vscode.commands.executeCommand('setContext', customExtensionContext.isCodeful, true); } - // Only return false if none of the possible validation mechanisms are present - if (!(hasValidCodelessWorkflow || hasValidCodefulWorkflow || isCodeful)) { + return hasValidCodelessWorkflow || hasValidCodefulWorkflow || isCodeful; +} + +/** + * Validates that a `workflow.json` file exists and declares a `Microsoft.Logic` + * workflow-definition `$schema` — the codeless Logic Apps workflow signal. + */ +async function isValidCodelessWorkflowFolder(workflowJsonPath: string): Promise { + if (!(await fse.pathExists(workflowJsonPath))) { return false; } - try { - const hostJsonData = await fse.readFile(hostFilePath, 'utf-8'); - const hostJson = JSON.parse(hostJsonData); - return hostJson?.extensionBundle?.id === extensionBundleId || hasValidCodefulWorkflow || isCodeful; + const workflowJsonData = await fse.readFile(workflowJsonPath, 'utf-8'); + const schema = JSON.parse(workflowJsonData)?.definition?.$schema; + return typeof schema === 'string' && schema.includes('Microsoft.Logic') && schema.includes('workflowdefinition.json'); } catch { return false; } From 312ef7f7d391defe479ccf42d608c8c38a236535 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Fri, 10 Jul 2026 14:03:42 -0700 Subject: [PATCH 17/19] feat(vscode): detect codeful Logic App projects structurally via .csproj SDK reference isLogicAppProject now recognizes a codeful project through hasCodefulSdkReference (a .NET 8 .csproj referencing Microsoft.Azure.Workflows.Sdk) instead of a hardcoded workflow.cs filename, so codeful workflows whose C# file is named anything are still recognized. Adds a statSync default to the fs-extra test mock and unit tests covering filename-independent codeful detection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../utils/__test__/verifyIsProject.test.ts | 39 ++++++++++++------- .../src/app/utils/verifyIsProject.ts | 26 ++++++++----- apps/vs-code-designer/test-setup.ts | 3 ++ 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts b/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts index dbb2285ecb6..f02ec64a3dc 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import type { WorkspaceFolder } from 'vscode'; import * as fse from 'fs-extra'; import * as path from 'path'; import * as verifyIsProject from '../verifyIsProject'; -import { codefulWorkflowFileName, hostFileName, localSettingsFileName, workflowFileName } from '../../../constants'; +import { hostFileName, localSettingsFileName, workflowFileName } from '../../../constants'; describe('tryGetAllLogicAppProjectRoots', () => { const testWorkspaceFolderPath = path.join('test', 'workspace', 'LogicApp1'); @@ -29,7 +29,7 @@ describe('tryGetAllLogicAppProjectRoots', () => { }); it('should return the folderPath if it is a logic app project', async () => { - vi.spyOn(fse, 'pathExists').mockImplementation(async (filePath: fse.PathLike) => !String(filePath).endsWith(codefulWorkflowFileName)); + vi.spyOn(fse, 'pathExists').mockResolvedValue(true); vi.spyOn(fse, 'readdir').mockImplementation(async (filePath: fse.PathLike) => { if (filePath === testWorkspaceFolderPath) return [hostFileName, 'workflow1']; if (filePath === path.join(testWorkspaceFolderPath, 'workflow1')) return [workflowFileName]; @@ -57,7 +57,7 @@ describe('tryGetAllLogicAppProjectRoots', () => { const testLogicAppProjectPath1 = path.join(testWorkspaceFolderPath, 'LogicApp1'); const testLogicAppProjectPath2 = path.join(testWorkspaceFolderPath, 'LogicApp2'); - vi.spyOn(fse, 'pathExists').mockImplementation(async (filePath: fse.PathLike) => !String(filePath).endsWith(codefulWorkflowFileName)); + vi.spyOn(fse, 'pathExists').mockResolvedValue(true); vi.spyOn(fse, 'readdir').mockImplementation(async (filePath: fse.PathLike) => { if (filePath === testWorkspaceFolderPath) return ['LogicApp1', 'LogicApp2']; if (filePath === testLogicAppProjectPath1) return [hostFileName, 'workflow1']; @@ -95,7 +95,7 @@ describe('tryGetAllLogicAppProjectRoots', () => { }); it('should return an empty array if no logic app project is found in root or subfolders', async () => { - vi.spyOn(fse, 'pathExists').mockImplementation(async (filePath: fse.PathLike) => !String(filePath).endsWith(codefulWorkflowFileName)); + vi.spyOn(fse, 'pathExists').mockResolvedValue(true); vi.spyOn(fse, 'readdir').mockImplementation(async (filePath: fse.PathLike) => { if (filePath === testWorkspaceFolderPath) return ['sub1', 'sub2']; if (filePath === path.join(testWorkspaceFolderPath, 'sub1')) return [workflowFileName]; @@ -128,7 +128,7 @@ describe('isLogicAppProject', () => { it('detects a codeless project via workflow.json even when host.json is missing', async () => { const workflowJsonPath = path.join(projectPath, 'stateful1', workflowFileName); - // No host.json, no local.settings.json, no workflow.cs on disk — only the workflow folder exists. + // No host.json, no local.settings.json, no .csproj on disk — only the workflow folder exists. vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { const s = String(p); return s === projectPath || s === workflowJsonPath; @@ -172,16 +172,27 @@ describe('isLogicAppProject', () => { expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(true); }); - it('detects a codeful project via workflow.cs at the project root', async () => { - const workflowCsPath = path.join(projectPath, codefulWorkflowFileName); - vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { - const s = String(p); - return s === projectPath || s === workflowCsPath; - }); + it('detects a codeful project via its .csproj SDK reference regardless of the workflow .cs file name', async () => { + const csprojPath = path.join(projectPath, 'MyLogicApp.csproj'); + // A codeful project is a .NET 8 project referencing the Logic Apps SDK; the workflow C# file + // can be named anything (here, MyCustomWorkflow.cs — not workflow.cs). Detection is structural + // (via the .csproj), so a literal workflow.cs is never required. + vi.spyOn(fse, 'statSync').mockReturnValue({ isDirectory: () => true } as unknown as fse.Stats); + vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => String(p) === projectPath); vi.spyOn(fse, 'readdir').mockImplementation(async (p: fse.PathLike) => - String(p) === projectPath ? [codefulWorkflowFileName, 'Program.cs'] : [] + String(p) === projectPath ? ['MyLogicApp.csproj', 'MyCustomWorkflow.cs', 'Program.cs'] : [] ); - vi.spyOn(fse, 'readFile').mockResolvedValue(''); + vi.spyOn(fse, 'readFile').mockImplementation(async (p: fse.PathLike) => { + if (String(p) === csprojPath) { + return [ + '', + ' net8', + ' ', + '', + ].join('\n'); + } + return ''; + }); expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(true); }); diff --git a/apps/vs-code-designer/src/app/utils/verifyIsProject.ts b/apps/vs-code-designer/src/app/utils/verifyIsProject.ts index 83e91aab3ca..326a18bddb5 100644 --- a/apps/vs-code-designer/src/app/utils/verifyIsProject.ts +++ b/apps/vs-code-designer/src/app/utils/verifyIsProject.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { extensionCommand, workflowFileName, codefulWorkflowFileName, customExtensionContext } from '../../constants'; +import { extensionCommand, workflowFileName, customExtensionContext } from '../../constants'; import { localize } from '../../localize'; import { getWorkspaceSetting, updateWorkspaceSetting } from './vsCodeConfig/settings'; import { isNullOrUndefined, isString } from '@microsoft/logic-apps-shared'; @@ -12,17 +12,20 @@ import * as path from 'path'; import type { MessageItem, WorkspaceFolder } from 'vscode'; import { NoWorkspaceError } from './errors'; import * as vscode from 'vscode'; -import { hasCodefulWorkflowSetting } from './codeful'; +import { hasCodefulSdkReference, hasCodefulWorkflowSetting } from './codeful'; const projectSubpathKey = 'projectSubpath'; /** * Determines whether the given folder is a Logic Apps project. * - * A Logic Apps project is identified by a workflow-folder signal — any one of: + * A Logic Apps project is identified by a workflow signal — any one of: * - a codeless `workflow.json` one level down whose `definition.$schema` is a * `Microsoft.Logic` workflow-definition schema, - * - a codeful `workflow.cs` at the project root, or + * - a codeful project: a .NET 8 `.csproj` at the project root that references the Logic Apps + * SDK (`Microsoft.Azure.Workflows.Sdk`), detected structurally via {@link hasCodefulSdkReference}. + * Detection is based on the project structure, not a fixed file name — the workflow C# file can be + * named anything, so a literal `workflow.cs` is not a reliable marker, or * - the codeful workspace setting (`hasCodefulWorkflowSetting`). * * host.json is intentionally NOT required. Source-controlled projects commonly @@ -45,21 +48,24 @@ export async function isLogicAppProject(folderPath: string): Promise { return false; } - // Codeful projects place workflow.cs at the root of the logic app project. - const hasValidCodefulWorkflow = await fse.pathExists(path.join(folderPath, codefulWorkflowFileName)); - // Codeless projects place workflow.json one level down, inside a per-workflow subfolder. const validCodelessWorkflowChecks = await Promise.all( subpaths.map((subpath) => isValidCodelessWorkflowFolder(path.join(folderPath, subpath, workflowFileName))) ); const hasValidCodelessWorkflow = validCodelessWorkflowChecks.some(Boolean); - const isCodeful = await hasCodefulWorkflowSetting(folderPath); - if (isCodeful) { + // Codeful projects are .NET 8 projects that reference the Logic Apps SDK. Detect them + // structurally from the .csproj so the workflow C# file can be named anything. + const hasCodefulProject = await hasCodefulSdkReference(folderPath); + + // The isCodeful workspace setting is an additional signal, but it lives in local.settings.json, + // which is commonly gitignored under source control — so it cannot be relied on alone. + const hasCodefulSetting = await hasCodefulWorkflowSetting(folderPath); + if (hasCodefulSetting) { vscode.commands.executeCommand('setContext', customExtensionContext.isCodeful, true); } - return hasValidCodelessWorkflow || hasValidCodefulWorkflow || isCodeful; + return hasValidCodelessWorkflow || hasCodefulProject || hasCodefulSetting; } /** diff --git a/apps/vs-code-designer/test-setup.ts b/apps/vs-code-designer/test-setup.ts index ed3c6e5af2b..09fe0b39687 100644 --- a/apps/vs-code-designer/test-setup.ts +++ b/apps/vs-code-designer/test-setup.ts @@ -100,6 +100,9 @@ vi.mock('fs-extra', () => ({ existsSync: vi.fn(() => {}), readdir: vi.fn(), stat: vi.fn(), + statSync: vi.fn(() => ({ + isDirectory: () => false, + })), })); vi.mock('child_process'); From cdad1225abde8628f521ecfab5f4efc8370b9973 Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Fri, 10 Jul 2026 15:44:35 -0700 Subject: [PATCH 18/19] refactor(vscode): drop redundant codeful-setting check from isLogicAppProject The WORKFLOW_CODEFUL_ENABLED workspace setting lives in the gitignored local.settings.json and adds no detection value alongside the authoritative .csproj SDK-reference check, so isLogicAppProject no longer consults it. The isCodeful UI context is now driven by the structural .csproj signal, which also works for source-controlled projects missing local.settings.json. Adds a test asserting a folder with only the setting (no .csproj) is not tagged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../utils/__test__/verifyIsProject.test.ts | 21 +++++++++++++++++++ .../src/app/utils/verifyIsProject.ts | 21 +++++++++---------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts b/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts index f02ec64a3dc..d843f9ce400 100644 --- a/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts +++ b/apps/vs-code-designer/src/app/utils/__test__/verifyIsProject.test.ts @@ -216,6 +216,27 @@ describe('isLogicAppProject', () => { expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(false); }); + it('does not tag a folder that only has the codeful workspace setting but no .csproj', async () => { + // WORKFLOW_CODEFUL_ENABLED lives in the gitignored local.settings.json and is no longer a + // detection signal — without the authoritative .csproj SDK reference the folder is not a project. + const localSettingsPath = path.join(projectPath, localSettingsFileName); + vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { + const s = String(p); + return s === projectPath || s === localSettingsPath; + }); + vi.spyOn(fse, 'readdir').mockImplementation(async (p: fse.PathLike) => + String(p) === projectPath ? [localSettingsFileName, 'Program.cs'] : [] + ); + vi.spyOn(fse, 'readFile').mockImplementation(async (p: fse.PathLike) => { + if (String(p) === localSettingsPath) { + return JSON.stringify({ Values: { WORKFLOW_CODEFUL_ENABLED: 'true' } }); + } + return ''; + }); + + expect(await verifyIsProject.isLogicAppProject(projectPath)).toBe(false); + }); + it('does not tag a folder whose workflow.json is not a Microsoft.Logic workflow', async () => { const workflowJsonPath = path.join(projectPath, 'wf', workflowFileName); vi.spyOn(fse, 'pathExists').mockImplementation(async (p: fse.PathLike) => { diff --git a/apps/vs-code-designer/src/app/utils/verifyIsProject.ts b/apps/vs-code-designer/src/app/utils/verifyIsProject.ts index 326a18bddb5..58010006fcd 100644 --- a/apps/vs-code-designer/src/app/utils/verifyIsProject.ts +++ b/apps/vs-code-designer/src/app/utils/verifyIsProject.ts @@ -12,21 +12,24 @@ import * as path from 'path'; import type { MessageItem, WorkspaceFolder } from 'vscode'; import { NoWorkspaceError } from './errors'; import * as vscode from 'vscode'; -import { hasCodefulSdkReference, hasCodefulWorkflowSetting } from './codeful'; +import { hasCodefulSdkReference } from './codeful'; const projectSubpathKey = 'projectSubpath'; /** * Determines whether the given folder is a Logic Apps project. * - * A Logic Apps project is identified by a workflow signal — any one of: + * A Logic Apps project is identified by a workflow signal — either of: * - a codeless `workflow.json` one level down whose `definition.$schema` is a - * `Microsoft.Logic` workflow-definition schema, + * `Microsoft.Logic` workflow-definition schema, or * - a codeful project: a .NET 8 `.csproj` at the project root that references the Logic Apps * SDK (`Microsoft.Azure.Workflows.Sdk`), detected structurally via {@link hasCodefulSdkReference}. * Detection is based on the project structure, not a fixed file name — the workflow C# file can be - * named anything, so a literal `workflow.cs` is not a reliable marker, or - * - the codeful workspace setting (`hasCodefulWorkflowSetting`). + * named anything, so a literal `workflow.cs` is not a reliable marker. + * + * The codeful `WORKFLOW_CODEFUL_ENABLED` workspace setting is intentionally NOT consulted: it lives in + * `local.settings.json`, which is commonly gitignored under source control, and every codeful project + * already carries the authoritative `.csproj` SDK reference — so the setting adds no detection value. * * host.json is intentionally NOT required. Source-controlled projects commonly * gitignore `host.json` and `local.settings.json`, so a freshly cloned project can @@ -57,15 +60,11 @@ export async function isLogicAppProject(folderPath: string): Promise { // Codeful projects are .NET 8 projects that reference the Logic Apps SDK. Detect them // structurally from the .csproj so the workflow C# file can be named anything. const hasCodefulProject = await hasCodefulSdkReference(folderPath); - - // The isCodeful workspace setting is an additional signal, but it lives in local.settings.json, - // which is commonly gitignored under source control — so it cannot be relied on alone. - const hasCodefulSetting = await hasCodefulWorkflowSetting(folderPath); - if (hasCodefulSetting) { + if (hasCodefulProject) { vscode.commands.executeCommand('setContext', customExtensionContext.isCodeful, true); } - return hasValidCodelessWorkflow || hasCodefulProject || hasCodefulSetting; + return hasValidCodelessWorkflow || hasCodefulProject; } /** From e57b58aa46c707e790f3afcfb17f26b6125b9fbf Mon Sep 17 00:00:00 2001 From: Brian Lam Date: Fri, 10 Jul 2026 16:43:01 -0700 Subject: [PATCH 19/19] test(vscode): rename 'golden' characterization tests to 'expected' Renames the golden-content test suites and their baseline objects (goldenHostJson/goldenRootHostJson -> expectedHostJson/expectedRootHostJson, 'golden ... content by logic app type' -> 'expected ... content by logic app type') so the intent reads clearly without the golden-test jargon. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../appSettings/__test__/localSettings.test.ts | 11 ++++++----- .../__test__/validateProjectArtifacts.test.ts | 18 +++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts index 05f8a22de2b..ca596d21b0f 100644 --- a/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts +++ b/apps/vs-code-designer/src/app/utils/appSettings/__test__/localSettings.test.ts @@ -22,8 +22,9 @@ describe('utils/appSettings', () => { // extension generates: the project-root (runtime) file (isDesignTime=false) and the // workflow-designtime/ folder file (isDesignTime=true). It is shared by fresh project creation // (CreateLogicAppWorkspace.createLocalConfigurationFiles), the design-time API startup, and - // regeneration of source-controlled clones (validateProjectArtifacts), so these golden tests lock - // the exact content and key order per (isDesignTime x ProjectType) so those paths cannot drift apart. + // regeneration of source-controlled clones (validateProjectArtifacts), so these characterization + // tests lock the exact content and key order per (isDesignTime x ProjectType) so those paths + // cannot drift apart. describe('getLocalSettingsSchema', () => { const projectPath = 'path/to/project'; @@ -52,7 +53,7 @@ describe('utils/appSettings', () => { expect(settings['Values']).toHaveProperty(ProjectDirectoryPathKey, projectPath); }); - // Golden / characterization tests: lock the exact content of every local.settings.json this + // Characterization tests: lock the exact content of every local.settings.json this // extension generates, for every logic-app content axis. The content varies by // (isDesignTime x ProjectType). // @@ -60,7 +61,7 @@ describe('utils/appSettings', () => { // the codebase uses) rather than magic strings. The assertions still reconstruct the full // expected object explicitly and deep-equal it, so a structural regression (a setting added, // dropped, or moved to the wrong branch) is caught. - describe('golden root content by logic app type', () => { + describe('expected root content by logic app type', () => { const baseRootValues = { [azureWebJobsStorageKey]: localEmulatorConnectionString, [functionsInprocNet8Enabled]: functionsInprocNet8EnabledTrue, @@ -122,7 +123,7 @@ describe('utils/appSettings', () => { }); }); - describe('golden design-time content by logic app type', () => { + describe('expected design-time content by logic app type', () => { it('design-time local.settings.json (codeless / Standard Node)', () => { expect(getLocalSettingsSchema(true, projectPath, ProjectType.logicApp)).toEqual({ IsEncrypted: false, diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts index f2e32f182ea..ca76d341b55 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/validateProjectArtifacts.test.ts @@ -508,15 +508,15 @@ describe('validateProjectArtifacts', () => { expect(writtenPaths.some((p) => p.includes('workflow-designtime-backup/workflow-designtime/host.json'))).toBe(true); }); - // Golden / characterization tests: lock the exact content written for each design-time file, per + // Characterization tests: lock the exact content written for each design-time file, per // logic app type. The expected objects are reconstructed explicitly from the shared constants // (rather than referencing hostFileContent / getLocalSettingsSchema directly), so a structural // change to the generated files is caught while the key/value strings stay in sync with the // named constants the codebase uses. - describe('golden content by logic app type', () => { + describe('expected content by logic app type', () => { // Mirrors the host.json that startDesignTimeApi generated inline before the regeneration // refactor. It is the regression baseline for the design-time host. - const goldenHostJson = { + const expectedHostJson = { version: '2.0', extensionBundle: { id: extensionBundleId, @@ -538,7 +538,7 @@ describe('validateProjectArtifacts', () => { await regenerateDesignTimeDirectory(context, projectPath); - expect(writtenContentFor('host.json')).toEqual(goldenHostJson); + expect(writtenContentFor('host.json')).toEqual(expectedHostJson); }); it('writes host.json equal to the design-time host baseline (codeful)', async () => { @@ -549,7 +549,7 @@ describe('validateProjectArtifacts', () => { await regenerateDesignTimeDirectory(context, projectPath); // host.json is type-independent: the codeful project produces the same host.json. - expect(writtenContentFor('host.json')).toEqual(goldenHostJson); + expect(writtenContentFor('host.json')).toEqual(expectedHostJson); }); it('writes design-time local.settings.json with the exact baseline and upserts Node runtime (codeless)', async () => { @@ -614,7 +614,7 @@ describe('validateProjectArtifacts', () => { await regenerateDesignTimeDirectory(context, projectPath); // Both artifacts are rewritten to the codeful baseline. - expect(writtenContentFor('host.json')).toEqual(goldenHostJson); + expect(writtenContentFor('host.json')).toEqual(expectedHostJson); expect(writtenContentFor('local.settings.json')).toEqual({ IsEncrypted: false, Values: { @@ -634,7 +634,7 @@ describe('validateProjectArtifacts', () => { // Mirrors the project-level host.json produced by the creation path // (CreateLogicAppWorkspace.getHostContent). Regression baseline for the root host.json. - const goldenRootHostJson = { + const expectedRootHostJson = { version: '2.0', logging: { applicationInsights: { @@ -656,7 +656,7 @@ describe('validateProjectArtifacts', () => { const created = await regenerateRootHostFile(projectPath); expect(created).toBe(true); - expect(writtenContentFor('host.json')).toEqual(goldenRootHostJson); + expect(writtenContentFor('host.json')).toEqual(expectedRootHostJson); }); it('regenerates host.json when it exists but is invalid', async () => { @@ -665,7 +665,7 @@ describe('validateProjectArtifacts', () => { const created = await regenerateRootHostFile(projectPath); expect(created).toBe(true); - expect(writtenContentFor('host.json')).toEqual(goldenRootHostJson); + expect(writtenContentFor('host.json')).toEqual(expectedRootHostJson); }); it('preserves a valid existing host.json and does not rewrite it', async () => {